The real CI for Nix, now on GitHub Actions!
Inspired by nixpkgs-review-gha and garnix (RIP).
The atelier name is stolen from
とんがり帽子のアトリエ's English localization
(great manga/anime btw, I caught up with the manga in a little less than a week,
that's how good it is).
Atelier evaluates a flake and fans out one native build per derivation across
GitHub-hosted runners, each surfaced as its own check run with live logs. An
optional rule file atelier.toml at the repo root selects what to build with
dotted glob patterns (similar to garnix, rip) matched against the full flake
attribute path; with no rule file, the built-in defaults apply.
See check status in my config repo (push target is secretless niks3 with GHA OIDC), click on the green/yellow/red dot/cross associated with commits.
Known limitations:
- It is recommended that all users to setup a cache endpoint or all builds will be lost
- Matrix jobs are independent, duplicate builds are expected. See more here
- GitHub app created PRs will not trigger build. See example here. Do note that you can invoke the action with in automated jobs like this but the check status will only be associated with the commit, not the PR. However dependabot created PRs will work
- If you have niks3 as cache push target, consider adding
https://cache.ysun.coto the substituters list, see reasonings here
atelier.toml is itself optional, as are its four keys. A missing rule file (or
an omitted key) falls back to the defaults below, so a repository with no
atelier.toml at all builds the defaults.
| key | type | default | meaning |
|---|---|---|---|
systems |
list of strings | ["x86_64-linux"] |
systems to evaluate and build for |
include |
list of globs | ["packages.*.*", "devShells.*.default"] |
attributes to build |
exclude |
list of globs | [] |
attributes to drop (exclude beats include) |
substituters |
list of strings | [] |
extra caches to check; a cached attribute is skipped |
Before building, atelier checks each attribute's outputs against substituters
(the official cache https://cache.nixos.org is always added, deduplicated). An
attribute already present in any of them is reported as a skipped check
Already in the binary cache rather than built, so no runner is spun up to
rebuild and re-push a path the cache already holds. Only outputs available from
a shared cache are skipped; a path present solely on the eval runner still
builds, since another runner could not substitute it.
Only three systems are supported, each mapped to a GitHub-hosted runner. A
system listed in systems that is not one of these is skipped with a warning.
| system | runner |
|---|---|
x86_64-linux |
ubuntu-latest |
aarch64-linux |
ubuntu-24.04-arm |
aarch64-darwin |
macos-latest |
Matching is dot-segmented with an equal segment count and fnmatch per segment,
so a bare * spans exactly one segment and a nested scope needs its own
segment. Thus legacyPackages.*.* matches legacyPackages.x86_64-linux.caddy
but not legacyPackages.x86_64-linux.ocamlPackages.dune, which needs the
explicit legacyPackages.*.ocamlPackages.*.
packages, legacyPackages, checks and devShells are addressed per system
as <set>.<system>.<rest>. nixosConfigurations and darwinConfigurations are
addressed by host as <set>.<host> and built through their
config.system.build.toplevel.
Manual excludes drop an attribute entirely. Broken and unsupported-platform attributes are detected from their eval error and reported as a skipped check rather than a build failure.
# systems to evaluate and build for; omit to default to ["x86_64-linux"]
systems = ["x86_64-linux", "aarch64-linux", "aarch64-darwin"]
# include selects, exclude removes, exclude wins
# omit include to default to ["packages.*.*", "devShells.*.default"]
include = [
"legacyPackages.*.*", # top level packages
"legacyPackages.*.ocamlPackages.*", # a nested scope
"devShells.*.default",
"nixosConfigurations.*", # built via config.system.build.toplevel
"darwinConfigurations.*", # built via config.system.build.toplevel
]
exclude = [
"legacyPackages.*.spotify", # unfree
]
# caches checked before building; an attr already in one is skipped, not rebuilt
# https://cache.nixos.org is always checked, so listing only your own is enough
substituters = ["https://cache.example.org"]Builds optionally push their results to a binary cache so later runs (and your machines) pull instead of rebuild. Configure one under Settings -> Secrets and variables -> Actions with repository variables and secrets. Atelier supports Attic, Cachix, and niks3, and pushes to every backend you configure.
Attic:
| name | kind | value |
|---|---|---|
ATTIC_SERVER |
variable | attic server endpoint |
ATTIC_CACHE |
variable | cache name |
ATTIC_TOKEN |
secret | push token |
Cachix:
| name | kind | value |
|---|---|---|
CACHIX_CACHE |
variable | cache name |
CACHIX_AUTH_TOKEN |
secret | auth token |
CACHIX_SIGNING_KEY |
secret | signing key (optional) |
niks3:
| name | kind | value |
|---|---|---|
NIKS3_SERVER |
variable | niks3 server URL |
NIKS3_TOKEN |
secret | bearer token (optional, enables token auth) |
niks3 authenticates with GitHub Actions OIDC by default: set only NIKS3_SERVER
and grant the calling workflow id-token: write (see the example below). For
OIDC the niks3 server must have a GitHub OIDC provider whose bound claims admit
your repository. Setting the NIKS3_TOKEN secret instead switches to that
static token and needs no id-token permission. Unlike Attic and Cachix, niks3
has no separate cache name - the server URL identifies the cache.
Pushes happen on a push to your repository's default branch (or master) and on
a run with push: true. Forked-PR runs never push. Caching is best-effort: a
failed push to any backend is logged as a warning and never fails the build.
Atelier runs against whatever repository calls it. actions/checkout inside the
reusable workflow checks out the caller, so --flake . evaluates your flake
and every check run lands on your commit. The atelier tool itself is fetched
from the published flake, so your flake stays entirely your own.
Add a thin workflow that calls atelier (optionally with an atelier.toml at
your repository root to customize what is built; without one, the built-in
defaults are used):
# .github/workflows/ci.yaml
name: CI
on:
push:
# what's your branch name?
branches: [main]
pull_request:
workflow_dispatch:
permissions:
# required:
contents: read
# required:
# so skipped-attribute checks can be posted
checks: write
# optional:
# so niks3 can push via OIDC (omit if you use NIKS3_TOKEN)
# id-token: write
jobs:
Atelier:
uses: stepbrobd/atelier/.github/workflows/discover.yaml@master
# uncomment the ones you need:
# without the secrets this job will build everything from scratch
# on every trigger and will not push to cache
# set a cache end point (preferable matching the one in `atelier.toml`)
# so that jobs can be skipped if they already exist in cache
# secrets:
# ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
# CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
# CACHIX_SIGNING_KEY: ${{ secrets.CACHIX_SIGNING_KEY }}
# NIKS3_TOKEN: ${{ secrets.NIKS3_TOKEN }}Map only the cache secrets you use. secrets: inherit is a tempting shortcut,
but GitHub forwards inherited secrets only when the caller is in the same
organization or enterprise as atelier - across accounts it silently passes
nothing, so an explicit map is the portable choice. Configuration variables
(vars.ATTIC_CACHE and friends) need no passing: GitHub resolves vars against
your repository automatically, so your cache name, token, and the pushes all
stay yours. Pin @master to track the latest, or a tag/SHA to pin a version.
Make Gate the single required status in branch protection: it stays green
whether the matrix is empty, every build passes, or attributes are skipped.
By default Atelier installs upstream Nix with nixos/nix-installer-action. Two
optional inputs change that without forking:
| input | type | default | meaning |
|---|---|---|---|
install-command |
string | "" |
Shell command that installs Nix, when set it replaces the installer |
extra-conf |
string | "" |
Extra nix.conf lines appended after Atelier's required settings |
Atelier separates installing Nix from configuring it. Whichever installer runs,
Atelier applies its own required nix.conf afterwards (the GitHub access token,
experimental-features = nix-command flakes, the build directory, the target
system, and the sandbox mode), then appends your extra-conf last. So a
custom installer still ends up with a correctly configured daemon, and adding
settings is independent of the installer choice. Use Nix's extra- prefixes to
add to a list setting rather than replace it.
The inputs apply to every job, so discovery and every build cell use the same Nix. Install Lix instead of upstream Nix and enable the pipe operator:
jobs:
Atelier:
uses: stepbrobd/atelier/.github/workflows/discover.yaml@master
with:
install-command: curl -sSf -L https://install.lix.systems/lix | sh -s -- install --no-confirm
extra-conf: |
extra-experimental-features = pipe-operatorDo note that Lix names the pipe-operator feature pipe-operator (singular),
whereas upstream Nix names it pipe-operators (plural). Match your installer!
See more about the discrepancy
here.
A custom install-command runs on a fresh runner, so it must be non-interactive
and install a working multi-user daemon. Atelier reloads the daemon after
applying its configuration. If your installer does not set one up, the build
settings will not take effect.
Prefer a fork if you want to hack on atelier itself or keep a vendored copy.
Fork the repo, enable Actions on the fork, add your cache secrets and variables,
edit atelier.toml, and pull upstream improvements with Sync fork.