Skip to content

ci(release): give a pre-release or .postN its base version's changelog - #658

Closed
JarryShaw wants to merge 1 commit into
mainfrom
ci/prerelease-body-base-changelog
Closed

JarryShaw wants to merge 1 commit into
mainfrom
ci/prerelease-body-base-changelog

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

The ask

postN and pre-releases are fine without dedicated changelog - that's expected. but we might
consider still include the main version changelog in the release body so that ppl can have a
better understanding and more useful information.

A pre-release and a .postN have no changelog entry of their own by design — util/bump_version.py
bumps the version without adding one — so Select release body fell through to the automatically
generated pull-request list alone. Meanwhile the base version's entry was sitting in CHANGELOG.md,
unread. A 1.5.0b5 release page therefore said nothing at all about what 1.5.0 is shaping up to
be.

This reuses that entry for a derivative release, behind a generated preamble that says which release
the entry actually describes.

⚠️ 1.5.0b5 is the validation run for this change — read this first

This PR edits the workflow that performs releases, and it is untested against a real release by
construction.
The Select release body step only runs inside Create Release; there is no way to
exercise 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-release or with GitHub's
generated-notes join.

The sequence is settled: merge this, then cut 1.5.0b5 as 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.0b5 release page

Top to bottom, the body should read:

  1. a one-line blockquote — This is a pre-release of 1.5.0, not 1.5.0 itself. …
  2. then ## 1.5.0 -- unreleased and the whole 1.5.0 entry, byte-identical to CHANGELOG.md
  3. then GitHub's own 🏕 Changes / 👒 Dependencies list, as configured by .github/release.yml

What confirms it in the workflow log

The github job's Select release body step is the direct evidence — it announces which branch it
took. On a successful b5 you should see exactly this notice and no undated warning:

Notice: Release body from CHANGELOG.md
1.5.0b5 has no changelog entry of its own, as expected, so the entry for its base version 1.5.0
leads the release body behind a generated preamble saying which release that entry describes; the
generated pull-request list follows it.

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 new
    branch did not fire, because the heading token did not match 1.5.0. Body will be
    generated-notes-only, i.e. exactly today's behaviour.
  • The old undated warning appearing on a pre-release at all — it should now be silent there.

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.0 entry and no preamble (the branch fired but the
preamble 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.0b5 burns the version number on PyPI
irreversibly: the pypi job uploads wheels for seven interpreters and skip-existing: true means a
re-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

heading is the version token on CHANGELOG.md's first line. base is
packaging.version.Version(version).base_version. derivative is is_prerelease or is_postrelease.

Condition Before After
CHANGELOG.md absent generated notes alone + warning unchanged
heading == version body_path=CHANGELOG.md, notice, undated-warning if unreleased unchanged
heading != version, derivative, heading == base generated notes alone + notice NEW — body_path=release-body.md (preamble + entry), notice; undated-warning only on a .postN
heading != version, derivative, heading != base generated notes alone + notice generated notes alone + notice — path unchanged, notice reworded
heading != version, not derivative generated notes alone + notice generated notes alone + notice — path unchanged, notice reworded

Only the third row is new behaviour. The two reworded notices still produce no path output and
still 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, in version_check, not shell

Version(...).base_version, computed in the version_check job and passed down as
PCAPKIT_BASE_VERSION. Three reasons for that placement over a shell strip or a python -c in the
consuming step:

  • It is a real PEP 440 reading, and shell pattern-matching is not. 1.5.0b5, 1.5.0rc1,
    1.5.0a1, 1.5.0.post2, 1.5.0.dev1 and 1.5.0b4.post1 all reduce to 1.5.0; an epoch stays
    attached (1!1.5.0b1 → 1!1.5.0), which is correct and which a naive strip gets wrong.
  • util/bump_version.py already derives the next version through exactly this call, so the
    workflow and the bumper now agree by construction rather than by coincidence.
  • version_check is the only job with packaging installed. The github job has no
    actions/setup-python and no pip install, so a python -c there would either add both to the
    release-critical path or gamble on the runner image having packaging importable. Deriving it
    upstream 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 differs
between a pre-release and a .postN and 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:

This is a pre-release of 1.5.0, not 1.5.0 itself. The changelog entry below belongs to 1.5.0 and describes that release in progress -- it is reproduced here so this pre-release can be read in context, and it may still change before 1.5.0 is final.

.postN:

This is a post-release of 1.5.0 and carries no library changes of its own. The changelog entry below belongs to 1.5.0 and is reproduced here for context; what this release actually changed is the generated list that follows it.

Assembled into release-body.md in the job's own checkout. CHANGELOG.md is never written to —
it is generated from docs/source/changelog/<version>.rst and the Changelog drift gate checks it
byte 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 is
not reliably rendered on a release page and degrades to a literal [!NOTE] where it is not.

The unreleased warning

1.5.0's entry currently reads ## 1.5.0 -- unreleased, and on the new branch that is correct:
1.5.0 has not shipped, which is the entire reason there is a 1.5.0b5. So the warning is silent
there. It still fires in the two cases where unreleased genuinely is an oversight:

  • exact match — unchanged, same message, pointing at docs/source/changelog/<version>.rst.
  • .postN — new message, pointing at the base version's entry file: a .postN exists only
    because its base version was released, so its entry should have been dated by then.

The sed … | grep -qi 'unreleased' test is hoisted into one undated flag so both branches read it
once. 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 committed
workflow YAML
and executed under bash in a scratch directory, against fixture CHANGELOG.md
files and env computed exactly as version_check computes it. The thing under test is the thing that
ships, not a transcription of it.

15 cases, all exiting 0:

Version CHANGELOG.md heading Path taken
1.5.0 ## 1.5.0 -- unreleased CHANGELOG.md + notice + undated warning
1.5.0 ## 1.5.0 -- 2026-09-30 CHANGELOG.md + notice, no warning
1.5.0b5 ## 1.5.0 -- unreleased release-body.md (pre-release preamble), no warning
1.5.0b4 ## 1.5.0 -- unreleased release-body.md (pre-release preamble), no warning
1.5.0a1 ## 1.5.0 -- unreleased release-body.md (pre-release preamble), no warning
1.5.0rc1 ## 1.5.0 -- unreleased release-body.md (pre-release preamble), no warning
1.5.0.dev1 ## 1.5.0 -- unreleased release-body.md (pre-release preamble), no warning
1.5.0.post1 ## 1.5.0 -- unreleased release-body.md (post preamble) + undated warning
1.5.0.post1 ## 1.5.0 -- 2026-09-30 release-body.md (post preamble), no warning
1.5.0b5 ## 1.4.1 -- 2026-08-01 no path — generated notes alone (fell back)
1.5.0.post1 ## 1.4.1 -- 2026-08-01 no path — generated notes alone (fell back)
1.6.0 ## 1.4.1 -- 2026-08-01 no path — generated notes alone (fell back)
1.5.0+local ## 1.5.0 -- unreleased no path — generated notes alone (not a pre/post release)
1.5.0b5 no heading at all no path — generated notes alone, '<no parseable heading>'
1.5.0b5 file absent no path — generated notes alone + absent-file warning

Every fall-back case was additionally asserted to leave no stray release-body.md behind.

Plus a smoke run against the repository's real 95,317-byte CHANGELOG.md:

  • as 1.5.0b5 → release-body.md, 95,574 bytes: the preamble, a blank line, then the 1.5.0 entry
    byte-identical to CHANGELOG.md, ending on its own Full changelog: trailer.
  • as 1.5.0 → body_path=CHANGELOG.md, byte-identical to the file, i.e. today's behaviour.
  • in both, CHANGELOG.md on disk was confirmed unmodified afterwards.

And the failure mode that hides longest, an invalid workflow GitHub silently ignores:

  • the full file parses under yaml.safe_load, and all six jobs plus the new version_check outputs
    (PCAPKIT_POSTRELEASE, PCAPKIT_BASE_VERSION) are present and readable from the parsed document;
  • the extracted run: script passes bash -n;
  • the four python -c one-liners in Get Version were run verbatim against this tree's pcapkit
    (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

  • No changelog entry. Another branch is consolidating changelog bullets across several PRs, and a
    bullet from here would collide with it. Omitted on purpose, not forgotten.
  • No edit to .github/release.yml. Its header comment now says something this change falsifies —
    "on a pre-release or a .postN vendor 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.
  • No tuning to the entry's current wording or length. The step keys only on the first line's
    version token, so the planned 1.5.0 entry 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-character
cap (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.0 entry restructure moves in the right direction either way, and if
a release ever does fail on body length, this branch is where to look first.

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`.
@JarryShaw JarryShaw added the ci Pull requests that change CI or workflow configuration (ci: subject prefix) label Sep 22, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Closing this as redundant against main, not as wrong.

Why

The behaviour this PR was written to add is already what main does. create-release.yml's Select release body step writes path=CHANGELOG.md on exactly one line, reachable only after [ "$heading" != "$PCAPKIT_VERSION" ] fails — so an exact version match attaches the changelog and everything else takes exit 0 with no path, leaving the automatically generated notes alone.

That was verified two independent ways rather than by reading alone:

  • By source. There is no other assignment to that output.
  • By brute force. 12 version strings × 10 CHANGELOG.md shapes = 120 combinations, including unparseable headings, ## with no space, a blank first line, a local version, an epoch (1!1.5.0b1) and a final release. Result: 8 attachments, every one with heading == version, and zero with heading != version.

So the rule the owner asked for — only a matching version release attaches the changelog; bN and .postN do not — needs no code change.

Nothing in this PR has value once its new branch is dropped: PCAPKIT_BASE_VERSION and PCAPKIT_POSTRELEASE exist only to feed it, the reworded notices are only needed because of it, and the undated hoist is a bare refactor.

Two things worth keeping out of this, because they outlive the PR

The changelog-attachment branch has never run in any release. Select release body landed in 41998c840 (#529) at 2026-09-20T05:21:05Z. The only release cut since is v1.5.0b4, four hours later, whose stored body is 2,461 characters of generated notes opening with <!-- Release notes generated using configuration in .github/release.yml -->. v1.4.1's body is 0 characters and predates the step. Everything between is a pre-release or a .postN. That branch is unproven in production, and a 1.5.0b5 release cannot exercise it, because b5 never matches a 1.5.0 heading.

Forcing a match for a test does not work. Setting CHANGELOG.md's heading token to 1.5.0b5 fails the Changelog drift gate — which has no if: and so runs on the release path — before Select release body is reached. The only way past is to create a real docs/source/changelog/1.5.0b5.rst, which is giving the pre-release a dedicated changelog entry, the thing that was explicitly not wanted.

The cheapest honest test is the github job alone, off the release path. The only surface local testing cannot reach is softprops/action-gh-release@v3.0.2 reading body_path and joining generated notes after it — no PyPI upload and no version number required. A workflow_dispatch copy of that job on a branch, pointed at a scratch tag, checked, then tag and release deleted. Filed as a follow-up rather than left here.

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 is_prerelease and is_postrelease (e.g. a hand-written 1.5.0.post1.dev1) would have taken the pre-release wording and suppressed the unreleased warning. util/bump_version.py cannot produce that combination. Recorded here since the code is not landing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci Pull requests that change CI or workflow configuration (ci: subject prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant