The job environment
Every hosted job runs in its own virtual machine on an Ubuntu 24.04 base. You
can add tools on top of that base in three ways, all through Nix. This page
helps you choose one, describes each, and shows how to find package names.
Setup time, caching and billing covers how the
environment reaches the job, what that costs, and how to keep it fast;
Actions, secrets and variables covers
uses: steps, secrets and environment variables.
Choosing a job environment
Section titled “Choosing a job environment”| You need | Use | Setup today |
|---|---|---|
What GitHub’s ubuntu-latest has on PATH: Go, Node.js, Java, .NET, Rust, Python, Ruby, Docker, the usual build tools |
nothing: the base environment | none: it is part of the VM image |
| A few extra tools, or a language toolchain at the version nixpkgs ships | bare packages: [jq, nodejs_26] |
small: typically 2–4 s |
| A language toolchain at a version you name | versioned packages: { go: "1.24" } |
small, like bare packages |
The exact environment you use locally, pinned by your own flake.lock, or tools nixpkgs does not carry |
flake: .#ci |
the largest: the whole closure is downloaded in every job (8–28 s for multi-GiB shells), and anything cache.nixos.org has not built is built in every job |
Start at the top of the table and move down only when you need to:
- Nothing. The base environment already has the
ubuntu-latesttoolchains, so many GitHub workflows run unchanged. - Bare
packages:are the first choice for anything else. A bare name means the package’s default version at the nixpkgs revision neosource pins. The name is checked before the run starts, cache.nixos.org has already built everything at that revision, and a small set of tools is ready in a few seconds. The deploy jobs of neosource’s own CI, for example, set up about 620 MiB of tools in about 3.5 s. - Versioned
packages:when the version number matters to you (go: "1.24",node: "20") and you would rather not know which nixpkgs attribute carries it. flake:for a custom or pinned environment: a dev shell you maintain in your repository, with your ownflake.lock. It is the most flexible option and the most expensive one today. See Keeping setup fast.
The same choices in YAML:
jobs: # The base environment: no environment keys at all. test-go: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: go test ./...
# Bare packages: default versions at the platform's nixpkgs revision. lint: packages: [shellcheck, actionlint, jq] steps: - uses: actions/checkout@v4 - run: shellcheck scripts/*.sh
# Versioned packages: name the version, the catalog picks the package. test-node-20: packages: node: "20" steps: - uses: actions/checkout@v4 - run: npm ci && npm test
# A flake dev shell from your own repository. build: flake: .#ci steps: - uses: actions/checkout@v4 - run: cargo build --releasepackages: and flake: are mutually exclusive: declaring both is an
error. Declaring neither is fine. Either works on every job, whatever its
runs-on says: runs-on selects where a job is scheduled, never its
environment, and no label gives you a different base image. runs-on is
optional; a job without it runs on ubuntu-latest.
jobs: test: runs-on: ubuntu-latest # scheduling label only packages: [nodejs_26] # still honoured steps: - run: node --versionMigrate from container images
Section titled “Migrate from container images”Replace the job’s container image with a list of tools:
# GitHub Actionsjobs: build: container: node:26 steps: - run: npm ci
# neosourcejobs: build: packages: [nodejs_26] steps: - run: npm ciimage: on a job is a parse error that prevents the whole workflow from
running. container: is ignored with a warning; the job uses the standard
environment. Remove both when migrating. On the Actions tab, click a
workflow’s warning or error badge to check its diagnostics.
The base environment
Section titled “The base environment”A job that declares no packages: and no flake: still gets a rich
Ubuntu 24.04 userland out of the box:
-
Standard build essentials (
git,curl,build-essential,pkg-config,cmake,jq,ripgrep,gh,unzip,zstd) and a rootfuldockerdwithbuildxandcompose. -
A pre-populated tool cache at
/opt/hostedtoolcache($RUNNER_TOOL_CACHE) carrying common versions of Go (1.24, 1.26, 1.27), Node.js (22, 24, 26), Python (3.14, 3.15), Temurin Java (17, 21, 25), and Ruby (3.4, 4.0), plus .NET (8, 10) in/usr/share/dotnet. Standardactions/setup-*steps find these versions immediately without downloading. -
The toolchains GitHub’s
ubuntu-latest(Ubuntu 24.04) has onPATHwithout a setup step, at the same default versions (except Node.js: 26, not 22), so a barerun: go testorrun: ./gradlew buildworks as it does there:Tool Default on PATHGo 1.24 ( go,gofmt);GOROOT_1_24_X64,GOROOT_1_26_X64,GOROOT_1_27_X64Node.js 26 (GitHub: 22), with npm,npx,corepackand Yarn 1 (yarn)Java Temurin 17; JAVA_HOME, plusJAVA_HOME_17_X64,JAVA_HOME_21_X64,JAVA_HOME_25_X64.NET the newest SDK (10) as dotnetRust stable via rustup, withclippyandrustfmtPython Ubuntu’s 3.12 as python/python3, withpipandpipxRuby Ubuntu’s 3.2, with gemandbundleThey are ordinary writable installs:
rustup component add,npm install -g,corepack enable,pip installandgem installwork. Go, Node.js, Java, .NET, Python and Ruby also work throughsudo.rustupandcargodo not: they live in~/.cargo/bin, whichsudo’s fixedPATHleaves out, so run them withoutsudo, as on GitHub. Anything you declare wins over them: asetup-*step,$GITHUB_PATH,packages:andflake:all come first onPATH. A Java you declare inpackages:orflake:also takes overJAVA_HOMEinrun:steps. When it comes from Nix, an action (uses:) still sees the defaultJAVA_HOME. SetJAVA_HOMEinenv:, or useactions/setup-java, if an action needs the declared one.
When a job declares packages: or flake:, the VM adds the declared Nix
environment to this base before the first step that needs it, with the
packages you declare first on PATH. Where there is no Ubuntu userland under
the job (a self-hosted runner without the base image, or neo run on your
machine), a job with packages: (and no flake:) also gets a baseline Nix
toolset (bash, coreutils, git, jq, curl, cacert).
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_26, rustc, cargo, python312, jq,
python3Packages.numpy. Each resolves to that attribute at the workflow’s
nixpkgs revision: the platform’s pinned revision unless the workflow sets
nixpkgs:. See
Finding packages for how to look a name up.
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. They cost what a flake: costs: their own
flake is evaluated, and its inputs are fetched, in the job.
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 python3Packages.numpy is still a bare attribute.
packages: - jq # nixpkgs attribute - python3Packages.numpy # 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 (node, python, go,
java, ruby, deno, bun, zig, dotnet, rust, cargo), 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. A
version the catalog has no entry for fails the run before it starts, with
the versions it does have; it is never rounded to a nearby one.
Unknown names are rejected before anything runs
Section titled “Unknown names are rejected before anything runs”Without a nixpkgs: override, bare attributes are checked against the
platform’s pinned revision when the run is created. A name that revision does
not carry, or one nixpkgs has removed, fails the run up front and nothing
runs. The same error is shown on the workflow when you push it:
unsupported packages: ripgrepp — cannot run workflow. lint: packages.ripgrepp: 'ripgrepp' is not a nixpkgs package at the platform's pinned revision (39ad350a0602). Check the name at /api/packages?q=ripgreppNothing was run.The revision in the message is the pin the name was checked against. The
/api/packages search it suggests covers the toolchain catalog only today (see
Finding packages), so an empty result there does not mean
the name is wrong. For a removed attribute the message says so and suggests a
current package or an older nixpkgs: pin. One exception: a removed attribute
that the toolchain catalog still carries from an older pinned revision
resolves through the catalog, so packages: [nodejs_20] runs the same Node 20
as packages: { node: "20" }.
The same check applies to names built from a matrix (packages: ["${{ matrix.pkg }}"]):
each leg is checked when it is created, and a leg whose name is unsupported fails
alone with the same error while the other legs run. Names the server cannot see
before the job starts (a matrix expanded at run time) are checked when the job
requests them, with the same outcome.
A current runner evaluates the job’s bare attributes at the platform’s revision, even if the runner was built against a different default.
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 with a top-level nixpkgs:, preferably an
exact commit:
name: CInixpkgs: github:NixOS/nixpkgs/<commit>
jobs: build: packages: [rustc, cargo] steps: - run: cargo buildThis only affects bare attributes. Flake references carry their own source and
are unaffected. An override has two costs: names are not checked before the
run (a wrong one fails in the job’s setup instead), and the job downloads that
nixpkgs revision’s source, which the VM image carries only for the platform’s
own revisions. A branch name such as nixos-25.05 also works, but it moves:
two runs of the same commit can get different tools.
Finding packages
Section titled “Finding packages”Versioned catalog toolchains. neosource.dev/packages
lists the toolchain catalog: every tool the versioned map accepts and the
versions it resolves. The same data is public at GET /api/packages?q=<prefix>
and GET /api/packages/<attr> (see the API reference). It does not
cover all of nixpkgs yet.
Bare names. A bare name must exist at the revision the platform pins. To find that revision:
neo versionprints the nixpkgs pin your CLI was built with; it matches the platform’s while your CLI is current (neo upgrade).- A job’s log names the revision it used, in its setup header:
==> setup: nix shell jq ripgrep (nixpkgs github:NixOS/nixpkgs/39ad350…).
Then look the name up:
- search.nixos.org searches the current heads of the nixpkgs channels, not the platform’s exact revision. A name listed there is almost always present at the pin, but a package added or renamed very recently can differ.
- To check exactly, ask Nix about that revision:
nix eval github:NixOS/nixpkgs/<revision>#ripgrep.version. - When the workflow is indexed (a push to the default branch), every bare name is checked, and the workflow’s error badge on the Actions tab names any the pin does not carry.
flake:: your own dev shell
Section titled “flake:: your own dev shell”If your repository already has a dev shell, point at it instead of listing packages:
jobs: build: flake: .#ci steps: - uses: actions/checkout@v4 - run: cargo testThe job runs nix develop .#ci. Because .#ci names your workspace, the
environment is set up after actions/checkout has filled it, just before the
first run: step; steps before that, such as the checkout itself, run on the
base environment. The environment is then captured once (nix print-dev-env)
and every later step runs inside that captured environment instead of entering
nix develop again; a shellHook still runs at the start of each step, as it
would under nix develop. If the shell can’t be captured faithfully (for
example it uses __structuredAttrs), or flake.nix/flake.lock changes
during the job, steps fall back to their own nix develop and the log says so
in one line.
The flake is yours: it pins its own nixpkgs in your flake.lock, can carry
overlays, custom derivations and tools nixpkgs does not have, and gives you
the same shell locally with nix develop. That freedom is also its cost:
everything the shell needs is downloaded in each job today, and anything
cache.nixos.org has not built is built in each job. Keep one lean shell per
kind of job rather than one shell with everything; see
Keeping setup fast.
The setup phase
Section titled “The setup phase”A job resolved through Nix starts with a labelled, timed log phase before the
first step that needs it: ==> setup: nix flake shell <ref> for flake:, or
==> setup: nix shell <entries> (nixpkgs <revision>) for packages:. It
ends with one summary line:
nix environment ready: substituted 133 paths (3.4 GiB unpacked): cache.nixos.org 133 (3.4 GiB); built 2 derivations in 14.2s (eval 4.1s · substitute 8.9s · build 1.0s: nix-shell-env 0.6s, …)Reading the setup line explains each field. When the flake or nixpkgs source lives in your repository, the phase runs after checkout. It counts against the job’s timeout, and a failure there fails the job before the step that needed the environment runs, naming the environment and carrying Nix’s error.
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: ubuntu-latestpackages: ["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. Service containers 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: 5432Every hosted job runs in its own virtual machine (see
Runners), and each service is a container
under that VM’s own dockerd, started before the first step. Services share the
VM’s network, so a service is reachable as localhost:<port> and by its
service key: postgres:5432 works as it does on GitHub. Any image works; there
is no allow-list.
Two differences from GitHub:
ports:does not remap. Both the bare and the"host:container"forms are accepted, but a service always listens on its container port; a remap to a different host port is logged and not applied.- The
neoCLI’s runner does not start services. A job that declaresservices:is never handed to aneo runner startagent, andneo runrefuses it before the first step. A self-hosted runner on the VM backend (see Runners) runs them as the hosted pool does.
Shells
Section titled “Shells”shell: accepts bash, sh, python, and pwsh (or powershell), or any
command template containing {0} (bash -euo pipefail {0}, nu {0}). bash is
the default. Any other value is a parse error.
On hosted runners shell: python works without packages:: the base
environment has Python 3.12 (python3). pwsh is not in it; declare it in
packages:. A self-hosted runner without the base image has neither unless
you declare them.