Skip to content

docs: add CHANGELOG.rst and render it in the Sphinx docs - #521

Merged
JarryShaw merged 6 commits into
mainfrom
feat/changelog
Sep 20, 2026
Merged

JarryShaw merged 6 commits into
mainfrom
feat/changelog

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Replaces the single root CHANGELOG.rst with the layout the release chain actually needs.

This description was rewritten. The PR originally proposed the opposite shape — root CHANGELOG.rst as the single source with a .. include:: stub under docs/ — which the repo owner replaced with the design below. The branch now implements the new design; the original description no longer described what it contained.

The layout

path role
CHANGELOG.md generated, not maintained. GFM, newest version's entry only, 40 lines. Feeds the Create Release body and the sdist surface.
docs/source/changelog/<version>.rst 37 per-version entries — the authored source of truth
docs/source/changelog.rst title, preamble, and a 37-entry toctree
util/changelog_md.py the generator, with a --check drift gate
CHANGELOG.rst removed

CHANGELOG.md is Markdown by deliberate exception to this repo's .rst convention. Its consumers are GitHub's release renderer and PyPI; neither reads reStructuredText.

Nothing is maintained twice

The point of generating rather than promoting: there is no hand-off step to forget, and the root file cannot drift from the history, because one is derived from the other. That mirrors what pcapkit/const/** already gets from pcapkit/vendor/**.

At release time the work is: date the newest entry (or add a new one), put its line at the top of the toctree, and run the generator. --check exits non-zero with a diff if CHANGELOG.md is not what its source entry produces.

"Newest" comes from the toctree's first line, not a version sort. Passing the version by hand would reinstate exactly the hand-off this design removes, and lexical ordering disagrees with PEP 440 on 1.5.0b3 against 1.5.0 against 1.10.0. The toctree has to be newest-first anyway for the rendered history to read correctly, so it is already the authority.

Verification

Content identity. Concatenating the preamble with all 37 entries in toctree order reproduces CHANGELOG.rst@b117b976b byte-for-byte: 32,671 bytes, 632 lines. Independently reproduced three times — by the split, by a cross-review, and by me.

The generator. Byte-identical output at 17,849 bytes. --check exits 0 in step and 1 with a unified diff after a one-character edit. 34 tests, 37 subtests.

Sphinx. Warning set unchanged against main, compared as sets after normalising line numbers rather than by raw count. Every entry page renders one h1 and no h2; changelog.html is a 37-link index. All 37 parse clean under plain docutils at the most sensitive report level.

sdist. twine check PASSED. CHANGELOG.md and all 37 entries ship. docs/source/changelog.rst deliberately does not — a bare toctree would not render standalone, which is the class of problem that blocked 1.5.0b1 from PyPI. The controls prove the MANIFEST.in line is load-bearing: CODE_OF_CONDUCT.md and CONTRIBUTING.md are both absent from the tarball, because global-include *.rst does not match *.md.

The residual-markup guard

A generator that copies what it cannot convert would put literal RST into a published release body. So render() refuses to emit when anything unconverted survives — 13 patterns, each proven to be the sole guard for its construct with uncovered: none.

Eight were added after review found them slipping through silently: inline and anonymous hyperlink references, substitution references, field lists, line blocks, grid tables, simple tables, and a setext underline absorbed into prose by the unwrap rule. That last one exposed a real bug: the heading rule fires only when the underline matches the title's length exactly, but RST only requires it to be no shorter, so a longer one fell straight through.

The docstring enumerates the guarded set item by item and names the two cases it deliberately does not catch, rather than claiming "unconverted RST" generally.

Known limitations

  • Footnote syntax renders on GitHub but not on PyPI. Rule 5 emits [^n], which cmark-gfm — what PyPI uses — prints literally, since footnotes are not in its extension set. Not live: the 1.5.0 entry has no footnotes. But the first release entry that carries one would ship raw marker text to PyPI. Flagged for a decision rather than silently accepted.
  • Conversion runs per source line before unwrapping, so an inline construct split across a line break would be missed. All 37 entries checked; zero occurrences. Latent, with the residual guard as the loud backstop.
  • The generator and --check are not yet wired into the Makefile or CI, so the drift gate is a procedure rather than a mechanism. make changelog / changelog-check targets and a one-line CI step are drafted but belong with the workflow change.

Two things genuinely lost

There is no longer a single scrollable page of the whole history — it is an index plus 37 pages. And GitHub's repo view shows CHANGELOG.md (latest only) rather than the full file, though all 37 entries render individually there. The generated file's trailer links to the rendered history, so a reader of the root file always has a path to the rest.

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.
Comment thread util/changelog_md.py
Comment thread util/changelog_md.py
@JarryShaw

Copy link
Copy Markdown
Owner Author

✅ GOOD TO MERGE — combined branch reviewed at the seam; the 37-entry split independently reconstructs CHANGELOG.rst byte-identically (32,671 bytes, sha256 match), Sphinx warning sets identical to main, and the two formerly-skipped tests proved to genuinely execute.

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.
@JarryShaw

Copy link
Copy Markdown
Owner Author

✅ GOOD TO MERGE — re-reviewed the delta since the last verdict (3e5fc53cf..807e37607 is mostly absorbed main merges; the actual new work is the single commit 807e37607, exactly the two files described), util/changelog_md.py --check passes clean against the shortened trailer, and tests/project/test_changelog_md.py is fully green (34 passed / 37 subtests, exit 0).

@JarryShaw

Copy link
Copy Markdown
Owner Author

Re-review of the delta (previous verdict covered 3e5fc53cf; this covers 807e37607)

Scope correction worth noting: git diff 3e5fc53cf..807e37607 is not a small two-file diff — it's 13 files / 814 insertions, because the branch absorbed three Merge branch 'main' into feat/changelog commits in between (pulling in #523, #520, #511/#507, and #510). git log --oneline 3e5fc53cf..807e37607 shows the actual new work is exactly one commit at the tip: 807e37607 docs: shorten the generated changelog trailer to just the history link. Isolated that commit with git show 807e37607 — it is indeed exactly the two files described: CHANGELOG.md (2 lines) and util/changelog_md.py (11 lines, mostly a docstring explaining the "why").

What changed: the generated TRAILER constant went from a two-sentence explanation of where each changelog entry lives in the repo, to a single line: Full changelog: <https://jarryshaw.github.io/PyPCAPKit/changelog.html>. Rationale given in the commit message (CHANGELOG.md's only two real audiences — a GitHub release page and PyPI — have no repository in front of them, so the "which .rst file" explanation was noise) is sound.

Verified independently, in an isolated worktree at 807e37607:

  • pcapkit.__file__ confirmed resolving inside that worktree before trusting anything.
  • python util/changelog_md.py --check → CHANGELOG.md is in step with docs/source/changelog/1.5.0.rst, exit 0.
  • pytest tests/project/test_changelog_md.py -q → 34 passed, 37 subtests passed, exit 0.
  • grep -rn "Only the version being released" across the whole tree → zero hits. No other file (docs, README, other tests) still expects or quotes the old trailer wording.

Verdict

No regressions found in the delta. Recommend merge.

@JarryShaw
JarryShaw merged commit e9e2336 into main Sep 20, 2026
24 checks passed
@JarryShaw
JarryShaw deleted the feat/changelog branch September 20, 2026 05:15
@JarryShaw
JarryShaw restored the feat/changelog branch September 20, 2026 05:15
@JarryShaw
JarryShaw deleted the feat/changelog branch September 20, 2026 05:21
@JarryShaw JarryShaw added the docs Pull requests that change documentation only (docs: 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

docs Pull requests that change documentation only (docs: subject prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant