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