Skip to content

Commit e117d4b

Browse files
committed
Add workflows
1 parent 0647033 commit e117d4b

4 files changed

Lines changed: 400 additions & 1 deletion

File tree

.github/workflows/publish.yml

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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+
runs-on: ubuntu-latest
12+
outputs:
13+
tag: ${{ steps.read_ver.outputs.tag }}
14+
file_ver: ${{ steps.read_ver.outputs.file_ver }}
15+
steps:
16+
- uses: actions/checkout@v4
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 tomli
24+
25+
- name: Read version from pyproject.toml & verify tag
26+
id: read_ver
27+
shell: python
28+
run: |
29+
import os, sys
30+
try:
31+
import tomllib as toml # Python 3.11+
32+
except Exception:
33+
import tomli as toml # fallback
34+
35+
# Read pyproject version
36+
with open("pyproject.toml","rb") as f:
37+
ver = toml.load(f)["project"]["version"]
38+
39+
# Extract tag from GITHUB_REF (e.g. "refs/tags/v1.2.3" -> "1.2.3")
40+
ref = os.environ["GITHUB_REF"]
41+
# Safety checks & strip prefix
42+
prefix = "refs/tags/v"
43+
if not ref.startswith(prefix):
44+
print(f"Unexpected GITHUB_REF: {ref}", file=sys.stderr)
45+
sys.exit(1)
46+
tag = ref[len(prefix):]
47+
48+
if tag != ver:
49+
print(f"❌ Tag ({tag}) != pyproject.toml ({ver})", file=sys.stderr)
50+
sys.exit(1)
51+
52+
# Expose outputs
53+
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
54+
fh.write(f"tag={tag}\n")
55+
fh.write(f"file_ver={ver}\n")
56+
57+
print(f"✅ Version OK: tag={tag} matches pyproject.toml={ver}")
58+
59+
- name: Build dists
60+
run: python -m build
61+
62+
- name: Upload dists
63+
uses: actions/upload-artifact@v4
64+
with:
65+
name: dist
66+
path: dist/*
67+
68+
changelog:
69+
needs: build
70+
runs-on: ubuntu-latest
71+
steps:
72+
- uses: actions/checkout@v4
73+
74+
- name: Extract release notes (fail if missing)
75+
id: notes
76+
shell: python
77+
env:
78+
TAG: ${{ needs.build.outputs.tag }}
79+
run: |
80+
import os, sys, re, pathlib
81+
ver = os.environ["TAG"] # e.g., 1.0.2
82+
chlog_path = pathlib.Path("CHANGELOG.md")
83+
if not chlog_path.exists():
84+
print("CHANGELOG.md not found", file=sys.stderr)
85+
sys.exit(2)
86+
87+
lines = chlog_path.read_text(encoding="utf-8").splitlines()
88+
89+
def is_heading_for_version(s: str) -> bool:
90+
s = s.strip()
91+
if not s.startswith("##"):
92+
return False
93+
s = s[2:].strip() # drop "##"
94+
s = s.strip("[]") # allow [1.0.2]
95+
s = re.sub(r"\s*-\s*.*$", "", s) # drop " - date"
96+
s = s.lstrip("v") # allow v1.0.2
97+
return s == ver
98+
99+
start_idx = None
100+
for i, line in enumerate(lines):
101+
if is_heading_for_version(line):
102+
start_idx = i + 1 # start AFTER heading
103+
break
104+
105+
if start_idx is None:
106+
print(f"No changelog section found for {ver}", file=sys.stderr)
107+
sys.exit(3)
108+
109+
end_idx = len(lines)
110+
for j in range(start_idx, len(lines)):
111+
if lines[j].lstrip().startswith("## "):
112+
end_idx = j
113+
break
114+
115+
section_lines = lines[start_idx:end_idx]
116+
while section_lines and not section_lines[0].strip():
117+
section_lines.pop(0)
118+
while section_lines and not section_lines[-1].strip():
119+
section_lines.pop()
120+
121+
section = "\n".join(section_lines)
122+
only_links = re.fullmatch(r"(?:\[[^\]]+\]:\s*\S+\s*(?:\n|$))*", section or "", flags=re.MULTILINE)
123+
if not section or only_links:
124+
print(f"Changelog section for {ver} is empty", file=sys.stderr)
125+
sys.exit(4)
126+
127+
pathlib.Path("RELEASE_NOTES.md").write_text(section, encoding="utf-8")
128+
129+
- name: Upload release notes
130+
uses: actions/upload-artifact@v4
131+
with:
132+
name: release-notes
133+
path: RELEASE_NOTES.md
134+
135+
publish:
136+
needs: [build, changelog]
137+
runs-on: ubuntu-latest
138+
environment: pypi
139+
permissions:
140+
id-token: write # required for PyPI Trusted Publishing (OIDC)
141+
contents: read
142+
steps:
143+
- uses: actions/download-artifact@v4
144+
with:
145+
name: dist
146+
path: dist
147+
148+
- name: Publish to PyPI via OIDC
149+
uses: pypa/gh-action-pypi-publish@release/v1
150+
with:
151+
verbose: true
152+
153+
github_release:
154+
needs: [publish, changelog]
155+
runs-on: ubuntu-latest
156+
permissions:
157+
contents: write
158+
steps:
159+
- uses: actions/download-artifact@v4
160+
with:
161+
name: dist
162+
path: dist
163+
- uses: actions/download-artifact@v4
164+
with:
165+
name: release-notes
166+
path: .
167+
- name: Create GitHub Release
168+
uses: softprops/action-gh-release@v2
169+
with:
170+
tag_name: ${{ github.ref_name }}
171+
name: ${{ github.ref_name }}
172+
body_path: RELEASE_NOTES.md
173+
files: |
174+
dist/*

.github/workflows/publish_test.yml

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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 tomli
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 pyproject 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+
55+
- name: Read version from pyproject.toml
56+
id: read_ver
57+
shell: python
58+
run: |
59+
import os
60+
try:
61+
import tomllib as toml # Python 3.11+
62+
except Exception:
63+
import tomli as toml # fallback
64+
with open("pyproject.toml","rb") as f:
65+
data = toml.load(f)
66+
ver = data["project"]["version"]
67+
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
68+
fh.write(f"file_ver={ver}\n")
69+
print(f"pyproject.toml version: {ver}")
70+
71+
- name: Verify latest tag matches pyproject.toml version
72+
shell: bash
73+
run: |
74+
TAG="${{ steps.tag.outputs.tag }}"
75+
FILE_VER="${{ steps.read_ver.outputs.file_ver }}"
76+
echo "Latest tag (stripped): $TAG"
77+
echo "pyproject.toml: $FILE_VER"
78+
if [ "$TAG" != "$FILE_VER" ]; then
79+
echo "❌ Latest tag ($TAG) != pyproject.toml ($FILE_VER)"
80+
exit 1
81+
fi
82+
83+
- name: Build dists
84+
run: python -m build
85+
86+
- name: Upload dists
87+
uses: actions/upload-artifact@v4
88+
with:
89+
name: dist
90+
path: dist/*
91+
92+
changelog:
93+
needs: build
94+
runs-on: ubuntu-latest
95+
steps:
96+
- uses: actions/checkout@v4
97+
with:
98+
fetch-depth: 0
99+
100+
- name: Extract release notes (fail if missing)
101+
id: notes
102+
shell: python
103+
env:
104+
TAG: ${{ needs.build.outputs.tag }}
105+
run: |
106+
import os, sys, re, pathlib
107+
108+
ver = os.environ["TAG"] # e.g., 1.0.2 (no leading 'v')
109+
chlog_path = pathlib.Path("CHANGELOG.md")
110+
if not chlog_path.exists():
111+
print("CHANGELOG.md not found", file=sys.stderr)
112+
sys.exit(2)
113+
114+
lines = chlog_path.read_text(encoding="utf-8").splitlines()
115+
116+
# Normalize heading lines and find the exact "## ..." that matches this version
117+
# Supports: "## 1.0.2", "## v1.0.2", "## [1.0.2]", "## [1.0.2] - 2025-10-06"
118+
def is_heading_for_version(s: str) -> bool:
119+
s = s.strip()
120+
if not s.startswith("##"):
121+
return False
122+
s = s[2:].strip() # drop leading '##'
123+
s = s.strip("[]") # allow [1.0.2]
124+
s = re.sub(r"\s*-\s*.*$", "", s) # drop trailing " - date"
125+
s = s.lstrip("v") # allow v1.0.2
126+
return s == ver
127+
128+
start_idx = None
129+
for i, line in enumerate(lines):
130+
if is_heading_for_version(line):
131+
start_idx = i + 1 # start AFTER the heading line
132+
break
133+
134+
if start_idx is None:
135+
print(f"No changelog section found for {ver}", file=sys.stderr)
136+
sys.exit(3)
137+
138+
# Collect until the next "## " heading (any version)
139+
end_idx = len(lines)
140+
for j in range(start_idx, len(lines)):
141+
if lines[j].lstrip().startswith("## "):
142+
end_idx = j
143+
break
144+
145+
section_lines = lines[start_idx:end_idx]
146+
147+
# Trim leading/trailing blank lines but KEEP all bullets, including the first one
148+
while section_lines and not section_lines[0].strip():
149+
section_lines.pop(0)
150+
while section_lines and not section_lines[-1].strip():
151+
section_lines.pop()
152+
153+
section = "\n".join(section_lines)
154+
155+
# Consider invalid if empty or only reference-style link defs
156+
only_links = re.fullmatch(r"(?:\[[^\]]+\]:\s*\S+\s*(?:\n|$))*", section or "", flags=re.MULTILINE)
157+
if not section or only_links:
158+
print(f"Changelog section for {ver} is empty", file=sys.stderr)
159+
sys.exit(4)
160+
161+
pathlib.Path("RELEASE_NOTES.md").write_text(section, encoding="utf-8")
162+
163+
- name: Upload notes
164+
uses: actions/upload-artifact@v4
165+
with:
166+
name: release-notes
167+
path: RELEASE_NOTES.md
168+
169+
dry_run_publish:
170+
needs: [build, changelog]
171+
runs-on: ubuntu-latest
172+
steps:
173+
- uses: actions/download-artifact@v4
174+
with:
175+
name: dist
176+
path: dist
177+
- uses: actions/download-artifact@v4
178+
with:
179+
name: release-notes
180+
path: .
181+
- name: Print dry-run summary
182+
run: |
183+
echo "✅ Would publish to PyPI via Trusted Publisher (OIDC)"
184+
echo " Latest tag (raw): ${{ steps.fetch_latest_tag.outputs.raw_tag }}"
185+
echo " Tag (stripped): ${{ needs.build.outputs.tag }}"
186+
echo " Version: ${{ needs.build.outputs.file_ver }}"
187+
echo " Dist files:"
188+
ls -lh dist
189+
echo
190+
echo "✅ Would create GitHub Release for tag ${{ steps.fetch_latest_tag.outputs.raw_tag }}"
191+
echo " Using changelog notes from RELEASE_NOTES.md:"
192+
echo "---------------------------------------------"
193+
cat RELEASE_NOTES.md
194+
echo "---------------------------------------------"
195+
echo "(Dry run complete — nothing was uploaded.)"

0 commit comments

Comments
 (0)