diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 788b24577..37fc5416e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -630,7 +630,7 @@ 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. - # release-image is a needs edge so its resolved image_digest output reaches + # release-image is a needs edge so its resolved image digests reach # 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 @@ -801,21 +801,22 @@ 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. + # The image digests are the ones release-image already resolved and + # verified. They are authoritative: the generator skips its own probe of + # `:git-`, a tag the paths-filtered image lane may never have + # published. The ref pins the manifest digest; the config digest is not + # pullable. Routed through env to keep context values off the command line. env: - IMAGE_DIGEST: ${{ needs.release-image.outputs.image_digest }} + IMAGE_MANIFEST_DIGEST: ${{ needs.release-image.outputs.image_manifest_digest }} + IMAGE_CONFIG_DIGEST: ${{ needs.release-image.outputs.image_config_digest }} run: | set -euo pipefail bun run tools/release-notes/index.ts \ --sha "$SHA12" \ --version "$VERSION" \ --tag "$TAG" \ - --image-digest "$IMAGE_DIGEST" \ + --image-manifest-digest "$IMAGE_MANIFEST_DIGEST" \ + --image-config-digest "$IMAGE_CONFIG_DIGEST" \ --asset "compass_${TAG}_linux-amd64" \ --asset "compass-server_${TAG}_linux-amd64" \ --asset "compass-runner_${TAG}_linux-amd64" \ @@ -929,9 +930,10 @@ jobs: # 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. + # of truth. release-assets reads the two digests and skips its own probe. outputs: - image_digest: ${{ steps.retag.outputs.image_digest }} + image_manifest_digest: ${{ steps.retag.outputs.image_manifest_digest }} + image_config_digest: ${{ steps.retag.outputs.image_config_digest }} resolved_sha12: ${{ steps.retag.outputs.resolved_sha12 }} steps: # Full history: the resolver walks first-parent ancestors of the release @@ -1091,11 +1093,18 @@ jobs: 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" + # outputs: block). The pullable ref needs the :vX.Y.Z MANIFEST digest; + # the config digest is only the coherence value checked above. + tag_manifest="$(skopeo inspect --format '{{.Digest}}' --authfile "$REGISTRY_AUTH_FILE" "$ref:$tag")" + case "$tag_manifest" in + sha256:*) ;; + *) echo "::error::$tag manifest digest unreadable: $tag_manifest" >&2; exit 1 ;; + esac + { + echo "image_manifest_digest=$tag_manifest" + echo "image_config_digest=$src_digest" + echo "resolved_sha12=$resolved_sha12" + } >> "$GITHUB_OUTPUT" publish-runner-image: name: publish-runner-image diff --git a/docs/designs/infra/release/compass-release-bundling/design.md b/docs/designs/infra/release/compass-release-bundling/design.md index e26860584..3b4a05a9a 100644 --- a/docs/designs/infra/release/compass-release-bundling/design.md +++ b/docs/designs/infra/release/compass-release-bundling/design.md @@ -165,8 +165,9 @@ and the publish enforces tag immutability + digest coherence copy dir:` tarball would duplicate a multi-GB closure GitHub caps and GHCR already serves, and would create a *second* image identity to keep coherent. Instead the Release **body** records `ghcr.io/rigelbuild/compass-agent@sha256:…` -(the config digest the publish verify step already computes, -`publish-agent-image.yml:173`) — a durable pointer to an immutable artifact. +(the manifest digest of the `:vX.Y.Z` tag, listed next to the config digest +the publish verify step already computes) — a durable pointer to an immutable +artifact. *Tradeoff:* if GHCR retention ever deletes the package, the Release's image pointer dangles; accepted because the package is public + repo-linked and nothing in the fleet deletes it, and a tarball's cost is paid on every release diff --git a/tools/release-notes/index.test.ts b/tools/release-notes/index.test.ts index 89542a0ed..616168de6 100644 --- a/tools/release-notes/index.test.ts +++ b/tools/release-notes/index.test.ts @@ -15,7 +15,7 @@ import { assemble, classifyImageResult, IMAGE_ABSENT_LINE, - imageFromDigest, + imageFromDigests, type NixOutput, parseArgs, requireImageAtRelease, @@ -34,7 +34,7 @@ function input(over: Partial = {}): AssembleInput { assets: ["compass_build-0123456789ab_linux-amd64", "SHA256SUMS"], image: { ref: "ghcr.io/rigelbuild/compass-agent@sha256:dead", - digest: "sha256:dead", + configDigest: "sha256:c0f1", }, nixOutputs: OUTPUTS, ...over, @@ -47,7 +47,7 @@ describe("image-present body — carries the ref and digest", () => { expect(body).toContain( "image: `ghcr.io/rigelbuild/compass-agent@sha256:dead`", ); - expect(body).toContain("digest: `sha256:dead`"); + expect(body).toContain("config digest: `sha256:c0f1`"); // The absence line must NOT appear when the image is present. expect(body).not.toContain(IMAGE_ABSENT_LINE); }); @@ -58,7 +58,7 @@ describe("image-absent degradation — a null image is a recorded absence, not a const { body } = assemble(input({ image: null })); expect(body).toContain(IMAGE_ABSENT_LINE); // No dangling digest/ref lines leak through. - expect(body).not.toContain("digest: `"); + expect(body).not.toContain("config digest: `"); expect(body).not.toContain("image: `ghcr.io"); }); }); @@ -138,11 +138,19 @@ 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("the image digests are optional: absent leaves the probe-selecting default", () => { + const none = parseArgs(required); + expect(none.imageManifestDigest).toBe(""); + expect(none.imageConfigDigest).toBe(""); + const both = parseArgs([ + ...required, + "--image-manifest-digest", + "sha256:dead", + "--image-config-digest", + "sha256:beef", + ]); + expect(both.imageManifestDigest).toBe("sha256:dead"); + expect(both.imageConfigDigest).toBe("sha256:beef"); }); test("a missing required flag throws", () => { @@ -163,7 +171,11 @@ describe("parseArgs — the edge's argv contract", () => { }); describe("classifyImageResult — the skopeo-result contract (crux of the skopeo fix)", () => { - const digestJson = JSON.stringify({ config: { digest: "sha256:beef" } }); + // Non-canonical bytes JSON.stringify would not reproduce: the ref must be + // the sha256 of exactly what skopeo printed. + const manifestRaw = '{"config": {"digest": "sha256:beef"}}\n'; + const manifestDigest = + "sha256:0575d6de78ab5aa1b4a857e96b1e17d344fc18507e0f61eca24095d71e2222af"; test("exit 127 THROWS — a missing skopeo can never masquerade as an absent image", () => { expect(() => @@ -185,12 +197,12 @@ describe("classifyImageResult — the skopeo-result contract (crux of the skopeo ).toBeNull(); }); - test("exit 0 with a config digest yields the @digest ref and the digest", () => { + test("exit 0 pins the ref to the manifest digest, never the config digest", () => { expect( - classifyImageResult({ exitCode: 0, stdout: digestJson, stderr: "" }), + classifyImageResult({ exitCode: 0, stdout: manifestRaw, stderr: "" }), ).toEqual({ - ref: "ghcr.io/rigelbuild/compass-agent@sha256:beef", - digest: "sha256:beef", + ref: `ghcr.io/rigelbuild/compass-agent@${manifestDigest}`, + configDigest: "sha256:beef", }); }); @@ -209,8 +221,8 @@ describe("classifyImageResult — the skopeo-result contract (crux of the skopeo describe("requireImageAtRelease — a null image is a release-time failure, a dry-run degradation", () => { const image = { - ref: "ghcr.io/rigelbuild/compass-agent@sha256:beef", - digest: "sha256:beef", + ref: "ghcr.io/rigelbuild/compass-agent@sha256:dead", + configDigest: "sha256:beef", }; test("release + null image FAILS — returns the no-image error message", () => { @@ -232,16 +244,21 @@ describe("requireImageAtRelease — a null image is a release-time failure, a dr }); }); -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", +describe("imageFromDigests — the caller-supplied digests bypass the skopeo probe", () => { + test("the ref pins the manifest digest; the config digest is carried separately", () => { + expect(imageFromDigests("sha256:dead", "sha256:beef")).toEqual({ + ref: "ghcr.io/rigelbuild/compass-agent@sha256:dead", + configDigest: "sha256:beef", }); }); - test("an empty or whitespace-only digest is null — the no-flag path", () => { - expect(imageFromDigest("")).toBeNull(); - expect(imageFromDigest(" ")).toBeNull(); + test("both digests empty is null — the no-flag path", () => { + expect(imageFromDigests("", "")).toBeNull(); + expect(imageFromDigests(" ", " ")).toBeNull(); + }); + + test("only one digest given throws — a half identity cannot be published", () => { + expect(() => imageFromDigests("", "sha256:beef")).toThrow("both"); + expect(() => imageFromDigests("sha256:dead", "")).toThrow("both"); }); }); diff --git a/tools/release-notes/index.ts b/tools/release-notes/index.ts index a51ed90c5..6c7818142 100644 --- a/tools/release-notes/index.ts +++ b/tools/release-notes/index.ts @@ -31,10 +31,10 @@ export type NixOutput = { /** The GHCR image identity for the sha, or null when not yet published. */ export type ImageIdentity = { - /** the pullable ref by digest, e.g. "ghcr.io/rigelbuild/compass-agent@sha256:…" */ + /** the pullable ref, pinned to the MANIFEST digest: "ghcr.io/rigelbuild/compass-agent@sha256:…" */ ref: string; - /** the config digest, e.g. "sha256:…" */ - digest: string; + /** the config blob digest; not pullable, kept for the re-tag coherence trail */ + configDigest: string; }; /** The input the pure core receives. */ @@ -95,7 +95,7 @@ export function assemble(input: AssembleInput): AssembleOutput { lines.push(IMAGE_ABSENT_LINE); } else { lines.push(`image: \`${input.image.ref}\``); - lines.push(`digest: \`${input.image.digest}\``); + lines.push(`config digest: \`${input.image.configDigest}\``); } lines.push(""); @@ -148,7 +148,8 @@ type Args = { bodyOut: string; manifestOut: string; dryRun: boolean; - imageDigest: string; + imageManifestDigest: string; + imageConfigDigest: string; }; /** Parse argv into the edge's inputs. Repeated `--asset` accumulates. */ @@ -161,7 +162,8 @@ export function parseArgs(argv: string[]): Args { bodyOut: "RELEASE_BODY.md", manifestOut: "nix-outputs.json", dryRun: false, - imageDigest: "", + imageManifestDigest: "", + imageConfigDigest: "", }; for (let i = 0; i < argv.length; i++) { const flag = argv[i]; @@ -196,8 +198,11 @@ export function parseArgs(argv: string[]): Args { case "--manifest-out": args.manifestOut = value; break; - case "--image-digest": - args.imageDigest = value; + case "--image-manifest-digest": + args.imageManifestDigest = value; + break; + case "--image-config-digest": + args.imageConfigDigest = value; break; default: throw new Error(`release-notes: unknown flag ${flag}`); @@ -220,7 +225,8 @@ export function parseArgs(argv: string[]): Args { * transport error DEGRADES (the image lane is paths-filtered independently, * and a re-run converges the pointer once the image publishes). * - exit 0 but unparseable output or no `.config.digest` => null. - * - exit 0 with a digest => the pullable @digest ref + the digest. + * - exit 0 with a digest => a ref pinned to the sha256 of the raw manifest + * bytes (pullable), plus the config digest carried on its own. */ export function classifyImageResult(result: { exitCode: number; @@ -235,38 +241,49 @@ export function classifyImageResult(result: { if (result.exitCode !== 0) { return null; } - let digest: string; + let configDigest: string; try { const raw = JSON.parse(result.stdout) as { config?: { digest?: string }; }; - digest = raw.config?.digest ?? ""; + configDigest = raw.config?.digest ?? ""; } catch { return null; } - if (digest === "") { + if (configDigest === "") { return null; } - return { ref: `${IMAGE_REPO}@${digest}`, digest }; + // A registry addresses a manifest by the sha256 of its exact raw bytes. + const manifest = new Bun.CryptoHasher("sha256") + .update(result.stdout) + .digest("hex"); + return { ref: `${IMAGE_REPO}@sha256:${manifest}`, configDigest }; } /** - * 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). + * Build the image identity from digests the caller already resolved, skipping + * the skopeo probe. WHY they are passed in: the image lane is paths-filtered, + * so a release commit that does not touch the image closure has no + * `:git-` image. The release-image job walks first-parent + * ancestors, re-tags the right `:git-` to `:vX.Y.Z`, and verifies it, so + * its digests are the source of truth. The ref pins the manifest digest; a + * config digest is not pullable. Returns null when neither flag was given. */ -export function imageFromDigest(digest: string): ImageIdentity | null { - const trimmed = digest.trim(); - if (trimmed === "") { +export function imageFromDigests( + manifestDigest: string, + configDigest: string, +): ImageIdentity | null { + const manifest = manifestDigest.trim(); + const config = configDigest.trim(); + if (manifest === "" && config === "") { return null; } - return { ref: `${IMAGE_REPO}@${trimmed}`, digest: trimmed }; + if (manifest === "" || config === "") { + throw new Error( + "release-notes: --image-manifest-digest and --image-config-digest must be given together (both or neither)", + ); + } + return { ref: `${IMAGE_REPO}@${manifest}`, configDigest: config }; } /** @@ -289,7 +306,7 @@ export function requireImageAtRelease( } /** - * Query GHCR for the image config digest at :git-, exactly as + * Query GHCR for the image manifest at :git-, exactly as * release.yml's publish-image verify does (`skopeo inspect --raw … | jq -r * .config.digest`). Returns null when the tag is not published — the image lane * is paths-filtered independently, so a go-only push has no image for its sha. @@ -363,13 +380,11 @@ async function main(): Promise { const args = parseArgs(process.argv.slice(2)); // 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. + // `:git-` image. When release-image passes its resolved digests, + // they are authoritative; otherwise (local/dry-run) probe the sha's own tag. const image = - args.imageDigest.trim() !== "" - ? imageFromDigest(args.imageDigest) - : await gatherImage(args.sha); + imageFromDigests(args.imageManifestDigest, args.imageConfigDigest) ?? + (await gatherImage(args.sha)); const releaseError = requireImageAtRelease(image, args.dryRun); if (releaseError !== null) { throw new Error(releaseError);