Skip to content

Writing workflows

Spindle workflows allow you to write CI/CD pipelines in a simple format. They’re located in the .tangled/workflows directory at the root of your repository, and are defined using YAML. This page shows each part with a small example. The workflow reference lists every field.

Before a workflow can run, the repository needs a spindle. Open the repository’s Settings → Pipelines and, under Spindle, run CI with Tangled or select your own spindle.

Create .tangled/workflows/check.yml:

when:
- event: push
branch: main
engine: microvm
image: nixos
dependencies:
- go
steps:
- name: Test
command: go test ./...

Commit and push it:

git add .tangled/workflows/check.yml
git commit -m "Add CI workflow"
git push

The push starts the first run. Open the repository’s Pipelines tab to watch it.

If a workflow file has a problem, git push prints it. Add -o verbose-ci to see the report when there is no problem.

A workflow has four main parts:

PartSays
whenWhen the workflow runs.
engineWhere the workflow runs.
dependenciesWhat tools it needs.
stepsWhat commands to run.

The when field is a list of conditions. The workflow runs if any condition matches.

Run on every push to main:

when:
- event: push
branch: main

Run on pull requests that target main:

when:
- event: pull_request
branch: main

Run when tags matching v* are pushed:

when:
- event: push
tag: "v*"

Run only when files in docs/ change:

when:
- event: push
branch: main
paths: ["docs/**"]

Run every night, at a stable minute in the 02:00 hour:

when:
- event: schedule
schedule:
- cron: "H 2 * * *"

Let people start it by hand:

when:
- event: manual

Combine them. This runs on pushes to main or develop, on pull requests that target main, and by hand:

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

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

git push -o skip-ci

The engine field picks the engine:

engine: microvm
image: nixos
engine: nixery
  • microvm runs the whole workflow inside its own microVM. Files, packages and services stay from one step to the next.
  • nixery runs each step in a fresh container. Only the workspace stays between steps.

Use microvm if you are not sure. With microvm, image picks the image to boot: nixos, or a non-NixOS image such as alpine.

On microvm with a NixOS image, dependencies is a list of nixpkgs packages. You can search for packages on https://search.nixos.org.

dependencies:
- nodejs
- pnpm

Take a package from any flake with flakeref#attr:

dependencies:
- github:nixos/nixpkgs#hello

On nixery, dependencies is a map from registry to packages:

dependencies:
nixpkgs:
- nodejs
- go

On a non-NixOS image, install tools in a step:

steps:
- name: "Install go"
command: apk add go

Each step has a name and a command. The step runs in a Bash shell. Its logs show on the pipelines page.

steps:
- name: "Build"
command: go build ./...
- name: "Test"
command: go test ./...

Use | for a command with more than one line:

steps:
- name: "Lint and test"
command: |
pnpm run lint
pnpm test

Set variables for every step with environment. Set variables for one step with that step’s environment:

environment:
NODE_ENV: "production"
steps:
- name: "Build backend"
command: "go build"
environment:
GOOS: "darwin"
GOARCH: "arm64"

Do not put secrets here, these environment variables are visible to anyone viewing the repository. Add secrets in the repository’s Settings → Pipelines → Secrets. They reach every step as environment variables. See Secrets.

The spindle also sets CI=true and TANGLED_* variables that describe the run:

echo "$TANGLED_REF_NAME" # main
echo "$TANGLED_SHA" # the commit that triggered the pipeline

Keep directories between runs with cache:

cache:
- key: go-mod
hash: [go.sum]
paths: [.gocache]

hash folds the content of go.sum into the key, so editing go.sum invalidates the cache. Caches are only used for trusted runs, and only when the spindle operator configured cache storage.

By default the spindle fetches only the commit that triggered the workflow. Fetch more history when a tool needs it, such as git describe:

clone:
depth: 50

Skip the clone when the workflow needs nothing from the repository:

clone:
skip: true
when:
- event: ["push", "pull_request"]
branch: ["main"]
engine: microvm
image: nixos
dependencies:
- pnpm
steps:
- name: "Install dependencies"
command: pnpm install --frozen-lockfile
- name: "Lint and test"
command: |
pnpm run lint
pnpm test
- name: "Build"
command: pnpm run build
when:
- event: ["push", "pull_request"]
branch: ["main"]
engine: microvm
image: alpine # slimmer image for checking the formatting
steps:
- name: "Install go"
command: apk add go
- name: "Check formatting"
command: test -z $(gofmt -l .)
when:
- event: ["push", "pull_request"]
branch: ["main"]
engine: microvm
image: nixos
dependencies:
- gcc
- cargo
- rustc
- clippy
- rustfmt
- pkg-config # exports PKG_CONFIG_PATH for the libraries below
- openssl # the C library + headers openssl-sys links against
steps:
- name: "Check formatting"
command: cargo fmt --check
- name: "Clippy"
command: cargo clippy --all-targets -- -D warnings
- name: "Test"
command: cargo test --all
- name: "Release build"
command: cargo build --release

Run migrations and integration tests against PostgreSQL

Section titled “Run migrations and integration tests against PostgreSQL”
when:
- event: ["push", "pull_request"]
branch: ["main"]
engine: microvm
image: nixos
environment:
DATABASE_URL: "postgresql:///spindle-workflow?host=/run/postgresql"
dependencies:
- gcc
- cargo
- rustc
- pkg-config
- openssl
- sqlx-cli
services:
postgresql:
enable: true
# has to be same name as the user for peer auth to work automatically
ensureDatabases: ["spindle-workflow"]
ensureUsers:
- name: spindle-workflow
ensureDBOwnership: true
steps:
- name: "Run migrations"
command: sqlx migrate run
- name: "Integration tests"
command: cargo test --all

REGISTRY_USER and REGISTRY_TOKEN are repository secrets.

when:
- event: ["push"]
tag: ["v*"]
engine: microvm
image: nixos
virtualisation:
docker: true
steps:
- name: "Build and push to ghcr.io"
command: |
set -euo pipefail
echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
image="ghcr.io/$REGISTRY_USER/myapp:$TANGLED_REF_NAME"
docker build -t "$image" -t "ghcr.io/$REGISTRY_USER/myapp:latest" .
docker push "$image"
docker push "ghcr.io/$REGISTRY_USER/myapp:latest"
# .tangled/workflows/deploy.yml
when:
- event: ["push"]
tag: ["v*"]
engine: microvm
image: nixos
dependencies:
- pnpm
steps:
- name: "Install dependencies"
command: pnpm install --frozen-lockfile
- name: "Deploy worker"
# `wrangler` picks up `CLOUDFLARE_API_TOKEN` from the env.
# set it under **Settings → Pipelines → Secrets**.
command: pnpm exec wrangler deploy

Authenticate to a cloud provider without a stored key

Section titled “Authenticate to a cloud provider without a stored key”

If the spindle operator enabled OIDC, set audience and exchange TANGLED_ID_TOKEN for short-lived cloud credentials. Configure the provider to trust the spindle’s issuer and to check the token’s repository and ref claims.

when:
- event: push
branch: main
engine: microvm
image: nixos
audience: sts.amazonaws.com
dependencies:
- awscli2
steps:
- name: "Deploy"
command: |
echo "$TANGLED_ID_TOKEN" > /tmp/token
export AWS_ROLE_ARN=arn:aws:iam::123456789012:role/deploy
export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/token
aws s3 sync dist/ s3://my-bucket/