Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/dependabot.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
labels:
- dependencies
- github_actions
- skip news
schedule:
interval: "weekly"
day: "tuesday"
Expand Down
30 changes: 30 additions & 0 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: ["main"]
tags-ignore: ["**"]
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
schedule:
- cron: "0 8 * * *"
concurrency:
Expand Down Expand Up @@ -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"
111 changes: 88 additions & 23 deletions .github/workflows/prepare-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
12 changes: 12 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]
129 changes: 19 additions & 110 deletions tasks/release.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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,
Expand All @@ -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

Expand Down