diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 27483d52..d0e122f9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -45,24 +45,78 @@ jobs: NVIDIA_DRIVER_CAPABILITIES: all NVIDIA_VISIBLE_DEVICES: all NVIDIA_DISABLE_REQUIRE: 1 + DOCS_MAX_VERSIONS: "4" # Max number of release versions to keep container: *container_template steps: - uses: actions/checkout@v4 + + - name: Cache Python dependencies + id: cache-pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-docs-${{ hashFiles('docs/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip-docs- + + - name: Restore previous docs output + if: github.event_name == 'push' + uses: actions/cache@v4 + with: + path: docs/build/html + key: docs-output-${{ github.repository }}-${{ github.ref_name }} + restore-keys: | + docs-output-${{ github.repository }}-${{ github.ref_name }}- + docs-output-${{ github.repository }}- + - name: Build docs + shell: bash run: | pip install -e . --extra-index-url http://pyp.open3dv.site:2345/simple/ --trusted-host pyp.open3dv.site pip install -r docs/requirements.txt cd ${GITHUB_WORKSPACE}/docs - echo "Start Building docs..." pip uninstall pymeshlab -y pip install pymeshlab==2023.12.post3 - make html + + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF_NAME}" + echo "Building docs for release tag ${VERSION}..." + + # Build only this version into its own subdirectory + sphinx-build source build/html/${VERSION} + + cd build/html + + # Prune old release versions beyond the window + mapfile -t TAG_DIRS < <(ls -d v*/ 2>/dev/null | sort -V) + while [[ ${#TAG_DIRS[@]} -gt ${DOCS_MAX_VERSIONS} ]]; do + echo "Pruning old version: ${TAG_DIRS[0]}" + rm -rf "${TAG_DIRS[0]}" + TAG_DIRS=("${TAG_DIRS[@]:1}") + done + + # Generate versions.json and root index.html + python3 ${GITHUB_WORKSPACE}/docs/scripts/generate_versions_json.py \ + --build-dir . + + else + echo "Building dev docs for main branch..." + # Build only main/ — don't touch existing version directories + rm -rf build/html/main + sphinx-build source build/html/main + + cd build/html + + # Generate versions.json and root index.html + python3 ${GITHUB_WORKSPACE}/docs/scripts/generate_versions_json.py \ + --build-dir . + fi + - name: Upload docs artifact - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' uses: actions/upload-pages-artifact@v3 - with: + with: path: ${{ github.workspace }}/docs/build/html - retention-days: 3 test: if: github.event_name == 'pull_request' @@ -86,19 +140,13 @@ jobs: pytest tests publish: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' needs: build runs-on: Linux permissions: pages: write - id-token: write - env: - NVIDIA_DRIVER_CAPABILITIES: all - NVIDIA_VISIBLE_DEVICES: all - NVIDIA_DISABLE_REQUIRE: 1 - container: *container_template + id-token: write steps: - - uses: actions/checkout@v4 - name: Download docs artifact uses: actions/download-artifact@v4 with: @@ -120,7 +168,7 @@ jobs: # steps: # - uses: actions/checkout@v4 # with: - # fetch-depth: 0 + # fetch-depth: 0 # - name: (Release) Install build tools # run: | @@ -144,4 +192,4 @@ jobs: # - name: (Release) Publish to PyPI # uses: pypa/gh-action-pypi-publish@release/v1 # with: - # password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + # password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/docs/Makefile b/docs/Makefile index 864eb2a7..ed4d9c22 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -19,3 +19,10 @@ help: %: Makefile @rm -rf "$(BUILDDIR)" @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +# Build current version only (for local development / PR verification) +.PHONY: current-docs +current-docs: + @rm -rf "$(BUILDDIR)/html" + @$(SPHINXBUILD) -W --keep-going "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) $(O) + @python3 "$(CURDIR)/scripts/generate_versions_json.py" --build-dir "$(BUILDDIR)/html" diff --git a/docs/requirements.txt b/docs/requirements.txt index 53d9dd9d..0c42b189 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -7,5 +7,4 @@ myst-parser sphinx-autosummary-accessors sphinxcontrib-bibtex sphinx-design -sphinx_autodoc_typehints -sphinx-multiversion \ No newline at end of file +sphinx_autodoc_typehints \ No newline at end of file diff --git a/docs/scripts/build_versions.py b/docs/scripts/build_versions.py new file mode 100644 index 00000000..dbbd7224 --- /dev/null +++ b/docs/scripts/build_versions.py @@ -0,0 +1,97 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Helper script for filtering versions to maintain buffer size.""" + +import re +from pathlib import Path + + +def parse_version(tag: str) -> tuple[int, int, int]: + """Parse a version tag like 'v1.2.3' into a tuple (1, 2, 3).""" + match = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", tag) + if not match: + return (0, 0, 0) + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def filter_versions( + all_versions: list[str], + buffer_size: int, + main_branch: str = "main", +) -> list[str]: + """Filter versions to maintain buffer size. + + Keeps the latest (buffer_size - 1) release versions plus the main branch. + + Args: + all_versions: List of all available version references + buffer_size: Total number of versions to keep (releases + main) + main_branch: Name of the main branch + + Returns: + List of versions to keep + """ + # Separate releases from branches + releases = [v for v in all_versions if re.match(r"^v\d+\.\d+\.\d+$", v)] + branches = [v for v in all_versions if v not in releases] + + # Sort releases by version (newest first) + releases.sort(key=parse_version, reverse=True) + + # Keep latest (buffer_size - 1) releases + releases_to_keep = releases[: (buffer_size - 1)] + + # Always include main branch if it exists + versions_to_keep = releases_to_keep.copy() + if main_branch in branches: + versions_to_keep.append(main_branch) + + return versions_to_keep + + +def main(): + """CLI entry point for version filtering.""" + import argparse + + parser = argparse.ArgumentParser( + description="Filter versions for multi-version docs" + ) + parser.add_argument( + "--versions", + nargs="+", + required=True, + help="List of all available versions", + ) + parser.add_argument( + "--buffer-size", + type=int, + default=5, + help="Total number of versions to keep (releases + main)", + ) + parser.add_argument( + "--main-branch", + default="main", + help="Name of the main branch", + ) + args = parser.parse_args() + + filtered = filter_versions(args.versions, args.buffer_size, args.main_branch) + print(" ".join(filtered)) + + +if __name__ == "__main__": + main() diff --git a/docs/scripts/generate_versions_json.py b/docs/scripts/generate_versions_json.py new file mode 100644 index 00000000..d4905565 --- /dev/null +++ b/docs/scripts/generate_versions_json.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- +"""Generate versions.json and root index.html for the docs version selector.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + + +def parse_version(tag: str) -> tuple[int, int, int]: + """Parse a version tag like 'v1.2.3' into a tuple (1, 2, 3).""" + match = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", tag) + if not match: + return (0, 0, 0) + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate versions.json and root index.html for multi-version docs" + ) + parser.add_argument( + "--build-dir", + default="build/html", + help="Path to build/html directory (default: build/html)", + ) + parser.add_argument( + "--output", + default=None, + help="Output path for versions.json (default: /versions.json)", + ) + parser.add_argument( + "--latest", + default=None, + help="Name of the latest stable version (default: auto-detected from tags, falls back to main)", + ) + args = parser.parse_args() + + html_dir = Path(args.build_dir) + output = Path(args.output) if args.output else html_dir / "versions.json" + + if not html_dir.exists(): + print(f"Error: Build directory '{html_dir}' does not exist.") + raise SystemExit(1) + + versions: list[dict[str, str]] = [] + + # Collect tag versions (vX.Y.Z directories), sorted newest-first + tag_dirs = sorted( + [d for d in html_dir.glob("v*") if d.is_dir()], + key=lambda d: parse_version(d.name), + reverse=True, + ) + for d in tag_dirs: + name = d.name + versions.append({"name": name, "url": f"./{name}/index.html", "type": "tag"}) + + # Collect main (dev branch) + if (html_dir / "main").is_dir(): + versions.append({"name": "main", "url": "./main/index.html", "type": "branch"}) + + # Determine latest: explicit arg > newest tag > main + if args.latest: + latest = args.latest + elif versions: + tag_names = [v["name"] for v in versions if v["type"] == "tag"] + latest = tag_names[0] if tag_names else "main" + else: + latest = "main" + + manifest = { + "latest": latest, + "versions": versions, + } + + # Write versions.json + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(manifest, indent=2)) + print(f"Generated {output} with {len(versions)} versions (latest: {latest})") + + # Write root index.html redirect + index_path = html_dir / "index.html" + index_content = ( + "\n" + "\n" + f" EmbodiChain Docs\n" + f' \n' + "\n" + ) + index_path.write_text(index_content) + print(f"Generated {index_path} (redirects to ./{latest}/index.html)") + + +if __name__ == "__main__": + main() diff --git a/docs/source/_static/version-redirect.js b/docs/source/_static/version-redirect.js new file mode 100644 index 00000000..effe08cf --- /dev/null +++ b/docs/source/_static/version-redirect.js @@ -0,0 +1,36 @@ +/** + * Version redirect script for multi-version documentation. + * Redirects to the latest stable release version, or falls back to 'main'. + */ + +(function() { + 'use strict'; + + // Try to fetch versions.json (generated by generate_versions_json.py) + fetch('versions.json') + .then(response => { + if (!response.ok) { + throw new Error('versions.json not found'); + } + return response.json(); + }) + .then(data => { + // Get the latest version from the JSON + const latestVersion = data.latest || data.versions?.[0]?.name || 'main'; + + const currentPath = window.location.pathname; + + // If we're at root, redirect to latest version + if (currentPath === '/' || currentPath.endsWith('/index.html') || currentPath.endsWith('/')) { + window.location.href = latestVersion + '/'; + } + }) + .catch(error => { + console.warn('Version redirect failed:', error.message); + // Fallback to main on error + const currentPath = window.location.pathname; + if (currentPath === '/' || currentPath.endsWith('/index.html') || currentPath.endsWith('/')) { + window.location.href = 'main/'; + } + }); +})(); diff --git a/docs/source/_templates/index.html b/docs/source/_templates/index.html new file mode 100644 index 00000000..f1351f20 --- /dev/null +++ b/docs/source/_templates/index.html @@ -0,0 +1,8 @@ + + + + Redirecting to the latest EmbodiChain documentation + + + + diff --git a/docs/source/_templates/versioning.html b/docs/source/_templates/versioning.html new file mode 100644 index 00000000..a6cb2726 --- /dev/null +++ b/docs/source/_templates/versioning.html @@ -0,0 +1,56 @@ + + diff --git a/docs/source/conf.py b/docs/source/conf.py index 59145215..a0b23064 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -41,7 +41,6 @@ "sphinx_design", "myst_parser", # if you prefer Markdown pages "sphinx_copybutton", - "sphinx_multiversion", ] # Napoleon settings if using Google/NumPy docstring style: napoleon_google_docstring = True @@ -65,17 +64,45 @@ exclude_patterns = [] +# -- Version selector sidebar --------------------------------------------------- +html_sidebars = { + "**": [ + "navbar-logo.html", + "versioning.html", + "search-field.html", + "sbt-sidebar-nav.html", + ] +} + + # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = "sphinx_book_theme" html_static_path = ["_static"] +# Don't include version-redirect.js automatically - we add it manually to root +html_js_files = [] # html_logo = "_static/logo_e.png" -# -- sphinx-multiversion configuration ------------------------------------------------- -# Only build tags that look like v1.0.0 or branches like main/dev -smv_tag_whitelist = r"^v\d+\.\d+\.\d+$" -smv_branch_whitelist = r"^(main|dev)$" -smv_remote_whitelist = r"^origin$" -smv_released_pattern = r"^tags/v\d+\.\d+\.\d+$" -smv_outputdir_format = "{ref.name}" +# Configure HTML base URL for better local previewing +# Use empty string to use relative paths from the build directory +html_baseurl = "" + +# HTML context for better path handling +html_context = { + "github_user": "dexforce", + "github_repo": "EmbodiChain", + "github_version": "main", + "doc_path": "docs/source", +} + +html_theme_options = { + "title": "EmbodiChain", + "logo_only": False, + "show_toc_level": 2, + "collapse_navigation": True, + "sticky_navigation": True, + "navigation_depth": 4, + "includehidden": True, + "prev_next_buttons_location": "bottom", +} diff --git a/docs/source/quick_start/docs.md b/docs/source/quick_start/docs.md index c62a3d71..1a8aef4d 100644 --- a/docs/source/quick_start/docs.md +++ b/docs/source/quick_start/docs.md @@ -10,9 +10,47 @@ pip install -r docs/requirements.txt ## 2. Build the HTML site +### Local development (current version only) + ```bash cd docs -make html +make current-docs ``` Then you can preview the documentation in your browser at `docs/build/html/index.html`. + +### Multi-version docs (CI/production) + +The production docs site hosts multiple versions side by side. Each version is built independently into its own subdirectory under `docs/build/html/`: + +``` +docs/build/html/ +├── index.html # Redirect → latest stable +├── versions.json # Version manifest for the sidebar selector +├── main/ # Dev docs (latest main branch) +├── v0.1.3/ # Release docs +└── v0.1.2/ # Release docs +``` + +To build a specific version into this layout: + +```bash +cd docs +sphinx-build source build/html/ +``` + +For example, to build the `main` branch docs: + +```bash +sphinx-build source build/html/main +``` + +Then generate the version manifest and root redirect: + +```bash +python3 scripts/generate_versions_json.py --build-dir build/html +``` + +This generates both `versions.json` (for the sidebar version selector) and `index.html` (redirects to the latest stable version, falling back to `main`). + +> Old release versions beyond `DOCS_MAX_VERSIONS` (default: 4) are automatically pruned during CI builds.