diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml
index d3c3b427..523f13ac 100644
--- a/.github/workflows/publish-docs.yml
+++ b/.github/workflows/publish-docs.yml
@@ -6,6 +6,15 @@ on:
workflow_dispatch:
+ push:
+ branches: [main]
+ paths:
+ - 'api-docs/**'
+ - 'scripts/generate_api_docs.py'
+ - 'scripts/generate_versioned_api_docs.py'
+ - 'requirements-dev.txt'
+ - '.github/workflows/publish-docs.yml'
+
permissions:
contents: read
pages: write
@@ -26,6 +35,7 @@ jobs:
with:
filter: tree:0
fetch-depth: 0
+ ref: main
- name: Setup Pages
uses: actions/configure-pages@v5
@@ -40,12 +50,14 @@ jobs:
run: python3 -m pip install -r requirements-dev.txt
- name: Build docs
- run: python3 scripts/generate_api_docs.py
+ env:
+ GITHUB_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 4c4140d5..9fa3c1eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,7 +44,8 @@ wheels/
# Sphinx docs
api-docs/api
api-docs/_build
-api-docs/_static
+api-docs/_static/*
+!api-docs/_static/version-selector.js
api-docs/_preprocessed
# PyInstaller
diff --git a/Makefile b/Makefile
index e53e1f2e..d4e8ae5c 100644
--- a/Makefile
+++ b/Makefile
@@ -137,6 +137,9 @@ build-from-source:
docs:
$(PYTHON) scripts/generate_api_docs.py
+docs-versioned:
+ $(PYTHON) scripts/generate_versioned_api_docs.py
+
# Memory profiling with memray (runs in Docker, reports go to tests/perf/reports/)
# More details for usage are in tests/perf/README.md
PERF_ENV ?= python-3.12-slim
diff --git a/README.md b/README.md
index 043cac50..52c01bf9 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,10 @@ See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/m
Documentation is published at [github.io/c2pa-python/api/c2pa](https://contentauth.github.io/c2pa-python/api/c2pa/index.html).
+Use the documentation version dropdown to choose a published release. The versions
+are selected in [api-docs/versions.json](api-docs/versions.json); the first entry
+is the default at the existing documentation URL.
+
To build documentation locally, refer to [this section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation).
## Contributing
diff --git a/api-docs/_static/version-selector.js b/api-docs/_static/version-selector.js
new file mode 100644
index 00000000..a4f031e0
--- /dev/null
+++ b/api-docs/_static/version-selector.js
@@ -0,0 +1,8 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const selector = document.getElementById("docs-version");
+ if (selector) {
+ selector.addEventListener("change", () => {
+ window.location.assign(selector.value);
+ });
+ }
+});
diff --git a/api-docs/_templates/version-selector.html b/api-docs/_templates/version-selector.html
new file mode 100644
index 00000000..95d438cb
--- /dev/null
+++ b/api-docs/_templates/version-selector.html
@@ -0,0 +1,14 @@
+
diff --git a/api-docs/conf.py b/api-docs/conf.py
index 9bb4b30a..d66a8d12 100644
--- a/api-docs/conf.py
+++ b/api-docs/conf.py
@@ -1,4 +1,6 @@
import os
+import json
+import posixpath
from pathlib import Path
# -- Project information -----------------------------------------------------
@@ -54,6 +56,24 @@
html_theme = "furo"
html_static_path = ["_static"]
+docs_versions = json.loads(os.environ.get("C2PA_DOCS_VERSIONS", "[]"))
+docs_tag = os.environ.get("C2PA_DOCS_TAG", "")
+docs_prefix = os.environ.get("C2PA_DOCS_PREFIX", "")
+if docs_versions:
+ release = docs_tag
+ version = docs_tag
+ html_sidebars = {
+ "**": [
+ "sidebar/brand.html",
+ "version-selector.html",
+ "sidebar/search.html",
+ "sidebar/scroll-start.html",
+ "sidebar/navigation.html",
+ "sidebar/scroll-end.html",
+ "sidebar/variant-selector.html",
+ ]
+ }
+
# Avoid executing package imports during docs build
autodoc_typehints = "description"
@@ -80,8 +100,23 @@ def _add_html_meta_tags(app, pagename, templatename, context, doctree):
context["metatags"] = context.get("metatags", "") + rendered
+def _add_version_selector(app, pagename, templatename, context, doctree):
+ current_directory = posixpath.dirname(
+ posixpath.join(docs_prefix, pagename + ".html")
+ ) or "."
+ context["docs_tag"] = docs_tag
+ context["docs_versions"] = [
+ {
+ "tag": tag,
+ "url": posixpath.relpath(f"versions/{tag}/index.html", current_directory),
+ }
+ for tag in docs_versions
+ ]
+
+
def setup(app):
app.connect("html-page-context", _add_html_meta_tags)
+ if docs_versions:
+ app.connect("html-page-context", _add_version_selector)
+ app.add_js_file("version-selector.js")
return {"parallel_read_safe": True, "parallel_write_safe": True}
-
-
diff --git a/api-docs/versions.json b/api-docs/versions.json
new file mode 100644
index 00000000..aab48ff7
--- /dev/null
+++ b/api-docs/versions.json
@@ -0,0 +1,6 @@
+{
+ "versions": [
+ "v0.38.0",
+ "v0.37.0"
+ ]
+}
diff --git a/docs/project-contributions.md b/docs/project-contributions.md
index f2f4b3d7..315873ba 100644
--- a/docs/project-contributions.md
+++ b/docs/project-contributions.md
@@ -141,14 +141,52 @@ cd c2pa-python
python3 -m pip install -r requirements-dev.txt
```
-Build docs by entering `make -C docs` or:
+Build docs for your working tree by entering `make docs` or:
```
python3 scripts/generate_api_docs.py
```
-View the output by loading `api-docs/build/html/index.html` in a web browser.
+View the output by loading `api-docs/_build/html/index.html` in a web browser.
This uses `sphinx-autoapi` to parse `src/c2pa` directly, avoiding imports of native libs.
- Entry script: `scripts/generate_api_docs.py`
- Config: `api-docs/conf.py`; index: `api-docs/index.rst`
+
+### Versioned release documentation
+
+Edit [api-docs/versions.json](../api-docs/versions.json) to choose the exact
+GitHub release tags shown in the sidebar dropdown. Entries must be unique
+tags such as `v0.38.0`. The first entry is the default served at the existing
+documentation URL; new releases are not automatically added to the list.
+
+Fetch the release tags and build the complete site:
+
+```sh
+git fetch origin --tags
+make docs-versioned
+```
+
+The builder verifies that every tag has a published release in
+`contentauth/c2pa-python` and fails if a release or local tag is missing.
+It needs network access to the GitHub API; optionally set `GITHUB_TOKEN`
+to avoid unauthenticated rate limits.
+
+Each version uses the Python sources from its exact tag with the current
+documentation configuration, templates, and build dependencies. It parses
+sources without importing the package or executing scripts from old releases.
+Builds run in temporary directories and leave the working tree's generated
+API pages untouched.
+
+The complete site is written to `api-docs/_build/versioned/`:
+
+- The default version is also served at the root, preserving existing API URLs.
+- Each selected release has a stable URL under `versions//`.
+- Switching versions opens that release's documentation home page.
+- A regular `make docs` build remains an unversioned working-tree preview.
+
+The publishing workflow always checks out `main` for the release list and
+build tooling, including when triggered by a release. It rebuilds all selected
+versions when a release is published, when relevant documentation files change
+on `main`, or when manually dispatched. Publish changes to the list on `main`
+to add, remove, or reorder dropdown entries.
diff --git a/scripts/generate_versioned_api_docs.py b/scripts/generate_versioned_api_docs.py
new file mode 100644
index 00000000..db2c4118
--- /dev/null
+++ b/scripts/generate_versioned_api_docs.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+"""Build a GitHub Pages site from the explicitly selected GitHub releases."""
+
+import io
+import json
+import os
+from pathlib import Path, PurePosixPath
+import re
+import shutil
+import stat
+import subprocess
+import sys
+import tempfile
+import zipfile
+
+import requests
+
+
+ROOT = Path(__file__).resolve().parents[1]
+RELEASES_URL = "https://api.github.com/repos/contentauth/c2pa-python/releases/tags"
+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.38.0.")
+ if len(set(versions)) != len(versions):
+ raise ValueError("Documentation versions must not contain duplicate tags.")
+ return versions
+
+
+def verify_release(tag: str) -> None:
+ headers = {"Accept": "application/vnd.github+json"}
+ token = os.environ.get("GITHUB_TOKEN")
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ response = requests.get(
+ f"{RELEASES_URL}/{tag}", headers=headers, timeout=30, allow_redirects=False
+ )
+ if response.status_code != 200:
+ raise RuntimeError(
+ f"Cannot verify GitHub release {tag}: HTTP {response.status_code}."
+ )
+ release = response.json()
+ 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:
+ # Resolve the exact tag, not a similarly named branch, and never run tagged code.
+ 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, "src/c2pa"],
+ cwd=root, check=True, capture_output=True,
+ ).stdout
+ with zipfile.ZipFile(io.BytesIO(archive)) as sources:
+ count = 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 (
+ path.parts[:2] != ("src", "c2pa")
+ and not (entry.is_dir() and path.parts == ("src",))
+ )
+ or stat.S_ISLNK(entry.external_attr >> 16)
+ ):
+ raise ValueError(f"Unsafe source archive in release {tag}.")
+ if entry.is_dir() or path.suffix != ".py":
+ continue
+ target = destination.joinpath(*path.parts)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(sources.read(entry))
+ count += 1
+ if not count:
+ raise ValueError(f"Release {tag} contains no Python API sources.")
+
+
+def build_site() -> Path:
+ docs = ROOT / "api-docs"
+ versions = load_versions(docs / "versions.json")
+ for tag in versions:
+ verify_release(tag)
+
+ build = docs / "_build"
+ build.mkdir(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()
+ shutil.copytree(
+ docs, stage / "api-docs",
+ ignore=shutil.ignore_patterns(
+ "_build", "_preprocessed", "api", "__pycache__", ".DS_Store"
+ ),
+ )
+ scripts = stage / "scripts"
+ scripts.mkdir()
+ shutil.copy2(ROOT / "scripts" / "generate_api_docs.py", scripts)
+ export_sources(ROOT, tag, stage)
+ environment = os.environ.copy()
+ environment.update(
+ C2PA_DOCS_TAG=tag,
+ C2PA_DOCS_VERSIONS=json.dumps(versions),
+ C2PA_DOCS_PREFIX=f"versions/{tag}",
+ )
+ subprocess.run(
+ [sys.executable, str(scripts / "generate_api_docs.py")],
+ cwd=stage, env=environment, check=True,
+ )
+ html = stage / "api-docs" / "_build" / "html"
+ shutil.copytree(html, site / "versions" / tag)
+
+ # Rebuild the default at the root so all switcher links remain relative.
+ default = workspace / versions[0]
+ environment.update(C2PA_DOCS_TAG=versions[0], C2PA_DOCS_PREFIX="")
+ shutil.rmtree(default / "api-docs" / "_build" / "html")
+ subprocess.run(
+ [sys.executable, str(default / "scripts" / "generate_api_docs.py")],
+ cwd=default, env=environment, check=True,
+ )
+ shutil.copytree(
+ default / "api-docs" / "_build" / "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_versioned_docs.py b/tests/test_versioned_docs.py
new file mode 100644
index 00000000..abff04af
--- /dev/null
+++ b/tests/test_versioned_docs.py
@@ -0,0 +1,209 @@
+import io
+import json
+import os
+from pathlib import Path
+import posixpath
+import runpy
+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.38.0", "v0.37.0"]}')
+ self.assertEqual(docs.load_versions(config), ["v0.38.0", "v0.37.0"])
+
+ def test_invalid_versions_are_rejected(self):
+ invalid = [
+ [], ["v0.38.0", "v0.38.0"], ["../../main"], ["--help"],
+ ["main"], [38], [None], ["v0.38.0\n"],
+ ]
+ with tempfile.TemporaryDirectory() as directory:
+ config = Path(directory) / "versions.json"
+ for versions in invalid:
+ with self.subTest(versions=versions):
+ config.write_text(json.dumps({"versions": versions}))
+ with self.assertRaises(ValueError):
+ docs.load_versions(config)
+ for value in [[], {}, {"versions": "v0.38.0"}]:
+ config.write_text(json.dumps(value))
+ with self.assertRaises(ValueError):
+ docs.load_versions(config)
+
+ @patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"})
+ @patch.object(docs.requests, "get")
+ def test_published_release_is_verified(self, get):
+ get.return_value = Mock(
+ status_code=200,
+ json=Mock(return_value={"tag_name": "v0.38.0", "draft": False}),
+ )
+ docs.verify_release("v0.38.0")
+ get.assert_called_once_with(
+ f"{docs.RELEASES_URL}/v0.38.0",
+ headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": "Bearer test-token",
+ },
+ timeout=30, allow_redirects=False,
+ )
+
+ @patch.object(docs.requests, "get")
+ def test_unpublished_or_mismatched_release_fails(self, get):
+ for release in [
+ {"tag_name": "v0.38.0", "draft": True},
+ {"tag_name": "v0.37.0", "draft": False},
+ {"tag_name": "v0.38.0"}, [],
+ ]:
+ with self.subTest(release=release):
+ get.return_value = Mock(status_code=200, json=Mock(return_value=release))
+ with self.assertRaises(ValueError):
+ docs.verify_release("v0.38.0")
+ get.return_value = Mock(status_code=404)
+ with self.assertRaisesRegex(RuntimeError, "HTTP 404"):
+ docs.verify_release("v0.38.0")
+
+ def make_archive(self, filename, mode=stat.S_IFREG):
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w") as archive:
+ archive.writestr("src/", b"")
+ archive.writestr("src/c2pa/", b"")
+ entry = zipfile.ZipInfo(filename)
+ entry.external_attr = mode << 16
+ archive.writestr(entry, b"TAGGED_SOURCE = True\n")
+ return buffer.getvalue()
+
+ @patch.object(docs.subprocess, "run")
+ def test_tagged_sources_are_exported_without_running_release_code(self, run):
+ run.side_effect = [
+ subprocess.CompletedProcess([], 0, stdout="a" * 40 + "\n"),
+ subprocess.CompletedProcess([], 0, stdout=self.make_archive("src/c2pa/api.py")),
+ ]
+ with tempfile.TemporaryDirectory() as directory:
+ destination = Path(directory)
+ docs.export_sources(docs.ROOT, "v0.37.0", destination)
+ self.assertEqual(
+ (destination / "src/c2pa/api.py").read_text(), "TAGGED_SOURCE = True\n"
+ )
+ self.assertEqual(
+ run.call_args_list[0].args[0],
+ ["git", "rev-parse", "--verify", "refs/tags/v0.37.0^{commit}"],
+ )
+ self.assertEqual(run.call_args_list[1].args[0][0:3], ["git", "archive", "--format=zip"])
+
+ @patch.object(docs.subprocess, "run")
+ def test_unsafe_or_empty_source_archives_fail(self, run):
+ cases = [
+ ("src/c2pa/../../escape.py", stat.S_IFREG),
+ ("/src/c2pa/api.py", stat.S_IFREG),
+ ("other/api.py", stat.S_IFREG),
+ ("src/c2pa/..\\escape.py", stat.S_IFREG),
+ ("src/c2pa/C:/escape.py", stat.S_IFREG),
+ ("src/c2pa/api.py", stat.S_IFLNK),
+ ("src/c2pa/README.md", stat.S_IFREG),
+ ]
+ with tempfile.TemporaryDirectory() as directory:
+ for filename, mode in cases:
+ with self.subTest(filename=filename, mode=mode):
+ run.side_effect = [
+ subprocess.CompletedProcess([], 0, stdout="a" * 40),
+ subprocess.CompletedProcess(
+ [], 0, stdout=self.make_archive(filename, mode)
+ ),
+ ]
+ with self.assertRaises(ValueError):
+ docs.export_sources(docs.ROOT, "v0.38.0", Path(directory))
+
+ def test_switcher_links_resolve_from_root_and_nested_version_pages(self):
+ versions = ["v0.38.0", "v0.37.0"]
+ for prefix in ["", "versions/v0.38.0", "versions/v0.37.0"]:
+ with self.subTest(prefix=prefix), patch.dict(
+ os.environ,
+ {
+ "C2PA_DOCS_TAG": "v0.38.0",
+ "C2PA_DOCS_VERSIONS": json.dumps(versions),
+ "C2PA_DOCS_PREFIX": prefix,
+ },
+ ):
+ config = runpy.run_path(str(docs.ROOT / "api-docs/conf.py"))
+ for page in ["index", "api/c2pa/index", "api/c2pa/c2pa/index"]:
+ context = {}
+ config["_add_version_selector"](None, page, None, context, None)
+ parent = posixpath.dirname(posixpath.join(prefix, page + ".html"))
+ for item in context["docs_versions"]:
+ self.assertEqual(
+ posixpath.normpath(posixpath.join(parent, item["url"])),
+ f"versions/{item['tag']}/index.html",
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_working_tree_build_has_no_version_switcher(self):
+ config = runpy.run_path(str(docs.ROOT / "api-docs/conf.py"))
+ app = Mock()
+ config["setup"](app)
+ self.assertEqual(app.connect.call_count, 1)
+ app.add_js_file.assert_not_called()
+
+ @patch.object(docs, "verify_release")
+ @patch.object(docs, "export_sources")
+ @patch.object(docs.subprocess, "run")
+ def test_site_build_preserves_default_and_cleans_temporary_builds(self, run, export, verify):
+ def build(*args, cwd, env, **kwargs):
+ html = cwd / "api-docs/_build/html"
+ html.mkdir(parents=True)
+ (html / "index.html").write_text(
+ f"{env['C2PA_DOCS_TAG']}:{env['C2PA_DOCS_PREFIX']}"
+ )
+
+ run.side_effect = build
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root / "api-docs").mkdir()
+ (root / "api-docs/versions.json").write_text(
+ '{"versions": ["v0.38.0", "v0.37.0"]}'
+ )
+ (root / "scripts").mkdir()
+ (root / "scripts/generate_api_docs.py").touch()
+ with patch.object(docs, "ROOT", root):
+ site = docs.build_site()
+ self.assertEqual((site / "index.html").read_text(), "v0.38.0:")
+ for tag in ["v0.38.0", "v0.37.0"]:
+ 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.assertFalse((root / "api-docs/api").exists())
+ self.assertEqual(verify.call_count, 2)
+ self.assertEqual(export.call_count, 2)
+ self.assertEqual(run.call_count, 3)
+
+ @patch.object(docs, "verify_release")
+ @patch.object(docs, "export_sources")
+ @patch.object(docs.subprocess, "run", side_effect=subprocess.CalledProcessError(1, "sphinx"))
+ def test_failed_build_leaves_previous_site_intact(self, run, export, verify):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ site = root / "api-docs/_build/versioned"
+ site.mkdir(parents=True)
+ (site / "index.html").write_text("previous site")
+ (root / "api-docs/versions.json").write_text('{"versions": ["v0.38.0"]}')
+ (root / "scripts").mkdir()
+ (root / "scripts/generate_api_docs.py").touch()
+ with patch.object(docs, "ROOT", root):
+ with self.assertRaises(subprocess.CalledProcessError):
+ docs.build_site()
+ self.assertEqual((site / "index.html").read_text(), "previous site")
+ self.assertEqual(list((root / "api-docs/_build").iterdir()), [site])
+
+
+if __name__ == "__main__":
+ unittest.main()