diff --git a/changelog/331.documentation.rst b/changelog/331.documentation.rst new file mode 100644 index 0000000..7e09e63 --- /dev/null +++ b/changelog/331.documentation.rst @@ -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`) diff --git a/docs/src/developer/specs/2026-09-15-api-stamps-design.md b/docs/src/developer/specs/2026-09-15-api-stamps-design.md new file mode 100644 index 0000000..86ee8e1 --- /dev/null +++ b/docs/src/developer/specs/2026-09-15-api-stamps-design.md @@ -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 diff --git a/docs/src/developer/specs/index.rst b/docs/src/developer/specs/index.rst index 85fa8e7..dfb4d4f 100644 --- a/docs/src/developer/specs/index.rst +++ b/docs/src/developer/specs/index.rst @@ -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. @@ -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