diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 098373850..4c65455d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1722,7 +1722,7 @@ jobs: # published `:latest` turns on one question — does this PR change the # image's inputs? — and the answer already lives in one place: the # `compass-agent-image` build task's `inputs` globs (agent-image/moon.yml), - # which mirror publish-agent-image.yml's on.push.paths (the reviewed + # which mirror release.yml's IMAGE_CLOSURE_PATHS (the reviewed # source of truth for what changes the published artifact). # # The discriminator is `moon query tasks --affected --project diff --git a/.github/workflows/publish-agent-image.yml b/.github/workflows/publish-agent-image.yml deleted file mode 100644 index 794a4a056..000000000 --- a/.github/workflows/publish-agent-image.yml +++ /dev/null @@ -1,212 +0,0 @@ -# Publishes the `compass-agent` runtime image to GHCR. -# -# WHY A SEPARATE WORKFLOW, NOT A STEP IN THE CI GATE — a deliberate, principled -# exception to this repo's ONE-JOB doctrine (see ci.yml's header): -# -# - Least privilege. A GHCR push needs `packages: write`; the gate job runs -# `contents: read` only. This workflow gets `contents: read` + -# `packages: write` and nothing else, and PR events never reach it at all — -# no token or secret is ever exposed to a fork PR. -# - Native paths scoping. `on.push.paths` (below) restricts publishing to -# closure-affecting pushes without a `dorny/paths-filter` step, which is -# what folding into ci.yml (whose `on:` has no per-job path filter) would -# have required. -# - Its own concurrency. Publishes must SERIALIZE -# (`cancel-in-progress: false`), the opposite of the gate's cancelling -# group — an in-flight `:latest` move must never be half-superseded. -# - Off the hot path, not a required check. The image closure is the heavy -# nix build; keeping it here leaves PR latency untouched and a publish flake -# never reds the required merge gate. -# - Failure ownership. The image BUILD is gated pre-merge by the -# `compass-agent-image` moon project (agent-image/moon.yml), so a build -# break is caught on the PR, not here. This workflow owns only the PUSH -# half — a GHCR/registry failure moving the two-tag set — surfaced by the -# main-branch Actions failure notification. -# -# It also enumerates NO moon projects, so it re-creates none of the -# silent-staleness failure the ONE-JOB doctrine targets: the gate's coverage -# stays sourced solely from .moon/workspace.yml. -# -# DOCUMENTED CONSUMER-SEAM SMOKE (not executed here — it needs a rootless-podman -# host, which GitHub-hosted runners are not). On such a host, after a publish: -# -# podman pull ghcr.io/rigelbuild/compass-agent:git- -# compass-runner --image ghcr.io/rigelbuild/compass-agent:git- -# # then drive one provision -# -# This exercises the exact seam compass-stack consumes (`--image` / -# $COMPASS_AGENT_IMAGE) and is the acceptance check compass-native repeats from -# the pull side. See docs/architecture/build-and-ci.md and the design record -# docs/designs/infra/ci/compass-agent-image-publish/design.md. - -name: agent-image - -on: - push: - branches: [main] - # Each glob tracks an input to the image's nix closure; a change to any of - # them can change the published artifact, so it must trigger a republish. - paths: - # The image definition itself (entrypoint FOD, devenv/toolchain modules). - - agent-image/** - # The first-party agent entrypoint bundled into the image. - - packages/compass-agent/** - # The workspace root manifest — the entrypoint FOD reads it to expand the - # workspace-member set. - - package.json - # The lockfile the entrypoint FOD pins the whole dependency tree to. - - bun.lock - # A fix to this lane itself must republish. - - .github/workflows/publish-agent-image.yml - # The bun pin the image's toolchain.nix consumes: the vendored bun - # derivation is built from versions/bun.nix, so a pin move there changes - # the published artifact and must trigger a republish. - - tools/toolchain/versions/bun.nix - workflow_dispatch: - -# Least privilege: read the tree, write the GHCR package, nothing else. -permissions: - contents: read - packages: write - -# Publishes SERIALIZE — an in-flight `:latest` move must never be -# half-superseded by a newer run. This is the OPPOSITE of ci.yml's -# `cancel-in-progress: true`: a superseded push here cleanly skips its tag -# (a missing tag is not a failure), it never tears the two-copy tag pair. -concurrency: - group: publish-agent-image - cancel-in-progress: false - -jobs: - publish: - name: publish - runs-on: ubuntu-latest - # workflow_dispatch runs on any branch; guard so a dispatch from a feature - # branch can never mint a `:git-` for unmerged code nor move `:latest` - # off main. Main pushes satisfy this trivially. - if: github.ref == 'refs/heads/main' - # The image closure is the heavy nix build that sizes this timeout, the same - # cost that motivates ci.yml's 90m. - timeout-minutes: 90 - # `run: ./publish.sh` resolves relative to this default, and publish.sh's own - # `github:RigelBuild/devenv` container build resolves only from agent-image/. The - # skopeo login/verify steps do NOT depend on this cwd — they invoke skopeo by - # name off PATH (bootstrapped below as an absolute store path). - defaults: - run: - working-directory: agent-image - steps: - # Default depth — the publish script needs only HEAD (git rev-parse HEAD - # for the :git- 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. - 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 publish lane invokes a plain `skopeo` (the RigelBuild/nix2container - # fork's patched build, understanding the `nix:` transport). Resolve it - # from the shared pinned helper, tools/toolchain/skopeo-nix2container-env.nix - # — which builds the exact derivation the root dev shell installs, from - # the nix2container + nixpkgs revisions ../devenv.lock pins (one source of - # truth for both revs, no raw nix2container flake ref) — and prepend its - # bin/ to PATH, so the login / publish / verify steps below invoke skopeo - # by name. This is the same out-of-band `nix build` pattern ci.yml uses - # for chromium-e2e-env.nix, and it avoids entering the root dev shell - # (whose enterShell banner would pollute a captured store path). skopeo is - # deliberately NOT in agent-image/devenv.nix: a package there would bake - # its ~168 MB closure into every published image via the container - # entrypoint's sourced shell env. - 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 publish script's `skopeo copy` - # run as SEPARATE `nix run` 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 }} - # The root compass dev shell's patched skopeo (bootstrapped onto PATH - # above) understands the `nix:` transport the publish uses. It is a plain - # command here — one skopeo backs the whole lane, resolved from the - # lockfile-pinned nix2container input, and no raw nix2container flake ref - # lives in this workflow. - run: | - skopeo \ - login ghcr.io -u "$ACTOR" --password-stdin \ - --authfile "$REGISTRY_AUTH_FILE" <<< "$GITHUB_TOKEN" - - - name: Build and publish the two-tag set - # No args = the default two-tag set (:git- then :latest). The script - # honors $REGISTRY_AUTH_FILE, builds the spec once, and enforces - # :git- immutability, exiting non-zero on violation. - run: ./publish.sh - - - name: Verify the published tags resolve from GHCR - # Proves the artifact is resolvable from GHCR (not merely that copy - # exited 0), that the platform contract holds, and that the two-copy pair - # landed coherently. Every skopeo call pins --authfile. - run: | - set -euo pipefail - sha12="$(git rev-parse --short=12 HEAD)" - ref="docker://ghcr.io/rigelbuild/compass-agent" - inspect_json="$RUNNER_TEMP/git-inspect.json" - - # Resolvable from GHCR at the immutable tag. skopeo is the root dev - # shell's patched skopeo on PATH (bootstrapped above), understanding - # the `nix:` transport. - skopeo inspect --authfile "$REGISTRY_AUTH_FILE" "$ref:git-$sha12" > "$inspect_json" - - # Cheapest platform-contract-regression tripwire. - arch="$(jq -r .Architecture "$inspect_json")" - os="$(jq -r .Os "$inspect_json")" - if [ "$arch" != "amd64" ] || [ "$os" != "linux" ]; then - echo "platform contract violated: got $os/$arch, want linux/amd64" >&2 - exit 1 - fi - - # :latest and :git- must share a config digest — proves the - # two-copy pair landed coherently. - git_digest="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:git-$sha12" | jq -r .config.digest)" - latest_digest="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:latest" | jq -r .config.digest)" - if [ "$git_digest" != "$latest_digest" ]; then - echo "tag pair incoherent: :git-$sha12=$git_digest != :latest=$latest_digest" >&2 - exit 1 - fi - echo "verified: $ref:git-$sha12 resolves, linux/amd64, coherent with :latest" diff --git a/docs/architecture/build-and-ci.md b/docs/architecture/build-and-ci.md index 0a1976023..e1f4a11db 100644 --- a/docs/architecture/build-and-ci.md +++ b/docs/architecture/build-and-ci.md @@ -185,8 +185,9 @@ the release jobs — the four-binary asset set is built stamped from `version.tx the `:vX.Y.Z` image tag. Dev/bundle builds off `app-bundle/build.sh` read the same `version.txt` base and append a `+g` suffix, so a local bundle reports the release base it descends from. The standalone -`publish-agent-image.yml` predates this unification; its retirement is a -separate follow-up. +`publish-agent-image.yml` predated this unification; it has been retired +(RIG-2994), its per-push publish duty relocated verbatim into `release.yml`'s +`publish-image` job. **Smoke.** On a runner host, pull the immutable tag and drive the consumer seam: diff --git a/docs/designs/infra/ci/compass-agent-image-publish/design.md b/docs/designs/infra/ci/compass-agent-image-publish/design.md index ab057bb1b..e662bc72c 100644 --- a/docs/designs/infra/ci/compass-agent-image-publish/design.md +++ b/docs/designs/infra/ci/compass-agent-image-publish/design.md @@ -21,10 +21,12 @@ Tracking: RIG-1690 (blocks compass-native RIG-1683/T2, RIG-1685/T4, RIG-1687/T6) > DL-298–301). That lane RELOCATES this lane's per-push publish DUTY verbatim — > the same `publish.sh` no-args call, the same closure-paths gate, the same > serialize / immutability / two-copy-coherence / least-privilege / -> off-hot-path posture — into a `publish-image` job, and the standalone -> workflow FILE retires in a two-PR staged delete (land the fold, observe one -> green per-push run, then delete). Every OTHER decision here stays live and is -> carried into that lane unchanged: the tag name/tag contract (`:git-` +> off-hot-path posture — into a `publish-image` job. The standalone +> `publish-agent-image.yml` workflow FILE has now been RETIRED (RIG-2994, +> 2026-08-30) via the two-PR staged delete this note prescribed: the unified +> lane's fold landed, one green per-push `publish-image` run was observed on +> main, and the old file was then deleted. Every OTHER decision here stays live +> and is carried into that lane unchanged: the tag name/tag contract (`:git-` > immutable pin + `:latest` fallback), the build-once/push-each-tag skopeo > mechanism, the `GITHUB_TOKEN` + `REGISTRY_AUTH_FILE` auth pin, the > `linux/amd64` single-arch platform contract, and the public-visibility diff --git a/tools/release-notes/index.ts b/tools/release-notes/index.ts index 53eb4d24d..a58a36be4 100644 --- a/tools/release-notes/index.ts +++ b/tools/release-notes/index.ts @@ -137,7 +137,7 @@ export function assemble(input: AssembleInput): AssembleOutput { // ── The edge (impure) ────────────────────────────────────────────────────── -/** The GHCR repo the agent image publishes to (publish-agent-image.yml:188). */ +/** The GHCR repo the agent image publishes to (release.yml publish-image). */ const IMAGE_REPO = "ghcr.io/rigelbuild/compass-agent"; type Args = { @@ -266,7 +266,7 @@ export function requireImageAtRelease( /** * Query GHCR for the image config digest at :git-, exactly as - * publish-agent-image.yml:206 does (`skopeo inspect --raw … | jq -r + * 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. */