Artifact 43bc54abfb8e84511df04054b567fa05119d82f5f2efb4622a25efd09c142fa5:
- File docs/rfcs/0019-workflows.md — part of check-in [a8c86a2810] at 2026-09-29 21:54:30 on branch rfc-0019-workflows — RFC 0019: CI files a person can write, where they run, and secrets only reviewed code can reach (user: zeitgeist, size: 13811) [annotate] [blame] [check-ins using]
- Feature Name:
workflows(a CI file a person writes in five minutes: steps with their commands, where they run, and secrets that only reviewed code can reach) - Start Date: 2026-09-29
- Status: Draft; three questions open (see Open questions)
- Amends: RFC 0009 §The spec (replaces its YAML example and keeps its rules), §Runners (a runner no longer holds a repository's secrets)
- Depends on: RFC 0007 (protected branches decide where a secret may go), RFC 0008 (letters gate the secrets page), RFC 0009 (engine, runs, premerge pairs)
- Upstream: dagnats
ci/(the compiler RFC 0009 says quarry never vendors); issues to file are listed in Build order
Summary
Setting up CI for pikchr-studio, the second repository on zeitforge to get it, took two JSON files to say one thing, timeouts in nanoseconds, a retry block whose zero means "run once", a worker group typed by hand, task names that had to start with the repository's name (learned from a 403), and a dedicated runner with a production token in a root-only file on the box, guarded by a flag written that afternoon. Every one of those is a fact about the engine that the author had to learn.
Codeberg's Woodpecker, GitHub Actions, GitLab and sourcehut all converge on the same shape, and this RFC adopts it:
- One YAML file per workflow. A step is a name and the shell
commands it runs;
depends_onmakes a graph; limits are written30m. The runner manifest (ci/runner.json) and the task-type naming disappear from what the author writes. when:says where a workflow or a step runs, by event (push,landing,manual,cron), branch glob and changed path. It is a filter, not an expression language, so RFC 0009's first rule (no logic in the spec) holds.- Secrets live in repository settings, not on runner machines. A step asks for one by name; the forge hands it to a runner only for a run that qualifies, and by default that means a check-in on a protected branch, never a landing's merge. This is GitHub's environments and GitLab's protected variables, and it is what makes "deploy after a landing" safe without a special runner.
Motivation
The deploy question showed the gap. "Deploy pikchr-studio after a
landing" needed a new pipeline key (quarry_when: default-branch), a new
runner flag (--branch trunk), a new system user, and a second worker
token, and it still only works for trunk. The user asked the right
question: why only trunk, and not wherever the user says?
The answer every other forge gives has two halves, and quarry had only been building one:
- The file says where a step wants to run. It is read at the check-in under test, so a branch can rewrite it. It is a convenience.
- Settings say where a secret may go. A branch cannot edit settings. This is the safeguard.
GitHub puts a deploy's branch policy on the environment, in settings, not in the workflow; GitLab exposes protected variables to protected branches only; Woodpecker limits each secret by event and keeps it from pull requests by default. quarry already has the right primitive for the second half: a protected branch (RFC 0007) is one a check-in reaches only through a landing somebody accepted. "Secrets reach protected branches only" is therefore "secrets reach reviewed code only", configured where the owner already configures who may land.
The file
ci/<name>.yml, one workflow per file, as RFC 0009 already named it. The
vocabulary is Woodpecker's where Woodpecker has a word, because Codeberg
users will copy from it and the words are plain: steps, commands,
depends_on, when, environment, from_secret. Nothing promises that
a .woodpecker.yaml runs unchanged; see What this is not.
pikchr-studio's two workflows, as they would be written:
# ci/test.yml: every push and every landing
steps:
- name: test
commands:
- npm test
environment:
PIKCHR_STUDIO_DIAG: "1"
timeout: 30m
- name: worker
commands:
- npx --yes [email protected] deploy --dry-run --outdir dist-dry-run
depends_on: []
# ci/deploy.yml: publish after a landing
when:
event: push
branch: trunk
steps:
- name: test
commands: [npm test]
- name: deploy
depends_on: [test]
commands:
- npx --yes [email protected] deploy
environment:
CLOUDFLARE_API_TOKEN:
from_secret: cloudflare
What the author no longer writes, and where it went:
| Today | Now |
|---|---|
ci/runner.json naming each task's argv |
the step's commands, run as one set -e script |
task type pikchr-studio.test |
derived: quarry names the task from the repository and step |
"worker_group": "pikchr-studio" |
derived: always the repository's own group (RFC 0009 already refuses any other) |
"timeout": 1800000000000 |
timeout: 30m; a default applies when absent |
"retry": {"max_attempts": 0} |
the default is once; retries: 2 when wanted |
"quarry_when": "landing" / "default-branch" |
when: |
"_comment" |
YAML comments |
Steps run in the order written unless one has depends_on, which turns
the workflow into a graph (Woodpecker's rule, so the common case needs no
graph at all). A workflow may depends_on another workflow by file name.
when:
Allowed at the top of a file (the whole workflow) and on a step. Every key is a filter; all given keys must match; a list means any of.
| Key | Matches | Notes |
|---|---|---|
event |
push (a check-in on a branch), landing (a landing's tested merge, RFC 0009 §premerge), manual (Re-run, ci.trigger), cron |
absent means push and landing, today's behaviour |
branch |
the check-in's branch, or for landing the target branch; globs, include/exclude |
same doublestar matching Woodpecker uses |
path |
a changed file; globs, include/exclude |
for landing, every file the landing changes |
cron |
a schedule's name, when event: cron |
schedules come from dagnats' existing on.schedule |
Deliberately absent: Woodpecker's evaluate (an expression language)
and GitHub's if:. RFC 0009 rule 1 stands: filters decide where, tasks
decide logic. A workflow that needs a real condition runs a step that
exits early.
quarry_when in a JSON pipeline keeps working and is documented as the
engine-format spelling of when: {event: landing} and of
when: {event: push, branch: <default>}.
Errors
Unknown keys are errors with a line and a column; so are a depends_on
naming no step, a cycle, a from_secret naming no secret the repository
has, and a when.event quarry does not emit. They show on the CI page
where the runs would be and on the landing's checks panel, per RFC 0009
rule 2. A misspelt key never silently runs everywhere.
Secrets
A Secrets section in repository settings, gated by a (RFC 0008),
holds named values. Each secret has a policy:
- Where it may go (the environment rule): protected branches by default, or named branch globs, or everywhere. A landing's merge never qualifies, whatever the policy says: it is unreviewed code by definition.
- Which events:
push,manual,cronby default;landingis not offered. - Who may re-run with it: a manual run of a qualifying check-in is
allowed to anyone holding
i, the letter that may run pipelines (RFC 0009); an owner can raise it toa.
Values are write-only in the UI: set, replace, delete, never shown. The settings trail (as for every setting) records who changed which secret and when, never the value.
Delivery never goes through the engine. dagnats persists a run's
input, so a secret in it would sit in NATS storage. Instead the runner,
when it claims a task whose step asks for secrets, asks the forge's
bridge for them with its worker token. The forge answers only if the
task's check-in qualifies, decided the way --branch decides today: it
reads the check-in's branch from its own index, not from the task input,
and refuses a pair outright. The runner puts the values in the step's
environment and masks them in the log it streams (every occurrence of a
value is replaced before it leaves the machine).
This retires the deploy runner. A runner holds a worker token and
nothing else; quarry runner --branch stays as an extra lock for owners
who want a machine that runs one branch, but it is no longer how a
secret is protected.
Where each piece lives
RFC 0009's three layers, unchanged in shape:
| Layer | Does |
|---|---|
| dagnats CI module | parses and validates the YAML (steps, commands, depends_on, timeouts, retries, environment, from_secret names), compiles it to a WorkflowDef, and returns each workflow's and step's when as structured data it does not interpret |
| quarry trigger | evaluates when against quarry's events (it is the only one that knows what a landing or a protected branch is), launches the matching workflows with the matching steps |
| quarry runner | runs a step's commands from the task input; fetches and masks secrets; still honours ci/runner.json for JSON pipelines |
| quarry forge | the Secrets page, storage, the bridge endpoint that decides delivery |
when stays out of dagnats on purpose: its GitHub front-end has its own
events, and a forge-agnostic compiler that knew "landing" would not be.
Build order
| Rung | Work | Waits on |
|---|---|---|
| 1 | This RFC; the three open questions answered | the owner |
| 2 | dagnats: spec v2 in the CI module: steps with commands, depends_on, duration strings, environment with from_secret (names only), when passed through as data; line/column errors; the current checks: spec keeps compiling |
a dagnats issue (to file) |
| 3 | quarry runner: a shell task type that runs commands from the task input; masks a list of values in its log sink |
nothing |
| 4 | quarry trigger: read ci/*.yml through the compiler, evaluate when per workflow and per step, map quarry_when onto it; errors on the CI page and checks panel |
rung 2 |
| 5 | Secrets: settings section, storage, trail, the bridge endpoint with the delivery rule, runner fetch and masking | rung 3 |
| 6 | Starter files on the empty CI page (test; test plus deploy); docs/running.md and a how-to | rung 4 |
| 7 | Move quarry's own CI and pikchr-studio to YAML; pikchr-studio's deploy uses a cloudflare secret; stop the deploy runner and delete its token |
rungs 4, 5 |
Rungs 2 and 3 are independent and can go in parallel; rung 5 can start
beside rung 4. The interim trunk-only deploy (quarry_when:
default-branch, --branch trunk) is what pikchr-studio uses until rung 7.
Rung 5's storage
Secrets are encrypted at rest with a key the forge reads from its environment (not from the database it encrypts), so a copied database or backup does not carry usable secrets. A backup restored without the key restores the names and the policies, and every value must be set again; the Secrets section says so when it happens.
What this is not
- Not Woodpecker- or Actions-compatible. The words are borrowed so
people feel at home; the files are quarry's. Running a
.woodpecker.yamlas-is would mean a container per step, and running Actions workflows would mean their marketplace; RFC 0009 already defers an Actions bridge and that stands. - Not containers. Steps run where the runner runs, as today. A step
image:is the obvious next step and is left to its own RFC, because it changes what a runner machine must have installed (a container runtime) and who may run what on it. - Not an expression language. See
when:. - Not approvals. dagnats has
approval: requiredfor a deploy; a human gate in front of a secret is a natural addition to the policy and is left for when somebody needs it.
Open questions
- Format: YAML or TOML. Recommended: YAML. No mainstream CI uses
TOML; the four above all use YAML, people copy CI files between
projects, and the nested
whenreads worse in TOML. YAML's traps (noread as false, anchors) are answered by strict parsing, which the dagnats compiler already does. - Vocabulary: Woodpecker's (
steps,commands,when), GitHub's (on,jobs,needs,run), or dagnats' current (checks,deploy). Recommended: Woodpecker's. It is the smallest, Codeberg's users know it, and it has one word for "where" (when) instead of GitHub's two (onandif). - Where the files live:
ci/or a dot-directory (.quarry/, as.woodpecker/and.github/workflows/). Recommended: stay inci/. RFC 0009 and every repository that has CI today already use it, and zit has no reason to hide the directory.
Testing
- The compiler: every example in this RFC compiles; each error in Errors is reported with its position.
- The trigger: a matrix of
whenagainst push, landing, manual and cron on the default branch, a protected branch, and an unprotected one, asserting which workflows and steps start (the shape ofinternal/ci/defaultbranch_test.go). - Secrets: a step asking for a secret on an unprotected branch, on a landing's merge, and on a protected branch; only the last gets it, and the runner's log shows the mask, never the value. The bridge refuses a worker token from another repository.
- A browser pass over the Secrets section and the CI page's error view, per the standing rule that UI is verified by looking.