ci: retire the standalone web image and build-web step (#1444)
ci/woodpecker/pr/ci Pipeline failed
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:
+6
-3
@@ -40,9 +40,12 @@ BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
|||||||
BETTER_AUTH_URL=http://localhost:14242
|
BETTER_AUTH_URL=http://localhost:14242
|
||||||
|
|
||||||
|
|
||||||
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
# ─── Web App (SPA) ───────────────────────────────────────────────────────────
|
||||||
# Public gateway URL — accessible from the browser, not just the server.
|
# Directory holding the built SPA bundle (vite build output). When set, the
|
||||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
|
# gateway serves the SPA same-origin; when unset (dev), run the Vite dev
|
||||||
|
# server (pnpm --filter @mosaicstack/web dev), which proxies to the gateway.
|
||||||
|
# safe-default: unset in dev — SPA serving is an opt-in production concern
|
||||||
|
#WEB_DIST_DIR=apps/web/dist
|
||||||
|
|
||||||
|
|
||||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -510,47 +510,3 @@ steps:
|
|||||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||||
# serialization invariant; add it to every new workspace consumer.
|
# serialization invariant; add it to every new workspace consumer.
|
||||||
- publish-next-npm
|
- publish-next-npm
|
||||||
|
|
||||||
build-web:
|
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
|
||||||
when: *image_build_when
|
|
||||||
environment:
|
|
||||||
REGISTRY_USER:
|
|
||||||
from_secret: REGISTRY_USERNAME
|
|
||||||
REGISTRY_PASS:
|
|
||||||
from_secret: REGISTRY_PASSWORD
|
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
|
||||||
commands:
|
|
||||||
- mkdir -p /kaniko/.docker
|
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
|
||||||
- |
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[publish] next web publish is sha-only"
|
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
|
||||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
|
||||||
fi
|
|
||||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
- verify
|
|
||||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
|
||||||
# its transform window and restores them at step end. Any step that
|
|
||||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
|
||||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
|
||||||
# pipeline 2648 raced a COPY inside the window and failed
|
|
||||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
|
||||||
# serialization invariant; add it to every new workspace consumer.
|
|
||||||
- publish-next-npm
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
FROM node:22-alpine AS base
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|
||||||
COPY apps/web/package.json ./apps/web/
|
|
||||||
COPY packages/ ./packages/
|
|
||||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
|
||||||
COPY scripts/ ./scripts/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm --filter @mosaicstack/web build
|
|
||||||
|
|
||||||
FROM base AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
|
||||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
|
||||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["node", "apps/web/server.js"]
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -199,7 +199,6 @@ test('the real publish pipeline: a failed verify provably blocks every publish e
|
|||||||
assert.deepEqual(effects.sort(), [
|
assert.deepEqual(effects.sort(), [
|
||||||
'build-appservice',
|
'build-appservice',
|
||||||
'build-gateway',
|
'build-gateway',
|
||||||
'build-web',
|
|
||||||
'publish-next-npm',
|
'publish-next-npm',
|
||||||
'publish-npm',
|
'publish-npm',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ test('the publish pipeline gates every publish effect behind exact-commit verifi
|
|||||||
assert.deepEqual(effects.sort(), [
|
assert.deepEqual(effects.sort(), [
|
||||||
'build-appservice',
|
'build-appservice',
|
||||||
'build-gateway',
|
'build-gateway',
|
||||||
'build-web',
|
|
||||||
'publish-next-npm',
|
'publish-next-npm',
|
||||||
'publish-npm',
|
'publish-npm',
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user