Skip to content
quarry Documentation
Login

Documentation

Summary

Quarry's CI skeleton is live — check-ins trigger dagnats pipelines, a poller mirrors status, terminal runs can write ci-pass/ci-fail tag artifacts — but the accept button cannot see any of it, the pipeline format is raw engine JSON, there are no logs, and runners attach by folklore. This RFC turns the skeleton into a surface: a checks panel on every landing that the accept path reads; a small spec (ci/quarry.yml) compiled by the engine's own CI module; premerge testing that adds nothing to the DAG; logs captured by the engine, offloaded to S3, and read through one viewer; a Runners page whose tokens are how machines — including hosted provider fleets — attach; and two doors for CI quarry does not run: an external checks op in, and webhooks out.

The stance sentence, already written in quarry's own source, stays the architecture: dagnats is the engine, quarry the trigger and display — and, this RFC adds, the policy.

Motivation

An accept today is a human reading a diff plus a clean-merge probe. Every other input to that judgment — does it build, do the tests pass, does the merged result still pass — either does not exist or exists where the accept cannot see it (/r/{repo}/ci lists runs; the landing page does not). Landings and strata are structurally complete and evidence-free.

Meanwhile the parts that do exist speak engine-ese: a pipeline is a raw dagnats WorkflowDef in ci/*.json, which is a users' manual in the same way the old capability-letter table was (RFC 0008 §motivation). And a run that fails answers "failed" with nothing behind the word — no log, no failing step, no way to reproduce.

Architecture: three layers

Each piece lives where somebody else would also use it:

Layer Owns Why there
dagnats core run engine, retries, approval gates; the facilities: log lane (#624), lifecycle events (#625), per-step timestamps (#626), scoped/JIT worker tokens (#627), worker directory (#628), run labels and filters (#629), re-register hash (#630), queue-depth signal (#632) every dagnats front-end needs them; logs and timestamps can only be captured inside the engine and worker SDK
dagnats CI module (mounted in dagnats serve, #633) POST /v1/ci/compile and /v1/ci/validate (the ci.yml compiler, errors as data); the worker set (dagger caller, Dagster adapter, later others); the annotation convention (#630) forge-agnostic CI vocabulary — the module's GitHub front-end uses the identical compiler and workers; none of it knows what a landing is
quarry internal/ci triggers from quarry events; tip-pair premerge orchestration; the checks panel and accept gating; check.report and ci.trigger ops; the Runners page; verdict-tag policy quarry semantics — landings, protected branches, the ledger's rules

Quarry speaks one URL. The compiler is never vendored and never shelled: compilation happens at the engine's own version behind the same base URL (--dagnats), so spec/engine skew is impossible by construction. The one quarry-shaped piece that runs on workers — the premerge checkout step — ships from the quarry repo, because its semantics (tip pairs, read-only credentials, verdict keying) are the forge's.

The spec

A repository opts into CI by committing a file. Two formats, one truth:

checks:
  test:  { call: "test" }
  lint:  { call: "lint" }
  build: { call: "build", needs: [test, lint], timeout: "20m" }

Rules, stated once and enforced by refusing to grow:

  1. No logic in the spec. No expressions, no conditionals, no templating. A step names a task, its needs, and its limits; logic lives in the tasks, which is what a DAG engine is for. This is the trap that ate the incumbent's format, avoided by construction.
  2. Validation is a surface, not a failure mode. The settings page and the empty CI page call /v1/ci/validate and render errors as data — line, field, message — before anything runs. A repository with a spec that does not compile shows the error where the runs would be, not a silent absence.
  3. The empty CI page offers a starter file, the way the ticket editor offers its vocabulary.

Triggers

Four ways a run starts; the first exists today.

  1. Check-ins — the watermark walk (internal/ci/trigger.go), unchanged in shape: new check-in, read the pipeline files at that check-in, compile if yml, start. Run input carries repo, check-in, branch, user, comment, clone URL — and, new, labels (repo=, checkin=, branch=) once dagnats #629 lands, so runs are findable and cancellable by what they are about.
  2. Landings — opening or updating a landing triggers the target branch's pipelines against the landing branch (tip mode) or the merged tree (premerge mode, below). A landing run is labeled landing=<id>; a new push to the branch cancels the stale runs by label (#629) and starts fresh ones.
  3. By hand — a ci.trigger op: run a named pipeline now, with inputs. Deploys, nightlies-on-demand, re-runs. Gated by i (RFC 0004: the check-in letter — if you may write code here, you may run its pipelines); the button on a red check row says "Re-run".
  4. From outside — dagnats' own cron and external trigger envelopes cover schedules and foreign events; and the loop quarry already half-owns closes the fully-external case: outbound webhooks (landing.opened, check-in events, RFC 0005) are what a foreign CI subscribes to, tests, and answers via check.report.

A JSON pipeline whose top-level "quarry_when" is "landing" (quarry's key, namespaced apart from any engine field; the engine ignores it) runs only where a proposal is judged: check-ins on the default branch and pushes to a branch with an open landing. A ci.yml carrying the key is refused, not silently run everywhere. A bare branch push skips it; a re-run by hand runs it. When a landing opens on a tip that lacks a verdict for a required check, the open starts only the pipelines with no verdict there, so a landing opened after its push gets its "quarry_when": "landing" pipelines once, without re-running the rest; this needs the pipeline among the target's required checks. quarry's own race detector (ci/race.json) is one, being the slowest check and one only a proposal needs.

Verdicts on landings

The Gerrit shape: the machine's opinion is a named thing beside the human's decision, never mixed into it.

Premerge: nothing enters the DAG

The ledger is append-only, so the git-world trick — materialize a speculative merge ref, test it, garbage-collect it — is exactly wrong here. Premerge is a checkout:

  1. A worker with read-only credentials clones (or reuses a cached clone), opens the target branch, and runs zit merge <branch-tip>. The merged tree exists only in the worktree; a conflict is itself the verdict, with the tree as evidence.
  2. The pipeline runs against that tree. Nothing is ever committed. The worktree is discarded.

Ledger safety is structural, not policy: nothing was committed, and the credentials could not have pushed it anyway. A malicious pipeline can trash its own worktree and nothing else.

Verdicts attach to the pair of tips (branch, target), never to a check-in hash — the merge check-in does not exist yet, and the one accept eventually writes will hash differently. The consequences fall out:

Tip mode is the default; premerge is opt-in per protected branch (mode: tip | premerge beside the RFC 0007 rule). The later optimisation — a pure zit verb taking two tips and answering with a merged tree tarball or a conflict report, so workers need no checkout at all — is an upstream request for when the checkout path shows its cost, not a blocker.

Rejected: speculative store copies (a real check-in hash, but a useless one, at full-store cost) and test-after-accept with auto-revert (reverts are ledger pollution, and the point is that trunk never goes red).

Logs

Split at the engine's edge (#624, as rescoped there): dagnats owns the hot lane and the contract; retention belongs to the consumer — a forge that needs a verdict to stay explainable for years stores the logs next to the verdict.

Per-step durations come from #626 (shipped: the engine persists StartedAt/CompletedAt).

Quarry's viewer is server-rendered chunks plus an SSE tail, with the spine that makes logs bearable: collapsible per-step groups (<details>), the failed step first, jump-to-first-failure, a permalink to any line — and on every failure a printed repro line (zit clone … && …: the failing task, locally, no forge in the loop), which is this architecture's answer to SSH-into-the-build-VM. Failures that carry the annotation convention (#630) additionally pin onto the landing diff as file/line notes.

Runners

A Runners section at both scopes — /fleet/settings and repository settings — gated by RFC 0008's letters (s fleet, a repo):

Each repository has a worker group — ci.GroupFor(name), its own name folded to one subject token — and a token minted from its Runners page is scoped to that group alone. A group-scoped token cannot reach another group or the ungrouped queue, so the group is the boundary and the mint form asks for a label and nothing else. The fold matters twice: a dot would make the group ambiguous with a dotted task type (the dagnats#695 bypass), and = is the sentinel dagnats encodes a group with (task.{task}.={group}.{runID}), so no repository name can spell a group boundary of its own.

Because the spec lives in the owner's repository, the author chooses the worker_group: their steps publish to — which makes the spelling in the file an authorization decision. ci.pipeline.save refuses a group that is not this repository's own, on the validate path as well as the save path. The risk it answers is not reading another repository's work but writing into it: a spec naming another group publishes its steps onto that repository's machines.

Hosted provider fleets — the Blacksmith class — attach through the same door, which is the point: a provider integrates once with dagnats and serves every dagnats front-end. Their two extras are upstream: single-use JIT registrations (#627) so a fresh VM per job never holds a reusable credential, and the queue-depth signal (#632) to size fleets without polling. A provider-run job is indistinguishable from a basement machine: same logs, same steps, same verdicts.

External CI

For CI quarry does not run — a Jenkins, a bespoke script, a mirror's pipeline — one op closes the loop:

Subscribe (webhooks out) → test (their infrastructure) → report (check.report) is the entire bring-your-own-CI contract.

Verdict tags

Pass/fail as tag artifacts is quarry's own invention — CI history that clones with the repository — and it is also a write into the DAG on every run. So it is opt-in per repository: a Settings toggle, with the fleet default page saying what new repositories start as. Preserve mechanics:

The --ci-verdicts flag becomes the fleet default this setting reads.

Capability mapping

Per RFC 0008, every surface is letters, stated here once:

Surface Gate
See runs, checks, logs the repository read gate, as ci.list today — CI is not more secret than the code
ci.trigger, re-run i
Required-checks config, premerge mode a, beside the RFC 0007 rules
Runners page, tokens (repo / fleet) a / s
check.report a reporter token, scoped to the op
Accept over red, auto-accept arming whoever could accept the landing (RFC 0007 owners; the override additionally requires the stated reason)

Build order

Rung Work Waits on
1 This RFC —
2 Checks panel on landings; required checks beside RFC 0007; disabled-accept wording; override-with-reason. Runs associate with a landing by its branch-tip check-in until rung 5's labels nothing (status via today's poller)
3 check.report + reporter tokens; ci.trigger; the external loop documented nothing
4 Spec: yml detection in the trigger, compile/validate via the CI module; starter file; errors-as-data on the settings and CI pages #633 (#631 folded in)
5 Landing triggers; labels on every run; cancel-superseded; events replace the poll #625, #629
6 Runners page, both scopes #627, #628
7 Log viewer (tail, groups, first-failure, permalinks, repro line, diff annotations) #624, #626, #630
8 Premerge: the checkout step helper, per-branch mode, tip-pair verdicts, carry-forward, auto-accept-when-green nothing upstream
9 Provider polish: JIT tokens surfaced, queue-depth on the fleet page #627 ext, #632

Rungs 2 and 3 ship on today's engine with no upstream movement; that is deliberate — the accept gets its evidence first, everything else makes the evidence better.

What this is not

Testing

The harness grows a fake engine (an httptest server speaking /workflows, /runs, /v1/ci/*) so every rung is testable without NATS: required checks block and unblock the accept control; the override records its comment before the accept; a moved target expires a pair verdict; the premerge step refuses to run with pushable credentials; check.report rows render and count; tag opt-in writes on terminal runs and toggling off writes nothing. The premerge checkout step never invokes commit or push by construction, and its integration test against a real store asserts the ledger's artifact count is byte-identical before and after a full premerge run — including a run whose pipeline tries to commit.