ci(publish): auto-create GitHub release page on v* tag from CHANGELOG#47
Conversation
Adds a `release` job that runs after `publish` succeeds and calls `gh release create` with notes extracted from the matching `## [X.Y.Z] - YYYY-MM-DD` section in CHANGELOG.md. Also adds an early presence-check to the `build` job that fails the workflow before PyPI upload if the tagged version has no CHANGELOG section, so PyPI and Releases stay consistent (no orphan PyPI ship without notes, no Releases page pointing at a tag that never published). Pre-release tags (anything containing `-`) get `--prerelease`; stable tags rely on `gh release create`'s `--latest=auto` behavior. Uses the workflow-default `GITHUB_TOKEN` with `permissions: contents: write` — no separate App token required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
cmeans
left a comment
There was a problem hiding this comment.
QA Round 1 — Findings
Reviewed at head 26fedc4. Workflow-only change; CI all green (lint, typecheck, test 3.11/3.12/3.13, deploy-smoke). Local re-run confirms 71/71 pytest, ruff/format/mypy clean.
Algorithm verification
Ran the awk extractor against the live CHANGELOG.md:
| Tag | Output | Head / tail |
|---|---|---|
v0.1.0 |
191 lines | ### Changed … collector finds pypinfo via sys.executable's neighbor instead of PATH. |
v0.1.1 |
50 lines | ### Added … last entry is the shields.io canonicalization (closes #16) |
v0.1.2 |
3 lines | ### Fixed, blank, the recategorization bullet |
v9.9.9 |
0 lines | empty-guard fires |
All four match the PR body's claims. Trim-leading and trim-trailing blank-line invariants hold. The ^## \[ and ^\[[^]]+\]:[[:space:]] exit conditions terminate at the right boundaries on all three real sections.
Findings
1. Heading-only section bypasses the early presence-check, but then breaks the empty-notes guard after PyPI ship (observation).
The build job's pre-publish check (grep -qE "^## \[${VERSION}\] - " CHANGELOG.md) verifies a section heading exists; it does not verify the section has any content. The release job's awk extractor checks if [ ! -s release_notes.md ] after extraction.
If a CHANGELOG looks like
## [Unreleased]
## [0.1.3] - 2026-04-29
## [0.1.2] - 2026-04-27
### Fixed
- something
— i.e. the new version's heading is present but has no body before the next ## [ heading or [link-ref]: line — the early check passes, PyPI publishes, then the release job's awk extracts 0 lines and exits 1. Empirically confirmed against a synthetic fixture: presence check PASSES, awk emits 0 lines, empty-guard fires.
Result: an orphan PyPI ship with no matching GitHub release page. The PR body's stated invariant — "no orphan PyPI ship without notes, no Releases page pointing at a tag that never published" — is technically violated for the empty-heading case (it holds for the missing-heading case).
Practical likelihood is near-zero (anyone tagging a release writes content for it), and the failure mode is loud (workflow run goes red), so this is observation-grade rather than a blocker. Suggested fix is small: replace the grep -qE early check with the same awk extractor (or have the early check verify at least one non-blank content line follows the heading), so the early gate matches the late gate's stricter notion of "section is publishable".
Things explicitly checked and clean
- Permission scope:
releasejob declarespermissions: contents: writeonly; no top-level escalation.publishjob retainsid-token: writefor OIDC.buildjob uses default read. needs: publishordering: release page only created after PyPI upload succeeds, so the no-orphan-Release-page direction holds.- Pre-release detection (
[[ "$VERSION" == *-* ]]) maps SemVer pre-release tags to--prerelease; stable tags rely ongh release create --latest=auto. - Token:
${{ secrets.GITHUB_TOKEN }}is sufficient forgh release createwithcontents: write. No App-token plumbing required. actions/checkout@v6matches the existingbuildjob; no version drift.- Tag pattern
v*from the existingon:trigger is unchanged. - CHANGELOG entry placement: under
[Unreleased]### Added, KaC-compliant ordering preserved relative to the existing### Fixedblock.
|
QA round 1 → QA Failed (one observation). Verdict basis: standing rule — every observation blocks signoff. Dev decides whether to address (small What I ran:
CI green at |
Addresses QA round 1 finding on PR #47. The earlier `grep -qE` presence check verified that a `## [X.Y.Z] -` heading existed but did not verify the section had any body. A heading-only section (e.g. an accidentally empty `## [0.1.3] - 2026-04-29` followed immediately by the next release heading) would have passed the build gate, shipped to PyPI, then failed the release job's empty-notes guard — leaving an orphan PyPI release with no GitHub release page and no rollback path. Replaces the grep with the same awk extractor used by the release job and gates on `[ ! -s release_notes.md ]`, so both ends now define "publishable section" the same way. The extracted notes ride through the workflow as a `release-notes` artifact, which lets the release job drop its checkout + duplicate awk and consume pre-validated notes directly. `gh release create` gains `--repo "$GITHUB_REPOSITORY"` since there is no longer a checkout providing a remote. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
QA round 1 finding addressed in Change: Replaced the build-job's What this fixes:
Local verification:
PR body updated to reflect the consolidated architecture. |
cmeans
left a comment
There was a problem hiding this comment.
QA Round 2 — PASS
Reviewed at head aceb236. CI all green. Local re-run: 71/71 pytest, ruff/format/mypy clean.
Round 1 finding resolved
The earlier grep -qE heading-only check is replaced with the same awk extractor used by the release job, gated on [ ! -s release_notes.md ]. Empirically validated against a synthetic heading-only fixture (## [0.1.3] - with no body): the build-job gate now fires exit 1 BEFORE PyPI publish runs. Missing-version (v9.9.9) and populated-section (v0.1.2) cases also behave correctly.
Architectural delta
The fix went further than the suggested grep→awk swap: release_notes.md rides through the workflow as a release-notes artifact, the release job drops its checkout + duplicate awk and consumes the pre-validated notes directly, and gh release create gains --repo "$GITHUB_REPOSITORY" to compensate for the missing checkout. Net effect: a single extractor gates both ends with zero duplicated logic — no risk of the two extractors drifting apart over time.
Algorithm verification (carried over from round 1, awk script byte-identical)
Live CHANGELOG.md:
| Tag | Lines | Behavior |
|---|---|---|
v0.1.0 |
191 | passes (extracts to [Unreleased]: link-ref boundary) |
v0.1.1 |
50 | passes (stops at ## [0.1.0]) |
v0.1.2 |
3 | passes (single ### Fixed entry) |
v9.9.9 |
0 | build-gate fires |
Synthetic heading-only fixture:
| Case | awk lines | build-gate |
|---|---|---|
## [0.1.3] - heading-only |
0 | FIRES (exit 1, no PyPI ship) |
## [9.9.9] missing |
0 | FIRES (exit 1, no PyPI ship) |
## [0.1.2] populated |
3 | passes (publish proceeds) |
Things explicitly checked and clean
- Permission scope unchanged:
releasejob declarescontents: write;publishretainsid-token: write;builduses default read. - Job ordering:
releaseisneeds: publish; artifact channel works because GitHub Actions artifacts are workflow-scoped, not dependency-scoped —releasecan pullbuild's artifact even without a directneeds: build. actions/download-artifact@v8with nopath:landsrelease_notes.mdat the cwd of the release job, wheregh release create --notes-file release_notes.mdreads it.- Pre-release detection (
[[ "$VERSION" == *-* ]]) and--latest=autosemantics unchanged. - CHANGELOG
[Unreleased]### Addedbullet rewritten to reflect the round-2 design; KaC ordering preserved.
Zero findings. Ready for QA Signoff.
|
QA round 2 → Ready for QA Signoff. Round 1's empty-heading observation is fully resolved at Re-verification:
Awaiting maintainer QA Approved. |
… hygiene (#48) Bumps version 0.1.2 → 0.1.3 and promotes the [Unreleased] section to [0.1.3] - 2026-04-28. Five PRs land in this release; full bullets in CHANGELOG.md. ### Added - ci(publish): auto-create GitHub release page on v* tag from CHANGELOG (#47, eae5e80) — first release that exercises this end-to-end. ### Fixed - chore(docker): bump uv pin from `==0.4.*` to `>=0.5,<1` (#44, 2679f07, closes #34) - docs(issue-template): refactor bug_report version placeholder to durable form (#45, 75a6384, closes #40) - docs(readme): refresh dogfood blockquote — count > 0, drop M3 reference (#46, eacbf60, closes #43) - fix(workflow): insert ### Changed in Keep-a-Changelog order on auto-CHANGELOG (#41, 4bb8584, closes #26) uv.lock refreshed to pick up the version bump (single source of truth in pyproject.toml; uv lock writes 0.1.3 into uv.lock automatically). Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le block each Round 1 QA flagged that the v0.2.0 section had `### Changed` (Alpha→Beta) → `### Added` (PR #49) → `### Changed` (PR #50) — both wrong order (KaC v1.1.0 specifies Added → Changed → Deprecated → Removed → Fixed → Security) and duplicate `### Changed` blocks (standard KaC has one block per category per release). PR #41 specifically enforces this ordering on auto-CHANGELOG runs; hand-authored release commit shouldn't regress it. Reorders the v0.2.0 section so `### Added` comes first, then a single `### Changed` block containing both the README modernization bullet (PR #50) and the Alpha → Beta promotion bullet, in that order. The auto-release-page workflow added in PR #47 will now render the v0.2.0 GitHub Release page with the standard KaC shape. No content change — just structural fix. Same words, regrouped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…51) * release: v0.2.0 — installer-mix v2 + uv-first README modernization Bumps version 0.1.3 → 0.2.0 and promotes the [Unreleased] section to [0.2.0] - 2026-04-29. Two PRs land in this release; full bullets in CHANGELOG.md. Minor bump (rather than patch) is justified because run_pypinfo's return type changed from `int` to `dict[str, int]` in PR #49 — not a public API but a public-ish internal contract that tests and future callers depend on. The feature surface is also new (7 new badge files per package per window), making 0.2.0 the right semantic line. ### Added - feat(collector): add per-installer badge files (installer-mix v2) (#49, 2898ee9). Six individual installer badges + pip-family aggregate per package; v1 hero unchanged. Already verified end-to-end on the live CT 112 deploy. ### Changed - docs: uv-first install everywhere + by-installer dogfood layout + breakdown narrative (#50, 830c051). Hero in top metadata row; by-installer breakdown below blockquote; uv tool install leads in README and deploy/README.md; SECURITY.md mentions both uv and pip. uv.lock refreshed to pick up the version bump (single source of truth in pyproject.toml; uv lock writes 0.2.0 into uv.lock automatically). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * release: promote Alpha → Beta on v0.2.0 Trigger: four v0.1.x releases shipped, real production deploy running since 2026-04-25 producing daily badges for four packages, 100% test coverage on src/, additive-only schema evolution (v2 added new badge files alongside the unchanged v1 hero rather than mutating anything). Folding into the v0.2.0 release PR so the bump lands with the minor version cut. - pyproject.toml: `Development Status :: 3 - Alpha` → `Development Status :: 4 - Beta`. PyPI's classifier display picks this up on next ship. - README.md ## Status section rewritten: leads with "Beta as of v0.2.0", names the live-since date, mentions the 100% coverage, and adds the explicit guarantee that the v1 hero badge JSON shape and filename are stable through 1.0 (so README readers and downstream maintainers know what's frozen). - CHANGELOG.md gets a new ### Changed bullet under [0.2.0] with the rationale + scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(changelog): fix v0.2.0 KaC ordering — Added before Changed, single block each Round 1 QA flagged that the v0.2.0 section had `### Changed` (Alpha→Beta) → `### Added` (PR #49) → `### Changed` (PR #50) — both wrong order (KaC v1.1.0 specifies Added → Changed → Deprecated → Removed → Fixed → Security) and duplicate `### Changed` blocks (standard KaC has one block per category per release). PR #41 specifically enforces this ordering on auto-CHANGELOG runs; hand-authored release commit shouldn't regress it. Reorders the v0.2.0 section so `### Added` comes first, then a single `### Changed` block containing both the README modernization bullet (PR #50) and the Alpha → Beta promotion bullet, in that order. The auto-release-page workflow added in PR #47 will now render the v0.2.0 GitHub Release page with the standard KaC shape. No content change — just structural fix. Same words, regrouped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
releasejob in.github/workflows/publish.ymlruns afterpublishsucceeds and callsgh release createwith notes extracted from the matching## [X.Y.Z] - YYYY-MM-DDsection inCHANGELOG.md. Futurev*tags will get a populated GitHub Releases page automatically — the manual backfill for v0.1.0 / v0.1.1 / v0.1.2 was the prompt for this.buildjob extracts the section via the sameawkscript and fails the workflow before PyPI upload if the extracted body is empty. This covers both the missing-section case and the heading-only case (a## [X.Y.Z] -line with no body underneath would otherwise pass a name-only presence check, ship to PyPI, and then leave the release job to fail with no PyPI rollback path). PyPI and Releases stay consistent.release_notes.mdrides through the workflow as arelease-notesartifact, so the same extractor gates both ends with no duplicated logic. Release job drops its checkout entirely and consumes the pre-validated notes;gh release creategains--repo "$GITHUB_REPOSITORY"since there's no remote to infer from.-, e.g.v1.0.0-rc1) get the--prereleaseflag; stable tags rely ongh release create's--latest=autosemver behavior. Uses the workflow-defaultGITHUB_TOKENwithpermissions: contents: write— no separate App token required.Algorithm
The release-notes extractor is a small
awkscript that:## [X.Y.Z] -matches,## [heading or[link-ref]:line,Validated locally against current
CHANGELOG.md:v0.1.0→ 191 lines, ends correctly before[Unreleased]:link refsv0.1.1→ 50 lines, stops at the## [0.1.0]headingv0.1.2→ 3 lines (the single### Fixedentry)v9.9.9(non-existent) → 0 lines (empty signal triggers the guard, blocks PyPI upload)## [0.1.3] -with no body) → 0 lines (also blocks PyPI upload — added in round 2 in response to QA finding)Test plan
v*tag,releasejob runs afterpublishand the resulting Release page body matches the CHANGELOG section verbatim, with the tag name as title.buildbefore reachingpublish.🤖 Generated with Claude Code