Skip to content

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 detection

The 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.

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 allow puts 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.

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 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 to main and on the nightly schedule it is the full moon run :ci: every task, every project, no affected filter. Affected detection trusts each task’s inputs globs, so the full sweep on everything that reaches main is 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 the hk pre-push hook run.
  • The real-Postgres suites — build-tagged pgtest, and therefore never compiled by the moon battery’s go 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 separate pgtest job, to keep a Postgres-service outage from redding the hermetic gate; they were folded in so there is one required check to gate main on, at the cost that a service-container flake now reds CI (a re-run clears it).

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.

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.

  • 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-ui task runs against fixtures, so no check exercises the UI against a running server.

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:

Terminal window
podman pull ghcr.io/rigelbuild/compass-agent:git-<sha12>
compass-runner --image ghcr.io/rigelbuild/compass-agent:git-<sha12>
# then drive one provision

One-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.

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.

moon task cache — whole-task-output caching, keyed by an inputs hash.