# Workflow reference

> Every field a spindle workflow file accepts, per engine, with defaults and validation rules.

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](/docs/spindles/writing-workflows/) walks through a first workflow; this page lists every field.

## All fields

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

```yaml
# 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]
```

## 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`](#trigger)            | no       | When the workflow runs. Without it, the workflow runs for every event. |
| [`engine`](#engine)           | **yes**  | Which engine runs the workflow.                                        |
| [`clone`](#clone-options)     | no       | How the repository is fetched.                                         |
| [`environment`](#environment) | no       | Environment variables for every step.                                  |
| [`cache`](#cache)             | no       | Directories kept between runs.                                         |
| [`steps`](#steps)             | no       | The commands to run.                                                   |
| [`runs_on`](#runs_on)         | no       | Executor labels, on spindles that use a mill.                          |
| [`audience`](#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](#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

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:

```yaml
when:
  - event: ["push", "manual"]
    branch: ["main", "develop"]
  - event: ["pull_request"]
    branch: ["main"]
```

To run a deployment workflow when tags matching `v*` are pushed:

```yaml
when:
  - event: ["push"]
    tag: ["v*"]
```

You can combine branch and tag patterns in a single condition. The workflow triggers if either matches:

```yaml
when:
  - event: ["push"]
    branch: ["main", "release-*"]
    tag: ["v*", "stable"]
```

Only run when documentation changes, and also when a pull request is merged:

```yaml
when:
  - event: push
    branch: main
    paths: ["docs/**", "mkdocs.yml"]
  - event: pull_request
    branch: main
    types: [merged]
```

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

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:

```yaml
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.

#### Skipping CI

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

```sh
git push -o skip-ci
```

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

### 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](https://nixery.dev) to run steps, which allows you to add [dependencies](#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](#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](#microvm-engine).

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

```yaml
engine: "nixery"
```

### Clone options

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:

```yaml
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.

### 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](/docs/spindles/secrets/).

```yaml
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`](#audience) is set. Step-level `environment` overrides workflow-level `environment`.

### Cache

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.

```yaml
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](/docs/self-hosting/spindle/). If no backend is configured, `cache` entries are ignored.

### Steps

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](#engines)) are available here. A multi-line `|` block runs as one script.
* `environment`: Like the workflow-level [environment](#environment), this **optional** key-value map sets environment variables for this step only. **Do not put secrets here.**

```yaml
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

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.

```yaml
runs_on: [arm64, large]
```

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

```yaml
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.

## Engines

The common fields above apply to every workflow. Each engine then adds its own fields on top. Pick an engine with the [`engine`](#engine) field and use the matching section below.

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

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](https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix3-registry-add).

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

```yaml
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.

#### Complete nixery workflow

```yaml
# .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](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml).

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

#### Image

A workflow picks the image to boot with the top-level `image` field:

```yaml
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`.

#### 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`](https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix3-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.

```yaml
dependencies:
  - go
  - unstable#zig
  - github:nixos/nixpkgs#hello
```

#### 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`:

```yaml
registry:
  nixpkgs: github:nixos/nixpkgs/nixos-unstable
  myflake: github:me/x
```

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

```yaml
substituters:
  https://nix-community.cachix.org: "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
```

#### 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`).

```yaml
services:
  postgresql:
    enable: true
    ensureDatabases: ["spindle-workflow"]
    ensureUsers:
      - name: spindle-workflow
        ensureDBOwnership: true


virtualisation:
  docker: true
```

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

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