tephpy API version stamps — design specification#

Estimated reading time: 18 minutes

Living document. This specification is maintained alongside the code, not archived behind it. The gate and the release runbook cite it by section — stamps spec §3.2 and the like — so these sections are the reasoning behind what they do, and where the two ever diverge it is the specification that gets corrected. Read it as current.

  • Date: 2026-09-15 (originated; maintained since)

  • Status: living design specification

  • Citation prefix: stamps spec §…

  • Scope: the rule that every published API object records the version it arrived in, the committed snapshot that lets the rule survive a release, and the two release-runbook steps that keep the snapshot true

  • Parent spec: 2026-07-22-tephpy-design.mdspec §10’s release execution, which this makes safe to carry out

  • Sibling specs: 2026-08-03-published-specs-design.mddocs spec §3.5’s status vocabulary, which §8 below uses

1. Purpose#

The version gate turns main red the moment a release is merged back into it.

#227 gave every published object a numpydoc Notes section carrying .. versionadded::, and #229 built the gate that enforces it: .github/scripts/check_api_docstrings.py, run by tests/test_api_docstrings.py. Its rule is phase 1: every object must cite exactly the target, the base version the next tag will carry, derived by setuptools_scm under semver-pep440-release-branch. That is exact and total while nothing has been released, because nothing can predate the first release. #227 recorded that it would stop being right once a tag existed, and deferred the fix to a phase 2 that could not start without one.

Measured on 2026-09-15, in a throwaway clone of main at 1ffb603, the moment is the first merge-back of any tag, a release candidate included — not the release itself:

repository state

setuptools_scm

target

tests/test_api_docstrings.py

main, no tag

0.1.0.dev223

0.1.0

52 passed

tag v0.1.0rc1 on v0.1.x

0.1.0rc1

0.1.0

52 passed

one commit past it on v0.1.x

0.1.0rc2.dev1

0.1.0

52 passed

main after merging v0.1.x back

0.2.0.dev2

0.2.0

3 failed

tag v0.1.0 on v0.1.x

0.1.0

0.1.0

52 passed

main after merging that back, one commit on

0.2.0.dev4

0.2.0

3 failed

The three failures are test_target_version_uses_the_project_version_scheme ('0.2.0' == '0.1.0'), test_every_published_docstring_is_stamped (all 94 objects: versionadded cites 0.1.0, expected 0.2.0), and test_the_gate_reports_rather_than_raises (main() returns 1). Every one of the 94 stamps is correct; the rule is what stops being. The release runbook’s rehearsal merges a release candidate back, so without this specification the first rehearsal of the first release would have left main red.

2. Decisions#

  1. Released objects are recorded in a committed snapshot, written at the release freeze. A plain-text file names every published object and the version it cites. An object the snapshot records keeps that version; an object it does not record cites the target. #227 proposed the shape; §3.2 settles the file.

  2. An object the snapshot records but the package no longer publishes is ignored until the next freeze rewrites the file. The snapshot means what the last release published, and a later removal does not make that wrong. A renamed object is a new name to a reader, so it cites the target.

  3. A release branch is merged back through a dedicated branch named without a version. §3.3 gives the reason: it is the only route by which a new object merged into main during a release can be re-stamped without turning main red.

  4. No snapshot file means phase 1. Before the first freeze every published object is absent from a snapshot that does not exist, so every object cites the target — exactly the rule the gate enforces today. The change can therefore land before any tag, and the first release writes the first snapshot.

3. Architecture#

3.1 The rule#

For each published object, in the sense docs-style.rst’s API Version Stamps section defines:

the object

the rule

carries no versionadded in its Notes section

fails — unchanged from phase 1

is recorded in the snapshot

must cite exactly the version the snapshot records

is not recorded in the snapshot

must cite the target

is recorded, but no longer published

ignored

The snapshot half needs no derived version. It compares a docstring against a committed file, so it keeps running where target_version() refuses to derive one — a shallow clone, or a git archive export with no tag to measure from (#310). There, as in phase 1, an object absent from the snapshot is checked for presence only.

Why this cannot deadlock the way #317’s gate did. That gate compared a tag-derived value against file contents that could not change in the same commit, and so had a window in which no tree satisfied it. Here, the snapshot half depends on a file and never on a derived value. The target half applies only to objects absent from the snapshot, whose stamps can be edited in the same change that moves the target — with the one exception §3.3 exists to handle.

What each half catches — the two failure modes #227’s phase-2 table named:

  • an object recorded at 0.1.0 and re-stamped 0.2.0: retroactive rewriting of history, caught by the snapshot half;

  • a new function stamped 0.1.0 because the function above it says 0.1.0: the lazy copy, and the likelier error, caught by the target half. A one-sided cited <= target would let it through (§5).

3.2 The snapshot#

.github/api-surface.txt. One line per published object, sorted by dotted name, three tab-separated fields — the dotted name, its role (module, class, exception, function, method or property), and the version its docstring cites — under a header comment saying that the gate writes the file and it is not edited by hand. Plain text so that a public-API change reads as a diff in review; beside the gate, and so already outside the sdist, because MANIFEST.in prunes .github.

Written by the gate: python .github/scripts/check_api_docstrings.py --write-snapshot. It runs the rule first and refuses to write, leaving any existing file untouched, when any published object fails, or when no target can be derived. A snapshot therefore never records a stamp the rule did not accept.

Read defensively. A line the reader cannot parse fails the gate rather than being skipped: a snapshot read as empty would silently return the rule to phase 1.

3.3 The release flow#

Two steps of docs/src/developer/release.rst change.

Step 4, Fill in the release metadata, writes the snapshot. Beside CITATION.cff, the release pull request runs --write-snapshot and commits .github/api-surface.txt, so the snapshot is on the commit that gets tagged. Folding it into step 4 rather than adding a step keeps the runbook’s numbering, and the references to it, valid. A patch release re-enters at the freeze and so rewrites the snapshot too; an object a patch adds keeps its patch version, 0.1.1 say, on main after the merge-back.

Step 10, the merge-back, goes through its own branch. The problem is an object merged into main after vA.B.x was cut and before its first tag comes back. Before the merge-back main’s target is 0.1.0, so the object must cite 0.1.0; after it the target is 0.2.0, and the object — which did not ship in 0.1.0 — must cite 0.2.0. The target moves in the merge commit itself. A pull request from vA.B.x cannot re-stamp the object, because the release branch does not contain it; re-stamping on main first turns main red until the merge; and main’s protection requires the test, docs, build, pre-commit.ci, changelog and Read the Docs checks to pass, so a red merge-back cannot land without an administrator’s bypass. The route that works:

  1. create merge-back from vA.B.x, and merge main into it locally;

  2. run the tests, and re-stamp any object the gate names with the version it asks for;

  3. push, and open the pull request from merge-back into main — labelled skip-changelog and merged with a merge commit, as step 10 already requires;

  4. once it merges, delete merge-back and keep vA.B.x.

The tag stays an ancestor of main, the release branch is not touched, and the failure appears in a pull request’s checks, where it can be fixed. The runbook’s note Two gates will move at the first tag is replaced: it named the hard-coded version assertion and the phase-1 rule, and this specification retires both.

3.4 Why the branch carries no version#

semver-pep440-release-branch reads a branch name that contains a version as a release branch. Measured 2026-09-15 in a throwaway clone — v0.1.x tagged v0.1.0rc1 after a commit of its own, then one new commit on main:

where

setuptools_scm

target

main before the merge-back

0.1.0.dev224

0.1.0

branch merge-back-v0.1.x, main merged in

0.1.0rc2.dev2

0.1.0

branch merge-back/0.1, main merged in

0.1.0rc2.dev2

0.1.0

branch merge-back, main merged in

0.2.0.dev2

0.2.0

branch merge-back-release, main merged in

0.2.0.dev2

0.2.0

a detached merge commit — what pull-request CI checks out

0.2.0.dev2

0.2.0

main after the pull request’s merge commit

0.2.0.dev3

0.2.0

On a versioned name a local run targets the release line and disagrees with the pull request’s CI: a correct re-stamp to 0.2.0 fails locally and passes in CI. A versionless name agrees with both. Pull-request CI is right either way, so a versioned name is not a deadlock — it is a false failure on release day, which is reason enough.

A trap for whoever measures this again. Tagging v0.1.x and merging it straight back, with no commit of its own on the branch, makes no merge commit at all: main is left on the tagged commit, derives 0.1.0rc1, and passes. Put a commit on the release branch first.

4. Companion changes#

  • .github/scripts/check_api_docstrings.pycheck_versionadded takes the snapshot; --write-snapshot; a reader for the snapshot that fails on a line it cannot parse; a failure message that, for an object the snapshot records, names the recorded version rather than the target.

  • tests/test_api_docstrings.py§6’s tests; test_target_version_uses_the_project_version_scheme stops asserting "0.1.0" and asserts a three-component release, which is what it exists to protect: without the scheme read from pyproject.toml, the target comes back 0.1.

  • docs/src/developer/docs-style.rstAPI Version Stamps gains the contributor’s half: a new object cites the version the gate names; an object from an earlier release keeps its stamp, and released API is never re-stamped; the release process writes the snapshot, a contributor does not.

  • docs/src/developer/release.rst — steps 4 and 10 as §3.3 describes, and the note it replaces.

5. Alternatives considered#

  • Read the previous release from its tag at check time. No file to maintain, but it needs the tags present — a shallow clone and pre-commit.ci’s checkout lack them — and enumerating the API as it stood at the tag means importing an old tree of the package. Rejected as fragile where the committed snapshot is not.

  • A one-sided rule, cited <= target, and no snapshot. It can never deadlock, and it catches a stamp naming a future version. But it passes the lazy copy, which #227’s phase-2 table identifies as the default mistake, from the first release onwards. Rejected for missing the likelier error.

  • Forbid new public API on main between cutting vA.B.x and merging its first tag back. Nothing would enforce it, and when it is broken there is no clean fix — §3.3’s reasons. Rejected for relying on memory.

  • Merge a red merge-back and re-stamp afterwards. Possible only by bypassing required checks, and it leaves main red in between — the release-day surprise #317 was. Rejected.

6. Testing#

what lands

what holds it

each row of §3.1’s table

a unit test per row, over fixture objects: recorded and matching with a different target passes; recorded and re-stamped fails naming the recorded version; absent and citing an old version fails naming the target; absent with no target checks presence; recorded with no target stays exact; recorded but unpublished is ignored

no snapshot means phase 1

the existing phase-1 tests, unchanged

the snapshot file

a write-then-read round trip, sorted, with the header; --write-snapshot refusing on a violation and on an underived target, writing nothing; a malformed line failing the reader

§1’s and §3.4’s measured tables

one integration test building a git repository in a temporary directory from the project’s committed [tool.setuptools_scm] settings: the rc tag and the release branch target 0.1.0, main after the merge-back and a merge-back branch target 0.2.0, a merge-back-v0.1.x branch targets 0.1.0. Guarded on the checkout’s .git, as tests/test_floors.py requires of every test that runs git, and passing identity and commit.gpgsign=false per command, so it runs on a machine with no git configuration

the policy is written down

test_the_policy_is_written_down, which today passes on any mention of versionadded, also requires docs-style.rst to name .github/api-surface.txt and --write-snapshot

the runbook’s command exists

release.rst names --write-snapshot and the merge-back branch, and the gate’s argument parser accepts --write-snapshot

Each rule is proven by mutation in the plan: deleting the snapshot half fails the re-stamp test; weakening the target half to <= fails the lazy-copy test; skipping a malformed line fails its test; renaming the flag fails the runbook check.

7. Scope#

In scope. The rule and the snapshot of §3.1 and §3.2, --write-snapshot, the tests of §6, the style guide’s policy, and the runbook’s steps 4 and 10 with the note they replace.

Out of scope. Writing the first real snapshot, which happens at the release candidate’s step 4 on v0.1.x and is the first time the flow runs for real. #224’s Raises rule, which shares the script and is untouched. Migrating to Sphinx 9’s version-added spelling (§8).

8. Open items#

Tagged per docs spec §3.5.

  • Deferred (#227) — migrating versionadded to version-added once the documentation’s Sphinx floor passes 9.0, where the hyphenated spelling first exists, and suggesting upstream that numpydoc’s DIRECTIVES list include it so GL10 covers it. #227 carries both today and closes with this specification’s implementation, so that pull request files one issue for them and repoints this item.

9. References#

  • #227 — the versionadded gate: phase 1’s design, and phase 2’s rules

  • #229 — phase 1’s implementation

  • #317 — the gate that deadlocked a release, and the one-sided lesson §3.1 applies

  • #310 — the underived version that target_version() refuses

  • docs/src/developer/docs-style.rst, API Version Stamps — the contributor-facing policy

  • docs/src/developer/release.rst — the runbook whose steps 4 and 10 §3.3 changes