Coming from GitHub Actions
neosource supports GitHub Actions workflow syntax, with differences in job environments, triggers, and supported features.
Migrate your workflows
Section titled “Migrate your workflows”- Enable or move the files. Either run
.github/workflows/in place withneo 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. - Declare tools with
packages:.image:on a job is a parse error;container:is ignored with a warning. See The job environment.
# beforecontainer: node:26
# afterruns-on: ubuntu-latestpackages: [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. |
Works, with caveats
Section titled “Works, with caveats”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.
Not yet
Section titled “Not yet”| 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. |
Job-level if:
Section titled “Job-level if:”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(). Ifbuildis skipped, adeploythatneeds: [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 withsuccess().if: github.ref_name == 'main'meanssuccess() && 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 analways()job runs during a cancellation; here a cancel is final. Do not rely on analways()job for cleanup after a cancel.cancelled()is never true. It is accepted and evaluates tofalse(so!cancelled()is always true), because the scheduler stops promoting jobs the moment a run starts cancelling — the state it would report is unreachable. Writealways()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.
Per-job path filters
Section titled “Per-job path filters”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-filterjobs: 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
# neosourcejobs: test: if: pathsChanged('src/**') runs-on: ubuntu-latest steps: - run: make testThe 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.
A migrated workflow
Section titled “A migrated workflow”Putting it together:
name: CIon: 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 warningsJobs 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.
See also
Section titled “See also”- Writing a workflow — the full accepted schema.
- The job environment —
packages:in depth. - Setup time, caching and billing — setup cost, caching and billed minutes.
- How CI works here — discovery, triggers and diagnostics.