diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 916fc36..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Publish Package - -on: - release: - types: - - published - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: extractions/setup-just@v4 - - uses: astral-sh/setup-uv@v8.2.0 - - run: just publish - env: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..aa59183 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +# Tag-driven: pushing a bare semver tag publishes to PyPI, creates the matching +# GitHub Release, and floats the `v0` action tag. Replaces the old +# `release: published` publish.yml (deleted) and folds in tag-major.yml (deleted). +# +# The tag is the sole, deliberate entry point. semvertag.yml dogfoods in +# DRY-RUN, so it never pushes a tag — only a maintainer's manual `git push` of +# a tag reaches here (GitHub suppresses workflow triggers from +# GITHUB_TOKEN-pushed refs, so even an auto-push would not fire this). By +# convention a tag is cut only off green main, so there is no in-workflow CI gate. +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' # stable: 0.9.0 + - '[0-9]+.[0-9]+.[0-9]+[a-z]+[0-9]+' # pre-release: 0.9.0rc1, 1.0.0a2 + +# Needed for softprops/action-gh-release to create the Release and for the +# v0 force-push. +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: extractions/setup-just@v4 + - uses: astral-sh/setup-uv@v7 + + # PyPI is irreversible, so it runs FIRST: if it fails the job stops and no + # GitHub Release or v0 move advertises a version that never reached PyPI. + # `just publish` derives the version from $GITHUB_REF_NAME (the tag name). + - run: just publish + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + + # Description source: planning/releases/.md if present (verbatim, no + # auto-changelog appended); otherwise GitHub's generated notes. A tag with + # a letter (0.9.0rc1) is a pre-release -> flagged so GitHub won't mark it + # "Latest" and so the v0 float below is skipped. + - name: Resolve release metadata + id: meta + run: | + set -euo pipefail + notes="planning/releases/${GITHUB_REF_NAME}.md" + if [ -f "$notes" ]; then + echo "body_path=$notes" >> "$GITHUB_OUTPUT" + echo "generate_notes=false" >> "$GITHUB_OUTPUT" + else + echo "generate_notes=true" >> "$GITHUB_OUTPUT" + fi + if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v3 + with: + body_path: ${{ steps.meta.outputs.body_path }} + generate_release_notes: ${{ steps.meta.outputs.generate_notes }} + prerelease: ${{ steps.meta.outputs.prerelease }} + draft: false + + # Floating major tag (folded-in tag-major.yml): consumers pin + # `uses: modern-python/semvertag@v0` and ride minor bumps. Skipped on + # pre-releases so a 0.9.0rc1 doesn't drag v0 ahead of the latest stable. + # References HEAD (the tag commit), so no fetch-depth: 0 is needed. + - name: Float major tag + if: steps.meta.outputs.prerelease == 'false' + run: | + set -euo pipefail + major="v${GITHUB_REF_NAME%%.*}" # 0.9.0 -> v0 + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git tag -fa "$major" -m "Update $major to $GITHUB_REF_NAME" + git push -f origin "$major" diff --git a/.github/workflows/semvertag.yml b/.github/workflows/semvertag.yml index 21ca0fa..366824e 100644 --- a/.github/workflows/semvertag.yml +++ b/.github/workflows/semvertag.yml @@ -1,25 +1,25 @@ name: semvertag -# Dogfood the local composite action against this repo. Auto-tags on -# push to main when the latest commit is a merge from `feat/...` (minor -# bump) or `bugfix/`/`hotfix/...` (patch). This repo's branch -# convention uses `feat/...`, so SEMVERTAG_BRANCH_PREFIX__MINOR -# overrides the default `feature/` mapping. +# Dogfood the local composite action against this repo in DRY-RUN: on every +# push to main it computes the planned bump (exercising action.yml + the +# published semvertag) but never pushes a tag. This keeps action.yml honest — +# a breaking change fails the run before it can affect external users. # -# The workflow only creates a tag — it does NOT trigger publish.yml, -# which fires on GitHub release creation. To publish to PyPI, create a -# GitHub release pointed at the auto-tagged commit. +# Releases are NOT cut here. A release is a maintainer pushing a bare semver +# tag by hand, which triggers .github/workflows/release.yml (PyPI + GitHub +# Release + v0). Dry-run is load-bearing: it guarantees the only tags in the +# repo are deliberate release tags — do not give this job a push token or +# remove `dry-run: true`. # -# `uses: ./` exercises the action.yml in the current checkout, so any -# breaking change to action.yml fails the dogfood run before it can -# affect external users. +# This repo's branch convention uses `feat/...`, so SEMVERTAG_BRANCH_PREFIX__MINOR +# overrides the default `feature/` mapping. on: push: branches: [main] permissions: - contents: write + contents: read concurrency: group: semvertag @@ -33,5 +33,7 @@ jobs: with: fetch-depth: 0 - uses: ./ + with: + dry-run: true env: SEMVERTAG_BRANCH_PREFIX__MINOR: '["feat/"]' diff --git a/.github/workflows/tag-major.yml b/.github/workflows/tag-major.yml deleted file mode 100644 index 136577e..0000000 --- a/.github/workflows/tag-major.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: tag-major - -# Maintains the floating `v0` major tag so users can pin `uses: -# modern-python/semvertag@v0` and ride minor bumps. Skipped on -# prereleases so a `0.5.0-rc1` does not drag `v0` ahead of the latest -# stable. When 1.0.0 ships, this same job creates `v1` automatically -# from the tag name's leading segment. -# -# This project's release tags are bare semver (e.g. `0.4.0`, no `v`), -# but the floating action tag is `v`-prefixed (`v0`) to match the GHA -# convention for `uses: org/repo@vN`. The shell below strips any -# leading `v` from RELEASE_TAG and unconditionally prepends one to the -# major segment so the workflow works for both bare and v-prefixed -# release tag styles. - -on: - release: - types: [published] - -permissions: - contents: write - -jobs: - update-major-tag: - if: ${{ !github.event.release.prerelease }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Update major tag - env: - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - set -euo pipefail - # RELEASE_TAG = '0.4.0' (this project) or 'v0.4.0' (defensive) → major = 'v0' - raw="${RELEASE_TAG#v}" - major="v${raw%%.*}" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git tag -fa "$major" "$RELEASE_TAG" -m "Update $major to $RELEASE_TAG" - git push -f origin "$major" diff --git a/CLAUDE.md b/CLAUDE.md index 1546601..c95244f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,21 @@ excluded from the mkdocs site automatically). When superpowers skills default to `docs/superpowers/specs/` or `docs/superpowers/plans/`, use the change bundle under `planning/changes/` here instead. +**Cutting a release (maintainers)** is tag-driven via +[`.github/workflows/release.yml`](.github/workflows/release.yml): write the +notes at `planning/releases/.md` (used verbatim as the GitHub Release +body), then push a bare semver tag off green `main` — +`git tag 0.9.0 && git push origin 0.9.0`. The workflow runs `just publish` (the +tag sets the version via `uv version $GITHUB_REF_NAME`; no `pyproject.toml` +bump) to PyPI, then creates the GitHub Release, then floats the `v0` action tag +— PyPI first, so a failed publish creates no Release. Pre-releases use the +PEP 440 form (`0.9.0rc1`, not `0.9.0-rc1`). PyPI is irreversible; there is no +CI gate (a tag is the commitment point). The dogfood (`semvertag.yml`) runs in +dry-run and never auto-tags, so the tag you push is the only tag. If `just +publish` succeeds but a later step fails (Release or `v0`), do **not** re-push +the tag — PyPI rejects re-uploading an existing version. Create the GitHub +Release and move `v0` by hand, or cut a new patch tag. + ## Commit messages Imperative present-tense, scoped where helpful: @@ -95,8 +110,9 @@ Two distinct tag conventions coexist — confusing them is easy: strategy emits bare-semver tags by default; release URLs are `releases/tag/0.4.0`. When touching the CLI / `Justfile` / publish flow, think bare semver — `$GITHUB_REF_NAME` is `0.4.0`, not `v0.4.0`. -- **Action floating tag: `v`-prefixed** (`v0`). `.github/workflows/tag-major.yml` - strips any leading `v` from the release tag then prepends `v` to the major - segment (`0.4.0` → `v0`), so consumers can pin `uses: modern-python/semvertag@v0` - per the GHA ecosystem convention. When touching `tag-major.yml` or +- **Action floating tag: `v`-prefixed** (`v0`). The `Float major tag` step in + [`.github/workflows/release.yml`](.github/workflows/release.yml) prepends `v` + to the release tag's major segment (`0.4.0` → `v0`) and force-updates the + floating tag, so consumers can pin `uses: modern-python/semvertag@v0` per the + GHA ecosystem convention. Skipped on pre-releases. When touching that step or action-consumer docs, think `v`-prefix. diff --git a/docs/contributing/release.md b/docs/contributing/release.md deleted file mode 100644 index 5376c05..0000000 --- a/docs/contributing/release.md +++ /dev/null @@ -1,121 +0,0 @@ -# Release runbook - -This document describes how to cut a new `semvertag` release to PyPI via the -trusted-publishing pipeline at `.github/workflows/publish.yml`. The pipeline -exchanges a GitHub OIDC token with PyPI — there is no long-lived `PYPI_TOKEN` -in the repo's secrets, and no maintainer needs PyPI credentials on a laptop -to ship a release (NFR13). - -## One-time setup (already done; documented for posterity) - -The trusted-publisher binding between this repo and PyPI is configured once, -before the first release. If this is being repeated (account migration, fork, -or recovery), all four fields below MUST match the workflow exactly. - -- **PyPI side:** project page → Publishing → Add a trusted publisher - - Owner: `modern-python` - - Repository: `semvertag` - - Workflow filename: `publish.yml` - - Environment name: `pypi` - -- **GitHub side:** Settings → Environments → New environment, name: `pypi` - - Recommended for v1.0 and beyond: enable **required reviewers** so the - publish step waits for a human "Approve" before deploying to the `pypi` - environment. The OIDC token is only issued after approval, which adds a - human gate on top of the tag-guard step. - - No environment secrets are required — trusted publishing does not use any. - -## Cutting a release (≤5 minutes, target NFR13 + 5-min-from-merge-to-PyPI) - -> The ≤5min budget covers the automated path (release-prep PR merge → -> `publish.yml` finishes uploading to PyPI). It does **not** include any -> human-approval wait when the `pypi` Environment is configured with -> required reviewers — that gate is unbounded and unaffected by this SLO. - -1. Land all PRs intended for the release on `main`. Verify CI is green - (`lint`, `pytest` matrix, `pip-audit`). -2. On GitHub → Releases → **Draft a new release**: - - Tag: `v` (strict SemVer 2.0; no leading zeros; e.g. `v0.1.0`). - - Title: `v`. - - Body: one-line release note, or use GitHub's auto-generated notes. - - Click **Publish release**. - - > `[project.version]` in `pyproject.toml` stays `"0"` as a placeholder — - > no version bump is needed before tagging. The publish workflow calls - > `uv version $TAG` at build time to inject the real version. - -3. The `publish.yml` workflow auto-fires on `release: published`: - - Validates the tag as strict SemVer 2.0 and strips the leading `v`. - - Runs `uv version $TAG` to stamp `pyproject.toml` with the release version. - - Runs `uv build` → produces wheel + sdist in `dist/`. - - Runs `uv publish` → uv detects the GitHub Actions OIDC environment, - exchanges the token with PyPI, and uploads the wheel + sdist (plus any - PEP 740 attestations found alongside the dist files). -4. Verify on that the new version is - listed and the wheel + sdist are downloadable. - -## v1.0 (and any subsequent major) pre-release gate - -**Do not release v1.0 or any subsequent major release without first:** - -- Re-running Story 4.8's shadow-mode parity validation against - `raif-autosemver` in `pypelines` for the current `main` HEAD: ≥2 weeks, - 100% byte-identical tag outcomes per **NFR9**. -- Recording the parity sign-off in the GitHub release notes (or linking from - them to a permanent gist / release-asset artifact). - -This gate is non-negotiable per the Epic 4 spec for Story 4.2 and PRD NFR9. -A release that cannot demonstrate the parity sign-off MUST be blocked. - -## Manual / emergency re-runs - -The `publish.yml` workflow also accepts a `workflow_dispatch` event with a -single input, `tag`, used in lieu of `github.event.release.tag_name` for the -version-guard check. This is intentionally narrow: - -- Use it to re-run a publish that failed at the upload step (e.g. transient - PyPI 503) without recreating the GitHub release. -- Do NOT use it for first-time publishes — go through the GitHub Release UI - so the tag, release notes, and changelog all land at the same git commit. - -## Troubleshooting - -- **"Effective tag 'X' is not strict SemVer 2.0"** — the tag doesn't match - the guard's regex (`MAJOR.MINOR.PATCH` with optional dot-separated - `-prerelease` and `+build` identifiers per SemVer §9/§10; no leading zeros - in any numeric identifier; no empty identifiers). Examples that fail: - `1.0`, `01.0.0`, `1.0.0-`, `1.0.0+`, `1.0.0-01`, `1.0.0-foo..bar`. - Examples that pass: `1.0.0`, `0.1.0`. - - > **Caveat for pre-release / build-metadata tags:** the guard validates - > SemVer 2.0, but PyPI enforces PEP 440. SemVer-valid forms like - > `1.0.0-rc.1` and `1.0.0+build.123` will pass the guard and then be - > rejected by `uv build` (PEP 440 normalization) or `uv publish` (PyPI - > rejects `+local` versions on public uploads). Until the workflow's tag - > language is aligned to PEP 440, stick to plain `MAJOR.MINOR.PATCH` tags - > for every release. - -- **OIDC token exchange fails on `uv publish`** — usually a setup mismatch. - Verify on PyPI: Owner = `modern-python`, Repository = `semvertag`, Workflow - filename = `publish.yml`, Environment name = `pypi`. All four MUST match - the workflow byte-equal. If any differs, the OIDC subject claim won't - match PyPI's binding and the token exchange is refused. - -- **PyPI rejects attestations** — workaround: temporarily add - `--no-attestations` to the `uv publish` invocation in `publish.yml`. This - is an emergency lever, not a default; the underlying attestation rejection - is a PyPI-side regression that should be reported upstream. - -- **`uv publish` fails partway (e.g. sdist uploaded, wheel did not)** — PyPI - rejects re-upload of an already-uploaded filename, even byte-identical. - A `workflow_dispatch` retry of the same tag will hit HTTP 400 on the - already-present sdist. Recovery: cut a new patch release (e.g. `1.0.0` → - `1.0.1`) and re-tag. Do NOT delete the partial PyPI artifact — PyPI does - not permit re-uploading the same version even after deletion. - -- **"Trusted publisher not configured"** from PyPI — the one-time setup at - the top of this document hasn't been done, or the PyPI project doesn't - yet exist. The first publish creates the PyPI project record; before that - point the trusted-publisher config can be set up as a "pending publisher" - (PyPI → Your Projects → Publishing → Add a pending publisher). Once the - first publish lands, the binding becomes a regular trusted publisher. diff --git a/docs/index.md b/docs/index.md index 4bd91f1..4627729 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,8 +51,3 @@ semvertag ships with two bump-decision strategies: Both strategies are configurable via environment variables — see the strategy pages for the full configuration surface. - -## Contributing - -- [Release runbook](contributing/release.md) — for maintainers cutting - a new release of semvertag itself. diff --git a/mkdocs.yml b/mkdocs.yml index 25d09e5..331c9dc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,8 +11,6 @@ nav: - Strategies: - Branch prefix: strategies/branch-prefix.md - Conventional commits: strategies/conventional-commits.md - - Contributing: - - Release runbook: contributing/release.md theme: name: material diff --git a/planning/changes/2026-06-25.01-tag-driven-release/design.md b/planning/changes/2026-06-25.01-tag-driven-release/design.md new file mode 100644 index 0000000..c039a1b --- /dev/null +++ b/planning/changes/2026-06-25.01-tag-driven-release/design.md @@ -0,0 +1,193 @@ +--- +status: shipped +date: 2026-06-25 +slug: tag-driven-release +summary: Tag-driven release.yml (PyPI + Release + v0); dogfood goes dry-run; publish.yml/tag-major.yml deleted. +supersedes: null +superseded_by: null +pr: 35 +outcome: | + Shipped a tag-driven release.yml (PyPI -> GitHub Release -> v0 float) replacing + publish.yml + tag-major.yml; semvertag.yml dogfood runs dry-run so a hand-pushed + bare semver tag is the sole release entry point. Maintainer runbook moved to + CLAUDE.md; stale docs/contributing/release.md removed. +--- + +# Design: Tag-driven release for semvertag + +## Summary + +Replace the manual GitHub-Release-creation gate with a single tag-driven +`release.yml`, adapting the change `modern-di` shipped in #233/#235. Pushing a +bare semver tag publishes to PyPI (version from the tag), creates the matching +GitHub Release (body from `planning/releases/.md` when present), and floats +the `v0` action tag — all in one job, PyPI first. To keep the tag the *sole, +deliberate* release entry point, the self-dogfooding auto-tagger +(`semvertag.yml`) switches to dry-run so it stops pushing tags. `publish.yml` +and `tag-major.yml` are deleted (the latter folded into `release.yml`), and the +maintainer-only release runbook moves out of the user-facing docs site into +`CLAUDE.md`. + +## Motivation + +`modern-di` collapsed its two-step release (push tag → manually draft a GitHub +Release to trigger publish) into one tag-driven workflow. We want the same +ergonomics here. But the adaptation is non-trivial, because semvertag +**dogfoods its own auto-tagger**: `semvertag.yml` auto-creates and pushes the +version tag on every qualifying merge to `main`, and today the *manual GitHub +Release* — not the tag — is the deliberate publish gate. + +Two facts force the design: + +1. **GitHub suppresses workflow triggers from `GITHUB_TOKEN`-pushed refs** + (anti-recursion). The dogfood pushes tags with `GITHUB_TOKEN`, so a naïve + `on: push: tags` trigger would never fire on auto-tags — releases would + silently never publish. The same rule is why the existing `tag-major.yml` + (`on: release: published`) would stop firing once `release.yml` creates the + Release with `GITHUB_TOKEN`. +2. A human `git push` of a tag is **not** suppressed. So a maintainer-pushed + tag is a clean, reliable entry point — *if* nothing else is pushing tags. + +The chosen model makes the manual tag the only tag: the dogfood goes dry-run +(it still exercises `action.yml` on every push via `--dry-run`, just stops +pushing), and the maintainer pushes the release tag by hand. + +Separately, `docs/contributing/release.md` is already stale — it documents OIDC +trusted-publishing, a tag-guard, `workflow_dispatch`, and `v`-prefixed tags, +none of which match the real `publish.yml` (which uses a `PYPI_TOKEN` secret and +bare semver). It centers on `publish.yml`, the file this change deletes. + +## Non-goals + +- Continuous/auto release on every merge (rejected: release history is + deliberate, each with a hand-written `planning/releases/.md`). +- Migrating PyPI auth to OIDC trusted publishing — the repo uses a `PYPI_TOKEN` + secret and that is unchanged here. +- Rewriting historical `planning/releases/*.md` — they record how past releases + actually happened and stay as-is. +- Changing the `just publish` recipe — it already derives the version from + `$GITHUB_REF_NAME` and works unchanged on a tag push. + +## Design + +### 1. `.github/workflows/release.yml` (new) + +Adapted verbatim from `modern-di`'s canonical `release.yml`, plus a folded-in +`Float major tag` step replacing `tag-major.yml`. + +- **Trigger**: `on: push: tags` with two patterns — stable + `[0-9]+.[0-9]+.[0-9]+` (e.g. `0.9.0`) and PEP 440 pre-release + `[0-9]+.[0-9]+.[0-9]+[a-z]+[0-9]+` (e.g. `0.9.0rc1`). Bare semver, matching + this repo's release-tag convention. +- **`permissions: contents: write`** — needed for `action-gh-release` and the + `v0` force-push. +- **Steps**: + 1. `actions/checkout@v6`, `extractions/setup-just@v4`, `astral-sh/setup-uv@v7` + (the `@v7` pin matches the org canonical template adopted in `modern-di` + #235). + 2. `just publish` with `PYPI_TOKEN` — **runs first** because PyPI is + irreversible; a failed publish stops the job before any Release is created. + `just publish` stamps the version from `$GITHUB_REF_NAME`. + 3. `meta` step: if `planning/releases/.md` exists, use it as `body_path` + (verbatim, no auto-changelog) and set `generate_notes=false`; else + `generate_notes=true`. `prerelease=true` when the tag contains a letter. + 4. `softprops/action-gh-release@v3` creates the Release + (`body_path` / `generate_release_notes` / `prerelease` / `draft: false`). + 5. **`Float major tag`** (folded-in `tag-major`), `if: + steps.meta.outputs.prerelease == 'false'`: + + ```bash + major="v${GITHUB_REF_NAME%%.*}" # 0.9.0 -> v0 + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git tag -fa "$major" -m "Update $major to $GITHUB_REF_NAME" + git push -f origin "$major" + ``` + + References `HEAD` (the tag commit), so no `fetch-depth: 0` is required, and + reuses the `meta` prerelease flag instead of `tag-major`'s old + `github.event.release.prerelease`. + +Ordering rationale: PyPI (irreversible) → GitHub Release (user-facing artifact) +→ `v0` (a convenience floating tag, last because it depends on nothing). + +### 2. Deletions + +- **Delete `.github/workflows/publish.yml`** — replaced by `release.yml`. Its + `on: release: published` trigger is removed so the Release that `release.yml` + now creates cannot re-fire a publish (double-publish). +- **Delete `.github/workflows/tag-major.yml`** — folded into `release.yml` + step 5. (Keeping it separate on `release: published` would break: a Release + created by `release.yml` with `GITHUB_TOKEN` does not fire it.) + +### 3. `.github/workflows/semvertag.yml` — dogfood to dry-run + +- Add `with: { dry-run: true }` to the `uses: ./` step. `action.yml` already + supports the `dry-run` input (`--dry-run`), so the dogfood still exercises the + action on every push to `main` but never pushes a tag. +- Rewrite the header comment: it no longer "creates a tag"; it computes the + planned bump only. Releases are cut by a manual tag push that triggers + `release.yml`. +- Downgrade `permissions:` from `contents: write` to `contents: read` — dry-run + never writes. + +### 4. Docs & maintainer guide + +- **Delete `docs/contributing/release.md`** and remove its mkdocs nav entry (the + whole `Contributing:` section — it is the only page under that node). The + release process is maintainer-only and does not belong in the user-facing + docs site (mirrors `modern-di`, which keeps it out of contributor docs). This + also retires the file's pre-existing OIDC/tag-guard staleness. +- **`CLAUDE.md`**: + - Add a "Cutting a release (maintainers)" note to the Workflow section: write + `planning/releases/.md`, push a bare semver tag off green `main` + (`git tag 0.9.0 && git push origin 0.9.0`); `release.yml` publishes to PyPI + (version from the tag), creates the GitHub Release, and floats `v0`. + Pre-releases use PEP 440 (`0.9.0rc1`). PyPI is irreversible; the tag is the + commitment point (no CI gate — a tag is cut off green `main`). Note the + dogfood is dry-run, so no auto-tags are created. + - Update the "Tag and release naming" section: the `v0` paragraph currently + points at `.github/workflows/tag-major.yml`; repoint it at `release.yml`'s + `Float major tag` step. + +## Operations + +None out-of-repo. The `PYPI_TOKEN` secret already exists (used by the current +`publish.yml`); `release.yml` reuses it under the same name. + +## Out of scope + +See Non-goals. + +## Testing + +These are CI YAML and docs changes; the 100%-branch pytest gate is Python-only +and does not apply. Verification gate before completion: + +- `python -c "import yaml, sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"` + on `release.yml`, `semvertag.yml` — both parse. +- Confirm `publish.yml` and `tag-major.yml` are gone and nothing references them + (`grep -rn` over `.github/`, `mkdocs.yml`, `CLAUDE.md`, `docs/`). +- `just lint-ci`. +- `just docs-build` (`mkdocs build --strict`) — proves the nav no longer + references the deleted `contributing/release.md`. + +The true integration test is the next real release (the maintainer pushes a tag +and watches `release.yml` go green: PyPI upload, Release created from the notes +file, `v0` repointed). Called out as the post-merge step in `plan.md`. + +## Risk + +- **Auto-tags silently stop (intended), but a maintainer forgets the dogfood is + now dry-run** and waits for an auto-tag that never comes. *Likelihood: low · + Impact: low.* Mitigated by the `CLAUDE.md` release note and the rewritten + `semvertag.yml` header comment. +- **A future change re-points the dogfood push at a PAT**, resurrecting + auto-tags that would then auto-publish. *Likelihood: low · Impact: high.* The + header comment documents the dry-run-is-load-bearing invariant; the + `release.yml` trigger comment notes the manual-tag-only contract. +- **First post-migration release double-publishes** if the old + `release: published` path somehow lingers. *Likelihood: very low · Impact: + high.* Mitigated by deleting `publish.yml` outright (no overlap window). +- **`planning/releases/.md` missing at release time** → `release.yml` + falls back to GitHub generated notes (graceful, not a failure). diff --git a/planning/changes/2026-06-25.01-tag-driven-release/plan.md b/planning/changes/2026-06-25.01-tag-driven-release/plan.md new file mode 100644 index 0000000..aa0f5eb --- /dev/null +++ b/planning/changes/2026-06-25.01-tag-driven-release/plan.md @@ -0,0 +1,394 @@ +--- +status: shipped +date: 2026-06-25 +slug: tag-driven-release +spec: tag-driven-release +pr: 35 +--- + +# tag-driven-release — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a hand-pushed bare semver tag the sole release entry point — +one `release.yml` publishes to PyPI, creates the GitHub Release, and floats +`v0` — and stop the dogfood from auto-tagging. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `ci/tag-driven-release` + +**Commit strategy:** Per-task commits; single PR; squash on merge. + +## Global constraints (copy verbatim, every task) + +- **Release tags are bare semver, no `v` prefix** (`0.9.0`, not `v0.9.0`). + `$GITHUB_REF_NAME` is the bare tag. +- **Action pins:** `actions/checkout@v6`, `extractions/setup-just@v4`, + `astral-sh/setup-uv@v7` (the `@v7` pin matches the org canonical template). +- **PyPI is irreversible → it runs first.** Nothing user-facing (Release, `v0`) + is created before `just publish` succeeds. +- **PyPI auth is the existing `PYPI_TOKEN` secret** (not OIDC). Reused under the + same name. +- **Pre-release tags use PEP 440** (`0.9.0rc1`), detected by a letter in the tag. +- These are CI/docs YAML + Markdown changes; the 100%-branch pytest gate is + Python-only and does not apply. Verification is YAML-parse + grep + lint + + strict docs build. +- Commit messages: imperative, scoped (`ci:` / `docs:`), no story prefixes; end + with `Co-Authored-By: Claude Opus 4.8 (1M context) `. + +--- + +### Task 1: Add tag-driven `release.yml`; delete `publish.yml` and `tag-major.yml` + +**Files:** +- Create: `.github/workflows/release.yml` +- Delete: `.github/workflows/publish.yml` +- Delete: `.github/workflows/tag-major.yml` + +Replace the manual `release: published` publish gate and the separate +`tag-major` workflow with one tag-driven workflow. The `v0` float logic from +`tag-major.yml` is folded into `release.yml`'s final step. + +- [ ] **Step 1: Create `.github/workflows/release.yml`** with exactly this content: + + ```yaml + name: Release + + # Tag-driven: pushing a bare semver tag publishes to PyPI, creates the matching + # GitHub Release, and floats the `v0` action tag. Replaces the old + # `release: published` publish.yml (deleted) and folds in tag-major.yml (deleted). + # + # The tag is the sole, deliberate entry point. semvertag.yml dogfoods in + # DRY-RUN, so it never pushes a tag — only a maintainer's manual `git push` of + # a tag reaches here (GitHub suppresses workflow triggers from + # GITHUB_TOKEN-pushed refs, so even an auto-push would not fire this). By + # convention a tag is cut only off green main, so there is no in-workflow CI gate. + on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' # stable: 0.9.0 + - '[0-9]+.[0-9]+.[0-9]+[a-z]+[0-9]+' # pre-release: 0.9.0rc1, 1.0.0a2 + + # Needed for softprops/action-gh-release to create the Release and for the + # v0 force-push. + permissions: + contents: write + + jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: extractions/setup-just@v4 + - uses: astral-sh/setup-uv@v7 + + # PyPI is irreversible, so it runs FIRST: if it fails the job stops and no + # GitHub Release or v0 move advertises a version that never reached PyPI. + # `just publish` derives the version from $GITHUB_REF_NAME (the tag name). + - run: just publish + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + + # Description source: planning/releases/.md if present (verbatim, no + # auto-changelog appended); otherwise GitHub's generated notes. A tag with + # a letter (0.9.0rc1) is a pre-release -> flagged so GitHub won't mark it + # "Latest" and so the v0 float below is skipped. + - name: Resolve release metadata + id: meta + run: | + set -euo pipefail + notes="planning/releases/${GITHUB_REF_NAME}.md" + if [ -f "$notes" ]; then + echo "body_path=$notes" >> "$GITHUB_OUTPUT" + echo "generate_notes=false" >> "$GITHUB_OUTPUT" + else + echo "generate_notes=true" >> "$GITHUB_OUTPUT" + fi + if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v3 + with: + body_path: ${{ steps.meta.outputs.body_path }} + generate_release_notes: ${{ steps.meta.outputs.generate_notes }} + prerelease: ${{ steps.meta.outputs.prerelease }} + draft: false + + # Floating major tag (folded-in tag-major.yml): consumers pin + # `uses: modern-python/semvertag@v0` and ride minor bumps. Skipped on + # pre-releases so a 0.9.0rc1 doesn't drag v0 ahead of the latest stable. + # References HEAD (the tag commit), so no fetch-depth: 0 is needed. + - name: Float major tag + if: steps.meta.outputs.prerelease == 'false' + run: | + set -euo pipefail + major="v${GITHUB_REF_NAME%%.*}" # 0.9.0 -> v0 + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git tag -fa "$major" -m "Update $major to $GITHUB_REF_NAME" + git push -f origin "$major" + ``` + +- [ ] **Step 2: Delete the two superseded workflows** + + ```bash + git rm .github/workflows/publish.yml .github/workflows/tag-major.yml + ``` + +- [ ] **Step 3: Verify `release.yml` parses as YAML** + + Run: + ```bash + python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))" && echo OK + ``` + Expected: `OK` + +- [ ] **Step 4: Verify the deletions are clean and nothing references them** + + Run (scoped to live config — `planning/` keeps its historical references on purpose): + ```bash + ls .github/workflows/ + grep -rn 'publish\.yml\|tag-major' .github/ mkdocs.yml docs/ || echo CLEAN + ``` + Expected: `publish.yml` and `tag-major.yml` absent from the listing. The grep + still prints one line — `semvertag.yml`'s stale `does NOT trigger publish.yml` + comment — which Task 2 rewrites. That single hit is expected here; do not fix + it in this task. No `tag-major` hits, no hits in `mkdocs.yml`/`docs/`. + +- [ ] **Step 5: Commit** + + ```bash + git add .github/workflows/release.yml + git commit -m "ci: replace publish.yml + tag-major.yml with tag-driven release.yml + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 2: Switch the dogfood (`semvertag.yml`) to dry-run + +**Files:** +- Modify: `.github/workflows/semvertag.yml` + +The auto-tagger must stop pushing tags, so the only tags that reach the repo are +deliberate, maintainer-pushed release tags. It still exercises `action.yml` on +every push to `main` via `--dry-run`. + +- [ ] **Step 1: Replace the entire contents of `.github/workflows/semvertag.yml`** with: + + ```yaml + name: semvertag + + # Dogfood the local composite action against this repo in DRY-RUN: on every + # push to main it computes the planned bump (exercising action.yml + the + # published semvertag) but never pushes a tag. This keeps action.yml honest — + # a breaking change fails the run before it can affect external users. + # + # Releases are NOT cut here. A release is a maintainer pushing a bare semver + # tag by hand, which triggers .github/workflows/release.yml (PyPI + GitHub + # Release + v0). Dry-run is load-bearing: it guarantees the only tags in the + # repo are deliberate release tags — do not give this job a push token or + # remove `dry-run: true`. + # + # This repo's branch convention uses `feat/...`, so SEMVERTAG_BRANCH_PREFIX__MINOR + # overrides the default `feature/` mapping. + + on: + push: + branches: [main] + + permissions: + contents: read + + concurrency: + group: semvertag + cancel-in-progress: false + + jobs: + tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: ./ + with: + dry-run: true + env: + SEMVERTAG_BRANCH_PREFIX__MINOR: '["feat/"]' + ``` + +- [ ] **Step 2: Verify it parses and the dry-run wiring is present** + + Run: + ```bash + python3 -c "import yaml; yaml.safe_load(open('.github/workflows/semvertag.yml'))" && echo OK + grep -n 'dry-run: true\|contents: read' .github/workflows/semvertag.yml + ``` + Expected: `OK`, and both `dry-run: true` and `contents: read` print. + +- [ ] **Step 3: Commit** + + ```bash + git add .github/workflows/semvertag.yml + git commit -m "ci: run the semvertag dogfood in dry-run (stop auto-tagging) + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 3: Move the release runbook out of user-facing docs + +**Files:** +- Delete: `docs/contributing/release.md` +- Modify: `mkdocs.yml` (remove the `Contributing:` nav node) +- Modify: `CLAUDE.md` (add maintainer release note; repoint the `v0` paragraph) + +The release process is maintainer-only and should not ship in the docs site. It +lives in `CLAUDE.md` instead. `release.md` is the only page under +`docs/contributing/`, so its whole nav node goes too. + +- [ ] **Step 1: Delete the runbook page** + + ```bash + git rm docs/contributing/release.md + ``` + +- [ ] **Step 2: Remove the `Contributing:` nav node from `mkdocs.yml`** + + Delete these two lines (currently lines 14–15): + ```yaml + - Contributing: + - Release runbook: contributing/release.md + ``` + After the edit, the `nav:` block ends with the `Strategies:` node + (`Conventional commits: strategies/conventional-commits.md`). + +- [ ] **Step 3: Add the maintainer release note to `CLAUDE.md`** + + In the `## Workflow` section, immediately after the paragraph that ends + "...use the change bundle under `planning/changes/` here instead." (the + "Planning artifacts live under `planning/`..." paragraph), insert a blank line + then this paragraph: + + ```markdown + **Cutting a release (maintainers)** is tag-driven via + [`.github/workflows/release.yml`](.github/workflows/release.yml): write the + notes at `planning/releases/.md` (used verbatim as the GitHub Release + body), then push a bare semver tag off green `main` — + `git tag 0.9.0 && git push origin 0.9.0`. The workflow runs `just publish` (the + tag sets the version via `uv version $GITHUB_REF_NAME`; no `pyproject.toml` + bump) to PyPI, then creates the GitHub Release, then floats the `v0` action tag + — PyPI first, so a failed publish creates no Release. Pre-releases use the + PEP 440 form (`0.9.0rc1`, not `0.9.0-rc1`). PyPI is irreversible; there is no + CI gate (a tag is the commitment point). The dogfood (`semvertag.yml`) runs in + dry-run and never auto-tags, so the tag you push is the only tag. + ``` + +- [ ] **Step 4: Repoint the `v0` paragraph in the "Tag and release naming" section** + + In `CLAUDE.md`, replace the second bullet (currently referencing + `.github/workflows/tag-major.yml`): + + ```markdown + - **Action floating tag: `v`-prefixed** (`v0`). `.github/workflows/tag-major.yml` + strips any leading `v` from the release tag then prepends `v` to the major + segment (`0.4.0` → `v0`), so consumers can pin `uses: modern-python/semvertag@v0` + per the GHA ecosystem convention. When touching `tag-major.yml` or + action-consumer docs, think `v`-prefix. + ``` + + with: + + ```markdown + - **Action floating tag: `v`-prefixed** (`v0`). The `Float major tag` step in + [`.github/workflows/release.yml`](.github/workflows/release.yml) prepends `v` + to the release tag's major segment (`0.4.0` → `v0`) and force-updates the + floating tag, so consumers can pin `uses: modern-python/semvertag@v0` per the + GHA ecosystem convention. Skipped on pre-releases. When touching that step or + action-consumer docs, think `v`-prefix. + ``` + +- [ ] **Step 5: Verify the docs build strictly and no stale refs remain** + + Run: + ```bash + grep -rn 'tag-major\|publish\.yml\|contributing/release' mkdocs.yml CLAUDE.md docs/ || echo CLEAN + just docs-build + ``` + Expected: grep prints `CLEAN`; `mkdocs build --strict` succeeds (proves the nav + no longer points at the deleted page and there are no dead links to it). + +- [ ] **Step 6: Commit** + + ```bash + git add docs/contributing/release.md mkdocs.yml CLAUDE.md + git commit -m "docs: move the release runbook into CLAUDE.md (maintainer-only) + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 4: Ship-time bookkeeping + +**Files:** +- Modify: `planning/changes/2026-06-25.01-tag-driven-release/design.md` (frontmatter) +- Modify: `planning/changes/2026-06-25.01-tag-driven-release/plan.md` (frontmatter) + +The implementing PR flips the bundle to `shipped` per the project convention. +No `architecture/` promotion — the release flow is not one of the capability +files (`strategies.md` / `providers.md` / `cli.md`). + +- [ ] **Step 1: Run the full verification gate once more** + + Run: + ```bash + python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml')); yaml.safe_load(open('.github/workflows/semvertag.yml'))" && echo YAML-OK + just lint-ci + just docs-build + ``` + Expected: `YAML-OK`, `lint-ci` clean, strict docs build succeeds. + +- [ ] **Step 2: Set `status: shipped` and fill `pr` in both frontmatters** + + In `design.md`: `status: draft` → `status: shipped`, and set `pr:` to the PR + number/URL. In `plan.md`: set `pr:` likewise. (Do this once the PR exists.) + +- [ ] **Step 3: Regenerate the change index** + + ```bash + just index + ``` + +- [ ] **Step 4: Commit** + + ```bash + git add planning/changes/2026-06-25.01-tag-driven-release/ + git commit -m "planning: ship tag-driven release bundle + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Post-merge (maintainer, not a code task) + +The true integration test is the next real release. When this PR merges to +`main`, the merge commit already carries the dry-run `semvertag.yml`, so the +merge itself creates no auto-tag. To cut the first release on the new flow: + +1. Ensure `planning/releases/.md` exists for the target version. +2. Off green `main`: `git tag && git push origin `. +3. Watch `release.yml`: PyPI upload (version from the tag) → GitHub Release built + from the notes file → `v0` repointed at the tag commit.