0024: A documentation site built by Zensical, hosted on GitHub Pages¶
Status: accepted
Date: 2026-10-01T14:43:57-03:00
Amended: 2026-10-14T09:21:02-03:00, one site per version with mike: dev from main, each release, stable (amendment 1)
Amended: 2026-10-15T09:43:36-03:00, a final release's run deletes its release candidates from the selector (amendment 2)
Amended: 2026-10-15T12:20:00-03:00, each run's Pages deployment is named after its gh-pages commit, so a tag on main's last commit publishes (amendment 3)
Context¶
Usage was documented in a long README and the Markdown files under docs/; the API was
documented only in docstrings. Users need guides, an options and tables reference, and an
API reference of the public surface (ADR 0021), published somewhere. Requirements: the
existing docs/ Markdown works unchanged (GitHub renders it too), the API reference comes
from the docstrings without importing the package (mssql_cdc imports PySpark, which a docs
build should not need, nor Java), and CI fails on broken links.
The state of the tools in October 2026:
- MkDocs 1.6.1 (August 2024) is its last release and unmaintained; the announced "MkDocs 2.0" drops the plugin system, and ProperDocs continues 1.6 as a drop-in fork (ProperDocs discussion, The Slow Collapse of MkDocs).
- Material for MkDocs (9.7.7) is in maintenance mode since November 2025: critical bugs and
security issues for at least 12 months, no new features
(Material blog).
Its team builds Zensical instead, MIT-licensed, which reads
mkdocs.yml(announcement). - Zensical (0.0.67, 2026-09-30) ships the Material features (search, palette toggle, code
copy, admonitions, Mermaid) and supports mkdocstrings since 0.0.11
(plugins);
zensical build --strictfails on links to missing pages or anchors (validation). - mkdocstrings-python (2.0.9) reads the source with Griffe;
allow_inspection: falseforbids importing a module (options). - Sphinx (9.1, Python 3.12+) with Furo needs MyST-Parser to read Markdown, and autodoc imports the modules it documents (autodoc; sphinx-autoapi does not). Read the Docs hosts either, as a second service to configure next to GitHub.
Decision¶
- Zensical builds the site from
mkdocs.ymlat the repository root,docs_dir: docs, intosite/(ignored). The existing pages stay where they are;mkdocs.ymllists every page in itsnav. Configuration stays in the MkDocs format, which mkdocstrings documents and the MkDocs-compatible tools read. docs/reference/api.mdis the API reference: mkdocstrings-python directives over the public surface of ADR 0021, read statically fromsrc/(allow_inspection: false). Listing an object there makes it public (ADR 0021, amended to point here).- Usage is documented once, on the site. The README keeps the pitch, the install, a minimal
example and links to the site's pages; the options and the output schema live only in
docs/reference/. docs/changelog.mdanddocs/contributing.mdinclude the rootCHANGELOG.mdandCONTRIBUTING.mdthroughpymdownx.snippets, withcheck_pathsso a missing file fails the build.- A
docsdependency group (zensical,mkdocstrings-python). The build runs with that group alone:uv run --only-group docs zensical build --strict, no PySpark, no package. - CI's
docsjob builds in strict mode on every push and pull request;docs.ymlbuilds and deploys to GitHub Pages on every push tomain. One version,main's; versioned docs when a release needs them.
Consequences¶
- Zensical is 0.0.x: the lock pins it, and an upgrade is checked with a strict build.
Should it stall, the same
mkdocs.ymlis the starting point for ProperDocs with a theme. - The validator checks the page sources, not text a snippet pulls in: a relative link inside
CHANGELOG.mdresolves againstdocs/changelog.mdon the site, unchecked, and a link that works on GitHub (docs/decisions/...) breaks there. SoCHANGELOG.mdandCONTRIBUTING.mdlink to docs pages by their absolute site URL. Links leavingdocs_dir(../README.md) fail the strict build. - Docstrings render as Markdown: reST roles such as
:mod:show literally. - A page missing from
navstill builds, reachable only by links: new pages and ADRs get anavline. - GitHub Pages must be enabled once, with "GitHub Actions" as the source.
Amendment 1: one site per version¶
1.0 freezes the public API (ADR 0021), and users on a release need the pages that describe
it while main moves on: the single site, main's, describes the next release, not theirs.
Zensical has no versioning of its own yet. Its documented path is a fork of mike, the tool
Material for MkDocs used, kept by the Zensical team until native versioning ships
(Zensical: versioning,
squidfunk/mike): it builds with zensical build and
commits each version to a directory of the gh-pages branch, with a versions.json at its
root that the theme's version selector reads (extra.version.provider: mike). The fork is
not on PyPI and gets compatibility fixes only.
- The
docsgroup pins the fork to a commit (uv.lockrecords it). pip-audit audits whatuv.lockpins by hash, and a git commit has none and no PyPI entry:security.ymlleaves that one package out of the export; its dependencies stay in. docs.ymlpublishes one version per run, all from one concurrency group, so the runs that commit togh-pagesnever overlap:- a push to
mainpublishesdev; - a tag
vX.Y.ZpublishesX.Y.Zand moves thestablealias to it, unlessstableis on a newer version already (a run again from an older tag publishes its version only); - a tag
vX.Y.ZrcNpublishesX.Y.ZrcNand leavesstablewhere it is, so a release candidate in use has its pages without becoming the default; - the site's root opens
stable, ordevuntil the first final release. A tag publishes only whenrelease.ymlwould release it: its version is the one inpyproject.toml, its commit is onmain, andCHANGELOG.mdhas its section. A tag pushed by mistake (v1.0.1for a 1.0.0) never shows as a version. Versions start with this amendment: the 0.x releases are not published. - GitHub Pages keeps "GitHub Actions" as its source.
gh-pagesis the store of every version; each run commits to it, pushes it, and deploys its whole tree as the Pages artifact (an alias's symlink becomes a copy there). The job that builds, which runs the third-party code of the docs group (Zensical, mkdocstrings, the mike fork), has a read-only token: on a hosted runner every step of a job can read its token, so a token given to one step only is given to the job. mike commits to a localgh-pages, and the job hands the new commits on as a git bundle. A second job, which runs nothing but git, holdscontents: writeand pushes them, without force:gh-pagesonly, and only as a fast-forward. - Links from before the versions (
/mssql-cdc-pyspark/guides/streaming/, in the README of every 0.x release on PyPI and inCHANGELOG.md) keep working: the site's root404.html(.github/pages/404.html, added to the artifact) sends a path whose first segment is not a version on to the same path instable, ordev. A missing page inside a version stays a 404. - mike's build is not strict, so
docs.ymlruns the strict build first.
Consequences: the github-pages environment needs a v* tag rule next to main, or a
tag's deploy is refused (its version is on gh-pages regardless, and the next push to
main deploys it). A tag publishes its version as soon as it is pushed, before PyPI's
reviewer approves the release; a tag deleted and pushed again publishes over it. A version
that passes the checks but never reaches PyPI (abandoned for the next number) stays in the
selector, and in stable, until removed by hand (RELEASING.md). When
Zensical ships native versioning, the fork goes, and the gh-pages layout (a directory per
version, versions.json) is what a replacement has to keep or redirect.
Amendment 2: a final release replaces its candidates¶
A release candidate's pages are for the weeks it runs before its final release (ADR 0021
amendment 11). Once X.Y.Z is out they describe the same version, and a selector that keeps
X.Y.Zrc1, X.Y.Zrc2... next to it grows with every release and offers pages nobody should
pick. So the docs run of a tag vX.Y.Z, after it publishes X.Y.Z (and moves stable),
deletes X.Y.ZrcN with mike delete, in the same commits the push job pushes: the build job
stays read-only. A run again from the same tag finds none left. Other versions stay until
removed by hand (RELEASING.md).
Amendment 3: a deployment per run, not per commit¶
actions/deploy-pages names a Pages deployment after the commit (GITHUB_SHA), with no
input to change it, and Pages answers a second deployment of the same commit with the first
one, as a success. A release tag is pushed on the commit main has just published (the
release commit, once CI passes), so 1.0.0rc1's run reported success and left the site as
main's run had it: the version was on gh-pages, not on the site, until the next push to
main. The deploy job now calls the Pages API itself (create the deployment from the build
job's artifact with the job's OIDC token, then wait for succeed), naming it after the
gh-pages commit the run pushed: mike always commits (--allow-empty), so every run, and
a re-run, publishes its tree. (Pages takes only a commit of the repository as that name.)