Skip to content

ci(publish): auto-create GitHub release page on v* tag from CHANGELOG#47

Merged
cmeans-claude-dev[bot] merged 2 commits into
mainfrom
ci-publish-release-pages
Apr 28, 2026
Merged

ci(publish): auto-create GitHub release page on v* tag from CHANGELOG#47
cmeans-claude-dev[bot] merged 2 commits into
mainfrom
ci-publish-release-pages

Conversation

@cmeans-claude-dev

@cmeans-claude-dev cmeans-claude-dev Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • New release job in .github/workflows/publish.yml 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. Future v* 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.
  • The build job extracts the section via the same awk script 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.
  • The extracted release_notes.md rides through the workflow as a release-notes artifact, 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 create gains --repo "$GITHUB_REPOSITORY" since there's no remote to infer from.
  • Pre-release tags (anything containing -, e.g. v1.0.0-rc1) get the --prerelease flag; stable tags rely on gh release create's --latest=auto semver behavior. Uses the workflow-default GITHUB_TOKEN with permissions: contents: write — no separate App token required.

Algorithm

The release-notes extractor is a small awk script that:

  1. Skips lines until ## [X.Y.Z] - matches,
  2. Captures lines until the next ## [ heading or [link-ref]: line,
  3. Trims leading and trailing blank lines.

Validated locally against current CHANGELOG.md:

  • v0.1.0 → 191 lines, ends correctly before [Unreleased]: link refs
  • v0.1.1 → 50 lines, stops at the ## [0.1.0] heading
  • v0.1.2 → 3 lines (the single ### Fixed entry)
  • v9.9.9 (non-existent) → 0 lines (empty signal triggers the guard, blocks PyPI upload)
  • Heading-only fixture (## [0.1.3] - with no body) → 0 lines (also blocks PyPI upload — added in round 2 in response to QA finding)

Test plan

  • CI passes (no Python tests should regress — workflow-only change).
  • On the next real v* tag, release job runs after publish and the resulting Release page body matches the CHANGELOG section verbatim, with the tag name as title.
  • If the next tag's CHANGELOG section is missing or empty (heading-only), the workflow fails in build before reaching publish.

🤖 Generated with Claude Code

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>
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels Apr 28, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

cmeans
cmeans previously approved these changes Apr 28, 2026

@cmeans cmeans left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@cmeans cmeans added the QA Active QA is actively reviewing; Dev should not push changes label Apr 28, 2026
@github-actions github-actions Bot removed the Ready for QA Dev work complete — QA can begin review label Apr 28, 2026

@cmeans cmeans left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ### Changedcollector 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: release job declares permissions: contents: write only; no top-level escalation. publish job retains id-token: write for OIDC. build job uses default read.
  • needs: publish ordering: 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 on gh release create --latest=auto.
  • Token: ${{ secrets.GITHUB_TOKEN }} is sufficient for gh release create with contents: write. No App-token plumbing required.
  • actions/checkout@v6 matches the existing build job; no version drift.
  • Tag pattern v* from the existing on: trigger is unchanged.
  • CHANGELOG entry placement: under [Unreleased] ### Added, KaC-compliant ordering preserved relative to the existing ### Fixed block.

@cmeans

cmeans commented Apr 28, 2026

Copy link
Copy Markdown
Owner

QA round 1 → QA Failed (one observation).

Verdict basis: standing rule — every observation blocks signoff. Dev decides whether to address (small awk-extractor swap on the early check) or accept the gap as low-risk; either path moves the PR forward.

What I ran:

  • Local pytest: 71/71 pass
  • Local ruff check + format check: clean
  • Local mypy: clean
  • Local awk-extractor against live CHANGELOG.md: 191 / 50 / 3 / 0 lines for v0.1.0 / v0.1.1 / v0.1.2 / v9.9.9 — matches PR body claims
  • Synthetic fixture (heading-only ## [0.1.3] - section): confirms early grep -qE presence-check passes while the release-job's empty-notes guard catches it — empirically grounding the observation

CI green at 26fedc4 (all 6 of: lint, typecheck, test 3.11/3.12/3.13, deploy-smoke).

@cmeans cmeans added QA Failed QA found issues — needs dev attention and removed QA Active QA is actively reviewing; Dev should not push changes labels Apr 28, 2026
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>
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA and removed QA Failed QA found issues — needs dev attention labels Apr 28, 2026
@cmeans-claude-dev

Copy link
Copy Markdown
Contributor Author

QA round 1 finding addressed in aceb236.

Change: Replaced the build-job's grep -qE presence check with the same awk extractor the release job uses, gated on [ ! -s release_notes.md ]. The build job now writes release_notes.md, uploads it as a release-notes artifact, and the release job consumes that artifact instead of re-extracting from CHANGELOG.md (drops a checkout + a duplicated awk script in the process). gh release create gains --repo "$GITHUB_REPOSITORY" since the release job no longer has a working tree.

What this fixes:

  • Heading-only sections (e.g. accidentally empty ## [0.1.3] - 2026-04-29 followed immediately by the next release heading) now fail in build before PyPI upload, not in release after.
  • Both gates use the same definition of "publishable section" — no algorithmic drift possible going forward.

Local verification:

  • Heading-only fixture (the exact one your QA report described) → awk emits 0 lines, build-job guard fires.
  • Live CHANGELOG.md v0.1.0 / v0.1.1 / v0.1.2 → 191 / 50 / 3 lines (unchanged from round 1).
  • YAML re-parses cleanly.

PR body updated to reflect the consolidated architecture.

@github-actions github-actions Bot added Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels Apr 28, 2026

@cmeans cmeans left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@cmeans cmeans added the QA Active QA is actively reviewing; Dev should not push changes label Apr 28, 2026
@github-actions github-actions Bot removed the Ready for QA Dev work complete — QA can begin review label Apr 28, 2026

@cmeans cmeans left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: release job declares contents: write; publish retains id-token: write; build uses default read.
  • Job ordering: release is needs: publish; artifact channel works because GitHub Actions artifacts are workflow-scoped, not dependency-scoped — release can pull build's artifact even without a direct needs: build.
  • actions/download-artifact@v8 with no path: lands release_notes.md at the cwd of the release job, where gh release create --notes-file release_notes.md reads it.
  • Pre-release detection ([[ "$VERSION" == *-* ]]) and --latest=auto semantics unchanged.
  • CHANGELOG [Unreleased] ### Added bullet rewritten to reflect the round-2 design; KaC ordering preserved.

Zero findings. Ready for QA Signoff.

@cmeans

cmeans commented Apr 28, 2026

Copy link
Copy Markdown
Owner

QA round 2 → Ready for QA Signoff.

Round 1's empty-heading observation is fully resolved at aceb236. The fix went beyond the suggested grep→awk swap: build-job extracts notes, validates non-empty, uploads as artifact; release-job consumes the pre-validated artifact, drops its checkout + duplicate awk. One extractor gates both ends — no drift risk.

Re-verification:

  • 71/71 pytest, ruff/format/mypy clean (local re-run, current session)
  • CI all green at aceb236 (lint, typecheck, test 3.11/3.12/3.13, deploy-smoke)
  • Awk algorithm against live CHANGELOG.md: 191/50/3/0 lines for v0.1.0/v0.1.1/v0.1.2/v9.9.9 (unchanged from round 1, byte-identical script)
  • Synthetic heading-only fixture (## [0.1.3] - with no body): build-gate fires exit 1 BEFORE publish runs

Awaiting maintainer QA Approved.

@cmeans cmeans added Ready for QA Signoff QA passed — ready for maintainer final review and merge QA Approved Manual QA testing completed and passed and removed QA Active QA is actively reviewing; Dev should not push changes Ready for QA Signoff QA passed — ready for maintainer final review and merge labels Apr 28, 2026
@cmeans-claude-dev cmeans-claude-dev Bot merged commit eae5e80 into main Apr 28, 2026
32 checks passed
@cmeans-claude-dev cmeans-claude-dev Bot deleted the ci-publish-release-pages branch April 28, 2026 20:16
@cmeans-claude-dev cmeans-claude-dev Bot mentioned this pull request Apr 29, 2026
6 tasks
cmeans-claude-dev Bot added a commit that referenced this pull request Apr 29, 2026
… 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>
cmeans-claude-dev Bot added a commit that referenced this pull request Apr 29, 2026
…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>
cmeans-claude-dev Bot added a commit that referenced this pull request Apr 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

QA Approved Manual QA testing completed and passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants