Build & CI
How a change goes from an editor to a verified build. Each layer is configured in a real format and composes cleanly:
devenv the dev shell — owns every toolchain (Go/bun/node/moon + the rest)moon the task graph: what to build/test, caching, affected detectionThe headline property: the same task graph runs remotely and locally. Only the scheduler differs — GitHub Actions remotely, moon on one box locally. What keeps that true rather than aspirational is the version-parity gate below, which fails the build when the two toolchains diverge.
Toolchains: devenv/nix
Section titled “Toolchains: devenv/nix”devenv (nix underneath) owns the entire toolchain — one
owner, one activation path. The language runtimes (Go, bun, node, moon) are
pinned in tools/toolchain/versions/*.nix; everything else — the
protobuf/contract tooling (buf, protoc, the Go codegen plugins), the Go
analysis tools (golangci-lint, govulncheck, go-licenses, nilaway), and the
linters — comes from the pinned nixpkgs revision. A single owner means PATH
order never silently decides which copy of a tool wins.
- Local:
direnv allowputs the toolchain on PATH. devenv injects tools, not a whole shell — you keep your own prompt and dotfiles. - CI: the identical derivations the dev shell builds are resolved from the
same pinned sources and put on PATH — no
setup-*actions. See CI below.
Task graph: moon
Section titled “Task graph: moon”moon owns the task graph (deps:), result caching
(inputs/outputs), affected-target detection, and local parallel execution.
moon run <project>:<task> is the interface; moon run :ci runs every
project’s ci task. Affected detection decides which projects a change
actually touches — CI’s PR gate drives it through moon ci :ci (the
CI-environment form, which reads the base from the provider), while a local
moon run :ci --affected is the same detection on demand.
moon runs go and bun as system tasks — it execs the toolchain on PATH
rather than managing its own. moon’s graph/caching layer stays
toolchain-agnostic; the nix pin files under tools/toolchain/versions/*.nix
remain the version source.
The contract gate
Section titled “The contract gate”The compass.v1 schema is the sole, owned door between any UI and the daemon,
and it is gated three ways:
buf lint— schema style and consistency.buf breaking— rejects backward-incompatible schema edits.- Drift — regenerate the clients, then
git diff --exit-code. A checked-in client that no longer matches the schema fails the build, so generated code can never silently fall out of sync.
The generated Go and TypeScript clients are checked in (not generated at build
time by consumers), so a normal go build / bun install needs no codegen
step.
.github/workflows/ci.yml runs on every pull request, every push to main,
and a nightly schedule. One job, CI, with two parts:
- The moon battery — the whole battery over the moon task graph. It runs
one of two ways by event. On a pull request it is
moon ci :ci, which runs only the projects the PR affects — a Go, UI, or docs change never pays for the vendored forks’ nix builds. On a push tomainand on the nightly schedule it is the fullmoon run :ci: every task, every project, no affected filter. Affected detection trusts each task’sinputsglobs, so the full sweep on everything that reachesmainis the backstop — an incomplete glob that let a task be skipped on a PR is caught the moment the change lands (and re-checked nightly), named rather than hidden. Either way nothing about the workspace is enumerated in the workflow, so a new project (or a newly vendored fork) is gated the moment it is registered in.moon/workspace.yml. This is the same task graph the local gate and thehkpre-push hook run. - The real-Postgres suites — build-tagged
pgtest, and therefore never compiled by the moon battery’sgo test ./.... They run as a step in this same job, unconditionally (no affected filter and no event filter, so they run on every trigger including the nightly), against a Postgres service container attached to the job. A step afterwards asserts they actually ran: the harness skips when it finds no database, so a service that never came up would otherwise pass silently. These suites were once a separatepgtestjob, to keep a Postgres-service outage from redding the hermetic gate; they were folded in so there is one required check to gatemainon, at the cost that a service-container flake now redsCI(a re-run clears it).
Where CI’s toolchain comes from
Section titled “Where CI’s toolchain comes from”CI’s toolchain is the local one — no remote-only seam. Nix builds the identical
derivations the dev shell builds, from the same pinned sources: the language
runtimes (bun, node, moon, go) from tools/toolchain/versions/*.nix, and the
nixpkgs-provided tools — buf, protoc, the Go analysis battery, biome,
markdownlint — from the nixpkgs revision devenv.lock pins
(tools/toolchain/gate-tools.nix). Their bin/ dirs go on PATH; there are no
setup-* actions. One owner, one activation path, remotely and locally.
The parity gate
Section titled “The parity gate”Reading a version from a file does not prove the runner got it. A step before
the gate (tools/toolchain/parity.ts, also scheduled by :ci as
toolchain-parity:parity) asserts that the toolchain actually on PATH is the
one the dev shell defines, and fails the build — never warns — when it is
not. Every tool is pinned the same way now — as a nix derivation — so the gate
checks every one the same way: it resolves realpath of each command on PATH
and asserts it lands inside the derivation the pinned sources build. That is a
store-path identity check, stronger than comparing version strings: it catches
an ambient binary of the same version shadowing the pinned one, and it works
for the tools (go-licenses, nilaway) that implement no version flag at all.
A tool the gate cannot check is reported UNVERIFIABLE and fails the build.
Skipping what it cannot verify would make its green mean nothing.
What CI does not gate
Section titled “What CI does not gate”- Upstream test suites inside the vendored forks. Each fork’s registered
task is its own
nix build; the upstream suites it vendors are not run. - A live UI↔server path. Every
compass-uitask runs against fixtures, so no check exercises the UI against a running server.
Publishing the agent image
Section titled “Publishing the agent image”The compass-agent runtime image (the base every agent workstream runs in) is
published to GHCR by the unified release.yml lane, separate from the gate
above. For the full design see the frozen record
docs/designs/platform/compass-unified-release-lane.md (the earlier
docs/designs/infra/ci/compass-agent-image-publish/design.md records the
original image-publish rationale); the durable operational shape is here.
Ref and tags. The image is ghcr.io/rigelbuild/compass-agent. Every
closure-affecting push to main publishes two per-push tags, and a tagged release
adds a third:
:git-<sha12>— the 12-hex short commit sha, immutable. This is the pin the native app bakes in and hands the runner via--image/$COMPASS_AGENT_IMAGE; it is the real consumption path. The publish refuses to overwrite an existing:git-<sha>whose content differs and re-inspects after each push to assert the digest landed, so the tag is immutable by enforcement, not convention.:latest— moving, documented first-run fallback only, never the default.:vX.Y.Z— the semver release tag, immutable, minted only when avX.Y.Zrelease is cut. The release-gatedrelease-imagejob digest-re-tags the already-published:git-<sha>image (no rebuild — same digest, a new pointer), so the released image is byte-identical to the per-push one.
The git-sha tag is pushed before :latest, so the immutable pin always exists
before the moving tag moves. Platform is linux/amd64 single-arch (the dogfood
milestone target; macOS/aarch64 multi-arch is a GA follow-up). The package is
public — compass is open-source, the image payload is public source, and it
carries no runtime secrets (those are runner-supplied per-exec) — so the
first-run pull needs no credential anywhere.
One derivation, two destinations. The published :git-<sha> and the local
dogfood:agent-image load are copies of the same nix derivation — both flow
through the fork’s container build agent. They diverge only in the skopeo
destination (a registry ref versus containers-storage:), so what CI publishes
is byte-for-byte what a developer loads locally.
The unified release lane. Publishing lives in
.github/workflows/release.yml, not a step in the CI gate and not a
required check. Two jobs share it. The per-push publish-image job runs
main-only plus workflow_dispatch, closure-gated in-job (an in-job changed-path
check, not a trigger paths: filter — release-pr needs every commit),
with its own concurrency group set to cancel-in-progress: false (publishes
serialize rather than tear a tag pair mid-push) and a packages: write token
the gate job never gets; it publishes :git-<sha> + :latest. The
release-gated release-image job fires only when a vX.Y.Z release is cut and
digest-re-tags that image to :vX.Y.Z. This is a principled exception to the
ONE-JOB doctrine: the doctrine exists to stop a second source of truth
for what the gate covers, and this workflow enumerates no moon projects
(agent-image/ is a standalone devenv, not a moon project) — so it recreates
none of the silent-staleness failure the doctrine guards against. A published
tag is the source of truth; a missing per-push tag (paths filtered it out, or a
superseding push skipped it under the serialized concurrency group) is not a
failure — workflow_dispatch republishes any HEAD on demand.
The release-please cadence. release.yml also runs release-please, which
keeps a standing Release PR that batches conventional commits since the last
release. Merging that PR is the release: it cuts the vX.Y.Z git tag, updates
the CHANGELOG, and stamps the root version.txt to X.Y.Z. The tag then gates
the release jobs — the four-binary asset set is built stamped from version.txt
(so every released binary reports the same X.Y.Z), and release-image mints
the :vX.Y.Z image tag. Dev/bundle builds off app-bundle/build.sh read the
same version.txt base and append a +g<shortsha> 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.
Smoke. On a runner host, pull the immutable tag and drive the consumer seam:
podman pull ghcr.io/rigelbuild/compass-agent:git-<sha12>compass-runner --image ghcr.io/rigelbuild/compass-agent:git-<sha12># then drive one provisionOne-time setup. The first GITHUB_TOKEN push creates the package
private-by-default (and only if the org policy permits GITHUB_TOKEN-created
packages, else the push 403s and an owner must pre-create it). An owner sets the
package public once in its settings after that first push; the repo linkage
grants the workflow write access thereafter. Pre-creating the empty package
also settles the immutability guard’s first-publish edge: the guard inspects
:git-<sha> before the creating copy and only an authoritative
manifest unknown frees the tag, so an owner-pre-created (hence
authenticatable) package guarantees the absent-tag inspect classifies cleanly
rather than on a not-yet-existent repository’s error shape.
Pre-merge build check
Section titled “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 tools/toolchain/versions/bun.nix.
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 closure set the
publish-image job’s in-job changed-path gate diffs over in release.yml (the
trigger carries no paths: filter — release-pr needs every commit, so the job
self-gates) — the reviewed source of truth for what changes the published
artifact — including the bun pin file, since the image now builds bun from that
pinned derivation, so a pin move there changes the output. 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
Section titled “Caching”moon task cache — whole-task-output caching, keyed by an inputs hash.