publish promotes development's content, then builds

The admin edits development and the build renders production, so nothing written in the panel could
reach the live site. Build was only half of publishing, and the missing half was invisible: it
succeeded, against the content somebody last promoted, and a successful build of yesterday's site
reads as a broken renderer.

Publish copies the site's content tables across and then builds. Two things it will not copy: `users`,
because production's logins are not development's and nobody should be locked out of the live site to
publish a photograph; and `contact_submissions`, the legacy inbox, because development's is empty and
production's is not — copying it would destroy real messages in order to publish nothing.

The table list is written out rather than derived. A table added later should be considered rather
than swept along, and an empty development table quietly overwriting production-only data is
discovered far too late to fix.

Order is load-bearing in two places. Production is dumped before anything is destroyed, and the
publish refuses to start if that dump fails. Development is read BEFORE production is cleared, so a
failure to read leaves production untouched rather than empty for a reason that had nothing to do with
it. `TRUNCATE ... RESTART IDENTITY`, so the sequences the copy sets are not merely ahead of the rows
it inserts — otherwise the collision arrives on the client's next save, hours later, somewhere else.

`backupAfterPublish` has been sitting in backups.ts without a caller since the day "On publish" became
a backup schedule. This is the caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 21:52:08 +01:00
co-authored by Claude Opus 5
parent ccd4e23217
commit 472fd9cb88
3 changed files with 243 additions and 5 deletions
+194
View File
@@ -0,0 +1,194 @@
import { mkdir, rm, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { db } from 'officerdb/db';
import { fofosAdminSettings } from '../db/schema';
import { readDatabases } from './databases';
import { backupAfterPublish, defaultBackupDir } from './backups';
import { buildSite } from './static-site';
/**
* Publish: copy the site's content from development to production, then build.
*
* ── Why this exists ──
*
* The admin edits development and the build renders production, so nothing written in the panel could
* reach the live site. Build was only half of publishing. Without this the first symptom is a
* successful build of yesterday's content, which reads as a broken build and sends somebody looking at
* the renderer.
*
* Copy and build are ONE action rather than two buttons in an order nobody is told. A copy without a
* build changes nothing anybody can see, and a build without a copy is the bug above.
*
* ── What is copied, and what is deliberately not ──
*
* Every table the site owns EXCEPT two:
*
* `users` — accounts and password hashes. Production's logins are not development's,
* and overwriting them would lock somebody out of the live site in order to
* publish a photograph.
* `contact_submissions` — the legacy inbox. Messages live in SQLite now and this table is only what
* predates that. Development's copy is empty, so copying it over production
* would destroy real messages to publish nothing — the most expensive thing
* this file could possibly do.
*
* The list is explicit rather than "every table except". A table added later should be considered
* rather than swept along, and the failure mode of the other choice — production-only data silently
* overwritten by an empty development table — is discovered far too late to fix.
*/
/** The site's content. Parents before children, so the restore satisfies its own foreign keys. */
const CONTENT_TABLES = [
'home_content',
'home_gallery_media',
'about_content',
'galleries',
'gallery_items',
'collections',
'collection_items',
'collection_item_media',
'gallery_pieces',
'gallery_piece_media',
'drops',
'drop_media',
'collabs',
'collab_media',
'service_nodes',
'partners',
'social_links',
'contact_details',
] as const;
/** Long enough for a slow dump of a few megabytes, short enough that a wedged psql is not forever. */
const TIMEOUT_MS = 5 * 60 * 1000;
/**
* Connection details as ENVIRONMENT, not as a URL argument.
*
* `libpq` reads these, so the password never appears in `ps` or in anything that logs a command line.
* The database name is the one part passed as an argument, because it is the part that is not secret
* and the part that changes between the two calls.
*/
const connection = (): Record<string, string> => {
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
if (!raw) throw new Error('The platform has no Postgres connection configured.');
const url = new URL(raw);
return {
PGHOST: url.hostname,
PGPORT: url.port || '5432',
PGUSER: decodeURIComponent(url.username),
PGPASSWORD: decodeURIComponent(url.password),
};
};
const run = async (argv: string[], env: Record<string, string>) => {
const proc = Bun.spawn(argv, {
stdout: 'pipe',
stderr: 'pipe',
stdin: 'ignore',
env: { ...process.env, ...env },
});
const timer = setTimeout(() => proc.kill(), TIMEOUT_MS);
try {
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { ok: code === 0, output: `${stdout}${stderr}`.trim() };
} finally {
clearTimeout(timer);
}
};
export type PublishResult = { ok: boolean; output: string };
export async function publish(secrets: Parameters<typeof buildSite>[0]): Promise<PublishResult> {
const row = (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null;
const status = await readDatabases();
const production = status.production.name;
const development = status.development.name;
if (!production || !status.production.exists) throw new Error('The production database does not exist.');
if (!development || !status.development.exists) throw new Error('The development database does not exist.');
const env = connection();
const dir = row?.backupDir ?? defaultBackupDir();
await mkdir(dir, { recursive: true });
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '').replace('T', '-');
const lines: string[] = [];
/*
── 1. A way back, before anything is destroyed ──
This truncates and replaces. A dump taken immediately before is the difference between a mistake
that costs a minute and one that costs everything the client has written, and the whole database is
a few megabytes. If it cannot be taken, nothing else happens.
*/
const safety = join(dir, `${production}-before-publish-${stamp}.sql`);
const dumped = await run(['pg_dump', '-f', safety, production], env);
if (!dumped.ok) {
return { ok: false, output: `Refusing to publish: production could not be backed up first.\n${dumped.output}` };
}
lines.push(`backed up production → ${safety}`);
/*
── 2. Read development BEFORE clearing production ──
Ordered this way on purpose. Dumping second would mean a failure to read development leaves
production already empty — the site down, for a reason that had nothing to do with production. Now
the worst a bad dump can do is stop before touching anything.
*/
const staged = join(dir, `.publish-${stamp}.sql`);
const dumpArgs = CONTENT_TABLES.flatMap((table) => ['--table', table]);
const copy = await run(['pg_dump', '--data-only', ...dumpArgs, '-f', staged, development], env);
if (!copy.ok) {
await rm(staged, { force: true });
return { ok: false, output: `${lines.join('\n')}\nCould not read development: ${copy.output}` };
}
const size = (await stat(staged).catch(() => null))?.size ?? 0;
lines.push(`read ${CONTENT_TABLES.length} tables from ${development} (${size} bytes)`);
try {
/*
── 3. Clear, in one statement ──
All of them together, because foreign keys between them make any single truncate fail on its own.
`RESTART IDENTITY` so the sequence values the dump then sets are not merely ahead of the rows it
inserts — a copy whose sequences still start at 1 collides on the client's next save, hours later,
somewhere else entirely.
*/
const cleared = await run(
['psql', '-v', 'ON_ERROR_STOP=1', '-c', `TRUNCATE ${CONTENT_TABLES.join(', ')} RESTART IDENTITY CASCADE;`, production],
env,
);
if (!cleared.ok) return { ok: false, output: `${lines.join('\n')}\nCould not clear production: ${cleared.output}` };
// ── 4. Load ──
const loaded = await run(['psql', '-v', 'ON_ERROR_STOP=1', '-f', staged, production], env);
if (!loaded.ok) {
return {
ok: false,
output: `${lines.join('\n')}\nThe copy failed and production content is now EMPTY.\nRestore it with:\n psql ${production} -f ${safety}\n\n${loaded.output}`,
};
}
lines.push('copied development content into production');
} finally {
await rm(staged, { force: true });
}
// ── 5. Render it ──
const built = await buildSite(secrets);
lines.push(built.output);
if (!built.ok) return { ok: false, output: lines.join('\n') };
/*
The configured backup, if "on publish" is what it is set to.
`backupAfterPublish` decides that for itself and swallows its own failures, so this calls it
unconditionally: a backup that could not be taken must not turn into a publish that appears to have
failed, when the thing the person asked for actually worked.
*/
await backupAfterPublish();
return { ok: true, output: lines.join('\n') };
}
+17
View File
@@ -14,6 +14,7 @@ import { watchMailbox } from './messages';
import { watchOriginals } from './originals-watch';
import { readServers, savePorts, siteSecrets, startServer, stopServer } from './servers';
import { buildSite, readStatic, startStatic, stopStatic } from './static-site';
import { publish } from './publish';
import { join, resolve, sep } from 'node:path';
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
@@ -514,6 +515,22 @@ router.post('/_config/static/build', async (ctx) => {
}
});
/*
Publish: promote development's content to production, then build.
A route of its own rather than a flag on build, and BEFORE `/:action` below — that handler would
otherwise catch `publish` and reject it as "must be start or stop", which is the kind of 400 nobody
can read backwards to a route-ordering mistake.
*/
router.post('/_config/static/publish', async (ctx) => {
try {
const result = await publish(await siteSecrets());
return ctx.json({ ...result, status: await readStatic() }, result.ok ? 200 : 400);
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
router.post('/_config/static/:action', async (ctx) => {
const action = ctx.req.param('action');
if (action !== 'start' && action !== 'stop') {
+32 -5
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { CircleDot, Hammer, Loader2, Play, Square } from 'lucide-react';
import { CircleDot, Hammer, Loader2, Play, Square, Upload } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { readableError } from './errors';
@@ -19,6 +19,17 @@ import { Console } from './Console';
*
* It renders every route through the real app and copies every referenced file. Said in the button,
* because a spinner sitting for three minutes with no explanation is indistinguishable from a hang.
*
* ── Publish and Build are different things, and Publish is the one to press ──
*
* The admin edits DEVELOPMENT. The build renders PRODUCTION. So Build alone renders whatever was last
* promoted, which after a morning of edits is yesterday's site — a successful build of the wrong
* content, which reads as a broken renderer and is the reason this needed a second button rather than
* a note somewhere.
*
* Publish copies the content across and then builds. Build stays because rebuilding production without
* promoting anything is a real thing to want — a template change, a failed build to retry — but it is
* the secondary action here and looks like one.
*/
type Static = {
@@ -38,7 +49,7 @@ export const StaticBlock = () => {
clientRef.current = client;
const [status, setStatus] = useState<Static | null>(null);
const [busy, setBusy] = useState<'build' | 'start' | 'stop' | null>(null);
const [busy, setBusy] = useState<'publish' | 'build' | 'start' | 'stop' | null>(null);
const [output, setOutput] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
@@ -54,12 +65,18 @@ export const StaticBlock = () => {
void load();
}, [load]);
const act = async (what: 'build' | 'start' | 'stop') => {
const act = async (what: 'publish' | 'build' | 'start' | 'stop') => {
setBusy(what);
setFailed(false);
setOutput(what === 'build' ? 'Rendering every page. This takes a few minutes.' : null);
setOutput(
what === 'publish'
? 'Copying content to production, then rendering every page. This takes a few minutes.'
: what === 'build'
? 'Rendering every page. This takes a few minutes.'
: null,
);
try {
const path = what === 'build' ? '/fofos-admin/_config/static/build' : `/fofos-admin/_config/static/${what}`;
const path = `/fofos-admin/_config/static/${what}`;
const result = await clientRef.current.post<{ ok: boolean; output: string; status: Static }>(path, {});
setStatus(result.status);
setOutput(result.output);
@@ -100,6 +117,16 @@ export const StaticBlock = () => {
</div>
<div className="flex shrink-0 gap-2">
<Button
size="sm"
className="w-24 justify-center"
onClick={() => void act('publish')}
disabled={busy !== null}
title="Copy the content you have been editing into production, then build"
>
{busy === 'publish' ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
{busy === 'publish' ? 'Publishing' : 'Publish'}
</Button>
<Button
variant="outline"
size="sm"