diff --git a/.github/workflows/publish-agent-image.yml b/.github/workflows/publish-agent-image.yml index fc3fcab36..9704b3d75 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 19fee1da7..5f4ec197b 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 000000000..1e97b8f2b --- /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 8eda2c1fb..33ffa80e0 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 2c04f424a..2e4570354 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