site-kit — deployment-truth governance for a repo-served docs site
A docs site served from the repository (GitHub Pages and equivalents) has two truths a gate battery must keep straight: what the tree says about the site’s host, and whether the live deployment is actually up. site-kit gates the tree-side truths — the cited host, and whether each page renders faithfully through the platform’s own parser — and ships a template for the deployment liveness, holding the boundary between gate and monitor so neither leaks into the other.
The monitor boundary
The line is where the asserted object lives. A gate asserts over the tree: a checkout plus bash is all it needs, which is what makes it deterministic, hermetic, and safe to block a merge with. Deployment truth is in no checkout — the live host’s responses, its certificate, and the bodies of the Releases it publishes are state no commit contains — so no gate can assert it, and enforcing it through a pre-commit or CI gate would break both the low-false-positive contract and the CI backstop’s checkout-plus-bash hermeticity (gate-sdk/SPEC.md §Enforcement tiers).
Object location governs rather than whether a commit caused the failure, and the difference is load-bearing. Some host-side failures are caused by nothing any commit did — DNS, a Pages incident, a stalled certificate renewal. Others are caused by a session skipping a step, which is commit-shaped in spirit and still ungateable, because the artifact it damaged sits on the host rather than in the tree. A criterion built on cause admits the first kind and stumbles on the second; a criterion built on where the object lives covers both, and stays true of every probe arm instead of only the arms whose causes it happens to list.
So the deployment probe is monitoring, not a gate: it ships as
templates/site-health.yml, a scheduled workflow a consumer copies, and signals
through an issue and a red run of its own, never a blocked merge. The
tree-honesty half — that the repo never cites a stale host — is a real gate,
because that is a property of the tree.
Layout and configuration
The kit is vendored beside gate-sdk (conventionally at site-kit/); its gate
is registered in the consumer’s gates.list by name and resolves through
gate-sdk’s multi-kit path. check-docs-cname-parity registers where a docs
site with a gated host exists; a consumer without one simply omits it.
Config follows the kit pattern: copy a site-config.sh into the gates dir (or
point SITE_KIT_CONFIG_FILE elsewhere) and override any knob; defaults fill
what the consumer left unset, and the loader exits 2 on a config path it was
told to load but cannot find. Knobs:
SITE_KIT_CNAME— the CNAME file holding the one authoritative host line, defaultdocs/CNAME.SITE_KIT_ALIASES— array, default empty: every reachable host that is not the cited docs host and must therefore never appear in a://URL in the tree. Rule content by nature, so it is consumer config — a kit literal carrying it would publish a project’s host names across the provenance seam.SITE_KIT_SCAN_ROOT— thegit ls-filesroot the gate walks, default..SITE_KIT_EXEMPT_PATHS— array of path globs skipped during the scan, default("*/gate-tests/*" "*docs/posts/*"): fixture trees deliberately cite aliases, and dated posts are immutable published artifacts.SITE_KIT_DOCS_DIR— the docs-site rootcheck-docs-render-fidelitywalks for tracked markdown pages, defaultdocs.SITE_KIT_RENDERER— array, the stdin→stdout single-document GFM-to-HTML command, default the kramdown CLI invocation with GFM input —ruby -e '…Kramdown::Document…input: "GFM"…', the parser GitHub Pages pins. It is the contractcheck-docs-render-fidelityrenders each page through whenSITE_KIT_RENDERER_BATCHis empty: one of the gate’s two renderer contracts, and the fallback of the pair. A consumer whose Pages stack differs points this at its own renderer; an unresolvable one fails the gate closed. Its value is also a port blocker, and repointing it moves one. The first element of whatever this knob holds is an external programcheck-docs-render-fidelityrequires, which is what gate-sdk’s criterion 7 screens for and whatgate-sdk/bin/port-blockers.shderives by resolving this knob (gate-sdk/SPEC.md §port-blockers). The dependency is spelled nowhere in the gate’s own source, so it is recorded here, where the knob lives: a consumer who points this at a renderer their payload already carries removes that blocker, and one who points it at a heavier toolchain deepens it.SITE_KIT_RENDERER_BATCH— array, optional: a command rendering N documents over one stream,NUL-terminated in both directions and count-preserving, the framing specified in §check-docs-render-fidelity. Setting it collapses the gate’s per-page interpreter restarts into a single process, which is where substantially all of that gate’s cost lives. Its default is the batch form of the same kramdown-with-GFM-input invocationSITE_KIT_RENDERERdefaults to — aruby -eloop splitting stdin onNULand writing oneNUL-terminated HTML document per input — filled only whenSITE_KIT_RENDERERis itself still at its kit default. Filling it unconditionally would defeat a deliberate pin: a consumer who pointsSITE_KIT_RENDERERat the version-locked bundle below, not knowing a second knob now exists, would have that pinned oracle replaced by this unpinned one and the gate would report clean against a parser build they explicitly rejected — a false clean produced by an upgrade. Under the rule they instead keep the per-document path at its own cost and its own semantics, and opt in by pointing this knob at the batch form of their own pinned renderer.SITE_KIT_CONFIG_FILE— the loader override; when set it must resolve, else the gate exits 2 rather than silently run on defaults.
templates/site-health.yml is governed by none of these. Its knobs — ALT_DOMAIN
and the three RELEASE_NOTE_* below — are step-level workflow env set in the
copied file itself, not SITE_KIT_* knobs, and lib/site.sh never loads them.
The template is copied and edited rather than sourced, so reaching into the gate
config loader would couple a monitor to the gates dir and hard-code a vendored
kit path into a workflow whose whole distribution model is verbatim copy.
check-docs-cname-parity
checks/check-docs-cname-parity.gate (precommit, binary-dispatched).
Invariant: no tracked file cites a SITE_KIT_ALIASES host in a :// URL,
where the authoritative host H is read from SITE_KIT_CNAME. The CNAME file
must hold exactly one non-blank host line (else exit 2); H itself is exempt
even when listed among the aliases, so a canonical apex that doubles as a
redirect target is cited freely. The scan enumerates tracked files under
SITE_KIT_SCAN_ROOT via git ls-files, drops the gate-sdk prune set and every
SITE_KIT_EXEMPT_PATHS glob, and greps the survivors for ://<host>; a host
that is a configured alias other than H is a finding. A rename is thus a
one-line edit to the CNAME file that the gate re-propagates — the host lives in
one gated place, and drift anywhere else is caught. The scan reads tracked
content only, so an untracked local file is never a source; a git ls-files
error is fail-closed (exit 2). The positional form
check-docs-cname-parity <scan-root> <cname-file> lets a fixture point both at
a synthetic tree without touching consumer config.
Its default scan root makes the whole tracked tree its content corpus, and
that is a port cost worth stating where the rule lives. With SITE_KIT_SCAN_ROOT
unset the walk is every tracked file, so every kit’s checks/*.sh and *.gate
is inside the corpus this gate reads as content — gate-sdk’s criterion 4
verbatim, reached through the walk rather than through the trigger field. The
# graph: couple is the single literal CNAME file, so the derived
substrate-sensitive set never selects it and gate-sdk’s conservation assertion
structurally cannot see the hold; it is the first worked instance in that
direction (gate-sdk/SPEC.md §The fourth budget batch). The discharge is the
general one: the fixture pair carries the arm, because gate-tests is pruned
from every live-tree walk and no port can change what is inside it. The pair’s
good case therefore takes the default arm — no positional, scan root and CNAME
both off the config bridge, a fixture declaration path inside its own corpus —
and the bad case keeps the explicit-root arm.
There is no third positional selecting a config file, and the ground is a
finding rather than a preference. Such an argument could only work by exporting
SITE_KIT_CONFIG_FILE and re-sourcing lib/site.sh mid-run. A
.gate-dispatched member cannot do that: the config bridge resolves every declared knob before the binary starts
(gate-sdk/SPEC.md §lib/gate.sh), so an argument that selects which config file
the knobs come from arrives a process too late, and a documented flag that
silently changes nothing is worse than none. A fixture supplies its own alias set
the way any consumer does — a site-config.sh in the gates dir the loader
already resolves — which is the shape gate-sdk/SPEC.md §The third budget batch
settled for the same cause, and it costs the pair nothing.
check-docs-render-fidelity
Invariant: every tracked markdown page under SITE_KIT_DOCS_DIR, rendered
through the Pages parser, leaks no code-span corruption symptom into
rendered text — neither a literal backtick nor a raw non-HTML-element tag
surviving outside a code context — promotes no code-fenced line into a heading,
and renders no fewer tables than its source GFM table starts. GitHub Pages
renders through kramdown’s GFM parser, which diverges from github.com’s cmark:
consecutive fenced blocks inside one list item corrupt the page — the second
fence prints literally and a #-leading skeleton line becomes a heading — so a
tree that reads green on github.com can ship a garbled Pages site with no gate in
the path. This gate is the faithful-artifact-verification class mechanized for
that artifact: it renders the real output and asserts the observed leakage class,
rather than trusting the source.
The leakage class has more than one root cause, and the assertion keys on the
shared symptom rather than on any single cause — one low-false-positive
assertion covering every shape that leaves the same signature. The causes
observed on this tree: (1) a code span that wraps across a line break whose
continuation begins with a block-level or generic XML tag; (2) a single-line code
span whose embedded angle-bracket token (a placeholder such as <verdict> or
<n>) is consumed as a raw HTML span before the span can close, no line break
involved; (3) a code span nesting escaped backticks alongside such a token. All
three are the same kramdown behavior — parsing blocks before spans, it treats the
angle-bracket token as the start of an HTML block, severing the span so it emits
raw HTML that swallows the rest of the page (gettalong/kramdown#843, closed
works-as-designed: a documented, permanent divergence, not a pending fix). A
severed span never forms the closing fence run, so it leaves not a multi-backtick
marker but a single stray backtick and a raw placeholder tag in the
rendered text — which is exactly the symptom the assertion keys on, so the gate is
a standing defense against the whole class rather than a stopgap against one
backtick-fence shape of it.
Reach is SITE_KIT_DOCS_DIR, and stays there. A repo-root page has no site
URL and is served by the forge’s own CommonMark view, so a kramdown-only
divergence there is not reader-visible and this gate is right not to walk it.
The renderer-to-surface map is a consumer’s own documentation concern, not a
widened scan.
The scan enumerates tracked *.md files under SITE_KIT_DOCS_DIR via
git ls-files (every underscore-prefixed directory segment excluded — those are
Jekyll internals, not published pages), strips Jekyll front matter so it renders
exactly the body kramdown sees, and asserts three properties per page:
- No span-corruption leakage — the rendered HTML’s text content, taken
after the
<pre>blocks and inline<code>spans are removed (a legitimate backtick renders inside<code>and a legitimate embedded tag renders as escaped entities inside it, so both are excluded before the check), carries neither of two symptoms: (a) any literal backtick — a single stray one, not only a`{3,}fence run — and (b) any raw tag whose element name is not a known HTML element, matched by name so an attribute-bearing legitimate tag (<a href…>) is excluded and a placeholder token (<verdict>,<n>,<KIT>) is not. Content inside an<svg>or<math>subtree is exempt from (b), tracked by open/close depth: those two roots open foreign content whose vocabularies (<circle>,<mi>) are not HTML element names, so scanning them by the HTML set would red a page for legitimate inline markup. The exemption is scoped to the subtree rather than granted to the names, so a bare<path>or<use>outside any<svg>still reds as the placeholder token it is. Keep such a subtree on one source line: kramdown severs a foreign root split across a line break, emitting its tail as escaped text, and that is a real render divergence the gate is right to red. Either symptom is the signature of a code span or fenced block the parser failed to form, regardless of which construct confused it: a backtick that never paired, or a placeholder tag kramdown passed through as a raw HTML block. The two are complementary — a swallowed page region whose own inline code re-leaks as literal backticks trips (a), while a swallowed backtick-free region (pure prose or headings) trips only (b) — so neither alone covers the class and the assertion keys on both. - No heading leakage — the count of rendered heading elements never exceeds
the count of source heading lines the gate’s own fence-aware scan (cmark
rules: ATX and setext, both skipped inside a fenced or
~-fenced block) places outside any code context. A surplus rendered heading is a#line promoted out of a broken code block. - No table leakage — the count of rendered
<table>elements is never less than the count of source GFM table starts the same fence-aware scan places outside code. A table start is a pipe-carrying row immediately followed by a delimiter row (dashes, colons, pipes — the| --- |shape). The direction is one-sided: rendered may exceed source (a raw-HTML<table>in source is legitimate and renders without a GFM start); only a deficit reds. A deficit is a source table that shipped as literal-pipe paragraph text — kramdown terminates a table only on a blank line, so a table whose last row abuts a following non-blank line collapses into a paragraph.
The renderer is the gate’s oracle. Before scanning, the gate probes it on a
one-line document; an unresolvable or non-producing renderer exits 2 with a help
line naming the dependency (ruby plus the kramdown-parser-gfm gem, or a
SITE_KIT_RENDERER override) — a gate that cannot run its oracle refuses, never
a false clean. That dependency joins the consumer’s toolchain only when the
consumer registers this gate; it stays outside env-probe’s probe-set floor, and
docs/install.md’s Requirements prose states the tier. A consumer with no
published docs site simply omits the gate by the registry-not-array convention
and never installs the dependency.
checks/check-docs-render-fidelity.gate (precommit, binary-dispatched).
Ported under shell-gate-tail-port as the last of its registered members, into
native/src/gates/docs_render_fidelity.rs. It is a criterion-7 wrapper: the
program the rule requires is the first element of whichever renderer knob the run
resolves to a command, so the compiled form spawns it and refuses at exit 2 when
it cannot run, the dependency moving not at all (gate-sdk/SPEC.md §The
port-candidate criteria, criterion 7). The dependency itself, and the fact that a
consumer who repoints a knob moves it, are owned at §Layout and configuration
under those knobs and are not restated here.
Its requirement is knob-derived, and it is two knobs rather than one.
--needs declares git plus ?<TAB>SITE_KIT_RENDERER_BATCH and
?<TAB>SITE_KIT_RENDERER, and port-blockers.sh resolves the pair through the
same bridge the dispatcher uses. Naming only SITE_KIT_RENDERER — as this section
did before the port — is wrong for a reason no zero-config run exposes: with the
batch knob non-empty the gate never invokes the per-document one, so a consumer
who pins only the batch renderer requires that command. The two defaults both
begin ruby, which is why the narrower claim read true here and would have
under-declared anywhere else. git is declared for its two spawns (rev-parse
--git-dir, ls-files) and is on the program floor, so it costs no criterion-7
residual; mktemp and awk do not survive the port at all, the scratch tree and
the three scans having moved in process.
The GNU-awk floor’s last live holder left with it. The three scans below were
written in GNU awk (BEGINFILE/ENDFILE/ARGIND), and this was the only
remaining registered shell member holding those extensions, which
interpreter-floor-gawk-residue-empty established by measurement. The port
retires the holder; it does not narrow docs/install.md §Requirements, whose
edge is operator-class and whose decision that entry owns. Recorded here so that
entry’s taker reads a discharged precondition rather than re-deriving it.
The second positional is retired on port, and its function is not.
[docs-dir] [config-file] read argv[2] by exporting SITE_KIT_CONFIG_FILE before
sourcing lib/site.sh. A compiled member cannot: a config file is bash, the
bridge transports resolved values and interprets nothing (gate-sdk/SPEC.md §The
port-candidate criteria, criterion 6), and the member receives knob values rather
than a path. The positional is therefore unreachable by construction, which is
what makes this a retirement-on-port rather than a narrowing. What it did survives
under the variable it always set: the bridge resolves a kit’s knobs by sourcing
that kit’s lib/*.sh in a subshell that inherits the caller’s environment, so a
harness pointing the renderer knobs at a synthetic setup exports
SITE_KIT_CONFIG_FILE and the resolution follows — which is the shape every
ported member’s bespoke tests already use. The first positional is unchanged
and still defaults to SITE_KIT_DOCS_DIR.
NUL-stripping becomes deliberate, and it has to. The framing’s
unforgeability rests on the gate’s own reader having already dropped any NUL in
the source before framing happens — a side effect, in the shell form, of bash not
being able to hold one in a variable. A compiled reader can hold one, so it
drops them at the same point on purpose. The property is load-bearing rather than
incidental now, which is the one place this port turns an accident of the
substrate into a stated rule.
Criterion 4 binds, and the live-tree arm was not demoted. The member’s own
declaration path moves at this port, so the comparison could have been made only
against the pre-descriptor tree. It was not: the pre-port rule was restored under
a name no member resolves to (gate-sdk/SPEC.md §The port-candidate criteria,
criterion 4), which put both implementations over the post-descriptor corpus.
The arm carries no bound — unlike §check-shellcheck’s, whose corpus is every
.sh and so contains its own probe file; this member’s corpus is docs/**.md,
so the probe sat outside it and both forms read the committed tree byte for byte.
Twenty-four comparisons over the same cwd, argv and config, all byte-identical
including exit codes: both render paths over the live tree, the fixture pair and
a front-matter/nested/_layouts corpus; the empty corpus; an absent docs dir; the
batch count mismatch and both probe failures; and each of those again with the
renderer removed from PATH (gate-sdk/SPEC.md §Fail-closed contract owns the
scrub technique). Two arms were probed rather than accepted: a “no repository”
case first built under .tmp/ sat inside this checkout, so it proved an empty
gitignored corpus instead, and re-running it from outside any repository is what
established the ordering fact below.
The refusal order, read off the shell text rather than assumed. Each check
below precedes any finding and wins over the ones after it: not a git
repository, then docs dir not found, then the renderer probe, then the
corpus enumeration. So an
absent docs dir reports the docs dir even with no renderer installed, and a tree
outside a repository reports that even with both. This member’s probe is also not
the presence test the first wrapper established — it probes the oracle by running
it, because for a renderer, on-PATH is not the property the gate needs, and an
absent program therefore surfaces as the probe’s own exit status inside the
member’s own sentence.
The batch stream, and why the count is the fail-closed. One renderer process
per page is the gate’s whole cost — the interpreter restarts, not the rendering —
so where SITE_KIT_RENDERER_BATCH is non-empty the gate renders the corpus over
one stream instead. The framing is NUL-terminated in both directions: the gate
writes each front-matter-stripped body, NUL-terminated, to the command’s stdin,
and the command writes each rendered document to stdout in the same order,
each NUL-terminated. NUL is a terminator rather than a separator, so N
documents produce exactly N NULs with no trailing-empty ambiguity, and it is
unforgeable by page content for a stronger reason than rarity: bash cannot hold a
NUL in a variable, so the gate’s own reader has already dropped any NUL in
the source before framing happens. A sentinel line would be forgeable — by a docs
page describing this contract, first of all. Length-prefixed framing is equally
sound and loses only on the obligation it puts on a consumer, who would have to
implement exact-byte reads where splitting on a byte is a one-liner in every
language a Pages stack might use.
Two implementation constraints follow, and a natural reading of the contract
breaks both. Command substitution strips NUL, so the batch output must
never pass through $(…); the gate reads it through a process substitution
instead, which also keeps the loop body in the current shell so the findings it
appends to survive. Writer and reader being separate processes
is what makes the exchange deadlock-free. Process substitution discards the
renderer’s exit status, so the batch path cannot keep the per-page fail-closed
the per-document loop has — and a renderer that dies mid-stream yields fewer
documents than pages. The gate therefore compares documents-read against
pages-enumerated after the loop, which detects renderer death, truncation and
framing error alike, one check standing in for the status the shell threw away.
It exits 2 — a refusal to run the oracle — never 1, which would report a finding
about the docs. Per-document scanning is unchanged: each document read off the
stream goes through the same assertions, and the source-side scans are untouched.
Probe routing. The gate probes the oracle it will actually run, and only that
one. With the batch knob empty it probes SITE_KIT_RENDERER on a one-line
document exactly as above. With the batch knob set it probes the batch command
instead, on a probe that exercises the framing as well as the parser: two
one-line documents in, exactly two non-empty documents back. Probing the
per-document renderer too would refuse a batch-only consumer over a renderer this
run never invokes. A failing batch probe exits 2 and does not fall back: a
set knob is the consumer’s deliberate statement about which parser is
authoritative, so quietly rendering through a different one is the same
false-clean class the fill condition in §Layout and configuration closes from the
other direction. A gate that cannot run its configured oracle refuses.
The false-positive floor is the assertion’s hard boundary, deliberately set. The
backtick symptom rests on a property of well-formed markdown: every backtick
belongs to a code span, which renders inside <code> and is excluded from the
scanned text, so a backtick surviving into paragraph, heading, or list text is a
code span that failed to form — not legitimate prose. Its one residual
legitimate case is documenting the backtick character itself; the faithful way
to do that is a doubled-backtick code span, which renders the character inside
<code> — excluded from the scan — so the honest form does not trip the gate and
only the fragile bare-backtick form would. The raw-tag symptom rests on
the HTML-element allowlist: kramdown passes an unrecognized <name> through
verbatim as raw HTML, while a <name> whose element is in the HTML standard is
legitimate markup — so a tag matched by element name against that set is
excluded, and only a token outside it (a placeholder kramdown mistook for an HTML
block) reds. Its residual legitimate case is a page that deliberately embeds a
non-standard element (a custom element or web component); a placeholder meant
as literal prose is instead written as a code span (`<verdict>` renders
<code><verdict></code>) and never surfaces as a raw tag. Both floors hold
empirically: across the tracked corpus each symptom fires on exactly the
corrupted pages and no clean page. The element set is generic mechanism — the
HTML standard’s element list is universal render truth, not consumer rule
content — so it is a kit built-in, not a config seam; a consumer that
legitimately ships non-standard elements in a docs page is the narrow accepted
false positive, and if that demand ever attests, an optional allowlist-extension
knob is the config-via-env answer, deferred until then rather than built against
a case no page presents.
Honest limit: this is not a full render-diff between the two parsers. It
mechanizes the observed leakage class — the code-span corruption symptom (a
surviving backtick or raw placeholder tag), headings, and tables — and stays
silent on divergences that corrupt none of the three. The first assertion keys
on the shared symptom rather than a multi-backtick fence run, so it implements
the severed-span defense (gettalong/kramdown#843) the section describes — prose
and assertion cover the same class. The table count can be
masked by an offsetting raw-HTML <table> on the same page: one collapsed GFM
table plus one HTML table balances the counts. The table detector is
deliberately conservative (delimiter-row anchored), so a table kramdown accepts
but the scan does not count can only under-count source starts — which
false-cleans, never false-reds. The observed table incident (2026-07-13): the
value page’s generated rollup table abutted its :end marker and shipped as a
literal-pipe paragraph with the gate silent; the emitter fix (a trailing blank
line) landed then, and this assertion mechanizes the channel. The good/bad
fixture pair exercises the span-corruption symptom (a bad page whose severed span
leaks a stray backtick and a raw placeholder tag, a good page whose faithful code
spans render clean) alongside the fence/heading case. Because that bad page would
red on its fence run alone, every assertion the pair cannot isolate carries a
hermetic unit test of its own, named here rather than described:
check-docs-render-fidelity-span.test.sh,
check-docs-render-fidelity-table.test.sh,
check-docs-render-fidelity-foreign.test.sh and
check-docs-render-fidelity-batch.test.sh.
The span test runs a page with no fence, no surplus heading and no table, whose
only defect is the severed span: it reds span-only and clears in the
doubled-backtick form, so a widening that reds nothing new cannot pass. The table
test is that same shape — a collapsed table reds table-only, a trailing blank
clears. The foreign test holds the SVG/MathML exemption to its scope: a page
carrying inline <svg> clears, because the exemption follows the subtree by
open/close depth rather than a name list, while a bare <path> outside any
<svg> still reds — and that second half is what keeps the first from passing
under a blanket widening, which would clear the good page at the cost of the
placeholder tokens the assertion exists for. The batch test exists because the
good/bad pair sets no renderer knob, so it already runs the batch path and cannot
by itself distinguish that path from the fallback; it asserts that the kit’s two
renderer defaults render a corpus byte-identically, that a pinned
SITE_KIT_RENDERER suppresses the batch default, that the batch path and the
per-document fallback return the same verdict on the same pages, and that a wrong
document count and an unresolvable batch command each exit 2. The count case uses
a stub that always emits two documents, so it passes the two-document probe and is
caught only by the corpus count — a stub the probe already rejected would never
reach the assertion under test.
All four are invoked through gate_run, so each names a gate and never a
substrate, and each supplies its renderer configuration as SITE_KIT_CONFIG_FILE
rather than as the retired second positional. The positional form
check-docs-render-fidelity [docs-dir] lets a fixture point the docs dir at a
synthetic tree without touching consumer config. precommit tier, coupling the
docs tree.
The port adds a third oracle, and the split is the one the pair cannot carry.
The good/bad pair and the four bespoke tests both need a real tracked tree, so
they stay where they are; what moved into crate unit tests is every assertion
that is a pure function of a page’s bytes — the foreign-content exemption in both
directions, a backtick inside and outside a code span, the element counts, the
fence-aware heading and table scans including the seventh-# bound, front-matter
stripping, NUL framing as a terminator rather than a separator, and the
Jekyll-internal segment test. Those are commit-time oracles through
check-crate-arms exactly as the bespoke tests are through the fixture runner,
and they are the arms a golden pair can only assert the presence of a line for
(gate-sdk/SPEC.md §The port-candidate criteria, criterion 2). The pair contract is
intact: both cases still exist and still exercise the batch path end to end.
Parser-version fidelity (a second honest limit). The oracle is faithful to the
parser GitHub Pages uses (kramdown with GFM input), not necessarily to the
exact kramdown version Pages pins: a locally-installed kramdown that differs
in patch or minor version from the Pages-locked one can render a construct
differently, so a green local run is not a categorical proof for a divergence
introduced between the two versions. The exact-pin recipe closes that gap for a
consumer that needs it: point SITE_KIT_RENDERER at a version-locked bundle —
a bundle exec ruby …Kramdown… invocation whose Gemfile.lock pins kramdown
(and kramdown-parser-gfm) to the versions the github-pages gem resolves — so
the oracle and the deploy render byte-for-byte the same parser build. Pin
SITE_KIT_RENDERER_BATCH in the same motion, at the batch form of that same
locked bundle, or leave it unset: those are the two states in which the gate runs
the parser build the pin names. Overriding only the per-document knob already
leaves the batch knob empty by its fill rule, so a half-applied pin costs speed
rather than fidelity — but a consumer who set a batch renderer earlier and pins
only the per-document one afterwards has pinned the knob the gate will not use.
The kit does not auto-resolve the pin: fetching the Pages-locked gemset at gate
time would break the hermetic no-network render contract the oracle depends on (a
gate must run offline and deterministically), so it stays a consumer’s deliberate
override, not kit-run machinery.
Renderer agreement (a third honest limit). Where a consumer sets both renderer knobs, the kit cannot verify that their batch renderer agrees document-for- document with their per-document one; a divergent pair yields a divergent oracle, and nothing in the framing contract can detect that. What the kit does hold is its own pair: the two defaults are asserted byte-identical over a corpus by fixture, so the zero-config path is covered by construction rather than by assumption.
lib/site.sh
The sourced config loader: it loads SITE_KIT_CONFIG_FILE (or the gates-dir
site-config.sh when that env is unset), then fills each knob’s default, so a
gate and a fixture read one resolved configuration. It carries no gate logic —
structure stays in the check, values in config, defaults here.
Every knob’s default is filled whenever the consumer left it unset, with one
exception the loader is the only place to state: SITE_KIT_RENDERER_BATCH is
filled only where the loader also owns SITE_KIT_RENDERER — where the consumer
overrode neither. A consumer who overrode the per-document renderer and left the
batch knob unset gets an empty batch array instead of a default, so the gate
keeps running the oracle they chose; §Layout and configuration states the rule
with the false clean it exists to prevent.
templates/site-health.yml
The scheduled live-site probe, copied verbatim into a consumer’s
.github/workflows/. It reads the apex host from the CNAME file (the same
source the gate trusts), then checks: the apex answers 200 over HTTPS, www
and http redirect to the canonical origin, an optional ALT_DOMAIN redirect
keeps its path, the certificate is at least a fortnight from expiry, and every
published release note is pointed at by a resolving URL in its Release body. A
failure opens or updates a single site-health issue and reds the run;
recovery closes it. The ALT_DOMAIN value is a bare hostname, never a ://
literal, so it does not itself trip the parity gate. A # enforce: marker rides
the template so that, once copied, an enforcement map projects it as a monitor.
The arm roster is a floor, not a ceiling, and “copied verbatim” describes the distribution model rather than forbidding an edit. The copy is the consumer’s file: nothing syncs it back, and no gate couples the two, so a consumer may add arms of its own. Which side of the seam an arm belongs on follows from what it asserts. An arm implementing an invariant this kit ships belongs in the template, where every consumer inherits it. An arm implementing a consumer-local invariant belongs in that consumer’s copy and must stay out of the template — copied into a tree that never declared the convention it derives from, it would assert something no kit ships and no consumer agreed to, which is dead config that reads as coverage. An arm cannot be truer than the declaration it composes, so it lives at the widest tier that declaration is true for. A consumer-local arm becomes template material only if its underlying declaration becomes kit mechanism — and then it arrives as an optional arm on the existing set-the-env-or-delete-the-arm pattern, the way the release-note knobs already do.
The workflow’s permissions: block is an allowlist, not an addition — every
scope it omits is none — so reading the tag list and the Release bodies needs
contents: read declared beside issues: write, and the probe step needs its own
GH_TOKEN. On a public repository the declaration is redundant and the arm
appears to work without it; at a private-repo consumer it is the difference
between the arm working and the arm 404ing on every note, because GitHub masks an
unauthorized read as an absent resource — the failure arrives looking like “no
such Release” rather than “not permitted”. This declaration is held by an oracle
rather than by review: check-action-permissions (gate-sdk/SPEC.md
§check-action-permissions) reds a job that consumes the GitHub token without
declaring what it takes, and this template’s probe job is armed by all three of
its triggers. What stays a non-goal is the wider workflow-security category the
gate deliberately does not enter — expression injection (gate-sdk/SPEC.md
§check-action-run-shell) and over-declaration alike.
The release-body arm. For every tracked release note whose front-matter tag key names a tag that exists on the remote, the arm asserts two properties over that tag’s Release:
- Presence — a Release exists for the tag, and its body contains the note’s
canonical URL, derived scheme-qualified as
https://plus the apex host from the CNAME file plus the path fromRELEASE_NOTE_URL_PATH. This is what catches a body that was never filled in. - Resolution — every apex-hosted URL as literally written in the body answers 200. This is what catches a body whose pointer is present but dead.
The two run over two different strings, and collapsing them re-opens the defect the other closes. A body carrying a trailing-slash URL contains the slash-less form as a substring, so a presence-only check passes a pointer that 404s; and a probe that derived the URL itself and then resolved that would confirm only its own arithmetic while the body stayed wrong. Presence runs over the URL the arm derives; resolution over the URLs the body actually carries.
Presence is deliberately loose in one direction and strict in the other. Containment, never equality: a body may write a suffixed or otherwise decorated variant of the URL, so a token-equality test reds a corpus that is in fact correct — and the looseness that permits it is exactly what lets the trailing-slash form through, which is why resolution exists rather than a stricter presence. Scheme-qualified: a markdown link whose visible label repeats the URL scheme-less satisfies a scheme-less presence test while the link target goes unchecked, and a wrong-but-resolving target then passes both assertions with nothing red.
URL extraction is scheme-anchored over the whole body text, never
markdown-link-aware — only the scheme reliably separates a link target from a
visible label — and a trailing run of punctuation is stripped from an
extracted URL before it is resolved. The punctuation that occurs in practice is
markdown-structural rather than sentential: a ) closing a link target, or )**
closing one inside bold. A stripping set written for the period captures those
closers into the URL and reds a healthy corpus, so the set carries the markdown
closers alongside sentence punctuation (the template owns the set), and strips a
run rather than a single character — or )** merely becomes )*.
The extracted set is not narrowed to note URLs: the assertion is over
apex-hosted URLs, so a body’s other apex links are covered deliberately.
The tag list comes from the API, paginated, never from git tag.
actions/checkout defaults to fetch-depth: 1 and fetches no tags, so a
git tag-driven arm finds zero released notes and reports green forever. Two
properties of the call are load-bearing: gh api --paginate discharges the
paging with no manual page loop, and because it concatenates the pages’ arrays,
--jq 'length' returns the last page’s length rather than the total — so the
count is taken by streaming the elements, and the pagination is forward-looking
insurance no run will ever confirm until a consumer passes its first page. The
tag list is not replaceable by a per-note Release-by-tag lookup: a 404 there
cannot distinguish a tag that was never pushed (a legitimate skip for a note
whose release is deferred) from a tag whose Release is missing (a real finding),
and conflating them either reds every deferred note or hides a missing Release.
The arm’s three knobs are step-level workflow env on the probe step, set in
the copied file the way ALT_DOMAIN already is (§Layout and configuration):
RELEASE_NOTE_GLOB— the tracked path glob enumerating release-note files.RELEASE_NOTE_TAG_KEY— the front-matter key whose value is the tag a note belongs to. Load-bearing rather than decorative: a posts directory holds posts that are not release notes, and this key is what separates them. It is read anchored inside the opening front-matter fence, so the same key spelled in a note’s body is not mistaken for the note’s own tag.RELEASE_NOTE_URL_PATH— the site path a note is published at, written with a{slug}token the arm substitutes with the note filename minus its extension. It holds the path only; the host is never repeated here, because the CNAME file stays the single source for it and a host rename must stay a one-file edit. This is the knob with no cross-check anywhere — nothing outside the site generator derives a note’s served URL — so it is the one a consumer can set wrongly and see nothing red. The concrete trap: a generator that emits/posts/<slug>.htmlwhile the host also serves the extensionless form makes the suffixed value look the more literally correct of the two, and it reds every body written to the bare form.
The zero-cases, ruled exhaustively. Every count the arm ranges over can be zero, and each zero is either a finding or a legitimate pass; an unruled one is how the arm becomes a green that means nothing.
- The glob matches no notes — a finding. A misconfigured
RELEASE_NOTE_GLOBis itself the defect, which is what makes “set these knobs or delete the arm” an instruction rather than advice. - The tag-list call fails — a finding, asserted on the call’s own exit
status with the API’s error text (which carries the HTTP code) in the message,
never inferred from the count that follows. The probe step runs under
set +e, so a failed invocation yields an empty tag list and a zero exit; every note then reads “not yet released” and the arm reports a clean run forever. On the per-Release lookup a failure reads like “no such Release”; on the tag-list call the same failure reads like “green”. - The per-Release lookup fails for a tag that exists — a finding. The tag list has already established that the tag is real, so this is the missing-Release case rather than the deferred-release one.
- The glob matches notes and none carries the tag key — not a finding, stated so it is not added by symmetry with the first case. A consumer whose notes genuinely precede its first release is in this state legitimately. It is nonetheless the likelier of the two knob misconfigurations, precisely because the glob’s failure is loud and the key’s is silent.
- Zero released notes — not a finding, for the same reason.
The census line makes those states readable; the rulings above are what make a
vacuous pass impossible. On every run the arm prints a census of what it ranged
over — notes found, of those carrying the tag key, of those released against the
remote tag count, and of those checked. No count in it is another by
construction: the gap between found and keyed separates a typo’d
RELEASE_NOTE_TAG_KEY from a corpus that simply mixes notes with ordinary posts,
the gap between keyed and released is the legitimate pre-release state, and
the gap between released and checked is a Release the arm could not read.
Collapsing found into keyed would render the first two of those states
identically, which is what reporting them apart buys. This is the same shape as
gate-sdk’s vacuous-pass
tripwire (§run-gates): a reading available to whoever opens the run log, not an
assertion, and on a green run nobody opens it. It earns its place as the
diagnostic naming which state a reader is in; it is not itself what stands
between this arm and a vacuous pass.
Stated coverage limit. The arm is driven from the tree’s notes, so it is total over note→Release and silent on the reverse: a Release carrying no note anywhere in the tree reds nowhere. The deliverable is the note’s pointer, and the population that has ever gone wrong is the pointer’s.
Cost per run. The paginated tag list, one Release lookup per released note, and one resolution request per distinct apex URL in each body — linear in release count, on a daily schedule, well inside the authenticated rate limit. Deliberately uncapped and unknobbed: a “newest N only” bound would stop probing exactly the old releases where this class of defect has actually been found.
The kit’s smoke/install.sh installs the template verbatim into the scratch tree
as governed surface, and registers the gates that read it. The scratch battery is
the union of what each kit’s smoke/install.sh registers; this kit contributes
check-docs-cname-parity and — because it writes the only Actions-shaped surface
any install writes — the gates that lint a workflow’s Actions shape:
check-action-pinning, check-action-run-shell, check-action-gh-repo.
check-tree-terms reads the installed file too, for tree terms. So a template
regression in the workflow’s bash, its action pins, or its gh repository
context reds the smoke rather than surfacing at a consumer.
Those four qualify under the registration accounting’s predicate — a gate earns a scratch-battery slot when it reads a surface the install writes — because this install writes the workflow they lint. The predicate binds every kit and is owned by gate-sdk/SPEC.md §Consumer smoke, which also rules on the omissions: what this kit leaves unregistered is decided by the accounting’s probe, not by a judgment recorded here.
Out of scope
The kit does not resolve DNS, provision certificates, or configure the host
platform — those are the deployment’s concerns, surfaced by the monitor, not
governed by a gate. It does not gate the content of the docs site (links,
commands, prose): that is canon-kit’s charge over the governed doc set —
check-docs-render-fidelity gates how a page renders, never what it says. And
it holds no opinion on which host a project uses — only that the tree cites one,
and that the CNAME file names it.