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 a workflow separate from the gate above. For the full
rationale see the design record
docs/designs/platform/compass-agent-image-publish.md; the durable operational
shape is here.
Ref and tags. The image is ghcr.io/rigelbuild/compass-agent. Every
closure-affecting main build publishes two tags:
: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.
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.
A separate least-privilege workflow. Publishing lives in
.github/workflows/publish-agent-image.yml, not a step in the CI gate and
not a required check. It runs main-only plus workflow_dispatch,
path-filtered to the image’s nix closure, 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. 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 one (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.
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 publish workflow’s
on.push.paths — 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.