From 9c964fa640fa0e5e0c240f9821ebd4f825e3f6b1 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Wed, 29 Jul 2026 19:01:05 -0400 Subject: [PATCH 1/9] docs(ci): remove legacy runner provider references --- .github/workflows/images-precheck.yml | 2 +- docs/packaging-readiness-gaps.md | 2 +- docs/runner-capacity.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/images-precheck.yml b/.github/workflows/images-precheck.yml index 7ccc6af..2ba092d 100644 --- a/.github/workflows/images-precheck.yml +++ b/.github/workflows/images-precheck.yml @@ -50,7 +50,7 @@ jobs: run: | go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 "$(go env GOPATH)/bin/actionlint" -color - if rg -n -i 'blacksmith|buildjet|runs-on:.*self-hosted|git clone|cargo build|pnpm.*build' .github/workflows/images-release.yml docker; then + if rg -n -i 'runs-on:.*self-hosted|git clone|cargo build|pnpm.*build' .github/workflows/images-release.yml docker; then echo "legacy runner or source-build path remains in packaging automation" >&2 exit 1 fi diff --git a/docs/packaging-readiness-gaps.md b/docs/packaging-readiness-gaps.md index 949fe70..1c425ce 100644 --- a/docs/packaging-readiness-gaps.md +++ b/docs/packaging-readiness-gaps.md @@ -8,7 +8,7 @@ The repository implementation is archive-first: it verifies already-built upstre - [x] Verify digest, exact sidecar filename, safe tar entries, expected bundle layout, extraction, and provenance. QA: `tests/upstream-archive.test.ts`. - [x] Enforce package-first OCI construction with no direct runtime binary path. QA: Dockerfile target checks plus runtime workflow smoke. - [x] Make dry-run execute all validation while forcibly skipping publication. QA: workflow policy and final readiness job. -- [x] Remove Blacksmith/self-hosted orchestration. QA: precheck scans workflow/Docker paths for legacy runner/source-build strings. +- [x] Remove external/self-hosted runner orchestration. QA: precheck scans workflow/Docker paths for legacy runner/source-build strings. - [x] Correct channel claims: block Alpine/musl and Intel macOS; enable upstream Linux Vulkan and arm64 CUDA 13. QA: matrix tests. - [ ] Re-certify GPU package and image QA against product-v2 bundles. QA: backend-neutral hosts pass `--version`, `--help`, `runtime list`, and an diff --git a/docs/runner-capacity.md b/docs/runner-capacity.md index 3c469ba..36a2ddc 100644 --- a/docs/runner-capacity.md +++ b/docs/runner-capacity.md @@ -1,6 +1,6 @@ # Efficiency and runner capacity -All automation uses GitHub-hosted runners. Linux amd64 uses `ubuntu-24.04`, Linux arm64 uses `ubuntu-24.04-arm`, and Homebrew uses `macos-15`. No self-hosted or Blacksmith runner contract remains. +All automation uses GitHub-hosted runners. Linux amd64 uses `ubuntu-24.04`, Linux arm64 uses `ubuntu-24.04-arm`, and Homebrew uses `macos-15`. No legacy external or self-hosted runner contract remains. The full active matrix has 11 Linux package/image rows but only 8 unique Linux product archives. Archive, host digest, runtime digest, and product-manifest From 66704797d168ec7b08b8e0a3bc5ef6919ec2d86e Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Thu, 30 Jul 2026 02:01:59 -0400 Subject: [PATCH 2/9] feat(ci): promote exact tested packaging outputs --- .github/workflows/images-precheck.yml | 16 +- .github/workflows/images-release.yml | 611 +++++++++++------------- .github/workflows/package-image-row.yml | 377 +++++++++++++++ README.md | 12 +- TODO.md | 31 ++ docker/Dockerfile.mesh-llm | 13 - docs/matrix.md | 7 +- docs/native-packages.md | 14 +- docs/publishing.md | 27 +- docs/release-checklist.md | 8 +- docs/runner-capacity.md | 7 +- scripts/release-index.ts | 496 +++++++++++++++++++ scripts/release-plan.ts | 344 +++++++++++++ scripts/upstream-node-addon.ts | 180 +++++++ tests/client-readiness-smoke.test.ts | 6 +- tests/node-sdk-runtime-smoke.test.ts | 9 +- tests/release-index.test.ts | 261 ++++++++++ tests/release-plan.test.ts | 319 +++++++++++++ tests/release-workflow.test.ts | 78 +++ tests/upstream-node-addon.test.ts | 85 ++++ tests/workflow-provenance.test.ts | 22 +- 21 files changed, 2530 insertions(+), 393 deletions(-) create mode 100644 .github/workflows/package-image-row.yml create mode 100644 scripts/release-index.ts create mode 100644 scripts/release-plan.ts create mode 100644 scripts/upstream-node-addon.ts create mode 100644 tests/release-index.test.ts create mode 100644 tests/release-plan.test.ts create mode 100644 tests/release-workflow.test.ts create mode 100644 tests/upstream-node-addon.test.ts diff --git a/.github/workflows/images-precheck.yml b/.github/workflows/images-precheck.yml index 2ba092d..6266aca 100644 --- a/.github/workflows/images-precheck.yml +++ b/.github/workflows/images-precheck.yml @@ -30,6 +30,12 @@ jobs: node --experimental-strip-types scripts/image-matrix.ts homebrew-plan --version v0.73.1 node --experimental-strip-types scripts/image-matrix.ts npm-matrix --version v0.73.1 node --experimental-strip-types scripts/image-matrix.ts npm-plan --version v0.73.1 --lane-filter linux-x64 + node --experimental-strip-types scripts/release-plan.ts \ + --version v0.73.1 --mesh-ref v0.73.1 --mesh-repository Mesh-LLM/mesh-llm \ + --image ghcr.io/mesh-llm/mesh-llm --dry-run true \ + --publish-images false --publish-release-assets false --publish-npm false \ + --validate-native true --validate-homebrew false --validate-npm false \ + --native-selector ubuntu-cpu-amd64 --npm-selector all if node --experimental-strip-types scripts/image-matrix.ts github-matrix --version v0.73.1 --variant-filter alpine-cpu; then echo "blocked Alpine row entered the package matrix" >&2 exit 1 @@ -39,8 +45,8 @@ jobs: node --experimental-strip-types --test --experimental-test-coverage \ --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 \ tests/image-matrix.test.ts - node --experimental-strip-types --test \ - tests/client-readiness-smoke.test.ts tests/homebrew-release.test.ts tests/node-sdk-package.test.ts tests/node-sdk-runtime-smoke.test.ts tests/product-contract.test.ts tests/release-evidence.test.ts tests/sbom-subject.test.ts tests/upstream-archive.test.ts tests/workflow-provenance.test.ts + node --experimental-strip-types --test --test-concurrency=1 \ + tests/client-readiness-smoke.test.ts tests/homebrew-release.test.ts tests/node-sdk-package.test.ts tests/node-sdk-runtime-smoke.test.ts tests/product-contract.test.ts tests/release-evidence.test.ts tests/release-index.test.ts tests/release-plan.test.ts tests/release-workflow.test.ts tests/sbom-subject.test.ts tests/upstream-archive.test.ts tests/upstream-node-addon.test.ts tests/workflow-provenance.test.ts - name: Lint shell scripts run: | sudo apt-get update @@ -50,7 +56,8 @@ jobs: run: | go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 "$(go env GOPATH)/bin/actionlint" -color - if rg -n -i 'runs-on:.*self-hosted|git clone|cargo build|pnpm.*build' .github/workflows/images-release.yml docker; then + if rg -n -i 'runs-on:.*self-hosted|git clone|cargo build|pnpm.*build' \ + .github/workflows/images-release.yml .github/workflows/package-image-row.yml docker; then echo "legacy runner or source-build path remains in packaging automation" >&2 exit 1 fi @@ -73,9 +80,6 @@ jobs: docker buildx build --check --target runtime --file docker/Dockerfile.mesh-llm \ --build-arg RUNTIME_BASE_IMAGE=ubuntu:24.04 --build-arg DISTRO=ubuntu \ --build-arg BACKEND=cpu --build-arg MESH_LLM_VERSION=0.73.1 . - docker buildx build --check --target runtime-qa --file docker/Dockerfile.mesh-llm \ - --build-arg RUNTIME_BASE_IMAGE=ubuntu:24.04 --build-arg DISTRO=ubuntu \ - --build-arg BACKEND=cpu --build-arg MESH_LLM_VERSION=0.73.1 . docker buildx build --check --platform linux/amd64 --target runtime --file docker/Dockerfile.mesh-llm \ --build-arg RUNTIME_BASE_IMAGE=archlinux:base --build-arg DISTRO=arch \ --build-arg BACKEND=vulkan --build-arg MESH_LLM_VERSION=0.73.1 . diff --git a/.github/workflows/images-release.yml b/.github/workflows/images-release.yml index 0c79de6..52f2ef5 100644 --- a/.github/workflows/images-release.yml +++ b/.github/workflows/images-release.yml @@ -38,20 +38,30 @@ on: required: true default: false type: boolean - variant_filter: - description: Optional comma-separated variant or artifact IDs - required: false - default: '' - type: string - platform_filter: - description: Optional comma-separated platform or architecture values - required: false - default: '' + validate_native: + description: Validate native packages and runtime images + required: true + default: true + type: boolean + validate_homebrew: + description: Validate the Homebrew formula + required: true + default: false + type: boolean + validate_npm: + description: Validate Node SDK addon lanes and package assembly + required: true + default: false + type: boolean + native_selector: + description: Exact native artifact ID, comma-separated exact IDs, or all + required: true + default: all type: string - npm_lane_filter: - description: Optional comma-separated Node SDK lane IDs or targets - required: false - default: '' + npm_selector: + description: Exact Node SDK lane ID, comma-separated exact IDs, or all + required: true + default: all type: string concurrency: @@ -75,17 +85,18 @@ jobs: homebrew_plan: ${{ steps.matrix.outputs.homebrew_plan }} npm_matrix: ${{ steps.matrix.outputs.npm_matrix }} npm_plan: ${{ steps.matrix.outputs.npm_plan }} - # Manual runs validate npm by default; automated handoffs build npm only when publishing. - npm_enabled: ${{ steps.matrix.outputs.npm_enabled == 'true' && (github.event_name == 'workflow_dispatch' || steps.meta.outputs.publish_npm == 'true') }} + native_enabled: ${{ steps.matrix.outputs.native_enabled }} + homebrew_enabled: ${{ steps.matrix.outputs.homebrew_enabled }} + npm_enabled: ${{ steps.matrix.outputs.npm_enabled }} mesh_repository: ${{ steps.meta.outputs.mesh_repository }} mesh_ref: ${{ steps.meta.outputs.mesh_ref }} mesh_sha: ${{ steps.meta.outputs.mesh_sha }} mesh_version: ${{ steps.meta.outputs.mesh_version }} dry_run: ${{ steps.meta.outputs.dry_run }} - publish_images: ${{ steps.meta.outputs.publish_images }} - publish_release_assets: ${{ steps.meta.outputs.publish_release_assets }} - publish_npm: ${{ steps.meta.outputs.publish_npm }} - release_assembly_enabled: ${{ steps.meta.outputs.release_assembly_enabled }} + publish_images: ${{ steps.matrix.outputs.publish_images }} + publish_release_assets: ${{ steps.matrix.outputs.publish_release_assets }} + publish_npm: ${{ steps.matrix.outputs.publish_npm }} + release_assembly_enabled: ${{ steps.matrix.outputs.release_assembly_enabled }} steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -108,9 +119,11 @@ jobs: INPUT_PUBLISH_IMAGES: ${{ inputs.publish_images }} INPUT_PUBLISH_RELEASE_ASSETS: ${{ inputs.publish_release_assets }} INPUT_PUBLISH_NPM: ${{ inputs.publish_npm }} - VARIANT_FILTER: ${{ inputs.variant_filter || '' }} - PLATFORM_FILTER: ${{ inputs.platform_filter || '' }} - NPM_LANE_FILTER: ${{ inputs.npm_lane_filter || '' }} + INPUT_VALIDATE_NATIVE: ${{ inputs.validate_native }} + INPUT_VALIDATE_HOMEBREW: ${{ inputs.validate_homebrew }} + INPUT_VALIDATE_NPM: ${{ inputs.validate_npm }} + INPUT_NATIVE_SELECTOR: ${{ inputs.native_selector }} + INPUT_NPM_SELECTOR: ${{ inputs.npm_selector }} shell: bash run: | set -euo pipefail @@ -121,6 +134,19 @@ jobs: publish_images="${DISPATCH_PUBLISH_IMAGES:-${INPUT_PUBLISH_IMAGES:-false}}" publish_release_assets="${DISPATCH_PUBLISH_RELEASE_ASSETS:-${INPUT_PUBLISH_RELEASE_ASSETS:-false}}" publish_npm="${DISPATCH_PUBLISH_NPM:-${INPUT_PUBLISH_NPM:-false}}" + if [[ "$GITHUB_EVENT_NAME" == repository_dispatch ]]; then + validate_native=true + validate_homebrew="$publish_release_assets" + validate_npm="$publish_npm" + native_selector=all + npm_selector=all + else + validate_native="${INPUT_VALIDATE_NATIVE:-true}" + validate_homebrew="${INPUT_VALIDATE_HOMEBREW:-false}" + validate_npm="${INPUT_VALIDATE_NPM:-false}" + native_selector="${INPUT_NATIVE_SELECTOR:-all}" + npm_selector="${INPUT_NPM_SELECTOR:-all}" + fi [[ "$repository" == "Mesh-LLM/mesh-llm" ]] || { echo "only Mesh-LLM/mesh-llm is accepted" >&2; exit 1; } [[ "$ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "mesh_ref must be a release tag" >&2; exit 1; } @@ -131,19 +157,6 @@ jobs: [[ "$publish_images" == true || "$publish_images" == false ]] || { echo "publish_images must be boolean" >&2; exit 1; } [[ "$publish_release_assets" == true || "$publish_release_assets" == false ]] || { echo "publish_release_assets must be boolean" >&2; exit 1; } [[ "$publish_npm" == true || "$publish_npm" == false ]] || { echo "publish_npm must be boolean" >&2; exit 1; } - if [[ "$dry_run" == true ]]; then - publish_images=false - publish_release_assets=false - publish_npm=false - fi - if [[ "$publish_images" == true || "$publish_release_assets" == true || "$publish_npm" == true ]]; then - if [[ -n "$VARIANT_FILTER" || -n "$PLATFORM_FILTER" || -n "$NPM_LANE_FILTER" ]]; then - echo "filtered workflow runs may validate but must never publish" >&2 - exit 1 - fi - fi - release_assembly_enabled=true - if [[ -n "$VARIANT_FILTER" || -n "$PLATFORM_FILTER" ]]; then release_assembly_enabled=false; fi release_json="$(gh api "repos/$repository/releases/tags/$ref")" [[ "$(jq -r .draft <<<"$release_json")" == false ]] || { echo "upstream release is still a draft" >&2; exit 1; } @@ -162,38 +175,59 @@ jobs: echo "publish_images=$publish_images" echo "publish_release_assets=$publish_release_assets" echo "publish_npm=$publish_npm" - echo "release_assembly_enabled=$release_assembly_enabled" + echo "validate_native=$validate_native" + echo "validate_homebrew=$validate_homebrew" + echo "validate_npm=$validate_npm" + echo "native_selector=$native_selector" + echo "npm_selector=$npm_selector" } >> "$GITHUB_OUTPUT" - id: matrix env: VERSION: ${{ steps.meta.outputs.mesh_version }} REF: ${{ steps.meta.outputs.mesh_ref }} REPOSITORY: ${{ steps.meta.outputs.mesh_repository }} - VARIANT_FILTER: ${{ inputs.variant_filter || '' }} - PLATFORM_FILTER: ${{ inputs.platform_filter || '' }} - NPM_LANE_FILTER: ${{ inputs.npm_lane_filter || '' }} + DRY_RUN: ${{ steps.meta.outputs.dry_run }} + PUBLISH_IMAGES: ${{ steps.meta.outputs.publish_images }} + PUBLISH_RELEASE_ASSETS: ${{ steps.meta.outputs.publish_release_assets }} + PUBLISH_NPM: ${{ steps.meta.outputs.publish_npm }} + VALIDATE_NATIVE: ${{ steps.meta.outputs.validate_native }} + VALIDATE_HOMEBREW: ${{ steps.meta.outputs.validate_homebrew }} + VALIDATE_NPM: ${{ steps.meta.outputs.validate_npm }} + NATIVE_SELECTOR: ${{ steps.meta.outputs.native_selector }} + NPM_SELECTOR: ${{ steps.meta.outputs.npm_selector }} shell: bash run: | set -euo pipefail - common=(--version "$VERSION" --mesh-ref "$REF" --mesh-repository "$REPOSITORY" --image "$IMAGE_NAME") - if [[ -n "$VARIANT_FILTER" ]]; then common+=(--variant-filter "$VARIANT_FILTER"); fi - if [[ -n "$PLATFORM_FILTER" ]]; then common+=(--platform-filter "$PLATFORM_FILTER"); fi - package_matrix="$(node --experimental-strip-types scripts/image-matrix.ts github-matrix "${common[@]}")" - upstream_matrix="$(node --experimental-strip-types scripts/image-matrix.ts upstream-matrix "${common[@]}")" - homebrew_plan="$(node --experimental-strip-types scripts/image-matrix.ts homebrew-plan --version "$VERSION")" - npm_args=(--version "$VERSION") - if [[ -n "$NPM_LANE_FILTER" ]]; then npm_args+=(--lane-filter "$NPM_LANE_FILTER"); fi - npm_matrix="$(node --experimental-strip-types scripts/image-matrix.ts npm-matrix "${npm_args[@]}")" - npm_plan="$(node --experimental-strip-types scripts/image-matrix.ts npm-plan "${npm_args[@]}")" + plan="$(node --experimental-strip-types scripts/release-plan.ts \ + --version "$VERSION" \ + --mesh-ref "$REF" \ + --mesh-repository "$REPOSITORY" \ + --image "$IMAGE_NAME" \ + --dry-run "$DRY_RUN" \ + --publish-images "$PUBLISH_IMAGES" \ + --publish-release-assets "$PUBLISH_RELEASE_ASSETS" \ + --publish-npm "$PUBLISH_NPM" \ + --validate-native "$VALIDATE_NATIVE" \ + --validate-homebrew "$VALIDATE_HOMEBREW" \ + --validate-npm "$VALIDATE_NPM" \ + --native-selector "$NATIVE_SELECTOR" \ + --npm-selector "$NPM_SELECTOR")" { - echo "package_matrix=$package_matrix" - echo "upstream_matrix=$upstream_matrix" - echo "homebrew_plan=$homebrew_plan" - echo "npm_matrix=$npm_matrix" - echo "npm_plan=$npm_plan" - echo "npm_enabled=$(jq -r .enabled <<<"$npm_plan")" + echo "package_matrix=$(jq -c .package_matrix <<<"$plan")" + echo "upstream_matrix=$(jq -c .upstream_matrix <<<"$plan")" + echo "homebrew_plan=$(jq -c .homebrew_plan <<<"$plan")" + echo "npm_matrix=$(jq -c .npm_matrix <<<"$plan")" + echo "npm_plan=$(jq -c .npm_plan <<<"$plan")" + echo "native_enabled=$(jq -r .native_enabled <<<"$plan")" + echo "homebrew_enabled=$(jq -r .homebrew_enabled <<<"$plan")" + echo "npm_enabled=$(jq -r .npm_enabled <<<"$plan")" + echo "publish_images=$(jq -r .publish_images <<<"$plan")" + echo "publish_release_assets=$(jq -r .publish_release_assets <<<"$plan")" + echo "publish_npm=$(jq -r .publish_npm <<<"$plan")" + echo "release_assembly_enabled=$(jq -r .release_assembly_enabled <<<"$plan")" } >> "$GITHUB_OUTPUT" - name: Verify producer product-v2 schema + if: steps.matrix.outputs.native_enabled == 'true' || steps.matrix.outputs.homebrew_enabled == 'true' env: MESH_SHA: ${{ steps.meta.outputs.mesh_sha }} shell: bash @@ -210,6 +244,7 @@ jobs: upstream: name: Verify upstream ${{ matrix.upstream_asset_name }} + if: needs.plan.outputs.native_enabled == 'true' needs: plan strategy: fail-fast: false @@ -255,6 +290,7 @@ jobs: upstream-host-invariant: name: Verify one immutable host per platform and architecture + if: needs.plan.outputs.native_enabled == 'true' needs: [plan, upstream] runs-on: ubuntu-24.04 steps: @@ -275,147 +311,25 @@ jobs: (( ${#provenance_files[@]} > 0 )) || { echo "no upstream provenance records downloaded" >&2; exit 1; } node --experimental-strip-types scripts/verify-host-invariant.ts "${provenance_files[@]}" - native-package: - name: Package ${{ matrix.artifact_id }} + package-image: + name: Package and image ${{ matrix.artifact_id }} + if: needs.plan.outputs.native_enabled == 'true' needs: [plan, upstream, upstream-host-invariant] strategy: fail-fast: false matrix: ${{ fromJSON(needs.plan.outputs.package_matrix) }} - runs-on: ${{ fromJSON(matrix.runner_labels) }} - steps: - - uses: actions/checkout@v7 - - uses: actions/download-artifact@v8 - with: - name: ${{ matrix.upstream_artifact_id }} - path: artifacts/upstream - - uses: docker/setup-buildx-action@v4 - - name: Build native package from verified upstream product bundle - uses: docker/build-push-action@v7 - with: - context: . - file: docker/Dockerfile.mesh-llm - target: native-package-artifact - platforms: ${{ matrix.platform }} - build-args: | - PACKAGE_BASE_IMAGE=${{ matrix.package_base_image }} - DISTRO=${{ matrix.distro }} - BACKEND=${{ matrix.backend }} - BACKEND_VERSION=${{ matrix.backend_version }} - TARGET_ARCH=${{ matrix.arch }} - MESH_LLM_VERSION=${{ matrix.mesh_version }} - outputs: type=local,dest=artifacts/native-package - cache-from: type=gha,scope=package-${{ matrix.artifact_id }} - cache-to: type=gha,mode=max,scope=package-${{ matrix.artifact_id }} - - name: Namespace and require BuildKit package provenance - env: - ARTIFACT_ID: ${{ matrix.artifact_id }} - PACKAGE_FILE: ${{ matrix.package_file }} - shell: bash - run: | - set -euo pipefail - test -s artifacts/native-package/provenance.json - package_sha="$(sha256sum "artifacts/native-package/$PACKAGE_FILE" | awk '{ print $1 }')" - jq -e --arg name "$PACKAGE_FILE" --arg digest "$package_sha" ' - ._type == "https://in-toto.io/Statement/v1" and - (.subject | length) == 1 and - .subject[0].name == $name and - .subject[0].digest.sha256 == $digest - ' artifacts/native-package/provenance.json >/dev/null - mv artifacts/native-package/provenance.json \ - "artifacts/native-package/$ARTIFACT_ID.buildkit-provenance.json" - - name: Verify and install package - shell: bash - run: | - scripts/native-package-qa.sh \ - --distro "${{ matrix.distro }}" \ - --backend "${{ matrix.backend }}" \ - --backend-version "${{ matrix.backend_version }}" \ - --arch "${{ matrix.arch }}" \ - --version "${{ matrix.mesh_version }}" \ - --package-format "${{ matrix.package_format }}" \ - --package-dir artifacts/native-package \ - --runtime-base-image "${{ matrix.runtime_base_image }}" \ - --install - - name: Attach exact upstream provenance to package artifact - run: cp artifacts/upstream/upstream-provenance.json "artifacts/native-package/${{ matrix.artifact_id }}.upstream-provenance.json" - - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - with: - file: artifacts/native-package/${{ matrix.package_file }} - format: spdx-json - output-file: artifacts/native-package/${{ matrix.artifact_id }}.spdx.json - syft-version: v1.42.3 - upload-artifact: false - upload-release-assets: false - - name: Verify exact package SBOM subject - run: | - node --experimental-strip-types scripts/verify-sbom-subject.ts \ - --package "artifacts/native-package/${{ matrix.package_file }}" \ - --sidecar "artifacts/native-package/${{ matrix.package_file }}.sha256" \ - --sbom "artifacts/native-package/${{ matrix.artifact_id }}.spdx.json" - - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.native_package_artifact_name }} - path: artifacts/native-package - if-no-files-found: error - retention-days: 14 - - runtime-image: - name: Runtime image ${{ matrix.artifact_id }} - needs: [plan, native-package] - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.plan.outputs.package_matrix) }} - runs-on: ${{ fromJSON(matrix.runner_labels) }} - steps: - - uses: actions/checkout@v7 - - uses: actions/download-artifact@v8 - with: - name: ${{ matrix.native_package_artifact_name }} - path: artifacts/native-package - - uses: docker/setup-buildx-action@v4 - - name: Read immutable product inputs - id: product - shell: bash - run: | - set -euo pipefail - mapfile -d '' -t provenance_files < <(find artifacts/native-package -name '*.upstream-provenance.json' -type f -print0) - if (( ${#provenance_files[@]} != 1 )); then - echo "expected exactly one upstream provenance file, found ${#provenance_files[@]}" >&2 - exit 1 - fi - provenance="${provenance_files[0]}" - jq -e ' - (.host_sha256 | type == "string" and test("^[0-9a-f]{64}$")) and - (.runtime_id | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._-]*$")) and - (.runtime_sha256 | type == "string" and test("^[0-9a-f]{64}$")) - ' "$provenance" >/dev/null - { - echo "host_sha=$(jq -r .host_sha256 "$provenance")" - echo "runtime_id=$(jq -r .runtime_id "$provenance")" - echo "runtime_sha=$(jq -r .runtime_sha256 "$provenance")" - } >> "$GITHUB_OUTPUT" - - name: Build and QA package-installed runtime image - uses: docker/build-push-action@v7 - with: - context: . - file: docker/Dockerfile.mesh-llm - target: runtime-qa - platforms: ${{ matrix.platform }} - outputs: type=cacheonly - build-args: | - RUNTIME_BASE_IMAGE=${{ matrix.runtime_base_image }} - DISTRO=${{ matrix.distro }} - BACKEND=${{ matrix.backend }} - BACKEND_VERSION=${{ matrix.backend_version }} - MESH_LLM_VERSION=${{ matrix.mesh_version }} - MESH_LLM_REF=${{ needs.plan.outputs.mesh_ref }} - MESH_LLM_SOURCE_SHA=${{ needs.plan.outputs.mesh_sha }} - MESH_LLM_HOST_SHA=${{ steps.product.outputs.host_sha }} - MESH_LLM_RUNTIME_ID=${{ steps.product.outputs.runtime_id }} - MESH_LLM_RUNTIME_SHA=${{ steps.product.outputs.runtime_sha }} + uses: ./.github/workflows/package-image-row.yml + with: + row_json: ${{ toJSON(matrix) }} + image_name: ${{ vars.IMAGE_NAME || 'ghcr.io/mesh-llm/mesh-llm' }} + mesh_ref: ${{ needs.plan.outputs.mesh_ref }} + mesh_sha: ${{ needs.plan.outputs.mesh_sha }} + publish_images: ${{ needs.plan.outputs.publish_images == 'true' }} + secrets: inherit homebrew: name: Verify Homebrew formula from upstream macOS archive + if: needs.plan.outputs.homebrew_enabled == 'true' needs: plan runs-on: ${{ fromJSON(needs.plan.outputs.homebrew_plan).runner }} steps: @@ -454,96 +368,43 @@ jobs: retention-days: 14 node-sdk-addon: - name: Build Node SDK addon ${{ matrix.name }} + name: Verify upstream Node SDK addon ${{ matrix.name }} if: needs.plan.outputs.npm_enabled == 'true' needs: plan strategy: fail-fast: false matrix: ${{ fromJSON(needs.plan.outputs.npm_matrix) }} - runs-on: ${{ fromJSON(matrix.runner_labels) }} - env: - LLAMA_STAGE_BACKEND: ${{ matrix.backend }} + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: actions/checkout@v7 - with: - repository: ${{ needs.plan.outputs.mesh_repository }} - ref: ${{ needs.plan.outputs.mesh_sha }} - path: upstream-source - persist-credentials: false - uses: actions/setup-node@v7 with: node-version: '24' package-manager-cache: false - - uses: dtolnay/rust-toolchain@stable - - uses: mozilla-actions/sccache-action@v0.0.9 - - name: Install Linux dependencies - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y build-essential cmake ninja-build pkg-config libssl-dev libdbus-1-dev curl lld - - name: Install macOS dependencies - if: runner.os == 'macOS' - run: brew install cmake ninja lld - - name: Configure and validate macOS x64 linker - if: matrix.target == 'darwin-x64' - working-directory: upstream-source - shell: bash - run: | - set -euo pipefail - lld_bin="$(brew --prefix lld)/bin" - test -x "$lld_bin/ld64.lld" - export PATH="$lld_bin:$PATH" - export CARGO_ENCODED_RUSTFLAGS='-Clink-arg=-fuse-ld=lld' - echo "$lld_bin" >> "$GITHUB_PATH" - echo "CARGO_ENCODED_RUSTFLAGS=$CARGO_ENCODED_RUSTFLAGS" >> "$GITHUB_ENV" - - probe="$RUNNER_TEMP/mesh-cargo-linker-check" - mkdir -p "$probe/src" - printf '[package]\nname = "mesh-cargo-linker-check"\nversion = "0.0.0"\nedition = "2024"\n' > "$probe/Cargo.toml" - printf 'fn main() {}\n' > "$probe/src/main.rs" - cargo rustc --quiet --manifest-path "$probe/Cargo.toml" --target x86_64-apple-darwin - - name: Test Node SDK JavaScript - run: npm test --prefix upstream-source/sdk/node - - name: Build Node SDK addon - run: npm run build:native --prefix upstream-source/sdk/node - - name: Pack, fresh-install, and start Node SDK addon - timeout-minutes: 3 + - name: Download, verify, and safely extract immutable addon shell: bash env: MESH_VERSION: ${{ needs.plan.outputs.mesh_version }} NODE_SDK_TARGET: ${{ matrix.target }} + MESH_REPOSITORY: ${{ needs.plan.outputs.mesh_repository }} + MESH_REF: ${{ needs.plan.outputs.mesh_ref }} run: | set -euo pipefail - smoke_root="$(mktemp -d)" - trap 'rm -rf "$smoke_root"' EXIT - mkdir -p "$smoke_root/tarball" "$smoke_root/consumer" - ( - cd upstream-source/sdk/node - npm pack --pack-destination "$smoke_root/tarball" - ) - tarball="$(find "$smoke_root/tarball" -maxdepth 1 -type f -name '*.tgz' -print -quit)" - test -n "$tarball" - ( - cd "$smoke_root/consumer" - npm init --yes >/dev/null - npm install "$tarball" - ) - node scripts/node-sdk-runtime-smoke.cjs \ - --package-root "$smoke_root/consumer" \ - --expected-version "$MESH_VERSION" \ - --target "$NODE_SDK_TARGET" - - name: Stage Node SDK addon - shell: bash - env: - NODE_SDK_TARGET: ${{ matrix.target }} - run: | - set -euo pipefail - source="upstream-source/sdk/node/native/$NODE_SDK_TARGET/mesh_llm_nodejs.node" - test -s "$source" - destination="artifacts/node-sdk-addons/$NODE_SDK_TARGET" - mkdir -p "$destination" - cp "$source" "$destination/mesh_llm_nodejs.node" + archive="mesh-llm-node-sdk-addon-$MESH_VERSION-$NODE_SDK_TARGET.tar.gz" + url="https://github.com/$MESH_REPOSITORY/releases/download/$MESH_REF/$archive" + mkdir -p downloads artifacts/node-sdk-addons + curl --fail --location --retry 3 --proto '=https' --tlsv1.2 \ + --output "downloads/$archive" "$url" + curl --fail --location --retry 3 --proto '=https' --tlsv1.2 \ + --output "downloads/$archive.sha256" "$url.sha256" + node --experimental-strip-types scripts/upstream-node-addon.ts \ + --archive "downloads/$archive" \ + --checksum "downloads/$archive.sha256" \ + --output-dir artifacts/node-sdk-addons \ + --target "$NODE_SDK_TARGET" \ + --version "$MESH_VERSION" - uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact_name }} @@ -670,85 +531,163 @@ jobs: test -n "$tarball" npm publish "./$tarball" --access public --tag "$dist_tag" --provenance - publish-images: - name: Publish image ${{ matrix.artifact_id }} + image-index: + name: Assemble exact tested image index if: needs.plan.outputs.publish_images == 'true' - needs: [plan, runtime-image] + needs: [plan, package-image] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: '24' + - uses: actions/download-artifact@v8 + with: + pattern: mesh-llm-image-result-* + path: image-results + - name: Bind every staged result to the selected matrix + env: + PACKAGE_MATRIX: ${{ needs.plan.outputs.package_matrix }} + IMAGE_NAME: ${{ env.IMAGE_NAME }} + VERSION: ${{ needs.plan.outputs.mesh_version }} + MESH_REF: ${{ needs.plan.outputs.mesh_ref }} + MESH_SHA: ${{ needs.plan.outputs.mesh_sha }} + shell: bash + run: | + set -euo pipefail + printf '%s\n' "$PACKAGE_MATRIX" > package-matrix.json + mapfile -d '' -t result_files < <(find image-results -name image-row-result.json -type f -print0 | sort -z) + expected="$(jq -er '.include | length' package-matrix.json)" + (( ${#result_files[@]} == expected )) || { + echo "expected $expected staged image results, found ${#result_files[@]}" >&2 + exit 1 + } + jq -s '.' "${result_files[@]}" > staged-results.json + jq -e --slurpfile results staged-results.json ' + (.include | length) == ($results[0] | length) and + ([.include[].artifact_id] | sort) == ([$results[0][].artifact_id] | sort) and + all(.include[] as $row; + any($results[0][]; .artifact_id == $row.artifact_id and + .platform == $row.platform and .arch == $row.arch and + .backend == $row.backend and .backend_version == $row.backend_version and + .package_file == $row.package_file and + (.package_base_image | split("@")[0]) == ($row.package_base_image | sub(":[^/:]+$"; "")) and + (.runtime_base_image | split("@")[0]) == ($row.runtime_base_image | sub(":[^/:]+$"; "")) and + .tags == ($row.tags | split("\n")))) + ' package-matrix.json >/dev/null + jq -n \ + --arg version "$VERSION" --arg mesh_ref "$MESH_REF" \ + --arg mesh_sha "$MESH_SHA" --arg packaging_sha "$GITHUB_SHA" \ + --arg image_name "$IMAGE_NAME" --slurpfile results staged-results.json \ + '{ + schema_version:1, + identity:{version:$version,mesh_ref:$mesh_ref,mesh_sha:$mesh_sha,packaging_sha:$packaging_sha}, + image_name:$image_name, + expected_rows:[$results[0][] | { + artifact_id,platform,arch,backend,backend_version,package_file, + package_base_image,runtime_base_image,tags + }] + }' > image-release-plan.json + node --experimental-strip-types scripts/release-index.ts assemble \ + --plan image-release-plan.json \ + --results image-results \ + --output release-index.json + node --experimental-strip-types scripts/release-index.ts verify \ + --plan image-release-plan.json \ + --results image-results \ + --index release-index.json + - uses: actions/upload-artifact@v7 + with: + name: mesh-llm-image-index-${{ needs.plan.outputs.mesh_version }} + path: | + image-release-plan.json + release-index.json + if-no-files-found: error + retention-days: 14 + + promote-images: + name: Promote exact tested image digests + if: needs.plan.outputs.publish_images == 'true' + needs: [plan, image-index] + runs-on: ubuntu-24.04 + environment: release permissions: - packages: write contents: read - id-token: write - attestations: write - environment: release - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.plan.outputs.package_matrix) }} - runs-on: ${{ fromJSON(matrix.runner_labels) }} + packages: write steps: - - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: - name: ${{ matrix.native_package_artifact_name }} - path: artifacts/native-package + name: mesh-llm-image-index-${{ needs.plan.outputs.mesh_version }} + path: image-index - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ github.token }} - - name: Read immutable product inputs - id: product + password: ${{ secrets.GITHUB_TOKEN }} + - name: Promote without rebuilding and record rollback state + env: + VERSION: ${{ needs.plan.outputs.mesh_version }} shell: bash run: | set -euo pipefail - mapfile -d '' -t provenance_files < <(find artifacts/native-package -name '*.upstream-provenance.json' -type f -print0) - if (( ${#provenance_files[@]} != 1 )); then - echo "expected exactly one upstream provenance file, found ${#provenance_files[@]}" >&2 - exit 1 - fi - provenance="${provenance_files[0]}" - jq -e ' - (.host_sha256 | type == "string" and test("^[0-9a-f]{64}$")) and - (.runtime_id | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._-]*$")) and - (.runtime_sha256 | type == "string" and test("^[0-9a-f]{64}$")) - ' "$provenance" >/dev/null - { - echo "host_sha=$(jq -r .host_sha256 "$provenance")" - echo "runtime_id=$(jq -r .runtime_id "$provenance")" - echo "runtime_sha=$(jq -r .runtime_sha256 "$provenance")" - } >> "$GITHUB_OUTPUT" - - id: push - uses: docker/build-push-action@v7 - with: - context: . - file: docker/Dockerfile.mesh-llm - target: runtime - platforms: ${{ matrix.platform }} - push: true - provenance: mode=max - sbom: true - tags: ${{ matrix.tags }} - build-args: | - RUNTIME_BASE_IMAGE=${{ matrix.runtime_base_image }} - DISTRO=${{ matrix.distro }} - BACKEND=${{ matrix.backend }} - BACKEND_VERSION=${{ matrix.backend_version }} - MESH_LLM_VERSION=${{ matrix.mesh_version }} - MESH_LLM_REF=${{ needs.plan.outputs.mesh_ref }} - MESH_LLM_SOURCE_SHA=${{ needs.plan.outputs.mesh_sha }} - MESH_LLM_HOST_SHA=${{ steps.product.outputs.host_sha }} - MESH_LLM_RUNTIME_ID=${{ steps.product.outputs.runtime_id }} - MESH_LLM_RUNTIME_SHA=${{ steps.product.outputs.runtime_sha }} - - uses: actions/attest-build-provenance@v4 - with: - subject-name: ${{ env.IMAGE_NAME }} - subject-digest: ${{ steps.push.outputs.digest }} - push-to-registry: true + manifest_digest() { + local ref="$1" + local raw + raw="$(mktemp)" + if ! docker buildx imagetools inspect "$ref" --raw > "$raw" 2>/dev/null; then + rm -f "$raw" + return 1 + fi + printf 'sha256:%s' "$(sha256sum "$raw" | awk '{ print $1 }')" + rm -f "$raw" + } + printf '[]\n' > rollback-ledger.json + while IFS= read -r encoded; do + row="$(printf '%s' "$encoded" | base64 --decode)" + image_name="$(jq -er .image.name <<<"$row")" + digest="$(jq -er .image.digest <<<"$row")" + source_ref="$image_name@$digest" + [[ "$(manifest_digest "$source_ref")" == "$digest" ]] || { + echo "staged source digest no longer resolves exactly: $source_ref" >&2 + exit 1 + } + while IFS= read -r tag; do + previous=null + if existing="$(manifest_digest "$tag")"; then previous="$existing"; fi + if [[ "$tag" == *":$VERSION-"* && "$previous" != null && "$previous" != "$digest" ]]; then + echo "immutable version tag $tag already resolves to $previous, expected $digest" >&2 + exit 1 + fi + if [[ "$previous" != "$digest" ]]; then + docker buildx imagetools create --prefer-index=false --tag "$tag" "$source_ref" + fi + [[ "$(manifest_digest "$tag")" == "$digest" ]] || { + echo "promoted tag $tag does not resolve to $digest" >&2 + exit 1 + } + jq --arg tag "$tag" --arg digest "$digest" --arg previous "$previous" \ + '. + [{tag:$tag,previous_digest:(if $previous == "null" then null else $previous end),promoted_digest:$digest}]' \ + rollback-ledger.json > rollback-ledger.next.json + mv rollback-ledger.next.json rollback-ledger.json + done < <(jq -r '.tags[]' <<<"$row") + done < <(jq -r '.rows[] | @base64' image-index/release-index.json) + - uses: actions/upload-artifact@v7 + if: always() + with: + name: mesh-llm-image-promotion-${{ needs.plan.outputs.mesh_version }} + path: | + image-index/release-index.json + rollback-ledger.json + if-no-files-found: error + retention-days: 30 release-assembly: name: Assemble immutable native package release evidence if: needs.plan.outputs.release_assembly_enabled == 'true' - needs: [plan, native-package, homebrew] + needs: [plan, package-image, homebrew] runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 @@ -929,15 +868,16 @@ jobs: readiness: name: Packaging readiness manifest if: always() - needs: [plan, upstream, native-package, runtime-image, homebrew, release-assembly, node-sdk-addon, node-sdk-preflight, publish-node-sdk, publish-images, publish-release-assets] + needs: [plan, upstream, package-image, homebrew, release-assembly, node-sdk-addon, node-sdk-preflight, publish-node-sdk, image-index, promote-images, publish-release-assets] runs-on: ubuntu-24.04 steps: - name: Record and enforce required results env: PLAN: ${{ needs.plan.result }} UPSTREAM: ${{ needs.upstream.result }} - PACKAGES: ${{ needs.native-package.result }} - IMAGES: ${{ needs.runtime-image.result }} + NATIVE_ENABLED: ${{ needs.plan.outputs.native_enabled }} + PACKAGE_IMAGES: ${{ needs.package-image.result }} + HOMEBREW_ENABLED: ${{ needs.plan.outputs.homebrew_enabled }} HOMEBREW: ${{ needs.homebrew.result }} ASSEMBLY_ENABLED: ${{ needs.plan.outputs.release_assembly_enabled }} RELEASE_ASSEMBLY: ${{ needs.release-assembly.result }} @@ -946,7 +886,8 @@ jobs: NPM_PREFLIGHT: ${{ needs.node-sdk-preflight.result }} PUBLISH_NPM_REQUESTED: ${{ needs.plan.outputs.publish_npm }} PUBLISH_NPM: ${{ needs.publish-node-sdk.result }} - PUBLISH_IMAGES: ${{ needs.publish-images.result }} + IMAGE_INDEX: ${{ needs.image-index.result }} + PROMOTE_IMAGES: ${{ needs.promote-images.result }} PUBLISH_ASSETS: ${{ needs.publish-release-assets.result }} PUBLISH_IMAGES_REQUESTED: ${{ needs.plan.outputs.publish_images }} PUBLISH_ASSETS_REQUESTED: ${{ needs.plan.outputs.publish_release_assets }} @@ -954,15 +895,19 @@ jobs: run: | set -euo pipefail jq -n \ - --arg plan "$PLAN" --arg upstream "$UPSTREAM" --arg packages "$PACKAGES" \ - --arg images "$IMAGES" --arg homebrew "$HOMEBREW" --arg release_assembly "$RELEASE_ASSEMBLY" \ + --arg plan "$PLAN" --arg upstream "$UPSTREAM" --arg package_images "$PACKAGE_IMAGES" \ + --arg homebrew "$HOMEBREW" --arg release_assembly "$RELEASE_ASSEMBLY" \ --arg npm_addons "$NPM_ADDONS" --arg npm_preflight "$NPM_PREFLIGHT" --arg publish_npm "$PUBLISH_NPM" \ - --arg publish_images "$PUBLISH_IMAGES" --arg publish_assets "$PUBLISH_ASSETS" \ - '{plan:$plan,upstream:$upstream,native_packages:$packages,runtime_images:$images,homebrew:$homebrew,release_assembly:$release_assembly,npm_addons:$npm_addons,npm_preflight:$npm_preflight,publish_npm:$publish_npm,publish_images:$publish_images,publish_release_assets:$publish_assets}' \ + --arg image_index "$IMAGE_INDEX" --arg promote_images "$PROMOTE_IMAGES" --arg publish_assets "$PUBLISH_ASSETS" \ + '{plan:$plan,upstream:$upstream,package_images:$package_images,homebrew:$homebrew,release_assembly:$release_assembly,npm_addons:$npm_addons,npm_preflight:$npm_preflight,publish_npm:$publish_npm,image_index:$image_index,promote_images:$promote_images,publish_release_assets:$publish_assets}' \ | tee packaging-readiness.json - for required in "$PLAN" "$UPSTREAM" "$PACKAGES" "$IMAGES" "$HOMEBREW"; do - [[ "$required" == success ]] || exit 1 - done + [[ "$PLAN" == success ]] || exit 1 + if [[ "$NATIVE_ENABLED" == true ]]; then + [[ "$UPSTREAM" == success && "$PACKAGE_IMAGES" == success ]] || exit 1 + fi + if [[ "$HOMEBREW_ENABLED" == true ]]; then + [[ "$HOMEBREW" == success ]] || exit 1 + fi if [[ "$ASSEMBLY_ENABLED" == true ]]; then [[ "$RELEASE_ASSEMBLY" == success ]] || exit 1 fi @@ -973,7 +918,7 @@ jobs: [[ "$PUBLISH_NPM" == success ]] || exit 1 fi if [[ "$PUBLISH_IMAGES_REQUESTED" == true ]]; then - [[ "$PUBLISH_IMAGES" == success ]] || exit 1 + [[ "$IMAGE_INDEX" == success && "$PROMOTE_IMAGES" == success ]] || exit 1 fi if [[ "$PUBLISH_ASSETS_REQUESTED" == true ]]; then [[ "$PUBLISH_ASSETS" == success ]] || exit 1 diff --git a/.github/workflows/package-image-row.yml b/.github/workflows/package-image-row.yml new file mode 100644 index 0000000..2cade97 --- /dev/null +++ b/.github/workflows/package-image-row.yml @@ -0,0 +1,377 @@ +name: Package and stage one image row + +on: + workflow_call: + inputs: + row_json: + description: One exact row from the checked-in packaging matrix + required: true + type: string + image_name: + description: Canonical OCI image repository + required: true + type: string + mesh_ref: + required: true + type: string + mesh_sha: + required: true + type: string + publish_images: + required: true + type: boolean + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + package: + name: Produce native package + runs-on: ${{ fromJSON(fromJSON(inputs.row_json).runner_labels) }} + outputs: + package_base_image: ${{ steps.bases.outputs.package_base_image }} + runtime_base_image: ${{ steps.bases.outputs.runtime_base_image }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v8 + with: + name: ${{ fromJSON(inputs.row_json).upstream_artifact_id }} + path: artifacts/upstream + - uses: docker/setup-buildx-action@v4 + - name: Resolve exact package and runtime base images + id: bases + env: + PACKAGE_BASE: ${{ fromJSON(inputs.row_json).package_base_image }} + RUNTIME_BASE: ${{ fromJSON(inputs.row_json).runtime_base_image }} + shell: bash + run: | + set -euo pipefail + resolve_ref() { + local ref="$1" + local raw digest name + raw="$(mktemp)" + docker buildx imagetools inspect "$ref" --raw > "$raw" + digest="sha256:$(sha256sum "$raw" | awk '{ print $1 }')" + rm -f "$raw" + name="${ref%%@*}" + if [[ "$name" == "$ref" ]]; then + slash_tail="${ref##*/}" + if [[ "$slash_tail" == *:* ]]; then name="${ref%:*}"; fi + fi + printf '%s@%s' "$name" "$digest" + } + package_base_image="$(resolve_ref "$PACKAGE_BASE")" + runtime_base_image="$(resolve_ref "$RUNTIME_BASE")" + { + echo "package_base_image=$package_base_image" + echo "runtime_base_image=$runtime_base_image" + } >> "$GITHUB_OUTPUT" + - name: Build native package from verified upstream product bundle + uses: docker/build-push-action@v7 + with: + context: . + file: docker/Dockerfile.mesh-llm + target: native-package-artifact + platforms: ${{ fromJSON(inputs.row_json).platform }} + build-args: | + PACKAGE_BASE_IMAGE=${{ steps.bases.outputs.package_base_image }} + DISTRO=${{ fromJSON(inputs.row_json).distro }} + BACKEND=${{ fromJSON(inputs.row_json).backend }} + BACKEND_VERSION=${{ fromJSON(inputs.row_json).backend_version }} + TARGET_ARCH=${{ fromJSON(inputs.row_json).arch }} + MESH_LLM_VERSION=${{ fromJSON(inputs.row_json).mesh_version }} + outputs: type=local,dest=artifacts/native-package + cache-from: type=gha,scope=package-${{ fromJSON(inputs.row_json).artifact_id }} + cache-to: type=gha,mode=max,scope=package-${{ fromJSON(inputs.row_json).artifact_id }} + - name: Namespace and require BuildKit package provenance + env: + ARTIFACT_ID: ${{ fromJSON(inputs.row_json).artifact_id }} + PACKAGE_FILE: ${{ fromJSON(inputs.row_json).package_file }} + shell: bash + run: | + set -euo pipefail + test -s artifacts/native-package/provenance.json + package_sha="$(sha256sum "artifacts/native-package/$PACKAGE_FILE" | awk '{ print $1 }')" + jq -e --arg name "$PACKAGE_FILE" --arg digest "$package_sha" ' + ._type == "https://in-toto.io/Statement/v1" and + (.subject | length) == 1 and + .subject[0].name == $name and + .subject[0].digest.sha256 == $digest + ' artifacts/native-package/provenance.json >/dev/null + mv artifacts/native-package/provenance.json \ + "artifacts/native-package/$ARTIFACT_ID.buildkit-provenance.json" + - name: Verify and install package + shell: bash + run: | + scripts/native-package-qa.sh \ + --distro "${{ fromJSON(inputs.row_json).distro }}" \ + --backend "${{ fromJSON(inputs.row_json).backend }}" \ + --backend-version "${{ fromJSON(inputs.row_json).backend_version }}" \ + --arch "${{ fromJSON(inputs.row_json).arch }}" \ + --version "${{ fromJSON(inputs.row_json).mesh_version }}" \ + --package-format "${{ fromJSON(inputs.row_json).package_format }}" \ + --package-dir artifacts/native-package \ + --runtime-base-image "${{ steps.bases.outputs.runtime_base_image }}" \ + --install + - name: Attach exact upstream provenance to package artifact + run: cp artifacts/upstream/upstream-provenance.json "artifacts/native-package/${{ fromJSON(inputs.row_json).artifact_id }}.upstream-provenance.json" + - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + file: artifacts/native-package/${{ fromJSON(inputs.row_json).package_file }} + format: spdx-json + output-file: artifacts/native-package/${{ fromJSON(inputs.row_json).artifact_id }}.spdx.json + syft-version: v1.42.3 + upload-artifact: false + upload-release-assets: false + - name: Verify exact package SBOM subject + run: | + node --experimental-strip-types scripts/verify-sbom-subject.ts \ + --package "artifacts/native-package/${{ fromJSON(inputs.row_json).package_file }}" \ + --sidecar "artifacts/native-package/${{ fromJSON(inputs.row_json).package_file }}.sha256" \ + --sbom "artifacts/native-package/${{ fromJSON(inputs.row_json).artifact_id }}.spdx.json" + - uses: actions/upload-artifact@v7 + with: + name: ${{ fromJSON(inputs.row_json).native_package_artifact_name }} + path: artifacts/native-package + if-no-files-found: error + retention-days: 14 + + dry-image: + name: Build and test local final image + if: ${{ !inputs.publish_images }} + needs: package + runs-on: ${{ fromJSON(fromJSON(inputs.row_json).runner_labels) }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v8 + with: + name: ${{ fromJSON(inputs.row_json).native_package_artifact_name }} + path: artifacts/native-package + - uses: docker/setup-buildx-action@v4 + - name: Read immutable product inputs + id: product + shell: bash + run: | + set -euo pipefail + mapfile -d '' -t provenance_files < <(find artifacts/native-package -name '*.upstream-provenance.json' -type f -print0) + if (( ${#provenance_files[@]} != 1 )); then + echo "expected exactly one upstream provenance file, found ${#provenance_files[@]}" >&2 + exit 1 + fi + provenance="${provenance_files[0]}" + jq -e ' + (.host_sha256 | type == "string" and test("^[0-9a-f]{64}$")) and + (.runtime_id | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._-]*$")) and + (.runtime_sha256 | type == "string" and test("^[0-9a-f]{64}$")) + ' "$provenance" >/dev/null + { + echo "host_sha=$(jq -r .host_sha256 "$provenance")" + echo "runtime_id=$(jq -r .runtime_id "$provenance")" + echo "runtime_sha=$(jq -r .runtime_sha256 "$provenance")" + } >> "$GITHUB_OUTPUT" + - name: Build the final runtime image once + uses: docker/build-push-action@v7 + with: + context: . + file: docker/Dockerfile.mesh-llm + target: runtime + platforms: ${{ fromJSON(inputs.row_json).platform }} + load: true + tags: mesh-packaging-local:${{ fromJSON(inputs.row_json).artifact_id }} + build-args: | + RUNTIME_BASE_IMAGE=${{ needs.package.outputs.runtime_base_image }} + DISTRO=${{ fromJSON(inputs.row_json).distro }} + BACKEND=${{ fromJSON(inputs.row_json).backend }} + BACKEND_VERSION=${{ fromJSON(inputs.row_json).backend_version }} + MESH_LLM_VERSION=${{ fromJSON(inputs.row_json).mesh_version }} + MESH_LLM_REF=${{ inputs.mesh_ref }} + MESH_LLM_SOURCE_SHA=${{ inputs.mesh_sha }} + MESH_LLM_HOST_SHA=${{ steps.product.outputs.host_sha }} + MESH_LLM_RUNTIME_ID=${{ steps.product.outputs.runtime_id }} + MESH_LLM_RUNTIME_SHA=${{ steps.product.outputs.runtime_sha }} + cache-from: type=gha,scope=image-${{ fromJSON(inputs.row_json).artifact_id }} + cache-to: type=gha,mode=max,scope=image-${{ fromJSON(inputs.row_json).artifact_id }} + - name: Test the exact locally loaded final image + env: + IMAGE_REF: mesh-packaging-local:${{ fromJSON(inputs.row_json).artifact_id }} + shell: bash + run: | + set -euo pipefail + image_id="$(docker image inspect --format '{{.Id}}' "$IMAGE_REF")" + [[ "$image_id" =~ ^sha256:[0-9a-f]{64}$ ]] + docker run --rm --entrypoint sh \ + -v "$GITHUB_WORKSPACE/docker/qa-runtime-image.sh:/tmp/qa-runtime-image.sh:ro" \ + "$IMAGE_REF" /tmp/qa-runtime-image.sh \ + "${{ fromJSON(inputs.row_json).distro }}" \ + "${{ fromJSON(inputs.row_json).backend }}" \ + "${{ fromJSON(inputs.row_json).mesh_version }}" + docker run --rm --entrypoint sh \ + -v "$GITHUB_WORKSPACE/scripts/client-readiness-smoke.sh:/tmp/client-readiness-smoke.sh:ro" \ + "$IMAGE_REF" -c \ + 'MESH_LLM_SMOKE_BIN=/usr/local/bin/mesh-llm-entrypoint sh /tmp/client-readiness-smoke.sh' + jq -n \ + --arg artifact_id "${{ fromJSON(inputs.row_json).artifact_id }}" \ + --arg image_id "$image_id" \ + --arg runtime_base_image "${{ needs.package.outputs.runtime_base_image }}" \ + '{schema_version:1, artifact_id:$artifact_id, mode:"local", image_id:$image_id, runtime_base_image:$runtime_base_image, qa:{passed:true,image_id:$image_id}}' \ + > image-row-result.json + - uses: actions/upload-artifact@v7 + with: + name: mesh-llm-image-result-${{ fromJSON(inputs.row_json).artifact_id }} + path: image-row-result.json + if-no-files-found: error + retention-days: 14 + + stage-image: + name: Stage and test exact registry digest + if: ${{ inputs.publish_images }} + needs: package + runs-on: ${{ fromJSON(fromJSON(inputs.row_json).runner_labels) }} + environment: release + permissions: + contents: read + packages: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v8 + with: + name: ${{ fromJSON(inputs.row_json).native_package_artifact_name }} + path: artifacts/native-package + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Read immutable product inputs + id: product + shell: bash + run: | + set -euo pipefail + mapfile -d '' -t provenance_files < <(find artifacts/native-package -name '*.upstream-provenance.json' -type f -print0) + if (( ${#provenance_files[@]} != 1 )); then + echo "expected exactly one upstream provenance file, found ${#provenance_files[@]}" >&2 + exit 1 + fi + provenance="${provenance_files[0]}" + jq -e ' + (.host_sha256 | type == "string" and test("^[0-9a-f]{64}$")) and + (.runtime_id | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._-]*$")) and + (.runtime_sha256 | type == "string" and test("^[0-9a-f]{64}$")) + ' "$provenance" >/dev/null + { + echo "host_sha=$(jq -r .host_sha256 "$provenance")" + echo "runtime_id=$(jq -r .runtime_id "$provenance")" + echo "runtime_sha=$(jq -r .runtime_sha256 "$provenance")" + } >> "$GITHUB_OUTPUT" + - name: Build and push one run-scoped staging image + id: stage + uses: docker/build-push-action@v7 + with: + context: . + file: docker/Dockerfile.mesh-llm + target: runtime + platforms: ${{ fromJSON(inputs.row_json).platform }} + push: true + tags: ${{ inputs.image_name }}:staging-${{ github.run_id }}-${{ github.run_attempt }}-${{ fromJSON(inputs.row_json).artifact_id }} + build-args: | + RUNTIME_BASE_IMAGE=${{ needs.package.outputs.runtime_base_image }} + DISTRO=${{ fromJSON(inputs.row_json).distro }} + BACKEND=${{ fromJSON(inputs.row_json).backend }} + BACKEND_VERSION=${{ fromJSON(inputs.row_json).backend_version }} + MESH_LLM_VERSION=${{ fromJSON(inputs.row_json).mesh_version }} + MESH_LLM_REF=${{ inputs.mesh_ref }} + MESH_LLM_SOURCE_SHA=${{ inputs.mesh_sha }} + MESH_LLM_HOST_SHA=${{ steps.product.outputs.host_sha }} + MESH_LLM_RUNTIME_ID=${{ steps.product.outputs.runtime_id }} + MESH_LLM_RUNTIME_SHA=${{ steps.product.outputs.runtime_sha }} + cache-from: type=gha,scope=image-${{ fromJSON(inputs.row_json).artifact_id }} + cache-to: type=gha,mode=max,scope=image-${{ fromJSON(inputs.row_json).artifact_id }} + - name: Pull and test the exact staged digest + env: + IMAGE_REF: ${{ inputs.image_name }}@${{ steps.stage.outputs.digest }} + shell: bash + run: | + set -euo pipefail + [[ "${{ steps.stage.outputs.digest }}" =~ ^sha256:[0-9a-f]{64}$ ]] + docker pull --platform "${{ fromJSON(inputs.row_json).platform }}" "$IMAGE_REF" + docker run --rm --platform "${{ fromJSON(inputs.row_json).platform }}" --entrypoint sh \ + -v "$GITHUB_WORKSPACE/docker/qa-runtime-image.sh:/tmp/qa-runtime-image.sh:ro" \ + "$IMAGE_REF" /tmp/qa-runtime-image.sh \ + "${{ fromJSON(inputs.row_json).distro }}" \ + "${{ fromJSON(inputs.row_json).backend }}" \ + "${{ fromJSON(inputs.row_json).mesh_version }}" + docker run --rm --platform "${{ fromJSON(inputs.row_json).platform }}" --entrypoint sh \ + -v "$GITHUB_WORKSPACE/scripts/client-readiness-smoke.sh:/tmp/client-readiness-smoke.sh:ro" \ + "$IMAGE_REF" -c \ + 'MESH_LLM_SMOKE_BIN=/usr/local/bin/mesh-llm-entrypoint sh /tmp/client-readiness-smoke.sh' + - name: Record exact package, product, base, image, and QA evidence + env: + ARTIFACT_ID: ${{ fromJSON(inputs.row_json).artifact_id }} + PLATFORM: ${{ fromJSON(inputs.row_json).platform }} + ARCH: ${{ fromJSON(inputs.row_json).arch }} + BACKEND: ${{ fromJSON(inputs.row_json).backend }} + BACKEND_VERSION: ${{ fromJSON(inputs.row_json).backend_version }} + PACKAGE_FILE: ${{ fromJSON(inputs.row_json).package_file }} + PACKAGE_BASE_IMAGE: ${{ needs.package.outputs.package_base_image }} + RUNTIME_BASE_IMAGE: ${{ needs.package.outputs.runtime_base_image }} + TAGS: ${{ fromJSON(inputs.row_json).tags }} + VERSION: ${{ fromJSON(inputs.row_json).mesh_version }} + MESH_REF: ${{ inputs.mesh_ref }} + MESH_SHA: ${{ inputs.mesh_sha }} + IMAGE_NAME: ${{ inputs.image_name }} + IMAGE_DIGEST: ${{ steps.stage.outputs.digest }} + HOST_SHA: ${{ steps.product.outputs.host_sha }} + RUNTIME_ID: ${{ steps.product.outputs.runtime_id }} + RUNTIME_SHA: ${{ steps.product.outputs.runtime_sha }} + shell: bash + run: | + set -euo pipefail + package_sha="$(sha256sum "artifacts/native-package/$PACKAGE_FILE" | awk '{ print $1 }')" + jq -n \ + --arg version "$VERSION" --arg mesh_ref "$MESH_REF" \ + --arg mesh_sha "$MESH_SHA" --arg packaging_sha "$GITHUB_SHA" \ + --arg artifact_id "$ARTIFACT_ID" --arg platform "$PLATFORM" --arg arch "$ARCH" \ + --arg backend "$BACKEND" --arg backend_version "$BACKEND_VERSION" \ + --arg package_file "$PACKAGE_FILE" --arg package_sha "$package_sha" \ + --arg package_base_image "$PACKAGE_BASE_IMAGE" \ + --arg runtime_base_image "$RUNTIME_BASE_IMAGE" --arg tags "$TAGS" \ + --arg host_sha "$HOST_SHA" --arg runtime_id "$RUNTIME_ID" \ + --arg runtime_sha "$RUNTIME_SHA" --arg image_name "$IMAGE_NAME" \ + --arg image_digest "$IMAGE_DIGEST" \ + '{ + schema_version:1, + plan_identity:{version:$version,mesh_ref:$mesh_ref,mesh_sha:$mesh_sha,packaging_sha:$packaging_sha}, + artifact_id:$artifact_id,platform:$platform,arch:$arch,backend:$backend, + backend_version:$backend_version,package_file:$package_file, + package_base_image:$package_base_image, + runtime_base_image:$runtime_base_image,tags:($tags | split("\n")), + package:{name:$package_file,sha256:$package_sha}, + upstream:{mesh_ref:$mesh_ref,mesh_sha:$mesh_sha}, + product:{host_sha256:$host_sha,runtime_id:$runtime_id,runtime_sha256:$runtime_sha}, + base_image:{ref:$runtime_base_image}, + image:{name:$image_name,digest:$image_digest}, + qa:{passed:true,image_digest:$image_digest} + }' > image-row-result.json + - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4 + with: + subject-name: ${{ inputs.image_name }} + subject-digest: ${{ steps.stage.outputs.digest }} + push-to-registry: true + - uses: actions/upload-artifact@v7 + with: + name: mesh-llm-image-result-${{ fromJSON(inputs.row_json).artifact_id }} + path: image-row-result.json + if-no-files-found: error + retention-days: 14 diff --git a/README.md b/README.md index 8f3f1c1..b9b27c9 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,15 @@ rendered Homebrew formula is also published through the canonical ## Automation -`.github/workflows/images-release.yml` accepts the `mesh-llm-release` repository dispatch event and safe manual backfills. Every manual run defaults to `dry_run=true`. Dry-run mode forces both publish switches off while still downloading, checksumming, packaging, installing, image-building, and Homebrew-testing the selected rows. +`.github/workflows/images-release.yml` accepts the `mesh-llm-release` repository dispatch event and safe manual backfills. Every manual run defaults to `dry_run=true`. Dry-run mode forces every publication switch off while preserving the explicitly selected validation components. -Publishing uses separate switches for GHCR images, package release assets, and npm. All require `dry_run=false`; npm uses the `npm` environment and the other channels use `release`. `npm_lane_filter` can schedule one or more addon lanes independently and always disables npm publication for that run. +Publishing uses separate switches for GHCR images, package release assets, and npm. All require `dry_run=false`; npm uses the `npm` environment and the other channels use `release`. Manual runs use typed `validate_native`, `validate_homebrew`, and `validate_npm` switches. `native_selector` and `npm_selector` accept only `all` or exact checked-in IDs; partial selections can validate but cannot publish. + +Each native row resolves its package and runtime bases to immutable digests, +produces one native package, and builds one final image. Dry runs load and test +that exact local image without registry writes. Publishing runs push a +run-scoped staging image, test it by digest, assemble a canonical release +index, and promote the tested digest without rebuilding. The upstream release repository must send this payload after its GitHub Release is published: @@ -73,7 +79,7 @@ Cross-repository dispatch requires a fine-grained token or GitHub App with Actio node --experimental-strip-types scripts/image-matrix.ts validate node --experimental-strip-types scripts/image-matrix.ts upstream-matrix --version v0.73.1 node --experimental-strip-types scripts/image-matrix.ts npm-matrix --version v0.73.1 -node --experimental-strip-types --test tests/*.test.ts +node --experimental-strip-types --test --test-concurrency=1 tests/*.test.ts shellcheck docker/*.sh packaging/native/*.sh scripts/*.sh actionlint docker buildx build --check --target native-package-artifact -f docker/Dockerfile.mesh-llm . diff --git a/TODO.md b/TODO.md index baa6cad..c8f3ea9 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,36 @@ # Production Readiness TODO +- [x] Replace widening workflow filters with a typed, fail-closed release plan. + Final result: manual runs select native, Homebrew, and npm validation through + typed booleans plus exact checked-in IDs or `all`; publication implications + require the complete matching producer set, and readiness accepts skips only + for disabled components. + QA: planner tests cover native-only, Homebrew-only, npm-only, full release, + exact selectors, invalid/duplicate selectors, publish implications, and + readiness-required versus expected-skipped results; actionlint and the + workflow provenance suite pass. + +- [x] Stage each runtime image once, test its exact registry digest, and promote + only that tested digest without another Docker build. + Final result: each row resolves immutable bases and produces one package plus + one final image; dry runs test a locally loaded image without registry writes, + while publish runs test a run-scoped staging digest, assemble a canonical + index, and retag only that digest with immutable-tag guards and a rollback + ledger. + QA: workflow tests prove one image build per publishing row, digest-bound QA, + immutable version-tag conflict rejection, convenience-tag rollback evidence, + zero Docker builds in promotion, and a complete deterministic release index. + +- [x] Consume upstream-produced Node addon artifacts instead of compiling addon + source in mesh-packaging. + Final result: the upstream release owns five platform-native addon producers; + packaging downloads their versioned archives and checksum sidecars, rejects + unsafe layouts or manifest/digest drift, and assembles npm without Cargo or a + native source build. + QA: upstream release tests prove all five target artifacts are checksummed and + published; packaging tests reject unsafe, missing, or digest-mismatched addons + and the workflow contains no downstream native compilation path. + - [x] Make native release evidence exact and publication immutable. Final result: every package row preserves a uniquely named BuildKit statement, scans only the exact package file into SPDX, verifies the package basename and diff --git a/docker/Dockerfile.mesh-llm b/docker/Dockerfile.mesh-llm index 35b7e39..1cebe14 100644 --- a/docker/Dockerfile.mesh-llm +++ b/docker/Dockerfile.mesh-llm @@ -64,16 +64,3 @@ COPY docker/entrypoint.sh /usr/local/bin/mesh-llm-entrypoint RUN chmod +x /usr/local/bin/mesh-llm-entrypoint /usr/local/bin/mesh-llm EXPOSE 3131 9337 ENTRYPOINT ["/usr/local/bin/mesh-llm-entrypoint"] - -# Dry-run validation extends the exact final runtime stage but emits no image -# tarball. This avoids duplicating multi-gigabyte GPU layers into the classic -# Docker store merely to execute package and loader checks. -FROM runtime AS runtime-qa -ARG DISTRO=ubuntu -ARG BACKEND=cpu -ARG MESH_LLM_VERSION -COPY docker/qa-runtime-image.sh /usr/local/bin/qa-runtime-image -COPY scripts/client-readiness-smoke.sh /usr/local/bin/client-readiness-smoke -RUN sh /usr/local/bin/qa-runtime-image "${DISTRO}" "${BACKEND}" "${MESH_LLM_VERSION}" && \ - MESH_LLM_SMOKE_BIN=/usr/local/bin/mesh-llm-entrypoint sh /usr/local/bin/client-readiness-smoke && \ - rm /usr/local/bin/qa-runtime-image /usr/local/bin/client-readiness-smoke diff --git a/docs/matrix.md b/docs/matrix.md index a04db85..de4c654 100644 --- a/docs/matrix.md +++ b/docs/matrix.md @@ -31,9 +31,10 @@ Windows upstream archives are not repackaged because this repository has no Wind ## npm lanes The `npm.lanes` entries use the same `matrix_enabled` and `release_enabled` -controls as package rows. They build `@mesh-llm/sdk` addons for macOS arm64/x64, -Linux arm64/x64, and Windows x64. `npm-matrix` expands the enabled lanes; -`npm_lane_filter` selects lane IDs or targets for isolated CI dry runs. +controls as package rows. Upstream builds and smoke-tests checksummed addon +archives for macOS arm64/x64, Linux arm64/x64, and Windows x64. Packaging safely +extracts those immutable release assets and assembles `@mesh-llm/sdk` without +Cargo or native source compilation. `npm_selector` accepts exact lane IDs. ## Archive deduplication diff --git a/docs/native-packages.md b/docs/native-packages.md index fbbb711..53ad037 100644 --- a/docs/native-packages.md +++ b/docs/native-packages.md @@ -36,12 +36,14 @@ unique API/console ports and cache/runtime roots to start `passive_mode`/`status=ready`/`role=client` event while the process is alive, and require bounded SIGINT shutdown. -The Dockerfile's `runtime-qa` stage extends the exact final runtime stage. It -verifies package ownership, rejects backend imports or unresolved libraries -from the host executable, and runs that same no-driver client readiness smoke -without device access. Backend libraries may reference their driver interface -only from inside the native runtime. Hardware-qualified serving is separate -additive coverage. +The per-row workflow builds the Dockerfile's final `runtime` target once, then +runs external QA against that exact image. It verifies package ownership, +rejects backend imports or unresolved libraries from the host executable, and +runs the same no-driver client readiness smoke without device access. +Publishing QA pulls the run-scoped staging image by digest; promotion retags +that tested digest without rebuilding. Backend libraries may reference their +driver interface only from inside the native runtime. Hardware-qualified +serving is separate additive coverage. Packages install the host at `/usr/local/bin/mesh-llm` and the selected runtime at `/usr/local/lib/mesh-llm//native-runtimes/`, alongside diff --git a/docs/publishing.md b/docs/publishing.md index 070adc8..e1ec6c4 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -2,10 +2,11 @@ The release workflow has four publication states: -1. `dry_run=true`: all required archive, package, image, and Homebrew validation - runs; publication is forcibly disabled. An unfiltered dry run also assembles - and verifies the exact release asset set and aggregate package provenance. -2. `dry_run=false,publish_images=true`: validated OCI images are pushed to GHCR and receive build-provenance attestations. +1. `dry_run=true`: publication is forcibly disabled while the explicitly + enabled native, Homebrew, and npm validation components run. +2. `dry_run=false,publish_images=true`: every final OCI image is pushed once to + a run-scoped staging tag, tested by immutable digest, attested, indexed, and + promoted without another build. 3. `dry_run=false,publish_release_assets=true`: native packages, checksums, SPDX SBOMs, and the rendered Homebrew formula are attached to a `packaging-v` release in this repository. 4. `dry_run=false,publish_npm=true`: the install-tested `@mesh-llm/sdk` tarball is published with provenance; stable versions use `latest` and @@ -13,10 +14,11 @@ The release workflow has four publication states: Non-npm publish jobs use the `release` GitHub environment, whose deployment policy accepts only `main`, while npm publishing uses the separate `npm` environment described below. Publish jobs have job-local write permissions, and all build and validation jobs are read-only. Add required reviewers when the repository plan supports environment reviewers. The upstream tag must already have a non-draft GitHub Release, the repository must be exactly `Mesh-LLM/mesh-llm`, the ref and version must match, and the tag is resolved to an immutable commit SHA for provenance labels. -Filtered runs are validation-only. If any native variant, platform, or npm lane -filter is present while a publication switch is enabled, planning fails before -build or publication. Native release publication therefore always represents -the checked-in complete 11-row package matrix. +Planning is typed and fail-closed. Native and npm selectors accept only `all` or +exact checked-in artifact/lane IDs; aliases, empty tokens, duplicates, and +unknown IDs fail before scheduling. Publication requires the complete matching +producer set: images require all native rows, package release assets require all +native rows plus Homebrew, and npm requires all addon lanes. Every enabled native package row preserves its BuildKit statement under an artifact-specific filename, scans the exact package file into SPDX, and verifies @@ -59,9 +61,12 @@ to `@mesh-llm/sdk`. All other package names are rejected, and subsequent upstream releases declare the canonical name directly. Moving convenience OCI tags are published alongside immutable version tags. -Rollback must never mutate a versioned host, runtime, product, package, or image -silently: stop the affected row, preserve both input digests as evidence, and -publish a new upstream version or explicit correction record. +The canonical image release index binds the upstream and packaging SHAs, +digest-qualified runtime bases, package/product hashes, exact QA-tested image +digests, and destination tags. Promotion rejects a conflicting version tag and +records each convenience tag's previous digest in a rollback ledger. Promotion +contains no Docker build. Rollback must never mutate a versioned host, runtime, +product, package, or image silently. This repository is the sole GHCR producer. A successful non-canary `Mesh-LLM/mesh-llm` release with the complete GPU bundle set dispatches diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 0c1528b..d24d948 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -4,6 +4,8 @@ - [ ] The upstream tag and non-draft GitHub Release exist and the tag resolves to one immutable SHA. - [ ] Every generated archive and `.sha256` sidecar exists in that release. +- [ ] All five Node addon archives and sidecars exist; safe extraction verifies + their exact target path, manifest identity, and inner addon digest. - [ ] Archive verification accepts only the product-v2 host, host-import report, product manifest, and exactly one runtime tree. It verifies every recorded digest and rejects unexpected or traversal-prone entries. @@ -40,11 +42,15 @@ - [ ] A reviewer confirms the selected publish switches and the `release` environment gate. - [ ] GHCR tags match `docs/tagging.md`; pushed digests receive provenance attestations. +- [ ] The canonical image index binds every selected row to its digest-qualified + base, package/product hashes, and exact QA-tested staging digest. +- [ ] Image promotion performs no Docker build, rejects version-tag drift, and + preserves previous moving-tag digests in the rollback ledger. - [ ] Package release assets contain exact packages, SHA256 manifests, SPDX SBOMs, and the rendered formula. - [ ] Every package SPDX document names the exact `.deb` or `.pkg.tar.zst` basename and its verified sidecar SHA-256. Every uniquely named per-row BuildKit statement names the same subject before aggregate assembly. -- [ ] No variant, platform, or npm lane filter is present on a publish run. +- [ ] Every publish channel uses its complete exact selector (`all`). - [ ] A new `packaging-v` release does not already exist. If it does, the workflow may no-op only when tag target, title, body, state, exact asset names, and GitHub asset digests all match; otherwise publication must fail. diff --git a/docs/runner-capacity.md b/docs/runner-capacity.md index 36a2ddc..9c39980 100644 --- a/docs/runner-capacity.md +++ b/docs/runner-capacity.md @@ -8,7 +8,12 @@ verification are deduplicated before distro fan-out. Host compilation, UI generation, and native-runtime builds happen only in upstream MeshLLM, eliminating the largest former cost and drift source. -Use `variant_filter` and `platform_filter` for review iteration. A production dry run should still exercise every active row because rolling Arch dependencies and vendor runtime bases can drift independently even when the upstream binary is unchanged. BuildKit GitHub cache scopes are per package row to keep package layers reusable without cross-row contamination. Runtime dry runs target `runtime-qa` with `type=cacheonly` and deliberately do not export a GitHub Actions cache: exporting either an image tarball or multi-gigabyte Arch CUDA/ROCm cache layers costs more disk, bandwidth, and cache quota than rebuilding the vendor package layer in place. +Use `native_selector` with exact artifact IDs for review iteration. A production +dry run should still exercise every active row because rolling Arch dependencies +and vendor runtime bases can drift independently even when the upstream binary +is unchanged. BuildKit GitHub cache scopes are per row. Dry runs build and load +one final image for external QA without registry writes; publishing runs push +one staging image and reuse its exact tested digest during promotion. The expected cost order is CPU < Vulkan < CUDA < ROCm, driven here by QA/runtime-base download and package installation rather than compilation. All diff --git a/scripts/release-index.ts b/scripts/release-index.ts new file mode 100644 index 0000000..8938d05 --- /dev/null +++ b/scripts/release-index.ts @@ -0,0 +1,496 @@ +#!/usr/bin/env -S node --experimental-strip-types +import { + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export type ReleaseIdentity = { + readonly version: string; + readonly mesh_ref: string; + readonly mesh_sha: string; + readonly packaging_sha: string; +}; + +export type ExpectedImageRow = { + readonly artifact_id: string; + readonly platform: string; + readonly arch: string; + readonly backend: string; + readonly backend_version: string; + readonly package_file: string; + readonly package_base_image: string; + readonly runtime_base_image: string; + readonly tags: readonly string[]; +}; + +export type ReleasePlan = { + readonly schema_version: 1; + readonly identity: ReleaseIdentity; + readonly image_name: string; + readonly expected_rows: readonly ExpectedImageRow[]; +}; + +export type ImageRowResult = ExpectedImageRow & { + readonly schema_version: 1; + readonly plan_identity: ReleaseIdentity; + readonly package: { + readonly name: string; + readonly sha256: string; + }; + readonly upstream: { + readonly mesh_ref: string; + readonly mesh_sha: string; + }; + readonly product: { + readonly host_sha256: string; + readonly runtime_id: string; + readonly runtime_sha256: string; + }; + readonly base_image: { + readonly ref: string; + }; + readonly image: { + readonly name: string; + readonly digest: string; + }; + readonly qa: { + readonly passed: true; + readonly image_digest: string; + }; +}; + +export type ReleaseIndex = { + readonly schema_version: 1; + readonly identity: ReleaseIdentity; + readonly image_name: string; + readonly rows: readonly ImageRowResult[]; +}; + +const SHA256 = /^[0-9a-f]{64}$/; +const IMAGE_DIGEST = /^sha256:[0-9a-f]{64}$/; +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const PLATFORM = /^[a-z0-9]+\/[a-z0-9_]+$/; +const OCI_NAME = /^[a-z0-9][a-z0-9._:-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/; +const OCI_TAG = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; + +function object(value: unknown, field: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + return value as Record; +} + +function exactKeys(value: Record, keys: readonly string[], field: string): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${field} must contain exactly: ${expected.join(", ")}`); + } +} + +function string(value: unknown, field: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${field} must be a non-empty string`); + } + return value; +} + +function matching(value: unknown, pattern: RegExp, field: string): string { + const result = string(value, field); + if (!pattern.test(result)) throw new Error(`${field} has an invalid value`); + return result; +} + +function sha256(value: unknown, field: string): string { + const result = string(value, field); + if (!SHA256.test(result)) throw new Error(`${field} must be a lowercase SHA-256 digest`); + return result; +} + +function imageDigest(value: unknown, field: string): string { + const result = string(value, field); + if (!IMAGE_DIGEST.test(result)) { + throw new Error(`${field} must be a lowercase sha256:`); + } + return result; +} + +function gitSha(value: unknown, field: string): string { + return matching(value, /^[0-9a-f]{40}$/, field); +} + +function ociName(value: unknown, field: string): string { + return matching(value, OCI_NAME, field); +} + +function digestQualifiedRef(value: unknown, field: string): string { + const result = string(value, field); + const separator = result.lastIndexOf("@sha256:"); + if (separator <= 0) throw new Error(`${field} must be digest-qualified`); + ociName(result.slice(0, separator), `${field} name`); + sha256(result.slice(separator + "@sha256:".length), `${field} digest`); + return result; +} + +function optionalSafeString(value: unknown, field: string): string { + if (typeof value !== "string") throw new Error(`${field} must be a string`); + if (value && !/^[A-Za-z0-9.-]+$/.test(value)) throw new Error(`${field} has an invalid value`); + return value; +} + +function identity(value: unknown, field: string): ReleaseIdentity { + const raw = object(value, field); + exactKeys(raw, ["version", "mesh_ref", "mesh_sha", "packaging_sha"], field); + const version = matching( + raw.version, + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/, + `${field}.version`, + ); + const meshRef = string(raw.mesh_ref, `${field}.mesh_ref`); + if (meshRef !== `v${version}`) throw new Error(`${field}.mesh_ref and version disagree`); + return { + version, + mesh_ref: meshRef, + mesh_sha: gitSha(raw.mesh_sha, `${field}.mesh_sha`), + packaging_sha: gitSha(raw.packaging_sha, `${field}.packaging_sha`), + }; +} + +function tags(value: unknown, imageName: string, field: string): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${field} must be a non-empty array`); + } + const prefix = `${imageName}:`; + const result = value.map((raw, index) => { + const tag = string(raw, `${field}[${index}]`); + if (!tag.startsWith(prefix) || !OCI_TAG.test(tag.slice(prefix.length))) { + throw new Error(`${field}[${index}] must be a tag for ${imageName}`); + } + return tag; + }); + if (new Set(result).size !== result.length) throw new Error(`${field} contains duplicate tags`); + return result.sort(); +} + +function rowFields( + value: Record, + imageName: string, + field: string, +): ExpectedImageRow { + return { + artifact_id: matching(value.artifact_id, SAFE_ID, `${field}.artifact_id`), + platform: matching(value.platform, PLATFORM, `${field}.platform`), + arch: matching(value.arch, /^[a-z0-9_]+$/, `${field}.arch`), + backend: matching(value.backend, SAFE_ID, `${field}.backend`), + backend_version: optionalSafeString(value.backend_version, `${field}.backend_version`), + package_file: matching(value.package_file, SAFE_ID, `${field}.package_file`), + package_base_image: digestQualifiedRef(value.package_base_image, `${field}.package_base_image`), + runtime_base_image: digestQualifiedRef(value.runtime_base_image, `${field}.runtime_base_image`), + tags: tags(value.tags, imageName, `${field}.tags`), + }; +} + +function expectedRow(value: unknown, imageName: string, field: string): ExpectedImageRow { + const raw = object(value, field); + exactKeys(raw, [ + "artifact_id", + "platform", + "arch", + "backend", + "backend_version", + "package_file", + "package_base_image", + "runtime_base_image", + "tags", + ], field); + return rowFields(raw, imageName, field); +} + +function assertUniqueRows(rows: readonly ExpectedImageRow[], field: string): void { + if (rows.length === 0) throw new Error(`${field} must not be empty`); + for (const key of ["artifact_id", "package_file"] as const) { + const values = rows.map((row) => row[key]); + if (new Set(values).size !== values.length) throw new Error(`${field} contains duplicate ${key}`); + } + const allTags = rows.flatMap((row) => row.tags); + if (new Set(allTags).size !== allTags.length) throw new Error(`${field} contains duplicate tags`); +} + +export function parseReleasePlan(value: unknown): ReleasePlan { + const raw = object(value, "plan"); + exactKeys(raw, ["schema_version", "identity", "image_name", "expected_rows"], "plan"); + if (raw.schema_version !== 1) throw new Error("plan.schema_version must be 1"); + const imageName = ociName(raw.image_name, "plan.image_name"); + if (!Array.isArray(raw.expected_rows)) throw new Error("plan.expected_rows must be an array"); + const rows = raw.expected_rows + .map((row, index) => expectedRow(row, imageName, `plan.expected_rows[${index}]`)) + .sort((left, right) => left.artifact_id.localeCompare(right.artifact_id)); + assertUniqueRows(rows, "plan.expected_rows"); + return { + schema_version: 1, + identity: identity(raw.identity, "plan.identity"), + image_name: imageName, + expected_rows: rows, + }; +} + +function packageRecord(value: unknown, expected: ExpectedImageRow, field: string) { + const raw = object(value, field); + exactKeys(raw, ["name", "sha256"], field); + const name = string(raw.name, `${field}.name`); + if (name !== expected.package_file) throw new Error(`${field}.name does not match the plan`); + return { name, sha256: sha256(raw.sha256, `${field}.sha256`) }; +} + +function upstreamRecord(value: unknown, plan: ReleasePlan, field: string) { + const raw = object(value, field); + exactKeys(raw, ["mesh_ref", "mesh_sha"], field); + const result = { + mesh_ref: string(raw.mesh_ref, `${field}.mesh_ref`), + mesh_sha: gitSha(raw.mesh_sha, `${field}.mesh_sha`), + }; + if (result.mesh_ref !== plan.identity.mesh_ref || result.mesh_sha !== plan.identity.mesh_sha) { + throw new Error(`${field} does not match the plan identity`); + } + return result; +} + +function productRecord(value: unknown, field: string) { + const raw = object(value, field); + exactKeys(raw, ["host_sha256", "runtime_id", "runtime_sha256"], field); + return { + host_sha256: sha256(raw.host_sha256, `${field}.host_sha256`), + runtime_id: matching(raw.runtime_id, SAFE_ID, `${field}.runtime_id`), + runtime_sha256: sha256(raw.runtime_sha256, `${field}.runtime_sha256`), + }; +} + +function baseRecord(value: unknown, expected: ExpectedImageRow, field: string) { + const raw = object(value, field); + exactKeys(raw, ["ref"], field); + const ref = digestQualifiedRef(raw.ref, `${field}.ref`); + if (ref !== expected.runtime_base_image) throw new Error(`${field}.ref does not match the plan`); + return { ref }; +} + +function imageRecord(value: unknown, plan: ReleasePlan, field: string) { + const raw = object(value, field); + exactKeys(raw, ["name", "digest"], field); + const name = ociName(raw.name, `${field}.name`); + if (name !== plan.image_name) throw new Error(`${field}.name does not match the plan`); + return { name, digest: imageDigest(raw.digest, `${field}.digest`) }; +} + +function qaRecord(value: unknown, digest: string, field: string) { + const raw = object(value, field); + exactKeys(raw, ["passed", "image_digest"], field); + if (raw.passed !== true) throw new Error(`${field}.passed must be true`); + const testedDigest = imageDigest(raw.image_digest, `${field}.image_digest`); + if (testedDigest !== digest) throw new Error(`${field}.image_digest does not match the image`); + return { passed: true as const, image_digest: testedDigest }; +} + +function sameExpectedRow(actual: ExpectedImageRow, expected: ExpectedImageRow, field: string): void { + if (stableJson(actual) !== stableJson(expected)) throw new Error(`${field} does not exactly match the plan row`); +} + +export function parseImageRowResult( + value: unknown, + plan: ReleasePlan, + expected: ExpectedImageRow, + field = "result", +): ImageRowResult { + const raw = object(value, field); + exactKeys(raw, [ + "schema_version", + "plan_identity", + "artifact_id", + "platform", + "arch", + "backend", + "backend_version", + "package_file", + "package_base_image", + "runtime_base_image", + "tags", + "package", + "upstream", + "product", + "base_image", + "image", + "qa", + ], field); + if (raw.schema_version !== 1) throw new Error(`${field}.schema_version must be 1`); + const resultIdentity = identity(raw.plan_identity, `${field}.plan_identity`); + if (stableJson(resultIdentity) !== stableJson(plan.identity)) { + throw new Error(`${field}.plan_identity does not match the plan`); + } + const row = rowFields(raw, plan.image_name, field); + sameExpectedRow(row, expected, field); + const image = imageRecord(raw.image, plan, `${field}.image`); + return { + schema_version: 1, + plan_identity: resultIdentity, + ...row, + package: packageRecord(raw.package, expected, `${field}.package`), + upstream: upstreamRecord(raw.upstream, plan, `${field}.upstream`), + product: productRecord(raw.product, `${field}.product`), + base_image: baseRecord(raw.base_image, expected, `${field}.base_image`), + image, + qa: qaRecord(raw.qa, image.digest, `${field}.qa`), + }; +} + +function verifyHostInvariant(rows: readonly ImageRowResult[]): void { + const hosts = new Map(); + for (const row of rows) { + const key = `${row.platform}/${row.arch}`; + const previous = hosts.get(key); + if (previous && previous !== row.product.host_sha256) { + throw new Error(`rows for ${key} contain different host SHA-256 values`); + } + hosts.set(key, row.product.host_sha256); + } +} + +export function assembleReleaseIndex(planValue: unknown, resultValues: readonly unknown[]): ReleaseIndex { + const plan = parseReleasePlan(planValue); + const expected = new Map(plan.expected_rows.map((row) => [row.artifact_id, row])); + const rawByArtifact = new Map(); + for (const [index, value] of resultValues.entries()) { + const raw = object(value, `results[${index}]`); + const artifactId = matching(raw.artifact_id, SAFE_ID, `results[${index}].artifact_id`); + if (!expected.has(artifactId)) throw new Error(`unexpected result artifact_id: ${artifactId}`); + if (rawByArtifact.has(artifactId)) throw new Error(`duplicate result artifact_id: ${artifactId}`); + rawByArtifact.set(artifactId, value); + } + const missing = plan.expected_rows + .map((row) => row.artifact_id) + .filter((artifactId) => !rawByArtifact.has(artifactId)); + if (missing.length > 0) throw new Error(`missing result rows: ${missing.join(", ")}`); + const rows = plan.expected_rows.map((row) => parseImageRowResult( + rawByArtifact.get(row.artifact_id), + plan, + row, + `result ${row.artifact_id}`, + )); + assertUniqueRows(rows, "results"); + verifyHostInvariant(rows); + return { + schema_version: 1, + identity: plan.identity, + image_name: plan.image_name, + rows, + }; +} + +function stable(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, stable(nested)]), + ); + } + return value; +} + +export function stableJson(value: unknown): string { + return `${JSON.stringify(stable(value), null, 2)}\n`; +} + +function jsonFiles(root: string): string[] { + const files: string[] = []; + const visit = (path: string) => { + for (const name of readdirSync(path).sort()) { + const child = resolve(path, name); + if (statSync(child).isDirectory()) visit(child); + else if (name.endsWith(".json")) files.push(child); + } + }; + visit(root); + return files; +} + +function inputs(planPath: string, resultsPath: string): [unknown, unknown[]] { + const files = jsonFiles(resultsPath); + if (files.length === 0) throw new Error("results directory contains no JSON records"); + return [ + JSON.parse(readFileSync(planPath, "utf8")), + files.map((path) => JSON.parse(readFileSync(path, "utf8"))), + ]; +} + +export function assembleReleaseIndexFiles(planPath: string, resultsPath: string, outputPath: string): ReleaseIndex { + const [plan, results] = inputs(planPath, resultsPath); + const index = assembleReleaseIndex(plan, results); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, stableJson(index)); + return index; +} + +export function verifyReleaseIndexFiles(planPath: string, resultsPath: string, indexPath: string): ReleaseIndex { + const [plan, results] = inputs(planPath, resultsPath); + const expected = assembleReleaseIndex(plan, results); + if (readFileSync(indexPath, "utf8") !== stableJson(expected)) { + throw new Error("release index is not the canonical index for the supplied plan and results"); + } + return expected; +} + +function parseArgs(argv: string[]): { command: string; values: Record } { + const command = argv[0] ?? ""; + const values: Record = {}; + for (let index = 1; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || !value || value.startsWith("--")) { + throw new Error(`invalid argument near ${key ?? "end of input"}`); + } + if (values[key.slice(2)]) throw new Error(`duplicate argument: ${key}`); + values[key.slice(2)] = value; + } + return { command, values }; +} + +function required(values: Record, name: string): string { + if (!values[name]) throw new Error(`--${name} is required`); + return resolve(values[name]); +} + +export function main(argv: string[]): number { + try { + const { command, values } = parseArgs(argv); + if (command === "assemble") { + const index = assembleReleaseIndexFiles( + required(values, "plan"), + required(values, "results"), + required(values, "output"), + ); + console.log(JSON.stringify({ rows: index.rows.length })); + } else if (command === "verify") { + const index = verifyReleaseIndexFiles( + required(values, "plan"), + required(values, "results"), + required(values, "index"), + ); + console.log(JSON.stringify({ rows: index.rows.length })); + } else { + throw new Error("command must be assemble or verify"); + } + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/scripts/release-plan.ts b/scripts/release-plan.ts new file mode 100644 index 0000000..3ff11cd --- /dev/null +++ b/scripts/release-plan.ts @@ -0,0 +1,344 @@ +#!/usr/bin/env -S node --experimental-strip-types +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + type Config, + type MatrixRow, + type NpmMatrixRow, + type UpstreamRow, + homebrewPlan, + loadConfig, + matrixRows, + normalizeVersion, + npmMatrixRows, + npmPlan, + stableStringify, + upstreamRows, + validate, +} from "./image-matrix.ts"; + +type JsonObject = Record; + +export type ReleasePlanInput = { + dry_run: boolean; + image?: string; + mesh_ref?: string; + mesh_repository?: string; + native_selector: string; + npm_selector: string; + publish_images: boolean; + publish_npm: boolean; + publish_release_assets: boolean; + validate_homebrew: boolean; + validate_native: boolean; + validate_npm: boolean; + version: string; +}; + +export type ExactSelection = { + ids: string[]; + mode: "all" | "selected"; +}; + +export type ReleasePlan = { + schema_version: 1; + dry_run: boolean; + homebrew_enabled: boolean; + homebrew_plan: ReturnType; + native_enabled: boolean; + native_selection: ExactSelection; + npm_enabled: boolean; + npm_matrix: { include: NpmMatrixRow[] }; + npm_plan: ReturnType; + npm_selection: ExactSelection; + package_matrix: { include: MatrixRow[] }; + publish_images: boolean; + publish_npm: boolean; + publish_release_assets: boolean; + release_assembly_enabled: boolean; + upstream_matrix: { include: UpstreamRow[] }; +}; + +type ParsedArgs = { + config: string; + options: Record; +}; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const DEFAULT_CONFIG = resolve(ROOT, "packaging/images.json"); +const BOOLEAN_OPTIONS = [ + "dry-run", + "publish-images", + "publish-npm", + "publish-release-assets", + "validate-homebrew", + "validate-native", + "validate-npm", +] as const; + +export function exactSelection( + value: string, + availableIds: readonly string[], + label: string, +): ExactSelection { + const tokens = value.split(",").map((token) => token.trim()); + if (tokens.some((token) => token.length === 0)) { + throw new Error(`${label} selector contains an empty token`); + } + const duplicates = tokens.filter((token, index) => tokens.indexOf(token) !== index); + if (duplicates.length > 0) { + throw new Error(`${label} selector contains duplicate id: ${duplicates[0]}`); + } + if (tokens.includes("all")) { + if (tokens.length !== 1) throw new Error(`${label} selector must use all by itself`); + return { ids: [...availableIds], mode: "all" }; + } + const available = new Set(availableIds); + const unknown = tokens.find((token) => !available.has(token)); + if (unknown) throw new Error(`${label} selector contains unknown exact id: ${unknown}`); + return { ids: tokens, mode: "selected" }; +} + +export function buildReleasePlan(config: Config, input: ReleasePlanInput): ReleasePlan { + const errors = validate(config); + if (errors.length > 0) throw new Error(errors.join("\n")); + validateInputBooleans(input); + if (!input.validate_native && !input.validate_homebrew && !input.validate_npm) { + throw new Error("at least one validation component must be enabled"); + } + + const version = normalizeVersion(input.version); + const image = input.image ?? config.image!.default_name!; + const repository = input.mesh_repository + ?? config.image!.source_repository!; + const meshRef = input.mesh_ref ?? `v${version}`; + const allNativeRows = matrixRows( + config, + image, + version, + meshRef, + repository, + new Set(), + new Set(), + false, + ); + const allNpmRows = npmMatrixRows(config, version, new Set(), false); + const nativeSelection = exactSelection( + input.native_selector, + allNativeRows.map((row) => row.artifact_id), + "native", + ); + const npmSelection = exactSelection( + input.npm_selector, + allNpmRows.map((row) => row.id), + "npm", + ); + requireDisabledSelectorIsAll(input.validate_native, nativeSelection, "native"); + requireDisabledSelectorIsAll(input.validate_npm, npmSelection, "npm"); + + const nativeRows = input.validate_native + ? selectNativeRows(config, image, version, meshRef, repository, nativeSelection) + : []; + const npmRows = input.validate_npm + ? selectNpmRows(config, version, npmSelection) + : []; + const publication = normalizedPublication(input); + enforcePublicationImplications(input, nativeSelection, npmSelection, publication); + + return { + schema_version: 1, + dry_run: input.dry_run, + homebrew_enabled: input.validate_homebrew, + homebrew_plan: homebrewPlan(config, version), + native_enabled: input.validate_native, + native_selection: nativeSelection, + npm_enabled: input.validate_npm, + npm_matrix: { include: npmRows }, + npm_plan: npmPlan(config, npmRows), + npm_selection: npmSelection, + package_matrix: { include: nativeRows }, + publish_images: publication.publish_images, + publish_npm: publication.publish_npm, + publish_release_assets: publication.publish_release_assets, + release_assembly_enabled: input.validate_native + && input.validate_homebrew + && nativeSelection.mode === "all", + upstream_matrix: { include: upstreamRows(nativeRows) }, + }; +} + +function selectNativeRows( + config: Config, + image: string, + version: string, + meshRef: string, + repository: string, + selection: ExactSelection, +): MatrixRow[] { + if (selection.mode === "all") { + return matrixRows(config, image, version, meshRef, repository, new Set(), new Set(), false); + } + return matrixRows( + config, + image, + version, + meshRef, + repository, + new Set(selection.ids), + new Set(), + false, + ); +} + +function selectNpmRows( + config: Config, + version: string, + selection: ExactSelection, +): NpmMatrixRow[] { + return npmMatrixRows( + config, + version, + selection.mode === "all" ? new Set() : new Set(selection.ids), + false, + ); +} + +function requireDisabledSelectorIsAll( + enabled: boolean, + selection: ExactSelection, + label: string, +): void { + if (!enabled && selection.mode !== "all") { + throw new Error(`${label} selector must be all when ${label} validation is disabled`); + } +} + +function normalizedPublication(input: ReleasePlanInput) { + if (input.dry_run) { + return { + publish_images: false, + publish_npm: false, + publish_release_assets: false, + }; + } + return { + publish_images: input.publish_images, + publish_npm: input.publish_npm, + publish_release_assets: input.publish_release_assets, + }; +} + +function enforcePublicationImplications( + input: ReleasePlanInput, + nativeSelection: ExactSelection, + npmSelection: ExactSelection, + publication: ReturnType, +): void { + if (publication.publish_images + && (!input.validate_native || nativeSelection.mode !== "all")) { + throw new Error("publishing images requires complete native validation"); + } + if (publication.publish_release_assets + && (!input.validate_native + || !input.validate_homebrew + || nativeSelection.mode !== "all")) { + throw new Error("publishing release assets requires complete native and Homebrew validation"); + } + if (publication.publish_npm + && (!input.validate_npm || npmSelection.mode !== "all")) { + throw new Error("publishing npm requires complete npm validation"); + } +} + +function validateInputBooleans(input: ReleasePlanInput): void { + for (const [name, value] of [ + ["dry_run", input.dry_run], + ["publish_images", input.publish_images], + ["publish_npm", input.publish_npm], + ["publish_release_assets", input.publish_release_assets], + ["validate_homebrew", input.validate_homebrew], + ["validate_native", input.validate_native], + ["validate_npm", input.validate_npm], + ] as const) { + if (typeof value !== "boolean") throw new Error(`${name} must be boolean`); + } +} + +function parseBoolean(value: string, name: string): boolean { + if (value === "true") return true; + if (value === "false") return false; + throw new Error(`--${name} must be true or false`); +} + +function requiredOption(options: Record, name: string): string { + const value = options[name]; + if (value === undefined) throw new Error(`--${name} is required`); + return value; +} + +function parseArgs(argv: string[]): ParsedArgs { + const parsed: ParsedArgs = { config: DEFAULT_CONFIG, options: {} }; + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name.startsWith("--")) throw new Error(`unexpected argument: ${name}`); + if (value === undefined || value.startsWith("--")) { + throw new Error(`${name} requires a value`); + } + const key = name.slice(2); + if (key === "config") { + parsed.config = resolve(process.cwd(), value); + } else { + if (parsed.options[key] !== undefined) throw new Error(`duplicate option: ${name}`); + parsed.options[key] = value; + } + } + return parsed; +} + +function inputFromOptions(options: Record): ReleasePlanInput { + const boolean = (name: typeof BOOLEAN_OPTIONS[number]) => + parseBoolean(requiredOption(options, name), name); + const knownOptions = new Set([ + ...BOOLEAN_OPTIONS, + "image", + "mesh-ref", + "mesh-repository", + "native-selector", + "npm-selector", + "version", + ]); + const unknown = Object.keys(options).find((name) => !knownOptions.has(name)); + if (unknown) throw new Error(`unknown option: --${unknown}`); + return { + dry_run: boolean("dry-run"), + image: options.image, + mesh_ref: options["mesh-ref"], + mesh_repository: options["mesh-repository"], + native_selector: requiredOption(options, "native-selector"), + npm_selector: requiredOption(options, "npm-selector"), + publish_images: boolean("publish-images"), + publish_npm: boolean("publish-npm"), + publish_release_assets: boolean("publish-release-assets"), + validate_homebrew: boolean("validate-homebrew"), + validate_native: boolean("validate-native"), + validate_npm: boolean("validate-npm"), + version: requiredOption(options, "version"), + }; +} + +export function main(argv: string[]): number { + try { + const args = parseArgs(argv); + const plan = buildReleasePlan(loadConfig(args.config), inputFromOptions(args.options)); + console.log(stableStringify(plan)); + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/scripts/upstream-node-addon.ts b/scripts/upstream-node-addon.ts new file mode 100644 index 0000000..c0f1487 --- /dev/null +++ b/scripts/upstream-node-addon.ts @@ -0,0 +1,180 @@ +#!/usr/bin/env -S node --experimental-strip-types +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + lstatSync, + mkdirSync, + readFileSync, + realpathSync, +} from "node:fs"; +import { basename, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +type Options = { + archive: string; + checksum: string; + outputDir: string; + target: string; + version: string; +}; + +const TARGETS = new Set([ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "win32-x64", +]); +const SHA256 = /^[0-9a-f]{64}$/; + +function parseArgs(argv: string[]): Options { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || !value || value.startsWith("--")) { + throw new Error(`invalid argument pair at ${name ?? ""}`); + } + if (values.has(name)) throw new Error(`duplicate option: ${name}`); + values.set(name, value); + } + const required = (name: string): string => { + const value = values.get(name); + if (!value) throw new Error(`${name} is required`); + return value; + }; + const known = new Set(["--archive", "--checksum", "--output-dir", "--target", "--version"]); + const unknown = [...values.keys()].find((name) => !known.has(name)); + if (unknown) throw new Error(`unknown option: ${unknown}`); + return { + archive: resolve(required("--archive")), + checksum: resolve(required("--checksum")), + outputDir: resolve(required("--output-dir")), + target: required("--target"), + version: required("--version"), + }; +} + +function digest(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function command(args: string[]): string { + const result = spawnSync("tar", args, { encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`tar ${args[0]} failed: ${result.stderr.trim()}`); + } + return result.stdout; +} + +function verifyChecksum(options: Options): void { + const line = readFileSync(options.checksum, "utf8").trim(); + const match = /^([0-9a-f]{64}) ([A-Za-z0-9][A-Za-z0-9._-]*)$/.exec(line); + if (!match || match[2] !== basename(options.archive)) { + throw new Error("checksum sidecar must bind exactly one expected archive filename"); + } + if (digest(options.archive) !== match[1]) throw new Error("archive checksum mismatch"); +} + +function verifyMembers(options: Options): void { + const root = `${options.target}/`; + const expected = new Map([ + [root, "d"], + [`${root}manifest.json`, "-"], + [`${root}mesh_llm_nodejs.node`, "-"], + ]); + const names = command([ + "--list", + "--gzip", + "--file", + options.archive, + ]).trimEnd().split("\n"); + if ( + names.length !== expected.size + || new Set(names).size !== expected.size + || names.some((name) => !expected.has(name)) + ) { + throw new Error("addon archive must contain exactly three expected entries"); + } + const listing = command([ + "--list", + "--verbose", + "--gzip", + "--file", + options.archive, + ]).trimEnd().split("\n"); + if (listing.length !== expected.size) throw new Error("addon archive must contain exactly three entries"); + const seen = new Set(); + for (const line of listing) { + const member = [...expected.keys()].find( + (name) => line.endsWith(` ${name}`) || line.includes(` ${name} -> `), + ); + if (!member || seen.has(member)) throw new Error("addon archive contains an unexpected or duplicate path"); + if (line[0] !== expected.get(member)) throw new Error(`unsafe archive member type: ${member}`); + seen.add(member); + } +} + +function verifyExtracted(options: Options): void { + const targetRoot = resolve(options.outputDir, options.target); + const outputRoot = `${realpathSync(options.outputDir)}/`; + const exactRoot = `${realpathSync(targetRoot)}/`; + if (!exactRoot.startsWith(outputRoot)) throw new Error("extracted target escaped output directory"); + const addon = resolve(targetRoot, "mesh_llm_nodejs.node"); + const manifestPath = resolve(targetRoot, "manifest.json"); + for (const path of [targetRoot, addon, manifestPath]) { + if (lstatSync(path).isSymbolicLink()) throw new Error("extracted addon contains a symbolic link"); + } + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record; + const keys = Object.keys(manifest).sort(); + const expectedKeys = ["file", "schema", "sha256", "target", "version"]; + if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) { + throw new Error("addon manifest has unexpected fields"); + } + if ( + manifest.schema !== "mesh-llm-node-sdk-addon-v1" + || manifest.version !== options.version + || manifest.target !== options.target + || manifest.file !== "mesh_llm_nodejs.node" + || typeof manifest.sha256 !== "string" + || !SHA256.test(manifest.sha256) + || digest(addon) !== manifest.sha256 + ) { + throw new Error("addon manifest does not bind the expected file, target, version, and digest"); + } +} + +export function verifyAndExtract(options: Options): void { + if (!TARGETS.has(options.target)) throw new Error(`unsupported Node addon target: ${options.target}`); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(options.version)) { + throw new Error(`invalid version: ${options.version}`); + } + verifyChecksum(options); + verifyMembers(options); + mkdirSync(options.outputDir, { recursive: true }); + command([ + "--extract", + "--gzip", + "--no-same-owner", + "--no-same-permissions", + "--directory", + options.outputDir, + "--file", + options.archive, + ]); + verifyExtracted(options); +} + +export function main(argv: string[]): number { + try { + verifyAndExtract(parseArgs(argv)); + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/tests/client-readiness-smoke.test.ts b/tests/client-readiness-smoke.test.ts index f8efd1c..b6b311c 100644 --- a/tests/client-readiness-smoke.test.ts +++ b/tests/client-readiness-smoke.test.ts @@ -135,9 +135,11 @@ test("client readiness smoke polls readiness without shell-signal wakeups", { co }); test("runtime image QA covers both direct binary and final entrypoint command paths", { concurrency: false }, () => { - const dockerfile = readFileSync(resolve("docker/Dockerfile.mesh-llm"), "utf8"); + const workflow = readFileSync(resolve(".github/workflows/package-image-row.yml"), "utf8"); const imageQa = readFileSync(resolve("docker/qa-runtime-image.sh"), "utf8"); - assert.match(dockerfile, /MESH_LLM_SMOKE_BIN=\/usr\/local\/bin\/mesh-llm-entrypoint sh \/usr\/local\/bin\/client-readiness-smoke/); + assert.match(workflow, /MESH_LLM_SMOKE_BIN=\/usr\/local\/bin\/mesh-llm-entrypoint sh \/tmp\/client-readiness-smoke\.sh/); + assert.equal((workflow.match(/target: runtime/g) ?? []).length, 2); + assert.doesNotMatch(workflow, /target: runtime-qa|outputs: type=cacheonly/); assert.match(imageQa, /\/usr\/local\/bin\/mesh-llm --version/); assert.match(imageQa, /\/usr\/local\/bin\/mesh-llm-entrypoint --version/); assert.ok(imageQa.includes('if ! ldd_output="$(ldd /usr/local/bin/mesh-llm 2>&1)"; then')); diff --git a/tests/node-sdk-runtime-smoke.test.ts b/tests/node-sdk-runtime-smoke.test.ts index ab09acd..22c916d 100644 --- a/tests/node-sdk-runtime-smoke.test.ts +++ b/tests/node-sdk-runtime-smoke.test.ts @@ -158,12 +158,11 @@ test("runtime smoke supervises graceful termination before forced termination", assert.ok(source.indexOf("child.kill('SIGTERM')") < source.indexOf("child.kill('SIGKILL')")); }); -test("release workflow fresh-installs and starts every addon lane and the assembled package", () => { +test("release workflow consumes verified upstream addons and starts the assembled package", () => { const workflow = readFileSync(resolve(".github/workflows/images-release.yml"), "utf8"); - assert.match(workflow, /name: Pack, fresh-install, and start Node SDK addon/); + assert.match(workflow, /name: Download, verify, and safely extract immutable addon/); assert.match(workflow, /NODE_SDK_TARGET: \$\{\{ matrix\.target \}\}/); - assert.match(workflow, /npm install "\$tarball"/); - assert.match(workflow, /node scripts\/node-sdk-runtime-smoke\.cjs/); - assert.match(workflow, /--target "\$NODE_SDK_TARGET"/); + assert.match(workflow, /scripts\/upstream-node-addon\.ts/); + assert.doesNotMatch(workflow, /npm run build:native|cargo build/); assert.match(workflow, /--target linux-x64-assembled-package/); }); diff --git a/tests/release-index.test.ts b/tests/release-index.test.ts new file mode 100644 index 0000000..d0c203f --- /dev/null +++ b/tests/release-index.test.ts @@ -0,0 +1,261 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import { + assembleReleaseIndex, + assembleReleaseIndexFiles, + main, + parseImageRowResult, + parseReleasePlan, + stableJson, + verifyReleaseIndexFiles, + type ExpectedImageRow, + type ImageRowResult, + type ReleaseIdentity, + type ReleasePlan, +} from "../scripts/release-index.ts"; + +const sha = (character: string): string => character.repeat(64); +const imageDigest = (character: string): string => `sha256:${sha(character)}`; +const identity: ReleaseIdentity = { + version: "0.75.0-rc.1", + mesh_ref: "v0.75.0-rc.1", + mesh_sha: "a".repeat(40), + packaging_sha: "b".repeat(40), +}; +const imageName = "ghcr.io/mesh-llm/mesh-llm"; + +function expectedRow( + artifactId: string, + backend: string, + arch = "amd64", +): ExpectedImageRow { + return { + artifact_id: artifactId, + platform: `linux/${arch}`, + arch, + backend, + backend_version: backend === "cpu" ? "" : "13.1.2", + package_file: `mesh-llm-0.75.0-${artifactId}.deb`, + package_base_image: `registry.example/package/base@${imageDigest("a")}`, + runtime_base_image: `registry.example/base/${backend}@${imageDigest("c")}`, + tags: [ + `${imageName}:0.75.0-${artifactId}`, + `${imageName}:${artifactId}`, + ], + }; +} + +function plan(rows = [ + expectedRow("ubuntu-cpu-amd64", "cpu"), + expectedRow("ubuntu-cuda-amd64", "cuda"), + expectedRow("ubuntu-cpu-arm64", "cpu", "arm64"), +]): ReleasePlan { + return { + schema_version: 1, + identity, + image_name: imageName, + expected_rows: rows, + }; +} + +function result( + row: ExpectedImageRow, + digestCharacter: string, + hostCharacter = row.arch === "arm64" ? "e" : "d", +): ImageRowResult { + const digest = imageDigest(digestCharacter); + return { + schema_version: 1, + plan_identity: identity, + ...row, + package: { name: row.package_file, sha256: sha("f") }, + upstream: { mesh_ref: identity.mesh_ref, mesh_sha: identity.mesh_sha }, + product: { + host_sha256: sha(hostCharacter), + runtime_id: `linux-${row.backend}-${row.arch}`, + runtime_sha256: sha("1"), + }, + base_image: { ref: row.runtime_base_image }, + image: { name: imageName, digest }, + qa: { passed: true, image_digest: digest }, + }; +} + +function validResults(value = plan()): ImageRowResult[] { + return value.expected_rows.map((row, index) => result(row, String(index + 2))); +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function fixture(t: { after(callback: () => void): void }) { + const root = mkdtempSync(resolve(tmpdir(), "release-index-test-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const planPath = resolve(root, "plan.json"); + const resultsPath = resolve(root, "results"); + const outputPath = resolve(root, "output", "release-index.json"); + mkdirSync(resultsPath); + const releasePlan = plan(); + const results = validResults(releasePlan).reverse(); + writeFileSync(planPath, JSON.stringify(releasePlan)); + results.forEach((row, index) => { + const directory = resolve(resultsPath, `artifact-${index}`); + mkdirSync(directory); + writeFileSync(resolve(directory, "result.json"), JSON.stringify(row)); + }); + return { root, planPath, resultsPath, outputPath, releasePlan, results }; +} + +test("assembles a canonical, deterministically ordered exact release index", () => { + const releasePlan = plan().expected_rows.slice().reverse(); + const unsortedPlan = plan(releasePlan); + const results = validResults(unsortedPlan).reverse(); + const index = assembleReleaseIndex(unsortedPlan, results); + assert.deepEqual(index.rows.map((row) => row.artifact_id), [ + "ubuntu-cpu-amd64", + "ubuntu-cpu-arm64", + "ubuntu-cuda-amd64", + ]); + assert.deepEqual(index.rows[0].tags, [...index.rows[0].tags].sort()); + assert.equal(index.identity.mesh_sha, identity.mesh_sha); + assert.equal(index.rows.every((row) => row.qa.image_digest === row.image.digest), true); + assert.equal(stableJson({ z: 1, a: { y: 2, x: 1 } }), '{\n "a": {\n "x": 1,\n "y": 2\n },\n "z": 1\n}\n'); + assert.equal(stableJson(index), stableJson(assembleReleaseIndex(clone(unsortedPlan), clone(results)))); +}); + +test("parses exact plans and rejects invalid identities, bases, rows, and tags", () => { + assert.deepEqual(parseReleasePlan(plan()), parseReleasePlan(clone(plan()))); + const invalid: [string, (value: Record) => void, RegExp][] = [ + ["schema", (value) => { value.schema_version = 2; }, /schema_version/], + ["extra", (value) => { value.extra = true; }, /contain exactly/], + ["image", (value) => { value.image_name = "GHCR.IO/Mesh/image"; }, /invalid/], + ["identity", (value) => { (value.identity as Record).mesh_ref = "v0.75.1"; }, /disagree/], + ["mesh sha", (value) => { (value.identity as Record).mesh_sha = "A".repeat(40); }, /invalid/], + ["empty image", (value) => { value.image_name = ""; }, /non-empty/], + ["base", (value) => { ((value.expected_rows as Record[])[0]).runtime_base_image = "ubuntu:24.04"; }, /digest-qualified/], + ["uppercase digest", (value) => { ((value.expected_rows as Record[])[0]).runtime_base_image = `ubuntu@sha256:${"A".repeat(64)}`; }, /lowercase/], + ["tag scalar", (value) => { (value.expected_rows as Record[])[0].tags = "tag"; }, /non-empty array/], + ["backend version type", (value) => { (value.expected_rows as Record[])[0].backend_version = 1; }, /must be a string/], + ["backend version content", (value) => { (value.expected_rows as Record[])[0].backend_version = "bad/value"; }, /invalid/], + ["empty", (value) => { value.expected_rows = []; }, /must not be empty/], + ["duplicate row", (value) => { (value.expected_rows as unknown[]).push(clone((value.expected_rows as unknown[])[0])); }, /duplicate artifact_id/], + ["duplicate package", (value) => { (value.expected_rows as Record[])[1].package_file = (value.expected_rows as Record[])[0].package_file; }, /duplicate package_file/], + ["foreign tag", (value) => { (value.expected_rows as Record[])[0].tags = ["ghcr.io/other/image:tag"]; }, /must be a tag/], + ["duplicate tag in row", (value) => { const row = (value.expected_rows as Record[])[0]; row.tags = [((row.tags as string[])[0]), ((row.tags as string[])[0])]; }, /duplicate tags/], + ["duplicate global tag", (value) => { (value.expected_rows as Record[])[1].tags = clone((value.expected_rows as Record[])[0].tags); }, /duplicate tags/], + ]; + for (const [, mutate, message] of invalid) { + const value = clone(plan()) as unknown as Record; + mutate(value); + assert.throws(() => parseReleasePlan(value), message); + } + assert.throws(() => parseReleasePlan(null), /must be an object/); + assert.throws(() => parseReleasePlan({ ...plan(), expected_rows: "bad" }), /must be an array/); +}); + +test("rejects missing, extra, duplicate, non-exact, or unbound row results", () => { + const releasePlan = plan(); + const results = validResults(releasePlan); + assert.throws(() => assembleReleaseIndex(releasePlan, results.slice(1)), /missing result rows/); + assert.throws(() => assembleReleaseIndex(releasePlan, [...results, results[0]]), /duplicate result artifact_id/); + assert.throws( + () => assembleReleaseIndex(releasePlan, [...results, { ...results[0], artifact_id: "unexpected" }]), + /unexpected result artifact_id/, + ); + const cases: [(value: ImageRowResult) => void, RegExp][] = [ + [(value) => { (value as unknown as Record).extra = true; }, /contain exactly/], + [(value) => { (value as unknown as { schema_version: number }).schema_version = 2; }, /schema_version/], + [(value) => { (value.plan_identity as unknown as { packaging_sha: string }).packaging_sha = "c".repeat(40); }, /plan_identity/], + [(value) => { (value as unknown as { backend: string }).backend = "rocm"; }, /exactly match/], + [(value) => { (value.package as unknown as { name: string }).name = "other.deb"; }, /package.name/], + [(value) => { (value.package as unknown as { sha256: string }).sha256 = "F".repeat(64); }, /lowercase/], + [(value) => { (value.upstream as unknown as { mesh_sha: string }).mesh_sha = "c".repeat(40); }, /upstream/], + [(value) => { (value.product as unknown as { runtime_id: string }).runtime_id = "bad/runtime"; }, /invalid/], + [(value) => { (value.base_image as unknown as { ref: string }).ref = `other/base@${imageDigest("c")}`; }, /base_image.ref/], + [(value) => { (value.image as unknown as { name: string }).name = "ghcr.io/other/image"; }, /image.name/], + [(value) => { (value.image as unknown as { digest: string }).digest = `sha256:${"A".repeat(64)}`; }, /lowercase/], + [(value) => { (value.qa as unknown as { passed: boolean }).passed = false; }, /passed/], + [(value) => { (value.qa as unknown as { image_digest: string }).image_digest = imageDigest("9"); }, /does not match/], + ]; + for (const [mutate, message] of cases) { + const changed = clone(results[0]); + mutate(changed); + assert.throws( + () => parseImageRowResult(changed, parseReleasePlan(releasePlan), releasePlan.expected_rows[0]), + message, + ); + } +}); + +test("enforces host identity per platform while allowing content-identical rows", () => { + const releasePlan = plan(); + const results = validResults(releasePlan); + results[1] = { + ...results[1], + image: { ...results[1].image, digest: results[0].image.digest }, + qa: { passed: true, image_digest: results[0].image.digest }, + }; + assert.equal(assembleReleaseIndex(releasePlan, results).rows.length, 3); + const hostMismatch = validResults(releasePlan); + hostMismatch[1] = { + ...hostMismatch[1], + product: { ...hostMismatch[1].product, host_sha256: sha("9") }, + }; + assert.throws(() => assembleReleaseIndex(releasePlan, hostMismatch), /different host SHA-256/); +}); + +test("file API and CLI assemble and verify only canonical complete indexes", (t) => { + const value = fixture(t); + const index = assembleReleaseIndexFiles(value.planPath, value.resultsPath, value.outputPath); + assert.equal(index.rows.length, 3); + assert.equal(readFileSync(value.outputPath, "utf8"), stableJson(index)); + assert.equal(verifyReleaseIndexFiles(value.planPath, value.resultsPath, value.outputPath).rows.length, 3); + const cliOutput = resolve(value.root, "cli", "release-index.json"); + const script = resolve("scripts/release-index.ts"); + const assembled = spawnSync(process.execPath, [ + "--experimental-strip-types", + script, + "assemble", + "--plan", value.planPath, + "--results", value.resultsPath, + "--output", cliOutput, + ], { encoding: "utf8" }); + assert.equal(assembled.status, 0, assembled.stderr); + assert.equal(readFileSync(cliOutput, "utf8"), stableJson(index)); + const verified = spawnSync(process.execPath, [ + "--experimental-strip-types", + script, + "verify", + "--plan", value.planPath, + "--results", value.resultsPath, + "--index", cliOutput, + ], { encoding: "utf8" }); + assert.equal(verified.status, 0, verified.stderr); + assert.equal(main([ + "verify", + "--plan", value.planPath, + "--results", value.resultsPath, + "--index", value.outputPath, + ]), 0); + writeFileSync(value.outputPath, JSON.stringify(index)); + assert.throws( + () => verifyReleaseIndexFiles(value.planPath, value.resultsPath, value.outputPath), + /not the canonical index/, + ); + assert.equal(main(["unknown"]), 1); + assert.equal(main(["assemble", "--plan", value.planPath]), 1); + assert.equal(main(["verify", "--plan", value.planPath, "--plan", value.planPath]), 1); + assert.equal(main(["assemble", "not-an-option"]), 1); + + const empty = resolve(value.root, "empty"); + mkdirSync(empty); + assert.throws( + () => assembleReleaseIndexFiles(value.planPath, empty, value.outputPath), + /no JSON records/, + ); +}); diff --git a/tests/release-plan.test.ts b/tests/release-plan.test.ts new file mode 100644 index 0000000..8c1659f --- /dev/null +++ b/tests/release-plan.test.ts @@ -0,0 +1,319 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { loadConfig } from "../scripts/image-matrix.ts"; +import { + type ReleasePlanInput, + buildReleasePlan, + exactSelection, + main, +} from "../scripts/release-plan.ts"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const CONFIG = resolve(ROOT, "packaging/images.json"); +const SCRIPT = resolve(ROOT, "scripts/release-plan.ts"); + +function config() { + return JSON.parse(JSON.stringify(loadConfig(CONFIG))); +} + +function input(overrides: Partial = {}): ReleasePlanInput { + return { + dry_run: true, + native_selector: "all", + npm_selector: "all", + publish_images: false, + publish_npm: false, + publish_release_assets: false, + validate_homebrew: false, + validate_native: true, + validate_npm: false, + version: "v0.73.1", + ...overrides, + }; +} + +function cliArgs(overrides: Record = {}): string[] { + const values: Record = { + "dry-run": "true", + "native-selector": "ubuntu-cpu-amd64", + "npm-selector": "all", + "publish-images": "false", + "publish-npm": "false", + "publish-release-assets": "false", + "validate-homebrew": "false", + "validate-native": "true", + "validate-npm": "false", + version: "0.73.1", + ...overrides, + }; + return Object.entries(values).flatMap(([name, value]) => [`--${name}`, value]); +} + +function cli(args: string[]) { + return spawnSync( + process.execPath, + ["--experimental-strip-types", SCRIPT, ...args], + { cwd: ROOT, encoding: "utf8" }, + ); +} + +test("a selected native canary enables only its exact producer graph", () => { + const plan = buildReleasePlan(config(), input({ native_selector: "ubuntu-cpu-amd64" })); + assert.equal(plan.schema_version, 1); + assert.equal(plan.native_enabled, true); + assert.equal(plan.homebrew_enabled, false); + assert.equal(plan.npm_enabled, false); + assert.deepEqual(plan.package_matrix.include.map((row) => row.artifact_id), ["ubuntu-cpu-amd64"]); + assert.equal(plan.upstream_matrix.include.length, 1); + assert.deepEqual(plan.npm_matrix.include, []); + assert.equal(plan.release_assembly_enabled, false); +}); + +test("Homebrew-only validation leaves native and npm matrices empty", () => { + const plan = buildReleasePlan(config(), input({ + validate_homebrew: true, + validate_native: false, + })); + assert.equal(plan.homebrew_enabled, true); + assert.deepEqual(plan.package_matrix.include, []); + assert.deepEqual(plan.upstream_matrix.include, []); + assert.deepEqual(plan.npm_matrix.include, []); + assert.equal(plan.homebrew_plan.target, "aarch64-apple-darwin"); +}); + +test("an exact npm canary enables one lane without native or Homebrew", () => { + const plan = buildReleasePlan(config(), input({ + npm_selector: "node-sdk-linux-x64", + validate_native: false, + validate_npm: true, + })); + assert.equal(plan.native_enabled, false); + assert.equal(plan.homebrew_enabled, false); + assert.equal(plan.npm_enabled, true); + assert.deepEqual(plan.npm_matrix.include.map((row) => row.id), ["node-sdk-linux-x64"]); + assert.deepEqual(plan.npm_plan.targets, ["linux-x64"]); +}); + +test("a full rehearsal deterministically includes every release row", () => { + const value = input({ + image: "ghcr.io/mesh-llm/custom", + mesh_ref: "refs/tags/v0.73.1", + mesh_repository: "Mesh-LLM/mesh-llm", + validate_homebrew: true, + validate_npm: true, + }); + const first = buildReleasePlan(config(), value); + const second = buildReleasePlan(config(), value); + assert.deepEqual(first, second); + assert.equal(first.package_matrix.include.length, 11); + assert.equal(first.upstream_matrix.include.length, 8); + assert.equal(first.npm_matrix.include.length, 5); + assert.equal(first.release_assembly_enabled, true); + assert.deepEqual(first.native_selection.mode, "all"); + assert.deepEqual(first.npm_selection.mode, "all"); + const partial = buildReleasePlan(config(), input({ + native_selector: "ubuntu-cpu-amd64", + validate_homebrew: true, + })); + assert.equal(partial.release_assembly_enabled, false); +}); + +test("selectors reject unknown IDs, aliases, empty tokens, duplicates, and mixed all", () => { + const available = ["ubuntu-cpu-amd64", "ubuntu-cpu-arm64"]; + for (const value of [ + "missing", + "ubuntu-cpu", + "amd64", + "linux/amd64", + "", + "ubuntu-cpu-amd64,", + "ubuntu-cpu-amd64,,ubuntu-cpu-arm64", + "ubuntu-cpu-amd64,ubuntu-cpu-amd64", + "all,ubuntu-cpu-amd64", + ]) { + assert.throws(() => exactSelection(value, available, "native")); + } + assert.throws(() => buildReleasePlan(config(), input({ + npm_selector: "linux-x64", + validate_native: false, + validate_npm: true, + })), /unknown exact id/); + assert.deepEqual( + exactSelection(" ubuntu-cpu-arm64 , ubuntu-cpu-amd64 ", available, "native"), + { ids: ["ubuntu-cpu-arm64", "ubuntu-cpu-amd64"], mode: "selected" }, + ); +}); + +test("disabled components require all selectors and at least one component", () => { + assert.throws(() => buildReleasePlan(config(), input({ + native_selector: "ubuntu-cpu-amd64", + validate_native: false, + validate_homebrew: true, + })), /native selector must be all/); + assert.throws(() => buildReleasePlan(config(), input({ + npm_selector: "node-sdk-linux-x64", + })), /npm selector must be all/); + assert.throws(() => buildReleasePlan(config(), input({ + validate_native: false, + })), /at least one validation component/); +}); + +test("image publication requires the complete native producer set", () => { + assert.throws(() => buildReleasePlan(config(), input({ + dry_run: false, + native_selector: "ubuntu-cpu-amd64", + publish_images: true, + })), /complete native validation/); + assert.throws(() => buildReleasePlan(config(), input({ + dry_run: false, + publish_images: true, + validate_homebrew: true, + validate_native: false, + })), /complete native validation/); +}); + +test("release asset publication requires complete native and Homebrew validation", () => { + assert.throws(() => buildReleasePlan(config(), input({ + dry_run: false, + publish_release_assets: true, + validate_homebrew: true, + validate_native: false, + })), /complete native and Homebrew validation/); + assert.throws(() => buildReleasePlan(config(), input({ + dry_run: false, + publish_release_assets: true, + })), /complete native and Homebrew validation/); + assert.throws(() => buildReleasePlan(config(), input({ + dry_run: false, + native_selector: "ubuntu-cpu-amd64", + publish_release_assets: true, + validate_homebrew: true, + })), /complete native and Homebrew validation/); +}); + +test("npm publication requires every upstream addon lane", () => { + assert.throws(() => buildReleasePlan(config(), input({ + dry_run: false, + publish_npm: true, + })), /complete npm validation/); + assert.throws(() => buildReleasePlan(config(), input({ + dry_run: false, + npm_selector: "node-sdk-linux-x64", + publish_npm: true, + validate_native: false, + validate_npm: true, + })), /complete npm validation/); +}); + +test("dry run forces publication off without changing selected validation", () => { + const plan = buildReleasePlan(config(), input({ + native_selector: "ubuntu-cpu-amd64", + publish_images: true, + publish_npm: true, + publish_release_assets: true, + })); + assert.equal(plan.publish_images, false); + assert.equal(plan.publish_npm, false); + assert.equal(plan.publish_release_assets, false); + assert.equal(plan.package_matrix.include.length, 1); +}); + +test("a production dispatch plan enables all required publication inputs", () => { + const plan = buildReleasePlan(config(), input({ + dry_run: false, + publish_images: true, + publish_npm: true, + publish_release_assets: true, + validate_homebrew: true, + validate_npm: true, + })); + assert.equal(plan.publish_images, true); + assert.equal(plan.publish_npm, true); + assert.equal(plan.publish_release_assets, true); + assert.equal(plan.release_assembly_enabled, true); + const withoutNpm = buildReleasePlan(config(), input({ + dry_run: false, + publish_images: true, + publish_npm: false, + publish_release_assets: true, + validate_homebrew: true, + })); + assert.equal(withoutNpm.npm_enabled, false); + assert.equal(withoutNpm.publish_npm, false); +}); + +test("invalid configuration and runtime boolean types fail closed", () => { + const invalid = config(); + invalid.schema_version = 0; + assert.throws(() => buildReleasePlan(invalid, input()), /schema_version/); + for (const name of [ + "dry_run", + "publish_images", + "publish_npm", + "publish_release_assets", + "validate_homebrew", + "validate_native", + "validate_npm", + ] as const) { + assert.throws(() => buildReleasePlan(config(), { + ...input(), + [name]: "true" as unknown as boolean, + }), new RegExp(`${name} must be boolean`)); + } +}); + +test("CLI emits deterministic schema JSON and rejects malformed options", () => { + const result = cli([ + "--config", + CONFIG, + ...cliArgs({ + image: "ghcr.io/mesh-llm/custom", + "mesh-ref": "v0.73.1", + "mesh-repository": "Mesh-LLM/mesh-llm", + }), + ]); + assert.equal(result.status, 0, result.stderr); + const plan = JSON.parse(result.stdout); + assert.equal(plan.schema_version, 1); + assert.deepEqual(Object.keys(plan), [...Object.keys(plan)].sort()); + assert.equal(main(cliArgs()), 0); + + const invalidArguments = [ + cliArgs({ "dry-run": "yes" }), + cliArgs({ unknown: "value" }), + ["value"], + ["--version"], + ["--version", "--dry-run"], + [...cliArgs(), "--version", "0.73.1"], + cliArgs({ "native-selector": "" }), + ]; + for (const args of invalidArguments) { + assert.notEqual(cli(args).status, 0); + } + const originalError = console.error; + console.error = () => {}; + try { + for (const args of invalidArguments) assert.equal(main(args), 1); + assert.equal(main([ + "--config", + CONFIG, + ...cliArgs({ + image: "ghcr.io/mesh-llm/custom", + "mesh-ref": "v0.73.1", + "mesh-repository": "Mesh-LLM/mesh-llm", + }), + ]), 0); + } finally { + console.error = originalError; + } + const originalLog = console.log; + console.log = () => { throw "non-error"; }; + try { + assert.equal(main(cliArgs()), 1); + } finally { + console.log = originalLog; + } +}); diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts new file mode 100644 index 0000000..4248bf2 --- /dev/null +++ b/tests/release-workflow.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { test } from "node:test"; + +const release = readFileSync(resolve(".github/workflows/images-release.yml"), "utf8"); +const row = readFileSync(resolve(".github/workflows/package-image-row.yml"), "utf8"); + +function section(source: string, start: string, end?: string): string { + const from = source.indexOf(start); + assert.notEqual(from, -1, `missing section ${start}`); + const to = end ? source.indexOf(end, from + start.length) : source.length; + assert.notEqual(to, -1, `missing section boundary ${end}`); + return source.slice(from, to); +} + +test("manual dispatch uses typed components and exact selectors", () => { + for (const input of [ + "validate_native:", + "validate_homebrew:", + "validate_npm:", + "native_selector:", + "npm_selector:", + ]) { + assert.ok(release.includes(input), `missing ${input}`); + } + assert.doesNotMatch(release, /variant_filter|platform_filter|npm_lane_filter/); + assert.match(release, /scripts\/release-plan\.ts/); +}); + +test("each path builds one final image and QA binds the same identity", () => { + const dry = section(row, " dry-image:", " stage-image:"); + const stage = section(row, " stage-image:"); + assert.equal((dry.match(/uses: docker\/build-push-action@v7/g) ?? []).length, 1); + assert.equal((stage.match(/uses: docker\/build-push-action@v7/g) ?? []).length, 1); + assert.match(dry, /target: runtime[\s\S]*load: true/); + assert.match(dry, /docker image inspect --format '\{\{\.Id\}\}' "\$IMAGE_REF"/); + assert.match(stage, /push: true[\s\S]*staging-\$\{\{ github\.run_id \}\}/); + assert.match(stage, /IMAGE_REF: \$\{\{ inputs\.image_name \}\}@\$\{\{ steps\.stage\.outputs\.digest \}\}/); + assert.equal((stage.match(/"\$IMAGE_REF" \/tmp\/qa-runtime-image\.sh/g) ?? []).length, 1); + assert.match(stage, /qa:\{passed:true,image_digest:\$image_digest\}/); + assert.doesNotMatch(row, /runtime-qa|type=cacheonly/); +}); + +test("dry validation cannot write to a registry", () => { + const dry = section(row, " dry-image:", " stage-image:"); + assert.doesNotMatch(dry, /packages: write|docker\/login-action|push: true/); + const stage = section(row, " stage-image:"); + assert.match(stage, /environment: release/); + assert.match(stage, /packages: write/); +}); + +test("promotion consumes the canonical tested index without rebuilding", () => { + const index = section(release, " image-index:", " promote-images:"); + const promotion = section(release, " promote-images:", " release-assembly:"); + assert.match(index, /scripts\/release-index\.ts assemble/); + assert.match(index, /scripts\/release-index\.ts verify/); + assert.match(promotion, /image-index\/release-index\.json/); + assert.match(promotion, /imagetools create --prefer-index=false/); + assert.match(promotion, /immutable version tag/); + assert.match(promotion, /rollback-ledger\.json/); + assert.doesNotMatch(promotion, /build-push-action|Dockerfile|docker buildx build/); +}); + +test("Node packaging consumes safe upstream addon artifacts without compiling", () => { + const addons = section(release, " node-sdk-addon:", " node-sdk-preflight:"); + assert.match(addons, /scripts\/upstream-node-addon\.ts/); + assert.match(addons, /releases\/download\/\$MESH_REF/); + assert.doesNotMatch(addons, /rust-toolchain|sccache|build:native|cargo/); +}); + +test("readiness requires only explicitly enabled or requested components", () => { + const readiness = section(release, " readiness:"); + assert.match(readiness, /if \[\[ "\$NATIVE_ENABLED" == true \]\]/); + assert.match(readiness, /if \[\[ "\$HOMEBREW_ENABLED" == true \]\]/); + assert.match(readiness, /if \[\[ "\$NPM_ENABLED" == true \]\]/); + assert.match(readiness, /"\$IMAGE_INDEX" == success && "\$PROMOTE_IMAGES" == success/); +}); diff --git a/tests/upstream-node-addon.test.ts b/tests/upstream-node-addon.test.ts new file mode 100644 index 0000000..cc7d2e7 --- /dev/null +++ b/tests/upstream-node-addon.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import { main, verifyAndExtract } from "../scripts/upstream-node-addon.ts"; + +function fixture(t: { after(callback: () => void): void }, mutate?: (root: string) => void) { + const root = mkdtempSync(resolve(tmpdir(), "upstream-addon-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const target = "linux-x64"; + const version = "0.75.0"; + const source = resolve(root, "source", target); + mkdirSync(source, { recursive: true }); + const addon = resolve(source, "mesh_llm_nodejs.node"); + writeFileSync(addon, "native-addon"); + const sha256 = createHash("sha256").update(readFileSync(addon)).digest("hex"); + writeFileSync(resolve(source, "manifest.json"), `${JSON.stringify({ + schema: "mesh-llm-node-sdk-addon-v1", + version, + target, + file: "mesh_llm_nodejs.node", + sha256, + })}\n`); + mutate?.(resolve(root, "source")); + const archive = resolve(root, `mesh-llm-node-sdk-addon-${version}-${target}.tar.gz`); + const packed = spawnSync("tar", ["-C", resolve(root, "source"), "-czf", archive, target]); + assert.equal(packed.status, 0); + const archiveSha = createHash("sha256").update(readFileSync(archive)).digest("hex"); + const checksum = `${archive}.sha256`; + writeFileSync(checksum, `${archiveSha} ${archive.split("/").at(-1)}\n`); + return { archive, checksum, outputDir: resolve(root, "output"), target, version }; +} + +test("verifies and safely extracts an exact upstream addon", (t) => { + const value = fixture(t); + verifyAndExtract(value); + assert.equal( + readFileSync(resolve(value.outputDir, value.target, "mesh_llm_nodejs.node"), "utf8"), + "native-addon", + ); +}); + +test("rejects checksum, manifest, and target drift", (t) => { + const checksum = fixture(t); + writeFileSync(checksum.checksum, `${"0".repeat(64)} ${checksum.archive.split("/").at(-1)}\n`); + assert.throws(() => verifyAndExtract(checksum), /checksum mismatch/); + + const manifest = fixture(t, (root) => { + const path = resolve(root, "linux-x64", "manifest.json"); + const value = JSON.parse(readFileSync(path, "utf8")); + value.version = "0.75.1"; + writeFileSync(path, JSON.stringify(value)); + }); + assert.throws(() => verifyAndExtract(manifest), /manifest does not bind/); + assert.throws(() => verifyAndExtract({ ...fixture(t), target: "linux-x86" }), /unsupported/); +}); + +test("rejects extra paths and symbolic-link members before extraction", (t) => { + const extra = fixture(t, (root) => { + writeFileSync(resolve(root, "linux-x64", "extra"), "unexpected"); + }); + assert.throws(() => verifyAndExtract(extra), /exactly three expected entries/); + + const link = fixture(t, (root) => { + rmSync(resolve(root, "linux-x64", "mesh_llm_nodejs.node")); + symlinkSync("/tmp/outside", resolve(root, "linux-x64", "mesh_llm_nodejs.node")); + }); + assert.throws(() => verifyAndExtract(link), /unsafe archive member type/); +}); + +test("CLI fails closed on malformed options", () => { + assert.equal(main([]), 1); + assert.equal(main(["--target", "linux-x64", "--target", "linux-x64"]), 1); + assert.equal(main(["unexpected"]), 1); +}); diff --git a/tests/workflow-provenance.test.ts b/tests/workflow-provenance.test.ts index 0debd67..f87fb7d 100644 --- a/tests/workflow-provenance.test.ts +++ b/tests/workflow-provenance.test.ts @@ -6,7 +6,8 @@ import { resolve } from "node:path"; import { test } from "node:test"; const workflow = readFileSync(resolve(".github/workflows/images-release.yml"), "utf8"); -const productInputSteps = [...workflow.matchAll( +const rowWorkflow = readFileSync(resolve(".github/workflows/package-image-row.yml"), "utf8"); +const productInputSteps = [...rowWorkflow.matchAll( / - name: Read immutable product inputs\n id: product\n shell: bash\n run: \|\n((?: .*\n)+?)(?= - )/g, )].map((match) => match[1].replace(/^ /gm, "")); @@ -32,7 +33,7 @@ function runStep(script: string, provenance: readonly object[]): { return { outputContents: contents, status: result.status, stderr: result.stderr }; } -test("both product input steps emit validated provenance", () => { +test("both final-image paths emit validated provenance", () => { assert.equal(productInputSteps.length, 2); const provenance = { host_sha256: "a".repeat(64), @@ -46,7 +47,7 @@ test("both product input steps emit validated provenance", () => { } }); -test("both product input steps reject ambiguous or invalid provenance", () => { +test("both final-image paths reject ambiguous or invalid provenance", () => { assert.equal(productInputSteps.length, 2); const valid = { host_sha256: "a".repeat(64), @@ -71,20 +72,23 @@ test("both product input steps reject ambiguous or invalid provenance", () => { test("native package evidence is exact, namespaced, and assembled before publication", () => { for (const snippet of [ - "matrix.package_file", - "$ARTIFACT_ID.buildkit-provenance.json", - "file: artifacts/native-package/${{ matrix.package_file }}", - "scripts/verify-sbom-subject.ts", "release-assembly:", "scripts/release-evidence.ts assemble", "subject-checksums: assembled/release-metadata/package-subjects.sha256", "steps.preflight.outputs.mode == 'create'", - "filtered workflow runs may validate but must never publish", - ".subject[0].name == $name", "(.include | length * 4) + 3", ]) { assert.ok(workflow.includes(snippet), `workflow is missing ${snippet}`); } + for (const snippet of [ + "fromJSON(inputs.row_json).package_file", + "$ARTIFACT_ID.buildkit-provenance.json", + "file: artifacts/native-package/${{ fromJSON(inputs.row_json).package_file }}", + "scripts/verify-sbom-subject.ts", + ".subject[0].name == $name", + ]) { + assert.ok(rowWorkflow.includes(snippet), `row workflow is missing ${snippet}`); + } assert.doesNotMatch(workflow, /gh release upload[\s\S]*--clobber/); const releaseAssembly = workflow.slice( workflow.indexOf(" release-assembly:"), From 3eea8e859754880540bb13c4db14c209002e083f Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Thu, 30 Jul 2026 03:09:13 -0400 Subject: [PATCH 3/9] fix(ci): authorize reusable image staging --- .github/workflows/images-release.yml | 5 +++++ .github/workflows/package-image-row.yml | 4 ++++ tests/release-workflow.test.ts | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/.github/workflows/images-release.yml b/.github/workflows/images-release.yml index 52f2ef5..c050ff7 100644 --- a/.github/workflows/images-release.yml +++ b/.github/workflows/images-release.yml @@ -315,6 +315,11 @@ jobs: name: Package and image ${{ matrix.artifact_id }} if: needs.plan.outputs.native_enabled == 'true' needs: [plan, upstream, upstream-host-invariant] + permissions: + contents: read + packages: write + id-token: write + attestations: write strategy: fail-fast: false matrix: ${{ fromJSON(needs.plan.outputs.package_matrix) }} diff --git a/.github/workflows/package-image-row.yml b/.github/workflows/package-image-row.yml index 2cade97..35450f0 100644 --- a/.github/workflows/package-image-row.yml +++ b/.github/workflows/package-image-row.yml @@ -30,6 +30,8 @@ env: jobs: package: name: Produce native package + permissions: + contents: read runs-on: ${{ fromJSON(fromJSON(inputs.row_json).runner_labels) }} outputs: package_base_image: ${{ steps.bases.outputs.package_base_image }} @@ -145,6 +147,8 @@ jobs: name: Build and test local final image if: ${{ !inputs.publish_images }} needs: package + permissions: + contents: read runs-on: ${{ fromJSON(fromJSON(inputs.row_json).runner_labels) }} steps: - uses: actions/checkout@v7 diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 4248bf2..4cdaaa5 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -43,7 +43,12 @@ test("each path builds one final image and QA binds the same identity", () => { }); test("dry validation cannot write to a registry", () => { + const caller = section(release, " package-image:", " homebrew:"); + assert.match(caller, /permissions:[\s\S]*packages: write[\s\S]*id-token: write[\s\S]*attestations: write/); + const packageJob = section(row, " package:", " dry-image:"); const dry = section(row, " dry-image:", " stage-image:"); + assert.match(packageJob, /permissions:\s+contents: read/); + assert.match(dry, /permissions:\s+contents: read/); assert.doesNotMatch(dry, /packages: write|docker\/login-action|push: true/); const stage = section(row, " stage-image:"); assert.match(stage, /environment: release/); From 6cc59c4510b388e4e38f85717793a24db945ca26 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Thu, 30 Jul 2026 03:20:33 -0400 Subject: [PATCH 4/9] fix(ci): avoid inheriting reusable workflow secrets --- .github/workflows/images-release.yml | 1 - tests/release-workflow.test.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/images-release.yml b/.github/workflows/images-release.yml index c050ff7..f314b1a 100644 --- a/.github/workflows/images-release.yml +++ b/.github/workflows/images-release.yml @@ -330,7 +330,6 @@ jobs: mesh_ref: ${{ needs.plan.outputs.mesh_ref }} mesh_sha: ${{ needs.plan.outputs.mesh_sha }} publish_images: ${{ needs.plan.outputs.publish_images == 'true' }} - secrets: inherit homebrew: name: Verify Homebrew formula from upstream macOS archive diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 4cdaaa5..952553c 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -45,6 +45,7 @@ test("each path builds one final image and QA binds the same identity", () => { test("dry validation cannot write to a registry", () => { const caller = section(release, " package-image:", " homebrew:"); assert.match(caller, /permissions:[\s\S]*packages: write[\s\S]*id-token: write[\s\S]*attestations: write/); + assert.doesNotMatch(caller, /secrets:\s*inherit/); const packageJob = section(row, " package:", " dry-image:"); const dry = section(row, " dry-image:", " stage-image:"); assert.match(packageJob, /permissions:\s+contents: read/); From 4e5d6d2af69b912fac372b3281bb5a4145c547e3 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Thu, 30 Jul 2026 18:05:42 -0400 Subject: [PATCH 5/9] fix(ci): pin packaging workflow actions --- .github/workflows/images-release.yml | 10 ++++---- .github/workflows/package-image-row.yml | 32 ++++++++++++------------- tests/release-workflow.test.ts | 29 ++++++++++++++++++++-- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/.github/workflows/images-release.yml b/.github/workflows/images-release.yml index f314b1a..80b7ef5 100644 --- a/.github/workflows/images-release.yml +++ b/.github/workflows/images-release.yml @@ -541,13 +541,13 @@ jobs: needs: [plan, package-image] runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '24' - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: mesh-llm-image-result-* path: image-results @@ -602,7 +602,7 @@ jobs: --plan image-release-plan.json \ --results image-results \ --index release-index.json - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mesh-llm-image-index-${{ needs.plan.outputs.mesh_version }} path: | @@ -678,7 +678,7 @@ jobs: mv rollback-ledger.next.json rollback-ledger.json done < <(jq -r '.tags[]' <<<"$row") done < <(jq -r '.rows[] | @base64' image-index/release-index.json) - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: mesh-llm-image-promotion-${{ needs.plan.outputs.mesh_version }} diff --git a/.github/workflows/package-image-row.yml b/.github/workflows/package-image-row.yml index 35450f0..a9d7bd6 100644 --- a/.github/workflows/package-image-row.yml +++ b/.github/workflows/package-image-row.yml @@ -37,14 +37,14 @@ jobs: package_base_image: ${{ steps.bases.outputs.package_base_image }} runtime_base_image: ${{ steps.bases.outputs.runtime_base_image }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ fromJSON(inputs.row_json).upstream_artifact_id }} path: artifacts/upstream - - uses: docker/setup-buildx-action@v4 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Resolve exact package and runtime base images id: bases env: @@ -74,7 +74,7 @@ jobs: echo "runtime_base_image=$runtime_base_image" } >> "$GITHUB_OUTPUT" - name: Build native package from verified upstream product bundle - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . file: docker/Dockerfile.mesh-llm @@ -136,7 +136,7 @@ jobs: --package "artifacts/native-package/${{ fromJSON(inputs.row_json).package_file }}" \ --sidecar "artifacts/native-package/${{ fromJSON(inputs.row_json).package_file }}.sha256" \ --sbom "artifacts/native-package/${{ fromJSON(inputs.row_json).artifact_id }}.spdx.json" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ fromJSON(inputs.row_json).native_package_artifact_name }} path: artifacts/native-package @@ -151,14 +151,14 @@ jobs: contents: read runs-on: ${{ fromJSON(fromJSON(inputs.row_json).runner_labels) }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ fromJSON(inputs.row_json).native_package_artifact_name }} path: artifacts/native-package - - uses: docker/setup-buildx-action@v4 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Read immutable product inputs id: product shell: bash @@ -181,7 +181,7 @@ jobs: echo "runtime_sha=$(jq -r .runtime_sha256 "$provenance")" } >> "$GITHUB_OUTPUT" - name: Build the final runtime image once - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . file: docker/Dockerfile.mesh-llm @@ -226,7 +226,7 @@ jobs: --arg runtime_base_image "${{ needs.package.outputs.runtime_base_image }}" \ '{schema_version:1, artifact_id:$artifact_id, mode:"local", image_id:$image_id, runtime_base_image:$runtime_base_image, qa:{passed:true,image_id:$image_id}}' \ > image-row-result.json - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mesh-llm-image-result-${{ fromJSON(inputs.row_json).artifact_id }} path: image-row-result.json @@ -245,15 +245,15 @@ jobs: id-token: write attestations: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ fromJSON(inputs.row_json).native_package_artifact_name }} path: artifacts/native-package - - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v4 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -281,7 +281,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Build and push one run-scoped staging image id: stage - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . file: docker/Dockerfile.mesh-llm @@ -373,7 +373,7 @@ jobs: subject-name: ${{ inputs.image_name }} subject-digest: ${{ steps.stage.outputs.digest }} push-to-registry: true - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mesh-llm-image-result-${{ fromJSON(inputs.row_json).artifact_id }} path: image-row-result.json diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 952553c..b34c5f1 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -5,6 +5,8 @@ import { test } from "node:test"; const release = readFileSync(resolve(".github/workflows/images-release.yml"), "utf8"); const row = readFileSync(resolve(".github/workflows/package-image-row.yml"), "utf8"); +const pinnedBuildPushAction = + "uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7"; function section(source: string, start: string, end?: string): string { const from = source.indexOf(start); @@ -31,8 +33,8 @@ test("manual dispatch uses typed components and exact selectors", () => { test("each path builds one final image and QA binds the same identity", () => { const dry = section(row, " dry-image:", " stage-image:"); const stage = section(row, " stage-image:"); - assert.equal((dry.match(/uses: docker\/build-push-action@v7/g) ?? []).length, 1); - assert.equal((stage.match(/uses: docker\/build-push-action@v7/g) ?? []).length, 1); + assert.equal(dry.split(pinnedBuildPushAction).length - 1, 1); + assert.equal(stage.split(pinnedBuildPushAction).length - 1, 1); assert.match(dry, /target: runtime[\s\S]*load: true/); assert.match(dry, /docker image inspect --format '\{\{\.Id\}\}' "\$IMAGE_REF"/); assert.match(stage, /push: true[\s\S]*staging-\$\{\{ github\.run_id \}\}/); @@ -42,6 +44,29 @@ test("each path builds one final image and QA binds the same identity", () => { assert.doesNotMatch(row, /runtime-qa|type=cacheonly/); }); +test("new reusable and image-index actions use immutable commits", () => { + assert.doesNotMatch(row, /uses: [^\s]+@v\d+/); + for (const action of [ + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7", + "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8", + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7", + "docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4", + "docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4", + pinnedBuildPushAction.slice("uses: ".length), + ]) { + assert.ok(row.includes(action), `row workflow is missing immutable ${action}`); + } + const index = section(release, " image-index:", " promote-images:"); + for (const action of [ + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7", + "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7", + "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8", + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7", + ]) { + assert.ok(index.includes(action), `image index is missing immutable ${action}`); + } +}); + test("dry validation cannot write to a registry", () => { const caller = section(release, " package-image:", " homebrew:"); assert.match(caller, /permissions:[\s\S]*packages: write[\s\S]*id-token: write[\s\S]*attestations: write/); From 2ae3760bd52a6d559b6ccda4e452a0746accbcb2 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Thu, 30 Jul 2026 20:01:47 -0400 Subject: [PATCH 6/9] fix(ci): validate prerelease packaging inputs --- .github/workflows/images-release.yml | 4 +++- scripts/upstream-archive.ts | 9 ++++++++- tests/release-workflow.test.ts | 6 ++++++ tests/upstream-archive.test.ts | 9 +++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/images-release.yml b/.github/workflows/images-release.yml index 80b7ef5..f9a3c2b 100644 --- a/.github/workflows/images-release.yml +++ b/.github/workflows/images-release.yml @@ -478,7 +478,9 @@ jobs: if npm view "@mesh-llm/sdk@$MESH_VERSION" version >/dev/null 2>&1; then echo "@mesh-llm/sdk@$MESH_VERSION is already published; skipping publish dry-run" else - npm publish "./$tarball" --dry-run --access public --provenance=false + dist_tag=latest + if [[ "$MESH_VERSION" == *-* ]]; then dist_tag=next; fi + npm publish "./$tarball" --dry-run --access public --tag "$dist_tag" --provenance=false fi ( cd consumer diff --git a/scripts/upstream-archive.ts b/scripts/upstream-archive.ts index 1c54204..0db40c1 100755 --- a/scripts/upstream-archive.ts +++ b/scripts/upstream-archive.ts @@ -17,6 +17,11 @@ type Inputs = { const runtimeIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +export function productBackendForFlavor(flavor: string): string { + if (flavor === "cuda-12" || flavor === "cuda-13") return "cuda"; + return flavor; +} + export function sha256File(path: string): Promise { return new Promise((resolveDigest, reject) => { const hash = createHash("sha256"); @@ -195,7 +200,9 @@ export async function verifyAndExtract(input: Inputs) { chmodSync(binary, 0o755); const productManifest = validateProductManifest(JSON.parse(readFileSync(resolve(input.outputDir, "product-manifest.json"), "utf8"))); if (productManifest.mesh_version !== input.version.replace(/^v/, "")) throw new Error("product manifest version does not match requested upstream version"); - if (productManifest.backend !== input.flavor) throw new Error("product manifest backend does not match requested upstream flavor"); + if (productManifest.backend !== productBackendForFlavor(input.flavor)) { + throw new Error("product manifest backend does not match requested upstream flavor"); + } const hostSha256 = await sha256File(binary); if (hostSha256 !== productManifest.host.sha256) throw new Error("product host digest does not match extracted mesh-llm"); const runtime = resolve(input.outputDir, productManifest.runtime.path); diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index b34c5f1..cd9aa1a 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -100,6 +100,12 @@ test("Node packaging consumes safe upstream addon artifacts without compiling", assert.doesNotMatch(addons, /rust-toolchain|sccache|build:native|cargo/); }); +test("Node prerelease validation uses a non-latest npm dist-tag", () => { + const preflight = section(release, " node-sdk-preflight:", " publish-node-sdk:"); + assert.match(preflight, /if \[\[ "\$MESH_VERSION" == \*-\* \]\]; then dist_tag=next; fi/); + assert.match(preflight, /npm publish "\.\/\$tarball" --dry-run --access public --tag "\$dist_tag"/); +}); + test("readiness requires only explicitly enabled or requested components", () => { const readiness = section(release, " readiness:"); assert.match(readiness, /if \[\[ "\$NATIVE_ENABLED" == true \]\]/); diff --git a/tests/upstream-archive.test.ts b/tests/upstream-archive.test.ts index 08570ac..bf16c32 100644 --- a/tests/upstream-archive.test.ts +++ b/tests/upstream-archive.test.ts @@ -8,6 +8,7 @@ import { test } from "node:test"; import { main, parseChecksum, + productBackendForFlavor, sha256File, sha256Tree, validateArchiveEntries, @@ -16,6 +17,14 @@ import { verifyAndExtract, } from "../scripts/upstream-archive.ts"; +test("maps versioned CUDA flavors to the product backend", () => { + assert.equal(productBackendForFlavor("cuda-12"), "cuda"); + assert.equal(productBackendForFlavor("cuda-13"), "cuda"); + for (const flavor of ["cpu", "metal", "rocm", "vulkan"]) { + assert.equal(productBackendForFlavor(flavor), flavor); + } +}); + function legacySha256Tree(root: string): string { const digest = createHash("sha256"); const filesBelow = (current: string): string[] => readdirSync(current).flatMap((name) => { From 59d866823f432e3b3a8286546fd72b15b51d6f16 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Thu, 30 Jul 2026 20:13:02 -0400 Subject: [PATCH 7/9] fix(ci): make package readiness deterministic --- docs/native-packages.md | 2 +- docs/packaging-readiness-gaps.md | 3 ++- .../homebrew/Formula/mesh-llm.rb.template | 18 +++++++++++++----- scripts/client-readiness-smoke.sh | 5 ++++- tests/client-readiness-smoke.test.ts | 5 +++++ tests/homebrew-release.test.ts | 5 ++++- 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/native-packages.md b/docs/native-packages.md index 53ad037..5c93173 100644 --- a/docs/native-packages.md +++ b/docs/native-packages.md @@ -31,7 +31,7 @@ the distro package manager, proves ownership of the host plus the versioned runtime directory, and runs `mesh-llm --version` plus `mesh-llm runtime list` without a GPU device or driver. It then uses the shared readiness helper with unique API/console ports and cache/runtime roots to start -`--log-format json --no-console client --auto`, require either the JSON +`--log-format json --no-console client` without public discovery, require either the JSON `Client ready` message or the structured `passive_mode`/`status=ready`/`role=client` event while the process is alive, and require bounded SIGINT shutdown. diff --git a/docs/packaging-readiness-gaps.md b/docs/packaging-readiness-gaps.md index 1c425ce..69aadce 100644 --- a/docs/packaging-readiness-gaps.md +++ b/docs/packaging-readiness-gaps.md @@ -12,7 +12,8 @@ The repository implementation is archive-first: it verifies already-built upstre - [x] Correct channel claims: block Alpine/musl and Intel macOS; enable upstream Linux Vulkan and arm64 CUDA 13. QA: matrix tests. - [ ] Re-certify GPU package and image QA against product-v2 bundles. QA: backend-neutral hosts pass `--version`, `--help`, `runtime list`, and an - isolated JSON `client --auto` readiness/clean-SIGINT smoke without devices or + isolated JSON `client` readiness/clean-SIGINT smoke without public discovery, + devices, or driver stubs; packages own versioned runtime trees; all product rows per OS/architecture attest the same host SHA-256; and a new full dry run succeeds. The earlier static-host baseline is preserved in diff --git a/packaging/homebrew/Formula/mesh-llm.rb.template b/packaging/homebrew/Formula/mesh-llm.rb.template index 5c5c015..9e38c33 100644 --- a/packaging/homebrew/Formula/mesh-llm.rb.template +++ b/packaging/homebrew/Formula/mesh-llm.rb.template @@ -17,7 +17,7 @@ class MeshLlm < Formula test do assert_match version.to_s, shell_output("#{bin}/mesh-llm --version") assert_match "native runtime", shell_output("#{bin}/mesh-llm runtime list") - assert_predicate libexec/"product-manifest.json", :exist? + assert_path_exists libexec/"product-manifest.json" require "json" require "timeout" @@ -41,7 +41,7 @@ class MeshLlm < Formula $stdout.reopen(file) $stderr.reopen(file) exec bin/"mesh-llm", "--log-format", "json", "--port", port.to_s, - "--console", console.to_s, "--no-console", "client", "--auto" + "--console", console.to_s, "--no-console", "client" end end @@ -62,7 +62,7 @@ class MeshLlm < Formula end begin - assert ready, "mesh-llm client did not reach Client ready: #{log.exist? ? log.read : "no log"}" + flunk "mesh-llm client did not reach Client ready: #{log.exist? ? log.read : "no log"}" unless ready assert Process.kill(0, pid), "mesh-llm client exited after readiness" ensure begin @@ -72,8 +72,16 @@ class MeshLlm < Formula rescue Errno::ESRCH, Errno::ECHILD flunk "mesh-llm client exited before bounded SIGINT shutdown" rescue Timeout::Error - Process.kill("TERM", pid) rescue nil - Process.wait(pid) rescue nil + begin + Process.kill("TERM", pid) + rescue Errno::ESRCH + nil + end + begin + Process.wait(pid) + rescue Errno::ECHILD + nil + end flunk "mesh-llm client did not stop within 10 seconds of SIGINT" end end diff --git a/scripts/client-readiness-smoke.sh b/scripts/client-readiness-smoke.sh index 463e5cd..a467e26 100644 --- a/scripts/client-readiness-smoke.sh +++ b/scripts/client-readiness-smoke.sh @@ -108,7 +108,10 @@ chmod 700 \ export XDG_RUNTIME_DIR="$smoke_root/runtime" export MESH_LLM_RUNTIME_ROOT="$smoke_root/runtime" export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$smoke_root/native-runtime-cache" - exec "$mesh_llm_bin" --log-format json --port "$api_port" --console "$console_port" --no-console client --auto + # Package/image QA must be self-contained. Plain client mode exercises the + # local API/passive runtime path without making readiness depend on public + # Nostr discovery or the availability of a remote mesh. + exec "$mesh_llm_bin" --log-format json --port "$api_port" --console "$console_port" --no-console client ) >"$log" 2>&1 & pid=$! diff --git a/tests/client-readiness-smoke.test.ts b/tests/client-readiness-smoke.test.ts index b6b311c..c9cda1b 100644 --- a/tests/client-readiness-smoke.test.ts +++ b/tests/client-readiness-smoke.test.ts @@ -61,6 +61,9 @@ test("client readiness smoke requires JSON readiness, a live process, and clean const fixture = fakeNodeExecutable(t, ` const fs = require('node:fs') fs.writeFileSync(process.env.SMOKE_MARKER, \`start:\${process.pid}\\n\`) +if (!process.argv.includes('client') || process.argv.includes('--auto')) { + process.exit(64) +} process.on('SIGINT', () => { fs.appendFileSync(process.env.SMOKE_MARKER, \`int:\${process.pid}\\n\`) process.exit(0) @@ -126,6 +129,8 @@ setInterval(() => {}, 1000) test("client readiness smoke polls readiness without shell-signal wakeups", { concurrency: false }, () => { const source = readFileSync(smoke, "utf8"); + assert.match(source, /--no-console client/); + assert.doesNotMatch(source, /client --auto/); assert.match(source, /readiness_reached=false/); assert.match(source, /readiness_in_log/); assert.match(source, /if ! kill -0 "\$pid" 2>\/dev\/null; then/); diff --git a/tests/homebrew-release.test.ts b/tests/homebrew-release.test.ts index 265d27e..ab2d8cb 100644 --- a/tests/homebrew-release.test.ts +++ b/tests/homebrew-release.test.ts @@ -38,9 +38,12 @@ test("formula test certifies isolated no-driver client readiness", () => { "MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", "MESH_LLM_RUNTIME_ROOT", '"--log-format", "json"', - '"--no-console", "client", "--auto"', + '"--no-console", "client"', "Client ready", 'Process.kill("INT", pid)', "Timeout.timeout(10)", ]) assert.match(template, new RegExp(snippet.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.doesNotMatch(template, /"client", "--auto"/); + assert.match(template, /assert_path_exists libexec\/"product-manifest\.json"/); + assert.doesNotMatch(template, /rescue nil/); }); From cc1f4c46e4b45c25758623c4a288cc83ce2b1059 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Thu, 30 Jul 2026 20:19:40 -0400 Subject: [PATCH 8/9] fix(ci): verify Syft package root subjects --- scripts/verify-sbom-subject.ts | 51 +++++++++++++++++++++++++++++++--- tests/sbom-subject.test.ts | 47 ++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/scripts/verify-sbom-subject.ts b/scripts/verify-sbom-subject.ts index d7f4e4e..e5ca635 100644 --- a/scripts/verify-sbom-subject.ts +++ b/scripts/verify-sbom-subject.ts @@ -10,13 +10,29 @@ type Checksum = { }; type SpdxFile = { + SPDXID?: unknown; fileName?: unknown; checksums?: unknown; }; +type SpdxPackage = { + SPDXID?: unknown; + name?: unknown; + primaryPackagePurpose?: unknown; + checksums?: unknown; +}; + +type SpdxRelationship = { + spdxElementId?: unknown; + relatedSpdxElement?: unknown; + relationshipType?: unknown; +}; + type SpdxDocument = { spdxVersion?: unknown; files?: unknown; + packages?: unknown; + relationships?: unknown; }; export type SbomSubject = { @@ -39,17 +55,44 @@ export function parseSidecar(contents: string, expectedName: string): string { return match[1].toLowerCase(); } -function exactFileSubjects(document: SpdxDocument, expectedName: string): SpdxFile[] { +type SpdxSubject = SpdxFile | SpdxPackage; + +function describedPackageIds(document: SpdxDocument): Set { + if (!Array.isArray(document.relationships)) return new Set(); + return new Set(document.relationships.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const relationship = entry as SpdxRelationship; + if ( + relationship.spdxElementId !== "SPDXRef-DOCUMENT" + || relationship.relationshipType !== "DESCRIBES" + || typeof relationship.relatedSpdxElement !== "string" + ) return []; + return [relationship.relatedSpdxElement]; + })); +} + +function exactFileSubjects(document: SpdxDocument, expectedName: string): SpdxSubject[] { if (document.spdxVersion !== "SPDX-2.3") throw new Error("SBOM must use SPDX-2.3"); - if (!Array.isArray(document.files)) throw new Error("SBOM does not contain a files array"); - return document.files.filter((entry): entry is SpdxFile => { + const files = Array.isArray(document.files) ? document.files : []; + const fileSubjects = files.filter((entry): entry is SpdxFile => { if (!entry || typeof entry !== "object") return false; const fileName = (entry as SpdxFile).fileName; return typeof fileName === "string" && basename(fileName) === expectedName; }); + const describedIds = describedPackageIds(document); + const packages = Array.isArray(document.packages) ? document.packages : []; + const packageSubjects = packages.filter((entry): entry is SpdxPackage => { + if (!entry || typeof entry !== "object") return false; + const subject = entry as SpdxPackage; + return subject.name === expectedName + && subject.primaryPackagePurpose === "FILE" + && typeof subject.SPDXID === "string" + && describedIds.has(subject.SPDXID); + }); + return [...fileSubjects, ...packageSubjects]; } -function hasSha256(entry: SpdxFile, digest: string): boolean { +function hasSha256(entry: SpdxSubject, digest: string): boolean { if (!Array.isArray(entry.checksums)) return false; return entry.checksums.some((checksum: Checksum) => ( checksum?.algorithm === "SHA256" diff --git a/tests/sbom-subject.test.ts b/tests/sbom-subject.test.ts index 7d8e21c..5bde77e 100644 --- a/tests/sbom-subject.test.ts +++ b/tests/sbom-subject.test.ts @@ -6,7 +6,11 @@ import { basename, resolve } from "node:path"; import { test } from "node:test"; import { main, parseSidecar, verifySbomSubject } from "../scripts/verify-sbom-subject.ts"; -function fixture(t: { after(callback: () => void): void }, name: string) { +function fixture( + t: { after(callback: () => void): void }, + name: string, + representation: "file" | "described-package" = "file", +) { const directory = mkdtempSync(resolve(tmpdir(), "sbom-subject-test-")); t.after(() => rmSync(directory, { recursive: true, force: true })); const packagePath = resolve(directory, name); @@ -15,12 +19,28 @@ function fixture(t: { after(callback: () => void): void }, name: string) { writeFileSync(packagePath, `package bytes for ${name}\n`); const digest = createHash("sha256").update(readFileSync(packagePath)).digest("hex"); writeFileSync(sidecarPath, `${digest} ${name}\n`); - writeFileSync(sbomPath, JSON.stringify({ + const subject = { + SPDXID: "SPDXRef-DocumentRoot-File-package", + checksums: [{ algorithm: "SHA256", checksumValue: digest }], + }; + writeFileSync(sbomPath, JSON.stringify(representation === "file" ? { spdxVersion: "SPDX-2.3", files: [{ + ...subject, SPDXID: "SPDXRef-File-package", fileName: name, - checksums: [{ algorithm: "SHA256", checksumValue: digest }], + }], + } : { + spdxVersion: "SPDX-2.3", + packages: [{ + ...subject, + name, + primaryPackagePurpose: "FILE", + }], + relationships: [{ + spdxElementId: "SPDXRef-DOCUMENT", + relatedSpdxElement: subject.SPDXID, + relationshipType: "DESCRIBES", }], })); return { directory, packagePath, sidecarPath, sbomPath, digest, name }; @@ -31,7 +51,8 @@ for (const name of [ "mesh-llm-0.74.0-arch-amd64-cpu.pkg.tar.zst", ]) { test(`verifies the exact ${name.split(".").at(-1)} package subject`, (t) => { - const value = fixture(t, name); + const representation = name.endsWith(".pkg.tar.zst") ? "described-package" : "file"; + const value = fixture(t, name, representation); assert.deepEqual(verifySbomSubject(value.packagePath, value.sidecarPath, value.sbomPath), { name, sha256: value.digest, @@ -77,3 +98,21 @@ test("rejects generic, ambiguous, stale, and malformed package identity", (t) => /exactly one/, ); }); + +test("requires package-form file subjects to be the exact document-described root", (t) => { + const value = fixture(t, "mesh-llm-0.74.0-arch-amd64-cpu.pkg.tar.zst", "described-package"); + const valid = JSON.parse(readFileSync(value.sbomPath, "utf8")); + const invalidDocuments = [ + { ...valid, relationships: [] }, + { ...valid, relationships: [{ ...valid.relationships[0], relationshipType: "CONTAINS" }] }, + { ...valid, packages: [{ ...valid.packages[0], primaryPackagePurpose: "APPLICATION" }] }, + { ...valid, packages: [{ ...valid.packages[0], name: "wrong.pkg.tar.zst" }] }, + ]; + for (const document of invalidDocuments) { + writeFileSync(value.sbomPath, JSON.stringify(document)); + assert.throws( + () => verifySbomSubject(value.packagePath, value.sidecarPath, value.sbomPath), + /SBOM/, + ); + } +}); From ffd240c099d38dc1e16cb252f30b347a6d835399 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Thu, 30 Jul 2026 20:26:21 -0400 Subject: [PATCH 9/9] fix(ci): accept canonical dual Syft subjects --- scripts/verify-sbom-subject.ts | 23 ++++++++++++++++------- tests/sbom-subject.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/scripts/verify-sbom-subject.ts b/scripts/verify-sbom-subject.ts index e5ca635..d7ca5f4 100644 --- a/scripts/verify-sbom-subject.ts +++ b/scripts/verify-sbom-subject.ts @@ -56,6 +56,10 @@ export function parseSidecar(contents: string, expectedName: string): string { } type SpdxSubject = SpdxFile | SpdxPackage; +type ExactSubjects = { + files: SpdxFile[]; + packages: SpdxPackage[]; +}; function describedPackageIds(document: SpdxDocument): Set { if (!Array.isArray(document.relationships)) return new Set(); @@ -71,7 +75,7 @@ function describedPackageIds(document: SpdxDocument): Set { })); } -function exactFileSubjects(document: SpdxDocument, expectedName: string): SpdxSubject[] { +function exactFileSubjects(document: SpdxDocument, expectedName: string): ExactSubjects { if (document.spdxVersion !== "SPDX-2.3") throw new Error("SBOM must use SPDX-2.3"); const files = Array.isArray(document.files) ? document.files : []; const fileSubjects = files.filter((entry): entry is SpdxFile => { @@ -89,7 +93,7 @@ function exactFileSubjects(document: SpdxDocument, expectedName: string): SpdxSu && typeof subject.SPDXID === "string" && describedIds.has(subject.SPDXID); }); - return [...fileSubjects, ...packageSubjects]; + return { files: fileSubjects, packages: packageSubjects }; } function hasSha256(entry: SpdxSubject, digest: string): boolean { @@ -113,12 +117,17 @@ export function verifySbomSubject( throw new Error(`package SHA-256 mismatch: sidecar=${sidecarDigest}, actual=${packageDigest}`); } const document = JSON.parse(readFileSync(sbomPath, "utf8")) as SpdxDocument; - const subjects = exactFileSubjects(document, name); - if (subjects.length !== 1) { - throw new Error(`SBOM must contain exactly one file subject named ${name}, found ${subjects.length}`); + const { files, packages } = exactFileSubjects(document, name); + if (files.length > 1 || packages.length > 1 || files.length + packages.length === 0) { + throw new Error( + `SBOM must contain one logical file subject named ${name}; ` + + `found ${files.length} file entries and ${packages.length} described package entries`, + ); } - if (!hasSha256(subjects[0], packageDigest)) { - throw new Error(`SBOM file subject ${name} does not contain SHA256 ${packageDigest}`); + for (const subject of [...files, ...packages]) { + if (!hasSha256(subject, packageDigest)) { + throw new Error(`SBOM file subject ${name} does not contain SHA256 ${packageDigest}`); + } } return { name, sha256: packageDigest }; } diff --git a/tests/sbom-subject.test.ts b/tests/sbom-subject.test.ts index 5bde77e..378e763 100644 --- a/tests/sbom-subject.test.ts +++ b/tests/sbom-subject.test.ts @@ -116,3 +116,25 @@ test("requires package-form file subjects to be the exact document-described roo ); } }); + +test("accepts Syft's dual file and described-package representation as one logical subject", (t) => { + const value = fixture(t, "mesh-llm-0.74.0-ubuntu-amd64-cpu.deb", "described-package"); + const valid = JSON.parse(readFileSync(value.sbomPath, "utf8")); + valid.files = [{ + SPDXID: "SPDXRef-File-package", + fileName: value.name, + checksums: [{ algorithm: "SHA256", checksumValue: value.digest }], + }]; + writeFileSync(value.sbomPath, JSON.stringify(valid)); + assert.deepEqual(verifySbomSubject(value.packagePath, value.sidecarPath, value.sbomPath), { + name: value.name, + sha256: value.digest, + }); + + valid.files.push(valid.files[0]); + writeFileSync(value.sbomPath, JSON.stringify(valid)); + assert.throws( + () => verifySbomSubject(value.packagePath, value.sidecarPath, value.sbomPath), + /one logical file subject/, + ); +});