Writing a workflow
Workflow files live in .neosource/workflows/ and end in .yml or .yaml.
The syntax follows GitHub Actions, with the differences listed below. Unknown
keys produce warnings; invalid definitions are rejected. For migration steps, see
Coming from GitHub Actions.
A minimal workflow
Section titled “A minimal workflow”name: CIon: push
jobs: test: runs-on: ubuntu-latest packages: [rustc, cargo] steps: - uses: actions/checkout@v4 - run: cargo testpackages: declares the job’s tools. See
The job environment for how to choose between
packages:, flake: and the base 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”Some accepted trigger events do not start runs. The tables below distinguish supported triggers from those that only parse.
Five events start a run today:
| Trigger | What fires it |
|---|---|
push |
A push to the repository. |
pull_request |
A pull request being opened or updated (synchronize); see types: below. |
merge_group |
A behind pull request entering the merge queue of a branch that requires up-to-date branches (checks_requested). |
schedule |
A cron entry coming due. |
workflow_dispatch |
Someone starting it by hand — see manual runs. |
on: [push, pull_request, merge_group]on: push: branches: [main, "release/*"] paths-ignore: ["**.md"] merge_group: branches: [main] schedule: - cron: "0 6 * * 1"Triggers that parse but do not run
Section titled “Triggers that parse but do not run”| Trigger | Status |
|---|---|
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,
…) warns and drops only that trigger; on: [push, release] still runs on
push. Bare on: schedule with no - cron: list also warns and drops.
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. Because change-update dispatch is not yet
implemented:
changeas a workflow’s only trigger is a hard parse error rather than registering a silent no-op.changealongside a working trigger is a warning and 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. They measure paths against different diffs:
- On
push, the one advance between the branch’s previous and new tip. - On
pull_request, the whole pull request (base..head), matching GitHub. A docs-only follow-up commit still runs apaths: ['src/**']check if an earlier commit on the PR touchedsrc/.
push also accepts tags and tags-ignore, with GitHub’s rules: tags:
filters tag pushes and branches: filters branch pushes. Declare only one kind
and a push of the other kind does not fire; declare neither and both fire.
paths and paths-ignore are not evaluated for tag pushes.
pull_request accepts types:. Without it a workflow fires on opened and
synchronize. It can also list closed (a merge sets
github.event.pull_request.merged), ready_for_review, converted_to_draft,
edited (title or body) and review_requested (when the requester has write
access). Other activity types (reopened, labeled, …) never fire.
merge_group accepts types: (default [checks_requested]; any other type is
ignored) plus branches and branches-ignore, matched against the target
branch. The checkout is the speculative merge commit produced by the merge
queue, which builds one only for a pull request that is behind a target
branch whose protection rule requires branches to be up to date.
A PR run auto-runs with the target repository’s secrets only when both the PR author and the last pusher of the source branch have write access to the target; otherwise the run waits for maintainer approval.
Glob semantics
Section titled “Glob semantics”Branch and path patterns use an anchored matcher:
| Pattern | Matches |
|---|---|
* |
A run of characters not containing / |
** |
Anything, including / |
? |
Exactly one character |
release/* matches release/v1 but not release/v1/rc1. Use
release/** for the whole subtree.
If the changed-path set cannot be determined or exceeds 1000 paths (for example a branch creation, a deleted target branch, or a fork PR), path filters degrade to matching everything rather than skipping the run.
Manual runs
Section titled “Manual runs”Anyone with Write permission can start an on: workflow_dispatch workflow
from Run workflow on the Actions tab or via the CLI:
neo actions run <workflow> --ref <branch> [--input k=v ...]Both call POST /api/workflow-runs and pin the run to the branch tip resolved
at dispatch time. The workflow definition comes from the default branch;
dispatching a branch whose copy of the workflow file differs (or is missing) is
refused with a conflict error.
Inputs follow GitHub’s schema:
on: workflow_dispatch: inputs: environment: description: Where to deploy type: choice options: [staging, prod] default: staging dry_run: type: boolean default: false replicas: type: number required: true| Key | Notes |
|---|---|
type |
string (default), choice, boolean, number or environment (treated as string). |
options |
Allowed values of a choice. |
default |
Used when omitted; coerced to the declared type. |
required |
Must be supplied when no default is set. |
description |
Help text in the dispatch form. |
Inputs are validated at dispatch time and rendered alphabetically. Jobs read
them as ${{ inputs.<name> }} (typed: booleans and numbers keep their type) or
${{ github.event.inputs.<name> }} (always a string). Gate a job-level
if: on inputs.<name>, because github.event is not resolved before a runner
claims the job.
jobs: <job-id>: runs-on: ubuntu-latest packages: [nodejs_26] needs: [build] env: LOG_LEVEL: debug steps: - run: npm test| Key | Notes |
|---|---|
runs-on |
Optional. A label string or a list of labels (matched case-insensitively); defaults to ubuntu-latest. |
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 |
Service 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. Honoured only by neo run; hosted runners accept and ignore it. |
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.
Running a job only when certain paths changed
Section titled “Running a job only when certain paths changed”if: pathsChanged('**/*.rs', 'Cargo.*') runs a job only when a matching path
changed. The arguments are a paths: filter list, read as a trigger reads one:
glob semantics, in order, the last match deciding, a leading
! excluding. A list of only ! patterns matches nothing, so
paths-ignore: [docs/**] is pathsChanged('**', '!docs/**'); no arguments
means “anything changed”. A rename counts both paths; a deletion counts.
The set is the one trigger paths: read: a pull request’s merge base → head, a
push’s previous → new tip, a new branch’s merge base with the default branch →
tip, a merge group’s target tip → candidate. A re-run reads its original’s set.
An unknown set makes pathsChanged() true: over 1000 paths, a fork pull
request, workflow_dispatch or schedule, no base. An empty set is false.
It works in a step if: and in ${{ }} too; see
Changed files. GitHub has no equivalent.
runs-on, packages: and flake:
Section titled “runs-on, packages: and flake:”runs-on says where the job runs; packages: and flake: say what it runs
with, on any runs-on. runs-on is optional and defaults to ubuntu-latest.
A job declares flake:, packages:, or neither — just never both:
# Valid — packagesruns-on: ubuntu-latestpackages: [rustc, cargo, git]
# Valid — a flakeruns-on: ubuntu-latestflake: .#ci
# Valid — neither. The job runs on the base environment alone.runs-on: ubuntu-latest
# Error — both. A flake is a complete environment; merging a# package list into it would silently produce neither.runs-on: nix was removed and is a parse error; write runs-on: ubuntu-latest
(or leave runs-on out) and keep the job’s flake: or packages:.
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.
exclude is applied first, then each include entry in order, with GitHub’s
rule: the entry’s extra keys are added to every combination whose axis values
it does not contradict (above, experimental: true lands on the node: 22
leg), and an entry that fits no combination becomes a new one. An original axis
value is never overwritten; a key added by an earlier include entry can be.
Services
Section titled “Services”Services keep real container images — the no-images rule applies to the job
environment, not to services. On hosted runners each service runs inside the
job’s VM with host networking, so a step reaches it as localhost:<port> or by
its key (postgres:5432), and the first step waits until every service passes
its health check.
services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: test ports: - 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. A remap
is not applied on hosted runners: the service listens on its container port.
healthcheck accepts either tcp: <port> or command: [...]; without one,
the job waits on a TCP check of the first port. args replaces the image’s
CMD and leaves its ENTRYPOINT alone.
tmpfs takes absolute paths inside the service 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
at 4 GiB, and its pages count against the job VM’s memory (the job’s size:).
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 service’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.
cpus is accepted, in Docker’s --cpus notation (4, 1.5, 0.25; at most
three decimals), and ignored on hosted runners: services share the job VM’s
vCPUs, so the job’s size: is the lever. Values above the largest size’s vCPU
count are rejected.
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), or any
command template containing {0} (such as bash -euo pipefail {0} or
nu {0}). 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”Warnings allow the file to parse, but unsupported fields or features are ignored:
- unknown keys at any level (workflow, job, step, service, strategy)
- valid GitHub Actions keys neosource does not model (
permissions:,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. 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 neitherowner/repo@ref(including sub-path actionsowner/repo/path@ref) nor a local./pathaction — adocker://…image, or a../pathoutside the repository, 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
Read diagnostics by clicking a workflow’s warning or error badge on the Actions tab, or retrieve them through the workflows API.
An error means the workflow does not run at all
Section titled “An error means the workflow does not run at all”When a file in .neosource/workflows/ fails to parse, it remains listed on the
Actions tab with an error diagnostic, but creates no runs or checks. If branch
protection requires one of those checks, merging stays blocked until it passes.
If CI stops after an edit, check the workflow’s diagnostics. You can also use
neo run locally to find parse errors before pushing.