forked from unom/punktfunk
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 inec9aa415, 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 inb79d90b4happened 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.
This commit is contained in:
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user