Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ jobs:
restore-keys: |
docs-full-site-${{ github.repository }}-

# Tag-scoped caches are invisible on main; merge live Pages so releases survive.
- name: Merge versions from live GitHub Pages
if: github.event_name == 'push'
shell: bash
run: |
SITE_URL="https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}"
SKIP_VERSION="main"
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
SKIP_VERSION="${GITHUB_REF_NAME}"
fi
python3 ${GITHUB_WORKSPACE}/docs/scripts/merge_published_site.py \
--build-dir ${GITHUB_WORKSPACE}/docs/build/html \
--site-base-url "${SITE_URL}" \
--skip-version "${SKIP_VERSION}"

- name: Build docs
shell: bash
run: |
Expand Down Expand Up @@ -102,7 +117,7 @@ jobs:

else
echo "Building dev docs for main branch..."
# Only rebuild main/ — all other version dirs come from the cache
# Only rebuild main/ — other versions come from cache + live Pages merge
rm -rf build/html/main
sphinx-build source build/html/main
cd build/html
Expand All @@ -112,9 +127,9 @@ jobs:
python3 ${GITHUB_WORKSPACE}/docs/scripts/generate_versions_json.py \
--build-dir .

# Save the updated full site so the next run can restore all versions
# Default-branch cache only (tag-scoped caches are not visible on main).
- name: Save full multi-version docs site
if: github.event_name == 'push'
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/cache/save@v4
with:
path: docs/build/html
Expand Down Expand Up @@ -145,17 +160,22 @@ jobs:
--extra-index-url https://download.blender.org/pypi/
echo "Unit test Start"
export HF_ENDPOINT=https://hf-mirror.com
pytest tests/docs -q --confcutdir=tests/docs
pytest tests

publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
permissions:
pages: write
id-token: write
steps:
- name: Deploy GitHub Pages
id: deployment
uses: actions/deploy-pages@v4


Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/tests/test_docs_publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ on:
workflow_dispatch:

jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run merge_published_site unit tests
run: pytest tests/docs -q --confcutdir=tests/docs

# -----------------------------------------------------------------------
# Scenario A: push to main — existing v0.1.0, v0.2.0 must survive
# Simulates: cache holds v0.1.0 + v0.2.0, build adds/updates main/
Expand Down Expand Up @@ -49,6 +56,68 @@ jobs:
"
echo "PASS: main_push — existing versions preserved"

# -----------------------------------------------------------------------
# Scenario D: main push after tag — stale cache (main only) + live Pages
# This is the production bug: tag cache is not on main; merge fixes it.
# -----------------------------------------------------------------------
test-main-after-tag-merge:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Stale default-branch cache (main/ only)
run: |
mkdir -p docs/build/html/main
echo "<html>stale main</html>" > docs/build/html/main/index.html

- name: Mock live GitHub Pages (has tag release v0.3.0)
run: |
PUBLISHED="${GITHUB_WORKSPACE}/mock-published-site"
mkdir -p "${PUBLISHED}/v0.3.0" "${PUBLISHED}/main"
echo "<html>v0.3.0 live</html>" > "${PUBLISHED}/v0.3.0/index.html"
echo "<html>main live</html>" > "${PUBLISHED}/main/index.html"
python3 -c "
import json, pathlib
root = pathlib.Path('${PUBLISHED}')
manifest = {
'latest': 'v0.3.0',
'versions': [
{'name': 'v0.3.0', 'url': './v0.3.0/index.html', 'type': 'tag'},
{'name': 'main', 'url': './main/index.html', 'type': 'branch'},
],
}
(root / 'versions.json').write_text(json.dumps(manifest, indent=2))
"

- name: Merge published (skip main — will rebuild)
run: |
python3 ${GITHUB_WORKSPACE}/docs/scripts/merge_published_site.py \
--build-dir ${GITHUB_WORKSPACE}/docs/build/html \
--published-root ${GITHUB_WORKSPACE}/mock-published-site \
--skip-version main

- name: Rebuild main/ only
run: |
rm -rf docs/build/html/main
mkdir -p docs/build/html/main
echo "<html>main rebuilt</html>" > docs/build/html/main/index.html
python3 ${GITHUB_WORKSPACE}/docs/scripts/generate_versions_json.py \
--build-dir ${GITHUB_WORKSPACE}/docs/build/html

- name: Assert — v0.3.0 preserved after main push
run: |
[ -d docs/build/html/v0.3.0 ] || (echo "FAIL: v0.3.0 missing after merge!" && exit 1)
grep -q "v0.3.0 live" docs/build/html/v0.3.0/index.html
grep -q "main rebuilt" docs/build/html/main/index.html
python3 -c "
import json
d = json.load(open('docs/build/html/versions.json'))
names = [v['name'] for v in d['versions']]
assert 'v0.3.0' in names and 'main' in names, names
assert d['latest'] == 'v0.3.0', d['latest']
"
echo "PASS: main_after_tag — release dir restored from published mock"

# -----------------------------------------------------------------------
# Scenario B: tag push v0.3.0 — new version added, old dirs untouched
# -----------------------------------------------------------------------
Expand Down
214 changes: 214 additions & 0 deletions docs/scripts/merge_published_site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
#!/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.
# ----------------------------------------------------------------------------
"""Merge version directories from the live docs site into a local build tree.

CI restores an Actions cache and rebuilds only one version (``main`` or a tag).
Tag-scoped cache entries are not visible on ``main`` pushes, so the cache alone
cannot hold all versions. This script fills *missing* version directories from
the currently published GitHub Pages site (or a local directory in tests).
"""

from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import urlopen

__all__ = ["load_versions_manifest", "merge_published_site"]


def load_versions_manifest(
*,
site_base_url: str | None = None,
published_root: Path | None = None,
) -> dict[str, Any] | None:
"""Load ``versions.json`` from a local tree or the live site URL."""
if published_root is not None:
manifest_path = published_root / "versions.json"
if not manifest_path.is_file():
return None
return json.loads(manifest_path.read_text(encoding="utf-8"))

if not site_base_url:
return None

manifest_url = f"{site_base_url.rstrip('/')}/versions.json"
try:
with urlopen(manifest_url, timeout=30) as response:
if response.status != 200:
return None
return json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc:
print(f"No published manifest at {manifest_url}: {exc}", file=sys.stderr)
return None


def _copy_local_version(src: Path, dest: Path) -> None:
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src, dest)


def _download_version_wget(site_base_url: str, version: str, dest: Path) -> None:
"""Download one version subtree with wget (available in CI containers)."""
url = f"{site_base_url.rstrip('/')}/{version}/"
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
shutil.rmtree(dest)

# -nH: no host-based dirs; -np: stay under version URL; -P: output prefix
result = subprocess.run(
[
"wget",
"-q",
"-r",
"-l",
"50",
"-np",
"-nH",
"-P",
str(dest.parent),
url,
],
check=False,
)
if result.returncode != 0:
print(f"wget failed for {url} (exit {result.returncode})", file=sys.stderr)
return

# wget may create dest.parent/<version>/ or nest extra path segments — normalize
if not dest.is_dir():
candidates = list(dest.parent.glob(f"*/{version}"))
if len(candidates) == 1 and candidates[0].is_dir():
candidates[0].rename(dest)
else:
nested = dest.parent / version
if nested.is_dir() and nested != dest:
nested.rename(dest)


def merge_published_site(
build_dir: Path,
*,
site_base_url: str | None = None,
published_root: Path | None = None,
skip_versions: frozenset[str] | None = None,
) -> list[str]:
"""Copy missing version dirs from published site into ``build_dir``.

Args:
build_dir: Sphinx output root (``docs/build/html``).
site_base_url: Live Pages base, e.g. ``https://org.github.io/Repo``.
published_root: Local published tree for tests (``versions.json`` + dirs).
skip_versions: Version names to leave for a fresh build (e.g. ``main``).

Returns:
Names of versions merged from the published site.
"""
build_dir = build_dir.resolve()
build_dir.mkdir(parents=True, exist_ok=True)
skip = skip_versions or frozenset()

manifest = load_versions_manifest(
site_base_url=site_base_url,
published_root=published_root,
)
if not manifest:
print("No published versions manifest; skipping merge.")
return []

merged: list[str] = []
for entry in manifest.get("versions", []):
name = entry.get("name")
if not name or name in skip:
continue
if (build_dir / name).is_dir():
continue

if published_root is not None:
src = published_root / name
if not src.is_dir():
print(
f"Published root missing directory {name}; skip.", file=sys.stderr
)
continue
print(f"Merging local published version: {name}")
_copy_local_version(src, build_dir / name)
merged.append(name)
elif site_base_url:
print(f"Downloading published version: {name}")
_download_version_wget(site_base_url, name, build_dir / name)
if (build_dir / name).is_dir():
merged.append(name)
else:
print(
"Neither published_root nor site_base_url set; cannot merge.",
file=sys.stderr,
)

return merged


def main() -> None:
parser = argparse.ArgumentParser(
description="Merge missing doc version dirs from live GitHub Pages into build/html"
)
parser.add_argument(
"--build-dir",
type=Path,
default=Path("build/html"),
help="Local docs build directory (default: build/html)",
)
parser.add_argument(
"--site-base-url",
default=None,
help="Published site base URL, e.g. https://org.github.io/EmbodiChain",
)
parser.add_argument(
"--published-root",
type=Path,
default=None,
help="Local directory mirroring published site (for tests)",
)
parser.add_argument(
"--skip-version",
action="append",
default=[],
help="Version to skip (repeatable); rebuilt in the same CI run",
)
args = parser.parse_args()

merged = merge_published_site(
args.build_dir,
site_base_url=args.site_base_url,
published_root=args.published_root,
skip_versions=frozenset(args.skip_version),
)
if merged:
print(f"Merged versions: {', '.join(merged)}")
else:
print("No versions merged from published site.")


if __name__ == "__main__":
main()
4 changes: 3 additions & 1 deletion docs/source/quick_start/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,6 @@ 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.
> Old release versions beyond `DOCS_MAX_VERSIONS` (default: 5 in CI) are automatically pruned during CI builds.
>
> CI merges missing version directories from the live GitHub Pages site before each build so a `main` push cannot wipe docs built for release tags. See `docs/scripts/merge_published_site.py` and `tests/docs/test_merge_published_site.py`.
15 changes: 15 additions & 0 deletions tests/docs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# ----------------------------------------------------------------------------
# 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.
# ----------------------------------------------------------------------------
Loading
Loading