Workflow files #
A workflow is a YAML file at .tangled/workflows/<name>.yml (or .yaml); its
name is the file name without the extension. It is pure data: the happy path
(steps) and the closed set of actions a run may take. There are no
conditionals, expressions or loops; a run changes course only when an action's
result says so.
The schema is run.rtw.ci.workflow.
Unknown fields are errors. ci validate checks every workflow in the repo.
For why it works this way, see DESIGN.md §6–8.
when:
- event: [push]
branch: [main]
engine: devenv
steps: [test, lint]
actions:
test: { command: go test ./... }
lint: { command: golangci-lint run }
Top-level fields #
| Field | Required | Meaning |
|---|---|---|
when |
yes | Trigger conditions (below) |
engine |
yes | Must be devenv |
environment |
Map of variable name to value, set for every action. Numbers and booleans become strings. Not for secrets: the file is in the repo | |
repos |
Extra checkouts next to the main one (below) | |
steps |
yes | The happy path: action names, in order |
actions |
yes | Map of action name to action (below) |
engine #
Only engine: devenv workflows are run by this engine; files with any other
engine are skipped (ci validate prints skip), so a repo can keep workflows
for Tangled's spindle (engine: microvm) in the same directory. The reverse
does not hold: a spindle marks a workflow whose engine it lacks as failed, so
keep devenv workflows in repos no spindle watches.
when #
A list of conditions; a push starts the workflow if any one matches.
| Field | Meaning |
|---|---|
event |
push or manual (required). pull_request and schedule are not supported and fail validation |
branch |
Branch globs. A push to a branch matches only if one of these matches |
tag |
Tag globs. A push of a tag matches only if one of these matches |
paths |
Push only: run only if a changed file matches one of these globs |
Globs use * (within one path segment) and ** (any number of segments), so
release/* matches release/1.2 and docs/** matches every file under
docs/. Every list also accepts a single string: event: push is
event: [push].
A push condition without branch or tag matches nothing. A manual start
(the appview, ci trigger, ci run) always runs the workflow it names,
whatever when says.
when:
- event: push
branch: [main, "release/**"]
paths: ["**/*.go", go.mod]
- event: push
tag: "v*"
- event: manual
repos #
Extra repositories checked out for each run, next to the main checkout.
| Field | Required | Meaning |
|---|---|---|
repo |
yes | A repository name on the same forge as the main repo (data), or a full clone URL (ssh://…, https://…, git@host:path) |
path |
yes | Where to check it out, relative to the main checkout. Must be a sibling: ../data |
rev |
Revision to check out. Default: the default branch when the run starts | |
env |
Variable that receives the checkout's absolute path, e.g. SECRETS_DIR |
repos:
- { repo: data, path: ../data, env: SECRETS_DIR }
- { repo: "https://github.com/example/tools", path: ../tools, rev: v1.4.0 }
A name is resolved against the main repo's origin
(git@git.example.org:servers → git@git.example.org:data), or against
$CI_CLONE_URL if set (git@host:{repo}). A URL is named by its last path
element; two different URLs with the same name fail validation. The commit of
every checkout is recorded in the run's events. Locally, ci run --repo data=../data uses a directory instead of a clean clone (see
running.md).
steps #
The happy path: the actions a run takes, in order, when every result is a
plain success. Each step names a declared action, and a step may not appear
twice (a result can route back to an earlier step instead). Actions that are
not steps are reached only by a result's next or by hand (manual).
actions #
A map of action name to action. Names use letters, digits, ., _ and -,
and stop is reserved. Results may route only to actions declared here, so
this is the complete list of what the workflow can do, and the appview renders
it as buttons.
| Field | Meaning |
|---|---|
command |
Required. A bash script (see below) |
dir |
Working directory, relative to the main checkout (../data). Default: the main checkout |
label |
Button and timeline label. Default: the action name |
gate |
Ask a person before running: { prompt, timeout }, or gate: true for the defaults. prompt is the question (default "Run |
input |
{ label }: ask for one line of text when the gate is answered. Requires gate. The action reads the text from the file at $CI_ANSWER |
concurrency |
A named lock: { group, supersede }, or just the group name. supersede cancels older runs waiting at this group's gate. See below |
manual |
Offer the action as a button on the run page at any time, and allow ci action --run |
style |
normal (default) or danger. A danger action needs the owner's session in the appview; agents can't run it |
audited |
A local run that executes this action reports itself to the appview (needs CI_APPVIEW_URL and CI_TOKEN), so the deploy shows up on the board even when started from a laptop |
command #
Each command runs as
devenv shell bash -euo pipefail -c 'cd "$CI_ACTION_DIR"; <command>'
started in the main checkout, so the main repo's devenv pins the toolchain
even when dir points at another repo. -euo pipefail means the first failing
command fails the action. Keep commands short and put the logic in a recipe
(just deploy), so it runs the same from a shell. The variables an action sees
are listed in environment.md; how its exit code and result
decide what runs next is in results.md.
gate #
A declared gate always applies: when routing reaches the action, the run stops and waits for a person to approve or dismiss it in the appview or the terminal. No result can skip it. Nothing runs and no lock is held while a run waits.
concurrency #
Actions in the same group never run at the same time, across runs, workflows
and repos on the runner (a local run with the appview configured takes the
runner's lock too). A run takes the lock when it first runs an action of the
group and holds it until the run ends or pauses at a gate.
With supersede: true, a newer run reaching this group's gate cancels older
runs of the same repo and workflow that are waiting at it, so only the latest
push can be deployed.
manual #
A manual action can be started on its own from a run's page, before or after
that run ends: a rollback, a restart. It starts a child run that shares the
parent's checkouts and $CI_STATE, so it can read what the parent's actions
recorded. The child runs the action (behind its gate, if any), follows only
what its result routes to, and ends with the action's status.
Examples #
Tests only #
# .tangled/workflows/ci.yml
when:
- event: push
branch: main
engine: devenv
steps: [test, lint]
actions:
test: { command: devenv test }
lint: { command: just lint }
A gated deploy with a health check and a rollback #
plan shows what would change and records the hosts; deploy waits for
approval; health-check polls and, if the hosts stay unhealthy, offers a
rollback. rollback is also a button on the run at any time.
# .tangled/workflows/deploy.yml
when:
- event: manual
engine: devenv
repos:
- { repo: data, path: ../data, env: SECRETS_DIR }
steps: [plan, deploy, health-check]
actions:
plan:
command: just plan
deploy:
command: just apply
label: Deploy
gate: { prompt: "Deploy these hosts?" }
concurrency: { group: fleet, supersede: true }
audited: true
health-check:
command: just health-check
rollback:
command: just rollback
label: Roll back
style: danger
manual: true
audited: true
gate: { prompt: "Roll back to what ran before this deploy?" }
concurrency: { group: fleet }
The recipes report to the engine with ci-result. plan passes its host list
on through $CI_STATE, and ends the run green if there is nothing to deploy:
# just plan
hosts=$(./plan.sh) # builds, diffs, prints changed hosts
if [ -z "$hosts" ]; then
ci-result ok --summary "Nothing to deploy" --stop
else
ci-result ok --summary-file plan.md --output hosts="$hosts"
fi
health-check turns a failure into a choice between rolling back and checking
again; a choice always waits for a person:
# just health-check
if ./probe.sh "$(jq -r .hosts "$CI_STATE/outputs.json")" > health.ndjson; then
ci-result ok --summary "All hosts healthy"
else
ci-result fail --summary "Hosts unhealthy after deploy" \
--next rollback --then stop \
--next health-check --label "Check again" \
--attach "health probes=health.ndjson"
fi