From 37ee8065ee52169a049f8b0d50041887bb5c2071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 13:59:29 +0100 Subject: [PATCH 01/42] New release config --- .../actions/update-release-preview/action.yml | 47 +++ .github/workflows/python-wheels.yml | 4 +- .github/workflows/release-checks.yml | 29 +- .github/workflows/release-major-guard.yml | 57 ++++ .github/workflows/release-please.yml | 23 -- .../release-pr-changelog-preview.yml | 48 +++ .github/workflows/release-pr.yml | 258 ++++++++++++++ .github/workflows/release-publish.yml | 72 ++++ .release-please-manifest.json | 4 - DEVELOPMENT.md | 78 +++-- cliff.toml | 72 ++++ .../src/guppylang_internals/__init__.py | 4 +- guppylang/src/guppylang/__init__.py | 4 +- release-please-config.json | 44 --- ruff.toml | 1 + scripts/release/compute_versions.py | 315 ++++++++++++++++++ scripts/release/extract_changelog.py | 75 +++++ scripts/release/render_pr_body.py | 86 +++++ scripts/release/update_changelog.py | 95 ++++++ tests/release/__init__.py | 0 tests/release/conftest.py | 16 + tests/release/test_compute_versions.py | 89 +++++ tests/release/test_extract_changelog.py | 65 ++++ tests/release/test_render_pr_body.py | 48 +++ tests/release/test_update_changelog.py | 60 ++++ 25 files changed, 1476 insertions(+), 118 deletions(-) create mode 100644 .github/actions/update-release-preview/action.yml create mode 100644 .github/workflows/release-major-guard.yml delete mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/release-pr-changelog-preview.yml create mode 100644 .github/workflows/release-pr.yml create mode 100644 .github/workflows/release-publish.yml delete mode 100644 .release-please-manifest.json create mode 100644 cliff.toml delete mode 100644 release-please-config.json create mode 100755 scripts/release/compute_versions.py create mode 100755 scripts/release/extract_changelog.py create mode 100755 scripts/release/render_pr_body.py create mode 100755 scripts/release/update_changelog.py create mode 100644 tests/release/__init__.py create mode 100644 tests/release/conftest.py create mode 100644 tests/release/test_compute_versions.py create mode 100644 tests/release/test_extract_changelog.py create mode 100644 tests/release/test_render_pr_body.py create mode 100644 tests/release/test_update_changelog.py diff --git a/.github/actions/update-release-preview/action.yml b/.github/actions/update-release-preview/action.yml new file mode 100644 index 000000000..3e6e423ea --- /dev/null +++ b/.github/actions/update-release-preview/action.yml @@ -0,0 +1,47 @@ +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). + +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: + GH_TOKEN: ${{ inputs.token }} + GUPPY: ${{ inputs.guppylang-version }} + INTERNALS: ${{ inputs.internals-version }} + NUMBER: ${{ inputs.pr-number }} + run: | + set -euo pipefail + # Extract verbatim sections (empty file if the section is missing). + python3 scripts/release/extract_changelog.py \ + guppylang/CHANGELOG.md "$GUPPY" > /tmp/guppy_section.md || true + python3 scripts/release/extract_changelog.py \ + guppylang-internals/CHANGELOG.md "$INTERNALS" > /tmp/internals_section.md || true + + gh pr view "$NUMBER" --json body --jq .body > /tmp/pr_body.md + python3 scripts/release/render_pr_body.py --body /tmp/pr_body.md \ + --package guppylang "$GUPPY" /tmp/guppy_section.md \ + --package guppylang-internals "$INTERNALS" /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 11712fa5b..1035c7c9c 100644 --- a/.github/workflows/python-wheels.yml +++ b/.github/workflows/python-wheels.yml @@ -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' || 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@v6 if: ${{ steps.check-tag.outputs.run == 'true' }} diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 8d1877929..4a3cc9ecc 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -22,12 +22,10 @@ jobs: check-release-guppylang-internals: name: Check `guppylang-internals` 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. + # Only run on the automated release PR. if: | github.event_name == 'workflow_dispatch' || - (startsWith(github.head_ref, 'release-please--') && endsWith(github.head_ref, '--components--guppylang-internals')) + github.head_ref == 'release-pr' strategy: fail-fast: false matrix: @@ -68,12 +66,10 @@ jobs: 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. + # Only run on the automated release PR. if: | github.event_name == 'workflow_dispatch' || - (startsWith(github.head_ref, 'release-please--') && endsWith(github.head_ref, '--components--guppylang')) + github.head_ref == 'release-pr' strategy: fail-fast: false matrix: @@ -121,7 +117,7 @@ jobs: runs-on: ubuntu-latest if: | github.event_name == 'workflow_dispatch' || - (startsWith(github.head_ref, 'release-please--') && endsWith(github.head_ref, '--components--guppylang')) + github.head_ref == 'release-pr' steps: - uses: actions/checkout@v6 with: @@ -140,30 +136,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@v6 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..ff207471c --- /dev/null +++ b/.github/workflows/release-major-guard.yml @@ -0,0 +1,57 @@ +# Guards against an accidental major version bump of `guppylang` in a release PR. +# +# While the language is in its alpha phase we never want to silently jump major +# versions (which would also reset the guppylang-internals build counter). This +# fails the release PR if the guppylang major version increases relative to +# `main`, unless the `X-allow-major-bump` label is explicitly applied. + +name: Release major-bump guard 🛑 + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled + +permissions: + contents: read + +jobs: + guard: + name: Disallow unapproved major bump + runs-on: ubuntu-latest + # Only relevant for the automated release PR. + 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 'index("X-allow-major-bump")' >/dev/null; then + echo "Major bump permitted by the 'X-allow-major-bump' label." + else + echo "::error::guppylang major version would bump $base_major → $head_major. Add the 'X-allow-major-bump' 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..38a010159 --- /dev/null +++ b/.github/workflows/release-pr-changelog-preview.yml @@ -0,0 +1,48 @@ +# Keeps the release-notes preview in the release PR body in sync with the +# committed changelogs. Runs on *every* push to the `release-pr` branch, so it +# refreshes both when the release bot updates the changelog and when a maintainer +# hand-edits CHANGELOG.md after removing the `X-regen-changelog` label. +# +# It only edits the PR description (no commits), so it cannot loop. + +name: Release PR changelog preview 🔎 + +on: + pull_request: + types: + - synchronize + branches: + - main + +permissions: + contents: read + pull-requests: write + +concurrency: + group: release-pr-preview + cancel-in-progress: true + +jobs: + preview: + name: Refresh release-notes preview + runs-on: ubuntu-latest + if: 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: 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..0a0bc0676 --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,258 @@ +# 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 an alpha pre-release (e.g. 1.0.0-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' may decide not to release)" + type: choice + default: auto + options: + - auto + - alpha + - rc + - patch + - minor + - major + - stable + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-pr + cancel-in-progress: false + +env: + BRANCH: release-pr + RELEASE_LABEL: X-release + REGEN_LABEL: X-regen-changelog + UV_VERSION: "0.11.22" + GH_TOKEN: ${{ secrets.HUGRBOT_PAT }} + 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 Python + run: uv python install 3.13 + - 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 + python3 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 + 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 + 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" + + python3 scripts/release/compute_versions.py set-internals "$INTERNALS" + git commit -aqm "chore: bump guppylang-internals to $INTERNALS" + + python3 scripts/release/compute_versions.py set-guppylang "$GUPPY" + git commit -aqm "chore: bump guppylang to $GUPPY" + + python3 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 + python3 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 + python3 scripts/release/update_changelog.py \ + guppylang-internals/CHANGELOG.md "$INTERNALS" /tmp/internals_section.md + 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 + fi + git commit -aqm "chore: update changelogs" || echo "changelogs unchanged" + + git push --force 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 main --head "$BRANCH" --title "$title" \ + --body-file /tmp/pr_body.md \ + --label "$RELEASE_LABEL" --label "$REGEN_LABEL" + NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number') + else + gh pr edit "$NUMBER" --title "$title" + fi + echo "number=$NUMBER" >> "$GITHUB_OUTPUT" + + - name: Refresh release-notes preview + if: steps.gate.outputs.proceed == 'true' + 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..a0e509d71 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,72 @@ +# Tags and publishes GitHub releases once a release PR is merged into `main`. +# +# On every push to `main` this checks whether the committed package versions are +# already tagged. If not (i.e. a release PR was just merged), it creates the tag +# and a GitHub release for each package, using release notes sliced verbatim from +# the committed CHANGELOG.md files. Creating the release triggers +# `python-wheels.yml`, which publishes the wheels to PyPI. + +name: Release publish 🚀 + +on: + push: + branches: + - main + +permissions: + contents: write + +env: + GH_TOKEN: ${{ secrets.HUGRBOT_PAT }} + +jobs: + publish: + name: Tag and create GitHub releases + 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: Create releases for newly versioned packages + run: | + set -euo pipefail + read_version() { sed -n 's/^version = "\(.*\)"/\1/p' "$1" | head -n1; } + + release_pkg() { + local name="$1" pyproject="$2" changelog="$3" prefix="$4" + 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 publish for $name." + return + fi + + echo "Publishing $name $version as 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 + + git tag "$tag" + git push origin "$tag" + + local args=(--title "$name $version" --notes-file /tmp/notes.md --verify-tag) + # 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[@]}" + } + + release_pkg "guppylang" \ + "guppylang/pyproject.toml" "guppylang/CHANGELOG.md" "guppylang-v" + release_pkg "guppylang-internals" \ + "guppylang-internals/pyproject.toml" "guppylang-internals/CHANGELOG.md" \ + "guppylang-internals-v" 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..61b1a6597 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -144,27 +144,58 @@ 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. +Releases are managed by a custom set of workflows under `.github/workflows` +(`release-pr.yml`, `release-pr-preview.yml`, `release-major-guard.yml`, and +`release-publish.yml`), driven by [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). -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. +The two distributions are versioned differently: -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). +- `guppylang` uses semantic versioning with an alpha pre-release suffix while the + language is unstable (e.g. `1.0.0-a6`). +- `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. -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`). +### The release PR -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`. +On every push to `main`, `release-pr.yml` opens or updates a single release PR on +the `release-pr` branch. It: + +1. computes the next `guppylang` version from conventional commits (by default it + just increments the alpha counter), +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. You can also trigger the workflow manually via +*workflow_dispatch* to force a PR open (`force_open`) and to choose the bump +(`auto`, `alpha`, `rc`, `patch`, `minor`, `major`, or `stable`) — this is how you +promote out of the alpha series (e.g. to an `rc` or a `stable` release). + +### 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-preview.yml`), which is +exactly what gets published. + +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 + +Once the release PR is merged, `release-publish.yml` tags both packages +(`guppylang-v` and `guppylang-internals-v`) and creates a GitHub +release for each, with notes sliced verbatim from the committed changelogs. +Creating the releases triggers `python-wheels.yml`, which publishes the wheels to +PyPI. ### Patch releases @@ -173,14 +204,11 @@ to include all the changes that have been merged into the main branch. In this c 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`. +You will need to modify the version and changelog manually in this case. Once the +branch is ready, create a draft PR so that the release team can review it, then +create a [github release](https://github.com/quantinuum/guppylang/releases/new) +with a tag following the format used by the previous releases (e.g. +`guppylang-v1.0.1`). The CI will build and publish the wheels. 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 diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 000000000..6036a4b11 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,72 @@ +# 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 }}\ +{% if commit.remote.pr_number %} ([#{{ commit.remote.pr_number }}](https://github.com/Quantinuum/guppylang/issues/{{ commit.remote.pr_number }}))\ +{% endif %} +{% 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 }}\ +{% if commit.remote.pr_number %} ([#{{ commit.remote.pr_number }}](https://github.com/Quantinuum/guppylang/issues/{{ commit.remote.pr_number }}))\ +{% endif %} ([{{ 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" + +# Only these conventional types appear in the changelog (matching the previous +# release-please behaviour: chore/ci/test/style/build are intentionally omitted). +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" }, + { message = ".*", 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/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..f1d60a2e4 --- /dev/null +++ b/scripts/release/compute_versions.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Version bump logic for the ``guppylang`` and ``guppylang-internals`` releases. + +``guppylang`` follows semantic versioning with an alpha pre-release suffix while +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. + +The script is intentionally dependency-free (standard library only) so it can be +run directly in CI without installing the project. It is split into a ``compute`` +command (pure, prints the next versions) and several ``set-*`` commands that +write the new versions into the relevant files. This lets the release workflow +apply each change as its own, appropriately named commit. + +Bump modes (``guppylang``), all easily adjustable: + +* ``auto`` -> same as ``alpha`` (the current, unstable-phase default). +* ``alpha`` -> ``1.0.0-a5`` becomes ``1.0.0-a6``. +* ``rc`` -> ``1.0.0-a5`` becomes ``1.0.0-rc1``; ``rc1`` becomes ``rc2``. +* ``patch`` -> ``1.0.0-a5`` becomes ``1.0.1-a1``. +* ``minor`` -> ``1.0.0-a5`` becomes ``1.1.0-a1``. +* ``major`` -> ``1.0.0-a5`` becomes ``2.0.0-a1`` (guarded by the release PR). +* ``stable``-> ``1.0.0-a5`` becomes ``1.0.0`` (drops the pre-release). +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +# The number the alpha series restarts from after a core (patch/minor/major) +# bump. Change this single constant to start fresh pre-releases elsewhere. +INITIAL_PRERELEASE_NUM = 1 + +BUMP_MODES = ("auto", "alpha", "rc", "patch", "minor", "major", "stable") + +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" + +_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[^"]*"') + + +@dataclass(frozen=True) +class GuppyVersion: + major: int + minor: int + patch: int + pre_label: str | 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}{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=pre_label, + 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.""" + if mode == "auto": + mode = "alpha" + + if mode == "alpha": + if current.pre_label != "a" or current.pre_num is None: + msg = ( + f"Cannot 'alpha'-bump {current.render()!r}: expected an alpha " + "pre-release. Use 'patch'/'minor'/'major' to start a new series." + ) + raise ValueError(msg) + return GuppyVersion( + current.major, current.minor, current.patch, "a", current.pre_num + 1 + ) + + if mode == "rc": + if current.pre_label == "rc" and current.pre_num is not None: + next_num = current.pre_num + 1 + elif current.pre_label in ("a", "b"): + next_num = 1 + else: + msg = f"Cannot 'rc'-bump stable version {current.render()!r}." + raise ValueError(msg) + return GuppyVersion(current.major, current.minor, current.patch, "rc", next_num) + + if mode == "stable": + if not current.is_prerelease: + msg = ( + f"{current.render()!r} is already stable; use 'patch'/'minor'/'major'." + ) + raise ValueError(msg) + return GuppyVersion(current.major, current.minor, current.patch) + + if mode == "patch": + return GuppyVersion( + current.major, current.minor, current.patch + 1, "a", INITIAL_PRERELEASE_NUM + ) + if mode == "minor": + return GuppyVersion( + current.major, current.minor + 1, 0, "a", INITIAL_PRERELEASE_NUM + ) + if mode == "major": + return GuppyVersion(current.major + 1, 0, 0, "a", INITIAL_PRERELEASE_NUM) + + msg = f"Unknown bump mode: {mode!r} (expected one of {', '.join(BUMP_MODES)})" + raise ValueError(msg) + + +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: + # Migration from the legacy scheme: seed the first build. + 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) + new_guppy = bump_guppylang(current, args.bump) + new_internals = bump_internals(read_current_internals(root), new_guppy.major) + major_bumped = new_guppy.major > current.major + + lines = [ + f"current_guppylang={current.render()}", + f"guppylang={new_guppy.render()}", + f"internals={new_internals}", + f"major_bumped={'true' if major_bumped else 'false'}", + ] + 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=BUMP_MODES, 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..ed8d27dfe --- /dev/null +++ b/scripts/release/extract_changelog.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Extract a single version's section from a ``CHANGELOG.md`` file. + +This is the *only* code path that turns a changelog into release notes. Both the +release-PR preview and the published GitHub release read their text from here, so +what you see in the PR preview is exactly what gets published. The committed +``CHANGELOG.md`` is the single source of truth: this script never regenerates or +reformats anything, it only slices out the requested section verbatim. + +A section starts at a ``## [] ...`` heading and ends just before the next +``## `` heading (or the end of the file). By default the ``## [...]`` heading +line itself is omitted, since the GitHub release already shows the version. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + + +def extract_section( + changelog: str, version: str, *, include_header: bool = False +) -> str: + """Return the changelog body for ``version`` (raises ``KeyError`` if absent).""" + header_re = re.compile(r"^## (?!\[?" + re.escape(version) + r"\b)") + target_re = re.compile(r"^## \[?" + re.escape(version) + r"\b") + + 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 if include_header else start + 1 + 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.") + parser.add_argument( + "--include-header", + action="store_true", + help="Include the '## [version]' heading line in the output.", + ) + args = parser.parse_args(argv) + + text = Path(args.changelog).read_text(encoding="utf-8") + try: + section = extract_section( + text, args.version, include_header=args.include_header + ) + 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..10d31a29c --- /dev/null +++ b/scripts/release/update_changelog.py @@ -0,0 +1,95 @@ +#!/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.""" + target_re = re.compile(r"^## \[?" + re.escape(version) + r"\b") + 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..8a8d14a94 --- /dev/null +++ b/tests/release/test_compute_versions.py @@ -0,0 +1,89 @@ +"""Tests for the version bump logic in ``scripts/release/compute_versions.py``.""" + +from __future__ import annotations + +import compute_versions as cv +import pytest + + +@pytest.mark.parametrize( + ("current", "mode", "expected"), + [ + ("1.0.0-a5", "auto", "1.0.0-a6"), + ("1.0.0-a5", "alpha", "1.0.0-a6"), + ("1.0.0-a9", "alpha", "1.0.0-a10"), + ("1.0.0-a5", "rc", "1.0.0-rc1"), + ("1.0.0-rc1", "rc", "1.0.0-rc2"), + ("1.0.0-a5", "stable", "1.0.0"), + ("1.0.0-rc2", "stable", "1.0.0"), + ("1.0.0-a5", "patch", "1.0.1-a1"), + ("1.0.0-a5", "minor", "1.1.0-a1"), + ("1.0.0-a5", "major", "2.0.0-a1"), + ], +) +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 + + +@pytest.mark.parametrize( + ("current", "mode", "match"), + [ + ("1.0.0", "alpha", "alpha'-bump"), + ("1.0.0", "rc", "rc'-bump stable"), + ("1.0.0", "stable", "already stable"), + ], +) +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_major_bumped_detection() -> None: + current = cv.parse_guppy_version("1.0.0-a5") + assert cv.bump_guppylang(current, "major").major > current.major + assert cv.bump_guppylang(current, "minor").major == current.major + + +@pytest.mark.parametrize( + ("current_internals", "new_major", "expected"), + [ + # Migration from the legacy 3-part scheme. + ("1.0.0-a5", 1, "1.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"), + ], +) +def test_bump_internals(current_internals: str, new_major: int, expected: str) -> None: + assert cv.bump_internals(current_internals, new_major) == expected + + +def test_set_version_in_pyproject() -> None: + text = 'name = "guppylang"\nversion = "1.0.0-a5"\nrequires-python = ">=3.10"\n' + out = cv.set_version_in_pyproject(text, "1.0.0-a6") + assert 'version = "1.0.0-a6"' in out + assert "1.0.0-a5" not in out + + +def test_set_dunder_version() -> None: + text = '# comment\n__version__ = "1.0.0-a5"\n' + out = cv.set_dunder_version(text, "1.0.0-a6") + assert out == '# comment\n__version__ = "1.0.0-a6"\n' + + +def test_set_internals_pin() -> None: + text = ( + 'dependencies = [\n "guppylang-internals~=1.0.0-a5",\n "numpy~=2.0",\n]\n' + ) + out = cv.set_internals_pin(text, "1.0") + assert '"guppylang-internals==1.0"' in out + assert "~=1.0.0-a5" not in out + assert '"numpy~=2.0"' in out + + +def test_replace_once_requires_single_match() -> None: + 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..38c759328 --- /dev/null +++ b/tests/release/test_extract_changelog.py @@ -0,0 +1,65 @@ +"""Tests for ``scripts/release/extract_changelog.py``.""" + +from __future__ import annotations + +import extract_changelog as ec +import pytest + +CHANGELOG = """\ +# Changelog + +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 "intro prose" not in section + assert "## [1.0.0-a6]" not in section + + +def test_extract_older_section() -> None: + 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_with_header() -> None: + section = ec.extract_section(CHANGELOG, "1.0.0-a6", include_header=True) + assert section.startswith("## [1.0.0-a6]") + + +def test_extract_missing_version() -> None: + with pytest.raises(KeyError): + ec.extract_section(CHANGELOG, "9.9.9") + + +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 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..9178ff5af --- /dev/null +++ b/tests/release/test_update_changelog.py @@ -0,0 +1,60 @@ +"""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 From e69d5f801902a4e536cb60f8c58be8918c6a77d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 13:59:48 +0100 Subject: [PATCH 02/42] Remove tests for now --- tests/release/__init__.py | 0 tests/release/conftest.py | 16 ----- tests/release/test_compute_versions.py | 89 ------------------------- tests/release/test_extract_changelog.py | 65 ------------------ tests/release/test_render_pr_body.py | 48 ------------- tests/release/test_update_changelog.py | 60 ----------------- 6 files changed, 278 deletions(-) delete mode 100644 tests/release/__init__.py delete mode 100644 tests/release/conftest.py delete mode 100644 tests/release/test_compute_versions.py delete mode 100644 tests/release/test_extract_changelog.py delete mode 100644 tests/release/test_render_pr_body.py delete mode 100644 tests/release/test_update_changelog.py diff --git a/tests/release/__init__.py b/tests/release/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/release/conftest.py b/tests/release/conftest.py deleted file mode 100644 index 62bdb70eb..000000000 --- a/tests/release/conftest.py +++ /dev/null @@ -1,16 +0,0 @@ -"""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 deleted file mode 100644 index 8a8d14a94..000000000 --- a/tests/release/test_compute_versions.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Tests for the version bump logic in ``scripts/release/compute_versions.py``.""" - -from __future__ import annotations - -import compute_versions as cv -import pytest - - -@pytest.mark.parametrize( - ("current", "mode", "expected"), - [ - ("1.0.0-a5", "auto", "1.0.0-a6"), - ("1.0.0-a5", "alpha", "1.0.0-a6"), - ("1.0.0-a9", "alpha", "1.0.0-a10"), - ("1.0.0-a5", "rc", "1.0.0-rc1"), - ("1.0.0-rc1", "rc", "1.0.0-rc2"), - ("1.0.0-a5", "stable", "1.0.0"), - ("1.0.0-rc2", "stable", "1.0.0"), - ("1.0.0-a5", "patch", "1.0.1-a1"), - ("1.0.0-a5", "minor", "1.1.0-a1"), - ("1.0.0-a5", "major", "2.0.0-a1"), - ], -) -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 - - -@pytest.mark.parametrize( - ("current", "mode", "match"), - [ - ("1.0.0", "alpha", "alpha'-bump"), - ("1.0.0", "rc", "rc'-bump stable"), - ("1.0.0", "stable", "already stable"), - ], -) -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_major_bumped_detection() -> None: - current = cv.parse_guppy_version("1.0.0-a5") - assert cv.bump_guppylang(current, "major").major > current.major - assert cv.bump_guppylang(current, "minor").major == current.major - - -@pytest.mark.parametrize( - ("current_internals", "new_major", "expected"), - [ - # Migration from the legacy 3-part scheme. - ("1.0.0-a5", 1, "1.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"), - ], -) -def test_bump_internals(current_internals: str, new_major: int, expected: str) -> None: - assert cv.bump_internals(current_internals, new_major) == expected - - -def test_set_version_in_pyproject() -> None: - text = 'name = "guppylang"\nversion = "1.0.0-a5"\nrequires-python = ">=3.10"\n' - out = cv.set_version_in_pyproject(text, "1.0.0-a6") - assert 'version = "1.0.0-a6"' in out - assert "1.0.0-a5" not in out - - -def test_set_dunder_version() -> None: - text = '# comment\n__version__ = "1.0.0-a5"\n' - out = cv.set_dunder_version(text, "1.0.0-a6") - assert out == '# comment\n__version__ = "1.0.0-a6"\n' - - -def test_set_internals_pin() -> None: - text = ( - 'dependencies = [\n "guppylang-internals~=1.0.0-a5",\n "numpy~=2.0",\n]\n' - ) - out = cv.set_internals_pin(text, "1.0") - assert '"guppylang-internals==1.0"' in out - assert "~=1.0.0-a5" not in out - assert '"numpy~=2.0"' in out - - -def test_replace_once_requires_single_match() -> None: - 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 deleted file mode 100644 index 38c759328..000000000 --- a/tests/release/test_extract_changelog.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Tests for ``scripts/release/extract_changelog.py``.""" - -from __future__ import annotations - -import extract_changelog as ec -import pytest - -CHANGELOG = """\ -# Changelog - -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 "intro prose" not in section - assert "## [1.0.0-a6]" not in section - - -def test_extract_older_section() -> None: - 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_with_header() -> None: - section = ec.extract_section(CHANGELOG, "1.0.0-a6", include_header=True) - assert section.startswith("## [1.0.0-a6]") - - -def test_extract_missing_version() -> None: - with pytest.raises(KeyError): - ec.extract_section(CHANGELOG, "9.9.9") - - -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 diff --git a/tests/release/test_render_pr_body.py b/tests/release/test_render_pr_body.py deleted file mode 100644 index e0dcdcfd8..000000000 --- a/tests/release/test_render_pr_body.py +++ /dev/null @@ -1,48 +0,0 @@ -"""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 deleted file mode 100644 index 9178ff5af..000000000 --- a/tests/release/test_update_changelog.py +++ /dev/null @@ -1,60 +0,0 @@ -"""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 From 934317f9f26c9ed84bd5e2de23f428be1860c667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 14:00:46 +0100 Subject: [PATCH 03/42] Formatting --- .github/workflows/release-checks.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 4a3cc9ecc..8eb416a31 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -115,9 +115,7 @@ jobs: check-guppy-docs-build: name: Check the guppy-docs build for `guppylang` release runs-on: ubuntu-latest - if: | - github.event_name == 'workflow_dispatch' || - github.head_ref == 'release-pr' + if: github.event_name == 'workflow_dispatch' || github.head_ref == 'release-pr' steps: - uses: actions/checkout@v6 with: From 99961dbd067cf3e9b76245bc39e109c24f4740da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 14:03:08 +0100 Subject: [PATCH 04/42] Simplify release checks --- .github/workflows/release-checks.yml | 70 ++++------------------------ 1 file changed, 8 insertions(+), 62 deletions(-) diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 8eb416a31..187505988 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,13 +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 `guppylang` release compatibility with ${{ matrix.target.resolution }} dependencies runs-on: ubuntu-latest - # Only run on the automated release PR. - if: | - github.event_name == 'workflow_dispatch' || - github.head_ref == 'release-pr' + if: github.event_name == 'workflow_dispatch' || github.head_ref == 'release-pr' strategy: fail-fast: false matrix: @@ -57,61 +52,12 @@ jobs: echo "\nDone! Installed dependencies:" uv pip list - name: Type check with mypy - run: uv run --no-sync mypy guppylang-internals + run: uv run --no-sync mypy guppylang guppylang-internals - name: Lint with ruff - run: uv run --no-sync ruff check guppylang-internals + run: uv run --no-sync ruff check guppylang 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 - # Only run on the automated release PR. - if: | - github.event_name == 'workflow_dispatch' || - github.head_ref == 'release-pr' - strategy: - fail-fast: false - matrix: - target: - - resolution: "highest" - python: "3.14" - - resolution: "lowest-direct" - python: "3.10" - steps: - - uses: actions/checkout@v6 - - 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 - - name: Lint with ruff - run: uv run --no-sync ruff check guppylang - - 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 From 2a06bb3f1b40f504efeb0e8a20a6809ec22a910b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 14:06:00 +0100 Subject: [PATCH 05/42] Major guarding --- .github/workflows/release-major-guard.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-major-guard.yml b/.github/workflows/release-major-guard.yml index ff207471c..253f61143 100644 --- a/.github/workflows/release-major-guard.yml +++ b/.github/workflows/release-major-guard.yml @@ -21,16 +21,19 @@ on: permissions: contents: read +env: + ALLOW_MAJOR_BUMP_LABEL: "X-allow-major-bump" + jobs: guard: name: Disallow unapproved major bump runs-on: ubuntu-latest - # Only relevant for the automated release PR. - if: startsWith(github.head_ref, 'release-pr') + if: 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) }} @@ -46,10 +49,10 @@ jobs: echo "guppylang major version: base=$base_major head=$head_major" if [ "$head_major" -gt "$base_major" ]; then - if echo "$LABELS" | jq -e 'index("X-allow-major-bump")' >/dev/null; then - echo "Major bump permitted by the 'X-allow-major-bump' label." + if echo "$LABELS" | jq -e "index($ALLOW_MAJOR_BUMP_LABEL)" >/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 'X-allow-major-bump' label to allow this release." + 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 From 87840ececa33c55047eba02424b23b0f3136d7ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 14:06:36 +0100 Subject: [PATCH 06/42] Major guarding --- .github/workflows/release-major-guard.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/release-major-guard.yml b/.github/workflows/release-major-guard.yml index 253f61143..8faf49b51 100644 --- a/.github/workflows/release-major-guard.yml +++ b/.github/workflows/release-major-guard.yml @@ -1,9 +1,5 @@ # Guards against an accidental major version bump of `guppylang` in a release PR. -# -# While the language is in its alpha phase we never want to silently jump major -# versions (which would also reset the guppylang-internals build counter). This -# fails the release PR if the guppylang major version increases relative to -# `main`, unless the `X-allow-major-bump` label is explicitly applied. +# Add the `X-allow-major-bump` label to the release PR opt-out of this guard. name: Release major-bump guard 🛑 From 442a22d27839c094b4a8b7d68f0486b66eb43fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 14:07:35 +0100 Subject: [PATCH 07/42] Naming is important --- .github/workflows/release-major-guard.yml | 2 +- .github/workflows/release-pr-changelog-preview.yml | 2 +- .github/workflows/release-pr.yml | 2 +- .github/workflows/release-publish.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-major-guard.yml b/.github/workflows/release-major-guard.yml index 8faf49b51..45de40c8b 100644 --- a/.github/workflows/release-major-guard.yml +++ b/.github/workflows/release-major-guard.yml @@ -1,7 +1,7 @@ # 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 🛑 +name: 🚚 Release major-bump guard on: pull_request: diff --git a/.github/workflows/release-pr-changelog-preview.yml b/.github/workflows/release-pr-changelog-preview.yml index 38a010159..eee7347bf 100644 --- a/.github/workflows/release-pr-changelog-preview.yml +++ b/.github/workflows/release-pr-changelog-preview.yml @@ -5,7 +5,7 @@ # # It only edits the PR description (no commits), so it cannot loop. -name: Release PR changelog preview 🔎 +name: 🚚 Release PR changelog preview on: pull_request: diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 0a0bc0676..e1e5885bc 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -17,7 +17,7 @@ # 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 🐍 +name: 🚚 Release PR on: push: diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index a0e509d71..18f7295a0 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -6,7 +6,7 @@ # the committed CHANGELOG.md files. Creating the release triggers # `python-wheels.yml`, which publishes the wheels to PyPI. -name: Release publish 🚀 +name: 🚚 Release publish on: push: From 32a776c7821c5677200502793fa958425023bb97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 14:12:38 +0100 Subject: [PATCH 08/42] Adjust commit groups --- cliff.toml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cliff.toml b/cliff.toml index 6036a4b11..a75696622 100644 --- a/cliff.toml +++ b/cliff.toml @@ -55,8 +55,6 @@ filter_commits = true topo_order = false sort_commits = "newest" -# Only these conventional types appear in the changelog (matching the previous -# release-please behaviour: chore/ci/test/style/build are intentionally omitted). commit_parsers = [ { message = "^feat", group = "Features" }, { message = "^fix", group = "Bug Fixes" }, @@ -64,7 +62,11 @@ commit_parsers = [ { message = "^refactor", group = "Code Refactoring" }, { message = "^docs", group = "Documentation" }, { message = "^revert", group = "Reverts" }, - { message = ".*", skip = true }, + # Not included in changelog + { message = "^chore", skip = true }, + { message = "^ci", skip = true }, + { message = "^test", skip = true }, + { message = "^style", skip = true }, ] [remote.github] From b9ef910a072ed8d04f4b221ae1fa7ff51c5dbb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 14:23:04 +0100 Subject: [PATCH 09/42] Refactor a bit --- .../actions/update-release-preview/action.yml | 23 +++++++++-------- .../release-pr-changelog-preview.yml | 15 +++++++---- .github/workflows/release-pr.yml | 1 - scripts/release/extract_changelog.py | 25 ++++--------------- 4 files changed, 28 insertions(+), 36 deletions(-) diff --git a/.github/actions/update-release-preview/action.yml b/.github/actions/update-release-preview/action.yml index 3e6e423ea..0fe90f4ed 100644 --- a/.github/actions/update-release-preview/action.yml +++ b/.github/actions/update-release-preview/action.yml @@ -2,6 +2,8 @@ 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: @@ -22,22 +24,23 @@ runs: steps: - shell: bash env: - GH_TOKEN: ${{ inputs.token }} - GUPPY: ${{ inputs.guppylang-version }} - INTERNALS: ${{ inputs.internals-version }} + 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). - python3 scripts/release/extract_changelog.py \ - guppylang/CHANGELOG.md "$GUPPY" > /tmp/guppy_section.md || true - python3 scripts/release/extract_changelog.py \ - guppylang-internals/CHANGELOG.md "$INTERNALS" > /tmp/internals_section.md || true + uv run scripts/release/extract_changelog.py \ + guppylang/CHANGELOG.md "$GUPPY_VERSION" > /tmp/guppy_section.md || true + uv run 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 - python3 scripts/release/render_pr_body.py --body /tmp/pr_body.md \ - --package guppylang "$GUPPY" /tmp/guppy_section.md \ - --package guppylang-internals "$INTERNALS" /tmp/internals_section.md \ + uv run 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 diff --git a/.github/workflows/release-pr-changelog-preview.yml b/.github/workflows/release-pr-changelog-preview.yml index eee7347bf..8e43ed868 100644 --- a/.github/workflows/release-pr-changelog-preview.yml +++ b/.github/workflows/release-pr-changelog-preview.yml @@ -1,9 +1,5 @@ # Keeps the release-notes preview in the release PR body in sync with the -# committed changelogs. Runs on *every* push to the `release-pr` branch, so it -# refreshes both when the release bot updates the changelog and when a maintainer -# hand-edits CHANGELOG.md after removing the `X-regen-changelog` label. -# -# It only edits the PR description (no commits), so it cannot loop. +# committed changelogs. Does not commit, so it cannot loop. name: 🚚 Release PR changelog preview @@ -22,6 +18,9 @@ concurrency: group: release-pr-preview cancel-in-progress: true +env: + UV_VERSION: "0.11.22" + jobs: preview: name: Refresh release-notes preview @@ -39,6 +38,12 @@ jobs: 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: diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index e1e5885bc..90c8e4545 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -55,7 +55,6 @@ env: RELEASE_LABEL: X-release REGEN_LABEL: X-regen-changelog UV_VERSION: "0.11.22" - GH_TOKEN: ${{ secrets.HUGRBOT_PAT }} GITHUB_TOKEN: ${{ secrets.HUGRBOT_PAT }} jobs: diff --git a/scripts/release/extract_changelog.py b/scripts/release/extract_changelog.py index ed8d27dfe..322889748 100755 --- a/scripts/release/extract_changelog.py +++ b/scripts/release/extract_changelog.py @@ -1,15 +1,9 @@ #!/usr/bin/env python3 """Extract a single version's section from a ``CHANGELOG.md`` file. -This is the *only* code path that turns a changelog into release notes. Both the -release-PR preview and the published GitHub release read their text from here, so -what you see in the PR preview is exactly what gets published. The committed -``CHANGELOG.md`` is the single source of truth: this script never regenerates or -reformats anything, it only slices out the requested section verbatim. - A section starts at a ``## [] ...`` heading and ends just before the next -``## `` heading (or the end of the file). By default the ``## [...]`` heading -line itself is omitted, since the GitHub release already shows the version. +``## `` heading (or the end of the file). The ``## [...]`` heading line itself is +omitted from the output of this script. """ from __future__ import annotations @@ -20,9 +14,7 @@ from pathlib import Path -def extract_section( - changelog: str, version: str, *, include_header: bool = False -) -> str: +def extract_section(changelog: str, version: str) -> str: """Return the changelog body for ``version`` (raises ``KeyError`` if absent).""" header_re = re.compile(r"^## (?!\[?" + re.escape(version) + r"\b)") target_re = re.compile(r"^## \[?" + re.escape(version) + r"\b") @@ -43,7 +35,7 @@ def extract_section( end = index break - body_start = start if include_header else start + 1 + body_start = start + 1 # Omit the header line containing the version. section = "\n".join(lines[body_start:end]).strip() return section @@ -52,18 +44,11 @@ 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.") - parser.add_argument( - "--include-header", - action="store_true", - help="Include the '## [version]' heading line in the output.", - ) args = parser.parse_args(argv) text = Path(args.changelog).read_text(encoding="utf-8") try: - section = extract_section( - text, args.version, include_header=args.include_header - ) + section = extract_section(text, args.version) except KeyError as err: print(f"error: {err}", file=sys.stderr) return 1 From f0736978dc575978c4f2a7fc0dbc0f6c6498bc30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 15:01:44 +0100 Subject: [PATCH 10/42] More refactor --- .github/workflows/release-pr.yml | 33 +++--- scripts/release/compute_versions.py | 172 ++++++++++++++++++---------- 2 files changed, 126 insertions(+), 79 deletions(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 90c8e4545..1bb444536 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -7,7 +7,7 @@ # `release-publish.yml` once this PR is merged. # # Versioning: -# * guppylang -> semver with an alpha pre-release (e.g. 1.0.0-a6). +# * 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. # @@ -30,17 +30,21 @@ on: type: boolean default: false bump: - description: "How to bump guppylang ('auto' may decide not to release)" + 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 - - stable permissions: contents: write @@ -73,8 +77,6 @@ jobs: with: version: ${{ env.UV_VERSION }} enable-cache: true - - name: Install Python - run: uv python install 3.13 - name: Install git-cliff uses: taiki-e/install-action@v2 with: @@ -107,7 +109,7 @@ jobs: id: plan run: | set -euo pipefail - python3 scripts/release/compute_versions.py compute \ + uv run scripts/release/compute_versions.py compute \ --bump "${{ github.event.inputs.bump || 'auto' }}" \ --github-output "$GITHUB_OUTPUT" @@ -182,13 +184,13 @@ jobs: git checkout -B "$BRANCH" - python3 scripts/release/compute_versions.py set-internals "$INTERNALS" + uv run scripts/release/compute_versions.py set-internals "$INTERNALS" git commit -aqm "chore: bump guppylang-internals to $INTERNALS" - python3 scripts/release/compute_versions.py set-guppylang "$GUPPY" + uv run scripts/release/compute_versions.py set-guppylang "$GUPPY" git commit -aqm "chore: bump guppylang to $GUPPY" - python3 scripts/release/compute_versions.py set-pin "$INTERNALS" + uv run scripts/release/compute_versions.py set-pin "$INTERNALS" git commit -aqm "chore: pin guppylang-internals==$INTERNALS in guppylang" uv lock @@ -198,13 +200,13 @@ jobs: git cliff --config cliff.toml --include-path 'guppylang/**' \ --tag-pattern '^guppylang-v' --unreleased --tag "guppylang-v$GUPPY" \ > /tmp/guppy_section.md - python3 scripts/release/update_changelog.py \ + uv run 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 - python3 scripts/release/update_changelog.py \ + uv run scripts/release/update_changelog.py \ guppylang-internals/CHANGELOG.md "$INTERNALS" /tmp/internals_section.md else [ -f /tmp/guppylang_CHANGELOG.md ] \ @@ -246,12 +248,3 @@ jobs: gh pr edit "$NUMBER" --title "$title" fi echo "number=$NUMBER" >> "$GITHUB_OUTPUT" - - - name: Refresh release-notes preview - if: steps.gate.outputs.proceed == 'true' - 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/scripts/release/compute_versions.py b/scripts/release/compute_versions.py index f1d60a2e4..055a61641 100755 --- a/scripts/release/compute_versions.py +++ b/scripts/release/compute_versions.py @@ -13,15 +13,19 @@ write the new versions into the relevant files. This lets the release workflow apply each change as its own, appropriately named commit. -Bump modes (``guppylang``), all easily adjustable: +Bump modes for ``guppylang``: * ``auto`` -> same as ``alpha`` (the current, unstable-phase default). -* ``alpha`` -> ``1.0.0-a5`` becomes ``1.0.0-a6``. -* ``rc`` -> ``1.0.0-a5`` becomes ``1.0.0-rc1``; ``rc1`` becomes ``rc2``. -* ``patch`` -> ``1.0.0-a5`` becomes ``1.0.1-a1``. -* ``minor`` -> ``1.0.0-a5`` becomes ``1.1.0-a1``. -* ``major`` -> ``1.0.0-a5`` becomes ``2.0.0-a1`` (guarded by the release PR). -* ``stable``-> ``1.0.0-a5`` becomes ``1.0.0`` (drops the pre-release). +* ``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 @@ -30,13 +34,29 @@ import re import sys from dataclasses import dataclass +from enum import Enum from pathlib import Path -# The number the alpha series restarts from after a core (patch/minor/major) -# bump. Change this single constant to start fresh pre-releases elsewhere. -INITIAL_PRERELEASE_NUM = 1 -BUMP_MODES = ("auto", "alpha", "rc", "patch", "minor", "major", "stable") +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" @@ -59,7 +79,7 @@ class GuppyVersion: major: int minor: int patch: int - pre_label: str | None = None + pre_label: PreLabel | None = None pre_num: int | None = None @property @@ -70,7 +90,7 @@ 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}{self.pre_num}" + return f"{core}-{self.pre_label.value}{self.pre_num}" def parse_guppy_version(text: str) -> GuppyVersion: @@ -84,57 +104,91 @@ def parse_guppy_version(text: str) -> GuppyVersion: major=int(match.group("major")), minor=int(match.group("minor")), patch=int(match.group("patch")), - pre_label=pre_label, + 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.""" - if mode == "auto": - mode = "alpha" - - if mode == "alpha": - if current.pre_label != "a" or current.pre_num is None: - msg = ( - f"Cannot 'alpha'-bump {current.render()!r}: expected an alpha " - "pre-release. Use 'patch'/'minor'/'major' to start a new series." + if mode == BumpMode.auto: + mode = BumpMode.alpha + + match mode: + case BumpMode.alpha: + if current.pre_label != "a": + 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 ) - raise ValueError(msg) - return GuppyVersion( - current.major, current.minor, current.patch, "a", current.pre_num + 1 - ) - - if mode == "rc": - if current.pre_label == "rc" and current.pre_num is not None: - next_num = current.pre_num + 1 - elif current.pre_label in ("a", "b"): - next_num = 1 - else: - msg = f"Cannot 'rc'-bump stable version {current.render()!r}." - raise ValueError(msg) - return GuppyVersion(current.major, current.minor, current.patch, "rc", next_num) - - if mode == "stable": - if not current.is_prerelease: - msg = ( - f"{current.render()!r} is already stable; use 'patch'/'minor'/'major'." + 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 ) - raise ValueError(msg) - return GuppyVersion(current.major, current.minor, current.patch) - - if mode == "patch": - return GuppyVersion( - current.major, current.minor, current.patch + 1, "a", INITIAL_PRERELEASE_NUM - ) - if mode == "minor": - return GuppyVersion( - current.major, current.minor + 1, 0, "a", INITIAL_PRERELEASE_NUM - ) - if mode == "major": - return GuppyVersion(current.major + 1, 0, 0, "a", INITIAL_PRERELEASE_NUM) - - msg = f"Unknown bump mode: {mode!r} (expected one of {', '.join(BUMP_MODES)})" + + 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 = BumpMode.__members__.values() + msg = f"Unknown bump mode: {mode!r} (expected one of {', '.join(bump_modes)})" raise ValueError(msg) @@ -220,13 +274,11 @@ def cmd_compute(args: argparse.Namespace) -> int: current = read_current_guppylang(root) new_guppy = bump_guppylang(current, args.bump) new_internals = bump_internals(read_current_internals(root), new_guppy.major) - major_bumped = new_guppy.major > current.major lines = [ f"current_guppylang={current.render()}", f"guppylang={new_guppy.render()}", f"internals={new_internals}", - f"major_bumped={'true' if major_bumped else 'false'}", ] print("\n".join(lines)) if args.github_output is not None: @@ -274,7 +326,9 @@ def build_parser() -> argparse.ArgumentParser: 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=BUMP_MODES, default="auto") + compute.add_argument( + "--bump", choices=BumpMode.__members__.values(), default="auto" + ) compute.add_argument( "--github-output", default=None, From fc38f1c54d6280a1b3a559ff45e5fb84e328ee88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 15:19:12 +0100 Subject: [PATCH 11/42] Auto through git cliff --- scripts/release/compute_versions.py | 96 +++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 13 deletions(-) diff --git a/scripts/release/compute_versions.py b/scripts/release/compute_versions.py index 055a61641..eba96f63e 100755 --- a/scripts/release/compute_versions.py +++ b/scripts/release/compute_versions.py @@ -1,21 +1,18 @@ #!/usr/bin/env python3 """Version bump logic for the ``guppylang`` and ``guppylang-internals`` releases. -``guppylang`` follows semantic versioning with an alpha pre-release suffix while +``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. -The script is intentionally dependency-free (standard library only) so it can be -run directly in CI without installing the project. It is split into a ``compute`` -command (pure, prints the next versions) and several ``set-*`` commands that -write the new versions into the relevant files. This lets the release workflow -apply each change as its own, appropriately named commit. - Bump modes for ``guppylang``: -* ``auto`` -> same as ``alpha`` (the current, unstable-phase default). +* ``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`` @@ -32,6 +29,7 @@ import argparse import re +import subprocess import sys from dataclasses import dataclass from enum import Enum @@ -63,6 +61,11 @@ class PreLabel(str, Enum): 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+))?$" @@ -72,6 +75,7 @@ class PreLabel(str, Enum): _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) @@ -111,10 +115,21 @@ def parse_guppy_version(text: str) -> GuppyVersion: def bump_guppylang(current: GuppyVersion, mode: str) -> GuppyVersion: """Compute the next ``guppylang`` version for the given bump mode.""" - if mode == BumpMode.auto: - mode = BumpMode.alpha - 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 != "a": msg = ( @@ -187,11 +202,62 @@ def bump_guppylang(current: GuppyVersion, mode: str) -> GuppyVersion: case BumpMode.major: return GuppyVersion(current.major + 1, 0, 0) + case _: + raise ValueError(f"Unknown bump mode: {mode!r}") + bump_modes = BumpMode.__members__.values() msg = f"Unknown bump mode: {mode!r} (expected 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: + 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. @@ -202,7 +268,7 @@ def bump_internals(current_text: str, new_guppy_major: int) -> str: """ match = _INTERNALS_RE.match(current_text.strip()) if match is None: - # Migration from the legacy scheme: seed the first build. + # 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")) @@ -272,11 +338,15 @@ def read_current_internals(root: Path) -> str: def cmd_compute(args: argparse.Namespace) -> int: root = Path(args.repo_root) current = read_current_guppylang(root) - new_guppy = bump_guppylang(current, args.bump) + 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(read_current_internals(root), new_guppy.major) lines = [ f"current_guppylang={current.render()}", + f"bump_mode={mode.value}", f"guppylang={new_guppy.render()}", f"internals={new_internals}", ] From 04ce3ecfc5f4d627ff5e8fd828f5b4bfb5931adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 15:25:35 +0100 Subject: [PATCH 12/42] Refactor --- scripts/release/compute_versions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release/compute_versions.py b/scripts/release/compute_versions.py index eba96f63e..07057d766 100755 --- a/scripts/release/compute_versions.py +++ b/scripts/release/compute_versions.py @@ -345,8 +345,8 @@ def cmd_compute(args: argparse.Namespace) -> int: new_internals = bump_internals(read_current_internals(root), new_guppy.major) lines = [ - f"current_guppylang={current.render()}", f"bump_mode={mode.value}", + f"current_guppylang={current.render()}", f"guppylang={new_guppy.render()}", f"internals={new_internals}", ] From c1174fff2651e6f93531bb44cadbf578dde190dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 15:28:46 +0100 Subject: [PATCH 13/42] Add a comment --- .github/workflows/release-pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 1bb444536..468a08c14 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -245,6 +245,7 @@ jobs: --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" From cbdf8984fe13c8684bca58475add1420fe520787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 15:35:48 +0100 Subject: [PATCH 14/42] Name change --- .github/workflows/release-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 468a08c14..abe248059 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -147,7 +147,7 @@ jobs: echo "Nothing to do; skipping release PR." fi - - name: Inspect existing release PR + - name: Inspect existing release PR for changelog regen id: pr if: steps.gate.outputs.proceed == 'true' run: | From eb39a214f5af7bf57f50a31f15315190d329daee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 15:38:05 +0100 Subject: [PATCH 15/42] Run on headref --- .github/workflows/release-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index abe248059..09598bc48 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -240,7 +240,7 @@ jobs: echo "" echo "" } > /tmp/pr_body.md - gh pr create --base main --head "$BRANCH" --title "$title" \ + gh pr create --base ${{ github.ref }} --head "$BRANCH" --title "$title" \ --body-file /tmp/pr_body.md \ --label "$RELEASE_LABEL" --label "$REGEN_LABEL" NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number') From 740a58745219babda90bef56945fad864638ed40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 15:40:13 +0100 Subject: [PATCH 16/42] temp: Run on my branch --- .github/workflows/release-major-guard.yml | 1 + .github/workflows/release-pr-changelog-preview.yml | 1 + .github/workflows/release-pr.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/release-major-guard.yml b/.github/workflows/release-major-guard.yml index 45de40c8b..38f0f087a 100644 --- a/.github/workflows/release-major-guard.yml +++ b/.github/workflows/release-major-guard.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + - mr/chore/new-release-workflow types: - opened - synchronize diff --git a/.github/workflows/release-pr-changelog-preview.yml b/.github/workflows/release-pr-changelog-preview.yml index 8e43ed868..1e86a0d98 100644 --- a/.github/workflows/release-pr-changelog-preview.yml +++ b/.github/workflows/release-pr-changelog-preview.yml @@ -9,6 +9,7 @@ on: - synchronize branches: - main + - mr/chore/new-release-workflow permissions: contents: read diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 09598bc48..937d340e0 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -23,6 +23,7 @@ on: push: branches: - main + - mr/chore/new-release-workflow workflow_dispatch: inputs: force_open: From 82fa02aec5f3a2436c21c8f5d5608897fedcfca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 15:43:59 +0100 Subject: [PATCH 17/42] Do everything in draft --- .github/workflows/release-pr.yml | 2 +- .github/workflows/release-publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 937d340e0..0a95c7b64 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -242,7 +242,7 @@ jobs: echo "" } > /tmp/pr_body.md gh pr create --base ${{ github.ref }} --head "$BRANCH" --title "$title" \ - --body-file /tmp/pr_body.md \ + --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 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 18f7295a0..e9ebe338d 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -57,7 +57,7 @@ jobs: git tag "$tag" git push origin "$tag" - local args=(--title "$name $version" --notes-file /tmp/notes.md --verify-tag) + local 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) ;; From 4961e500b4cec34bb19cbaff1b3eaf977f2a08b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 15:46:30 +0100 Subject: [PATCH 18/42] No project --- .github/workflows/release-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 0a95c7b64..31cb69ac2 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -110,7 +110,7 @@ jobs: id: plan run: | set -euo pipefail - uv run scripts/release/compute_versions.py compute \ + uv run --no-project scripts/release/compute_versions.py compute \ --bump "${{ github.event.inputs.bump || 'auto' }}" \ --github-output "$GITHUB_OUTPUT" From fd46c1649fa33522ac6544911d1cbdda260016ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 15:49:45 +0100 Subject: [PATCH 19/42] More no project --- .github/workflows/release-pr.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 31cb69ac2..3bda8b80e 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -185,13 +185,13 @@ jobs: git checkout -B "$BRANCH" - uv run scripts/release/compute_versions.py set-internals "$INTERNALS" + uv run --no-project scripts/release/compute_versions.py set-internals "$INTERNALS" git commit -aqm "chore: bump guppylang-internals to $INTERNALS" - uv run scripts/release/compute_versions.py set-guppylang "$GUPPY" + uv run --no-project scripts/release/compute_versions.py set-guppylang "$GUPPY" git commit -aqm "chore: bump guppylang to $GUPPY" - uv run scripts/release/compute_versions.py set-pin "$INTERNALS" + uv run --no-project scripts/release/compute_versions.py set-pin "$INTERNALS" git commit -aqm "chore: pin guppylang-internals==$INTERNALS in guppylang" uv lock @@ -201,13 +201,13 @@ jobs: git cliff --config cliff.toml --include-path 'guppylang/**' \ --tag-pattern '^guppylang-v' --unreleased --tag "guppylang-v$GUPPY" \ > /tmp/guppy_section.md - uv run scripts/release/update_changelog.py \ + 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 scripts/release/update_changelog.py \ + uv run --no-project scripts/release/update_changelog.py \ guppylang-internals/CHANGELOG.md "$INTERNALS" /tmp/internals_section.md else [ -f /tmp/guppylang_CHANGELOG.md ] \ From 5c43e93786a50c06b8ec6b2a5f863e6345a41dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 16:01:39 +0100 Subject: [PATCH 20/42] parse with optional minor and patch for now --- guppylang-internals/src/guppylang_internals/engine.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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. From 24684652297d2136386f3861916204319f45801e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 16:16:10 +0100 Subject: [PATCH 21/42] Also refresh release notes here --- .github/workflows/release-pr.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 3bda8b80e..635f479e6 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -250,3 +250,11 @@ jobs: 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 }} From fba2a1302ff8b79c39777def1c29cb6b97b7d97a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 16:18:10 +0100 Subject: [PATCH 22/42] No more project --- .github/actions/update-release-preview/action.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/update-release-preview/action.yml b/.github/actions/update-release-preview/action.yml index 0fe90f4ed..6f57fd022 100644 --- a/.github/actions/update-release-preview/action.yml +++ b/.github/actions/update-release-preview/action.yml @@ -31,13 +31,13 @@ runs: run: | set -euo pipefail # Extract verbatim sections (empty file if the section is missing). - uv run scripts/release/extract_changelog.py \ + uv run --no-project scripts/release/extract_changelog.py \ guppylang/CHANGELOG.md "$GUPPY_VERSION" > /tmp/guppy_section.md || true - uv run scripts/release/extract_changelog.py \ + 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 scripts/release/render_pr_body.py \ + 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 \ From 2b365124a508de089c78f9d1f33efd3d73d2d20a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 16:27:14 +0100 Subject: [PATCH 23/42] Include release pr in branch name --- .github/workflows/python-wheels.yml | 2 +- .github/workflows/release-checks.yml | 4 ++-- .github/workflows/release-major-guard.yml | 2 +- .github/workflows/release-pr-changelog-preview.yml | 4 ++-- .github/workflows/release-pr.yml | 9 +++++---- DEVELOPMENT.md | 5 +++-- 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml index 1035c7c9c..5923e9209 100644 --- a/.github/workflows/python-wheels.yml +++ b/.github/workflows/python-wheels.yml @@ -37,7 +37,7 @@ jobs: run: | echo "run=$SHOULD_RUN" >> $GITHUB_OUTPUT env: - SHOULD_RUN: ${{ (github.event_name != 'release' && (github.ref == 'refs/heads/main' || github.head_ref == 'release-pr')) || (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@v6 if: ${{ steps.check-tag.outputs.run == 'true' }} diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 187505988..4273217a6 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -20,7 +20,7 @@ jobs: check-release-guppylang: name: Check `guppylang` release compatibility with ${{ matrix.target.resolution }} dependencies runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || github.head_ref == 'release-pr' + if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-pr--') strategy: fail-fast: false matrix: @@ -61,7 +61,7 @@ jobs: check-guppy-docs-build: name: Check the guppy-docs build for `guppylang` release runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || github.head_ref == 'release-pr' + if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-pr--') steps: - uses: actions/checkout@v6 with: diff --git a/.github/workflows/release-major-guard.yml b/.github/workflows/release-major-guard.yml index 38f0f087a..eb19a7a27 100644 --- a/.github/workflows/release-major-guard.yml +++ b/.github/workflows/release-major-guard.yml @@ -25,7 +25,7 @@ jobs: guard: name: Disallow unapproved major bump runs-on: ubuntu-latest - if: github.head_ref == 'release-pr' + if: startsWith(github.head_ref, 'release-pr--') steps: - uses: actions/checkout@v6 with: diff --git a/.github/workflows/release-pr-changelog-preview.yml b/.github/workflows/release-pr-changelog-preview.yml index 1e86a0d98..2f7e622b7 100644 --- a/.github/workflows/release-pr-changelog-preview.yml +++ b/.github/workflows/release-pr-changelog-preview.yml @@ -16,7 +16,7 @@ permissions: pull-requests: write concurrency: - group: release-pr-preview + group: release-pr-preview-${{ github.head_ref }} cancel-in-progress: true env: @@ -26,7 +26,7 @@ jobs: preview: name: Refresh release-notes preview runs-on: ubuntu-latest - if: github.head_ref == 'release-pr' + if: startsWith(github.head_ref, 'release-pr--') steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 635f479e6..a601b2f63 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -3,7 +3,7 @@ # 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 +# on the `release-pr--` branch. The actual GitHub releases are cut by # `release-publish.yml` once this PR is merged. # # Versioning: @@ -52,11 +52,12 @@ permissions: pull-requests: write concurrency: - group: release-pr + group: release-pr--${{ github.ref_name }} cancel-in-progress: false env: - BRANCH: release-pr + BASE: ${{ github.ref_name }} + BRANCH: release-pr--${{ github.ref_name }} RELEASE_LABEL: X-release REGEN_LABEL: X-regen-changelog UV_VERSION: "0.11.22" @@ -241,7 +242,7 @@ jobs: echo "" echo "" } > /tmp/pr_body.md - gh pr create --base ${{ github.ref }} --head "$BRANCH" --title "$title" \ + 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') diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 61b1a6597..333d1f44b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -159,7 +159,8 @@ The two distributions are versioned differently: ### The release PR On every push to `main`, `release-pr.yml` opens or updates a single release PR on -the `release-pr` branch. It: +the `release-pr--` branch (e.g. `release-pr--main` for releases off `main`). +It: 1. computes the next `guppylang` version from conventional commits (by default it just increments the alpha counter), @@ -183,7 +184,7 @@ exactly what gets published. 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. +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 From 9552dd29a90875f727f98ef8ab9b03c783dbb39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 16:31:12 +0100 Subject: [PATCH 24/42] Yup --- DEVELOPMENT.md | 81 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 333d1f44b..6d68559ae 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -2,7 +2,8 @@ This guide is intended to help you get started with developing guppylang. -If you find any errors or omissions in this document, please [open an issue](https://github.com/quantinuum/guppylang/issues/new)! +If you find any errors or omissions in this document, +please [open an issue](https://github.com/quantinuum/guppylang/issues/new)! ## #️⃣ Setting up the development environment @@ -30,8 +31,8 @@ To setup the environment manually you will need: - Just: [just.systems](https://just.systems/) - uv `>=0.6`: [docs.astral.sh](https://docs.astral.sh/uv/getting-started/installation/) - - If you have an older manually installed `uv` version you can upgrade it with `uv self update`, - or by following the instructions in your package manager. + - If you have an older manually installed `uv` version you can upgrade it with `uv self update`, + or by following the instructions in your package manager. - bencher_cli: [bencer.dev](https://bencher.dev/docs/tutorial/quick-start/?adapter=json) Once you have these installed, you can install the required python dependencies and setup pre-commit hooks with: @@ -67,7 +68,8 @@ or uploaded. ### codspeed benchmarks -We use [codspeed](https://codspeed.io/docs) for doing one-shot CPU benchmarking of the compilation, checking and emulation of guppy programs in CI. Benchmarks are run for every PR and the results are available in the +We use [codspeed](https://codspeed.io/docs) for doing one-shot CPU benchmarking of the compilation, checking and +emulation of guppy programs in CI. Benchmarks are run for every PR and the results are available in the [codspeed dashboard](https://codspeed.io/Quantinuum/guppylang). ### bencher.dev benchmarks @@ -117,9 +119,11 @@ and open it with your favourite coverage viewer. In VSCode, you can use ## 🌐 Contributing to Guppy -We welcome contributions to Guppy! Please open [an issue](https://github.com/quantinuum/guppylang/issues/new) or [pull request](https://github.com/quantinuum/guppylang/compare) if you have any questions or suggestions. +We welcome contributions to Guppy! Please open [an issue](https://github.com/quantinuum/guppylang/issues/new) +or [pull request](https://github.com/quantinuum/guppylang/compare) if you have any questions or suggestions. -PRs should be made against the `main` branch, and should pass all CI checks before being merged. This includes using the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) format for the PR title. +PRs should be made against the `main` branch, and should pass all CI checks before being merged. This includes using +the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) format for the PR title. The general format of a contribution title should be: @@ -127,7 +131,8 @@ The general format of a contribution title should be: ()!: ``` -Where the scope is optional, and the `!` is only included if this is a semver breaking change that requires a major version bump. +Where the scope is optional, and the `!` is only included if this is a semver breaking change that requires a major +version bump. We accept the following contribution types: @@ -145,8 +150,8 @@ We accept the following contribution types: ## :shipit: Releasing new versions Releases are managed by a custom set of workflows under `.github/workflows` -(`release-pr.yml`, `release-pr-preview.yml`, `release-major-guard.yml`, and -`release-publish.yml`), driven by [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). +(`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: @@ -162,24 +167,57 @@ On every push to `main`, `release-pr.yml` opens or updates a single release PR o the `release-pr--` branch (e.g. `release-pr--main` for releases off `main`). It: -1. computes the next `guppylang` version from conventional commits (by default it - just increments the alpha counter), +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. You can also trigger the workflow manually via -*workflow_dispatch* to force a PR open (`force_open`) and to choose the bump -(`auto`, `alpha`, `rc`, `patch`, `minor`, `major`, or `stable`) — this is how you -promote out of the alpha series (e.g. to an `rc` or a `stable` release). +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*. + +`guppylang` follows semantic versioning with an optional pre-release suffix +(`-a` alpha, `-b` beta, `-rc`). 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`). + +`guppylang-internals` follows `-`, and is always derived +from the new `guppylang` version: its build number increments on every release and +resets to `0` whenever the `guppylang` major version changes. ### 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-preview.yml`), which is -exactly what gets published. +release notes (refreshed on every push by `release-pr-changelog-preview.yml`), +which is exactly what gets published. 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: @@ -193,10 +231,11 @@ to allow it. ### Publishing Once the release PR is merged, `release-publish.yml` tags both packages -(`guppylang-v` and `guppylang-internals-v`) and creates a GitHub -release for each, with notes sliced verbatim from the committed changelogs. -Creating the releases triggers `python-wheels.yml`, which publishes the wheels to -PyPI. +(`guppylang-v` and `guppylang-internals-v`) and creates a +**draft** GitHub release for each, with notes sliced verbatim from the committed +changelogs. 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 merge first creates it. ### Patch releases From c4d74c3d40d9a319b6162eb14cfdd0154cf653a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 16:31:27 +0100 Subject: [PATCH 25/42] Revert "temp: Run on my branch" This reverts commit 740a58745219babda90bef56945fad864638ed40. --- .github/workflows/release-major-guard.yml | 1 - .github/workflows/release-pr-changelog-preview.yml | 1 - .github/workflows/release-pr.yml | 1 - 3 files changed, 3 deletions(-) diff --git a/.github/workflows/release-major-guard.yml b/.github/workflows/release-major-guard.yml index eb19a7a27..d9248952f 100644 --- a/.github/workflows/release-major-guard.yml +++ b/.github/workflows/release-major-guard.yml @@ -7,7 +7,6 @@ on: pull_request: branches: - main - - mr/chore/new-release-workflow types: - opened - synchronize diff --git a/.github/workflows/release-pr-changelog-preview.yml b/.github/workflows/release-pr-changelog-preview.yml index 2f7e622b7..a229a3fc0 100644 --- a/.github/workflows/release-pr-changelog-preview.yml +++ b/.github/workflows/release-pr-changelog-preview.yml @@ -9,7 +9,6 @@ on: - synchronize branches: - main - - mr/chore/new-release-workflow permissions: contents: read diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index a601b2f63..b858ded00 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -23,7 +23,6 @@ on: push: branches: - main - - mr/chore/new-release-workflow workflow_dispatch: inputs: force_open: From a602d98eb00eabf315a5ef2e8434844616bfc72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Fri, 19 Jun 2026 16:34:23 +0100 Subject: [PATCH 26/42] Reapply "temp: Run on my branch" This reverts commit c4d74c3d40d9a319b6162eb14cfdd0154cf653a2. --- .github/workflows/release-major-guard.yml | 1 + .github/workflows/release-pr-changelog-preview.yml | 1 + .github/workflows/release-pr.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/release-major-guard.yml b/.github/workflows/release-major-guard.yml index d9248952f..eb19a7a27 100644 --- a/.github/workflows/release-major-guard.yml +++ b/.github/workflows/release-major-guard.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + - mr/chore/new-release-workflow types: - opened - synchronize diff --git a/.github/workflows/release-pr-changelog-preview.yml b/.github/workflows/release-pr-changelog-preview.yml index a229a3fc0..2f7e622b7 100644 --- a/.github/workflows/release-pr-changelog-preview.yml +++ b/.github/workflows/release-pr-changelog-preview.yml @@ -9,6 +9,7 @@ on: - synchronize branches: - main + - mr/chore/new-release-workflow permissions: contents: read diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index b858ded00..a601b2f63 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -23,6 +23,7 @@ on: push: branches: - main + - mr/chore/new-release-workflow workflow_dispatch: inputs: force_open: From 914123b884bd66425718d6b556fd31b9a2790a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 10:30:39 +0100 Subject: [PATCH 27/42] No extra PR refs in changelog --- cliff.toml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cliff.toml b/cliff.toml index a75696622..af90fb209 100644 --- a/cliff.toml +++ b/cliff.toml @@ -30,8 +30,6 @@ body = """ ### ⚠ BREAKING CHANGES {% for commit in breaking %} * {{ commit.message | split(pat="\n") | first | upper_first }}\ -{% if commit.remote.pr_number %} ([#{{ commit.remote.pr_number }}](https://github.com/Quantinuum/guppylang/issues/{{ commit.remote.pr_number }}))\ -{% endif %} {% endfor %} {% endif %}\ {% for group, commits in commits | group_by(attribute="group") %} @@ -39,8 +37,7 @@ body = """ ### {{ group | striptags | trim | upper_first }} {% for commit in commits %} * {{ commit.message | split(pat="\n") | first | upper_first }}\ -{% if commit.remote.pr_number %} ([#{{ commit.remote.pr_number }}](https://github.com/Quantinuum/guppylang/issues/{{ commit.remote.pr_number }}))\ -{% endif %} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/Quantinuum/guppylang/commit/{{ commit.id }})) +([{{ commit.id | truncate(length=7, end="") }}](https://github.com/Quantinuum/guppylang/commit/{{ commit.id }})) {% endfor %} {% endfor %}\n """ From d5d14a9d2e6e04832b34a2e544a541d5024fb727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 10:49:53 +0100 Subject: [PATCH 28/42] Specific git commits --- .github/workflows/release-pr.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index a601b2f63..59051791b 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -210,13 +210,16 @@ jobs: --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 commit -aqm "chore: update changelogs" || echo "changelogs unchanged" git push --force origin "$BRANCH" From 59abd010c007797743e4bc0a19b37024356c85e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 11:03:00 +0100 Subject: [PATCH 29/42] Fix extract changelog --- scripts/release/extract_changelog.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/release/extract_changelog.py b/scripts/release/extract_changelog.py index 322889748..8089ba8cf 100755 --- a/scripts/release/extract_changelog.py +++ b/scripts/release/extract_changelog.py @@ -16,8 +16,11 @@ def extract_section(changelog: str, version: str) -> str: """Return the changelog body for ``version`` (raises ``KeyError`` if absent).""" - header_re = re.compile(r"^## (?!\[?" + re.escape(version) + r"\b)") - target_re = re.compile(r"^## \[?" + re.escape(version) + r"\b") + # 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 From 764b45d62e12e3ecf1a12edf6884741c8340ebf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 11:04:20 +0100 Subject: [PATCH 30/42] try releasing a thing From 7640c5b9a5774b01fb460af76f3cf0510021dc90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 11:07:01 +0100 Subject: [PATCH 31/42] Rename workflow --- .github/workflows/python-wheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml index 5923e9209..6a2df5586 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, From 114a0bb6108e5532ffadb40c4e51b0831884fc54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 11:23:55 +0100 Subject: [PATCH 32/42] Whitespace --- cliff.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cliff.toml b/cliff.toml index af90fb209..8cb38038c 100644 --- a/cliff.toml +++ b/cliff.toml @@ -37,7 +37,7 @@ body = """ ### {{ 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 }})) + ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/Quantinuum/guppylang/commit/{{ commit.id }})) {% endfor %} {% endfor %}\n """ From 564bd282b479927d459bb70abd03a0bb058c828f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 11:29:10 +0100 Subject: [PATCH 33/42] Unwanted changes --- DEVELOPMENT.md | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6d68559ae..2730c7fa6 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -2,8 +2,7 @@ This guide is intended to help you get started with developing guppylang. -If you find any errors or omissions in this document, -please [open an issue](https://github.com/quantinuum/guppylang/issues/new)! +If you find any errors or omissions in this document, please [open an issue](https://github.com/quantinuum/guppylang/issues/new)! ## #️⃣ Setting up the development environment @@ -31,8 +30,8 @@ To setup the environment manually you will need: - Just: [just.systems](https://just.systems/) - uv `>=0.6`: [docs.astral.sh](https://docs.astral.sh/uv/getting-started/installation/) - - If you have an older manually installed `uv` version you can upgrade it with `uv self update`, - or by following the instructions in your package manager. + - If you have an older manually installed `uv` version you can upgrade it with `uv self update`, + or by following the instructions in your package manager. - bencher_cli: [bencer.dev](https://bencher.dev/docs/tutorial/quick-start/?adapter=json) Once you have these installed, you can install the required python dependencies and setup pre-commit hooks with: @@ -68,8 +67,7 @@ or uploaded. ### codspeed benchmarks -We use [codspeed](https://codspeed.io/docs) for doing one-shot CPU benchmarking of the compilation, checking and -emulation of guppy programs in CI. Benchmarks are run for every PR and the results are available in the +We use [codspeed](https://codspeed.io/docs) for doing one-shot CPU benchmarking of the compilation, checking and emulation of guppy programs in CI. Benchmarks are run for every PR and the results are available in the [codspeed dashboard](https://codspeed.io/Quantinuum/guppylang). ### bencher.dev benchmarks @@ -119,11 +117,9 @@ and open it with your favourite coverage viewer. In VSCode, you can use ## 🌐 Contributing to Guppy -We welcome contributions to Guppy! Please open [an issue](https://github.com/quantinuum/guppylang/issues/new) -or [pull request](https://github.com/quantinuum/guppylang/compare) if you have any questions or suggestions. +We welcome contributions to Guppy! Please open [an issue](https://github.com/quantinuum/guppylang/issues/new) or [pull request](https://github.com/quantinuum/guppylang/compare) if you have any questions or suggestions. -PRs should be made against the `main` branch, and should pass all CI checks before being merged. This includes using -the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) format for the PR title. +PRs should be made against the `main` branch, and should pass all CI checks before being merged. This includes using the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) format for the PR title. The general format of a contribution title should be: @@ -131,8 +127,7 @@ The general format of a contribution title should be: ()!: ``` -Where the scope is optional, and the `!` is only included if this is a semver breaking change that requires a major -version bump. +Where the scope is optional, and the `!` is only included if this is a semver breaking change that requires a major version bump. We accept the following contribution types: From f0925cb0891f489644c9e3e51157a5d9224f6e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 11:34:04 +0100 Subject: [PATCH 34/42] Formatting --- DEVELOPMENT.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2730c7fa6..64718cafb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -150,8 +150,7 @@ and `release-publish.yml`), driven by [conventional commits](https://www.convent The two distributions are versioned differently: -- `guppylang` uses semantic versioning with an alpha pre-release suffix while the - language is unstable (e.g. `1.0.0-a6`). +- `guppylang` follows semantic versioning with an optional pre-release suffix (`-a` alpha, `-b` beta, `-rc`; e.g. `1.2.3b5`). - `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. @@ -179,8 +178,7 @@ 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*. -`guppylang` follows semantic versioning with an optional pre-release suffix -(`-a` alpha, `-b` beta, `-rc`). The available bump modes are: +The available bump modes are: | mode | example | notes | |---------------|--------------------------|------------------------------------------------| @@ -203,16 +201,12 @@ while an unchanged core — the usual case while iterating on a pre-release — increments the alpha counter. Use an explicit mode to promote out of the alpha series (e.g. `beta`, `rc`, or `stable`). -`guppylang-internals` follows `-`, and is always derived -from the new `guppylang` version: its build number increments on every release and -resets to `0` whenever the `guppylang` major version changes. - ### 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. +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: From 02b2b2d9f8304f673b444b3d45f2688a3eacc86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 11:52:17 +0100 Subject: [PATCH 35/42] Update patch release workflow --- .github/workflows/release-publish.yml | 115 +++++++++++++++++++------- DEVELOPMENT.md | 62 +++++++++----- 2 files changed, 127 insertions(+), 50 deletions(-) diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index e9ebe338d..adc234ce7 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -1,10 +1,16 @@ -# Tags and publishes GitHub releases once a release PR is merged into `main`. +# Creates the GitHub releases for `guppylang` and `guppylang-internals`. # -# On every push to `main` this checks whether the committed package versions are -# already tagged. If not (i.e. a release PR was just merged), it creates the tag -# and a GitHub release for each package, using release notes sliced verbatim from -# the committed CHANGELOG.md files. Creating the release triggers -# `python-wheels.yml`, which publishes the wheels to PyPI. +# 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 @@ -12,16 +18,27 @@ 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: - publish: - name: Tag and create GitHub releases + # 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 @@ -32,41 +49,79 @@ jobs: run: | git config user.name "hugrbot" git config user.email "hugrbot@users.noreply.github.com" - - name: Create releases for newly versioned packages + - name: Tag newly versioned packages run: | set -euo pipefail read_version() { sed -n 's/^version = "\(.*\)"/\1/p' "$1" | head -n1; } - release_pkg() { - local name="$1" pyproject="$2" changelog="$3" prefix="$4" + 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 publish for $name." + echo "$tag already exists; nothing to tag." return fi - echo "Publishing $name $version as 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 - + echo "Tagging $tag" git tag "$tag" + # Pushed with the bot PAT so it triggers the `create-release` job. git push origin "$tag" - - local 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[@]}" } - release_pkg "guppylang" \ - "guppylang/pyproject.toml" "guppylang/CHANGELOG.md" "guppylang-v" - release_pkg "guppylang-internals" \ - "guppylang-internals/pyproject.toml" "guppylang-internals/CHANGELOG.md" \ - "guppylang-internals-v" + 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/DEVELOPMENT.md b/DEVELOPMENT.md index 64718cafb..23965ab14 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -219,26 +219,48 @@ to allow it. ### Publishing -Once the release PR is merged, `release-publish.yml` tags both packages -(`guppylang-v` and `guppylang-internals-v`) and creates a -**draft** GitHub release for each, with notes sliced verbatim from the committed -changelogs. 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 merge first creates it. +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. -### 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. +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. -You will need to modify the version and changelog manually in this case. Once the -branch is ready, create a draft PR so that the release team can review it, then -create a [github release](https://github.com/quantinuum/guppylang/releases/new) -with a tag following the format used by the previous releases (e.g. -`guppylang-v1.0.1`). The CI will build and publish the wheels. +### Patch releases -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 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. +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. From d338ff4ff8e61f88154c167fb9989d5a4682c97b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 12:01:53 +0100 Subject: [PATCH 36/42] Release note sync and cleanup --- .github/workflows/release-pr-changelog-preview.yml | 1 + .github/workflows/release-pr.yml | 1 + DEVELOPMENT.md | 8 +++++--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-pr-changelog-preview.yml b/.github/workflows/release-pr-changelog-preview.yml index 2f7e622b7..8e105fad6 100644 --- a/.github/workflows/release-pr-changelog-preview.yml +++ b/.github/workflows/release-pr-changelog-preview.yml @@ -10,6 +10,7 @@ on: branches: - main - mr/chore/new-release-workflow + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 59051791b..e897b4e67 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -142,6 +142,7 @@ jobs: # 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" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 23965ab14..74227659b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -240,7 +240,7 @@ 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 scripts/release/compute_versions.py set-guppylang 1.0.1` + - `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 `. @@ -249,8 +249,10 @@ the draft GitHub release created automatically, no merge to `main` required. 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. + 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 From d86535553ce56e60a29b589c821eddec575b6525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 14:31:52 +0100 Subject: [PATCH 37/42] Address review comments --- .github/workflows/release-checks.yml | 2 +- .github/workflows/release-major-guard.yml | 2 +- .github/workflows/release-pr.yml | 2 +- DEVELOPMENT.md | 2 +- scripts/release/compute_versions.py | 5 +---- scripts/release/update_changelog.py | 5 +++-- 6 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 4273217a6..367a094e9 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -18,7 +18,7 @@ env: jobs: check-release-guppylang: - name: Check `guppylang` release compatibility with ${{ matrix.target.resolution }} dependencies + name: Check release compatibility with ${{ matrix.target.resolution }} dependencies runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-pr--') strategy: diff --git a/.github/workflows/release-major-guard.yml b/.github/workflows/release-major-guard.yml index eb19a7a27..03b74b04a 100644 --- a/.github/workflows/release-major-guard.yml +++ b/.github/workflows/release-major-guard.yml @@ -46,7 +46,7 @@ jobs: echo "guppylang major version: base=$base_major head=$head_major" if [ "$head_major" -gt "$base_major" ]; then - if echo "$LABELS" | jq -e "index($ALLOW_MAJOR_BUMP_LABEL)" >/dev/null; 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." diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index e897b4e67..c0286b61a 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -222,7 +222,7 @@ jobs: git commit -aqm "chore: update changelogs (by user)" || echo "changelogs unchanged" fi - git push --force origin "$BRANCH" + git push --force-with-lease origin "$BRANCH" - name: Create or update the release PR id: openpr diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 74227659b..7d7dd84af 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -150,7 +150,7 @@ and `release-publish.yml`), driven by [conventional commits](https://www.convent 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.3b5`). +- `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. diff --git a/scripts/release/compute_versions.py b/scripts/release/compute_versions.py index 07057d766..77c237663 100755 --- a/scripts/release/compute_versions.py +++ b/scripts/release/compute_versions.py @@ -202,11 +202,8 @@ def bump_guppylang(current: GuppyVersion, mode: str) -> GuppyVersion: case BumpMode.major: return GuppyVersion(current.major + 1, 0, 0) - case _: - raise ValueError(f"Unknown bump mode: {mode!r}") - bump_modes = BumpMode.__members__.values() - msg = f"Unknown bump mode: {mode!r} (expected one of {', '.join(bump_modes)})" + msg = f"Unknown mode: {mode!r} (must be one of {', '.join(bump_modes)})" raise ValueError(msg) diff --git a/scripts/release/update_changelog.py b/scripts/release/update_changelog.py index 10d31a29c..ca69a6f09 100755 --- a/scripts/release/update_changelog.py +++ b/scripts/release/update_changelog.py @@ -21,7 +21,8 @@ def _section_bounds(lines: list[str], version: str) -> tuple[int, int] | None: """Return the ``[start, end)`` line range of ``version``'s section, if any.""" - target_re = re.compile(r"^## \[?" + re.escape(version) + r"\b") + 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): @@ -39,7 +40,7 @@ def _section_bounds(lines: list[str], version: str) -> tuple[int, int] | None: def _first_version_header(lines: list[str]) -> int | None: for index, line in enumerate(lines): - if line.startswith("## "): + if line.startswith("## ["): return index return None From fa0f0492ece5fdbfb31f4e899f3afc872075312f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 14:34:45 +0100 Subject: [PATCH 38/42] Revert "Reapply "temp: Run on my branch"" This reverts commit a602d98e --- .github/workflows/release-major-guard.yml | 1 - .github/workflows/release-pr-changelog-preview.yml | 1 - .github/workflows/release-pr.yml | 1 - 3 files changed, 3 deletions(-) diff --git a/.github/workflows/release-major-guard.yml b/.github/workflows/release-major-guard.yml index 03b74b04a..2abac29d3 100644 --- a/.github/workflows/release-major-guard.yml +++ b/.github/workflows/release-major-guard.yml @@ -7,7 +7,6 @@ on: pull_request: branches: - main - - mr/chore/new-release-workflow types: - opened - synchronize diff --git a/.github/workflows/release-pr-changelog-preview.yml b/.github/workflows/release-pr-changelog-preview.yml index 8e105fad6..796c74736 100644 --- a/.github/workflows/release-pr-changelog-preview.yml +++ b/.github/workflows/release-pr-changelog-preview.yml @@ -9,7 +9,6 @@ on: - synchronize branches: - main - - mr/chore/new-release-workflow workflow_dispatch: permissions: diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index c0286b61a..4c8f02125 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -23,7 +23,6 @@ on: push: branches: - main - - mr/chore/new-release-workflow workflow_dispatch: inputs: force_open: From 5087b10cb2f64f054acab14abc6f284ccb7e0b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 14:35:13 +0100 Subject: [PATCH 39/42] Revert "Remove tests for now" This reverts commit e69d5f801902a4e536cb60f8c58be8918c6a77d0. --- tests/release/__init__.py | 0 tests/release/conftest.py | 16 +++++ tests/release/test_compute_versions.py | 89 +++++++++++++++++++++++++ tests/release/test_extract_changelog.py | 65 ++++++++++++++++++ tests/release/test_render_pr_body.py | 48 +++++++++++++ tests/release/test_update_changelog.py | 60 +++++++++++++++++ 6 files changed, 278 insertions(+) create mode 100644 tests/release/__init__.py create mode 100644 tests/release/conftest.py create mode 100644 tests/release/test_compute_versions.py create mode 100644 tests/release/test_extract_changelog.py create mode 100644 tests/release/test_render_pr_body.py create mode 100644 tests/release/test_update_changelog.py 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..8a8d14a94 --- /dev/null +++ b/tests/release/test_compute_versions.py @@ -0,0 +1,89 @@ +"""Tests for the version bump logic in ``scripts/release/compute_versions.py``.""" + +from __future__ import annotations + +import compute_versions as cv +import pytest + + +@pytest.mark.parametrize( + ("current", "mode", "expected"), + [ + ("1.0.0-a5", "auto", "1.0.0-a6"), + ("1.0.0-a5", "alpha", "1.0.0-a6"), + ("1.0.0-a9", "alpha", "1.0.0-a10"), + ("1.0.0-a5", "rc", "1.0.0-rc1"), + ("1.0.0-rc1", "rc", "1.0.0-rc2"), + ("1.0.0-a5", "stable", "1.0.0"), + ("1.0.0-rc2", "stable", "1.0.0"), + ("1.0.0-a5", "patch", "1.0.1-a1"), + ("1.0.0-a5", "minor", "1.1.0-a1"), + ("1.0.0-a5", "major", "2.0.0-a1"), + ], +) +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 + + +@pytest.mark.parametrize( + ("current", "mode", "match"), + [ + ("1.0.0", "alpha", "alpha'-bump"), + ("1.0.0", "rc", "rc'-bump stable"), + ("1.0.0", "stable", "already stable"), + ], +) +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_major_bumped_detection() -> None: + current = cv.parse_guppy_version("1.0.0-a5") + assert cv.bump_guppylang(current, "major").major > current.major + assert cv.bump_guppylang(current, "minor").major == current.major + + +@pytest.mark.parametrize( + ("current_internals", "new_major", "expected"), + [ + # Migration from the legacy 3-part scheme. + ("1.0.0-a5", 1, "1.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"), + ], +) +def test_bump_internals(current_internals: str, new_major: int, expected: str) -> None: + assert cv.bump_internals(current_internals, new_major) == expected + + +def test_set_version_in_pyproject() -> None: + text = 'name = "guppylang"\nversion = "1.0.0-a5"\nrequires-python = ">=3.10"\n' + out = cv.set_version_in_pyproject(text, "1.0.0-a6") + assert 'version = "1.0.0-a6"' in out + assert "1.0.0-a5" not in out + + +def test_set_dunder_version() -> None: + text = '# comment\n__version__ = "1.0.0-a5"\n' + out = cv.set_dunder_version(text, "1.0.0-a6") + assert out == '# comment\n__version__ = "1.0.0-a6"\n' + + +def test_set_internals_pin() -> None: + text = ( + 'dependencies = [\n "guppylang-internals~=1.0.0-a5",\n "numpy~=2.0",\n]\n' + ) + out = cv.set_internals_pin(text, "1.0") + assert '"guppylang-internals==1.0"' in out + assert "~=1.0.0-a5" not in out + assert '"numpy~=2.0"' in out + + +def test_replace_once_requires_single_match() -> None: + 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..38c759328 --- /dev/null +++ b/tests/release/test_extract_changelog.py @@ -0,0 +1,65 @@ +"""Tests for ``scripts/release/extract_changelog.py``.""" + +from __future__ import annotations + +import extract_changelog as ec +import pytest + +CHANGELOG = """\ +# Changelog + +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 "intro prose" not in section + assert "## [1.0.0-a6]" not in section + + +def test_extract_older_section() -> None: + 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_with_header() -> None: + section = ec.extract_section(CHANGELOG, "1.0.0-a6", include_header=True) + assert section.startswith("## [1.0.0-a6]") + + +def test_extract_missing_version() -> None: + with pytest.raises(KeyError): + ec.extract_section(CHANGELOG, "9.9.9") + + +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 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..9178ff5af --- /dev/null +++ b/tests/release/test_update_changelog.py @@ -0,0 +1,60 @@ +"""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 From 60f8123894d9dd44b11638ec09d0bb861d01db65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 14:55:52 +0100 Subject: [PATCH 40/42] Moar tests --- tests/release/test_compute_versions.py | 150 +++++++++++++++++++----- tests/release/test_extract_changelog.py | 63 +++++++++- tests/release/test_update_changelog.py | 19 +++ 3 files changed, 195 insertions(+), 37 deletions(-) diff --git a/tests/release/test_compute_versions.py b/tests/release/test_compute_versions.py index 8a8d14a94..a87fe0a6f 100644 --- a/tests/release/test_compute_versions.py +++ b/tests/release/test_compute_versions.py @@ -2,59 +2,147 @@ from __future__ import annotations +from pathlib import Path + import compute_versions as cv import pytest - -@pytest.mark.parametrize( - ("current", "mode", "expected"), - [ - ("1.0.0-a5", "auto", "1.0.0-a6"), - ("1.0.0-a5", "alpha", "1.0.0-a6"), - ("1.0.0-a9", "alpha", "1.0.0-a10"), - ("1.0.0-a5", "rc", "1.0.0-rc1"), - ("1.0.0-rc1", "rc", "1.0.0-rc2"), - ("1.0.0-a5", "stable", "1.0.0"), - ("1.0.0-rc2", "stable", "1.0.0"), - ("1.0.0-a5", "patch", "1.0.1-a1"), - ("1.0.0-a5", "minor", "1.1.0-a1"), - ("1.0.0-a5", "major", "2.0.0-a1"), - ], -) +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", "mode", "match"), + ("current", "bumped_core", "expected"), [ - ("1.0.0", "alpha", "alpha'-bump"), - ("1.0.0", "rc", "rc'-bump stable"), - ("1.0.0", "stable", "already stable"), + # 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_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_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_major_bumped_detection() -> None: - current = cv.parse_guppy_version("1.0.0-a5") - assert cv.bump_guppylang(current, "major").major > current.major - assert cv.bump_guppylang(current, "minor").major == current.major +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. + # 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: @@ -76,12 +164,12 @@ def test_set_dunder_version() -> None: def test_set_internals_pin() -> None: text = ( - 'dependencies = [\n "guppylang-internals~=1.0.0-a5",\n "numpy~=2.0",\n]\n' + 'dependencies = [\n "guppylang-internals==1.0.0-a5",\n "numpy>=2.0",\n]\n' ) out = cv.set_internals_pin(text, "1.0") assert '"guppylang-internals==1.0"' in out - assert "~=1.0.0-a5" not in out - assert '"numpy~=2.0"' in out + assert "1.0.0-a5" not in out + assert '"numpy>=2.0"' in out def test_replace_once_requires_single_match() -> None: diff --git a/tests/release/test_extract_changelog.py b/tests/release/test_extract_changelog.py index 38c759328..355a44d79 100644 --- a/tests/release/test_extract_changelog.py +++ b/tests/release/test_extract_changelog.py @@ -8,6 +8,8 @@ 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) @@ -35,26 +37,32 @@ def test_extract_latest_section() -> None: 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_with_header() -> None: - section = ec.extract_section(CHANGELOG, "1.0.0-a6", include_header=True) - assert section.startswith("## [1.0.0-a6]") - - def test_extract_missing_version() -> None: - with pytest.raises(KeyError): + 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" @@ -63,3 +71,46 @@ def test_plain_internals_version_header() -> None: 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_update_changelog.py b/tests/release/test_update_changelog.py index 9178ff5af..a45271933 100644 --- a/tests/release/test_update_changelog.py +++ b/tests/release/test_update_changelog.py @@ -58,3 +58,22 @@ def test_insert_into_changelog_without_versions() -> None: 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 From e0ff53d851e74ee63fc410c87bfa297e213375b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 15:10:19 +0100 Subject: [PATCH 41/42] EVEN MOAR --- scripts/release/compute_versions.py | 4 +- tests/release/test_compute_versions.py | 26 +-- tests/release/test_release_integration.py | 208 ++++++++++++++++++++++ 3 files changed, 214 insertions(+), 24 deletions(-) create mode 100644 tests/release/test_release_integration.py diff --git a/scripts/release/compute_versions.py b/scripts/release/compute_versions.py index 77c237663..dab3adebd 100755 --- a/scripts/release/compute_versions.py +++ b/scripts/release/compute_versions.py @@ -335,15 +335,17 @@ def read_current_internals(root: Path) -> str: 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(read_current_internals(root), new_guppy.major) + 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}", ] diff --git a/tests/release/test_compute_versions.py b/tests/release/test_compute_versions.py index a87fe0a6f..c9bc5e74f 100644 --- a/tests/release/test_compute_versions.py +++ b/tests/release/test_compute_versions.py @@ -149,29 +149,9 @@ def test_bump_internals(current_internals: str, new_major: int, expected: str) - assert cv.bump_internals(current_internals, new_major) == expected -def test_set_version_in_pyproject() -> None: - text = 'name = "guppylang"\nversion = "1.0.0-a5"\nrequires-python = ">=3.10"\n' - out = cv.set_version_in_pyproject(text, "1.0.0-a6") - assert 'version = "1.0.0-a6"' in out - assert "1.0.0-a5" not in out - - -def test_set_dunder_version() -> None: - text = '# comment\n__version__ = "1.0.0-a5"\n' - out = cv.set_dunder_version(text, "1.0.0-a6") - assert out == '# comment\n__version__ = "1.0.0-a6"\n' - - -def test_set_internals_pin() -> None: - text = ( - 'dependencies = [\n "guppylang-internals==1.0.0-a5",\n "numpy>=2.0",\n]\n' - ) - out = cv.set_internals_pin(text, "1.0") - assert '"guppylang-internals==1.0"' in out - assert "1.0.0-a5" not in out - assert '"numpy>=2.0"' in out - - 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_release_integration.py b/tests/release/test_release_integration.py new file mode 100644 index 000000000..6d89b2711 --- /dev/null +++ b/tests/release/test_release_integration.py @@ -0,0 +1,208 @@ +"""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_resolves_via_git_cliff( + fake_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # 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" From 7579b2c1cb0070831fc069b6c3a2b3e280c64f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= Date: Mon, 22 Jun 2026 15:29:03 +0100 Subject: [PATCH 42/42] Tests are my lifeblood --- scripts/release/compute_versions.py | 6 +++--- tests/release/test_release_integration.py | 22 +++++++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/scripts/release/compute_versions.py b/scripts/release/compute_versions.py index dab3adebd..36fa23d5f 100755 --- a/scripts/release/compute_versions.py +++ b/scripts/release/compute_versions.py @@ -131,7 +131,7 @@ def bump_guppylang(current: GuppyVersion, mode: str) -> GuppyVersion: return GuppyVersion(current.major, current.minor, current.patch + 1) case BumpMode.alpha: - if current.pre_label != "a": + 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." @@ -202,7 +202,7 @@ def bump_guppylang(current: GuppyVersion, mode: str) -> GuppyVersion: case BumpMode.major: return GuppyVersion(current.major + 1, 0, 0) - bump_modes = BumpMode.__members__.values() + bump_modes = [m.value for m in BumpMode] msg = f"Unknown mode: {mode!r} (must be one of {', '.join(bump_modes)})" raise ValueError(msg) @@ -210,7 +210,7 @@ def bump_guppylang(current: GuppyVersion, mode: str) -> GuppyVersion: def _auto_mode_from_core( current: GuppyVersion, bumped_core: tuple[int, int, int] | None ) -> BumpMode: - if bumped_core is not None: + if bumped_core is not None and not current.is_prerelease: major, minor, patch = bumped_core if major > current.major: return BumpMode.major diff --git a/tests/release/test_release_integration.py b/tests/release/test_release_integration.py index 6d89b2711..abd7bb184 100644 --- a/tests/release/test_release_integration.py +++ b/tests/release/test_release_integration.py @@ -96,9 +96,29 @@ def test_compute_auto_falls_back_without_git_cliff( assert values["guppylang"] == "1.0.0-a6" -def test_compute_auto_resolves_via_git_cliff( +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"