diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..fff1a714 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,246 @@ +# Cuts a GitHub Release per qualifying build, carrying the compiled binaries, +# the container-image identity (a GHCR digest pointer), and the nix build-output +# identity manifest — so consumers pin and download a verified artifact instead +# of rebuilding from source. +# +# WHY A SEPARATE WORKFLOW, NOT A STEP IN THE CI GATE — the same deliberate +# exception to this repo's ONE-JOB doctrine that publish-agent-image.yml makes, +# with `contents: write` where that lane needs `packages: write`: +# +# - Least privilege. Cutting a Release needs `contents: write`; the gate job +# runs `contents: read` only. This workflow gets `contents: write` and +# NOTHING else — in particular NOT `packages: write`: this per-build lane +# only writes Releases. Moving the GHCR image tags is the semver lane's job +# (T3 adds `push: tags: ['v*']` + a job-level `packages: write`); the +# per-build lane never touches a registry. +# - No PR trigger, ever. PR events never reach this workflow, so fork-PR code +# never runs with the write token (Global Constraint 3 / Fork 4 posture — +# identical to publish-agent-image.yml, which has no PR trigger for the same +# reason). +# - Native paths scoping. `on.push.paths` restricts the lane to +# binary-affecting pushes (the Go tree + the Go toolchain pin + this file), +# NOT unioned with the image lane's closure paths — an image-only sha +# republishes the image but mints no Release (OQ-3, ruled). +# - Its own concurrency. Releases SERIALIZE (`cancel-in-progress: false`), the +# opposite of the gate's cancelling group — a superseded run cleanly skips +# rather than half-superseding a Release mid-upload. +# - Off the hot path, not a required check. The nix toolchain resolve sizes +# the timeout the same way it does for ci.yml; keeping it here leaves PR +# latency untouched. +# +# See docs/designs/platform/compass-release-bundling.md (§Plan T1/T2, Forks 3-4) +# and docs/architecture/build-and-ci.md. + +name: release + +on: + push: + branches: [main] + # Each glob is a binary-affecting input: a change to any of them can change + # a built binary, so it must mint a new Release. Deliberately NOT the image + # lane's closure paths — an image-only sha mints no Release (OQ-3). + paths: + # The Go tree — every binary's source. + - go/** + # The pinned Go toolchain: a pin move rebuilds every binary. NOTE the + # boundary (OQ-3, within-contract): the built go is the go-overlay applied + # to devenv.lock's nixpkgs, so a devenv.lock rev bump that changes the go + # derivation byte-for-byte re-cuts NO Release — and the 0.1.0+g string + # would be identical across it, so the two builds are indistinguishable by + # Release identity. Only a versions/go.nix move re-cuts. + - tools/toolchain/versions/go.nix + # A fix to this lane itself must re-cut. + - .github/workflows/release.yml + workflow_dispatch: + +# Least privilege: write Releases, nothing else. NOT packages:write — that is +# the T3 semver lane's grant only. +permissions: + contents: write + +# Releases SERIALIZE — a superseded run must not half-supersede a Release +# mid-upload. The OPPOSITE of ci.yml's cancelling group, mirroring +# publish-agent-image.yml:76-82. +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + name: release + runs-on: ubuntu-latest + # workflow_dispatch runs on any branch; guard so a dispatch from a feature + # branch can never mint a Release for unmerged code. Main pushes satisfy + # this trivially (mirrors publish-agent-image.yml:87). + if: github.ref == 'refs/heads/main' + # The nix toolchain resolve is the cost that sizes this timeout — the same + # ceiling ci.yml uses. + timeout-minutes: 90 + steps: + # Default depth — the lane needs only HEAD (git rev-parse HEAD for the + # build- tag). + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - 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 (copied verbatim from ci.yml:191-194). + 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 6): 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 + # Version string: `0.1.0+g` — the app-bundle/build.sh + # `0.1.0+g` shape (semver build metadata off the `0.1.0` base + # each main.go stamps), with the 12-hex short sha the image tag and + # Release name also speak. ONE string across all binaries in a Release + # (Global Constraint 4). The Release NAME is `build-` (Global + # Constraint 2). -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. + run: | + set -euo pipefail + sha12="$(git rev-parse --short=12 HEAD)" + tag="build-$sha12" + version="0.1.0+g$sha12" + echo "SHA12=$sha12" >>"$GITHUB_ENV" + echo "TAG=$tag" >>"$GITHUB_ENV" + echo "VERSION=$version" >>"$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 (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. --dry-run would print + # both without writing; here it writes 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: Create or update the prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Prereleases are PUBLISHED, not draft — a draft is invisible to the + # anonymous asset-download consumer and this lane has no human step + # (Fork 3). Idempotency (re-run on the same sha): check-then-branch. If + # the Release already exists, re-upload its assets with --clobber rather + # than failing on the duplicate tag; otherwise create it fresh. Both + # paths converge on the same six assets. + 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 + ) + if gh release view "$TAG" >/dev/null 2>&1; then + # Re-run on the same sha: converge BOTH assets AND notes. The + # image-absent->present transition (Fork 2(ii)/OQ-3) mints the build + # with the image recorded absent; a later workflow_dispatch re-run + # after the image publishes must refresh the body to the + # freshly-computed digest pointer, not leave the stale absence line. + gh release upload --clobber "$TAG" "${assets[@]}" + gh release edit "$TAG" --prerelease --notes-file RELEASE_BODY.md + else + gh release create "$TAG" \ + --prerelease \ + --title "$TAG" \ + --notes-file RELEASE_BODY.md \ + "${assets[@]}" + fi diff --git a/bun.lock b/bun.lock index 6bab1a22..97bc96be 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": { @@ -427,6 +437,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 00000000..99b4ab8f --- /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 00000000..73f9d020 --- /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 100755 index 00000000..94c87ae0 --- /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 00000000..e6eba4c0 --- /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 00000000..8d4bf834 --- /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 00000000..47d3248b --- /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"] + } +}