ci: retire the standalone web image and build-web step (#1444)
ci/woodpecker/pr/ci Pipeline failed

The gateway image now carries the SPA bundle, so web.Dockerfile,
scripts/build-web.mjs (+ test), and the publish build-web step go away.
The two CI-structure tests drop build-web from their expected-effects
lists. .env.example replaces the Next block with WEB_DIST_DIR docs.
This commit is contained in:
fred
2026-08-27 07:32:38 -05:00
parent 385b2b6886
commit c5a45784ac
7 changed files with 6 additions and 368 deletions
-146
View File
@@ -1,146 +0,0 @@
#!/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();
}
-149
View File
@@ -1,149 +0,0 @@
import assert from 'node:assert/strict';
import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { buildWeb } from './build-web.mjs';
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `build-web-${process.pid}`);
async function fixture(name) {
const root = path.join(fixtureRoot, name);
await mkdir(path.join(root, 'apps', 'web', '.next'), { recursive: true });
return root;
}
async function exists(target) {
try {
await access(target);
return true;
} catch {
return false;
}
}
test.after(async () => {
await rm(fixtureRoot, { recursive: true, force: true });
});
test('a successful web build atomically publishes its source and symlink certification', async () => {
const root = await fixture('success');
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} });
assert.deepEqual(JSON.parse(await readFile(marker, 'utf8')), {
version: 1,
sourceFingerprint: 'certified',
symlinkManifestHash: '8a5a375cea6a55d24bd5f875856da63feba33adbefb15a92a0007719b84bcf11',
});
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
});
test('a failed web build leaves no certification marker', async () => {
const root = await fixture('failure');
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
await writeFile(marker, 'stale\n');
await writeFile(manifest, 'stale\n');
await assert.rejects(
buildWeb({
root,
fingerprint: async () => 'before',
runBuild: async () => {
throw new Error('build failed');
},
}),
/build failed/,
);
assert.equal(await exists(marker), false);
assert.equal(await exists(manifest), false);
});
test('overlapping web builds are serialized while the marker remains absent', async () => {
const root = await fixture('overlap');
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
await writeFile(marker, 'stale\n');
await writeFile(manifest, 'stale\n');
let releaseFirst;
let secondEntered = false;
const firstEntered = new Promise((resolve) => {
releaseFirst = resolve;
});
let markFirstEntered;
const firstStarted = new Promise((resolve) => {
markFirstEntered = resolve;
});
const first = buildWeb({
root,
fingerprint: async () => 'certified',
runBuild: async () => {
markFirstEntered();
await firstEntered;
},
});
await firstStarted;
const second = buildWeb({
root,
fingerprint: async () => 'certified',
runBuild: async () => {
secondEntered = true;
},
});
await new Promise((resolve) => setTimeout(resolve, 75));
assert.equal(secondEntered, false);
assert.equal(await exists(marker), false);
assert.equal(await exists(manifest), false);
releaseFirst();
await Promise.all([first, second]);
assert.equal(secondEntered, true);
assert.equal(JSON.parse(await readFile(marker, 'utf8')).sourceFingerprint, 'certified');
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
});
test('a build that replaces .next with a symbolic link cannot publish outside the checkout', async () => {
const root = await fixture('symbolic-next');
const nextDir = path.join(root, 'apps', 'web', '.next');
const outside = path.join(root, 'outside-generated');
await mkdir(outside);
await assert.rejects(
buildWeb({
root,
fingerprint: async () => 'certified',
runBuild: async () => {
await rm(nextDir, { recursive: true });
await symlink(outside, nextDir);
},
}),
/must be a real directory/,
);
assert.equal(await exists(path.join(outside, '.mosaic-source-hash')), false);
assert.equal(await exists(path.join(outside, '.mosaic-symlink-manifest')), false);
});
test('inputs changed during a web build are not certified', async () => {
const root = await fixture('changed-inputs');
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
const fingerprints = ['before', 'after'];
await assert.rejects(
buildWeb({
root,
fingerprint: async () => fingerprints.shift(),
runBuild: async () => {},
}),
/inputs changed during next build/,
);
assert.equal(await exists(marker), false);
assert.equal(await exists(manifest), false);
});
-1
View File
@@ -199,7 +199,6 @@ test('the real publish pipeline: a failed verify provably blocks every publish e
assert.deepEqual(effects.sort(), [
'build-appservice',
'build-gateway',
'build-web',
'publish-next-npm',
'publish-npm',
]);
-1
View File
@@ -112,7 +112,6 @@ test('the publish pipeline gates every publish effect behind exact-commit verifi
assert.deepEqual(effects.sort(), [
'build-appservice',
'build-gateway',
'build-web',
'publish-next-npm',
'publish-npm',
]);