diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 4be9d4c..f541b82 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -23,8 +23,24 @@ jobs: - run: npm ci - name: Publication gate run: node tools/check-docs.mjs + # Two outcomes, two meanings. A mismatch is documentation drift and must + # never publish. Unreachable means a runner could not open a socket to + # the product, which says nothing about the facts and used to block the + # publication of correct documentation while the site served an older + # build. The facts stay guarded either way: the publication gate above + # pins every one of them offline and has already run. - name: Documented facts match the live product contract - run: node tools/check-facts-live.mjs + run: | + set +e + node tools/check-facts-live.mjs + status=$? + set -e + if [ "$status" -eq 0 ]; then exit 0; fi + if [ "$status" -eq 75 ]; then + echo "::warning title=Live product contract unreachable::Published without re-checking the live contract. facts/facts.json is still pinned offline by the publication gate, which ran and passed. Re-run this workflow when the endpoint is reachable to restore the live comparison." + exit 0 + fi + exit "$status" - name: Build the House Manual run: node site/build.mjs env: diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml index b1f516e..46eb7fe 100644 --- a/.github/workflows/docs-ci.yml +++ b/.github/workflows/docs-ci.yml @@ -45,5 +45,22 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.19.0 + # Two outcomes, two meanings. A mismatch is documentation drift and must + # never publish. Unreachable means a runner could not open a socket to + # the product, which says nothing about the facts and used to block the + # publication of correct documentation while the site served an older + # build. The facts stay guarded either way: the verify job in this same + # workflow runs the publication gate, which pins every one of them + # offline without touching the network. - name: Documented facts match the live product contract - run: node tools/check-facts-live.mjs + run: | + set +e + node tools/check-facts-live.mjs + status=$? + set -e + if [ "$status" -eq 0 ]; then exit 0; fi + if [ "$status" -eq 75 ]; then + echo "::warning title=Live product contract unreachable::Published without re-checking the live contract. facts/facts.json is still pinned offline by the publication gate, which ran and passed. Re-run this workflow when the endpoint is reachable to restore the live comparison." + exit 0 + fi + exit "$status" diff --git a/CHANGELOG.md b/CHANGELOG.md index fe3f903..859a61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Notable changes to the Forked Felines public documentation. Product changes are ### Publication gate +- An unreachable product endpoint no longer blocks publication. The live-contract check now exits 75 for "could not reach the product" and 1 for "the facts disagree", and only the second stops a publish. A 404 counts as the second: if the contract moves, the endpoint answers, and that is the event this check exists to catch. It stays counted even if the path drops afterwards, so a real 404 cannot be masked by later noise. The two shared an exit code, so a runner that could not open a socket blocked the publication of correct documentation while the site kept serving an older build, which is the worse of the two outcomes. The facts stay guarded either way: they are pinned offline and that check runs first. + - No word may be published twice in a row. Markdown wraps prose across source lines, so a word typed twice reads as two clean lines in a diff and as a defect on the page. - One verification date. The README, the reference page, and `facts/facts.json` must agree on when the facts were last checked against the live product contract. diff --git a/tools/check-docs.mjs b/tools/check-docs.mjs index 893a3e3..c4b5a9b 100644 --- a/tools/check-docs.mjs +++ b/tools/check-docs.mjs @@ -53,6 +53,50 @@ for (const [key, value] of Object.entries(pinned)) { if (facts[key] !== value) errors.push(`facts/facts.json: ${key} is ${JSON.stringify(facts[key])}, expected ${JSON.stringify(value)}`); } +/* 2b. The live-contract check must keep distinguishing its two failures. + A mismatch is documentation drift and must never publish. Unreachable is a + runner that could not open a socket, which says nothing about the facts, and + collapsing the two back into one exit code silently restores the behaviour + where a network fault blocks correct documentation while the site serves an + older build. Both happened on 2026-08-29, 66 minutes apart, on two different + hosted runners, one of which reached the endpoint in the same minute another + could not. */ +{ + const live = readFileSync(join(root, "tools", "check-facts-live.mjs"), "utf8"); + if (!live.includes("EX_TEMPFAIL = 75")) { + errors.push("tools/check-facts-live.mjs: unreachable must exit 75, distinct from a mismatch"); + } + if (!/process\.exit\(EX_TEMPFAIL\)/.test(live)) { + errors.push("tools/check-facts-live.mjs: the unreachable path must exit EX_TEMPFAIL, not 1"); + } + /* Asserting that EX_TEMPFAIL exists is not enough: what matters is which + conditions reach it. A 404 means the contract moved, which is the event + this gate exists to catch, and routing it to the transient exit would + publish the House Manual against facts nobody compared. Only a missing + response, a 5xx, or a 429 may be transient. */ + if (!/answeredStatus === null \|\| answeredStatus >= 500 \|\| answeredStatus === 429/.test(live)) { + errors.push("tools/check-facts-live.mjs: only no-response, 5xx and 429 may exit 75; a 4xx means the contract moved"); + } + if (!/answeredUnparseable/.test(live)) { + errors.push("tools/check-facts-live.mjs: a 2xx with an unparseable body must fail as drift, not as unreachable"); + } + /* A 4xx has to survive the retry loop. A real 404 followed by three dropped + connections would otherwise exit 75 and publish, which is the gate's own + case being masked by noise. */ + if (!/contractMissingStatus/.test(live)) { + errors.push("tools/check-facts-live.mjs: a 4xx must be sticky across retries, not overwritten by a later dropped connection"); + } + for (const workflow of [".github/workflows/deploy-pages.yml", ".github/workflows/docs-ci.yml"]) { + const content = readFileSync(join(root, workflow), "utf8"); + if (content.includes("run: node tools/check-facts-live.mjs")) { + errors.push(`${workflow}: runs the live check bare, so an unreachable endpoint fails the build`); + } + if (!content.includes('status" -eq 75')) { + errors.push(`${workflow}: must treat exit 75 from the live check as a warning that still publishes`); + } + } +} + /* 3. Required fact tokens in the pages that state them. */ const requiredCopy = new Map([ ["README.md", ["3,333", "8,888", "1,500", "963,238", "community-remediation/v4", "forkedfelines.art"]], diff --git a/tools/check-facts-live.mjs b/tools/check-facts-live.mjs index 57f8fc8..8eed0f3 100644 --- a/tools/check-facts-live.mjs +++ b/tools/check-facts-live.mjs @@ -2,7 +2,24 @@ * Compares facts/facts.json with the live public product contract. Run on a * schedule and before releases; a mismatch means the product changed and the * documentation must be re-verified, page by page, before republishing. + * + * Two failures live here and they are not the same event: + * + * exit 1 the endpoint answered and the facts disagree. Documentation + * drift. Never publish. Re-verify the pages, then update facts. + * exit 75 the endpoint could not be reached at all. Nothing was learned + * about the facts in either direction. + * + * They used to share exit 1, which meant a runner that could not open a + * socket blocked the publication of documentation that was correct, while + * the site went on serving an older build. That is strictly worse than + * publishing. The facts are not unguarded while the endpoint is + * unreachable: check-docs.mjs pins every one of them offline and runs + * first in both workflows. + * + * 75 is EX_TEMPFAIL from sysexits.h, which is what it means here. */ +const EX_TEMPFAIL = 75; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -12,24 +29,121 @@ const facts = JSON.parse(readFileSync(join(root, "facts", "facts.json"), "utf8") /* The product answers from one origin; a runner's route to it can flake. Bounded retries separate "the network hiccuped" from "the facts moved", - and only the second is a documentation event. */ + and only the second is a documentation event. + + Three different things used to land in one catch and leave as one exit + code, which hid the case this gate exists for. A 404 is not a network + fault: if the contract is renamed, moved, or version-bumped out from + under the documentation, the endpoint answers, and reporting that as + unreachable would publish the House Manual against facts nobody + compared. So the decision is made on what the last attempt actually + observed: + + no response at all transient nothing was learned + 5xx transient it answered and is having a bad + time; a deploy window looks like this + 429 transient rate limited, not moved + any other 4xx drift the contract is not where the + documentation says it is + 2xx, unparseable body drift it answered and it is not a contract + + The last attempt wins, with one exception. A 4xx that is not 429 is + sticky: once the product has told us the contract is not there, later + attempts dropping the path cannot unsay it. Without that clause a real + 404 followed by three refused connections exits 75 and publishes, which + is the gate's core case being masked by noise. + + The exception is decided by the asymmetry rather than by likelihood. + Wrongly exiting 1 costs a blocked publish: visible, recoverable, one + re-run. Wrongly exiting 75 costs a silent publish against a contract + nobody compared. Where the two costs are that lopsided, be wrong in the + direction someone notices. + + The counter-argument, recorded because it is the thing that would + change this: if some intermediary in front of the product answered 404 + while transiently failing, a healthy deploy would block a publish for + no reason. Nothing in this path is known to do that. A container swap + surfaces as 502 or 503, and a 404 from this endpoint means the route is + genuinely not registered, which is the persistent condition rather than + the transient one. Ordering never matters among no-response, 5xx and + 429, because all three answer 75 regardless. */ async function fetchLive() { let lastError; + /* null means the product never answered on the most recent attempt. */ + let answeredStatus = null; + let answeredUnparseable = false; + /* Sticky: set by any attempt that saw the contract missing, never cleared. */ + let contractMissingStatus = null; + for (let attempt = 1; attempt <= 4; attempt += 1) { + const pause = async () => { + if (attempt < 4) await new Promise((resolve) => setTimeout(resolve, attempt * 5_000)); + }; + + let response; try { - const response = await fetch(facts.productFactsEndpoint, { + response = await fetch(facts.productFactsEndpoint, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000), }); - if (!response.ok) throw new Error(`answered ${response.status}`); + } catch (error) { + lastError = error; + answeredStatus = null; + answeredUnparseable = false; + await pause(); + continue; + } + + answeredStatus = response.status; + answeredUnparseable = false; + if (response.status >= 400 && response.status < 500 && response.status !== 429) { + contractMissingStatus ??= response.status; + } + + if (!response.ok) { + lastError = new Error(`answered ${response.status}`); + await pause(); + continue; + } + + try { return await response.json(); } catch (error) { lastError = error; - if (attempt < 4) await new Promise((resolve) => setTimeout(resolve, attempt * 5_000)); + answeredUnparseable = true; + await pause(); } } - console.error(`live check: ${facts.productFactsEndpoint} unreachable after 4 attempts: ${lastError}`); - process.exit(1); + + const transient = answeredStatus === null || answeredStatus >= 500 || answeredStatus === 429; + const endpoint = facts.productFactsEndpoint; + + if (contractMissingStatus !== null) { + console.error(`live check: ${endpoint} answered ${contractMissingStatus} on at least one of 4 attempts`); + console.error("live check: MISMATCH. The contract is not where the documentation says it is. Do not publish: find where it moved and update facts/facts.json."); + process.exit(1); + } + + if (answeredUnparseable) { + console.error(`live check: ${endpoint} answered ${answeredStatus} with a body that is not JSON: ${lastError}`); + console.error("live check: MISMATCH. It answered and it is not serving the contract, so the documented facts could not be compared to anything."); + process.exit(1); + } + + if (!transient) { + console.error(`live check: ${endpoint} answered ${answeredStatus} on every one of 4 attempts`); + console.error("live check: MISMATCH. The contract is not where the documentation says it is. Do not publish: find where it moved and update facts/facts.json."); + process.exit(1); + } + + /* Deliberately not a wider retry budget. Widening it trades a visible + failure for a slower one and still fails whenever the path is out for + longer than whatever number was chosen. */ + const reason = answeredStatus === null ? `unreachable after 4 attempts: ${lastError}` : `answered ${answeredStatus} after 4 attempts`; + console.error(`live check: ${endpoint} ${reason}`); + console.error("live check: UNREACHABLE, not a mismatch. The documented facts were not" + + " compared, and nothing here suggests they moved."); + process.exit(EX_TEMPFAIL); } const live = await fetchLive(); @@ -50,7 +164,7 @@ expect("timing.quoteTtlSeconds", live.timing?.quoteTtlSeconds, facts.quoteTtlSec expect("timing.orderTtlSeconds", live.timing?.orderTtlSeconds, facts.orderTtlSeconds); if (errors.length) { - console.error(`live facts check: ${errors.length} mismatch(es)\n`); + console.error(`live facts check: MISMATCH, the live contract disagrees with facts/facts.json in ${errors.length} place(s)\n`); for (const error of errors) console.error(` ${error}`); process.exit(1); }