Skip to content

ci: feed the generated CHANGELOG.md to the release body, and gate its drift - #529

Merged
JarryShaw merged 8 commits into
mainfrom
feat/changelog-ci
Sep 20, 2026
Merged

JarryShaw merged 8 commits into
mainfrom
feat/changelog-ci

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

Closes the open question on #521 at util/changelog_md.py:2 — "is it wired in our workflow? like Create Release GHA or some other suitable places?" The answer was no. This wires it in.

1. The release body

create-release.yml's release step set generate_release_notes: true and no body at all, so the hand-written changelog never reached a reader of a GitHub Release. It now passes body_path, and generate_release_notes deliberately stays on.

The ordering claim was verified against the pinned version rather than assumed. softprops/action-gh-release@v3.0.2 does not rely on the REST API's own prepend: prepareReleaseMutation (src/github.ts:185-213) fetches the notes itself, forces generate_release_notes = false before the real call, and concatenates client-side as `${releaseParams.body}\n\n${releaseNotes.data.body}` — body first. v3.0.2 is byte-identical to v3.0.3 for that function and for releaseBody(). Separately, the REST docs for create say the supplied body "will be pre-pended to the automatically generated notes" (api.github.com.json:74377); note the flag is absent from the update schema entirely, which is why the action reimplements it. The generated file is ~17 KB against the action's 124,999-character ceiling, so nothing truncates.

2. The post-release decision — it is not only post-releases

The brief asked what happens on a 1.3.5.post44. Working it through, the mismatch is wider than post-releases and live today:

CHANGELOG.md holds the newest toctree entry, i.e. the version being prepared, while the release publishes pcapkit.__version__. util/bump_version.py bumps without ever adding an entry — it turns the current 1.5.0b3 into 1.5.0b4, and a final release into .post1. So:

release CHANGELOG.md heading correct body?
1.5.0 (final) 1.5.0 yes — the entry
1.5.0b4 (pre-release) 1.5.0 no — and this is the next release
1.5.0.post1 / 1.3.5.post44 1.5.0 no — republishes a base version's notes under a release that changed no library code

An unconditional body_path would therefore be wrong on the very next automated release, not merely on some hypothetical post-release. So a Select release body step uses the file only when its heading names the version being released, and otherwise leaves the body unset so the release keeps the generated notes alone — which for a vendor refresh is the honest summary, since the commit list is the only thing that changed. Both outcomes emit a ::notice; neither is silent.

Only the version token is compared, so an entry still reading -- unreleased still matches. That case gets its own ::warning instead of blocking a release, because the entry is dated by hand at release time and a cosmetic slip should not stop a publish — it should be loud. (Flagging this as a judgement call; drop it if you'd rather it stayed silent.)

Leaving the body unset is safe by inspection of the pinned action, not by hope: body_path is guarded by a plain truthy check, '' is falsy, and the only reader of that input in the whole src/ tree is releaseBody() in src/util.ts:57-68 — there is no existsSync elsewhere to trip on an empty string. prepareReleaseMutation then takes its else branch and the release still gets the generated notes.

The two traps, checked and left alone. The other two action-gh-release steps (pypi, now line 308; conda, now line 459) carry neither body nor body_path and are untouched — asserted mechanically, see below. The four publishing jobs' startsWith(github.ref_name, 'v') || PCAPKIT_TAG_EXISTS == 'false' gating is byte-identical to the base.

3. The drift gate, and an honest correction to #521's description

A changelog job in unit-tests.yml runs util/changelog_md.py --check.

unit-tests.yml is the right home because it is the only workflow create-release.yml depends on. Placed there and deliberately ungated by gate-only, the check runs on the release path, so a release body cannot be built from a drifted file — that is the whole point, and it is why this job does not copy the if: inputs.gate-only != true the matrix jobs use. deploy-pages.yml also runs on PRs but cancels superseded runs and is not on the release path, so a gate there would be both weaker and blind to releases. The job needs no pip install: the generator imports only argparse, difflib, pathlib, re, sys, typing, verified by running it under python -S.

One correction worth making explicitly. #521's description says the drift gate "is a procedure rather than a mechanism". That is slightly overstated — tests/project/test_changelog_md.py::RepositoryStateTests::test_committed_changelog_is_in_step_with_the_newest_entry already asserts the same identity, and already runs in the matrix. So this job's marginal value is narrower than "drift is now gated for the first time", and is threefold: that test skipTests when either file is absent (a silent pass, the exact failure mode to avoid), it reports a mismatch as two 17 KB strings rather than a unified diff, and it does not run early or cheaply. --check cannot skip, exits 1 on a missing file, and prints a diff.

A consequence to be aware of: because the job is ungated, a drifted CHANGELOG.md will now also fail cron-vendor.yml's gate and so block the weekly vendor refresh. I think that is correct — the same argument as the existing test gate — but it is a behaviour change, not a no-op.

Sequencing

Based on feat/changelog (#521) at 807e376, not main, so --base feat/changelog. util/changelog_md.py and CHANGELOG.md do not exist on main, so against main the --check step would fail on a missing file and body_path would point at nothing. This merges after #521 and should not be merged before it.

Basing on the branch is what lets the drift gate be unguarded. The alternative — guarding the step so it no-ops when the file is absent — would have to be loud to be worth anything, and a loud guard for a file that is expected to be missing until #521 lands is just a warning nobody can act on. .github/ is byte-identical between main and feat/changelog, so this diff is purely additive and will not conflict either way. util/changelog_md.py is untouched — it belongs to #521.

Verified

  • actionlint 1.7.7 + shellcheck 0.10.0: finding sets compared as sets with line numbers normalised, base vs branch — 15 both sides, zero added, zero removed. All 15 are pre-existing house patterns (SC2129/SC2086 in Get Version, also present in cron-conda.yml).
  • Both files parse, and the job/step graph asserted programmatically: exactly 3 action-gh-release steps, exactly 1 setting a body, that one in github with generate_release_notes: true; the other two in pypi/conda setting neither; all four gate expressions unchanged; the three pre-existing unit-tests.yml if: expressions unchanged; changelog has no if:.
  • --check exercised locally on this tree: exit 0 in step, exit 1 with a unified diff after a one-character edit, exit 1 when CHANGELOG.md is absent, exit 0 again after restore. Runs clean under python -S.
  • The selector's exact run: block extracted from the YAML and executed against six inputs: 1.5.0 → path=CHANGELOG.md + undated warning; 1.5.0b4, 1.5.0.post1, 1.3.5.post44 → no path + notice; dated heading + 1.5.0 → path, no warning; absent file → warning, no path, exit 0.

Not verified

A workflow change cannot be fully tested without running it. Specifically unverified until a release actually runs: that the rendered release body has the entry above the generated list; that an empty body_path behaves in the live runner as the source says (verified by reading v3.0.2's source, not by executing it); and the changelog job's real timing. The ::notice/::warning strings have not been seen rendered in a live log. A green Create Release with the four publishing jobs skipped is not a release, per that gating — so it would not exercise this path either.

This PR runs no GitHub Actions at all, and that is a direct cost of the sequencing. Every pull_request trigger in this repo is restricted to branches: [main] (unit-tests, deploy-pages, python-compatibility, codeql-analysis), so a PR targeting feat/changelog fires none of them — confirmed, gh run list --branch feat/changelog-ci is empty and the only check is the external pyup.io/safety-ci, which passed. The changelog job therefore cannot prove itself here; it first executes when #521 reaches main with this folded in. Basing on main instead would have bought a live CI run at the price of a --check step that had to be guarded into uselessness, which seemed the worse trade — but it does mean the local evidence above is the only evidence until #521 lands.

The repository had no changelog at all -- release history lived only in git
tags and GitHub Releases.

- `CHANGELOG.rst` at the repository root, 632 lines, covering 0.13.0 through
  a new `1.5.0 -- unreleased` section
- `docs/source/changelog.rst`, a stub that only does
  `.. include:: ../../CHANGELOG.rst`, so no content is duplicated
- `changelog` added to the second toctree in `docs/source/index.rst`
- `pyproject.toml` `[project.urls].changelog` repointed from the Releases page
  to the rendered page

Root rather than `docs/` because `MANIFEST.in` carries both
`global-include *.rst` and `prune docs`: a changelog kept only under `docs/`
would be missing from every sdist, which is where packagers read it. A copy in
both places would drift.

The stub deliberately has no title of its own -- measured, one carrying its own
title renders two `<h1>` elements. `:start-after:` was rejected for silently
dropping the file's intro line. Nitpicky build warning count is unchanged at
1166 with an identical warning set, and the file parses clean under plain
docutils, so the sdist copy survives a PyPI-style render.
Replaces the single root CHANGELOG.rst with the layout the release chain
actually needs: a Markdown CHANGELOG.md at the root holding only the newest
version's entry, and the full history under docs/ as .rst wired into Sphinx.

- docs/source/changelog/<version>.rst: 37 per-version entries, split out of the
  632-line root file. Concatenating the preamble and all 37 in toctree order
  reproduces CHANGELOG.rst byte-for-byte (32,671 bytes, 632 lines).
- docs/source/changelog.rst: title, preamble and a 37-entry toctree, already
  reachable from docs/source/index.rst:33.
- CHANGELOG.md: generated, not maintained. 40 lines of GFM holding the 1.5.0
  entry, for the Create Release body and the sdist surface, both of which read
  Markdown rather than reStructuredText.
- util/changelog_md.py: the generator, plus a --check mode that exits non-zero
  when the committed CHANGELOG.md has drifted from its source entry. "Newest"
  comes from the toctree's first line rather than a version sort, since PEP 440
  ordering and lexical ordering disagree on 1.5.0b3 against 1.5.0 against
  1.10.0. A residual-markup guard of 13 patterns refuses to emit when any
  unconverted RST survives, so nothing malformed can reach a published release.
- MANIFEST.in: include CHANGELOG.md explicitly, since global-include *.rst does
  not match it, and ship the per-version entries.

CHANGELOG.md is Markdown by deliberate exception to the .rst convention: its
consumers are GitHub's release renderer and PyPI, neither of which reads RST.

34 tests, 37 subtests. Sphinx warning set unchanged against main. sdist ships
CHANGELOG.md and all 37 entries, and twine check passes.
The trailer explained which `docs/source/changelog/<version>.rst` the entry came
from. CHANGELOG.md is read on a GitHub release page and on PyPI, where the
reader has no repository in front of them, so that half was noise to its only
two audiences while the link is useful to both.

Regenerated accordingly; `--check` confirms the committed file is in step.
… drift

- The release step set `generate_release_notes: true` with no body, so the
  hand-written changelog reached no reader of a GitHub Release. Add `body_path`;
  the action joins body-then-generated, so the entry leads and the automatic
  pull-request list follows it.
- `CHANGELOG.md` carries the newest toctree entry, which is the version being
  prepared rather than released: `bump_version.py` produces `1.5.0b4` and
  `.postN` without adding an entry, so an unconditional body would republish a
  base version's notes under a vendor refresh that changed no library code. The
  body is used only when the file's heading names the version being released,
  and both outcomes are announced in the log.
- `util/changelog_md.py --check` was wired into nothing, so drift in a published
  file was caught only by eye. Add a `changelog` job to `unit-tests.yml`,
  deliberately ungated by `gate-only` so it also guards the release path that
  reads the file.

Verified: actionlint and shellcheck report the same 15 findings as the base;
`--check` exits 0 in step and 1 both on a one-character edit and on a missing
file; the selector was exercised against final, pre-release, post-release, dated
and absent inputs. Behaviour on a real release is unverified until one runs.
@JarryShaw

Copy link
Copy Markdown
Owner Author

✅ GOOD TO MERGE, one judgement call for you: the body_path/generate_release_notes precedence and the drift-gate reasoning are independently confirmed against action-gh-release's actual source and by executing the release-body script locally against six real cases, but the ungated changelog job in unit-tests.yml now also blocks cron-conda.yml's weekly build-number reset (not just cron-vendor.yml, as the PR text says) whenever CHANGELOG.md drifts — a real, intentional widening of blast radius that's just under-described in the PR body, not a bug.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Detailed review (independent, execution-based falsification)

Reviewed at base origin/feat/changelog, head 99f02ebfe19ea61ab1e603eeee13203e3908e377 — confirmed the real diff is .github/workflows/create-release.yml (+55) and .github/workflows/unit-tests.yml (+41), not PR #521's changeset (diffing against main would have shown that changeset by mistake; diffed against feat/changelog instead).

1. body_path + generate_release_notes: true precedence — confirmed against the action's real source, not just its README. Fetched action.yml and src/github.ts at softprops/action-gh-release@v3.0.2: generate_release_notes's handling appends the auto-generated notes AFTER the supplied body/body_path content (pre-pend semantics), for both createRelease and updateRelease code paths.

2. Other upload steps (pypi, conda) can't clobber the body — confirmed by a stronger mechanism than the PR states. Traced release() in github.ts: since neither the pypi nor conda upload step sets body/body_path, the action explicitly falls back to body = existingReleaseBody — it re-fetches and re-sends the current release body rather than relying on "omitted field means no change." This holds regardless of step ordering.

3. Four publishing jobs' tag-gating logic — confirmed undisturbed. git diff origin/feat/changelog...HEAD -- .github/workflows/create-release.yml is 100% additive (55 insertions, 0 deletions). All four occurrences of startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_TAG_EXISTS == 'false' (jobs github, tag, pypi, conda) are byte-identical to before.

4. Post-release .postN reasoning / drift gate — confirmed by direct execution. The actual Select release body script was extracted and run against six real cases (1.5.0/unreleased heading, 1.5.0b4, 1.5.0.post1, 1.3.5.post44, 1.5.0/dated heading, file absent) — all six matched the PR's claimed behavior, all exit 0 (the step never fails the job). util/changelog_md.py --check was also run directly: exit 0 in sync, exit 1 with a precise unified diff on an induced one-word drift, exit 1 on a fully absent file, confirmed to run under stdlib-only python3 -S (no install step required). The version-bump logic in bump_version.py that the whole .postN argument rests on was confirmed to match (is_postrelease branch increments the post number).

5. actionlint + shellcheck finding-set claim — independently reproduced, not trusted. Fresh actionlint 1.7.7 plus the repo's installed shellcheck 0.10.0 were run against the whole .github/workflows/ tree on both HEAD and origin/feat/changelog: exactly 15 findings both sides, same types at the same relative positions (two shifted by exactly the +55 lines this PR inserted) — zero new findings from this PR's own added code.

6. YAML/job-wiring sanity — confirmed. The new changelog job in unit-tests.yml is a correctly-indented top-level sibling of test/integration/gate, deliberately carrying no if: (by design — test/integration/gate key off inputs.gate-only, changelog doesn't). The Select release body step is correctly nested between checkout and release-creation in the github job, and steps.release_body.outputs.path is correctly referenced downstream. actionlint reported zero structural/expression/schema errors on either file.

7. Widened blast radius — the judgment call. Both cron-vendor.yml AND cron-conda.yml call unit-tests.yml with gate-only: true, so both cron jobs — not just cron-vendor.yml, which is the only one the PR's prose names — are now blocked by changelog drift. deploy-pages.yml also calls the workflow but is separately guarded by if: github.event_name != 'pull_request', consistent with the PR's "not on the release path" framing. This is a real, intentional consequence of the design (no if: on the new job), just incompletely enumerated in the PR body — not a defect.

8. Confirmed no CI has run on this PR at all, consistent with the PR's own "Not verified" section: gh run list for this branch is empty, and none of the four pull_request-triggering workflows target a PR based on feat/changelog (all scope branches: [main]).

What remains unverifiable until this actually runs live

  • The rendered GitHub Release body's real ordering/formatting in a live Actions run (confirmed by source trace and local script execution, never seen against the real GitHub API).
  • ::notice/::warning annotation rendering in a live Actions log.
  • Real job timing in the shared runner pool.
  • action-gh-release's retry/lookup behavior under real replication delay between the github job creating the release and the pypi/conda jobs finding it (pre-existing action behavior this PR doesn't touch, but part of claim 2's mechanism).
  • This entire path cannot execute for real until feat/changelog (docs: add CHANGELOG.rst and render it in the Sphinx docs #521) lands on main — there is no way to trigger it before then, by the repo's own trigger design.

Verdict

GOOD TO MERGE. No logic errors found across any of the reasoning chains checked. The one judgment call — changelog drift now gating cron-conda.yml in addition to cron-vendor.yml — is a real, correctly-implemented behavior the owner should be aware of, not a defect to fix before merge.

Base automatically changed from feat/changelog to main September 20, 2026 05:15
@JarryShaw
JarryShaw merged commit 41998c8 into main Sep 20, 2026
24 checks passed
@JarryShaw
JarryShaw deleted the feat/changelog-ci branch September 20, 2026 05:21
@JarryShaw JarryShaw added the ci Pull requests that change CI or workflow configuration (ci: subject prefix) label Sep 22, 2026
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