From 5a5ef65ef40de9160c8381b6b54d1eefaf90a2c3 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 31 Aug 2026 12:26:54 -0400 Subject: [PATCH] Catch a doc page whose source moved on without it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generated page warns that edits made THERE are deleted on the next run. Nothing guarded the other direction, and that is the one that hurts: a source fixed and the page never regenerated fails silently and stays served. /self-hosting was live for a day telling self-hosters to run `docker compose up -d` without the `docker network create web` the fix a day earlier had added — so the published instructions still produced the error that fix removed. The banner now records a hash of the source it was built from, and `check-doc-pages.py` compares it against the source as it stands. 🔴 Hashes rather than re-rendering, and that is the whole design decision. The obvious check is to run pandoc in CI and diff the output. That makes the job hostage to the pandoc version: a different release emits different HTML, so it fails on machines where nothing is wrong, and the tempting fix is to commit whatever the CI image happened to produce. A hash answers the question actually being asked — was this page built from the current source? — and needs no pandoc, so the job is alpine plus python3 plus a shallow clone. A page generated before the hash existed is reported stale rather than skipped. "We cannot tell" must not read as "it is fine", which is the failure being fixed. Verified by breaking it on purpose: editing the engine's SPEC.md flags spec.leaf, editing the relay's own API.md flags api.leaf, both name the pair of hashes, and a missing engine checkout exits 2 with what to set rather than passing. The job body was run in alpine:3.20 exactly as CI will. The nine page diffs are the banner line only — no content changed. Closes #7 Co-Authored-By: Claude Opus 4.8 --- .gitlab-ci.yml | 20 +++++++ Resources/Views/api.leaf | 3 +- Resources/Views/audit.leaf | 3 +- Resources/Views/configuration.leaf | 3 +- Resources/Views/engine.leaf | 3 +- Resources/Views/hosting.leaf | 3 +- Resources/Views/relay.leaf | 3 +- Resources/Views/security.leaf | 3 +- Resources/Views/self-hosting.leaf | 3 +- Resources/Views/spec.leaf | 3 +- Scripts/build-doc-pages.sh | 4 +- Scripts/check-doc-pages.py | 94 ++++++++++++++++++++++++++++++ Scripts/md2leaf.py | 23 +++++++- 13 files changed, 155 insertions(+), 13 deletions(-) create mode 100755 Scripts/check-doc-pages.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1ef5dd1..d6d334e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/Resources/Views/api.leaf b/Resources/Views/api.leaf index a608bf7..e66d032 100644 --- a/Resources/Views/api.leaf +++ b/Resources/Views/api.leaf @@ -2,7 +2,8 @@ #export("content"): + Change the markdown source and re-run it. + source-sha256: 81839b97940fe782 -->

Relay API

diff --git a/Resources/Views/audit.leaf b/Resources/Views/audit.leaf index 7f2e486..1851b3c 100644 --- a/Resources/Views/audit.leaf +++ b/Resources/Views/audit.leaf @@ -2,7 +2,8 @@ #export("content"): + Change the markdown source and re-run it. + source-sha256: 3e8629521db871e2 -->

Audit scope

diff --git a/Resources/Views/configuration.leaf b/Resources/Views/configuration.leaf index ac419c0..06d1c1a 100644 --- a/Resources/Views/configuration.leaf +++ b/Resources/Views/configuration.leaf @@ -2,7 +2,8 @@ #export("content"): + Change the markdown source and re-run it. + source-sha256: ab6906714a4d6d47 -->

Configuring a relay

diff --git a/Resources/Views/engine.leaf b/Resources/Views/engine.leaf index 31f89fc..a100a3b 100644 --- a/Resources/Views/engine.leaf +++ b/Resources/Views/engine.leaf @@ -2,7 +2,8 @@ #export("content"): + Change the markdown source and re-run it. + source-sha256: a8943191a78ff640 -->

flamenet-e2e

diff --git a/Resources/Views/hosting.leaf b/Resources/Views/hosting.leaf index cdbc8be..f59c222 100644 --- a/Resources/Views/hosting.leaf +++ b/Resources/Views/hosting.leaf @@ -2,7 +2,8 @@ #export("content"): + Change the markdown source and re-run it. + source-sha256: cddcb5a3308c96cc -->

Running your own relay

diff --git a/Resources/Views/relay.leaf b/Resources/Views/relay.leaf index d089594..fb3c90c 100644 --- a/Resources/Views/relay.leaf +++ b/Resources/Views/relay.leaf @@ -2,7 +2,8 @@ #export("content"): + Change the markdown source and re-run it. + source-sha256: dcca28c62e60afd4 -->

flamenet-relay

diff --git a/Resources/Views/security.leaf b/Resources/Views/security.leaf index fda9647..5ae3c5d 100644 --- a/Resources/Views/security.leaf +++ b/Resources/Views/security.leaf @@ -2,7 +2,8 @@ #export("content"): + Change the markdown source and re-run it. + source-sha256: ea3544ef2e092cf9 -->

Security

diff --git a/Resources/Views/self-hosting.leaf b/Resources/Views/self-hosting.leaf index c3188f1..8e52112 100644 --- a/Resources/Views/self-hosting.leaf +++ b/Resources/Views/self-hosting.leaf @@ -2,7 +2,8 @@ #export("content"): + Change the markdown source and re-run it. + source-sha256: a4ed8fd6048d30cd -->

Self-hosting

diff --git a/Resources/Views/spec.leaf b/Resources/Views/spec.leaf index 613247e..5f62830 100644 --- a/Resources/Views/spec.leaf +++ b/Resources/Views/spec.leaf @@ -2,7 +2,8 @@ #export("content"): + Change the markdown source and re-run it. + source-sha256: 41002a1ef1cc4ff5 -->

Flamenet diff --git a/Scripts/build-doc-pages.sh b/Scripts/build-doc-pages.sh index 9d19ebc..2131a74 100755 --- a/Scripts/build-doc-pages.sh +++ b/Scripts/build-doc-pages.sh @@ -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:" diff --git a/Scripts/check-doc-pages.py b/Scripts/check-doc-pages.py new file mode 100755 index 0000000..927956f --- /dev/null +++ b/Scripts/check-doc-pages.py @@ -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[^\n]+?)\.\s*\n" + r".*?source-sha256:\s*(?P[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()) diff --git a/Scripts/md2leaf.py b/Scripts/md2leaf.py index f68e07c..358626d 100755 --- a/Scripts/md2leaf.py +++ b/Scripts/md2leaf.py @@ -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 [source-path] +Usage: md2leaf.py [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 = ( "\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) + '
\n' '
\n' f"{body}\n" -- 2.54.0