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>
223 lines
9.7 KiB
JavaScript
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);
|
|
});
|