From fbcb0a3cb0796e8102af2255d726d283d6b6aa7f Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Sun, 23 Aug 2026 15:17:12 +0000 Subject: [PATCH 1/3] docs: publish the library changelogs Every library has a CHANGELOG.md at its package root, but you can only read it by finding the right folder in the repo. Copy each one into the reference tree during preprocessing, under a per-library heading, and give them a Change logs page in the Reference section. Three heading conventions are in use: a per-version H1, a prose "# Changelog" with H2 versions, and Keep a Changelog with bracketed versions. A leading prose heading is dropped rather than demoted, so those pages don't carry a redundant Changelog section under their own title, and any remaining H1 is demoted so each page has one top-level heading. --- .docs/extensions/diataxis_docs_fallback.py | 8 +- .docs/reference/changelogs.md | 9 ++ .docs/reference/index.md | 1 + .docs/scripts/diataxis_preprocessor.py | 72 ++++++++++++- .docs/tests/test_diataxis_docs_fallback.py | 18 ++++ .docs/tests/test_diataxis_preprocessor.py | 116 +++++++++++++++++++++ docs.just | 2 + 7 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 .docs/reference/changelogs.md diff --git a/.docs/extensions/diataxis_docs_fallback.py b/.docs/extensions/diataxis_docs_fallback.py index 3665c380e..93e17c45a 100644 --- a/.docs/extensions/diataxis_docs_fallback.py +++ b/.docs/extensions/diataxis_docs_fallback.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Sphinx fallback extension for per-library diataxis docs. +"""Sphinx fallback extension for per-library diataxis docs and change logs. The ``diataxis_preprocessor.py`` script (run by ``just docs``) copies library docs into the Sphinx source tree and generates ``_lib-*.md`` include files. @@ -44,3 +44,9 @@ def _fallback(app: sphinx.application.Sphinx) -> None: path = docs_dir / category / f'_lib-{category}.md' if not path.exists(): path.write_text('') + # Change logs live under reference/ rather than in their own top-level + # section, so the include file goes there too. + changelog_include = docs_dir / 'reference' / '_lib-changelog.md' + if not changelog_include.exists(): + changelog_include.parent.mkdir(parents=True, exist_ok=True) + changelog_include.write_text('') diff --git a/.docs/reference/changelogs.md b/.docs/reference/changelogs.md new file mode 100644 index 000000000..5e746fea7 --- /dev/null +++ b/.docs/reference/changelogs.md @@ -0,0 +1,9 @@ +(reference-changelogs)= +# Change logs + +Every library in the [charmlibs monorepo](https://github.com/canonical/charmlibs) keeps its own change log next to its source in the repository. This page collects those change logs so that they are discoverable from the docs site without having to browse the repository. + +Each library is versioned independently; follow the link for the library you're using to see its release history. + +```{include} _lib-changelog.md +``` diff --git a/.docs/reference/index.md b/.docs/reference/index.md index 05ab62f59..443803a8b 100644 --- a/.docs/reference/index.md +++ b/.docs/reference/index.md @@ -8,4 +8,5 @@ interface-libs charmlibs charmlibs-interfaces interfaces +changelogs ``` diff --git a/.docs/scripts/diataxis_preprocessor.py b/.docs/scripts/diataxis_preprocessor.py index 2ca09d6aa..b224c4dd3 100755 --- a/.docs/scripts/diataxis_preprocessor.py +++ b/.docs/scripts/diataxis_preprocessor.py @@ -18,13 +18,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Copy per-library diataxis docs into the Sphinx source tree. +"""Copy per-library diataxis docs and changelogs into the Sphinx source tree. Walks every package returned by ``.scripts/ls.py``, looks for a ``docs/`` directory, and copies tutorial, how-to, and explanation pages into the -corresponding Sphinx source directories. Generates ``_lib-*.md`` include -files containing toctree entries that the index pages pull in via -``{include}``. +corresponding Sphinx source directories. Also copies each package's +``CHANGELOG.md`` into ``reference/charmlibs/{pkg}/`` so the change logs +are discoverable from the docs site rather than only from the repo tree. +Generates ``_lib-*.md`` include files containing toctree entries that +the index pages pull in via ``{include}``. Run from ``just docs``; see ``docs.just`` for the invocation. @@ -82,16 +84,32 @@ def _main() -> None: sources = [_REPO_ROOT / lib_path / f for f in doc_files] entries = _copy_category(sources, lib_path, category, sphinx_map) all_entries.setdefault(category, []).extend(entries) + # Collect changelog entries alongside the diataxis categories. + changelog_entries = _copy_changelogs(packages, sphinx_map) + if changelog_entries: + all_entries['changelog'] = changelog_entries # Write include files with toctree entries for each category. for category, entries in all_entries.items(): if not entries: continue - path = _DOCS_DIR / category / f'_lib-{category}.md' + path = _include_path_for(category) content = _TOCTREE_HEADER + '\n'.join(sorted(entries)) + '\n' + _TOCTREE_FOOTER path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content) +def _include_path_for(category: str) -> pathlib.Path: + """Return where the ``_lib-{category}.md`` include file lives. + + Changelog includes live under ``reference/`` so they render alongside + the other reference pages. Diataxis categories keep their own top-level + directory. + """ + if category == 'changelog': + return _DOCS_DIR / 'reference' / '_lib-changelog.md' + return _DOCS_DIR / category / f'_lib-{category}.md' + + def _copy_category( sources: list[pathlib.Path], lib_path: pathlib.PurePath, @@ -114,6 +132,45 @@ def _copy_category( return entries +def _copy_changelogs( + packages: list[dict[str, Any]], sphinx_map: dict[pathlib.PurePath, str] +) -> list[str]: + """Copy each package's ``CHANGELOG.md`` into the reference tree. + + Each package's change log ends up at + ``reference/charmlibs/{pkg}/CHANGELOG.md``, with a synthetic + ``# {pkg}: Changelog`` H1 prepended so the page has a single, + library-scoped top-level heading. The existing per-version H1s + (``# 1.2.3 - 4 May 2026``) are demoted to H2 so they appear as + subsections. Relative links (e.g. to package sources) are rewritten + with :func:`_rewrite_links` so they resolve on the published site. + Packages without a ``CHANGELOG.md`` are skipped. + """ + entries: list[str] = [] + for pkg in packages: + lib_path = pathlib.PurePath(pkg['path']) + source = _REPO_ROOT / lib_path / 'CHANGELOG.md' + if not source.is_file(): + continue + raw = source.read_text() + # Two conventions are in use. Most packages go straight to a per-version + # H1 (`# 1.2.3 - 4 May 2026`); the interfaces packages open with a prose + # `# Changelog` and use H2 for versions. Drop a leading prose heading + # rather than demoting it, so the page doesn't end up with a redundant + # `Changelog` section directly under its own title. + body = re.sub(r'\A\s*#\s+(?!\d)[^\n]*\n+', '', raw) + # Demote any remaining H1s (per-version headings) to H2 so the injected + # library heading is the sole H1 for the page. + demoted = re.sub(r'^# ', '## ', body, flags=re.MULTILINE) + content = f'# {lib_path.name}: Changelog\n\n{demoted}' + content = _rewrite_links(content, source, sphinx_map) + out_dir = _DOCS_DIR / 'reference' / 'charmlibs' / lib_path + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / 'CHANGELOG.md').write_text(content) + entries.append(f'{lib_path.name} ') + return entries + + def _extract_h1(content: str, source: pathlib.Path) -> str: """Extract the first H1 heading text from content.""" if source.suffix == '.md': @@ -173,6 +230,11 @@ def _build_sphinx_map(packages: list[dict[str, Any]]) -> dict[pathlib.PurePath, m[readme.relative_to(_REPO_ROOT)] = ( f'/reference/interfaces/{lib_path.name}/{readme.parent.name}' ) + # Per-package change log — rewrite links from anywhere in the tree + # to the docs-site copy. + changelog = _REPO_ROOT / lib_path / 'CHANGELOG.md' + if changelog.is_file(): + m[changelog.relative_to(_REPO_ROOT)] = f'/reference/charmlibs/{lib_path}/CHANGELOG' return m diff --git a/.docs/tests/test_diataxis_docs_fallback.py b/.docs/tests/test_diataxis_docs_fallback.py index a01e6ab58..dc9ae1d2a 100644 --- a/.docs/tests/test_diataxis_docs_fallback.py +++ b/.docs/tests/test_diataxis_docs_fallback.py @@ -40,6 +40,7 @@ def test_fallback_writes_empty_include_when_missing(tmp_path: pathlib.Path): """Fallback writes empty include files when preprocessor hasn't run.""" for category in CATEGORIES: (tmp_path / category).mkdir() + (tmp_path / 'reference').mkdir() fallback._fallback(_app(tmp_path)) @@ -47,6 +48,9 @@ def test_fallback_writes_empty_include_when_missing(tmp_path: pathlib.Path): path = tmp_path / category / f'_lib-{category}.md' assert path.exists() assert path.read_text() == '' + changelog = tmp_path / 'reference' / '_lib-changelog.md' + assert changelog.exists() + assert changelog.read_text() == '' def test_fallback_skips_when_include_exists(tmp_path: pathlib.Path): @@ -54,9 +58,23 @@ def test_fallback_skips_when_include_exists(tmp_path: pathlib.Path): for category in CATEGORIES: (tmp_path / category).mkdir() (tmp_path / category / f'_lib-{category}.md').write_text('existing') + (tmp_path / 'reference').mkdir() + (tmp_path / 'reference' / '_lib-changelog.md').write_text('existing-changelog') fallback._fallback(_app(tmp_path)) for category in CATEGORIES: path = tmp_path / category / f'_lib-{category}.md' assert path.read_text() == 'existing' + assert (tmp_path / 'reference' / '_lib-changelog.md').read_text() == 'existing-changelog' + + +def test_fallback_creates_reference_dir(tmp_path: pathlib.Path): + """Fallback creates the reference directory if it doesn't already exist.""" + for category in CATEGORIES: + (tmp_path / category).mkdir() + # No reference/ directory yet. + + fallback._fallback(_app(tmp_path)) + + assert (tmp_path / 'reference' / '_lib-changelog.md').exists() diff --git a/.docs/tests/test_diataxis_preprocessor.py b/.docs/tests/test_diataxis_preprocessor.py index 453029107..56f87470a 100644 --- a/.docs/tests/test_diataxis_preprocessor.py +++ b/.docs/tests/test_diataxis_preprocessor.py @@ -249,3 +249,119 @@ def test_copy_category_empty_sources(tmp_path: pathlib.Path, monkeypatch: pytest monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir) entries = pp._copy_category([], pathlib.PurePath('mylib'), 'how-to', {}) assert entries == [] + + +# --- _include_path_for --- + + +def test_include_path_for_diataxis(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): + monkeypatch.setattr(pp, '_DOCS_DIR', tmp_path) + assert pp._include_path_for('how-to') == tmp_path / 'how-to' / '_lib-how-to.md' + + +def test_include_path_for_changelog(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): + monkeypatch.setattr(pp, '_DOCS_DIR', tmp_path) + assert pp._include_path_for('changelog') == tmp_path / 'reference' / '_lib-changelog.md' + + +# --- _copy_changelogs --- + + +def test_copy_changelogs_generates_page(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): + docs_dir = tmp_path / 'docs_site' + (tmp_path / 'mylib').mkdir() + (tmp_path / 'mylib' / 'CHANGELOG.md').write_text( + '# 1.0.0 - 1 May 2026\n\nFirst release.\n\n# 0.1.0 - 1 Apr 2026\n\nBeta.\n' + ) + monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path) + monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir) + entries = pp._copy_changelogs([{'path': 'mylib', 'docs': {}}], {}) + out = docs_dir / 'reference' / 'charmlibs' / 'mylib' / 'CHANGELOG.md' + assert out.exists() + text = out.read_text() + assert text.startswith('# mylib: Changelog\n\n') + # Existing per-version H1s are demoted to H2 so the injected heading is + # the only H1 on the page. + assert '\n## 1.0.0 - 1 May 2026\n' in text + assert '\n## 0.1.0 - 1 Apr 2026\n' in text + assert '\n# 1.0.0' not in text + assert entries == ['mylib '] + + +def test_copy_changelogs_drops_leading_prose_heading( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + # The interfaces packages open with a prose '# Changelog' and use H2 for + # versions, rather than going straight to a per-version H1. + docs_dir = tmp_path / 'docs_site' + (tmp_path / 'mylib').mkdir() + (tmp_path / 'mylib' / 'CHANGELOG.md').write_text( + '# Changelog\n\n## 1.0.0\n\nInitial release.\n' + ) + monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path) + monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir) + pp._copy_changelogs([{'path': 'mylib', 'docs': {}}], {}) + text = (docs_dir / 'reference' / 'charmlibs' / 'mylib' / 'CHANGELOG.md').read_text() + assert text.startswith('# mylib: Changelog\n\n') + # The prose heading is dropped rather than demoted, so the page doesn't + # carry a redundant 'Changelog' section under its own title. + assert '## Changelog' not in text + assert '\n## 1.0.0\n' in text + + +def test_copy_changelogs_keeps_version_heading_that_starts_with_a_digit( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + docs_dir = tmp_path / 'docs_site' + (tmp_path / 'mylib').mkdir() + (tmp_path / 'mylib' / 'CHANGELOG.md').write_text('# 1.0.0 - 1 May 2026\n\nFirst.\n') + monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path) + monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir) + pp._copy_changelogs([{'path': 'mylib', 'docs': {}}], {}) + text = (docs_dir / 'reference' / 'charmlibs' / 'mylib' / 'CHANGELOG.md').read_text() + assert '\n## 1.0.0 - 1 May 2026\n' in text + assert 'First.' in text + + +def test_copy_changelogs_skips_missing(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): + docs_dir = tmp_path / 'docs_site' + (tmp_path / 'has_log').mkdir() + (tmp_path / 'has_log' / 'CHANGELOG.md').write_text('# 1.0.0\n\nRelease.\n') + (tmp_path / 'no_log').mkdir() + monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path) + monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir) + entries = pp._copy_changelogs( + [{'path': 'has_log', 'docs': {}}, {'path': 'no_log', 'docs': {}}], {} + ) + assert entries == ['has_log '] + assert (docs_dir / 'reference' / 'charmlibs' / 'has_log' / 'CHANGELOG.md').exists() + assert not (docs_dir / 'reference' / 'charmlibs' / 'no_log').exists() + + +def test_copy_changelogs_rewrites_links(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): + docs_dir = tmp_path / 'docs_site' + (tmp_path / 'mylib').mkdir() + (tmp_path / 'mylib' / 'CHANGELOG.md').write_text( + '# 1.0.0 - 1 May 2026\n\nSee [README](README.md) for details.\n' + ) + (tmp_path / 'mylib' / 'README.md').touch() + monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path) + monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir) + pp._copy_changelogs([{'path': 'mylib', 'docs': {}}], {}) + out = docs_dir / 'reference' / 'charmlibs' / 'mylib' / 'CHANGELOG.md' + # Unknown-in-map links become absolute GitHub URLs. + assert 'https://github.com/canonical/charmlibs/blob/main/mylib/README.md' in out.read_text() + + +# --- _build_sphinx_map (changelog) --- + + +def test_build_sphinx_map_changelog(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): + """A package's ``CHANGELOG.md`` maps to its docs-site copy.""" + monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path) + monkeypatch.setattr(pp, '_DOCS_DIR', tmp_path / '.docs') + (tmp_path / '.docs').mkdir() + (tmp_path / 'mylib').mkdir() + (tmp_path / 'mylib' / 'CHANGELOG.md').touch() + m = pp._build_sphinx_map([{'path': 'mylib', 'docs': {}}]) + assert m[pathlib.PurePath('mylib/CHANGELOG.md')] == '/reference/charmlibs/mylib/CHANGELOG' diff --git a/docs.just b/docs.just index d818c5b61..cf9b46ba3 100644 --- a/docs.just +++ b/docs.just @@ -121,6 +121,8 @@ clean: rm -f tutorials/_lib-tutorials.md rm -f how-to/_lib-how-to.md rm -f explanation/_lib-explanation.md + # per-library changelog include (per-package copies live under reference/charmlibs, cleaned above) + rm -f reference/_lib-changelog.md [doc('Run `pyright` for local sphinx extensions.')] ext-static *pyrightargs: From 793ec1596bbb3502620cac4276e9adfe8a901ca2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 31 Aug 2026 20:41:42 +1200 Subject: [PATCH 2/3] Apply suggestion from @tonyandrewmeyer --- docs.just | 1 - 1 file changed, 1 deletion(-) diff --git a/docs.just b/docs.just index cf9b46ba3..55a400dd8 100644 --- a/docs.just +++ b/docs.just @@ -121,7 +121,6 @@ clean: rm -f tutorials/_lib-tutorials.md rm -f how-to/_lib-how-to.md rm -f explanation/_lib-explanation.md - # per-library changelog include (per-package copies live under reference/charmlibs, cleaned above) rm -f reference/_lib-changelog.md [doc('Run `pyright` for local sphinx extensions.')] From a9d2275cd6f6d1e75ee4bc749bd7c38c84a75a7e Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 16 Sep 2026 14:35:39 +1200 Subject: [PATCH 3/3] fix(docs): don't delete a changelog's leading heading unless it says "Changelog" The heuristic dropped a leading H1 whose text doesn't start with a digit, to get rid of the prose `# Changelog` the interfaces packages open with. That also deletes `# Unreleased`, which is how `nginx_k8s/CHANGELOG.md` opens: its entries ended up directly under the page title, reading as page intro, immediately above a released version they have nothing to do with. A released `# v1.2.3 - 4 May 2026` would go the same way, which is the worse version of the same bug. The prose heading is now matched by name. Every changelog test built its own fixture, so the suite never saw the files the code exists to process, and the fourth convention had been sitting in the repo the whole time with 63 tests passing. The real files are now parametrised over: the injected heading must be the only H1, and every heading in the source has to survive at one level deeper. That test fails on `nginx_k8s` without the regex change, and it will catch the fifth convention when someone invents it. Co-Authored-By: Claude Opus 5 (1M context) --- .docs/scripts/diataxis_preprocessor.py | 16 ++++--- .docs/tests/test_diataxis_preprocessor.py | 57 +++++++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/.docs/scripts/diataxis_preprocessor.py b/.docs/scripts/diataxis_preprocessor.py index b224c4dd3..f9b00bd77 100755 --- a/.docs/scripts/diataxis_preprocessor.py +++ b/.docs/scripts/diataxis_preprocessor.py @@ -153,12 +153,16 @@ def _copy_changelogs( if not source.is_file(): continue raw = source.read_text() - # Two conventions are in use. Most packages go straight to a per-version - # H1 (`# 1.2.3 - 4 May 2026`); the interfaces packages open with a prose - # `# Changelog` and use H2 for versions. Drop a leading prose heading - # rather than demoting it, so the page doesn't end up with a redundant - # `Changelog` section directly under its own title. - body = re.sub(r'\A\s*#\s+(?!\d)[^\n]*\n+', '', raw) + # Three conventions are in use. Most packages go straight to a + # per-version H1 (`# 1.2.3 - 4 May 2026`), the interfaces packages open + # with a prose `# Changelog` and use H2 for versions, and nginx_k8s + # opens with `# Unreleased`. Drop a leading `# Changelog` rather than + # demoting it, so the page doesn't end up with a redundant `Changelog` + # section directly under its own title -- but match that heading by + # name: "an H1 that doesn't start with a digit" also deletes + # `# Unreleased`, orphaning its entries under the page title, and would + # delete a released `# v1.2.3` too. + body = re.sub(r'\A\s*#\s+Changelog\s*\n+', '', raw, flags=re.IGNORECASE) # Demote any remaining H1s (per-version headings) to H2 so the injected # library heading is the sole H1 for the page. demoted = re.sub(r'^# ', '## ', body, flags=re.MULTILINE) diff --git a/.docs/tests/test_diataxis_preprocessor.py b/.docs/tests/test_diataxis_preprocessor.py index 56f87470a..4a98acf54 100644 --- a/.docs/tests/test_diataxis_preprocessor.py +++ b/.docs/tests/test_diataxis_preprocessor.py @@ -365,3 +365,60 @@ def test_build_sphinx_map_changelog(tmp_path: pathlib.Path, monkeypatch: pytest. (tmp_path / 'mylib' / 'CHANGELOG.md').touch() m = pp._build_sphinx_map([{'path': 'mylib', 'docs': {}}]) assert m[pathlib.PurePath('mylib/CHANGELOG.md')] == '/reference/charmlibs/mylib/CHANGELOG' + + +# --- _copy_changelogs against the repo's real change logs --- + + +def _real_changelogs() -> list[pathlib.Path]: + """Every CHANGELOG.md `ls.py packages` would publish. + + `_packages` globs `[a-z]*` from the repo root and from `interfaces/`, so + dot-directories (`.package`, `.template`, ...) are not packages. + """ + root = pathlib.Path(pp._REPO_ROOT) + found = [*root.glob('[a-z]*/CHANGELOG.md'), *root.glob('interfaces/[a-z]*/CHANGELOG.md')] + return sorted(p for p in found if p.is_file()) + + +def test_there_are_real_changelogs_to_check(): + """Guard the guard: a glob that silently matched nothing would pass everything.""" + assert len(_real_changelogs()) > 10 + + +@pytest.mark.parametrize('changelog', _real_changelogs(), ids=lambda p: p.parent.name) +def test_copy_changelogs_keeps_every_heading_in_a_real_changelog( + changelog: pathlib.Path, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """The synthetic fixtures above only cover the conventions we thought of. + + `nginx_k8s` opens with `# Unreleased` rather than a version or a prose + `# Changelog`, and a heuristic written as "an H1 that doesn't start with a + digit" deleted that heading, orphaning its entries under the page title. + Running the real files is what catches the convention nobody wrote a + fixture for -- including the next one. + """ + lib = changelog.parent.relative_to(pp._REPO_ROOT) + docs_dir = tmp_path / 'docs_site' + pkg_dir = tmp_path / lib + pkg_dir.mkdir(parents=True) + (pkg_dir / 'CHANGELOG.md').write_text(changelog.read_text(), encoding='utf-8') + monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path) + monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir) + + pp._copy_changelogs([{'path': str(lib), 'docs': {}}], {}) + + text = (docs_dir / 'reference' / 'charmlibs' / lib / 'CHANGELOG.md').read_text() + h1s = [line for line in text.splitlines() if line.startswith('# ')] + assert h1s == [f'# {lib.name}: Changelog'], 'the injected heading must be the only H1' + # Every heading in the source survives, at one level deeper. The prose + # `# Changelog` heading is the one exception: it is dropped deliberately, + # because the injected heading says the same thing. + published = {line.lstrip('#').strip() for line in text.splitlines() if line.startswith('#')} + for line in changelog.read_text().splitlines(): + if not line.startswith(('# ', '## ')): + continue + heading = line.lstrip('#').strip() + if heading.lower() == 'changelog': + continue + assert heading in published, f'{heading!r} was dropped from {lib}'