Skip to content

ci(docs): give the docs gate assertions worth requiring#261

Merged
jsugg merged 1 commit into
mainfrom
ci/docs-gate-assertions
Jul 15, 2026
Merged

ci(docs): give the docs gate assertions worth requiring#261
jsugg merged 1 commit into
mainfrom
ci/docs-gate-assertions

Conversation

@jsugg

@jsugg jsugg commented Jul 15, 2026

Copy link
Copy Markdown
Owner

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 docs verified three things — non-empty, LF endings, no
conflict 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 an alt. Empty is allowed —
alt="" is the honest way to say "decorative" — but it has to be a decision,
not an omission. Markdown's ![alt](src) always carries the slot, so only the
HTML form can be missing it. Which is what README.md:2 was doing, in a project
whose purpose is alt text.

2 · Env-var coverage. Every variable read by config/*.js must appear in
DEVELOPMENT.md and .env.example. This is the check that pays for the others.
It immediately found six:

DEVELOPMENT.md  OUTBOUND_ALLOWED_HOSTS
.env.example    OUTBOUND_ALLOWED_HOSTS, TLS_ENABLED, NODE_EXTRA_CA_CERTS,
                STATUS_RATE_LIMIT_MAX, STATUS_RATE_LIMIT_WINDOW_MS

Scoped to config/*.js, not config/**: the latter sweeps in
config/jest/*.cjs, whose ALLURE_RESULTS_DIR is a Jest reporter switch with
no 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 for
certs/localhost-key.pemcerts/ is gitignored, which is why it is
documented. That line is also the wording .env.example was 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-length at 80 columns, a convention these docs have never followed,
and MD033/no-inline-html, which would forbid the very <img alt=""> the first
check just required. Those are off, with reasons in the config. ~40 rules remain
on, and they bite — verified against a fixture: MD001/heading-increment and
MD024/no-duplicate-heading both 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/*.js to 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 exactly
that change, leaving check 2 decorative. The job costs seconds.

What the checks caught, fixed here

  • README.md:2 banner gets alt="" — decorative, and the # Alt-Text 4 All
    heading immediately below already carries the name, so a description would be
    announced twice.
  • .env.example stops claiming "Development TLS defaults already point to the
    checked-in localhost certs."
    Nothing about that was true: certs/ is
    gitignored, 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.js sitting at that
    directory depth — the guidance is now absolute paths.
  • The five undocumented variables are written down. STATUS_RATE_LIMIT_MAX
    defaults to 60, verified against config/index.js:145 rather than assumed.

OUTBOUND_ALLOWED_HOSTS

Chosen resolution: keep it supported, make it safe and legible.

It is consumed at config/index.js:131, was in no schema
(validateEnvVars ends .unknown()), and appeared in neither doc. It is now
validated as host[:port]:

127.0.0.1:19090            -> accepted   (the Postman harness keeps working)
fixtures.internal:8080,... -> accepted
*.example.com              -> rejected at boot
https://example.com/path   -> rejected at boot

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 entry
resolving to 169.254.169.254 will be fetched. Matching is exact and
case-normalized — no wildcards, no suffix matching. Not set on the service.

Verification

docs:validate 8 files / 0 violations · docs:lint 0 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 scope
boundary.

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 ![alt](src) 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.
@jsugg
jsugg merged commit b6b73eb into main Jul 15, 2026
22 checks passed
@jsugg
jsugg deleted the ci/docs-gate-assertions branch July 15, 2026 08:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant