From c5a45784acabd7c310da65de372062b504ff2f29 Mon Sep 17 00:00:00 2001 From: fred Date: Thu, 27 Aug 2026 07:32:38 -0500 Subject: [PATCH] ci: retire the standalone web image and build-web step (#1444) 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. --- .env.example | 9 +- .woodpecker/publish.yml | 44 ------- docker/web.Dockerfile | 24 ---- scripts/build-web.mjs | 146 ----------------------- scripts/build-web.test.mjs | 149 ------------------------ scripts/publish-gate-structure.test.mjs | 1 - scripts/verify-release.test.mjs | 1 - 7 files changed, 6 insertions(+), 368 deletions(-) delete mode 100644 docker/web.Dockerfile delete mode 100644 scripts/build-web.mjs delete mode 100644 scripts/build-web.test.mjs diff --git a/.env.example b/.env.example index 440d185e..2a5e425e 100644 --- a/.env.example +++ b/.env.example @@ -40,9 +40,12 @@ BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string BETTER_AUTH_URL=http://localhost:14242 -# ─── Web App (Next.js) ─────────────────────────────────────────────────────── -# Public gateway URL — accessible from the browser, not just the server. -NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242 +# ─── Web App (SPA) ─────────────────────────────────────────────────────────── +# Directory holding the built SPA bundle (vite build output). When set, the +# 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 ─────────────────────────────────────────────────────────── diff --git a/.woodpecker/publish.yml b/.woodpecker/publish.yml index f4e99282..0b1afdc4 100644 --- a/.woodpecker/publish.yml +++ b/.woodpecker/publish.yml @@ -510,47 +510,3 @@ steps: # ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the # serialization invariant; add it to every new workspace consumer. - 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 diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile deleted file mode 100644 index 8661d367..00000000 --- a/docker/web.Dockerfile +++ /dev/null @@ -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"] diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs deleted file mode 100644 index dde9d60c..00000000 --- a/scripts/build-web.mjs +++ /dev/null @@ -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(); -} diff --git a/scripts/build-web.test.mjs b/scripts/build-web.test.mjs deleted file mode 100644 index 2a53a107..00000000 --- a/scripts/build-web.test.mjs +++ /dev/null @@ -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); -}); diff --git a/scripts/publish-gate-structure.test.mjs b/scripts/publish-gate-structure.test.mjs index 68a30f80..0c6280d0 100644 --- a/scripts/publish-gate-structure.test.mjs +++ b/scripts/publish-gate-structure.test.mjs @@ -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', ]); diff --git a/scripts/verify-release.test.mjs b/scripts/verify-release.test.mjs index 677c65af..c614a7c9 100644 --- a/scripts/verify-release.test.mjs +++ b/scripts/verify-release.test.mjs @@ -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', ]);