Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 18 additions & 1 deletion .github/workflows/docs-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
44 changes: 44 additions & 0 deletions tools/check-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]],
Expand Down
128 changes: 121 additions & 7 deletions tools/check-facts-live.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();

Expand All @@ -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);
}
Expand Down