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

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 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 only docs/ still runs a paths: ['src/**'] check when an earlier commit on the same PR touched src/. 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.

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.

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. 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 accepts flake:, packages:, or neither — just never both:

# Valid — packages
runs-on: nix
packages: [rustc, cargo, git]
# Valid — a flake
runs-on: nix
flake: .#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.

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
args: ["-c", "fsync=off"]
tmpfs:
- /var/lib/postgresql/data
shm-size: 2g

ports 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: 10

A 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 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:, defaults:, run-name:, environment:, secrets:)
  • tags: / tags-ignore: — parsed and discarded
  • 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. Unless one of those siblings needs: 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: and packages:
  • a step with both run and uses, or with neither
  • a uses: reference that is not owner/repo@ref — a docker://… image or a local ./path action is rejected at parse time
  • runs-on in the map form (group: / labels:)
  • strategy.matrix given as an expression, such as fromJson(…)
  • an unknown shell: value
  • a non-scalar with: value
  • a malformed matrix axis, or malformed include/exclude
  • change as 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.