Moving the bun packages to bun2nix (1db8f763) removed the aggregate deps hash that used to go stale — but not the second, quieter way a generated file rots.
bun2nix is a pure function of bun.lock (WASM; reads the lockfile text, emits one fetchurl per package), so bun.nix drift is always exactly detectable and mechanically fixable. The problem is when it regenerates: only on a local bun installthat runs lifecycle scripts. It does not regenerate under bun install --ignore-scripts — which is what every bun install in CI uses — and it cannot regenerate on a merge or rebase, where git carries someone else's bun.lock change past a bun.nix generated before it and reports no conflict.
That is not theoretical. web/bun.nix went onto main holding brace-expansion@5.0.7 (plus two nested entries the ^5.0.8 override had already collapsed) while web/bun.lock said 5.0.8: the override landed in ec9aa415, the bun2nix branch had generated bun.nix off the pre-override lock, and the merge kept both. The Nix build fetches node_modules strictly from bun.nix, so the derivation's offline bun install was asked for a tarball its store cache did not contain and nix build .#punktfunk-web failed.
It stayed broken for 553 commits (2026-07-27 → 2026-08-05) and was closed by accident, when an unrelated advisory bump (b79d90b4) happened to rerun a real bun install.
Nothing caught it because nothing could: the string nix appeared in exactly one workflow file, and only in a comment about bun2nix breaking a Windows step.
What this adds
scripts/ci/check-bun-nix.sh — regenerates each bun.nix from its committed bun.lock and diffs; --fix rewrites them. It reads which packages to check out of packages.nix's own bunNix = src + … lines rather than a hardcoded list, so a third bun package is covered the day it is added, and an empty list is a hard error — a gate that checks nothing passes exactly like a clean tree. It also enforces the bun2nix version pin that flake.nix and the README only stated: bun.nix has no schema stability across releases, so the flake input ref and both package.json devDependencies must agree, and generation always uses the pinned version rather than a floating bunx.
ci.yml job bun-nix — runs it. Deliberately unfiltered and not in nix.yml: it needs no Nix, takes under a minute, and the whole point is that this drift arrives through commits that look unrelated to Nix.
.gitea/workflows/nix.yml — the first CI that evaluates the flake at all. nix flake check --no-build over every output, then a real build of punktfunk-web + punktfunk-scripting, the two derivations whose inputs churn constantly and the only ones that do not compile Rust. That leg is the end-to-end proof the drift gate cannot give: it catches a tarball the registry no longer serves, or the codegen going quietly message-less (see packages.nix's inlang note). Path-filtered, with a workflow_dispatch opt-in for the hour-long Rust packages, and pull_request kept — flatpak.yml shipped push-only and let manifest breakage reach main invisibly for weeks.
packaging/nix/README.md — it had been recommending an unpinnedbunx bun2nix -o bun.nix, which is precisely the floating-version hazard it warns about a paragraph later. Now documents the gate, the --fix command, and why the devDependency hook is a convenience rather than a guarantee.
Verification
All four arms of the script were exercised locally:
case
result
clean tree
passes, EXIT=0
injected drift in web/bun.nix
fails EXIT=1 with an actionable diff
--fix
restores byte-identically (git status clean)
bun2nix devDep bumped out of step with flake.nix
fails EXIT=1
bunNix lines removed from packages.nix
fails EXIT=1 (vacuous-gate guard)
shellcheck -s sh clean; runs correctly under dash, which is oven/bun:1's /bin/sh; cwd-independent. No process substitution — that parse-time trap is what silently disabled ci.yml's shader SPIR-V gate for 35 commits. Both workflow YAMLs parse.
⚠ Known gap
nix.yml has not been dry-run — no Nix runner was reachable to do it (.21 has nix + flakes but its disk is 100% full; every other Linux box was unreachable). Its first run needs watching. The risky parts are nixos/nix:latest shipping no node (installed via nix profile install nixpkgs#nodejsbeforeactions/checkout, following flatpak.yml's pattern) and whether the fleet tolerates that image at all. The ci.yml drift gate is fully validated and does not depend on it.
Aside
deb.yml:174 is the one CI bun installwithout--ignore-scripts, so web's postinstall regenerates bun.nix there and throws it away. Harmless — but it means bun must stay on PATH there, the same thing that broke windows-host.yml. Left alone.
## The drift bun2nix did not remove
Moving the bun packages to bun2nix (`1db8f763`) removed the aggregate deps **hash** that used to go stale — but not the second, quieter way a generated file rots.
bun2nix is a pure function of `bun.lock` (WASM; reads the lockfile text, emits one `fetchurl` per package), so `bun.nix` drift is always exactly detectable and mechanically fixable. The problem is *when* it regenerates: only on a local `bun install` **that runs lifecycle scripts**. It does **not** regenerate under `bun install --ignore-scripts` — which is what every bun install in CI uses — and it **cannot** regenerate on a **merge or rebase**, where git carries someone else's `bun.lock` change past a `bun.nix` generated before it and reports no conflict.
That is not theoretical. `web/bun.nix` went onto main holding `brace-expansion@5.0.7` (plus two nested entries the `^5.0.8` override had already collapsed) while `web/bun.lock` said `5.0.8`: the override landed in `ec9aa415`, the bun2nix branch had generated `bun.nix` off the pre-override lock, and the merge kept both. The Nix build fetches `node_modules` strictly from `bun.nix`, so the derivation's offline `bun install` was asked for a tarball its store cache did not contain and **`nix build .#punktfunk-web` failed**.
It stayed broken for **553 commits** (2026-07-27 → 2026-08-05) and was closed *by accident*, when an unrelated advisory bump (`b79d90b4`) happened to rerun a real `bun install`.
Nothing caught it because nothing could: the string `nix` appeared in exactly one workflow file, and only in a comment about bun2nix breaking a Windows step.
## What this adds
- **`scripts/ci/check-bun-nix.sh`** — regenerates each `bun.nix` from its committed `bun.lock` and diffs; `--fix` rewrites them. It reads *which* packages to check out of `packages.nix`'s own `bunNix = src + …` lines rather than a hardcoded list, so a third bun package is covered the day it is added, and an **empty list is a hard error** — a gate that checks nothing passes exactly like a clean tree. It also enforces the bun2nix version pin that `flake.nix` and the README only *stated*: `bun.nix` has no schema stability across releases, so the flake input ref and both `package.json` devDependencies must agree, and generation always uses the pinned version rather than a floating `bunx`.
- **`ci.yml` job `bun-nix`** — runs it. Deliberately **unfiltered** and *not* in `nix.yml`: it needs no Nix, takes under a minute, and the whole point is that this drift arrives through commits that look unrelated to Nix.
- **`.gitea/workflows/nix.yml`** — the first CI that evaluates the flake at all. `nix flake check --no-build` over every output, then a real build of `punktfunk-web` + `punktfunk-scripting`, the two derivations whose inputs churn constantly and the only ones that do not compile Rust. That leg is the end-to-end proof the drift gate cannot give: it catches a tarball the registry no longer serves, or the codegen going quietly message-less (see `packages.nix`'s inlang note). Path-filtered, with a `workflow_dispatch` opt-in for the hour-long Rust packages, and `pull_request` kept — `flatpak.yml` shipped push-only and let manifest breakage reach main invisibly for weeks.
- **`packaging/nix/README.md`** — it had been recommending an **unpinned** `bunx bun2nix -o bun.nix`, which is precisely the floating-version hazard it warns about a paragraph later. Now documents the gate, the `--fix` command, and why the devDependency hook is a convenience rather than a guarantee.
## Verification
All four arms of the script were exercised locally:
| case | result |
|---|---|
| clean tree | passes, `EXIT=0` |
| injected drift in `web/bun.nix` | fails `EXIT=1` with an actionable diff |
| `--fix` | restores byte-identically (`git status` clean) |
| `bun2nix` devDep bumped out of step with `flake.nix` | fails `EXIT=1` |
| `bunNix` lines removed from `packages.nix` | fails `EXIT=1` (vacuous-gate guard) |
`shellcheck -s sh` clean; runs correctly under **dash**, which is `oven/bun:1`'s `/bin/sh`; cwd-independent. No process substitution — that parse-time trap is what silently disabled `ci.yml`'s shader SPIR-V gate for 35 commits. Both workflow YAMLs parse.
## ⚠ Known gap
**`nix.yml` has not been dry-run** — no Nix runner was reachable to do it (`.21` has nix + flakes but its disk is 100% full; every other Linux box was unreachable). Its first run needs watching. The risky parts are `nixos/nix:latest` shipping no node (installed via `nix profile install nixpkgs#nodejs` *before* `actions/checkout`, following `flatpak.yml`'s pattern) and whether the fleet tolerates that image at all. The `ci.yml` drift gate is fully validated and does not depend on it.
## Aside
`deb.yml:174` is the one CI `bun install` *without* `--ignore-scripts`, so web's `postinstall` regenerates `bun.nix` there and throws it away. Harmless — but it means `bun` must stay on PATH there, the same thing that broke `windows-host.yml`. Left alone.
Moving the bun packages to bun2nix (1db8f763) removed the aggregate deps hash
that used to go stale, but not the second, quieter way a generated file rots.
bun2nix is a pure function of bun.lock, and `bun.nix` is committed alongside it
— but it regenerates ONLY from a local `bun install` that runs lifecycle
scripts. It does not regenerate under `bun install --ignore-scripts`, which is
what every bun install in CI uses, and it cannot regenerate on a merge or
rebase, where git carries someone else's lockfile change past a `bun.nix`
generated before it and reports no conflict.
That is not theoretical. web/bun.nix went onto main holding brace-expansion@5.0.7
(plus two nested entries the `^5.0.8` override had already collapsed) while
web/bun.lock said 5.0.8: the override landed in ec9aa415, the bun2nix branch had
generated bun.nix off the pre-override lock, and the merge kept both. The Nix
build fetches node_modules strictly from bun.nix, so the derivation's offline
`bun install` was asked for a tarball its store cache did not contain and
`nix build .#punktfunk-web` failed. It stayed broken for 553 commits
(2026-07-27 -> 2026-08-05) and was closed by accident, when an unrelated
advisory bump in b79d90b4 happened to rerun a real `bun install`.
Nothing caught it because nothing could: the string "nix" appeared in exactly
one workflow file, and only in a comment about bun2nix breaking a Windows step.
* scripts/ci/check-bun-nix.sh regenerates each bun.nix from its committed
bun.lock and diffs, with `--fix` to rewrite them. It reads which packages to
check out of packages.nix's own `bunNix = src + ...` lines rather than a
hardcoded list, so a third bun package is covered the day it is added, and
an empty list is a hard error — a gate that checks nothing passes exactly
like a clean tree. It also enforces the bun2nix version pin that flake.nix
and the README only stated: bun.nix has no schema stability across releases,
so the flake input ref and both package.json devDependencies must agree, and
generation always uses the pinned version rather than a floating `bunx`.
* ci.yml gains a `bun-nix` job running it. Deliberately unfiltered and not in
nix.yml: it needs no Nix, takes under a minute, and the whole point is that
this drift arrives through commits that look unrelated to Nix.
* nix.yml is new and is the first CI that evaluates the flake at all —
`nix flake check --no-build` over every output, then a real build of
punktfunk-web + punktfunk-scripting, which are the two derivations whose
inputs churn and the only ones that do not compile Rust. That leg is the
end-to-end proof the drift gate cannot give: it catches a tarball the
registry no longer serves, or the codegen going quietly message-less. It is
path-filtered, has a workflow_dispatch opt-in for the hour-long Rust
packages, and keeps `pull_request` — flatpak.yml shipped push-only and let
manifest breakage reach main invisibly for weeks.
The script is POSIX sh (shellcheck-clean, exercised under dash, which is the
CI container's /bin/sh) and avoids process substitution, the parse-time failure
that silently disabled ci.yml's shader SPIR-V gate for 35 commits.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The drift bun2nix did not remove
Moving the bun packages to bun2nix (
1db8f763) removed the aggregate deps hash that used to go stale — but not the second, quieter way a generated file rots.bun2nix is a pure function of
bun.lock(WASM; reads the lockfile text, emits onefetchurlper package), sobun.nixdrift is always exactly detectable and mechanically fixable. The problem is when it regenerates: only on a localbun installthat runs lifecycle scripts. It does not regenerate underbun install --ignore-scripts— which is what every bun install in CI uses — and it cannot regenerate on a merge or rebase, where git carries someone else'sbun.lockchange past abun.nixgenerated before it and reports no conflict.That is not theoretical.
web/bun.nixwent onto main holdingbrace-expansion@5.0.7(plus two nested entries the^5.0.8override had already collapsed) whileweb/bun.locksaid5.0.8: the override landed inec9aa415, the bun2nix branch had generatedbun.nixoff the pre-override lock, and the merge kept both. The Nix build fetchesnode_modulesstrictly frombun.nix, so the derivation's offlinebun installwas asked for a tarball its store cache did not contain andnix build .#punktfunk-webfailed.It stayed broken for 553 commits (2026-07-27 → 2026-08-05) and was closed by accident, when an unrelated advisory bump (
b79d90b4) happened to rerun a realbun install.Nothing caught it because nothing could: the string
nixappeared in exactly one workflow file, and only in a comment about bun2nix breaking a Windows step.What this adds
scripts/ci/check-bun-nix.sh— regenerates eachbun.nixfrom its committedbun.lockand diffs;--fixrewrites them. It reads which packages to check out ofpackages.nix's ownbunNix = src + …lines rather than a hardcoded list, so a third bun package is covered the day it is added, and an empty list is a hard error — a gate that checks nothing passes exactly like a clean tree. It also enforces the bun2nix version pin thatflake.nixand the README only stated:bun.nixhas no schema stability across releases, so the flake input ref and bothpackage.jsondevDependencies must agree, and generation always uses the pinned version rather than a floatingbunx.ci.ymljobbun-nix— runs it. Deliberately unfiltered and not innix.yml: it needs no Nix, takes under a minute, and the whole point is that this drift arrives through commits that look unrelated to Nix..gitea/workflows/nix.yml— the first CI that evaluates the flake at all.nix flake check --no-buildover every output, then a real build ofpunktfunk-web+punktfunk-scripting, the two derivations whose inputs churn constantly and the only ones that do not compile Rust. That leg is the end-to-end proof the drift gate cannot give: it catches a tarball the registry no longer serves, or the codegen going quietly message-less (seepackages.nix's inlang note). Path-filtered, with aworkflow_dispatchopt-in for the hour-long Rust packages, andpull_requestkept —flatpak.ymlshipped push-only and let manifest breakage reach main invisibly for weeks.packaging/nix/README.md— it had been recommending an unpinnedbunx bun2nix -o bun.nix, which is precisely the floating-version hazard it warns about a paragraph later. Now documents the gate, the--fixcommand, and why the devDependency hook is a convenience rather than a guarantee.Verification
All four arms of the script were exercised locally:
EXIT=0web/bun.nixEXIT=1with an actionable diff--fixgit statusclean)bun2nixdevDep bumped out of step withflake.nixEXIT=1bunNixlines removed frompackages.nixEXIT=1(vacuous-gate guard)shellcheck -s shclean; runs correctly under dash, which isoven/bun:1's/bin/sh; cwd-independent. No process substitution — that parse-time trap is what silently disabledci.yml's shader SPIR-V gate for 35 commits. Both workflow YAMLs parse.⚠ Known gap
nix.ymlhas not been dry-run — no Nix runner was reachable to do it (.21has nix + flakes but its disk is 100% full; every other Linux box was unreachable). Its first run needs watching. The risky parts arenixos/nix:latestshipping no node (installed vianix profile install nixpkgs#nodejsbeforeactions/checkout, followingflatpak.yml's pattern) and whether the fleet tolerates that image at all. Theci.ymldrift gate is fully validated and does not depend on it.Aside
deb.yml:174is the one CIbun installwithout--ignore-scripts, so web'spostinstallregeneratesbun.nixthere and throws it away. Harmless — but it meansbunmust stay on PATH there, the same thing that brokewindows-host.yml. Left alone.