diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6014a30..4886c11d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -490,7 +490,13 @@ jobs: # release job (§A5(2)): a workflow_dispatch from a feature branch must never # mint release artifacts for unmerged code, even were release-please to emit # releases_created there. - needs: [release-pr, release-assets-macos] + # release-image is a needs edge so its resolved image_digest output reaches + # the generator below. Both jobs share the same `if:` (main ref + a real + # release cut), so this edge never skips release-assets on a release run. It + # DOES serialize release-assets behind release-image, which itself queues in + # the publish-agent-image concurrency group behind an in-flight publish-image + # — accepted added latency for a single authoritative digest. + needs: [release-pr, release-assets-macos, release-image] if: github.ref == 'refs/heads/main' && needs.release-pr.outputs.releases_created == 'true' # Least privilege: write the Release, nothing else. NOT packages:write — the # release-notes generator reads the PUBLIC image digest anonymously, and this @@ -655,12 +661,21 @@ jobs: # recorded-absence line when the image lane has not published this sha) # and runs `nix path-info` over the toolchain `langs` set, writing the # two files the release upload consumes. + # IMAGE_DIGEST is the digest release-image already resolved and verified. + # It is authoritative: the generator builds the image identity from it and + # skips its own GHCR probe, which would query `:git-` — a tag + # the paths-filtered image lane never published for a release commit that + # did not touch the image closure. Routed through env (not inlined ${{ }}) + # to keep the context value off the run: command line. + env: + IMAGE_DIGEST: ${{ needs.release-image.outputs.image_digest }} run: | set -euo pipefail bun run tools/release-notes/index.ts \ --sha "$SHA12" \ --version "$VERSION" \ --tag "$TAG" \ + --image-digest "$IMAGE_DIGEST" \ --asset "compass_${TAG}_linux-amd64" \ --asset "compass-server_${TAG}_linux-amd64" \ --asset "compass-runner_${TAG}_linux-amd64" \ @@ -750,6 +765,18 @@ jobs: # this trivially (§A5(2), mirrors publish-image). if: github.ref == 'refs/heads/main' && needs.release-pr.outputs.releases_created == 'true' timeout-minutes: 90 + # Publish the resolved ancestor identity for release-assets to consume. The + # agent-image lane is paths-filtered (publish-image runs only when the image + # closure changes), so a release commit that touches nothing in that closure + # has NO `:git-` image — the release sha's tag was never built. + # This resolver already walks first-parent ancestors and proves the correct + # source digest (§A4 byte-identity). Rather than have release-assets re-probe + # `:git-` — a tag that does not exist, which hard-fails the notes + # generator — export the digest it already verified here as the single source + # of truth. release-assets reads `image_digest` and skips its own probe. + outputs: + image_digest: ${{ steps.retag.outputs.image_digest }} + resolved_sha12: ${{ steps.retag.outputs.resolved_sha12 }} steps: # Full history: the resolver walks first-parent ancestors of the release # sha (§A4/Plan T3), so a shallow checkout would truncate the walk. @@ -821,6 +848,7 @@ jobs: --authfile "$REGISTRY_AUTH_FILE" <<< "$GITHUB_TOKEN" - name: Digest-re-tag the newest ancestor image to the semver tag + id: retag # The load-bearing §A4 resolver: registry-anchored, NOT path-based. A # `git log -- ` resolver was rejected (§A4:159-164) because the # per-push lane keys its tag on the PUSH HEAD sha, not the last @@ -906,6 +934,13 @@ jobs: fi echo "verified: $ref:$tag re-tagged from :git-$resolved_sha12, config digests coherent" + # Publish the resolved identity for release-assets (see the job-level + # outputs: block). Reuse the digest the coherence check above already + # verified — never a second probe. release-assets treats image_digest + # as authoritative and skips its own GHCR query. + echo "image_digest=$src_digest" >> "$GITHUB_OUTPUT" + echo "resolved_sha12=$resolved_sha12" >> "$GITHUB_OUTPUT" + publish-runner-image: name: publish-runner-image runs-on: ubuntu-latest diff --git a/tools/release-notes/index.test.ts b/tools/release-notes/index.test.ts index 424cd452..89542a0e 100644 --- a/tools/release-notes/index.test.ts +++ b/tools/release-notes/index.test.ts @@ -15,6 +15,7 @@ import { assemble, classifyImageResult, IMAGE_ABSENT_LINE, + imageFromDigest, type NixOutput, parseArgs, requireImageAtRelease, @@ -137,6 +138,13 @@ describe("parseArgs — the edge's argv contract", () => { expect(args.assets).toEqual(["a", "b"]); }); + test("--image-digest is optional: absent leaves the probe-selecting default", () => { + expect(parseArgs(required).imageDigest).toBe(""); + expect( + parseArgs([...required, "--image-digest", "sha256:beef"]).imageDigest, + ).toBe("sha256:beef"); + }); + test("a missing required flag throws", () => { expect(() => parseArgs(["--sha", "abc", "--version", "0.1.0+gabc"]), @@ -223,3 +231,17 @@ describe("requireImageAtRelease — a null image is a release-time failure, a dr expect(requireImageAtRelease(image, true)).toBeNull(); }); }); + +describe("imageFromDigest — the caller-supplied digest bypasses the skopeo probe", () => { + test("a real digest yields the @digest ref and the digest itself", () => { + expect(imageFromDigest("sha256:beef")).toEqual({ + ref: "ghcr.io/rigelbuild/compass-agent@sha256:beef", + digest: "sha256:beef", + }); + }); + + test("an empty or whitespace-only digest is null — the no-flag path", () => { + expect(imageFromDigest("")).toBeNull(); + expect(imageFromDigest(" ")).toBeNull(); + }); +}); diff --git a/tools/release-notes/index.ts b/tools/release-notes/index.ts index 597e20f8..a51ed90c 100644 --- a/tools/release-notes/index.ts +++ b/tools/release-notes/index.ts @@ -148,6 +148,7 @@ type Args = { bodyOut: string; manifestOut: string; dryRun: boolean; + imageDigest: string; }; /** Parse argv into the edge's inputs. Repeated `--asset` accumulates. */ @@ -160,6 +161,7 @@ export function parseArgs(argv: string[]): Args { bodyOut: "RELEASE_BODY.md", manifestOut: "nix-outputs.json", dryRun: false, + imageDigest: "", }; for (let i = 0; i < argv.length; i++) { const flag = argv[i]; @@ -194,6 +196,9 @@ export function parseArgs(argv: string[]): Args { case "--manifest-out": args.manifestOut = value; break; + case "--image-digest": + args.imageDigest = value; + break; default: throw new Error(`release-notes: unknown flag ${flag}`); } @@ -245,6 +250,25 @@ export function classifyImageResult(result: { return { ref: `${IMAGE_REPO}@${digest}`, digest }; } +/** + * Build the image identity from a digest the caller already resolved, skipping + * the skopeo probe. WHY the digest is passed in: the image lane is + * paths-filtered, so a release commit that does not touch the image closure has + * no `:git-` image — probing the release sha asks for a tag that + * was never published. The release-image job walks first-parent ancestors, + * resolves the correct ancestor `:git-` digest, and re-tags it to + * `:vX.Y.Z`; passing that already-verified config digest here is the single + * source of truth and avoids a re-probe race. Pure + exported so it is + * unit-tested. Returns null for an empty/whitespace-only digest (no flag given). + */ +export function imageFromDigest(digest: string): ImageIdentity | null { + const trimmed = digest.trim(); + if (trimmed === "") { + return null; + } + return { ref: `${IMAGE_REPO}@${trimmed}`, digest: trimmed }; +} + /** * At release time a null image is a hard failure, not a degradation: a * published `vX.Y.Z` with no resolvable container image violates the @@ -338,7 +362,14 @@ async function gatherNixOutputs(): Promise { async function main(): Promise { const args = parseArgs(process.argv.slice(2)); - const image = await gatherImage(args.sha); + // The image lane is paths-filtered, so a release commit often has no + // `:git-` image. When release-image passes its already-resolved + // ancestor digest via --image-digest, that is the authoritative identity; + // otherwise (local/dry-run) fall back to probing the sha's own tag. + const image = + args.imageDigest.trim() !== "" + ? imageFromDigest(args.imageDigest) + : await gatherImage(args.sha); const releaseError = requireImageAtRelease(image, args.dryRun); if (releaseError !== null) { throw new Error(releaseError);