Skip to content

feat(docs): add fork-preview banner, dual Atom feeds, gh-pages reset - #451

Draft
netravnen wants to merge 8 commits into
peeringdb:masterfrom
netravnen:admincom/docs-infra
Draft

feat(docs): add fork-preview banner, dual Atom feeds, gh-pages reset#451
netravnen wants to merge 8 commits into
peeringdb:masterfrom
netravnen:admincom/docs-infra

Conversation

@netravnen

@netravnen netravnen commented Aug 24, 2026

Copy link
Copy Markdown

Split out of #449 to make review easier. The content fixes moved to a
separate PR; #448 is the tracking issue for the whole line of work.

This branch started as infra for running a dev/preview fork of this
site, and grew to fix a few problems that showed up while doing that:
preview deploys were indistinguishable from production in a browser,
the two content streams (blog posts and release notes) only had one
combined feed to subscribe to, and gh-pages accumulated deploy
history that made no promise about matching the branch that built it.

Opening as draft because part of this (the fork-preview banner) is
fork-specific scaffolding that may not be wanted upstream as-is;
flagging for discussion rather than asking for a merge.

Changes

  • Fork-preview banner: shows fork repo URL, current serving URL,
    and where production lives, so a preview deploy is never mistaken
    for the real site. Suppressed via PROD=TRUE.
  • Two independent Atom feeds: docs/blog/atom.xml and
    docs/release_notes/atom.xml, so blog and release-notes readers can
    subscribe separately. Share a common scripts/atom_common.py.
    Release-notes entries are parsed from release_notes/index.md's
    existing ## Release X.Y.Z sections.
  • Site-URL resolution policy (scripts/site_url.py): one shared
    precedence (PROD=TRUE > SITE_URL_OVERRIDE > detected local
    dev-server address > http://localhost:8000/) used by the banner,
    both feeds, and dynamic docs/CNAME generation, so they can't
    disagree about what URL they're building for.
  • gh-pages deploy: added --no-history so every deploy is a
    single fresh commit built from the current branch, instead of
    accumulating commits on top of whatever was already there.

Testing

  • uv run mkdocs build --strict -- 0 warnings.
  • Feed self-links verified against both mkdocs serve and a
    separately-served static build.
  • Banner verified visually, including suppression under PROD=TRUE.

Notes for reviewers

scripts/generate_banner.py and the SITE_URL_OVERRIDE dev-domain
plumbing in docs-deploy.yml are the pieces most likely to need
changes, or removal, before this could land on master as-is. Happy
to split those out of the eventual mergeable set if preferred.

netravnen and others added 8 commits August 24, 2026 14:30
docs.peeringdb.com is served via GitHub Pages today, but entirely
manually -- someone runs `mkdocs gh-deploy --clean` from their own
machine, which pushes the built site to upstream's `gh-pages`
branch. There is no CI/CD in this repo's history at all, so nothing
catches a broken link or nav omission before it ships, and the fork
has no equivalent preview of its own. This adds two workflows
scoped to this fork only -- upstream's manual production deploy is
untouched.

Changes:
- .github/workflows/docs-build-check.yml: runs `mkdocs build
  --strict` on every pull request and on push to master/
  admincom/docs-improvements, using uv to install the pinned
  dependency set from uv.lock. Fails the check on any warning
  (broken links, pages missing from nav, etc.) -- the automated
  version of the manual --strict checks run throughout this
  session's doc work.
- .github/workflows/docs-deploy.yml: automates the exact
  `mkdocs gh-deploy --clean` command README.md already documents as
  the manual process, targeting the fork's own (currently
  nonexistent) gh-pages branch. Strips docs/CNAME before building
  first, since that file lives inside docs_dir and would otherwise
  get copied into the built site and wrongly claim
  docs.peeringdb.com as the fork's own custom domain. Runs on push
  to master/admincom/docs-improvements plus workflow_dispatch for
  manual redeploys; --force is required since a stateless CI runner
  never has prior gh-pages history to compare against.

Security:
- Deploy workflow requests `contents: write` on GITHUB_TOKEN,
  scoped to this repo only; PR-check workflow stays `contents:
  read` since it never publishes anything. Neither workflow runs on
  pull_request_target or handles third-party PR code with elevated
  permissions, so forked-PR privilege escalation isn't a concern.

Testing:
- Both YAML files parse cleanly with PyYAML; `mkdocs build --strict`
  still passes with 0 warnings locally. End-to-end verification
  (Actions tab going green, gh-pages branch creation, Pages URL
  serving correctly) requires pushing to GitHub and is documented as
  follow-up manual verification, not achievable from a local clone.

Backwards Compatibility:
- N/A -- new files only; no existing workflow, branch, or build
  behavior is changed. Upstream's manual deploy process is
  completely unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The blog page listed posts in docs/blogs.md with no way to subscribe
to new ones. Add a mkdocs hook that regenerates docs/atom.xml from
that list on every build, so a feed reader can follow new posts
without manually checking the page.

Post dates come from git history rather than blogs.md's hand-typed
date text: a bulk git-log pass over docs/blog/ resolves each post's
first-added commit date in one shot, falling back to a slower
per-file `git log --follow` lookup only for the handful of posts that
pass has missed (files introduced via a rename, which git classifies
as an R event rather than A, so a --diff-filter=A bulk pass silently
skips them). This gives real commit timestamps (time of day, correct
timezone) instead of a hand-typed day-only date, and self-corrects if
blogs.md's date text is ever wrong or stale.

The feed's self-link is driven by an ATOM_SITE_URL environment
variable (falling back to mkdocs.yml's site_url) rather than always
hardcoding the production URL, so this fork's own preview deploy can
correctly self-reference wherever it actually serves from instead of
falsely claiming to be docs.peeringdb.com. docs-deploy.yml sets it
from GitHub Actions context; docs-build-check.yml gets fetch-depth: 0
added to its checkout, which the git-log date lookups require --
without full history, every post would appear to have no git history
at all, and mkdocs build --strict promotes that warning to a failure.

Duplicate timestamps (two posts added in the same commit) are nudged
apart by 1-second increments after date resolution, so every entry's
atom:updated value stays unique per the W3C Feed Validator's
interoperability recommendation, without affecting sort order.

Changes
- scripts/generate_atom_feed.py (new): on_pre_build hook. Bulk +
  per-file git-log date lookup, markdown-to-plaintext excerpt
  extraction for each entry's summary, ISO-8601 formatting (normalized
  to UTC for git's real commit timezones), duplicate-timestamp
  dedupe, Atom 1.0 XML construction via stdlib ElementTree/minidom --
  no third-party feed library.
- mkdocs.yml: register the hook under hooks:.
- docs/blogs.md: "Subscribe via Atom feed" line; also the underlying
  source list this hook parses.
- peeringdb_theme/main.html: <link rel="alternate"> feed-discovery tag.
- .gitignore: docs/atom.xml is a generated build artifact, not tracked.
- .github/workflows/docs-build-check.yml,
  .github/workflows/docs-deploy.yml: fetch-depth: 0 (required for the
  git-log date lookups); docs-deploy.yml sets ATOM_SITE_URL from
  GitHub Actions context and strips any production docs/CNAME before
  building, so this fork's preview never falsely claims to be
  docs.peeringdb.com.

Security
- N/A

Testing
- uv run mkdocs build --strict: 0 warnings.
- Spot-checked generated dates against git log for several posts,
  including a renamed file resolved via the --follow fallback.
- Confirmed duplicate-timestamp entries get distinct atom:updated
  values while preserving their original relative order.

Backwards Compatibility
- N/A

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The fork's preview site needs its own domain, distinct from upstream
production's docs.peeringdb.com -- docs-dev.peeringdb.dk. The
straightforward way to do this (edit docs/CNAME's tracked content
directly) has the same problem as removing it outright: docs/CNAME is
the same file both this fork's automation and upstream's manual
`mkdocs gh-deploy --clean` rely on, so whatever value sits in it on
this branch would silently become production's value the moment this
branch merges upstream -- either breaking the custom domain entirely
(if removed) or replacing it with the wrong one (if hardcoded to the
dev domain).

Instead of choosing a value to store, generate the file at build time
from configuration, mirroring the pattern already used for the atom
feed's self-link: an optional SITE_URL_OVERRIDE env var takes
priority when set, falling back to mkdocs.yml's site_url
(https://docs.peeringdb.com/) when unset. This makes the merge-safety
concern moot rather than just documented: upstream's deploy process
sets no override, so it always regenerates the correct production
CNAME automatically, with no manual edit required at merge time.

Changes:
- scripts/generate_cname.py: new mkdocs on_pre_build hook. Derives
  the GitHub Pages custom domain from SITE_URL_OVERRIDE if set, else
  config.site_url, via urllib.parse (stdlib only, no new dependency),
  and writes docs/CNAME before mkdocs collects static files -- same
  timing/mechanism already used for the atom feed.
- scripts/generate_atom_feed.py: rename the ATOM_SITE_URL env var to
  SITE_URL_OVERRIDE, since it now drives two hooks, not just the feed.
- mkdocs.yml: register the new hook alongside the existing one.
- docs/CNAME: untracked (git rm --cached) and added to .gitignore,
  alongside docs/atom.xml -- both are now build artifacts regenerated
  every run, not source content.
- .github/workflows/docs-deploy.yml: drop the now-unnecessary
  "strip CNAME" step, and set SITE_URL_OVERRIDE from a repo variable
  (Settings -> Actions -> Variables -> SITE_URL_OVERRIDE, already
  configured to https://docs-dev.peeringdb.dk) rather than hardcoding
  the domain in the workflow file.

Security:
- N/A -- documentation only. urllib.parse.urlparse on a value from a
  repo-scoped Actions variable is not an injection vector.

Testing:
- mkdocs build --strict passes with 0 warnings in both modes: with no
  SITE_URL_OVERRIDE set, site/CNAME correctly resolves to
  docs.peeringdb.com (config.site_url fallback, matching current
  production behavior exactly); with SITE_URL_OVERRIDE=
  https://docs-dev.peeringdb.dk/, site/CNAME resolves to
  docs-dev.peeringdb.dk and the atom feed's self-link matches it too,
  confirming both hooks stay in sync off the same override.

Backwards Compatibility:
- N/A for upstream -- unchanged behavior when no override is set.
  For this fork specifically, docs/CNAME's tracked value changes from
  a static docs.peeringdb.com to being generated at build time from
  the SITE_URL_OVERRIDE repo variable; the fork's actual GitHub Pages
  custom-domain setting and docs-dev.peeringdb.dk DNS record still
  need to be configured to match (outside this branch's scope).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
generate_cname.py and generate_atom_feed.py each independently
resolved SITE_URL_OVERRIDE-or-site_url with slightly different
fallback logic. Extract one shared policy so both (and later hooks)
always agree on what "the site URL" is for a given build.

scripts/site_url.py's resolve_site_url() resolves in this order:
1. PROD=TRUE -- real production, full stop.
2. SITE_URL_OVERRIDE, if set.
3. The local dev-server address mkdocs itself is serving on (e.g.
   http://127.0.0.1:8000/), if `mkdocs serve` is what's actually
   running. Requires callers to be wired to on_pre_build, not
   on_config: mkdocs's own serve.py only overwrites config.site_url
   with the dev address *after* on_config has already run (see
   commands/serve.py's `config.site_url = f'http://{config.dev_addr}...'`,
   right before it calls build()), so on_config never sees it.
4. LOCAL_SITE_URL ("http://localhost:8000/", mkdocs's own default
   --dev-addr) -- the fallback when none of the above apply: a plain
   `mkdocs build` with no env vars and no live dev server, e.g.
   docs-build-check.yml's CI check or a one-off local build served
   separately afterward (a static server started after the build
   already finished can't be predicted at build time -- there's
   nothing to detect there). Deliberately assumes "local" rather than
   guessing a real GitHub Pages URL from `git remote get-url origin`:
   a real checkout always has an `origin` remote, so a git-remote-based
   guess would almost always "succeed" with a URL that doesn't
   correspond to anything actually being served, which is more
   misleading than an honest localhost default.

Changes
- scripts/site_url.py (new): resolve_site_url() and origin_repo_url(),
  the shared policy described above.
- scripts/generate_cname.py: use the shared resolver instead of its
  own SITE_URL_OVERRIDE-or-site_url fallback. Also now skips writing
  docs/CNAME when the resolved host is a loopback address, since a
  CNAME file only means anything for a real GitHub Pages deploy.
- scripts/generate_atom_feed.py: use the shared resolver instead of
  its own fallback.

Security
- N/A

Testing
- uv run mkdocs build --strict in four scenarios (no env vars /
  SITE_URL_OVERRIDE / PROD=TRUE / both), all 0 warnings.
- Real `mkdocs serve` run: both docs/CNAME and the feed's self-link
  correctly resolve to the actual dev-server address, confirmed via
  curl that the server responded at that address.
- Confirmed a plain `mkdocs build` with no env vars and no dev server
  running resolves to http://localhost:8000/ rather than a GitHub
  Pages guess, and that docs/CNAME is correctly left untouched (a
  loopback host skips the write).

Backwards Compatibility
- N/A

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This fork's preview deploy currently looks identical to real
production, making it easy to mistake one for the other. Add a
banner above the nav bar showing the fork's repo, the URL currently
serving the page, and where production actually lives.

The banner is on by default -- any build without further signal is
assumed to be a dev/fork build -- and is suppressed only when PROD=TRUE
is set at build time, deliberately the opposite default from
site_url.resolve_site_url()'s own production-safe fallback, since a
real production deploy lives outside this repo and can set PROD=TRUE
explicitly.

Changes
- scripts/generate_banner.py (new): on_pre_build hook that exposes
  fork repo/live/production URLs to templates via
  config.extra.fork_banner, gated on PROD=TRUE. Uses on_pre_build
  (like the cname/atom-feed hooks) rather than on_config, required for
  resolve_site_url()'s local-serve detection tier to see mkdocs's
  overwritten config.site_url.
- peeringdb_theme/base.html (new): full copy of mkdocs 1.4.3's packaged
  base.html (pinned version) with the banner markup inserted before the
  nav bar div, since no block wraps that div for main.html to override.
- docs/extra.css: .fork-banner styling. Height is fixed to 3.5rem to
  match the nav bar's own documented height (mkdocs's base.css:
  scroll-padding-top comment), which .nav-link's 1rem/1rem padding plus
  Bootstrap's 1.5rem line-height already add up to -- no JS measurement
  needed. The banner is sticky at top:0 with the nav bar offset below it
  (body.has-fork-banner .navbar.fixed-top { top: 3.5rem }) so both stay
  pinned together while scrolling.
- mkdocs.yml: register generate_banner.py in hooks:.

Security
- N/A

Testing
- uv run mkdocs build --strict in the same four scenarios as the
  previous commit, all 0 warnings, banner present/absent as expected
  in each.
- Visually verified in-browser: banner height matches the nav bar,
  stays sticky above it while scrolling, single-line content doesn't
  wrap.

Backwards Compatibility
N/A -- peeringdb_theme/base.html duplicates mkdocs 1.4.3's packaged
base.html. If mkdocs is ever upgraded, this file needs to be re-diffed
against the new packaged version and the banner insertion reapplied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The blog feed generator (generate_atom_feed.py) inlined its Atom
feed/entry XML construction, ISO-8601 formatting, markdown-link
stripping, and timestamp-collision dedupe logic (commit a02694c6).
A second, independent feed for release notes is coming in a follow-up
commit -- pull the generic, non-blog-specific pieces out into a shared
scripts/atom_common.py first so that feed doesn't have to duplicate
~60 lines of ElementTree/minidom boilerplate.

Rename generate_atom_feed.py to generate_blog_atom_feed.py for
symmetry with the upcoming generate_release_notes_atom_feed.py, and
relocate its output from docs/atom.xml to docs/blog/atom.xml so the
feed is co-located with its source content the same way the release
notes feed will be (docs/blog/ + docs/release_notes/).

Changes
- scripts/atom_common.py (new): build_feed(), dedupe_published(),
  isoformat(), plain_text(), truncate() -- extracted unchanged from
  generate_atom_feed.py.
- scripts/generate_atom_feed.py -> scripts/generate_blog_atom_feed.py
  (renamed + refactored): keeps its blog-specific git-log date
  derivation and post-excerpt logic, now calls atom_common.build_feed()
  instead of inlining feed construction. Output moved to
  docs/blog/atom.xml.
- mkdocs.yml: hooks: entry updated for the rename.
- peeringdb_theme/main.html: blog feed's <link rel="alternate"> href
  updated to blog/atom.xml.
- docs/blogs.md: "Subscribe via Atom feed" link updated to the new
  blog/atom.xml path.
- .gitignore: docs/atom.xml entry updated to docs/blog/atom.xml.

Security
- N/A

Testing
- uv run mkdocs build --strict in four scenarios (no env vars /
  SITE_URL_OVERRIDE / PROD=TRUE / both), all 0 warnings.
- Diffed the feed's content against a pre-refactor build: byte-
  identical apart from the self-link, confirming the atom_common
  extraction and the file move didn't change any feed data.
- Confirmed docs/blogs.md's and site/blogs/index.html's rendered
  Subscribe links resolve to the new blog/atom.xml path.

Backwards Compatibility
Subscribers following the old docs/atom.xml URL on this fork's preview
will need to resubscribe at docs/blog/atom.xml -- flagging since it's
a real, if minor, break. Production (docs.peeringdb.com) has never had
this feed live, so nothing changes there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The blog feed was the only Atom feed this repo generated. Add a
second, independent feed for release notes, sourced from
docs/release_notes/index.md's `## Release X.Y.Z` sections, so
subscribers can follow releases without also getting blog posts (and
vice versa). Builds on the shared scripts/atom_common.py extracted in
the previous commit.

Release notes entry dates come straight from each section's
`Release Date:` line (falling back to `Beta Announcement Date:` if
absent) rather than git history -- unlike blog posts, this content is
already hand-dated inline, so there's no gap to fill. Entry URLs are
built with markdown.extensions.toc.slugify() (mkdocs's own anchor
slugifier, confirmed against the real built HTML: "Release 2.81.0"
becomes id="release-2810") rather than a hand-rolled one, guaranteeing
they always match the real heading IDs mkdocs generates. Entry
summaries are a plain issue count ("N GitHub issues addressed in this
release"), counted by matching table rows starting with `| [` rather
than parsing cell contents -- the summary column has occasional
unescaped `{`/`[`/`'` characters that would make a content-parsing
regex fragile for no real benefit, since a feed entry's job is to
notify, not replace the page a click away. Only index.md is scoped in,
not the per-year release_notes_YYYY.md archives -- a feed is about
what's new, and index.md alone already covers back to release 2.75.0.

Changes
- scripts/generate_release_notes_atom_feed.py (new): on_pre_build hook
  parsing docs/release_notes/index.md into feed entries as described
  above, writing docs/release_notes/atom.xml via atom_common.build_feed().
- mkdocs.yml: hooks: entry for the new hook.
- peeringdb_theme/main.html: second <link rel="alternate"> for the
  release notes feed.
- docs/release_notes/index.md: "Subscribe via Atom feed" line, matching
  docs/blogs.md's existing convention.
- .gitignore: docs/release_notes/atom.xml entry -- a build artifact,
  not tracked.

Security
- N/A

Testing
- uv run mkdocs build --strict in four scenarios (no env vars /
  SITE_URL_OVERRIDE / PROD=TRUE / both), all 0 warnings, both feeds'
  self-links agreeing with docs/CNAME in every scenario.
- Spot-checked the feed: Release 2.81.0's entry URL
  (.../release_notes/#release-2810) matches the real anchor ID in the
  built HTML, published date matches its Release Date line, and the
  issue count (8) matches a manual count of that section's table rows.

Backwards Compatibility
N/A -- new feed, nothing previously depended on this URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mkdocs gh-deploy already regenerates the whole site tree from the
current branch on every run, so removed pages never linger. What
wasn't guaranteed was the gh-pages branch's commit history: without
--no-history, ghp-import fetches the existing gh-pages HEAD and
commits on top of it, so the branch's state technically depends on
whatever was already there rather than purely on this run's build.
--no-history makes every deploy a single force-pushed root commit,
so gh-pages is unconditionally reset on top of whatever this branch
built, with no path for prior branch state to carry forward.

Changes:
- Added --no-history to the mkdocs gh-deploy invocation in
  docs-deploy.yml, alongside the existing --clean --force flags.

Security:
- N/A

Testing:
- Not run through an actual gh-deploy (would force-push the shared
  gh-pages branch); verified by reading mkdocs/ghp-import's
  documented --no-history behavior against the existing --force
  (permissions: contents: write) setup already in this workflow.

Backwards Compatibility:
- gh-pages loses its multi-commit history going forward (each deploy
  now replaces it with one commit instead of appending). The served
  site content is unaffected; only the branch's own commit log style
  changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant