From 5f73fec1467466238faecb0e1240f37c1e0c7a5f Mon Sep 17 00:00:00 2001 From: Rand McKinney Date: Mon, 19 Oct 2026 10:27:52 -0700 Subject: [PATCH] Add doc versioning --- .github/workflows/ci.yml | 30 ++++ .github/workflows/publish-docs.yml | 15 +- .gitignore | 1 + README.md | 42 +++++- api-docs/versions.json | 6 + docs/doxygen-extra.css | 5 + docs/version-selector.js | 30 ++++ scripts/generate_api_docs.sh | 3 +- scripts/generate_versioned_api_docs.py | 151 ++++++++++++++++++++ tests/test_version_selector.js | 53 +++++++ tests/test_versioned_docs.py | 188 +++++++++++++++++++++++++ 11 files changed, 516 insertions(+), 8 deletions(-) create mode 100644 api-docs/versions.json create mode 100644 docs/version-selector.js create mode 100644 scripts/generate_versioned_api_docs.py create mode 100644 tests/test_version_selector.js create mode 100644 tests/test_versioned_docs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae96dcf..f9b97b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,36 @@ on: - main jobs: + docs: + name: Versioned API documentation + permissions: + contents: read + if: | + github.event_name != 'pull_request' || + github.event.pull_request.author_association == 'COLLABORATOR' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.user.login == 'dependabot[bot]' || + contains(github.event.pull_request.labels.*.name, 'safe to test') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + filter: tree:0 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install Doxygen + run: sudo apt-get install doxygen + - name: Test documentation tooling + run: | + python3 -m unittest discover -s tests -p 'test_versioned_docs.py' + node --test tests/test_version_selector.js + - name: Build versioned documentation + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/generate_versioned_api_docs.py + tests: name: Unit tests diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 33d821f..735d9f4 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -1,6 +1,9 @@ name: Build and publish docs on: + release: + types: [published] + push: branches: ['main'] @@ -26,6 +29,7 @@ jobs: with: filter: tree:0 fetch-depth: 0 + ref: main - name: Setup Pages uses: actions/configure-pages@v5 @@ -33,13 +37,20 @@ jobs: - name: Install Doxygen run: sudo apt-get install doxygen + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Build docs - run: bash ./scripts/generate_api_docs.sh + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/generate_versioned_api_docs.py - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: 'api-docs/_build/html/' + path: 'api-docs/_build/versioned/' - name: Deploy to GitHub Pages id: deployment diff --git a/.gitignore b/.gitignore index 0ce856c..beb3401 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ compile_commands.json # Doxygen docs /api-docs/_build +__pycache__/ # Generated files /src/c2pa.cpp diff --git a/README.md b/README.md index d114630..cfa1f95 100644 --- a/README.md +++ b/README.md @@ -175,13 +175,17 @@ ctest --test-dir build/debug --output-on-failure ### Building API documentation -Doxygen automatically builds API documentation on each PR. +The [API documentation](https://contentauth.github.io/c2pa-cpp/) is built with +Doxygen from selected published releases. Use the version selector to switch +releases. The first tag in [api-docs/versions.json](api-docs/versions.json) is +the default at the existing root URL; each release is also available at +`https://contentauth.github.io/c2pa-cpp/versions//`. To generate API docs locally, these are the main files: - Configuration file: `c2pa-cpp/Doxyfile` - Script: `c2pa-cpp/scripts/generate_api_docs.sh` -- Output directory: `docs/_build/html` +- Output directory: `api-docs/_build/html` Install Doxygen if needed: @@ -196,9 +200,39 @@ To generate docs, enter the command: ./scripts/generate_api_docs.sh ``` -Or run `make -C docs`. +Open `api-docs/_build/html/index.html` to see the working-tree documentation. +This local build does not include a version selector. -Open `_build/html/index.html` to see the results. +To build the versioned site, install Python 3.10 or newer and the +[GitHub CLI](https://cli.github.com/), authenticate with `gh auth login` (or +set `GH_TOKEN`), and ensure the selected release tags are available locally +with `git fetch --tags`. Then run: + +```sh +python3 scripts/generate_versioned_api_docs.py +``` + +Open `api-docs/_build/versioned/index.html` to see the default release. +Version-specific pages are under `api-docs/_build/versioned/versions//`. +The build verifies that each configured tag has a published GitHub release and +reads that tag's headers, C sources, and README without running release code. +It uses the current branch's Doxygen configuration and styling. + +To publish documentation for another release, add its tag to +[api-docs/versions.json](api-docs/versions.json), putting the desired default +first. Publishing a release rebuilds the configured versions but does not +automatically add it to this list. Pushes to `main` and manual workflow runs +also rebuild and deploy the site. + +Run the documentation tooling tests with: + +```sh +python3 -m unittest discover -s tests -p 'test_versioned_docs.py' +node --test tests/test_version_selector.js +``` + +The selector tests require Node.js 18 or newer. CI runs these tests and builds +the versioned documentation on pull requests. ## License diff --git a/api-docs/versions.json b/api-docs/versions.json new file mode 100644 index 0000000..5e08527 --- /dev/null +++ b/api-docs/versions.json @@ -0,0 +1,6 @@ +{ + "versions": [ + "v0.26.14", + "v0.26.13" + ] +} diff --git a/docs/doxygen-extra.css b/docs/doxygen-extra.css index 2a8b6c4..9c1907f 100644 --- a/docs/doxygen-extra.css +++ b/docs/doxygen-extra.css @@ -3,3 +3,8 @@ display: none; } +.docs-version-selector { + display: block; + margin: 0.5em 0; + font-size: 14px; +} diff --git a/docs/version-selector.js b/docs/version-selector.js new file mode 100644 index 0000000..865df83 --- /dev/null +++ b/docs/version-selector.js @@ -0,0 +1,30 @@ +(function () { + "use strict"; + + const script = document.currentScript; + const versions = JSON.parse(script.dataset.versions); + const assets = new URL(".", script.src); + const root = new URL(script.dataset.prefix ? "../../" : "./", assets); + const container = document.getElementById("projectalign"); + if (!container) { + throw new Error("Cannot find the Doxygen project title for the version selector."); + } + + const label = document.createElement("label"); + label.className = "docs-version-selector"; + label.textContent = "Version: "; + const select = document.createElement("select"); + select.setAttribute("aria-label", "API documentation version"); + for (const tag of versions) { + const option = document.createElement("option"); + option.value = new URL("versions/" + tag + "/index.html", root).href; + option.textContent = tag; + option.selected = tag === script.dataset.version; + select.appendChild(option); + } + select.addEventListener("change", function () { + window.location.assign(select.value); + }); + label.appendChild(select); + container.appendChild(label); +})(); diff --git a/scripts/generate_api_docs.sh b/scripts/generate_api_docs.sh index 425e59a..40b1572 100755 --- a/scripts/generate_api_docs.sh +++ b/scripts/generate_api_docs.sh @@ -10,9 +10,8 @@ if ! command -v doxygen >/dev/null 2>&1; then fi OUT_DIR="api-docs/_build/html" -rm -rf "api-docs/_build" || true +rm -rf "$OUT_DIR" "api-docs/_build/xml" doxygen Doxyfile | cat echo "Doxygen HTML docs: $OUT_DIR/index.html" - diff --git a/scripts/generate_versioned_api_docs.py b/scripts/generate_versioned_api_docs.py new file mode 100644 index 0000000..05d52e6 --- /dev/null +++ b/scripts/generate_versioned_api_docs.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Build Doxygen documentation from explicitly selected published releases.""" + +import html +import io +import json +from pathlib import Path, PurePosixPath +import re +import shutil +import stat +import subprocess +import tempfile +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +TAG_PATTERN = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?") + + +def load_versions(path: Path) -> list[str]: + config = json.loads(path.read_text(encoding="utf-8")) + versions = config.get("versions") if isinstance(config, dict) else None + if not isinstance(versions, list) or not versions: + raise ValueError("Documentation versions must be a non-empty list.") + for tag in versions: + if not isinstance(tag, str) or not TAG_PATTERN.fullmatch(tag): + raise ValueError("Documentation versions must be release tags such as v0.26.14.") + if len(set(versions)) != len(versions): + raise ValueError("Documentation versions must not contain duplicate tags.") + return versions + + +def verify_release(tag: str) -> None: + result = subprocess.run( + ["gh", "api", f"repos/contentauth/c2pa-cpp/releases/tags/{tag}"], + check=True, stdout=subprocess.PIPE, text=True, + ) + release = json.loads(result.stdout) + if ( + not isinstance(release, dict) + or release.get("tag_name") != tag + or release.get("draft") is not False + ): + raise ValueError(f"{tag} is not a published GitHub release.") + + +def export_sources(root: Path, tag: str, destination: Path) -> None: + # Only export documentation inputs; never execute code from a release. + commit = subprocess.run( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"], + cwd=root, check=True, capture_output=True, text=True, + ).stdout.strip() + archive = subprocess.run( + ["git", "archive", "--format=zip", commit, "include", "src", "README.md"], + cwd=root, check=True, capture_output=True, + ).stdout + with zipfile.ZipFile(io.BytesIO(archive)) as sources: + headers = 0 + for entry in sources.infolist(): + path = PurePosixPath(entry.filename) + if ( + path.is_absolute() + or ".." in path.parts + or any("\\" in part or ":" in part for part in path.parts) + or not path.parts + or path.parts[0] not in ("include", "src", "README.md") + or (path.parts[0] == "README.md" and len(path.parts) != 1) + or stat.S_ISLNK(entry.external_attr >> 16) + ): + raise ValueError(f"Unsafe source archive in release {tag}.") + if entry.is_dir() or ( + path != PurePosixPath("README.md") + and path.suffix not in (".h", ".hpp", ".c") + ): + continue + target = destination.joinpath(*path.parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(sources.read(entry)) + if path.parts[0] == "include" and path.suffix in (".h", ".hpp"): + headers += 1 + if not headers or not (destination / "README.md").is_file(): + raise ValueError(f"Release {tag} is missing API headers or README.md.") + + +def build_html(stage: Path, output: Path, tag: str, versions: list[str], prefix: str) -> None: + header = stage / "header.html" + subprocess.run( + ["doxygen", "-w", "html", str(header), str(stage / "footer.html"), + str(stage / "doxygen.css"), "Doxyfile"], + cwd=stage, check=True, + ) + template = header.read_text(encoding="utf-8") + if "" not in template: + raise ValueError("Doxygen's default HTML header has no closing head element.") + script = ( + '\n' + ) + header.write_text(template.replace("", script + ""), encoding="utf-8") + config = (stage / "Doxyfile").read_text(encoding="utf-8") + config += ( + f'\nOUTPUT_DIRECTORY = "{output.as_posix()}"\n' + f'PROJECT_NUMBER = "{tag}"\n' + 'HTML_HEADER = "header.html"\n' + 'HTML_EXTRA_FILES = "docs/version-selector.js"\n' + 'GENERATE_XML = NO\n' + ) + subprocess.run(["doxygen", "-"], input=config, text=True, cwd=stage, check=True) + if not (output / "html/index.html").is_file(): + raise RuntimeError(f"Doxygen did not generate an index page for {tag}.") + + +def build_site() -> Path: + versions = load_versions(ROOT / "api-docs/versions.json") + for tag in versions: + verify_release(tag) + + build = ROOT / "api-docs/_build" + build.mkdir(parents=True, exist_ok=True) + output = build / "versioned" + with tempfile.TemporaryDirectory(prefix="versioned-docs-", dir=build) as temporary: + workspace = Path(temporary) + site = workspace / "site" + for tag in versions: + stage = workspace / tag + stage.mkdir() + export_sources(ROOT, tag, stage) + shutil.copy2(ROOT / "Doxyfile", stage) + (stage / "docs").mkdir() + for asset in ("doxygen-extra.css", "version-selector.js"): + shutil.copy2(ROOT / "docs" / asset, stage / "docs") + destination = stage / "output" + build_html(stage, destination, tag, versions, f"versions/{tag}") + shutil.copytree(destination / "html", site / "versions" / tag) + + # Rebuild the default so selector links also work at the existing root URL. + default = workspace / versions[0] + destination = default / "default-output" + build_html(default, destination, versions[0], versions, "") + shutil.copytree(destination / "html", site, dirs_exist_ok=True) + (site / ".nojekyll").touch() + if output.exists(): + shutil.rmtree(output) + shutil.move(str(site), str(output)) + return output + + +if __name__ == "__main__": + print(f"Versioned API docs generated at: {build_site()}") diff --git a/tests/test_version_selector.js b/tests/test_version_selector.js new file mode 100644 index 0000000..d8904af --- /dev/null +++ b/tests/test_version_selector.js @@ -0,0 +1,53 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const source = fs.readFileSync( + path.join(__dirname, "../docs/version-selector.js"), "utf8" +); + +for (const base of ["https://contentauth.github.io/c2pa-cpp/", "file:///tmp/site/"]) { + for (const prefix of ["", "versions/v0.26.14", "versions/v0.26.13"]) { + test("selector links and navigation: " + base + prefix, function () { + const versions = ["v0.26.14", "v0.26.13"]; + const tag = prefix ? prefix.split("/").pop() : versions[0]; + function element() { + return { + children: [], + appendChild(child) { this.children.push(child); }, + setAttribute() {}, + addEventListener(event, listener) { this[event] = listener; } + }; + } + const container = element(); + let navigated; + vm.runInNewContext(source, { + URL, + document: { + currentScript: { + src: new URL((prefix ? prefix + "/" : "") + "version-selector.js", base).href, + dataset: { versions: JSON.stringify(versions), version: tag, prefix } + }, + getElementById(id) { + assert.equal(id, "projectalign"); + return container; + }, + createElement: element + }, + window: { location: { assign(url) { navigated = url; } } } + }); + const select = container.children[0].children[0]; + assert.equal(select.children.length, versions.length); + for (const [index, option] of select.children.entries()) { + assert.equal(option.value, base + "versions/" + versions[index] + "/index.html"); + assert.equal(option.selected, versions[index] === tag); + assert.equal(option.textContent, versions[index]); + } + select.value = select.children[1].value; + select.change(); + assert.equal(navigated, base + "versions/v0.26.13/index.html"); + }); + } +} diff --git a/tests/test_versioned_docs.py b/tests/test_versioned_docs.py new file mode 100644 index 0000000..00564b6 --- /dev/null +++ b/tests/test_versioned_docs.py @@ -0,0 +1,188 @@ +import io +import json +from pathlib import Path +import stat +import subprocess +import tempfile +import unittest +from unittest.mock import Mock, patch +import zipfile + +from scripts import generate_versioned_api_docs as docs + + +class VersionedDocsTests(unittest.TestCase): + def test_version_list_preserves_default_and_order(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "versions.json" + config.write_text('{"versions": ["v0.26.14", "v0.26.13"]}') + self.assertEqual(docs.load_versions(config), ["v0.26.14", "v0.26.13"]) + + def test_invalid_version_lists_are_rejected(self): + invalid = [ + {}, [], {"versions": "v0.26.14"}, + *({"versions": versions} for versions in [ + [], ["v0.26.14", "v0.26.14"], ["../../main"], ["--help"], + ["main"], [26], [None], ["v0.26.14\n"], + ]), + ] + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "versions.json" + for value in invalid: + with self.subTest(value=value): + config.write_text(json.dumps(value), encoding="utf-8") + with self.assertRaises(ValueError): + docs.load_versions(config) + + @patch.object(docs.subprocess, "run") + def test_published_release_is_verified(self, run): + run.return_value = Mock( + stdout=json.dumps({"tag_name": "v0.26.14", "draft": False}) + ) + docs.verify_release("v0.26.14") + run.assert_called_once_with( + ["gh", "api", "repos/contentauth/c2pa-cpp/releases/tags/v0.26.14"], + check=True, stdout=subprocess.PIPE, text=True, + ) + + @patch.object(docs.subprocess, "run") + def test_unpublished_or_mismatched_release_fails(self, run): + for release in [ + {"tag_name": "v0.26.14", "draft": True}, + {"tag_name": "v0.26.13", "draft": False}, + {"tag_name": "v0.26.14"}, [], + ]: + with self.subTest(release=release): + run.return_value = Mock(stdout=json.dumps(release)) + with self.assertRaises(ValueError): + docs.verify_release("v0.26.14") + run.side_effect = subprocess.CalledProcessError(1, ["gh", "api"]) + with self.assertRaises(subprocess.CalledProcessError): + docs.verify_release("v0.26.14") + + def make_archive(self, filename="include/c2pa.hpp", mode=stat.S_IFREG): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("include/", b"") + archive.writestr("src/", b"") + archive.writestr("README.md", b"# Release documentation\n") + archive.writestr("src/CMakeLists.txt", b"do not execute") + entry = zipfile.ZipInfo(filename) + entry.external_attr = mode << 16 + archive.writestr(entry, b"// Tagged API source\n") + return buffer.getvalue() + + @patch.object(docs.subprocess, "run") + def test_export_uses_exact_tag_and_only_documentation_inputs(self, run): + run.side_effect = [ + Mock(stdout="a" * 40 + "\n"), + Mock(stdout=self.make_archive()), + ] + with tempfile.TemporaryDirectory() as directory: + destination = Path(directory) + docs.export_sources(docs.ROOT, "v0.26.14", destination) + self.assertEqual( + (destination / "include/c2pa.hpp").read_text(), + "// Tagged API source\n", + ) + self.assertTrue((destination / "README.md").is_file()) + self.assertFalse((destination / "src/CMakeLists.txt").exists()) + self.assertEqual( + run.call_args_list[0].args[0], + ["git", "rev-parse", "--verify", "refs/tags/v0.26.14^{commit}"], + ) + self.assertEqual( + run.call_args_list[1].args[0], + ["git", "archive", "--format=zip", "a" * 40, "include", "src", "README.md"], + ) + + @patch.object(docs.subprocess, "run") + def test_unsafe_or_missing_headers_fail(self, run): + for filename, mode in [ + ("include/../../escape.h", stat.S_IFREG), + ("/include/api.h", stat.S_IFREG), + ("other/api.h", stat.S_IFREG), + ("include/..\\escape.h", stat.S_IFREG), + ("include/C:/escape.h", stat.S_IFREG), + ("include/api.h", stat.S_IFLNK), + ("include/README.md", stat.S_IFREG), + ]: + with self.subTest(filename=filename), tempfile.TemporaryDirectory() as directory: + run.side_effect = [ + Mock(stdout="a" * 40), + Mock(stdout=self.make_archive(filename, mode)), + ] + with self.assertRaises(ValueError): + docs.export_sources(docs.ROOT, "v0.26.14", Path(directory)) + + @patch.object(docs.subprocess, "run") + def test_doxygen_header_and_configuration(self, run): + def doxygen(command, cwd, **kwargs): + if command[1] == "-w": + (cwd / "header.html").write_text("\n", encoding="utf-8") + else: + (cwd / "output/html").mkdir(parents=True) + (cwd / "output/html/index.html").touch() + + run.side_effect = doxygen + with tempfile.TemporaryDirectory() as directory: + stage = Path(directory) + (stage / "Doxyfile").write_text("INPUT = include src README.md\n") + docs.build_html(stage, stage / "output", "v0.26.14", ["v0.26.14"], "") + header = (stage / "header.html").read_text() + self.assertIn('src="$relpath^version-selector.js"', header) + self.assertIn('data-version="v0.26.14"', header) + self.assertIn('data-prefix=""', header) + self.assertIn("PROJECT_NUMBER = \"v0.26.14\"", run.call_args.kwargs["input"]) + self.assertIn("HTML_EXTRA_FILES", run.call_args.kwargs["input"]) + + @patch.object(docs.subprocess, "run") + def test_missing_generated_index_is_an_error(self, run): + with tempfile.TemporaryDirectory() as directory: + stage = Path(directory) + (stage / "Doxyfile").write_text("") + (stage / "header.html").write_text("") + with self.assertRaisesRegex(RuntimeError, "did not generate"): + docs.build_html(stage, stage / "output", "v0.26.14", ["v0.26.14"], "") + + @patch.object(docs, "verify_release") + @patch.object(docs, "export_sources") + @patch.object(docs, "build_html") + def test_site_build_and_failure_preserve_existing_output(self, build_html, export, verify): + def build(stage, output, tag, versions, prefix): + (output / "html").mkdir(parents=True) + (output / "html/index.html").write_text(f"{tag}:{prefix}") + + build_html.side_effect = build + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "api-docs").mkdir() + (root / "api-docs/versions.json").write_text( + '{"versions": ["v0.26.14", "v0.26.13"]}' + ) + (root / "Doxyfile").touch() + (root / "docs").mkdir() + for asset in ("doxygen-extra.css", "version-selector.js"): + (root / "docs" / asset).touch() + with patch.object(docs, "ROOT", root): + site = docs.build_site() + self.assertEqual((site / "index.html").read_text(), "v0.26.14:") + for tag in ("v0.26.14", "v0.26.13"): + self.assertEqual( + (site / "versions" / tag / "index.html").read_text(), + f"{tag}:versions/{tag}", + ) + self.assertTrue((site / ".nojekyll").is_file()) + self.assertEqual(list((root / "api-docs/_build").iterdir()), [site]) + self.assertEqual(verify.call_count, 2) + self.assertEqual(export.call_count, 2) + self.assertEqual(build_html.call_count, 3) + build_html.side_effect = RuntimeError("Doxygen build failed") + with self.assertRaisesRegex(RuntimeError, "Doxygen build failed"): + docs.build_site() + self.assertEqual((site / "index.html").read_text(), "v0.26.14:") + self.assertEqual(list((root / "api-docs/_build").iterdir()), [site]) + + +if __name__ == "__main__": + unittest.main()