Skip to content

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.

Every field a workflow file accepts. Each one links to its section below.

# common fields, every engine
when: # 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 UTC
engine: microvm # microvm, nixery or dummy (required)
clone:
skip: false
depth: 1
submodules: true
tags: true
environment:
KEY: value
cache:
- key: go-mod # required
hash: [go.sum]
fallback: false
paths: [.gocache] # required
compression-level: 5 # 1 to 19
when: on-success # or always
steps:
- name: test
command: go test ./...
environment:
KEY: value
runs_on: [arm64] # executor labels
audience: sts.amazonaws.com # oidc token audience
# microvm only
image: nixos
dependencies: [go, "unstable#zig", "github:nixos/nixpkgs#hello"]
registry:
nixpkgs: github:nixos/nixpkgs/nixos-unstable
substituters:
https://nix-community.cachix.org: "nix-community.cachix.org-1:..."
services:
postgresql: true
virtualisation:
docker: true
# nixery only
dependencies:
nixpkgs: [go]

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:

FieldRequiredPurpose
whennoWhen the workflow runs. Without it, the workflow runs for every event.
engineyesWhich engine runs the workflow.
clonenoHow the repository is fetched.
environmentnoEnvironment variables for every step.
cachenoDirectories kept between runs.
stepsnoThe commands to run.
runs_onnoExecutor labels, on spindles that use a mill.
audiencenoRequest 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.

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. See types.
    • manual: marks the workflow as one you start by hand. The spindle doesn’t evaluate when for manual runs at all: it runs the workflows the caller names, or every workflow.
    • schedule: the workflow runs according to its cron expressions 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. With push, commits to the branch(es) listed here trigger the workflow. With pull_request, updates to pull requests targeting the branch(es) listed here trigger the workflow. Supports glob patterns using * and ** (e.g., main, develop, release-*). Either branch or tag (or both) must be specified for push events.
  • tag: Defines which tags the workflow should run for. Only used with push: 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 with pull_request. Which pull request actions trigger the workflow: opened, reopened, synchronize (a new round was pushed), closed, merged. Defaults to opened, reopened and synchronize.
  • 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 for schedule events and invalid for other events. Each entry requires a cron field containing a five-field expression (minute hour day-of-month month day-of-week) and accepts an optional IANA timezone, which defaults to UTC. Hashed H fields are supported; seconds, nicknames such as @daily, and timezone prefixes are not. A condition containing schedule cannot also specify branch, tag, types, or paths.

* 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]

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.

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/London

H 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.

To skip CI for a push, pass a Git push option:

git push -o skip-ci

ci-skip is also accepted. The push is recorded as usual; no pipeline starts for it.

The required engine field picks the engine the workflow runs on. A workflow without it fails with missing engine. The engines are:

Which engines are available depends on the spindle. A spindle operator can run either or both.

engine: "nixery"

When a workflow starts, its first step fetches the triggering commit into the workspace. The optional clone field changes this:

  • skip: true skips the fetch. Useful when the workflow needs nothing from the repository. Default false. Setting depth, submodules or tags together with skip produces a warning.
  • depth: How many commits to fetch, counting back from the triggering commit. 0 or 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). Default true.
  • tags: Fetch tags as well. Default true.

The default settings are:

clone:
skip: false
depth: 1
submodules: true
tags: true

The fetch uses the repository’s HTTPS clone URL on its knot. The checkout is detached at the triggering commit.

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:

VariableValue
CIAlways true.
TANGLED_PIPELINE_IDThe id of the current pipeline.
TANGLED_PIPELINE_KINDOne of push, pull_request, manual or schedule.
TANGLED_PIPELINE_SOURCEDID 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_KNOTThe repository’s knot hostname.
TANGLED_REPO_DIDThe DID of the repository owner.
TANGLED_REPO_REPO_DIDThe repository’s own DID.
TANGLED_REPO_NAMEThe name of the repository.
TANGLED_REPO_DEFAULT_BRANCHThe default branch of the repository.
TANGLED_REPO_URLThe 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:

VariableValue
TANGLED_REFThe full git reference (e.g., refs/heads/main or refs/tags/v1.0.0). For pull requests, the source branch.
TANGLED_REF_NAMEThe short name of the reference (e.g., main or v1.0.0).
TANGLED_REF_TYPEbranch or tag.
TANGLED_SHAThe commit SHA that triggered the pipeline.
TANGLED_COMMIT_SHAAlias for TANGLED_SHA.

Schedule triggers additionally provide:

VariableValue
TANGLED_SCHEDULED_ATThe UTC minute selected by the cron expression.

These variables are only available when the pipeline is triggered by a pull request:

VariableValue
TANGLED_PR_SOURCE_BRANCHThe source branch of the pull request.
TANGLED_PR_TARGET_BRANCHThe target branch of the pull request.
TANGLED_PR_SOURCE_SHAThe commit SHA of the source branch.
TANGLED_PIPELINE_SOURCE_BRANCHSame as TANGLED_PR_SOURCE_BRANCH.
TANGLED_PIPELINE_TARGET_BRANCHSame 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 editing go.sum invalidates 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, default false. With hash set, 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/repo on microvm, /tangled/workspace on 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) to 19 (smallest). Defaults to 5. Saving needs zstd in the image; without it the cache isn’t saved.
  • when: optional save policy: on-success (the default) or always, which also saves after a failed run.
cache:
- key: go-mod
hash:
- go.sum
- go.mod
fallback: true
paths:
- .gocache

Caches 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.

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 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 aud claim.
  • The token is an ES256 JWT with claims iss, aud, sub (<repository>:<ref>), iat, nbf, exp, jti, repository, ref, sha and actor. repository is 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. ref is 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-configuration and /jwks. If the operator hasn’t configured OIDC, no token is issued.

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.

Each step runs in a fresh container built by Nixery from your dependencies. The workspace is shared between steps; nothing else is.

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_pkg

These dependencies are now available to every step.

# .tangled/workflows/build.yml
when:
- event: ["push", "manual"]
branch: ["main", "develop"]
- event: ["pull_request"]
branch: ["main"]
engine: "nixery"
# using the default values
clone:
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.

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: microvm
image: nixos

There 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. The dependencies, services, virtualisation, registry and substituters fields 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.

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#hello

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/x

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="

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: true

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.

engine: dummy accepts steps and environment and doesn’t execute anything. It exists for testing spindle itself.