Canonical source of the shared tooling used by every *-nix packaging repo.
This repo is the single source of truth, consumed two ways:
- Nix-side logic (lint/format gate, dev shell, package→check aliasing,
conformance + schema checks) is a flake-parts flakeModule that each repo
imports through
inputs.std, which follows thereleasebranch and is locked like any other input. - GitHub Actions are three reusable workflows in
.github/workflows/package-*.yml. Each consumer carries three small callers (callers/*.yml, kept byte-identical bystd-conformance), and every job fetches the standard at the commit that defines the workflow it runs, so the scripts and the workflow that calls them always come from one commit. A fix to a workflow or script reaches the whole fleet on its next run once it is released; nothing is copied into the consumers.
The release branch only moves through scripts/release.sh, which checks the
candidate against every consumer first (see "Releasing").
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
git-hooks = {
url = "github:cachix/git-hooks.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
std = {
url = "github:Daaboulex/nix-packaging-standard/release";
inputs.nixpkgs.follows = "nixpkgs";
inputs.git-hooks.follows = "git-hooks";
};
};
outputs =
inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" ]; # see "Architecture and platforms"
imports = [ inputs.std.flakeModules.base ];
perSystem =
{ pkgs, ... }:
{
packages.default = pkgs.callPackage ./package.nix { };
};
};
}Follow the release branch, never main and never a tag. main is where
the standard is developed; release only fast-forwards to a commit that passed
the standard's CI and the fleet check, and each repo's scheduled lock refresh
moves it there and verifies the result like any other input bump. A tag pin
never moves: on 2026-10-23, 17 repos still sat on v2.40.1 after v2.42.0 had
fixed their failure reports. std-follows-release refuses one.
Every consumer repo is the same shape: a Nix package (plus an overlay and,
where useful, a NixOS/Home Manager module) for one piece of software. There is no
per-repo "archetype" taxonomy beyond a single declared field. upstream.type
in .github/update.json is the repo's update path (how, or whether, a new
version is detected): github-release / github-commit / custom / none are
values of that one field, not different kinds of repo, and the standard treats
every repo identically except along that axis. Your own software is the none
path with yourself named as the upstream (first-party); see the update-type
paths below.
Every consumer repo follows these. Metadata (description + topics) is declared in
.github/update.json and applied with sync-meta.sh, so it can't silently drift.
- Default branch:
main, and a single branch — the Maintenance cleanup job prunes staleupdate/*branches. - No per-repo dependabot: GitHub Actions are pinned by SHA in this
standard's reusable workflows and bumped here by dependabot; a consumer's
callers pin no action, so a consumer
.github/dependabot.ymlhas nothing to bump and consumers carry none.fleet-auditenforces this. - Issues enabled: the Update and Maintenance workflows report a failed update or
lock bump by filing an issue, so every repo keeps GitHub Issues enabled — a
disabled-Issues repo would swallow those failures silently.
fleet-auditenforces this. - License: the repo's
LICENSEis MIT — it licenses your Nix packaging code, which is permissive and reusable. The packaged software's licence is declared in the derivation'smeta.license, which must be accurate. Two exceptions keep upstream's licence verbatim: forks (the repo vendors upstream source) and derivative transcriptions (e.g. a module that re-expresses GPL config). Proprietary upstreams: MIT packaging +meta.licensemarked unfree. - Topics:
nix,nixos,flakebaseline; addnixos-module/home-managerwhen the repo ships one; then the software name and a few domain topics. Lowercase, hyphenated. - Description:
<Upstream Name> packaged for NixOS — <one concise clause>. HM-only → "… Home Manager module — …"; forks lead with the fork's value-add; original (non-packaging) projects describe themselves directly. ≤120 chars, one clause, no cross-repo references, no marketing fluff. - Architecture: the flake's
systemsis the supported arch set; every arch dropped from the fleet canonical set carries a reason inplatforms— see Architecture and platforms.
Packages that track an upstream branch (mesa-git, scx-git) follow the pinned-repo rules above, plus:
- Inherit, don't fork the nixpkgs build closure — override
src/rev/hashes on the nixpkgs derivation; do not fork shared libraries (libdrm, libbpf) or strip the validated driver/build flags. The closure stays in lockstep with nixpkgs and bleeding-edge drift is contained tosrc. - Relax exact-output assertions — an
installCheckthat asserts the precise set of produced binaries/outputs breaks on every upstream add/remove; check "produced a reasonable set" instead. - Skip flaky upstream tests with
doCheck = false, not file-by-file — overriding a nixpkgs package (even only to dodge a test) changes its derivation, forcing a from-source rebuild that re-runs the package's WHOLE upstream test suite. Disabling one fragile test path at a time is whack-a-mole: the next numerically- or timing-fragile test breaks the next bump. If you change none of the package's code, skip its check phase entirely (doCheck = false) — its real correctness is already gated by nixpkgs' own build (the canonical case: unsloth'saccelerateoverride). - Crates come from
static.crates.io(thecrates.io/api/v1download endpoint rate-limits CI and 403s); CI also retries transient fetch failures, twice, after 60 and 300 seconds.
A repo's README documents its OWN flake and nothing else; the rules are checkable against the tree:
- True to the code. Every package attribute (
nix build .#x), NixOS/Home Manager option path, and command the README shows resolves against the repo's flake. A documented output or option that does not exist is a defect. - License agreement. The License section, the
LICENSEfile, and the derivationmeta.licensestate the same license (packaging MIT, per Repo conventions); a section disclaiming or contradicting the shipped LICENSE is wrong. - No branding. No maintainer attribution or personal-identity line; functional links (upstream, clone URL) stay.
- Plain, not marketing. Concrete terms for what the repo is and does — no hype
adjectives, no generic CI-boilerplate paragraph, no emoji. The
generated:*marker blocks carry the machine-fillable surface; everything outside them is hand-written and must be accurate.
| Module | Provides |
|---|---|
base |
git-hooks gate (nixfmt, typos, rumdl, check-readme-sections, check-shell-pipelines, and std-home-proof at push time), formatter, devShells.default, every declared package aliased into checks on the systems its meta.platforms supports (so nix flake check BUILDS it), and these checks: std-conformance (synced files byte-match the canonical), std-no-retired-copies (no script copy the workflows no longer read), std-follows-release (inputs.std.url is the release branch, releaseUrl), std-update-json (.github/update.json satisfies the schema), std-devstate (the baseline .gitignore entries are present), std-devshell-order (every dev shell pins PRE_COMMIT_HOME before it installs the hooks), std-homestate (no home.file in the home root), std-meta-maintainers (maintainers carry the nixpkgs shape), std-no-fail-open (no shell shape that reports success while doing nothing), std-no-deprecated-system (no pkgs.system read), std-no-host-profile (no path under the host's profile) |
Helpers consumed as inputs.std.lib.*. Module repos add a module-instantiation
check that forces FULL evaluation (options + assertions + every mkIf path)
without building the closure — eval-only and cheap, even in CI.
| Helper | Use case |
|---|---|
nixosModuleCheck { nixpkgs, system, module, config?, overlays? } |
NixOS module repos. overlays supplies the repo's overlay when the module refs overlay-only pkgs. |
homeModuleCheck { nixpkgs, home-manager, system, module, config?, overlays? } |
Home Manager module repos. Imports nixpkgs with config.allowUnfree = true for unfree packages. |
drvEvalCheck { pkgs, name?, drv } |
Packages whose closure is not on cache.nixos.org and cannot build on a free CI runner (CUDA/ROCm, very large builds). Forces drv's full build-graph evaluation without realizing it; skips with a trivial pass on a system the drv's meta.platforms excludes. See the off-CI exception below. |
pythonSitePackagesCheck { pkgs, drv, package, name? } |
First-party python application repos. Proves the BUILT output ships exactly one top-level import package (plus its dist-info) in site-packages -- a flat top-level module (cli.py, utils.py) collides with any other application in a merged environment. See "Python apps: one top-level package" below. |
pristineBinaryCheck { pkgs, package, binaryPath, name? } |
Prebuilt self-reading binaries (embedded resources / appended payloads). Asserts the installed binary is byte-identical to package.src, so no fixup phase can silently corrupt it. See "Prebuilt self-reading binaries" below. |
patchAssertions (a shell string, not a function) |
Any postPatch that rewrites upstream source with sed/awk rather than substituteInPlace --replace-fail. Prepend it, then assert after every edit with landed / gone / present / exactly_one / landed_soft. Without it a renamed upstream anchor makes the edit apply to nothing and the package still builds green. See "Patching upstream source" below. |
devStateHook (a shell string) |
The exports that keep every tool's cache and home under the project's .devshell/; base wires it into the default shell. See "Self-contained dev state" below. |
mkDevShell { pkgs, config } args |
A custom dev shell that cannot get the pin order wrong: the pins, then the hook install, then the caller's own hook. |
releaseUrl (a string) |
The one inputs.std.url every consumer declares; std-follows-release, scripts/release.sh and scripts/fleet-roll.sh all read it here. |
failOpenPatterns (a list) |
The shell shapes std-no-fail-open refuses, each with its bad and good example; the standard's own check proves each pattern matches only the bad form. |
Example:
checks.module-eval-nixos = inputs.std.lib.nixosModuleCheck {
inherit (inputs) nixpkgs;
inherit system;
overlays = [ self.overlays.default ];
module = ./module.nix;
config.programs.foo.enable = true;
};| File | Bootstrapped to (per repo) | Purpose |
|---|---|---|
flake.nix |
(not synced) | Exposes flakeModules.* |
flake-modules/base.nix |
(imported, not synced) | The shared flakeModule |
synced-files.json |
(the map itself) | Consumer path to canonical file, or null for a retired copy that must be absent: the one map sync.sh reads and std-conformance and std-no-retired-copies enforce |
callers/ci.yml |
.github/workflows/ci.yml |
Triggers, permissions and concurrency of CI, passing the RECLAIM_DISK repository variable (on or off; unset is off, anything else fails the run); calls package-ci.yml@release |
callers/update.yml |
.github/workflows/update.yml, except in a first-party repo (upstream type none), which carries none |
The Update trigger, one cron for Sunday and one for the other days; the job's condition reads the UPDATE_CADENCE repository variable (daily or weekly; unset is daily), so a day it does not run starts no runner, and passes it on (anything else fails the Sunday run); calls package-update.yml@release |
callers/maintenance.yml |
.github/workflows/maintenance.yml |
The Maintenance trigger, one cron for Sunday, one for Wednesday and one for the other days; the job's condition reads the MAINTENANCE_CADENCE repository variable (daily, biweekly or weekly; unset is weekly), so a day it does not run starts no runner, and passes it on (anything else fails the Sunday run) |
.github/workflows/package-ci.yml |
(reusable, not synced) | Archetype-blind CI (build every output on a runner per declared system) |
.github/workflows/package-update.yml |
(reusable, not synced) | The update, verified on every declared system before it pushes |
.github/workflows/package-maintenance.yml |
(reusable, not synced) | flake.lock refresh on the repo's cadence: heal probe, every declared system evaluated, the checks built on a runner per declared system, pushed only if all are green |
update.sh |
(run from here; retired copy scripts/update.sh) |
Detect + apply upstream updates. A custom repo keeps its own scripts/update.sh, which the Update workflow runs instead |
heal-overlays.sh |
(run from here; retired copy scripts/heal-overlays.sh) |
Probe every temporary divergence against the updated inputs and drop the healed ones |
after-lock-update.sh |
(run from here; retired copy scripts/after-lock-update.sh) |
Run the flake's apps.<system>.after-lock-update after the lock update, when it declares one, and stage the tracked files it rewrote |
classify-build-failure.sh |
(run from here; retired copy scripts/classify-build-failure.sh) |
Name a red run's failure class and enumerate its failed targets |
declared-runners.sh |
(run from here; retired copy scripts/declared-runners.sh) |
Name a GitHub runner for every system the flake declares checks on, refusing an unknown system or a flake that does not evaluate |
report-failure.sh |
(run from here; retired copy scripts/report-failure.sh) |
Keep one open issue per failure: file it the first day, then rewrite it each failing run with the days since it opened, commenting only when the failure changes |
update-readme-options.sh |
scripts/update-readme-options.sh |
Only where a repo already carries it: splice an options reference into the README |
.envrc |
.envrc |
use flake, so nix-direnv carries the dev-state pins into every entry point |
.editorconfig |
.editorconfig |
The fleet's editor defaults |
update.schema.json |
(reference, not synced) | JSON Schema for update.json |
sync.sh |
(run from here) | Write the synced files into repos and delete the retired copies |
sync-meta.sh |
(run from here) | Apply repo description + topics from update.json to GitHub |
scripts/release.sh |
(run from here) | The only way release moves: CI on the commit, then the fleet check, then the tag and the fast-forward |
scripts/fleet-audit.sh |
(run from here) | The fleet's green/red oracle: conformance + metadata + archetype + branches + issues + CI, local and remote |
scripts/fleet-roll.sh |
(run from here) | Adopt the release head across every consumer: pin, lock, sync from that commit, build, commit, push, each repo restored on any failure; --build-on-ci evaluates every declared system and leaves the build to each repo's CI on push |
scripts/check-shell-pipelines.sh, scripts/check-readme-sections.sh |
(hooks, not synced) | The pre-commit hooks base wires into every consumer |
"Run from here" means a job fetches the file from the standard at the commit
of the reusable workflow it runs (job.workflow_repository at
job.workflow_sha). What a consumer carries is byte-identical fleet-wide and
enforced by std-conformance; a release that changes it needs a fleet-roll
right after it, and scripts/release.sh says so when it does.
An overlay exists so a consumer can get the package built against their nixpkgs, with their overlays and their fixes applied. Write it as:
flake.overlays.default = final: _prev: {
thing = final.callPackage ./package.nix { };
};Not as:
flake.overlays.default = _final: prev: {
thing = self.packages.${prev.stdenv.hostPlatform.system}.thing; # wrong
};The second hands back a package this repo already built against the nixpkgs in
its own flake.lock. The consumer's nixpkgs version, their overlays and any
fix they carry are all ignored, and their system ends up with a second nixpkgs
closure for one package. It still evaluates, which is why it survives review:
the defect is invisible until someone asks why a consumer overlay had no
effect.
A package that genuinely cannot be built by the consumer (a prebuilt artifact that must be fetched with this repo's own hashes, say) is the exception, and the overlay should say so in a comment naming the reason.
The standard is designed for dendritic extension — each repo adds what it needs
in its own perSystem, never by patching the standard:
- Module collision (
disabledModules): when nixpkgs ships a module at the same option path, the repo'smodule.nixaddsdisabledModules = [ "programs/foo.nix" ];(streamcontroller, coolercontrol). - Custom devShell:
lib.mkForcethe standard's lint-only default to fold in the repo's own build toolchain (eden: cmake/ninja/ccache). - Typos exclusions:
_typos.tomlwith[files] extend-excludeto skip vendored/forked C++ source (vkBasalt, lmstudio). - Rumdl exclusions/overrides: in
perSystem, extendpre-commit.settings.hooks.rumdl.excludesor.settings.configurationto skip vendored markdown or disable rules the README legitimately triggers (vkBasalt:MD033for inline-HTML screenshots). - Unfree:
config.nixpkgs.config.allowUnfree = truein the module-eval config (lmstudio); standalonepkgsimport withconfig.allowUnfree = trueinperSystem(mesa-git).
One archetype-blind package-ci.yml, called by every consumer. It runs the AI-artifact
guard, reclaims ~20 GB of preinstalled toolchains nix never uses when the repository
variable RECLAIM_DISK is on (a large source build otherwise exhausts the runner's
~14 GB default disk; the step takes one to five minutes on an x86_64 runner, longer
than many repos' whole build, so it is opt-in), then builds every
output the flake declares for each runner's system via nix-fast-build --skip-cached. The matrix is not fixed: a plan job runs
declared-runners.sh, which names ubuntu-latest for x86_64-linux
and ubuntu-24.04-arm for aarch64-linux among the systems the flake declares
checks on, so no runner starts for an arch the repo does not build, and a flake
that does not evaluate fails the plan instead of reading as "nothing declared".
A package whose meta.platforms excludes a declared arch is simply not built
there (declared == built, per system and per package). There
is no per-repo build target, no archetype conditional, and no binary-cache token:
cache.nixos.org substitutes every unmodified dependency for free.
The lint check (pre-commit) runs once per repo, on x86_64-linux when the
repo declares it and otherwise on its first declared system: it reads the same
files with the same rules on any architecture, and on aarch64-linux its
rumdl is the 16K-page build the M1 needs, which no binary cache holds, so an
arm job spent 26 of its 27 minutes compiling it (rosemary-lineage-root,
2026-10-23). Every job of the three
reusable workflows carries a timeout-minutes at about twice the longest run
measured on 2026-10-26 (240 for a build or verify, 180 for the updater, 60 for
the lock refresh, 15 or less for the rest), so a hung step fails in hours at
most instead of burning the six-hour default; std-every-job-has-a-timeout
refuses a job without one.
Exception — off-CI packages. A package whose closure is not on
cache.nixos.org and cannot build on a free runner (CUDA/ROCm toolchains, very
large builds) is exposed via flake.packages.<system> — a real nix build .#x
target — instead of perSystem.packages (which base aliases into checks and
so builds). CI gates it with std.lib.drvEvalCheck, forcing its full
build-graph evaluation without realizing it: CI proves it evaluates (catching
dependency/version breakage) while the heavy build runs off-CI against a project
cache (e.g. cachix use cuda-maintainers). Document that substituter in the
repo README — declare the off-CI build, never silently drop coverage.
On a dev host with binfmt emulation (qemu-aarch64 for image builds),
nix flake check treats foreign arches as locally buildable and grinds
their check set under emulation. Gate locally scoped to the native system
(nix-fast-build --flake .#checks.<system>) and let each arch's native CI
runner validate its own leg.
CI builds, it does not run. A runner has no GPU, no display and no KVM, so nothing that appears only once the program executes is ever exercised, and a package can stay green for months while being broken from its first launch. Close that gap by turning each runtime failure you find into a static check in the same change. A green run means the outputs build, never that they work.
The one place a built binary does run on a runner is the Update verification
(verify.binary, and a custom updater's smoke test). A runner is not a dev
host: it has no GPU, no display, no KVM, and Ubuntu's AppArmor refuses the
unprivileged user namespace a buildFHSEnv wrapper's bwrap needs, so
the Update workflow opens that knob before the update runs and proves it with
unshare. A verification added to an update path is proven on a runner
(dispatch the Update workflow once) before the change is called done: a pass
on a development host says nothing about the runner's kernel, sandboxing or
devices, and the first live run is otherwise the first test.
The fleet's canonical target set is x86_64-linux + aarch64-linux, and
the CI, Update and Maintenance workflows build on a native runner for each
one a repo declares (ubuntu-latest, ubuntu-24.04-arm). A repo's supported
arches are its flake systems — the executable truth CI builds; nothing
restates it.
Because CI is declared == built, an arch a repo does not list in systems
is silently unsupported: no runner starts for it and CI goes green by not
trying. That silence is the trap — a green check that never meant the arch
works. So the standard makes every dropped arch declared, with a reason:
-
To support an arch — add it to
systems. The native runner then builds and proves it on every push; nothing else to declare. -
To drop an arch — record a one-line reason in
.github/update.jsonplatforms, keyed by the dropped arch:
fleet-audit binds the two: it reads each repo's evaluable systems and, for
every canonical arch the repo does not build, requires a platforms reason — a
silent drop is RED, and a reason for an arch the repo does build is stale
(RED). "untested" is not a reason: declare the arch and let the arm
runner settle it. Genuine x86-only constraints are the real reasons — 32-bit-only
(pkgsi686Linux), a prebuilt amd64 binary/.deb, x86 assembly in upstream
source, or an x86-pinned kernel/firmware.
Per-package, not just per-repo. flakeModules.base aliases a package into
checks.<system> only when it is lib.meta.availableOn that system, and
std.lib.drvEvalCheck skips on a system the drv's meta.platforms excludes. So a
repo may mix arch-portable and x86_64-only packages in one flake — each builds only
where its own meta allows (a package with an x86-only dependency must say so in
its meta.platforms). Accordingly fleet-audit treats an arch as supported only
when the repo builds a real output there (a package-* or module/repo check) — not
when it merely lists the system; declaring an arch that builds nothing real still
needs a documented platforms reason.
git clone https://github.com/Daaboulex/nix-packaging-standard.git
cd nix-packaging-standard
export PKG_REPOS_DIR=/path/to/repos # directory holding the packaging clones
./sync.sh # bootstrap canonical files into all repos
./sync.sh ripgrep-nix # named repos only
./sync.sh --check # report drift, change nothing, exit 1 if anysync.sh copies each synced file and deletes each retired copy; a custom-type
repo keeps its own scripts/update.sh. Enforcement is the per-repo
std-conformance and std-no-retired-copies checks; sync.sh --check is the
same comparison locally.
export PKG_REPOS_DIR=/path/to/repos
scripts/release.sh v3.0.1 --dry-run # every check, the tag message, nothing pushed
scripts/release.sh v3.0.1 # then tag it and fast-forward releaserelease.sh refuses unless the tree is clean on main and pushed, the
standard's own CI passed on GitHub for that commit, the version is newer than
every tag and has a History entry, and release fast-forwards to the commit.
It then runs the fleet check: each consumer is fetched and cloned at its
origin/main (its HEAD when it has no remote), the clone is rolled to the
candidate (pin and synced files), and every system the consumer
declares is evaluated with std overridden to the candidate. A check whose
derivation did not change cannot change its result, so only the changed ones
are built, on every system this host or its remote builders build; any other
system is evaluated only. The tag message records how many consumers passed,
how many changed checks were built and where, which systems were evaluated
only, and every consumer left out with --skip. One failing consumer refuses
the release.
When a release changes what consumers carry (every file synced-files.json
names, the map itself, or the baseline .gitignore), each consumer's next lock
refresh fails std-conformance or std-devstate until it is rolled, so
release.sh names the roll to run: scripts/fleet-roll.sh --execute --notes <file>. Everything else, every workflow and script change, reaches the fleet
on its next run. A roll left to run unattended adds --build-on-ci: each repo
is still evaluated on every declared system before its push, and its own CI
builds it on that push, instead of this host compiling every consumer's checks
for its own system first.
Two of the three workflows run before a release reaches anyone: this repo's CI
calls package-ci.yml from the same commit on every push, and its own
Maintenance calls the local package-maintenance.yml, so main refreshes this
repo's lock with the workflow it is about to release. release.sh requires
that CI to be green. The Update workflow has no canary here (this repo has no
upstream to update); when it or a script it runs changed, release.sh says so
and names the dispatch to run on one consumer before 06:00 UTC, when every
consumer runs it.
actionlint 1.7.12 does not yet know job.workflow_repository and
job.workflow_sha (rhysd/actionlint#707 is open), so this repo's own
actionlint hook ignores exactly those two findings. std-actionlint-ignore-still-needed
fails the day the pinned actionlint accepts them, so the ignore goes with the
next lock bump that makes it unnecessary.
The fleet's single green/red oracle. It composes the per-file checks above with the fleet-wide conditions no single repo can prove on its own, and exits 0 only when every one passes in a full run.
It is coverage-honest: a run reports FLEET GREEN only when it exercised
every certification dimension -- the local half (conformance, metadata, eval,
arch) and the remote half (open issues, branches, and CI-green, the only
proof that builds realize and that no failure issue is open). A partial run
(--local, --remote, --skip-nix, or one where the network never reached the
remote half) reports FLEET PARTIAL, names what it did not audit, and never
claims certification. Exit codes are tri-state so a caller can tell the states
apart: 0 = GREEN (fully certified), 1 = RED (a real defect), 2 =
usage/environment error (could not run), 3 = PARTIAL (ran clean, coverage
incomplete). Anything wanting certification checks for 0.
export PKG_REPOS_DIR=/path/to/repos
./scripts/fleet-audit.sh # full audit, local + remote
./scripts/fleet-audit.sh --local # flake-state only (no gh)
./scripts/fleet-audit.sh --remote # GitHub state only
./scripts/fleet-audit.sh --no-build # eval + conformance, skip full builds
./scripts/fleet-audit.sh --skip-nix # skip nix flake check (fast structural pass)
./scripts/fleet-audit.sh ripgrep-nix # named repos onlyIt checks, per consumer (every dir with a .github/update.json):
- local --
sync.sh --check(synced files byte-match),sync-meta.sh --check(GitHub metadata matchesupdate.json), a knownupstream.type, complete metadata (description+topics), the first-party discipline (a self-owned version + aCHANGELOG.md, and a python app wires the single-package site-packages gate), architecture honesty (every canonical arch the flake does not build carries aplatformsreason), andnix flake check(std-conformance+ eval; full builds are proven remotely by CI); - off-CI surface -- names each package whose closure CI never realizes
(exposed via
flake.packages.<system>with nopackage-<name>check, the off-CI escape hatch): an informational line, so a green audit is never misread as "the off-CI CUDA/ROCm build works" -- its build is proven only off-CI against a project cache, and it must stay eval-gated. Does not fail the audit; it scopes what GREEN means; - temporary divergences -- every
overlays/<name>.nixfix is shape-checked (meta.reason,meta.added,overlay, exactly one ofdropWhen/dropWhenBuilds, plus an exportedoverlays.probe) and NAMED with its reason and age, so a live workaround, added dependency, or pin is always visible fleet-wide; a malformed or orphaned one fails the audit; - custom updaters -- a
customrepo's ownscripts/update.shgates a bump on the full check suite; one whose only flake check isnix flake check --no-buildis RED, because that flag evaluates every build-time check and runs none (an eval-only pass beside the full suite, say over every architecture, is fine); - remote (
gh) -- every repo on GitHub carrying.github/update.jsonhas a clone underPKG_REPOS_DIR(a full sweep only: the fleet is what GitHub holds, not what happens to be cloned, and a consumer nobody cloned is reached by no sync, no roll and no audit), a singlemainbranch (no staleupdate/*), zero open automation issues, and a green latest run of CI / Maintenance / Update onmain.
A repo with no git remote (an unpushed WIP) is audited locally and skipped
remotely. The standard itself, the private site registry, and the owner's
GitHub profile carry no update.json and are out of scope by construction.
A project dev shell never writes $HOME, and per-project build/cache dirs
stay out of the tree: the default shell exports each tool's cache/home/build
dir into the project's gitignored .devshell/ (std.lib.devStateHook --
pre-commit, ruff, mypy, python bytecode, pip, cargo target, npm cache; extend
per tool from the $DEVSHELL_STATE seam. CARGO_HOME/HF_HOME stay
machine-global by explicit choice: shared registry/model caches). The synced
canonical .envrc (use flake, activate once with direnv allow) makes
nix-direnv capture those exports for every in-project entry point -- nix develop, nix run, nix shell, and raw tool calls. Build outputs (result*),
.direnv/, .devshell/, and ad-hoc tool litter (coverage, dist/, target/,
node_modules/, venvs) stay untracked via the baseline .gitignore; the
std-devstate check fails a repo whose core entries went missing. The proof
behind the rule, std-home-proof, runs at push time: it enters the dev shell
in a fresh clone with an empty home and fails on anything written there; on a
host the flake declares no dev shell for, it enters the hook set
host-hooks.nix builds for that host instead, the shell that host actually
uses.
A packaged app's state belongs in the XDG directories, never loose in the home root, and the way to put it there is the app's own mechanism -- never a file moved by hand and never a store link over a path the app writes itself. Try in this order and stop at the first that holds: the app already follows XDG, so do nothing; it documents a variable or a flag, so the wrapper or the module sets it to an absolute path; it owns a pointer or home file of its own, so activation seeds that file when it is absent and leaves it alone otherwise; it hardcodes the path with no knob at all, so the README names the upstream file and line that proves it, which is what makes the exception reviewable instead of a shrug.
Three paths are never relocated. ~/.ssh has no mechanism at all. GNUPGHOME
exists but derives a socket directory that systemd's own units do not agree
with, so moving it breaks gpg-agent. ~/.var belongs to Flatpak.
A home-manager module therefore declares xdg.configFile or xdg.dataFile, or
sets the documented variable; home.file.".<name>" is refused by std-homestate.
Prove where an app looks by reading its source or its own documentation, and cite
that in the commit: a version that reads $XDG_CONFIG_HOME today is a fact with
a date on it, not a permanent property.
A derivation names its dependencies and reaches them by store path. A path under
the host's profile is not a dependency, it is a guess about the machine the
package lands on: /run/current-system/sw/bin, /etc/profiles/per-user,
/nix/var/nix/profiles and ~/.nix-profile all change under the package
without it noticing, and none of them is guaranteed to hold the program at all.
The failure is quiet. A wrapper that puts a profile directory on PATH still
builds and still starts, and only the feature that needed the absent binary goes
missing, with nothing in the log to read. std-no-host-profile refuses these
paths in every tracked *.nix and *.sh.
Runtime interfaces NixOS itself defines stay allowed: /run/opengl-driver is
the graphics contract, and a daemon's socket under /run is that daemon's
address, not a profile lookup.
Extending PATH or LD_LIBRARY_PATH for a wrapped program leaves its own
lookups intact. Replacing one takes them over, and from that point every binary
the program runs has to be on the list you wrote, including the helpers it
executes internally and never documents.
A missing helper is often read as a feature being unavailable rather than as an error, so the call returns success and the work simply does not happen. Pin the helpers the wrapped program executes, and assert the load-bearing ones in a check, because nothing reports their absence at runtime.
An app packaged as a python.withPackages env plus upstream source (no wheel
build) gets no pythonRuntimeDepsCheck, so a new upstream requirement would
ship silently and fail only at the user's runtime import. Two pieces close
the loop:
std.lib.requirementsCoveredCheck { pkgs, env, src, file?, ignore? }-- a CI check comparing the source's requirements file against the distributions actually present in the built env, failing by name.update.jsonpythonRequirements { file, envFile? }-- update.sh fetches the new tag's requirements file and auto-adds each nixpkgs-resolvable new requirement above the# std:requirements-auto-addmarker inenvFile; the coverage check then proves it during the update's verification build. An unmappable name (pypi name != nixpkgs attr) fails the run naming the requirement instead of shipping broken.
site-packages is a global namespace: every application in a merged
environment (a Nix profile's buildEnv, a venv) lands in the same directory,
so a flat top-level module (cli.py, utils.py) collides with any other
application shipping the same generic name and the profile fails to build on
the conflict. First-party python apps therefore ship EVERYTHING under one
package named after the project (src/<name>/..., entry points
<name>.module:fn, no setuptools py-modules) and wire
std.lib.pythonSitePackagesCheck { pkgs, drv, package } so the built output
proves it stays that way. fleet-audit reds a first-party python repo (a
pyproject.toml at the root) that does not wire the check.
A component inside a monorepo release versions itself independently, so passing
the release version down to it makes the derivation contradict the wheel it
builds. nixpkgs' pythonMetadataCheckPhase catches exactly that and fails with
"has version 'X' but .dist-info/METADATA specifies version 'Y'" (openviking's
ragfs-python shipped the 0.4.11 release version over a crate that declares
0.1.0).
Declare the component's own version. It then agrees with METADATA by
construction, needs no patching, and that check becomes a permanent guard: an
upstream bump of the component fails loudly instead of shipping a lie. Reach for
pyprojectVersionPatchHook only when the release version genuinely must be
stamped in -- and note it rewrites pyproject.toml in the source ROOT, so it
does not reach a project under buildAndTestSubdir.
substituteInPlace --replace-fail fails closed and is always the first choice.
For the edits it cannot express -- an insertion, a line-range rewrite, an awk
pass -- a bare sed -i reports nothing: when upstream renames what it matched,
the edit applies to nothing, the package builds green, and the change it was
meant to make is simply absent.
Prepend inputs.std.lib.patchAssertions and assert after every edit:
packages.default = pkgs.callPackage ./package.nix {
inherit (inputs.std.lib) patchAssertions;
};postPatch = patchAssertions + ''
sed -i 's/if (getuid()) {/if (false) {/' gui/main.cpp
landed gui/main.cpp 'if (false) {' "main.cpp: privilege check disabled"
'';Assert the postcondition, not the precondition: a grep -q guard before the
edit can pass while the edit itself matches nothing, which is how a guard ends
up looser than the sed it protects. Where the postcondition is an absence,
pair gone with present -- on its own gone passes just as happily against
a source that never held the text.
exactly_one is for an insertion anchor: it refuses to insert when the anchor
matches twice, which would otherwise duplicate a C label or a table entry.
landed_soft warns instead of failing, for an edit that is deliberately
best-effort.
Before writing a patch or a substitution, read upstream for the knob it already
ships, and use it: a build option, a command-line flag, an environment
variable, a config key. Every one found in the 2026-10 audit removed an edit
that broke on a later release: eden's -Werror turns off with upstream's
-DENABLE_WERROR=OFF; StreamController takes its data directory from --data;
coolercontrold now spawns an absolute /bin/sh, so the substitution that
pointed it at a shell had nothing left to fix. A package built against
nixpkgs-unstable switches off upstream's warnings-as-errors through that
option, because a new compiler's new warning is not a defect of the package.
A patch that stays carries its own retirement: a check that fails the build
the day upstream adopts the change, with the step to take in its message
(coolercontrol's *-still-needed checks retired four patches that way in
5.1.1). An edit without one is permanent the moment it lands.
Anything upstream already states, the package reads instead of copying: a
version from Cargo.toml with lib.importTOML, npm dependencies with
importNpmLock from the lock file, Python requirements through
pythonRequirements, a second derivation's hash through subpackages. A
package that overrides a nixpkgs derivation to track git main does not
inherit an edit written against one release's literal text: unsloth passes
pypaBuildFlags = [ "--skip-dependency-check" ] instead of matching upstream's
setuptools pins, which move with every bump. A copied value is correct for one
release and wrong from the next, and the update that finds it out fails.
No package, module or activation step hides a failure: no || true, no
2>/dev/null in front of a failure, no empty except. A step that may
legitimately have nothing to do tests for that case explicitly ([ -e ... ])
and fails on anything else. update.sh, which every consumer carried until
v3.0.0, still carries these shapes, and so may the custom updaters, which is
why std-no-fail-open does not refuse them yet: adding the shape is its own
release, through the fleet check, once both are clean.
Nix seeds a tool's file once when it is absent and never replays it: no unit that re-applies settings at login or boot, no store link over a file the program writes. A rewrite of the tool's own file is allowed only as a guarded migration of a legacy value, and is removed once every host has migrated.
A source build finds the libraries it dlopens through its RUNPATH
(patchelf --add-rpath, addDriverRunpath), proven by a check that resolves
each one from the binary, never through LD_LIBRARY_PATH in a wrapper, which
leaks into every child process the program starts.
Some prebuilt binaries read their own file at runtime -- embedded resource
maps, appended payloads, .NET single-file bundles. autoPatchelfHook,
explicit patchelf, or strip grows or reshapes the ELF, the embedded
offsets shift, and the binary breaks at runtime while every build stays
green (OCCT's launcher died with "No map found" after a ~38KB patchelf
growth). The rules:
- Install the binary byte-pristine: no patchelf, no strip,
dontStripas needed. Gate it withlib.pristineBinaryCheckso the property is enforced, not assumed. - Launch through the host loader instead of patching the interpreter:
/lib64/ld-linux-x86-64.so.2exists on FHS distros and via nix-ld on NixOS. Guard in the wrapper with a clear failure message when absent. - Supply libraries via wrapper env (
LD_LIBRARY_PATH,LD_PRELOADshims), never via ELF surgery on the self-reading file. Patching COPIES of ordinary libraries stays fine. - Runtime self-extraction targets
$TMPDIRon some apps; hardened hosts mount/tmpnoexec, so the wrapper exports an exec-allowedTMPDIR(XDG data or cache) before exec. substituteInPlaceonly with--replace-fail/--replace-warn; bare--replaceis deprecated and hides missing-pattern drift.
Upstreams with rolling download URLs can also re-release in place: the FOD hash is the discriminator. Recorded hash == current upstream means local corruption (fixup phases); a mismatch means upstream replaced the artifact -- re-pin and note it.
Prebuilt Proton forks (GE-Proton, Proton-CachyOS) package as two-output
derivations mirroring nixpkgs proton-ge-bin: a stub out plus
steamcompattool holding the tool, consumed via
programs.steam.extraCompatPackages.
- Normalize the
compatibilitytool.vdfidentity to a stable, version-free name (substituteInPlace --replace-fail), overridable via asteamDisplayNameargument. Steam maps games to the vdf identity: a versioned name breaks every per-game mapping on each release. - The shape check asserts the tool layout (
compatibilitytool.vdf, theprotonentry point), the stable identity prefix, and -- via a grep forpackage.version-- that no versioned identity leaked. - Package every variant upstream publishes (ISA tiers, arm64) as a variant
map in one
package.nix; a new upstream variant is one added entry. The baseline variant forces all variant sources (anallVariantSourceslist attr) soupdate.sh's hash convergence reaches every hash through.#default.
grep -q exits on its first match. That closes the pipe, the producer takes
SIGPIPE, and under set -o pipefail the pipeline reports failure. A successful
match therefore reads as a failed test and the wrong branch is taken. The same
holds for head -n, which stops reading just as early.
writeShellApplication always sets pipefail, so every shell string inside a
Nix file is in scope, not just *.sh.
Capture first, then match a here-string, so the producer's exit status is not decided by the consumer closing the pipe:
out=$(producer)
if grep -q PATTERN <<<"$out"; thenThe capture must not swallow the producer's own failure: $(producer || true)
turns an error into an empty value that reads as "no match", the same wrong
branch by another road. std-no-fail-open refuses that shape for nix eval.
check-shell-pipelines enforces this over every tracked *.sh, *.nix and
workflow file (a step with shell: bash runs under bash -eo pipefail; a step
with no shell: runs under bash -e alone, so a workflow step that pipes sets
pipefail itself).
A line already ending in || true is accepted, since its status is discarded
either way. Mark a deliberate exception on the same line with pipefail-safe.
Rolling inputs break in classes, and every class is either fenced or NAMED:
update verification builds and the updater's check suite run with
--keep-going, so one red run enumerates every failing dependency instead of
fix-one-discover-next; every failure issue carries a machine class
(transient-infra, eval-error, compile-error, sandbox-download,
setup-hook-error, upstream-rerelease-hash-mismatch, nixpkgs-package-drop,
missing-python-dep, requirements-coverage,
python-metadata-version-mismatch, substitution-pattern-drift, or an honest
unclassified) plus the failed attributes of every runner and the failed
derivations (classify-build-failure.sh); temporary divergences
bridge nixpkgs regressions, missing dependencies, and pins, and remove
themselves once they stop doing any work; and the weekly Fleet CI watch
(standard repo only) keeps one open issue naming any consumer with an open
update-failed or maintenance issue or a required workflow that is red or
missing, closing it when the fleet is clean.
Each job uploads its log under its own file name and the report downloads them
with merge-multiple, so the log reaches the issue whether one job failed or
several. report-failure.sh keeps one open issue per failure: the
first failing run files it, every later failing run rewrites its title and body
with the days since it opened, and it comments only when the failure itself
changes, so a failure that has lasted three weeks reads as three weeks old.
transient-infra covers a runner-side store/fetch race as well as a network
one (path '...' is not valid): the same revision re-runs green, so it is never
a repo defect to chase.
Every temporary divergence from nixpkgs goes here — there is no other sanctioned place for one, and none of them may be permanent:
- a regression bridge (a package breaks on the new default python, a dependency stops building, an upstream test suite goes incompatible);
- an added dependency the packaged upstream needs but nixpkgs' own package
does not carry yet — the normal case for a repo whose overridden
srchas moved ahead of nixpkgs' release; - a version pin.
One fix per file under overlays/, mirroring the main config's
parts/overlays/_fixes convention. Each carries meta, an overlay, and
exactly one heal predicate:
{
meta = {
reason = "torchao is disabled on python 3.14, nixpkgs' default python3";
added = "2026-07-13";
upstream = "https://github.com/NixOS/nixpkgs/..."; # optional
};
# Probed against pkgs WITHOUT the fixes (overlays.probe): true means
# nixpkgs works normally again and CI removes this file.
dropWhen = pkgs: (builtins.tryEval pkgs.python3Packages.torchao.drvPath).success;
overlay = final: prev: { ... };
}The predicate must test the real condition. A version-string proxy
(dropWhen = pkgs: pkgs.foo.version != "1.2.3") is forbidden: it fires on an
unrelated bump, so the fix is dropped while the breakage is still live and the
repo re-breaks on a later run. Pick by what the condition actually is:
| Predicate | Use when the condition is visible at | Fires on |
|---|---|---|
dropWhen = pkgs: <bool> |
eval — an attribute exists again, a dependency is back in propagatedBuildInputs, an option is gone |
the boolean going true |
dropWhenBuilds = pkgs: <drv> |
build — a failing test suite, a pkg-config version reject, a patch that no longer applies |
that derivation building cleanly without the fix |
dropWhenBuilds never names a fixed-output derivation (a fetchurl,
fetchzip, fetchFromGitHub source). Nix reports such a build as done whenever
its output already sits in the store, so a fix that rewrites a source URL would
read as healed on any machine that ever fetched the file, and be dropped while
the URL is still broken. A source-URL fix tests the eval-visible condition it
rewrites instead (dropWhen = pkgs: !builtins.any (pkgs.lib.hasPrefix "https://broken.host/") pkgs.foo.src.urls), and both heal-overlays.sh and
fleet-audit refuse a dropWhenBuilds whose derivation carries an
outputHash.
An added dependency is eval-visible, so it uses dropWhen and retires itself
the moment nixpkgs' own package carries the dependency:
{
meta = {
reason = "unsloth git-main added a structlog runtime dep; nixpkgs' unsloth predates it";
added = "2026-07-30";
};
dropWhen =
pkgs:
builtins.any (d: (d.pname or "") == "structlog") (
pkgs.python3Packages.unsloth.propagatedBuildInputs or [ ]
);
overlay = final: prev: { ... };
}The repo composes overlays.default = its permanent glue + every fix, and
exports overlays.probe = the glue alone. heal-overlays.sh, run by the
Maintenance workflow, probes every fix against the probe pkgs -- evaluating a dropWhen, building
a dropWhenBuilds -- and deletes the ones that fire.
Where it runs is load-bearing. Maintenance runs the probe INSIDE the
lock-update job, immediately after nix flake update and before the
verification build, so every fix is judged against the inputs the repo is moving
to. Probing the committed lock instead reads a fix that is needed only on
the NEW nixpkgs as healed, drops it, and re-breaks on the next bump -- a churn
loop, and the second half of what made a version proxy so damaging. Because the
removals share the lock bump's verification on every declared system, the two
land together or neither does: green pushes both in one commit, red pushes
nothing, so the fixes stay, and files a maintenance issue naming what was kept
and which healed removals were not pushed. Nothing is ever dropped blind.
Fail-closed by construction: a fix with no predicate, with both, with malformed
meta, with a dropWhenBuilds that cannot even evaluate, or with a
dropWhenBuilds on a fixed-output derivation is a hard error in both
heal-overlays.sh and fleet-audit -- never silently read as "still needed",
which would pin it forever, nor as "healed", which would drop it blind. The lock keeps moving under automated
maintenance, every live workaround stays captured and visible (fleet-audit names
each one with its reason and age), and each retires itself the moment it stops
doing any work.
{
"package": "ripgrep", // package / repo name
"upstream": { "type": "...", ... }, // see upstream types below
"packageFile": "package.nix", // file `nix build .#default` centers on
"versionFile": "flake.nix", // file holding the canonical version
// literal (default: packageFile)
"versionAttr": "version", // attribute name to match (default
// "version"; e.g. "portmasterVersion")
"revFile": "package.nix", // file holding the src `rev` literal
// (default: versionFile)
"versionScheme": "unstable-date", // optional; "literal" (default),
// "unstable-date", or "rev-only"
// (the latter two: commit-tracked)
"versionBase": "2.0.0", // "unstable-date" FALLBACK base, read
// only when upstream has no tag
"hashes": [ // SRI hash fields, dependency order:
"hash", // bare name -> auto-located, or
{ "field": "vendorHash", // {field,file} to disambiguate when
"file": "agfs.nix" } // a name appears in several files
],
"verify": { "binary": null, "check": "wrapper" }
}platformsdocuments the exceptions to the canonical arch set: an object keyed by each canonical arch the flake'ssystemsdoes NOT build, whose value is the one-line drop reason. Absent means the repo builds the full set. The supported set is never restated here — it stays insystems. See Architecture and platforms.versionAttrmatches bothversion = "x"and parameterized<attr> ? "x"default-argument forms.versionFiledecouples the version literal's location frompackageFile.revFilescopes therevliteral bump for commit-tracked upstreams.hashesentries list SRI hash fields in evaluation-dependency order (source first), each a bare field name or{"field","file"}to disambiguate. The updater rewrites a field by name, so each hash needs a distinct attribute name (hash,cargoHash,vendorHash,npmDepsHash, ...): twohash = "..."literals in one file collide and the updater clobbers both with one value. Bind the extra hash to a named literal (cargoHash = "sha256-...";thenhash = cargoHash;) so it is uniquely targetable. Exactly one of them may be a source hash. A failing source FOD reports no field identity, so the updater can only route its mismatch to the single declared non-vendor field; a second source hash absorbs nothing, stays dummied, and the convergence loop can never finish. Declaring two fails closed withconfig-error— usevariantAssetsinstead.subpackagesnames attributes of the default package whose own fixed-output hashes nix-update must refresh too, each handed to it as--subpackage. nix-update reaches only the hashes of the package it updates, so a hash in a separate derivation, a web UI'snpmDepsHashbuilt apart from the daemon that embeds it, stays stale and the next bump fails on it. Expose that derivation throughpassthru(passthru.ui = coolercontrol-ui-data;) and list the attribute here ("subpackages": [ "ui" ]).variantAssetsis the multi-variant prebuilt-release mode (mutually exclusive withhashes). The updater enumerates the newest matching release's assets, takes each<variant>from<assetPrefix><tag>-<variant><assetSuffix>, prefetches it unpacked (the exact hash afetchzipyields, root dir stripped), and regenerates thevariantsattrset between the# std:variants-begin/# std:variants-endmarkers insourceFile. Attribution is by asset URL, so any number of variants converge in one pass and a newly published one is picked up with no config change.defaultVariantnames the variant whose asset carries no-<variant>token (GE-Proton ships<tag>.tar.gzfor x86_64 next to<tag>-aarch64.tar.gz). Pins outside the markers are never rewritten.- For commit-tracked packages prefer
versionFile: "version.json". versionSchemecontrols the written version literal (commit-tracked types only).literal(default) writes the upstream string verbatim (a bare 7-char SHA for commit-tracked repos);unstable-datewrites<base>-unstable-<YYYY-MM-DD>(the nixpkgs VCS-snapshot convention), where<base>is upstream's newesttagFilter-matching tag, refetched on every bump so an upstream release can never leave the snapshot understating itself (agithub-commitpackage whose upstream tags 1.1.2 stops writing1.1.1-...without anyone editing config).versionBaseis the fallback, read only when upstream publishes no tag or the type has no tag API (gitlab-commit,gitea-commit,git-ls-remote); a tag fetch that errors fails closed with exit 2 rather than falling back to a possibly stale base;rev-onlybumpsrev(+ hash + date) and LEAVES the human-setversionstring untouched (git-snapshot packages like mesa-git whose version is upstream's self-reported value). Comparison is byrevfor both non-literalschemes.tagFilter(an ERE underupstream, forgithub-release/github-tag, and for the base-tag lookup of agithub-commitpackage onunstable-date) pins to one tag namespace when upstream publishes several (e.g.^[0-9]to skip anandroid/*namespace); the newest matching tag wins. The search pages through the list (up to ten 100-item pages) until it matches, so a tracked namespace stays reachable behind a high-volume one — CachyOS/wine-cachyos carries ~370 rollingexperimental-*tags ahead of its newest*-winebuild tag. A filter that matches nothing fails closed withno-matching-tag.trackOnly: trueis the hand-mirrored mode: the updater only DETECTS upstream movement (againsttrackFile/trackKey, defaultupstream-version.json/commit) and files aremirror-neededreminder issue -- it never writes, builds, or advances a version. Requires a commit-tracked upstream type.verify.checkis one ofelf/wrapper/desktop/eval.eval(also the default when omitted) makes eval + a clean build the whole verification; the others additionally assert the named artifact exists underresult/.verify.binary(+verify.args) instead runs a built binary.
github-release · github-tag · github-commit · gitlab-tag ·
gitlab-commit · gitea-commit · git-ls-remote · none · custom
none — module/multi-component repos with nothing to track.
custom — the repo ships its own scripts/update.sh (multi-channel apps, or
non-API sources like OCCT). The canonical update.sh exits 0 early for them;
their bespoke script must honour the same exit contract and gate the bump the
way the canonical's check_suite does: the full nix flake check --no-eval-cache, never --no-build, which evaluates every build-time check
and runs none (fleet-audit reds it).
First-party (owner-authored software: gpucycler, corecycler) is the none
path with the owner named as the upstream:
upstream: { "type": "none", "owner": "<you>", "repo": "<self>" }. The repo IS
the upstream, so version and releases are self-owned (a hand-cut tag plus a
CHANGELOG.md entry) and nothing external is polled: update.sh no-ops on the
none branch. A bare none with no owner is a third-party package with no
tracked upstream (a pinned module/overlay); none with the owner is
first-party. fleet-audit enforces the discipline (a self-owned version literal
plus a CHANGELOG.md).
- exit 0 — no update needed, or update applied + verified.
- exit 1 — a real failure (config, version read/write, hash extraction,
build, verification) → workflow opens an
update-failedissue. - exit 2 — a transient condition only (unreachable host, DNS, 5xx, 403/429,
a timeout) → no issue, retried next run. A 404, a moved file, a changed
upstream layout or schema will not resolve by waiting: that is exit 1 with an
error_type, so the failure is filed the day it starts. The workflow counts consecutive exit-2 runs and escalates a week-long streak to anupdate-failedissue (error_type: stalled), so a permanent failure cannot hide behind the transient exit indefinitely (eden-nix#13: a 404 reported as exit 2 kept the Update run green for two months while the pin stood still). - Outputs (to
$GITHUB_OUTPUT):updated,old_version,new_version,package_name,upstream_url,error_type.
- Update success → the change is built on a runner for every other system the
flake declares checks on (
declared-runners.sh), then committed and pushed to the default branch as the exact commit that was verified. A system that fails files the sameupdate-failedissue asexit 1and pushes nothing. - Update failure (
exit 1) → the one openupdate-failedissue is filed or rewritten with the build log and a recovery branch, and the run ends red, so the Actions page and the Fleet CI watch see it too; the next success closes the issue. A branch that cannot be pushed is named in the issue with git's own error, never dropped silently. - Update transient (
exit 2) → no issue, but the run carries a streak counter in the Actions cache; the seventh consecutive exit-2 run files the sameupdate-failedissue witherror_type: stalled, and the next exit-0 run closes it and resets the streak. EXIT_CODE=${PIPESTATUS[0]}captures the real exit — nottee's.- Maintenance:
nix flake updateon the repo's cadence, then the flake'safter-lock-updateapp when it declares one (a repo that pins something outsideflake.lock, such as meson wrap files, rewrites it from the new lock there; the app may only rewrite tracked files, an untracked file fails the run, and what it rewrote rides the same verification and commit as the lock), the heal probe,nix flake check --no-build --all-systems, then the checks built on a runner for every declared system; push only if every one is green, else file or rewrite the one openmaintenanceissue, whose class names an evaluation failure (eval-error) apart from a build one; plus stale-branch cleanup (>30 days). - A Maintenance refresh that changes no input and heals no fix builds nothing: the committed lock is the one that was verified when it was pushed, so the verify and publish jobs are skipped and no open issue is touched.
- Every verify job restores the Nix store of the newest earlier run for its runner and saves it again under the new lock, so CI on the pushed commit starts from the store the verification built.
- Update runs on the repo's
UPDATE_CADENCEand Maintenance on itsMAINTENANCE_CADENCE: each caller schedules one cron per kind of day and its job's condition comparesgithub.event.schedulewith the cadence, so a day it does not run starts no runner and bills nothing. The reusable workflows still check the cadence, which keeps a caller from before v3.2.0 correct until its roll. Publishing, reporting and retiring attempt branches share onefinishjob. - A first-party repo (upstream type
none) has nothing for Update to follow, so it carries no Update caller:sync.shdeletes it andstd-no-retired-copiesrefuses one. - An issue opens with a Cause section: the log line that named its class (for
missing-python-depthe dependency the check found missing, forsetup-hook-errorthe hook's owndiemessage), ahead of the first error and the log tail. - A private repo past its included Actions minutes cannot report anything:
GitHub refuses to start every job, the report job too.
fleet-auditnames such a run as refused at the spending limit; the weekly Fleet CI watch runs with this repo's token and does not see private repos.
Pre-2026-05-19 each repo carried its own update.sh and they silently diverged.
The standard was centralized, then promoted to this dedicated repo 2026-05-20.
v2.0.0 (2026-05) replaced file-copy + a curl-based drift-check with the
flake-parts flakeModule + in-flake std-conformance model above.
v2.13.0 (2026-07) added pristineBinaryCheck plus the prebuilt
self-reading-binary and Steam compatibility-tool conventions.
v2.14.0 (2026-07) added variantAssets for multi-variant prebuilt releases.
v2.15.0 (2026-07) made a second source hash in hashes fail closed instead of
silently misrouting every mismatch onto the first field, and taught
variantAssets the defaultVariant asset naming.
v2.16.0 (2026-07) made the tagFilter search page through the tag/release list
instead of reading only the first 100 entries — an upstream that publishes a
high-volume second tag namespace previously hid the tracked one from the updater
entirely, failing every scheduled run.
v2.17.0 (2026-07) generalized overlays/ from "nixpkgs regression bridge" to
every temporary divergence — regression, added dependency, or pin — and gave
it a predicate that cannot lie: dropWhenBuilds BUILDS the un-fixed target, so a
breakage invisible to eval (a broken test suite, a pkg-config version reject)
heals on real evidence instead of a version-string proxy. A proxy predicate had
dropped openviking's pandas-stubs fix on an unrelated bump and the repo
re-broke. Exactly one predicate is now required, and a dropWhenBuilds that
cannot evaluate fails closed rather than pinning the fix forever. The heal probe
also MOVED into the lock-update job, right after nix flake update: as a
separate job it probed the committed lock, so a fix needed only on the new
nixpkgs read as healed and was dropped, then the next bump re-broke it. Removals
now share the lock bump's single verification and land atomically with it, and a
red build restores them (from HEAD -- git rm had already cleared the index, so
the old restore could not have worked). The failure classifier also learned the
runner-side store race (transient-infra), the missing-runtime-dependency form
nixpkgs' pythonRuntimeDepsCheck reports, and
python-metadata-version-mismatch.
v2.31.0 (2026-08) closed the gap where a patch that rewrites upstream source
reports nothing. update.sh gated its auto-push on nix flake check --no-build, so every build-time check in every repo was evaluated and never
run -- the upstream-version bump, the one moment a source rewrite stops
matching, was the one path that could not see it. patchAssertions gives that
rewrite the postcondition substituteInPlace --replace-fail already has, and
std-no-fail-open gates the three shell shapes that report success while doing
nothing: grep -c ... || echo N, bare --replace, and --replace-warn. Found
live: a kernel CPUID anchor that had gained an argument, a printk rewrite
upstream had already made unnecessary, a getuid() guard that no longer existed,
and two edits that were no-ops by construction -- every one of them reporting
success. This repo's own CI also began running its declared flake checks, which
it never had.
v2.32.0 (2026-08) stopped update.yml destroying its own successful work.
Dispatched on an update/* branch it ran the whole verification chain against
the real upstream release, committed the bump, pushed it, and then its own
branch-retirement job deleted the branch it had just pushed to: the retirement
loop skips only the branch named by a FAILED update's issue, so on success it
retires every update/* ref for the package, including the one the run is
standing on. Found live, on a real lmstudio update. The fix is not the missing
self-exclusion but the precondition: a run dispatched off the default branch is
working on a branch and has no business garbage-collecting refs at all, so the
whole step is now gated on the default branch, which removes the class and fails
in the safe direction. std-update-retire-guard proves it both ways, requiring
the shipped canonical to carry the gate and the same detector to reject a copy
with the gate stripped. Dependabot's action bump also reached the workflows this
repo ships, not only the ones it runs, which is the split std-action-pins
exists to catch and had been failing on since the pull request opened.
v2.32.1 (2026-08) closed a fail-open in fleet-roll.sh itself. A repo named on
the command line that was not a consumer was skipped in silence: the per-repo
loop continues past anything without a .github/update.json, which is right
for the discovered set because that sweeps every directory, and wrong for an
explicit one. Found live, asking for two repos and getting a roll of one with
failed: 0 and exit 0 -- the exact shape this tool exists to prevent, in the
tool. Named targets are now validated before any repo is touched and refused
with exit 2. Consumers gain nothing from this tag: fleet-roll.sh is not a
synced file, so v2.32.1 is byte-identical to v2.32.0 from a consumer's side.
v2.33.0 (2026-09) closed three fail-opens. A
custom update.sh reported a 404 as exit 2 for two months while its Update run
stayed green (eden-nix#13: upstream had moved its dependency manifest), so
update.yml now carries a streak of consecutive exit-2 runs in the Actions
cache and files the seventh as update-failed / stalled, and the contract
names what exit 2 is for. A dropWhenBuilds on a fixed-output fetch read as
healed the moment the fixed file sat in the store (linux-corecycler#16, a
source mirror that had turned into a Google Drive interstitial), so
heal-overlays.sh and fleet-audit refuse a dropWhenBuilds whose derivation
carries an outputHash, and a source-URL fix tests the eval-visible condition
with dropWhen. And nix fmt in every consumer failed with "unexpected end of
input": Nix 2.34 no longer passes . to the formatter and bare nixfmt reads
stdin, so formatter is nixfmt-tree, which formats the tree without
arguments. Pre-commit was never affected; it passes filenames.
v2.33.1 (2026-09) repaired this repo's own CI, which v2.33.0 had turned red:
its formatting step passed nixfmt's --check through nix fmt, and treefmt
has no such flag. The step now runs the formatter and fails on a resulting
diff, which holds for any formatter. Consumers gain nothing from this tag: the
shipped ci.yml never invokes the formatter, so v2.33.1 is byte-identical to
v2.33.0 from a consumer's side.
v2.34.0 (2026-09) let a repo be aarch64-only and say why (a platforms reason
for the dropped x86_64 leg, and fleet-audit reads a repo's documented outputs
on the arch it builds), built the markdown linter for a 16K-page host, and made
fleet-roll keep every step's own error text.
v2.35.0 (2026-09) made the dev state self-contained and proven: every pin runs
before the hook install, every XDG cache moves under .devshell/ anchored on
the repo root, std-home-proof enters a fresh clone with an empty home at push
time and is exported as a package, std-no-host-profile refuses paths under the
host's profile, the classifier names a substitution whose pattern upstream
moved, and the update path names the real failure. v2.36.0 and v2.37.0 added
mkDevShell and std-devshell-order, so a custom shell cannot get the pin
order wrong and every shell proves it at eval time; v2.37.1 let a shell with no
hook install pass that check. v2.38.0 refetched the version base for a
non-GitHub upstream too; v2.38.1 restored an executable bit.
v2.39.0 (2026-09) closed a set of fail-opens. update.yml opens the
unprivileged user namespace a buildFHSEnv wrapper needs on Ubuntu's runners
and proves it, after a custom updater's smoke test, proven only on development
hosts, failed on its first live run. The standard's own maintenance workflow
had become an older copy of the shipped one (it still carried the separate heal
job v2.17.0 removed), so std-own-copies-match-shipped refuses an own copy that
differs from a canonical. The README omitted checks, helpers and releases, so
std-readme-names-every-surface refuses a README that does not name every
check, helper and synced file, or whose example pin is not the newest release.
sync.sh and base.nix read one synced-files.json instead of restating the
map. A consumer that exists on GitHub but is not cloned where the fleet tools
run is reached by no sync, roll or audit, and one had fallen five tags behind
that way: fleet-audit reds a GitHub consumer with no clone. Custom updaters
still gated a bump on --no-build, the fail-open v2.31.0 closed for the
canonical; the audit reds it and the shipped recovery text stops recommending
it. The | grep -q pipelines in the shipped workflows were rewritten and
check-shell-pipelines scans workflow files. host-hooks.nix builds a
consumer's hook set for the host it is entered on, so a consumer whose flake
declares no output for that host can still run its gate before a commit. The
three actions run on whatever ubuntu-latest is, with the knobs guarded on
existence, so the move to 26.04 needs no edit.
v2.39.1 (2026-09) made the roll and the audit treat a hook whose config link
is gone as dead: a collection can take the generated .pre-commit-config.yaml
while the hook binary survives, and the adoption commit then fails on a missing
config after a green build. Consumers gain nothing from this tag: no shipped
file changed, so v2.39.1 is byte-identical to v2.39.0 from a consumer's side.
v2.39.2 (2026-09) made the roll install the hook set of the tag it adopts. The hooks are a function of the pinned standard, but the roll regenerated them only when they were dead, so the adoption commit and push ran the old tag's gate; the old push-time proof needs a native dev shell and refused the push of every flake the host could not build. Consumers gain nothing from this tag either: it is byte-identical to v2.39.0 from a consumer's side.
v2.39.3 (2026-09) let the roll adopt a tag in a consumer with no git remote. It failed such a repo at the fetch, so an unpushed WIP could never be rolled and sat on an old tag while the audit red its drift, with nothing able to clear it; the roll now skips the fetch and the push for it and commits the adoption locally, which is how fleet-audit already treats a remoteless repo. Byte-identical to v2.39.0 from a consumer's side.
v2.39.4 (2026-09) took every verdict in this repo's own CI from this flake's
lock. The step that lints every tracked script called a bare shellcheck, the
runner image's older build, which reports findings the pinned one does not, so
this repo's CI was red on three consecutive tags for code that is clean
locally and the log gave no reason. The two findings are also fixed at the
source so any version is clean, and std-own-ci-pins-its-tools refuses a step
that names such a tool without the pin on the same line.
v2.39.5 (2026-09) repaired the README gate that v2.39.4 tripped: a History entry is recognised by the version at the start of its line, and v2.39.4's did not begin that way, so the gate read the newest entry as the tag before it and called the example pin wrong. The entry above is the shape the gate reads. Every tag from v2.39.1 on is byte-identical to v2.39.0 from a consumer's side.
v2.39.6 (2026-09) renamed the formatter hook from nixfmt-rfc-style to
nixfmt. git-hooks.nix removed the old name on 2026-09-27 and now asserts on
it, so every consumer's scheduled lock refresh failed to evaluate its
pre-commit and std-devshell-order checks and filed a maintenance issue,
and an aarch64-only consumer pushed the broken lock. The formatter package is
unchanged, so formatting is identical. The new name evaluates against both
the old and the new git-hooks.nix.
v2.40.0 (2026-09) verifies every declared system before anything is pushed.
The maintenance and update jobs built on one x86 runner, and the step that
asked whether the flake declared checks for it swallowed the evaluation's
error: an aarch64-only consumer, or any flake whose checks no longer
evaluated, read as "nothing declared", was reported as verified and pushed.
That is how steam-arm64-nix shipped the lock v2.39.6 fixes. Both jobs now
produce the change once, build it on a runner for each system the flake
declares (scripts/declared-runners.sh, which fails on an evaluation error or
an unknown system), and push the verified commit only when every system is
green; maintenance evaluates every system first. ci.yml takes its matrix
from the same script, so no runner starts for an arch a repo does not build.
An evaluation failure is classified as eval-error, a build that downloads
inside the sandbox (CMake CPM or FetchContent) as sandbox-download, and the
issue quotes the first error, which the log tail had cut. The pipeline checker no longer
recommends the swallowing capture that caused this, std-no-fail-open
refuses a swallowed nix eval in workflows too, and fleet-roll refuses a tag
that is not yet on the remote before touching any repo. The Fleet CI watch read
a fork's pull request from a branch named like the default branch as the
default branch's state, and skipped a consumer that is a GitHub fork; it now
judges each repo by its own non-PR runs and watches every consumer.
update.sh bound a bare hashes[] field to whichever matching file grep
listed first, so a runner image that changed directory order moved
free-claude-code-nix's source hash onto a pinned wheel and failed it daily;
a bare field set in more than one file is now a config-error before any
fetch, naming the files, as the schema section below already required.
v2.40.1 (2026-09) made v2.40.0 adoptable; no consumer ever ran v2.40.0. Its
first roll failed both trial repos before any push, on two defects the tag
exposed. The consumer std-no-fail-open scan passed its --include filters
after --, so grep read them as file names and exited 2; the old || true
had hidden that for as long as the scan existed, and it searched every file
type. The filters now precede the pattern, the scan lives in lib.nix as
failOpenScan, and std-no-fail-open-scan runs it here against a fixture.
And the roll built and committed with commit -a without staging what
sync.sh wrote, so the new scripts/declared-runners.sh was invisible to the
build and would never have been committed; the roll now stages the adoption,
and its restore resets the index instead of checking out from it.
v2.41.0 (2026-10) lets a consumer keep a second pin in step with its lock.
vkBasalt_overlay_wayland builds third-party sources from flake inputs and
also ships meson wrap files for plain meson builds; the maintenance job moved
the lock and never the wraps, so the two drifted with every bump. The job now
runs scripts/after-lock-update.sh right after nix flake update: it reads
whether the flake declares apps.<system>.after-lock-update with the ?
operator, so a flake without one is a no-op and a flake that does not evaluate
fails; it runs the app, refuses any file the app left untracked, and stages
the tracked files it rewrote into the candidate. tests/run.sh Test 23 pins
the no-op, the staged rewrite, the untracked refusal, a failing app and a
broken flake.
v2.42.0 (2026-10) makes a failure visible for as long as it lasts. From
v2.40.0 the report job looked for each log in a folder named after its
artifact, but download-artifact extracts a lone artifact flat into the path,
so every failure seen on one system was filed with "No job uploaded a log" and
classed unclassified: 43 fleet issues in three weeks. Each failing run also
closed the previous issue as not planned and filed a new one, and the Update
run stayed green, so two packages failed every day for three weeks while every
issue looked a day old and the Fleet CI watch reported a clean fleet. Now every
job uploads its log under its own name and the report downloads with
merge-multiple; scripts/report-failure.sh, shared by both workflows, keeps
one open issue, rewrites it with the days since it opened and comments only
when the failure changes; the Update run ends red when it reports; an attempt
branch that cannot be pushed is named in the issue instead of being swallowed
by an empty catch; a retry no longer overwrites the log of the attempt
before it. The updater's check suite runs with --keep-going, so one fired
patch tripwire no longer cancels the rest. The classifier reads coloured store
paths, keeps every runner's failed attributes, and names a compile-error.
The Fleet CI watch also names any consumer with an open failure issue, and
stops treating a failed repository lookup as "not a consumer".
tests/run.sh Test 10b and Test 24 pin all of it with a stubbed gh. The
updater also learns subpackages: coolercontrol's web UI is its own
derivation, so nix-update never refreshed its npmDepsHash and no release
since 4.3.1 updated without a hand edit; each listed attribute now reaches
nix-update as --subpackage (Test 16e). Four rules join the README from the
same audit of the fleet's patches and workarounds: prefer upstream's own
switch to any edit, regenerate rather than restate, keep errors visible and a
tool's own state its own, and find dlopened libraries through RUNPATH. They
are written rules for now; std-no-fail-open gains the || true shape only
once the synced update.sh no longer carries it.
v3.0.0 (2026-10) ends the copy-and-roll cycle. Through v2 every consumer
carried byte-identical copies of three workflows and up to seven scripts, so
each fix to them was a minor bump that reached a repo only when someone rolled
it there, and a tag pin never moved: on 2026-10-23, 17 repos still ran v2.40.1
and filed the false "No job uploaded a log" issues v2.42.0 had fixed, and the
v2.42.0 roll itself copied an uncommitted working tree into one repo. The
workflows are now reusable workflows in this repo; a consumer carries three
callers, every job fetches the scripts at the commit that defines its
workflow, and the retired copies are deleted by sync.sh and refused by
std-no-retired-copies. Consumers follow the release branch
(std-follows-release, releaseUrl), which only scripts/release.sh moves,
after the commit's CI and a fleet check that builds every check the candidate
changes (tests/run.sh Test 26). fleet-roll adopts what release points to
and syncs from that commit, never from the working tree. The maintenance
cadence fails on an unknown value instead of falling back to weekly; branch
cleanup fails on an error it cannot handle instead of logging it as a skip;
CI retries a transient fetch twice. std-action-pins is gone: the shipped
workflows are the ones dependabot bumps. std-base-fixtures evaluates base
against three fixture consumers.
v3.1.0 (2026-10) spends Actions minutes only where they verify something. The
owner's private repos used 2,130 of the Free plan's 2,000 included minutes by
2026-10-25 and GitHub stopped starting their jobs. Of that, 514 minutes were
arm jobs compiling the 16K-page rumdl for the lint check and 349 were jobs
billed at the one-minute floor. The lint check now runs once per repo
(std-base-fixtures proves it on fixtures declaring both systems and only
aarch64-linux); a Maintenance refresh that changes nothing builds nothing;
verify jobs restore and save the Nix store; Update follows an
UPDATE_CADENCE variable, which callers/update.yml passes, so this release
needs a fleet roll; publish, report and retire share one job; every job has a
timeout (std-every-job-has-a-timeout). Issues open with the log line that
named their class, read from the classifier's new evidence output
(tests/run.sh Tests 10c and 24h), and setup-hook-error names a hook that
dies, which a nixpkgs makeWrapper change made of durdraw's wrapper arguments.
v3.2.0 (2026-10) stops starting runners that decide to do nothing. Measured
from the jobs API for 2026-10-01 to 2026-10-26, the daily Update of the three
first-party private repos billed 221 minutes to log that they have no
upstream, the cadence gates billed a minute on every skipped day, and the disk
reclaim took 62 to 282 seconds of every x86_64 build job, against 40 seconds
for all of playstation-tools' checks. The Update and Maintenance callers now
skip a day in the job's condition, before any runner starts; a first-party
repo carries no Update caller (sync.sh, std-no-retired-copies,
std-base-fixtures, tests/run.sh Test 25); the reclaim runs only where
RECLAIM_DISK is on, and a caller that passes nothing keeps it on, so a repo
is never without it before its roll. The callers changed, so this release
needs a fleet roll, and a repo whose x86_64 build needs the disk sets
RECLAIM_DISK=on before it rolls.
fleet-audit names a run GitHub refused at the spending limit.
MIT. See LICENSE.