#!/usr/bin/env node /** * checkQuickstart.mjs — PLAN.md §12, added in phase 7 for D35. * * The org lead chose a self-contained quickstart: `/docs/getting-started/install-the-site/` * prints a Compose file and an environment file the reader can copy without going to * another repository first. That is the one place this site knowingly keeps a copy of * somebody else's file, and §1 is a long argument about why copies rot. * * So the copy is checked rather than trusted. Every service, image, published port, mount * and environment key in `src/data/quickstart.mjs` is re-read from `website`'s own * `docker-compose.yml` and `.env.example` on `main`, over the Gitea API — never from a * working tree, per §1's process rule — and any disagreement fails the build. * * It checks in BOTH directions, which is the property that keeps it honest: * * - every value the quickstart states must match upstream's; * - every service and variable upstream has must be either included or listed as * deliberately omitted, WITH a reason. A new variable in `.env.example` therefore turns * this repo red until someone decides whether a first install needs it — the same * intent as checkFacts.mjs and the Integration Kit's checkCoreApi.js; * - and an entry in either omission list that upstream no longer has fails too, so the * lists cannot rot into permanent exemptions. * * GITEA_TOKEN= node scripts/checkQuickstart.mjs * * Anonymous raw fetches fail on this instance, so the token is required. A check that * silently skips itself is worse than no check. */ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { parse as parseYaml } from 'yaml'; import { compose, services, omittedServices, env, envOmitted, notInUpstreamEnvExample, } from '../src/data/quickstart.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8')); const BASE = platform.gitea.base; const ORG = platform.gitea.org; const TOKEN = process.env.GITEA_TOKEN?.trim(); const failures = []; const checked = []; const ok = (what) => checked.push(what); const fail = (what, detail) => failures.push({ what, detail }); /** Same raw-file accessor checkFacts.mjs uses, and for the same reason. */ async function raw(repo, filePath, ref) { const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`; const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`); return res.text(); } /** * `KEY=value` lines from a dotenv file. Commented-out suggestions (`# MODULES=…`) are NOT * keys: they are prose about a variable, and treating them as declared would make the * omission list argue with documentation rather than with configuration. */ function envKeys(text) { const out = new Map(); for (const line of text.split(/\r?\n/)) { const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/); if (m) out.set(m[1], m[2].replace(/\s+#.*$/, '').trim()); } return out; } /** Published host:container port pairs, as written. */ const portsOf = (svc) => (svc.ports ?? []).map(String); /** Container-side paths of every volume entry, which is what a reader's site depends on. */ const mountTargets = (svc) => (svc.volumes ?? []).map((v) => String(v).split(':')[1]); async function run() { if (!TOKEN) { console.error('checkQuickstart: GITEA_TOKEN is not set. This check cannot run anonymously.'); process.exit(2); } const upstreamComposeText = await raw('website', 'docker-compose.yml', 'main'); const upstreamEnvText = await raw('website', '.env.example', 'main'); const upstream = parseYaml(upstreamComposeText); const ours = parseYaml(compose); if (!upstream?.services) throw new Error('website main:docker-compose.yml has no services block — the file shape changed.'); // ── 1. The services we ship ─────────────────────────────────────────────── for (const name of services) { const mine = ours.services?.[name]; const theirs = upstream.services?.[name]; if (!mine) { fail(`service ${name}`, 'declared in quickstart.mjs but absent from its own compose text'); continue; } if (!theirs) { fail(`service ${name}`, 'no longer exists in website main:docker-compose.yml'); continue; } if (String(mine.image) !== String(theirs.image)) { fail(`service ${name}: image`, `quickstart "${mine.image}" vs upstream "${theirs.image}"`); } else ok(`service ${name}: image`); const minePorts = portsOf(mine).join(', '); const theirPorts = portsOf(theirs).join(', '); if (minePorts !== theirPorts) { fail(`service ${name}: ports`, `quickstart [${minePorts}] vs upstream [${theirPorts}]`); } else ok(`service ${name}: ports`); // Every mount we keep must land where upstream lands it. Upstream may have mounts we // dropped (the schema bind, which needs a checkout); dropping one is safe, moving one // is not. for (const target of mountTargets(mine)) { if (!mountTargets(theirs).includes(target)) { fail(`service ${name}: mount ${target}`, 'upstream mounts nothing at that container path'); } else ok(`service ${name}: mount ${target}`); } for (const [key, value] of Object.entries(mine.environment ?? {})) { const theirValue = theirs.environment?.[key]; if (theirValue === undefined) { fail(`service ${name}: ${key}`, 'upstream no longer sets it in the compose file'); } else if (String(theirValue) !== String(value)) { fail(`service ${name}: ${key}`, `quickstart "${value}" vs upstream "${theirValue}"`); } else ok(`service ${name}: ${key}`); } } // ── 2. The services we left out, and any that appeared ──────────────────── const upstreamServiceNames = Object.keys(upstream.services); for (const [name, reason] of Object.entries(omittedServices)) { if (!upstreamServiceNames.includes(name)) { fail(`omitted service ${name}`, 'upstream no longer has this service — drop it from omittedServices'); } else if (!reason?.trim()) { fail(`omitted service ${name}`, 'listed without a reason'); } else ok(`omitted service ${name}`); } for (const name of upstreamServiceNames) { if (!services.includes(name) && !(name in omittedServices)) { fail(`service ${name}`, 'is new in website main:docker-compose.yml — include it in the quickstart or record why not'); } } // ── 3. The environment file ─────────────────────────────────────────────── const theirEnv = envKeys(upstreamEnvText); const mineEnv = new Map(env.map((e) => [e.key, e])); for (const entry of env) { const theirValue = theirEnv.get(entry.key); const excused = notInUpstreamEnvExample[entry.key]; if (theirValue === undefined) { if (excused) { ok(`env ${entry.key} (absent upstream, declared: ${excused})`); } else { fail(`env ${entry.key}`, 'not in website main:.env.example — either it is gone, or it needs a reason in notInUpstreamEnvExample'); } continue; } if (excused) { fail( `env ${entry.key}`, 'is now in website main:.env.example — remove it from notInUpstreamEnvExample, and re-read the prose that describes it as missing', ); continue; } // A value an operator is told to replace is a placeholder on both sides; comparing two // placeholders would only ever assert that two people picked the same filler words. if (!entry.fill && theirValue !== String(entry.value)) { fail(`env ${entry.key}`, `quickstart "${entry.value}" vs upstream "${theirValue}"`); } else ok(`env ${entry.key}`); } for (const [key, reason] of Object.entries(envOmitted)) { if (!theirEnv.has(key)) { fail(`omitted env ${key}`, 'upstream .env.example no longer sets it — drop it from envOmitted'); } else if (!reason?.trim()) { fail(`omitted env ${key}`, 'listed without a reason'); } else ok(`omitted env ${key}`); } for (const key of theirEnv.keys()) { if (!mineEnv.has(key) && !(key in envOmitted)) { fail(`env ${key}`, 'is new in website main:.env.example — add it to the quickstart or record why a first install does not need it'); } } // ── Report ──────────────────────────────────────────────────────────────── if (failures.length === 0) { console.log(`checkQuickstart: ${checked.length} checks passed against website main.`); return; } console.error(`checkQuickstart: ${failures.length} disagreement(s) with website main:\n`); for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`); console.error( '\nThe quickstart on /docs/getting-started/install-the-site/ is a copy of website\'s own\n' + 'deployment files (D35). Either update src/data/quickstart.mjs to match, or record the\n' + 'difference with a reason. Do not "fix" the check.', ); process.exit(1); } run().catch((err) => { console.error(`checkQuickstart: ${err.message}`); process.exit(1); });