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.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/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.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/workflows/docs.yml b/.github/workflows/docs.yml
index 8925b33..6ffa8da 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,18 @@ 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-node@v6
+ with:
+ node-version: "24"
+
+ - name: Check documentation tooling
+ run: bash --version && jq --version
- name: Resolve JDK version
id: java
@@ -45,16 +70,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..68713cb 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:
+ bash .github/scripts/generate-versioned-docs.sh
+
+docs-test:
+ bash .github/scripts/test-versioned-docs.sh
+ 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..6055a84 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 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:
+
+```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 (Bash 3.2+, `jq`, Git, and Node.js 18+; no
+Python, 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"
+ ]
+}