fix: move the build banner to the footer, link the canonical site, declare the sitemap (#691, #692) - #693
Conversation
…clare the sitemap Three problems reported from the live site, plus the robots.txt gap filed alongside them. The banner was too prominent (#692). #641 injected it as an `!!! info` admonition under each page's first H1, so a five-line box came between the title and the first sentence on all 91 pages. The facts earn a place on every page; they do not earn that one. `on_page_markdown` is gone — the hook now publishes both facts into `config.extra` through `on_config`, and `overrides/main.html` renders them once in the footer. The readthedocs theme ships a `main.html` whose entire content is a comment saying customisations belong there, so this is the sanctioned extension point rather than a copied template that can drift. The version it named was two releases stale, and not because the hook was wrong. `docs.yml` is path-filtered; its last deploy ran at the release-PR merge (4c54aa1), and CI resolves the version by asking PyPI at build time, which still served 0.14.0 because the tag came minutes later. Nothing rebuilt the site afterwards — #686 touched RELEASING.md and #688 touched a workflow, neither matching the filter. So publishing a release never refreshed the page that states which release exists, which is the one moment that statement changes. `docs.yml` now also runs on `release: [published]`; the deploy step gates on credentials rather than on ref, and non-PR events already deploy with `--branch=main`, so a release event produces a production deploy. Nothing linked https://disarm.dev/ from the docs, in either direction, so neither site passed the other any signal. The footer carries the link now, with og:site_name and a schema.org isPartOf naming it the parent site. Deliberately NOT done by repointing rel=canonical, though that is what was asked for. base.html emits a correct self-referential canonical per page; aiming those at the landing site declares all 91 pages duplicates of it, and the usual outcome is the docs dropping out of results for their own content. The two sites are genuinely distinct — different titles, different content — so isPartOf plus a real link is the accurate signal. A test asserts the override emits no canonical of its own. robots.txt (#691): with none of its own, Cloudflare served a managed file that was the Content Signals preamble and nothing else — no User-agent, no Allow, no Sitemap, and no actual signals. The 75-page sitemap was undeclared. docs/ is copied to the site root, as docs/_redirects already relied on, so docs/robots.txt lands at /robots.txt with no workflow change. Tests: the five that pinned the old injection are replaced rather than dropped. The new ones cover on_config publishing both facts, absent facts staying None so the template renders nothing rather than blanks, `extra` not being clobbered, and — coverage the old design could not have — that the template still reads the keys the hook writes and that mkdocs.yml still points at overrides/. A hook publishing a fact nothing reads is an empty footer that looks exactly like a working one. Each guard confirmed to fail when perturbed. Verified: 4500 passed; ruff (CI's pinned 0.15.17), mypy, docs-index gate and mkdocs --strict clean; all 39 doc pages pass; robots.txt lands at the site root with the Sitemap line; canonical stays self-referential; JSON-LD parses. Closes #691 Closes #692 Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude Code:claude-opus-5
|
📄 Docs preview: https://cac08604.disarm-docs.pages.dev |
There was a problem hiding this comment.
🟡 Changes recommended
The new release: published docs workflow trigger can cause production docs to be built from the release tag commit (not main) unless checkout/ref handling is adjusted, which risks deploying stale docs content.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adjusts the docs site’s provenance messaging and cross-site SEO signals by moving the build “banner” into a single footer-rendered element, adding a canonical-site link/metadata to connect docs.disarm.dev to disarm.dev, and ensuring the sitemap is declared via an origin robots.txt. It also updates CI so docs can rebuild when a release is published (so the “latest release” fact stays current).
Changes:
- Replace per-page Markdown injection with an
on_confighook that publishes build provenance intoconfig.extra, rendered once via a ReadTheDocs theme override. - Add footer link + OpenGraph/JSON-LD
isPartOfmetadata connecting docs tohttps://disarm.dev/, without rewriting per-page canonical URLs. - Add
docs/robots.txtto declare the MkDocs sitemap, and trigger docs workflow onrelease: published.
File summaries
| File | Description |
|---|---|
scripts/mkdocs_build_banner.py |
Switch from on_page_markdown injection to on_config publishing provenance into config.extra. |
overrides/main.html |
Add theme override for footer provenance rendering and cross-site OG/JSON-LD metadata. |
mkdocs.yml |
Wire in custom_dir: overrides/, update footer copyright to link disarm.dev. |
docs/robots.txt |
Provide origin robots.txt that declares the sitemap. |
.github/workflows/docs.yml |
Add release: published trigger so docs rebuild when a release is published. |
tests/test_docs_release_drift.py |
Replace placement tests with guards for hook/template wiring and canonical preservation. |
CHANGELOG.md |
Document the banner/footer/robots changes under Unreleased. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…sertion
`py/incomplete-url-substring-sanitization`, high severity, on
assert "https://disarm.dev/" in mkdocs
Substring checks against a URL are bypassable, which is what the rule is about.
Here the string is a config file rather than an untrusted URL, so the security
reading does not apply — but following it up found the assertion was weak for a
different reason, and that one is real.
mkdocs.yml names the URL three times: `site_url`, the comment above the
`copyright:` setting, and the setting itself. Searching the whole file therefore
passes on the comment. Deleting the actual footer link — the entire thing the
test exists to protect — left it green. I wrote the failure mode I have been
guarding against elsewhere all week into my own test.
It now reads the `href` values out of the `copyright:` line and compares by
equality, so it tests the rendered footer instead of the file's prose. That
removes the substring-on-URL pattern as a side effect rather than as the point.
Confirmed by perturbation: removing the link while leaving the comment in place
now fails the test, where before it passed.
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Assisted-by: Claude Code:claude-opus-5
Review found a real bug in the trigger this PR added, plus two assertions with
the same weakness as the one CodeQL flagged.
The bug. `actions/checkout` with no `ref:` resolves a `release` event to the
*tag's* commit, not to `main`. So the trigger added here would have deployed the
tag's docs to production — refreshing the version in the footer by making every
other page older, which is a worse outcome than the stale banner it set out to
fix. The site tracks `main`; only the banner's *version* comes from the release.
`ref:` is now forced to `main` for release events and left at checkout's own
default ('') everywhere else, so push and pull_request behave exactly as before.
Following from that, DISARM_DOCS_COMMIT came from GITHUB_SHA, which on a release
event is the tag's SHA while the tree built is `main`. It now comes from
`git rev-parse HEAD` after checkout, so the banner names the commit that was
actually built rather than one that was not.
Two test assertions searched a whole file for a literal, which passes when the
literal survives only in a comment:
- `"custom_dir: overrides/" in mkdocs` also matches the setting commented out,
and that is precisely the state it guards — the theme's own empty main.html
wins and the footer silently loses its provenance line. It now requires an
active YAML key, ignoring comment lines.
- the footer-link assertion had the same shape and was fixed in af17a02, which
is the commit CodeQL's alert prompted.
The JSON-LD duplicated `site_url`, which mkdocs.yml already declares; the URL now
comes from `config.site_url`. The *name* stays literal: `config.site_name` is
"disarm" for both nodes, and two WebSite entries sharing one name is ambiguous
structured data. Only the URL was duplicated config, so only the URL moved.
A new assertion ties the isPartOf URL to the same constant the footer-link test
uses, so the structured-data signal and the visible link cannot drift apart.
Each guard confirmed to fail when perturbed: commenting out `custom_dir` fails
the wiring test, and changing the isPartOf URL fails the canonical test.
Verified: 4500 passed; ruff (CI's 0.15.17), mypy, docs-index and mkdocs --strict
clean; all 39 doc pages pass; JSON-LD parses; canonical still self-referential.
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Assisted-by: Claude Code:claude-opus-5
… reads
Green locally, red on CI's Python 3.12:
assert config["extra"]["build_commit"] is None
E AssertionError: assert 'eb02996' is None
`_build_commit` consults DISARM_DOCS_COMMIT and then GITHUB_SHA. The test
cleared the first and not the second, so it only isolated the function on a
machine where GITHUB_SHA happens to be unset. Actions always sets it, which is
the one environment where the test runs unattended — so it could only ever fail
there, and passing locally proved nothing.
Fixed by naming the set the function reads (`_COMMIT_ENV`) and having the test
clear all of it. Inlining the tuple is what let the two drift: a third variable
added later would silently narrow the test's isolation again, with the same
local-green CI-red signature.
Reproduced the failure locally before and after:
GITHUB_SHA=eb0299600000000000000000000000000000000 pytest -q
4500 passed
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Assisted-by: Claude Code:claude-opus-5
Closes #691. Closes #692.
Three problems reported from the live site, plus the robots.txt gap filed alongside them.
The banner was too prominent, on all 91 pages
#641 injected it as an
!!! infoadmonition under each page's first H1, so a five-line boxcame between the title and the first sentence of every page. The facts earn a place on
every page. They do not earn that one.
on_page_markdownis gone.scripts/mkdocs_build_banner.pypublishes both facts intoconfig.extrathroughon_config, andoverrides/main.htmlrenders them once in thefooter. The
readthedocstheme ships amain.htmlwhose entire content is a commentsaying customisations belong there, so this is the sanctioned extension point rather than a
copied template that drifts when mkdocs updates.
It named a version two releases stale, and the hook was not at fault
The site said
0.14.0; the release is0.14.1.docs.ymlis path-filtered. Its last deploy ran at4c54aa1, the release-PR merge, and CIresolves the version by asking PyPI at build time — which still served
0.14.0, becausethe tag came minutes later. Nothing rebuilt the site afterwards: #686 touched
RELEASING.mdand #688 touched a workflow, neither matching the filter.So publishing a release never refreshed the page that states which release exists, which is
the one moment that statement changes.
docs.ymlnow also runs onrelease: [published].Checked rather than assumed: the deploy step gates on credentials, not on ref, and non-PR
events already deploy with
--branch=main, so a release event produces a productiondeploy.
Nothing linked disarm.dev, and canonical was the wrong tool for it
Both sites are live and distinct, and nothing connected them in either direction. The
footer links it now, with
og:site_nameand a schema.orgisPartOfnaming it the parent.Not done by repointing
rel=canonical, which is what was asked for.base.htmlemitsa correct self-referential canonical per page. Aiming those at the landing site declares
all 91 pages duplicates of it, and the usual outcome is the docs dropping out of results
for their own content — the opposite of the goal. A test asserts the override emits no
canonical of its own.
robots.txt
With none of its own, Cloudflare served a managed file that was the Content Signals
explanatory preamble and nothing else: no
User-agent, noAllow, noSitemap, and noactual signals. The 75-page sitemap MkDocs writes on every build was undeclared.
docs/is copied to the site root, asdocs/_redirectsalready relied on, sodocs/robots.txtlands at/robots.txtwith no change tomkdocs.ymlor the workflow.The managed preamble goes with it, which loses nothing operative. Content Signals, if
wanted, belong there as explicit
Content-Signal:lines rather than inherited by default.Tests
The five that pinned the old injection are replaced, not dropped. The new ones cover
on_configpublishing both facts, absent facts stayingNoneso the template rendersnothing rather than a sentence of blanks, and
extrasurviving another owner.Two are coverage the old design could not have: that the template still reads the keys the
hook writes, and that
mkdocs.ymlstill points atoverrides/. A hook publishing a factnothing reads is an empty footer that looks exactly like a working one.
Each guard confirmed to fail when perturbed:
custom_dirfrommkdocs.ymltest_the_override_exists_and_is_wired_inconfig.extra.build_commitin the templatetest_the_template_reads_what_the_hook_writes[build_commit]rel="canonical"to the overridetest_it_does_not_rewrite_the_canonical_urlVerification
pytestruff check/format --check(CI's pinned 0.15.17)mypy python/disarmscripts/generate_docs_index.sh --checkmkdocs build --strictscripts/run_doc_tests.pyBuilt-output checks:
The footer renders: Built from
mainat 82dd777. The published release is0.14.1.Anything documented here that landed after it is not yet in
pip install disarm. TheJSON-LD parses.