From 9e71c4c37a02df38084d3fff2606bb1001ccd56f Mon Sep 17 00:00:00 2001 From: seal Date: Fri, 7 Aug 2026 00:48:18 -0400 Subject: [PATCH] =?UTF-8?q?ci(agent-image):=20gate=20the=20compass-agent?= =?UTF-8?q?=20image=20build=20pre-merge=20as=20a=20moon=20project=20agent-?= =?UTF-8?q?image/=20was=20outside=20moon,=20so=20the=20CI=20gate=20never?= =?UTF-8?q?=20built=20the=20image=20=E2=80=94=20it=20had=20zero=20pre-merg?= =?UTF-8?q?e=20coverage,=20and=20an=20image-build=20break=20(a=20bun-drift?= =?UTF-8?q?=20assert=20in=20toolchain.nix,=20a=20stale=20FOD=20hash=20or?= =?UTF-8?q?=20a=20broken=20bundle=20in=20entrypoint.nix)=20surfaced=20only?= =?UTF-8?q?=20post-merge=20in=20the=20publish=20workflow,=20while=20a=20co?= =?UTF-8?q?nsumer=20waited=20on=20a=20tag.=20This=20registers=20agent-imag?= =?UTF-8?q?e/=20as=20the=20compass-agent-image=20moon=20project=20so=20the?= =?UTF-8?q?=20gate=20builds=20the=20image=20on=20any=20PR=20that=20affects?= =?UTF-8?q?=20its=20closure,=20pulling=20the=20whole=20image-build=20break?= =?UTF-8?q?=20class=20to=20PR=20time.=20Closes=20SEA-1749.=20##=20How=20ag?= =?UTF-8?q?ent-image/moon.yml=20defines=20a=20nix=20`build`=20task=20that?= =?UTF-8?q?=20realises=20the=20image=20with=20the=20same=20fork-pinned=20d?= =?UTF-8?q?erivation=20the=20publish=20lane=20ships=20and=20the=20local=20?= =?UTF-8?q?dogfood=20load=20produces:=20=20=20=20=20nix=20run=20path:../fo?= =?UTF-8?q?rks/devenv#devenv=20--=20container=20build=20agent=20so=20a=20g?= =?UTF-8?q?reen=20build=20proves=20the=20exact=20artifact=20that=20publish?= =?UTF-8?q?es=20still=20builds=20=E2=80=94=20both=20an=20eval-time=20break?= =?UTF-8?q?=20(the=20bun-pin=20drift=20assert)=20and=20a=20realise-time=20?= =?UTF-8?q?break=20(FOD-hash=20invalidation,=20a=20broken=20bundle),=20the?= =?UTF-8?q?=20full=20class.=20The=20`ci`=20task=20depends=20on=20`build`;?= =?UTF-8?q?=20both=20carry=20cache:false=20(nix=20owns=20its=20store-path?= =?UTF-8?q?=20caching,=20so=20moon=20must=20not=20false-green=20a=20build?= =?UTF-8?q?=20it=20did=20not=20re-run)=20and=20runInCI:true,=20matching=20?= =?UTF-8?q?the=20two=20vendored=20fork=20projects'=20nix=20builds.=20Regis?= =?UTF-8?q?tered=20in=20.moon/workspace.yml=20as=20compass-agent-image,=20?= =?UTF-8?q?which=20is=20what=20puts=20it=20in=20the=20gate:=20moon=20disco?= =?UTF-8?q?vers=20projects=20from=20that=20map,=20so=20ci.yml's=20one-job?= =?UTF-8?q?=20`moon=20ci=20:ci`=20picks=20it=20up=20with=20no=20workflow?= =?UTF-8?q?=20edit.=20##=20Cost,=20and=20why=20it=20is=20bounded=20The=20i?= =?UTF-8?q?mage=20closure=20is=20the=20dominant=20CI=20cost=20(the=20reaso?= =?UTF-8?q?n=20ci.yml's=20timeout=20is=2090m),=20but=20it=20is=20not=20pai?= =?UTF-8?q?d=20on=20every=20PR.=20`moon=20ci`=20runs=20a=20PR's=20affected?= =?UTF-8?q?=20projects=20only,=20and=20the=20build=20task's=20inputs=20sco?= =?UTF-8?q?pe=20it=20to=20the=20image=20closure:=20the=20agent-image/=20tr?= =?UTF-8?q?ee,=20packages/compass-agent/,=20both=20vendored=20forks,=20the?= =?UTF-8?q?=20root=20package.json=20and=20bun.lock,=20and=20.prototools.?= =?UTF-8?q?=20A=20PR=20touching=20none=20of=20those=20never=20builds=20the?= =?UTF-8?q?=20image;=20every=20push=20to=20main=20runs=20it=20in=20the=20f?= =?UTF-8?q?ull=20sweep.=20The=20inputs=20MIRROR=20publish-agent-image.yml'?= =?UTF-8?q?s=20on.push.paths=20=E2=80=94=20the=20reviewed=20source=20of=20?= =?UTF-8?q?truth=20for=20what=20changes=20the=20published=20artifact=20?= =?UTF-8?q?=E2=80=94=20plus=20.prototools=20(which=20publish=20excludes=20?= =?UTF-8?q?because=20a=20pin=20move=20there=20cannot=20change=20the=20outp?= =?UTF-8?q?ut,=20but=20which=20reddens=20the=20toolchain=20assert,=20exact?= =?UTF-8?q?ly=20what=20this=20gate=20catches=20at=20PR=20time).=20As=20a?= =?UTF-8?q?=20project=20in=20the=20one-job=20gate=20the=20image=20build=20?= =?UTF-8?q?is=20now=20a=20required=20check:=20a=20build=20break=20blocks?= =?UTF-8?q?=20merge,=20the=20same=20posture=20as=20the=20vendored=20forks.?= =?UTF-8?q?=20##=20A=20benign=20hasher=20warn=20moon's=20task=20hasher=20l?= =?UTF-8?q?ogs=20"Attempted=20to=20hash=20input=20forks/devenv/tests/keycl?= =?UTF-8?q?oak-certs/realms=20but=20only=20files=20can=20be=20hashed"=20?= =?UTF-8?q?=E2=80=94=20the=20vendored=20devenv=20fork=20ships=20a=20direct?= =?UTF-8?q?ory=20symlink=20at=20that=20path=20(a=20keycloak=20test=20fixtu?= =?UTF-8?q?re,=20never=20in=20the=20image=20closure).=20It=20is=20cosmetic?= =?UTF-8?q?=20and=20cannot=20skew=20the=20gate:=20affected-scheduling=20is?= =?UTF-8?q?=20VCS-touched-file=20based=20(a=20change=20to=20or=20repoint?= =?UTF-8?q?=20of=20any=20closure=20path=20still=20runs=20the=20build),=20a?= =?UTF-8?q?nd=20the=20hasher's=20cache=20key,=20the=20only=20thing=20the?= =?UTF-8?q?=20unhashable=20node=20feeds,=20is=20unused=20because=20the=20t?= =?UTF-8?q?ask=20is=20cache:false.=20Documented=20in=20the=20task's=20inpu?= =?UTF-8?q?ts=20comment.=20##=20Verification=20-=20`moon=20run=20compass-a?= =?UTF-8?q?gent-image:build`=20realises=20the=20image=20green=20=20=20(det?= =?UTF-8?q?erministic=20output=20/nix/store/...-image-compass-agent.json).?= =?UTF-8?q?=20-=20moon=20resolves=20the=20build=20task's=20inputs=20to=20e?= =?UTF-8?q?xactly=20the=20closure=20globs=20=20=20above=20(plus=20moon's?= =?UTF-8?q?=20own=20.moon/*.{yml,...}=20config=20auto-input).=20-=20Docs?= =?UTF-8?q?=20pass=20root:markdownlint=20(0=20errors).=20##=20Supersedes?= =?UTF-8?q?=20An=20earlier=20eval-only=20variant=20of=20this=20check=20(a?= =?UTF-8?q?=20separate=20advisory=20PR-triggered=20workflow=20running=20`n?= =?UTF-8?q?ix=20eval`=20the=20image=20drv=20without=20realising=20it)=20wa?= =?UTF-8?q?s=20superseded=20by=20the=20decision=20to=20build=20the=20full?= =?UTF-8?q?=20image=20in=20the=20gate:=20eval-only=20caught=20only=20the?= =?UTF-8?q?=20eval-time=20bun-drift=20assert,=20whereas=20the=20moon=20bui?= =?UTF-8?q?ld=20also=20catches=20realise-time=20breaks=20and=20reuses=20th?= =?UTF-8?q?e=20nix-in-gate=20posture=20the=20vendored=20forks=20already=20?= =?UTF-8?q?establish.=20The=20frozen=20record's=20deferred-=20hardening=20?= =?UTF-8?q?note=20(docs/designs/platform/compass-agent-image-publish.md)?= =?UTF-8?q?=20and=20the=20publish=20workflow's=20failure-ownership=20comme?= =?UTF-8?q?nt=20are=20updated=20to=20match;=20the=20durable=20operational?= =?UTF-8?q?=20shape=20is=20folded=20into=20docs/architecture/build-and-ci.?= =?UTF-8?q?md.=20Co-authored-by:=20Matt=20Wilkinson=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish-agent-image.yml | 8 +- .moon/workspace.yml | 5 ++ agent-image/moon.yml | 79 +++++++++++++++++++ docs/architecture/build-and-ci.md | 30 +++++++ .../platform/compass-agent-image-publish.md | 20 +++-- 5 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 agent-image/moon.yml diff --git a/.github/workflows/publish-agent-image.yml b/.github/workflows/publish-agent-image.yml index fc3fcab3..9704b3d7 100644 --- a/.github/workflows/publish-agent-image.yml +++ b/.github/workflows/publish-agent-image.yml @@ -17,9 +17,11 @@ # - 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. `agent-image/` is not a moon project, so the gate never -# builds it — the main-branch Actions failure notification is the surfacing -# mechanism for a red publish. +# - 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 diff --git a/.moon/workspace.yml b/.moon/workspace.yml index 19fee1da..5f4ec197 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -26,6 +26,11 @@ projects: compass-eng-docs: 'apps/eng-docs' # The backend Go module: server + comms + the generated compass.v1 Go stubs. compass-go: 'go' + # The compass-agent runtime image: a standalone nix devenv whose build + # realises the exact artifact the publish lane ships. Registered here so the + # image build is in the gate — affected-detection runs it only on PRs that + # touch the image closure (see agent-image/moon.yml). + compass-agent-image: 'agent-image' # The toolchain version-parity gate: asserts CI's PATH holds the dev shell's # toolchain, and carries the unit tests for its own comparison logic. toolchain-parity: 'tools/toolchain' diff --git a/agent-image/moon.yml b/agent-image/moon.yml new file mode 100644 index 00000000..1e97b8f2 --- /dev/null +++ b/agent-image/moon.yml @@ -0,0 +1,79 @@ +# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json +# +# compass-agent-image (agent-image/): the compass-agent runtime image. A +# standalone nix devenv, not a bun package — the build is +# `nix run path:../forks/devenv#devenv -- container build agent`, the same +# fork-pinned derivation the publish lane ships (agent-image/publish.sh) and the +# local `dogfood:agent-image` load produce. A green build here is proof the +# exact artifact that publishes still builds. +# +# WHY THIS IS A MOON PROJECT. Registering it in .moon/workspace.yml is what puts +# the image build in the gate: on a PR `moon ci :ci` runs this project's `ci` +# only when the PR affects the image closure (the `inputs` below), and every +# push to main runs it unconditionally (ci.yml's affected-PR / full-main split). +# Before this, agent-image/ was outside moon, so an image-build break — a +# bun-drift assert (toolchain.nix), a stale FOD hash or a broken bundle +# (entrypoint.nix) — surfaced only post-merge in the publish workflow, while a +# consumer waited on a tag. The build is heavy (the image closure is the +# dominant CI cost, the reason ci.yml's timeout is 90m), but affected-detection +# keeps it off every PR that does not touch the closure, and the two vendored +# forks already build through nix in this same gate. +layer: 'application' +language: 'nix' + +workspace: + inheritedTasks: + # Not a bun package, so the tag-bun `install` must never run here; `lint` + # and `format` are whole-repo tasks on the root project, never inherited. + # agent-image carries no `bun` tag, so nothing is inherited today — this is + # the same defensive guard the sibling nix projects carry, in case + # tag-bun.yml later grows a task a nix tree must not run. + exclude: ['install', 'lint', 'format'] + +tasks: + build: + # Realise the image exactly as the publish lane and the dogfood load do — + # the same fork-pinned derivation, so a green build here proves the exact + # artifact that publishes builds. Runs from agent-image/ (moon's default + # project-dir cwd) so the `path:../forks/*` flake refs resolve, matching + # publish.sh. The substituters the build fetches from are named in ci.yml's + # install-nix-action config, the reviewed trust surface for this gate. + # + # cache:false — nix owns its store-path caching; moon must not false-green a + # build it did not re-run (the same posture the fork builds take). + command: 'nix run path:../forks/devenv#devenv -- container build agent' + options: + cache: false + runInCI: true + # The image's build closure. MIRRORS publish-agent-image.yml's + # on.push.paths — the reviewed source of truth for what changes the + # published artifact — so affected-detection schedules this build on exactly + # the PRs a republish fires on. Plus .prototools: the build's toolchain.nix + # bun-drift assert reads it, so a pin move there can red this build (publish + # excludes .prototools because it cannot change the output — but reddening on + # the assert at PR time is precisely this gate's purpose). A leading `/` is + # workspace-root-relative; the bare glob is this project's own tree (image + # definition, toolchain, entrypoint FOD). + # + # A benign warn is expected here: moon's task hasher logs "Attempted to hash + # input forks/devenv/tests/keycloak-certs/realms but only files can be + # hashed" because the vendored devenv fork ships a directory SYMLINK at that + # path (a keycloak test fixture, never in the image closure). It is cosmetic + # and cannot skew this gate: affected-scheduling is VCS-touched-file based + # (a change to, or a repoint of, any path under the globs below still runs + # the build), and the hasher's cache key — the only thing the unhashable + # node would feed — is unused because this task is cache:false. + inputs: + - '**/*' + - '/packages/compass-agent/**' + - '/forks/devenv/**' + - '/forks/nix2container/**' + - '/package.json' + - '/bun.lock' + - '/.prototools' + + ci: + deps: ['build'] + options: + cache: false + runInCI: true diff --git a/docs/architecture/build-and-ci.md b/docs/architecture/build-and-ci.md index 8eda2c1f..33ffa80e 100644 --- a/docs/architecture/build-and-ci.md +++ b/docs/architecture/build-and-ci.md @@ -199,6 +199,36 @@ also settles the immutability guard's first-publish edge: the guard inspects authenticatable) package guarantees the absent-tag inspect classifies cleanly rather than on a not-yet-existent repository's error shape. +### Pre-merge build check + +`agent-image/` is registered as the `compass-agent-image` moon project +(`.moon/workspace.yml`), so the CI gate builds the image on any PR that +affects its closure. Before this the image was outside moon and had zero +pre-merge coverage — an image-build break surfaced only post-merge in the +publish workflow, while a consumer waited on a tag. + +The project's `build` task realises the image with the same fork-pinned +derivation the publish lane ships +(`nix run path:../forks/devenv#devenv -- container build agent`), so a green +build proves the exact artifact that publishes still builds — both an +eval-time break (a bun-pin drift against the `agent-image/toolchain.nix` +assert) and a realise-time break (an `agent-image/entrypoint.nix` FOD-hash +invalidation or a broken bundle), the full class. + +The build is heavy — the image closure is the dominant CI cost, the reason +the gate's timeout is 90m — but it is not paid on every PR. `moon ci` runs a +PR's *affected* projects only, and the task's `inputs` scope it to the image +closure: the `agent-image/` tree, the two vendored forks, `packages/compass-agent/`, +the root `package.json` and `bun.lock`, and `.prototools`. A PR that touches +none of those never builds the image; every push to main runs it +unconditionally in the full sweep. Its `inputs` mirror the publish workflow's +`on.push.paths` — the reviewed source of truth for what changes the published +artifact — plus `.prototools` (which publish excludes because a pin move there +cannot change the output, but which reddens the toolchain assert, exactly what +this gate exists to catch at PR time). As a project in the one-job gate it is a +required check: a build break blocks merge, the same posture as the vendored +forks' nix builds. + ## Caching **moon task cache** — whole-task-output caching, keyed by an inputs hash. diff --git a/docs/designs/platform/compass-agent-image-publish.md b/docs/designs/platform/compass-agent-image-publish.md index 2c04f424..2e457035 100644 --- a/docs/designs/platform/compass-agent-image-publish.md +++ b/docs/designs/platform/compass-agent-image-publish.md @@ -175,9 +175,10 @@ silent-staleness failure the doctrine targets. What it *does* buy: `agent-image/toolchain.nix:50-56`; an FOD-hash invalidation, `entrypoint.nix:116`) would otherwise sit unnoticed while a consumer waits on a tag. Publish failures are owned by compass-runner (this record's owner); - the main-branch Actions failure notification is the surfacing mechanism. A - cheap eval-only pre-merge check (`nix eval` the image drv without realising - it) is deferred hardening, noted in T4. + the main-branch Actions failure notification is the surfacing mechanism for a + push failure. The image BUILD is now gated pre-merge by the + `compass-agent-image` moon project (the deferred-hardening item below, + implemented), so a build break is caught on the PR rather than here. Triggers: `push: branches: [main]` with a `paths:` filter (next section), plus `workflow_dispatch` as the manual/backstop lane. No PR trigger — no secret or @@ -483,9 +484,16 @@ Interfaces: `docs/architecture/build-and-ci.md`. - [ ] One-time (whoever lands T2): set the GHCR package **public** after the first push (Matt's ruling — compass is OSS). -- [ ] Deferred hardening (T4-adjacent): a cheap eval-only pre-merge check - (`nix eval` the image drv without realising it) so an image-build break - is caught at PR time instead of in the post-merge publish. +- [x] Deferred hardening (T4-adjacent): gate the image build pre-merge so an + image-build break is caught at PR time instead of in the post-merge + publish. Implemented as the `compass-agent-image` moon project + (`agent-image/moon.yml`), which realises the full image in the CI gate on + any PR affecting the image closure. An earlier eval-only variant (`nix + eval` the drv without realising) was superseded by Matt's ruling to build + the full image: eval-only caught only the eval-time bun-drift assert, + whereas the moon build also catches realise-time breaks (FOD-hash + invalidation, a broken bundle), and reuses the same nix-in-gate posture + the vendored forks already establish. ## Open Questions