Skip to content

Commit 17786e1

Browse files
RonnyPfannschmidtCursor AIclaude
committed
feat: automate release pipeline with towncrier-fragments version scheme
Replace the manual release process with an automated CI pipeline: - Configure setuptools_scm with the `towncrier-fragments` version scheme (from vcs-versioning, bundled with setuptools-scm>=10.0) to derive semver bumps from changelog fragment types automatically. - Add `prepare-release.yml` workflow: triggers on every push to main, computes the next version from fragments, creates/updates a `release-X.Y.Z` branch with towncrier-built changelog, and opens a PR. - Modify `test.yml`: set SETUPTOOLS_SCM_PRETEND_VERSION on release PRs so built packages have the exact release version. Add a `release-artifacts` job that creates/updates a draft GitHub release with the tested wheel+sdist after all checks pass. - Replace `deploy.yml`: trigger on `release: published` instead of tag push. Downloads bit-identical assets from the draft release, verifies PR checks, uploads to PyPI via trusted publishing, merges the PR, and cleans up the release branch. - Reclassify `632.removal.rst` as `632.feature.rst` (it's a deprecation warning, not a breaking removal). - Update RELEASING.rst and scripts/release.py to document and support the new automated flow (with manual fallback preserved). Co-authored-by: Cursor AI <ai@cursor.sh> Co-authored-by: Anthropic Claude Opus 4 <claude@anthropic.com>
1 parent dedb892 commit 17786e1

8 files changed

Lines changed: 408 additions & 53 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,114 @@
11
name: deploy
22

33
on:
4-
push:
5-
tags:
6-
- "*"
4+
release:
5+
types: [published]
76

8-
# Set permissions at the job level.
97
permissions: {}
108

119
jobs:
12-
package:
13-
name: Build & inspect our package.
10+
verify:
11+
name: Verify release PR status
12+
if: github.repository == 'pytest-dev/pluggy'
1413
runs-on: ubuntu-latest
1514
permissions:
16-
id-token: write
17-
attestations: write
15+
checks: read
16+
pull-requests: read
17+
outputs:
18+
pr-number: ${{ steps.find-pr.outputs.number }}
1819
steps:
19-
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
20-
with:
21-
fetch-depth: 0
22-
persist-credentials: false
23-
- uses: hynek/build-and-inspect-python-package@fe0a0fb1925ca263d076ca4f2c13e93a6e92a33e # v2.17.0
24-
with:
25-
attest-build-provenance-github: 'true'
20+
- name: Find release PR
21+
id: find-pr
22+
env:
23+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24+
GH_REPO: ${{ github.repository }}
25+
run: |
26+
VERSION="${GITHUB_REF_NAME}"
27+
BRANCH="release-$VERSION"
28+
PR_NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
29+
if [ -z "$PR_NUMBER" ]; then
30+
echo "::error::No open PR found for branch $BRANCH"
31+
exit 1
32+
fi
33+
echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
34+
echo "Found PR #$PR_NUMBER for $BRANCH"
35+
36+
- name: Check PR CI status
37+
env:
38+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39+
GH_REPO: ${{ github.repository }}
40+
run: |
41+
PR_NUMBER="${{ steps.find-pr.outputs.number }}"
42+
# Verify all required checks passed on the PR.
43+
FAILED=$(gh pr checks "$PR_NUMBER" --json name,state \
44+
--jq '[.[] | select(.state != "SUCCESS" and .state != "SKIPPED")] | length')
45+
if [ "$FAILED" -gt 0 ]; then
46+
echo "::error::PR #$PR_NUMBER has failing checks — refusing to deploy."
47+
gh pr checks "$PR_NUMBER" --json name,state \
48+
--jq '.[] | select(.state != "SUCCESS" and .state != "SKIPPED") | "\(.name): \(.state)"'
49+
exit 1
50+
fi
51+
echo "All PR checks passed."
2652
2753
deploy:
28-
needs: [package]
29-
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'pytest-dev/pluggy'
54+
name: Publish to PyPI
55+
needs: [verify]
3056
runs-on: ubuntu-latest
3157
permissions:
3258
id-token: write
59+
attestations: write
60+
contents: write
3361
steps:
34-
- name: Download built packages from the check-package job.
35-
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
62+
- name: Download release assets
63+
env:
64+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65+
GH_REPO: ${{ github.repository }}
66+
run: |
67+
mkdir dist
68+
gh release download "${GITHUB_REF_NAME}" --dir dist
69+
echo "Downloaded assets:"
70+
ls -la dist/
71+
72+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
3673
with:
37-
name: Packages
38-
path: dist
39-
- name: Publish package
74+
path: repo
75+
sparse-checkout: |
76+
.github
77+
persist-credentials: false
78+
79+
- name: Generate build provenance attestations
80+
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
81+
with:
82+
subject-path: "dist/*"
83+
84+
- name: Publish to PyPI
4085
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
4186
with:
4287
attestations: true
88+
89+
merge:
90+
name: Merge release PR
91+
needs: [verify, deploy]
92+
runs-on: ubuntu-latest
93+
permissions:
94+
contents: write
95+
pull-requests: write
96+
steps:
97+
- name: Merge PR
98+
env:
99+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
100+
GH_REPO: ${{ github.repository }}
101+
run: |
102+
PR_NUMBER="${{ needs.verify.outputs.pr-number }}"
103+
gh pr merge "$PR_NUMBER" --merge \
104+
--subject "Merge release ${{ github.ref_name }} (#$PR_NUMBER)"
105+
echo "Merged PR #$PR_NUMBER"
106+
107+
- name: Delete release branch
108+
env:
109+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
110+
GH_REPO: ${{ github.repository }}
111+
run: |
112+
BRANCH="release-${GITHUB_REF_NAME}"
113+
gh api -X DELETE "repos/${GH_REPO}/git/refs/heads/$BRANCH" || true
114+
echo "Deleted branch $BRANCH"
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
name: prepare-release
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
permissions: {}
9+
10+
jobs:
11+
prepare:
12+
name: Prepare release PR
13+
runs-on: ubuntu-latest
14+
# Only run in the upstream repo, not forks.
15+
if: github.repository == 'pytest-dev/pluggy'
16+
permissions:
17+
contents: write
18+
pull-requests: write
19+
steps:
20+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
21+
with:
22+
fetch-depth: 0
23+
24+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
25+
with:
26+
python-version: "3.13"
27+
28+
- name: Install dependencies
29+
run: pip install "setuptools-scm[toml]>=10.0" packaging towncrier
30+
31+
- name: Compute next version
32+
id: version
33+
run: |
34+
VERSION=$(python scripts/compute_version.py) || exit 0
35+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
36+
echo "branch=release-$VERSION" >> "$GITHUB_OUTPUT"
37+
echo "Computed version: $VERSION"
38+
39+
- name: Check for existing identical release branch
40+
if: steps.version.outputs.version
41+
id: check
42+
run: |
43+
VERSION="${{ steps.version.outputs.version }}"
44+
BRANCH="release-$VERSION"
45+
# If the release branch already exists and was built from this
46+
# exact main commit, there is nothing to update.
47+
if git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
48+
BRANCH_BASE=$(git log "origin/$BRANCH" --format='%H' --max-count=1 --skip=1)
49+
if [ "$BRANCH_BASE" = "${{ github.sha }}" ]; then
50+
echo "Release branch $BRANCH already up-to-date with main."
51+
echo "skip=true" >> "$GITHUB_OUTPUT"
52+
fi
53+
fi
54+
55+
- name: Create release branch
56+
if: steps.version.outputs.version && steps.check.outputs.skip != 'true'
57+
run: |
58+
VERSION="${{ steps.version.outputs.version }}"
59+
BRANCH="${{ steps.version.outputs.branch }}"
60+
61+
git config user.name "github-actions[bot]"
62+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
63+
64+
# Start a fresh branch from the current main HEAD.
65+
git checkout -B "$BRANCH"
66+
67+
# Build the changelog (removes fragments, updates CHANGELOG.rst).
68+
towncrier build --yes --version "$VERSION"
69+
70+
git add -A
71+
git commit -m "Preparing release $VERSION"
72+
git push --force origin "$BRANCH"
73+
74+
- name: Create or update pull request
75+
if: steps.version.outputs.version && steps.check.outputs.skip != 'true'
76+
env:
77+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
78+
run: |
79+
VERSION="${{ steps.version.outputs.version }}"
80+
BRANCH="${{ steps.version.outputs.branch }}"
81+
82+
EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number // empty')
83+
84+
BODY="$(cat <<'EOF'
85+
Automated release PR created by CI.
86+
87+
## Checklist
88+
89+
- [ ] Review the generated CHANGELOG.rst
90+
- [ ] Ensure all CI checks pass
91+
- [ ] When ready, **publish the draft GitHub release** to trigger PyPI deployment and merge
92+
EOF
93+
)"
94+
95+
if [ -n "$EXISTING_PR" ]; then
96+
gh pr edit "$EXISTING_PR" \
97+
--title "Release $VERSION" \
98+
--body "$BODY"
99+
echo "Updated existing PR #$EXISTING_PR"
100+
else
101+
# Close any stale release PRs for a different version.
102+
for pr in $(gh pr list --head 'release-' --json number,headRefName \
103+
--jq '.[] | select(.headRefName != "'"$BRANCH"'") | .number'); do
104+
gh pr close "$pr" --comment "Superseded by release $VERSION."
105+
done
106+
107+
gh pr create \
108+
--head "$BRANCH" \
109+
--base main \
110+
--title "Release $VERSION" \
111+
--body "$BODY"
112+
echo "Created new PR for $BRANCH"
113+
fi

.github/workflows/test.yml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,17 @@ jobs:
2222
with:
2323
fetch-depth: 0
2424
persist-credentials: false
25+
26+
- name: Detect release branch version
27+
id: release
28+
if: startsWith(github.head_ref || '', 'release-')
29+
run: |
30+
VERSION="${GITHUB_HEAD_REF#release-}"
31+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
32+
2533
- uses: hynek/build-and-inspect-python-package@fe0a0fb1925ca263d076ca4f2c13e93a6e92a33e # v2.17.0
34+
env:
35+
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PLUGGY: ${{ steps.release.outputs.version || '' }}
2636

2737
test:
2838
needs: [check-package]
@@ -145,3 +155,62 @@ jobs:
145155
fail_ci_if_error: true
146156
files: ./coverage.xml
147157
verbose: true
158+
159+
release-artifacts:
160+
name: Update draft GitHub release
161+
needs: [check-package, test]
162+
if: >-
163+
github.repository == 'pytest-dev/pluggy'
164+
&& github.event_name == 'pull_request'
165+
&& startsWith(github.head_ref, 'release-')
166+
runs-on: ubuntu-latest
167+
permissions:
168+
contents: write
169+
steps:
170+
- name: Determine version from branch
171+
id: version
172+
run: |
173+
VERSION="${GITHUB_HEAD_REF#release-}"
174+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
175+
176+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
177+
with:
178+
persist-credentials: false
179+
180+
- name: Download built packages
181+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
182+
with:
183+
name: Packages
184+
path: dist
185+
186+
- name: Create or update draft release
187+
env:
188+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
189+
run: |
190+
VERSION="${{ steps.version.outputs.version }}"
191+
TAG="$VERSION"
192+
193+
# Generate release notes from the PR's CHANGELOG.rst changes.
194+
NOTES="Release $VERSION — built from \`${{ github.head_ref }}\` at ${{ github.event.pull_request.head.sha }}."
195+
196+
if gh release view "$TAG" >/dev/null 2>&1; then
197+
# Delete existing assets and re-upload.
198+
gh release edit "$TAG" \
199+
--draft \
200+
--title "pluggy $VERSION" \
201+
--notes "$NOTES" \
202+
--target "${{ github.event.pull_request.head.sha }}"
203+
# Remove old assets.
204+
for asset in $(gh release view "$TAG" --json assets --jq '.assets[].name'); do
205+
gh release delete-asset "$TAG" "$asset" --yes
206+
done
207+
else
208+
gh release create "$TAG" \
209+
--draft \
210+
--title "pluggy $VERSION" \
211+
--notes "$NOTES" \
212+
--target "${{ github.event.pull_request.head.sha }}"
213+
fi
214+
215+
# Upload the wheel and sdist as release assets.
216+
gh release upload "$TAG" dist/* --clobber

0 commit comments

Comments
 (0)