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”Two forms, and you can mix them freely in one list.
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 referencePinning 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: requires runs-on: nix, and is mutually exclusive with packages: —
declaring both, or neither, is an error.
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: 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.
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: 5432Actions
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. 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.
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.