From a28202e3a8768332df667832cd12707c8fa6822a Mon Sep 17 00:00:00 2001 From: "Fabian H." <73600109+teutoburg@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:33:48 +0200 Subject: [PATCH 1/4] Enhance version bump workflow with dev version support Updated version bump workflow to include 'dev' option and handle dev versioning logic. --- .github/workflows/bump.yml | 63 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index ba01ca0..0e24e48 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -11,7 +11,8 @@ on: description: Version bump level required: true options: - - prerelease + - dev + # NOTE: bare "prerelease" is deliberately absent: under the always-on-devN scheme it crashes (from X.Y.Z.devN, poetry-core AssertionError) or skips a number (from X.Y.ZbN.devM -> bN+1 instead of bN). Use an explicit version instead. - "prerelease --next-phase" - prepatch - preminor @@ -39,6 +40,12 @@ on: bumpmsg: description: Message created by the version bump. value: ${{ jobs.bump.outputs.bumpmsg }} + version: + description: New version number after the bump (empty on dry-run). + value: ${{ jobs.bump.outputs.version }} + sha: + description: SHA of the bump commit (empty on dry-run). + value: ${{ jobs.bump.outputs.sha }} jobs: bump: @@ -48,6 +55,8 @@ jobs: contents: write outputs: bumpmsg: ${{ steps.bumping.outputs.BUMP_MSG }} + version: ${{ steps.commit.outputs.VERSION }} + sha: ${{ steps.commit.outputs.SHA }} steps: - name: Write App private key to file @@ -102,12 +111,57 @@ jobs: with: python-version: "3.13" + - name: Install packaging + if: inputs.rule == 'dev' + shell: bash + run: pip install packaging + + - name: Compute next dev version + id: devver + if: inputs.rule == 'dev' + shell: python + run: | + import os, subprocess + from packaging.version import Version + + # Get current version from poetry + current = Version(subprocess.run( + "poetry version -s", + shell=True, + check=True, + text=True, + capture_output=True, + ).stdout.strip()) + + if current.dev is not None: + # Already on a dev version (with or without pre-release + # segment): increment the dev counter, keep everything else. + pre = f"{current.pre[0]}{current.pre[1]}" if current.pre else "" + nxt = f"{current.base_version}{pre}.dev{current.dev + 1}" + elif current.pre is not None: + # Freshly tagged pre-release (e.g. 0.12.0b0): start dev cycle + # towards the next tag in the same phase (0.12.0b1.dev0). + nxt = (f"{current.base_version}" + f"{current.pre[0]}{current.pre[1] + 1}.dev0") + else: + # Stable release: start dev cycle towards the next patch + # version. The actual release may still become minor/major, + # devN sorts below either, so guessing patch is always safe. + major, minor, patch = (*current.release, 0, 0)[:3] + nxt = f"{major}.{minor}.{patch + 1}.dev0" + + with open(os.environ.get("GITHUB_OUTPUT"), "a") as output: + output.write(f"NEXT={nxt}\n") + - name: Bump package Version using Poetry id: bumping env: - RULE: ${{ inputs.rule }} + RULE: ${{ inputs.rule == 'dev' && steps.devver.outputs.NEXT || inputs.rule }} DRY: ${{ inputs.dry-run && '--dry-run' || '' }} - run: echo "BUMP_MSG=$(poetry version $DRY $RULE)" >> $GITHUB_OUTPUT + # Assignment first, so a poetry failure fails this step (command substitution inside echo would swallow the exit code). + run: | + BUMP_MSG=$(poetry version $DRY $RULE) + echo "BUMP_MSG=$BUMP_MSG" >> $GITHUB_OUTPUT - name: Output debug msg if: inputs.dry-run @@ -118,6 +172,7 @@ jobs: echo "Dry run only, no actual modification made." >> $GITHUB_STEP_SUMMARY - name: Commit changes + id: commit # could also use https://github.com/stefanzweifel/git-auto-commit-action instead if: ${{ ! inputs.dry-run }} env: @@ -127,5 +182,7 @@ jobs: git config user.email "217837468+myciapp[bot]@users.noreply.github.com" git commit -am "$BUMP_MSG" git push + echo "VERSION=$(poetry version -s)" >> $GITHUB_OUTPUT + echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "## $BUMP_MSG" >> $GITHUB_STEP_SUMMARY echo "Successfully committed and pushed version bump." >> $GITHUB_STEP_SUMMARY From 26fe1357330763cdf399f9384895d0fe489d0799 Mon Sep 17 00:00:00 2001 From: "Fabian H." <73600109+teutoburg@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:37:41 +0200 Subject: [PATCH 2/4] Verify tag matches package version in workflow Added a verification step to ensure the release tag matches the package version before publishing to PyPI. --- .github/workflows/publish_pypi.yml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 4639762..3578c31 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -19,6 +19,16 @@ jobs: with: python-version: "3.13" + - name: Verify tag matches package version + env: + TAG: ${{ github.event.release.tag_name }} + run: | + PKGVER=$(poetry version -s) + if [ "${TAG#v}" != "$PKGVER" ]; then + echo "::error::Release tag $TAG does not match package version $PKGVER at the tagged commit." + exit 1 + fi + - name: Create step summary run: echo "## Building $(poetry version)" >> $GITHUB_STEP_SUMMARY @@ -45,7 +55,8 @@ jobs: name: Publish package to PyPI runs-on: ubuntu-latest needs: Build - if: needs.build.result == 'success' + # Internal pre-release tags (aN/bN/rcN) get built and attached to the GitHub release above, but are never published to PyPI. + if: ${{ needs.Build.result == 'success' && !github.event.release.prerelease }} steps: - name: Checkout code uses: actions/checkout@v6 @@ -81,12 +92,5 @@ jobs: SUBURL="${PKGVER// //}" echo "https://pypi.org/project/$SUBURL/" >> $GITHUB_STEP_SUMMARY - Bump: - name: Bump to (next) dev version - needs: Publish - if: ${{ needs.publish.result == 'success' }} - uses: ./.github/workflows/bump.yml - secrets: inherit - with: - rule: prerelease - branch: main + # NOTE: The former trailing "Bump to (next) dev version" job has moved to prepare_release.yml, which bumps to the next dev version + # immediately after creating the draft release, so the release version string never lingers on main while the draft awaits review. From 1a1dd044127aa9a86c2f90e1df864a4b833c8b8a Mon Sep 17 00:00:00 2001 From: "Fabian H." <73600109+teutoburg@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:54:46 +0200 Subject: [PATCH 3/4] Add version command reference This was created by Claude Fable 5 to provide a reference for the version scheme changes. --- version_command_reference.md | 165 +++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 version_command_reference.md diff --git a/version_command_reference.md b/version_command_reference.md new file mode 100644 index 0000000..c030af7 --- /dev/null +++ b/version_command_reference.md @@ -0,0 +1,165 @@ +# Version Bumping & Release Command Reference + +*Companion to the workflow model document. Records every version-bump situation, the exact commands involved, and why the CI scripts ended up the way they did. +Intended as onboarding material and as ground truth when the automation needs debugging.* + +All behavior below was verified empirically against **Poetry 2.3.0** (the version pinned in the CI workflows). Re-verify the rule matrix in Β§2 when that pin changes. + +## 1. Ground rules +- Version numbers change **only on `main`** (or a maintenance branch, Β§9), and in the normal case **only via CI** (bot commits). + Never bump versions on feature branches β€” this is what makes overlapping branches conflict-free. +- The repo is **always on a `.devN` version** between tags. + Every tag is immediately followed by a dev-bump commit; the tagged version string never lingers. +- Poetry has no `dev` bump rule. + The CI `dev` rule is our own: it computes the next dev version with `packaging` and passes it to `poetry version` as an explicit string (algorithm in Β§3). +- `poetry version ` accepts any valid PEP 440 string (verified: `0.11.5.dev0`, `0.12.0b0`, `1.0.0`, ...). + When no rule does what you need, an explicit version is always a legitimate escape hatch. + +## 2. Poetry rule matrix (empirical, Poetry 2.3.0) +What `poetry version ` produces from each phase our scheme visits: + +| from \ rule | `patch` | `minor` | `major` | `prerelease` | `prerelease --next-phase` | `preminor` | +|-------------------|----------|----------|---------|---------------|---------------------------|------------| +| `0.11.4` (stable) | `0.11.5` | `0.12.0` | `1.0.0` | `0.11.5a0` | `0.11.5a0` | `0.12.0a0` | +| `0.11.5.dev2` | `0.11.5` | `0.12.0` | `1.0.0` | πŸ’₯ crash | πŸ’₯ crash | `0.12.0a0` | +| `0.12.0b0` | `0.12.0` | `0.12.0` | `1.0.0` | `0.12.0b1` | `0.12.0rc0` | `0.12.0a0` | +| `0.12.0b1.dev0` | `0.12.0` | `0.12.0` | `1.0.0` | `0.12.0b2` ⚠️ | `0.12.0rc0` | `0.12.0a0` | +| `0.12.0rc1.dev0` | `0.12.0` | `0.12.0` | `1.0.0` | `0.12.0rc2` ⚠️ | `0.12.0` | `0.12.0a0` | + +Three findings that shaped the conventions below: + +1. **πŸ’₯ `prerelease` crashes from a plain dev version** (unhandled `AssertionError` in poetry-core 2.x). Entering the freeze phase from `X.Y.Z.devN` therefore cannot use `prerelease`; use `preminor` / `premajor` (lands on `a0`) or an explicit version (to land on `b0` directly, per team convention). +3. **⚠️ `prerelease` from a dev-on-pre version skips a number**: from `0.12.0b1.dev0` (i.e. "working towards b1") it yields `b2`, not `b1`, because Poetry increments the pre-counter without knowing our "dev points at the *next* tag" convention. + Tagging the version the repo is actually pointing at requires an explicit version (strip the `.devN`). +5. **Stable rules "complete" a pre-release instead of jumping past it** β€” with one asymmetry: from any `0.12.0*` pre/dev state, `patch` *and* `minor` both yield `0.12.0` (the minor-ness was already encoded when the freeze was entered via `preminor`), but **`major` does not complete β€” it jumps to `1.0.0`**. + Rule of thumb: the real bump decision is made when *entering* the freeze; the final release always uses `patch`. + +## 3. The `dev` rule (our addition) +Implemented in `DevOps/bump.yml`; the algorithm, for reference and manual reproduction: + +| current version | next dev version | reasoning | +|-----------------|------------------|-----------| +| `X.Y.Z` (just tagged stable) | `X.Y.(Z+1).dev0` | Guess patch: sorts below *any* possible next release, so always safe. The real bump is decided at release time. | +| `X.Y.Z
N` (just tagged pre-release, e.g. `0.12.0b0`) | `X.Y.Z
(N+1).dev0` (e.g. `0.12.0b1.dev0`) | Guess next tag in the same phase; sorts below both `b1` and `rc0`, so safe whichever comes. |
+| `...devN` (already dev) | `...dev(N+1)` | Plain counter increment, everything else preserved. |
+
+Manual equivalent (rarely needed; e.g. if CI is down):
+```bash
+poetry version -s                 # inspect current state
+poetry version 0.11.5.dev3        # explicit next version per the table above
+git commit -am "Bumping version from 0.11.5.dev2 to 0.11.5.dev3"
+git push                          # on main, with appropriate permissions
+```
+
+## 4. Situation: dependency PR merged into `main`
+**Trigger:** PR with the `dependencies` label merges.
+**Automation:** `bump_on_dependency.yml` β†’ `bump.yml` with `rule: dev`, fully automatic.
+
+Effect: `0.11.5.dev1 β†’ 0.11.5.dev2`.
+Purpose: "which version are you on?" has a useful answer when debugging cross-package dependency mismatches.
+Deliberately *not* run on every merged PR (history noise without unique identification; see workflow model Β§3).
+
+## 5. Situation: regular release from `main`
+**Trigger:** human decision.
+**Command:** run the **Prepare release** workflow (Actions UI) on `main` with rule `patch`, `minor`, or `major` β€” derived from the `minor`/`major` PR labels merged since the last tag (maximum wins; unlabelled = patch).
+
+Under the hood, three steps (this is the whole reason `prepare_release.yml` exists β€” they must happen together, in order):
+
+```bash
+poetry version minor              # 1. e.g. 0.11.5.dev4 -> 0.12.0; commit β‘ 
+# 2. draft GitHub release created, tag v0.12.0 pinned to commit β‘ 's SHA
+poetry version 0.12.1.dev0        # 3. dev rule; commit β‘‘ immediately after
+```
+
+Then: review the draft notes (link in the job summary), edit the manual section if desired, **publish**. Publishing creates the tag at the pinned SHA and triggers `publish_pypi.yml` (tag↔version guard β†’ build β†’ attach artifacts β†’ PyPI upload).
+
+Note from Β§2: these rules also work when releasing directly out of a freeze (`0.12.0rc1.dev0 + patch β†’ 0.12.0`) β€” see Β§7 for why `patch` is the right rule there.
+
+## 6. Situation: entering feature freeze
+**Trigger:** team decision to stop merging features for the next release.
+Team convention marks the freeze with a **`b0`** version.
+
+Because of gotcha Β§2.1 (`prerelease` crashes from dev) and because `preminor` lands on `a0` rather than `b0`, entering the freeze at `b0` requires an **explicit version**:
+
+```bash
+poetry version 0.12.0b0           # explicit; encodes the minor-ness of the release
+```
+
+⚠️ This is the moment the patch/minor/major decision is actually made β€” choose the base version accordingly (check the `minor`/`major` PR labels merged since the last tag). Everything after this point only completes to that base (Β§2.3).
+
+Whether the freeze point is *tagged* depends on the package:
+
+- **Packages with pooch-managed data** (or downstream packages needing a pinnable snapshot): run **Prepare release** with the explicit version in the `version` input (which overrides the rule choice).
+  The version is auto-detected as a pre-release: the GitHub release is marked "pre-release", PyPI upload is skipped, artifacts are still built and attached.
+  CI then bumps to `0.12.0b1.dev0` automatically.
+- **Everyone else:** a plain bump (no tag) suffices: run the **Bump** workflow with the explicit version in its `version` input, then bump to the dev version (rule `dev`), or do both locally.
+  If untagged, remember pooch-style tag lookups don't apply anyway.
+
+Note: the bare `prerelease` rule has been removed from all workflow choice lists on purpose β€” under the always-on-`.devN` scheme it either crashes (Β§2.1) or skips a number (Β§2.2), and its legitimate jobs are covered by the `dev` rule and explicit versions.
+
+## 7. Situation: during and out of the freeze
+**Tagging the next beta** (`b1` while on `0.12.0b1.dev0`): explicit version only β€” **Prepare release** with `version: 0.12.0b1` (or locally, `poetry version 0.12.0b1`).
+The bare `prerelease` rule would skip to `b2` (gotcha Β§2.2) and has been removed from the workflow menus.
+
+**Advancing the phase b β†’ rc:** `poetry version prerelease --next-phase` works correctly from dev-on-pre states (`0.12.0b1.dev0 β†’ 0.12.0rc0`) β€” this is why the rule remains in the workflow choice lists.
+On reaching `rc`, the **replaceholder** workflow becomes runnable (it refuses to run on non-rc versions by design): it substitutes `PLACEHOLDER_NEXT_RELEASE_VERSION` in source files with the upcoming stable version.
+This is also when packages with separate release notes integrate them.
+
+**Final release:** **Prepare release** with rule **`patch`** β€” it completes `0.12.0rc1.dev0 β†’ 0.12.0` (Β§2.3). Never use `major` here (it jumps to `1.0.0` instead of completing). Never return to plain `.devN` for a version that already has pre-release tags: `0.12.0.dev0` would sort *below* `0.12.0b0`, breaking ordering.
+
+## 8. Situation: first 1.0 (or any major) release
+Rule `major` via **Prepare release**, from any plain dev state: `0.11.5.dev4 + major β†’ 1.0.0`.
+(Explicit `poetry version 1.0.0` is equivalent.) If the 1.0 gets a freeze phase, enter it with `poetry version 1.0.0b0` explicitly (Β§6) and release with `patch` (Β§7).
+
+Remember the workflow-model implication: from 1.0 on, "breaking β‡’ major β‡’ rare", so the first `major`-labelled merge after a release is the moment the old series can no longer be released from `main` β€” pause and confirm the team is ready for that before merging.
+
+## 9. Situation: backport to a released series
+Only needed once a breaking change has landed on `main` (otherwise: just release `main` as the next minor).
+Fixes land on `main` **first**, then get cherry-picked out β€” never developed only on the maintenance branch.
+
+### 9a. Cutting the maintenance branch
+(once per major series, lazily, the day the first backport is needed)
+
+- [ ] Branch from the **last tag** of the series, not from `main`:
+      `git branch v2.x v2.1.0 && git push origin v2.x` (branching from a later `main` commit would smuggle unreleased feature work into a "critical patch")
+- [ ] Verify CI workflow triggers cover the branch (add `"v*.x"` to the `branches:` lists if not already glob-covered)
+- [ ] Repoint data-download fallbacks from `main` to `v2.x` (e.g. the pooch fallback-branch constant), so source installs from the branch fetch series-matching data
+- [ ] Note the support window for the old series in README/docs
+
+### 9b. The backport itself
+```bash
+# fix is merged into main as commit 
+git switch -c backport-fix v2.x
+git cherry-pick -x           # -x records the origin commit in the message
+# open PR against v2.x, let CI run, merge
+```
+
+### 9c. Releasing from the branch
+Run **Prepare release** *with `v2.x` selected in the branch dropdown*, rule `patch`, and set **`previous-tag`** to the last tag of the series (e.g. `v2.1.0`) β€” releases now happen out of version order, and without it GitHub may generate the notes against the wrong comparison base.
+Everything else is identical to Β§5: draft pinned to the branch commit, publish creates `v2.1.1`, PyPI accepts out-of-order uploads (users on `==2.1.*` get it; unconstrained users keep getting the newest release), and the branch is auto-bumped to `2.1.2.dev0`.
+
+Patches go only to the **newest** release of a series (2.1.1, never 2.0.2).
+
+### 9d. End of support
+Delete the branch when the window closes β€” every released state is preserved by its tag.
+
+## 10. One-time migration (transitional, delete this section later)
+Repos still on the legacy pseudo-alpha scheme (e.g. `0.11.5a1`) transition by running the **Bump** workflow once with rule `dev`, yielding `0.11.5a2.dev0`.
+Not pretty, but it sorts above everything already installed from `main` β€” unlike a manual reset to `0.11.5.dev0`, which would sort *below* the `a1` builds β€” and it vanishes at the next release.
+
+## Quick reference
+
+| Situation                   | Command / action                                                                          |
+|-----------------------------|-------------------------------------------------------------------------------------------|
+| Dependency PR merged        | automatic (`dev` rule)                                                                    |
+| Manual dev bump             | Bump workflow, rule `dev`                                                                 |
+| Regular release             | Prepare release, rule `patch`/`minor`/`major` (labels decide)                             |
+| Enter freeze at `b0`        | Prepare release (tagged) or Bump (untagged), explicit `version: 0.12.0b0` (Β§6)            |
+| Tag next beta from `bN.dev` | Prepare release, explicit `version: 0.12.0b1` (**not** `prerelease` β€” removed from menus) |
+| Beta β†’ release candidate    | rule `prerelease --next-phase`                                                            |
+| Replace docs placeholders   | replaceholder workflow (requires rc)                                                      |
+| Release out of freeze       | Prepare release, rule `patch` (**never** `major`)                                         |
+| First 1.0                   | Prepare release, rule `major`                                                             |
+| Cut maintenance branch      | checklist Β§9a                                                                             |
+| Backport fix                | merge to `main`, `git cherry-pick -x` onto `v2.x` PR                                      |
+| Backport release            | Prepare release from `v2.x`, rule `patch`, set `previous-tag`                             |

From 04758ea407dca1232ca6e50a03a9c37ec136c31a Mon Sep 17 00:00:00 2001
From: "Fabian H." <73600109+teutoburg@users.noreply.github.com>
Date: Mon, 20 Jul 2026 16:01:45 +0200
Subject: [PATCH 4/4] Add workflow to prepare releases and check version

This workflow automates the process of preparing a release by bumping the package version, creating a draft GitHub release, and then bumping the version to the next development version.
---
 .github/workflows/prepare_release.yml | 137 ++++++++++++++++++++++++++
 1 file changed, 137 insertions(+)
 create mode 100644 .github/workflows/prepare_release.yml

diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml
new file mode 100644
index 0000000..ca1f69c
--- /dev/null
+++ b/.github/workflows/prepare_release.yml
@@ -0,0 +1,137 @@
+name: Prepare release
+
+# Performs the version-bump / draft-release / dev-bump dance:
+#   1. Bump the package version to the release version (given rule).
+#   2. Create a DRAFT GitHub release whose tag is pinned to the SHA of that bump commit.
+#      The tag itself is only created by GitHub when a human reviews the (auto-generated + manual) notes and publishes the draft, which then triggers the publish_pypi workflow as before.
+#   3. Immediately bump the version to the next dev version, so the release version string never lingers on the branch, even while the draft awaits review.
+
+# Works for both stable releases (rule: patch/minor/major) and internal pre-release tags (rule: preminor/prerelease/...).
+# Pre-release versions are automatically marked as "pre-release" on the GitHub release, which the publish workflow uses to skip the PyPI upload.
+# Also works for backport releases: dispatch the workflow from a maintenance branch (e.g. v2.x) with rule "patch" and set previous-tag to the last tag of that series so the generated notes compare against the right base.
+
+on:
+  workflow_call:
+    inputs:
+      rule:
+        type: string
+        description: Poetry version bump rule for the release version
+        required: true
+      notes:
+        type: string
+        description: >
+          Manual release notes section, shown above the auto-generated
+          notes. Can also be added/edited later in the draft release UI.
+        required: false
+        default: ""
+      tag-prefix:
+        type: string
+        description: Prefix for the git tag created from the version number
+        required: false
+        default: "v"
+      branch:
+        type: string
+        description: >
+          Branch (ref) to release from. Defaults to the ref the workflow
+          was dispatched on, so releasing from a maintenance branch (e.g.
+          v2.x) only requires selecting that branch in the Actions UI.
+        required: false
+        default: ${{ github.ref_name }}
+      previous-tag:
+        type: string
+        description: >
+          Tag to use as comparison base for the auto-generated release
+          notes. Leave empty to let GitHub choose automatically (fine for
+          regular releases from main). Set explicitly for backport
+          releases from maintenance branches, where releases are created
+          out of version order and GitHub may pick the wrong base (e.g.
+          set to "v2.1.0" when releasing 2.1.1 after 2.5.0 exists).
+        required: false
+        default: ""
+    outputs:
+      version:
+        description: The release version number.
+        value: ${{ jobs.BumpRelease.outputs.version }}
+      draft-url:
+        description: URL of the created draft release.
+        value: ${{ jobs.Draft.outputs.url }}
+
+jobs:
+  BumpRelease:
+    name: Bump to release version
+    uses: ./.github/workflows/bump.yml
+    secrets: inherit
+    with:
+      rule: ${{ inputs.rule }}
+      branch: ${{ inputs.branch }}
+
+  Draft:
+    name: Create draft release
+    runs-on: ubuntu-latest
+    needs: BumpRelease
+    permissions:
+      contents: write
+    outputs:
+      url: ${{ steps.draft.outputs.url }}
+
+    steps:
+      - name: Set up Python
+        uses: actions/setup-python@v6
+        with:
+          python-version: "3.13"
+
+      - name: Install packaging
+        shell: bash
+        run: pip install packaging
+
+      - name: Determine release type
+        id: reltype
+        env:
+          VERSION: ${{ needs.BumpRelease.outputs.version }}
+        shell: python
+        run: |
+          import os, sys
+          from packaging.version import Version
+
+          version = Version(os.environ.get("VERSION"))
+
+          # Dev versions are never tagged or released.
+          if version.is_devrelease:
+              sys.exit(f"::error:: Refusing to create a release for dev "
+                       f"version {version!s}; check the bump rule.")
+
+          # Pre-releases (aN/bN/rcN) become internal-only tags: marked as pre-release on GitHub, skipped by the PyPI publish workflow.
+          with open(os.environ.get("GITHUB_OUTPUT"), "a") as output:
+              output.write(
+                  f"PRERELEASE={str(version.is_prerelease).lower()}\n")
+
+      - name: Create draft release
+        id: draft
+        uses: softprops/action-gh-release@v2
+        with:
+          tag_name: "${{ inputs.tag-prefix }}${{ needs.BumpRelease.outputs.version }}"
+          target_commitish: ${{ needs.BumpRelease.outputs.sha }}
+          draft: true
+          prerelease: ${{ steps.reltype.outputs.PRERELEASE == 'true' }}
+          generate_release_notes: true
+          previous_tag: ${{ inputs.previous-tag }}
+          body: ${{ inputs.notes }}
+
+      - name: Create step summary
+        env:
+          DRAFT_URL: ${{ steps.draft.outputs.url }}
+          VERSION: ${{ needs.BumpRelease.outputs.version }}
+          SHA: ${{ needs.BumpRelease.outputs.sha }}
+        run: |
+          echo "## Draft release for $VERSION created" >> $GITHUB_STEP_SUMMARY
+          echo "Tag will be created at \`$SHA\` upon publishing." >> $GITHUB_STEP_SUMMARY
+          echo "Review and publish here: $DRAFT_URL" >> $GITHUB_STEP_SUMMARY
+
+  BumpDev:
+    name: Bump to next dev version
+    needs: Draft
+    uses: ./.github/workflows/bump.yml
+    secrets: inherit
+    with:
+      rule: dev
+      branch: ${{ inputs.branch }}