Skip to content
quarry Artifact [78c4a68c0b]
Login

Artifact [78c4a68c0b]

Artifact 78c4a68c0bd83dd182e5c04ca2f9fde4f3607670dc60b1163aaf071ccf0edba8:


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. A check-in on a branch with an open landing runs on its merge with the landing's target, not its bare tip (below). 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 runs the branch's pipelines on its merge with the target (below). 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.

"quarry_when": "default-branch" is the deploy's key, GitHub's on: push: branches: [main]: the pipeline runs on check-ins of the default branch and nowhere else, by any door. A branch tip, a landing's merge and a Re-run of either skip it; a Re-run of a default-branch check-in runs it, which is how a deploy is repeated or rolled back. Any other value of quarry_when refuses that pipeline in words, because a misspelt default-branch running everywhere would deploy every branch.

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

A landing's checks run on its merge with the target, the way a GitHub pull request's checks run on its test merge (quarry#290). There is no setting: every landing is tested this way.

The ledger is append-only, so the git-world trick — materialize a speculative merge ref, test it, garbage-collect it — is exactly wrong here. The merge is a tree the forge serves, never a check-in:

  1. The runner fetches /archive/pair:<branch tip>:<target tip> exactly as it fetches a check-in's archive. The forge builds it with zit merge-tree against its own store, which merges on the store alone and never writes to it (danmestas/zit#223). A pair that does not merge answers a merge_conflict refusal naming the files, which fails the step once, without the checkout retries: the conflict is the verdict.
  2. The pipeline runs against that tree. Nothing is ever committed. The runner needs no zit, no clone and no credential that could push.

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.

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

One run per change

A change used to be tested up to three times: its bare branch tip on push, its merge when a landing opened, and trunk's merge check-in after the accept. Those are one tree, or nearly; it is tested once, unless the target moves.

Inheritance

The default branch's merge check-in of a tested pair inherits each verdict the pair reached, instead of running again:

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.

A runner holding a secret runs one branch. Any check-in's pipelines may name any task type in the repository's group, so a pipeline marked default-branch keeps a deploy off branches only while nobody writes a second pipeline naming the same task. The runner that holds the deploy credential therefore decides for itself: quarry runner --branch trunk asks the forge which branch each task's check-in is on (/api/v1/r/<repo>/checkin/<hash>, not the task's input, which a pipeline wrote) and refuses anything else, and every landing's merge, before fetching it. Keep such a runner to the deploy's task types and its secret out of every other runner's environment.

A run nothing serves does not run for ever. A step whose task type no worker serves is never dispatched and, with no step timeout running, never ends. A run whose steps no worker has taken up after 24 hours (--ci-expire) is stopped and settles as cancelled, its reason saying no worker serves its task type; the fleet-lease holder does this from the status poll. Cancelled, not failed, because nothing was tested and a failed verdict would mark the check-in. A repository admin can end any run that is going from its page (ci.cancel, the Cancel control), which records who cancelled it.

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 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 leaves a pair verdict standing and is named on the landing; check.report rows render and count; tag opt-in writes on terminal runs and toggling off writes nothing. Building a pair's merged tree never writes to the store, and its test against a real store asserts the store file is byte-identical before and after a clean merge and a conflicting one.