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 apart: what the tree says about the site’s host, and whether the live deployment is up. site-kit gates the tree side: the cited host, whether each page renders faithfully through the platform’s own parser, and whether each page parses under the platform’s template engine. It ships a template for deployment liveness, and holds 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 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. Enforcing it through a pre-commit or CI gate would break the low-false-positive contract and the CI backstop’s checkout-plus-bash hermeticity (gate-sdk/SPEC.md §Enforcement tiers).

Object location governs, not whether a commit caused the failure. 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, and are still ungateable, because the artifact damaged sits on the host and not in the tree. A criterion built on cause admits the first kind and stumbles on the second. One built on where the object lives covers both, and holds for every probe arm.

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. That the repo never cites a stale host is a property of the tree, so that half is a real gate.

Layout and configuration

The kit is vendored beside gate-sdk (conventionally at site-kit/). Its gates register in the consumer’s gates.list by name and resolve through gate-sdk’s multi-kit path, each where its surface exists:

  • check-docs-cname-parity — where a docs site with a gated host exists; a consumer without one omits it.
  • check-docs-render-fidelity and check-docs-collapsible — where a docs site built by the Pages parser exists.
  • check-docs-liquid-parse — where a docs site is built by Jekyll; a consumer whose host runs no Liquid omits it.
  • check-docs-highlight-coverage — anywhere. It is disarmed until SITE_KIT_HIGHLIGHT_TOKENS names a snapshot, and a site whose layout restyles code highlighting arms it.

Config is a knob file: write a site-config.knobs in the gates dir, or point SITE_KIT_KNOB_FILE elsewhere, setting any knob §Knob defaults lists. Defaults fill what the file leaves unset. gate-sdk/SPEC.md §The knob file owns the grammar, the .local overlay, the environment-over-file precedence for a scalar, and the refusals: a set SITE_KIT_KNOB_FILE that does not exist, a left-behind site-config.sh, and a non-empty file named by the retired SITE_KIT_CONFIG_FILE.

templates/site-health.yml is governed by none of these. Its knobs (ALT_DOMAIN, the three RELEASE_NOTE_* and the two HIGHLIGHT_* below) are step-level workflow env set in the copied file itself, not SITE_KIT_* knobs, and no knob file names them. The template is copied and edited, not read as config. Reaching into the gates dir would couple a monitor to it, and would hard-code a vendored kit path into a workflow distributed by verbatim copy.

Knob defaults

site-kit’s knobs are static: the binary resolves them in process from its own defaults table and the consumer’s knob file (gate-sdk/SPEC.md §The knob file). bash gate-sdk/bin/run-gates.sh --emit knob-roster prints each one with its shape and rendered default.

  • SITE_KIT_CNAME — the CNAME file holding the one authoritative host line, default docs/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 — the git ls-files root 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 root, default docs. check-docs-render-fidelity and check-docs-collapsible walk it for tracked markdown pages, and check-docs-liquid-parse for the files Liquid runs over.
  • SITE_KIT_HIGHLIGHT_TOKENS — the tracked snapshot of the token classes the site theme’s highlight CSS colours, default empty, which disarms check-docs-highlight-coverage. Which theme a site uses and which classes it colours is one project’s content, so the list is consumer config.
  • SITE_KIT_HIGHLIGHT_OVERRIDES — array of pathspecs listing the tracked files whose CSS overrides the theme, default ("docs/_layouts/*.html").
  • SITE_KIT_HIGHLIGHT_SCOPE — the selector the theme scopes its highlight classes under, default .highlight.
  • 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. check-docs-render-fidelity renders each page through it when SITE_KIT_RENDERER_BATCH is empty, so it is the fallback of the gate’s two renderer contracts. A consumer whose Pages stack differs points it at its own renderer; an unresolvable one fails the gate closed.

    Its value is also a port blocker, and repointing it moves one. The knob’s first element is an external program check-docs-render-fidelity requires, the class gate-sdk’s criterion 7 screens for. bash gate-sdk/bin/run-gates.sh --emit port-blockers derives it by resolving this knob (gate-sdk/SPEC.md §port-blockers). The gate’s own source spells the dependency nowhere, so it is recorded here, where the knob lives. A consumer who points the knob 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. Where it is non-empty the gate never invokes SITE_KIT_RENDERER, so the program the gate requires is this knob’s first element.

    Default: the batch form of SITE_KIT_RENDERER’s default, a ruby -e loop splitting stdin on NUL and writing one NUL-terminated HTML document per input. It is filled only when SITE_KIT_RENDERER is itself still at its kit default. Filled unconditionally it would defeat a deliberate pin. A consumer who pointed SITE_KIT_RENDERER at the version-locked bundle below would have that pinned oracle replaced by this unpinned one, and the gate would report clean against a parser build they rejected. Under the rule they keep the per-document path at its own cost and semantics, and opt in by pointing this knob at the batch form of their own pinned renderer. “Pinned” means set in a knob file, tracked or overlay: an indexed knob takes no environment override, so a file is the only place SITE_KIT_RENDERER is set from.

  • SITE_KIT_LIQUID_PARSER — array, the batch Liquid-parse command check-docs-liquid-parse streams its corpus through, on the verdict contract specified in §check-docs-liquid-parse. Default: a ruby -e loop that requires liquid, splits stdin on NUL and parses each document with Liquid::Template.parse under Jekyll’s own settings, writing one NUL-terminated verdict per input. Its value is also a port blocker, on SITE_KIT_RENDERER’s terms: the first element is an external program the gate requires, which --emit port-blockers derives by resolving this knob.
  • SITE_KIT_LIQUID_TEMPLATES — array of repo-relative pathspecs naming the tracked files check-docs-liquid-parse reads as Liquid templates, default ("docs/_layouts/*" "docs/_includes/*" "docs/_posts/*"), Jekyll’s three template directories under the default docs dir. A site that renames them in its Jekyll config, or moves its docs dir, sets this beside SITE_KIT_DOCS_DIR.

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 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 reads the survivors for ://<host>. A file carrying a NUL byte is binary and is skipped. A host that is a configured alias other than H is a finding, each occurrence on a line its own.
  • Sources are tracked content only, so an untracked local file is never one. 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.

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.

Its default scan root makes the whole tracked tree its content corpus. 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. That is gate-sdk’s criterion 4, reached through the walk and not through the trigger field. The # graph: couple is the single literal CNAME file, so the derived substrate-sensitive set never selects this gate, and gate-sdk’s conservation assertion cannot see the hold (gate-sdk/SPEC.md §The fourth budget batch). The fixture pair carries the arm instead, because gate-tests is pruned from every live-tree walk and no port can change what is inside it. The pair’s good case takes the default arm: no positional, scan root and CNAME both at their knob defaults, a fixture declaration path inside its own corpus. The bad case keeps the explicit-root arm.

There is no third positional selecting a config file. SITE_KIT_KNOB_FILE already selects which knob file the member reads, so a positional would be a second spelling of that one knob. A fixture supplies its own alias set the way any consumer does, with a site-config.knobs in the gates dir of its case.

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;
  • 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 renders the real output and asserts the observed leakage class; it does not trust the source.

The leakage class has more than one root cause, and the assertion keys on the shared symptom, so one low-false-positive assertion covers every shape that leaves the same signature. The causes observed:

  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;
  4. a single-backtick span whose content begins or ends with a space.

The first three are one kramdown behavior. Parsing blocks before spans, it treats the angle-bracket token as the start of an HTML block and severs the span, emitting raw HTML that swallows the rest of the page (gettalong/kramdown#843, closed works-as-designed: a documented, permanent divergence).

The fourth is a different mechanism with the same signature. Such a span does not form at all under the GFM parser, so its opening backtick stays literal and pairs with the next backtick on the page, severing a well-formed span further down. The reported location is therefore downstream of the defect, and the cause is found by reading upward from it.

A severed span never forms the closing fence run. It leaves a single stray backtick and a raw placeholder tag in the rendered text, not a multi-backtick marker, and those are the two symptoms the assertion keys on.

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 does not 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 path segment is excluded, a file’s own name included: those are Jekyll internals, not published pages. It strips Jekyll front matter, which only a leading --- opens and the next --- line closes, so it renders exactly the body kramdown sees. An opening --- that no later one closes delimits no block, which is Jekyll’s reading, and the page is rendered whole. It asserts three properties per page:

  1. No span-corruption leakage — the rendered text carries neither a literal backtick nor a raw tag whose element name is not a known HTML element.
  2. No heading leakage — the count of rendered heading elements never exceeds the count of source heading lines outside any code context. The source count is the gate’s own fence-aware scan under cmark rules: ATX and setext, both skipped inside a fenced or ~-fenced block. A surplus rendered heading is a # line promoted out of a broken code block.
  3. No table leakage — the count of rendered <table> elements is never less than the count of source GFM table starts the same 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, since a raw-HTML <table> in source renders without a GFM start, and 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 span-corruption scan. The scanned text is the rendered HTML’s text content 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. Two symptoms red:

  • (a) any literal backtick, a single stray one and not only a `{3,} fence run;
  • (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.

Either is the signature of a code span or fenced block the parser failed to form: a backtick that never paired, or a placeholder tag kramdown passed through as a raw HTML block. The two are complementary, so the assertion keys on both. A swallowed page region whose own inline code re-leaks as literal backticks trips (a); a swallowed backtick-free region, pure prose or headings, trips only (b).

The foreign-content exemption. 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 and not 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 and emits its tail as escaped text, a real render divergence the gate is right to red.

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. The 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.

checks/check-docs-render-fidelity.gate (precommit, binary-dispatched), coupling the docs tree, its rule in 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. The member spawns it and refuses at exit 2 when it cannot run (gate-sdk/SPEC.md §The port-candidate criteria, criterion 7). §Knob defaults owns the dependency, and the fact that a consumer who repoints a knob moves it.

Its requirement is knob-derived, and it is two knobs. --needs declares git plus ?<TAB>SITE_KIT_RENDERER_BATCH and ?<TAB>SITE_KIT_RENDERER, and the port arm resolves the pair through the same knob resolver the member reads them through. Naming only SITE_KIT_RENDERER would under-declare: 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. 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.

The positional form is check-docs-render-fidelity [docs-dir], defaulting to SITE_KIT_DOCS_DIR, which lets a fixture point the docs dir at a synthetic tree without touching consumer config. No second positional selects a config file. A harness pointing the renderer knobs at a synthetic setup exports SITE_KIT_KNOB_FILE naming a knob file, and the member reads its static knobs from that file (gate-sdk/SPEC.md §The knob file).

The refusal order. 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. The probe is not a presence test. It probes the oracle by running it, because for a renderer on-PATH is not the property the gate needs. An absent program therefore surfaces as the probe’s own exit status inside the member’s own sentence.

The batch stream. 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. The framing is NUL-terminated in both directions. The gate writes each front-matter-stripped body, NUL-terminated, to the command’s stdin. 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 an unterminated tail is not a document.

NUL-stripping is deliberate. Page content cannot forge the terminator, because the gate’s own reader has already dropped any NUL in the source before framing happens. In the shell form that was a side effect of bash being unable to hold one in a variable. A compiled reader can hold one, so it drops them at the same point on purpose. 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 implement exact-byte reads where splitting on a byte is a one-liner in every language a Pages stack might use.

The count is the fail-closed. The count stands in for a per-page exit status, which the shell form’s process substitution discarded. A renderer that dies mid-stream yields fewer documents than pages. So after the stream the gate compares documents read against pages enumerated, one check detecting renderer death, truncation and framing error alike. A mismatch 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. 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 false-clean class the fill condition in §Knob defaults closes from the other direction.

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. One 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 form is a doubled-backtick code span, which renders the character inside <code>, 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 only a token outside the set, a placeholder kramdown mistook for an HTML block, reds. A placeholder meant as literal prose is written as a code span (`<verdict>` renders <code>&lt;verdict&gt;</code>) and never surfaces as a raw tag. The residual legitimate case is a page that deliberately embeds a non-standard element, a custom element or web component: the narrow accepted false positive.
  • The element set is a kit built-in, not a config seam. The HTML standard’s element list is universal render truth, not consumer rule content. If a consumer’s demand for non-standard elements ever attests, an optional allowlist-extension knob is the config-via-env answer; it is not 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 span-corruption symptom, headings, and tables) and stays silent on divergences that corrupt none of the three.

  • 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 and never false-reds.

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. That bad page would red on its fence run alone, so every assertion the pair cannot isolate carries a hermetic unit test of its own: 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, and a trailing blank line clears it.
  • The foreign test holds the SVG/MathML exemption to its scope. A page carrying inline <svg> clears, while a bare <path> outside any <svg> still reds. That second half 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 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, read off --emit knob-roster, render a corpus byte-identically. It asserts 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.

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_KNOB_FILE. That a pinned SITE_KIT_RENDERER suppresses the batch default is a crate unit test on the site-kit knob table, because the fill rule is a property of the table and not of a tracked tree.

Crate unit tests carry what the pair cannot. Every assertion that is a pure function of a page’s bytes is one:

  • 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;
  • the Jekyll-internal segment test.

They are commit-time oracles through check-crate-arms, as the bespoke tests are through the fixture runner. 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 and the four bespoke tests need a real tracked tree, 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. The oracle and the deploy then render through 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 leaves the batch knob empty by its fill rule, so a half-applied pin costs speed and not fidelity. 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 the pin 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.

check-docs-liquid-parse

checks/check-docs-liquid-parse.gate (precommit, binary-dispatched, install: on-surface), its rule in native/src/gates/docs_liquid_parse.rs. Invariant: every tracked file under SITE_KIT_DOCS_DIR that the Pages build runs Liquid over parses under the parser SITE_KIT_LIQUID_PARSER names. Jekyll runs Liquid over a page before kramdown sees it, and a Liquid syntax error fails the whole build while the host keeps serving the last good tree. So one unbalanced {{ on one page freezes the site with no signal in the tree. §check-docs-render-fidelity cannot see it, because it renders through kramdown alone.

The corpus is enumerated with git ls-files under the docs dir, the gate-sdk prune set applied. A path is a template when one of SITE_KIT_LIQUID_TEMPLATES’ pathspecs selects it, resolved as git resolves a pathspec against the repository-relative path. A pathspec selecting a file outside the docs dir selects nothing, and an empty knob selects no template. Every other path is classed relative to the docs dir. It holds three classes:

  • every *.md file outside an underscore-prefixed path segment — §check-docs-render-fidelity’s page set, because Pages’ optional-front-matter plugin renders a front-matter-less markdown page too;
  • any other tracked file outside such a segment whose first line is the front-matter fence ---, because Jekyll renders a file carrying front matter whatever its extension (a search.json template, say);
  • every tracked file SITE_KIT_LIQUID_TEMPLATES names, Jekyll’s template directories at the default. Every underscore directory it does not name stays excluded, as render-fidelity excludes it.

Front matter is not handed to the parser, since Jekyll reads it as YAML and never as Liquid. Where a file opens with the fence and a later line closes it, the gate replaces every line from the opening fence through the closing one with an empty line, so every line number the parser reports is a line of the file. A fence line may carry trailing whitespace. An opening fence that nothing closes is no front matter, which is Jekyll’s own reading, and the file is parsed whole. A NUL in the source is dropped before framing, on §check-docs-render-fidelity’s rule.

The parser contract. SITE_KIT_LIQUID_PARSER is an array knob naming one command, which gets the whole corpus over one stream. It reads NUL-terminated documents from stdin and, for each document in order, writes one NUL-terminated verdict to stdout: empty when the document parses, otherwise the parser’s error message. That is §check-docs-render-fidelity’s batch framing with a verdict in place of a rendered document. There is no per-document form: the gate reads only a verdict, so a second contract would buy a second program to probe and nothing else. The default parses under Jekyll’s own Liquid settings (error_mode: :warn, line_numbers: true) and writes a Liquid::Error’s message with any NUL replaced by a space; any other exception propagates, so the command dies and the count check refuses.

The probe. Before enumerating, the gate runs the parser on two documents, {{ probe }} and the unterminated {{ probe, and must get back exactly two verdicts, the first empty and the second non-empty. Anything else — an unrunnable command included — exits 2 with a help line naming the knob and its contract. The probe exercises detection as well as framing, so a command that parses nothing (cat, or a stub answering every document with an empty verdict) is refused rather than read as a clean corpus.

Refusal order, each refusal before any finding and winning over the ones after it: not a git repository; docs dir not found; the probe; the enumeration; and the count check. After the stream, verdicts read must equal documents written, else exit 2: a short count is a parser that died, truncated its output or framed it wrongly.

Red (exit 1) is one finding per file with a non-empty verdict, <path>: <verdict>, the verdict cut to its first line and that line to 200 characters with a trailing …. The help line names the remedies: wrap Liquid-significant text a page means literally in a raw block (for a generated page, in its emitter), or remove the token. Clean reports the files parsed, by class; an empty corpus is clean on a zero count.

--needs declares git and ?<TAB>SITE_KIT_LIQUID_PARSER, so --emit port-blockers resolves the requirement from the knob — §check-docs-render-fidelity’s knob-derived shape, with one knob. The # graph: couples are render-fidelity’s plus knob:SITE_KIT_LIQUID_TEMPLATES. A front-matter file in any other place is outside the hook’s trigger; the full battery and CI still read it. No valve: a page whose Liquid does not parse fails the Pages build, so an exempted finding would still freeze the site.

The good/bad pair carries the three classes, a raw-wrapped workflow expression, a front-matter fence and an out-of-corpus .html file whose bare {{ must stay unread. Each case’s knob file renames the layouts directory, so a template under the renamed one is read and, in good/, a broken file left in the unnamed _layouts/ is not. check-docs-liquid-parse-parser.test.sh carries the arms the pair cannot:

  • a blind parser failing the probe;
  • a stub passing the probe and then returning a short count;
  • an unresolvable command;
  • an error below a front-matter block reporting the file’s own line.

The corpus classes, the front-matter blanking, the verdict clipping and the probe verdicts are crate unit tests.

Honest limits.

  • A parse is not a render. A balanced {{ … }} or {% … %} parses and renders as something else, and an unknown variable renders blank, so a page that means such a token literally loses it silently and this gate does not see it.
  • Unknown tags. The default parser is bare Liquid. A tag only Jekyll or a plugin registers (highlight, link, post_url, seo) raises Unknown tag under it, so a site that uses one points the knob at a command that loads Jekyll and those plugins first.
  • Version fidelity. The default parses with whichever liquid the host resolves. §check-docs-render-fidelity’s exact-pin recipe applies unchanged — a bundle exec form whose lock pins liquid to the version the github-pages gem resolves — and the kit does not fetch the pin at gate time, for the hermetic-oracle reason that section gives.
  • Over-reading. A file _config.yml excludes, or a post Jekyll would not render, is still read; a finding there is a false red, cleared by the same raw block.
  • Other markdown extensions. A front-matter-less page with a markdown extension other than .md is not read.

check-docs-collapsible

checks/check-docs-collapsible.gate (precommit, binary-dispatched, install: on-surface), its rule in native/src/gates/docs_collapsible.rs. Invariant: every collapsible region on a tracked page under SITE_KIT_DOCS_DIR renders its body as markdown, names what it holds, and hides no heading.

  • Corpus: §check-docs-render-fidelity’s page set, the same git ls-files enumeration with underscore-prefixed segments excluded.
  • A region opens at a line whose first non-blank text is <details and closes at a </details>, counted by depth so a nested region is its own region. Fenced blocks, HTML comments and the front-matter block are skipped, the block read as §check-docs-render-fidelity reads it, so a page whose opening --- nothing closes is scanned whole.
  • Assertions, one finding each:
    • the opening tag carries markdown="1" or markdown="block", since without it the Pages parser passes the body through raw;
    • a <summary> element with non-empty text follows the opening tag on its own line or on the next non-blank line;
    • no ATX or setext heading sits inside the region, since a heading a link targets must not be hidden;
    • every region closes before the page ends.

Red names the page, the opening line and the assertion; the help: line names the region’s form. Clean counts pages and regions; a page with none is clean. Exit 2: not a git repository, a docs dir not found, or an unreadable page, in that order, the refusal order §check-docs-render-fidelity states. No valve: a raw body and an unlabelled region have no legitimate case, and a region that must hold a heading is a section to leave open. No renderer is needed; the check reads source only, so it runs where Ruby is absent.

Honest limit: the gate holds the form, not the choice. Whether a region hides the primary path is review’s.

check-docs-highlight-coverage

checks/check-docs-highlight-coverage.gate (precommit, binary-dispatched, install: zero-config, armed by SITE_KIT_HIGHLIGHT_TOKENS). A site that restyles code highlighting has to override every token class the theme’s own highlight CSS colours. A class it misses keeps the theme’s colour, and on a dark code background that can be an unreadable token: Rouge marks a shell && with .o, and a light theme colours .o black.

The invariant splits along §The monitor boundary. Two objects are involved, and they live in two places. The layout’s overrides are in the tree, so that the layout covers a declared class list is a tree property, and this gate asserts it. The theme’s coloured class set lives on the host: it is served from the deployment, at a theme version the platform chooses, and no commit contains it. That the declared list still matches the live theme is deployment truth, which the theme-drift arm of templates/site-health.yml asserts. The tracked snapshot is the seam between the two: this gate reads it as its oracle, and the monitor holds it to the live CSS.

  • The snapshot is the file SITE_KIT_HIGHLIGHT_TOKENS names. Its first line is a # contract: header; below it, one class per line, written as .o. A # line and a blank line are ignored, and any other line that is not one .<class> token is a finding. Empty, the knob disarms the gate, which still prints its clean line saying nothing was asserted.
  • The overrides are the tracked files git ls-files lists for each SITE_KIT_HIGHLIGHT_OVERRIDES pathspec. A file carrying a <style element is read for its <style> bodies only, so a template brace elsewhere in the page opens no rule; any other file is CSS throughout. Comments are stripped, and a rule’s selector list is the text since the previous {, } or ;, so the inner rule of an at-rule is a rule.
  • Covered. A class is covered when some rule has a selector that, after the selector list is split on commas and its whitespace collapsed, ends in <scope> .<class>, where <scope> is SITE_KIT_HIGHLIGHT_SCOPE. Any ancestor prefix before the scope is admitted, joined by a space or >, so .site-content .markdown-body .highlight .o covers .o. The split is to the comma because a grouped rule’s later members are the ones a first-selector reading misses: in .highlight .k,.highlight .kv{…} only the split reaches .kv.
  • Assertion: every snapshot class is covered.

Findings, each on its own line:

  • each uncovered class, naming the class and its snapshot line;
  • an armed snapshot holding zero classes. A capture that extracted nothing is the extraction’s failure, not an empty theme, so this reader reds on finding none;
  • an override pathspec listing no tracked file, since the covering file is gone;
  • a snapshot line that is not one class.

The clean line counts snapshot classes, override files and covering rules. Exit 2 when the snapshot is named but does not exist or cannot be read, and when git ls-files fails. The remedy for an uncovered class is an override taking its token family’s colour, and for a stale snapshot the monitor’s printed list. The window the split leaves open is stated with the monitor’s arm (§templates/site-health.yml, the theme-drift arm).

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 (Pages renews about thirty days out, so a certificate inside a fortnight means renewal has stalled);
  • every published release note is pointed at by a resolving URL in its Release body;
  • an optional theme-drift arm holds the highlight snapshot to the live theme.

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. Its floating runs-on label follows gate-sdk/SPEC.md §templates/gates-workflow.yml.

The resample. One bad sample is not a failure. The probe set runs up to a bounded number of attempts with a pause between them, and only a set that fails on the last attempt reaches the failure path. The template owns the count and the pause as literals, as it owns each probe’s timeout. The whole set is resampled, never one request, so a single bound covers every arm, the API reads included, and a wrong answer no HTTP client classes as transient is resampled too.

  • The report is the last attempt’s failures; an earlier attempt’s are printed to the run log only.
  • A standing defect is resampled too, a misconfigured knob included, so it reaches the issue later by the pauses.
  • The synthetic failure the force_fail input injects is added after the last attempt and waits out no pause.
  • Each attempt prints its own census lines, and the run log says which attempt settled the set.
  • A failing run costs up to the attempt count times what Cost per run below states.

The arm roster is a floor, not a ceiling, and “copied verbatim” describes the distribution model, not a ban on edits. 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: 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. It then 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 the arm without it 404s on every note, because GitHub masks an unauthorized read as an absent resource: the failure arrives looking like “no such Release”, not “not permitted”.

An oracle holds this declaration, not 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. The wider workflow-security category stays a non-goal 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. The URL is derived scheme-qualified, as https:// plus the apex host from the CNAME file plus the path from RELEASE_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. 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. That looseness is what lets the trailing-slash form through, which is why resolution exists and not 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. 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, since only the scheme reliably separates a link target from a visible label. A trailing run of punctuation is stripped from an extracted URL before it is resolved. The punctuation that occurs in practice is markdown-structural, not 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 a run is stripped, since stripping one character leaves )** as )*. 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;
  • it concatenates the pages’ arrays, so --jq 'length' returns the last page’s length and not the total. The count is taken by streaming the elements. No run confirms the paging until a consumer passes its first page.

A per-note Release-by-tag lookup cannot replace the tag list. 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. 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. It is load-bearing: 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: the CNAME file stays the single source for it, and a host rename must stay a one-file edit. This knob has no cross-check anywhere, since nothing outside the site generator derives a note’s served URL. The concrete trap: a generator emits /posts/<slug>.html while the host also serves the extensionless form. The suffixed value then looks 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_GLOB is itself the defect, which is what makes “set these knobs or delete the arm” an instruction and not 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. Unasserted, every note would then read “not yet released” and the arm would report a clean run forever.
  • 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 and not 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, 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, and each gap is a state.

  • Found to keyed separates a typo’d RELEASE_NOTE_TAG_KEY from a corpus that simply mixes notes with ordinary posts.
  • Keyed to released is the legitimate pre-release state.
  • Released to checked is a Release the arm could not read.

The census is a reading available to whoever opens the run log, not an assertion, and on a green run nobody opens it: the shape of gate-sdk’s vacuous-pass tripwire (gate-sdk/SPEC.md §run-gates). It is the diagnostic naming which state a reader is in, and is not 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.

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 the old releases, and a dead pointer there is no less a defect.

The theme-drift arm is the monitor half of §check-docs-highlight-coverage. It is optional, on the set-the-env-or-delete-the-arm pattern, and takes two step-level env values:

  • HIGHLIGHT_CSS_PATH — the site path of the theme stylesheet;
  • HIGHLIGHT_TOKENS_FILE — the tracked snapshot, the same file SITE_KIT_HIGHLIGHT_TOKENS names.

The arm fetches https://<apex><HIGHLIGHT_CSS_PATH>, the apex read from the CNAME file as the other arms read it, and extracts the class set by the extraction rule:

  • Rules. Comments are stripped and the stylesheet is split into rules at }.
  • Colour-bearing. A rule is kept when its declaration block sets color, background or background-color.
  • Selectors. Its selector list is split on ,.
  • Classes. Each selector that is exactly .highlight .<class>, one class after the scope, is kept.
  • Result. Sorted and de-duplicated.

It compares that set with the snapshot’s classes (# and blank lines dropped) in both directions. A class the theme colours and the snapshot lacks is a finding, because the gate would never ask for its override. A class the snapshot lists and the theme does not colour is a finding, because the snapshot is a copy and a copy must match. On either, it prints the added and removed classes and the full new list, so the remedy is a paste into the snapshot, followed by whatever overrides the gate then reds on.

The zero-cases:

  • a fetch failure is a finding, asserted on the call’s own exit status with curl -f, so an HTTP error is a failure rather than an error page parsed;
  • a fetched stylesheet yielding zero classes is a finding, since the extraction or the theme’s structure broke;
  • an unreadable snapshot, or only one of the two values set, is a finding;
  • with both values empty the arm is skipped, with a census line saying so. The template ships them empty.

The census prints fetched bytes, colour-bearing rules, extracted classes and snapshot classes. The fetch is anonymous HTTPS, so the arm needs no new permissions: scope.

The honest limit. The gate cannot see a theme bump, and this arm sees it on its next scheduled run. Between a bump and that run the live site can show uncovered tokens. The daily schedule bounds how long that lasts, and the pre-bump tree cannot prevent it.

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’s leg registers its docs gates. The workflow is linted by the check-action-* gates and read by check-tree-terms, which gate-sdk’s own leg registers beside the workflow template gate-sdk installs. So a template regression those lints read, in the workflow’s action pins, its run steps’ declarations or its gh repository context, reds the smoke and does not surface at a consumer. The smoke lints the probe’s bash and never executes it.

Each registration qualifies under the registration accounting’s predicate, which binds every kit and rules on the omissions (gate-sdk/SPEC.md §Consumer smoke): 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), which is canon-kit’s charge over the governed doc set. check-docs-render-fidelity gates how a page renders and check-docs-liquid-parse whether it parses, 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.