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"