Skip to content

Commit 426048c

Browse files
committed
fix(build): fingerprint workspace dependencies and the lockfile
The web app compiles its workspace dependencies from source, so a change in one of them altered the bundle while the fingerprint stayed put and the freshness check passed a stale bundle. A dependency resolution change does the same without touching any tracked source, so the lockfile counts as an input too.
1 parent 6b51f3c commit 426048c

2 files changed

Lines changed: 54 additions & 12 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ async function assertWebAssetsCurrent() {
4747
const { hash, fileCount } = await computeWebInputHash();
4848
if (recorded.sourceHash !== hash) {
4949
throw new Error(
50-
'The committed web bundle is stale: apps/pythinker-web has changed since ' +
50+
'The committed web bundle is stale: a web build input has changed since ' +
5151
`it was built (bundle ${String(recorded.sourceHash).slice(0, 12)}, ` +
5252
`source ${hash.slice(0, 12)}; ${recorded.sourceFileCount} -> ${fileCount} files). ` +
5353
REBUILD,

apps/pythinker-code/scripts/web-bundle-manifest.mjs

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99

1010
import { createHash } from 'node:crypto';
1111
import { readFile } from 'node:fs/promises';
12-
import { globSync, statSync } from 'node:fs';
13-
import { dirname, resolve } from 'node:path';
12+
import { existsSync, globSync, readFileSync, statSync } from 'node:fs';
13+
import { dirname, relative, resolve } from 'node:path';
1414
import { fileURLToPath } from 'node:url';
1515

1616
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -19,9 +19,9 @@ export const webRoot = resolve(repoRoot, 'apps/pythinker-web');
1919
export const bundleRoot = resolve(appRoot, 'dist-web');
2020
export const MANIFEST_NAME = '.web-bundle-manifest.json';
2121

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.
22+
// Everything the Vite build reads out of a package. `public/` is copied
23+
// verbatim into the bundle, and the configs decide how the source is compiled,
24+
// so a change to any of them makes the committed bundle stale.
2525
const INPUT_GLOBS = [
2626
'src/**/*',
2727
'public/**/*',
@@ -31,15 +31,57 @@ const INPUT_GLOBS = [
3131
'package.json',
3232
];
3333

34-
/** Sorted list of build-input paths, relative to apps/pythinker-web. */
34+
// The web app compiles workspace dependencies from source (they publish
35+
// `./src/index.ts` directly), so their files are build inputs too. Hashing only
36+
// apps/pythinker-web would let a change in one of them alter the bundle while
37+
// the fingerprint stayed put. The lockfile is included because a dependency
38+
// resolution change alters the bundle without touching any tracked source.
39+
const EXTRA_INPUTS = ['pnpm-lock.yaml'];
40+
41+
/** Workspace package roots the web app depends on, transitively. */
42+
function workspaceDependencyRoots() {
43+
const roots = [];
44+
const pending = [webRoot];
45+
const seen = new Set([webRoot]);
46+
while (pending.length > 0) {
47+
const root = pending.pop();
48+
let manifest;
49+
try {
50+
manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
51+
} catch {
52+
continue;
53+
}
54+
const deps = { ...manifest.dependencies, ...manifest.devDependencies };
55+
for (const [name, range] of Object.entries(deps)) {
56+
if (typeof range !== 'string' || !range.startsWith('workspace:')) continue;
57+
const dir = name.replace(/^@[^/]+\//u, '');
58+
for (const candidate of [resolve(repoRoot, 'packages', dir), resolve(repoRoot, 'apps', dir)]) {
59+
if (seen.has(candidate) || !existsSync(resolve(candidate, 'package.json'))) continue;
60+
seen.add(candidate);
61+
roots.push(candidate);
62+
pending.push(candidate);
63+
break;
64+
}
65+
}
66+
}
67+
return roots.sort();
68+
}
69+
70+
/** Sorted list of build-input paths, relative to the repo root. */
3571
export function webInputFiles() {
3672
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);
73+
for (const root of [webRoot, ...workspaceDependencyRoots()]) {
74+
for (const pattern of INPUT_GLOBS) {
75+
for (const file of globSync(pattern, { cwd: root })) {
76+
const full = resolve(root, file);
77+
if (!statSync(full).isFile()) continue;
78+
seen.add(relative(repoRoot, full).split('\\').join('/'));
79+
}
4180
}
4281
}
82+
for (const file of EXTRA_INPUTS) {
83+
if (existsSync(resolve(repoRoot, file))) seen.add(file);
84+
}
4385
return [...seen].sort();
4486
}
4587

@@ -53,7 +95,7 @@ export async function computeWebInputHash() {
5395
for (const file of files) {
5496
digest.update(file);
5597
digest.update('\0');
56-
digest.update(await readFile(resolve(webRoot, file)));
98+
digest.update(await readFile(resolve(repoRoot, file)));
5799
digest.update('\0');
58100
}
59101
return { hash: digest.digest('hex'), fileCount: files.length };

0 commit comments

Comments
 (0)