Files
runicgateway.com/scripts/checkQuickstart.mjs
wtclaude 5e987518c6 fix(checks): read files through Gitea's contents endpoint, not raw
The three checks that read another repository -- checkFacts, checkQuickstart,
checkReference -- fetched source files from the API's `raw` route, which answers
`Cache-Control: public, max-age=21600`. The CDN in front of Gitea caches that,
so the checks can read a blob most of a working day old.

It bit on cutover day. checkFacts reported

    FAIL  moduleApi
          platform.json says : 1.9.0
          website main:server/src/modules/version.js says : 1.6.0

against a `main` that says 1.9.0 -- the served copy was two weeks old
(`cf-cache-status: HIT`, `Age: 15713`, `last-modified: 18 Aug`). No edit in this
repository could have made it pass, and the same run reported a bundle triple
that had already been republished as still current: a stale read fails BOTH
ways, and the false pass is the dangerous one.

The `contents` endpoint answers `private, must-revalidate`, which the CDN
bypasses, so it is always the ref's current blob. The cost is a JSON parse and a
base64 decode. checkReference's canonical-document existence loop already used
it, which is why that half was never affected.

PLAN.md 12 records the finding next to the check it constrains.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 12:41:01 -05:00

223 lines
9.7 KiB
JavaScript

#!/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=<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 file accessor checkFacts.mjs uses, and for the same reason -- including the CDN one. */
async function raw(repo, filePath, ref) {
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${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}`);
const meta = await res.json();
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
throw new Error(
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
);
}
return Buffer.from(meta.content, 'base64').toString('utf8');
}
/**
* `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);
});