fix: harden reproducible checkout races
This commit is contained in:
+107
-34
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user