fix(build): build the web UI from prepack, not from every npm ci (#3166)

* fix(build): build the web UI from prepack, not from every npm ci

gitnexus-web is a separate ~650-package tree (React, Vite, LangChain,
Mermaid). Because `prepare` built it, every `npm ci` in gitnexus/ also
installed and Vite-built a second product. On CI that install ran
uncached inside an execSync timeout, so a healthy-but-slow install was
SIGTERM'd mid-flight and surfaced as `spawnSync /bin/sh ETIMEDOUT` --
repeatedly killing node floor compat, a job that only import-links the
CLI dist and never needs the UI.

The UI is only needed inside the published tarball, so build it from
prepack instead. `npm run build` and `prepare` are now CLI-only; pass
--web (or npm run build:web) to include it. Jobs that pack or publish
install gitnexus-web in their own visible step, and the in-script
fallback install is untimed so a slow install can no longer be killed
halfway and reported as a build failure. The tsc/vite timeout default
goes 300s -> 600s so the remaining bounded steps have headroom.

Default build on this machine: 30s, no gitnexus-web work.

* fix(build): enforce web package artifact integrity

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(build): clarify web packaging helpers without changing behavior

Keep the same opt-in, fail-closed, and pack/publish preserve rules while
trimming comments, sharing the test harness, and reading index.html
directly instead of probing it first.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: skip prepare on typecheck so a cold shared install cannot cancel the job

quality/typecheck's 10-minute budget was spent on an uncached gitnexus-shared
npm install plus a full prepare tsc that tsc --noEmit does not need.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: stop typecheck-web from canceling before the npm cache can save

Hashing gitnexus-shared into the web cache key forced a cold 650-package
install; the 10-minute job then canceled and never wrote a warm cache.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: give format the same 10-minute budget as lint

A cold root npm ci already took 4m19s and canceled prettier at the 5-minute
cap. Lint does the same install and needed 7m41s on that run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: stop installing TypeScript 7 just to compile gitnexus-shared

A dedicated npm ci in gitnexus-shared took 7 minutes to add two packages
(TypeScript 7's optional per-platform binaries) and cancelled typecheck,
Windows pack, and coverage shard 1. Compile shared with gitnexus's tsc.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3166)

- Run tsc via execFileSync so the compiler path is never interpolated into a shell.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3166)

- Run tsc as node typescript/bin/tsc so Windows never has to execFile a .cmd shim.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Launch tsc via node and lib/tsc.js on every OS.

The npm .bin/tsc shim is tsc.cmd on Windows, which execFileSync cannot spawn.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): lock eval containment against a dedicated shared npm ci

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergő Magyar 2026-09-04 13:28:11 +01:00 committed by GitHub
parent 47c2799ba7
commit a348bc3957
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 570 additions and 65 deletions

View file

@ -11,12 +11,19 @@ runs:
cache: npm
cache-dependency-path: gitnexus-web/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
shell: bash
working-directory: gitnexus-shared
- name: Install web dependencies
run: npm ci
shell: bash
working-directory: gitnexus-web
env:
# Browsers are installed explicitly by e2e. Typecheck only needs types.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
# Compile shared with the web package's TypeScript 5. Do not npm-ci
# gitnexus-shared (TypeScript 7 optional-platform install, ~7 minutes).
- name: Build gitnexus-shared
# node + lib/tsc.js — same on Windows/macOS/Linux. Do not use .bin/tsc
# (tsc.cmd on Windows; execFileSync cannot launch .cmd without a shell).
run: node ../gitnexus-web/node_modules/typescript/lib/tsc.js
shell: bash
working-directory: gitnexus-shared

View file

@ -6,6 +6,13 @@ inputs:
description: Whether to run npm run build after install
required: false
default: 'false'
lifecycle-scripts:
description: >
Run npm lifecycle scripts (prepare/postinstall) during gitnexus npm ci.
Typecheck-only and pack-only jobs should set this to false: they do not
need dist/ or native grammar builds.
required: false
default: 'true'
runs:
using: composite
@ -16,16 +23,30 @@ runs:
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
shell: bash
working-directory: gitnexus-shared
# Do not npm-ci gitnexus-shared. Its TypeScript 7 install is a 7-minute
# stall (optional platform packages) and is not in the CLI npm cache.
# prepare/build.js compiles shared with gitnexus's tsc; typecheck does
# the same below after an ignore-scripts install.
- name: Install dependencies
if: ${{ inputs.lifecycle-scripts != 'false' }}
run: npm ci
shell: bash
working-directory: gitnexus
- name: Install dependencies
if: ${{ inputs.lifecycle-scripts == 'false' }}
run: npm ci --ignore-scripts
shell: bash
working-directory: gitnexus
- name: Build gitnexus-shared
if: ${{ inputs.lifecycle-scripts == 'false' }}
# node + lib/tsc.js — same on Windows/macOS/Linux. Do not use .bin/tsc
# (tsc.cmd on Windows; execFileSync cannot launch .cmd without a shell).
run: node ../gitnexus/node_modules/typescript/lib/tsc.js
shell: bash
working-directory: gitnexus-shared
- name: Build
if: ${{ inputs.build == 'true' }}
run: npm run build

View file

@ -9,7 +9,9 @@ permissions:
jobs:
format:
runs-on: ubuntu-latest
timeout-minutes: 5
# Same root npm ci as lint. A cold install already took 4m19s here and
# canceled prettier at the 5-minute job cap; lint needed 7m41s the same run.
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
@ -19,7 +21,7 @@ jobs:
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm ci --ignore-scripts
- run: npx prettier --check .
lint:
@ -34,7 +36,7 @@ jobs:
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm ci --ignore-scripts
- run: npx eslint .
typecheck:
@ -44,13 +46,20 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# tsc --noEmit reads source + gitnexus-shared/dist. Skip prepare/postinstall
# so a cold shared install cannot eat the 10-minute budget on a second tsc.
- uses: ./.github/actions/setup-gitnexus
with:
lifecycle-scripts: 'false'
- run: npx tsc --noEmit
working-directory: gitnexus
typecheck-web:
runs-on: ubuntu-latest
timeout-minutes: 10
# Cold gitnexus-web npm ci is several minutes (mermaid/langchain/playwright).
# A 10-minute cancel prevents setup-node from saving the cache, so the next
# run is cold again.
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:

View file

@ -307,7 +307,9 @@ jobs:
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
# Windows pack + web install regularly exceeds 15 minutes when setup also
# runs prepare/postinstall/build before prepack compiles the same tree again.
timeout-minutes: 20
steps:
# persist-credentials: false — this job runs npm pack + npm install -g
# from a tarball and never pushes back; the token in .git/config would
@ -316,9 +318,20 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Skip prepare/postinstall/build here. `npm pack` runs prepack, which
# compiles CLI + web into the tarball this job actually installs.
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
lifecycle-scripts: 'false'
# `npm pack` runs prepack, which builds the web UI into gitnexus/web/
# so the tarball matches what `npm publish` ships. Install those deps
# here, in their own visible step, rather than letting build.js do it
# from inside an execSync.
- name: Install gitnexus-web dependencies
shell: bash
run: npm ci
working-directory: gitnexus-web
- name: Pack gitnexus tarball
shell: bash
@ -360,6 +373,10 @@ jobs:
fi
echo "Installed package at: $INSTALLED"
# The npm package contract includes the built web UI. Validate the
# installed artifact, not just the source workflow that produced it.
node "$INSTALLED/scripts/assert-web-assets.mjs" "$INSTALLED/web"
# #836 invariant: no node_modules/ or build/ under any vendor/*.
BAD=$(find "$INSTALLED/vendor" \( -name node_modules -o -name build \) -print 2>/dev/null || true)
if [ -n "$BAD" ]; then
@ -419,9 +436,6 @@ jobs:
node-version: '22'
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm ci && npm run build
working-directory: gitnexus-shared
- name: Install and build gitnexus
shell: bash
run: |
@ -856,11 +870,6 @@ jobs:
"${canary_runtime}/node_modules/@anthropic-ai/claude-code/package.json"
test "$("${canary_runtime}/node_modules/@anthropic-ai/claude-code-linux-x64/claude" --version)" = \
'2.1.214 (Claude Code)'
- name: Build pinned shared runtime
run: |
npm ci
npm run build
working-directory: gitnexus-shared
- name: Install and build pinned GitNexus runtime
run: |
npm ci

View file

@ -396,14 +396,17 @@ jobs:
# cache-poisoning audit). ~30s slower per release; runs rarely.
package-manager-cache: false
- name: Build gitnexus-shared
run: npm ci && npm run build
working-directory: gitnexus-shared
- name: Install gitnexus dependencies
run: npm ci
working-directory: gitnexus
# The published tarball ships the web UI (`files: [... "web"]`), built
# by prepack during `npm publish`. Install its deps in their own step
# so a slow install is visible here instead of dying inside build.js.
- name: Install gitnexus-web dependencies
run: npm ci
working-directory: gitnexus-web
# ── Stable-only: verify the tag and package.json agree ───────────────
- name: Verify version consistency (stable)
if: needs.route.outputs.mode == 'stable'

View file

@ -55,9 +55,6 @@ jobs:
node-version: '22'
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm ci && npm run build
working-directory: gitnexus-shared
- name: Install gitnexus
run: npm ci
working-directory: gitnexus

View file

@ -189,11 +189,13 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
assert claude_lock["packages"]["node_modules/@anthropic-ai/claude-code"]["integrity"].startswith("sha512-")
assert "if(p.version!=='2.1.214') process.exit(1)" in workflow
assert "'2.1.214 (Claude Code)'" in workflow
assert containment_steps["Build pinned shared runtime"]["working-directory"] == "gitnexus-shared"
assert containment_steps["Build pinned shared runtime"]["run"].splitlines() == [
"npm ci",
"npm run build",
]
# Shared is compiled by gitnexus `npm run build` (scripts/build.js runTsc).
# A dedicated npm ci in gitnexus-shared pulls TypeScript 7 and stalls CI.
assert "Build pinned shared runtime" not in containment_steps
assert not any(
step.get("working-directory") == "gitnexus-shared" and "npm ci" in str(step.get("run", ""))
for step in containment["steps"]
)
assert containment_steps["Install and build pinned GitNexus runtime"]["working-directory"] == "gitnexus"
assert containment_steps["Install and build pinned GitNexus runtime"]["run"].splitlines() == [
"npm ci",

View file

@ -40,6 +40,7 @@
],
"scripts": {
"build": "node scripts/build.js",
"build:web": "node scripts/build.js --web",
"serve": "tsx src/cli/index.ts serve",
"dev": "tsx watch src/cli/index.ts",
"test": "vitest run",
@ -52,7 +53,7 @@
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
"prepare": "node scripts/build.js",
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js",
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js --web && node scripts/assert-web-assets.mjs web",
"version": "node scripts/sync-plugin-manifests.mjs"
},
"dependencies": {

View file

@ -0,0 +1,35 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
const webDir = path.resolve(process.argv[2] ?? 'web');
const indexPath = path.join(webDir, 'index.html');
let html;
try {
html = fs.readFileSync(indexPath, 'utf8');
} catch (err) {
if (err?.code === 'ENOENT') {
console.error(`[web-assets] missing ${indexPath}`);
process.exit(1);
}
throw err;
}
const localRefs = [...html.matchAll(/(?:src|href)=["']([^"'#]+)["']/g)]
.map((match) => match[1])
.filter((ref) => !/^(?:[a-z]+:|\/\/|data:)/i.test(ref))
.map((ref) => ref.split(/[?#]/, 1)[0].replace(/^\/+/, ''));
const assetRefs = localRefs.filter((ref) => ref.startsWith('assets/'));
if (assetRefs.length === 0) {
console.error(`[web-assets] ${indexPath} references no assets`);
process.exit(1);
}
const missing = assetRefs.filter((ref) => !fs.existsSync(path.join(webDir, ref)));
if (missing.length > 0) {
console.error(`[web-assets] ${indexPath} references missing assets:\n${missing.join('\n')}`);
process.exit(1);
}
console.log(`[web-assets] verified index.html and ${assetRefs.length} referenced asset(s)`);

View file

@ -0,0 +1,72 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
export function shouldBuildWeb(argv = process.argv, env = process.env) {
return argv.includes('--web') || env.GITNEXUS_BUILD_WEB === '1';
}
export function shouldPreserveWebOutput(env = process.env) {
return (
env.npm_lifecycle_event === 'prepare' &&
(env.npm_command === 'pack' || env.npm_command === 'publish')
);
}
/** Build and copy the web UI when `--web` / GITNEXUS_BUILD_WEB=1 is set. */
export function runWebBuild({
root,
dist,
timeoutMs,
argv = process.argv,
env = process.env,
fsImpl = fs,
exec = execSync,
}) {
const webRoot = path.resolve(root, '..', 'gitnexus-web');
const webDest = path.join(dist, '..', 'web');
if (!shouldBuildWeb(argv, env)) {
if (shouldPreserveWebOutput(env)) {
console.log('[build] preserving prepack web UI during npm prepare');
} else {
fsImpl.rmSync(webDest, { recursive: true, force: true });
console.log(
'[build] skipping web UI and removed stale output ' +
'(pass --web or set GITNEXUS_BUILD_WEB=1 to include it)',
);
}
return { status: 'skipped', webDest };
}
if (!fsImpl.existsSync(path.join(webRoot, 'package.json'))) {
throw new Error(
`[build] web UI requested, but gitnexus-web was not found at ${webRoot}. ` +
'Run this command from the complete monorepo checkout.',
);
}
console.log('[build] building gitnexus-web…');
if (!fsImpl.existsSync(path.join(webRoot, 'node_modules'))) {
// Deliberately untimed: this is a full second install, and killing it
// partway through leaves a broken tree and a misleading ETIMEDOUT.
// CI should install gitnexus-web itself (cached, its own step) so this
// fallback only fires for a local `npm pack` / `npm publish`.
console.log('[build] installing gitnexus-web dependencies (no local node_modules)…');
// String form uses the platform shell (cmd.exe / sh) so `npm` resolves to
// npm.cmd on Windows. execFileSync('npm') / execFileSync('npm.cmd')
// without a shell fails on Windows.
exec('npm ci', { cwd: webRoot, stdio: 'inherit' });
}
exec('npm run build', { cwd: webRoot, stdio: 'inherit', timeout: timeoutMs });
const builtIndex = path.join(webRoot, 'dist', 'index.html');
if (!fsImpl.existsSync(builtIndex)) {
throw new Error(`[build] gitnexus-web build completed without ${builtIndex}`);
}
fsImpl.rmSync(webDest, { recursive: true, force: true });
fsImpl.cpSync(path.join(webRoot, 'dist'), webDest, { recursive: true });
console.log('[build] copied web UI → gitnexus/web/');
return { status: 'built', webDest };
}

View file

@ -8,17 +8,18 @@
* 3. Copy gitnexus-shared/dist dist/_shared
* 4. Rewrite bare 'gitnexus-shared' specifiers relative paths
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runWebBuild } from './build-web.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SHARED_ROOT = path.resolve(ROOT, '..', 'gitnexus-shared');
const DIST = path.join(ROOT, 'dist');
const SHARED_DEST = path.join(DIST, '_shared');
const DEFAULT_BUILD_TIMEOUT_MS = 300_000;
const DEFAULT_BUILD_TIMEOUT_MS = 600_000;
function getBuildTimeoutMs() {
const raw = process.env.GITNEXUS_BUILD_TIMEOUT_MS;
@ -51,17 +52,29 @@ if (!fs.existsSync(SHARED_ROOT)) {
process.exit(1);
}
// Launch tsc as `node typescript/lib/tsc.js` on every OS. The `.bin/tsc` /
// `tsc.cmd` shims are Windows-only wrappers; `execFileSync` cannot spawn a
// `.cmd` without a shell, and a separate `npm ci` in gitnexus-shared pulls
// TypeScript 7 optional platform packages (7+ minutes in CI).
const tscJs = path.join(ROOT, 'node_modules', 'typescript', 'lib', 'tsc.js');
if (!fs.existsSync(tscJs)) {
console.error(
`[build] missing ${tscJs}. Install gitnexus dependencies first (npm ci in gitnexus/).`,
);
process.exit(1);
}
function runTsc(cwd) {
execFileSync(process.execPath, [tscJs], { cwd, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
}
// ── 1. Build gitnexus-shared ───────────────────────────────────────
console.log('[build] compiling gitnexus-shared…');
const tscCmd =
process.platform === 'win32'
? path.join('node_modules', '.bin', 'tsc.cmd')
: path.join('node_modules', '.bin', 'tsc');
execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
runTsc(SHARED_ROOT);
// ── 2. Build gitnexus ──────────────────────────────────────────────
console.log('[build] compiling gitnexus…');
execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
runTsc(ROOT);
// ── 3. Copy shared dist ────────────────────────────────────────────
console.log('[build] copying shared module into dist/_shared…');
@ -104,26 +117,16 @@ walk(DIST, ['.js', '.d.ts'], rewriteFile);
// ── 5. Make CLI entry executable ────────────────────────────────────
const cliEntry = path.join(DIST, 'cli', 'index.js');
if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755);
// ── 6. Build & copy web UI ──────────────────────────────────────────
const WEB_ROOT = path.resolve(ROOT, '..', 'gitnexus-web');
const WEB_DEST = path.join(DIST, '..', 'web');
if (fs.existsSync(path.join(WEB_ROOT, 'package.json'))) {
console.log('[build] building gitnexus-web…');
if (!fs.existsSync(path.join(WEB_ROOT, 'node_modules'))) {
console.log('[build] installing gitnexus-web dependencies…');
execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
}
execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
// Copy dist → gitnexus/web/ (shipped in the npm package)
fs.rmSync(WEB_DEST, { recursive: true, force: true });
fs.cpSync(path.join(WEB_ROOT, 'dist'), WEB_DEST, { recursive: true });
console.log('[build] copied web UI → gitnexus/web/');
} else {
console.log('[build] skipping web UI (gitnexus-web not found)');
if (process.platform !== 'win32' && fs.existsSync(cliEntry)) {
fs.chmodSync(cliEntry, 0o755);
}
// ── 6. Build & copy web UI (opt-in) ─────────────────────────────────
// Web UI is a separate package and is only required in the published
// tarball, so it is built by `prepack --web`, not by `prepare`. Serve
// falls back to the landing page when web/ is absent. CLI-only builds
// delete stale web/ except during npm pack/publish prepare, which must
// keep the prepack output.
runWebBuild({ root: ROOT, dist: DIST, timeoutMs: BUILD_TIMEOUT_MS });
console.log(`[build] done — rewrote ${rewritten} files.`);

View file

@ -0,0 +1,346 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { load } from 'js-yaml';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { runWebBuild, shouldBuildWeb, shouldPreserveWebOutput } from '../../scripts/build-web.js';
/** Default prepare/build stay CLI-only; the web UI ships only via prepack --web. */
const REPO_ROOT = path.resolve(__dirname, '../../..');
const PACKAGE_JSON = JSON.parse(
readFileSync(path.join(REPO_ROOT, 'gitnexus/package.json'), 'utf8'),
) as { scripts?: Record<string, string> };
const tempDirs: string[] = [];
interface WorkflowStep {
name?: string;
run?: unknown;
uses?: string;
with?: Record<string, unknown>;
env?: Record<string, unknown>;
if?: string;
'working-directory'?: string;
}
interface WorkflowJob {
'timeout-minutes'?: number;
steps?: WorkflowStep[];
}
function jobs(workflowPath: string): Record<string, WorkflowJob> {
const doc = load(readFileSync(path.join(REPO_ROOT, workflowPath), 'utf8')) as {
jobs?: Record<string, WorkflowJob>;
};
return doc.jobs ?? {};
}
function compositeAction(actionPath: string): {
inputs?: Record<string, { default?: string }>;
runs?: { steps?: WorkflowStep[] };
} {
return load(readFileSync(path.join(REPO_ROOT, actionPath), 'utf8')) as {
inputs?: Record<string, { default?: string }>;
runs?: { steps?: WorkflowStep[] };
};
}
const ciJobs = jobs('.github/workflows/ci-tests.yml');
const publishJobs = jobs('.github/workflows/publish.yml');
const qualityJobs = jobs('.github/workflows/ci-quality.yml');
const setupGitnexus = compositeAction('.github/actions/setup-gitnexus/action.yml');
const setupGitnexusWeb = compositeAction('.github/actions/setup-gitnexus-web/action.yml');
function stepIndex(steps: WorkflowStep[], predicate: (step: WorkflowStep) => boolean): number {
return steps.findIndex(predicate);
}
const installsWeb = (step: WorkflowStep) =>
step['working-directory'] === 'gitnexus-web' && String(step.run ?? '').includes('npm ci');
function runWeb(
fixture: ReturnType<typeof buildFixture>,
overrides: {
timeoutMs?: number;
argv?: string[];
env?: NodeJS.Dict<string>;
exec?: (...args: unknown[]) => unknown;
} = {},
) {
return runWebBuild({
root: fixture.root,
dist: fixture.dist,
timeoutMs: 600_000,
argv: ['node', 'build.js'],
env: {},
exec: vi.fn(),
...overrides,
});
}
function buildFixture({ withWeb = true, withNodeModules = true } = {}) {
const workspace = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-build-web-'));
tempDirs.push(workspace);
const root = path.join(workspace, 'gitnexus');
const dist = path.join(root, 'dist');
const webRoot = path.join(workspace, 'gitnexus-web');
mkdirSync(dist, { recursive: true });
if (withWeb) {
mkdirSync(path.join(webRoot, 'dist', 'assets'), { recursive: true });
writeFileSync(path.join(webRoot, 'package.json'), '{}');
writeFileSync(
path.join(webRoot, 'dist', 'index.html'),
'<script src="/assets/app.js"></script>',
);
writeFileSync(path.join(webRoot, 'dist', 'assets', 'app.js'), 'export {};');
if (withNodeModules) mkdirSync(path.join(webRoot, 'node_modules'));
}
return { root, dist, webRoot, webDest: path.join(root, 'web') };
}
afterEach(() => {
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe('gitnexus build scripts', () => {
it('keeps the default build CLI-only', () => {
expect(PACKAGE_JSON.scripts?.build).toBe('node scripts/build.js');
expect(PACKAGE_JSON.scripts?.prepare).toBe('node scripts/build.js');
expect(PACKAGE_JSON.scripts?.prepare).not.toContain('--web');
});
it('compiles gitnexus-shared with gitnexus TypeScript, not a separate TypeScript 7 install', () => {
const src = readFileSync(path.join(REPO_ROOT, 'gitnexus/scripts/build.js'), 'utf8');
expect(src).toContain("path.join(ROOT, 'node_modules', 'typescript', 'lib', 'tsc.js')");
expect(src).toContain('execFileSync(process.execPath, [tscJs]');
expect(src).not.toMatch(/node_modules['"]?, ['"]\.bin/);
expect(src).not.toMatch(/execFileSync\([^)]*tsc\.cmd/);
expect(src).not.toContain("typescript', 'bin', 'tsc'");
});
it.skipIf(!existsSync(path.join(REPO_ROOT, 'gitnexus/node_modules/typescript/lib/tsc.js')))(
'can launch TypeScript via node + lib/tsc.js on this OS',
() => {
const probe = spawnSync(
process.execPath,
[path.join(REPO_ROOT, 'gitnexus/node_modules/typescript/lib/tsc.js'), '--version'],
{ encoding: 'utf8' },
);
expect(probe.status).toBe(0);
expect(probe.stdout).toMatch(/Version \d+/);
},
);
it('builds the web UI from prepack, which is what ships the tarball', () => {
expect(PACKAGE_JSON.scripts?.prepack).toContain('scripts/build.js --web');
expect(PACKAGE_JSON.scripts?.prepack).toContain('scripts/assert-web-assets.mjs web');
expect(PACKAGE_JSON.scripts?.['build:web']).toBe('node scripts/build.js --web');
});
it('recognizes only explicit CLI or environment opt-ins', () => {
expect(shouldBuildWeb(['node', 'build.js'], {})).toBe(false);
expect(shouldBuildWeb(['node', 'build.js', '--web'], {})).toBe(true);
expect(shouldBuildWeb(['node', 'build.js'], { GITNEXUS_BUILD_WEB: '1' })).toBe(true);
expect(shouldBuildWeb(['node', 'build.js'], { GITNEXUS_BUILD_WEB: 'true' })).toBe(false);
});
it('removes stale packaged output from a default build', () => {
const fixture = buildFixture();
mkdirSync(fixture.webDest, { recursive: true });
writeFileSync(path.join(fixture.webDest, 'index.html'), 'stale');
const exec = vi.fn();
const result = runWeb(fixture, { exec });
expect(result.status).toBe('skipped');
expect(exec).not.toHaveBeenCalled();
expect(existsSync(fixture.webDest)).toBe(false);
});
it('preserves prepack output during npm prepare for pack and publish', () => {
for (const npmCommand of ['pack', 'publish']) {
const fixture = buildFixture();
mkdirSync(fixture.webDest, { recursive: true });
writeFileSync(path.join(fixture.webDest, 'index.html'), npmCommand);
expect(
shouldPreserveWebOutput({
npm_lifecycle_event: 'prepare',
npm_command: npmCommand,
}),
).toBe(true);
runWeb(fixture, {
env: { npm_lifecycle_event: 'prepare', npm_command: npmCommand },
});
expect(readFileSync(path.join(fixture.webDest, 'index.html'), 'utf8')).toBe(npmCommand);
}
});
it('fails closed when an explicit web build has no web package', () => {
const fixture = buildFixture({ withWeb: false });
expect(() => runWeb(fixture, { argv: ['node', 'build.js', '--web'] })).toThrow(
'web UI requested, but gitnexus-web was not found',
);
});
it('builds and copies the web UI with an untimed fallback install', () => {
const fixture = buildFixture({ withNodeModules: false });
const exec = vi.fn();
const result = runWeb(fixture, {
timeoutMs: 123_456,
argv: ['node', 'build.js', '--web'],
exec,
});
expect(exec).toHaveBeenNthCalledWith(1, 'npm ci', {
cwd: fixture.webRoot,
stdio: 'inherit',
});
expect(exec).toHaveBeenNthCalledWith(2, 'npm run build', {
cwd: fixture.webRoot,
stdio: 'inherit',
timeout: 123_456,
});
expect(result.status).toBe('built');
expect(readFileSync(path.join(fixture.webDest, 'index.html'), 'utf8')).toContain('app.js');
});
it('rejects a packaged web UI with missing referenced assets', () => {
const fixture = buildFixture();
const checker = path.join(REPO_ROOT, 'gitnexus/scripts/assert-web-assets.mjs');
expect(spawnSync(process.execPath, [checker, path.join(fixture.webRoot, 'dist')]).status).toBe(
0,
);
rmSync(path.join(fixture.webRoot, 'dist', 'assets', 'app.js'));
const invalid = spawnSync(process.execPath, [checker, path.join(fixture.webRoot, 'dist')], {
encoding: 'utf8',
});
expect(invalid.status).toBe(1);
expect(invalid.stderr).toContain('references missing assets');
const missingIndex = spawnSync(
process.execPath,
[checker, path.join(fixture.webRoot, 'none')],
{
encoding: 'utf8',
},
);
expect(missingIndex.status).toBe(1);
expect(missingIndex.stderr).toContain('missing');
});
});
describe('workflows that need the web UI install it themselves', () => {
it('packaged install smoke installs gitnexus-web before npm pack', () => {
const steps = ciJobs['packaged-install-smoke']?.steps ?? [];
const webIdx = stepIndex(steps, installsWeb);
const packIdx = stepIndex(steps, (step) => String(step.run ?? '').includes('npm pack'));
expect(webIdx).toBeGreaterThanOrEqual(0);
expect(packIdx).toBeGreaterThan(webIdx);
});
it('packaged install smoke validates web assets in the installed tarball', () => {
const steps = ciJobs['packaged-install-smoke']?.steps ?? [];
const artifactCheck = steps.find((step) =>
String(step.run ?? '').includes('scripts/assert-web-assets.mjs'),
);
expect(artifactCheck).toBeTruthy();
expect(String(artifactCheck?.run)).toContain('$INSTALLED/web');
});
it('publish installs gitnexus-web before it packs the tarball', () => {
const steps = publishJobs['publish']?.steps ?? [];
const webIdx = stepIndex(steps, installsWeb);
const publishIdx = stepIndex(steps, (step) =>
String(step.run ?? '').includes('npm publish --dry-run'),
);
expect(webIdx).toBeGreaterThanOrEqual(0);
expect(publishIdx).toBeGreaterThan(webIdx);
});
it('node floor compat stays CLI-only — it never installs the web tree', () => {
const steps = ciJobs['node-floor-compat']?.steps ?? [];
expect(steps.length).toBeGreaterThan(0);
expect(steps.filter(installsWeb)).toHaveLength(0);
});
it('packaged install smoke skips a pre-pack CLI build and keeps a 20-minute budget', () => {
const job = ciJobs['packaged-install-smoke'];
const setup = job?.steps?.find((step) => step.uses === './.github/actions/setup-gitnexus');
expect(job?.['timeout-minutes']).toBe(20);
expect(setup?.with?.['lifecycle-scripts']).toBe('false');
expect(setup?.with?.build).toBeUndefined();
});
});
describe('setup-gitnexus job budget', () => {
it('does not npm-ci gitnexus-shared (TypeScript 7 optional-platform install stalls CI)', () => {
const shared = setupGitnexus.runs?.steps?.find((step) => step.name === 'Build gitnexus-shared');
expect(String(shared?.run)).toBe('node ../gitnexus/node_modules/typescript/lib/tsc.js');
expect(String(shared?.run)).not.toContain('.bin');
expect(shared?.if).toContain("lifecycle-scripts == 'false'");
expect(
setupGitnexus.runs?.steps?.some(
(step) =>
step['working-directory'] === 'gitnexus-shared' &&
String(step.run ?? '').includes('npm ci'),
),
).toBe(false);
expect(setupGitnexus.inputs?.['lifecycle-scripts']?.default).toBe('true');
expect(
setupGitnexus.runs?.steps?.some((step) =>
String(step.run ?? '').includes('--ignore-scripts'),
),
).toBe(true);
});
it('setup-gitnexus-web compiles shared with the web TypeScript and skips Playwright browsers', () => {
const setupNode = setupGitnexusWeb.runs?.steps?.find((step) =>
String(step.uses ?? '').startsWith('actions/setup-node@'),
);
const shared = setupGitnexusWeb.runs?.steps?.find(
(step) => step.name === 'Build gitnexus-shared',
);
const webInstall = setupGitnexusWeb.runs?.steps?.find(
(step) => step.name === 'Install web dependencies',
);
expect(String(setupNode?.with?.['cache-dependency-path'])).toBe(
'gitnexus-web/package-lock.json',
);
expect(String(shared?.run)).toBe('node ../gitnexus-web/node_modules/typescript/lib/tsc.js');
expect(String(shared?.run)).not.toContain('.bin');
expect(String(shared?.run)).not.toContain('npm ci');
expect(webInstall?.env?.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD).toBe('1');
});
it('quality typecheck skips prepare/postinstall so tsc --noEmit fits in 10 minutes', () => {
const job = qualityJobs.typecheck;
const setup = job?.steps?.find((step) => step.uses === './.github/actions/setup-gitnexus');
expect(job?.['timeout-minutes']).toBe(10);
expect(setup?.with?.['lifecycle-scripts']).toBe('false');
});
it('quality typecheck-web can finish a cold web install instead of canceling before cache save', () => {
expect(qualityJobs['typecheck-web']?.['timeout-minutes']).toBe(15);
});
it('quality format matches lint budget and skips husky during npm ci', () => {
const formatCi = qualityJobs.format?.steps?.find((step) =>
String(step.run ?? '').includes('npm ci'),
);
const lintCi = qualityJobs.lint?.steps?.find((step) =>
String(step.run ?? '').includes('npm ci'),
);
expect(qualityJobs.format?.['timeout-minutes']).toBe(10);
expect(qualityJobs.lint?.['timeout-minutes']).toBe(10);
expect(String(formatCi?.run)).toContain('--ignore-scripts');
expect(String(lintCi?.run)).toContain('--ignore-scripts');
});
});