The job environment
This is the page to read before writing your first workflow, and the one thing about neosource CI that will not match your instincts.
Jobs do not choose a container image. They declare the tools they need.
jobs: build: runs-on: nix packages: [nodejs_20] steps: - run: node --versionThe translation you probably need
Section titled “The translation you probably need”If you are moving a workflow across, this is the change you will make most often:
# GitHub Actionsjobs: build: container: node:20 steps: - run: npm ci
# neosourcejobs: build: runs-on: nix packages: [nodejs_20] steps: - run: npm ciimage: on a job is a hard parse error — the whole file fails, with a
message pointing at packages:. That is deliberate: silently ignoring it would
run your job in an environment you did not ask for and did not get.
container: on a job is a warning, and it is ignored. The job still runs, in
the canonical environment. Because the web UI does not currently render workflow
warnings, a container: you forgot to remove is invisible — the job just quietly
runs somewhere other than where you think. Grep your workflows for it after a
migration.
Why it works this way
Section titled “Why it works this way”A container image is a coarse tool for a simple need. Most workflows pick
node:20 because they want a node binary, then discover they also need git,
curl, or a compiler, and end up either layering a custom image or
apt-get install-ing at the top of every run.
Declaring tools directly removes that whole layer. packages: entries resolve
against nixpkgs, so a job can ask for
several toolchains at once without anyone building and publishing an image that
has that particular combination:
runs-on: nixpackages: [nodejs_20, python312, ripgrep, jq]The result is content-addressed and pinned, so the same workflow resolves to the same tools on every run.
What packages: accepts
Section titled “What packages: accepts”Three forms. The first two are list entries and mix freely in one list; the third replaces the list with a map.
Bare nixpkgs attributes — nodejs_20, rustc, cargo, python312, jq,
nodePackages.typescript. These resolve against the workflow’s nixpkgs
revision. Find attribute names at search.nixos.org.
Full flake references — anything carrying a source, such as
github:nix-community/fenix#complete.toolchain or .#mytool. These are passed
through as their own installable.
The rule that decides which is which: an entry containing #, : or / is
treated as a flake reference; anything else is a bare nixpkgs attribute. A dotted
name like nodePackages.typescript is still a bare attribute.
packages: - jq # nixpkgs attribute - nodePackages.typescript # dotted nixpkgs attribute - github:nix-community/fenix#complete.toolchain # flake referenceVersioned map — instead of a list, give a map of toolchain name to version
string. Names resolve against a built-in catalog (go, node, python,
java, and friends), so you ask for a version rather than knowing the nixpkgs
attribute that carries it:
packages: go: "1.24" node: "20"Values must be strings — quote them, or YAML turns 20 into a number and
1.24 into a float. Use the list form for anything outside the catalog.
Pinning the package set
Section titled “Pinning the package set”Bare attributes resolve against a default nixpkgs revision pinned by the platform. Override it per workflow:
name: CInixpkgs: github:NixOS/nixpkgs/nixos-25.05
jobs: build: runs-on: nix packages: [rustc, cargo] steps: - run: cargo buildThis only affects bare attributes. Flake references carry their own source and are unaffected.
flake: for full hermeticity
Section titled “flake: for full hermeticity”If your repository already has a dev shell, point at it instead of listing packages:
runs-on: nixflake: .#cisteps: - run: cargo testflake: works on every job, whatever the runs-on label, and is mutually
exclusive with packages: — declaring both is an error. Declaring neither
is fine; the job resolves to the baseline toolset alone.
packages: works on every job
Section titled “packages: works on every job”packages: is not tied to runs-on: nix. Any job that declares packages: gets
those tools, whatever its runs-on says:
jobs: test: runs-on: ubuntu-latest # scheduling label only packages: [nodejs_20] # still honoured steps: - run: node --versionruns-on selects where a job is scheduled. It does not select the environment.
There is no label that gives you a different base image.
The base environment
Section titled “The base environment”A job that declares no packages: and no flake: still gets a working
userland with the common build dependencies (git, curl, compilers, standard
headers) already present. On hosted runners that is the runner’s own job image;
on a local or self-hosted runner that wraps a real container it is
buildpack-deps:noble. ADR 0057’s end state is a single published neosource
job image that the two converge on.
Any job resolved through Nix — one with packages: and no flake: — also gets
a baseline toolset merged in implicitly: bash, coreutils, git, jq,
curl and cacert. Packages you declare keep PATH precedence over it.
That is the floor. Add anything else with packages: rather than installing it
at runtime.
Matrix over toolchains
Section titled “Matrix over toolchains”Because the toolchain is a value rather than an image, a matrix can vary it directly:
strategy: matrix: node: [18, 20, 22]runs-on: nixpackages: ["nodejs_${{ matrix.node }}"]steps: - run: node --versionThis is the direct replacement for a matrix over container images.
Services keep real images
Section titled “Services keep real images”The no-images rule applies to the job environment only. Sidecar services still take ordinary container images, because that is the right shape for a database:
services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: test ports: - 5432 healthcheck: tcp: 5432Two limits that GitHub does not have:
- Sidecars run on hosted runners only. A self-hosted or local runner
refuses a job that declares
services:outright rather than filtering it. - The image must be on the allow-list, which today is
postgres,nats,dxflrs/garageandfoundationdb. The tag is unrestricted but an untagged image is rejected, and anything else —redis:7included — is refused when the job is claimed. Opening this to arbitrary images is a separate decision.
ports: is accepted in both the bare and "host:container" forms, but the
host port is never bound — reach a sidecar by its container port.
Actions
Section titled “Actions”uses: steps work in two ways.
Native implementations. The most common actions are re-implemented inside the runner rather than downloaded, so they are fast and have no network dependency:
actions/checkoutactions/cache, plusactions/cache/saveandactions/cache/restoreactions/upload-artifact,actions/download-artifact- toolchain setup —
actions/setup-node,setup-python,setup-go,setup-java,setup-dotnet,setup-ruby, and the vendor-namespaced ones under their real slugs:ruby/setup-ruby,denoland/setup-deno,mlugg/setup-zigandgoto-bus-stop/setup-zig,shivammathur/setup-php,haskell-actions/setupandhaskell/actions,erlef/setup-beam,oven-sh/setup-bun,dtolnay/rust-toolchain - the docker family —
docker/login-action,docker/metadata-action,docker/build-push-action— mapped onto buildah, since a job pod has no dockerd
Version suffixes are accepted and ignored — actions/checkout@v4 and
actions/checkout@v6.0.2 both resolve to the native implementation.
Fetched actions. Anything else is fetched as owner/repo@ref by shallow
git clone from github.com, and its action.yml is executed. Composite,
Docker and Node actions (node12/node16/node20) run. Any other
runs.using value is rejected with an error naming what is supported.
Two practical consequences: fetching an action requires network egress to GitHub
at job time, and a uses: reference that is not owner/repo@ref is rejected.
A docker://… image reference or a local ./path action fails at parse
time, taking the whole file with it; an owner/repo/subdir@ref form parses
and then fails the step mid-job.
Reusable workflows (jobs.<id>.uses:) are a different feature and are not
supported — such a job is skipped with a warning while its siblings still run.
Secrets
Section titled “Secrets”Reference secrets with the usual expression syntax:
steps: - run: ./deploy.sh env: API_TOKEN: ${{ secrets.API_TOKEN }}Secrets are managed per workspace through the API:
GET /api/workspaces/{workspace_id}/ci/secretsPUT /api/workspaces/{workspace_id}/ci/secrets/{name}DELETE /api/workspaces/{workspace_id}/ci/secrets/{name}The listing returns names and metadata only, never values. On hosted runners, secrets are fetched per job rather than held on the runner.
Environment variables
Section titled “Environment variables”env: can be set at workflow, job and step level. Precedence is
step > job > workflow — a workflow-level env reaches every job, and a job
or step redefining the same key wins.
Expression contexts available to ${{ … }} include env, steps, needs,
matrix, github, runner, inputs, secrets, vars, job and strategy.
Shells
Section titled “Shells”shell: accepts bash, sh, python, and pwsh (or powershell). bash is
the default. Any other value is a parse error.
If you use python or pwsh, declare the interpreter in packages: — the base
environment is not guaranteed to carry every interpreter the parser will accept.