fix(docs): preserve theme assets in deny-by-default publication
Publish documentation / publish (push) Successful in 36s

This commit is contained in:
Codex
2026-09-16 09:36:30 +02:00
parent 5f3680a0fb
commit b1c510a097
4 changed files with 169 additions and 3 deletions
+9 -1
View File
@@ -32,7 +32,11 @@ bash scripts/test-verify-dwh-auth-docs.sh
The strict build also verifies the public boundary: exactly 20 navigation pages, The strict build also verifies the public boundary: exactly 20 navigation pages,
five approved source assets, no internal pages/assets, and matching search entries. five approved source assets, no internal pages/assets, and matching search entries.
Theme-generated assets are separate from those five source assets. The locked Windmill theme requires a separate exact allowlist of 25 static assets;
MkDocs applies `exclude_docs` to those files too. Never replace the list with broad
directory exceptions. Sources under `docs/` may not shadow theme asset paths.
The verifier follows stylesheet/script/image references and CSS font references
from generated pages and fails when a local dependency is absent.
Choose a unique release identifier consisting of UTC timestamp and source SHA. Choose a unique release identifier consisting of UTC timestamp and source SHA.
Record the current symlink and container identity before proceeding: Record the current symlink and container identity before proceeding:
@@ -78,6 +82,10 @@ briefly interrupt this manual only.
`install/standalone-manual-it/` and `install/standalone-manual-en/`. `install/standalone-manual-it/` and `install/standalone-manual-en/`.
- Compare live home and `search/search_index.json` checksums to the build. Search - Compare live home and `search/search_index.json` checksums to the build. Search
must contain only the 20 approved pages, never plans, reports or architecture. must contain only the 20 approved pages, never plans, reports or architecture.
- Check the actual stylesheet and JavaScript URLs in both installation pages:
HTTP 200, CSS served as `text/css`, JavaScript with a valid script content type,
and fonts available. In a browser confirm the stylesheets load and the layout is
styled. HTML 200 alone is not enough to accept a publication.
- Require HTTP 404 for retired/internal paths, including `architecture/overview/`, - Require HTTP 404 for retired/internal paths, including `architecture/overview/`,
`plans/2026-09-08-memory-management/`, and `operations/compose-reference/`. `plans/2026-09-08-memory-management/`, and `operations/compose-reference/`.
- Record source SHA, release ID, previous release and checks in the cleanup/release - Record source SHA, release ID, previous release and checks in the cleanup/release
+27
View File
@@ -45,6 +45,8 @@ markdown_extensions:
# Public manual: deny by default, including search and copied assets. # Public manual: deny by default, including search and copied assets.
# Internal developer documents remain in Git, not in the generated site. # Internal developer documents remain in Git, not in the generated site.
# Keep these exceptions aligned with nav; scripts/verify-public-docs.py checks it. # Keep these exceptions aligned with nav; scripts/verify-public-docs.py checks it.
# MkDocs applies these rules to bundled theme assets too. Keep exact exceptions
# below in sync with THEME_ASSETS; never allow whole directories or docs overrides.
exclude_docs: | exclude_docs: |
* *
!/index.md !/index.md
@@ -72,6 +74,31 @@ exclude_docs: |
!/install/examples/thothii-installation.local.yaml !/install/examples/thothii-installation.local.yaml
!/install/examples/thothii-installation.server.yaml !/install/examples/thothii-installation.server.yaml
!/install/examples/workspace-bindings.env.example !/install/examples/workspace-bindings.env.example
!/css/base.css
!/css/bootstrap-3.3.7.css
!/css/bootstrap-3.3.7.min.css
!/css/font-awesome-4.7.0.css
!/css/font-awesome-4.7.0.min.css
!/css/highlight.css
!/fonts/fontawesome-webfont.eot
!/fonts/fontawesome-webfont.svg
!/fonts/fontawesome-webfont.ttf
!/fonts/fontawesome-webfont.woff
!/fonts/fontawesome-webfont.woff2
!/fonts/glyphicons-halflings-regular.eot
!/fonts/glyphicons-halflings-regular.svg
!/fonts/glyphicons-halflings-regular.ttf
!/fonts/glyphicons-halflings-regular.woff
!/fonts/glyphicons-halflings-regular.woff2
!/img/favicon.ico
!/js/base.js
!/js/bootstrap-3.3.7.js
!/js/bootstrap-3.3.7.min.js
!/js/elasticlunr.js
!/js/elasticlunr.min.js
!/js/highlight.pack.js
!/js/jquery-3.2.1.js
!/js/jquery-3.2.1.min.js
nav: nav:
- Home: index.md - Home: index.md
- Understand ThothII: - Understand ThothII:
+41
View File
@@ -27,10 +27,18 @@ class PublicationBoundaryTest(unittest.TestCase):
self.write(self.root / "docs" / source, "# Public") self.write(self.root / "docs" / source, "# Public")
self.write(self.site / verifier.output_path(source), "public") self.write(self.site / verifier.output_path(source), "public")
self.write(self.root / "mkdocs.yml", self.config) self.write(self.root / "mkdocs.yml", self.config)
for asset in sorted(verifier.THEME_ASSETS):
self.config += f" !/{asset}\n"
self.write(self.site / asset, "theme fixture")
self.write(self.root / "mkdocs.yml", self.config)
self.search = {"docs": [{"location": "", "text": "public"}]} self.search = {"docs": [{"location": "", "text": "public"}]}
self.write_search() self.write_search()
self.write(self.root / "docs/plans/private.md", "# Internal") self.write(self.root / "docs/plans/private.md", "# Internal")
self.write(self.root / "docs/reports/screenshot.png", "internal asset") self.write(self.root / "docs/reports/screenshot.png", "internal asset")
self.write(self.site / "index.html", '<link rel="stylesheet" href="css/base.css"><script src="js/base.js"></script>')
self.write(self.site / "css/base.css", '@font-face { src: url("../fonts/demo.woff2?v=1"); }')
self.write(self.site / "fonts/demo.woff2", "font fixture")
self.write(self.site / "js/base.js", "// script fixture")
@staticmethod @staticmethod
def write(path, content): def write(path, content):
@@ -81,6 +89,39 @@ class PublicationBoundaryTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "missing generated public"): with self.assertRaisesRegex(ValueError, "missing generated public"):
verifier.check(self.root, self.site) verifier.check(self.root, self.site)
def test_missing_theme_stylesheet(self):
(self.site / "css/base.css").unlink()
with self.assertRaisesRegex(ValueError, "missing generated page asset: css/base.css"):
verifier.check(self.root, self.site)
def test_docs_cannot_shadow_theme_assets(self):
self.write(self.root / "docs/css/base.css", "internal content")
with self.assertRaisesRegex(ValueError, "docs source shadows theme asset"):
verifier.check(self.root, self.site)
def test_missing_theme_script(self):
(self.site / "js/base.js").unlink()
with self.assertRaisesRegex(ValueError, "missing generated page asset: js/base.js"):
verifier.check(self.root, self.site)
def test_missing_css_font(self):
(self.site / "fonts/demo.woff2").unlink()
with self.assertRaisesRegex(ValueError, "missing generated page asset: fonts/demo.woff2"):
verifier.check(self.root, self.site)
def test_asset_outside_site_prefix(self):
self.write(self.root / "mkdocs.yml", self.config + "site_url: https://docs.example.invalid/manual/\n")
self.write(self.site / "index.html", '<link rel="stylesheet" href="/css/base.css">')
with self.assertRaisesRegex(ValueError, "asset escapes public site prefix"):
verifier.check(self.root, self.site)
def test_nested_installation_asset_resolution(self):
self.write(self.site / "install/manual/index.html", '<link rel="stylesheet" href="../../css/base.css">')
verifier.check_assets(self.site, ["install/manual.md"], "https://docs.example.invalid/manual/")
(self.site / "css/base.css").unlink()
with self.assertRaisesRegex(ValueError, "missing generated page asset: css/base.css"):
verifier.check_assets(self.site, ["install/manual.md"], "https://docs.example.invalid/manual/")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+92 -2
View File
@@ -3,8 +3,10 @@
import argparse import argparse
import json import json
import re
from html.parser import HTMLParser
from pathlib import Path from pathlib import Path
from urllib.parse import unquote, urlsplit from urllib.parse import unquote, urljoin, urlsplit
import yaml import yaml
@@ -16,6 +18,35 @@ PUBLIC_ASSETS = {
"install/examples/thothii-installation.server.yaml", "install/examples/thothii-installation.server.yaml",
"install/examples/workspace-bindings.env.example", "install/examples/workspace-bindings.env.example",
} }
# Exact static assets shipped by the locked Windmill theme. Sources in docs/ must
# not shadow these paths, or an internal file could bypass the public allowlist.
THEME_ASSETS = {
"css/base.css",
"css/bootstrap-3.3.7.css",
"css/bootstrap-3.3.7.min.css",
"css/font-awesome-4.7.0.css",
"css/font-awesome-4.7.0.min.css",
"css/highlight.css",
"fonts/fontawesome-webfont.eot",
"fonts/fontawesome-webfont.svg",
"fonts/fontawesome-webfont.ttf",
"fonts/fontawesome-webfont.woff",
"fonts/fontawesome-webfont.woff2",
"fonts/glyphicons-halflings-regular.eot",
"fonts/glyphicons-halflings-regular.svg",
"fonts/glyphicons-halflings-regular.ttf",
"fonts/glyphicons-halflings-regular.woff",
"fonts/glyphicons-halflings-regular.woff2",
"img/favicon.ico",
"js/base.js",
"js/bootstrap-3.3.7.js",
"js/bootstrap-3.3.7.min.js",
"js/elasticlunr.js",
"js/elasticlunr.min.js",
"js/highlight.pack.js",
"js/jquery-3.2.1.js",
"js/jquery-3.2.1.min.js",
}
INTERNAL_DIRS = { INTERNAL_DIRS = {
"adr", "agents", "architecture", "contracts", "maintenance", "plans", "adr", "agents", "architecture", "contracts", "maintenance", "plans",
"reports", "research", "testing", "reports", "research", "testing",
@@ -46,6 +77,57 @@ def output_path(source):
return path.with_suffix("") / "index.html" if path.name != "index.md" else path.with_suffix(".html") return path.with_suffix("") / "index.html" if path.name != "index.md" else path.with_suffix(".html")
class AssetLinks(HTMLParser):
def __init__(self):
super().__init__()
self.urls = []
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == "link" and "stylesheet" in attrs.get("rel", "").split():
self.urls.append(attrs.get("href", ""))
elif tag in ("script", "img") and attrs.get("src"):
self.urls.append(attrs["src"])
def check_assets(site, pages, site_url):
"""Follow page assets and CSS font/image references using browser URL rules."""
base = site_url.rstrip("/") + "/"
origin = urlsplit(base)
visited = set()
def visit(reference, parent):
if not reference or reference.startswith("#"):
return
url = urlsplit(urljoin(parent, reference))
if url.scheme not in ("http", "https") or url.netloc != origin.netloc:
return
path = unquote(url.path)
if not path.startswith(origin.path):
raise ValueError(f"asset escapes public site prefix: {reference}")
relative = path[len(origin.path):]
asset = (site / relative).resolve()
if site.resolve() not in asset.parents or not asset.is_file():
raise ValueError(f"missing generated page asset: {relative}")
if relative in visited:
return
visited.add(relative)
if asset.suffix == ".css":
css = asset.read_text()
refs = re.findall(r"url\(\s*['\"]?([^)'\"\s]+)", css)
refs += re.findall(r"@import\s+['\"]([^'\"]+)", css)
for child in refs:
visit(child, url.geturl())
for page in pages:
output = output_path(page)
parser = AssetLinks()
parser.feed((site / output).read_text())
page_url = urljoin(base, output.as_posix())
for reference in parser.urls:
visit(reference, page_url)
def check(root, site): def check(root, site):
# BaseLoader reads configuration without executing custom Python YAML tags. # BaseLoader reads configuration without executing custom Python YAML tags.
config = yaml.load((root / "mkdocs.yml").read_text(), Loader=yaml.BaseLoader) config = yaml.load((root / "mkdocs.yml").read_text(), Loader=yaml.BaseLoader)
@@ -66,10 +148,14 @@ def check(root, site):
if not rules or rules[0] != "*" or any(not r.startswith("!/") for r in rules[1:]): if not rules or rules[0] != "*" or any(not r.startswith("!/") for r in rules[1:]):
raise ValueError("public docs must use deny-by-default exclusions and explicit exceptions") raise ValueError("public docs must use deny-by-default exclusions and explicit exceptions")
published = [r[2:] for r in rules[1:]] published = [r[2:] for r in rules[1:]]
if len(published) != len(set(published)) or set(published) != set(pages) | PUBLIC_ASSETS: if len(published) != len(set(published)) or set(published) != set(pages) | PUBLIC_ASSETS | THEME_ASSETS:
raise ValueError("publication exceptions must match nav pages and approved assets exactly") raise ValueError("publication exceptions must match nav pages and approved assets exactly")
for source in published: for source in published:
if source in THEME_ASSETS:
if (root / "docs" / source).exists():
raise ValueError(f"docs source shadows theme asset: {source}")
continue
if not (root / "docs" / source).is_file(): if not (root / "docs" / source).is_file():
raise ValueError(f"missing public source: {source}") raise ValueError(f"missing public source: {source}")
if not (site / output_path(source)).is_file(): if not (site / output_path(source)).is_file():
@@ -94,6 +180,10 @@ def check(root, site):
indexed.add(location) indexed.add(location)
if indexed != expected_urls: if indexed != expected_urls:
raise ValueError("search index does not cover exactly the public pages") raise ValueError("search index does not cover exactly the public pages")
check_assets(site, pages, config.get("site_url", "https://docs.example.invalid/"))
for asset in sorted(THEME_ASSETS):
if not (site / asset).is_file():
raise ValueError(f"missing generated theme asset: {asset}")
return len(pages) return len(pages)