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.

Two forms, and you can mix them freely in one list.

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

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: requires runs-on: nix, and is mutually exclusive with packages: — declaring both, or neither, is an error.

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: runs in the canonical container image, currently buildpack-deps:noble — Ubuntu 24.04 with the common build dependencies (git, curl, compilers, standard headers) already present.

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

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. These include actions/checkout, actions/upload-artifact, actions/download-artifact, and the toolchain setup family — actions/setup-node, setup-python, setup-go, setup-bun, setup-java, setup-dotnet, setup-ruby, setup-deno, setup-zig, setup-php, setup-haskell, erlef/setup-beam, oven-sh/setup-bun and dtolnay/rust-toolchain.

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 fails the step.

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.