Coming from GitHub Actions
neosource CI aims to be familiar, not identical. Most of a GitHub Actions workflow carries over unchanged. Some of it carries over with behaviour you need to know about. Some of it does not work at all yet.
This page is the inventory. It is deliberately blunt: a compatibility page that oversells is worse than no page, because you find out at 2am.
Start here: two things to do first
Section titled “Start here: two things to do first”- Move the files.
.github/workflows/is parsed and reported on the Actions tab, but nothing in it is ever registered or run. Move your workflows to.neosource/workflows/or you have no CI. Only the top level of that directory is read, and only on your default branch. - Replace images with packages.
image:on a job is a hard parse error;container:is silently ignored. Jobs declare tools instead — see The job environment.
# beforecontainer: node:20
# afterruns-on: nixpackages: [nodejs_20]These behave as you expect.
| 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 bash/sh body 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, exclude, fail-fast, max-parallel. |
Job outputs: |
Unchanged. |
continue-on-error, timeout-minutes |
Both job and step level. |
working-directory: |
Unchanged. |
shell: |
bash, sh, python, pwsh/powershell. A command template (bash -euo pipefail {0}) is accepted for its interpreter, and warns that the options are dropped — errexit applies regardless, but pipefail and -u do not; set them in the script. |
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 — but only from an allow-list (postgres, nats, dxflrs/garage, foundationdb; untagged rejected), and only on hosted runners. A declared host port is not bound. |
${{ … }} expressions |
Contexts: env, steps, needs, matrix, github, runner, inputs, secrets, vars, job, strategy. |
${{ secrets.NAME }} |
Secrets are managed per workspace via the API. |
actions/checkout |
Native implementation. Version suffix ignored. |
actions/upload-artifact, actions/download-artifact |
Native implementations. |
actions/cache |
Native, including actions/cache/save and actions/cache/restore. |
| Toolchain setup actions | Native: actions/setup-{node,python,go,java,dotnet,ruby}, plus denoland/setup-deno, mlugg/setup-zig and goto-bus-stop/setup-zig, shivammathur/setup-php, haskell-actions/setup and haskell/actions, ruby/setup-ruby, erlef/setup-beam, oven-sh/setup-bun, dtolnay/rust-toolchain. |
docker/login-action, docker/metadata-action, docker/build-push-action |
Native, mapped onto buildah — there is no dockerd in a job pod. |
| Third-party actions | Fetched from github.com as owner/repo@ref. Composite, Docker and Node (node12/16/20) 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/, top level only, indexed from the default branch only. Subdirectories are ignored. |
on: pull_request |
Fires on opened and synchronize only. Other types: — reopened, closed, labeled — never fire, even if you list them. |
| 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. |
runs-on: |
Scheduling label only. No value selects a different image or OS. |
runs-on: ${{ … }} |
Resolved from your strategy.matrix before scheduling, so runs-on: ${{ matrix.os }} works whenever every leg resolves to the same label (a single-valued axis, or an include:-only matrix). A real cross-platform matrix (os: [ubuntu-latest, macos-latest]) does not: one runner executes every leg of a job here, so no runner can satisfy it. That job fails with a runs-on-unschedulable message in its log naming the value and the labels its legs wanted — it does not sit queued forever, and its siblings still run. Same outcome for 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. |
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-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. |
| Third-party actions | Fetching requires network egress to github.com at job time. |
| Platform | Linux only. |
Every caveat above that drops something — an ignored container:, a dropped
trigger, a misspelled key — records a parse diagnostic, and the Actions page
shows them: each workflow in the sidebar carries a badge, and the workflow’s own
page lists them in full. A file that fails to parse outright is stored inert
(no triggers, no jobs) and flagged as an error rather than silently never
running. After migrating, the badges are the thing to check; the same
diagnostics are on GET /api/repos/{owner}/{repo}/workflows as a warnings
array per workflow.
Not yet
Section titled “Not yet”These do not work. Plan around them.
| Feature | Status |
|---|---|
.github/workflows/ |
Parsed and reported on the Actions tab, but never registered and never run. Move the files. |
image: on a job |
Hard parse error, with a pointer to packages:. |
tags: / tags-ignore: |
Parsed and discarded. A workflow filtering only on tags fires on every push. |
on: workflow_dispatch |
Parses, but no endpoint dispatches it. Use POST /api/workflow-runs for manual runs. |
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.
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.
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: nix packages: [rustc, cargo] strategy: matrix: rust: [stable] steps: - uses: actions/checkout@v4 - run: cargo test
lint: runs-on: nix packages: [rustc, cargo, clippy] needs: [test] steps: - uses: actions/checkout@v4 - run: cargo clippy -- -D warningsNote what is absent: no container:, no permissions:, no concurrency:. Those
would parse, but three of them would be silently ignored.
See also
Section titled “See also”- Writing a workflow — the full accepted schema.
- The job environment —
packages:in depth. - How CI works here — discovery, triggers and diagnostics.