Skip to content

Commit c3f0f5f

Browse files
committed
build: fail when the committed web bundle is stale
apps/pythinker-code/dist-web is generated from apps/pythinker-web and read by no other gate, so editing the web UI without rebuilding shipped a CLI whose embedded UI silently lagged its own source, with nothing red anywhere. The existing check only asserted the bundle was present, and nothing invoked it. copy-web-assets now records a fingerprint of every build input in the bundle and check-web-assets recomputes it, so a stale bundle fails. It runs in pre-push, in the CLI build, and on prepack. `pnpm run build:web` rebuilds and restages in one step.
1 parent c2c0a0a commit c3f0f5f

7 files changed

Lines changed: 121 additions & 13 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Adding an OpenAI-compatible provider requires **zero code changes** — just add
6969
| `packages/pi-tui` | Vendored TUI library | Upstream fork with local divergences; tests run with `node --test`, not vitest. See its `AGENTS.md`. |
7070
| `packages/protocol` | Shared REST + WS protocol schemas | Envelope, error codes, pagination, WS-control types. |
7171

72-
The web bundle: `apps/pythinker-code/dist-web` is the committed, prebuilt bundle of `apps/pythinker-web` (built with `pnpm --filter @pymodel/pythinker-web run build` and copied via `scripts/copy-web-assets.mjs`). `apps/pythinker-code/scripts/check-web-assets.mjs` guards packaging against a missing bundle — sync and commit the bundle in the same change whenever the web UI should ship differently. `packages/server` and `packages/server-e2e` are empty leftover directories excluded from the workspace — not packages.
72+
The web bundle: `apps/pythinker-code/dist-web` is the committed, prebuilt bundle of `apps/pythinker-web` (built with `pnpm --filter @pymodel/pythinker-web run build` and copied via `scripts/copy-web-assets.mjs`). `apps/pythinker-code/scripts/check-web-assets.mjs` fails when the bundle is missing **or stale** (it compares a fingerprint of every `apps/pythinker-web` build input against the one recorded at copy time); it runs in pre-push, in the CLI `build`, and on `prepack`. Whenever you touch the web UI, run `pnpm run build:web` and commit the restaged bundle in the same change. `packages/server` and `packages/server-e2e` are empty leftover directories excluded from the workspace — not packages.
7373

7474
## Environment
7575

apps/pythinker-code/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"provenance": true
5151
},
5252
"scripts": {
53-
"build": "pnpm -C ../pythinker-web run build && tsdown && tsdown --config tsdown.dist-worker.config.ts && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs",
53+
"build": "pnpm -C ../pythinker-web run build && tsdown && tsdown --config tsdown.dist-worker.config.ts && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs && node scripts/check-web-assets.mjs",
5454
"prebuild": "node scripts/build-vis-asset.mjs",
5555
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
5656
"smoke": "node scripts/smoke.mjs",
@@ -75,7 +75,8 @@
7575
"test": "pnpm -w run build:packages && vitest run",
7676
"e2e": "pnpm -w run build:packages && PYTHINKER_E2E=1 vitest run test/e2e",
7777
"e2e:real": "pnpm -w run build:packages && PYTHINKER_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts",
78-
"postinstall": "node scripts/postinstall.mjs"
78+
"postinstall": "node scripts/postinstall.mjs",
79+
"prepack": "node scripts/check-web-assets.mjs"
7980
},
8081
"optionalDependencies": {
8182
"@mariozechner/clipboard": "^0.3.9",

apps/pythinker-code/scripts/check-web-assets.mjs

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
1-
// Verify the built web bundle is present before packaging.
1+
// Verify the committed web bundle is present AND current.
22
//
33
// This repo keeps the web UI source at apps/pythinker-web. The bundle is
44
// staged at apps/pythinker-code/dist-web by scripts/copy-web-assets.mjs after
5-
// a web build. This check only asserts the staged bundle is in place, so a
6-
// packaging run never silently ships a CLI without the web UI.
5+
// a web build. Presence alone is not enough: no other gate reads the bundle,
6+
// so editing the web source without rebuilding ships a CLI whose embedded UI
7+
// lags its own source, with nothing red to show for it. Compare the source
8+
// fingerprint recorded at copy time against the current source.
79

8-
import { readdir, stat } from 'node:fs/promises';
10+
import { readFile, readdir, stat } from 'node:fs/promises';
911
import { dirname, resolve } from 'node:path';
1012
import { fileURLToPath } from 'node:url';
1113

14+
import { MANIFEST_NAME, computeWebInputHash } from './web-bundle-manifest.mjs';
15+
1216
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
1317
const target = resolve(appRoot, 'dist-web');
1418

19+
const REBUILD = 'Run `pnpm run build:web` to rebuild and restage it.';
20+
1521
async function assertWebAssets() {
1622
try {
1723
const info = await stat(resolve(target, 'index.html'));
@@ -27,6 +33,30 @@ async function assertWebAssets() {
2733
}
2834
}
2935

36+
async function assertWebAssetsCurrent() {
37+
const manifestPath = resolve(target, MANIFEST_NAME);
38+
let recorded;
39+
try {
40+
recorded = JSON.parse(await readFile(manifestPath, 'utf8'));
41+
} catch {
42+
throw new Error(
43+
`The staged web bundle has no ${MANIFEST_NAME}, so it cannot be checked ` +
44+
`against apps/pythinker-web. ${REBUILD}`,
45+
);
46+
}
47+
const { hash, fileCount } = await computeWebInputHash();
48+
if (recorded.sourceHash !== hash) {
49+
throw new Error(
50+
'The committed web bundle is stale: apps/pythinker-web has changed since ' +
51+
`it was built (bundle ${String(recorded.sourceHash).slice(0, 12)}, ` +
52+
`source ${hash.slice(0, 12)}; ${recorded.sourceFileCount} -> ${fileCount} files). ` +
53+
REBUILD,
54+
);
55+
}
56+
return hash;
57+
}
58+
3059
await assertWebAssets();
60+
const sourceHash = await assertWebAssetsCurrent();
3161
const files = await readdir(target, { recursive: true });
32-
console.log(`Web assets OK: ${target} (${files.length} entries)`);
62+
console.log(`Web assets OK: ${target} (${files.length} entries, source ${sourceHash.slice(0, 12)})`);

apps/pythinker-code/scripts/copy-web-assets.mjs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { cp, rm, stat } from 'node:fs/promises';
1+
import { cp, rm, stat, writeFile } from 'node:fs/promises';
22
import { dirname, resolve } from 'node:path';
33
import { fileURLToPath } from 'node:url';
44

5+
import { MANIFEST_NAME, computeWebInputHash } from './web-bundle-manifest.mjs';
6+
57
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
68
const repoRoot = resolve(appRoot, '../..');
79
const source = resolve(repoRoot, 'apps/pythinker-web/dist');
@@ -21,7 +23,14 @@ async function assertBuiltWeb() {
2123
}
2224

2325
await assertBuiltWeb();
26+
// Fingerprint the source that produced this bundle, so check-web-assets.mjs
27+
// can tell a stale committed bundle from a current one.
28+
const { hash, fileCount } = await computeWebInputHash();
2429
await rm(target, { recursive: true, force: true });
2530
await cp(source, target, { recursive: true });
31+
await writeFile(
32+
resolve(target, MANIFEST_NAME),
33+
`${JSON.stringify({ sourceHash: hash, sourceFileCount: fileCount }, null, 2)}\n`,
34+
);
2635

27-
console.log(`Copied Pythinker web assets to ${target}`);
36+
console.log(`Copied Pythinker web assets to ${target} (source ${hash.slice(0, 12)}, ${fileCount} files)`);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Shared fingerprint of the web UI's build inputs.
2+
//
3+
// The committed bundle at apps/pythinker-code/dist-web is generated from
4+
// apps/pythinker-web. Nothing in the type-check, lint, or test gates reads the
5+
// bundle, so editing the web source and forgetting to rebuild ships a CLI whose
6+
// embedded UI silently lags the source — a class of bug that reaches users and
7+
// leaves no trace in CI. copy-web-assets.mjs stamps this fingerprint into the
8+
// bundle; check-web-assets.mjs recomputes it and fails on a mismatch.
9+
10+
import { createHash } from 'node:crypto';
11+
import { readFile } from 'node:fs/promises';
12+
import { globSync, statSync } from 'node:fs';
13+
import { dirname, resolve } from 'node:path';
14+
import { fileURLToPath } from 'node:url';
15+
16+
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17+
export const repoRoot = resolve(appRoot, '../..');
18+
export const webRoot = resolve(repoRoot, 'apps/pythinker-web');
19+
export const bundleRoot = resolve(appRoot, 'dist-web');
20+
export const MANIFEST_NAME = '.web-bundle-manifest.json';
21+
22+
// Everything the Vite build reads. `public/` is copied verbatim into the
23+
// bundle, and the configs decide how the source is compiled, so a change to
24+
// any of them makes the committed bundle stale.
25+
const INPUT_GLOBS = [
26+
'src/**/*',
27+
'public/**/*',
28+
'index.html',
29+
'vite.config.ts',
30+
'tsconfig.json',
31+
'package.json',
32+
];
33+
34+
/** Sorted list of build-input paths, relative to apps/pythinker-web. */
35+
export function webInputFiles() {
36+
const seen = new Set();
37+
for (const pattern of INPUT_GLOBS) {
38+
for (const file of globSync(pattern, { cwd: webRoot })) {
39+
const normalized = file.split('\\').join('/');
40+
if (statSync(resolve(webRoot, normalized)).isFile()) seen.add(normalized);
41+
}
42+
}
43+
return [...seen].sort();
44+
}
45+
46+
/**
47+
* Content hash over every build input. Path-and-content, so a rename with
48+
* identical bytes still changes the fingerprint.
49+
*/
50+
export async function computeWebInputHash() {
51+
const files = webInputFiles();
52+
const digest = createHash('sha256');
53+
for (const file of files) {
54+
digest.update(file);
55+
digest.update('\0');
56+
digest.update(await readFile(resolve(webRoot, file)));
57+
digest.update('\0');
58+
}
59+
return { hash: digest.digest('hex'), fileCount: files.length };
60+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"dev:cli": "pnpm -C apps/pythinker-code run dev",
1212
"dev:desktop": "pnpm -C apps/desktop run dev",
1313
"dev:web": "pnpm -C apps/pythinker-web run dev",
14+
"build:web": "pnpm --filter @pymodel/pythinker-web run build && node apps/pythinker-code/scripts/copy-web-assets.mjs",
15+
"check:web": "node apps/pythinker-code/scripts/check-web-assets.mjs",
1416
"package:desktop": "pnpm -C apps/desktop run package",
1517
"dist:mac:desktop": "pnpm -C apps/desktop run dist:mac",
1618
"dist:win:desktop": "pnpm -C apps/desktop run dist:win",

scripts/pre-push.sh

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,17 @@ step "sherif" pnpm run sherif || fail "sherif" "monorepo dependency versions are
6565
# 3. oxlint (CI: `pnpm run lint`).
6666
step "lint" pnpm run lint || fail "lint" "oxlint reported errors"
6767

68-
# 4. Nix fetchPnpmDeps hash freshness proxy (CI: nix build).
68+
# 4. Committed web bundle freshness. apps/pythinker-code/dist-web is generated
69+
# from apps/pythinker-web and read by no other gate, so a web source edit
70+
# without a rebuild ships a stale embedded UI with nothing red to show for it.
71+
step "web-bundle-freshness" node apps/pythinker-code/scripts/check-web-assets.mjs ||
72+
fail "build" "apps/pythinker-web changed without restaging dist-web (run 'pnpm run build:web')"
73+
74+
# 5. Nix fetchPnpmDeps hash freshness proxy (CI: nix build).
6975
step "nix-hash-freshness" node scripts/check-nix-hash-fresh.mjs ||
7076
fail "nix build (flake.nix)" "pnpm-lock.yaml changed without refreshing flake.nix's pnpmDeps hash"
7177

72-
# 5. Typecheck, scoped to packages changed since $base_ref (CI: `pnpm run
78+
# 6. Typecheck, scoped to packages changed since $base_ref (CI: `pnpm run
7379
# typecheck`). Full typecheck builds every package first and takes
7480
# minutes — not affordable pre-push, so this narrows to what pnpm's own
7581
# dependency graph says was actually touched.
@@ -81,7 +87,7 @@ else
8187
echo "[pre-push] run 'pnpm run typecheck' manually before pushing if you touched types."
8288
fi
8389

84-
# 6. Tests, scoped via vitest's own changed-file impact analysis (CI: `pnpm test`).
90+
# 7. Tests, scoped via vitest's own changed-file impact analysis (CI: `pnpm test`).
8591
if [[ -n "$base_ref" ]]; then
8692
step "test (changed)" pnpm exec vitest run --changed "$base_ref" --passWithNoTests ||
8793
fail "test" "tests affected by changed files are failing"

0 commit comments

Comments
 (0)