Skip to content
Merged
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
37 changes: 36 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<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.
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" \
Expand Down Expand Up @@ -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-<release-sha>` 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-<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.
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.
Expand Down Expand Up @@ -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 -- <paths>` resolver was rejected (§A4:159-164) because the
# per-push lane keys its tag on the PUSH HEAD sha, not the last
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions tools/release-notes/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
assemble,
classifyImageResult,
IMAGE_ABSENT_LINE,
imageFromDigest,
type NixOutput,
parseArgs,
requireImageAtRelease,
Expand Down Expand Up @@ -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"]),
Expand Down Expand Up @@ -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();
});
});
33 changes: 32 additions & 1 deletion tools/release-notes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ type Args = {
bodyOut: string;
manifestOut: string;
dryRun: boolean;
imageDigest: string;
};

/** Parse argv into the edge's inputs. Repeated `--asset` accumulates. */
Expand All @@ -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];
Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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-<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).
*/
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
Expand Down Expand Up @@ -338,7 +362,14 @@ async function gatherNixOutputs(): Promise<NixOutput[]> {
async function main(): Promise<void> {
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-<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.
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);
Expand Down
Loading