Skip to content

Writing a workflow

Workflow files live in .neosource/workflows/ and end in .yml or .yaml. The syntax is deliberately close to GitHub Actions. This page describes what the parser accepts today; anything not listed here is either ignored with a warning or rejected. If you are migrating existing workflows, read Coming from GitHub Actions alongside this.

name: CI
on: push
jobs:
test:
runs-on: nix
packages: [rustc, cargo]
steps:
- uses: actions/checkout@v4
- run: cargo test

The one thing that will look unfamiliar is packages:. Jobs do not choose a container image — they declare the tools they need. See The job environment.

Key Type Notes
name string Optional. Defaults to unnamed-workflow.
on string, list, or map Triggers. See below.
env map of string to string Inherited by every job; a job’s own env wins on collision.
jobs map Required in practice — a workflow with no jobs can never do anything.
nixpkgs string Optional pin for the package set packages: resolves against.

Any other top-level key — permissions:, concurrency:, defaults:, run-name: — is recorded as a warning and ignored. The file still parses.

Be precise about this section: the parser accepts more trigger events than the server can actually dispatch. A trigger that parses is not necessarily a trigger that runs.

Three events start a run today:

Trigger What fires it
push A push to the repository.
pull_request A pull request being opened, or updated with new commits (synchronize).
schedule A cron entry coming due.
on: push
on: [push, pull_request]
on:
push:
branches: [main, "release/*"]
paths-ignore: ["**.md"]
schedule:
- cron: "0 6 * * 1"
Trigger Status
workflow_dispatch Parses, but there is no endpoint that dispatches it. Declaring it has no runtime effect. To start a run by hand, use the API — see manual runs.
repository_dispatch Parses, but nothing receives or dispatches it. It cannot start a run.
change Parses, but change-update dispatch is not implemented. See below.

An event neosource does not model at all — release, issues, workflow_run and the rest — produces a warning and drops only that trigger. on: [push, release] still runs on push.

Bare schedule is also ignored: on: schedule with no cron list warns and drops. schedule needs - cron: entries.

change is a jj-native trigger in the design: fire when a change is updated, rather than when a branch moves. It parses, but change-update dispatch is not implemented, so it cannot start a run. The parser refuses to let that be silent:

  • change as a workflow’s only trigger is a hard parse error. The file could never fire under any event, so it fails loudly rather than registering a no-op.
  • change alongside a working trigger is a warning, and the change trigger is dropped. The workflow still runs on its other triggers.
# Rejected — this workflow could never run.
on: change
# Accepted with a warning — runs on push; the change trigger is ignored.
on: [push, change]

push accepts branches, branches-ignore, paths and paths-ignore. pull_request accepts branches and branches-ignore.

tags: and tags-ignore: are parsed and then discarded. They are not honoured. A workflow whose only filter is tags: will fire on every push, not just tag pushes. Do not rely on them.

pull_request also accepts types:, but since only opened and synchronize are ever emitted, listing other types (reopened, closed, labeled, …) will not make those events fire.

Branch and path patterns use neosource’s own matcher, anchored at both ends:

Pattern Matches
* A run of characters not containing /
** Anything, including /
? Exactly one character

The consequence worth remembering: release/* matches release/v1 but not release/v1/rc1. Use release/** if you want the whole subtree. This differs from what many people assume, and it is the most common filter surprise.

Path filters have one safety behaviour: if the set of changed paths cannot be determined, or the change touches more than 1000 paths, the filter degrades to matching everything rather than skipping the run.

Because workflow_dispatch has no dispatcher, a manual run is started through the API instead:

POST /api/workflow-runs

with a workflow_id and a cause. It requires Write permission on the repository. See the API reference.

jobs:
<job-id>:
runs-on: nix
packages: [nodejs_20]
needs: [build]
env:
LOG_LEVEL: debug
steps:
- run: npm test
Key Notes
runs-on Required. A label string, a list of labels, or nix. A job without it is an error.
packages List of packages to make available. Works on every job.
flake Flake reference. Only valid with runs-on: nix, and mutually exclusive with packages.
steps Required, non-empty. A job with no steps is an error.
needs Job IDs this job waits for. Enforced — see below.
env Job environment; overrides workflow env, overridden by step env.
services Sidecar containers — these do take real images.
strategy matrix, fail-fast (default true), max-parallel.
outputs Map of output name to expression.
continue-on-error Boolean.
timeout-minutes Integer.
size small, medium, or large. Defaults to small.

needs: is enforced by the server: a job stays waiting until its dependencies finish, and if a dependency fails, the jobs that depend on it are skipped transitively.

Job-level if: is parsed but never evaluated. A condition on a job does not gate it. Use step-level if:, which does work.

runs-on: nix requires exactly one of flake: or packages::

# Valid — packages
runs-on: nix
packages: [rustc, cargo, git]
# Valid — a flake
runs-on: nix
flake: .#ci
# Error — neither
# Error — both

flake: outside runs-on: nix is an error.

strategy:
fail-fast: false
max-parallel: 4
matrix:
node: [18, 20, 22]
include:
- node: 22
experimental: true
exclude:
- node: 18

Axis names are free-form. include and exclude are reserved and must be lists of objects. An axis whose value is not a list is an error.

Sidecars keep real container images — the no-images rule applies to the job environment, not to services.

services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports:
- 5432
- "15432:5432"
healthcheck:
tcp: 5432

ports accepts a bare container port or a "host:container" string. healthcheck accepts either tcp: <port> or command: [...].

steps:
- name: Run tests
id: tests
run: pytest
working-directory: backend
shell: bash
env:
CI: "true"
if: ${{ success() }}
continue-on-error: false
timeout-minutes: 10

A step must have either run or uses — both is an error, neither is an error.

Step-level if: is evaluated.

shell: accepts bash, sh, python, pwsh (or powershell). Any other value is an error.

with: values must be scalars. Strings, numbers and booleans are coerced to their text form the way GitHub Actions does, and a null becomes an empty string. A list or a map as a with: value is an error:

# Fine — coerced to "true", "3", "1.5", "v1.0.0"
with:
generate_release_notes: true
retries: 3
ratio: 1.5
tag: v1.0.0
# Error — an action input cannot be a list
with:
args: [a, b]

The split is deliberate, and it is worth internalising because it determines whether a mistake costs you a run or a silent no-op.

Warnings — the file parses, the workflow runs, and the diagnostic is recorded:

  • unknown keys at any level (workflow, job, step, service, strategy)
  • valid GitHub Actions keys neosource does not model (permissions:, concurrency:, defaults:, run-name:, environment:, secrets:)
  • unsupported trigger events
  • container: on a job — ignored, with a pointer to packages:
  • a job that calls a reusable workflow via uses: — that job is skipped, its siblings still run

Errors — the whole file fails to parse:

  • image: on a job (see the job environment)
  • a job missing runs-on, or with no steps
  • runs-on: nix with neither or both of flake:/packages:
  • flake: without runs-on: nix
  • a step with both run and uses, or with neither
  • an unknown shell: value
  • a non-scalar with: value
  • a malformed matrix axis, or malformed include/exclude
  • change as a workflow’s only trigger
  • malformed YAML

Diagnostics are recorded against the workflow and returned by the workflows API. Note that the web UI does not currently render them — see How CI works here for how to read them.