diff --git a/.github/workflows/nix-lockfile-check.yml b/.github/workflows/nix-lockfile-check.yml new file mode 100644 index 000000000..3e69feb1f --- /dev/null +++ b/.github/workflows/nix-lockfile-check.yml @@ -0,0 +1,64 @@ +name: Nix Lockfile Check + +on: + pull_request: + paths: + - 'ui-tui/package.json' + - 'ui-tui/package-lock.json' + - 'web/package.json' + - 'web/package-lock.json' + - 'nix/tui.nix' + - 'nix/web.nix' + - 'nix/lib.nix' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: nix-lockfile-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: nixbuild/nix-quick-install-action@63ca48f939ee3b8d835f4126562537df0fee5b91 # v30 + + - name: Check lockfile hashes + id: check + continue-on-error: true + run: nix run .#fix-lockfiles -- --check + + - name: Post sticky PR comment (stale) + if: steps.check.outputs.stale == 'true' && github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 + with: + header: nix-lockfile-check + message: | + ### ⚠️ npm lockfile hash out of date + + The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`: + + ${{ steps.check.outputs.report }} + + #### Apply the fix + + - [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch + - Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`) + - Or locally: `nix run .#fix-lockfiles -- --apply` and commit the diff + + - name: Clear sticky PR comment (resolved) + if: steps.check.outputs.stale == 'false' && github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 + with: + header: nix-lockfile-check + delete: true + + - name: Fail if stale + if: steps.check.outputs.stale == 'true' + run: exit 1 diff --git a/.github/workflows/nix-lockfile-fix.yml b/.github/workflows/nix-lockfile-fix.yml new file mode 100644 index 000000000..a64d45510 --- /dev/null +++ b/.github/workflows/nix-lockfile-fix.yml @@ -0,0 +1,126 @@ +name: Nix Lockfile Fix + +on: + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to fix (leave empty to run on the selected branch)' + required: false + type: string + issue_comment: + types: [edited] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: nix-lockfile-fix-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }} + cancel-in-progress: false + +jobs: + fix: + # Run on manual dispatch OR when a task-list checkbox in the sticky + # lockfile-check comment flips from `[ ]` to `[x]`. + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' + && github.event.issue.pull_request != null + && contains(github.event.comment.body, '[x] **Apply lockfile fix**') + && !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**')) + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Authorize & resolve PR + id: resolve + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + // 1. Verify the actor has write access — applies to both checkbox + // clicks and manual dispatch. + const { data: perm } = + await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor, + }); + if (!['admin', 'write', 'maintain'].includes(perm.permission)) { + core.setFailed( + `${context.actor} lacks write access (has: ${perm.permission})` + ); + return; + } + + // 2. Resolve which ref to check out. + let prNumber = ''; + if (context.eventName === 'issue_comment') { + prNumber = String(context.payload.issue.number); + } else if (context.eventName === 'workflow_dispatch') { + prNumber = context.payload.inputs.pr_number || ''; + } + + if (!prNumber) { + core.setOutput('ref', context.ref.replace(/^refs\/heads\//, '')); + core.setOutput('repo', context.repo.repo); + core.setOutput('owner', context.repo.owner); + core.setOutput('pr', ''); + return; + } + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: Number(prNumber), + }); + core.setOutput('ref', pr.head.ref); + core.setOutput('repo', pr.head.repo.name); + core.setOutput('owner', pr.head.repo.owner.login); + core.setOutput('pr', String(pr.number)); + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }} + ref: ${{ steps.resolve.outputs.ref }} + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - uses: nixbuild/nix-quick-install-action@63ca48f939ee3b8d835f4126562537df0fee5b91 # v30 + + - name: Apply lockfile hashes + id: apply + run: nix run .#fix-lockfiles -- --apply + + - name: Commit & push + if: steps.apply.outputs.changed == 'true' + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add nix/tui.nix nix/web.nix + git commit -m "fix(nix): refresh npm lockfile hashes" + git push + + - name: Comment on PR (applied) + if: steps.apply.outputs.changed == 'true' && steps.resolve.outputs.pr != '' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number('${{ steps.resolve.outputs.pr }}'), + body: 'Pushed a commit refreshing the npm lockfile hashes.', + }); + + - name: Comment on PR (already current) + if: steps.apply.outputs.changed == 'false' && steps.resolve.outputs.pr != '' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number('${{ steps.resolve.outputs.pr }}'), + body: 'npm lockfile hashes are already current — nothing to commit.', +z }); diff --git a/nix/devShell.nix b/nix/devShell.nix index 63edc59cf..d0d56e40b 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -7,7 +7,8 @@ let hermes-agent = inputs.self.packages.${system}.default; hermes-tui = inputs.self.packages.${system}.tui; - packages = [ hermes-agent hermes-tui ]; + hermes-web = inputs.self.packages.${system}.web; + packages = [ hermes-agent hermes-tui hermes-web ]; in { devShells.default = pkgs.mkShell { inputsFrom = packages; diff --git a/nix/lib.nix b/nix/lib.nix new file mode 100644 index 000000000..f2e1c8291 --- /dev/null +++ b/nix/lib.nix @@ -0,0 +1,151 @@ +# nix/lib.nix — Shared helpers for nix stuff +{ pkgs, npm-lockfile-fix }: +{ + # Shell script that refreshes node_modules, fixes the lockfile, and + # rewrites the `hash = "sha256-..."` line in the given nix file so + # fetchNpmDeps picks up the new package-lock.json. + mkUpdateLockfileScript = + { + name, # script binary name, e.g. "update_tui_lockfile" + folder, # repo-relative folder with package.json, e.g. "ui-tui" + nixFile, # repo-relative nix file with the hash line, e.g. "nix/tui.nix" + attr, # flake package attr to build to cause the failure, e.g. "tui" + }: + pkgs.writeShellScriptBin name '' + set -euox pipefail + + REPO_ROOT=$(git rev-parse --show-toplevel) + + cd "$REPO_ROOT/${folder}" + rm -rf node_modules/ + npm cache clean --force + CI=true npm install + ${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json + + NIX_FILE="$REPO_ROOT/${nixFile}" + sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE + NIX_OUTPUT=$(nix build .#${attr} 2>&1 || true) + NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}') + echo got new hash $NEW_HASH + sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE + nix build .#${attr} + echo "Updated npm hash in $NIX_FILE to $NEW_HASH" + ''; + + # devShell bootstrap snippet: runs `npm install` in the target folder when + # package.json or package-lock.json has changed since the last install. + # Hashing happens in bash (not nix eval), and the post-install stamp is + # recomputed so a lockfile that npm rewrites during install still matches. + mkNpmDevShellHook = + { + name, # project-unique stampfile name, e.g. "hermes-tui" + folder, # repo-relative folder with package.json + package-lock.json + }: + '' + _hermes_npm_stamp() { + sha256sum "${folder}/package.json" "${folder}/package-lock.json" \ + 2>/dev/null | sha256sum | awk '{print $1}' + } + STAMP=".nix-stamps/${name}" + STAMP_VALUE="$(_hermes_npm_stamp)" + if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then + echo "${name}: installing npm dependencies..." + ( cd ${folder} && CI=true npm install --silent --no-fund --no-audit 2>/dev/null ) + mkdir -p .nix-stamps + _hermes_npm_stamp > "$STAMP" + fi + unset -f _hermes_npm_stamp + ''; + + # Aggregate `fix-lockfiles` bin from a list of packages carrying + # passthru.npmLockfile = { attr; folder; nixFile; }; + # Invocations: + # fix-lockfiles --check # exit 1 if any hash is stale + # fix-lockfiles --apply # rewrite stale hashes in place + # Writes machine-readable fields (stale, changed, report) to $GITHUB_OUTPUT + # when set, so CI workflows can post a sticky PR comment directly. + mkFixLockfiles = + { + packages, # list of packages with passthru.npmLockfile + }: + let + entries = map (p: p.passthru.npmLockfile) packages; + entryArgs = pkgs.lib.concatMapStringsSep " " ( + e: "\"${e.attr}:${e.folder}:${e.nixFile}\"" + ) entries; + in + pkgs.writeShellScriptBin "fix-lockfiles" '' + set -uo pipefail + MODE="''${1:---check}" + 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 + + ENTRIES=(${entryArgs}) + + REPO_ROOT="$(git rev-parse --show-toplevel)" + cd "$REPO_ROOT" + + STALE=0 + FIXED=0 + REPORT="" + + for entry in "''${ENTRIES[@]}"; do + IFS=":" read -r ATTR FOLDER NIX_FILE <<< "$entry" + echo "==> .#$ATTR ($FOLDER -> $NIX_FILE)" + OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1) + STATUS=$? + if [ "$STATUS" -eq 0 ]; then + echo " ok" + continue + fi + + NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}') + if [ -z "$NEW_HASH" ]; then + echo " build failed with no hash mismatch:" >&2 + echo "$OUTPUT" | tail -40 >&2 + exit 1 + fi + + OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \ + | sed -E 's/hash = "(.*)"/\1/') + echo " stale: $OLD_HASH -> $NEW_HASH" + STALE=1 + REPORT+="- \`$NIX_FILE\` (\`.#$ATTR\`): \`$OLD_HASH\` -> \`$NEW_HASH\`"$'\n' + + if [ "$MODE" = "--apply" ]; then + sed -i "s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_HASH\";|" "$NIX_FILE" + nix build ".#$ATTR.npmDeps" --no-link --print-build-logs + FIXED=1 + echo " fixed" + fi + done + + 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<> "$GITHUB_OUTPUT" + fi + + if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then + echo + echo "Stale lockfile hashes detected. Run:" + echo " nix run .#fix-lockfiles -- --apply" + exit 1 + fi + + exit 0 + ''; +} diff --git a/nix/packages.nix b/nix/packages.nix index 912be7843..721546851 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -8,10 +8,14 @@ inherit (inputs) uv2nix pyproject-nix pyproject-build-systems; }; - hermesTui = pkgs.callPackage ./tui.nix { + hermesNpmLib = pkgs.callPackage ./lib.nix { npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default; }; + hermesTui = pkgs.callPackage ./tui.nix { + inherit hermesNpmLib; + }; + # Import bundled skills, excluding runtime caches bundledSkills = pkgs.lib.cleanSourceWith { src = ../skills; @@ -19,7 +23,7 @@ }; hermesWeb = pkgs.callPackage ./web.nix { - npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default; + inherit hermesNpmLib; }; runtimeDeps = with pkgs; [ @@ -111,6 +115,10 @@ tui = hermesTui; web = hermesWeb; + + fix-lockfiles = hermesNpmLib.mkFixLockfiles { + packages = [ hermesTui hermesWeb ]; + }; }; }; } diff --git a/nix/tui.nix b/nix/tui.nix index 7303edecb..66658bb42 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -1,16 +1,14 @@ # nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled -{ pkgs, npm-lockfile-fix, ... }: +{ pkgs, hermesNpmLib, ... }: let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-mG3vpgGi4ljt4X3XIf3I/5mIcm+rVTUAmx2DQ6YVA90="; + hash = "sha256-BlxkTyn1x7ZQcj7pcMB5y5C2AyToT/CzxmtacTfEXmY="; }; packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json")); version = packageJson.version; - - npmLockHash = builtins.hashString "sha256" (builtins.readFile ../ui-tui/package-lock.json); in pkgs.buildNpmPackage { pname = "hermes-tui"; @@ -18,6 +16,12 @@ pkgs.buildNpmPackage { doCheck = false; + patchPhase = '' + runHook prePatch + sed -i -z 's/\n$//' package-lock.json + runHook postPatch + ''; + installPhase = '' runHook preInstall @@ -39,39 +43,23 @@ pkgs.buildNpmPackage { ''; nativeBuildInputs = [ - (pkgs.writeShellScriptBin "update_tui_lockfile" '' - set -euox pipefail - - # get root of repo - REPO_ROOT=$(git rev-parse --show-toplevel) - - # cd into ui-tui and reinstall - cd "$REPO_ROOT/ui-tui" - rm -rf node_modules/ - npm cache clean --force - CI=true npm install # ci env var to suppress annoying unicode install banner lag - ${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json - - NIX_FILE="$REPO_ROOT/nix/tui.nix" - # compute the new hash - sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE - NIX_OUTPUT=$(nix build .#tui 2>&1 || true) - NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}') - echo got new hash $NEW_HASH - sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE - nix build .#tui - echo "Updated npm hash in $NIX_FILE to $NEW_HASH" - '') + (hermesNpmLib.mkUpdateLockfileScript { + name = "update_tui_lockfile"; + folder = "ui-tui"; + nixFile = "nix/tui.nix"; + attr = "tui"; + }) ]; - passthru.devShellHook = '' - STAMP=".nix-stamps/hermes-tui" - STAMP_VALUE="${npmLockHash}" - if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then - echo "hermes-tui: installing npm dependencies..." - cd ui-tui && CI=true npm install --silent --no-fund --no-audit 2>/dev/null && cd .. - mkdir -p .nix-stamps - echo "$STAMP_VALUE" > "$STAMP" - fi - ''; + passthru = { + devShellHook = hermesNpmLib.mkNpmDevShellHook { + name = "hermes-tui"; + folder = "ui-tui"; + }; + npmLockfile = { + attr = "tui"; + folder = "ui-tui"; + nixFile = "nix/tui.nix"; + }; + }; } diff --git a/nix/web.nix b/nix/web.nix index 247889753..3926ed9ed 100644 --- a/nix/web.nix +++ b/nix/web.nix @@ -1,13 +1,11 @@ # nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build -{ pkgs, npm-lockfile-fix, ... }: +{ pkgs, hermesNpmLib, ... }: let src = ../web; npmDeps = pkgs.fetchNpmDeps { inherit src; hash = "sha256-Y0pOzdFG8BLjfvCLmsvqYpjxFjAQabXp1i7X9W/cCU4="; }; - - npmLockHash = builtins.hashString "sha256" (builtins.readFile ../web/package-lock.json); in pkgs.buildNpmPackage { pname = "hermes-web"; @@ -28,36 +26,23 @@ pkgs.buildNpmPackage { ''; nativeBuildInputs = [ - (pkgs.writeShellScriptBin "update_web_lockfile" '' - set -euox pipefail - - REPO_ROOT=$(git rev-parse --show-toplevel) - - cd "$REPO_ROOT/web" - rm -rf node_modules/ - npm cache clean --force - CI=true npm install - ${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json - - NIX_FILE="$REPO_ROOT/nix/web.nix" - sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE - NIX_OUTPUT=$(nix build .#web 2>&1 || true) - NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}') - echo got new hash $NEW_HASH - sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE - nix build .#web - echo "Updated npm hash in $NIX_FILE to $NEW_HASH" - '') + (hermesNpmLib.mkUpdateLockfileScript { + name = "update_web_lockfile"; + folder = "web"; + nixFile = "nix/web.nix"; + attr = "web"; + }) ]; - passthru.devShellHook = '' - STAMP=".nix-stamps/hermes-web" - STAMP_VALUE="${npmLockHash}" - if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then - echo "hermes-web: installing npm dependencies..." - cd web && CI=true npm install --silent --no-fund --no-audit 2>/dev/null && cd .. - mkdir -p .nix-stamps - echo "$STAMP_VALUE" > "$STAMP" - fi - ''; + passthru = { + devShellHook = hermesNpmLib.mkNpmDevShellHook { + name = "hermes-web"; + folder = "web"; + }; + npmLockfile = { + attr = "web"; + folder = "web"; + nixFile = "nix/web.nix"; + }; + }; }