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/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 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.