From f005611731d320f34cad500c0d7cbe60eb4e22b3 Mon Sep 17 00:00:00 2001 From: AKILSURYA SIVAKUMAR Date: Wed, 28 Oct 2026 12:17:29 -0700 Subject: [PATCH 1/7] Add versioned documentation publishing and navigation Signed-off-by: AKILSURYA SIVAKUMAR --- .github/workflows/doc.yml | 39 +++++++++- docs/README.md | 26 +++++++ docs/publish.py | 63 ++++++++++++++++ docs/source/_static/custom.css | 9 +++ docs/source/_static/versions.js | 32 +++++++++ .../_templates/partials/globaltoc-above.html | 9 +++ docs/source/conf.py | 6 ++ test/test_docs_publish.py | 71 +++++++++++++++++++ 8 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/publish.py create mode 100644 docs/source/_static/versions.js create mode 100644 docs/source/_templates/partials/globaltoc-above.html create mode 100644 test/test_docs_publish.py diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 60a2245173..4a96ad148c 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -5,6 +5,8 @@ on: # yamllint disable-line rule:truthy push: branches: - main + release: + types: [published] pull_request: permissions: @@ -16,10 +18,15 @@ concurrency: jobs: make_html: + if: github.event_name != 'release' || !github.event.release.prerelease runs-on: ubuntu-latest + env: + SDM_DOC_VERSION: ${{ github.event.release.tag_name || 'latest' }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 @@ -48,9 +55,37 @@ jobs: run: | uv run --no-project sphinx-build -M doctest "source" "build" -W --keep-going + publish: + if: github.event_name == 'push' || github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') + needs: make_html + runs-on: ubuntu-latest + concurrency: + group: documentation-publish + cancel-in-progress: false + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Download documentation + uses: actions/download-artifact@v6 + with: + name: documentation + path: docs/build/html + + - name: Check out published versions + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: gh-pages + path: docs/build/site + + - name: Stage documentation + env: + SDM_DOC_VERSION: ${{ github.event.release.tag_name || 'latest' }} + run: python docs/publish.py docs/build/html docs/build/site "$SDM_DOC_VERSION" + - name: Push documentation to gh-pages - if: github.event_name == 'push' uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: docs/build/html + publish_dir: docs/build/site + exclude_assets: '.git,.github' diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000..c2d8e40567 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,26 @@ +# Documentation + +Install the package and documentation dependencies, then build HTML with warnings treated as errors: + +```bash +uv pip install --torch-backend=cpu --group=doc . +uv run --no-project sphinx-build -M html docs/source docs/build -W --keep-going +``` + +## Published versions + +- The site root contains **latest (main)**. Existing documentation links continue to work. +- Publishing a non-prerelease GitHub release builds the documentation from that release's tag and stores it at `versions/vX.Y.Z/`. Release tags must use the `vX.Y.Z` format. +- Every publication preserves other release builds and updates `versions.json`. The version selector reads this shared index, so archived pages can discover newly published releases without being rebuilt. Switching versions opens the selected version's home page because an individual page may not exist in another release. +- Pull requests build HTML and run doctests without publishing. The workflow can also be dispatched on `main` to rebuild the development documentation. + +Release publishing starts with tags that contain the versioning workflow and assets. Earlier releases are not automatically backfilled. Each release build installs the package and documentation dependencies from its own checkout; it does not document the current `main` API under an older version label. + +For a local release preview, set `SDM_DOC_VERSION=vX.Y.Z` while building the matching release checkout. Serve the staged site over HTTP to exercise the selector: + +```bash +python docs/publish.py docs/build/html docs/build/site vX.Y.Z +python -m http.server --directory docs/build/site +``` + +Without a published index, the selector displays the current version and remains disabled. diff --git a/docs/publish.py b/docs/publish.py new file mode 100644 index 0000000000..65f4818421 --- /dev/null +++ b/docs/publish.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Stage documentation in an existing gh-pages checkout before publishing.""" + +import argparse +import json +import re +import shutil +from pathlib import Path + +RELEASE_TAG = re.compile(r"v\d+\.\d+\.\d+") + + +def stage(source: Path, site: Path, version: str) -> None: + """Replace one build while preserving all other published versions.""" + if version != "latest" and not RELEASE_TAG.fullmatch(version): + raise ValueError( + f"Expected latest or a stable vX.Y.Z tag, got {version}" + ) + if not (source / "index.html").is_file(): + raise FileNotFoundError(source / "index.html") + + site.mkdir(parents=True, exist_ok=True) + if version == "latest": + destination = site + for path in site.iterdir(): + if path.name not in {".git", "versions", "CNAME", ".nojekyll"}: + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + else: + destination = site / "versions" / version + if destination.exists(): + shutil.rmtree(destination) + + shutil.copytree(source, destination, dirs_exist_ok=True) + releases = sorted( + ( + path.name + for path in (site / "versions").glob("v*") + if RELEASE_TAG.fullmatch(path.name) + and (path / "index.html").is_file() + ), + key=lambda tag: tuple(int(part) for part in tag[1:].split(".")), + reverse=True, + ) + versions = [{"version": "latest", "path": "./"}] + versions.extend( + {"version": tag, "path": f"versions/{tag}/"} for tag in releases + ) + (site / "versions.json").write_text(json.dumps(versions, indent=2) + "\n") + (site / ".nojekyll").touch() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + parser.add_argument("site", type=Path) + parser.add_argument("version") + args = parser.parse_args() + stage(args.source, args.site, args.version) diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css index dfd2611c00..62e83a433c 100644 --- a/docs/source/_static/custom.css +++ b/docs/source/_static/custom.css @@ -21,3 +21,12 @@ #structured-data-models h1 { text-align: center; } + +.sdm-doc-version { + margin-bottom: 1rem; +} + +.sdm-doc-version select { + color: inherit; + background: transparent; +} diff --git a/docs/source/_static/versions.js b/docs/source/_static/versions.js new file mode 100644 index 0000000000..700a258394 --- /dev/null +++ b/docs/source/_static/versions.js @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +(async () => { + const select = document.getElementById("sdm-doc-version"); + if (!select) return; + + const currentRoot = new URL(select.dataset.root, window.location.href); + const siteRoot = new URL( + select.dataset.version === "latest" ? "./" : "../../", + currentRoot, + ); + + try { + const response = await fetch(new URL("versions.json", siteRoot)); + if (!response.ok) return; + const versions = await response.json(); + select.replaceChildren(...versions.map(({ version, path }) => { + const option = new Option( + version === "latest" ? "latest (main)" : version, + new URL(path, siteRoot).href, + ); + option.selected = version === select.dataset.version; + return option; + })); + select.disabled = false; + // Version home pages are always present; individual APIs may not be. + select.addEventListener("change", () => window.location.assign(select.value)); + } catch { + // Local builds and offline readers still see the current version. + } +})(); diff --git a/docs/source/_templates/partials/globaltoc-above.html b/docs/source/_templates/partials/globaltoc-above.html new file mode 100644 index 0000000000..c8fa11a29c --- /dev/null +++ b/docs/source/_templates/partials/globaltoc-above.html @@ -0,0 +1,9 @@ +{% include "!partials/globaltoc-above.html" %} + +
+ + +
diff --git a/docs/source/conf.py b/docs/source/conf.py index 6b471dd833..5fc77a862e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import importlib +import os from datetime import date from importlib.metadata import PackageNotFoundError from importlib.metadata import version as package_version @@ -42,8 +43,13 @@ "accent_color": "green", "github_url": "https://github.com/NVIDIA/structured-data-models", } +doc_version = os.environ.get("SDM_DOC_VERSION", "latest") +html_context = {"doc_version": doc_version} +if doc_version != "latest": + html_context["source_version"] = doc_version html_static_path = ["_static"] html_css_files = ["custom.css"] +html_js_files = ["versions.js"] autosummary_generate = True autosummary_context = {"import_module": importlib.import_module} autodoc_member_order = "bysource" diff --git a/test/test_docs_publish.py b/test/test_docs_publish.py new file mode 100644 index 0000000000..758c66d4d9 --- /dev/null +++ b/test/test_docs_publish.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import json +import runpy +from pathlib import Path + +import pytest + +stage = runpy.run_path(str(Path(__file__).parents[1] / "docs/publish.py"))[ + "stage" +] + + +def test_publish_preserves_releases_and_replaces_latest( + tmp_path: Path, +) -> None: + source = tmp_path / "html" + source.mkdir() + (source / "index.html").write_text("release") + site = tmp_path / "site" + stage(source, site, "v0.2.0") + stage(source, site, "v0.10.0") + (site / "obsolete.html").write_text("obsolete") + (site / "CNAME").write_text("docs.example.com") + (source / "index.html").write_text("development") + stage(source, site, "latest") + + assert (site / "index.html").read_text() == "development" + assert not (site / "obsolete.html").exists() + assert (site / "CNAME").read_text() == "docs.example.com" + assert (site / "versions/v0.2.0/index.html").read_text() == "release" + assert (site / "versions/v0.10.0/index.html").read_text() == "release" + assert json.loads((site / "versions.json").read_text()) == [ + {"version": "latest", "path": "./"}, + {"version": "v0.10.0", "path": "versions/v0.10.0/"}, + {"version": "v0.2.0", "path": "versions/v0.2.0/"}, + ] + assert (site / ".nojekyll").exists() + + +def test_republish_replaces_only_requested_release(tmp_path: Path) -> None: + source = tmp_path / "html" + source.mkdir() + (source / "index.html").write_text("original") + (source / "removed.html").write_text("removed") + site = tmp_path / "site" + stage(source, site, "latest") + stage(source, site, "v0.2.0") + (source / "removed.html").unlink() + (source / "index.html").write_text("rebuilt") + stage(source, site, "v0.2.0") + + assert (site / "index.html").read_text() == "original" + assert (site / "versions/v0.2.0/index.html").read_text() == "rebuilt" + assert not (site / "versions/v0.2.0/removed.html").exists() + + +@pytest.mark.parametrize("version", ["../escape", "v0.2.0rc1", "main"]) +def test_invalid_version_does_not_modify_site( + tmp_path: Path, version: str +) -> None: + with pytest.raises(ValueError, match=r"stable vX\.Y\.Z"): + stage(tmp_path / "html", tmp_path / "site", version) + assert not (tmp_path / "site").exists() + + +def test_missing_build_does_not_modify_site(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + stage(tmp_path / "html", tmp_path / "site", "latest") + assert not (tmp_path / "site").exists() From 27f0d9edd13cc2d92ad23bc0ff4a86757496c3c2 Mon Sep 17 00:00:00 2001 From: AKILSURYA SIVAKUMAR Date: Thu, 29 Oct 2026 10:38:08 -0700 Subject: [PATCH 2/7] Support backfilling documentation from existing release tags Signed-off-by: AKILSURYA SIVAKUMAR --- .github/workflows/doc.yml | 39 ++++++++++++++++++++++++++++++++------- docs/README.md | 8 +++++--- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 4a96ad148c..10a50a711b 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -2,6 +2,11 @@ name: Documentation on: # yamllint disable-line rule:truthy workflow_dispatch: + inputs: + release_tag: + description: 'Stable release tag to publish (vX.Y.Z); leave empty for main' + required: false + type: string push: branches: - main @@ -10,7 +15,7 @@ on: # yamllint disable-line rule:truthy pull_request: permissions: - contents: write # gh-pages push + contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ startsWith(github.ref, 'refs/pull/') || github.run_number }} # yamllint disable-line @@ -21,13 +26,30 @@ jobs: if: github.event_name != 'release' || !github.event.release.prerelease runs-on: ubuntu-latest env: - SDM_DOC_VERSION: ${{ github.event.release.tag_name || 'latest' }} + SDM_DOC_VERSION: ${{ github.event.release.tag_name || inputs.release_tag || 'latest' }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + - name: Validate release tag + if: env.SDM_DOC_VERSION != 'latest' + shell: bash + run: | + if [[ ! "$SDM_DOC_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo '::error::Release documentation requires a vX.Y.Z tag' + exit 1 + fi + + - name: Check out documentation source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ env.SDM_DOC_VERSION == 'latest' && github.sha || format('refs/tags/{0}', env.SDM_DOC_VERSION) }} + path: .doc-source + fetch-depth: 0 + persist-credentials: false + - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: @@ -35,13 +57,14 @@ jobs: activate-environment: true - name: Install package + working-directory: ./.doc-source run: | uv pip install --torch-backend=cpu --group=doc . - name: Build documentation - working-directory: ./docs + working-directory: ./.doc-source/docs run: | - uv run --no-project sphinx-build -M html "source" "build" -W --keep-going + uv run --no-project sphinx-build -M html source "$GITHUB_WORKSPACE/docs/build" -c "$GITHUB_WORKSPACE/docs/source" -W --keep-going - name: Upload documentation uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -51,14 +74,16 @@ jobs: retention-days: 1 - name: Run doctests - working-directory: ./docs + working-directory: ./.doc-source/docs run: | - uv run --no-project sphinx-build -M doctest "source" "build" -W --keep-going + uv run --no-project sphinx-build -M doctest source "$GITHUB_WORKSPACE/docs/build" -c "$GITHUB_WORKSPACE/docs/source" -W --keep-going publish: if: github.event_name == 'push' || github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') needs: make_html runs-on: ubuntu-latest + permissions: + contents: write # gh-pages push concurrency: group: documentation-publish cancel-in-progress: false @@ -80,7 +105,7 @@ jobs: - name: Stage documentation env: - SDM_DOC_VERSION: ${{ github.event.release.tag_name || 'latest' }} + SDM_DOC_VERSION: ${{ github.event.release.tag_name || inputs.release_tag || 'latest' }} run: python docs/publish.py docs/build/html docs/build/site "$SDM_DOC_VERSION" - name: Push documentation to gh-pages diff --git a/docs/README.md b/docs/README.md index c2d8e40567..4183916d10 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,11 +12,13 @@ uv run --no-project sphinx-build -M html docs/source docs/build -W --keep-going - The site root contains **latest (main)**. Existing documentation links continue to work. - Publishing a non-prerelease GitHub release builds the documentation from that release's tag and stores it at `versions/vX.Y.Z/`. Release tags must use the `vX.Y.Z` format. - Every publication preserves other release builds and updates `versions.json`. The version selector reads this shared index, so archived pages can discover newly published releases without being rebuilt. Switching versions opens the selected version's home page because an individual page may not exist in another release. -- Pull requests build HTML and run doctests without publishing. The workflow can also be dispatched on `main` to rebuild the development documentation. +- Pull requests build HTML and run doctests without publishing. Dispatch the workflow on `main` with an empty `release_tag` to rebuild the development documentation, or supply an existing stable tag such as `v0.2.1` to publish or rebuild that release. -Release publishing starts with tags that contain the versioning workflow and assets. Earlier releases are not automatically backfilled. Each release build installs the package and documentation dependencies from its own checkout; it does not document the current `main` API under an older version label. +To backfill releases that predate this workflow, run **Actions → Documentation → Run workflow** on `main` and enter the release tag. Repeat for each release to archive. Publishing a new stable GitHub release archives it automatically once its tag contains this workflow. -For a local release preview, set `SDM_DOC_VERSION=vX.Y.Z` while building the matching release checkout. Serve the staged site over HTTP to exercise the selector: +Each build installs the package and documentation dependencies from the requested source checkout and reads that checkout's documentation pages. The Sphinx configuration, theme templates, and version selector come from the workflow checkout, so older tags can use the current publishing tools without changing their model code or documenting the current `main` API under an older version label. Builds and doctests must pass before anything is published. Only the publishing job has write permission. + +For a local release preview, install the matching release checkout into a separate environment. Build its documentation with `SDM_DOC_VERSION=vX.Y.Z` and `-c /path/to/current-checkout/docs/source` to use the versioning configuration. Serve the staged site over HTTP to exercise the selector: ```bash python docs/publish.py docs/build/html docs/build/site vX.Y.Z From 77ed76fb835f692f87ade49fc1bdbee3259ecabc Mon Sep 17 00:00:00 2001 From: AKILSURYA SIVAKUMAR Date: Thu, 29 Oct 2026 11:34:37 -0700 Subject: [PATCH 3/7] Prepare documentation hosting on Read the Docs Signed-off-by: AKILSURYA SIVAKUMAR --- .github/workflows/doc.yml | 73 +------------------ .readthedocs.yaml | 17 +++++ docs/README.md | 27 ++++--- docs/publish.py | 63 ---------------- docs/source/_static/custom.css | 9 --- docs/source/_static/versions.js | 32 -------- .../_templates/partials/globaltoc-above.html | 9 --- docs/source/conf.py | 6 +- test/test_docs_publish.py | 71 ------------------ 9 files changed, 35 insertions(+), 272 deletions(-) create mode 100644 .readthedocs.yaml delete mode 100644 docs/publish.py delete mode 100644 docs/source/_static/versions.js delete mode 100644 docs/source/_templates/partials/globaltoc-above.html delete mode 100644 test/test_docs_publish.py diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 10a50a711b..9a1cfda755 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -2,16 +2,9 @@ name: Documentation on: # yamllint disable-line rule:truthy workflow_dispatch: - inputs: - release_tag: - description: 'Stable release tag to publish (vX.Y.Z); leave empty for main' - required: false - type: string push: branches: - main - release: - types: [published] pull_request: permissions: @@ -23,33 +16,13 @@ concurrency: jobs: make_html: - if: github.event_name != 'release' || !github.event.release.prerelease runs-on: ubuntu-latest - env: - SDM_DOC_VERSION: ${{ github.event.release.tag_name || inputs.release_tag || 'latest' }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - - name: Validate release tag - if: env.SDM_DOC_VERSION != 'latest' - shell: bash - run: | - if [[ ! "$SDM_DOC_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo '::error::Release documentation requires a vX.Y.Z tag' - exit 1 - fi - - - name: Check out documentation source - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - ref: ${{ env.SDM_DOC_VERSION == 'latest' && github.sha || format('refs/tags/{0}', env.SDM_DOC_VERSION) }} - path: .doc-source - fetch-depth: 0 - persist-credentials: false - - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: @@ -57,14 +30,13 @@ jobs: activate-environment: true - name: Install package - working-directory: ./.doc-source run: | uv pip install --torch-backend=cpu --group=doc . - name: Build documentation - working-directory: ./.doc-source/docs + working-directory: ./docs run: | - uv run --no-project sphinx-build -M html source "$GITHUB_WORKSPACE/docs/build" -c "$GITHUB_WORKSPACE/docs/source" -W --keep-going + uv run --no-project sphinx-build -M html "source" "build" -W --keep-going - name: Upload documentation uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -74,43 +46,6 @@ jobs: retention-days: 1 - name: Run doctests - working-directory: ./.doc-source/docs + working-directory: ./docs run: | - uv run --no-project sphinx-build -M doctest source "$GITHUB_WORKSPACE/docs/build" -c "$GITHUB_WORKSPACE/docs/source" -W --keep-going - - publish: - if: github.event_name == 'push' || github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') - needs: make_html - runs-on: ubuntu-latest - permissions: - contents: write # gh-pages push - concurrency: - group: documentation-publish - cancel-in-progress: false - - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Download documentation - uses: actions/download-artifact@v6 - with: - name: documentation - path: docs/build/html - - - name: Check out published versions - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - ref: gh-pages - path: docs/build/site - - - name: Stage documentation - env: - SDM_DOC_VERSION: ${{ github.event.release.tag_name || inputs.release_tag || 'latest' }} - run: python docs/publish.py docs/build/html docs/build/site "$SDM_DOC_VERSION" - - - name: Push documentation to gh-pages - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: docs/build/site - exclude_assets: '.git,.github' + uv run --no-project sphinx-build -M doctest "source" "build" -W --keep-going diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000000..0c47370553 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,17 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: '3.11' + jobs: + post_checkout: + - if [ "$(git rev-parse --is-shallow-repository)" = true ]; then git fetch --unshallow; fi + - git fetch --tags + post_install: + - python -m pip install uv + - python -m uv pip install --python "$READTHEDOCS_VIRTUALENV_PATH/bin/python" --torch-backend=cpu --group=doc . + +sphinx: + configuration: docs/source/conf.py + fail_on_warning: true diff --git a/docs/README.md b/docs/README.md index 4183916d10..3164d8aac0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,28 +1,27 @@ # Documentation -Install the package and documentation dependencies, then build HTML with warnings treated as errors: +Install the package and documentation dependencies, then build HTML and run doctests with warnings treated as errors: ```bash uv pip install --torch-backend=cpu --group=doc . uv run --no-project sphinx-build -M html docs/source docs/build -W --keep-going +uv run --no-project sphinx-build -M doctest docs/source docs/build -W --keep-going ``` -## Published versions +## Read the Docs -- The site root contains **latest (main)**. Existing documentation links continue to work. -- Publishing a non-prerelease GitHub release builds the documentation from that release's tag and stores it at `versions/vX.Y.Z/`. Release tags must use the `vX.Y.Z` format. -- Every publication preserves other release builds and updates `versions.json`. The version selector reads this shared index, so archived pages can discover newly published releases without being rebuilt. Switching versions opens the selected version's home page because an individual page may not exist in another release. -- Pull requests build HTML and run doctests without publishing. Dispatch the workflow on `main` with an empty `release_tag` to rebuild the development documentation, or supply an existing stable tag such as `v0.2.1` to publish or rebuild that release. +[`.readthedocs.yaml`](../.readthedocs.yaml) builds the existing Sphinx site on Python 3.11 with CPU-only PyTorch and the `doc` dependency group. It fetches Git history and tags so `setuptools-scm` can identify the installed package version. Read the Docs handles hosting and its built-in version menu; no custom archive, version index, or selector is required. GitHub Actions continues to validate HTML and doctests without publishing to GitHub Pages. -To backfill releases that predate this workflow, run **Actions → Documentation → Run workflow** on `main` and enter the release tag. Repeat for each release to archive. Publishing a new stable GitHub release archives it automatically once its tag contains this workflow. +The Sphinx configuration uses `READTHEDOCS_CANONICAL_URL` for canonical page URLs and the theme's copied-source URLs, including release documentation and pull request previews. Local builds work without this environment variable. -Each build installs the package and documentation dependencies from the requested source checkout and reads that checkout's documentation pages. The Sphinx configuration, theme templates, and version selector come from the workflow checkout, so older tags can use the current publishing tools without changing their model code or documenting the current `main` API under an older version label. Builds and doctests must pass before anything is published. Only the publishing job has write permission. +## Maintainer setup -For a local release preview, install the matching release checkout into a separate environment. Build its documentation with `SDM_DOC_VERSION=vX.Y.Z` and `-c /path/to/current-checkout/docs/source` to use the versioning configuration. Serve the staged site over HTTP to exercise the selector: +1. Import this repository into the organization's Read the Docs account and enable its GitHub integration, using `.readthedocs.yaml` as the configuration file. +2. Build `latest` from `main` and verify the generated API pages and version menu. Enable pull request builds if preview documentation is desired. +3. Activate supported release versions. Read the Docs provides `stable` for the greatest stable semantic version and `latest` for the default branch; configure the default documentation version and version-activation rules in the dashboard. +4. Plan older-release builds separately: immutable tags predating `.readthedocs.yaml` do not inherit configuration from `main`. Backport documentation configuration to maintained release branches where appropriate, and validate each historical version before advertising it. Do not move existing release tags. +5. Once the hosted site and its final URL are verified, update public documentation links and arrange redirects from the existing GitHub Pages URLs. This change stops Pages publication but leaves the previously published site intact during the migration. -```bash -python docs/publish.py docs/build/html docs/build/site vX.Y.Z -python -m http.server --directory docs/build/site -``` +Account setup, version activation, preview builds, and redirects require maintainer access. A local Sphinx build verifies the documentation but cannot verify these hosted settings. -Without a published index, the selector displays the current version and remains disabled. +See the Read the Docs guides for [version management](https://docs.readthedocs.com/platform/stable/versions.html), [build customization](https://docs.readthedocs.com/platform/stable/build-customization.html), and the [version menu](https://docs.readthedocs.com/platform/stable/flyout-menu.html). diff --git a/docs/publish.py b/docs/publish.py deleted file mode 100644 index 65f4818421..0000000000 --- a/docs/publish.py +++ /dev/null @@ -1,63 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Stage documentation in an existing gh-pages checkout before publishing.""" - -import argparse -import json -import re -import shutil -from pathlib import Path - -RELEASE_TAG = re.compile(r"v\d+\.\d+\.\d+") - - -def stage(source: Path, site: Path, version: str) -> None: - """Replace one build while preserving all other published versions.""" - if version != "latest" and not RELEASE_TAG.fullmatch(version): - raise ValueError( - f"Expected latest or a stable vX.Y.Z tag, got {version}" - ) - if not (source / "index.html").is_file(): - raise FileNotFoundError(source / "index.html") - - site.mkdir(parents=True, exist_ok=True) - if version == "latest": - destination = site - for path in site.iterdir(): - if path.name not in {".git", "versions", "CNAME", ".nojekyll"}: - if path.is_dir(): - shutil.rmtree(path) - else: - path.unlink() - else: - destination = site / "versions" / version - if destination.exists(): - shutil.rmtree(destination) - - shutil.copytree(source, destination, dirs_exist_ok=True) - releases = sorted( - ( - path.name - for path in (site / "versions").glob("v*") - if RELEASE_TAG.fullmatch(path.name) - and (path / "index.html").is_file() - ), - key=lambda tag: tuple(int(part) for part in tag[1:].split(".")), - reverse=True, - ) - versions = [{"version": "latest", "path": "./"}] - versions.extend( - {"version": tag, "path": f"versions/{tag}/"} for tag in releases - ) - (site / "versions.json").write_text(json.dumps(versions, indent=2) + "\n") - (site / ".nojekyll").touch() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("source", type=Path) - parser.add_argument("site", type=Path) - parser.add_argument("version") - args = parser.parse_args() - stage(args.source, args.site, args.version) diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css index 62e83a433c..dfd2611c00 100644 --- a/docs/source/_static/custom.css +++ b/docs/source/_static/custom.css @@ -21,12 +21,3 @@ #structured-data-models h1 { text-align: center; } - -.sdm-doc-version { - margin-bottom: 1rem; -} - -.sdm-doc-version select { - color: inherit; - background: transparent; -} diff --git a/docs/source/_static/versions.js b/docs/source/_static/versions.js deleted file mode 100644 index 700a258394..0000000000 --- a/docs/source/_static/versions.js +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -(async () => { - const select = document.getElementById("sdm-doc-version"); - if (!select) return; - - const currentRoot = new URL(select.dataset.root, window.location.href); - const siteRoot = new URL( - select.dataset.version === "latest" ? "./" : "../../", - currentRoot, - ); - - try { - const response = await fetch(new URL("versions.json", siteRoot)); - if (!response.ok) return; - const versions = await response.json(); - select.replaceChildren(...versions.map(({ version, path }) => { - const option = new Option( - version === "latest" ? "latest (main)" : version, - new URL(path, siteRoot).href, - ); - option.selected = version === select.dataset.version; - return option; - })); - select.disabled = false; - // Version home pages are always present; individual APIs may not be. - select.addEventListener("change", () => window.location.assign(select.value)); - } catch { - // Local builds and offline readers still see the current version. - } -})(); diff --git a/docs/source/_templates/partials/globaltoc-above.html b/docs/source/_templates/partials/globaltoc-above.html deleted file mode 100644 index c8fa11a29c..0000000000 --- a/docs/source/_templates/partials/globaltoc-above.html +++ /dev/null @@ -1,9 +0,0 @@ -{% include "!partials/globaltoc-above.html" %} - -
- - -
diff --git a/docs/source/conf.py b/docs/source/conf.py index 5fc77a862e..c2c84e05eb 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -43,13 +43,9 @@ "accent_color": "green", "github_url": "https://github.com/NVIDIA/structured-data-models", } -doc_version = os.environ.get("SDM_DOC_VERSION", "latest") -html_context = {"doc_version": doc_version} -if doc_version != "latest": - html_context["source_version"] = doc_version +html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") html_static_path = ["_static"] html_css_files = ["custom.css"] -html_js_files = ["versions.js"] autosummary_generate = True autosummary_context = {"import_module": importlib.import_module} autodoc_member_order = "bysource" diff --git a/test/test_docs_publish.py b/test/test_docs_publish.py deleted file mode 100644 index 758c66d4d9..0000000000 --- a/test/test_docs_publish.py +++ /dev/null @@ -1,71 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -import json -import runpy -from pathlib import Path - -import pytest - -stage = runpy.run_path(str(Path(__file__).parents[1] / "docs/publish.py"))[ - "stage" -] - - -def test_publish_preserves_releases_and_replaces_latest( - tmp_path: Path, -) -> None: - source = tmp_path / "html" - source.mkdir() - (source / "index.html").write_text("release") - site = tmp_path / "site" - stage(source, site, "v0.2.0") - stage(source, site, "v0.10.0") - (site / "obsolete.html").write_text("obsolete") - (site / "CNAME").write_text("docs.example.com") - (source / "index.html").write_text("development") - stage(source, site, "latest") - - assert (site / "index.html").read_text() == "development" - assert not (site / "obsolete.html").exists() - assert (site / "CNAME").read_text() == "docs.example.com" - assert (site / "versions/v0.2.0/index.html").read_text() == "release" - assert (site / "versions/v0.10.0/index.html").read_text() == "release" - assert json.loads((site / "versions.json").read_text()) == [ - {"version": "latest", "path": "./"}, - {"version": "v0.10.0", "path": "versions/v0.10.0/"}, - {"version": "v0.2.0", "path": "versions/v0.2.0/"}, - ] - assert (site / ".nojekyll").exists() - - -def test_republish_replaces_only_requested_release(tmp_path: Path) -> None: - source = tmp_path / "html" - source.mkdir() - (source / "index.html").write_text("original") - (source / "removed.html").write_text("removed") - site = tmp_path / "site" - stage(source, site, "latest") - stage(source, site, "v0.2.0") - (source / "removed.html").unlink() - (source / "index.html").write_text("rebuilt") - stage(source, site, "v0.2.0") - - assert (site / "index.html").read_text() == "original" - assert (site / "versions/v0.2.0/index.html").read_text() == "rebuilt" - assert not (site / "versions/v0.2.0/removed.html").exists() - - -@pytest.mark.parametrize("version", ["../escape", "v0.2.0rc1", "main"]) -def test_invalid_version_does_not_modify_site( - tmp_path: Path, version: str -) -> None: - with pytest.raises(ValueError, match=r"stable vX\.Y\.Z"): - stage(tmp_path / "html", tmp_path / "site", version) - assert not (tmp_path / "site").exists() - - -def test_missing_build_does_not_modify_site(tmp_path: Path) -> None: - with pytest.raises(FileNotFoundError): - stage(tmp_path / "html", tmp_path / "site", "latest") - assert not (tmp_path / "site").exists() From 9ef5a00ba7ab2bef27702115cbfc81ded0a73fdb Mon Sep 17 00:00:00 2001 From: Akihiro Nitta Date: Fri, 30 Oct 2026 11:10:09 -0700 Subject: [PATCH 4/7] update --- .readthedocs.yaml | 4 ++-- docs/README.md | 27 --------------------------- 2 files changed, 2 insertions(+), 29 deletions(-) delete mode 100644 docs/README.md diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 0c47370553..58efc84141 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -9,8 +9,8 @@ build: - if [ "$(git rev-parse --is-shallow-repository)" = true ]; then git fetch --unshallow; fi - git fetch --tags post_install: - - python -m pip install uv - - python -m uv pip install --python "$READTHEDOCS_VIRTUALENV_PATH/bin/python" --torch-backend=cpu --group=doc . + - pip install torch --index-url https://download.pytorch.org/whl/cpu + - pip install --group doc . sphinx: configuration: docs/source/conf.py diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 3164d8aac0..0000000000 --- a/docs/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Documentation - -Install the package and documentation dependencies, then build HTML and run doctests with warnings treated as errors: - -```bash -uv pip install --torch-backend=cpu --group=doc . -uv run --no-project sphinx-build -M html docs/source docs/build -W --keep-going -uv run --no-project sphinx-build -M doctest docs/source docs/build -W --keep-going -``` - -## Read the Docs - -[`.readthedocs.yaml`](../.readthedocs.yaml) builds the existing Sphinx site on Python 3.11 with CPU-only PyTorch and the `doc` dependency group. It fetches Git history and tags so `setuptools-scm` can identify the installed package version. Read the Docs handles hosting and its built-in version menu; no custom archive, version index, or selector is required. GitHub Actions continues to validate HTML and doctests without publishing to GitHub Pages. - -The Sphinx configuration uses `READTHEDOCS_CANONICAL_URL` for canonical page URLs and the theme's copied-source URLs, including release documentation and pull request previews. Local builds work without this environment variable. - -## Maintainer setup - -1. Import this repository into the organization's Read the Docs account and enable its GitHub integration, using `.readthedocs.yaml` as the configuration file. -2. Build `latest` from `main` and verify the generated API pages and version menu. Enable pull request builds if preview documentation is desired. -3. Activate supported release versions. Read the Docs provides `stable` for the greatest stable semantic version and `latest` for the default branch; configure the default documentation version and version-activation rules in the dashboard. -4. Plan older-release builds separately: immutable tags predating `.readthedocs.yaml` do not inherit configuration from `main`. Backport documentation configuration to maintained release branches where appropriate, and validate each historical version before advertising it. Do not move existing release tags. -5. Once the hosted site and its final URL are verified, update public documentation links and arrange redirects from the existing GitHub Pages URLs. This change stops Pages publication but leaves the previously published site intact during the migration. - -Account setup, version activation, preview builds, and redirects require maintainer access. A local Sphinx build verifies the documentation but cannot verify these hosted settings. - -See the Read the Docs guides for [version management](https://docs.readthedocs.com/platform/stable/versions.html), [build customization](https://docs.readthedocs.com/platform/stable/build-customization.html), and the [version menu](https://docs.readthedocs.com/platform/stable/flyout-menu.html). From 919f8baf3740fe3d362d48c79875a0e254684b8e Mon Sep 17 00:00:00 2001 From: Akihiro Nitta Date: Fri, 30 Oct 2026 11:12:14 -0700 Subject: [PATCH 5/7] keep doc.yml for now --- .github/workflows/doc.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 9a1cfda755..60a2245173 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -8,7 +8,7 @@ on: # yamllint disable-line rule:truthy pull_request: permissions: - contents: read + contents: write # gh-pages push concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ startsWith(github.ref, 'refs/pull/') || github.run_number }} # yamllint disable-line @@ -20,8 +20,6 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 @@ -49,3 +47,10 @@ jobs: working-directory: ./docs run: | uv run --no-project sphinx-build -M doctest "source" "build" -W --keep-going + + - name: Push documentation to gh-pages + if: github.event_name == 'push' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: docs/build/html From 0d50bc728dd62a022024ec138e0c314be28c04be Mon Sep 17 00:00:00 2001 From: Akihiro Nitta Date: Fri, 30 Oct 2026 11:25:21 -0700 Subject: [PATCH 6/7] update --- .readthedocs.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 58efc84141..a1cf216c86 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,9 +1,9 @@ version: 2 build: - os: ubuntu-24.04 + os: ubuntu-26.04 tools: - python: '3.11' + python: "3.14" jobs: post_checkout: - if [ "$(git rev-parse --is-shallow-repository)" = true ]; then git fetch --unshallow; fi From ad06678399b1e177c13d3362a3d1f56f9eae77bc Mon Sep 17 00:00:00 2001 From: Akihiro Nitta Date: Fri, 30 Oct 2026 11:26:36 -0700 Subject: [PATCH 7/7] Remove optional Read the Docs canonical URL configuration --- docs/source/conf.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index c2c84e05eb..6b471dd833 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 import importlib -import os from datetime import date from importlib.metadata import PackageNotFoundError from importlib.metadata import version as package_version @@ -43,7 +42,6 @@ "accent_color": "green", "github_url": "https://github.com/NVIDIA/structured-data-models", } -html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") html_static_path = ["_static"] html_css_files = ["custom.css"] autosummary_generate = True