mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-06 08:16:02 +00:00
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:
parent
47c2799ba7
commit
a348bc3957
12 changed files with 570 additions and 65 deletions
17
.github/actions/setup-gitnexus-web/action.yml
vendored
17
.github/actions/setup-gitnexus-web/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
31
.github/actions/setup-gitnexus/action.yml
vendored
31
.github/actions/setup-gitnexus/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
17
.github/workflows/ci-quality.yml
vendored
17
.github/workflows/ci-quality.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
29
.github/workflows/ci-tests.yml
vendored
29
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
11
.github/workflows/publish.yml
vendored
11
.github/workflows/publish.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
3
.github/workflows/skill-sync.yml
vendored
3
.github/workflows/skill-sync.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
35
gitnexus/scripts/assert-web-assets.mjs
Normal file
35
gitnexus/scripts/assert-web-assets.mjs
Normal 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)`);
|
||||
72
gitnexus/scripts/build-web.js
Normal file
72
gitnexus/scripts/build-web.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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.`);
|
||||
|
|
|
|||
346
gitnexus/test/unit/build-web-optin.test.ts
Normal file
346
gitnexus/test/unit/build-web-optin.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue