diff --git a/.github/workflows/images-release.yml b/.github/workflows/images-release.yml index f9a3c2b..a897f1d 100644 --- a/.github/workflows/images-release.yml +++ b/.github/workflows/images-release.yml @@ -18,6 +18,11 @@ on: description: Release version (for example 0.73.1) required: true type: string + expected_mesh_sha: + description: Optional expected immutable upstream tag SHA (40 lowercase hexadecimal characters) + required: false + default: '' + type: string dry_run: description: Build and test everything without publishing required: true @@ -115,6 +120,7 @@ jobs: INPUT_REPOSITORY: ${{ inputs.mesh_repository }} INPUT_REF: ${{ inputs.mesh_ref }} INPUT_VERSION: ${{ inputs.mesh_version }} + INPUT_EXPECTED_MESH_SHA: ${{ inputs.expected_mesh_sha }} INPUT_DRY_RUN: ${{ inputs.dry_run }} INPUT_PUBLISH_IMAGES: ${{ inputs.publish_images }} INPUT_PUBLISH_RELEASE_ASSETS: ${{ inputs.publish_release_assets }} @@ -130,6 +136,7 @@ jobs: repository="${DISPATCH_REPOSITORY:-${INPUT_REPOSITORY:-Mesh-LLM/mesh-llm}}" ref="${DISPATCH_REF:-${INPUT_REF:-}}" version="${DISPATCH_VERSION:-${INPUT_VERSION:-}}" + expected_mesh_sha="${INPUT_EXPECTED_MESH_SHA:-}" dry_run="${DISPATCH_DRY_RUN:-${INPUT_DRY_RUN:-true}}" publish_images="${DISPATCH_PUBLISH_IMAGES:-${INPUT_PUBLISH_IMAGES:-false}}" publish_release_assets="${DISPATCH_PUBLISH_RELEASE_ASSETS:-${INPUT_PUBLISH_RELEASE_ASSETS:-false}}" @@ -165,6 +172,20 @@ jobs: mesh_sha="$(git ls-remote "https://github.com/$repository.git" "refs/tags/$ref" | awk 'NR == 1 { print $1 }')" fi [[ "$mesh_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "could not resolve immutable upstream tag SHA" >&2; exit 1; } + validate_expected_mesh_sha() { + local expected="$1" + local resolved="$2" + [[ -z "$expected" ]] && return 0 + [[ "$expected" =~ ^[0-9a-f]{40}$ ]] || { + echo "expected_mesh_sha must be 40 lowercase hexadecimal characters" >&2 + return 1 + } + [[ "$expected" == "$resolved" ]] || { + echo "resolved upstream tag SHA $resolved does not match expected_mesh_sha $expected" >&2 + return 1 + } + } + validate_expected_mesh_sha "$expected_mesh_sha" "$mesh_sha" { echo "mesh_repository=$repository" @@ -574,8 +595,8 @@ jobs: 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 + 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 diff --git a/TODO.md b/TODO.md index 51e9e0f..2df53b8 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,16 @@ # Production Readiness TODO +- [x] Repair exact-image index assembly after the v0.75.0 publication failure. + Final result: the release workflow uses jq's valid `all(generator; condition)` + form and retains the selected matrix row as an explicit binding while matching + every staged, digest-tested result. Manual recovery dispatches may additionally + pin the expected immutable upstream tag SHA and fail before build fan-out if it + is malformed or differs from the resolved tag. + QA: the workflow regression executes the embedded jq filter against exact and + mismatched fixtures, and directly exercises empty, matching, mismatching, and + malformed expected SHA values; the full TypeScript suite, actionlint, release + matrix consistency check, and `git diff --check` pass. + - [x] Add measured Depot Registry pull-through base-image support. Final result: trusted image rows may substitute configured Depot mirrors for Ubuntu, CUDA, ROCm, and Arch bases while retaining the original tag or digest; diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index a65a90b..c246835 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { test } from "node:test"; @@ -32,6 +34,27 @@ test("manual dispatch uses typed components and exact selectors", () => { assert.match(release, /scripts\/release-plan\.ts/); }); +test("manual dispatch can fail closed on an expected immutable upstream SHA", () => { + assert.match(release, /expected_mesh_sha:[\s\S]*required: false[\s\S]*type: string/); + const functionMatch = release.match( + /(validate_expected_mesh_sha\(\) \{[\s\S]*?^ \})/m, + ); + assert.ok(functionMatch, "expected SHA validator function is missing"); + const validator = functionMatch[1].replace(/^ {10}/gm, ""); + const sha = "a".repeat(40); + const run = (expected: string, resolved = sha) => spawnSync( + "bash", + ["-c", `${validator}\nvalidate_expected_mesh_sha "$1" "$2"`, "test", expected, resolved], + { encoding: "utf8" }, + ); + + assert.equal(run("", sha).status, 0); + assert.equal(run(sha, sha).status, 0); + assert.notEqual(run("b".repeat(40), sha).status, 0); + assert.notEqual(run("A".repeat(40), sha).status, 0); + assert.notEqual(run("short", sha).status, 0); +}); + 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:"); @@ -182,6 +205,49 @@ test("promotion consumes the canonical tested index without rebuilding", () => { assert.doesNotMatch(promotion, /build-push-action|Dockerfile|docker buildx build/); }); +test("image index matrix binding executes for exact staged results", (t) => { + const index = section(release, " image-index:", " promote-images:"); + const filterMatch = index.match( + /jq -e --slurpfile results staged-results\.json '\n([\s\S]*?)\n ' package-matrix\.json/, + ); + assert.ok(filterMatch, "image index matrix-binding jq filter is missing"); + const filter = filterMatch[1].replace(/^ {12}/gm, ""); + const directory = mkdtempSync(resolve(tmpdir(), "image-index-workflow-test-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const matrixPath = resolve(directory, "package-matrix.json"); + const resultsPath = resolve(directory, "staged-results.json"); + const matrixRow = { + artifact_id: "ubuntu-cpu-amd64", + platform: "linux/amd64", + arch: "amd64", + backend: "cpu", + backend_version: "", + package_file: "mesh-llm-0.75.0-ubuntu-amd64-cpu.deb", + package_base_image: "ubuntu:24.04", + runtime_base_image: "ubuntu:24.04", + tags: "ghcr.io/mesh-llm/mesh-llm:0.75.0-ubuntu-amd64-cpu\nghcr.io/mesh-llm/mesh-llm:ubuntu-amd64-cpu", + }; + const resultRow = { + ...matrixRow, + package_base_image: `ubuntu@sha256:${"a".repeat(64)}`, + runtime_base_image: `ubuntu@sha256:${"a".repeat(64)}`, + tags: matrixRow.tags.split("\n"), + }; + writeFileSync(matrixPath, JSON.stringify({ include: [matrixRow] })); + writeFileSync(resultsPath, JSON.stringify([resultRow])); + + const exact = spawnSync("jq", ["-e", "--slurpfile", "results", resultsPath, filter, matrixPath], { + encoding: "utf8", + }); + assert.equal(exact.status, 0, exact.stderr); + + writeFileSync(resultsPath, JSON.stringify([{ ...resultRow, backend: "vulkan" }])); + const mismatch = spawnSync("jq", ["-e", "--slurpfile", "results", resultsPath, filter, matrixPath], { + encoding: "utf8", + }); + assert.notEqual(mismatch.status, 0); +}); + 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/);