Workflow reference
A workflow is a YAML file in .tangled/workflows/ at the root of your
repository. Your repository’s spindle reads every file in that directory
at the triggering commit and runs the ones whose trigger matches.
Writing Workflows walks through a first
workflow; this page lists every field.
All fields
Section titled “All fields”Every field a workflow file accepts. Each one links to its section below.
# common fields, every enginewhen: # list of conditions, any one matches - event: [push, pull_request, manual, schedule] branch: ["main", "release-*"] # push and pull_request tag: ["v*"] # push only types: [opened, reopened, synchronize, closed, merged] # pull_request only paths: ["src/**"] # changed files schedule: # schedule only - cron: "H 2 * * *" timezone: Europe/London # default UTCengine: microvm # microvm, nixery or dummy (required)clone: skip: false depth: 1 submodules: true tags: trueenvironment: KEY: valuecache: - key: go-mod # required hash: [go.sum] fallback: false paths: [.gocache] # required compression-level: 5 # 1 to 19 when: on-success # or alwayssteps: - name: test command: go test ./... environment: KEY: valueruns_on: [arm64] # executor labelsaudience: sts.amazonaws.com # oidc token audience
# microvm onlyimage: nixosdependencies: [go, "unstable#zig", "github:nixos/nixpkgs#hello"]registry: nixpkgs: github:nixos/nixpkgs/nixos-unstablesubstituters: https://nix-community.cachix.org: "nix-community.cachix.org-1:..."services: postgresql: truevirtualisation: docker: true
# nixery onlydependencies: nixpkgs: [go]Pipelines
Section titled “Pipelines”One trigger event produces one pipeline. Each workflow file that matches the event becomes a workflow in that pipeline and runs in its own container or microVM, in parallel with the others. A workflow is a list of steps run in order; the first step that exits non-zero fails the workflow, and later steps don’t run.
A workflow has a set of common fields that apply no matter which engine you pick:
| Field | Required | Purpose |
|---|---|---|
when | no | When the workflow runs. Without it, the workflow runs for every event. |
engine | yes | Which engine runs the workflow. |
clone | no | How the repository is fetched. |
environment | no | Environment variables for every step. |
cache | no | Directories kept between runs. |
steps | no | The commands to run. |
runs_on | no | Executor labels, on spindles that use a mill. |
audience | no | Request an OIDC ID token for this audience. |
On top of these, each engine has its own options for dependencies and images. See Engines.
The spindle rejects unknown keys: a typo such as registre: fails the
workflow with an error that points at the offending line, instead of
being ignored.
Trigger
Section titled “Trigger”The trigger defines when a workflow runs. It’s the when field, a list
of conditions. The workflow runs if any condition matches. Each
condition has these fields:
event: required. A string or a list of one or more of:push: a branch or tag is pushed to the repository.pull_request: a pull request targeting this repository changes. Seetypes.manual: marks the workflow as one you start by hand. The spindle doesn’t evaluatewhenfor manual runs at all: it runs the workflows the caller names, or every workflow.schedule: the workflow runs according to itscronexpressions using the default-branch name and commit saved when spindle last refreshed the repository’s schedules.
branch: Defines which branches the workflow should run for. Withpush, commits to the branch(es) listed here trigger the workflow. Withpull_request, updates to pull requests targeting the branch(es) listed here trigger the workflow. Supports glob patterns using*and**(e.g.,main,develop,release-*). Eitherbranchortag(or both) must be specified forpushevents.tag: Defines which tags the workflow should run for. Only used withpush: when tags matching the pattern(s) listed here are pushed, the workflow triggers. Supports glob patterns using*and**(e.g.,v*,v1.*,release-**).types: Only used withpull_request. Which pull request actions trigger the workflow:opened,reopened,synchronize(a new round was pushed),closed,merged. Defaults toopened,reopenedandsynchronize.paths: Glob patterns over changed file paths. When set, the condition only matches if at least one changed file matches a pattern. For pushes, if the spindle can’t compute the list of changed files, the condition is not skipped on that account.schedule: A list of scheduled trigger entries. This field is required forscheduleevents and invalid for other events. Each entry requires acronfield containing a five-field expression (minute hour day-of-month month day-of-week) and accepts an optional IANAtimezone, which defaults toUTC. HashedHfields are supported; seconds, nicknames such as@daily, and timezone prefixes are not. A condition containingschedulecannot also specifybranch,tag,types, orpaths.
* matches any run of characters except /; ** also matches /. Any
of the list fields accepts a single string instead of a list.
For example, a workflow that runs when commits are pushed to main or
develop, when pull requests that target main are updated, or by
hand:
when: - event: ["push", "manual"] branch: ["main", "develop"] - event: ["pull_request"] branch: ["main"]To run a deployment workflow when tags matching v* are pushed:
when: - event: ["push"] tag: ["v*"]You can combine branch and tag patterns in a single condition. The workflow triggers if either matches:
when: - event: ["push"] branch: ["main", "release-*"] tag: ["v*", "stable"]Only run when documentation changes, and also when a pull request is merged:
when: - event: push branch: main paths: ["docs/**", "mkdocs.yml"] - event: pull_request branch: main types: [merged]Pull request triggers
Section titled “Pull request triggers”A pull_request run starts on its own when the pull request’s author
has push access to the repository. Any other pull request’s run waits,
blocked, until a collaborator approves it. The checkout is the pull
request’s source commit; branch matches the target branch.
Only a run of a branch of this repository that needed no approval gets the repository’s secrets, caches and ID token. Runs of fork and patch-based pull requests, and every approved run and its retries, get none of them: approving lets the code run, untrusted.
Schedule triggers
Section titled “Schedule triggers”Scheduled workflows use the saved default-branch revision, including after a spindle restart. A condition can contain multiple schedule entries, each with its own timezone. For example, this runs at a stable minute during the 02:00 hour in New York and at 09:00 on weekdays in London:
when: - event: schedule schedule: - cron: "H 2 * * *" timezone: America/New_York - cron: "0 9 * * 1-5" timezone: Europe/LondonH spreads scheduled work using a deterministic hash of the repository
DID and workflow filename. The minute stays stable across restarts;
changing either identity changes the hash. Ranges and steps are
supported, for example H(0-29)/10 or H/15. Unbounded day-of-month
hashes use days 1–28. Exact expressions remain exact; spindle logs a
warning recommending H rather than silently changing their timing.
Spindle checks schedules once per minute. The minimum interval defaults
to five minutes and is configured with
SPINDLE_SCHEDULE_MINIMUM_INTERVAL (for example 10m). It must be a
positive whole number of minutes. Spindle rejects workflows whose
combined schedule entries have distinct occurrences closer than this
interval. Validation is best-effort: it examines up to 400 days or 4096
merged occurrences, whichever comes first. Simultaneous entries count as
one run. Runtime claims also enforce the minimum interval.
SPINDLE_SCHEDULE_CONCURRENCY controls parallel repository dispatches
and startup schedule refreshes (default 4). Dispatch finishes each
minute’s batch before starting the next. Occurrences while spindle is
offline are not replayed.
Skipping CI
Section titled “Skipping CI”To skip CI for a push, pass a Git push option:
git push -o skip-cici-skip is also accepted. The push is recorded as usual; no pipeline
starts for it.
Engine
Section titled “Engine”The required engine field picks the engine the workflow runs on. A
workflow without it fails with missing engine. The engines are:
nixery: This uses an instance of Nixery to run steps, which allows you to add dependencies from Nixpkgs (https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org. Each step runs in its own container. See Nixery Engine.microvm: Runs the whole workflow inside its own microVM. Has configuration features for NixOS images that let you enable services, run Docker inside the VM, and so on. See microVM Engine.
Which engines are available depends on the spindle. A spindle operator can run either or both.
engine: "nixery"Clone options
Section titled “Clone options”When a workflow starts, its first step fetches the triggering commit
into the workspace. The optional clone field changes this:
skip:trueskips the fetch. Useful when the workflow needs nothing from the repository. Defaultfalse. Settingdepth,submodulesortagstogether withskipproduces a warning.depth: How many commits to fetch, counting back from the triggering commit.0or unset fetches 1, only the commit that triggered the workflow.submodules: Recursively fetch Git submodules (https://git-scm.com/book/en/v2/Git-Tools-Submodules). Defaulttrue.tags: Fetch tags as well. Defaulttrue.
The default settings are:
clone: skip: false depth: 1 submodules: true tags: trueThe fetch uses the repository’s HTTPS clone URL on its knot. The checkout is detached at the triggering commit.
Environment
Section titled “Environment”The environment field defines environment variables available
throughout the entire workflow. Do not put secrets here: these values
are visible to anyone who can read the repository. Add secrets in the
repository’s settings instead; see Secrets.
environment: GOOS: "linux" GOARCH: "arm64" NODE_ENV: "production" MY_ENV_VAR: "MY_ENV_VALUE"By default, the following environment variables are set:
| Variable | Value |
|---|---|
CI | Always true. |
TANGLED_PIPELINE_ID | The id of the current pipeline. |
TANGLED_PIPELINE_KIND | One of push, pull_request, manual or schedule. |
TANGLED_PIPELINE_SOURCE | DID of the repository the code was checked out from. Differs from the repository’s own DID only for manual runs of a fork. |
TANGLED_REPO_KNOT | The repository’s knot hostname. |
TANGLED_REPO_DID | The DID of the repository owner. |
TANGLED_REPO_REPO_DID | The repository’s own DID. |
TANGLED_REPO_NAME | The name of the repository. |
TANGLED_REPO_DEFAULT_BRANCH | The default branch of the repository. |
TANGLED_REPO_URL | The full clone URL of the repository. |
These variables identify the selected git revision. SHA variables are available for every trigger kind; ref variables are available for pushes, pull requests, schedules, and manual runs that specify a ref:
| Variable | Value |
|---|---|
TANGLED_REF | The full git reference (e.g., refs/heads/main or refs/tags/v1.0.0). For pull requests, the source branch. |
TANGLED_REF_NAME | The short name of the reference (e.g., main or v1.0.0). |
TANGLED_REF_TYPE | branch or tag. |
TANGLED_SHA | The commit SHA that triggered the pipeline. |
TANGLED_COMMIT_SHA | Alias for TANGLED_SHA. |
Schedule triggers additionally provide:
| Variable | Value |
|---|---|
TANGLED_SCHEDULED_AT | The UTC minute selected by the cron expression. |
These variables are only available when the pipeline is triggered by a pull request:
| Variable | Value |
|---|---|
TANGLED_PR_SOURCE_BRANCH | The source branch of the pull request. |
TANGLED_PR_TARGET_BRANCH | The target branch of the pull request. |
TANGLED_PR_SOURCE_SHA | The commit SHA of the source branch. |
TANGLED_PIPELINE_SOURCE_BRANCH | Same as TANGLED_PR_SOURCE_BRANCH. |
TANGLED_PIPELINE_TARGET_BRANCH | Same as TANGLED_PR_TARGET_BRANCH. |
Manual runs expose each input passed with the trigger as
TANGLED_INPUT_<KEY>, with the key upper-cased.
Repository secrets are added to the environment of every step, and
TANGLED_ID_TOKEN when audience is set. Step-level
environment overrides workflow-level environment.
The cache field lets a workflow persist directories across pipeline
runs. Before the first step, the engine looks up each entry and extracts
the matching archive into the workspace; after the steps finish, the
paths are archived again and stored.
key: required. Name the cache is saved under: letters, digits,.,_and-, starting with a letter or digit, at most 128 characters. Keys must be unique within a workflow. Entries are scoped to the repository and engine, and each saved entry is tagged with the workflow file, ref and commit that produced it.hash: optional list of repo files (lockfiles, manifests) whose content is folded into the entry. The entry is stored as<key>-<hash of those files>, so editinggo.suminvalidates the cache without bumping the key by hand. Paths are relative to the repository root, are read from git at the commit being built, and can’t contain globs.fallback: optional, defaultfalse. Withhashset, when no entry matches the current hash, restore the newest entry for the same workflow and ref whose hash differs. This is how you start from yesterday’s dependencies after a lockfile change.paths: required, at least one. Paths to archive. Relative paths are anchored at the repository checkout, the directory steps start in (/workspace/repoon microvm,/tangled/workspaceon nixery). Absolute paths work too, for caching directories outside the checkout, but they name engine-specific locations. All paths must be writable by the CI user and contain no spaces, quotes, shell metacharacters or...compression-level: optional zstd level,1(fastest) to19(smallest). Defaults to5. Saving needszstdin the image; without it the cache isn’t saved.when: optional save policy:on-success(the default) oralways, which also saves after a failed run.
cache: - key: go-mod hash: - go.sum - go.mod fallback: true paths: - .gocacheCaches are only restored and saved for trusted pipelines (pushes and
same-repository pull requests). Pipelines building untrusted code, like
manual runs of a fork, skip the cache entirely. Saving follows each
entry’s when policy, except on timeout, when nothing is saved. A cache
miss or failure never fails the workflow, but an invalid cache entry
does.
The spindle operator chooses the storage backend and per-owner limits.
See Run a spindle. If no backend is
configured, cache entries are ignored.
The steps field is a list of step objects, each with:
name: Shown in the run’s logs and used to describe what the step does.command: The command to run. The step runs in a Bash shell, and its output is visible on the repository’s pipelines page. Any dependencies you added in your engine’s section (see Engines) are available here. A multi-line|block runs as one script.environment: Like the workflow-level environment, this optional key-value map sets environment variables for this step only. Do not put secrets here.
steps: - name: "Build backend" command: "go build" environment: GOOS: "darwin" GOARCH: "arm64" - name: "Build frontend" command: "npm run build" environment: NODE_ENV: "production"The whole workflow, including time spent waiting for a free slot, must
finish within the spindle’s workflow timeout. The default is 5 minutes;
operators can change it. A workflow that runs out of time ends with
status timeout.
runs_on
Section titled “runs_on”On a spindle split into a mill and executors, runs_on restricts the
workflow to executors carrying all of the listed labels. Labels are
chosen by the operator. On a standalone spindle it has no effect.
runs_on: [arm64, large]audience
Section titled “audience”audience asks the spindle to mint an OpenID Connect ID token for this
run and expose it to every step as TANGLED_ID_TOKEN. Use it to
authenticate to a cloud provider or other service that trusts the
spindle’s issuer, without storing a long-lived credential.
audience: sts.amazonaws.com- The value must be printable ASCII, at most 150 characters. It becomes
the token’s
audclaim. - The token is an ES256 JWT with claims
iss,aud,sub(<repository>:<ref>),iat,nbf,exp,jti,repository,ref,shaandactor.repositoryis the repository DID. - It lives for the workflow’s time budget, at least 15 minutes.
- It’s masked in logs like a secret.
- It’s only minted for trusted pipelines. Manual runs of a fork never
get one.
refis empty for pull request runs, and for manual runs it’s only set when the spindle verified that the ref contains the commit. - The spindle publishes its keys at
/.well-known/openid-configurationand/jwks. If the operator hasn’t configured OIDC, no token is issued.
Engines
Section titled “Engines”The common fields above apply to every workflow. Each engine then adds
its own fields on top. Pick an engine with the engine field
and use the matching section below.
Nixery engine
Section titled “Nixery engine”Each step runs in a fresh container built by Nixery from your dependencies. The workspace is shared between steps; nothing else is.
Dependencies
Section titled “Dependencies”The dependencies field lets you define which dependencies to get, and
from where. It’s a key-value map, with the key being the registry to
fetch dependencies from, and the value being the list of dependencies to
fetch.
The registry URL syntax can be found in the Nix manual.
Say you want Node.js and Go from nixpkgs, and a package called
my_pkg you’ve made in your own repository at
https://tangled.org/@example.com/my_pkg:
dependencies: # nixpkgs nixpkgs: - nodejs - go # unstable nixpkgs/nixpkgs-unstable: - bun # custom registry git+https://tangled.org/@example.com/my_pkg: - my_pkgThese dependencies are now available to every step.
Complete nixery workflow
Section titled “Complete nixery workflow”# .tangled/workflows/build.yml
when: - event: ["push", "manual"] branch: ["main", "develop"] - event: ["pull_request"] branch: ["main"]
engine: "nixery"
# using the default valuesclone: skip: false depth: 1 submodules: true
dependencies: # nixpkgs nixpkgs: - nodejs - go # custom registry git+https://tangled.org/@example.com/my_pkg: - my_pkg
environment: GOOS: "linux" GOARCH: "arm64" NODE_ENV: "production" MY_ENV_VAR: "MY_ENV_VALUE"
steps: - name: "Build backend" command: "go build" environment: GOOS: "darwin" GOARCH: "arm64" - name: "Build frontend" command: "npm run build" environment: NODE_ENV: "production"For another example, see the workflow Tangled uses to build the project.
microVM engine
Section titled “microVM engine”The whole workflow runs in one QEMU microVM, so files, installed
packages and running services carry over from one step to the next.
Steps run as the unprivileged spindle-workflow user in
/workspace/repo. The VM has outbound network access only, with private
address ranges blocked.
A workflow picks the image to boot with the top-level image field:
engine: microvmimage: nixosThere are two flavours of images:
- NixOS images (e.g.
nixos): the whole guest is built with Nix, so you can configure it from the workflow file itself. Thedependencies,services,virtualisation,registryandsubstitutersfields below are all understood here, and the guest builds and activates that configuration before any of your steps run. - Non-NixOS images (e.g.
alpine,almalinux10): there’s no NixOS to configure, so the workflow-level config fields above have no effect. You still get a full machine to run steps in.
The available image names depend on what the spindle operator has
installed. nixos, alpine and almalinux10 are the images built from
the Tangled repository. If image is omitted, the spindle’s configured
default image is used (nixos-x86_64 unless the operator changed it).
In every image, nixpkgs (as in nix shell nixpkgs#hello or
<nixpkgs>) is the nixpkgs revision the image was built from, and
nix flake metadata nixpkgs prints it. unstable is the latest
nixpkgs-unstable, as in nix shell unstable#hello.
Dependencies
Section titled “Dependencies”On the microVM engine, dependencies is a flat list of packages that
are made available to every step. This field only applies to NixOS
images; for other images, use the image’s package manager in a step.
The guest builds a
nix develop-style
devshell from your dependencies and uses it for each step. For example,
adding pkg-config and openssl is enough for the openssl-sys crate
to build in a Rust project.
A bare name like go is looked up in the image’s nixpkgs, so it is the
same go as nixpkgs#go in a step. You can also point at any flake with
the flakeref#attr syntax, so unstable#go takes go from
nixpkgs-unstable and github:nixos/nixpkgs#hello pulls hello straight
out of that flake.
dependencies: - go - unstable#zig - github:nixos/nixpkgs#helloRegistry
Section titled “Registry”The registry field remaps flake references, the same way
nix registry does. This lets you pin or alias the flakes used by
dependencies.
For example, pin nixpkgs to nixos-unstable so that the bare go
above resolves from unstable, and alias your own flake so you can use
myflake#tool in dependencies:
registry: nixpkgs: github:nixos/nixpkgs/nixos-unstable myflake: github:me/xSubstituters
Section titled “Substituters”The substituters field is a map of Nix binary cache URL to its trusted
public key. These are fed into the spindle’s read proxy, so the guest
can substitute prebuilt paths from them instead of building everything
from scratch.
substituters: https://nix-community.cachix.org: "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="Services and virtualisation
Section titled “Services and virtualisation”The services and virtualisation fields are passed straight through
to NixOS. Anything you could write under services.* or
virtualisation.* in a NixOS configuration, you can write here, and
it’s brought up before any of your steps run.
As a convenience, true works as shorthand for .enable = true
anywhere an enable option exists (e.g. virtualisation.docker: true).
services: postgresql: enable: true ensureDatabases: ["spindle-workflow"] ensureUsers: - name: spindle-workflow ensureDBOwnership: true
virtualisation: docker: trueDebugging a failed run
Section titled “Debugging a failed run”If the operator enabled debug SSH, a microVM whose step fails is kept
alive for a grace period (5 minutes by default). The step’s log ends
with a Debug shell entry containing the exact ssh command. You
need an SSH key with push access to the repository.
Dummy engine
Section titled “Dummy engine”engine: dummy accepts steps and environment and doesn’t execute
anything. It exists for testing spindle itself.