fix: harden reproducible checkout races

This commit is contained in:
2026-07-31 18:25:20 -05:00
parent ece7653ca0
commit 0f7061195b
8 changed files with 413 additions and 72 deletions
+107 -34
View File
@@ -1,13 +1,17 @@
#!/usr/bin/env node
import { constants } from 'node:fs';
import { access, lstat, readdir } from 'node:fs/promises';
import { access, lstat, readFile, readdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
export const MISSING_DEPS_EXIT = 42;
export const GENERATED_STATE_EXIT = 43;
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
async function entries(root) {
const result = [];
async function walk(current) {
@@ -28,17 +32,90 @@ async function entries(root) {
return result;
}
async function fileMtimeRange(paths) {
let newest = 0;
let oldest = Number.POSITIVE_INFINITY;
for (const target of paths) {
const stats = await lstat(target);
if (stats.isFile()) {
newest = Math.max(newest, stats.mtimeMs);
oldest = Math.min(oldest, stats.mtimeMs);
const webSourceRoots = (root) => [
path.join(root, 'apps', 'web', 'src'),
path.join(root, 'apps', 'web', 'public'),
path.join(root, 'apps', 'web', 'next-env.d.ts'),
path.join(root, 'apps', 'web', 'next.config.ts'),
path.join(root, 'apps', 'web', 'postcss.config.mjs'),
path.join(root, 'apps', 'web', 'package.json'),
path.join(root, 'apps', 'web', 'tsconfig.json'),
path.join(root, 'packages', 'design-tokens', 'src'),
path.join(root, 'packages', 'design-tokens', 'package.json'),
path.join(root, 'packages', 'design-tokens', 'tsconfig.json'),
path.join(root, 'package.json'),
path.join(root, 'tsconfig.base.json'),
path.join(root, 'pnpm-lock.yaml'),
path.join(root, 'pnpm-workspace.yaml'),
path.join(root, 'turbo.json'),
];
// next.config.ts currently reads no server-only environment. Add any future
// server-side build inputs here; all resolved NEXT_PUBLIC_* inputs are automatic.
const serverBuildEnvironmentKeys = [];
function publicBuildEnvironment(root) {
const webDir = path.join(root, 'apps', 'web');
const requireFromWeb = createRequire(path.join(scriptRoot, 'apps', 'web', 'package.json'));
const requireFromNext = createRequire(requireFromWeb.resolve('next/package.json'));
const { loadEnvConfig, resetEnv, updateInitialEnv } = requireFromNext('@next/env');
const originalEnvironment = { ...process.env };
updateInitialEnv(originalEnvironment);
try {
const { combinedEnv } = loadEnvConfig(webDir, false, { info() {}, error() {} }, true);
return Object.fromEntries(
Object.entries(combinedEnv).filter(
([key, value]) =>
value !== undefined &&
(key.startsWith('NEXT_PUBLIC_') || serverBuildEnvironmentKeys.includes(key)),
),
);
} finally {
resetEnv();
}
}
export async function sourceFingerprint(root = process.cwd()) {
const files = [];
for (const sourceRoot of webSourceRoots(root)) {
try {
const stats = await lstat(sourceRoot);
if (stats.isSymbolicLink()) {
throw new Error(
`Web build input must not be a symbolic link: ${path.relative(root, sourceRoot)}`,
);
}
if (stats.isFile()) files.push(sourceRoot);
if (stats.isDirectory()) {
for (const target of await entries(sourceRoot)) {
const targetStats = await lstat(target);
if (targetStats.isSymbolicLink()) {
throw new Error(
`Web build input must not be a symbolic link: ${path.relative(root, target)}`,
);
}
if (targetStats.isFile()) files.push(target);
}
}
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
return { newest, oldest: Number.isFinite(oldest) ? oldest : 0 };
const digest = createHash('sha256');
for (const [key, value] of Object.entries(publicBuildEnvironment(root)).sort()) {
digest.update(`env:${key}\0${value.length}\0${value}\0`);
}
for (const target of files.sort()) {
const contents = await readFile(target);
digest.update(path.relative(root, target).split(path.sep).join('/'));
digest.update('\0');
digest.update(String(contents.length));
digest.update('\0');
digest.update(contents);
digest.update('\0');
}
return digest.digest('hex');
}
export async function runPreflight({ root = process.cwd(), uid = process.getuid?.() } = {}) {
@@ -59,6 +136,17 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid?
};
}
const buildLock = path.join(root, '.mosaic-test-work', 'web-build.lock');
try {
await lstat(buildLock);
return {
code: GENERATED_STATE_EXIT,
message: `MOSAIC_PREFLIGHT_GENERATED_STATE: web build is in progress or interrupted at ${buildLock}; wait for it to finish or rerun pnpm build to recover the stale lock`,
};
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
const nextDir = path.join(root, 'apps', 'web', '.next');
let generated = [];
try {
@@ -76,34 +164,19 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid?
}
}
const sourceRoots = [
path.join(root, 'apps', 'web', 'src'),
path.join(root, 'apps', 'web', 'next.config.ts'),
path.join(root, 'apps', 'web', 'package.json'),
path.join(root, 'apps', 'web', 'tsconfig.json'),
];
const source = [];
for (const sourceRoot of sourceRoots) {
try {
const stats = await lstat(sourceRoot);
source.push(sourceRoot);
if (stats.isDirectory()) source.push(...(await entries(sourceRoot)));
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
let generatedFingerprint = null;
try {
generatedFingerprint = (
await readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8')
).trim();
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
const generatedTypes = generated.filter((target) =>
target.startsWith(path.join(nextDir, 'types') + path.sep),
);
const sourceMtime = await fileMtimeRange(source);
const generatedMtime = await fileMtimeRange(generatedTypes);
const stale =
source.length > 0 && generatedTypes.length > 0 && sourceMtime.newest > generatedMtime.oldest;
const stale = generatedFingerprint !== (await sourceFingerprint(root));
if (foreign.length > 0 || stale) {
const reasons = [
foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '',
stale ? 'generated output is older than web source/configuration' : '',
stale ? 'generated source fingerprint does not match web source/configuration' : '',
].filter(Boolean);
return {
code: GENERATED_STATE_EXIT,