Skip to content

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.

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

packages: declares the job’s tools. See The job environment for how to choose between packages:, flake: and the base 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.

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

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:

  • change as a workflow’s only trigger is a hard parse error rather than registering a silent no-op.
  • change alongside 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]

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 a paths: ['src/**'] check if an earlier commit on the PR touched src/.

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.

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.

Anyone with Write permission can start an on: workflow_dispatch workflow from Run workflow on the Actions tab or via the CLI:

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

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.

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 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: 2g

ports 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: 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), 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 list
with:
args: [a, b]

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 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 neither owner/repo@ref (including sub-path actions owner/repo/path@ref) nor a local ./path action — a docker://… image, or a ../path outside the repository, 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

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.