diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 845ddbb37..eb765ec6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -317,3 +317,394 @@ jobs: exit 1 fi echo "verified: $ref:git-$sha12 resolves, linux/amd64, coherent with :latest" + + release-assets: + name: release-assets + runs-on: ubuntu-latest + # Build the four released binaries at the release sha and attach them — plus + # SHA256SUMS and the nix-outputs manifest — to the GitHub Release + # release-please already created for this tag. Runs ONLY when the merged + # Release PR actually cut a release. The main-ref guard is kept on EVERY + # 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 + 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 + # lane never writes a registry (Global Constraint 7). + permissions: + contents: write + # The nix toolchain resolve is the cost that sizes this timeout — the same + # ceiling ci.yml and publish-image use. + timeout-minutes: 90 + steps: + # Check out the release sha — the Release PR's merge commit release-please + # tagged. Default depth: the assets build from that tree, and the + # release-notes digest pointer resolves from `git rev-parse HEAD`. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release-pr.outputs.sha }} + + - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 + with: + # nix-command + flakes for the RigelBuild forks' flakes. The two caches + # are declared HERE, not delegated via `accept-flake-config` — that + # setting makes nix trust the `nixConfig` of ANY flake it evaluates + # (the RigelBuild/devenv flake carries such a block), so a PR could add its + # own substituter AND trusted key and have CI run attacker-signed + # binaries. Naming the caches in this reviewed file keeps that trust + # reviewed. + extra_nix_config: | + experimental-features = nix-command flakes + extra-substituters = https://devenv.cachix.org https://cachix.cachix.org + extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= cachix.cachix.org-1:eWNHQldwUO7G2VkjpnjDbWwy4KQ/HNxht7H4SSoMckM= + + - name: Put the language toolchains on PATH + # The pinned toolchain comes from nix, never `setup-go` (Global + # Constraint 8): gate-tools.nix's `langs` output resolves the identical + # derivations the dev shell does — go from the go-overlay applied to the + # devenv.lock-pinned nixpkgs — so this build runs the pinned go + # byte-for-byte. Copied verbatim from ci.yml:207-220 (phase one). + run: | + stores=$(nix eval --json -f tools/toolchain/gate-tools.nix langs \ + | jq -r '.[].store') + # Fail closed locally rather than leaning on the absence of a + # root-level flake.nix: with no installables `nix build` would build a + # default package if one existed, so an empty `langs` must error here. + [ -n "$stores" ] || { + echo "::error::gate-tools.nix langs produced no store paths" + exit 1 + } + nix build --no-link $stores + for store in $stores; do + echo "$store/bin" >>"$GITHUB_PATH" + done + + - name: Put the fork's patched skopeo on PATH + # The release-notes generator queries GHCR for the image config digest + # with a plain `skopeo` (the RigelBuild/nix2container fork's patched + # build). The langs bootstrap above carries only go/bun/node/moon, so + # skopeo must be provisioned here or the digest query — a core T2 + # deliverable (Fork 2(ii)) — silently records the image absent on every + # release. Resolve it from the shared pinned helper + # tools/toolchain/skopeo-nix2container-env.nix and prepend its bin/, the + # same out-of-band `nix build` pattern publish-agent-image.yml:117-148 + # uses. The image is public (Matt-ruled), so reading the digest needs no + # `skopeo login` / packages:read — this lane stays contents:write-only. + working-directory: . + run: | + set -euo pipefail + # `--print-out-paths` prints every output (skopeo ships a `-man` output + # too); take the one carrying bin/skopeo, not a fixed line. + skopeo_bin="" + for store in $(nix build --no-link --print-out-paths \ + -f tools/toolchain/skopeo-nix2container-env.nix skopeo); do + if [ -x "$store/bin/skopeo" ]; then + skopeo_bin="$store/bin" + break + fi + done + if [ -z "$skopeo_bin" ]; then + echo "::error::skopeo-nix2container-env.nix produced no output carrying bin/skopeo" >&2 + exit 1 + fi + echo "$skopeo_bin" >> "$GITHUB_PATH" + + - name: Build the release binaries + # The release version is the semver release-please cut, sourced from + # version.txt (Global Constraint 1: ONE version string across all + # binaries AND the image tag). Asset names carry the semver tag. #711's + # build step, re-based from the `build-` prerelease shape to the + # semver `vX.Y.Z` tag. -trimpath + CGO_ENABLED=0: the three daemons/CLI + # are pure Go (cgo is needed only by the pgtest suites, not any build + # here), and the darwin-arm64 CLI cross-compiles cleanly from this ubuntu + # runner. + env: + TAG_NAME: ${{ needs.release-pr.outputs.tag_name }} + run: | + set -euo pipefail + sha12="$(git rev-parse --short=12 HEAD)" + version="$(cat version.txt)" + tag="$TAG_NAME" + echo "SHA12=$sha12" >>"$GITHUB_ENV" + echo "VERSION=$version" >>"$GITHUB_ENV" + echo "TAG=$tag" >>"$GITHUB_ENV" + + ldflags="-X main.version=$version" + + # Three linux-amd64 binaries (the deployable daemons + the CLI). + for name in compass compass-server compass-runner; do + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go -C go build -trimpath -ldflags "$ldflags" \ + -o "../${name}_${tag}_linux-amd64" "./cmd/${name}" + done + + # The CLI cross-built for darwin-arm64 (Matt's dev machines run the CLI + # against remote stacks; the daemons deploy on Linux only — Fork 2(i)). + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \ + go -C go build -trimpath -ldflags "$ldflags" \ + -o "../compass_${tag}_darwin-arm64" ./cmd/compass + + - name: Generate SHA256SUMS over the built assets + run: | + set -euo pipefail + sha256sum \ + "compass_${TAG}_linux-amd64" \ + "compass-server_${TAG}_linux-amd64" \ + "compass-runner_${TAG}_linux-amd64" \ + "compass_${TAG}_darwin-arm64" \ + > SHA256SUMS + + - name: Generate the release body + nix-outputs manifest + # The T2 generator (a bun/TS tool with a pure, unit-tested core). It + # queries GHCR for the image digest at :git- (DEGRADING to a + # 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. + run: | + set -euo pipefail + bun run tools/release-notes/index.ts \ + --sha "$SHA12" \ + --version "$VERSION" \ + --tag "$TAG" \ + --asset "compass_${TAG}_linux-amd64" \ + --asset "compass-server_${TAG}_linux-amd64" \ + --asset "compass-runner_${TAG}_linux-amd64" \ + --asset "compass_${TAG}_darwin-arm64" \ + --asset "SHA256SUMS" \ + --body-out RELEASE_BODY.md \ + --manifest-out nix-outputs.json + + - name: Upload assets and append the appendix to the Release notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The Release already EXISTS: release-please cut it (tag + generated + # changelog notes) on the merge commit. This lane never creates it — it + # attaches the built assets with --clobber (idempotent on re-run) and + # APPENDS the generator's binaries/image/nix-outputs appendix BELOW + # release-please's generated notes, preserving the changelog rather than + # overwriting it. Not `--prerelease`: this is a real release. + run: | + set -euo pipefail + assets=( + "compass_${TAG}_linux-amd64" + "compass-server_${TAG}_linux-amd64" + "compass-runner_${TAG}_linux-amd64" + "compass_${TAG}_darwin-arm64" + SHA256SUMS + # nix-outputs.json (below) is the generator's machine-readable + # manifest — regenerable output attached for consumers, deliberately + # not in SHA256SUMS, which checksums the downloadable binaries. + nix-outputs.json + ) + gh release upload "$TAG" --clobber "${assets[@]}" + + # Append the generator appendix below release-please's changelog, + # preserving the changelog rather than replacing it. Idempotent on + # re-run: strip any prior appendix (the sentinel comment onward, which + # includes the separator that follows it) and any CR from GitHub's + # CRLF-normalized body, then capture via `$(...)` so all trailing + # newlines collapse, and reconstruct with a fixed separator. Repeated + # "Re-run all jobs" runs produce byte-identical notes — not a stack of + # duplicate appendices, nor a single appendix atop a growing blank-line + # gap (the CR strip is what makes the fixpoint hold against the live + # API, which returns the body CRLF-terminated). The release-image + # remediation invites a re-run, so this path is real. + notes="$RUNNER_TEMP/release-notes.md" + changelog="$(gh release view "$TAG" --json body -q .body \ + | sed '//,$d' \ + | sed 's/\r$//')" + { + printf '%s\n\n\n\n---\n\n' "$changelog" + cat RELEASE_BODY.md + } > "$notes" + gh release edit "$TAG" --notes-file "$notes" + + release-image: + name: release-image + runs-on: ubuntu-latest + # Digest-re-tag the already-published per-push `:git-` image to the + # semver `:vX.Y.Z`. NEVER a second build (the release sha's image content is + # byte-identical to the last closure-affecting sha's — §A4), NEVER touches + # `:latest` (owned exclusively by publish-image). Runs ONLY when the merged + # Release PR actually cut a release. + needs: release-pr + # Least privilege: read the tree, write the GHCR package, nothing else + # (Global Constraint 7). + permissions: + contents: read + packages: write + # The SAME group as publish-image: a non-superseding `:vX.Y.Z` release mint + # must serialize behind an in-flight `:latest` move WITHOUT being cancelled + # by a later per-push entrant claiming the single default pending slot. + # `queue: max` (the bare literal token — no numeric form; up to 100 pending) + # rather than the default single pending slot preserves the §A4 no-drop + # invariant; bare `cancel-in-progress: false` alone would drop it. + # Within a single release run this also queues behind that run's own + # publish-image job (same group) — intended serialization latency, not a + # hang; neither job `needs` the other. + concurrency: + group: publish-agent-image + cancel-in-progress: false + queue: max + # workflow_dispatch runs on any branch; guard so a dispatch from a feature + # branch can never mint a `:vX.Y.Z` for unmerged code. Main pushes satisfy + # this trivially (§A5(2), mirrors publish-image). + if: github.ref == 'refs/heads/main' && needs.release-pr.outputs.releases_created == 'true' + timeout-minutes: 90 + steps: + # Full history: the resolver walks first-parent ancestors of the release + # sha (§A4/Plan T3), so a shallow checkout would truncate the walk. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release-pr.outputs.sha }} + fetch-depth: 0 + + - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 + with: + # nix-command + flakes for the RigelBuild forks' flakes. The two caches + # are declared HERE, not delegated via `accept-flake-config` — that + # setting makes nix trust the `nixConfig` of ANY flake it evaluates + # (the RigelBuild/devenv flake carries such a block), so a PR could add its + # own substituter AND trusted key and have CI run attacker-signed + # binaries. Naming the caches in this reviewed file keeps that trust + # reviewed. + extra_nix_config: | + experimental-features = nix-command flakes + extra-substituters = https://devenv.cachix.org https://cachix.cachix.org + extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= cachix.cachix.org-1:eWNHQldwUO7G2VkjpnjDbWwy4KQ/HNxht7H4SSoMckM= + + - name: Put the fork's patched skopeo on PATH + # The resolver probes and copies with a plain `skopeo` (the + # RigelBuild/nix2container fork's patched build). Resolve it from the + # shared pinned helper, tools/toolchain/skopeo-nix2container-env.nix, and + # prepend its bin/ to PATH — the same out-of-band `nix build` pattern + # publish-agent-image.yml:117-148 uses. + working-directory: . + run: | + set -euo pipefail + # `--print-out-paths` prints every output (skopeo ships a `-man` output + # too); take the one carrying bin/skopeo, not a fixed line. + skopeo_bin="" + for store in $(nix build --no-link --print-out-paths \ + -f tools/toolchain/skopeo-nix2container-env.nix skopeo); do + if [ -x "$store/bin/skopeo" ]; then + skopeo_bin="$store/bin" + break + fi + done + if [ -z "$skopeo_bin" ]; then + echo "::error::skopeo-nix2container-env.nix produced no output carrying bin/skopeo" >&2 + exit 1 + fi + echo "$skopeo_bin" >> "$GITHUB_PATH" + + - name: Pin the registry auth file + # LOAD-BEARING. `skopeo login` and the resolver's `skopeo copy` run as + # SEPARATE processes and must resolve the SAME creds file. The default + # location ($XDG_RUNTIME_DIR/containers/auth.json) is + # environment-dependent on GitHub-hosted runners — a mismatch greens the + # login step and then 401s the copy. Export an explicit path both honor. + run: echo "REGISTRY_AUTH_FILE=$RUNNER_TEMP/ghcr-auth.json" >> "$GITHUB_ENV" + + - name: Log in to GHCR + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Pass the actor through env rather than interpolating ${{ }} into the + # shell — behavior-identical here (GitHub usernames carry no shell + # metacharacters), but keeps context values off the run: command line. + ACTOR: ${{ github.actor }} + # This job WRITES the registry (`skopeo copy` push to :vX.Y.Z), so it + # needs auth (packages:write authorizes it). The token is passed via env + # and `--password-stdin` only — never on a command line or in a log. + run: | + skopeo \ + login ghcr.io -u "$ACTOR" --password-stdin \ + --authfile "$REGISTRY_AUTH_FILE" <<< "$GITHUB_TOKEN" + + - name: Digest-re-tag the newest ancestor image to the semver tag + # 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 + # closure-touching commit, so a path resolver could assert a tag that was + # never published. Walk first-parent ancestors of the release sha + # newest-first and take the FIRST whose :git- resolves on GHCR: + # that tag's tree provably contains every closure change at-or-before it, + # so its image is byte-identical to the release sha's content. Copy that + # digest manifest to :vX.Y.Z (a registry-side write, no build), then + # verify coherence. Never touches :latest; never builds. + env: + TAG_NAME: ${{ needs.release-pr.outputs.tag_name }} + RELEASE_SHA: ${{ needs.release-pr.outputs.sha }} + run: | + set -euo pipefail + ref="docker://ghcr.io/rigelbuild/compass-agent" + tag="$TAG_NAME" + release_sha="$RELEASE_SHA" + + # Bound the walk so a systemic publish outage fails fast with the + # remediation pointer rather than probing unboundedly. 50 first-parent + # ancestors comfortably spans any realistic gap between a release sha + # and the last closure-affecting publish. + max_walk=50 + + resolved_sha12="" + count=0 + while IFS= read -r ancestor; do + [ -n "$ancestor" ] || continue + count=$((count + 1)) + if [ "$count" -gt "$max_walk" ]; then + break + fi + sha12="$(git rev-parse --short=12 "$ancestor")" + # Distinguish a definitive not-found (walk on to the next ancestor) + # from a transient/ambiguous error (GHCR 5xx, throttle, auth blip). + # Swallowing the latter as "absent" would silently fall through to + # an OLDER published ancestor and re-tag a STALE image under + # :vX.Y.Z, breaking the §A4 newest-first byte-identity invariant + # (the final config-digest verify cannot catch that — it proves the + # copy landed, not that the right source was chosen). Hard-fail on + # ambiguity rather than retry (no-retries repo law). + if probe="$(skopeo inspect --authfile "$REGISTRY_AUTH_FILE" \ + "$ref:git-$sha12" 2>&1)"; then + resolved_sha12="$sha12" + echo "resolved source image at ancestor $sha12 (walk position $count)" + break + fi + # A definitive absent manifest surfaces as "manifest unknown" or an + # HTTP "404"; the broad "not found" glob is a deliberate belt — safe + # here because this job runs after a successful login against a + # PUBLIC image, so a 401/403 auth message is not in play, and + # skopeo's transport failures phrase differently ("no such host", + # "503", "i/o timeout") and fall to the hard-fail arm. Erring toward + # continue-the-walk on this set (vs a stale re-tag) is the safe bias. + case "$probe" in + *"manifest unknown"* | *"not found"* | *"404"*) ;; + *) + echo "::error::ambiguous skopeo error probing :git-$sha12 (not a definitive 404); refusing to fall through to an older ancestor: $probe" >&2 + exit 1 + ;; + esac + done < <(git rev-list --first-parent "$release_sha") + + if [ -z "$resolved_sha12" ]; then + echo "::error::no ancestor image :git- resolved on GHCR within $max_walk first-parent ancestors of $release_sha. Remediation: workflow_dispatch the release workflow (publish-image) on the release sha to publish the per-push image, then re-run this release. NEVER rebuild here." >&2 + exit 1 + fi + + # Registry-side manifest write: copy the resolved source tag's + # digest-resolved manifest to :vX.Y.Z. No nix build at all. + skopeo copy --authfile "$REGISTRY_AUTH_FILE" \ + "$ref:git-$resolved_sha12" "$ref:$tag" + + # Verify :vX.Y.Z resolves AND shares the source tag's config digest — + # the same coherence shape as publish-image's two-copy check + # (release.yml:311-318). Hard-fail on mismatch. + src_digest="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:git-$resolved_sha12" | jq -r .config.digest)" + tag_digest="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:$tag" | jq -r .config.digest)" + if [ "$src_digest" != "$tag_digest" ]; then + echo "re-tag incoherent: :git-$resolved_sha12=$src_digest != :$tag=$tag_digest" >&2 + exit 1 + fi + echo "verified: $ref:$tag re-tagged from :git-$resolved_sha12, config digests coherent" diff --git a/bun.lock b/bun.lock index 7f4310773..93969b2fa 100644 --- a/bun.lock +++ b/bun.lock @@ -179,6 +179,16 @@ "typescript": "catalog:", }, }, + "tools/release-notes": { + "name": "@compass/release-notes", + "bin": { + "release-notes": "./index.ts", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "tools/renovate": { "name": "@compass/renovate", "devDependencies": { @@ -437,6 +447,8 @@ "@compass/orion-ref-gate": ["@compass/orion-ref-gate@workspace:tools/orion-ref-gate"], + "@compass/release-notes": ["@compass/release-notes@workspace:tools/release-notes"], + "@compass/renovate": ["@compass/renovate@workspace:tools/renovate"], "@compass/renovate-preflight": ["@compass/renovate-preflight@workspace:tools/renovate-preflight"], diff --git a/tools/release-notes/biome.json b/tools/release-notes/biome.json new file mode 100644 index 000000000..99b4ab8f0 --- /dev/null +++ b/tools/release-notes/biome.json @@ -0,0 +1,3 @@ +{ + "extends": "//" +} diff --git a/tools/release-notes/index.test.ts b/tools/release-notes/index.test.ts new file mode 100644 index 000000000..73f9d0200 --- /dev/null +++ b/tools/release-notes/index.test.ts @@ -0,0 +1,199 @@ +// Unit tests for the release-notes pure core (index.ts). +// +// These defend the generator's contract (design record §Plan T2, Fork 2): +// the image-present body carries the ref + digest; the image-ABSENT case +// DEGRADES to the recorded-absence line rather than failing; the nix-outputs +// manifest echoes the sha/version/tag and carries every output verbatim; and +// the one build version string is echoed into the body. +// +// Only the PURE core is exercised — the edge (skopeo / nix / file writes) is +// import.meta.main-guarded, so importing index.ts never runs it. No network. + +import { describe, expect, test } from "bun:test"; +import { + type AssembleInput, + assemble, + classifyImageResult, + IMAGE_ABSENT_LINE, + type NixOutput, + parseArgs, +} from "./index.ts"; + +const OUTPUTS: NixOutput[] = [ + { name: "bun", path: "/nix/store/aaa-bun", narHash: "sha256-bun" }, + { name: "go", path: "/nix/store/bbb-go", narHash: "sha256-go" }, +]; + +function input(over: Partial = {}): AssembleInput { + return { + sha: "0123456789ab", + version: "0.1.0+g0123456789ab", + tag: "build-0123456789ab", + assets: ["compass_build-0123456789ab_linux-amd64", "SHA256SUMS"], + image: { + ref: "ghcr.io/rigelbuild/compass-agent@sha256:dead", + digest: "sha256:dead", + }, + nixOutputs: OUTPUTS, + ...over, + }; +} + +describe("image-present body — carries the ref and digest", () => { + test("both the pullable ref and config digest appear in the body", () => { + const { body } = assemble(input()); + expect(body).toContain( + "image: `ghcr.io/rigelbuild/compass-agent@sha256:dead`", + ); + expect(body).toContain("digest: `sha256:dead`"); + // The absence line must NOT appear when the image is present. + expect(body).not.toContain(IMAGE_ABSENT_LINE); + }); +}); + +describe("image-absent degradation — a null image is a recorded absence, not a failure", () => { + test("a null image emits the absence line and does not throw", () => { + 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("image: `ghcr.io"); + }); +}); + +describe("manifest assembly — echoes identity and carries every output", () => { + test("the manifest mirrors the sha/version/tag and the full output list", () => { + const { manifest } = assemble(input()); + expect(manifest).toEqual({ + sha: "0123456789ab", + version: "0.1.0+g0123456789ab", + tag: "build-0123456789ab", + outputs: OUTPUTS, + }); + }); + + test("the manifest is unaffected by whether the image is present", () => { + const withImage = assemble(input()).manifest; + const withoutImage = assemble(input({ image: null })).manifest; + expect(withoutImage).toEqual(withImage); + }); + + test("an output with an unknown narHash renders `(unknown)` in the body", () => { + const { body } = assemble( + input({ + nixOutputs: [{ name: "go", path: "/nix/store/bbb-go", narHash: null }], + }), + ); + expect(body).toContain("- `go`: `/nix/store/bbb-go` ((unknown))"); + }); + + test("no nix outputs still yields a body and an empty output list", () => { + const { body, manifest } = assemble(input({ nixOutputs: [] })); + expect(body).toContain("(none recorded)"); + expect(manifest.outputs).toEqual([]); + }); +}); + +describe("version-stamp echo — the one build version string appears in the body", () => { + test("the body carries the version and the assets", () => { + const { body } = assemble(input()); + expect(body).toContain("Version: `0.1.0+g0123456789ab`"); + expect(body).toContain("Commit: `0123456789ab`"); + expect(body).toContain("- `compass_build-0123456789ab_linux-amd64`"); + expect(body).toContain("- `SHA256SUMS`"); + }); +}); + +describe("parseArgs — the edge's argv contract", () => { + const required = [ + "--sha", + "abc", + "--version", + "0.1.0+gabc", + "--tag", + "build-abc", + ]; + + test("all three required flags present parses, defaults fill the rest", () => { + const args = parseArgs(required); + expect(args.sha).toBe("abc"); + expect(args.version).toBe("0.1.0+gabc"); + expect(args.tag).toBe("build-abc"); + expect(args.assets).toEqual([]); + expect(args.bodyOut).toBe("RELEASE_BODY.md"); + expect(args.manifestOut).toBe("nix-outputs.json"); + expect(args.dryRun).toBe(false); + }); + + test("--dry-run is a valueless flag and does not consume the next token", () => { + const args = parseArgs([...required, "--dry-run", "--asset", "a"]); + expect(args.dryRun).toBe(true); + expect(args.assets).toEqual(["a"]); + }); + + test("repeated --asset accumulates in order", () => { + const args = parseArgs([...required, "--asset", "a", "--asset", "b"]); + expect(args.assets).toEqual(["a", "b"]); + }); + + test("a missing required flag throws", () => { + expect(() => + parseArgs(["--sha", "abc", "--version", "0.1.0+gabc"]), + ).toThrow("required"); + }); + + test("an unknown flag throws", () => { + expect(() => parseArgs([...required, "--bogus", "x"])).toThrow( + "unknown flag", + ); + }); + + test("a trailing flag with no value throws", () => { + expect(() => parseArgs([...required, "--asset"])).toThrow("needs a value"); + }); +}); + +describe("classifyImageResult — the skopeo-result contract (crux of the skopeo fix)", () => { + const digestJson = JSON.stringify({ config: { digest: "sha256:beef" } }); + + test("exit 127 THROWS — a missing skopeo can never masquerade as an absent image", () => { + expect(() => + classifyImageResult({ + exitCode: 127, + stdout: "", + stderr: "skopeo: command not found", + }), + ).toThrow("not found on PATH"); + }); + + test("a non-127 non-zero (404/transport) DEGRADES to null, not a throw", () => { + expect( + classifyImageResult({ + exitCode: 1, + stdout: "", + stderr: "manifest unknown", + }), + ).toBeNull(); + }); + + test("exit 0 with a config digest yields the @digest ref and the digest", () => { + expect( + classifyImageResult({ exitCode: 0, stdout: digestJson, stderr: "" }), + ).toEqual({ + ref: "ghcr.io/rigelbuild/compass-agent@sha256:beef", + digest: "sha256:beef", + }); + }); + + test("exit 0 with unparseable output degrades to null", () => { + expect( + classifyImageResult({ exitCode: 0, stdout: "not json", stderr: "" }), + ).toBeNull(); + }); + + test("exit 0 with no config.digest degrades to null", () => { + expect( + classifyImageResult({ exitCode: 0, stdout: "{}", stderr: "" }), + ).toBeNull(); + }); +}); diff --git a/tools/release-notes/index.ts b/tools/release-notes/index.ts new file mode 100644 index 000000000..94c87ae00 --- /dev/null +++ b/tools/release-notes/index.ts @@ -0,0 +1,338 @@ +#!/usr/bin/env bun +// release-notes (T2) — the Release body + nix-outputs manifest generator. +// +// PURE CORE: `assemble(input)` translates the gathered facts (sha, version, +// tag, asset list, the GHCR image digest OR null, and the parsed nix path-info +// identity) into the Release body markdown + the nix-outputs manifest object. +// It is a pure function: no I/O, no skopeo/nix/git invocation, no clock, no +// `process`/`env`/`Bun` access. A null image digest is not a failure — it +// DEGRADES to a recorded-absence line in the body (the image lane is +// paths-filtered independently and may not have run for a go-only push). +// +// THE EDGE: `main()` (guarded by `import.meta.main`) parses argv, gathers the +// inputs (queries GHCR with the fork skopeo, runs `nix path-info` over the +// toolchain `langs` set), calls the pure core, and — unless `--dry-run` — writes +// the body + manifest files. Guarding behind `import.meta.main` lets the test +// import the pure core without firing the edge. + +import { $ } from "bun"; + +// ── Pure-core types ──────────────────────────────────────────────────────── + +/** One nix output's identity, as `nix path-info --json` reports it. */ +export type NixOutput = { + /** the derivation/output name (e.g. "go", "bun", "agent-image-spec") */ + name: string; + /** the store path */ + path: string; + /** the NAR hash (present for a realised path; null if unknown) */ + narHash: string | null; +}; + +/** 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:…" */ + ref: string; + /** the config digest, e.g. "sha256:…" */ + digest: string; +}; + +/** The input the pure core receives. */ +export type AssembleInput = { + /** the 12-hex short sha this build was cut from */ + sha: string; + /** the one version string stamped into every binary (e.g. "0.1.0+g") */ + version: string; + /** the Release name/tag (e.g. "build-") */ + tag: string; + /** the binary + checksum asset filenames attached to the Release */ + assets: string[]; + /** the GHCR image identity, or null when the image is not yet published */ + image: ImageIdentity | null; + /** the nix build outputs (toolchain langs set + optional image spec) */ + nixOutputs: NixOutput[]; +}; + +/** The manifest written to nix-outputs.json. */ +export type NixManifest = { + sha: string; + version: string; + tag: string; + outputs: NixOutput[]; +}; + +export type AssembleOutput = { + /** the Release body markdown */ + body: string; + /** the object serialised to nix-outputs.json */ + manifest: NixManifest; +}; + +// ── Pure core ──────────────────────────────────────────────────────────────── + +/** The line recorded when the image is not yet published for this build. */ +export const IMAGE_ABSENT_LINE = "image: not yet published for this build"; + +/** + * Assemble the Release body markdown + the nix-outputs manifest object. + * Pure — no I/O. A null `image` degrades to IMAGE_ABSENT_LINE, never a throw. + */ +export function assemble(input: AssembleInput): AssembleOutput { + const lines: string[] = []; + + lines.push(`# ${input.tag}`); + lines.push(""); + lines.push(`Version: \`${input.version}\``); + lines.push(`Commit: \`${input.sha}\``); + lines.push(""); + + // Image identity — a durable pointer to the immutable GHCR artifact, or a + // recorded absence (Fork 2(ii)). Never a failure: the image lane is + // paths-filtered independently of this lane. + lines.push("## Container image"); + lines.push(""); + if (input.image === null) { + lines.push(IMAGE_ABSENT_LINE); + } else { + lines.push(`image: \`${input.image.ref}\``); + lines.push(`digest: \`${input.image.digest}\``); + } + lines.push(""); + + // Binaries — what consumers download; verify against SHA256SUMS. + lines.push("## Assets"); + lines.push(""); + for (const asset of input.assets) { + lines.push(`- \`${asset}\``); + } + lines.push(""); + + // Nix build-output identity — the verifiable statement of which outputs this + // build produced, without shipping the closure (Fork 2(iii)). + lines.push("## Nix outputs"); + lines.push(""); + if (input.nixOutputs.length === 0) { + lines.push("(none recorded)"); + } else { + for (const out of input.nixOutputs) { + const hash = out.narHash ?? "(unknown)"; + lines.push(`- \`${out.name}\`: \`${out.path}\` (${hash})`); + } + } + lines.push(""); + lines.push( + "The machine-readable manifest is attached as `nix-outputs.json`.", + ); + lines.push(""); + + const manifest: NixManifest = { + sha: input.sha, + version: input.version, + tag: input.tag, + outputs: input.nixOutputs, + }; + + return { body: `${lines.join("\n")}\n`, manifest }; +} + +// ── The edge (impure) ────────────────────────────────────────────────────── + +/** The GHCR repo the agent image publishes to (publish-agent-image.yml:188). */ +const IMAGE_REPO = "ghcr.io/rigelbuild/compass-agent"; + +type Args = { + sha: string; + version: string; + tag: string; + assets: string[]; + bodyOut: string; + manifestOut: string; + dryRun: boolean; +}; + +/** Parse argv into the edge's inputs. Repeated `--asset` accumulates. */ +export function parseArgs(argv: string[]): Args { + const args: Args = { + sha: "", + version: "", + tag: "", + assets: [], + bodyOut: "RELEASE_BODY.md", + manifestOut: "nix-outputs.json", + dryRun: false, + }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + if (flag === "--dry-run") { + args.dryRun = true; + continue; + } + const value = argv[++i]; + if (value === undefined) { + throw new Error(`release-notes: flag ${flag} needs a value`); + } + switch (flag) { + case "--sha": + args.sha = value; + break; + case "--version": + args.version = value; + break; + case "--tag": + args.tag = value; + break; + case "--asset": + args.assets.push(value); + break; + case "--body-out": + args.bodyOut = value; + break; + case "--manifest-out": + args.manifestOut = value; + break; + default: + throw new Error(`release-notes: unknown flag ${flag}`); + } + } + if (args.sha === "" || args.version === "" || args.tag === "") { + throw new Error("release-notes: --sha, --version, and --tag are required"); + } + return args; +} + +/** + * Decide the image identity from a raw `skopeo inspect` result — the + * load-bearing branch of the skopeo-provisioning fix, kept pure so it is + * unit-tested (the edge that runs skopeo cannot be exercised where skopeo is + * always present): + * - exit 127 => THROW: skopeo is not on PATH, a workflow bootstrap regression; + * fail LOUD so a missing tool can never masquerade as an absent image tag. + * - any other non-zero => null: a 404 for an unpublished tag or a transient + * 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. + */ +export function classifyImageResult(result: { + exitCode: number; + stdout: string; + stderr: string; +}): ImageIdentity | null { + if (result.exitCode === 127) { + throw new Error( + `release-notes: skopeo not found on PATH (exit 127); the workflow must provision the fork skopeo before generating the release body. stderr: ${result.stderr.trim()}`, + ); + } + if (result.exitCode !== 0) { + return null; + } + let digest: string; + try { + const raw = JSON.parse(result.stdout) as { + config?: { digest?: string }; + }; + digest = raw.config?.digest ?? ""; + } catch { + return null; + } + if (digest === "") { + return null; + } + return { ref: `${IMAGE_REPO}@${digest}`, digest }; +} + +/** + * Query GHCR for the image config digest at :git-, exactly as + * publish-agent-image.yml:206 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. + */ +async function gatherImage(sha: string): Promise { + const ref = `${IMAGE_REPO}:git-${sha}`; + const result = await $`skopeo inspect --raw docker://${ref}` + .nothrow() + .quiet(); + return classifyImageResult({ + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }); +} + +/** The `nix path-info --json` record shape (the fields the manifest reads). */ +type PathInfoEntry = { path: string; narHash?: string }; + +/** + * Resolve the toolchain `langs` set to store paths and run `nix path-info` over + * them, mapping each language name to its output identity. + */ +async function gatherNixOutputs(): Promise { + const langsJson = + await $`nix eval --json -f tools/toolchain/gate-tools.nix langs` + .quiet() + .text(); + const langs = JSON.parse(langsJson) as Record; + + const outputs: NixOutput[] = []; + for (const name of Object.keys(langs).sort()) { + const store = langs[name]?.store; + if (store === undefined || store === "") { + continue; + } + const infoJson = await $`nix path-info --json ${store}`.quiet().text(); + const info = JSON.parse(infoJson) as + | PathInfoEntry[] + | Record; + // nix path-info emits an array (newer nix) or an object keyed by path. + const entries: PathInfoEntry[] = Array.isArray(info) + ? info + : Object.entries(info).map(([path, v]) => ({ path, ...v })); + // A single-store-path query returns exactly one entry; anything else means + // `store` did not resolve to one output path and picking [0] would record + // an arbitrary identity — fail loud rather than ship a wrong manifest entry. + if (entries.length !== 1) { + throw new Error( + `release-notes: nix path-info for ${name} (${store}) returned ${entries.length} entries, expected exactly 1`, + ); + } + const entry = entries[0]; + outputs.push({ + name, + path: entry?.path ?? store, + narHash: entry?.narHash ?? null, + }); + } + return outputs; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + const image = await gatherImage(args.sha); + const nixOutputs = await gatherNixOutputs(); + + const { body, manifest } = assemble({ + sha: args.sha, + version: args.version, + tag: args.tag, + assets: args.assets, + image, + nixOutputs, + }); + + if (args.dryRun) { + console.log("=== release body ==="); + console.log(body); + console.log("=== nix-outputs.json ==="); + console.log(JSON.stringify(manifest, null, 2)); + return; + } + + await Bun.write(args.bodyOut, body); + await Bun.write(args.manifestOut, `${JSON.stringify(manifest, null, 2)}\n`); + console.log(`release-notes: wrote ${args.bodyOut} + ${args.manifestOut}`); +} + +if (import.meta.main) { + await main(); +} diff --git a/tools/release-notes/moon.yml b/tools/release-notes/moon.yml new file mode 100644 index 000000000..e6eba4c0e --- /dev/null +++ b/tools/release-notes/moon.yml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json +# +# release-notes (T2) — the Release body + nix-outputs manifest generator. Emits +# the GitHub Release body (binaries + the GHCR image-digest pointer, degrading +# to a recorded absence when the image lane has not published the sha) and +# nix-outputs.json (nix path-info identity over the toolchain `langs` set). A +# bun/TypeScript CLI; a hoisted root-workspace member (`bun` tag): install is +# inherited via .moon/tasks/tag-bun.yml (the shared root install), so this leaf +# has no own bun.lock and never runs its own install. It is itself a +# `ci-group.bun` project so the CI matrix generator's zero-untagged assertion +# does not fire on it. +layer: 'tool' +language: 'typescript' +tags: ['bun', 'ci-group.bun'] + +tasks: + typecheck: + command: 'bunx tsc --noEmit' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', 'package.json', '/bun.lock'] + test: + command: 'bun test' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', 'package.json', '/bun.lock'] + ci: + deps: ['typecheck', 'test'] + options: + cache: false diff --git a/tools/release-notes/package.json b/tools/release-notes/package.json new file mode 100644 index 000000000..8d4bf834b --- /dev/null +++ b/tools/release-notes/package.json @@ -0,0 +1,14 @@ +{ + "name": "@compass/release-notes", + "private": true, + "type": "module", + "description": "Release-notes generator: emits the GitHub Release body (binaries + GHCR image-digest pointer, degrading to a recorded absence when unpublished) and the nix-outputs.json build-output identity manifest for the release lane.", + "module": "index.ts", + "bin": { + "release-notes": "./index.ts" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/tools/release-notes/tsconfig.json b/tools/release-notes/tsconfig.json new file mode 100644 index 000000000..47d3248bb --- /dev/null +++ b/tools/release-notes/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "types": ["bun"] + } +}