From 870659d2114131fa0770b6c00bb833d3cd2ed43b Mon Sep 17 00:00:00 2001 From: Rand McKinney Date: Mon, 19 Oct 2026 13:32:05 -0700 Subject: [PATCH 1/2] docs: add release-versioned Android API documentation Build selected published releases with Dokka, add a header version selector, and deploy default and version-specific API documentation through GitHub Pages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/doc-version-selector.css | 27 ++ .github/scripts/doc-version-selector.js | 29 ++ .github/scripts/generate_versioned_docs.py | 161 ++++++++++++ .github/scripts/test-doc-version-selector.cjs | 73 ++++++ .github/scripts/test_versioned_docs.py | 247 ++++++++++++++++++ .github/workflows/docs.yml | 51 +++- .github/workflows/release.yml | 9 + .gitignore | 1 + Makefile | 18 +- README.md | 53 ++++ docs/versions.json | 6 + 11 files changed, 665 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/doc-version-selector.css create mode 100644 .github/scripts/doc-version-selector.js create mode 100644 .github/scripts/generate_versioned_docs.py create mode 100644 .github/scripts/test-doc-version-selector.cjs create mode 100644 .github/scripts/test_versioned_docs.py create mode 100644 docs/versions.json diff --git a/.github/scripts/doc-version-selector.css b/.github/scripts/doc-version-selector.css new file mode 100644 index 0000000..3372b45 --- /dev/null +++ b/.github/scripts/doc-version-selector.css @@ -0,0 +1,27 @@ +#c2pa-doc-version-selector { + display: inline-flex; + align-items: center; + gap: 0.4rem; + white-space: nowrap; +} + +#c2pa-doc-version-selector select { + max-width: 10rem; + padding: 0.25rem; + border: 1px solid currentColor; + border-radius: 0.25rem; + color: inherit; + background: transparent; + font: inherit; +} + +#c2pa-doc-version-selector option { + color: CanvasText; + background: Canvas; +} + +@media print { + #c2pa-doc-version-selector { + display: none; + } +} diff --git a/.github/scripts/doc-version-selector.js b/.github/scripts/doc-version-selector.js new file mode 100644 index 0000000..2e04db3 --- /dev/null +++ b/.github/scripts/doc-version-selector.js @@ -0,0 +1,29 @@ +(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("library-version"); + if (!container) { + throw new Error("Dokka did not provide a library-version header slot."); + } + const label = document.createElement("label"); + label.id = "c2pa-doc-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 + "/", 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.replaceChildren(label); +})(); diff --git a/.github/scripts/generate_versioned_docs.py b/.github/scripts/generate_versioned_docs.py new file mode 100644 index 0000000..ad5511f --- /dev/null +++ b/.github/scripts/generate_versioned_docs.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Build a static Dokka site from explicitly selected published releases.""" + +import html +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import stat +import subprocess +import tempfile +import zipfile + + +ROOT = Path(__file__).resolve().parents[2] +TAG_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?") +PACKAGE_INDEX = Path("c2pa-android/org.contentauth.c2pa/index.html") + + +def load_versions(filename: Path) -> list[str]: + config = json.loads(filename.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 0.0.15.") + 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-android/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_release(root: Path, tag: str, destination: Path) -> None: + 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], + cwd=root, check=True, capture_output=True, + ).stdout + with zipfile.ZipFile(io.BytesIO(archive)) as sources: + for entry in sources.infolist(): + path = PurePosixPath(entry.filename) + if ( + not path.parts + or path.is_absolute() + or ".." in path.parts + or path.parts[0] == ".git" + or any("\\" in part or ":" in part for part in path.parts) + or stat.S_ISLNK(entry.external_attr >> 16) + ): + raise ValueError(f"Unsafe source archive in release {tag}.") + if entry.is_dir(): + continue + target = destination.joinpath(*path.parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(sources.read(entry)) + if (entry.external_attr >> 16) & 0o111: + target.chmod(0o755) + required = [ + destination / "gradlew", + destination / "gradle/wrapper/gradle-wrapper.jar", + destination / "library/build.gradle.kts", + destination / "library/MODULE.md", + ] + if not all(path.is_file() for path in required) or not list( + (destination / "library/src/main/kotlin").rglob("*.kt") + ): + raise ValueError(f"Release {tag} is missing its Gradle wrapper or Kotlin API sources.") + + +def build_docs(stage: Path) -> Path: + environment = os.environ.copy() + for name in ("GH_TOKEN", "GITHUB_TOKEN"): + environment.pop(name, None) + subprocess.run( + [ + "./gradlew", "--no-daemon", "--console=plain", + ":library:dokkaGeneratePublicationHtml", "--rerun-tasks", + ], + cwd=stage, env=environment, check=True, + ) + output = stage / "build/docs" + if not (output / "index.html").is_file() or not (output / PACKAGE_INDEX).is_file(): + raise RuntimeError("Dokka did not generate the library and C2PA package landing pages.") + return output + + +def render_docs(source: Path, output: Path, tag: str, versions: list[str], prefix: str) -> None: + shutil.copytree(source, output) + for page in output.rglob("*.html"): + relative = page.relative_to(output) + # Dokka loads this sidebar fragment separately; it is not a complete page. + if relative == Path("navigation.html"): + continue + content = page.read_text(encoding="utf-8") + if "" not in content or 'id="library-version"' not in content: + raise ValueError(f"Dokka page is missing its head or version slot: {relative}") + assets = "../" * (len(relative.parts) - 1) + additions = ( + f'\n' + f'\n' + ) + page.write_text(content.replace("", additions + ""), encoding="utf-8") + for asset in ("doc-version-selector.js", "doc-version-selector.css"): + shutil.copy2(ROOT / ".github/scripts" / asset, output) + + +def build_site() -> Path: + versions = load_versions(ROOT / "docs/versions.json") + for tag in versions: + verify_release(tag) + build = ROOT / "build" + build.mkdir(exist_ok=True) + output = build / "docs-versioned" + with tempfile.TemporaryDirectory(prefix="versioned-docs-", dir=build) as temporary: + workspace = Path(temporary) + site = workspace / "site" + default_docs = None + for tag in versions: + stage = workspace / tag + stage.mkdir() + export_release(ROOT, tag, stage) + generated = build_docs(stage) + if tag == versions[0]: + default_docs = generated + render_docs(generated, site / "versions" / tag, tag, versions, f"versions/{tag}") + if default_docs is None: + raise RuntimeError("No default documentation was built.") + default = workspace / "default" + render_docs(default_docs, default, versions[0], versions, "") + shutil.copytree(default, 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 Dokka documentation generated at: {build_site()}") diff --git a/.github/scripts/test-doc-version-selector.cjs b/.github/scripts/test-doc-version-selector.cjs new file mode 100644 index 0000000..287ad66 --- /dev/null +++ b/.github/scripts/test-doc-version-selector.cjs @@ -0,0 +1,73 @@ +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, "doc-version-selector.js"), "utf8"); +const versions = ["0.0.15", "0.0.14"]; + +function element() { + return { + children: [], + appendChild(child) { this.children.push(child); }, + replaceChildren(child) { this.children = [child]; }, + setAttribute(name, value) { this[name] = value; }, + addEventListener(event, listener) { this[event] = listener; } + }; +} + +for (const base of [ + "https://contentauth.github.io/c2pa-android/", + "https://docs.example.test/nested/sdk/" +]) { + for (const prefix of ["", ...versions.map(tag => "versions/" + tag)]) { + test("Dokka version selector: " + base + (prefix || "default"), function () { + const tag = prefix ? prefix.split("/").pop() : versions[0]; + const container = element(); + let destination; + vm.runInNewContext(source, { + URL, + document: { + currentScript: { + src: new URL((prefix ? prefix + "/" : "") + "doc-version-selector.js", base).href, + dataset: { versions: JSON.stringify(versions), version: tag, prefix } + }, + getElementById(id) { + assert.equal(id, "library-version"); + return container; + }, + createElement: element + }, + window: { location: { assign(url) { destination = url; } } } + }); + assert.equal(container.children.length, 1); + const label = container.children[0]; + assert.equal(label.id, "c2pa-doc-version-selector"); + const select = label.children[0]; + assert.equal(select["aria-label"], "API documentation version"); + assert.equal(select.children.length, versions.length); + for (const [index, option] of select.children.entries()) { + assert.equal(option.value, base + "versions/" + versions[index] + "/"); + assert.equal(option.selected, versions[index] === tag); + assert.equal(option.textContent, versions[index]); + } + select.value = select.children[1].value; + select.change(); + assert.equal(destination, base + "versions/0.0.14/"); + }); + } +} + +test("A missing Dokka version slot fails explicitly", function () { + assert.throws(() => vm.runInNewContext(source, { + URL, + document: { + currentScript: { + src: "https://contentauth.github.io/c2pa-android/doc-version-selector.js", + dataset: { versions: JSON.stringify(versions), version: versions[0], prefix: "" } + }, + getElementById() { return null; } + } + }), /Dokka did not provide a library-version header slot/); +}); diff --git a/.github/scripts/test_versioned_docs.py b/.github/scripts/test_versioned_docs.py new file mode 100644 index 0000000..bb713a1 --- /dev/null +++ b/.github/scripts/test_versioned_docs.py @@ -0,0 +1,247 @@ +import io +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import tempfile +import unittest +from unittest.mock import Mock, patch +import zipfile + +import generate_versioned_docs as docs + + +VERSIONS = ["0.0.15", "0.0.14"] +PAGE = 'C2PA
' +NAVIGATION = '
C2PA
' + + +def make_dokka(output): + for relative in [Path("index.html"), docs.PACKAGE_INDEX, docs.PACKAGE_INDEX.parent / "-reader/index.html"]: + page = output / relative + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text(PAGE, encoding="utf-8") + (output / "navigation.html").write_text(NAVIGATION, encoding="utf-8") + (output / "scripts").mkdir() + (output / "scripts/main.js").write_text("// Dokka fixture", encoding="utf-8") + return output + + +class VersionedDocsTests(unittest.TestCase): + def test_versions_preserve_default_and_order(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "versions.json" + config.write_text(json.dumps({"versions": VERSIONS})) + self.assertEqual(docs.load_versions(config), VERSIONS) + config.write_text('{"versions": ["0.0.16-beta.1", "0.0.15"]}') + self.assertEqual(docs.load_versions(config)[0], "0.0.16-beta.1") + + def test_invalid_versions_fail(self): + invalid = [ + {}, [], {"versions": "0.0.15"}, + *({"versions": versions} for versions in [ + [], ["0.0.15", "0.0.15"], ["main"], ["../0.0.15"], + ["--help"], ["v0.0.15"], ["0.0.15\n"], [15], [None], + ]), + ] + 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)) + with self.assertRaises(ValueError): + docs.load_versions(config) + + @patch.object(docs.subprocess, "run") + def test_only_matching_published_releases_are_accepted(self, run): + run.return_value = Mock(stdout=json.dumps({"tag_name": VERSIONS[0], "draft": False})) + docs.verify_release(VERSIONS[0]) + run.assert_called_once_with( + ["gh", "api", f"repos/contentauth/c2pa-android/releases/tags/{VERSIONS[0]}"], + check=True, stdout=subprocess.PIPE, text=True, + ) + for release in [ + [], {"tag_name": VERSIONS[0]}, {"tag_name": VERSIONS[0], "draft": True}, + {"tag_name": VERSIONS[1], "draft": False}, + ]: + with self.subTest(release=release): + run.return_value = Mock(stdout=json.dumps(release)) + with self.assertRaises(ValueError): + docs.verify_release(VERSIONS[0]) + run.side_effect = subprocess.CalledProcessError(1, ["gh", "api"]) + with self.assertRaises(subprocess.CalledProcessError): + docs.verify_release(VERSIONS[0]) + + def make_archive(self, filename="gradlew", mode=stat.S_IFREG | 0o755): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("gradle/wrapper/gradle-wrapper.jar", b"fixture") + archive.writestr("library/build.gradle.kts", "// fixture") + archive.writestr("library/MODULE.md", "# Module c2pa-android") + archive.writestr("library/src/main/kotlin/Reader.kt", "class Reader") + entry = zipfile.ZipInfo(filename) + entry.external_attr = mode << 16 + archive.writestr(entry, "#!/bin/sh\nexit 0\n") + return buffer.getvalue() + + @patch.object(docs.subprocess, "run") + def test_export_resolves_exact_tag_and_preserves_wrapper_permissions(self, run): + run.side_effect = [Mock(stdout="a" * 40 + "\n"), Mock(stdout=self.make_archive())] + with tempfile.TemporaryDirectory() as directory: + stage = Path(directory) + docs.export_release(docs.ROOT, VERSIONS[0], stage) + self.assertTrue((stage / "gradlew").stat().st_mode & stat.S_IXUSR) + self.assertTrue((stage / "library/src/main/kotlin/Reader.kt").is_file()) + self.assertEqual( + run.call_args_list[0].args[0], + ["git", "rev-parse", "--verify", f"refs/tags/{VERSIONS[0]}^{{commit}}"], + ) + self.assertEqual(run.call_args_list[1].args[0], ["git", "archive", "--format=zip", "a" * 40]) + + @patch.object(docs.subprocess, "run") + def test_unsafe_archives_are_rejected(self, run): + for filename, mode in [ + ("../escape", stat.S_IFREG), ("/escape", stat.S_IFREG), + ("library/..\\escape", stat.S_IFREG), ("C:/escape", stat.S_IFREG), + (".git/config", stat.S_IFREG), ("link", stat.S_IFLNK), + ]: + 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.assertRaisesRegex(ValueError, "Unsafe source archive"): + docs.export_release(docs.ROOT, VERSIONS[0], Path(directory)) + + @patch.object(docs.subprocess, "run") + def test_missing_wrapper_is_rejected(self, run): + run.side_effect = [Mock(stdout="a" * 40), Mock(stdout=self.make_archive("README.md"))] + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex(ValueError, "missing its Gradle wrapper"): + docs.export_release(docs.ROOT, VERSIONS[0], Path(directory)) + + @patch.dict(os.environ, {"GH_TOKEN": "documentation-test-value", "GITHUB_TOKEN": "documentation-test-value"}) + @patch.object(docs.subprocess, "run") + def test_build_uses_release_dokka_without_github_tokens(self, run): + with tempfile.TemporaryDirectory() as directory: + stage = Path(directory) + generated = make_dokka(stage / "build/docs") + self.assertEqual(docs.build_docs(stage), generated) + self.assertEqual( + run.call_args.args[0], + ["./gradlew", "--no-daemon", "--console=plain", + ":library:dokkaGeneratePublicationHtml", "--rerun-tasks"], + ) + self.assertEqual(run.call_args.kwargs["cwd"], stage) + self.assertTrue(run.call_args.kwargs["check"]) + self.assertNotIn("GH_TOKEN", run.call_args.kwargs["env"]) + self.assertNotIn("GITHUB_TOKEN", run.call_args.kwargs["env"]) + + @patch.object(docs.subprocess, "run") + def test_missing_dokka_output_fails(self, run): + with tempfile.TemporaryDirectory() as directory: + stage = Path(directory) + with self.assertRaisesRegex(RuntimeError, "landing pages"): + docs.build_docs(stage) + (stage / "build/docs").mkdir(parents=True) + (stage / "build/docs/index.html").write_text(PAGE) + with self.assertRaisesRegex(RuntimeError, "landing pages"): + docs.build_docs(stage) + + def test_assets_are_relative_on_default_and_deep_version_pages(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = make_dokka(root / "source") + for prefix in ["", f"versions/{VERSIONS[0]}", f"versions/{VERSIONS[1]}"]: + with self.subTest(prefix=prefix): + output = root / (prefix or "default") + docs.render_docs(source, output, VERSIONS[0], VERSIONS, prefix) + self.assertIn('src="doc-version-selector.js"', (output / "index.html").read_text()) + for relative in [docs.PACKAGE_INDEX, docs.PACKAGE_INDEX.parent / "-reader/index.html"]: + page = (output / relative).read_text() + assets = "../" * (len(relative.parts) - 1) + self.assertIn(f'src="{assets}doc-version-selector.js"', page) + self.assertIn(f'data-prefix="{prefix}"', page) + self.assertIn('data-versions="["0.0.15", "0.0.14"]"', page) + self.assertTrue((output / "doc-version-selector.css").is_file()) + self.assertTrue((output / "doc-version-selector.js").is_file()) + self.assertEqual((source / "index.html").read_text(), PAGE) + + def test_dokka_navigation_fragment_and_existing_assets_are_preserved(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = make_dokka(root / "source") + output = root / "output" + docs.render_docs(source, output, VERSIONS[0], VERSIONS, "") + self.assertEqual((output / "navigation.html").read_text(), NAVIGATION) + self.assertEqual((output / "scripts/main.js").read_text(), "// Dokka fixture") + + def test_missing_head_or_version_slot_fails_explicitly(self): + for content in ["No slot", '
']: + with self.subTest(content=content), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = make_dokka(root / "source") + (source / "index.html").write_text(content) + with self.assertRaisesRegex(ValueError, "missing its head or version slot"): + docs.render_docs(source, root / "output", VERSIONS[0], VERSIONS, "") + + def configure_root(self, root): + (root / "docs").mkdir() + (root / "docs/versions.json").write_text(json.dumps({"versions": VERSIONS})) + scripts = root / ".github/scripts" + scripts.mkdir(parents=True) + for asset in ["doc-version-selector.css", "doc-version-selector.js"]: + shutil.copy2(docs.ROOT / ".github/scripts" / asset, scripts) + previous = root / "build/docs-versioned" + previous.mkdir(parents=True) + (previous / "previous.html").write_text("Existing site") + working = root / "build/docs" + working.mkdir() + (working / "working.html").write_text("Working-tree site") + + def test_failed_release_build_preserves_previous_site_and_cleans_workspace(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.configure_root(root) + def build(stage): + if stage.name == VERSIONS[1]: + raise subprocess.CalledProcessError(1, ["./gradlew"]) + return make_dokka(stage / "build/docs") + with ( + patch.object(docs, "ROOT", root), + patch.object(docs, "verify_release"), + patch.object(docs, "export_release"), + patch.object(docs, "build_docs", side_effect=build), + ): + with self.assertRaises(subprocess.CalledProcessError): + docs.build_site() + self.assertEqual((root / "build/docs-versioned/previous.html").read_text(), "Existing site") + self.assertEqual(list((root / "build").glob("versioned-docs-*")), []) + + def test_complete_site_has_default_and_both_versions_without_touching_working_docs(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.configure_root(root) + with ( + patch.object(docs, "ROOT", root), + patch.object(docs, "verify_release") as verify, + patch.object(docs, "export_release") as export, + patch.object(docs, "build_docs", side_effect=lambda stage: make_dokka(stage / "build/docs")), + ): + output = docs.build_site() + self.assertEqual(output, root / "build/docs-versioned") + self.assertTrue((output / ".nojekyll").is_file()) + self.assertFalse((output / "previous.html").exists()) + for prefix, tag in [("", VERSIONS[0]), *(("versions/" + tag, tag) for tag in VERSIONS)]: + page = (output / prefix / docs.PACKAGE_INDEX).read_text() + self.assertIn(f'data-version="{tag}"', page) + self.assertIn(f'data-prefix="{prefix}"', page) + self.assertEqual([call.args[0] for call in verify.call_args_list], VERSIONS) + self.assertEqual([call.args[1] for call in export.call_args_list], VERSIONS) + self.assertEqual((root / "build/docs/working.html").read_text(), "Working-tree site") + self.assertEqual(list((root / "build").glob("versioned-docs-*")), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8925b33..f5d3e57 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,18 +1,31 @@ name: Documentation on: + release: + types: [published] push: - tags: - - "[0-9]+.[0-9]+.[0-9]+" + branches: [main] + paths: + - "docs/versions.json" + - ".github/scripts/*doc*" + - ".github/workflows/docs.yml" + - ".github/workflows/release.yml" + - "Makefile" + pull_request: + paths: + - "docs/versions.json" + - ".github/scripts/*doc*" + - ".github/workflows/docs.yml" + - ".github/workflows/release.yml" + - "Makefile" workflow_dispatch: + workflow_call: permissions: contents: read - pages: write - id-token: write concurrency: - group: "pages" + group: "pages-${{ github.event_name == 'pull_request' && github.ref || 'deploy' }}" cancel-in-progress: false jobs: @@ -21,6 +34,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'release' && 'main' || github.ref }} + filter: tree:0 + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + + - uses: actions/setup-node@v6 + with: + node-version: "24" - name: Resolve JDK version id: java @@ -45,16 +71,25 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 - - name: Build Documentation - run: make docs + - name: Test documentation tooling + run: make docs-test + + - name: Build versioned documentation + env: + GH_TOKEN: ${{ github.token }} + run: make docs-versioned - name: Upload Documentation Artifact uses: actions/upload-pages-artifact@v5 with: - path: ./build/docs + path: ./build/docs-versioned deploy: name: Deploy to GitHub Pages + if: github.event_name != 'pull_request' + permissions: + pages: write + id-token: write needs: build-docs runs-on: ubuntu-latest environment: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e29eb56..39d7a8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,3 +154,12 @@ jobs: See the [commit history](https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.tag }}...${{ github.event.inputs.version }}) for a full list of changes. env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-docs: + name: Publish versioned API documentation + needs: build-and-release + permissions: + contents: read + pages: write + id-token: write + uses: ./.github/workflows/docs.yml diff --git a/.gitignore b/.gitignore index d6a097e..f036bb9 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ library/src/main/jni/c2pa.h # Generated files *.aar +__pycache__/ # OS files .DS_Store diff --git a/Makefile b/Makefile index f63de48..9111598 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: all clean setup library publish download-binaries tests coverage help test-app example-app \ run-test-app run-example-app signing-server-start signing-server-stop signing-server-status \ - signing-server-build tests-with-server lint format docs docs-clean + signing-server-build tests-with-server lint format docs docs-clean docs-versioned docs-test # Optional. Build the native libraries from archives in this directory instead of # downloading them from a c2pa-rs release. Expects the four @@ -101,7 +101,15 @@ docs: @echo "Output: build/docs/index.html" @echo "To view: open build/docs/index.html" -# Clean generated documentation +# Generate API documentation from selected published releases +docs-versioned: + python3 .github/scripts/generate_versioned_docs.py + +docs-test: + python3 -m unittest discover -s .github/scripts -p 'test_versioned_docs.py' + node --test .github/scripts/test-doc-version-selector.cjs + +# Clean generated working-tree documentation docs-clean: @echo "Cleaning generated documentation..." @rm -rf build/docs @@ -244,6 +252,12 @@ help: @echo "Publishing:" @echo " publish - Publish library to GitHub packages" @echo "" + @echo "Documentation:" + @echo " docs - Generate API docs from the working tree" + @echo " docs-versioned - Generate default and versioned docs from published releases" + @echo " docs-test - Test the documentation build tooling and version selector" + @echo " docs-clean - Clean generated working-tree documentation" + @echo "" @echo "Usage examples:" @echo " make tests - Run all tests (start server first if testing hardware signing)" @echo " make tests-with-server - Run all tests with automatic server management" diff --git a/README.md b/README.md index 1f261c9..6f8fbf6 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,59 @@ re-extracts automatically. This is also how the `track/c2pa-rs-main` branch is built in CI. +## API documentation + +The [published API documentation](https://contentauth.github.io/c2pa-android/) +is built from the published release tags listed in [docs/versions.json](docs/versions.json), +not from the current working tree. The first version in that list is the default. +The documentation header includes a version selector. + +- Default: +- Version 0.0.15: +- Version 0.0.14: + +The existing direct package URL + +continues to serve the default version. + +### Building documentation locally + +To build the selected releases, install Python 3.10 or newer, Git, the GitHub CLI +(`gh`), and the JDK and Android SDK required by those releases. Authenticate `gh` +or set `GH_TOKEN` to a token with read access to the repository, then run: + +```bash +git fetch origin --tags +make docs-versioned +``` + +The output is `build/docs-versioned/`, containing the default site and +`versions//` for every configured release, including the default. +All documentation and selector links are relative, so this directory can be +served over HTTP locally or hosted under `/c2pa-android/` on GitHub Pages. + +The builder verifies that each tag is a published GitHub release, exports its +sources to a temporary workspace, and runs that release's Gradle/Dokka +configuration. It preserves executable permissions on the Gradle wrapper and +does not pass `GH_TOKEN` or `GITHUB_TOKEN` to Gradle. Release builds execute the +tagged Gradle scripts, so only add trusted release tags. Temporary workspaces are +removed after success or failure, and existing versioned output is replaced only +after all selected releases build successfully. + +The existing `make docs` command still generates working-tree documentation in +`build/docs/`; it does not replace the versioned site. Run `make docs-test` to test +the versioning tooling and selector (Python 3.10+ and Node.js 18+; no Android SDK +or JDK required for these unit tests). + +### Maintaining versions + +Add a published release tag to `docs/versions.json`; put it first to make it the +default. Publishing a release alone does not change the selected versions. +The documentation workflow validates pull requests and deploys on relevant +changes to `main`, manual dispatch, and published releases. The release workflow +also calls it explicitly after release completion, because events created with +`GITHUB_TOKEN` do not trigger other workflows. + ## Repository structure - `/library` - Android library module with C2PA Kotlin APIs and JNI bindings diff --git a/docs/versions.json b/docs/versions.json new file mode 100644 index 0000000..c82ef85 --- /dev/null +++ b/docs/versions.json @@ -0,0 +1,6 @@ +{ + "versions": [ + "0.0.15", + "0.0.14" + ] +} From b8d19e2614114b67139f52edd1f09ae516924c2d Mon Sep 17 00:00:00 2001 From: Rand McKinney Date: Tue, 27 Oct 2026 10:05:06 -0700 Subject: [PATCH 2/2] Remove Python dep and use Bash instead --- .github/scripts/generate-versioned-docs.sh | 138 ++++++++++++ .github/scripts/generate_versioned_docs.py | 161 -------------- .github/scripts/test-versioned-docs.sh | 221 ++++++++++++++++++ .github/scripts/test_versioned_docs.py | 247 --------------------- .github/workflows/docs.yml | 7 +- Makefile | 4 +- README.md | 6 +- 7 files changed, 367 insertions(+), 417 deletions(-) create mode 100644 .github/scripts/generate-versioned-docs.sh delete mode 100644 .github/scripts/generate_versioned_docs.py create mode 100644 .github/scripts/test-versioned-docs.sh delete mode 100644 .github/scripts/test_versioned_docs.py diff --git a/.github/scripts/generate-versioned-docs.sh b/.github/scripts/generate-versioned-docs.sh new file mode 100644 index 0000000..16824e7 --- /dev/null +++ b/.github/scripts/generate-versioned-docs.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PACKAGE_INDEX=c2pa-android/org.contentauth.c2pa/index.html + +fail() { + printf '%s\n' "$*" >&2 + return 1 +} + +load_versions() { + local filename="$1" + jq -e --arg pattern '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9][A-Za-z0-9.-]*)?\z' ' + if type != "object" then false + elif (.versions | type) != "array" then false + else .versions | length > 0 and + all(.[]; if type == "string" then test($pattern) else false end) and + (length == (unique | length)) + end + ' "$filename" >/dev/null || { fail "Documentation versions must be unique release tags in a non-empty list."; return 1; } + jq -r '.versions[]' "$filename" +} + +verify_release() { + local tag="$1" release + release="$(gh api "repos/contentauth/c2pa-android/releases/tags/$tag")" || return 1 + printf '%s' "$release" | jq -e --arg tag "$tag" ' + type == "object" and .tag_name == $tag and .draft == false + ' >/dev/null || { fail "$tag is not a published GitHub release."; return 1; } +} + +export_release() { + local root="$1" tag="$2" destination="$3" commit entry mode path + commit="$(git -C "$root" rev-parse --verify "refs/tags/$tag^{commit}")" || return 1 + git -C "$root" ls-tree -rz --full-tree "$commit" > "$destination.tree" || return 1 + while IFS= read -r -d '' entry; do + mode="${entry%% *}" + path="${entry#*$'\t'}" + case "$mode" in + 100644|100755) ;; + *) fail "Unsafe source entry in release $tag: $path"; return 1 ;; + esac + case "/$path/" in + //*|*/../*|*/./*|*/.git/*|*\\*|*:*) fail "Unsafe source path in release $tag: $path"; return 1 ;; + esac + done < "$destination.tree" + git -C "$root" archive --format=tar "$commit" > "$destination.tar" || return 1 + tar -xf "$destination.tar" -C "$destination" || return 1 + local required + for required in gradlew gradle/wrapper/gradle-wrapper.jar library/build.gradle.kts library/MODULE.md; do + [[ -f "$destination/$required" ]] || { fail "Release $tag is missing its Gradle wrapper or Kotlin API sources."; return 1; } + done + [[ -n "$(find "$destination/library/src/main/kotlin" -type f -name '*.kt' -print -quit)" ]] || { + fail "Release $tag is missing Kotlin API sources."; return 1; + } +} + +build_docs() { + local stage="$1" + ( + cd "$stage" + unset GH_TOKEN GITHUB_TOKEN + ./gradlew --no-daemon --console=plain :library:dokkaGeneratePublicationHtml --rerun-tasks + ) || return 1 + [[ -f "$stage/build/docs/index.html" && -f "$stage/build/docs/$PACKAGE_INDEX" ]] || { + fail "Dokka did not generate the library and C2PA package landing pages."; return 1; + } +} + +render_docs() { + local source="$1" output="$2" tag="$3" versions="$4" prefix="$5" + local page relative directory assets additions escaped_tag escaped_versions escaped_prefix + mkdir -p "$output" + cp -R "$source/." "$output/" + escaped_tag="$(printf '%s' "$tag" | jq -Rr '@html')" + escaped_versions="$(printf '%s' "$versions" | jq -Rr '@html')" + escaped_prefix="$(printf '%s' "$prefix" | jq -Rr '@html')" + find "$output" -type f -name '*.html' -print0 > "$output.pages" + while IFS= read -r -d '' page; do + relative="${page#"$output/"}" + [[ "$relative" != navigation.html ]] || continue + assets="" + directory="$relative" + while [[ "$directory" == */* ]]; do + assets="../$assets" + directory="${directory#*/}" + done + additions=" +" + DOC_VERSION_ADDITIONS="$additions" awk ' + BEGIN { additions = ENVIRON["DOC_VERSION_ADDITIONS"] } + { + if (index($0, "id=\"library-version\"")) slot = 1 + position = index($0, "") + if (position) { + head = 1 + printf "%s%s\n%s\n", substr($0, 1, position - 1), additions, substr($0, position) + } else print + } + END { if (!head || !slot) exit 1 } + ' "$page" > "$page.tmp" || { fail "Dokka page is missing its head or version slot: $relative"; return 1; } + mv "$page.tmp" "$page" + done < "$output.pages" + rm "$output.pages" + cp "$ROOT/.github/scripts/doc-version-selector.js" "$ROOT/.github/scripts/doc-version-selector.css" "$output/" +} + +build_site() ( + set -euo pipefail + local versions=() tag versions_json workspace output stage default_docs="" + mkdir -p "$ROOT/build" + output="$ROOT/build/docs-versioned" + workspace="$(mktemp -d "$ROOT/build/versioned-docs-XXXXXX")" + trap 'status=$?; if [[ -d "$workspace/previous" && ! -e "$output" ]]; then mv "$workspace/previous" "$output" || status=1; fi; rm -rf -- "$workspace"; exit "$status"' EXIT + load_versions "$ROOT/docs/versions.json" > "$workspace/versions" + while IFS= read -r tag; do versions+=("$tag"); done < "$workspace/versions" + versions_json="$(jq -c '.versions' "$ROOT/docs/versions.json")" + for tag in "${versions[@]}"; do verify_release "$tag"; done + for tag in "${versions[@]}"; do + stage="$workspace/$tag" + mkdir "$stage" + export_release "$ROOT" "$tag" "$stage" + build_docs "$stage" + [[ "$tag" != "${versions[0]}" ]] || default_docs="$stage/build/docs" + render_docs "$stage/build/docs" "$workspace/site/versions/$tag" "$tag" "$versions_json" "versions/$tag" + done + render_docs "$default_docs" "$workspace/default" "${versions[0]}" "$versions_json" "" + cp -R "$workspace/default/." "$workspace/site/" + touch "$workspace/site/.nojekyll" + if [[ -e "$output" ]]; then mv "$output" "$workspace/previous"; fi + mv "$workspace/site" "$output" + printf 'Versioned Dokka documentation generated at: %s\n' "$output" +) + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + build_site +fi \ No newline at end of file diff --git a/.github/scripts/generate_versioned_docs.py b/.github/scripts/generate_versioned_docs.py deleted file mode 100644 index ad5511f..0000000 --- a/.github/scripts/generate_versioned_docs.py +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env python3 -"""Build a static Dokka site from explicitly selected published releases.""" - -import html -import io -import json -import os -from pathlib import Path, PurePosixPath -import re -import shutil -import stat -import subprocess -import tempfile -import zipfile - - -ROOT = Path(__file__).resolve().parents[2] -TAG_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?") -PACKAGE_INDEX = Path("c2pa-android/org.contentauth.c2pa/index.html") - - -def load_versions(filename: Path) -> list[str]: - config = json.loads(filename.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 0.0.15.") - 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-android/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_release(root: Path, tag: str, destination: Path) -> None: - 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], - cwd=root, check=True, capture_output=True, - ).stdout - with zipfile.ZipFile(io.BytesIO(archive)) as sources: - for entry in sources.infolist(): - path = PurePosixPath(entry.filename) - if ( - not path.parts - or path.is_absolute() - or ".." in path.parts - or path.parts[0] == ".git" - or any("\\" in part or ":" in part for part in path.parts) - or stat.S_ISLNK(entry.external_attr >> 16) - ): - raise ValueError(f"Unsafe source archive in release {tag}.") - if entry.is_dir(): - continue - target = destination.joinpath(*path.parts) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(sources.read(entry)) - if (entry.external_attr >> 16) & 0o111: - target.chmod(0o755) - required = [ - destination / "gradlew", - destination / "gradle/wrapper/gradle-wrapper.jar", - destination / "library/build.gradle.kts", - destination / "library/MODULE.md", - ] - if not all(path.is_file() for path in required) or not list( - (destination / "library/src/main/kotlin").rglob("*.kt") - ): - raise ValueError(f"Release {tag} is missing its Gradle wrapper or Kotlin API sources.") - - -def build_docs(stage: Path) -> Path: - environment = os.environ.copy() - for name in ("GH_TOKEN", "GITHUB_TOKEN"): - environment.pop(name, None) - subprocess.run( - [ - "./gradlew", "--no-daemon", "--console=plain", - ":library:dokkaGeneratePublicationHtml", "--rerun-tasks", - ], - cwd=stage, env=environment, check=True, - ) - output = stage / "build/docs" - if not (output / "index.html").is_file() or not (output / PACKAGE_INDEX).is_file(): - raise RuntimeError("Dokka did not generate the library and C2PA package landing pages.") - return output - - -def render_docs(source: Path, output: Path, tag: str, versions: list[str], prefix: str) -> None: - shutil.copytree(source, output) - for page in output.rglob("*.html"): - relative = page.relative_to(output) - # Dokka loads this sidebar fragment separately; it is not a complete page. - if relative == Path("navigation.html"): - continue - content = page.read_text(encoding="utf-8") - if "" not in content or 'id="library-version"' not in content: - raise ValueError(f"Dokka page is missing its head or version slot: {relative}") - assets = "../" * (len(relative.parts) - 1) - additions = ( - f'\n' - f'\n' - ) - page.write_text(content.replace("", additions + ""), encoding="utf-8") - for asset in ("doc-version-selector.js", "doc-version-selector.css"): - shutil.copy2(ROOT / ".github/scripts" / asset, output) - - -def build_site() -> Path: - versions = load_versions(ROOT / "docs/versions.json") - for tag in versions: - verify_release(tag) - build = ROOT / "build" - build.mkdir(exist_ok=True) - output = build / "docs-versioned" - with tempfile.TemporaryDirectory(prefix="versioned-docs-", dir=build) as temporary: - workspace = Path(temporary) - site = workspace / "site" - default_docs = None - for tag in versions: - stage = workspace / tag - stage.mkdir() - export_release(ROOT, tag, stage) - generated = build_docs(stage) - if tag == versions[0]: - default_docs = generated - render_docs(generated, site / "versions" / tag, tag, versions, f"versions/{tag}") - if default_docs is None: - raise RuntimeError("No default documentation was built.") - default = workspace / "default" - render_docs(default_docs, default, versions[0], versions, "") - shutil.copytree(default, 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 Dokka documentation generated at: {build_site()}") diff --git a/.github/scripts/test-versioned-docs.sh b/.github/scripts/test-versioned-docs.sh new file mode 100644 index 0000000..c97e764 --- /dev/null +++ b/.github/scripts/test-versioned-docs.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-versioned-docs.sh" +source "$(dirname "$TEST_SCRIPT")/generate-versioned-docs.sh" +SOURCE_ROOT="$ROOT" + +gh() { + [[ "$1" == api ]] || return 1 + printf '{"tag_name":"%s","draft":false}\n' "${2##*/}" +} + +if [[ "${1:-}" == --build ]]; then + ROOT="$2" + build_site + exit +fi + +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/versioned-docs-tests-XXXXXX")" +trap 'rm -rf -- "$TEST_ROOT"' EXIT + +expect_failure() { + if "$@" > "$ROOT/failure.log" 2>&1; then + cat "$ROOT/failure.log" + fail "Expected command to fail: $*" + return 1 + fi +} + +make_dokka() { + local output="$1" relative + mkdir -p "$output/scripts" + for relative in index.html c2pa-android/org.contentauth.c2pa/index.html c2pa-android/org.contentauth.c2pa/-reader/index.html; do + mkdir -p "$(dirname "$output/$relative")" + printf '%s\n' 'C2PA
' > "$output/$relative" + done + printf '%s\n' '
Navigation
' > "$output/navigation.html" + printf '%s\n' '// Existing asset' > "$output/scripts/main.js" +} + +configure_root() { + mkdir -p "$ROOT/docs" "$ROOT/.github/scripts" "$ROOT/build/docs-versioned" "$ROOT/build/docs" + printf '%s\n' '{"versions":["0.0.15","0.0.14"]}' > "$ROOT/docs/versions.json" + cp "$SOURCE_ROOT/.github/scripts/doc-version-selector.js" "$SOURCE_ROOT/.github/scripts/doc-version-selector.css" "$ROOT/.github/scripts/" + printf 'Existing site\n' > "$ROOT/build/docs-versioned/previous.html" + printf 'Working-tree site\n' > "$ROOT/build/docs/working.html" +} + +commit_sources() { + git -C "$ROOT" add -- gradlew gradle library + git -C "$ROOT" -c user.name=Fixture -c user.email=fixture@example.invalid -c commit.gpgsign=false commit -qm fixture +} + +prepare_releases() { + configure_root + git -C "$ROOT" init -q + mkdir -p "$ROOT/gradle/wrapper" "$ROOT/library/src/main/kotlin" + printf fixture > "$ROOT/gradle/wrapper/gradle-wrapper.jar" + printf fixture > "$ROOT/library/build.gradle.kts" + printf '# Module\n' > "$ROOT/library/MODULE.md" + printf 'class Reader\n' > "$ROOT/library/src/main/kotlin/Reader.kt" + { + printf '#!/usr/bin/env bash\nset -euo pipefail\n' + declare -f make_dokka + printf '%s\n' '[[ -z "${GH_TOKEN+x}" && -z "${GITHUB_TOKEN+x}" ]] || exit 70' 'if [[ -f library/FAIL_BUILD ]]; then exit 71; fi' 'make_dokka "$PWD/build/docs"' + } > "$ROOT/gradlew" + chmod +x "$ROOT/gradlew" + commit_sources + git -C "$ROOT" tag 0.0.15 + git -C "$ROOT" tag 0.0.14 +} + +test_versions_preserve_order() { + printf '%s\n' '{"versions":["0.0.16-beta.1","0.0.15"]}' > "$ROOT/config.json" + [[ "$(load_versions "$ROOT/config.json")" == $'0.0.16-beta.1\n0.0.15' ]] +} + +test_invalid_versions() { + local config + for config in '{}' '[]' '{"versions":"0.0.15"}' '{"versions":[]}' \ + '{"versions":["0.0.15","0.0.15"]}' '{"versions":["main"]}' \ + '{"versions":["../0.0.15"]}' '{"versions":["--help"]}' \ + '{"versions":["v0.0.15"]}' '{"versions":["0.0.15\n"]}' \ + '{"versions":[15]}' '{"versions":[null]}' 'not JSON'; do + printf '%s\n' "$config" > "$ROOT/config.json" + expect_failure load_versions "$ROOT/config.json" + done +} + +test_published_releases() { + verify_release 0.0.15 + local response + for response in '[]' '{"tag_name":"0.0.15"}' '{"tag_name":"0.0.15","draft":true}' '{"tag_name":"0.0.14","draft":false}'; do + gh() { printf '%s\n' "$response"; } + expect_failure verify_release 0.0.15 + done + gh() { return 1; } + expect_failure verify_release 0.0.15 +} + +test_export_and_permissions() { + prepare_releases + mkdir "$ROOT/stage" + export_release "$ROOT" 0.0.15 "$ROOT/stage" + [[ -x "$ROOT/stage/gradlew" && -f "$ROOT/stage/library/src/main/kotlin/Reader.kt" ]] + expect_failure export_release "$ROOT" 0.0.1 "$ROOT/stage" +} + +test_unsafe_sources() { + prepare_releases + local name tag=1 + for name in link 'unsafe\name' 'C:escape'; do + if [[ "$name" == link ]]; then ln -s ../outside "$ROOT/library/$name"; else touch "$ROOT/library/$name"; fi + commit_sources + git -C "$ROOT" tag "1.0.$tag" + mkdir "$ROOT/stage-$tag" + expect_failure export_release "$ROOT" "1.0.$tag" "$ROOT/stage-$tag" + [[ ! -e "$ROOT/stage-$tag/gradlew" ]] + rm "$ROOT/library/$name" + tag=$((tag + 1)) + done +} + +test_missing_sources() { + prepare_releases + rm "$ROOT/gradle/wrapper/gradle-wrapper.jar" + commit_sources + git -C "$ROOT" tag 1.0.0 + mkdir "$ROOT/stage" + expect_failure export_release "$ROOT" 1.0.0 "$ROOT/stage" +} + +test_build_removes_tokens() { + prepare_releases + export GH_TOKEN=fixture-token GITHUB_TOKEN=fixture-token + build_docs "$ROOT" + [[ "$GH_TOKEN" == fixture-token && "$GITHUB_TOKEN" == fixture-token ]] +} + +test_missing_dokka_output() { + printf '#!/usr/bin/env bash\nexit 0\n' > "$ROOT/gradlew" + chmod +x "$ROOT/gradlew" + expect_failure build_docs "$ROOT" + mkdir -p "$ROOT/build/docs" + touch "$ROOT/build/docs/index.html" + expect_failure build_docs "$ROOT" +} + +test_render_relative_assets() { + configure_root + make_dokka "$ROOT/source" + local prefix output + for prefix in '' versions/0.0.15 versions/0.0.14; do + output="$ROOT/output/${prefix:-default}" + render_docs "$ROOT/source" "$output" 0.0.15 '["0.0.15","0.0.14"]' "$prefix" + grep -Fq 'src="doc-version-selector.js"' "$output/index.html" + grep -Fq 'src="../../../doc-version-selector.js"' "$output/c2pa-android/org.contentauth.c2pa/-reader/index.html" + grep -Fq 'data-versions="["0.0.15","0.0.14"]"' "$output/index.html" + grep -Fq "data-prefix=\"$prefix\"" "$output/index.html" + cmp "$ROOT/source/navigation.html" "$output/navigation.html" + cmp "$ROOT/source/scripts/main.js" "$output/scripts/main.js" + [[ -f "$output/doc-version-selector.css" && -f "$output/doc-version-selector.js" ]] + done + ! grep -Fq doc-version-selector "$ROOT/source/index.html" +} + +test_invalid_dokka_pages() { + configure_root + make_dokka "$ROOT/source" + local page count=0 + for page in 'No slot' '
'; do + printf '%s\n' "$page" > "$ROOT/source/index.html" + expect_failure render_docs "$ROOT/source" "$ROOT/output-$count" 0.0.15 '["0.0.15"]' '' + count=$((count + 1)) + done +} + +test_complete_site() { + prepare_releases + GH_TOKEN=fixture-token GITHUB_TOKEN=fixture-token bash "$TEST_SCRIPT" --build "$ROOT" + [[ -f "$ROOT/build/docs-versioned/.nojekyll" && ! -e "$ROOT/build/docs-versioned/previous.html" ]] + local prefix + for prefix in '' versions/0.0.15 versions/0.0.14; do + grep -Fq "data-prefix=\"$prefix\"" "$ROOT/build/docs-versioned/$prefix/$PACKAGE_INDEX" + done + grep -Fq 'data-version="0.0.15"' "$ROOT/build/docs-versioned/index.html" + grep -Fq 'data-version="0.0.14"' "$ROOT/build/docs-versioned/versions/0.0.14/index.html" + [[ "$(cat "$ROOT/build/docs/working.html")" == 'Working-tree site' ]] + [[ -z "$(find "$ROOT/build" -name 'versioned-docs-*' -print)" ]] +} + +test_failed_build_preserves_site() { + prepare_releases + touch "$ROOT/library/FAIL_BUILD" + commit_sources + git -C "$ROOT" tag -f 0.0.14 >/dev/null + expect_failure bash "$TEST_SCRIPT" --build "$ROOT" + [[ "$(cat "$ROOT/build/docs-versioned/previous.html")" == 'Existing site' ]] + [[ "$(cat "$ROOT/build/docs/working.html")" == 'Working-tree site' ]] + [[ -z "$(find "$ROOT/build" -name 'versioned-docs-*' -print)" ]] +} + +tests=(test_versions_preserve_order test_invalid_versions test_published_releases + test_export_and_permissions test_unsafe_sources test_missing_sources + test_build_removes_tokens test_missing_dokka_output test_render_relative_assets + test_invalid_dokka_pages test_complete_site test_failed_build_preserves_site) +for test in "${tests[@]}"; do + ROOT="$TEST_ROOT/$test" + mkdir "$ROOT" + set +e + ( set -e; "$test" ) > "$ROOT/test.log" 2>&1 + status=$? + set -e + if [[ "$status" != 0 ]]; then + cat "$ROOT/test.log" + fail "FAIL: $test" + exit 1 + fi + printf 'PASS: %s\n' "$test" +done +printf '%s Bash tests passed.\n' "${#tests[@]}" \ No newline at end of file diff --git a/.github/scripts/test_versioned_docs.py b/.github/scripts/test_versioned_docs.py deleted file mode 100644 index bb713a1..0000000 --- a/.github/scripts/test_versioned_docs.py +++ /dev/null @@ -1,247 +0,0 @@ -import io -import json -import os -from pathlib import Path -import shutil -import stat -import subprocess -import tempfile -import unittest -from unittest.mock import Mock, patch -import zipfile - -import generate_versioned_docs as docs - - -VERSIONS = ["0.0.15", "0.0.14"] -PAGE = 'C2PA
' -NAVIGATION = '' - - -def make_dokka(output): - for relative in [Path("index.html"), docs.PACKAGE_INDEX, docs.PACKAGE_INDEX.parent / "-reader/index.html"]: - page = output / relative - page.parent.mkdir(parents=True, exist_ok=True) - page.write_text(PAGE, encoding="utf-8") - (output / "navigation.html").write_text(NAVIGATION, encoding="utf-8") - (output / "scripts").mkdir() - (output / "scripts/main.js").write_text("// Dokka fixture", encoding="utf-8") - return output - - -class VersionedDocsTests(unittest.TestCase): - def test_versions_preserve_default_and_order(self): - with tempfile.TemporaryDirectory() as directory: - config = Path(directory) / "versions.json" - config.write_text(json.dumps({"versions": VERSIONS})) - self.assertEqual(docs.load_versions(config), VERSIONS) - config.write_text('{"versions": ["0.0.16-beta.1", "0.0.15"]}') - self.assertEqual(docs.load_versions(config)[0], "0.0.16-beta.1") - - def test_invalid_versions_fail(self): - invalid = [ - {}, [], {"versions": "0.0.15"}, - *({"versions": versions} for versions in [ - [], ["0.0.15", "0.0.15"], ["main"], ["../0.0.15"], - ["--help"], ["v0.0.15"], ["0.0.15\n"], [15], [None], - ]), - ] - 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)) - with self.assertRaises(ValueError): - docs.load_versions(config) - - @patch.object(docs.subprocess, "run") - def test_only_matching_published_releases_are_accepted(self, run): - run.return_value = Mock(stdout=json.dumps({"tag_name": VERSIONS[0], "draft": False})) - docs.verify_release(VERSIONS[0]) - run.assert_called_once_with( - ["gh", "api", f"repos/contentauth/c2pa-android/releases/tags/{VERSIONS[0]}"], - check=True, stdout=subprocess.PIPE, text=True, - ) - for release in [ - [], {"tag_name": VERSIONS[0]}, {"tag_name": VERSIONS[0], "draft": True}, - {"tag_name": VERSIONS[1], "draft": False}, - ]: - with self.subTest(release=release): - run.return_value = Mock(stdout=json.dumps(release)) - with self.assertRaises(ValueError): - docs.verify_release(VERSIONS[0]) - run.side_effect = subprocess.CalledProcessError(1, ["gh", "api"]) - with self.assertRaises(subprocess.CalledProcessError): - docs.verify_release(VERSIONS[0]) - - def make_archive(self, filename="gradlew", mode=stat.S_IFREG | 0o755): - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w") as archive: - archive.writestr("gradle/wrapper/gradle-wrapper.jar", b"fixture") - archive.writestr("library/build.gradle.kts", "// fixture") - archive.writestr("library/MODULE.md", "# Module c2pa-android") - archive.writestr("library/src/main/kotlin/Reader.kt", "class Reader") - entry = zipfile.ZipInfo(filename) - entry.external_attr = mode << 16 - archive.writestr(entry, "#!/bin/sh\nexit 0\n") - return buffer.getvalue() - - @patch.object(docs.subprocess, "run") - def test_export_resolves_exact_tag_and_preserves_wrapper_permissions(self, run): - run.side_effect = [Mock(stdout="a" * 40 + "\n"), Mock(stdout=self.make_archive())] - with tempfile.TemporaryDirectory() as directory: - stage = Path(directory) - docs.export_release(docs.ROOT, VERSIONS[0], stage) - self.assertTrue((stage / "gradlew").stat().st_mode & stat.S_IXUSR) - self.assertTrue((stage / "library/src/main/kotlin/Reader.kt").is_file()) - self.assertEqual( - run.call_args_list[0].args[0], - ["git", "rev-parse", "--verify", f"refs/tags/{VERSIONS[0]}^{{commit}}"], - ) - self.assertEqual(run.call_args_list[1].args[0], ["git", "archive", "--format=zip", "a" * 40]) - - @patch.object(docs.subprocess, "run") - def test_unsafe_archives_are_rejected(self, run): - for filename, mode in [ - ("../escape", stat.S_IFREG), ("/escape", stat.S_IFREG), - ("library/..\\escape", stat.S_IFREG), ("C:/escape", stat.S_IFREG), - (".git/config", stat.S_IFREG), ("link", stat.S_IFLNK), - ]: - 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.assertRaisesRegex(ValueError, "Unsafe source archive"): - docs.export_release(docs.ROOT, VERSIONS[0], Path(directory)) - - @patch.object(docs.subprocess, "run") - def test_missing_wrapper_is_rejected(self, run): - run.side_effect = [Mock(stdout="a" * 40), Mock(stdout=self.make_archive("README.md"))] - with tempfile.TemporaryDirectory() as directory: - with self.assertRaisesRegex(ValueError, "missing its Gradle wrapper"): - docs.export_release(docs.ROOT, VERSIONS[0], Path(directory)) - - @patch.dict(os.environ, {"GH_TOKEN": "documentation-test-value", "GITHUB_TOKEN": "documentation-test-value"}) - @patch.object(docs.subprocess, "run") - def test_build_uses_release_dokka_without_github_tokens(self, run): - with tempfile.TemporaryDirectory() as directory: - stage = Path(directory) - generated = make_dokka(stage / "build/docs") - self.assertEqual(docs.build_docs(stage), generated) - self.assertEqual( - run.call_args.args[0], - ["./gradlew", "--no-daemon", "--console=plain", - ":library:dokkaGeneratePublicationHtml", "--rerun-tasks"], - ) - self.assertEqual(run.call_args.kwargs["cwd"], stage) - self.assertTrue(run.call_args.kwargs["check"]) - self.assertNotIn("GH_TOKEN", run.call_args.kwargs["env"]) - self.assertNotIn("GITHUB_TOKEN", run.call_args.kwargs["env"]) - - @patch.object(docs.subprocess, "run") - def test_missing_dokka_output_fails(self, run): - with tempfile.TemporaryDirectory() as directory: - stage = Path(directory) - with self.assertRaisesRegex(RuntimeError, "landing pages"): - docs.build_docs(stage) - (stage / "build/docs").mkdir(parents=True) - (stage / "build/docs/index.html").write_text(PAGE) - with self.assertRaisesRegex(RuntimeError, "landing pages"): - docs.build_docs(stage) - - def test_assets_are_relative_on_default_and_deep_version_pages(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - source = make_dokka(root / "source") - for prefix in ["", f"versions/{VERSIONS[0]}", f"versions/{VERSIONS[1]}"]: - with self.subTest(prefix=prefix): - output = root / (prefix or "default") - docs.render_docs(source, output, VERSIONS[0], VERSIONS, prefix) - self.assertIn('src="doc-version-selector.js"', (output / "index.html").read_text()) - for relative in [docs.PACKAGE_INDEX, docs.PACKAGE_INDEX.parent / "-reader/index.html"]: - page = (output / relative).read_text() - assets = "../" * (len(relative.parts) - 1) - self.assertIn(f'src="{assets}doc-version-selector.js"', page) - self.assertIn(f'data-prefix="{prefix}"', page) - self.assertIn('data-versions="["0.0.15", "0.0.14"]"', page) - self.assertTrue((output / "doc-version-selector.css").is_file()) - self.assertTrue((output / "doc-version-selector.js").is_file()) - self.assertEqual((source / "index.html").read_text(), PAGE) - - def test_dokka_navigation_fragment_and_existing_assets_are_preserved(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - source = make_dokka(root / "source") - output = root / "output" - docs.render_docs(source, output, VERSIONS[0], VERSIONS, "") - self.assertEqual((output / "navigation.html").read_text(), NAVIGATION) - self.assertEqual((output / "scripts/main.js").read_text(), "// Dokka fixture") - - def test_missing_head_or_version_slot_fails_explicitly(self): - for content in ["No slot", '
']: - with self.subTest(content=content), tempfile.TemporaryDirectory() as directory: - root = Path(directory) - source = make_dokka(root / "source") - (source / "index.html").write_text(content) - with self.assertRaisesRegex(ValueError, "missing its head or version slot"): - docs.render_docs(source, root / "output", VERSIONS[0], VERSIONS, "") - - def configure_root(self, root): - (root / "docs").mkdir() - (root / "docs/versions.json").write_text(json.dumps({"versions": VERSIONS})) - scripts = root / ".github/scripts" - scripts.mkdir(parents=True) - for asset in ["doc-version-selector.css", "doc-version-selector.js"]: - shutil.copy2(docs.ROOT / ".github/scripts" / asset, scripts) - previous = root / "build/docs-versioned" - previous.mkdir(parents=True) - (previous / "previous.html").write_text("Existing site") - working = root / "build/docs" - working.mkdir() - (working / "working.html").write_text("Working-tree site") - - def test_failed_release_build_preserves_previous_site_and_cleans_workspace(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - self.configure_root(root) - def build(stage): - if stage.name == VERSIONS[1]: - raise subprocess.CalledProcessError(1, ["./gradlew"]) - return make_dokka(stage / "build/docs") - with ( - patch.object(docs, "ROOT", root), - patch.object(docs, "verify_release"), - patch.object(docs, "export_release"), - patch.object(docs, "build_docs", side_effect=build), - ): - with self.assertRaises(subprocess.CalledProcessError): - docs.build_site() - self.assertEqual((root / "build/docs-versioned/previous.html").read_text(), "Existing site") - self.assertEqual(list((root / "build").glob("versioned-docs-*")), []) - - def test_complete_site_has_default_and_both_versions_without_touching_working_docs(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - self.configure_root(root) - with ( - patch.object(docs, "ROOT", root), - patch.object(docs, "verify_release") as verify, - patch.object(docs, "export_release") as export, - patch.object(docs, "build_docs", side_effect=lambda stage: make_dokka(stage / "build/docs")), - ): - output = docs.build_site() - self.assertEqual(output, root / "build/docs-versioned") - self.assertTrue((output / ".nojekyll").is_file()) - self.assertFalse((output / "previous.html").exists()) - for prefix, tag in [("", VERSIONS[0]), *(("versions/" + tag, tag) for tag in VERSIONS)]: - page = (output / prefix / docs.PACKAGE_INDEX).read_text() - self.assertIn(f'data-version="{tag}"', page) - self.assertIn(f'data-prefix="{prefix}"', page) - self.assertEqual([call.args[0] for call in verify.call_args_list], VERSIONS) - self.assertEqual([call.args[1] for call in export.call_args_list], VERSIONS) - self.assertEqual((root / "build/docs/working.html").read_text(), "Working-tree site") - self.assertEqual(list((root / "build").glob("versioned-docs-*")), []) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f5d3e57..6ffa8da 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -40,14 +40,13 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@v6 - with: - python-version: "3.10" - - uses: actions/setup-node@v6 with: node-version: "24" + - name: Check documentation tooling + run: bash --version && jq --version + - name: Resolve JDK version id: java run: echo "version=$(.github/scripts/java-version.sh)" >> "$GITHUB_OUTPUT" diff --git a/Makefile b/Makefile index 9111598..68713cb 100644 --- a/Makefile +++ b/Makefile @@ -103,10 +103,10 @@ docs: # Generate API documentation from selected published releases docs-versioned: - python3 .github/scripts/generate_versioned_docs.py + bash .github/scripts/generate-versioned-docs.sh docs-test: - python3 -m unittest discover -s .github/scripts -p 'test_versioned_docs.py' + bash .github/scripts/test-versioned-docs.sh node --test .github/scripts/test-doc-version-selector.cjs # Clean generated working-tree documentation diff --git a/README.md b/README.md index 6f8fbf6..6055a84 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ continues to serve the default version. ### Building documentation locally -To build the selected releases, install Python 3.10 or newer, Git, the GitHub CLI +To build the selected releases, install Bash 3.2 or newer, `jq`, Git, the GitHub CLI (`gh`), and the JDK and Android SDK required by those releases. Authenticate `gh` or set `GH_TOKEN` to a token with read access to the repository, then run: @@ -111,8 +111,8 @@ after all selected releases build successfully. The existing `make docs` command still generates working-tree documentation in `build/docs/`; it does not replace the versioned site. Run `make docs-test` to test -the versioning tooling and selector (Python 3.10+ and Node.js 18+; no Android SDK -or JDK required for these unit tests). +the versioning tooling and selector (Bash 3.2+, `jq`, Git, and Node.js 18+; no +Python, Android SDK, or JDK required for these unit tests). ### Maintaining versions