Catch a doc page whose source moved on without it #69

Merged
paulhitt merged 1 commits from 7-doc-page-drift-guard into main 2026-08-31 17:04:29 +00:00
13 changed files with 155 additions and 13 deletions
+20
View File
@@ -170,6 +170,26 @@ conformance:
timeout: 40 minutes
# What gets a package wrong is missing files, not failing logic.
# 🔴 The generated pages are what people actually read. A source can be fixed
# and the page left stale, and nothing says so — /self-hosting was served for a
# day telling self-hosters to run `docker compose up -d` without the
# `docker network create web` that the fix a day earlier had added.
#
# ⚠️ Hashes, not a re-render. Running pandoc here and diffing would make this
# hostage to the pandoc version — a different release emits different HTML, the
# job fails where nothing is wrong, and the tempting "fix" is to commit whatever
# the CI image produced. The banner records a hash of the source it was built
# from, so this needs no pandoc at all: alpine + python3 + a shallow clone.
doc-pages:
<<: *rules
stage: test
image: alpine:3.20
script:
- apk add --no-cache python3 git >/dev/null
# Same public clone the conformance job uses.
- git clone --depth 1 https://gitlab.com/paulhitt/flamenet-e2e.git /tmp/e2e
- python3 Scripts/check-doc-pages.py --engine /tmp/e2e
complete:
<<: *rules
stage: test
+2 -1
View File
@@ -2,7 +2,8 @@
#export("content"):
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/API.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it. -->
Change the markdown source and re-run it.
source-sha256: 81839b97940fe782 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="relay-api">Relay API</h1>
+2 -1
View File
@@ -2,7 +2,8 @@
#export("content"):
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/docs/AUDIT-SCOPE.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it. -->
Change the markdown source and re-run it.
source-sha256: 3e8629521db871e2 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="audit-scope">Audit scope</h1>
+2 -1
View File
@@ -2,7 +2,8 @@
#export("content"):
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/CONFIGURATION.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it. -->
Change the markdown source and re-run it.
source-sha256: ab6906714a4d6d47 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
+2 -1
View File
@@ -2,7 +2,8 @@
#export("content"):
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/README.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it. -->
Change the markdown source and re-run it.
source-sha256: a8943191a78ff640 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-e2e">flamenet-e2e</h1>
+2 -1
View File
@@ -2,7 +2,8 @@
#export("content"):
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/HOSTING.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it. -->
Change the markdown source and re-run it.
source-sha256: cddcb5a3308c96cc -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="running-your-own-relay">Running your own relay</h1>
+2 -1
View File
@@ -2,7 +2,8 @@
#export("content"):
<!-- GENERATED by Scripts/build-doc-pages.sh from README.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it. -->
Change the markdown source and re-run it.
source-sha256: dcca28c62e60afd4 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-relay">flamenet-relay</h1>
+2 -1
View File
@@ -2,7 +2,8 @@
#export("content"):
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/SECURITY.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it. -->
Change the markdown source and re-run it.
source-sha256: ea3544ef2e092cf9 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="security">Security</h1>
+2 -1
View File
@@ -2,7 +2,8 @@
#export("content"):
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/SELF-HOSTING.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it. -->
Change the markdown source and re-run it.
source-sha256: a4ed8fd6048d30cd -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="self-hosting">Self-hosting</h1>
+2 -1
View File
@@ -2,7 +2,8 @@
#export("content"):
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/docs/SPEC.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it. -->
Change the markdown source and re-run it.
source-sha256: 41002a1ef1cc4ff5 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-messenger--end-to-end-encryption-spec-v1">Flamenet
+3 -1
View File
@@ -44,7 +44,9 @@ render() {
local shown="${src#"$here/"}"
shown="${shown#"$engine/"}"
[ "$shown" = "$src" ] || [ "${src#"$engine/"}" = "$src" ] || shown="flamenet-e2e/$shown"
python3 "$here/Scripts/md2leaf.py" "$slug" "$tmp/$slug.html" "$views" "$shown"
# `$src` is passed as well as `$shown`: the first is what gets hashed into
# the banner, the second is only what the banner displays.
python3 "$here/Scripts/md2leaf.py" "$slug" "$tmp/$slug.html" "$views" "$shown" "$src"
}
echo "rendering doc pages:"
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Verify every generated doc page was built from the markdown as it stands now.
Usage: check-doc-pages.py [--views DIR] [--engine DIR]
🔴 **This catches the failure the banner cannot.** Each generated page warns that
edits made *there* are deleted on the next run. That failure is loud: your words
disappear and you notice. The opposite one is silent — a source edited and the
page never regenerated — and it stays served. `/self-hosting` was live for a day
telling self-hosters to run `docker compose up -d` without the
`docker network create web` that the fix a day earlier had added, because the
markdown was corrected and nobody re-ran the generator.
⚠️ **It compares hashes rather than re-rendering, deliberately.** Running pandoc
here and diffing the output would make the check hostage to the pandoc version:
a different release emits different HTML, so the job would fail on machines
where nothing is wrong, and the obvious "fix" would be to commit whatever the CI
image happened to produce. The hash answers the question actually being asked —
was this page built from the current source? — and needs no pandoc at all.
"""
import argparse
import hashlib
import pathlib
import re
import sys
BANNER = re.compile(
r"GENERATED by Scripts/build-doc-pages\.sh from (?P<source>[^\n]+?)\.\s*\n"
r".*?source-sha256:\s*(?P<digest>[0-9a-f]+|unknown)",
re.S,
)
def main() -> int:
here = pathlib.Path(__file__).resolve().parent.parent
ap = argparse.ArgumentParser()
ap.add_argument("--views", default=here / "Resources/Views")
# Where the engine repository is checked out. Same contract as
# build-doc-pages.sh, so CI can set one variable for both.
ap.add_argument("--engine", default=here.parent / "flamenet-e2e")
args = ap.parse_args()
views, engine = pathlib.Path(args.views), pathlib.Path(args.engine)
stale, unresolved, checked = [], [], 0
for page in sorted(views.glob("*.leaf")):
head = page.read_text(encoding="utf-8")[:2000]
m = BANNER.search(head)
if not m:
continue # hand-written page, not generated — not ours to police
source, recorded = m.group("source"), m.group("digest")
if source.startswith("flamenet-e2e/"):
path = engine / source[len("flamenet-e2e/"):]
else:
path = here / source
if not path.is_file():
unresolved.append((page.name, source))
continue
if recorded == "unknown":
# Generated before the hash existed. Treat as stale rather than
# passing it: "we cannot tell" must not read as "it is fine".
stale.append((page.name, source, "no hash recorded"))
continue
actual = hashlib.sha256(path.read_bytes()).hexdigest()[:16]
checked += 1
if actual != recorded:
stale.append((page.name, source, f"{recorded} != {actual}"))
for name, source in unresolved:
print(f"cannot find the source for {name}: {source}", file=sys.stderr)
if unresolved:
print("\nis the flamenet-e2e checkout present? "
"set --engine or FLAMENET_E2E_DIR", file=sys.stderr)
return 2
if stale:
print("These pages were not regenerated after their source changed:\n",
file=sys.stderr)
for name, source, why in stale:
print(f" {name:<22} from {source} ({why})", file=sys.stderr)
print("\nRun Scripts/build-doc-pages.sh and commit the result.",
file=sys.stderr)
print("The page is what people read; the markdown is not served.",
file=sys.stderr)
return 1
print(f"{checked} generated pages match their sources")
return 0
if __name__ == "__main__":
sys.exit(main())
+20 -3
View File
@@ -1,12 +1,21 @@
#!/usr/bin/env python3
"""Wrap a pandoc HTML fragment as a Leaf page that extends the site layout.
Usage: md2leaf.py <slug> <html-fragment-file> <views-dir> [source-path]
Usage: md2leaf.py <slug> <html-fragment-file> <views-dir> [shown-path] [real-source]
"""
import hashlib
import sys
slug, src, views = sys.argv[1], sys.argv[2], sys.argv[3]
source = sys.argv[4] if len(sys.argv) > 4 else "a markdown source"
# The markdown this page was built from, hashed into the banner so
# `check-doc-pages.py` can tell whether the page is still current WITHOUT
# running pandoc. Checking by re-rendering would make CI hostage to the pandoc
# version: a different release emits different HTML, and the job would fail on
# machines where nothing is actually wrong.
real = sys.argv[5] if len(sys.argv) > 5 else None
digest = (hashlib.sha256(open(real, "rb").read()).hexdigest()[:16]
if real else "unknown")
body = open(src, encoding="utf-8").read()
# The documents refer to the deployed site absolutely. On the site itself that
@@ -36,16 +45,24 @@ body = body.replace("#", '#("#")')
# what the page looks like. Inside the content rather than above `#extend` so it
# lands in the body, well after the doctype, where no parser has an opinion
# about it. It costs three lines in the served source.
#
# ⚠️ The banner also carries a hash of the source, and that is not decoration:
# it is the only thing that catches the OPPOSITE failure. The warning above
# guards edits made here, which fail loudly because they vanish. A source
# edited and the page never regenerated fails silently and stays served —
# which is what happened to /self-hosting, live for a day with instructions
# that did not work.
BANNER = (
"<!-- GENERATED by Scripts/build-doc-pages.sh from {source}.\n"
" Edits made HERE are deleted the next time that script runs.\n"
" Change the markdown source and re-run it. -->\n"
" Change the markdown source and re-run it.\n"
" source-sha256: {digest} -->\n"
)
out = (
'#extend("layout"):\n'
'#export("content"):\n' +
BANNER.format(source=source) +
BANNER.format(source=source, digest=digest) +
'<article class="band doc doc-generated">\n'
' <div class="wrap wrap-narrow">\n'
f"{body}\n"