From d5d0a22e576fe331169ae6fce0437f98d2d2a3ab Mon Sep 17 00:00:00 2001 From: Miguel Casal Date: Sat, 29 Aug 2026 12:13:19 +0000 Subject: [PATCH 1/3] fix(ci): an unreachable product endpoint is not documentation drift The House Manual failed to publish twice this afternoon and once this morning, all three times on the same step, none of them because anything was wrong with the documentation: live check: https://forkedfelines.art/api/v1/product unreachable after 4 attempts: TypeError: fetch failed The check conflated two different events behind one exit code. A mismatch means the product moved and the documentation is now wrong, which must never publish. Unreachable means a runner could not open a socket, which says nothing about the facts in either direction. Treating them alike meant a network fault blocked the publication of documentation that was correct, while the site went on serving an older build. That is strictly the worse of the two outcomes, and it is what happened: the expired-order tone fix sat unpublished while the previous wording stayed live. Unreachable now exits 75, EX_TEMPFAIL, and both workflows turn that into a warning annotation and continue. A mismatch still exits 1 and still stops the publish. The facts are not unguarded while the endpoint is away. check-docs.mjs pins every one of them offline against hardcoded values and runs first in the Pages workflow and as a sibling job in CI, so an unreachable product leaves them guarded by the pinned check alone, which is a reasonable posture for a transient network fault where the old one was not. Deliberately not a wider retry budget: that trades a visible failure for a slower one and still fails whenever the path is out for longer than whatever number gets picked. A new publication-gate rule keeps the two exit codes distinct and rejects either workflow running the check bare, so this cannot quietly collapse back into one failure. Evidence that it is the network and not the product: at 12:04 two GitHub-hosted runners ran this identical check against this identical endpoint within the same minute. One reached it and passed. The other could not, for 72 seconds across four attempts. The endpoint answered 200 from the workstation throughout, in about 160ms, with no AAAA record and no rate-limit headers. Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy-pages.yml | 18 +++++++++++++++++- .github/workflows/docs-ci.yml | 19 ++++++++++++++++++- CHANGELOG.md | 2 ++ tools/check-docs.mjs | 27 +++++++++++++++++++++++++++ tools/check-facts-live.mjs | 23 +++++++++++++++++++++-- 5 files changed, 85 insertions(+), 4 deletions(-) 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..323ac93 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. 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..b4efd85 100644 --- a/tools/check-docs.mjs +++ b/tools/check-docs.mjs @@ -53,6 +53,33 @@ 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"); + } + 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..e66a471 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"; @@ -29,7 +46,9 @@ async function fetchLive() { } } console.error(`live check: ${facts.productFactsEndpoint} unreachable after 4 attempts: ${lastError}`); - process.exit(1); + 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 +69,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); } From 156590574925b8b744fd9cb20457966ed9accc5b Mon Sep 17 00:00:00 2001 From: Miguel Casal Date: Sat, 29 Aug 2026 12:27:50 +0000 Subject: [PATCH 2/3] fix(ci): a 404 is the contract moving, not the network failing Review of the first commit found a fail-open I had introduced. Three conditions landed in one catch and left through one exit code, and after moving that exit code to 75 they all published with a warning: fetch threw, no response genuinely transient response answered, !response.ok it ANSWERED response.json() threw it ANSWERED The second is the one that matters. If /api/v1/product is renamed, moved, or version-bumped out from under the documentation, the endpoint answers 404, and the previous commit would have reported that as UNREACHABLE and published the House Manual against facts nobody compared. That is exactly the event this gate exists to catch, arriving in a network costume, and it was worse than before my change rather than merely unfixed: a 404 used to exit 1 and block. The decision now reads what the last attempt observed: no response 75 nothing was learned 5xx 75 it answered and is having a bad time, which is also what a deploy window looks like 429 75 rate limited, not moved any other 4xx 1 the contract is not where the docs say it is 2xx unparseable 1 it answered and it is not a contract The last attempt wins rather than the worst one, because a 404 followed by three dropped connections is better described as losing the path than as the contract moving. All five paths were exercised against a real local server rather than asserted. The first harness reported two false failures because spawnSync blocks the event loop, so the in-process server could never answer and every case looked unreachable; re-run out-of-process, 404 and the unparseable body exit 1, and 500, 429 and a refused connection exit 75. The gate rule is tightened to match. Asserting that EX_TEMPFAIL exists says nothing about which conditions reach it, so it now pins the transient set and rejects any widening to 4xx, which was proved to bite by widening it by hand. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- tools/check-docs.mjs | 11 ++++++ tools/check-facts-live.mjs | 77 +++++++++++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 323ac93..a6395d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ 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. 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. +- 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. 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 b4efd85..65bfdc9 100644 --- a/tools/check-docs.mjs +++ b/tools/check-docs.mjs @@ -69,6 +69,17 @@ for (const [key, value] of Object.entries(pinned)) { 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"); + } 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")) { diff --git a/tools/check-facts-live.mjs b/tools/check-facts-live.mjs index e66a471..3b85428 100644 --- a/tools/check-facts-live.mjs +++ b/tools/check-facts-live.mjs @@ -29,23 +29,90 @@ 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 rather than the worst one, because a 404 followed + by three dropped connections is better described as "we lost the path" + than as "the contract moved". */ async function fetchLive() { let lastError; + /* null means the product never answered on the most recent attempt. */ + let answeredStatus = null; + let answeredUnparseable = false; + 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.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}`); + + const transient = answeredStatus === null || answeredStatus >= 500 || answeredStatus === 429; + const endpoint = facts.productFactsEndpoint; + + 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); From c5c2cfe84f815f6319d0cda0279db54d744d1b58 Mon Sep 17 00:00:00 2001 From: Miguel Casal Date: Sat, 29 Aug 2026 12:31:43 +0000 Subject: [PATCH 3/3] fix(ci): a 4xx survives the retry loop Review found the one sequence where last-attempt-wins resolves the wrong way: attempt 1 404 the contract moved attempts 2-4 refused the path drops last wins -> 75 -> publishes That is the gate's own case arriving and then being masked by noise. Unlikely, and the only sequence where the ordering rule changes the outcome at all. Decided on the asymmetry rather than on 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, which is precisely what this gate exists to prevent. Where the two error costs are that lopsided, be wrong in the direction someone notices. The change is one clause rather than worst-wins wholesale. A 4xx that is not 429 is sticky once seen; everything else keeps the last-attempt rule and the existing boundaries. Ordering never mattered among no-response, 5xx and 429 anyway, since all three answer 75 regardless. The counter-argument is recorded in the source because it is the thing that would change this: if some intermediary 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 not registered, which is the persistent condition rather than the transient one. Exercised against a server that answers 404 once and then stops listening, which is the exact sequence: exit 1, "answered 404 on at least one of 4 attempts". The gate rule pins the stickiness and was proved to bite by renaming the variable that carries it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- tools/check-docs.mjs | 6 ++++++ tools/check-facts-live.mjs | 34 +++++++++++++++++++++++++++++++--- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6395d2..859a61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ 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. 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. +- 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 65bfdc9..c4b5a9b 100644 --- a/tools/check-docs.mjs +++ b/tools/check-docs.mjs @@ -80,6 +80,12 @@ for (const [key, value] of Object.entries(pinned)) { 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")) { diff --git a/tools/check-facts-live.mjs b/tools/check-facts-live.mjs index 3b85428..8eed0f3 100644 --- a/tools/check-facts-live.mjs +++ b/tools/check-facts-live.mjs @@ -47,14 +47,33 @@ const facts = JSON.parse(readFileSync(join(root, "facts", "facts.json"), "utf8") documentation says it is 2xx, unparseable body drift it answered and it is not a contract - The last attempt wins rather than the worst one, because a 404 followed - by three dropped connections is better described as "we lost the path" - than as "the contract moved". */ + 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 () => { @@ -77,6 +96,9 @@ async function fetchLive() { 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}`); @@ -96,6 +118,12 @@ async function fetchLive() { 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.");