Skip to content

Commit 43aaecd

Browse files
TKassisclaude
andcommitted
ci: create the GitHub release from CHANGELOG.md on tag push
release.yml published to PyPI and stopped there, leaving release entries to be written by hand. That step was routinely skipped: v2.19.0 shipped to PyPI while the releases page still showed v2.18.0 as latest, and v2.15.0 and v2.16.0 have no entry at all. The workflow now extracts the changelog section for the tag into release-notes.md and, after trusted publishing succeeds, creates the GitHub release from it. Ordering it after the publish step keeps the releases page from advertising a version that failed to upload, and the create/edit conditional makes a re-run of the same tag refresh the notes instead of failing. This needs contents: write, which no earlier step uses. Extraction runs before the check steps rather than next to the release step, so a tag whose version has no changelog section fails in seconds instead of after the package is already on PyPI. scripts/changelog_notes.py does the extraction: it takes the body of the `## [X.Y.Z]` section, rejects a missing, non-semver, or empty section, and appends a compare link to the preceding release unless the notes already carry one. It is stdlib-only and doubles as a preview command. Covered by eight tests, including one asserting the shipped package version always has notes to publish. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6685881 commit 43aaecd

5 files changed

Lines changed: 302 additions & 6 deletions

File tree

.github/workflows/release.yml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ jobs:
1616
environment: pypi
1717
permissions:
1818
id-token: write
19-
contents: read
19+
# contents: write is needed only by the final step, which creates the GitHub release.
20+
contents: write
2021
steps:
2122
- uses: actions/checkout@v7
2223
- uses: astral-sh/setup-uv@v9.0.0
@@ -35,6 +36,12 @@ jobs:
3536
sys.exit(f"tag v{tag} does not match pyproject version {version}")
3637
print(f"tag matches version {version}")
3738
EOF
39+
- name: Changelog documents this version
40+
# Extracted up front so a missing changelog entry fails before anything
41+
# is published, rather than after the release is already on PyPI.
42+
run: >-
43+
uv run --frozen python scripts/changelog_notes.py "${GITHUB_REF_NAME#v}"
44+
--output release-notes.md --repo-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
3845
- name: Ruff
3946
run: uv run --frozen ruff check .
4047
- name: Mypy
@@ -65,3 +72,19 @@ jobs:
6572
EOF
6673
- name: Publish (trusted publishing)
6774
run: uv publish --trusted-publishing always
75+
- name: Create GitHub release
76+
# Runs only after PyPI publishing succeeds, so the releases page never
77+
# advertises a version that is not installable. Idempotent so a re-run
78+
# of the same tag refreshes the notes instead of failing.
79+
env:
80+
GH_TOKEN: ${{ github.token }}
81+
TAG: ${{ github.ref_name }}
82+
run: |
83+
if gh release view "$TAG" >/dev/null 2>&1; then
84+
gh release edit "$TAG" --title "$TAG" --notes-file release-notes.md \
85+
--latest --draft=false
86+
else
87+
gh release create "$TAG" --title "$TAG" --notes-file release-notes.md \
88+
--latest --verify-tag
89+
fi
90+
gh release view "$TAG" --json tagName,isDraft,url

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to the Scientific Writer project will be documented in this
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- **GitHub releases are created by the release workflow**`release.yml` now creates the GitHub release from the `CHANGELOG.md` section for the tag, after PyPI publishing succeeds. Previously the workflow only published to PyPI and release entries were written by hand, so the releases page drifted (`v2.19.0` shipped to PyPI while the page still showed `v2.18.0` as latest, and several earlier versions have no entry at all). The step is idempotent: re-running a tag refreshes the notes instead of failing.
10+
- **`scripts/changelog_notes.py`** — extracts the release body for one version from `CHANGELOG.md` and appends a compare link to the previous release. Run it to preview what a tag will publish: `uv run scripts/changelog_notes.py X.Y.Z`.
11+
12+
### Changed
13+
14+
- **A changelog entry is now required to release** — the workflow extracts the notes before running any checks, so a tag whose version has no `## [X.Y.Z]` section (or an empty one) fails immediately, rather than after the package is already on PyPI. `docs/RELEASING.md` documents this, along with why the local `scripts/publish.py` path does not produce a GitHub release.
15+
716
---
817

918
## [2.19.0] - 2026-07-28

docs/RELEASING.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,29 @@ Releases publish to PyPI through `.github/workflows/release.yml` using [Trusted
1515
uv run scripts/bump_version.py minor # or patch | major
1616
uv lock
1717

18-
# 2. Update CHANGELOG.md, then commit the version files and uv.lock
18+
# 2. Add a `## [X.Y.Z] - YYYY-MM-DD` section to CHANGELOG.md (required — the release
19+
# workflow uses it as the GitHub release body and fails if it is missing or empty),
20+
# then commit the version files and uv.lock
1921
git add -A && git commit -m "Bump version to X.Y.Z" && git push
2022

2123
# 3. Tag and push the tag — this triggers the release workflow
2224
git tag -a vX.Y.Z -m "Release vX.Y.Z"
2325
git push origin vX.Y.Z
2426
```
2527

26-
The workflow verifies the tag matches the package version, installs the committed lock
27-
with `--frozen`, runs Ruff, mypy, pytest, codespell, consistency and package checks,
28-
builds the exact artifacts to publish, asserts the wheel ships the bundled `.claude`
29-
payload, and publishes.
28+
The workflow verifies the tag matches the package version, extracts the changelog
29+
section for that version, installs the committed lock with `--frozen`, runs Ruff, mypy,
30+
pytest, codespell, consistency and package checks, builds the exact artifacts to
31+
publish, asserts the wheel ships the bundled `.claude` payload, publishes to PyPI, and
32+
finally creates the GitHub release from the extracted changelog notes.
33+
34+
The GitHub release is created last, so the releases page never advertises a version that
35+
failed to publish; re-running the workflow for the same tag refreshes the existing
36+
release rather than failing. To preview the body a tag will produce:
37+
38+
```bash
39+
uv run scripts/changelog_notes.py X.Y.Z
40+
```
3041

3142
## Alternative: Local publish with a token
3243

@@ -48,6 +59,16 @@ The publisher script validates metadata, verifies skills and quality checks, pus
4859
automatically created release commit, builds and checks the artifacts, publishes via
4960
`uv publish`, and only then creates and pushes the git tag (`vX.Y.Z`).
5061

62+
Note that pushing that tag still triggers `release.yml`, whose publish step then fails
63+
because the version is already on PyPI — and because the GitHub release is created after
64+
publishing, no release entry is produced. Prefer the trusted-publishing path above, or
65+
create the release manually afterwards:
66+
67+
```bash
68+
uv run scripts/changelog_notes.py X.Y.Z --output release-notes.md
69+
gh release create vX.Y.Z --title vX.Y.Z --notes-file release-notes.md --latest --verify-tag
70+
```
71+
5172
## Bump the Version (semver)
5273

5374
Use the helper script to bump patch, minor, or major and keep `pyproject.toml`, `scientific_writer/__init__.py`, and `.claude-plugin/marketplace.json` in sync:

scripts/changelog_notes.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
"""Extract GitHub release notes for one version out of CHANGELOG.md.
3+
4+
Used by .github/workflows/release.yml so the GitHub release body is the changelog
5+
entry itself, rather than prose written by hand after the tag is already pushed.
6+
Historically that manual step was skipped and the releases page went stale while
7+
PyPI was current.
8+
9+
The extracted notes are the body of the `## [X.Y.Z]` section, with a compare link
10+
to the preceding released version appended when one exists.
11+
12+
Usage:
13+
python scripts/changelog_notes.py 2.19.0
14+
Print the notes for 2.19.0 to stdout.
15+
python scripts/changelog_notes.py 2.19.0 --output release-notes.md
16+
Write the notes to a file.
17+
"""
18+
19+
import argparse
20+
import re
21+
import sys
22+
from pathlib import Path
23+
24+
REPO_ROOT = Path(__file__).resolve().parent.parent
25+
CHANGELOG = REPO_ROOT / "CHANGELOG.md"
26+
DEFAULT_REPO_URL = "https://github.com/K-Dense-AI/claude-scientific-writer"
27+
28+
SECTION_HEADING = re.compile(r"^##\s+\[([^\]]+)\]", re.MULTILINE)
29+
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
30+
31+
32+
class ChangelogError(RuntimeError):
33+
"""Raised when a version has no usable changelog entry."""
34+
35+
36+
def iter_sections(text: str) -> list[tuple[str, str]]:
37+
"""Return (label, body) for every `## [label]` section, in document order."""
38+
matches = list(SECTION_HEADING.finditer(text))
39+
sections = []
40+
for index, match in enumerate(matches):
41+
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
42+
body = text[match.end() : end]
43+
# Drop the remainder of the heading line (the ` - YYYY-MM-DD` date suffix).
44+
_, _, body = body.partition("\n")
45+
sections.append((match.group(1), _strip_separators(body)))
46+
return sections
47+
48+
49+
def _strip_separators(body: str) -> str:
50+
"""Strip surrounding whitespace and the `---` rules that divide changelog entries."""
51+
lines = body.strip().splitlines()
52+
while lines and lines[-1].strip() in {"", "---"}:
53+
lines.pop()
54+
while lines and lines[0].strip() in {"", "---"}:
55+
lines.pop(0)
56+
return "\n".join(lines).strip()
57+
58+
59+
def previous_version(text: str, version: str) -> str | None:
60+
"""Return the released version documented directly below `version`, if any."""
61+
labels = [label for label, _ in iter_sections(text)]
62+
if version not in labels:
63+
return None
64+
for label in labels[labels.index(version) + 1 :]:
65+
if SEMVER.match(label):
66+
return label
67+
return None
68+
69+
70+
def build_notes(text: str, version: str, repo_url: str = DEFAULT_REPO_URL) -> str:
71+
"""Build the release body for `version`, appending a compare link when possible.
72+
73+
Raises
74+
------
75+
ChangelogError
76+
If the version has no section, or its section has no content. Failing here
77+
keeps the workflow from publishing a release with an empty body.
78+
"""
79+
if not SEMVER.match(version):
80+
raise ChangelogError(f"not a semantic version: {version!r}")
81+
82+
sections = dict(iter_sections(text))
83+
if version not in sections:
84+
raise ChangelogError(f"{CHANGELOG.name} has no '## [{version}]' section")
85+
86+
body = sections[version]
87+
if not body:
88+
raise ChangelogError(f"the '## [{version}]' section in {CHANGELOG.name} is empty")
89+
90+
if "Full Changelog" in body:
91+
return body
92+
93+
previous = previous_version(text, version)
94+
if previous is None:
95+
return body
96+
compare = f"{repo_url}/compare/v{previous}...v{version}"
97+
return f"{body}\n\n**Full Changelog**: {compare}"
98+
99+
100+
def main() -> int:
101+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
102+
parser.add_argument("version", help="version to extract, without a leading 'v'")
103+
parser.add_argument("--output", type=Path, help="write to this file instead of stdout")
104+
parser.add_argument(
105+
"--changelog",
106+
type=Path,
107+
default=CHANGELOG,
108+
help=f"changelog to read (default: {CHANGELOG.name})",
109+
)
110+
parser.add_argument(
111+
"--repo-url",
112+
default=DEFAULT_REPO_URL,
113+
help="repository URL used to build the compare link",
114+
)
115+
args = parser.parse_args()
116+
117+
version = args.version.removeprefix("v")
118+
try:
119+
notes = build_notes(
120+
args.changelog.read_text(encoding="utf-8"), version, repo_url=args.repo_url
121+
)
122+
except (ChangelogError, OSError) as error:
123+
print(f"Error: {error}", file=sys.stderr)
124+
return 1
125+
126+
if args.output:
127+
args.output.write_text(notes + "\n", encoding="utf-8")
128+
print(f"Wrote {len(notes.splitlines())} line(s) of release notes to {args.output}")
129+
else:
130+
print(notes)
131+
return 0
132+
133+
134+
if __name__ == "__main__":
135+
sys.exit(main())

tests/test_changelog_notes.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Tests for changelog-derived GitHub release notes."""
2+
3+
import importlib.util
4+
import re
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
10+
ROOT = Path(__file__).parents[1]
11+
SCRIPT_PATH = ROOT / "scripts" / "changelog_notes.py"
12+
SPEC = importlib.util.spec_from_file_location("changelog_notes", SCRIPT_PATH)
13+
assert SPEC is not None and SPEC.loader is not None
14+
changelog_notes = importlib.util.module_from_spec(SPEC)
15+
SPEC.loader.exec_module(changelog_notes)
16+
17+
18+
SAMPLE = """# Changelog
19+
20+
All notable changes will be documented in this file.
21+
22+
## [Unreleased]
23+
24+
---
25+
26+
## [2.19.0] - 2026-07-28
27+
28+
### Fixed
29+
30+
- Corrected the image model slug.
31+
32+
---
33+
34+
## [2.18.0] - 2026-07-28
35+
36+
### Changed
37+
38+
- Refreshed vendored skills.
39+
40+
---
41+
"""
42+
43+
44+
def test_extracts_only_the_requested_section():
45+
notes = changelog_notes.build_notes(SAMPLE, "2.19.0", repo_url="https://example.test/repo")
46+
47+
assert "Corrected the image model slug." in notes
48+
assert "Refreshed vendored skills." not in notes
49+
assert "## [2.18.0]" not in notes
50+
assert not notes.startswith("---")
51+
52+
53+
def test_appends_compare_link_to_previous_release():
54+
notes = changelog_notes.build_notes(SAMPLE, "2.19.0", repo_url="https://example.test/repo")
55+
56+
assert notes.endswith("**Full Changelog**: https://example.test/repo/compare/v2.18.0...v2.19.0")
57+
58+
59+
def test_oldest_release_has_no_compare_link():
60+
notes = changelog_notes.build_notes(SAMPLE, "2.18.0", repo_url="https://example.test/repo")
61+
62+
assert "Full Changelog" not in notes
63+
64+
65+
def test_existing_compare_link_is_not_duplicated():
66+
text = SAMPLE.replace(
67+
"- Corrected the image model slug.",
68+
"- Corrected the image model slug.\n\n**Full Changelog**: https://example.test/hand-written",
69+
)
70+
71+
notes = changelog_notes.build_notes(text, "2.19.0", repo_url="https://example.test/repo")
72+
73+
assert notes.count("Full Changelog") == 1
74+
assert "hand-written" in notes
75+
76+
77+
def test_unreleased_section_is_not_a_valid_version():
78+
with pytest.raises(changelog_notes.ChangelogError):
79+
changelog_notes.build_notes(SAMPLE, "Unreleased")
80+
81+
82+
def test_missing_section_is_rejected():
83+
with pytest.raises(changelog_notes.ChangelogError):
84+
changelog_notes.build_notes(SAMPLE, "9.9.9")
85+
86+
87+
def test_empty_section_is_rejected():
88+
text = SAMPLE.replace("### Fixed\n\n- Corrected the image model slug.\n", "")
89+
90+
with pytest.raises(changelog_notes.ChangelogError):
91+
changelog_notes.build_notes(text, "2.19.0")
92+
93+
94+
def test_real_changelog_documents_the_current_package_version():
95+
"""The release workflow extracts these notes, so the shipped version must have an entry."""
96+
match = re.search(
97+
r'^version\s*=\s*"([^"]+)"',
98+
(ROOT / "pyproject.toml").read_text(encoding="utf-8"),
99+
re.MULTILINE,
100+
)
101+
assert match is not None
102+
version = match.group(1)
103+
104+
notes = changelog_notes.build_notes(
105+
(ROOT / "CHANGELOG.md").read_text(encoding="utf-8"), version
106+
)
107+
108+
assert notes.strip()

0 commit comments

Comments
 (0)