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.2and 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.md—spec §10’s release execution, which this makes safe to carry outSibling specs:
2026-08-03-published-specs-design.md—docs 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 |
|
target |
|
|---|---|---|---|
|
|
|
52 passed |
tag |
|
|
52 passed |
one commit past it on |
|
|
52 passed |
|
|
|
3 failed |
tag |
|
|
52 passed |
|
|
|
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#
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.
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.
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
mainduring a release can be re-stamped without turningmainred.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 |
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.0and re-stamped0.2.0: retroactive rewriting of history, caught by the snapshot half;a new function stamped
0.1.0because the function above it says0.1.0: the lazy copy, and the likelier error, caught by the target half. A one-sidedcited <= targetwould 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:
create
merge-backfromvA.B.x, and mergemaininto it locally;run the tests, and re-stamp any object the gate names with the version it asks for;
push, and open the pull request from
merge-backintomain— labelledskip-changelogand merged with a merge commit, as step 10 already requires;once it merges, delete
merge-backand keepvA.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 |
|
target |
|---|---|---|
|
|
|
branch |
|
|
branch |
|
|
branch |
|
|
branch |
|
|
a detached merge commit — what pull-request CI checks out |
|
|
|
|
|
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.py—check_versionaddedtakes 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_schemestops asserting"0.1.0"and asserts a three-component release, which is what it exists to protect: without the scheme read frompyproject.toml, the target comes back0.1.docs/src/developer/docs-style.rst— API 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
mainbetween cuttingvA.B.xand 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
mainred 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; |
one integration test building a git repository in a temporary directory from the project’s committed |
|
the policy is written down |
|
the runbook’s command exists |
|
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
versionaddedtoversion-addedonce the documentation’s Sphinx floor passes 9.0, where the hyphenated spelling first exists, and suggesting upstream that numpydoc’sDIRECTIVESlist include it soGL10covers 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
versionaddedgate: 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()refusesdocs/src/developer/docs-style.rst, API Version Stamps — the contributor-facing policydocs/src/developer/release.rst— the runbook whose steps 4 and 10 §3.3 changes