Skip to content

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.

  1. 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.
  2. 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.
# before
container: node:20
# after
runs-on: nix
packages: [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.

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.

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.

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.

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.

Putting it together:

name: CI
on:
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 warnings

Note what is absent: no container:, no permissions:, no concurrency:. Those would parse, but three of them would be silently ignored.