Skip to content

fix: move the build banner to the footer, link the canonical site, declare the sitemap (#691, #692) - #693

Merged
raeq merged 4 commits into
mainfrom
docs/footer-provenance-and-canonical-links
Aug 29, 2026
Merged

fix: move the build banner to the footer, link the canonical site, declare the sitemap (#691, #692)#693
raeq merged 4 commits into
mainfrom
docs/footer-provenance-and-canonical-links

Conversation

@raeq

@raeq raeq commented Aug 29, 2026

Copy link
Copy Markdown
Owner

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 !!! info admonition under each page's first H1, so a five-line box
came 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_markdown is gone. scripts/mkdocs_build_banner.py 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 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 is 0.14.1.

docs.yml is path-filtered. Its last deploy ran at 4c54aa1, the release-PR merge, 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].
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 production
deploy.

Nothing linked disarm.dev, and canonical was the wrong tool for it

$ grep -rn "https://disarm.dev" docs/ mkdocs.yml README.md
(no matches)

Both sites are live and distinct, and nothing connected them in either direction. The
footer links it now, with og:site_name and a schema.org isPartOf naming it the parent.

Not done by repointing rel=canonical, which 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 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, no Allow, no Sitemap, and no
actual signals. The 75-page sitemap MkDocs writes on every build 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 change to mkdocs.yml or 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_config publishing both facts, absent facts staying None so the template renders
nothing rather than a sentence of blanks, and extra surviving another owner.

Two are 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:

perturbation test that fired
drop custom_dir from mkdocs.yml test_the_override_exists_and_is_wired_in
rename config.extra.build_commit in the template test_the_template_reads_what_the_hook_writes[build_commit]
add a rel="canonical" to the override test_it_does_not_rewrite_the_canonical_url

Verification

gate result
pytest 4500 passed, 29 skipped
ruff check / format --check (CI's pinned 0.15.17) clean
mypy python/disarm clean
scripts/generate_docs_index.sh --check up to date
mkdocs build --strict built
scripts/run_doc_tests.py all 39 doc pages passed

Built-output checks:

$ cat site/robots.txt | grep -i sitemap
Sitemap: https://docs.disarm.dev/sitemap.xml

$ grep -c '<loc>' site/sitemap.xml
75

$ grep -o '<link rel="canonical"[^>]*>' site/user-guide/slugification.html
<link rel="canonical" href="https://docs.disarm.dev/user-guide/slugification.html" />

$ grep -c admonition site/user-guide/slugification.html
0

The footer renders: Built from main at 82dd777. The published release is 0.14.1.
Anything documented here that landed after it is not yet in pip install disarm.
The
JSON-LD parses.

…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
Copilot AI lite review requested due to automatic review settings August 29, 2026 08:29
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown

📄 Docs preview: https://cac08604.disarm-docs.pages.dev

@raeq raeq added this to the 0.15.0 milestone Aug 29, 2026
Comment thread tests/test_docs_release_drift.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_config hook that publishes build provenance into config.extra, rendered once via a ReadTheDocs theme override.
  • Add footer link + OpenGraph/JSON-LD isPartOf metadata connecting docs to https://disarm.dev/, without rewriting per-page canonical URLs.
  • Add docs/robots.txt to declare the MkDocs sitemap, and trigger docs workflow on release: 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.

Comment thread tests/test_docs_release_drift.py
Comment thread tests/test_docs_release_drift.py
Comment thread overrides/main.html Outdated
Comment thread .github/workflows/docs.yml
raeq added 3 commits August 29, 2026 10:34
…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
@raeq
raeq merged commit cf24e3b into main Aug 29, 2026
22 checks passed
@raeq
raeq deleted the docs/footer-provenance-and-canonical-links branch August 29, 2026 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants