Skip to content

Prepare Release

Prepare Release #15

name: Prepare Release
# Run manually from the Actions tab.
# Creates a branch + PR that bumps the version, builds the changelog,
# and updates the docs switcher — ready to review before tagging.
on:
workflow_dispatch:
inputs:
bump:
description: "Version component to bump"
required: true
type: choice
options:
- finalize # drop the bN suffix: release the current beta's base as stable (0.3.0b2 -> 0.3.0)
- minor
- bugfix
- major
- pre-release # increments the bN counter on the current base version
beta:
description: "Mark as beta pre-release (adds bN suffix). Ignored for 'pre-release' (always beta) and 'finalize' (always stable)."
required: false
type: boolean
default: false
permissions:
contents: write # push branch
pull-requests: write # open PR
jobs:
prepare:
name: Prepare release PR
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
python-version: "3.13"
enable-cache: true
- name: Install dev dependencies
run: uv sync
# ── Compute the new version ──────────────────────────────────────────
- name: Compute new version
id: version
env:
BUMP: ${{ inputs.bump }}
IS_BETA: ${{ inputs.beta }}
run: |
CURRENT=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
export CURRENT_VERSION="$CURRENT"
NEW_VERSION=$(python3 - <<'PYEOF'
import re, os
current = os.environ["CURRENT_VERSION"]
bump = os.environ["BUMP"]
is_beta = os.environ["IS_BETA"].lower() == "true"
m = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?", current)
major = int(m.group(1))
minor = int(m.group(2))
patch = int(m.group(3))
beta_n = int(m.group(4)) if m.group(4) else None
on_beta = beta_n is not None
if bump == "finalize":
# Release the current beta's base as stable: just drop the bN
# suffix, keep major.minor.patch. e.g. 0.3.0b2 -> 0.3.0.
if not on_beta:
raise SystemExit(
f"'finalize' requires a beta base version, but current "
f"version {current!r} has no bN suffix. Use minor / bugfix "
f"/ major to start a new release instead."
)
is_beta = False
elif bump == "pre-release":
# Keep the same base; just walk the beta counter forward.
is_beta = True
beta_n = (beta_n or 0) + 1
elif on_beta:
# We are on a beta of the NEXT release (e.g. 0.3.0b2). The base
# major.minor.patch is that upcoming version, so minor/bugfix/major
# must bump relative to the LAST STABLE (base - the in-progress
# component), not skip a whole version. The common intent from a
# beta is 'finalize', so steer the user there rather than guess.
raise SystemExit(
f"Current version {current!r} is a beta of the upcoming "
f"{major}.{minor}.{patch} release. To ship it, use bump="
f"'finalize' (-> {major}.{minor}.{patch}). A '{bump}' bump from "
f"a beta would skip {major}.{minor}.{patch} entirely "
f"(e.g. -> {'%d.%d.0' % (major, minor + 1) if bump == 'minor' else '...'}); "
f"that is almost never intended."
)
elif bump == "major":
major, minor, patch = major + 1, 0, 0
elif bump == "minor":
minor, patch = minor + 1, 0
elif bump == "bugfix":
patch += 1
if is_beta:
if bump != "pre-release":
beta_n = 1 # fresh beta series for the new base
print(f"{major}.{minor}.{patch}b{beta_n}", end="")
else:
print(f"{major}.{minor}.{patch}", end="")
PYEOF
)
# Derive is_beta from the COMPUTED version (ends in bN?), not the raw
# input — so 'finalize' is always treated as stable and a mismatched
# beta checkbox can't mislabel the switcher / skip the root redirect.
if [[ "$NEW_VERSION" =~ b[0-9]+$ ]]; then IS_BETA_OUT=true; else IS_BETA_OUT=false; fi
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "branch=release/v$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "is_beta=$IS_BETA_OUT" >> "$GITHUB_OUTPUT"
echo "Bumping (${{ inputs.bump }}): $CURRENT → $NEW_VERSION"
# ── Bump version strings ─────────────────────────────────────────────
- name: Bump version in pyproject.toml
run: |
sed -i 's/^version = ".*"/version = "${{ steps.version.outputs.new_version }}"/' pyproject.toml
- name: Bump version in docs/conf.py
run: |
sed -i 's/^release = ".*"/release = "${{ steps.version.outputs.new_version }}"/' docs/conf.py
# ── Build changelog ──────────────────────────────────────────────────
- name: Build changelog with towncrier
run: |
FRAGMENT_COUNT=$(find upcoming_changes -maxdepth 1 -name "*.rst" \
! -name "README.rst" | wc -l)
if [ "$FRAGMENT_COUNT" -eq 0 ]; then
echo "⚠ No news fragments found — skipping towncrier (CHANGELOG.rst unchanged)."
else
uvx towncrier build --yes --version "${{ steps.version.outputs.new_version }}"
fi
# ── Update docs switcher.json ────────────────────────────────────────
- name: Update docs/switcher.json
env:
VERSION_TAG: ${{ steps.version.outputs.tag }}
IS_BETA: ${{ steps.version.outputs.is_beta }}
shell: python
run: |
import json, re, pathlib, os
version = os.environ["VERSION_TAG"]
is_beta = os.environ["IS_BETA"].lower() == "true"
path = pathlib.Path("docs/_root/switcher.json")
text = path.read_text()
# The file may contain a trailing comma; strip it before parsing.
text_clean = re.sub(r",(\s*[\]\}])", r"\1", text)
entries = json.loads(text_clean)
# Remove any existing entry for this version (makes the step idempotent).
entries = [e for e in entries if e.get("version") != version]
label = f"{version} (beta)" if is_beta else f"{version} (stable)"
url = f"https://cssfrancis.github.io/anyplotlib/{version}/"
# Insert right after the "dev" entry so newest stable floats to top.
entries.insert(1, {"name": label, "version": version, "url": url})
path.write_text(json.dumps(entries, indent=2) + "\n")
# ── Update root redirect for stable releases ─────────────────────────
- name: Update root redirect (stable releases only)
if: ${{ steps.version.outputs.is_beta == 'false' && inputs.bump != 'pre-release' }}
env:
VERSION_TAG: ${{ steps.version.outputs.tag }}
shell: python
run: |
import re, pathlib, os
version = os.environ["VERSION_TAG"]
path = pathlib.Path("docs/_root/index.html")
text = path.read_text()
text = re.sub(r'(content="0; url=)[^"]+(")', rf"\g<1>{version}/\2", text)
text = re.sub(r'(rel="canonical" href=")[^"]+(")', rf"\g<1>{version}/\2", text)
text = re.sub(r'(<a href=")[^"]+(">[^<]*</a>)', rf"\g<1>{version}/\2", text)
text = re.sub(r'(Redirecting to <a href="[^"]+">)[^<]*(</a>)',
rf"\g<1>{version} documentation\2", text)
path.write_text(text)
# ── Commit and push ──────────────────────────────────────────────────
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Commit release changes
run: |
git checkout -b "${{ steps.version.outputs.branch }}"
# Stage version bumps, updated changelog, and consumed fragments.
git add pyproject.toml docs/conf.py CHANGELOG.rst
git add docs/_root/switcher.json docs/_root/index.html
git add -A upcoming_changes/ # stages deleted fragment files
git commit -m "chore: prepare release ${{ steps.version.outputs.tag }}"
git push origin "${{ steps.version.outputs.branch }}"
# ── Open pull request ────────────────────────────────────────────────
- name: Open pull request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
BRANCH: ${{ steps.version.outputs.branch }}
run: |
gh pr create \
--title "Release ${TAG}" \
--base main \
--head "${BRANCH}" \
--body "## Release ${TAG}
> Auto-generated by the **Prepare Release** workflow.
### What changed
- Version bumped to \`${TAG}\` in \`pyproject.toml\` and \`docs/conf.py\`
- \`CHANGELOG.rst\` updated from towncrier fragments
- \`docs/_root/switcher.json\` updated with the new version entry
$([ '${{ steps.version.outputs.is_beta }}' = 'false' ] && echo '- Root redirect updated to point to this release' || echo '')
### Review checklist
- [ ] \`CHANGELOG.rst\` reads well — edit the fragment text directly if needed
- [ ] Version strings are correct in \`pyproject.toml\` and \`docs/conf.py\`
- [ ] \`switcher.json\` has the right label and URL
- [ ] CI passes
### After merging
Create and push the tag to trigger the Release and Docs workflows:
\`\`\`bash
git fetch origin
git tag ${TAG} origin/main
git push origin ${TAG}
\`\`\`"