Tangled-compatible personal CI server based on Nix
ci docs workflows.md
9.2 kB · 243 lines
Markdown
at main

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