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..f9b00bd77 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,49 @@ 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() + # 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) + 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 +234,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..4a98acf54 100644 --- a/.docs/tests/test_diataxis_preprocessor.py +++ b/.docs/tests/test_diataxis_preprocessor.py @@ -249,3 +249,176 @@ 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' + + +# --- _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}' diff --git a/docs.just b/docs.just index d818c5b61..55a400dd8 100644 --- a/docs.just +++ b/docs.just @@ -121,6 +121,7 @@ clean: rm -f tutorials/_lib-tutorials.md rm -f how-to/_lib-how-to.md rm -f explanation/_lib-explanation.md + rm -f reference/_lib-changelog.md [doc('Run `pyright` for local sphinx extensions.')] ext-static *pyrightargs: