Conversation
A release whose version has no changelog entry of its own -- every pre-release and every `.postN` vendor refresh, because `bump_version.py` bumps without adding an entry -- fell back to the automatically generated pull-request list alone, while the base version's entry sat in `CHANGELOG.md` unread. A `1.5.0b5` page therefore said nothing about what 1.5.0 is shaping up to be. - `Select release body` now reuses the entry on file when it is the *base* version's and the release is a pre-release or a `.postN`, writing `release-body.md` as a generated preamble followed by the entry. The preamble names which release the entry actually describes, so a `1.5.0b5` page leading with the `1.5.0` entry cannot be read as 1.5.0 having shipped. `CHANGELOG.md` itself is never modified. - The base version is derived in `version_check`, which already has `packaging` installed, as `Version(...).base_version` -- the same PEP 440 reading `util/bump_version.py` does. New outputs `PCAPKIT_BASE_VERSION` and `PCAPKIT_POSTRELEASE`; the consuming step needs nothing but a string comparison. - The "still reads 'unreleased'" warning no longer fires for a pre-release, where an undated base entry is correct rather than an oversight. It still fires on an exact match, and on a `.postN`, where it does mean the base version shipped undated. All three existing paths are unchanged -- absent file, exact match, and a mismatch with no base entry on file. Verified by running the step's own shell, extracted from the workflow, over a 15-case version/fixture matrix; the workflow YAML parses and the script passes `bash -n`.
|
Closing this as redundant against WhyThe behaviour this PR was written to add is already what That was verified two independent ways rather than by reading alone:
So the rule the owner asked for — only a matching version release attaches the changelog; Nothing in this PR has value once its new branch is dropped: Two things worth keeping out of this, because they outlive the PRThe changelog-attachment branch has never run in any release. Forcing a match for a test does not work. Setting The cheapest honest test is the The cross-review on this PR returned GOOD TO GO on a different model, and found one real non-blocking gap: a version that is both |
The ask
A pre-release and a
.postNhave no changelog entry of their own by design —util/bump_version.pybumps the version without adding one — so
Select release bodyfell through to the automaticallygenerated pull-request list alone. Meanwhile the base version's entry was sitting in
CHANGELOG.md,unread. A
1.5.0b5release page therefore said nothing at all about what1.5.0is shaping up tobe.
This reuses that entry for a derivative release, behind a generated preamble that says which release
the entry actually describes.
1.5.0b5is the validation run for this change — read this firstThis PR edits the workflow that performs releases, and it is untested against a real release by
construction. The
Select release bodystep only runs insideCreate Release; there is no way toexercise it without publishing something. Everything below was verified by extracting the step's own
shell out of this YAML and running it against fixtures (see How it was verified), which covers
the branch logic but not the interaction with
softprops/action-gh-releaseor with GitHub'sgenerated-notes join.
The sequence is settled: merge this, then cut
1.5.0b5as the validation run. Per the owner,b5 is a test of the updated workflow and changelog body rewrites do not block it.
What success looks like on the
v1.5.0b5release pageTop to bottom, the body should read:
## 1.5.0 -- unreleasedand the whole1.5.0entry, byte-identical toCHANGELOG.md.github/release.ymlWhat confirms it in the workflow log
The
githubjob'sSelect release bodystep is the direct evidence — it announces which branch ittook. On a successful b5 you should see exactly this notice and no undated warning:
Two log lines that mean it went wrong instead:
Notice: Release body generated … which is neither 1.5.0b5 nor its base version 1.5.0 …— the newbranch did not fire, because the heading token did not match
1.5.0. Body will begenerated-notes-only, i.e. exactly today's behaviour.
What failure looks like, and the blast radius
Two plausible cosmetic failures: a body that is generated-notes-only (the new branch did not
fire), or a body that leads with the
1.5.0entry and no preamble (the branch fired but thepreamble was not prepended). Both are fixable after the fact by editing the release body in place on
the release page — nothing needs a re-release to correct them.
That reassurance stops at the release body. Cutting
1.5.0b5burns the version number on PyPIirreversibly: the
pypijob uploads wheels for seven interpreters andskip-existing: truemeans are-upload of the same version is silently ignored rather than replaced. So a bad body is
recoverable and a bad release is not, and this change can only affect the former.
What changed
Branch conditions — before and after
headingis the version token onCHANGELOG.md's first line.baseispackaging.version.Version(version).base_version.derivativeisis_prerelease or is_postrelease.CHANGELOG.mdabsentheading == versionbody_path=CHANGELOG.md, notice, undated-warning ifunreleasedheading != version,derivative,heading == basebody_path=release-body.md(preamble + entry), notice; undated-warning only on a.postNheading != version,derivative,heading != baseheading != version, notderivativeOnly the third row is new behaviour. The two reworded notices still produce no
pathoutput andstill leave the release carrying generated notes alone; the old text claimed a mismatch is "Expected
for a pre-release and for a .postN vendor refresh, neither of which has a changelog entry of its
own", which after this change is the description of the branch that no longer lands there, so
leaving it would have been actively misleading. The two fall-through cases now also get separate
messages, because they are not the same fact: one means the base entry is not on file, the other
means it is but this release is not the kind that may borrow it.
Base-version derivation —
packaging, inversion_check, not shellVersion(...).base_version, computed in theversion_checkjob and passed down asPCAPKIT_BASE_VERSION. Three reasons for that placement over a shell strip or apython -cin theconsuming step:
1.5.0b5,1.5.0rc1,1.5.0a1,1.5.0.post2,1.5.0.dev1and1.5.0b4.post1all reduce to1.5.0; an epoch staysattached (
1!1.5.0b1→1!1.5.0), which is correct and which a naive strip gets wrong.util/bump_version.pyalready derives the next version through exactly this call, so theworkflow and the bumper now agree by construction rather than by coincidence.
version_checkis the only job withpackaginginstalled. Thegithubjob has noactions/setup-pythonand nopip install, so apython -cthere would either add both to therelease-critical path or gamble on the runner image having
packagingimportable. Deriving itupstream leaves the consuming step doing nothing but string comparison — no new dependency on the
path that publishes.
PCAPKIT_POSTRELEASE(is_postrelease) is added alongside, because the preamble's wording differsbetween a pre-release and a
.postNand because it is what excludes a bare local version(
1.5.0+local: base differs from version, but neither pre nor post) from the new branch.The preamble, as committed
Pre-release:
.postN:Assembled into
release-body.mdin the job's own checkout.CHANGELOG.mdis never written to —it is generated from
docs/source/changelog/<version>.rstand theChangelog driftgate checks itbyte for byte, so modifying it in place would break that gate. The scratch file is discarded with the
job.
Deliberately plain
**bold**in a blockquote rather than GitHub's> [!NOTE]alert syntax, which isnot reliably rendered on a release page and degrades to a literal
[!NOTE]where it is not.The
unreleasedwarning1.5.0's entry currently reads## 1.5.0 -- unreleased, and on the new branch that is correct:1.5.0has not shipped, which is the entire reason there is a1.5.0b5. So the warning is silentthere. It still fires in the two cases where
unreleasedgenuinely is an oversight:docs/source/changelog/<version>.rst..postN— new message, pointing at the base version's entry file: a.postNexists onlybecause its base version was released, so its entry should have been dated by then.
The
sed … | grep -qi 'unreleased'test is hoisted into oneundatedflag so both branches read itonce. Same expression, same result; it is now just read from a variable.
How it was verified
You cannot test this by releasing, so the step's
run:script is extracted from the committedworkflow YAML and executed under
bashin a scratch directory, against fixtureCHANGELOG.mdfiles and env computed exactly as
version_checkcomputes it. The thing under test is the thing thatships, not a transcription of it.
15 cases, all exiting 0:
CHANGELOG.mdheading1.5.0## 1.5.0 -- unreleasedCHANGELOG.md+ notice + undated warning1.5.0## 1.5.0 -- 2026-09-30CHANGELOG.md+ notice, no warning1.5.0b5## 1.5.0 -- unreleasedrelease-body.md(pre-release preamble), no warning1.5.0b4## 1.5.0 -- unreleasedrelease-body.md(pre-release preamble), no warning1.5.0a1## 1.5.0 -- unreleasedrelease-body.md(pre-release preamble), no warning1.5.0rc1## 1.5.0 -- unreleasedrelease-body.md(pre-release preamble), no warning1.5.0.dev1## 1.5.0 -- unreleasedrelease-body.md(pre-release preamble), no warning1.5.0.post1## 1.5.0 -- unreleasedrelease-body.md(post preamble) + undated warning1.5.0.post1## 1.5.0 -- 2026-09-30release-body.md(post preamble), no warning1.5.0b5## 1.4.1 -- 2026-08-01path— generated notes alone (fell back)1.5.0.post1## 1.4.1 -- 2026-08-01path— generated notes alone (fell back)1.6.0## 1.4.1 -- 2026-08-01path— generated notes alone (fell back)1.5.0+local## 1.5.0 -- unreleasedpath— generated notes alone (not a pre/post release)1.5.0b5path— generated notes alone,'<no parseable heading>'1.5.0b5path— generated notes alone + absent-file warningEvery fall-back case was additionally asserted to leave no stray
release-body.mdbehind.Plus a smoke run against the repository's real 95,317-byte
CHANGELOG.md:1.5.0b5→release-body.md, 95,574 bytes: the preamble, a blank line, then the1.5.0entrybyte-identical to
CHANGELOG.md, ending on its ownFull changelog:trailer.1.5.0→body_path=CHANGELOG.md, byte-identical to the file, i.e. today's behaviour.CHANGELOG.mdon disk was confirmed unmodified afterwards.And the failure mode that hides longest, an invalid workflow GitHub silently ignores:
yaml.safe_load, and all six jobs plus the newversion_checkoutputs(
PCAPKIT_POSTRELEASE,PCAPKIT_BASE_VERSION) are present and readable from the parsed document;run:script passesbash -n;python -cone-liners inGet Versionwere run verbatim against this tree'spcapkit(
1.5.0b4) —PRERELEASE=true,POSTRELEASE=false,BASE=1.5.0.No test suite was run; nothing here touches
pcapkit/**.Deliberately not in this PR
bullet from here would collide with it. Omitted on purpose, not forgotten.
.github/release.yml. Its header comment now says something this change falsifies —"on a pre-release or a
.postNvendor refresh -- neither of which has a changelog entry of its own-- they are the whole of the notes". After this, they follow the borrowed entry instead. That file
is owned by another branch; flagging it here so the correction is not lost.
version token, so the planned
1.5.0entry restructure changes nothing about which branch fires.One margin worth knowing
A pre-release body now carries the whole base entry — 95,574 bytes as of this branch, of which the
preamble is 257. A final release already sent 95,317, so the size is not new; what is new is that a
pre-release now sends it too.
Being precise about the limit, because I checked and could not confirm it: GitHub's REST documentation
for the releases endpoints states no maximum length for
body, and the widely-cited 125,000-charactercap (surfaced as a 422
body is too long) is not written down there. So treat "about 76% of the cap"as folklore rather than as a measurement — the 95,574 figure is measured, the ceiling it is measured
against is not. The planned
1.5.0entry restructure moves in the right direction either way, and ifa release ever does fail on body length, this branch is where to look first.