147 lines
5.1 KiB
JavaScript
147 lines
5.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
|
|
import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs';
|
|
|
|
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
function run(command, args, options) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, options);
|
|
child.once('error', reject);
|
|
child.once('exit', (code, signal) => {
|
|
if (code === 0) resolve();
|
|
else
|
|
reject(
|
|
new Error(signal ? `next build terminated by ${signal}` : `next build exited ${code}`),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
|
|
async function requireRealDirectory(target, { allowMissing = false } = {}) {
|
|
try {
|
|
const stats = await lstat(target);
|
|
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
throw new Error(`${target} must be a real directory, not a symbolic link.`);
|
|
}
|
|
} catch (error) {
|
|
if (allowMissing && error.code === 'ENOENT') return;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function acquireBuildLock(root) {
|
|
const workRoot = path.join(root, '.mosaic-test-work');
|
|
const lock = path.join(workRoot, 'web-build.lock');
|
|
const nonce = randomUUID();
|
|
const owner = JSON.stringify({ pid: process.pid, nonce });
|
|
const deadline = Date.now() + 120_000;
|
|
await mkdir(workRoot, { recursive: true });
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
await mkdir(lock);
|
|
await writeFile(path.join(lock, 'owner.json'), owner, { mode: 0o600 });
|
|
return async () => {
|
|
const current = await readFile(path.join(lock, 'owner.json'), 'utf8');
|
|
if (current !== owner) throw new Error('Web build lock ownership changed before release.');
|
|
const released = `${lock}.released-${nonce}`;
|
|
await rename(lock, released);
|
|
await rm(released, { recursive: true, force: true });
|
|
};
|
|
} catch (error) {
|
|
if (error.code !== 'EEXIST') throw error;
|
|
let lockOwner;
|
|
try {
|
|
lockOwner = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8'));
|
|
} catch (ownerError) {
|
|
if (ownerError.code === 'ENOENT') {
|
|
await delay(25);
|
|
continue;
|
|
}
|
|
throw new Error(`Web build lock is unreadable at ${lock}.`, { cause: ownerError });
|
|
}
|
|
try {
|
|
process.kill(lockOwner.pid, 0);
|
|
} catch (processError) {
|
|
if (processError.code !== 'ESRCH') throw processError;
|
|
const stale = `${lock}.stale-${nonce}`;
|
|
try {
|
|
await rename(lock, stale);
|
|
await rm(stale, { recursive: true, force: true });
|
|
} catch (renameError) {
|
|
if (renameError.code !== 'ENOENT') throw renameError;
|
|
}
|
|
continue;
|
|
}
|
|
await delay(25);
|
|
}
|
|
}
|
|
throw new Error(`Timed out waiting for the web build lock at ${lock}.`);
|
|
}
|
|
|
|
export async function buildWeb({
|
|
root = scriptRoot,
|
|
fingerprint = sourceFingerprint,
|
|
runBuild = async (webDir) =>
|
|
run(path.join(webDir, 'node_modules', '.bin', 'next'), ['build'], {
|
|
cwd: webDir,
|
|
stdio: 'inherit',
|
|
}),
|
|
} = {}) {
|
|
const releaseLock = await acquireBuildLock(root);
|
|
try {
|
|
const webDir = path.join(root, 'apps', 'web');
|
|
const nextDir = path.join(webDir, '.next');
|
|
const certificationMarker = path.join(nextDir, '.mosaic-source-hash');
|
|
const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest');
|
|
const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`;
|
|
const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`;
|
|
const before = await fingerprint(root);
|
|
|
|
await requireRealDirectory(nextDir, { allowMissing: true });
|
|
await Promise.all([
|
|
rm(certificationMarker, { force: true }),
|
|
rm(symlinkManifest, { force: true }),
|
|
]);
|
|
await runBuild(webDir);
|
|
await requireRealDirectory(nextDir);
|
|
|
|
const after = await fingerprint(root);
|
|
if (after !== before) {
|
|
throw new Error(
|
|
'Web build inputs changed during next build; generated output was not certified.',
|
|
);
|
|
}
|
|
|
|
const manifestContents = await generatedSymlinkManifest(nextDir);
|
|
const certificationContents = `${JSON.stringify({
|
|
version: 1,
|
|
sourceFingerprint: before,
|
|
symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'),
|
|
})}\n`;
|
|
await Promise.all([
|
|
writeFile(certificationTemporary, certificationContents, { mode: 0o600 }),
|
|
writeFile(manifestTemporary, manifestContents, { mode: 0o600 }),
|
|
]);
|
|
// The certification marker is the commit point. Publishing the manifest first
|
|
// leaves interrupted builds untrusted because the marker remains absent.
|
|
await rename(manifestTemporary, symlinkManifest);
|
|
await rename(certificationTemporary, certificationMarker);
|
|
} finally {
|
|
await releaseLock();
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
await buildWeb();
|
|
}
|