Skip to content

Coming from GitHub Actions

neosource supports GitHub Actions workflow syntax, with differences in job environments, triggers, and supported features.

  1. Enable or move the files. Either run .github/workflows/ in place with neo repo set --ci-workflow-source neosource-and-github, or move the files to .neosource/workflows/ on your default branch. Only files at the top level of either directory run.
  2. Declare tools with packages:. image: on a job is a parse error; container: is ignored with a warning. See The job environment.
# before
container: node:26
# after
runs-on: ubuntu-latest
packages: [nodejs_26]
Feature Notes
Workflow file syntax Same YAML shape, .yml or .yaml.
name, jobs, steps Unchanged.
run: steps Unchanged, including GitHub’s implicit bash -e {0}: every body under a keyword bash/sh shell (or the default) runs under errexit, so a command failing mid-block fails the step rather than being hidden by the last command’s status. Guard the ones allowed to fail (cmd || true, or an if).
env: at workflow, job and step level Same precedence: step > job > workflow.
needs: Enforced server-side, in both the list and bare-scalar spellings; dependants are skipped transitively when a dependency fails. A name that resolves to no job, or a cycle, is a hard parse error.
Step-level if: Evaluated, with GitHub’s implicit success() && gate: a condition naming no status function does not run the step after an earlier step in the job failed.
strategy.matrix Free-form axes, plus include (merged into matching combinations, as on GitHub), exclude, fail-fast, max-parallel.
Job outputs: Unchanged.
continue-on-error, timeout-minutes Step and job level, with one exception: a job-level continue-on-error is honoured only by neo run: on hosted runners it is accepted and ignored, so a failing job fails the run.
working-directory: Unchanged, ${{ }} expressions included. defaults.run.working-directory applies at workflow and job level, to run: steps without their own.
shell: bash, sh, python, pwsh/powershell. A command template containing {0} (bash -euo pipefail {0}, nu {0}, /usr/bin/env -u ENV /bin/sh -eu {0}) runs as written, as on GitHub: its options are honoured and no errexit is added, so bash {0} runs past a failing command. A job with such a template waits in the queue until a runner that supports it claims it; a self-hosted runner built before this support must be upgraded to run it. A command without {0} (/bin/bash, bash --login) runs under its interpreter with a warning that its options are ignored.
concurrency: Workflow and job level. One active + one pending per group, newer pending cancels older, cancel-in-progress: true also cancels the active run. Group names case-insensitive, scoped per repo.
services: Real container images, with env, ports and healthcheck. Each runs under the job VM’s own dockerd with host networking, so reach it as localhost:<port> or by its service key (postgres:5432). A "host:container" remap is not applied: the service listens on its container port. Not on the neo CLI’s runner or neo run.
${{ … }} expressions Contexts: env, steps, needs, matrix, github, runner, inputs, secrets, vars, job, strategy.
${{ secrets.NAME }} Secrets are managed per workspace via the API.
on: merge_group Dispatched by the merge queue (types: [checks_requested], plus branches / branches-ignore on the target branch). Supersedes and cancels redundant pull_request runs on behind heads, under a rule that requires up-to-date branches.
actions/checkout, actions/cache, actions/setup-*, docker/* Real upstream actions, SHA-pinned and cached in Garage by the platform (mounted on a read-only EROFS disk on hosted VMs). Common toolchains (Go, Node, Python, Java, Ruby, .NET) are pre-installed in /opt/hostedtoolcache and missing versions are pre-hydrated from a Garage mirror. actions/cache uses the platform’s cache service.
actions/upload-artifact, actions/download-artifact Native v4 implementations backed by neosource’s artifact store (@v4 or later; @v1–@v3 fail with GitHub’s deprecation error).
Third-party and sub-path actions Any owner/repo@ref or owner/repo/path@ref action, plus local ./path actions. Composite, Docker, and Node (node24; node12/16/20 run on node24) actions run.
Self-hosted runners Registration, long-poll claim, heartbeat and completion all work. The agent is embedded in the neo CLI (neo runner start) rather than a separate download, and a registration is actor-bound by default. See Runners.

These work, but not exactly the way they do on GitHub.

Feature Caveat
Workflow location .neosource/workflows/ (plus .github/workflows/ once enabled with neo repo set --ci-workflow-source neosource-and-github), top level only, indexed from the default branch only. Subdirectories are ignored.
on: pull_request Fires on opened and synchronize by default. types: can also select closed (with github.event.pull_request.merged set for a merge), ready_for_review, converted_to_draft, edited (title or body) and review_requested (only when the requester has write access). reopened, labeled and the other activity types never fire: nothing here performs them. The closed run of a merge checks out the merge commit (github.sha, with github.event.pull_request.head.sha still the PR head), as on GitHub; every other pull-request run checks out the PR head itself, not GitHub’s test-merge commit.
Branch and path globs Custom matcher, anchored at both ends: * does not cross /, ** does. release/* matches release/v1 but not release/v1/rc1.
paths / paths-ignore Same semantics as GitHub on both push and pull_request (the PR form filters the whole base..head diff). Degrade to matching everything when the changed-path set is unknown or exceeds 1000 paths — including a fork PR, whose commits are not in the target repo until the merge. The run happens rather than being skipped.
tags / tags-ignore Same semantics as GitHub on push: tags: filters tag pushes and branches: branch pushes; with only one kind declared, the other kind of push does not fire. paths: is not evaluated for a tag push.
runs-on: Scheduling label only. No value selects a different image or OS. Optional: a job without it runs on ubuntu-latest. A job that has waited 15 minutes for a runner and whose labels no runner available to the repository offers fails with a runs-on-unschedulable log naming the missing labels; enroll a runner with them or change runs-on, then re-run.
runs-on: ${{ … }} Resolved from your strategy.matrix before scheduling. A static matrix (no ${{ }} anywhere in its axes, include: or exclude:) is split into one job per leg, and each leg is placed by its own resolved runs-on, so a cross-platform matrix (os: [ubuntu-latest, macos-latest]) runs each leg on a runner with that leg’s label. A matrix with an expression in it stays one job that one runner executes every leg of, so its runs-on: ${{ matrix.os }} works only when every leg resolves to the same label; a whole-matrix expression (matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}) leaves matrix.* unbound. Either of those fails, as does a runs-on reading needs.*, inputs.*, vars.* or github.event.*: those are not knowable before the job is claimed, and guessing would place the job on a runner you did not ask for. The job fails with a runs-on-unschedulable message in its log naming the value and why it cannot be placed — it does not sit queued forever, and its siblings still run.
runs-on: [] / "" Rejected. An empty label set matches every runner here, so honouring it would place a job on any fleet — the same reason runs-on: {group: X} without labels: is rejected. Name at least one label.
Job-level if: Evaluated, with GitHub’s semantics — but a cancelled run skips always() jobs, and cancelled() is never true. See below.
Unmodeled GitHub keys permissions:, defaults.run.shell:, run-name:, environment:, secrets: inherit are accepted, warned about, and ignored. Your workflow still runs.
container: Warned and ignored — the job runs in the canonical environment regardless.
with: inputs Must be scalars. A list or map value is a parse error.
Artifact action versions actions/upload-artifact and actions/download-artifact require @v4 (or a v4.* tag/SHA). @v1–@v3 fail with GitHub’s deprecation error.
Platform Hosted runners are Linux (x86_64) only.

After migrating, click each workflow’s warning or error badge on the Actions tab to review its diagnostics. Parse errors prevent the workflow from running. The workflows API also returns diagnostics in each workflow’s warnings array.

Feature Status
.github/workflows/ Parsed and reported on the Actions tab; runs only once a repo admin enables it (neo repo set --ci-workflow-source neosource-and-github). A same-named .neosource/workflows/ file shadows it.
image: on a job Hard parse error, with a pointer to packages:.
on: repository_dispatch Parses, but nothing dispatches or receives it.
on: change Not implemented. A hard parse error when it is a workflow’s only trigger; a warning otherwise.
Reusable workflows (jobs.<id>.uses:) Not supported. That job is skipped with a warning; its siblings still run — unless one of them needs: it, which is a hard parse error and stops the whole file running. Inline the reusable workflow’s steps.
permissions: Ignored — no per-job token scoping.
Deployment environment: Ignored — no environments, approvals or protection rules.
Windows and macOS runners Linux only.
Other trigger events release, issues, workflow_run and the rest warn and drop that trigger.

A condition on a job gates the job, following GitHub’s rules — including the one most people have backwards:

  • A skipped dependency does not satisfy success(). If build is skipped, a deploy that needs: [build] is skipped too, and so is everything downstream of it. This is the default, not an error case.
  • An if: that names no status function is implicitly &&-ed with success(). if: github.ref_name == 'main' means success() && github.ref_name == 'main', so it does not resurrect a job whose dependencies failed. To opt out, name a status function yourself: always(), failure(), !cancelled(), success() || …. Any of those replaces the implicit gate and the expression alone decides.

A skipped job reports a green check, not a red one — the same as GitHub. A job you gated off will not block a merge on a required check.

Two deliberate differences from GitHub:

  • always() does not survive a run cancel. Cancelling a run cancels every job that has not started, if: always() included. On GitHub an always() job runs during a cancellation; here a cancel is final. Do not rely on an always() job for cleanup after a cancel.
  • cancelled() is never true. It is accepted and evaluates to false (so !cancelled() is always true), because the scheduler stops promoting jobs the moment a run starts cancelling — the state it would report is unreachable. Write always() if you mean “regardless of what happened upstream”.

What a job if: can read is narrower than at step level, because it is evaluated on the server before any job exists. github.* and needs.* resolve. env, steps, runner, strategy, matrix, secrets, vars, inputs and github.event do not — except github.event.action and github.event.pull_request.merged on a pull-request run, so if: github.event.pull_request.merged == true gates a deploy-on-merge job correctly.

Reading one of them does not silently skip the job. The condition is evaluated in three values — true, false, and unknown — and only a genuinely unanswerable result runs the job (with a warning in the run log). An unknown that does not change the answer is ignored:

# `build` failed, so `success()` is false — the job is SKIPPED, even though
# the other half cannot be evaluated.
if: success() && vars.DEPLOY == 'prod'
# On branch `dev` the first half is false — SKIPPED, not run.
if: github.ref_name == 'main' && vars.DEPLOY == 'prod'
# Nothing decides this one, so it RUNS rather than vanishing silently.
if: vars.DEPLOY == 'prod'

Anything that fails to evaluate at all (a typo, an unknown function) also runs the job, rather than wedging the run.

GitHub has no per-job path filter, so workflows bolt one on with dorny/paths-filter (a classifier job whose outputs gate the rest) or tj-actions/changed-files (a step that lists the files). Both still run here. neosource also answers the question directly, from the same changed-path set the trigger’s paths: filter read:

# GitHub, with dorny/paths-filter
jobs:
changes:
runs-on: ubuntu-latest
outputs:
src: ${{ steps.filter.outputs.src }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
src:
- 'src/**'
test:
needs: changes
if: needs.changes.outputs.src == 'true'
runs-on: ubuntu-latest
steps:
- run: make test
# neosource
jobs:
test:
if: pathsChanged('src/**')
runs-on: ubuntu-latest
steps:
- run: make test

The job is decided before it is scheduled, so no classifier job runs, and when the set cannot be determined (more than 1000 paths, a fork pull request, a manual or scheduled run) the job runs instead of being skipped. For the file list tj-actions/changed-files gives a step, read $NEOSOURCE_CHANGED_FILES or ${{ neosource.changes.files }}; see Changed files. pathsChanged() and neosource.* are neosource extensions: a workflow that uses them does not run on GitHub.

Putting it together:

name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
test:
runs-on: ubuntu-latest
packages: [rustc, cargo]
strategy:
matrix:
rust: [stable]
steps:
- uses: actions/checkout@v4
- run: cargo test
lint:
runs-on: ubuntu-latest
packages: [rustc, cargo, clippy]
needs: [test]
steps:
- uses: actions/checkout@v4
- run: cargo clippy -- -D warnings

Jobs use packages: for their tools. container: and permissions: are ignored with warnings. You can add concurrency: to limit concurrent runs or jobs; it is enforced at both levels.