Skip to content

Commit 62bc16d

Browse files
committed
Release tooling: full TabPFN-pattern pipeline + Towncrier
Ports TabPFN's complete release stack to tabpfn-extensions: - release-create-pr.yml: workflow_dispatch -> opens release/v* PR with version bump and Towncrier-assembled CHANGELOG. - release-tag-on-merge.yml: merge of release/v* -> auto-tags merge commit, triggering release-publish.yml. - release-publish.yml: tag push -> build, TestPyPI publish + install verify, gated PyPI publish (manual approval at `pypi` env), auto GitHub Release with notes extracted from CHANGELOG. - check-changelog.yml: enforces every PR adds a changelog fragment via scientific-python/action-towncrier-changelog. - pull_request.yml: adds a build_verify job that runs `uv build` and `twine check` so packaging regressions fail PR CI. - pyproject.toml: adds [tool.towncrier] with 5 categories matching TabPFN (breaking, added, changed, fixed, deprecated). - CHANGELOG.md: drops the empty Added/Changed subsections under [Unreleased] (Towncrier inserts the new release section directly after the Unreleased heading at release time). - changelog/README.md: contributor docs for the per-PR fragment convention. Trusted Publishing replaces all API tokens. No secrets are required for publish steps. The tag-on-merge step uses RELEASE_BOT_TOKEN (a fine scoped PAT) because the default GITHUB_TOKEN can't push tags that trigger another workflow.
1 parent a69f681 commit 62bc16d

8 files changed

Lines changed: 513 additions & 3 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Check Changelog
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, labeled, unlabeled]
6+
merge_group:
7+
8+
jobs:
9+
check:
10+
name: Changelog entry
11+
if: >-
12+
github.event_name == 'pull_request' &&
13+
github.actor != 'dependabot[bot]' &&
14+
!startsWith(github.head_ref, 'release/v')
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
18+
with:
19+
fetch-depth: 0
20+
21+
- name: Check changelog entry
22+
id: changelog_check
23+
uses: scientific-python/action-towncrier-changelog@v2.0.0
24+
env:
25+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26+
BOT_USERNAME: changelog-bot
27+
28+
- name: Show instructions on failure
29+
if: failure() && steps.changelog_check.outcome == 'failure'
30+
run: |
31+
cat << EOF
32+
33+
─────────────────────────────────────────────────────────────
34+
Missing changelog entry!
35+
36+
Add a file: changelog/${{ github.event.pull_request.number }}.<category>.md
37+
Categories: breaking, added, changed, fixed, deprecated
38+
39+
Example:
40+
towncrier create ${{ github.event.pull_request.number }}.added.md --content "Your change description"
41+
42+
Or request the "no changelog needed" label from a maintainer.
43+
See changelog/README.md for details.
44+
─────────────────────────────────────────────────────────────
45+
46+
EOF
47+
exit 1

.github/workflows/pull_request.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,23 @@ jobs:
137137
path: ${{ github.workspace }}/model_cache
138138
key: model-cache-${{ hashFiles('model_cache/**') }}
139139
enableCrossOsArchive: true
140+
141+
build_verify:
142+
name: Build wheel + sdist
143+
runs-on: ubuntu-latest
144+
steps:
145+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
146+
147+
- name: Set up Python
148+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
149+
with:
150+
python-version: "3.12"
151+
152+
- name: Install uv
153+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
154+
155+
- name: Build sdist + wheel
156+
run: uv build
157+
158+
- name: Check artifacts
159+
run: uvx twine check dist/*
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
name: Create Release PR
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: "Version to release (e.g. 0.3.1)"
8+
required: true
9+
base_ref:
10+
description: "Commit SHA to release from"
11+
required: true
12+
base_branch:
13+
description: "Branch to open the PR against"
14+
required: false
15+
default: "main"
16+
17+
permissions:
18+
contents: write
19+
pull-requests: write
20+
21+
jobs:
22+
release_pr:
23+
runs-on: ubuntu-latest
24+
environment: release
25+
steps:
26+
- name: Guard base_ref, must be a full 40-char commit SHA
27+
run: |
28+
echo "${{ inputs.base_ref }}" | grep -Eq '^[0-9a-f]{40}$' || {
29+
echo "base_ref must be a full 40-char commit SHA (got: ${{ inputs.base_ref }})"
30+
exit 1
31+
}
32+
33+
- name: Checkout
34+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
35+
with:
36+
fetch-depth: 0
37+
38+
- name: Set up Python
39+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
40+
with:
41+
python-version: "3.12"
42+
43+
- name: Set up uv
44+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
45+
46+
- name: Install Towncrier
47+
run: uv pip install --system towncrier
48+
49+
- name: Set variables
50+
run: |
51+
echo "VERSION=${{ inputs.version }}" >> $GITHUB_ENV
52+
echo "BASE_REF=${{ inputs.base_ref }}" >> $GITHUB_ENV
53+
echo "BASE_BRANCH=${{ inputs.base_branch }}" >> $GITHUB_ENV
54+
echo "RELEASE_BRANCH=release/v${{ inputs.version }}" >> $GITHUB_ENV
55+
56+
- name: Create release branch from base ref
57+
run: |
58+
git fetch --all --tags
59+
git checkout --detach "${BASE_REF}"
60+
git checkout -b "${RELEASE_BRANCH}"
61+
62+
- name: Bump version in pyproject.toml
63+
run: |
64+
python - <<'PY'
65+
import os
66+
import re
67+
from pathlib import Path
68+
69+
version = os.environ["VERSION"]
70+
p = Path("pyproject.toml")
71+
s = p.read_text(encoding="utf-8")
72+
73+
pat = r'(?ms)(^\[project\]\s*\n.*?^\s*version\s*=\s*")([^"]+)(")'
74+
m = re.search(pat, s)
75+
if not m:
76+
raise SystemExit("Could not find [project].version in pyproject.toml")
77+
78+
out = s[:m.start(2)] + version + s[m.end(2):]
79+
p.write_text(out, encoding="utf-8")
80+
print("Bumped [project].version to", version)
81+
PY
82+
83+
- name: Towncrier build (updates CHANGELOG.md, deletes fragments)
84+
run: towncrier build --version "${VERSION}" --yes
85+
86+
- name: Commit release changes
87+
run: |
88+
git config user.name "github-actions[bot]"
89+
git config user.email "github-actions[bot]@users.noreply.github.com"
90+
91+
git add pyproject.toml CHANGELOG.md changelog || true
92+
93+
if git diff --cached --quiet; then
94+
echo "No changes staged. Are there changelog fragments to release?"
95+
exit 1
96+
fi
97+
98+
git commit -m "Release v${VERSION}"
99+
100+
- name: Push branch
101+
run: git push --set-upstream origin "${RELEASE_BRANCH}"
102+
103+
- name: Create Pull Request
104+
env:
105+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
106+
run: |
107+
gh pr create \
108+
--base "${BASE_BRANCH}" \
109+
--head "${RELEASE_BRANCH}" \
110+
--title "Release v${VERSION}" \
111+
--body "Automated release PR for v${VERSION}."
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
name: Release to PyPI
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
id-token: write
11+
12+
jobs:
13+
build:
14+
runs-on: ubuntu-latest
15+
environment: release
16+
outputs:
17+
version: ${{ steps.ver.outputs.version }}
18+
steps:
19+
- name: Checkout code
20+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
21+
22+
- name: Extract version from tag
23+
id: ver
24+
run: |
25+
VERSION="${GITHUB_REF_NAME#v}"
26+
echo "version=$VERSION" >> $GITHUB_OUTPUT
27+
echo "VERSION=$VERSION" >> $GITHUB_ENV
28+
29+
- name: Verify tag matches pyproject.toml version
30+
env:
31+
VERSION: ${{ env.VERSION }}
32+
run: |
33+
python - <<'PY'
34+
import os, re
35+
from pathlib import Path
36+
37+
v_tag = os.environ["VERSION"]
38+
s = Path("pyproject.toml").read_text(encoding="utf-8")
39+
m = re.search(r'(?ms)^\[project\]\s*\n.*?^\s*version\s*=\s*"([^"]+)"', s)
40+
if not m:
41+
raise SystemExit("Could not find [project].version in pyproject.toml")
42+
v_file = m.group(1).strip()
43+
if v_tag != v_file:
44+
raise SystemExit(f"Version mismatch: tag={v_tag} pyproject={v_file}")
45+
print("OK: tag matches pyproject version:", v_tag)
46+
PY
47+
48+
- name: Install uv
49+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
50+
with:
51+
enable-cache: true
52+
53+
- name: Install build tool
54+
run: uv tool install build
55+
56+
- name: Clean previous builds
57+
run: git clean -xfd dist build *.egg-info || true
58+
59+
- name: Build package
60+
run: uv tool run --from build pyproject-build
61+
62+
- name: Extract release notes from CHANGELOG.md
63+
env:
64+
VERSION: ${{ env.VERSION }}
65+
run: |
66+
python - <<'PY'
67+
import os, re, pathlib
68+
69+
v = os.environ["VERSION"]
70+
text = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8")
71+
72+
# Matches: ## [X.Y.Z] - YYYY-MM-DD
73+
pat = rf"(?ms)^##\s+\[{re.escape(v)}\]\s+-\s+\d{{4}}-\d{{2}}-\d{{2}}\s*\n(.*?)(?=^##\s+\[|\Z)"
74+
m = re.search(pat, text)
75+
if not m:
76+
raise SystemExit(f"Could not find Towncrier section for version {v} in CHANGELOG.md")
77+
78+
notes = m.group(1).strip() + "\n"
79+
pathlib.Path("release_notes.md").write_text(notes, encoding="utf-8")
80+
print("Wrote to release_notes.md")
81+
PY
82+
83+
- name: Upload build artifacts
84+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
85+
with:
86+
name: dist
87+
path: dist/
88+
retention-days: 7
89+
90+
- name: Upload release notes
91+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
92+
with:
93+
name: release_notes
94+
path: release_notes.md
95+
retention-days: 7
96+
97+
publish-testpypi:
98+
needs: build
99+
runs-on: ubuntu-latest
100+
if: startsWith(github.ref_name, 'v')
101+
environment:
102+
name: testpypi
103+
permissions:
104+
contents: read
105+
id-token: write
106+
steps:
107+
- name: Download build artifacts
108+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
109+
with:
110+
name: dist
111+
path: dist/
112+
113+
- name: Publish to TestPyPI
114+
uses: pypa/gh-action-pypi-publish@6733eb7d741f0b11ec6a39b58540dab7590f9b7d # v1.14.0
115+
with:
116+
repository-url: https://test.pypi.org/legacy/
117+
print-hash: true
118+
skip-existing: true
119+
120+
- name: Install uv
121+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
122+
with:
123+
enable-cache: true
124+
125+
- name: Wait for TestPyPI propagation
126+
run: |
127+
echo "⏳ Waiting for package to be available on TestPyPI..."
128+
sleep 30
129+
130+
- name: Test installation from TestPyPI
131+
env:
132+
VERSION: ${{ needs.build.outputs.version }}
133+
run: |
134+
echo "🧪 Testing installation of tabpfn-extensions==${VERSION} from TestPyPI..."
135+
136+
TEST_DIR=$(mktemp -d)
137+
cd "$TEST_DIR"
138+
139+
uv venv
140+
source .venv/bin/activate
141+
142+
uv pip install \
143+
--index-url https://test.pypi.org/simple/ \
144+
--extra-index-url https://pypi.org/simple/ \
145+
--index-strategy unsafe-best-match \
146+
tabpfn-extensions==${VERSION}
147+
148+
python -c "import tabpfn_extensions; print('imported tabpfn_extensions OK, version:', tabpfn_extensions.__version__)"
149+
150+
echo "✅ Installation test completed."
151+
152+
publish-pypi:
153+
needs: [build, publish-testpypi]
154+
runs-on: ubuntu-latest
155+
if: startsWith(github.ref_name, 'v')
156+
environment:
157+
name: pypi
158+
permissions:
159+
contents: write
160+
id-token: write
161+
steps:
162+
- name: Download build artifacts
163+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
164+
with:
165+
name: dist
166+
path: dist/
167+
168+
- name: Publish to PyPI
169+
uses: pypa/gh-action-pypi-publish@6733eb7d741f0b11ec6a39b58540dab7590f9b7d # v1.14.0
170+
with:
171+
print-hash: true
172+
skip-existing: true
173+
174+
- name: Checkout code
175+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
176+
177+
- name: Download release notes
178+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
179+
with:
180+
name: release_notes
181+
path: .
182+
183+
- name: Create GitHub Release
184+
env:
185+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
186+
VERSION: ${{ needs.build.outputs.version }}
187+
run: |
188+
# Create release if it doesn't exist; if it exists, just fail fast
189+
gh release create "v$VERSION" \
190+
--title "v$VERSION" \
191+
--notes-file release_notes.md

0 commit comments

Comments
 (0)