Skip to content

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-material drops 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.