ci(docs): give the docs gate assertions worth requiring#261
Merged
Conversation
A required check named `docs` verified three things — non-empty, LF endings, no conflict markers — on Markdown only. It could pass stale, broken, or leaked documentation, and did. Five checks now stand behind the name: Image alt text. Raw <img> must carry an alt attribute; the value may be empty, because alt="" is the honest way to say "decorative", but it has to be a decision rather than an omission. Markdown's  always carries the slot, so only the HTML form can be missing it — which is exactly what the README banner was doing, in a project whose entire purpose is alt text. Env-var coverage. Every variable read by config/*.js must appear in DEVELOPMENT.md, which calls itself the full configuration reference, and in .env.example. This is the check that pays for the rest: it found six variables nobody had written down, including OUTBOUND_ALLOWED_HOSTS, which bypasses the SSRF guard. Scoped to config/*.js rather than config/**, since config/jest's ALLURE_RESULTS_DIR is a reporter switch and does not belong in .env.example. Internal links and anchors. Link targets must be tracked by git and fragments must match a real heading, using GitHub's slug rules. This was already green; it is locked in before it rots. Only link targets are held to that standard — prose stays free to name a gitignored path, because certs/localhost-key.pem is documented precisely because it is not checked in. Prohibited references. Published documentation must not point at internal working notes (.local/, typecheck-debt.md, jobs.md), whatever they are named. Markdown lint. Structure and heading hierarchy. Line-length, inline-HTML, and ordered-list-prefix rules are off, with reasons recorded in the config: each contradicts a deliberate choice in these documents rather than catching a mistake in them, and the banner needs the inline <img> the alt lives on. The job now runs unconditionally. The gate asserts across the repository rather than within the changed files, so a change that adds an undocumented env var without touching Markdown is the case most worth catching — and a docs_changed condition would have skipped precisely that change. Fixes what the new checks caught: the README banner gets alt="", .env.example stops claiming development TLS points at "checked-in localhost certs" (certs/ is gitignored and absent — the app self-signs in-process), and the five remaining undocumented variables are written down. OUTBOUND_ALLOWED_HOSTS becomes a supported, validated setting rather than an undocumented one: a host[:port] pattern, so `*.example.com` now fails at boot instead of being accepted and silently never matching — which, for an allowlist, is indistinguishable from working. Its documentation states what it actually does: a listed host returns before both assertPublicAddress() and DNS resolution, so an entry resolving to a link-local address will be fetched.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on #260, which had to land first — these assertions are only meaningful
once the gate stops depending on what is lying around on the developer's disk.
Before
A required check named
docsverified three things — non-empty, LF endings, noconflict markers — on Markdown only. It could pass stale, broken, or leaked
documentation. It was passing all three of those at the time this was written.
Five checks
1 · Image alt text. Raw
<img>must carry analt. Empty is allowed —alt=""is the honest way to say "decorative" — but it has to be a decision,not an omission. Markdown's
always carries the slot, so only theHTML form can be missing it. Which is what
README.md:2was doing, in a projectwhose purpose is alt text.
2 · Env-var coverage. Every variable read by
config/*.jsmust appear inDEVELOPMENT.mdand.env.example. This is the check that pays for the others.It immediately found six:
Scoped to
config/*.js, notconfig/**: the latter sweeps inconfig/jest/*.cjs, whoseALLURE_RESULTS_DIRis a Jest reporter switch withno business in
.env.example. There is a test pinning that boundary.3 · Internal links and anchors. Targets must be tracked by git; fragments
must match a real heading, using GitHub's slug rules. Already green — locked in
before it rots. My checker independently reproduced "zero broken across the 8
tracked docs", which is a decent sign the slug rules are right.
4 · Prohibited references. No pointers to internal working notes
(
.local/,typecheck-debt.md,jobs.md).5 · Markdown lint. Structure and heading hierarchy.
Two places I did not follow the letter
Prohibited references were specified as "any path matched by
.gitignore".That would fail
DEVELOPMENT.md:447, which documents that the app looks forcerts/localhost-key.pem—certs/is gitignored, which is why it isdocumented. That line is also the wording
.env.examplewas supposed to copy.So: links must resolve to tracked files, prose may name any path, and
the named internal notes are banned outright. Same intent, without failing a
line that is correct.
Markdown lint with defaults produced 331 errors — 320 of them
MD013/line-lengthat 80 columns, a convention these docs have never followed,and
MD033/no-inline-html, which would forbid the very<img alt="">the firstcheck just required. Those are off, with reasons in the config. ~40 rules remain
on, and they bite — verified against a fixture:
MD001/heading-incrementandMD024/no-duplicate-headingboth fire.The job now runs unconditionally
Worth a second look. The gate now asserts across the repository, not within the
changed files: it holds every env var read by
config/*.jsto being documented.A change that adds one without touching any Markdown is the single case most
worth catching — and
if: docs_changed == 'true'would have skipped exactlythat change, leaving check 2 decorative. The job costs seconds.
What the checks caught, fixed here
README.md:2banner getsalt=""— decorative, and the# Alt-Text 4 Allheading immediately below already carries the name, so a description would be
announced twice.
.env.examplestops claiming "Development TLS defaults already point to thechecked-in localhost certs." Nothing about that was true:
certs/isgitignored, untracked, and absent from disk; the app self-signs in-process
when the files are missing. It also documented
TLS_KEY=../../certs/...,which only resolves by coincidence of
loadTlsCredentials.jssitting at thatdirectory depth — the guidance is now absolute paths.
STATUS_RATE_LIMIT_MAXdefaults to
60, verified againstconfig/index.js:145rather than assumed.OUTBOUND_ALLOWED_HOSTSChosen resolution: keep it supported, make it safe and legible.
It is consumed at
config/index.js:131, was in no schema(
validateEnvVarsends.unknown()), and appeared in neither doc. It is nowvalidated as
host[:port]:The wildcard case is the point: it was previously accepted and then silently
never matched, which for an allowlist is indistinguishable from working.
The docs say what it does rather than what it is for: a listed host returns
before both
assertPublicAddress()and DNS resolution, so an entryresolving to
169.254.169.254will be fetched. Matching is exact andcase-normalized — no wildcards, no suffix matching. Not set on the service.
Verification
docs:validate8 files / 0 violations ·docs:lint0 errors · lint ·typecheck · 863/863 unit (103 suites). Each assertion has positive and negative
tests, including the
certs/false-positive guard and the Jest-config scopeboundary.