Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 25 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<release-sha>` — 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-<release-sha>`, 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" \
Expand Down Expand Up @@ -929,9 +930,10 @@ jobs:
# source digest (§A4 byte-identity). Rather than have release-assets re-probe
# `:git-<release-sha>` — 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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 41 additions & 24 deletions tools/release-notes/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
assemble,
classifyImageResult,
IMAGE_ABSENT_LINE,
imageFromDigest,
imageFromDigests,
type NixOutput,
parseArgs,
requireImageAtRelease,
Expand All @@ -34,7 +34,7 @@ function input(over: Partial<AssembleInput> = {}): 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,
Expand All @@ -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);
});
Expand All @@ -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");
});
});
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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(() =>
Expand All @@ -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",
});
});

Expand All @@ -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", () => {
Expand All @@ -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");
});
});
81 changes: 48 additions & 33 deletions tools/release-notes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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("");

Expand Down Expand Up @@ -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. */
Expand All @@ -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];
Expand Down Expand Up @@ -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}`);
Expand All @@ -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;
Expand All @@ -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-<release-sha>` 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-<sha>` 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-<release-sha>` image. The release-image job walks first-parent
* ancestors, re-tags the right `:git-<sha>` 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 };
}

/**
Expand All @@ -289,7 +306,7 @@ export function requireImageAtRelease(
}

/**
* Query GHCR for the image config digest at :git-<sha12>, exactly as
* Query GHCR for the image manifest at :git-<sha12>, 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.
Expand Down Expand Up @@ -363,13 +380,11 @@ async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));

// The image lane is paths-filtered, so a release commit often has no
// `:git-<release-sha>` 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-<release-sha>` 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);
Expand Down
Loading