Files
punktfunk/scripts/ci/check-bun-nix.sh
T
enricobuehler fb707b4956
ci / rust-arm64 (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Failing after 2m19s
ci / docs-site (pull_request) Successful in 1m17s
android / android (pull_request) Successful in 3m10s
ci / bun-nix (pull_request) Successful in 1m6s
nix / flake (pull_request) Failing after 1m6s
ci / rust (pull_request) Failing after 6m2s
ci(nix): a stale bun.nix can no longer reach main unnoticed
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.
2026-08-06 15:28:14 +02:00

182 lines
8.8 KiB
Bash
Executable File

#!/bin/sh
# Drift gate for the generated bun2nix lockfile expressions (web/bun.nix, sdk/bun.nix).
#
# `bun.nix` is a DERIVED file: bun2nix is a pure function of `bun.lock` (it reads the lockfile text
# and emits one `fetchurl` per package, keyed by the lockfile's own integrity hashes — see
# packaging/nix/README.md). Nothing but the lockfile goes in, so any disagreement between the two
# committed files is drift, and it is always mechanically fixable.
#
# Why this exists: moving the bun packages to bun2nix (1db8f763) removed the *aggregate deps hash*
# that used to go stale, but not the second, quieter way a derived file rots. `bun.nix` regenerates
# only from a local `bun install` that runs lifecycle scripts (web's `postinstall`, the SDK's
# `prepare`). It does NOT regenerate on:
#
# * `bun install --ignore-scripts` — which is what EVERY bun install in CI uses (ci.yml,
# web-screenshots.yml, windows-host.yml, sdk-publish.yml), because web's `postinstall` shells
# out to a `bun` on PATH that CI's portable bun isn't;
# * a merge or rebase — git merges `bun.lock` and `bun.nix` as two unrelated files, so a branch
# that generated `bun.nix` before picking up someone else's lockfile change silently commits
# the pair out of step;
# * a lockfile edited or re-resolved by hand.
#
# That second case is not hypothetical: it is how `web/bun.nix` shipped on main carrying
# brace-expansion@5.0.7 (plus two nested entries the override had already collapsed) while
# `web/bun.lock` said 5.0.8 — the `^5.0.8` override from ec9aa415 landed in the lockfile, 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 offline `bun install` inside the
# derivation is then asked for a tarball the store cache does not contain and `punktfunk-web` fails
# to build — with a "package not found" that names npm, not the lockfile that actually drifted.
#
# The gate also enforces the version pin the flake and README only *state*: `bun.nix` has no schema
# stability across bun2nix releases, so the flake input ref and BOTH npm devDependencies must name
# the same exact version. Nothing checked that before; a half-moved pin regenerates the file with a
# generator the flake does not use.
#
# The list of packages to check is read out of packaging/nix/packages.nix (its `bunNix = src + …`
# lines) rather than hardcoded here, so a third bun package is covered the day it is added — and an
# empty list is a hard error, because a gate that checks nothing passes exactly like a clean tree.
#
# Usage:
# scripts/ci/check-bun-nix.sh # verify; non-zero on drift (CI)
# scripts/ci/check-bun-nix.sh --fix # regenerate the committed files in place
set -eu
FIX=0
if [ $# -gt 0 ]; then
case "$1" in
--fix) FIX=1 ;;
*) echo "usage: $0 [--fix]" >&2; exit 2 ;;
esac
fi
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
PACKAGES_NIX="$ROOT/packaging/nix/packages.nix"
FLAKE="$ROOT/flake.nix"
command -v bun >/dev/null 2>&1 || {
echo "check-bun-nix: bun is not on PATH (needed to run bun2nix and to read package.json)" >&2
exit 1
}
[ -f "$PACKAGES_NIX" ] || { echo "check-bun-nix: no $PACKAGES_NIX" >&2; exit 1; }
[ -f "$FLAKE" ] || { echo "check-bun-nix: no $FLAKE" >&2; exit 1; }
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# --- the pinned bun2nix version -------------------------------------------------------------------
# flake.nix: url = "github:nix-community/bun2nix?ref=2.1.2";
PINNED=$(sed -n 's/.*github:nix-community\/bun2nix?ref=\([^"]*\)".*/\1/p' "$FLAKE" | head -1)
[ -n "$PINNED" ] || {
echo "check-bun-nix: could not read the bun2nix input ref out of $FLAKE." >&2
echo "Expected a line like: url = \"github:nix-community/bun2nix?ref=<version>\";" >&2
exit 1
}
# --- which packages carry a generated bun.nix -----------------------------------------------------
# packages.nix: bunDeps = bun2nix.fetchBunDeps { bunNix = src + "/web/bun.nix"; };
sed -n 's/.*bunNix *= *src *+ *"\/\(.*\)\/bun\.nix".*/\1/p' "$PACKAGES_NIX" | sort -u > "$TMP/roots"
if [ ! -s "$TMP/roots" ]; then
echo "check-bun-nix: found no \`bunNix = src + \"/<dir>/bun.nix\"\` in $PACKAGES_NIX." >&2
echo "Either the bun packages were removed (delete this gate) or the expression changed shape" >&2
echo "and the gate silently stopped checking anything. Not passing vacuously." >&2
exit 1
fi
fail=0
checked=0
# --- version pin agreement ------------------------------------------------------------------------
# `bun.nix` has no schema stability across bun2nix versions, so the generator the flake builds with
# and the generator `bun install` runs must be the SAME exact version (packaging/nix/README.md).
while read -r dir; do
pkgjson="$ROOT/$dir/package.json"
[ -f "$pkgjson" ] || { echo "check-bun-nix: no $pkgjson" >&2; fail=1; continue; }
dev=$(bun -e "const d=require(process.argv[1]).devDependencies||{};console.log(d.bun2nix??'')" \
"$pkgjson")
if [ "$dev" != "$PINNED" ]; then
echo "check-bun-nix: bun2nix version pin disagrees." >&2
echo " flake.nix input ref : $PINNED" >&2
echo " $dir/package.json devDependency : ${dev:-<absent>}" >&2
echo "These must be the same exact version — bun.nix has no schema stability across" >&2
echo "bun2nix releases. Move both together, then rerun this script with --fix." >&2
fail=1
fi
done < "$TMP/roots"
# --- the generator ---------------------------------------------------------------------------------
# Prefer an already-installed bun2nix at the pinned version (fast, offline — the dev case); otherwise
# fetch exactly the pinned one, once, into $TMP. Never a floating `bunx bun2nix`: that would generate
# with whatever is newest, and `bun.nix` has no schema stability across releases.
BUN2NIX=""
while read -r dir; do
cand="$ROOT/$dir/node_modules/bun2nix/index.ts"
[ -f "$cand" ] || continue
have=$(bun -e "console.log(require(process.argv[1]).version??'')" \
"$ROOT/$dir/node_modules/bun2nix/package.json" 2>/dev/null || echo '')
if [ "$have" = "$PINNED" ]; then BUN2NIX="$cand"; break; fi
done < "$TMP/roots"
if [ -z "$BUN2NIX" ]; then
# Installed in its own scratch dir, so this never touches the repo's lockfiles or .npmrc.
mkdir -p "$TMP/gen"
if ! ( cd "$TMP/gen" && bun add --exact "bun2nix@$PINNED" ) > "$TMP/geninstall.log" 2>&1; then
echo "check-bun-nix: could not install bun2nix@$PINNED" >&2
cat "$TMP/geninstall.log" >&2
exit 1
fi
BUN2NIX="$TMP/gen/node_modules/bun2nix/index.ts"
[ -f "$BUN2NIX" ] || { echo "check-bun-nix: bun2nix@$PINNED installed but $BUN2NIX is absent" >&2; exit 1; }
fi
run_bun2nix() { # <lockfile> <outfile>
bun "$BUN2NIX" --lock-file "$1" --output-file "$2"
}
# --- regenerate + compare ---------------------------------------------------------------------------
while read -r dir; do
lock="$ROOT/$dir/bun.lock"
nix="$ROOT/$dir/bun.nix"
[ -f "$lock" ] || { echo "check-bun-nix: no $lock (packages.nix expects $dir/bun.nix)" >&2; fail=1; continue; }
out="$TMP/$(echo "$dir" | tr '/' '_').bun.nix"
run_bun2nix "$lock" "$out" >/dev/null
if [ "$FIX" -eq 1 ]; then
if [ ! -f "$nix" ] || ! cmp -s "$nix" "$out"; then
cp "$out" "$nix"
echo "check-bun-nix: regenerated $dir/bun.nix from $dir/bun.lock"
else
echo "check-bun-nix: $dir/bun.nix already in sync"
fi
checked=$((checked + 1))
continue
fi
if [ ! -f "$nix" ]; then
echo "check-bun-nix: $dir/bun.nix is MISSING — packages.nix fetches node_modules from it." >&2
fail=1
continue
fi
# Plain files, not `diff <(…) <(…)`: Gitea's runner executes a step's `run:` under `sh`, and
# dash has no process substitution — it would reject the script at parse time and the gate
# would never compare anything (exactly how the shader SPIR-V gate in ci.yml was lost).
if cmp -s "$nix" "$out"; then
echo "check-bun-nix: $dir/bun.nix matches $dir/bun.lock"
else
echo "check-bun-nix: $dir/bun.nix is STALE — it does not match $dir/bun.lock." >&2
echo "The Nix build fetches node_modules only from bun.nix, so punktfunk's bun packages" >&2
echo "would build against the wrong dependency set (or fail to fetch it at all)." >&2
echo "Regenerate and commit it: scripts/ci/check-bun-nix.sh --fix" >&2
echo "--- diff (committed -> regenerated from bun.lock) ---" >&2
diff -u "$nix" "$out" >&2 || true
fail=1
fi
checked=$((checked + 1))
done < "$TMP/roots"
[ "$checked" -gt 0 ] || { echo "check-bun-nix: checked nothing — refusing to report success" >&2; exit 1; }
if [ "$fail" -eq 0 ] && [ "$FIX" -eq 0 ]; then
echo "check-bun-nix: $checked bun package(s) in sync, bun2nix pinned at $PINNED everywhere"
fi
exit "$fail"