diff --git a/.github/actions/update-release-preview/action.yml b/.github/actions/update-release-preview/action.yml
new file mode 100644
index 000000000..6f57fd022
--- /dev/null
+++ b/.github/actions/update-release-preview/action.yml
@@ -0,0 +1,50 @@
+name: Update release-notes preview
+description: >-
+ Refresh the verbatim release-notes preview block in a release PR's body, using
+ the committed changelog sections (the exact text that will be published).
+
+ Assumes that uv is set up in the job using this action.
+
+inputs:
+ pr-number:
+ description: The release PR number to update.
+ required: true
+ guppylang-version:
+ description: The guppylang version being released.
+ required: true
+ internals-version:
+ description: The guppylang-internals version being released.
+ required: true
+ token:
+ description: Token with pull-requests write permission.
+ required: true
+
+runs:
+ using: composite
+ steps:
+ - shell: bash
+ env:
+ GUPPY_VERSION: ${{ inputs.guppylang-version }}
+ INTERNALS_VERSION: ${{ inputs.internals-version }}
+ NUMBER: ${{ inputs.pr-number }}
+ GITHUB_TOKEN: ${{ inputs.token }} # For GH CLI auth
+ run: |
+ set -euo pipefail
+ # Extract verbatim sections (empty file if the section is missing).
+ uv run --no-project scripts/release/extract_changelog.py \
+ guppylang/CHANGELOG.md "$GUPPY_VERSION" > /tmp/guppy_section.md || true
+ uv run --no-project scripts/release/extract_changelog.py \
+ guppylang-internals/CHANGELOG.md "$INTERNALS_VERSION" > /tmp/internals_section.md || true
+
+ gh pr view "$NUMBER" --json body --jq .body > /tmp/pr_body.md
+ uv run --no-project scripts/release/render_pr_body.py \
+ --body /tmp/pr_body.md \
+ --package guppylang "$GUPPY_VERSION" /tmp/guppy_section.md \
+ --package guppylang-internals "$INTERNALS_VERSION" /tmp/internals_section.md \
+ > /tmp/pr_body_new.md
+
+ if ! cmp -s /tmp/pr_body.md /tmp/pr_body_new.md; then
+ gh pr edit "$NUMBER" --body-file /tmp/pr_body_new.md
+ else
+ echo "Preview already up to date."
+ fi
diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml
index fa790804b..020f1776b 100644
--- a/.github/workflows/python-wheels.yml
+++ b/.github/workflows/python-wheels.yml
@@ -1,4 +1,4 @@
-name: Build and publish python wheels
+name: Python wheels
# Builds and publishes the wheels on pypi.
#
# When running on a push-to-main event, or as a workflow dispatch on a branch,
@@ -31,13 +31,13 @@ jobs:
name: Package wheels and test
runs-on: ubuntu-latest
steps:
- # Skip the workflow when triggered by a release event for a non guppylang package.
+ # Skip the workflow when triggered by a release event for a non guppylang(-internals) package.
- name: Check tag
id: check-tag
run: |
echo "run=$SHOULD_RUN" >> $GITHUB_OUTPUT
env:
- SHOULD_RUN: ${{ (github.event_name != 'release' && (github.ref == 'refs/heads/main' || startsWith(github.head_ref, 'release-please-')) ) || ( github.ref_type == 'tag' && (startsWith(github.ref, 'refs/tags/guppylang-v') || startsWith(github.ref, 'refs/tags/guppylang-internals-v'))) }}
+ SHOULD_RUN: ${{ (github.event_name != 'release' && (github.ref == 'refs/heads/main' || startsWith(github.head_ref, 'release-pr--'))) || (github.ref_type == 'tag' && (startsWith(github.ref, 'refs/tags/guppylang-v') || startsWith(github.ref, 'refs/tags/guppylang-internals-v'))) }}
- uses: actions/checkout@v7
if: ${{ steps.check-tag.outputs.run == 'true' }}
diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml
index 6b1f5503f..83617ae05 100644
--- a/.github/workflows/release-checks.yml
+++ b/.github/workflows/release-checks.yml
@@ -1,10 +1,8 @@
# A check that ensures that `guppylang` and `guppylang-internals` work correctly with the lowest and highest allowed
-# versions of their dependencies. Additionally, `guppylang` is tested to work with the latest released version of
-# `guppylang-internals`.
+# versions of their dependencies.
#
-# If either checks fail, it is likely that the packages are not compatible with the respective versions of their
-# dependencies and the version ranges have to be adjusted. Additionally, this might fail if `guppylang` requires an
-# unreleased or unpublished version of `guppylang-internals`.
+# If checks fail, it is likely that the packages are not compatible with the respective versions of their dependencies
+# and the version ranges have to be adjusted.
name: 🚚 Release checks
@@ -19,15 +17,10 @@ env:
UV_VERSION: "0.9.5"
jobs:
- check-release-guppylang-internals:
- name: Check `guppylang-internals` release compatibility with ${{ matrix.target.resolution }} dependencies
+ check-release-guppylang:
+ name: Check release compatibility with ${{ matrix.target.resolution }} dependencies
runs-on: ubuntu-latest
- # Check if we are running on a release PR, targeting some branch (sits in the middle of the release branch name)
- #
- # release-please always uses this branch name.
- if: |
- github.event_name == 'workflow_dispatch' ||
- (startsWith(github.head_ref, 'release-please--') && endsWith(github.head_ref, '--components--guppylang-internals'))
+ if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-pr--')
strategy:
fail-fast: false
matrix:
@@ -59,69 +52,16 @@ jobs:
echo "\nDone! Installed dependencies:"
uv pip list
- name: Type check with mypy
- run: uv run --no-sync mypy guppylang-internals
- - name: Lint with ruff
- run: uv run --no-sync ruff check guppylang-internals
- - name: Run tests
- run: UV_NO_SYNC=1 just test
-
- check-release-guppylang:
- name: Check `guppylang` release compatibility with ${{ matrix.target.resolution }} dependencies
- runs-on: ubuntu-latest
- # Check if we are running on a release PR, targeting some branch (sits in the middle of the release branch name)
- #
- # release-please always uses this branch name.
- if: |
- github.event_name == 'workflow_dispatch' ||
- (startsWith(github.head_ref, 'release-please--') && endsWith(github.head_ref, '--components--guppylang'))
- strategy:
- fail-fast: false
- matrix:
- target:
- - resolution: "highest"
- python: "3.14"
- - resolution: "lowest-direct"
- python: "3.10"
- steps:
- - uses: actions/checkout@v7
- - uses: mozilla-actions/sccache-action@v0.0.10
- - name: Set up uv
- uses: astral-sh/setup-uv@v7
- with:
- version: ${{ env.UV_VERSION }}
- enable-cache: true
- - name: Set up Just
- uses: extractions/setup-just@v4
- - name: Install Python ${{ matrix.target.python }}
- run: |
- uv python install ${{ matrix.target.python }}
- uv python pin ${{ matrix.target.python }}
- - name: Setup `guppylang` with only pypi dependencies
- env:
- RES: ${{ matrix.target.resolution }}
- run: |
- UV_RESOLUTION="$RES" uv lock --prerelease=allow --no-sources -U
- UV_RESOLUTION="$RES" uv sync --prerelease=allow --no-sources --no-install-workspace
- # Install the latest published release of guppylang-internals.
- uv pip install --no-sources --prerelease allow guppylang-internals
- # Install the local version of guppylang.
- uv pip install -e guppylang --no-deps
- echo "\nDone! Installed dependencies:"
- uv pip list
- - name: Type check with mypy
- run: uv run --no-sync mypy guppylang
+ run: uv run --no-sync mypy guppylang guppylang-internals
- name: Lint with ruff
- run: uv run --no-sync ruff check guppylang
+ run: uv run --no-sync ruff check guppylang guppylang-internals
- name: Run tests
run: UV_NO_SYNC=1 just test
-
check-guppy-docs-build:
name: Check the guppy-docs build for `guppylang` release
runs-on: ubuntu-latest
- if: |
- github.event_name == 'workflow_dispatch' ||
- (startsWith(github.head_ref, 'release-please--') && endsWith(github.head_ref, '--components--guppylang'))
+ if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-pr--')
steps:
- uses: actions/checkout@v7
with:
@@ -140,30 +80,31 @@ jobs:
run: |
cd guppy-docs
uv add "guppylang @ git+https://github.com/Quantinuum/guppylang.git@${{ github.event.pull_request.head.sha }}#subdirectory=guppylang" || uv sync --group docs
- - name: Checkout guppylang release-please manifest
+ - name: Checkout guppylang pyproject.toml
uses: actions/checkout@v7
with:
repository: Quantinuum/guppylang
ref: ${{ github.event.pull_request.head.sha }}
path: guppylang
sparse-checkout: |
- .release-please-manifest.json
+ guppylang/pyproject.toml
sparse-checkout-cone-mode: false
- name: Verify guppylang version
run: |
cd guppy-docs
uv run python <<'PY'
- import json
+ import re
from pathlib import Path
import guppylang
GUPPYLANG_PACKAGE_VERSION = guppylang.__version__
- with Path.open(Path("../guppylang/.release-please-manifest.json")) as json_data:
- release_please_data = json.load(json_data)
- MANIFEST_GUPPYLANG_VERSION = release_please_data["guppylang"]
+ pyproject = Path("../guppylang/guppylang/pyproject.toml").read_text()
+ match = re.search(r'^version = "([^"]+)"', pyproject, re.MULTILINE)
+ assert match is not None, "Could not find the guppylang project version"
+ PYPROJECT_GUPPYLANG_VERSION = match.group(1)
- assert GUPPYLANG_PACKAGE_VERSION == MANIFEST_GUPPYLANG_VERSION
+ assert GUPPYLANG_PACKAGE_VERSION == PYPROJECT_GUPPYLANG_VERSION
print(f"Verification complete! Verified Guppy version is {GUPPYLANG_PACKAGE_VERSION}")
PY
- name: Run docs build
diff --git a/.github/workflows/release-major-guard.yml b/.github/workflows/release-major-guard.yml
new file mode 100644
index 000000000..2abac29d3
--- /dev/null
+++ b/.github/workflows/release-major-guard.yml
@@ -0,0 +1,56 @@
+# Guards against an accidental major version bump of `guppylang` in a release PR.
+# Add the `X-allow-major-bump` label to the release PR opt-out of this guard.
+
+name: 🚚 Release major-bump guard
+
+on:
+ pull_request:
+ branches:
+ - main
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - labeled
+ - unlabeled
+
+permissions:
+ contents: read
+
+env:
+ ALLOW_MAJOR_BUMP_LABEL: "X-allow-major-bump"
+
+jobs:
+ guard:
+ name: Disallow unapproved major bump
+ runs-on: ubuntu-latest
+ if: startsWith(github.head_ref, 'release-pr--')
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Compare guppylang major versions
+ env:
+ LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
+ BASE_REF: ${{ github.base_ref }}
+ run: |
+ set -euo pipefail
+ major() { sed -n 's/^version = "\([0-9][0-9]*\)\..*/\1/p' "$1" | head -n1; }
+
+ head_major=$(major guppylang/pyproject.toml)
+ git fetch origin "$BASE_REF" --depth=1
+ git show "origin/$BASE_REF:guppylang/pyproject.toml" > /tmp/base_pyproject.toml
+ base_major=$(major /tmp/base_pyproject.toml)
+
+ echo "guppylang major version: base=$base_major head=$head_major"
+ if [ "$head_major" -gt "$base_major" ]; then
+ if echo "$LABELS" | jq -e --arg l "$ALLOW_MAJOR_BUMP_LABEL" 'index($l)' >/dev/null; then
+ echo "Major bump permitted by the '$ALLOW_MAJOR_BUMP_LABEL' label."
+ else
+ echo "::error::guppylang major version would bump $base_major → $head_major. Add the '$ALLOW_MAJOR_BUMP_LABEL' label to allow this release."
+ exit 1
+ fi
+ else
+ echo "No major bump detected."
+ fi
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
deleted file mode 100644
index 27a54eaa6..000000000
--- a/.github/workflows/release-please.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-# Automatic changelog and version bumping with release-please for python projects
-name: Release-please 🐍
-
-on:
- workflow_dispatch: {}
- push:
- branches:
- - main
-
-permissions:
- contents: write
- pull-requests: write
-
-jobs:
- release-please:
- name: Create release PR
- runs-on: ubuntu-latest
- steps:
- - uses: googleapis/release-please-action@v5
- with:
- # Using a personal access token so releases created by this workflow can trigger the deployment workflow
- token: ${{ secrets.HUGRBOT_PAT }}
- config-file: release-please-config.json
diff --git a/.github/workflows/release-pr-changelog-preview.yml b/.github/workflows/release-pr-changelog-preview.yml
new file mode 100644
index 000000000..796c74736
--- /dev/null
+++ b/.github/workflows/release-pr-changelog-preview.yml
@@ -0,0 +1,54 @@
+# Keeps the release-notes preview in the release PR body in sync with the
+# committed changelogs. Does not commit, so it cannot loop.
+
+name: 🚚 Release PR changelog preview
+
+on:
+ pull_request:
+ types:
+ - synchronize
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: write
+
+concurrency:
+ group: release-pr-preview-${{ github.head_ref }}
+ cancel-in-progress: true
+
+env:
+ UV_VERSION: "0.11.22"
+
+jobs:
+ preview:
+ name: Refresh release-notes preview
+ runs-on: ubuntu-latest
+ if: startsWith(github.head_ref, 'release-pr--')
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Read released versions
+ id: versions
+ run: |
+ set -euo pipefail
+ guppy=$(sed -n 's/^version = "\(.*\)"/\1/p' guppylang/pyproject.toml | head -n1)
+ internals=$(sed -n 's/^version = "\(.*\)"/\1/p' guppylang-internals/pyproject.toml | head -n1)
+ echo "guppylang=$guppy" >> "$GITHUB_OUTPUT"
+ echo "internals=$internals" >> "$GITHUB_OUTPUT"
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ version: ${{ env.UV_VERSION }}
+ enable-cache: true
+
+ - name: Refresh release-notes preview
+ uses: ./.github/actions/update-release-preview
+ with:
+ pr-number: ${{ github.event.pull_request.number }}
+ guppylang-version: ${{ steps.versions.outputs.guppylang }}
+ internals-version: ${{ steps.versions.outputs.internals }}
+ token: ${{ secrets.HUGRBOT_PAT }}
diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml
new file mode 100644
index 000000000..4c8f02125
--- /dev/null
+++ b/.github/workflows/release-pr.yml
@@ -0,0 +1,264 @@
+# Opens or updates a single release PR for `guppylang` and `guppylang-internals`.
+#
+# Runs on every push to `main` (and can be dispatched manually). It computes the
+# next versions from conventional commits, applies the version bumps, refreshes
+# the lock file, seeds a draft changelog for each package, and opens/updates a PR
+# on the `release-pr--` branch. The actual GitHub releases are cut by
+# `release-publish.yml` once this PR is merged.
+#
+# Versioning:
+# * guppylang -> semver with optional pre-release (e.g. 1.2.3 or 1.2.3-a6).
+# * guppylang-internals -> . (e.g. 1.6), build reset on
+# a guppylang major bump.
+#
+# Changelogs are only a *draft* seeded by git-cliff. While the `X-regen-changelog`
+# label is set (added by default), the draft is regenerated on every push. Remove
+# the label to take manual control: subsequent pushes then leave the committed
+# changelogs untouched. The published release notes are sliced verbatim from the
+# committed CHANGELOG.md files, so the PR preview is exactly what ships.
+
+name: 🚚 Release PR
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+ inputs:
+ force_open:
+ description: "Open/update the release PR even if no releasable commits are found"
+ type: boolean
+ default: false
+ bump:
+ description: "How to bump guppylang (`auto` by default). See `scripts/release/compute_versions.py` for reference."
+ type: choice
+ default: auto
+ options:
+ - auto
+ - alpha
+ - alpha-patch
+ - alpha-minor
+ - alpha-major
+ - beta
+ - rc
+ - stable
+ - patch
+ - minor
+ - major
+
+permissions:
+ contents: write
+ pull-requests: write
+
+concurrency:
+ group: release-pr--${{ github.ref_name }}
+ cancel-in-progress: false
+
+env:
+ BASE: ${{ github.ref_name }}
+ BRANCH: release-pr--${{ github.ref_name }}
+ RELEASE_LABEL: X-release
+ REGEN_LABEL: X-regen-changelog
+ UV_VERSION: "0.11.22"
+ GITHUB_TOKEN: ${{ secrets.HUGRBOT_PAT }}
+
+jobs:
+ release-pr:
+ name: Open or update the release PR
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ # Full history + tags are required for git-cliff.
+ fetch-depth: 0
+ token: ${{ secrets.HUGRBOT_PAT }}
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ version: ${{ env.UV_VERSION }}
+ enable-cache: true
+ - name: Install git-cliff
+ uses: taiki-e/install-action@v2
+ with:
+ tool: git-cliff
+
+ - name: Configure git identity
+ run: |
+ git config user.name "hugrbot"
+ git config user.email "hugrbot@users.noreply.github.com"
+
+ - name: Detect releasable commits
+ id: releasable
+ run: |
+ set -euo pipefail
+ count() {
+ git cliff --config cliff.toml --include-path "$1" --tag-pattern "$2" \
+ --unreleased --context 2>/dev/null | jq '[.[]?.commits[]?] | length'
+ }
+ guppy=$(count 'guppylang/**' '^guppylang-v')
+ internals=$(count 'guppylang-internals/**' '^guppylang-internals-v')
+ echo "guppylang=$guppy" >> "$GITHUB_OUTPUT"
+ echo "internals=$internals" >> "$GITHUB_OUTPUT"
+ if [ "${guppy:-0}" != "0" ] || [ "${internals:-0}" != "0" ]; then
+ echo "any=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "any=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Compute next versions
+ id: plan
+ run: |
+ set -euo pipefail
+ uv run --no-project scripts/release/compute_versions.py compute \
+ --bump "${{ github.event.inputs.bump || 'auto' }}" \
+ --github-output "$GITHUB_OUTPUT"
+
+ - name: Detect a pending publish
+ id: pending
+ run: |
+ set -euo pipefail
+ # On `main`, the committed guppylang version is normally already tagged
+ # (i.e. released). If it is not, the just-merged commit is a release PR
+ # whose tags are still being created by `release-publish.yml`. Opening a
+ # new release PR now would double-bump, so we skip this run.
+ cur=$(sed -n 's/^version = "\(.*\)"/\1/p' guppylang/pyproject.toml | head -n1)
+ if git rev-parse -q --verify "refs/tags/guppylang-v$cur" >/dev/null; then
+ echo "pending=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "pending=true" >> "$GITHUB_OUTPUT"
+ echo "guppylang v$cur is not tagged yet; a release is pending publish."
+ fi
+
+ - name: Decide whether to proceed
+ id: gate
+ run: |
+ proceed=false
+ if [ "${{ steps.releasable.outputs.any }}" = "true" ] || \
+ [ "${{ github.event.inputs.force_open }}" = "true" ]; then
+ proceed=true
+ fi
+ # A pending publish only blocks automatic push runs, never manual dispatch.
+ if [ "${{ github.event_name }}" = "push" ] && \
+ [ "${{ steps.pending.outputs.pending }}" = "true" ]; then
+ echo "Publish pending on main!"
+ proceed=false
+ fi
+ echo "proceed=$proceed" >> "$GITHUB_OUTPUT"
+ if [ "$proceed" != "true" ]; then
+ echo "Nothing to do; skipping release PR."
+ fi
+
+ - name: Inspect existing release PR for changelog regen
+ id: pr
+ if: steps.gate.outputs.proceed == 'true'
+ run: |
+ set -euo pipefail
+ data=$(gh pr list --head "$BRANCH" --state open --json number,labels)
+ number=$(echo "$data" | jq -r '.[0].number // ""')
+ echo "number=$number" >> "$GITHUB_OUTPUT"
+ if [ -z "$number" ]; then
+ # No PR yet: seed the changelog draft on first creation.
+ echo "regen=true" >> "$GITHUB_OUTPUT"
+ else
+ regen=$(echo "$data" | jq -r --arg l "$REGEN_LABEL" \
+ '([.[0].labels[].name] | index($l)) != null')
+ echo "regen=$regen" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Build the release branch
+ if: steps.gate.outputs.proceed == 'true'
+ env:
+ GUPPY: ${{ steps.plan.outputs.guppylang }}
+ INTERNALS: ${{ steps.plan.outputs.internals }}
+ REGEN: ${{ steps.pr.outputs.regen }}
+ run: |
+ set -euo pipefail
+
+ # Preserve human-curated changelogs when regeneration is disabled.
+ if [ "$REGEN" != "true" ]; then
+ git fetch origin "$BRANCH" || true
+ git show "origin/$BRANCH:guppylang/CHANGELOG.md" \
+ > /tmp/guppylang_CHANGELOG.md 2>/dev/null || true
+ git show "origin/$BRANCH:guppylang-internals/CHANGELOG.md" \
+ > /tmp/internals_CHANGELOG.md 2>/dev/null || true
+ fi
+
+ git checkout -B "$BRANCH"
+
+ uv run --no-project scripts/release/compute_versions.py set-internals "$INTERNALS"
+ git commit -aqm "chore: bump guppylang-internals to $INTERNALS"
+
+ uv run --no-project scripts/release/compute_versions.py set-guppylang "$GUPPY"
+ git commit -aqm "chore: bump guppylang to $GUPPY"
+
+ uv run --no-project scripts/release/compute_versions.py set-pin "$INTERNALS"
+ git commit -aqm "chore: pin guppylang-internals==$INTERNALS in guppylang"
+
+ uv lock
+ git commit -aqm "chore: update uv.lock" || echo "uv.lock unchanged"
+
+ if [ "$REGEN" = "true" ]; then
+ git cliff --config cliff.toml --include-path 'guppylang/**' \
+ --tag-pattern '^guppylang-v' --unreleased --tag "guppylang-v$GUPPY" \
+ > /tmp/guppy_section.md
+ uv run --no-project scripts/release/update_changelog.py \
+ guppylang/CHANGELOG.md "$GUPPY" /tmp/guppy_section.md
+
+ git cliff --config cliff.toml --include-path 'guppylang-internals/**' \
+ --tag-pattern '^guppylang-internals-v' --unreleased \
+ --tag "guppylang-internals-v$INTERNALS" > /tmp/internals_section.md
+ uv run --no-project scripts/release/update_changelog.py \
+ guppylang-internals/CHANGELOG.md "$INTERNALS" /tmp/internals_section.md
+
+ git commit -aqm "chore: update changelogs (by regen)" || echo "changelogs unchanged"
+ else
+ [ -f /tmp/guppylang_CHANGELOG.md ] \
+ && cp /tmp/guppylang_CHANGELOG.md guppylang/CHANGELOG.md || true
+ [ -f /tmp/internals_CHANGELOG.md ] \
+ && cp /tmp/internals_CHANGELOG.md guppylang-internals/CHANGELOG.md || true
+
+ git commit -aqm "chore: update changelogs (by user)" || echo "changelogs unchanged"
+ fi
+
+ git push --force-with-lease origin "$BRANCH"
+
+ - name: Create or update the release PR
+ id: openpr
+ if: steps.gate.outputs.proceed == 'true'
+ env:
+ GUPPY: ${{ steps.plan.outputs.guppylang }}
+ INTERNALS: ${{ steps.plan.outputs.internals }}
+ NUMBER: ${{ steps.pr.outputs.number }}
+ run: |
+ set -euo pipefail
+ title="chore: release guppylang $GUPPY (internals $INTERNALS)"
+ if [ -z "$NUMBER" ]; then
+ {
+ echo "Automated release PR."
+ echo
+ echo "- \`guppylang\` → \`$GUPPY\`"
+ echo "- \`guppylang-internals\` → \`$INTERNALS\`"
+ echo
+ echo "Remove the \`$REGEN_LABEL\` label to take manual control of the changelogs."
+ echo
+ echo ""
+ echo ""
+ } > /tmp/pr_body.md
+ gh pr create --base "$BASE" --head "$BRANCH" --title "$title" \
+ --body-file /tmp/pr_body.md --draft \
+ --label "$RELEASE_LABEL" --label "$REGEN_LABEL"
+ NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number')
+ else
+ # Only update title, body is edited through changelog preview sync workflow.
+ gh pr edit "$NUMBER" --title "$title"
+ fi
+ echo "number=$NUMBER" >> "$GITHUB_OUTPUT"
+
+ - name: Refresh release-notes preview
+ uses: ./.github/actions/update-release-preview
+ with:
+ pr-number: ${{ steps.openpr.outputs.number }}
+ guppylang-version: ${{ steps.plan.outputs.guppylang }}
+ internals-version: ${{ steps.plan.outputs.internals }}
+ token: ${{ secrets.HUGRBOT_PAT }}
diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml
new file mode 100644
index 000000000..adc234ce7
--- /dev/null
+++ b/.github/workflows/release-publish.yml
@@ -0,0 +1,127 @@
+# Creates the GitHub releases for `guppylang` and `guppylang-internals`.
+#
+# Release creation is driven by tags. Pushing a `guppylang-v*` or
+# `guppylang-internals-v*` tag creates a draft GitHub release for the matching
+# package, with notes sliced verbatim from the committed CHANGELOG.md at that tag.
+# Publishing that draft release triggers `python-wheels.yml`, which uploads the
+# wheels to PyPI.
+#
+# Tags are produced in two ways, both of which feed the same `create-release` job:
+# * Automatically: when a release PR is merged into `main`, the committed
+# package versions are tagged here (the `tag-on-main` job).
+# * Manually: for an out-of-band release (e.g. a patch cut from a branch off an
+# old tag), a maintainer tags the commit themselves and pushes the tag.
+
+name: 🚚 Release publish
+
+on:
+ push:
+ branches:
+ - main
+ tags:
+ - "guppylang-v*"
+ - "guppylang-internals-v*"
+
+permissions:
+ contents: write
+
+concurrency:
+ group: release-publish-${{ github.ref }}
+ cancel-in-progress: false
+
+env:
+ GH_TOKEN: ${{ secrets.HUGRBOT_PAT }}
+
+jobs:
+ # On a push to `main` (typically a merged release PR), tag any package whose
+ # committed version is not yet tagged. Pushing the tag with the bot PAT then
+ # triggers the `create-release` job below.
+ tag-on-main:
+ name: Tag newly versioned packages
+ if: github.ref_type == 'branch'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.HUGRBOT_PAT }}
+ - name: Configure git identity
+ run: |
+ git config user.name "hugrbot"
+ git config user.email "hugrbot@users.noreply.github.com"
+ - name: Tag newly versioned packages
+ run: |
+ set -euo pipefail
+ read_version() { sed -n 's/^version = "\(.*\)"/\1/p' "$1" | head -n1; }
+
+ tag_pkg() {
+ local pyproject="$1" prefix="$2"
+ local version tag
+ version=$(read_version "$pyproject")
+ tag="${prefix}${version}"
+
+ if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
+ echo "$tag already exists; nothing to tag."
+ return
+ fi
+
+ echo "Tagging $tag"
+ git tag "$tag"
+ # Pushed with the bot PAT so it triggers the `create-release` job.
+ git push origin "$tag"
+ }
+
+ tag_pkg "guppylang/pyproject.toml" "guppylang-v"
+ tag_pkg "guppylang-internals/pyproject.toml" "guppylang-internals-v"
+
+ # On a push of a package tag (from `tag-on-main`, or a manual `git push` on a
+ # release branch), create the matching draft GitHub release.
+ create-release:
+ name: Create the GitHub release for the pushed tag
+ if: github.ref_type == 'tag'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.HUGRBOT_PAT }}
+ - name: Create the GitHub release
+ env:
+ TAG: ${{ github.ref_name }}
+ run: |
+ set -euo pipefail
+
+ case "$TAG" in
+ guppylang-internals-v*)
+ name="guppylang-internals"
+ version="${TAG#guppylang-internals-v}"
+ changelog="guppylang-internals/CHANGELOG.md"
+ ;;
+ guppylang-v*)
+ name="guppylang"
+ version="${TAG#guppylang-v}"
+ changelog="guppylang/CHANGELOG.md"
+ ;;
+ *)
+ echo "Tag $TAG does not match a known package; skipping."
+ exit 0
+ ;;
+ esac
+
+ if gh release view "$TAG" >/dev/null 2>&1; then
+ echo "Release $TAG already exists; nothing to do."
+ exit 0
+ fi
+
+ echo "Creating release for $name $version (tag $TAG)"
+ if ! python3 scripts/release/extract_changelog.py "$changelog" "$version" > /tmp/notes.md; then
+ echo "No changelog section for $version; publishing with empty notes." >&2
+ : > /tmp/notes.md
+ fi
+
+ args=(--title "$name $version" --notes-file /tmp/notes.md --verify-tag --draft)
+ # PEP 440 pre-releases carry a '-' separator (e.g. 1.0.0-a6, 1.0.0-rc1).
+ case "$version" in
+ *-*) args+=(--prerelease) ;;
+ esac
+ gh release create "$TAG" "${args[@]}"
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
deleted file mode 100644
index 974163d05..000000000
--- a/.release-please-manifest.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "guppylang": "1.0.0-a5",
- "guppylang-internals": "1.0.0-a5"
-}
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 981df1703..7d7dd84af 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -144,44 +144,125 @@ We accept the following contribution types:
## :shipit: Releasing new versions
-We use automation to bump the version number and generate changelog entries
-based on the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) labels. Release PRs are created automatically
-for each package when new changes are merged into the `main` branch. Once the PR is
-approved by someone in the [release team](.github/CODEOWNERS) and is merged, the new package
-is published on PyPI.
-
-The changelog can be manually edited before merging the release PR. Note however
-that modifying the diff before other changes are merged will cause the
-automation to close the release PR and create a new one to avoid conflicts.
-
-Releases are managed by `release-please`. This tool always bumps the
-minor version (or the pre-release version if the previous version was a
-pre-release).
-
-To override the version getting released, you must merge a PR to `main` containing
-`Release-As: 0.1.0` in the description.
-Python pre-release versions should be formatted as `0.1.0a1` (or `b1`, `rc1`).
-
-Before merging a release PR, make sure to update the uv lock file by running `uv
-lock` and pushing to the release branch.
-When releasing `guppylang-internals`, also update the dependency version in `guppylang/pyproject.toml`.
+Releases are managed by a custom set of workflows under `.github/workflows`
+(`release-pr.yml`, `release-pr-changelog-preview.yml`, `release-major-guard.yml`,
+and `release-publish.yml`), driven by [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/).
+
+The two distributions are versioned differently:
+
+- `guppylang` follows semantic versioning with an optional pre-release suffix (`-a` alpha, `-b` beta, `-rc`; e.g. `1.2.3-b5`).
+- `guppylang-internals` uses the scheme `.` (e.g. `1.6`).
+ The build number increases on every release and resets to `0` whenever the
+ `guppylang` major version changes.
+
+### The release PR
+
+On every push to `main`, `release-pr.yml` opens or updates a single release PR on
+the `release-pr--` branch (e.g. `release-pr--main` for releases off `main`).
+It:
+
+1. computes the next `guppylang` version (see [Choosing the version
+ bump](#choosing-the-version-bump) below),
+2. bumps the `guppylang-internals` build number,
+3. pins `guppylang-internals==` in `guppylang/pyproject.toml`,
+4. runs `uv lock`, and
+5. seeds a draft changelog for each package with `git-cliff`.
+
+Each step lands as its own commit, and the PR is opened in **draft**. You can also
+trigger the workflow manually via *workflow_dispatch* to force a PR open
+(`force_open`) and to override the bump (see below).
+
+### Choosing the version bump
+
+The version math lives in `scripts/release/compute_versions.py`. By default the
+workflow uses the `auto` mode; you can override it through the `bump` input of the
+`release-pr.yml` *workflow_dispatch*.
+
+The available bump modes are:
+
+| mode | example | notes |
+|---------------|--------------------------|------------------------------------------------|
+| `auto` | `1.0.0-a5` → `1.0.0-a6` | default; see below |
+| `alpha` | `1.0.0-a5` → `1.0.0-a6` | increment the alpha counter |
+| `alpha-patch` | `1.2.3` → `1.2.4-a0` | start a new alpha series for a patch |
+| `alpha-minor` | `1.2.3` → `1.3.0-a0` | start a new alpha series for a minor |
+| `alpha-major` | `1.2.3` → `2.0.0-a0` | start a new alpha series for a major |
+| `beta` | `1.0.0-a5` → `1.0.0-b0` | promote alpha → beta (`b1` → `b2`) |
+| `rc` | `1.0.0-b2` → `1.0.0-rc0` | promote to a release candidate (`rc1` → `rc2`) |
+| `stable` | `1.0.0-rc1` → `1.0.0` | drop the pre-release suffix |
+| `patch` | `1.0.1` → `1.0.2` | stable patch bump |
+| `minor` | `1.2.1` → `1.3.0` | stable minor bump |
+| `major` | `1.3.0` → `2.0.0` | stable major bump |
+
+In `auto` mode the script asks `git-cliff` what the conventional commits imply and
+expresses that in the current pre-release scheme: a `fix`/`feat`/breaking change
+that would bump the release core maps to `alpha-patch`/`alpha-minor`/`alpha-major`,
+while an unchanged core — the usual case while iterating on a pre-release — simply
+increments the alpha counter. Use an explicit mode to promote out of the alpha
+series (e.g. `beta`, `rc`, or `stable`).
+
+### Curating the changelog
+
+`git-cliff` only seeds a *draft*; the committed `CHANGELOG.md` files are the
+single source of truth. The release PR body shows a verbatim preview of the
+release notes (refreshed on every push by `release-pr-changelog-preview.yml`),
+which is exactly what gets published in the GitHub release.
+
+While the `X-regen-changelog` label is set (added by default), the draft is
+regenerated on every push to `main`. **Remove the label** to take manual control:
+subsequent pushes then leave the committed changelogs untouched, and your edits
+to the `release-pr--` branch stick.
+
+A breaking change that would bump the `guppylang` major version is blocked by
+`release-major-guard.yml`. Add the `X-allow-major-bump` label to the release PR
+to allow it.
+
+### Publishing
+
+Release creation is **driven by tags**. When the release PR is merged, the
+`tag-on-main` job in `release-publish.yml` tags both packages
+(`guppylang-v` and `guppylang-internals-v`) and pushes the tags.
+Each tag push then triggers the `create-release` job, which creates a **draft**
+GitHub release for the matching package, with notes sliced verbatim from the
+committed changelog at that tag.
+
+Review each draft release and **publish** it when ready: publishing triggers
+`python-wheels.yml`, which uploads the wheels to PyPI. Wheels are only published
+once you publish the draft release, not when the tag first creates it.
### Patch releases
-Sometimes we need to release a patch version to fix a critical bug, but we don't want
-to include all the changes that have been merged into the main branch. In this case,
-you can create a new branch from the latest release tag and cherry-pick the commits
-you want to include in the patch release.
-
-You will need to modify the version and changelog manually in this case. Check
-the existing release PRs for examples on how to do this. Once the branch is
-ready, create a draft PR so that the release team can review it.
-
-The wheel building process and publication to PyPI is handled by the CI. Just
-create a [github release](https://github.com/quantinuum/guppylang/releases/new) from
-the **unmerged** branch, and the CI will take care of the rest. The release tag
-should follow the format used in the previous releases, e.g. `v0.1.1`.
-
-After the release is published, make sure to merge the changes to the CHANGELOG
-and versions back into the `main` branch. This may be done by cherry-picking the
-PR used to create the release.
+Sometimes we need to release a patch version to fix a critical bug without
+shipping everything that has since landed on `main`. Because release creation is
+tag-driven, this works off any branch — **pushing the tag is all it takes** to get
+the draft GitHub release created automatically, no merge to `main` required.
+
+1. Branch off the release tag you want to patch (e.g. `git switch -c
+ patch/1.0.1 guppylang-v1.0.0`) and cherry-pick or commit the fixes you want.
+2. Bump the version and update the changelog **on the branch**:
+ - `guppylang`: `uv run --no-project scripts/release/compute_versions.py set-guppylang 1.0.1`
+ (and `set-pin ` if the internals pin needs to change).
+ - `guppylang-internals`: `uv run scripts/release/compute_versions.py
+ set-internals `.
+ - Add a matching `## []` section to the package's `CHANGELOG.md`. The
+ `create-release` job extracts this section verbatim for the release notes, so
+ it must exist before you tag.
+ - Run `uv lock` to refresh the lock file.
+
+ Optionally open a PR against the branch so the release team can review the diff.
+ You can also dispatch the workflow `release-pr.yml` with `bump: patch` mode on the branch to automate the process.
+ Note however that the release notes preview will not automatically refresh; you will have to dispatch
+ `release-pr-changelog-preview.yml` manually if you want to preview the notes.
+3. Tag the commit with the same format as the previous releases and push the tag:
+
+ ```sh
+ git tag guppylang-v1.0.1
+ git push origin guppylang-v1.0.1
+ ```
+
+ `release-publish.yml` picks up the tag, creates the draft GitHub release, and —
+ once you publish it — `python-wheels.yml` builds and uploads the wheels. Tag
+ each package you are releasing (`guppylang-v*` and/or `guppylang-internals-v*`).
+
+After the release is published, merge the changelog and version changes back into
+`main` (e.g. by cherry-picking the review PR) so the history stays consistent.
diff --git a/cliff.toml b/cliff.toml
new file mode 100644
index 000000000..8cb38038c
--- /dev/null
+++ b/cliff.toml
@@ -0,0 +1,71 @@
+# git-cliff configuration for the guppylang monorepo.
+#
+# git-cliff is used ONLY to seed a *draft* changelog section inside the release
+# PR. It is never trusted for the published release notes: those are sliced
+# verbatim out of the committed CHANGELOG.md files by
+# scripts/release/extract_changelog.py. The draft is meant to be curated by hand
+# in the release PR before merging.
+#
+# The release workflow runs git-cliff once per package, scoping commits to that
+# package's directory with `--include-path`, and selecting the package's tags
+# with `--tag-pattern`. The output is a single `## [] ...` section that
+# scripts/release/update_changelog.py inserts at the top of the relevant
+# CHANGELOG.md.
+
+[changelog]
+# No header: the section is inserted into an existing CHANGELOG.md that already
+# has its title and intro.
+header = ""
+body = """
+{% if version %}\
+## [{{ version | trim_start_matches(pat="guppylang-internals-v") | trim_start_matches(pat="guppylang-v") }}]\
+{% if previous.version %}(https://github.com/Quantinuum/guppylang/compare/{{ previous.version }}...{{ version }})\
+{% endif %} ({{ timestamp | date(format="%Y-%m-%d") }})
+{% else %}\
+## [Unreleased]
+{% endif %}\
+{% set breaking = commits | filter(attribute="breaking", value=true) %}\
+{% if breaking | length > 0 %}
+
+### ⚠ BREAKING CHANGES
+{% for commit in breaking %}
+* {{ commit.message | split(pat="\n") | first | upper_first }}\
+{% endfor %}
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+
+### {{ group | striptags | trim | upper_first }}
+{% for commit in commits %}
+* {{ commit.message | split(pat="\n") | first | upper_first }}\
+ ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/Quantinuum/guppylang/commit/{{ commit.id }}))
+{% endfor %}
+{% endfor %}\n
+"""
+trim = true
+
+[git]
+conventional_commits = true
+filter_unconventional = true
+split_commits = false
+protect_breaking_commits = true
+filter_commits = true
+topo_order = false
+sort_commits = "newest"
+
+commit_parsers = [
+ { message = "^feat", group = "Features" },
+ { message = "^fix", group = "Bug Fixes" },
+ { message = "^perf", group = "Performance Improvements" },
+ { message = "^refactor", group = "Code Refactoring" },
+ { message = "^docs", group = "Documentation" },
+ { message = "^revert", group = "Reverts" },
+ # Not included in changelog
+ { message = "^chore", skip = true },
+ { message = "^ci", skip = true },
+ { message = "^test", skip = true },
+ { message = "^style", skip = true },
+]
+
+[remote.github]
+owner = "Quantinuum"
+repo = "guppylang"
diff --git a/guppylang-internals/src/guppylang_internals/__init__.py b/guppylang-internals/src/guppylang_internals/__init__.py
index 2a31946be..0b96d65ee 100644
--- a/guppylang-internals/src/guppylang_internals/__init__.py
+++ b/guppylang-internals/src/guppylang_internals/__init__.py
@@ -1,3 +1,3 @@
-# This is updated by our release-please workflow, triggered by this
-# annotation: x-release-please-version
+# This is kept in sync with the project version by the release workflow
+# (.github/workflows/release-pr.yml).
__version__ = "1.0.0-a5"
diff --git a/guppylang-internals/src/guppylang_internals/engine.py b/guppylang-internals/src/guppylang_internals/engine.py
index eb6054860..385ec5557 100644
--- a/guppylang-internals/src/guppylang_internals/engine.py
+++ b/guppylang-internals/src/guppylang_internals/engine.py
@@ -538,7 +538,10 @@ def _compile(
)
graph.hugr.module_root.metadata[HugrUsedExtensions] = used_exts_meta
graph.hugr.module_root.metadata[HugrGenerator] = GeneratorDesc(
- name="guppylang", version=Version.parse(guppylang_internals.__version__)
+ name="guppylang",
+ version=Version.parse(
+ guppylang_internals.__version__, optional_minor_and_patch=True
+ ),
)
# Package all non-standard extensions used in the hugr.
# Standard hugr extensions are universally available and don't need bundling.
diff --git a/guppylang/src/guppylang/__init__.py b/guppylang/src/guppylang/__init__.py
index 6cdab22e4..83f9ba9e4 100644
--- a/guppylang/src/guppylang/__init__.py
+++ b/guppylang/src/guppylang/__init__.py
@@ -19,6 +19,6 @@
"qubit",
)
-# This is updated by our release-please workflow, triggered by this
-# annotation: x-release-please-version
+# This is kept in sync with the project version by the release workflow
+# (.github/workflows/release-pr.yml).
__version__ = "1.0.0-a5"
diff --git a/release-please-config.json b/release-please-config.json
deleted file mode 100644
index cbd3b52b8..000000000
--- a/release-please-config.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
- "separate-pull-requests": true,
- "pull-request-title-pattern": "chore: release${component} ${version}",
- "include-component-in-tag": true,
- "include-v-in-tag": true,
- "extra-label": "X-release",
- "packages": {
- "guppylang": {
- "release-type": "python",
- "component": "guppylang",
- "package-name": "guppylang",
- "draft": false,
- "prerelease": true,
- "prerelease-type": "a",
- "versioning": "prerelease",
- "draft-pull-request": true,
- "extra-files": [
- {
- "type": "toml",
- "path": "uv.lock",
- "jsonpath": "$.package[?(@.name.value=='guppylang')].version"
- }
- ]
- },
- "guppylang-internals": {
- "release-type": "python",
- "component": "guppylang-internals",
- "package-name": "guppylang_internals",
- "draft": false,
- "prerelease": true,
- "prerelease-type": "a",
- "versioning": "prerelease",
- "draft-pull-request": true,
- "extra-files": [
- {
- "type": "toml",
- "path": "uv.lock",
- "jsonpath": "$.package[?(@.name.value=='guppylang-internals')].version"
- }
- ]
- }
- }
-}
\ No newline at end of file
diff --git a/ruff.toml b/ruff.toml
index 32f5e4c37..92052b658 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -80,6 +80,7 @@ ignore = [
"guppy/decorator.py" = ["B010"]
"tests/integration/*" = ["F841", "C416", "RUF005", "RUF012", "TC001"]
"tests/{hugr,integration}/*" = ["B", "FBT", "SIM", "I"]
+"scripts/release/*" = ["T201"] # CLI scripts print to stdout
"__init__.py" = ["F401"] # module imported but unused
[per-file-target-version]
diff --git a/scripts/release/compute_versions.py b/scripts/release/compute_versions.py
new file mode 100755
index 000000000..36fa23d5f
--- /dev/null
+++ b/scripts/release/compute_versions.py
@@ -0,0 +1,438 @@
+#!/usr/bin/env python3
+"""Version bump logic for the ``guppylang`` and ``guppylang-internals`` releases.
+
+``guppylang`` follows semantic versioning with an optional pre-release suffix if
+the language is unstable (e.g. ``1.0.0-a5``). ``guppylang-internals`` uses the
+custom scheme ``.`` (e.g. ``1.0``, ``1.1``, ...). The
+internals build number is incremented on every release and reset to ``0``
+whenever the ``guppylang`` major version changes.
+
+Bump modes for ``guppylang``:
+
+* ``auto`` -> ask ``git-cliff`` what the conventional commits imply and express
+ that in the current pre-release scheme: a breaking/feature/fix bump of the
+ release core maps to ``alpha-major``/``alpha-minor``/``alpha-patch``, while an
+ unchanged core (the usual pre-release case) just increments the alpha number.
+* ``alpha`` -> ``1.0.0-a1`` becomes ``1.0.0-a2``
+* ``alpha-patch`` -> ``1.2.3`` becomes ``1.2.4-a0``
+* ``alpha-minor`` -> ``1.2.3`` becomes ``1.3.0-a0``
+* ``alpha-major`` -> ``1.2.3`` becomes ``2.0.0-a0``
+* ``beta`` -> ``1.0.0-a1`` becomes ``1.0.0-b0``; ``b1`` becomes ``b2``
+* ``rc`` -> ``1.0.0-X`` becomes ``1.0.0-rc0``; ``rc1`` becomes ``rc2``
+* ``stable``-> ``1.0.0-X`` becomes ``1.0.0`` (drops the pre-release)
+* ``patch`` -> ``1.0.1`` becomes ``1.0.2``
+* ``minor`` -> ``1.2.1`` becomes ``1.3.0``
+* ``major`` -> ``1.3.0`` becomes ``2.0.0``
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import subprocess
+import sys
+from dataclasses import dataclass
+from enum import Enum
+from pathlib import Path
+
+
+class BumpMode(str, Enum):
+ auto = "auto"
+ alpha = "alpha"
+ alpha_patch = "alpha-patch"
+ alpha_minor = "alpha-minor"
+ alpha_major = "alpha-major"
+ beta = "beta"
+ rc = "rc"
+ stable = "stable"
+ patch = "patch"
+ minor = "minor"
+ major = "major"
+
+
+class PreLabel(str, Enum):
+ alpha = "a"
+ beta = "b"
+ rc = "rc"
+
+
+GUPPYLANG_PYPROJECT = "guppylang/pyproject.toml"
+GUPPYLANG_INIT = "guppylang/src/guppylang/__init__.py"
+INTERNALS_PYPROJECT = "guppylang-internals/pyproject.toml"
+INTERNALS_INIT = "guppylang-internals/src/guppylang_internals/__init__.py"
+
+# Scoping used to ask git-cliff about the next guppylang version in ``auto`` mode.
+GUPPYLANG_TAG_PATTERN = "^guppylang-v"
+GUPPYLANG_INCLUDE_PATH = "guppylang/**"
+DEFAULT_CLIFF_CONFIG = "cliff.toml"
+
+_GUPPY_RE = re.compile(
+ r"^(?P\d+)\.(?P\d+)\.(?P\d+)"
+ r"(?:-?(?Pa|b|rc)(?P\d+))?$"
+)
+_INTERNALS_RE = re.compile(r"^(?P\d+)\.(?P\d+)$")
+
+_VERSION_LINE_RE = re.compile(r'(?m)^version = "[^"]*"')
+_DUNDER_VERSION_RE = re.compile(r'(?m)^__version__ = "[^"]*"')
+_INTERNALS_DEP_RE = re.compile(r'"guppylang-internals[^"]*"')
+_CORE_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
+
+
+@dataclass(frozen=True)
+class GuppyVersion:
+ major: int
+ minor: int
+ patch: int
+ pre_label: PreLabel | None = None
+ pre_num: int | None = None
+
+ @property
+ def is_prerelease(self) -> bool:
+ return self.pre_label is not None
+
+ def render(self) -> str:
+ core = f"{self.major}.{self.minor}.{self.patch}"
+ if self.pre_label is None:
+ return core
+ return f"{core}-{self.pre_label.value}{self.pre_num}"
+
+
+def parse_guppy_version(text: str) -> GuppyVersion:
+ match = _GUPPY_RE.match(text.strip())
+ if match is None:
+ msg = f"Cannot parse guppylang version: {text!r}"
+ raise ValueError(msg)
+ pre_label = match.group("pre_label")
+ pre_num = match.group("pre_num")
+ return GuppyVersion(
+ major=int(match.group("major")),
+ minor=int(match.group("minor")),
+ patch=int(match.group("patch")),
+ pre_label=PreLabel(pre_label) if pre_label is not None else None,
+ pre_num=int(pre_num) if pre_num is not None else None,
+ )
+
+
+def bump_guppylang(current: GuppyVersion, mode: str) -> GuppyVersion:
+ """Compute the next ``guppylang`` version for the given bump mode."""
+ match mode:
+ # If we remain with auto even after consulting git-cliff earlier, bump on best
+ # effort basis.
+ case BumpMode.auto:
+ if current.is_prerelease:
+ return GuppyVersion(
+ current.major,
+ current.minor,
+ current.patch,
+ current.pre_label,
+ current.pre_num + 1,
+ )
+ else:
+ return GuppyVersion(current.major, current.minor, current.patch + 1)
+
+ case BumpMode.alpha:
+ if current.pre_label != PreLabel.alpha:
+ msg = (
+ f"Cannot 'alpha'-bump {current.render()!r}: expected alpha series."
+ "Use 'alpha-{patch,minor,major}' to start a new series."
+ )
+ raise ValueError(msg)
+ return GuppyVersion(
+ current.major,
+ current.minor,
+ current.patch,
+ PreLabel.alpha,
+ current.pre_num + 1,
+ )
+
+ case BumpMode.alpha_patch:
+ return GuppyVersion(
+ current.major,
+ current.minor,
+ current.patch + 1,
+ PreLabel.alpha,
+ pre_num=0,
+ )
+ case BumpMode.alpha_minor:
+ return GuppyVersion(
+ current.major, current.minor + 1, 0, PreLabel.alpha, pre_num=0
+ )
+ case BumpMode.alpha_major:
+ return GuppyVersion(current.major + 1, 0, 0, PreLabel.alpha, pre_num=0)
+
+ case BumpMode.beta:
+ if current.pre_label == PreLabel.alpha:
+ next_num = 0
+ elif current.pre_label == PreLabel.beta:
+ next_num = current.pre_num + 1
+ else:
+ msg = (
+ f"Cannot 'beta'-bump {current.render()!r}: expected alpha or beta "
+ "series. Use 'alpha-{patch,minor,major}' to start a new series."
+ )
+ raise ValueError(msg)
+ return GuppyVersion(
+ current.major, current.minor, current.patch, PreLabel.beta, next_num
+ )
+
+ case BumpMode.rc:
+ if current.pre_label in (PreLabel.alpha, PreLabel.beta):
+ next_num = 0
+ elif current.pre_label == PreLabel.rc:
+ next_num = current.pre_num + 1
+ else:
+ msg = f"Cannot 'rc'-bump non-prerelease version: {current.render()!r}."
+ raise ValueError(msg)
+ return GuppyVersion(
+ current.major, current.minor, current.patch, PreLabel.rc, next_num
+ )
+
+ case BumpMode.stable:
+ if not current.is_prerelease:
+ msg = (
+ f"{current.render()!r} already stable; use 'patch'/'minor'/'major'."
+ )
+ raise ValueError(msg)
+ return GuppyVersion(current.major, current.minor, current.patch)
+
+ case BumpMode.patch:
+ return GuppyVersion(current.major, current.minor, current.patch + 1)
+ case BumpMode.minor:
+ return GuppyVersion(current.major, current.minor + 1, 0)
+ case BumpMode.major:
+ return GuppyVersion(current.major + 1, 0, 0)
+
+ bump_modes = [m.value for m in BumpMode]
+ msg = f"Unknown mode: {mode!r} (must be one of {', '.join(bump_modes)})"
+ raise ValueError(msg)
+
+
+def _auto_mode_from_core(
+ current: GuppyVersion, bumped_core: tuple[int, int, int] | None
+) -> BumpMode:
+ if bumped_core is not None and not current.is_prerelease:
+ major, minor, patch = bumped_core
+ if major > current.major:
+ return BumpMode.major
+ if major == current.major and minor > current.minor:
+ return BumpMode.minor
+ if (major, minor) == (current.major, current.minor) and patch > current.patch:
+ return BumpMode.patch
+ return BumpMode.auto
+
+
+def _git_cliff_bumped_core(root: Path) -> tuple[int, int, int] | None:
+ """Return the ``major.minor.patch`` git-cliff proposes for ``guppylang``.
+
+ Returns ``None`` when git-cliff is unavailable or produces no parseable
+ version (e.g. when there are no releasable commits).
+ """
+ cmd = [
+ "git-cliff",
+ "--config",
+ DEFAULT_CLIFF_CONFIG,
+ "--include-path",
+ GUPPYLANG_INCLUDE_PATH,
+ "--tag-pattern",
+ GUPPYLANG_TAG_PATTERN,
+ "--bumped-version",
+ ]
+ try:
+ result = subprocess.run( # noqa: S603
+ cmd, cwd=str(root), capture_output=True, text=True, check=True
+ )
+ except (OSError, subprocess.SubprocessError):
+ return None
+ match = _CORE_RE.search(result.stdout)
+ if match is None:
+ return None
+ return (int(match[1]), int(match[2]), int(match[3]))
+
+
+def try_resolve_auto_mode(current: GuppyVersion, root: Path) -> BumpMode:
+ """Resolve the ``auto`` bump mode by consulting git-cliff, if possible."""
+ bumped_core = _git_cliff_bumped_core(root)
+ return _auto_mode_from_core(current, bumped_core)
+
+
+def bump_internals(current_text: str, new_guppy_major: int) -> str:
+ """Compute the next ``guppylang-internals`` version.
+
+ Increments the build number, resetting it to ``0`` when the ``guppylang``
+ major version changes. Versions that do not yet follow the
+ ``.`` scheme (e.g. the legacy ``1.0.0-a5``) are treated as a
+ migration and seeded at ``.0``.
+ """
+ match = _INTERNALS_RE.match(current_text.strip())
+ if match is None:
+ # Seed the first build of the new series as 0
+ return f"{new_guppy_major}.0"
+ current_major = int(match.group("major"))
+ current_build = int(match.group("build"))
+ if current_major != new_guppy_major:
+ return f"{new_guppy_major}.0"
+ return f"{new_guppy_major}.{current_build + 1}"
+
+
+def _replace_once(
+ pattern: re.Pattern[str], replacement: str, text: str, *, what: str
+) -> str:
+ new_text, count = pattern.subn(replacement, text, count=1)
+ if count != 1:
+ msg = f"Expected exactly one {what} to replace, found {count}."
+ raise ValueError(msg)
+ return new_text
+
+
+def set_version_in_pyproject(text: str, version: str) -> str:
+ return _replace_once(
+ _VERSION_LINE_RE, f'version = "{version}"', text, what="project version"
+ )
+
+
+def set_dunder_version(text: str, version: str) -> str:
+ return _replace_once(
+ _DUNDER_VERSION_RE, f'__version__ = "{version}"', text, what="__version__"
+ )
+
+
+def set_internals_pin(text: str, internals_version: str) -> str:
+ return _replace_once(
+ _INTERNALS_DEP_RE,
+ f'"guppylang-internals=={internals_version}"',
+ text,
+ what="guppylang-internals dependency",
+ )
+
+
+def _read(path: Path) -> str:
+ return path.read_text(encoding="utf-8")
+
+
+def _write(path: Path, text: str) -> None:
+ path.write_text(text, encoding="utf-8")
+
+
+def read_current_guppylang(root: Path) -> GuppyVersion:
+ text = _read(root / GUPPYLANG_PYPROJECT)
+ match = _VERSION_LINE_RE.search(text)
+ if match is None:
+ msg = f"No project version found in {GUPPYLANG_PYPROJECT}"
+ raise ValueError(msg)
+ raw = match.group(0).split('"')[1]
+ return parse_guppy_version(raw)
+
+
+def read_current_internals(root: Path) -> str:
+ text = _read(root / INTERNALS_PYPROJECT)
+ match = _VERSION_LINE_RE.search(text)
+ if match is None:
+ msg = f"No project version found in {INTERNALS_PYPROJECT}"
+ raise ValueError(msg)
+ return match.group(0).split('"')[1]
+
+
+def cmd_compute(args: argparse.Namespace) -> int:
+ root = Path(args.repo_root)
+ current = read_current_guppylang(root)
+ internals = read_current_internals(root)
+ mode = BumpMode(args.bump)
+ if mode is BumpMode.auto:
+ mode = try_resolve_auto_mode(current, root)
+ new_guppy = bump_guppylang(current, mode)
+ new_internals = bump_internals(internals, new_guppy.major)
+
+ lines = [
+ f"bump_mode={mode.value}",
+ f"current_guppylang={current.render()}",
+ f"current_internals={internals}",
+ f"guppylang={new_guppy.render()}",
+ f"internals={new_internals}",
+ ]
+ print("\n".join(lines))
+ if args.github_output is not None:
+ with Path(args.github_output).open("a", encoding="utf-8") as fh:
+ fh.write("\n".join(lines) + "\n")
+ return 0
+
+
+def cmd_set_internals(args: argparse.Namespace) -> int:
+ root = Path(args.repo_root)
+ pyproject = root / INTERNALS_PYPROJECT
+ init = root / INTERNALS_INIT
+ _write(pyproject, set_version_in_pyproject(_read(pyproject), args.version))
+ _write(init, set_dunder_version(_read(init), args.version))
+ return 0
+
+
+def cmd_set_guppylang(args: argparse.Namespace) -> int:
+ root = Path(args.repo_root)
+ pyproject = root / GUPPYLANG_PYPROJECT
+ init = root / GUPPYLANG_INIT
+ _write(pyproject, set_version_in_pyproject(_read(pyproject), args.version))
+ _write(init, set_dunder_version(_read(init), args.version))
+ return 0
+
+
+def cmd_set_pin(args: argparse.Namespace) -> int:
+ root = Path(args.repo_root)
+ pyproject = root / GUPPYLANG_PYPROJECT
+ _write(pyproject, set_internals_pin(_read(pyproject), args.version))
+ return 0
+
+
+def _default_repo_root() -> Path:
+ return Path(__file__).resolve().parents[2]
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--repo-root",
+ default=str(_default_repo_root()),
+ help="Repository root (defaults to the script's repository).",
+ )
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ compute = sub.add_parser("compute", help="Compute and print the next versions.")
+ compute.add_argument(
+ "--bump", choices=BumpMode.__members__.values(), default="auto"
+ )
+ compute.add_argument(
+ "--github-output",
+ default=None,
+ help="Optional path of a GITHUB_OUTPUT file to append the results to.",
+ )
+ compute.set_defaults(func=cmd_compute)
+
+ set_internals = sub.add_parser(
+ "set-internals", help="Write the guppylang-internals version."
+ )
+ set_internals.add_argument("version")
+ set_internals.set_defaults(func=cmd_set_internals)
+
+ set_guppylang = sub.add_parser("set-guppylang", help="Write the guppylang version.")
+ set_guppylang.add_argument("version")
+ set_guppylang.set_defaults(func=cmd_set_guppylang)
+
+ set_pin = sub.add_parser(
+ "set-pin", help="Pin the guppylang-internals dependency in guppylang."
+ )
+ set_pin.add_argument("version")
+ set_pin.set_defaults(func=cmd_set_pin)
+
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ return args.func(args)
+ except ValueError as err:
+ print(f"error: {err}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/release/extract_changelog.py b/scripts/release/extract_changelog.py
new file mode 100755
index 000000000..8089ba8cf
--- /dev/null
+++ b/scripts/release/extract_changelog.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""Extract a single version's section from a ``CHANGELOG.md`` file.
+
+A section starts at a ``## [] ...`` heading and ends just before the next
+``## `` heading (or the end of the file). The ``## [...]`` heading line itself is
+omitted from the output of this script.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+
+def extract_section(changelog: str, version: str) -> str:
+ """Return the changelog body for ``version`` (raises ``KeyError`` if absent)."""
+ # Match the version *exactly*: the version must not be followed by another
+ # version character, so e.g. ``1.0`` does not prefix-match ``1.0.0-a4``.
+ boundary = r"(?![\w.-])"
+ header_re = re.compile(r"^## (?!\[?" + re.escape(version) + boundary + r")")
+ target_re = re.compile(r"^## \[?" + re.escape(version) + boundary)
+
+ lines = changelog.splitlines()
+ start: int | None = None
+ for index, line in enumerate(lines):
+ if target_re.match(line):
+ start = index
+ break
+ if start is None:
+ msg = f"No changelog section found for version {version!r}"
+ raise KeyError(msg)
+
+ end = len(lines)
+ for index in range(start + 1, len(lines)):
+ if header_re.match(lines[index]):
+ end = index
+ break
+
+ body_start = start + 1 # Omit the header line containing the version.
+ section = "\n".join(lines[body_start:end]).strip()
+ return section
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("changelog", help="Path to the CHANGELOG.md file.")
+ parser.add_argument("version", help="The version section to extract.")
+ args = parser.parse_args(argv)
+
+ text = Path(args.changelog).read_text(encoding="utf-8")
+ try:
+ section = extract_section(text, args.version)
+ except KeyError as err:
+ print(f"error: {err}", file=sys.stderr)
+ return 1
+ print(section)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/release/render_pr_body.py b/scripts/release/render_pr_body.py
new file mode 100755
index 000000000..8da888040
--- /dev/null
+++ b/scripts/release/render_pr_body.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""Render the release-notes preview block in a release PR's body.
+
+The preview shows, verbatim, the changelog sections that will be published for
+each package -- i.e. the exact output of ``extract_changelog.py``. It lives
+between two HTML-comment markers so it can be refreshed in place on every push to
+the release branch without disturbing the rest of the PR description.
+
+This script only manipulates text; it never talks to GitHub. The workflow pipes
+the current PR body in and the rendered body out.
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+BEGIN = ""
+END = ""
+
+
+def render_block(packages: list[tuple[str, str, str]]) -> str:
+ """Render the preview block for ``(name, version, section)`` triples."""
+ parts = [
+ BEGIN,
+ "## :memo: Release notes preview",
+ "",
+ "Extracted verbatim from the committed changelogs. This is **exactly** "
+ "what will be published to the GitHub releases.",
+ ]
+ for name, version, section in packages:
+ body = section.strip() or "_No changelog entry._"
+ parts += [
+ "",
+ f"{name} {version}
",
+ "",
+ body,
+ "",
+ " ",
+ ]
+ parts.append(END)
+ return "\n".join(parts)
+
+
+def update_body(body: str, block: str) -> str:
+ """Insert or replace the preview block in ``body``."""
+ if BEGIN in body and END in body:
+ before = body[: body.index(BEGIN)]
+ after = body[body.index(END) + len(END) :]
+ return f"{before.rstrip()}\n\n{block}\n{after.lstrip()}".rstrip() + "\n"
+ return f"{body.rstrip()}\n\n{block}\n"
+
+
+def _read(path: str | None) -> str:
+ if path in (None, "-"):
+ return sys.stdin.read()
+ return Path(path).read_text(encoding="utf-8")
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--body", default="-", help="Current PR body file ('-' = stdin)."
+ )
+ parser.add_argument(
+ "--package",
+ action="append",
+ default=[],
+ nargs=3,
+ metavar=("NAME", "VERSION", "SECTION_FILE"),
+ help="A package's name, version and the file with its changelog section.",
+ )
+ args = parser.parse_args(argv)
+
+ packages = [
+ (name, version, Path(section_file).read_text(encoding="utf-8"))
+ for name, version, section_file in args.package
+ ]
+ block = render_block(packages)
+ sys.stdout.write(update_body(_read(args.body), block))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/release/update_changelog.py b/scripts/release/update_changelog.py
new file mode 100755
index 000000000..ca69a6f09
--- /dev/null
+++ b/scripts/release/update_changelog.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+"""Insert a freshly generated section into a ``CHANGELOG.md``.
+
+The release workflow asks git-cliff for a single ``## [] ...`` section
+and uses this script to splice it into the package's ``CHANGELOG.md`` just above
+the most recent existing version, preserving the file's title/intro/front-matter.
+
+The operation is idempotent: if a section for ``version`` already exists (e.g. a
+previous draft of the same release), it is removed first and replaced. This is
+what lets the workflow regenerate the draft on every push (while the
+``X-regen-changelog`` label is set) without piling up duplicate sections.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+
+def _section_bounds(lines: list[str], version: str) -> tuple[int, int] | None:
+ """Return the ``[start, end)`` line range of ``version``'s section, if any."""
+ boundary = r"(?![\w.-])"
+ target_re = re.compile(r"^## \[?" + re.escape(version) + boundary)
+ start: int | None = None
+ for index, line in enumerate(lines):
+ if target_re.match(line):
+ start = index
+ break
+ if start is None:
+ return None
+ end = len(lines)
+ for index in range(start + 1, len(lines)):
+ if lines[index].startswith("## "):
+ end = index
+ break
+ return start, end
+
+
+def _first_version_header(lines: list[str]) -> int | None:
+ for index, line in enumerate(lines):
+ if line.startswith("## ["):
+ return index
+ return None
+
+
+def update_changelog(changelog: str, version: str, section: str) -> str:
+ """Return ``changelog`` with ``section`` inserted/replaced for ``version``."""
+ lines = changelog.splitlines()
+ section_block = section.strip("\n").splitlines()
+
+ # Drop any existing section for this version so regeneration is idempotent.
+ existing = _section_bounds(lines, version)
+ if existing is not None:
+ start, end = existing
+ del lines[start:end]
+ insert_at = start
+ else:
+ first = _first_version_header(lines)
+ insert_at = first if first is not None else len(lines)
+
+ # Ensure a blank line separates the new section from following content.
+ block = [*section_block, ""]
+ new_lines = lines[:insert_at] + block + lines[insert_at:]
+ text = "\n".join(new_lines)
+ if not text.endswith("\n"):
+ text += "\n"
+ return text
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("changelog", help="Path to the CHANGELOG.md to update.")
+ parser.add_argument("version", help="The version of the new section.")
+ parser.add_argument(
+ "section",
+ help="Path to a file containing the rendered '## [version] ...' section.",
+ )
+ args = parser.parse_args(argv)
+
+ changelog_path = Path(args.changelog)
+ section_text = Path(args.section).read_text(encoding="utf-8")
+ if not section_text.strip():
+ print("error: generated section is empty", file=sys.stderr)
+ return 1
+
+ updated = update_changelog(
+ changelog_path.read_text(encoding="utf-8"), args.version, section_text
+ )
+ changelog_path.write_text(updated, encoding="utf-8")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/release/__init__.py b/tests/release/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/release/conftest.py b/tests/release/conftest.py
new file mode 100644
index 000000000..62bdb70eb
--- /dev/null
+++ b/tests/release/conftest.py
@@ -0,0 +1,16 @@
+"""Make the release scripts importable from the tests.
+
+The scripts under ``scripts/release`` are standalone (no package), so we add that
+directory to ``sys.path`` here, allowing the tests to ``import compute_versions``
+and ``import extract_changelog`` directly.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "scripts" / "release"
+
+if str(_SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(_SCRIPTS_DIR))
diff --git a/tests/release/test_compute_versions.py b/tests/release/test_compute_versions.py
new file mode 100644
index 000000000..c9bc5e74f
--- /dev/null
+++ b/tests/release/test_compute_versions.py
@@ -0,0 +1,157 @@
+"""Tests for the version bump logic in ``scripts/release/compute_versions.py``."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import compute_versions as cv
+import pytest
+
+POSITIVE_BUMP_TESTS = [
+ # auto (best-effort, used when git-cliff cannot refine the bump):
+ # a pre-release just increments its number, a stable version bumps patch.
+ ("1.0.0-a5", "auto", "1.0.0-a6"),
+ ("1.2.3", "auto", "1.2.4"),
+ # alpha: increment the alpha number within the same series.
+ ("1.0.0-a5", "alpha", "1.0.0-a6"),
+ ("1.0.0-a9", "alpha", "1.0.0-a10"),
+ # alpha-{patch,minor,major}: start a fresh alpha series off a core bump.
+ ("1.2.3", "alpha-patch", "1.2.4-a0"),
+ ("1.2.3", "alpha-minor", "1.3.0-a0"),
+ ("1.2.3", "alpha-major", "2.0.0-a0"),
+ # ... also valid starting from an existing pre-release, regardless of type.
+ ("1.2.3-a5", "alpha-patch", "1.2.4-a0"),
+ ("1.2.3-rc1", "alpha-minor", "1.3.0-a0"),
+ ("1.2.3-b2", "alpha-major", "2.0.0-a0"),
+ # beta: promote alpha -> b0, or increment an existing beta series.
+ ("1.0.0-a5", "beta", "1.0.0-b0"),
+ ("1.0.0-b1", "beta", "1.0.0-b2"),
+ # rc: promote alpha/beta -> rc0, or increment an existing rc series.
+ ("1.0.0-a5", "rc", "1.0.0-rc0"),
+ ("1.0.0-b2", "rc", "1.0.0-rc0"),
+ ("1.0.0-rc1", "rc", "1.0.0-rc2"),
+ # stable: drop the pre-release suffix.
+ ("1.0.0-a5", "stable", "1.0.0"),
+ ("1.0.0-rc2", "stable", "1.0.0"),
+ # patch/minor/major: plain semver bumps that drop any pre-release.
+ ("1.0.1", "patch", "1.0.2"),
+ ("1.0.0-a5", "patch", "1.0.1"),
+ ("1.2.1", "minor", "1.3.0"),
+ ("1.2.1-a5", "minor", "1.3.0"),
+ ("1.3.0", "major", "2.0.0"),
+ ("1.3.0-a5", "major", "2.0.0"),
+]
+
+
+@pytest.mark.parametrize(("current", "mode", "expected"), POSITIVE_BUMP_TESTS)
+def test_bump_guppylang(current: str, mode: str, expected: str) -> None:
+ result = cv.bump_guppylang(cv.parse_guppy_version(current), mode)
+ assert result.render() == expected
+
+
+NEGATIVE_BUMP_TESTS = [
+ # alpha-bump requires an existing alpha series.
+ ("1.0.0", "alpha", "alpha'-bump"),
+ ("1.0.0-b1", "alpha", "alpha'-bump"),
+ ("1.0.0-rc1", "alpha", "alpha'-bump"),
+ # beta-bump requires an alpha or beta series.
+ ("1.0.0", "beta", "expected alpha or beta"),
+ ("1.0.0-rc1", "beta", "expected alpha or beta"),
+ # rc-bump requires a pre-release.
+ ("1.0.0", "rc", "non-prerelease"),
+ # stable requires a pre-release to drop.
+ ("1.0.0", "stable", "already stable"),
+]
+
+
+@pytest.mark.parametrize(("current", "mode", "match"), NEGATIVE_BUMP_TESTS)
+def test_bump_guppylang_invalid(current: str, mode: str, match: str) -> None:
+ with pytest.raises(ValueError, match=match):
+ cv.bump_guppylang(cv.parse_guppy_version(current), mode)
+
+
+def test_bump_guppylang_unknown_mode() -> None:
+ with pytest.raises(ValueError, match="Unknown mode"):
+ cv.bump_guppylang(cv.parse_guppy_version("1.0.0"), "wibble-mode")
+
+
+def test_every_bump_mode_is_exercised() -> None:
+ """Guard against a new ``BumpMode`` slipping through untested."""
+ expected_positive = {mode for _, mode, _ in POSITIVE_BUMP_TESTS}
+ expected_negative = {mode for _, mode, _ in NEGATIVE_BUMP_TESTS}
+ expected = expected_negative.union(expected_positive)
+ assert {mode.value for mode in cv.BumpMode} == expected
+
+
+@pytest.mark.parametrize(
+ ("current", "bumped_core", "expected"),
+ [
+ # git-cliff unavailable / no releasable commits -> stay on auto.
+ ("1.0.0-a5", None, cv.BumpMode.auto),
+ # An unchanged release core is the usual pre-release case -> stay on auto.
+ ("1.0.0-a5", (1, 0, 0), cv.BumpMode.auto),
+ # A bumped release core maps onto the matching semver bump.
+ ("1.2.3", (1, 2, 4), cv.BumpMode.patch),
+ ("1.2.3", (1, 3, 0), cv.BumpMode.minor),
+ ("1.2.3", (2, 0, 0), cv.BumpMode.major),
+ # A lower / equal core never downgrades the bump.
+ ("1.2.3", (1, 2, 3), cv.BumpMode.auto),
+ ],
+)
+def test_auto_mode_from_core(
+ current: str, bumped_core: tuple[int, int, int] | None, expected: cv.BumpMode
+) -> None:
+ result = cv._auto_mode_from_core(cv.parse_guppy_version(current), bumped_core)
+ assert result is expected
+
+
+def test_try_resolve_auto_mode_uses_git_cliff(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(cv, "_git_cliff_bumped_core", lambda root: (1, 3, 0))
+ mode = cv.try_resolve_auto_mode(cv.parse_guppy_version("1.2.3"), Path())
+ assert mode is cv.BumpMode.minor
+
+
+def test_try_resolve_auto_mode_falls_back_when_git_cliff_missing(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(cv, "_git_cliff_bumped_core", lambda root: None)
+ mode = cv.try_resolve_auto_mode(cv.parse_guppy_version("1.0.0-a5"), Path())
+ assert mode is cv.BumpMode.auto
+
+
+def test_git_cliff_bumped_core_handles_missing_binary(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ def boom(*_args: object, **_kwargs: object) -> object:
+ raise OSError("git-cliff not found")
+
+ monkeypatch.setattr(cv.subprocess, "run", boom)
+ assert cv._git_cliff_bumped_core(Path()) is None
+
+
+@pytest.mark.parametrize(
+ ("current_internals", "new_major", "expected"),
+ [
+ # Migration from the legacy 3-part scheme seeds build 0.
+ ("1.0.0-a5", 1, "1.0"),
+ ("2.3.4", 2, "2.0"),
+ # Normal build increment within the same major.
+ ("1.0", 1, "1.1"),
+ ("1.7", 1, "1.8"),
+ # Reset to build 0 on a major bump.
+ ("1.7", 2, "2.0"),
+ ("1.0", 3, "3.0"),
+ ],
+)
+def test_bump_internals(current_internals: str, new_major: int, expected: str) -> None:
+ assert cv.bump_internals(current_internals, new_major) == expected
+
+
+def test_replace_once_requires_single_match() -> None:
+ # The file-rewriting helpers refuse to touch a file with no version line,
+ # rather than silently producing a no-op. (The happy paths are covered
+ # end-to-end in test_release_integration.py.)
+ with pytest.raises(ValueError, match="Expected exactly one"):
+ cv.set_dunder_version("no version here", "1.0.0")
diff --git a/tests/release/test_extract_changelog.py b/tests/release/test_extract_changelog.py
new file mode 100644
index 000000000..355a44d79
--- /dev/null
+++ b/tests/release/test_extract_changelog.py
@@ -0,0 +1,116 @@
+"""Tests for ``scripts/release/extract_changelog.py``."""
+
+from __future__ import annotations
+
+import extract_changelog as ec
+import pytest
+
+CHANGELOG = """\
+# Changelog
+
+## A confusing header at the beginning
+
+Some intro prose that must never appear in release notes.
+
+## [1.0.0-a6](https://example.com/compare/a5...a6) (2026-06-19)
+
+### Features
+
+* Add a shiny new thing ([#1900](https://example.com/1900))
+
+### Bug Fixes
+
+* Fix an old thing ([#1899](https://example.com/1899))
+
+## [1.0.0-a5](https://example.com/compare/a4...a5) (2026-06-15)
+
+### Features
+
+* An older feature ([#1850](https://example.com/1850))
+"""
+
+
+def test_extract_latest_section() -> None:
+ section = ec.extract_section(CHANGELOG, "1.0.0-a6")
+ assert section.startswith("### Features")
+ assert "Add a shiny new thing" in section
+ assert "Fix an old thing" in section
+ # Must not bleed into the previous version or the intro.
+ assert "An older feature" not in section
+ assert "confusing header" not in section
+ assert "intro prose" not in section
+ # The header line itself is omitted from the body.
+ assert "## [1.0.0-a6]" not in section
+
+
+def test_extract_older_section() -> None:
+ # The final section runs to the end of the file.
+ section = ec.extract_section(CHANGELOG, "1.0.0-a5")
+ assert "An older feature" in section
+ assert "Add a shiny new thing" not in section
+
+
+def test_extract_missing_version() -> None:
+ with pytest.raises(KeyError, match=r"9\.9\.9"):
+ ec.extract_section(CHANGELOG, "9.9.9")
+
+
+def test_plain_header_without_brackets() -> None:
+ # git-cliff can emit headers without the markdown link brackets.
+ changelog = "# Changelog\n\n## 1.0.0-a6\n\n* a change\n\n## 1.0.0-a5\n\n* old\n"
+ section = ec.extract_section(changelog, "1.0.0-a6")
+ assert "a change" in section
+ assert "old" not in section
+
+
+def test_plain_internals_version_header() -> None:
+ changelog = (
+ "# Changelog\n\n## [1.1](https://x) (2026-06-19)\n\n"
+ "* internals change\n\n## [1.0](https://x)\n\n* old\n"
+ )
+ section = ec.extract_section(changelog, "1.1")
+ assert "internals change" in section
+ assert "old" not in section
+
+
+def test_short_version_does_not_prefix_match_longer_one() -> None:
+ """``1.0`` must not match a legacy ``1.0.0-aX`` section that may sit below it."""
+ changelog = (
+ "# Changelog\n\n"
+ "## [1.0](https://x) (2026-06-19)\n\n"
+ "* the real 1.0 notes\n\n"
+ "## [1.0.0-a4](https://x) (2025-01-01)\n\n"
+ "* legacy alpha notes\n"
+ )
+ section = ec.extract_section(changelog, "1.0")
+ assert "the real 1.0 notes" in section
+ assert "legacy alpha notes" not in section
+
+
+def test_stable_version_distinct_from_prerelease() -> None:
+ """``1.0.0`` must not match a prerelease ``1.0.0-aX`` section that may sit below
+ it."""
+ changelog = (
+ "# Changelog\n\n"
+ "## [1.0.0](https://x) (2026-06-20)\n\n"
+ "* stable notes\n\n"
+ "## [1.0.0-a6](https://x) (2026-06-19)\n\n"
+ "* alpha notes\n"
+ )
+ section = ec.extract_section(changelog, "1.0.0")
+ assert "stable notes" in section
+ assert "alpha notes" not in section
+
+
+def test_prerelease_does_not_prefix_match_longer_number() -> None:
+ """``1.0.0-a6`` must not match ``1.0.0-a60``."""
+ changelog = (
+ "# Changelog\n\n"
+ "## [1.0.0-a60](https://x)\n\n"
+ "* the sixtieth alpha\n\n"
+ "## [1.0.0-a6](https://x)\n\n"
+ "* the sixth alpha\n"
+ )
+ section = ec.extract_section(changelog, "1.0.0-a6")
+ assert "the sixth alpha" in section
+ assert "the sixtieth alpha" not in section
diff --git a/tests/release/test_release_integration.py b/tests/release/test_release_integration.py
new file mode 100644
index 000000000..abd7bb184
--- /dev/null
+++ b/tests/release/test_release_integration.py
@@ -0,0 +1,228 @@
+"""End-to-end tests driving the release scripts through their CLI entry points."""
+
+from __future__ import annotations
+
+import textwrap
+from typing import TYPE_CHECKING
+
+import compute_versions as cv
+import extract_changelog as ec
+import pytest
+import update_changelog as uc
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@pytest.fixture
+def fake_repo(tmp_path: Path) -> Path:
+ """A minimal two-package repo laid out like the main guppylang repo."""
+ files = {
+ "guppylang/pyproject.toml": textwrap.dedent("""\
+ [project]
+ name = "guppylang"
+ version = "1.0.0-a5"
+ requires-python = ">=3.10"
+ dependencies = [
+ "guppylang-internals==1.0.0-a5",
+ "numpy>=2.0",
+ ]
+ """),
+ "guppylang/src/guppylang/__init__.py": '__version__ = "1.0.0-a5"\n',
+ "guppylang/CHANGELOG.md": "# Changelog\n",
+ "guppylang-internals/pyproject.toml": textwrap.dedent("""\
+ [project]
+ name = "guppylang-internals"
+ version = "1.0"
+ requires-python = ">=3.10"
+ """),
+ "guppylang-internals/src/guppylang_internals/__init__.py": (
+ '__version__ = "1.0"\n'
+ ),
+ "guppylang-internals/CHANGELOG.md": "# Changelog\n",
+ }
+ repo = tmp_path / "repo"
+ for rel, content in files.items():
+ path = repo / rel
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return repo
+
+
+def _outputs(path: Path) -> dict[str, str]:
+ """Parse a ``key=value`` ``GITHUB_OUTPUT`` file into a dict."""
+ values: dict[str, str] = {}
+ for line in path.read_text(encoding="utf-8").splitlines():
+ key, sep, value = line.partition("=")
+ if sep:
+ values[key] = value
+ return values
+
+
+def test_compute_writes_github_output(fake_repo: Path, tmp_path: Path) -> None:
+ out = tmp_path / "gh_output"
+ rc = cv.main(
+ [
+ "--repo-root",
+ str(fake_repo),
+ "compute",
+ "--bump",
+ "rc",
+ "--github-output",
+ str(out),
+ ]
+ )
+ assert rc == 0
+ values = _outputs(out)
+ assert values["bump_mode"] == "rc"
+ assert values["current_guppylang"] == "1.0.0-a5"
+ assert values["current_internals"] == "1.0"
+ assert values["guppylang"] == "1.0.0-rc0"
+ assert values["internals"] == "1.1"
+
+
+def test_compute_auto_falls_back_without_git_cliff(
+ fake_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(cv, "_git_cliff_bumped_core", lambda root: None)
+ out = tmp_path / "gh_output"
+ rc = cv.main(
+ ["--repo-root", str(fake_repo), "compute", "--github-output", str(out)]
+ )
+ assert rc == 0
+ values = _outputs(out)
+ assert values["bump_mode"] == "auto"
+ # Best-effort on a pre-release just increments the alpha number.
+ assert values["guppylang"] == "1.0.0-a6"
+
+
+def test_compute_auto_does_not_resolve_via_git_cliff_if_prerelease(
+ fake_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # Start from a pre-release version.
+ assert cv.main(["--repo-root", str(fake_repo), "set-guppylang", "1.0.0-a7"]) == 0
+
+ monkeypatch.setattr(cv, "_git_cliff_bumped_core", lambda root: (1, 1, 0))
+ out = tmp_path / "gh_output"
+ rc = cv.main(
+ ["--repo-root", str(fake_repo), "compute", "--github-output", str(out)]
+ )
+ assert rc == 0
+ values = _outputs(out)
+ assert values["bump_mode"] == "auto" # Left as auto because we are in a prerelease
+ assert values["guppylang"] == "1.0.0-a8" # Alpha bumped in auto mode
+
+
+def test_compute_auto_resolves_via_git_cliff_if_not_prerelease(
+ fake_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # Start from a stable version.
+ assert cv.main(["--repo-root", str(fake_repo), "set-guppylang", "1.0.0"]) == 0
+
+ # git-cliff proposing a minor core bump flows through to a minor release.
+ monkeypatch.setattr(cv, "_git_cliff_bumped_core", lambda root: (1, 1, 0))
+ out = tmp_path / "gh_output"
+ rc = cv.main(
+ ["--repo-root", str(fake_repo), "compute", "--github-output", str(out)]
+ )
+ assert rc == 0
+ values = _outputs(out)
+ assert values["bump_mode"] == "minor"
+ assert values["guppylang"] == "1.1.0"
+
+
+def test_set_commands_rewrite_package_files(fake_repo: Path) -> None:
+ assert cv.main(["--repo-root", str(fake_repo), "set-guppylang", "1.0.0-rc0"]) == 0
+ assert cv.main(["--repo-root", str(fake_repo), "set-internals", "1.1"]) == 0
+ assert cv.main(["--repo-root", str(fake_repo), "set-pin", "1.1"]) == 0
+
+ guppy_pyproject = (fake_repo / "guppylang/pyproject.toml").read_text()
+ guppy_init = (fake_repo / "guppylang/src/guppylang/__init__.py").read_text()
+ internals_pyproject = (fake_repo / "guppylang-internals/pyproject.toml").read_text()
+ internals_init = (
+ fake_repo / "guppylang-internals/src/guppylang_internals/__init__.py"
+ ).read_text()
+
+ assert 'version = "1.0.0-rc0"' in guppy_pyproject
+ assert '__version__ = "1.0.0-rc0"' in guppy_init
+ assert 'version = "1.1"' in internals_pyproject
+ assert '__version__ = "1.1"' in internals_init
+ # The pin is rewritten without disturbing the other dependencies.
+ assert '"guppylang-internals==1.1"' in guppy_pyproject
+ assert '"numpy>=2.0"' in guppy_pyproject
+ assert "1.0.0-a5" not in guppy_pyproject
+
+
+def test_release_rehearsal_end_to_end(
+ fake_repo: Path,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ monkeypatch.setattr(cv, "_git_cliff_bumped_core", lambda root: None)
+
+ out = tmp_path / "gh_output"
+ assert (
+ cv.main(
+ [
+ "--repo-root",
+ str(fake_repo),
+ "compute",
+ "--bump",
+ "stable",
+ "--github-output",
+ str(out),
+ ]
+ )
+ == 0
+ )
+ values = _outputs(out)
+ guppy, internals = values["guppylang"], values["internals"]
+ assert guppy == "1.0.0" # 1.0.0-a5 promoted to stable
+ assert internals == "1.1"
+
+ # Apply the computed versions, exactly as the workflow does.
+ assert cv.main(["--repo-root", str(fake_repo), "set-internals", internals]) == 0
+ assert cv.main(["--repo-root", str(fake_repo), "set-guppylang", guppy]) == 0
+ assert cv.main(["--repo-root", str(fake_repo), "set-pin", internals]) == 0
+
+ assert 'version = "1.0.0"' in (fake_repo / "guppylang/pyproject.toml").read_text()
+ assert (
+ '"guppylang-internals==1.1"'
+ in (fake_repo / "guppylang/pyproject.toml").read_text()
+ )
+
+ # Seed a changelog section, then slice it back out for the release notes.
+ section = tmp_path / "section.md"
+ section.write_text(
+ f"## [{guppy}](https://x) (2026-06-22)\n\n### Features\n\n* Shipped it\n"
+ )
+ changelog = fake_repo / "guppylang/CHANGELOG.md"
+ assert uc.main([str(changelog), guppy, str(section)]) == 0
+ assert f"## [{guppy}]" in changelog.read_text()
+
+ capsys.readouterr() # drop output captured so far
+ assert ec.main([str(changelog), guppy]) == 0
+ notes = capsys.readouterr().out
+ assert "Shipped it" in notes
+ # The header line is not part of the extracted notes.
+ assert f"## [{guppy}]" not in notes
+
+
+def test_extract_changelog_cli_missing_version(
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
+) -> None:
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text("# Changelog\n\n## [1.0.0](https://x)\n\n* notes\n")
+ assert ec.main([str(changelog), "9.9.9"]) == 1
+ assert "9.9.9" in capsys.readouterr().err
+
+
+def test_update_changelog_cli_rejects_empty_section(tmp_path: Path) -> None:
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text("# Changelog\n")
+ section = tmp_path / "section.md"
+ section.write_text("\n")
+ assert uc.main([str(changelog), "1.0.0", str(section)]) == 1
+ # The changelog is left untouched on failure.
+ assert changelog.read_text() == "# Changelog\n"
diff --git a/tests/release/test_render_pr_body.py b/tests/release/test_render_pr_body.py
new file mode 100644
index 000000000..e0dcdcfd8
--- /dev/null
+++ b/tests/release/test_render_pr_body.py
@@ -0,0 +1,48 @@
+"""Tests for ``scripts/release/render_pr_body.py``."""
+
+from __future__ import annotations
+
+import render_pr_body as rp
+
+
+def test_render_block_contains_markers_and_sections() -> None:
+ block = rp.render_block(
+ [
+ ("guppylang", "1.0.0-a6", "### Features\n\n* thing"),
+ ("guppylang-internals", "1.6", "### Bug Fixes\n\n* fix"),
+ ]
+ )
+ assert block.startswith(rp.BEGIN)
+ assert block.endswith(rp.END)
+ assert "guppylang" in block
+ assert "1.0.0-a6" in block
+ assert "guppylang-internals" in block
+ assert "1.6" in block
+ assert "* thing" in block
+ assert "* fix" in block
+
+
+def test_render_block_empty_section_placeholder() -> None:
+ block = rp.render_block([("guppylang", "1.0.0-a6", " ")])
+ assert "_No changelog entry._" in block
+
+
+def test_update_body_inserts_when_absent() -> None:
+ body = "Some PR description.\n"
+ block = rp.render_block([("guppylang", "1.0.0-a6", "* x")])
+ out = rp.update_body(body, block)
+ assert "Some PR description." in out
+ assert rp.BEGIN in out
+ assert rp.END in out
+
+
+def test_update_body_replaces_in_place_idempotently() -> None:
+ body = "Intro.\n"
+ first = rp.update_body(body, rp.render_block([("guppylang", "1.0.0-a6", "* a")]))
+ second = rp.update_body(first, rp.render_block([("guppylang", "1.0.0-a7", "* b")]))
+ # The intro survives and only one preview block exists.
+ assert second.count(rp.BEGIN) == 1
+ assert second.count(rp.END) == 1
+ assert "Intro." in second
+ assert "1.0.0-a7" in second
+ assert "1.0.0-a6" not in second
diff --git a/tests/release/test_update_changelog.py b/tests/release/test_update_changelog.py
new file mode 100644
index 000000000..a45271933
--- /dev/null
+++ b/tests/release/test_update_changelog.py
@@ -0,0 +1,79 @@
+"""Tests for ``scripts/release/update_changelog.py``."""
+
+from __future__ import annotations
+
+import update_changelog as uc
+
+BASE = """\
+# Changelog
+
+Intro prose that stays at the top.
+
+## [1.0.0-a5](https://x/compare/a4...a5) (2026-06-15)
+
+### Features
+
+* Old feature ([#1](https://x/1))
+"""
+
+NEW_SECTION = """\
+## [1.0.0-a6](https://x/compare/a5...a6) (2026-06-19)
+
+### Features
+
+* New feature ([#2](https://x/2))
+"""
+
+
+def test_insert_above_latest() -> None:
+ out = uc.update_changelog(BASE, "1.0.0-a6", NEW_SECTION)
+ lines = out.splitlines()
+ # Intro preserved at the top.
+ assert lines[0] == "# Changelog"
+ assert "Intro prose that stays at the top." in out
+ # New section comes before the previous one.
+ assert out.index("1.0.0-a6") < out.index("1.0.0-a5")
+ assert "New feature" in out
+ assert "Old feature" in out
+
+
+def test_regeneration_is_idempotent() -> None:
+ once = uc.update_changelog(BASE, "1.0.0-a6", NEW_SECTION)
+ twice = uc.update_changelog(once, "1.0.0-a6", NEW_SECTION)
+ assert once == twice
+ assert twice.count("1.0.0-a6") == NEW_SECTION.count("1.0.0-a6")
+
+
+def test_replace_existing_draft() -> None:
+ once = uc.update_changelog(BASE, "1.0.0-a6", NEW_SECTION)
+ revised = NEW_SECTION.replace("New feature", "Revised feature")
+ out = uc.update_changelog(once, "1.0.0-a6", revised)
+ assert "Revised feature" in out
+ assert "New feature" not in out
+ assert out.count("## [1.0.0-a6]") == 1
+
+
+def test_insert_into_changelog_without_versions() -> None:
+ base = "# Changelog\n\nNothing released yet.\n"
+ out = uc.update_changelog(base, "1.0", NEW_SECTION.replace("1.0.0-a6", "1.0"))
+ assert "# Changelog" in out
+ assert "1.0" in out
+
+
+def test_replace_short_version_keeps_longer_prefixed_section() -> None:
+ """Replacing the ``1.0`` draft must not also swallow the legacy ``1.0.0-a4`` section
+ that shares its prefix."""
+ base = (
+ "# Changelog\n\n"
+ "## [1.0](https://x)\n\n"
+ "* draft 1.0 notes\n\n"
+ "## [1.0.0-a4](https://x)\n\n"
+ "* legacy alpha notes\n"
+ )
+ revised = "## [1.0](https://x)\n\n* revised 1.0 notes\n"
+ out = uc.update_changelog(base, "1.0", revised)
+ assert "revised 1.0 notes" in out
+ assert "draft 1.0 notes" not in out
+ # The legacy section below must be preserved untouched.
+ assert "legacy alpha notes" in out
+ assert out.count("## [1.0.0-a4]") == 1