Skip to content

About

Canonical packaging standard for the Daaboulex NixOS package fleet — shared update workflow, drift check, JSON schema.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

174 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Nix Packaging Standard

Canonical source of the shared tooling used by every *-nix packaging repo.

This repo is the single source of truth, consumed two ways:

  1. 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 the release branch and is locked like any other input.
  2. GitHub Actions are three reusable workflows in .github/workflows/package-*.yml. Each consumer carries three small callers (callers/*.yml, kept byte-identical by std-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").

How a repo consumes the standard

{
  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.

Repo conventions

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 stale update/* 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.yml has nothing to bump and consumers carry none. fleet-audit enforces 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-audit enforces this.
  • License: the repo's LICENSE is MIT — it licenses your Nix packaging code, which is permissive and reusable. The packaged software's licence is declared in the derivation's meta.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.license marked unfree.
  • Topics: nix, nixos, flake baseline; add nixos-module / home-manager when 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 systems is the supported arch set; every arch dropped from the fleet canonical set carries a reason in platforms — see Architecture and platforms.

Git-tracked / overlay packages (*-git)

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 to src.
  • Relax exact-output assertions — an installCheck that 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's accelerate override).
  • Crates come from static.crates.io (the crates.io/api/v1 download endpoint rate-limits CI and 403s); CI also retries transient fetch failures, twice, after 60 and 300 seconds.

Documentation

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 LICENSE file, and the derivation meta.license state 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.

flakeModules

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)

lib

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;
};

Files

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.

Overlays compose against the consumer, not against our own lock

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.

Per-repo extensions

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's module.nix adds disabledModules = [ "programs/foo.nix" ]; (streamcontroller, coolercontrol).
  • Custom devShell: lib.mkForce the standard's lint-only default to fold in the repo's own build toolchain (eden: cmake/ninja/ccache).
  • Typos exclusions: _typos.toml with [files] extend-exclude to skip vendored/forked C++ source (vkBasalt, lmstudio).
  • Rumdl exclusions/overrides: in perSystem, extend pre-commit.settings.hooks.rumdl.excludes or .settings.configuration to skip vendored markdown or disable rules the README legitimately triggers (vkBasalt: MD033 for inline-HTML screenshots).
  • Unfree: config.nixpkgs.config.allowUnfree = true in the module-eval config (lmstudio); standalone pkgs import with config.allowUnfree = true in perSystem (mesa-git).

CI model

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.

Architecture and platforms

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.json platforms, keyed by the dropped arch:

    "platforms": { "aarch64-linux": "amd64-only upstream .deb" }

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.

sync.sh

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 any

sync.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.

Releasing

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 release

release.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.

fleet-audit

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 only

It checks, per consumer (every dir with a .github/update.json):

  • local -- sync.sh --check (synced files byte-match), sync-meta.sh --check (GitHub metadata matches update.json), a known upstream.type, complete metadata (description + topics), the first-party discipline (a self-owned version + a CHANGELOG.md, and a python app wires the single-package site-packages gate), architecture honesty (every canonical arch the flake does not build carries a platforms reason), and nix 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 no package-<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>.nix fix is shape-checked (meta.reason, meta.added, overlay, exactly one of dropWhen/dropWhenBuilds, plus an exported overlays.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 custom repo's own scripts/update.sh gates a bump on the full check suite; one whose only flake check is nix flake check --no-build is 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.json has a clone under PKG_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 single main branch (no stale update/*), zero open automation issues, and a green latest run of CI / Maintenance / Update on main.

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.

Self-contained dev state

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.

Packaged apps keep their state out of the home root

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.

Packages pin what they need, not the host's profile

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.

Replacing an environment makes you own it

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.

Python env+source apps: requirements coverage

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.json pythonRequirements { file, envFile? } -- update.sh fetches the new tag's requirements file and auto-adds each nixpkgs-resolvable new requirement above the # std:requirements-auto-add marker in envFile; 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.

Python apps: one top-level package

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.

Python subpackages: declare the version the artifact actually has

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.

Patching upstream source

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.

Prefer upstream's own switch to any edit

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.

Regenerate, never restate

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.

Errors stay visible, and a tool's own state stays its own

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.

Prebuilt self-reading binaries

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, dontStrip as needed. Gate it with lib.pristineBinaryCheck so the property is enforced, not assumed.
  • Launch through the host loader instead of patching the interpreter: /lib64/ld-linux-x86-64.so.2 exists 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_PRELOAD shims), never via ELF surgery on the self-reading file. Patching COPIES of ordinary libraries stays fine.
  • Runtime self-extraction targets $TMPDIR on some apps; hardened hosts mount /tmp noexec, so the wrapper exports an exec-allowed TMPDIR (XDG data or cache) before exec.
  • substituteInPlace only with --replace-fail / --replace-warn; bare --replace is 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.

Steam compatibility tools

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.vdf identity to a stable, version-free name (substituteInPlace --replace-fail), overridable via a steamDisplayName argument. 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, the proton entry point), the stable identity prefix, and -- via a grep for package.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 (an allVariantSources list attr) so update.sh's hash convergence reaches every hash through .#default.

Short-circuiting pipelines under pipefail

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"; then

The 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-CI failure handling

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.

Temporary divergences from nixpkgs (self-healing)

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 src has 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.

.github/update.json schema

{
  "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" }
}
  • platforms documents the exceptions to the canonical arch set: an object keyed by each canonical arch the flake's systems does 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 in systems. See Architecture and platforms.
  • versionAttr matches both version = "x" and parameterized <attr> ? "x" default-argument forms.
  • versionFile decouples the version literal's location from packageFile.
  • revFile scopes the rev literal bump for commit-tracked upstreams.
  • hashes entries 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, ...): two hash = "..." literals in one file collide and the updater clobbers both with one value. Bind the extra hash to a named literal (cargoHash = "sha256-..."; then hash = 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 with config-error — use variantAssets instead.
  • subpackages names 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's npmDepsHash built apart from the daemon that embeds it, stays stale and the next bump fails on it. Expose that derivation through passthru (passthru.ui = coolercontrol-ui-data;) and list the attribute here ("subpackages": [ "ui" ]).
  • variantAssets is the multi-variant prebuilt-release mode (mutually exclusive with hashes). The updater enumerates the newest matching release's assets, takes each <variant> from <assetPrefix><tag>-<variant><assetSuffix>, prefetches it unpacked (the exact hash a fetchzip yields, root dir stripped), and regenerates the variants attrset between the # std:variants-begin / # std:variants-end markers in sourceFile. 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. defaultVariant names the variant whose asset carries no -<variant> token (GE-Proton ships <tag>.tar.gz for x86_64 next to <tag>-aarch64.tar.gz). Pins outside the markers are never rewritten.
  • For commit-tracked packages prefer versionFile: "version.json".
  • versionScheme controls 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-date writes <base>-unstable-<YYYY-MM-DD> (the nixpkgs VCS-snapshot convention), where <base> is upstream's newest tagFilter-matching tag, refetched on every bump so an upstream release can never leave the snapshot understating itself (a github-commit package whose upstream tags 1.1.2 stops writing 1.1.1-... without anyone editing config). versionBase is 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-only bumps rev (+ hash + date) and LEAVES the human-set version string untouched (git-snapshot packages like mesa-git whose version is upstream's self-reported value). Comparison is by rev for both non-literal schemes.
  • tagFilter (an ERE under upstream, for github-release/github-tag, and for the base-tag lookup of a github-commit package on unstable-date) pins to one tag namespace when upstream publishes several (e.g. ^[0-9] to skip an android/* 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 rolling experimental-* tags ahead of its newest *-wine build tag. A filter that matches nothing fails closed with no-matching-tag.
  • trackOnly: true is the hand-mirrored mode: the updater only DETECTS upstream movement (against trackFile/trackKey, default upstream-version.json/commit) and files a remirror-needed reminder issue -- it never writes, builds, or advances a version. Requires a commit-tracked upstream type.
  • verify.check is one of elf / 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 under result/. verify.binary (+ verify.args) instead runs a built binary.

Upstream types

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).

update.sh contract

  • 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-failed issue.
  • 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 an update-failed issue (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 and Maintenance behaviour

  • 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 same update-failed issue as exit 1 and pushes nothing.
  • Update failure (exit 1) → the one open update-failed issue 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 same update-failed issue with error_type: stalled, and the next exit-0 run closes it and resets the streak.
  • EXIT_CODE=${PIPESTATUS[0]} captures the real exit — not tee's.
  • Maintenance: nix flake update on the repo's cadence, then the flake's after-lock-update app when it declares one (a repo that pins something outside flake.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 open maintenance issue, 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_CADENCE and Maintenance on its MAINTENANCE_CADENCE: each caller schedules one cron per kind of day and its job's condition compares github.event.schedule with 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 one finish job.
  • A first-party repo (upstream type none) has nothing for Update to follow, so it carries no Update caller: sync.sh deletes it and std-no-retired-copies refuses one.
  • An issue opens with a Cause section: the log line that named its class (for missing-python-dep the dependency the check found missing, for setup-hook-error the hook's own die message), 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-audit names 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.

History

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.

License

MIT. See LICENSE.

About

Canonical packaging standard for the Daaboulex NixOS package fleet — shared update workflow, drift check, JSON schema.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages