From 77f83456439160e99334809a1aebe16ced65a6ee Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:15:03 -0400 Subject: [PATCH 1/2] fix(nix/desktop): compile node-pty against electron.headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hermes-desktop's renderer compiled node-pty against a hand-pinned fetchurl of Electron's node headers, whose URL is templated off electron.version but whose sha256 was hardcoded. Every nixpkgs electron bump swaps the tarball out from under that pinned hash, failing the fixed-output derivation at build time — and only there, never in the networked Docker/npm ci paths. nixpkgs recently resolved electron to 41.9.1, breaking the build for every Nix consumer. Use nixpkgs' own electron.headers derivation instead. It is version-locked to the electron package, so it tracks every bump automatically with no hand-pinned hash to go stale, needs no network, and is already the --nodedir layout (so the manual fetch + tar extraction both go away). Same pattern as signal-desktop, github-desktop, session-desktop, rstudio in nixpkgs. --- nix/desktop.nix | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/nix/desktop.nix b/nix/desktop.nix index cb18a4f0491b..bd0c0ec7fb65 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -29,11 +29,6 @@ let packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json")); version = packageJson.version; - electronHeaders = pkgs.fetchurl { - url = "https://artifacts.electronjs.org/headers/dist/v${electron.version}/node-v${electron.version}-headers.tar.gz"; - sha256 = "sha256-zi/QMwRZ0+FwE9XTE+DiSIeJXAwxmLKEaBWD5W3pMOI="; - }; - # node-pty ships no Electron-tagged prebuild we can trust to match this # exact nixpkgs electron version, so it's always compiled from source # against Electron's own headers (not whatever Node ran `npm`). @@ -81,17 +76,16 @@ let node scripts/bundle-electron-main.mjs # Compile node-pty against Electron's actual ABI (the nixpkgs - # `electron` we ship). Headers come from a pinned fetchurl input - # since the sandbox has no network here, so node-gyp's - # normal --disturl download path can't run. - mkdir -p "$TMPDIR/electron-headers" - tar -xzf ${electronHeaders} -C "$TMPDIR/electron-headers" --strip-components=1 - + # `electron` we ship). Headers come from nixpkgs' own + # `electron.headers` derivation — version-locked to `electron`, so it + # tracks every bump automatically with no hand-pinned hash to go + # stale, and needs no network (node-gyp's --disturl path can't run in + # the sandbox). `electron.headers` is already the --nodedir layout. npm rebuild node-pty \ --build-from-source \ --runtime=electron \ --target=${electron.version} \ - --nodedir="$TMPDIR/electron-headers" \ + --nodedir=${electron.headers} \ --disturl="" \ --offline From 08d4cb068098e9fe1a16d7457ae8c3216dda5046 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:15:03 -0400 Subject: [PATCH 2/2] ci(nix): offline nix-build guard for .#web/.#tui/.#desktop No workflow built the flake offline before this (the nix-setup composite existed but nothing called it), so two classes of regression could merge green while breaking downstream Nix consumers: a package-lock.json that an incremental npm install pruned platform-optional packages from (fails the offline importNpmLock build of .#web/.#tui), and a desktop native-compile break like the electron.headers hash this PR also fixes. Add a nix-build workflow that runs `nix build .#web .#tui .#desktop` offline, gated on a new `nix` lane (nix/, flake.nix, flake.lock) OR the frontend lane (package-lock.json / web / ui-tui / apps), and require it in the all-checks gate. --- .github/actions/detect-changes/action.yml | 5 ++- .github/workflows/ci.yml | 12 ++++++ .github/workflows/nix-build.yml | 45 +++++++++++++++++++++++ scripts/ci/classify_changes.py | 7 ++++ tests/ci/test_classify_changes.py | 10 ++++- 5 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/nix-build.yml diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index 145d742d5b12..6c1822a5b857 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -1,7 +1,7 @@ name: Detect affected areas description: >- Classify a PR's changed files into CI work lanes (python, frontend, site, - scan, deps, mcp_catalog) so the orchestrator can conditionally call only + scan, deps, nix, mcp_catalog) so the orchestrator can conditionally call only the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch events and fail open (everything "true") when the diff cannot be computed. @@ -33,6 +33,9 @@ outputs: npm_lock: description: Post/update the semantic package-lock.json diff PR comment. value: ${{ steps.classify.outputs.npm_lock }} + nix: + description: Build the Nix flake outputs (.#web, .#tui, .#desktop) offline. + value: ${{ steps.classify.outputs.nix }} mcp_catalog: description: Require MCP catalog security review label. value: ${{ steps.classify.outputs.mcp_catalog }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3441d7bb6db3..3548e6deed9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,7 @@ jobs: scan: ${{ steps.classify.outputs.scan }} deps: ${{ steps.classify.outputs.deps }} npm_lock: ${{ steps.classify.outputs.npm_lock }} + nix: ${{ steps.classify.outputs.nix }} docker_meta: ${{ steps.classify.outputs.docker_meta }} mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }} ci_review: ${{ steps.classify.outputs.ci_review }} @@ -166,6 +167,16 @@ jobs: name: OSV scan uses: ./.github/workflows/osv-scanner.yml + # Offline Nix build of the flake outputs (.#web, .#tui, .#desktop). Runs on + # both the inputs the build depends on: the `frontend` lane (package-lock.json + # — the file an incremental `npm install` prunes platform-optional packages + # from — plus the web/ui-tui/apps sources) and the `nix` lane (nix/, flake.nix, + # flake.lock). See .github/workflows/nix-build.yml for the full rationale. + nix-build: + needs: detect + if: needs.detect.outputs.frontend == 'true' || needs.detect.outputs.nix == 'true' + uses: ./.github/workflows/nix-build.yml + # ───────────────────────────────────────────────────────────────────── # Live-updating PR review comment. # @@ -270,6 +281,7 @@ jobs: - supply-chain - review-labels - osv-scanner + - nix-build # comment-live is a polling job — it doesn't block the gate. # we don't require docker to pass rn because it's so slow lol # - docker diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml new file mode 100644 index 000000000000..5ea6ddf0878d --- /dev/null +++ b/.github/workflows/nix-build.yml @@ -0,0 +1,45 @@ +# .github/workflows/nix-build.yml +# +# Offline Nix builds of the flake outputs (.#web, .#tui, .#desktop). Guards two +# classes of regression that merge green under the networked Docker/`npm ci` +# paths but break downstream Nix consumers: +# +# 1. Pruned lockfile. `.#web` and `.#tui` build via `importNpmLock`, which +# vendors *only* what package-lock.json enumerates — with no network during +# the build. So a lockfile missing a platform-specific optional package +# (e.g. an incremental `npm install` on macOS pruning the +# `@esbuild/` natives) fails here, even though the Docker image +# build and `npm ci` (both networked) succeed by fetching it at build time. +# +# 2. Stale Electron-headers hash. `.#desktop`'s renderer compiles node-pty +# against Electron's node headers. Those now come from nixpkgs' +# `electron.headers` (version-locked to `electron`); this job builds +# `.#desktop` so that if anyone reintroduces a hand-pinned headers fetch, +# the fixed-output hash it desyncs on the next nixpkgs `electron` bump is +# caught here — at build time — instead of only in downstream consumers. +# +# No workflow built the flake offline before this job (the `nix-setup` composite +# existed but nothing called it), so both classes could merge green. +# +# Scope: `.#web` / `.#tui` are the lightweight npm bundles; both vendor from the +# root package-lock.json via importNpmLock, so building them exercises the exact +# offline path a prune breaks. `.#desktop` is heavier — its wrapper pulls the +# full uv2nix Python venv — but Cachix keeps that cheap on cache-hit while the +# renderer's native compile reliably exercises class (2). +name: Nix build + +on: + workflow_call: + +jobs: + nix-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/nix-setup + # retry covers transient registry blips while importNpmLock fetches the + # npm tarballs as fixed-output derivations (same rationale as the npm ci + # retry in typecheck.yml). + - uses: ./.github/actions/retry + with: + command: nix build -L --no-link .#web .#tui .#desktop diff --git a/scripts/ci/classify_changes.py b/scripts/ci/classify_changes.py index fd0b870281f0..27e686ad76d9 100644 --- a/scripts/ci/classify_changes.py +++ b/scripts/ci/classify_changes.py @@ -15,6 +15,7 @@ * ``scan`` — supply-chain scan (Python files, .pth, setup hooks). * ``deps`` — pyproject.toml dependency bounds check. * ``npm_lock`` — semantic package-lock.json diff PR comment. +* ``nix`` — offline ``nix build .#web .#tui .#desktop``. * ``mcp_catalog`` — bundled MCP catalog / installer review. Docker is not a lane — it builds on push-to-main and release only, @@ -40,6 +41,10 @@ _ROOT_NPM = {"package.json", "package-lock.json"} # shifts every package's tree _DOCKER_META = ("docker/", ".hadolint.yml", "Dockerfile") # docker setup _SITE = ("website/", "skills/", "optional-skills/") # docs site + skill pages +# Nix flake build inputs — gate the offline `nix build .#web .#tui .#desktop` +# lane. The importNpmLock build also breaks on a pruned package-lock.json +# (caught via the frontend lane), so nix-build runs on `frontend OR nix`. +_NIX = ("nix/", "flake.nix", "flake.lock") # Prose/frontend trees that can't touch Python. skills/ is excluded on purpose. _PY_SKIP = ("docs/", "website/") + _FRONTEND @@ -106,6 +111,7 @@ def classify(files: list[str]) -> dict[str, bool]: "scan": any(_is_scan(f) for f in files), "deps": any(f == "pyproject.toml" for f in files), "npm_lock": any(f.split("/")[-1] == "package-lock.json" for f in files), + "nix": any(f.startswith(_NIX) for f in files), "mcp_catalog": any(_is_mcp_catalog(f) for f in files), "ci_review": any(_is_ci_review(f) for f in files), } @@ -118,6 +124,7 @@ def classify(files: list[str]) -> dict[str, bool]: ret["deps"] = True ret["npm_lock"] = True ret["ci_review"] = True + ret["nix"] = True # explicitly skip mcp catalog here. it's not needed unless those files are modified. return ret diff --git a/tests/ci/test_classify_changes.py b/tests/ci/test_classify_changes.py index c9b0818fb484..217b7a89f760 100644 --- a/tests/ci/test_classify_changes.py +++ b/tests/ci/test_classify_changes.py @@ -29,12 +29,13 @@ "scan": True, "deps": True, "npm_lock": True, + "nix": True, "mcp_catalog": False, "ci_review": True, } -def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm_lock=False, mcp_catalog=False, docker_meta=False, ci_review=False) -> dict[str, bool]: +def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm_lock=False, nix=False, mcp_catalog=False, docker_meta=False, ci_review=False) -> dict[str, bool]: return { "python": python, "frontend": frontend, @@ -43,6 +44,7 @@ def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm "scan": scan, "deps": deps, "npm_lock": npm_lock, + "nix": nix, "mcp_catalog": mcp_catalog, "ci_review": ci_review, } @@ -56,8 +58,14 @@ def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm "ts package → frontend": (["apps/desktop/src/app.tsx"], _lanes(frontend=True)), "ui-tui → frontend": (["ui-tui/src/entry.ts"], _lanes(frontend=True)), # Lockfile bump shifts every TS package's tree, but not the Python suite. + # It also feeds the offline Nix build, but via the frontend lane (which + # nix-build OR-gates on), so the nix lane itself stays off here. "root lockfile → frontend, not python": (["package-lock.json"], _lanes(frontend=True, npm_lock=True)), "nested lockfile → npm_lock": (["website/package-lock.json"], _lanes(site=True, npm_lock=True)), + # Nix flake inputs gate the offline `nix build .#web .#tui .#desktop` lane. + # Python stays on as the conservative fail-safe (unrecognized top-level path). + "flake.lock → nix": (["flake.lock"], _lanes(python=True, nix=True)), + "nix expression → nix": (["nix/desktop.nix"], _lanes(python=True, nix=True)), "website → site": (["website/docs/intro.md"], _lanes(site=True)), # SKILL.md reads like docs, but the skill-doc tests read skills/, so a # skill edit must still run Python.