diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 69d0cfd..fccacae 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -2,6 +2,10 @@ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" + labels: + - dependencies + - github_actions + - skip news schedule: interval: "weekly" day: "tuesday" diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index ad7df4f..a1095f1 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -5,6 +5,7 @@ on: branches: ["main"] tags-ignore: ["**"] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] schedule: - cron: "0 8 * * *" concurrency: @@ -80,3 +81,32 @@ jobs: run: tasks/old_target_check.sh 3.5 rejected - name: Reject Python 2.7 run: tasks/old_target_check.sh 2.7 rejected + changelog: + if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'skip news') + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + python-version: "3.15" + enable-cache: false + - run: uvx towncrier check --compare-with "origin/${GITHUB_BASE_REF}" + gate: + name: Required checks pass + if: always() + needs: [test, old-target, changelog] + runs-on: ubuntu-24.04 + steps: + - env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + jq -e ' + all( + to_entries[]; + .value.result == "success" or + (.key == "changelog" and .value.result == "skipped") + ) + ' <<< "$NEEDS_JSON" diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index adfa2e3..a1c1d9c 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -17,28 +17,34 @@ permissions: env: FORCE_COLOR: 1 jobs: - prepare-release: + assemble: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 - environment: release-auth permissions: - contents: write + contents: read + outputs: + version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 - token: ${{ secrets.RELEASE_PAT }} - persist-credentials: true - - name: Install the latest version of uv + fetch-depth: 0 # The next version comes from the tags. + persist-credentials: false + - name: Install uv uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: python-version: "3.15" - enable-cache: true - cache-dependency-glob: "pyproject.toml" - github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Configure git + enable-cache: false + - name: Require a news fragment run: | - git config user.name "${GITHUB_ACTOR}" - git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + if ! compgen -G 'docs/changelog/*.*.rst' > /dev/null; then + echo '::error::add a docs/changelog/*.rst fragment before preparing a release' + exit 1 + fi + - name: Set up remote tracking + run: | + git remote -v + git fetch origin + git branch -a - name: Calculate next version id: version env: @@ -78,15 +84,74 @@ jobs: esac echo "Next version: $next" - echo "version=$next" >> $GITHUB_OUTPUT - - name: Install tox - run: uv tool install --python-preference only-managed --python 3.15 tox@latest --with tox-uv - - name: Run release process - run: tox run -e release -- ${STEPS_VERSION_OUTPUTS_VERSION} + echo "version=$next" >> "$GITHUB_OUTPUT" + - name: Build release patch + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + uvx towncrier build --yes --version "$VERSION" + uvx --with pre-commit-uv pre-commit run --files docs/changelog.rst || + uvx --with pre-commit-uv pre-commit run --files docs/changelog.rst + git diff HEAD --binary > "$RUNNER_TEMP/release.patch" + test -s "$RUNNER_TEMP/release.patch" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-patch + path: ${{ runner.temp }}/release.patch + if-no-files-found: error + push: + needs: assemble + runs-on: ubuntu-24.04 + environment: release-auth + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-patch + path: ${{ runner.temp }}/release-patch + - name: Validate release patch env: - GH_TOKEN: ${{ secrets.RELEASE_PAT }} - STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} - - name: Display completion message - run: echo "Release ${STEPS_VERSION_OUTPUTS_VERSION} prepared and pushed successfully!" + VERSION: ${{ needs.assemble.outputs.version }} + run: | + set -euo pipefail + git fetch origin main + git reset --hard origin/main + git apply --check "$RUNNER_TEMP/release-patch/release.patch" + git apply --index "$RUNNER_TEMP/release-patch/release.patch" + while IFS= read -r path; do + case "$path" in + docs/changelog.rst|docs/changelog/*.rst) ;; + *) echo "::error::unexpected release path: $path"; exit 1 ;; + esac + done < <(git diff --cached --name-only) + grep -qE "^ v${VERSION//./\\.} \(" docs/changelog.rst + git diff --cached --check + - id: token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + - name: Commit and tag the release env: - STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ needs.assemble.outputs.version }} + GH_TOKEN: ${{ steps.token.outputs.token }} + APP_SLUG: ${{ steps.token.outputs.app-slug }} + run: | + set -euo pipefail + app_user_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) + git config user.name "${APP_SLUG}[bot]" + git config user.email "${app_user_id}+${APP_SLUG}[bot]@users.noreply.github.com" + git commit -m "release $VERSION" + git tag -a "$VERSION" -m "release $VERSION" + gh auth setup-git --hostname github.com --force + git push --atomic origin HEAD:main "refs/tags/$VERSION" + gh release create "$VERSION" --title "v$VERSION" --generate-notes --verify-tag diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 259993a..86ddf93 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,6 +14,18 @@ jobs: with: fetch-depth: 0 persist-credentials: false + - name: Require a changelog section for the tag + env: + TAG: ${{ github.ref_name }} + run: | + if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::unexpected release tag $TAG" + exit 1 + fi + if ! grep -qE "^ v${TAG//./\\.} \\(" docs/changelog.rst; then + echo "::error::docs/changelog.rst has no section for $TAG" + exit 1 + fi - name: Install the latest version of uv uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: diff --git a/pyproject.toml b/pyproject.toml index 8b504da..9475c43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -228,5 +228,4 @@ type = [ { directory = "doc", name = "Improved documentation", showcontent = true }, { directory = "packaging", name = "Packaging updates and notes for downstreams", showcontent = true }, { directory = "contrib", name = "Contributor-facing changes", showcontent = true }, - { directory = "misc", name = "Miscellaneous internal changes", showcontent = true }, ] diff --git a/tasks/release.py b/tasks/release.py index 9306ae9..5249ffb 100644 --- a/tasks/release.py +++ b/tasks/release.py @@ -1,55 +1,33 @@ -"""Handles creating a release commit and tag, then publishes the GitHub release.""" - from __future__ import annotations from pathlib import Path from subprocess import CalledProcessError, check_call, run +from typing import Final -from git import Commit, Head, Remote, Repo, TagReference +from git import Commit, Remote, Repo from packaging.version import Version -ROOT_SRC_DIR = Path(__file__).parents[1] +_ROOT_SRC_DIR: Final[Path] = Path(__file__).parents[1] def main(version_str: str) -> None: version = Version(version_str) - repo = Repo(str(ROOT_SRC_DIR)) + repo = Repo(str(_ROOT_SRC_DIR)) if repo.is_dirty(): msg = "Current repository is dirty. Please commit any changes and try again." raise RuntimeError(msg) - upstream, release_branch = create_release_branch(repo, version) - original_main_sha = upstream.refs.main.commit.hexsha - main_pushed = False - tag_pushed = False - release_created = False - try: - main_pushed, tag_pushed, release_created = push_release(repo, upstream, release_branch, version) - finalize_release(repo, upstream, release_branch) - except Exception: - cleanup_failed_release( - repo, - upstream, - version, - release_branch, - original_main_sha, - release_created=release_created, - tag_pushed=tag_pushed, - main_pushed=main_pushed, - ) - raise - - -def create_release_branch(repo: Repo, version: Version) -> tuple[Remote, Head]: - print("create release branch from upstream main") # ruff:ignore[print] upstream = get_upstream(repo) upstream.fetch() - branch_name = f"release-{version}" - release_branch = repo.create_head(branch_name, upstream.refs.main, force=True) - upstream.push(refspec=f"{branch_name}:{branch_name}", force=True) - release_branch.set_tracking_branch(upstream.refs[branch_name]) - release_branch.checkout() - return upstream, release_branch + if str(version) in {tag.name for tag in repo.tags}: + msg = f"release tag {version} already exists" + raise RuntimeError(msg) + repo.git.checkout("-B", f"release-{version}", upstream.refs.main) + release_commit = release_changelog(repo, version) + repo.create_tag(str(version), ref=release_commit.hexsha, message=f"release {version}") + repo.git.push(upstream.name, "HEAD:main") + repo.git.push(upstream.name, f"refs/tags/{version}") + create_github_release(version) def get_upstream(repo: Repo) -> Remote: @@ -60,47 +38,26 @@ def get_upstream(repo: Repo) -> Remote: raise RuntimeError(msg) -def push_release(repo: Repo, upstream: Remote, release_branch: Head, version: Version) -> tuple[bool, bool, bool]: - release_commit = release_changelog(repo, version) - tag = tag_release_commit(release_commit, repo, version) - print("push release commit") # ruff:ignore[print] - repo.git.push(upstream.name, f"{release_branch}:main", "-f") - print("push release tag") # ruff:ignore[print] - repo.git.push(upstream.name, tag, "-f") - create_github_release(version) - return True, True, True - - def release_changelog(repo: Repo, version: Version) -> Commit: print("generate release commit") # ruff:ignore[print] - check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(ROOT_SRC_DIR)) # ruff:ignore[start-process-with-partial-path] + check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(_ROOT_SRC_DIR)) # ruff:ignore[start-process-with-partial-path] print("format changelog with pre-commit") # ruff:ignore[print] - changelog_path = ROOT_SRC_DIR / "docs" / "changelog.rst" + changelog_path = _ROOT_SRC_DIR / "docs" / "changelog.rst" try: - check_call(["pre-commit", "run", "--files", str(changelog_path)], cwd=str(ROOT_SRC_DIR)) # ruff:ignore[start-process-with-partial-path] + check_call(["pre-commit", "run", "--files", str(changelog_path)], cwd=str(_ROOT_SRC_DIR)) # ruff:ignore[start-process-with-partial-path] except CalledProcessError: - print("pre-commit made formatting changes, staging them") # ruff:ignore[print] - repo.index.add([str(changelog_path)]) + check_call(["pre-commit", "run", "--files", str(changelog_path)], cwd=str(_ROOT_SRC_DIR)) # ruff:ignore[start-process-with-partial-path] + repo.git.add("-A", "--", "docs/changelog.rst", "docs/changelog") return repo.index.commit(f"release {version}") -def tag_release_commit(release_commit: Commit, repo: Repo, version: Version) -> TagReference: - print("tag release commit") # ruff:ignore[print] - version_str = str(version) - if version_str in {tag.name for tag in repo.tags}: - print(f"delete existing tag {version_str}") # ruff:ignore[print] - repo.delete_tag(repo.tags[version_str]) - print(f"create tag {version_str}") # ruff:ignore[print] - return repo.create_tag(version_str, ref=release_commit.hexsha, force=True) - - def create_github_release(version: Version) -> None: print("create github release") # ruff:ignore[print] version_str = str(version) try: result = run( ["gh", "release", "create", version_str, "--title", f"v{version_str}", "--generate-notes"], # ruff:ignore[start-process-with-partial-path] - cwd=str(ROOT_SRC_DIR), + cwd=str(_ROOT_SRC_DIR), capture_output=True, text=True, check=True, @@ -116,54 +73,6 @@ def create_github_release(version: Version) -> None: raise -def finalize_release(repo: Repo, upstream: Remote, release_branch: Head) -> None: - print("checkout main to new release and delete release branch") # ruff:ignore[print] - repo.heads.main.checkout() - repo.delete_head(release_branch, force=True) - print("delete remote release branch") # ruff:ignore[print] - repo.git.push(upstream.name, f":{release_branch}", "--no-verify") - upstream.fetch() - repo.git.reset("--hard", f"{upstream.name}/main") - print("All done!") # ruff:ignore[print] - - -def cleanup_failed_release( # ruff:ignore[too-many-arguments] - repo: Repo, - upstream: Remote, - version: Version, - release_branch: Head, - original_main_sha: str, - *, - release_created: bool, - tag_pushed: bool, - main_pushed: bool, -) -> None: - print("Release failed! Cleaning up...") # ruff:ignore[print] - if release_created: - print(f"Deleting GitHub release {version}") # ruff:ignore[print] - try: - check_call(["gh", "release", "delete", str(version), "--yes"], cwd=str(ROOT_SRC_DIR)) # ruff:ignore[start-process-with-partial-path] - except Exception as cleanup_error: # ruff:ignore[blind-except] - print(f"Warning: Failed to delete GitHub release: {cleanup_error}") # ruff:ignore[print] - if tag_pushed: - print(f"Deleting remote tag {version}") # ruff:ignore[print] - try: - repo.git.push(upstream.name, f":refs/tags/{version}", "--no-verify") - except Exception as cleanup_error: # ruff:ignore[blind-except] - print(f"Warning: Failed to delete remote tag: {cleanup_error}") # ruff:ignore[print] - if main_pushed: - print(f"Reverting main to {original_main_sha[:8]}") # ruff:ignore[print] - try: - repo.git.push(upstream.name, f"{original_main_sha}:main", "-f", "--no-verify") - except Exception as cleanup_error: # ruff:ignore[blind-except] - print(f"Warning: Failed to revert main: {cleanup_error}") # ruff:ignore[print] - print("Deleting remote release branch") # ruff:ignore[print] - try: - repo.git.push(upstream.name, f":{release_branch}", "--no-verify") - except Exception as cleanup_error: # ruff:ignore[blind-except] - print(f"Warning: Failed to delete remote branch: {cleanup_error}") # ruff:ignore[print] - - if __name__ == "__main__": import argparse