Files
Stefan Broenner 8310b3f27c ci(docs): validate the Pages site on PRs and harden the build (#772)
* ci(docs): validate the Pages site on PRs and harden the build

The docs site was validated by nothing before merge: CI Gate never built
it, and pre-commit.ps1 classifies gh-pages/ as doc-only. A broken site was
only discoverable after it had already deployed.

Validation
- New "Docs Site" CI job: mkdocs build --strict, audit_site.py and the new
  check_deploy_paths.py on every pull request.
- check_deploy_paths.py proves the deploy workflow paths: filter covers
  every source hooks.py mirrors. That filter was hand-maintained, so a new
  mirrored file silently stopped triggering a deploy.
- Dependabot pip entry for gh-pages: mkdocs-material was never updating.

Build fixes
- hooks.py wrote its generated pages into docs_dir, which mkdocs serve
  watches, so every build retriggered itself and looped forever. Generated
  output moved to gh-pages/_generated/ with snippets base_path extended to
  match. Rebuild detections after one edit: 9+ -> 1.
- Added a watch: key so serve reloads on FEATURES.md, CHANGELOG.md, docs/
  and skills/ instead of only on gh-pages/.

Workflow split
- Persisting the star history needs contents: write, and it shared a job
  with pip install -r requirements.txt. Split into star-history.yml; the
  Pages build is now contents: read and only renders the chart. The
  generator tests also run on pull requests now, not just after merge.
- New link-check.yml runs lychee weekly over the built site and files an
  issue on link rot. Scheduled rather than per-PR because external
  endpoints rate-limit and would make it flaky.

Site output is byte-identical across all 164 files.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 388229c1-868a-416f-8b0a-7d6ee058e788

* fix(docs): keep excluding the former docs/_generated/ location

Moving generated output out of docs_dir also removed the exclude_docs
entry, so a stale docs/_generated/ left in a long-lived clone was silently
built and published as real pages: reproduced at 52 pages instead of 51,
with audit_site.py passing.

Restore the exclusion and keep the old .gitignore entry so the leftover
directory neither gets published nor shows up as untracked.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 388229c1-868a-416f-8b0a-7d6ee058e788

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 388229c1-868a-416f-8b0a-7d6ee058e788
2026-08-15 15:51:34 +02:00

92 lines
3.1 KiB
Python

#!/usr/bin/env python3
"""Verify the Pages deploy workflow rebuilds the site for every canonical source.
``hooks.py`` pulls canonical Markdown from all over the repo into the site, but
``.github/workflows/deploy-gh-pages.yml`` decides *when* to rebuild from a
hand-written ``paths:`` filter. Nothing kept the two in sync, so adding a new
mirrored source silently produced a stale website until the next nightly cron.
This script fails when a source that ``hooks.py`` reads is not covered by the
workflow filter. Run it from the ``gh-pages`` directory::
python check_deploy_paths.py
"""
from __future__ import annotations
import fnmatch
import re
import sys
from pathlib import Path
import yaml
GH_PAGES = Path(__file__).resolve().parent
REPO_ROOT = GH_PAGES.parent
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "deploy-gh-pages.yml"
# Literal _read("...") calls in hooks.py: CHANGELOG.md, SECURITY.md, the
# installation pages and so on. The dict-driven sources are collected from the
# imported module instead, because those paths are built with f-strings.
_READ_CALL = re.compile(r'_read\(\s*"([^"]+)"')
def mirrored_sources() -> set[str]:
sys.path.insert(0, str(GH_PAGES))
import hooks # noqa: PLC0415 - deliberate late import; needs sys.path above
sources: set[str] = set()
sources.update(hooks.FEATURE_SOURCES.values())
sources.update(hooks.GUIDE_SOURCES.values())
sources.update(f"skills/shared/{name}" for name in hooks.SKILL_SOURCES)
sources.update(_READ_CALL.findall((GH_PAGES / "hooks.py").read_text(encoding="utf-8")))
return sources
def workflow_paths() -> list[str]:
config = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
# YAML 1.1 parses a bare `on:` key as the boolean True.
triggers = config.get("on", config.get(True, {})) or {}
return list((triggers.get("push") or {}).get("paths") or [])
def main() -> int:
if not WORKFLOW.is_file():
print(f"ERROR: {WORKFLOW} not found", file=sys.stderr)
return 2
patterns = workflow_paths()
if not patterns:
print("ERROR: the deploy workflow declares no push paths filter", file=sys.stderr)
return 2
missing: list[str] = []
for source in sorted(mirrored_sources()):
if not (REPO_ROOT / source).is_file():
missing.append(f"{source}: read by hooks.py but does not exist in the repo")
continue
if not any(fnmatch.fnmatch(source, pattern) for pattern in patterns):
missing.append(
f"{source}: mirrored into the site but no paths: entry in "
f"{WORKFLOW.relative_to(REPO_ROOT).as_posix()} matches it"
)
if missing:
print(
"Deploy path check FAILED - editing these files would not rebuild "
f"the site:\n"
)
for item in missing:
print(f" - {item}")
return 1
print(
f"Deploy path check passed: {len(mirrored_sources())} mirrored sources, "
f"all covered by {len(patterns)} paths entries."
)
return 0
if __name__ == "__main__":
sys.exit(main())