Op Waves
This guide is for a project that applies the same Op to several places in order, such as dev before staging before prod, or one environment made of many databases. Each place is a run of the Op against one target. Runs that go out together form a wave, and each wave waits on one gate before it applies. chant run --generate renders the waves as a pipeline for GitHub, GitLab or Forgejo, and every job in it runs chant run wave.
Write the spec
Section titled “Write the spec”The spec is a JSON file committed to the repository. Its jobs read it at run time, so the pipeline and the runs never disagree.
{ "name": "migrations", "op": "migrate", "plan": ["./bin/plan", "{target}", "{plan}"], "apply": ["./bin/apply", "{target}", "{plan}"], "waves": [ { "name": "dev", "runs": [{ "target": "dev" }], "gate": "never" }, { "name": "staging", "runs": [{ "target": "staging" }], "gate": "on-destructive" }, { "name": "prod", "runs": [{ "target": "prod" }], "gate": "always", "environment": { "name": "production" } } ]}| Field | Meaning |
|---|---|
name | The pipeline’s name, and the op every wave gate is recorded under |
op | The Op a run runs when the run names none (runs[].op overrides it) |
plan | Plans one run and writes its plan file to {plan} |
apply | Applies one run. Default chant run {op} --env {target} |
gate | The base gate name. Default: name |
branches | Pushes to these branches run the waves. Default ["main"] |
base | The commit the gate policy is read from. Default HEAD^1 |
waves[].runs | One entry per target. A target appears once per wave |
waves[].gate | always, on-destructive or never. Default always |
waves[].approval | ledger, pr-review or sealed: which approvals count (Which approvals count). Default ledger |
waves[].shares | Split a wide wave into a deciding job and this many share jobs |
waves[].environment, variables, setup | The wave’s forge environment, its jobs’ variables, and steps run after the checkout |
resume | { "schedule": "<cron>" } renders a scheduled job that resumes waiting waves whose approval arrived (Resume the waiting job) |
The commands are argv templates. {op}, {target}, {wave} and {plan} are filled in per run. The plan command writes JSON:
{ "planDigest": "jcs1-sha256:...", "destructive": false, "empty": false }planDigest names the plan; two plans that would do the same thing share one. destructive is what on-destructive waits on. A run whose plan is empty changes nothing, and a wave whose runs are all empty never waits.
Render the pipeline
Section titled “Render the pipeline”chant run --generate github --spec waves.jsonchant run --generate gitlab --spec waves.jsonchant run --generate forgejo --spec waves.jsonRun it from the repository root. GitHub and Forgejo get <name>.yml in their workflow directory, and GitLab gets <name>.gitlab-ci.yml at the root for .gitlab-ci.yml to include. The file can also hold { "waves": <spec>, "options": { ... } }, where options sets the image, beforeScript, extraScript and variables for every job. The default image is node:22, which has git; install chant in it with beforeScript. From code, generateOpWavesPipeline(spec, lexicon, { specFile }) in @intentius/chant/op does the same thing.
Each wave is one job, wave-<k>-<name>, and it needs the job before it. A push to the branch runs wave 1. If a wave waits or fails, the waves after it do not run.
What a wave job does
Section titled “What a wave job does”chant run wave --spec waves.json --wave <k> does three things:
- Plans every run in wave
kwith theplancommand and reads each plan file. - Reads the wave’s
gatefrom the spec file at the base commit, and decides. - Applies every run with the
applycommand.
When the policy asks for an approval, the wave waits on gate <gate>-wave-<k> under op <name>. The gate is bound to the wave’s set digest: one digest over every run’s { target, planDigest }, the same set digest a component fan-out’s wave binds. An approval of that digest lets the wave apply. If any run’s plan changes, the digest changes, and so does what has to be approved.
| Policy | The wave waits when |
|---|---|
always | at least one run’s plan changes something |
on-destructive | at least one run’s plan is destructive |
never | never |
A waiting wave applies nothing and exits 3. It records a pending fact on chant/lifecycle and prints the approval command:
chant approve migrations migrations-wave-3 --plan jcs1-sha256:...Approve it, then start the job again: by hand in the forge (GitHub and Forgejo call it re-running failed jobs, GitLab calls it a retry), or with --resume as the next section shows. The wave plans again and applies once it finds the approval for its digest.
Resume the waiting job
Section titled “Resume the waiting job”A push to chant/lifecycle starts no pipeline, so an approval alone leaves the job stopped. When the wave job runs in CI, its pending fact records where it runs: the forge, the run, and on GitLab the job. --resume uses that to start the job again after recording the approval:
chant approve migrations migrations-wave-3 --plan jcs1-sha256:... --resumeThe approval is recorded and pushed first. If the push does not land, nothing is resumed, because the resumed job would not find the approval. A gate with a quorum is resumed only once the quorum is met.
| Forge | What --resume asks | Least access its token needs |
|---|---|---|
| GitHub | re-run the failed jobs of the run (POST /repos/{repo}/actions/runs/{run}/rerun-failed-jobs); the waves after it run too | actions: write: a fine-grained token with Actions read and write, or a job’s GITHUB_TOKEN granted it |
| GitLab | retry the waiting job (POST /projects/{id}/jobs/{job}/retry); the jobs it skipped run after it | a project or personal access token with the api scope, as a member with the Developer role. CI_JOB_TOKEN cannot retry jobs |
| Forgejo | dispatch the workflow again on its branch (POST /repos/{repo}/actions/workflows/{file}/dispatches), since Forgejo has no API to re-run a run | a token with the write:repository scope |
The token is read from CHANT_FORGE_TOKEN, else GITHUB_TOKEN or GH_TOKEN on GitHub and Forgejo, else GITLAB_TOKEN on GitLab. On GitHub and GitLab the run resumes at the commit that waited. A Forgejo dispatch runs the whole workflow at the branch’s head, so the waves before the gate run again. Their plans are empty because their runs already applied, so they wait for nothing. Their apply command still runs again and has to be safe to repeat.
Resuming approves nothing. The resumed job decides the gate again from the ledger. A run that is still going, that succeeded, or that was resumed already (a later GitHub attempt, a newer GitLab job of the same name, a Forgejo run of the workflow created after the approval) is left alone.
On a schedule
Section titled “On a schedule”With "resume": { "schedule": "*/10 * * * *" } in the spec, the rendered pipeline gets a job that runs chant run resume --op <name> on that schedule. It resumes every waiting wave of the spec whose approval has arrived, so an approval needs no --resume and no second step.
| Forge | The resume job |
|---|---|
| GitHub | <name>-resume.yml, on the cron, with permissions: { contents: read, actions: write } and the job’s own token |
| GitLab | <name>-resume in the same file, run only in scheduled pipelines. Create a pipeline schedule with the cron, and set CHANT_FORGE_TOKEN to a project access token as in the table above |
| Forgejo | <name>-resume.yml, on the cron, with the CHANT_FORGE_TOKEN secret |
Which approvals count
Section titled “Which approvals count”A wave’s approval decides which approvals of its digest let it apply. Every mode binds the digest, so an approval of other plans never counts.
approval | Counts | What it proves |
|---|---|---|
ledger (default) | any chant approve of the digest on chant/lifecycle | someone who can push to chant/lifecycle approved these plans, in whatever name they wrote |
pr-review | the merged pull request’s approving review of its head, by a writer other than its author, when the head planned this digest; and any chant approve of the digest, as under ledger | a writer other than the author approved the change whose plans these are |
sealed | only a chant approve --sign whose seal verifies against the signers file at the base commit (.chant/allowed_signers, or the file .chant/trust.json names), for the approver it names | a listed person approved these plans |
pr-review
Section titled “pr-review”The pull request’s pipeline records what its head plans. chant run wave --spec waves.json --record-plans plans every wave at the head and writes each wave’s digest to _wave-plans/<name>/<head>.json on chant/lifecycle. The rendered pipeline runs it on each pull request (<name>-plans.yml on GitHub and Forgejo, <name>-record-plans in GitLab merge request pipelines), and the job needs to push to chant/lifecycle.
After the merge, a wave with no chant approve of its digest reads the review of the pull request that merged the commit:
| The review | The wave |
|---|---|
| a writer other than the author approved the head, and the head planned this digest | records the approval on chant/lifecycle (the pull request, its head and the reviewers) and applies |
| the same, but the head planned another digest (the plans moved after the review) | waits, with the chant approve command for the new digest |
| only the author approved, no writer approved the head, a writer requests changes, no plans were recorded for the head, or no pull request merged the commit | waits, as under ledger |
| Forge | An approving review | A writer | Token |
|---|---|---|---|
| GitHub | the reviewer’s newest review is an approval that names the head commit | write, maintain or admin on the repository | pull-requests: read, which the rendered workflow asks for |
| Forgejo | the same, not stale or dismissed | the review is official, which Forgejo marks for a reviewer with write access | the job’s token |
| GitLab | an approval of the merge request, counted only when the project removes approvals on a new push (Settings > Merge requests > Remove all approvals when commits are added), since a GitLab approval names no commit | Developer or higher | CHANT_FORGE_TOKEN with read_api |
sealed
Section titled “sealed”Add .chant/allowed_signers in a reviewed change, one <principal> <key> line per approver, then approve with --sign:
chant approve migrations migrations-wave-3 --plan jcs1-sha256:... --actor github:alice --sign ~/.ssh/id_ed25519The signers file is read at the base commit, like the policy. A pull request that adds its author’s key to it cannot use that key to approve its own waves. Without a signers file at base, no approval counts.
Where the policy is read from
Section titled “Where the policy is read from”The policy and the approval mode for wave k come from the spec file as it was at the base commit, HEAD^1 by default. On a merge or a squash onto the branch, that is the branch before the change. A pull request that sets prod to never merges, and prod still waits under the policy the branch had. If the wave or the file does not exist at the base commit, the wave is always. The jobs check out the full history so the base commit is there.
Everything else in the spec is read from the commit being applied.
A wave over many targets
Section titled “A wave over many targets”A wave with hundreds of targets can be split with shares:
{ "name": "prod", "shares": 4, "runs": [{ "target": "tenant-001" }, { "target": "tenant-002" }] }This renders wave-<k>-prod-decide and wave-<k>-prod-share-1 to -share-4. The deciding job plans every run and decides one gate over all of them. Its decision (.chant/op-waves/<name>/wave-<k>.json) is kept as an artifact. A share job plans its slice of the runs again, with the runs sorted by target and cut into contiguous slices. It applies only the runs whose plan digest matches the recorded decision. When a run’s plan has moved since then, that run is named and skipped. The rest of the slice still applies, and the job exits 4. The next wave needs every share job.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
| 0 | The wave applied, or the deciding job decided it may apply |
| 1 | A plan or an apply failed |
| 3 | The wave is waiting for an approval of its set digest; nothing in it ran |
| 4 | A share job refused a run whose plan moved since the decision |
The forge’s own reviewers
Section titled “The forge’s own reviewers”A wave’s environment lands on its jobs on GitHub and GitLab, where a protected environment can hold the job for a reviewer before it starts. Forgejo Actions has no environments, so the Forgejo workflow drops the key and its header says so. The chant gate holds the wave on all three.
See also
Section titled “See also”chant run: therun wavecommand and its flags- Ops: gates, and why a gate approves a plan rather than the next run
- Pinned Module Rollout: waves of Terraform roots