fix(nix): hashless npm deps via importNpmLock (#48883)

The npm workspace pins a single npmDepsHash for fetchNpmDeps. Any change to
package-lock.json that doesn't also refresh that hash breaks the bundled
hermes-tui / hermes-desktop-renderer build for Nix flake consumers, and no
nix CI catches it — the workflow that ran fix-lockfiles was removed in
9eb0bcd6 ("change(ci): rip out nix ci for now").

Fetch the workspace deps with pkgs.importNpmLock instead. It resolves each
package from the lockfile's own integrity hashes, so package-lock.json is the
single source of truth and there is no separate hash to drift.

This also removes:

- the fix-lockfiles checker/refresher and its devShell wiring — it existed
  only to keep npmDepsHash in sync, so it is dead once the hash is gone, and
  its sole CI consumer was already removed in 9eb0bcd6;
- the patchPhase that normalized lockfile trailing newlines — importNpmLock's
  npmConfigHook overwrites the lockfile rather than diffing it, so the
  normalization is unnecessary.

npm-lockfile-fix is retained: importNpmLock requires an integrity-complete
lockfile, which that tool guarantees when the lockfile is regenerated.

Co-authored-by: ak2k <19240940+ak2k@users.noreply.github.com>
This commit is contained in:
Siddharth Balyan 2026-06-19 13:57:12 +05:30 committed by GitHub
parent 9362ce2575
commit 3ca0ef7e3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 19 additions and 224 deletions

View file

@ -12,7 +12,6 @@
let
packages = builtins.attrValues self'.packages;
hermesNpmLib = self'.packages.default.passthru.hermesNpmLib;
fixLockfilesExe = pkgs.lib.getExe self'.packages.fix-lockfiles;
# Collect all packageJsonPath values from npm workspace packages.
npmPackageJsonPaths = builtins.filter (p: p != null) (
@ -33,7 +32,7 @@
shellHook = ''
echo "Hermes Agent dev shell"
${combinedNonNpm}
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths fixLockfilesExe}
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths}
echo "Ready. Run 'hermes' to start."
'';
};

View file

@ -2,8 +2,7 @@
#
# All npm packages in this repo are workspace members sharing a single
# root package-lock.json. mkNpmPassthru provides the shared src, npmDeps,
# npmRoot, and npmDepsFetcherVersion so individual .nix files don't
# duplicate them. One hash to rule them all.
# npmRoot, and npmConfigHook so individual .nix files don't duplicate them.
#
# mkNpmPassthru returns packageJsonPath (e.g. "ui-tui/package.json")
# instead of a per-package devShellHook. The root devshell hook
@ -19,28 +18,19 @@ let
# The workspace root — where the single package-lock.json lives.
src = ../.;
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-kbjJksq7limRIYqP3DwI+GNgCXkG96tXcsQqmuEedxo=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;
fetcherVersion = 2;
hash = npmDepsHash;
};
# npm dependencies for the workspace, shared by all members. importNpmLock
# resolves each package from the lockfile's own `integrity` hashes, so the
# lockfile is the single source of truth — no separate dependency hash to
# keep in sync with it.
npmDeps = pkgs.importNpmLock.importNpmLock { npmRoot = src; };
in
{
# Returns a buildNpmPackage-compatible attrs set that provides:
# src, npmDeps, npmRoot, npmDepsFetcherVersion
# patchPhase — ensures root lockfile has exactly one trailing newline
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
# passthru.packageJsonPath — relative path to this workspace's package.json
# nodejs — fixed nodejs version for all packages we use in the repo
#
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
# npm-deps cache lockfile. fetchNpmDeps preserves whatever trailing
# newlines the lockfile has. The patchPhase normalizes to exactly one
# trailing newline so both sides always match.
# src, npmDeps, npmRoot — workspace source + importNpmLock dep set
# npmConfigHook — importNpmLock's offline `npm install` hook
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
# passthru.packageJsonPath — relative path to this workspace's package.json
# nodejs — fixed nodejs version for all packages we use in the repo
#
# Usage:
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
@ -62,35 +52,15 @@ in
in
{
inherit src npmDeps nodejs;
# importNpmLock's hook installs the rewritten lockfile (every `resolved`
# rewritten to a /nix/store file: path) into the unpacked workspace and
# runs `npm install` offline, so every workspace member's dependencies
# resolve without network access.
npmConfigHook = pkgs.importNpmLock.npmConfigHook;
npmRoot = ".";
npmDepsFetcherVersion = 2;
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
patchPhase = ''
runHook prePatch
# Normalize trailing newlines on the root lockfile so source and
# npm-deps always match, regardless of what fetchNpmDeps preserves.
sed -i -z 's/\\n*$/\\n/' package-lock.json
# Make npmConfigHook's byte-for-byte diff newline-agnostic by
# replacing its hardcoded /nix/store/.../diff with a wrapper that
# normalizes trailing newlines on both sides before comparing.
mkdir -p "$TMPDIR/bin"
cat > "$TMPDIR/bin/diff" << DIFFWRAP
#!/bin/sh
f1=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$1" > "\\$f1"
f2=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$2" > "\\$f2"
${pkgs.diffutils}/bin/diff "\\$f1" "\\$f2" && rc=0 || rc=\\$?
rm -f "\\$f1" "\\$f2"
exit \\$rc
DIFFWRAP
chmod +x "$TMPDIR/bin/diff"
export PATH="$TMPDIR/bin:$PATH"
runHook postPatch
'';
nativeBuildInputs = [
(pkgs.writeShellScriptBin "update_${attr}_lockfile" ''
set -euox pipefail
@ -104,7 +74,6 @@ in
CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
# Hash lives in lib.nix — just rebuild to verify.
nix build .#${attr}
echo "Lockfile updated and build verified for .#${attr}"
'')
@ -120,12 +89,9 @@ in
# Takes a list of package.json relative paths (from mkNpmPassthru .passthru.packageJsonPath),
# stamps all of them, and if any changed:
# 1. Runs `npm i --package-lock-only` from root to update the lockfile
# 2. If the lockfile changed, runs `npm ci` + fix-lockfiles
#
# fixLockfilesExe: absolute path to the fix-lockfiles binary
# (from pkgs.lib.getExe self'.packages.fix-lockfiles in devShell.nix).
# 2. If the lockfile changed, runs `npm ci`
mkNpmDevShellHook =
packageJsonPaths: fixLockfilesExe:
packageJsonPaths:
pkgs.writeShellScript "npm-dev-hook" ''
REPO_ROOT=$(git rev-parse --show-toplevel)
@ -158,172 +124,4 @@ in
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
fi
'';
# Build `fix-lockfiles` bin that checks/updates the single npmDepsHash
# fix-lockfiles --check # exit 1 if any hash is stale
# fix-lockfiles --apply # rewrite stale hashes in place
# fix-lockfiles # alias of --apply
# Writes machine-readable fields (stale, changed, report) to $GITHUB_OUTPUT
# when set, so CI workflows can post a sticky PR comment directly.
mkFixLockfiles =
{
attr, # flake package attr for fallback verification build, e.g. "tui"
}:
pkgs.writeShellScriptBin "fix-lockfiles" ''
set -uox pipefail
MODE="''${1:---apply}"
case "$MODE" in
--check|--apply) ;;
-h|--help)
echo "usage: fix-lockfiles [--check|--apply]"
exit 0 ;;
*)
echo "usage: fix-lockfiles [--check|--apply]" >&2
exit 2 ;;
esac
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
# When running in GH Actions, emit Markdown links in the report pointing
# at the offending line of the nix file (and the lockfile) at the exact
# commit that was checked. LINK_SHA should be set by the workflow to the
# PR head SHA; falls back to GITHUB_SHA (which on pull_request is the
# test-merge commit, still browseable).
LINK_SERVER="''${GITHUB_SERVER_URL:-https://github.com}"
LINK_REPO="''${GITHUB_REPOSITORY:-}"
LINK_SHA="''${LINK_SHA:-''${GITHUB_SHA:-}}"
STALE=0
FIXED=0
REPORT=""
# All workspace packages share the root package-lock.json, so
# we only need to check the hash once.
LOCK_FILE="package-lock.json"
LIB_FILE="nix/lib.nix"
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
if [ -z "$NEW_HASH" ]; then
echo "prefetch-npm-deps failed, falling back to nix build" >&2
OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "ok (via nix build)"
exit 0
fi
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -z "$NEW_HASH" ]; then
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo "skipped (transient cache failure see primary nix build for real status)" >&2
echo "$OUTPUT" | tail -8 >&2
exit 0
fi
echo "build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2
exit 1
fi
fi
OLD_HASH=$(grep -oE 'npmDepsHash = "sha256-[^"]+"' "$LIB_FILE" | head -1 \
| sed -E 's/npmDepsHash = "(.*)"/\1/')
# prefetch-npm-deps says the hash already matches — but it only hashes the
# lockfile *contents* and can disagree with fetchNpmDeps + npmConfigHook,
# which validate the full source lockfile against the realized deps cache.
# Trusting prefetch alone produced false "ok" results while the actual
# build was broken (e.g. lockfile engines/os/cpu fields the pinned nixpkgs
# strips from the deps cache, tripping npmConfigHook). So when prefetch
# claims the hash is current, confirm with a real consumer build before
# believing it.
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
if VERIFY_OUT=$(nix build ".#${attr}" --no-link --print-build-logs 2>&1); then
echo "ok"
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
{ echo "stale=false"; echo "changed=false"; } >> "$GITHUB_OUTPUT"
fi
exit 0
fi
# Build failed despite a matching hash. A fixed-output 'got:' means
# prefetch genuinely disagreed with fetchNpmDeps — adopt the real hash
# and fall through to the stale-handling path below.
CORRECT_HASH=$(echo "$VERIFY_OUT" | awk '/got:/ {print $2; exit}')
if [ -n "$CORRECT_HASH" ]; then
echo "prefetch-npm-deps reported current ($OLD_HASH) but fetchNpmDeps wants $CORRECT_HASH" >&2
NEW_HASH="$CORRECT_HASH"
elif echo "$VERIFY_OUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo "skipped (transient cache failure see primary nix build for real status)" >&2
echo "$VERIFY_OUT" | tail -8 >&2
exit 0
else
# Not a stale-hash problem — surface it honestly instead of "ok".
echo "::error::nix build .#${attr} failed and it is NOT a stale npmDepsHash (no 'got:' hash in output)." >&2
echo "The committed lockfile may be incompatible with the pinned nixpkgs" >&2
echo "(e.g. engines/os/cpu fields that prefetch-npm-deps strips from the" >&2
echo "deps cache, tripping npmConfigHook). fix-lockfiles cannot repair this." >&2
echo "$VERIFY_OUT" | tail -40 >&2
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
{ echo "stale=false"; echo "changed=false"; } >> "$GITHUB_OUTPUT"
fi
exit 1
fi
fi
HASH_LINE=$(grep -n 'npmDepsHash = "sha256-' "$LIB_FILE" | head -1 | cut -d: -f1)
echo "stale: $LIB_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
STALE=1
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
LIB_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LIB_FILE#L$HASH_LINE"
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
REPORT="- [\`$LIB_FILE:$HASH_LINE\`]($LIB_URL): \`$OLD_HASH\` \`$NEW_HASH\` lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\\n'
else
REPORT="- \`$LIB_FILE:$HASH_LINE\`: \`$OLD_HASH\` \`$NEW_HASH\`"$'\\n'
fi
if [ "$MODE" = "--apply" ]; then
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$NEW_HASH\";|" "$LIB_FILE"
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>/dev/null; then
# prefetch-npm-deps may disagree with fetchNpmDeps (it hashes
# the lockfile contents, not the full source tree). Extract the
# correct hash from the nix build error and retry.
RETRY_OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
CORRECT_HASH=$(echo "$RETRY_OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -n "$CORRECT_HASH" ]; then
echo "prefetch-npm-deps gave $NEW_HASH but nix wants $CORRECT_HASH retrying" >&2
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$CORRECT_HASH\";|" "$LIB_FILE"
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs; then
echo "verification build failed after hash retry" >&2
exit 1
fi
NEW_HASH="$CORRECT_HASH"
else
echo "verification build failed after hash update" >&2
exit 1
fi
fi
FIXED=1
echo "fixed"
fi
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
{
[ "$STALE" -eq 1 ] && echo "stale=true" || echo "stale=false"
[ "$FIXED" -eq 1 ] && echo "changed=true" || echo "changed=false"
if [ -n "$REPORT" ]; then
echo "report<<REPORT_EOF"
printf "%s" "$REPORT"
echo "REPORT_EOF"
fi
} >> "$GITHUB_OUTPUT"
fi
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
echo
echo "Stale lockfile hash detected. Run:"
echo " nix run .#fix-lockfiles"
exit 1
fi
exit 0
'';
}

View file

@ -50,8 +50,6 @@
tui = hermesAgent.hermesTui;
web = hermesAgent.hermesWeb;
desktop = hermesAgent.hermesDesktop;
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles { attr = "tui"; };
};
};
}