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.
A minimal workflow
Section titled “A minimal workflow”name: CIon: push
jobs: test: runs-on: nix packages: [rustc, cargo] steps: - uses: actions/checkout@v4 - run: cargo testThe 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.
Top level
Section titled “Top level”| 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.
Triggers
Section titled “Triggers”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: pushon: [push, pull_request]on: push: branches: [main, "release/*"] paths-ignore: ["**.md"] schedule: - cron: "0 6 * * 1"Triggers that parse but do not run
Section titled “Triggers that parse but do not run”| 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.
on: change
Section titled “on: change”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:
changeas 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.changealongside a working trigger is a warning, and thechangetrigger 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]Filters
Section titled “Filters”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.
Glob semantics
Section titled “Glob semantics”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.
Manual runs
Section titled “Manual runs”Because workflow_dispatch has no dispatcher, a manual run is started through
the API instead:
POST /api/workflow-runswith 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
Section titled “runs-on: nix”runs-on: nix requires exactly one of flake: or packages::
# Valid — packagesruns-on: nixpackages: [rustc, cargo, git]
# Valid — a flakeruns-on: nixflake: .#ci
# Error — neither# Error — bothflake: outside runs-on: nix is an error.
Matrix
Section titled “Matrix”strategy: fail-fast: false max-parallel: 4 matrix: node: [18, 20, 22] include: - node: 22 experimental: true exclude: - node: 18Axis 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.
Services
Section titled “Services”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: 5432ports 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: 10A 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 listwith: args: [a, b]Warnings versus errors
Section titled “Warnings versus errors”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 topackages:- 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: nixwith neither or both offlake:/packages:flake:withoutruns-on: nix- a step with both
runanduses, or with neither - an unknown
shell:value - a non-scalar
with:value - a malformed matrix axis, or malformed
include/exclude changeas 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.