Files
jeanlucmakiola.de/scripts/sync-external.mjs
T
makiolaj 8a1eb54a45 Render the app privacy policies from the app repos
Each policy now has exactly one copy: docs/PRIVACY.md in the app's own
repository. The pages keep their URLs and chrome and render that file
through a content collection, so the published page and the app's own
documentation cannot drift.

scripts/sync-external.mjs shallow-clones both repos into external/ from
prebuild and predev — not from CI: Coolify builds the site from the repo,
so a checkout that only ran in a Gitea job would never reach the deploy.
It falls back to the raw file if git is unavailable, and takes <APP>_REF
or <APP>_LOCAL for work against a branch or an unpushed working copy.

A missing, empty or malformed policy fails the build, verified against the
real image: the deploy stops rather than publishing an empty privacy page.
2026-09-09 16:33:11 +02:00

147 lines
5.9 KiB
JavaScript

// Fetch the app repositories whose Markdown this site renders.
//
// Each app's docs/PRIVACY.md is the single copy of that policy: the app repo
// owns it, and this site renders it through a content collection (see
// src/content.config.ts). Nothing here is authored in this repository, and
// nothing is written back — this only makes the sources present at build time.
//
// It runs from `prebuild` and `predev`, NOT from CI: Coolify builds the site
// straight from the repo, so a checkout that only happened in a Gitea job
// would never reach the deployed page.
//
// Refs default to `main`. Point a source at a branch with e.g.
// AGENDULA_REF=feat/caldav-sync, or at a working copy on this machine with
// AGENDULA_LOCAL=/path/to/agendula (which skips the network entirely).
//
// The guardrail is the point of the script: if a policy source is missing,
// empty, or malformed, the build FAILS. A privacy page that silently renders
// nothing is the one outcome this design exists to prevent.
import { execFileSync } from 'node:child_process';
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const EXTERNAL = join(ROOT, 'external');
const SOURCES = [
{
name: 'calendula',
repo: 'https://codeberg.org/jlmakiola/calendula.git',
raw: (ref) => `https://codeberg.org/jlmakiola/calendula/raw/branch/${ref}/docs/PRIVACY.md`,
file: 'docs/PRIVACY.md',
},
{
name: 'agendula',
repo: 'https://codeberg.org/jlmakiola/agendula.git',
raw: (ref) => `https://codeberg.org/jlmakiola/agendula/raw/branch/${ref}/docs/PRIVACY.md`,
file: 'docs/PRIVACY.md',
},
];
const REQUIRED_FRONTMATTER = ['title', 'description', 'updated'];
const MIN_BODY_CHARS = 400;
const env = (name, suffix) => process.env[`${name.toUpperCase()}_${suffix}`]?.trim() || '';
const log = (msg) => console.log(`[external] ${msg}`);
const git = (args, cwd) =>
execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
/** Shallow single-branch clone, or a shallow fetch if it is already there. */
function syncRepo(source, ref, dir) {
if (existsSync(join(dir, '.git'))) {
git(['fetch', '--depth', '1', 'origin', ref], dir);
git(['checkout', '--detach', 'FETCH_HEAD'], dir);
return `fetched ${ref}`;
}
rmSync(dir, { recursive: true, force: true });
git(['clone', '--depth', '1', '--single-branch', '--branch', ref, source.repo, dir]);
return `cloned ${ref}`;
}
/** Last resort when git is unavailable or the clone fails: the raw file. */
async function fetchRaw(source, ref, dir) {
const res = await fetch(source.raw(ref), { headers: { Accept: 'text/plain' } });
if (!res.ok) throw new Error(`raw fetch returned HTTP ${res.status}`);
const body = await res.text();
const target = join(dir, source.file);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, body);
return `fetched ${source.file} at ${ref} over HTTPS`;
}
/** Parse just enough frontmatter to know the entry will satisfy the schema. */
function validate(source, dir) {
const path = join(dir, source.file);
if (!existsSync(path)) return `${source.file} is missing`;
const text = readFileSync(path, 'utf8');
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return `${source.file} has no frontmatter block`;
const [, frontmatter, body] = match;
const missing = REQUIRED_FRONTMATTER.filter(
(key) => !new RegExp(`^${key}:\\s*\\S`, 'm').test(frontmatter)
);
if (missing.length) return `${source.file} frontmatter is missing: ${missing.join(', ')}`;
// Strip HTML comments — the maintainer note must not count as content.
const prose = body.replace(/<!--[\s\S]*?-->/g, '').trim();
if (prose.length < MIN_BODY_CHARS) return `${source.file} body is empty or too short`;
if (!/^##\s/m.test(prose)) return `${source.file} body has no sections`;
return null;
}
let failed = false;
mkdirSync(EXTERNAL, { recursive: true });
for (const source of SOURCES) {
const dir = join(EXTERNAL, source.name);
const local = env(source.name, 'LOCAL');
const ref = env(source.name, 'REF') || 'main';
if (local) {
// Development escape hatch: render a working copy on this machine, for a
// policy edit that is not pushed yet. Never set in CI or on the server.
const target = isAbsolute(local) ? local : resolve(ROOT, local);
if (!existsSync(target)) {
console.error(`[external] ${source.name}: ${source.name.toUpperCase()}_LOCAL points at ${target}, which does not exist`);
failed = true;
continue;
}
if (existsSync(dir) || lstatSync(dir, { throwIfNoEntry: false })) {
rmSync(dir, { recursive: true, force: true });
}
symlinkSync(target, dir, 'dir');
log(`${source.name}: local override -> ${target}`);
} else {
try {
log(`${source.name}: ${syncRepo(source, ref, dir)}`);
} catch (error) {
const reason = (error.stderr?.toString() || error.message).split('\n')[0];
log(`${source.name}: git failed (${reason}); falling back to the raw file`);
try {
log(`${source.name}: ${await fetchRaw(source, ref, dir)}`);
} catch (fallbackError) {
log(`${source.name}: fallback failed (${fallbackError.message})`);
}
}
}
const problem = validate(source, dir);
if (problem) {
console.error(`[external] ${source.name}: ${problem}`);
failed = true;
} else {
log(`${source.name}: ${source.file} ok`);
}
}
if (failed) {
console.error(
'\n[external] A privacy policy source is unusable, so the build stops here.\n' +
' These pages must never render empty — fix the source, or set\n' +
' <APP>_REF / <APP>_LOCAL if you are working against a branch.'
);
process.exit(1);
}