Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/actions/detect-changes/action.yml
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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 }}
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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.
#
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions .github/workflows/nix-build.yml
Original file line number Diff line number Diff line change
@@ -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/<platform>` 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
18 changes: 6 additions & 12 deletions nix/desktop.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions scripts/ci/classify_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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),
}
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion tests/ci/test_classify_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}
Expand All @@ -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.
Expand Down
Loading