- 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](#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](#build-order)
## Summary
[summary]: #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_on` makes a graph; limits are written
`30m`. 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
[motivation]: #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:
1. **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.
2. **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
[file]: #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](#not).
pikchr-studio's two workflows, as they would be written:
```yaml
# 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: []
```
```yaml
# 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:`
[when]: #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
[errors]: #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
[secrets]: #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`, `cron` by default; `landing` is 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 to `a`.
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
[layers]: #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
[build]: #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
[storage]: #rung-5s-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]: #not
- **Not Woodpecker- or Actions-compatible.** The words are borrowed so
people feel at home; the files are quarry's. Running a
`.woodpecker.yaml` as-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:`](#when).
- **Not approvals.** dagnats has `approval: required` for 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
[open]: #open-questions
1. **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 `when` reads worse in TOML. YAML's traps
(`no` read as false, anchors) are answered by strict parsing, which
the dagnats compiler already does.
2. **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 (`on` and `if`).
3. **Where the files live: `ci/` or a dot-directory (`.quarry/`, as
`.woodpecker/` and `.github/workflows/`).** Recommended: stay in
`ci/`. RFC 0009 and every repository that has CI today already use
it, and zit has no reason to hide the directory.
## Testing
[testing]: #testing
- The compiler: every example in this RFC compiles; each error in
[Errors](#errors) is reported with its position.
- The trigger: a matrix of `when` against push, landing, manual and cron
on the default branch, a protected branch, and an unprotected one,
asserting which workflows and steps start (the shape of
`internal/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.