ADR 0039: The docs toolchain stays on MkDocs, the dependency closure is pinned by hash so a fork cannot arrive transitively, and adoption of ProperDocs is a decision we make deliberately or not at all¶
Status: Accepted Date: 2026-08-06 Issue: #1297
Context¶
docs.cndcs.io is a product surface. It is built by MkDocs with the Material
theme, packaged in deploy/docker/Dockerfile.docs, and published from a
digest-pinned base image. Until 2026-08-06 the Python half of that toolchain was
declared as two open-ended ranges:
mkdocs-material>=9
mkdocs-redirects>=1.2
A digest-pinned image whose Python tree re-resolves on every build is only partly pinned, and on 2026-08-06 that gap produced a concrete surprise.
What arrived, and how¶
mkdocs-redirects 1.2.3, published 2026-03-28, declares a hard runtime
dependency on properdocs>=1.6.5, a fork of MkDocs. Version 1.2.2 declared only
mkdocs>=1.1.1. The entire code change between the two releases is:
try:
import properdocs.replacement_warning
properdocs.replacement_warning.setup()
except ImportError:
pass
No fix and no feature. The except ImportError establishes that the dependency
is not functionally required even by its own author, and
properdocs/replacement_warning.py is an advertisement whose docstring
solicits other plugin authors to embed the same thing.
Two further facts decided how seriously to take it. The canonical repository,
github.com/mkdocs/mkdocs-redirects, has no v1.2.3 tag — its last release
is 1.2.2 — and the 1.2.3 release points its Source metadata at
github.com/ProperDocs/mkdocs-redirects, an organisation created two weeks
before publication. So a release under a long-trusted package name has no
corresponding tag on the repository that name has always pointed at.
The fork is legitimate, which is why this is a position rather than an incident¶
properdocs on PyPI carries the same author email as mkdocs itself, meaning
MkDocs' original creator. It is BSD-2-Clause, its version line continues MkDocs'
own (1.6.1 to 1.6.5), and its README states the fork openly. MkDocs upstream has
released nothing since 1.6.1 on 2024-08-30.
This is therefore a migration campaign by a project's original author against a stalled upstream, distributed as a required dependency. It is not an attack, and treating it as one would be wrong. What it is not is a change anyone here chose.
Separately, mkdocs-material now prints a red banner during every build warning
that MkDocs 2.0 removes the plugin system. That banner does not name
ProperDocs, and its is_mkdocs() guard silences it when the build is driven by
a fork's executable, so both sides now nag the other's users and each goes
quiet under the other. Neither banner fails --strict.
Decision¶
We stay on MkDocs. ProperDocs is not adopted.
The whole dependency closure is pinned by exact version and hash.
docs/requirements.in holds the direct dependencies and the rationale;
docs/requirements.txt is generated from it by make docs-requirements and
carries the full transitive closure with --generate-hashes. Every consumer,
namely Dockerfile.docs, lint-docs.yml, deploy-docs.yml, and
make docs-build, installs with --require-hashes.
mkdocs-redirects is pinned below 1.2.3, which keeps properdocs out of
the published image.
A direct-only pin would not have implemented this decision. properdocs never
appears in our requirements file; it arrives through another package's metadata.
Pinning only what we name leaves the transitive edge free, which is precisely
the hole that produced the surprise.
Alternatives Considered¶
Pin only the direct dependencies. Smaller diff, easier to read, and it does not close the reported hole. Rejected on that ground alone.
Adopt ProperDocs now. It is by MkDocs' original author, it is actively released where upstream is not, and the ecosystem may follow it. It may well be the right destination. Rejected as a default: arriving there because a transitive dependency was rewritten is not a decision, and a migration of a published product surface deserves to be chosen on its merits with the ecosystem's direction visible. This ADR does not argue against ProperDocs. It argues against being moved without choosing.
Stay on the ranges and watch the build. Rejected. The failure mode is
silent: a resolution changes, the image ships, and nobody looks until something
renders wrong. The only reason we caught this one is that an unrelated CI change
put fresh pip install output in front of a human.
Fork or vendor mkdocs-redirects. Rejected as disproportionate. A version
pin costs nothing and is reversible in one line; a fork is a maintenance
obligation.
Consequences¶
The lock is a generated artifact. docs/requirements.txt must never be
hand-edited. --require-hashes makes a hand-edited pin fail the install rather
than drift silently, which is the intended behaviour, but it means an edit to a
version goes through docs/requirements.in and make docs-requirements.
Upgrades become deliberate. A new Material release is now a change we make
rather than one we receive, which is the point, and the cost is that security
updates need a human. Dependabot covers /docs as a pip ecosystem so the
prompt arrives.
The lock is compiled under Python 3.11, the docs base image's interpreter.
Per-Python wheels are hashed for every supported version, so the closure
installs unchanged on a runner with a different 3.x, verified on 3.11 and
3.14.
This decision has an expiry we do not control. Reopen it when any of these fire:
- MkDocs 2.0 ships and removes the plugin system, breaking plugins we depend on.
mkdocs-materialdrops MkDocs support, or makes ProperDocs its primary target.- A plugin we need becomes available only in a fork-dependent release.
- MkDocs upstream resumes releases, which would settle the question the other way.
Until one fires, the position holds and nobody needs to re-derive the archaeology above.