Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions changelog/331.documentation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Specified phase 2 of the API version stamps in a new specification, ``stamps
spec``, before the first release candidate needs it. Measured in a throwaway
clone, the gate's rule — every published object cites the version the next tag
will carry — turns ``main`` red at the first merge-back of any tag, a release
candidate included, over 94 correct stamps. The specification records a
committed snapshot of the published API, written at the release freeze, that
keeps released objects on their recorded version; and a merge-back through a
branch named without a version, the only route by which a new object on
``main`` can be re-stamped without turning ``main`` red (:issue:`227`).
(:user:`claude`)
270 changes: 270 additions & 0 deletions docs/src/developer/specs/2026-09-15-api-stamps-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
# tephpy API version stamps — design specification

```{readingtime}
```

> **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.md`](2026-07-22-tephpy-design.md) —
`spec §10`'s release execution, which this makes safe to carry out
- **Sibling specs:**
[`2026-08-03-published-specs-design.md`](2026-08-03-published-specs-design.md) —
`docs spec §3.5`'s status vocabulary, which §8 below uses

(stamps-spec-1)=
## 1. Purpose

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

{issue}`227` gave every published object a numpydoc `Notes` section carrying
`.. versionadded::`, and {pull}`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.
{issue}`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.

(stamps-spec-2)=
## 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. {issue}`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.

(stamps-spec-3)=
## 3. Architecture

(stamps-spec-3-1)=
### 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 ({issue}`310`). There,
as in phase 1, an object absent from the snapshot is checked for presence only.

**Why this cannot deadlock the way {pull}`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 {issue}`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).

(stamps-spec-3-2)=
### 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.

(stamps-spec-3-3)=
### 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.

(stamps-spec-3-4)=
### 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.

(stamps-spec-4)=
## 4. Companion changes

- `.github/scripts/check_api_docstrings.py` — `check_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.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.

(stamps-spec-5)=
## 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
{issue}`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 {pull}`317` was.
Rejected.

(stamps-spec-6)=
## 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.

(stamps-spec-7)=
## 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. {issue}`224`'s
`Raises` rule, which shares the script and is untouched. Migrating to Sphinx 9's
`version-added` spelling (§8).

(stamps-spec-8)=
## 8. Open items

Tagged per docs spec §3.5.

- **Deferred** ({issue}`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.
{issue}`227` carries both today and closes with this specification's implementation, so
that pull request files one issue for them and repoints this item.

(stamps-spec-9)=
## 9. References

- {issue}`227` — the `versionadded` gate: phase 1's design, and phase 2's rules
- {pull}`229` — phase 1's implementation
- {pull}`317` — the gate that deadlocked a release, and the one-sided lesson §3.1 applies
- {issue}`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
3 changes: 3 additions & 0 deletions docs/src/developer/specs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ counterpart in the parent specification.
- :doc:`2026-09-11-plotting-tour-design`
* - ``whatsnew spec §…``
- :doc:`2026-09-14-whatsnew-design`
* - ``stamps spec §…``
- :doc:`2026-09-15-api-stamps-design`

A new specification chooses a prefix unique across this collection and declares it in its
own header banner.
Expand Down Expand Up @@ -93,3 +95,4 @@ own header banner.
2026-09-09-reanchor-plotting-design
2026-09-11-plotting-tour-design
2026-09-14-whatsnew-design
2026-09-15-api-stamps-design