Skip to content

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 --version

If you are moving a workflow across, this is the change you will make most often:

# GitHub Actions
jobs:
build:
container: node:20
steps:
- run: npm ci
# neosource
jobs:
build:
runs-on: nix
packages: [nodejs_20]
steps:
- run: npm ci

image: 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.

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: nix
packages: [nodejs_20, python312, ripgrep, jq]

The result is content-addressed and pinned, so the same workflow resolves to the same tools on every run.

Three forms. The first two are list entries and mix freely in one list; the third replaces the list with a map.

Bare nixpkgs attributesnodejs_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 reference

Versioned 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.

Bare attributes resolve against a default nixpkgs revision pinned by the platform. Override it per workflow:

name: CI
nixpkgs: github:NixOS/nixpkgs/nixos-25.05
jobs:
build:
runs-on: nix
packages: [rustc, cargo]
steps:
- run: cargo build

This only affects bare attributes. Flake references carry their own source and are unaffected.

If your repository already has a dev shell, point at it instead of listing packages:

runs-on: nix
flake: .#ci
steps:
- run: cargo test

flake: 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: 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 --version

runs-on selects where a job is scheduled. It does not select the environment. There is no label that gives you a different base image.

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.

Because the toolchain is a value rather than an image, a matrix can vary it directly:

strategy:
matrix:
node: [18, 20, 22]
runs-on: nix
packages: ["nodejs_${{ matrix.node }}"]
steps:
- run: node --version

This is the direct replacement for a matrix over container 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: 5432

Two 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/garage and foundationdb. The tag is unrestricted but an untagged image is rejected, and anything else — redis:7 included — 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.

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/checkout
  • actions/cache, plus actions/cache/save and actions/cache/restore
  • actions/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-zig and goto-bus-stop/setup-zig, shivammathur/setup-php, haskell-actions/setup and haskell/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.

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/secrets
PUT /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.

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.

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.