diff --git a/.github/workflows/package-image-row.yml b/.github/workflows/package-image-row.yml index a9d7bd6..90916ee 100644 --- a/.github/workflows/package-image-row.yml +++ b/.github/workflows/package-image-row.yml @@ -26,12 +26,15 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + # The MeshLLM Depot project owns the persistent remote BuildKit cache. + DEPOT_PROJECT_ID: mzm95zcv7p jobs: package: name: Produce native package permissions: contents: read + id-token: write runs-on: ${{ fromJSON(fromJSON(inputs.row_json).runner_labels) }} outputs: package_base_image: ${{ steps.bases.outputs.package_base_image }} @@ -44,7 +47,10 @@ jobs: with: name: ${{ fromJSON(inputs.row_json).upstream_artifact_id }} path: artifacts/upstream - - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - name: Set up Depot CLI + uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1 + with: + version: 2.101.77 - name: Resolve exact package and runtime base images id: bases env: @@ -74,8 +80,10 @@ jobs: echo "runtime_base_image=$runtime_base_image" } >> "$GITHUB_OUTPUT" - name: Build native package from verified upstream product bundle - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + id: package + uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1 with: + project: ${{ env.DEPOT_PROJECT_ID }} context: . file: docker/Dockerfile.mesh-llm target: native-package-artifact @@ -87,26 +95,60 @@ jobs: BACKEND_VERSION=${{ fromJSON(inputs.row_json).backend_version }} TARGET_ARCH=${{ fromJSON(inputs.row_json).arch }} MESH_LLM_VERSION=${{ fromJSON(inputs.row_json).mesh_version }} + # Package QA and artifact upload require the package bytes on the runner. 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 + # Preserve a BuildKit attestation when the exporter can provide one. + provenance: mode=max + - name: Namespace exact package build provenance env: ARTIFACT_ID: ${{ fromJSON(inputs.row_json).artifact_id }} + DEPOT_BUILD_ID: ${{ steps.package.outputs.build-id }} + DEPOT_PROJECT_ID: ${{ steps.package.outputs.project-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" + exporter_provenance="artifacts/native-package/provenance.json" + provenance="artifacts/native-package/$ARTIFACT_ID.buildkit-provenance.json" + has_exact_subject() { + 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 + ' "$1" >/dev/null + } + # Depot's local exporter can emit an image-stage statement rather than + # one for the exported package. Preserve an exact statement; otherwise + # bind the exact package bytes to the remote Depot build. + if has_exact_subject "$exporter_provenance" 2>/dev/null; then + mv "$exporter_provenance" "$provenance" + else + rm -f "$exporter_provenance" + [[ "$DEPOT_BUILD_ID" =~ ^[a-z0-9]+$ ]] + [[ "$DEPOT_PROJECT_ID" =~ ^[a-z0-9]+$ ]] + jq_args=( + --arg name "$PACKAGE_FILE" + --arg digest "$package_sha" + --arg project_id "$DEPOT_PROJECT_ID" + --arg build_id "$DEPOT_BUILD_ID" + ) + jq -n "${jq_args[@]}" ' + { + _type: "https://in-toto.io/Statement/v1", + subject: [{name: $name, digest: {sha256: $digest}}], + predicateType: "https://meshllm.cloud/depot-build-receipt/v1", + predicate: { + builder: { + id: ("https://depot.dev/projects/" + $project_id + "/builds/" + $build_id) + }, + build: {project_id: $project_id, build_id: $build_id} + } + } + ' > "$provenance" + fi + has_exact_subject "$provenance" - name: Verify and install package shell: bash run: | @@ -149,6 +191,7 @@ jobs: needs: package permissions: contents: read + id-token: write runs-on: ${{ fromJSON(fromJSON(inputs.row_json).runner_labels) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -158,7 +201,10 @@ jobs: with: name: ${{ fromJSON(inputs.row_json).native_package_artifact_name }} path: artifacts/native-package - - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - name: Set up Depot CLI + uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1 + with: + version: 2.101.77 - name: Read immutable product inputs id: product shell: bash @@ -181,12 +227,14 @@ jobs: echo "runtime_sha=$(jq -r .runtime_sha256 "$provenance")" } >> "$GITHUB_OUTPUT" - name: Build the final runtime image once - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1 with: + project: ${{ env.DEPOT_PROJECT_ID }} context: . file: docker/Dockerfile.mesh-llm target: runtime platforms: ${{ fromJSON(inputs.row_json).platform }} + # Runtime QA runs Docker commands on the runner, so only this dry-run image is loaded. load: true tags: mesh-packaging-local:${{ fromJSON(inputs.row_json).artifact_id }} build-args: | @@ -200,8 +248,6 @@ jobs: 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 }} @@ -252,7 +298,10 @@ jobs: with: name: ${{ fromJSON(inputs.row_json).native_package_artifact_name }} path: artifacts/native-package - - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - name: Set up Depot CLI + uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1 + with: + version: 2.101.77 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io @@ -281,12 +330,14 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Build and push one run-scoped staging image id: stage - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1 with: + project: ${{ env.DEPOT_PROJECT_ID }} context: . file: docker/Dockerfile.mesh-llm target: runtime platforms: ${{ fromJSON(inputs.row_json).platform }} + # Push directly from Depot; downstream QA pulls the exact emitted digest. push: true tags: ${{ inputs.image_name }}:staging-${{ github.run_id }}-${{ github.run_attempt }}-${{ fromJSON(inputs.row_json).artifact_id }} build-args: | @@ -300,8 +351,6 @@ jobs: 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 }} diff --git a/TODO.md b/TODO.md index c8f3ea9..551951e 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,18 @@ immutable version-tag conflict rejection, convenience-tag rollback evidence, zero Docker builds in promotion, and a complete deterministic release index. +- [ ] Route package and image BuildKit execution through Depot remote builders. + Final result: native package, dry-image, and staging-image builds use the + MeshLLM Depot project and its persistent cache; package bytes return only for + required package QA/upload, dry images load only for runner-side QA, and + staging images push directly from Depot before digest-bound QA and promotion. + Operational prerequisite: configure a GitHub Actions OIDC trust relationship + for GitHub organization `Mesh-LLM` and repository `mesh-packaging` in the + Depot project; do not replace OIDC with a long-lived repository token. + QA: release-workflow tests reject hosted Buildx and `type=gha` cache use in + these paths; matrix validation, YAML/actionlint checks, Dockerfile checks, + and the relevant GitHub Actions workflow pass. + - [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; diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index cd9aa1a..3cb194d 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -5,8 +5,10 @@ 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"; +const pinnedDepotSetupAction = + "uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1"; +const pinnedDepotBuildPushAction = + "uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1"; function section(source: string, start: string, end?: string): string { const from = source.indexOf(start); @@ -30,11 +32,13 @@ test("manual dispatch uses typed components and exact selectors", () => { assert.match(release, /scripts\/release-plan\.ts/); }); -test("each path builds one final image and QA binds the same identity", () => { +test("each path builds once on Depot and QA binds the same identity", () => { + const packageJob = section(row, " package:", " dry-image:"); const dry = section(row, " dry-image:", " stage-image:"); const stage = section(row, " stage-image:"); - assert.equal(dry.split(pinnedBuildPushAction).length - 1, 1); - assert.equal(stage.split(pinnedBuildPushAction).length - 1, 1); + assert.equal(packageJob.split(pinnedDepotBuildPushAction).length - 1, 1); + assert.equal(dry.split(pinnedDepotBuildPushAction).length - 1, 1); + assert.equal(stage.split(pinnedDepotBuildPushAction).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 \}\}/); @@ -44,17 +48,38 @@ test("each path builds one final image and QA binds the same identity", () => { assert.doesNotMatch(row, /runtime-qa|type=cacheonly/); }); +test("package and image BuildKit work uses the Depot project cache", () => { + const packageJob = section(row, " package:", " dry-image:"); + const dry = section(row, " dry-image:", " stage-image:"); + const stage = section(row, " stage-image:"); + assert.match(row, /DEPOT_PROJECT_ID: mzm95zcv7p/); + assert.equal(row.split(pinnedDepotSetupAction).length - 1, 3); + for (const job of [packageJob, dry, stage]) { + assert.match(job, new RegExp(pinnedDepotSetupAction.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(job, new RegExp(pinnedDepotBuildPushAction.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(job, /project: \$\{\{ env\.DEPOT_PROJECT_ID \}\}/); + assert.doesNotMatch(job, /docker\/(?:setup-buildx-action|build-push-action)/); + } + assert.match(packageJob, /id: package[\s\S]*outputs: type=local,dest=artifacts\/native-package/); + assert.match(packageJob, /provenance: mode=max/); + assert.match(packageJob, /DEPOT_BUILD_ID: \$\{\{ steps\.package\.outputs\.build-id \}\}/); + assert.match(packageJob, /DEPOT_PROJECT_ID: \$\{\{ steps\.package\.outputs\.project-id \}\}/); + assert.match(packageJob, /https:\/\/meshllm\.cloud\/depot-build-receipt\/v1/); + assert.match(dry, /load: true/); + assert.doesNotMatch(stage, /outputs: type=local|load: true/); + assert.match(stage, /push:\s+true/); + assert.match(stage, /tags: \$\{\{ inputs\.image_name \}\}:staging-/); + assert.match(stage, /IMAGE_REF: \$\{\{ inputs\.image_name \}\}@\$\{\{ steps\.stage\.outputs\.digest \}\}/); + assert.doesNotMatch(row, /cache-(?:from|to): type=gha/); +}); + 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 externalActionReferences = [...row.matchAll( + /^\s*(?:-\s+)?uses:\s*([^@\s]+)@([^\s#]+)/gm, + )]; + assert.ok(externalActionReferences.length > 0); + for (const [, action, ref] of externalActionReferences) { + assert.match(ref, /^[0-9a-f]{40}$/, `${action} must use an immutable commit SHA`); } const index = section(release, " image-index:", " promote-images:"); for (const action of [ @@ -73,8 +98,8 @@ test("dry validation cannot write to a registry", () => { 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/); - assert.match(dry, /permissions:\s+contents: read/); + assert.match(packageJob, /permissions:\s+contents: read\s+id-token: write/); + assert.match(dry, /permissions:\s+contents: read\s+id-token: write/); assert.doesNotMatch(dry, /packages: write|docker\/login-action|push: true/); const stage = section(row, " stage-image:"); assert.match(stage, /environment: release/); diff --git a/tests/workflow-provenance.test.ts b/tests/workflow-provenance.test.ts index f87fb7d..9a90d7f 100644 --- a/tests/workflow-provenance.test.ts +++ b/tests/workflow-provenance.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { test } from "node:test"; @@ -10,6 +11,13 @@ const rowWorkflow = readFileSync(resolve(".github/workflows/package-image-row.ym 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, "")); +const packageProvenanceStep = rowWorkflow.slice( + rowWorkflow.indexOf(" - name: Namespace exact package build provenance"), + rowWorkflow.indexOf(" - name: Verify and install package"), +); +const packageProvenanceScript = packageProvenanceStep + .slice(packageProvenanceStep.indexOf(" run: |\n") + " run: |\n".length) + .replace(/^ /gm, ""); function runStep(script: string, provenance: readonly object[]): { readonly outputContents: string; @@ -33,6 +41,50 @@ function runStep(script: string, provenance: readonly object[]): { return { outputContents: contents, status: result.status, stderr: result.stderr }; } +function runPackageProvenanceStep( + script: string, + options: { + readonly depotBuildId?: string; + readonly depotProjectId?: string; + readonly exporterProvenance?: object; + } = {}, +): { + readonly statement: object | undefined; + readonly status: number | null; + readonly stderr: string; +} { + const directory = mkdtempSync(resolve(tmpdir(), "package-provenance-test-")); + const artifactDirectory = resolve(directory, "artifacts/native-package"); + const packageName = "mesh-llm.deb"; + const packageContents = "native package bytes"; + mkdirSync(artifactDirectory, { recursive: true }); + writeFileSync(resolve(artifactDirectory, packageName), packageContents); + if (options.exporterProvenance) { + writeFileSync( + resolve(artifactDirectory, "provenance.json"), + JSON.stringify(options.exporterProvenance) + "\n", + ); + } + const result = spawnSync("bash", ["-c", script], { + cwd: directory, + encoding: "utf8", + env: { + ...process.env, + ARTIFACT_ID: "ubuntu-cpu-amd64", + DEPOT_BUILD_ID: options.depotBuildId ?? "k9f43vh0xp", + DEPOT_PROJECT_ID: options.depotProjectId ?? "mzm95zcv7p", + PACKAGE_FILE: packageName, + }, + }); + const namespaced = resolve(artifactDirectory, "ubuntu-cpu-amd64.buildkit-provenance.json"); + const statement = + result.status === 0 && existsSync(namespaced) + ? (JSON.parse(readFileSync(namespaced, "utf8")) as object) + : undefined; + rmSync(directory, { recursive: true, force: true }); + return { statement, status: result.status, stderr: result.stderr }; +} + test("both final-image paths emit validated provenance", () => { assert.equal(productInputSteps.length, 2); const provenance = { @@ -70,6 +122,64 @@ test("both final-image paths reject ambiguous or invalid provenance", () => { } }); +test("Depot package receipts bind local exports to their remote build", () => { + const packageSha256 = createHash("sha256").update("native package bytes").digest("hex"); + const result = runPackageProvenanceStep(packageProvenanceScript); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.statement, { + _type: "https://in-toto.io/Statement/v1", + subject: [{ name: "mesh-llm.deb", digest: { sha256: packageSha256 } }], + predicateType: "https://meshllm.cloud/depot-build-receipt/v1", + predicate: { + builder: { + id: "https://depot.dev/projects/mzm95zcv7p/builds/k9f43vh0xp", + }, + build: { project_id: "mzm95zcv7p", build_id: "k9f43vh0xp" }, + }, + }); +}); + +test("Depot package receipts replace an exporter statement that does not bind the package", () => { + const packageSha256 = createHash("sha256").update("native package bytes").digest("hex"); + const exporterProvenance = { + _type: "https://in-toto.io/Statement/v1", + subject: [{ name: "native-package-artifact", digest: { sha256: packageSha256 } }], + predicateType: "https://slsa.dev/provenance/v1", + predicate: { buildType: "https://mobyproject.org/buildkit@v1" }, + }; + const result = runPackageProvenanceStep(packageProvenanceScript, { exporterProvenance }); + assert.equal(result.status, 0, result.stderr); + assert.ok(result.statement); + const receipt = result.statement as { readonly predicateType?: unknown; readonly subject?: unknown }; + assert.equal(receipt.predicateType, "https://meshllm.cloud/depot-build-receipt/v1"); + assert.deepEqual(receipt.subject, [ + { name: "mesh-llm.deb", digest: { sha256: packageSha256 } }, + ]); +}); + +test("Depot package receipts reject a malformed remote identity", () => { + for (const options of [ + { depotBuildId: "" }, + { depotBuildId: "build/id" }, + { depotProjectId: "project_id" }, + ]) { + assert.notEqual(runPackageProvenanceStep(packageProvenanceScript, options).status, 0); + } +}); + +test("package provenance preserves an exporter statement when one is available", () => { + const packageSha256 = createHash("sha256").update("native package bytes").digest("hex"); + const exporterProvenance = { + _type: "https://in-toto.io/Statement/v1", + subject: [{ name: "mesh-llm.deb", digest: { sha256: packageSha256 } }], + predicateType: "https://slsa.dev/provenance/v1", + predicate: { buildType: "https://mobyproject.org/buildkit@v1" }, + }; + const result = runPackageProvenanceStep(packageProvenanceScript, { exporterProvenance }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.statement, exporterProvenance); +}); + test("native package evidence is exact, namespaced, and assembled before publication", () => { for (const snippet of [ "release-assembly:",