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. |
concurrency |
string or map | Serialization group. A bare string is the group name; the map form takes group: and cancel-in-progress:. Also valid at job level. |
concurrency: holds one active and one pending run per group — a newer
arrival cancels the older pending one, and cancel-in-progress: true also
cancels the active run. Group names are compared case-insensitively, scoped
per repository. Unknown sub-keys inside the map are warned about and ignored.
Any other top-level key — permissions:, 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 and pull_request both accept branches, branches-ignore, paths
and paths-ignore.
The two events measure paths against a different set of changes, and the
difference matters:
- On
push, the set is the one advance — what changed between the bookmark’s previous tip and the new one. - On
pull_request, the set is the whole pull request — everything that changed between the merge base and the PR head, which is what GitHub filters on too. So a follow-up commit touching onlydocs/still runs apaths: ['src/**']check when an earlier commit on the same PR touchedsrc/. The check is a statement about the PR, not about its last push.
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. On pull_request that
covers a branch-creation push, a deleted target branch, and a pull request
opened from a fork — a fork’s commits are not in the target repository
until the merge, so there is no diff to filter and the check always runs.
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. Works on every job, and mutually exclusive with packages. |
steps |
Required, non-empty. A job with no steps is an error. |
needs |
Job IDs this job waits for, as a list or a bare job ID. 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, large, or mega. 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. Both GitHub spellings work — needs: build means exactly
needs: [build]. Every name must resolve to a job in the same file, and the
graph must be acyclic; neither is a warning (see
Warnings versus errors).
A job-level if: gates the job. Two rules carry over from GitHub and catch
people out: a skipped dependency does not satisfy success() (so it skips
everything downstream of it), and a condition that names no status function is
implicitly &&-ed with success() — if: github.ref_name == 'main' will not
run after a failed dependency. Write always(), failure() or !cancelled()
when you mean it. A skipped job reports a green check.
A job if: is evaluated on the server before the job exists, so it can read
github.* and needs.* but not env, steps, runner, strategy,
matrix, secrets, vars, inputs or github.event — including the job’s
own env:, which is not yet set when the condition is decided. Referencing one of those does not silently skip the job:
conditions are evaluated in three values, and only a result nothing can decide
runs the job (with a warning). if: success() && vars.X == 'y' after a failed
dependency still skips, because success() already decided it. See
Job-level if: for that and the
divergences around cancellation.
runs-on: nix
Section titled “runs-on: nix”runs-on: nix accepts flake:, packages:, or neither — just never both:
# Valid — packagesruns-on: nixpackages: [rustc, cargo, git]
# Valid — a flakeruns-on: nixflake: .#ci
# Valid — neither. Resolves to the baseline toolset alone.runs-on: nix
# Error — both. A flake is a complete environment; merging a# package list into it would silently produce neither.runs-on: nix is now only a scheduling alias. Both packages: and
flake: are job-level keys honoured on any runs-on — the label says
where the job runs, packages:/flake: say what it runs with. The
mutual exclusion applies on every label form, not just nix.
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: 5432 args: ["-c", "fsync=off"] tmpfs: - /var/lib/postgresql/data shm-size: 2gports accepts a bare container port or a "host:container" string.
healthcheck accepts either tcp: <port> or command: [...]. args replaces
the image’s CMD and leaves its ENTRYPOINT alone.
tmpfs takes absolute paths inside the sidecar and backs each with RAM instead
of a disk — the usual reason is a database whose data directory has no
durability requirement, where the writes are pure waste. Each mount is capped,
and the cap is added to the sidecar’s memory reservation, so a job that declares
one schedules against a larger budget than a bare sidecar does. Paths must be
absolute, must not be /, and must not contain ..; /dev/shm is spelled
shm-size instead, because that one carries a size.
shm-size sizes the sidecar’s /dev/shm, in Docker’s notation (2g, 512m,
or a bare byte count — every suffix is 1024-based, as Docker’s are). Without it
a container gets the runtime default of 64 MiB, which is enough to break
anything using POSIX shared memory in bulk: Postgres’ parallel-query workers
allocate there, and exhausting it is an ENOSPC, a backend crash and a recovery
cycle rather than a clean error.
Neither field is read by a runner older than the release that added them, and an older runner ignores an unknown field rather than failing on it — so a sidecar would quietly run without the tmpfs or on 64 MiB of shared memory. On the hosted runners this only matters during a rollout.
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, and carries GitHub’s implicit success() &&
gate: if: env.DEPLOY == 'yes' means success() && env.DEPLOY == 'yes', so the
step is skipped once an earlier step in the same job has failed. Naming a status
function yourself — always(), failure(), !cancelled(), success() || … —
replaces that gate entirely and the expression alone decides, which is how
cleanup and notify steps still run after a failure.
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:,defaults:,run-name:,environment:,secrets:) tags:/tags-ignore:— parsed and discarded- 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. Unless one of those siblingsneeds:it — a dependency on a job that was never kept can never be satisfied, so that combination is an error (below), not a warning.
Errors — the whole file fails to parse:
image:on a job (see the job environment)- a job missing
runs-on, or with no steps - a job declaring both
flake:andpackages: - a step with both
runanduses, or with neither - a
uses:reference that is notowner/repo@ref— adocker://…image or a local./pathaction is rejected at parse time runs-onin the map form (group:/labels:)strategy.matrixgiven as an expression, such asfromJson(…)- an unknown
shell:value - a non-scalar
with:value - a malformed matrix axis, or malformed
include/exclude changeas a workflow’s only trigger- a
needs:naming a job the workflow does not define — including one that was dropped, such as a reusable-workflow (uses:) job. The dependent could only ever be skipped, and a skipped job reports success, so the run would go green without ever running it - a
needs:cycle — two or more jobs waiting on each other, directly or transitively (a job that needs itself counts) - 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.
An error means the workflow does not run at all
Section titled “An error means the workflow does not run at all”This is the part worth knowing before it happens to you. When a file in
.neosource/workflows/ fails to parse, the push still indexes it — so it
does not silently vanish from the Actions tab — but it is stored with no
triggers and no jobs. Nothing dispatches it. You do not get a failed run, or a
red check, or an email: you get silence, and a workflow that used to run on
every push simply stops.
That is deliberate. A workflow whose file is wrong has no correct behaviour to
fall back on, and a job that can never run is worse than no job: before this was
an error, a dangling needs: produced a green run with the dependent silently
skipped, which is the one outcome you cannot notice. Silence also fails closed
for branch protection — a required check that never arrives blocks the merge,
where a false green did not.
So if CI stops after an edit, read the workflow’s diagnostics first: the file is
indexed with a severity: error warning naming the exact problem. Locally,
neo run reports the same errors against the same file without pushing
anything, which is the faster loop.