fix(nix): replace manual hash management with bun2nix (#1021)

* fix(nix): replace manual hash management with bun2nix

Eliminate the recurring problem of Nix build hashes breaking whenever
bun dependencies change or the bun version in nixpkgs updates.

Changes:
- Add bun2nix flake input (pinned to v2.0.1) for per-package fetchurl
  expressions from bun.lock, replacing the single FOD hash approach
- Use allowBuiltinFetchGit for cargo git dependencies, removing manual
  outputHashes that required updates on every git dep change
- Add scripts/check-nix-deps.ts (cross-platform, runs via bun) that
  auto-regenerates .nix/bun.nix when bun.lock changes, triggered by
  the postinstall hook in package.json
- Update CI workflow to verify bun.nix is in sync and evaluate flake
- Remove scripts/update-nix-hashes.sh (no longer needed)

* style: format check-nix-deps.ts with prettier

* ci(nix): make nix-check job a required check

* fix(nix): address review feedback

- Fix bun2nix input pinning: use path syntax (github:owner/repo/tag)
  instead of ?tag= query parameter so flake.lock records the ref
  and `nix flake update` respects the pin
- Make check-nix-deps.ts exit with 0 on bun2nix failure so that
  `bun install` is not blocked for non-Nix developers (CI validates
  bun.nix independently)
- Fix stale reference to check-nix-deps.sh in flake.nix comment

* ci(nix): add full nix build step to CI workflow

Add `nix build .#handy -L --show-trace` after flake evaluation to
catch runtime build errors (broken dependencies, sandbox issues,
compilation failures) that flake eval alone cannot detect.

* fix(nix): update bun2nix pin from 2.0.1 to 2.0.8

bun2nix 2.0.1 has a bug in cache-entry-creator that causes
"ln: failed to create symbolic link '/p': Permission denied"
during the build. Version 2.0.8 fixes this.
This commit is contained in:
Evgeny Khudoba 2026-03-13 17:45:46 +07:00 committed by GitHub
parent 01b8b05bdc
commit 7056edce28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1695 additions and 265 deletions

View file

@ -1,18 +1,62 @@
# Verify that Nix dependency files (.nix/bun.nix) are in sync with lockfiles
# and that the flake evaluates successfully.
#
# This catches cases where a developer updated bun.lock but forgot to
# regenerate .nix/bun.nix (e.g., committed without running bun install,
# which triggers the postinstall hook).
name: "nix build check"
on: [pull_request]
jobs:
nix-build:
runs-on: ubuntu-24.04
continue-on-error: true
continue-on-error: false
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: cachix/install-nix-action@v30
with:
nix_path: nixpkgs=channel:nixos-unstable
# Regenerate .nix/bun.nix from bun.lock and check if it matches
# what's committed. A diff means the developer forgot to run
# bun scripts/check-nix-deps.ts or bun install (which triggers it).
- name: Check bun.nix is up to date
id: bun-check
run: |
bunx bun2nix -o .nix/bun.nix
if ! git diff --quiet .nix/bun.nix; then
echo "outdated=true" >> "$GITHUB_OUTPUT"
fi
- name: Hint on outdated bun.nix
if: steps.bun-check.outputs.outdated == 'true'
run: |
echo ""
echo "::warning::.nix/bun.nix is out of sync with bun.lock"
echo ""
echo "┌──────────────────────────────────────────────────────────────┐"
echo "│ .nix/bun.nix is outdated. To fix, run: │"
echo "│ │"
echo "│ bun scripts/check-nix-deps.ts │"
echo "│ │"
echo "│ Or simply run 'bun install' — the postinstall hook will │"
echo "│ regenerate it automatically. Commit the resulting changes. │"
echo "└──────────────────────────────────────────────────────────────┘"
echo ""
echo "Diff:"
git diff .nix/bun.nix
exit 1
# Evaluate the flake to catch issues with cargo git dependency hashes,
# missing inputs, or other Nix expression errors.
# Skip if bun.nix is already outdated — nix eval would fail with a
# cryptic error, and the bun-check step already printed a clear message.
- name: Check flake evaluation
if: steps.bun-check.outputs.outdated != 'true'
id: eval
run: |
if ! nix eval .#packages.x86_64-linux.handy.drvPath 2>eval_err.log; then
@ -24,16 +68,13 @@ jobs:
if: steps.eval.outputs.failed == 'true'
run: |
echo ""
echo "::warning::flake.nix evaluation failed — likely outdated outputHashes or bunDeps hash."
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ To fix, run on NixOS, Ubuntu/Debian, macOS with nix installed: │"
echo "│ │"
echo "│ ./scripts/update-nix-hashes.sh │"
echo "│ │"
echo "│ The script will update version keys and hashes in flake.nix │"
echo "│ automatically. Commit the resulting changes. │"
echo "└─────────────────────────────────────────────────────────────────┘"
echo "::warning::flake.nix evaluation failed"
echo ""
cat eval_err.log
exit 1
# Full build — catches runtime build errors (broken dependencies,
# sandbox issues, compilation failures) that flake eval alone misses.
- name: Build handy
if: steps.bun-check.outputs.outdated != 'true' && steps.eval.outputs.failed != 'true'
run: nix build .#handy -L --show-trace

1
.nix/bun-lock-hash Normal file
View file

@ -0,0 +1 @@
e00b12c719a762004194cec01f2ad0b78ae483c41452bcca8537179d28e704b1

1400
.nix/bun.nix Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,63 @@
{
"nodes": {
"bun2nix": {
"inputs": {
"flake-parts": "flake-parts",
"import-tree": "import-tree",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1770895533,
"narHash": "sha256-v3QaK9ugy9bN9RXDnjw0i2OifKmz2NnKM82agtqm/UY=",
"owner": "nix-community",
"repo": "bun2nix",
"rev": "c843f477b15f51151f8c6bcc886954699440a6e1",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "2.0.8",
"repo": "bun2nix",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1769996383,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"import-tree": {
"locked": {
"lastModified": 1763762820,
"narHash": "sha256-ZvYKbFib3AEwiNMLsejb/CWs/OL/srFQ8AogkebEPF0=",
"owner": "vic",
"repo": "import-tree",
"rev": "3c23749d8013ec6daa1d7255057590e9ca726646",
"type": "github"
},
"original": {
"owner": "vic",
"repo": "import-tree",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1770562336,
@ -16,10 +74,62 @@
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1769909678,
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "72716169fe93074c333e8d0173151350670b824c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"root": {
"inputs": {
"bun2nix": "bun2nix",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"bun2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1770228511,
"narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "337a4fe074be1042a35086f15481d763b8ddc0e7",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",

View file

@ -3,12 +3,22 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# bun2nix: generates per-package Nix fetchurl expressions from bun.lock,
# replacing the old FOD approach where a single hash covered the entire
# node_modules directory (that hash would break on bun version changes).
# See: https://github.com/nix-community/bun2nix
bun2nix = {
url = "github:nix-community/bun2nix/2.0.8";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
bun2nix,
}:
let
supportedSystems = [
@ -24,35 +34,11 @@
packages = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
lib = pkgs.lib;
bunDeps = pkgs.stdenv.mkDerivation {
pname = "handy-bun-deps";
inherit version;
src = self;
nativeBuildInputs = [
pkgs.bun
pkgs.cacert
];
dontFixup = true;
buildPhase = ''
export HOME=$TMPDIR
bun install --frozen-lockfile --no-progress
'';
installPhase = ''
mkdir -p $out
cp -r node_modules $out/
'';
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = "sha256-84Aw9E2+fEZT+lIb9k1bodessoex+YFr0im2GMVAPnw=";
pkgs = import nixpkgs {
inherit system;
overlays = [ bun2nix.overlays.default ];
};
lib = pkgs.lib;
in
{
handy = pkgs.rustPlatform.buildRustPackage {
@ -64,13 +50,11 @@
cargoLock = {
lockFile = ./src-tauri/Cargo.lock;
outputHashes = {
"rdev-0.5.0-2" = "sha256-0F7EaPF8Oa1nnSCAjzEAkitWVpMldL3nCp3c5DVFMe0=";
"rodio-0.20.1" = "sha256-wq72awTvN4fXZ9qZc5KLYS9oMxtNDZ4YGxfqz8msofs=";
"tauri-nspanel-2.1.0" = "sha256-gotQQ1DOhavdXU8lTEux0vdY880LLetk7VLvSm6/8TI=";
"tauri-runtime-2.10.0" = "sha256-s1IBM9hOY+HRdl/E5r7BsRTE7aLaFCCMK/DdS+bvZRc=";
"vad-rs-0.1.5" = "sha256-Q9Dxq31npyUPY9wwi6OxqSJrEvFvG8/n0dbyT7XNcyI=";
};
# Automatically fetch git dependencies using builtins.fetchGit.
# This eliminates the need for manual outputHashes that had to be
# updated every time a git dependency changed in Cargo.lock.
# Safe for standalone flakes (not allowed in nixpkgs, it is needed something like crate2nix).
allowBuiltinFetchGit = true;
};
postPatch = ''
@ -78,6 +62,12 @@
src-tauri/tauri.conf.json > $TMPDIR/tauri.conf.json
cp $TMPDIR/tauri.conf.json src-tauri/tauri.conf.json
# Strip postinstall hook — it runs check-nix-deps.ts which is only
# needed during local development, not inside the Nix sandbox.
${pkgs.jq}/bin/jq 'del(.scripts.postinstall)' \
package.json > $TMPDIR/package.json
cp $TMPDIR/package.json package.json
# Point libappindicator-sys to the Nix store path
substituteInPlace \
$cargoDepsCopy/libappindicator-sys-*/src/lib.rs \
@ -93,11 +83,31 @@
--replace-fail '.write_to_file("opencc.h");' '// skipped'
'';
# Bun dependencies: fetched per-package using hashes from .nix/bun.nix.
# This file is auto-generated by `bunx bun2nix -o .nix/bun.nix` and
# kept in sync via the postinstall hook in package.json.
# To regenerate manually: bun scripts/check-nix-deps.ts
bunDeps = pkgs.bun2nix.fetchBunDeps {
bunNix = ./.nix/bun.nix;
};
nativeBuildInputs = with pkgs; [
cargo-tauri.hook
pkg-config
wrapGAppsHook4
bun
# pkgs.bun2nix (from overlay), not the flake input — `with pkgs;`
# doesn't shadow function arguments in Nix.
pkgs.bun2nix.hook # Sets up node_modules from pre-fetched bun cache
jq
cmake
llvmPackages.libclang
shaderc
];
preBuild = ''
cp -r ${bunDeps}/node_modules node_modules
chmod -R +w node_modules
substituteInPlace node_modules/.bin/{tsc,vite} \
--replace-fail "/usr/bin/env node" "${lib.getExe pkgs.bun}"
# bun2nix.hook has already set up node_modules from pre-fetched cache.
# Build the frontend with bun (tsc + vite).
export HOME=$TMPDIR
bun run build
'';
@ -116,17 +126,6 @@
runHook postInstall
'';
nativeBuildInputs = with pkgs; [
cargo-tauri.hook
pkg-config
wrapGAppsHook4
bun
jq
cmake
llvmPackages.libclang
shaderc
];
buildInputs = with pkgs; [
webkitgtk_4_1
gtk3

View file

@ -16,7 +16,8 @@
"format:backend": "cd src-tauri && cargo fmt",
"test:playwright": "playwright test",
"test:playwright:ui": "playwright test --ui",
"check:translations": "bun scripts/check-translations.ts"
"check:translations": "bun scripts/check-translations.ts",
"postinstall": "bun scripts/check-nix-deps.ts"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.16",

81
scripts/check-nix-deps.ts Normal file
View file

@ -0,0 +1,81 @@
// scripts/check-nix-deps.ts — Keep .nix/bun.nix in sync with bun.lock
//
// Handy uses bun2nix to generate per-package Nix fetchurl expressions from
// bun.lock. This replaces the old FOD (Fixed-Output Derivation) approach
// where a single hash covered the entire node_modules — that hash would
// break whenever the bun version in nixpkgs changed, even without any
// dependency updates.
//
// How it works:
// 1. Computes sha256 of bun.lock
// 2. Compares with stored hash in .nix/bun-lock-hash
// 3. If they match — nothing to do (~2ms)
// 4. If they differ — runs `bunx bun2nix` to regenerate .nix/bun.nix
//
// When it runs:
// - Automatically via "postinstall" in package.json — triggers after every
// bun install / bun add / bun remove / bun update
// - Can also be run manually: bun scripts/check-nix-deps.ts
//
// What to commit:
// If the script regenerated .nix/bun.nix, commit it together with bun.lock:
// git add bun.lock .nix/bun.nix .nix/bun-lock-hash
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { join, resolve } from "path";
const root = resolve(import.meta.dirname, "..");
const nixDir = join(root, ".nix");
const lockFile = join(root, "bun.lock");
const hashFile = join(nixDir, "bun-lock-hash");
const nixFile = join(nixDir, "bun.nix");
// No bun.lock — nothing to do
if (!existsSync(lockFile)) process.exit(0);
// Ensure .nix directory exists
mkdirSync(nixDir, { recursive: true });
// Compute sha256 of the current bun.lock
const currentHash = new Bun.CryptoHasher("sha256")
.update(readFileSync(lockFile))
.digest("hex");
// Read the previously stored hash (empty if first run)
const storedHash = existsSync(hashFile)
? readFileSync(hashFile, "utf-8").trim()
: "";
// If hashes match, bun.nix is up to date — nothing to do
if (currentHash === storedHash) process.exit(0);
// bun.lock has changed — regenerate the Nix dependency file
console.log(
`[check-nix-deps] bun.lock has changed, regenerating ${nixFile}...`,
);
const result = Bun.spawnSync(["bunx", "bun2nix", "-o", nixFile], {
cwd: root,
stdio: ["inherit", "inherit", "inherit"],
});
if (result.exitCode !== 0) {
console.warn(
"[check-nix-deps] Warning: bunx bun2nix failed. .nix/bun.nix may be outdated.",
);
console.warn(
"[check-nix-deps] Nix users: run `bunx bun2nix -o .nix/bun.nix` manually.",
);
console.warn(
"[check-nix-deps] Non-Nix users: this is safe to ignore, CI will catch it.",
);
// Exit 0 so that `bun install` is not blocked for non-Nix developers.
// CI validates bun.nix independently.
process.exit(0);
}
writeFileSync(hashFile, currentHash + "\n");
console.log(`[check-nix-deps] Updated ${nixFile}`);
console.log(
"[check-nix-deps] Don't forget to commit: .nix/bun.nix .nix/bun-lock-hash",
);

View file

@ -1,203 +0,0 @@
#!/usr/bin/env bash
# Updates outputHashes and bunDeps hash in flake.nix
# when Cargo or JS dependencies change.
#
# Usage: ./scripts/update-nix-hashes.sh
#
# Handles:
# - Version changes in git dependencies (Cargo.lock → outputHashes)
# - bun.lock changes (→ bunDeps outputHash)
#
# Requires: nix, awk, sed
# Works on: NixOS, Ubuntu/Debian, macOS
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
FLAKE_NIX="$PROJECT_DIR/flake.nix"
CARGO_LOCK="$PROJECT_DIR/src-tauri/Cargo.lock"
FAKE_HASH="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
# Portable sed -i (macOS requires -i '', GNU sed requires just -i)
sedi() {
if sed --version >/dev/null 2>&1; then
sed -i "$@"
else
sed -i '' "$@"
fi
}
if ! command -v nix >/dev/null 2>&1; then
echo "error: nix is not installed. Install it from https://nixos.org/download/" >&2
exit 1
fi
if [ ! -f "$FLAKE_NIX" ]; then
echo "error: flake.nix not found at $FLAKE_NIX" >&2
exit 1
fi
if [ ! -f "$CARGO_LOCK" ]; then
echo "error: Cargo.lock not found at $CARGO_LOCK" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Step 1: Extract git dependency representative keys from Cargo.lock
#
# Cargo.lock format (consecutive lines per package):
# [[package]]
# name = "foo"
# version = "1.2.3"
# source = "git+https://...#commit"
#
# Multiple packages from the same git URL share one outputHash entry keyed
# by the alphabetically first "name-version" from that URL.
# ---------------------------------------------------------------------------
extract_cargo_git_keys() {
awk '
/^name = / { name = substr($3, 2, length($3) - 2) }
/^version = / { version = substr($3, 2, length($3) - 2) }
/^source = "git\+/ {
src = $3
gsub(/^"git\+/, "", src)
sub(/#.*/, "", src)
key = name "-" version
if (!(src in best) || key < best[src])
best[src] = key
}
END { for (s in best) print best[s] }
' "$CARGO_LOCK" | sort
}
# ---------------------------------------------------------------------------
# Step 2: Extract current outputHashes keys from flake.nix
# ---------------------------------------------------------------------------
extract_flake_keys() {
# Portable: no grep -P, use awk instead
sed -n '/outputHashes/,/};/p' "$FLAKE_NIX" \
| awk -F'"' '/sha256-/ { print $2 }' \
| sort
}
# ---------------------------------------------------------------------------
# Step 3: Compare keys and update flake.nix where needed
# ---------------------------------------------------------------------------
update_output_hash_keys() {
local cargo_keys flake_keys
cargo_keys=$(extract_cargo_git_keys)
flake_keys=$(extract_flake_keys)
local changed=0
# For each flake key, check if it still matches a Cargo.lock git dep.
# If the package name matches but version differs -> update.
echo "$flake_keys" | while IFS= read -r fk; do
[ -z "$fk" ] && continue
# Extract the package name prefix (everything before the version)
fname=$(echo "$fk" | sed 's/-[0-9][0-9.]*[-0-9]*$//')
if echo "$cargo_keys" | grep -qxF "$fk"; then
continue
fi
# Key not found in Cargo.lock — look for a replacement with the same name
replacement=$(echo "$cargo_keys" | while IFS= read -r ck; do
cname=$(echo "$ck" | sed 's/-[0-9][0-9.]*[-0-9]*$//')
if [ "$cname" = "$fname" ]; then
echo "$ck"
break
fi
done)
if [ -n "$replacement" ]; then
echo "outputHashes: $fk -> $replacement"
sedi "s|\"$fk\" = \"sha256-[^\"]*\"|\"$replacement\" = \"$FAKE_HASH\"|" "$FLAKE_NIX"
changed=1
else
echo "warning: $fk not found in Cargo.lock git deps and no replacement detected" >&2
echo " This entry may need to be removed or added manually." >&2
fi
done
# Check for new git deps not yet in flake.nix
echo "$cargo_keys" | while IFS= read -r ck; do
[ -z "$ck" ] && continue
if ! echo "$flake_keys" | grep -qxF "$ck" && ! grep -q "\"$ck\"" "$FLAKE_NIX"; then
echo "warning: git dep $ck exists in Cargo.lock but not in flake.nix outputHashes" >&2
echo " You may need to add it manually." >&2
fi
done
return $changed
}
# ---------------------------------------------------------------------------
# Step 4: Iteratively fix hashes by running nix build and parsing errors
# ---------------------------------------------------------------------------
fix_hashes() {
local max_attempts=10
local attempt=0
while [ "$attempt" -lt "$max_attempts" ]; do
attempt=$((attempt + 1))
echo ""
echo "=== nix build attempt $attempt/$max_attempts ==="
local output
if output=$(nix build .#handy 2>&1); then
echo "Build successful!"
return 0
fi
# Check for hash mismatch
if echo "$output" | grep -q "hash mismatch in fixed-output derivation"; then
local specified got
specified=$(echo "$output" | grep "specified:" | awk '{print $2}')
got=$(echo "$output" | grep "got:" | awk '{print $2}')
if [ -n "$specified" ] && [ -n "$got" ]; then
echo "Hash mismatch: $specified -> $got"
sedi "s|$specified|$got|" "$FLAKE_NIX"
continue
fi
fi
# If we can't parse the error, show it and bail out
echo ""
echo "Build failed with an error that cannot be fixed automatically:" >&2
echo "$output" | tail -20 >&2
return 1
done
echo "error: exceeded max attempts ($max_attempts)" >&2
return 1
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
cd "$PROJECT_DIR"
echo "=== Nix flake hash updater ==="
echo ""
echo "Checking outputHashes keys against Cargo.lock..."
if update_output_hash_keys; then
echo "All outputHashes keys are up to date."
fi
echo ""
echo "Running nix build to verify/fix hashes..."
fix_hashes
echo ""
echo "Done. Changes in flake.nix:"
git diff --stat -- flake.nix 2>/dev/null || true