# Writing workflows

> A walkthrough of the workflow syntax, one field at a time, with small examples and ready-made recipes.

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

## Your first workflow

Create `.tangled/workflows/check.yml`:

```yaml
when:
  - event: push
    branch: main


engine: microvm
image: nixos


dependencies:
  - go


steps:
  - name: Test
    command: go test ./...
```

Commit and push it:

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

| Part           | Says                     |
| -------------- | ------------------------ |
| `when`         | When the workflow runs.  |
| `engine`       | Where the workflow runs. |
| `dependencies` | What tools it needs.     |
| `steps`        | What commands to run.    |

## When it runs

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

Run on every push to `main`:

```yaml
when:
  - event: push
    branch: main
```

Run on pull requests that target `main`:

```yaml
when:
  - event: pull_request
    branch: main
```

Run when tags matching `v*` are pushed:

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

Run only when files in `docs/` change:

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

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

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

Let people start it by hand:

```yaml
when:
  - event: manual
```

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

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

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

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

## Where it runs

The `engine` field picks the engine:

```yaml
engine: microvm
image: nixos
```

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

## Tools

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

```yaml
dependencies:
  - nodejs
  - pnpm
```

Take a package from any flake with `flakeref#attr`:

```yaml
dependencies:
  - github:nixos/nixpkgs#hello
```

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

```yaml
dependencies:
  nixpkgs:
    - nodejs
    - go
```

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

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

## Steps

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

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

Use `|` for a command with more than one line:

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

## Environment variables

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

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

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

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

## Caching

Keep directories between runs with `cache`:

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

## Cloning

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

```yaml
clone:
  depth: 50
```

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

```yaml
clone:
  skip: true
```

## Recipes

### Lint, test and build a Node project

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

### Check formatting

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

### Build a Rust project that links OpenSSL

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

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

### Build and push a Docker image on tag

`REGISTRY_USER` and `REGISTRY_TOKEN` are repository secrets.

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

### Deploy to Cloudflare Workers on tag

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

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.

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