Skip to content

Commit b051849

Browse files
committed
Update workflows
1 parent f21f4ff commit b051849

File tree

4 files changed

+443
-36
lines changed

4 files changed

+443
-36
lines changed

.github/workflows/publish.yml

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
---
2+
name: Release (PyPI + GitHub)
3+
4+
on:
5+
push:
6+
tags:
7+
- "v*.*.*" # e.g. v1.2.3
8+
9+
jobs:
10+
build:
11+
name: Build wheels on ${{ matrix.os }}
12+
runs-on: ${{ matrix.os }}
13+
strategy:
14+
matrix:
15+
os: [ubuntu-latest, ubuntu-24.04-arm]
16+
outputs:
17+
tag: ${{ steps.read_ver.outputs.tag }}
18+
file_ver: ${{ steps.read_ver.outputs.file_ver }}
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: actions/setup-python@v5
23+
with:
24+
python-version: "3.12"
25+
26+
- name: Install build tools
27+
run: pip install setuptools build cibuildwheel==2.23.2
28+
29+
- name: Read version from setup.py & verify tag
30+
id: read_ver
31+
shell: python
32+
run: |
33+
import os, sys, re, pathlib
34+
text = pathlib.Path("setup.py").read_text()
35+
m = re.search(r"version\s*=\s*[\"']([^\"']+)[\"']", text)
36+
ver = m.group(1)
37+
38+
# Extract tag from GITHUB_REF (e.g. "refs/tags/v1.2.3" -> "1.2.3")
39+
ref = os.environ["GITHUB_REF"]
40+
# Safety checks & strip prefix
41+
prefix = "refs/tags/v"
42+
if not ref.startswith(prefix):
43+
print(f"Unexpected GITHUB_REF: {ref}", file=sys.stderr)
44+
sys.exit(1)
45+
tag = ref[len(prefix):]
46+
47+
if tag != ver:
48+
print(f"❌ Tag ({tag}) != setup.py ({ver})", file=sys.stderr)
49+
sys.exit(1)
50+
51+
# Expose outputs
52+
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
53+
fh.write(f"tag={tag}\n")
54+
fh.write(f"file_ver={ver}\n")
55+
56+
print(f"✅ Version OK: tag={tag} matches setup.py={ver}")
57+
58+
- name: Build wheels
59+
run: python -m cibuildwheel --output-dir dist
60+
env:
61+
CIBW_BUILD: "cp39-*"
62+
CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28
63+
CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28
64+
CIBW_ARCHS: auto64
65+
CIBW_TEST_REQUIRES: pytest syrupy
66+
CIBW_TEST_COMMAND: python -m pytest -vv {project}/tests
67+
68+
# Don't build PyPy and MUSL wheels for now
69+
CIBW_SKIP: "pp* *-musllinux_*"
70+
71+
- name: Build sdist (once)
72+
if: ${{ matrix.os == 'ubuntu-latest' }}
73+
run: python -m build --sdist
74+
75+
- name: Upload wheels and sdist
76+
uses: actions/upload-artifact@v4
77+
with:
78+
name: dist-${{ matrix.os }}
79+
path: |
80+
dist/*.whl
81+
dist/*.tar.gz
82+
if-no-files-found: error
83+
84+
changelog:
85+
needs: build
86+
runs-on: ubuntu-latest
87+
steps:
88+
- uses: actions/checkout@v4
89+
90+
- name: Extract release notes (fail if missing)
91+
id: notes
92+
shell: python
93+
env:
94+
TAG: ${{ needs.build.outputs.tag }}
95+
run: |
96+
import os, sys, re, pathlib
97+
ver = os.environ["TAG"] # e.g., 1.0.2
98+
chlog_path = pathlib.Path("CHANGELOG.md")
99+
if not chlog_path.exists():
100+
print("CHANGELOG.md not found", file=sys.stderr)
101+
sys.exit(2)
102+
103+
lines = chlog_path.read_text(encoding="utf-8").splitlines()
104+
105+
def is_heading_for_version(s: str) -> bool:
106+
s = s.strip()
107+
if not s.startswith("##"):
108+
return False
109+
s = s[2:].strip() # drop "##"
110+
s = s.strip("[]") # allow [1.0.2]
111+
s = re.sub(r"\s*-\s*.*$", "", s) # drop " - date"
112+
s = s.lstrip("v") # allow v1.0.2
113+
return s == ver
114+
115+
start_idx = None
116+
for i, line in enumerate(lines):
117+
if is_heading_for_version(line):
118+
start_idx = i + 1 # start AFTER heading
119+
break
120+
121+
if start_idx is None:
122+
print(f"No changelog section found for {ver}", file=sys.stderr)
123+
sys.exit(3)
124+
125+
end_idx = len(lines)
126+
for j in range(start_idx, len(lines)):
127+
if lines[j].lstrip().startswith("## "):
128+
end_idx = j
129+
break
130+
131+
section_lines = lines[start_idx:end_idx]
132+
while section_lines and not section_lines[0].strip():
133+
section_lines.pop(0)
134+
while section_lines and not section_lines[-1].strip():
135+
section_lines.pop()
136+
137+
section = "\n".join(section_lines)
138+
only_links = re.fullmatch(r"(?:\[[^\]]+\]:\s*\S+\s*(?:\n|$))*", section or "", flags=re.MULTILINE)
139+
if not section or only_links:
140+
print(f"Changelog section for {ver} is empty", file=sys.stderr)
141+
sys.exit(4)
142+
143+
pathlib.Path("RELEASE_NOTES.md").write_text(section, encoding="utf-8")
144+
145+
- name: Upload release notes
146+
uses: actions/upload-artifact@v4
147+
with:
148+
name: release-notes
149+
path: RELEASE_NOTES.md
150+
151+
publish:
152+
needs: [build, changelog]
153+
runs-on: ubuntu-latest
154+
environment: pypi
155+
permissions:
156+
id-token: write # required for PyPI Trusted Publishing (OIDC)
157+
contents: read
158+
steps:
159+
- name: Download all dists
160+
uses: actions/download-artifact@v4
161+
with:
162+
pattern: dist-*
163+
merge-multiple: true
164+
path: dist
165+
166+
- name: Publish to PyPI via OIDC
167+
uses: pypa/gh-action-pypi-publish@release/v1
168+
with:
169+
verbose: true
170+
171+
github_release:
172+
needs: [build, changelog, publish]
173+
runs-on: ubuntu-latest
174+
permissions:
175+
contents: write
176+
steps:
177+
- name: Download all dists
178+
uses: actions/download-artifact@v4
179+
with:
180+
pattern: dist-*
181+
merge-multiple: true
182+
path: dist
183+
- uses: actions/download-artifact@v4
184+
with:
185+
name: release-notes
186+
path: .
187+
- name: Create GitHub Release
188+
uses: softprops/action-gh-release@v2
189+
with:
190+
tag_name: ${{ github.ref_name }}
191+
name: ${{ github.ref_name }}
192+
body_path: RELEASE_NOTES.md
193+
files: |
194+
dist/*

.github/workflows/publish_test.yml

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
---
2+
name: Release Dry Run (PyPI + GitHub)
3+
4+
on:
5+
workflow_dispatch: {}
6+
7+
jobs:
8+
build:
9+
runs-on: ubuntu-latest
10+
outputs:
11+
tag: ${{ steps.tag.outputs.tag }}
12+
file_ver: ${{ steps.read_ver.outputs.file_ver }}
13+
steps:
14+
- uses: actions/checkout@v4
15+
with:
16+
fetch-depth: 0 # IMPORTANT: get full history + tags
17+
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: "3.x"
21+
22+
- name: Install build tools
23+
run: pip install build
24+
25+
- name: Extract latest version tag (semver)
26+
id: tag
27+
shell: bash
28+
run: |
29+
# Ensure we have tags (checkout with fetch-depth: 0 usually gets them)
30+
git fetch --tags --force --quiet
31+
32+
# List tags in descending semantic order. v-prefixed and bare tags supported.
33+
# Filter to tags that look like v1.2.3 or 1.2.3 (3-part semver).
34+
mapfile -t TAGS < <(git tag --list --sort=-v:refname)
35+
LATEST=""
36+
for t in "${TAGS[@]}"; do
37+
if [[ "$t" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
38+
LATEST="$t"
39+
break
40+
fi
41+
done
42+
43+
if [ -z "$LATEST" ]; then
44+
echo "❌ No semver-like tags found (expected tags like v1.2.3 or 1.2.3)" >&2
45+
exit 1
46+
fi
47+
48+
# Strip leading 'v' for comparison to setup.py version
49+
STRIPPED="${LATEST#v}"
50+
51+
echo "Found latest tag: $LATEST (stripped: $STRIPPED)"
52+
echo "raw_tag=$LATEST" >> "$GITHUB_OUTPUT"
53+
echo "tag=$STRIPPED" >> "$GITHUB_OUTPUT"
54+
- name: Get version from setup.py
55+
id: read_ver
56+
shell: python
57+
run: |
58+
import os, re, pathlib
59+
text = pathlib.Path("setup.py").read_text()
60+
m = re.search(r"version\s*=\s*[\"']([^\"']+)[\"']", text)
61+
ver = m.group(1)
62+
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
63+
fh.write(f"file_ver={ver}\n")
64+
print(f"setup.py version: {ver}")
65+
66+
- name: Verify latest tag matches setup.py version
67+
shell: bash
68+
run: |
69+
TAG="${{ steps.tag.outputs.tag }}"
70+
FILE_VER="${{ steps.read_ver.outputs.file_ver }}"
71+
echo "Latest tag (stripped): $TAG"
72+
echo "setup.py: $FILE_VER"
73+
if [ "$TAG" != "$FILE_VER" ]; then
74+
echo "❌ Latest tag ($TAG) != setup.py ($FILE_VER)"
75+
exit 1
76+
fi
77+
78+
- name: Build dists
79+
run: python -m build
80+
81+
- name: Upload dists
82+
uses: actions/upload-artifact@v4
83+
with:
84+
name: dist
85+
path: dist/*
86+
87+
changelog:
88+
needs: build
89+
runs-on: ubuntu-latest
90+
steps:
91+
- uses: actions/checkout@v4
92+
with:
93+
fetch-depth: 0
94+
95+
- name: Extract release notes (fail if missing)
96+
id: notes
97+
shell: python
98+
env:
99+
TAG: ${{ needs.build.outputs.tag }}
100+
run: |
101+
import os, sys, re, pathlib
102+
103+
ver = os.environ["TAG"] # e.g., 1.0.2 (no leading 'v')
104+
chlog_path = pathlib.Path("CHANGELOG.md")
105+
if not chlog_path.exists():
106+
print("CHANGELOG.md not found", file=sys.stderr)
107+
sys.exit(2)
108+
109+
lines = chlog_path.read_text(encoding="utf-8").splitlines()
110+
111+
# Normalize heading lines and find the exact "## ..." that matches this version
112+
# Supports: "## 1.0.2", "## v1.0.2", "## [1.0.2]", "## [1.0.2] - 2025-10-06"
113+
def is_heading_for_version(s: str) -> bool:
114+
s = s.strip()
115+
if not s.startswith("##"):
116+
return False
117+
s = s[2:].strip() # drop leading '##'
118+
s = s.strip("[]") # allow [1.0.2]
119+
s = re.sub(r"\s*-\s*.*$", "", s) # drop trailing " - date"
120+
s = s.lstrip("v") # allow v1.0.2
121+
return s == ver
122+
123+
start_idx = None
124+
for i, line in enumerate(lines):
125+
if is_heading_for_version(line):
126+
start_idx = i + 1 # start AFTER the heading line
127+
break
128+
129+
if start_idx is None:
130+
print(f"No changelog section found for {ver}", file=sys.stderr)
131+
sys.exit(3)
132+
133+
# Collect until the next "## " heading (any version)
134+
end_idx = len(lines)
135+
for j in range(start_idx, len(lines)):
136+
if lines[j].lstrip().startswith("## "):
137+
end_idx = j
138+
break
139+
140+
section_lines = lines[start_idx:end_idx]
141+
142+
# Trim leading/trailing blank lines but KEEP all bullets, including the first one
143+
while section_lines and not section_lines[0].strip():
144+
section_lines.pop(0)
145+
while section_lines and not section_lines[-1].strip():
146+
section_lines.pop()
147+
148+
section = "\n".join(section_lines)
149+
150+
# Consider invalid if empty or only reference-style link defs
151+
only_links = re.fullmatch(r"(?:\[[^\]]+\]:\s*\S+\s*(?:\n|$))*", section or "", flags=re.MULTILINE)
152+
if not section or only_links:
153+
print(f"Changelog section for {ver} is empty", file=sys.stderr)
154+
sys.exit(4)
155+
156+
pathlib.Path("RELEASE_NOTES.md").write_text(section, encoding="utf-8")
157+
158+
- name: Upload notes
159+
uses: actions/upload-artifact@v4
160+
with:
161+
name: release-notes
162+
path: RELEASE_NOTES.md
163+
164+
dry_run_publish:
165+
needs: [build, changelog]
166+
runs-on: ubuntu-latest
167+
steps:
168+
- uses: actions/download-artifact@v4
169+
with:
170+
name: dist
171+
path: dist
172+
- uses: actions/download-artifact@v4
173+
with:
174+
name: release-notes
175+
path: .
176+
- name: Print dry-run summary
177+
run: |
178+
echo "✅ Would publish to PyPI via Trusted Publisher (OIDC)"
179+
echo " Latest tag (raw): ${{ steps.fetch_latest_tag.outputs.raw_tag }}"
180+
echo " Tag (stripped): ${{ needs.build.outputs.tag }}"
181+
echo " Version: ${{ needs.build.outputs.file_ver }}"
182+
echo " Dist files:"
183+
ls -lh dist
184+
echo
185+
echo "✅ Would create GitHub Release for tag ${{ steps.fetch_latest_tag.outputs.raw_tag }}"
186+
echo " Using changelog notes from RELEASE_NOTES.md:"
187+
echo "---------------------------------------------"
188+
cat RELEASE_NOTES.md
189+
echo "---------------------------------------------"
190+
echo "(Dry run complete — nothing was uploaded.)"

0 commit comments

Comments
 (0)