Catch a doc page whose source moved on without it #69
@@ -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,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,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,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,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,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,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,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,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,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
|
||||
|
||||
@@ -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:"
|
||||
|
||||
Executable
+94
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user