diff --git a/.github/workflows/docbuild.yml b/.github/workflows/docbuild.yml index 394250c0..82ccb65b 100644 --- a/.github/workflows/docbuild.yml +++ b/.github/workflows/docbuild.yml @@ -7,6 +7,7 @@ on: - '*' pull_request: branches: [ master ] + workflow_dispatch: permissions: contents: write @@ -72,35 +73,39 @@ jobs: - name: Get version from tag id: get_version run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - name: Fetch existing switcher.json from gh-pages + - name: Check out gh-pages into a worktree run: | - curl -sSL https://tee-ar-ex.github.io/trx-python/switcher.json -o switcher.json || echo '[]' > switcher.json - - name: Update switcher.json with new version - run: python tools/update_switcher.py switcher.json --version ${{ steps.get_version.outputs.VERSION }} - - name: Deploy all release docs in a single push - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - - # Configure git git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - - # Check out gh-pages into a temp folder git fetch origin gh-pages git worktree add gh-pages-out origin/gh-pages + # Read switcher.json from the worktree rather than the published site: Pages + # serves it through a CDN cache, so two tags pushed in quick succession can + # otherwise be built from a stale copy and drop an entry. + - name: Update switcher.json with new version + id: switcher + run: | + python tools/update_switcher.py gh-pages-out/switcher.json \ + --version "${{ steps.get_version.outputs.VERSION }}" \ + --github-output "$GITHUB_OUTPUT" + - name: Deploy all release docs in a single push + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" # Update versioned folder rm -rf "gh-pages-out/${VERSION}" mkdir -p "gh-pages-out/${VERSION}" cp -r docs/_build/html/. "gh-pages-out/${VERSION}/" - # Update stable folder - rm -rf gh-pages-out/stable - mkdir -p gh-pages-out/stable - cp -r docs/_build/html/. gh-pages-out/stable/ - - # Update switcher.json at root - cp switcher.json gh-pages-out/switcher.json + # Only the newest release owns the stable alias, so that a backport tag + # published after a newer release does not demote it. + if [ "${{ steps.switcher.outputs.is_latest }}" = "true" ]; then + rm -rf gh-pages-out/stable + mkdir -p gh-pages-out/stable + cp -r docs/_build/html/. gh-pages-out/stable/ + else + echo "${VERSION} is not the latest release; leaving stable/ untouched." + fi # Update root redirect index.html cat > gh-pages-out/index.html << 'EOF' @@ -130,3 +135,36 @@ jobs: git diff --cached --quiet && echo "No changes to deploy." && exit 0 git commit -m "Deploy docs for ${VERSION} 🚀" git push origin HEAD:gh-pages + + rebuild-switcher: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && github.repository == 'tee-ar-ex/trx-python' + steps: + - uses: actions/checkout@v5 + - name: Check out gh-pages into a worktree + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin gh-pages + git worktree add gh-pages-out origin/gh-pages + # The version list comes from the folders actually published on gh-pages, not + # from git tags: older tags predate the versioned docs and have no folder, so + # listing them would produce dead switcher links. + - name: Rebuild switcher.json from published version folders + run: | + args="" + for path in gh-pages-out/*/; do + folder=$(basename "$path") + case "$folder" in + [0-9]*) args="$args --version $folder" ;; + esac + done + echo "Rebuilding switcher from:$args" + python tools/update_switcher.py gh-pages-out/switcher.json --rebuild $args + - name: Commit and push switcher.json + run: | + cd gh-pages-out + git add switcher.json + git diff --cached --quiet && echo "switcher.json already up to date." && exit 0 + git commit -m "Rebuild docs version switcher" + git push origin HEAD:gh-pages diff --git a/docs/source/conf.py b/docs/source/conf.py index eec60e43..75896154 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -28,15 +28,9 @@ except ImportError: version = "dev" -# Normalize version for switcher matching -# Remove .devX suffix for matching against switcher.json -version_match = version.split('.dev')[0] if '.dev' in version else version -if version_match == version and 'dev' not in version: - # This is a release version - pass -else: - # Development version - match against "dev" - version_match = "dev" +# Development builds all share the single "dev" entry in switcher.json; +# releases match their own entry. +version_match = "dev" if "dev" in version else version # -- Project information ----------------------------------------------------- diff --git a/tools/update_switcher.py b/tools/update_switcher.py index 04a04e25..91a48af3 100644 --- a/tools/update_switcher.py +++ b/tools/update_switcher.py @@ -9,9 +9,38 @@ import argparse import json from pathlib import Path +import re import sys BASE_URL = "https://tee-ar-ex.github.io/trx-python" +DEV_VERSION = "dev" +STABLE_SUFFIX = " (stable)" + +_NUMERIC_PREFIX = re.compile(r"^(\d+(?:\.\d+)*)") + + +def parse_version(version): + """Convert a version string into a comparable tuple of integers. + + Only the leading dotted numeric part is considered, so pre-release and local + suffixes are ignored. Strings without such a prefix (``"dev"`` for instance) + are not releases and therefore have no ordering. + + Parameters + ---------- + version : str + Version string to parse (e.g., ``"0.5.0"`` or ``"0.3"``). + + Returns + ------- + tuple of int or None + Tuple of integers suitable for sorting, or None when the string does not + start with a dotted numeric version. + """ + match = _NUMERIC_PREFIX.match(version or "") + if match is None: + return None + return tuple(int(part) for part in match.group(1).split(".")) def load_switcher(path): @@ -49,8 +78,8 @@ def save_switcher(path, versions): f.write("\n") -def ensure_dev_entry(versions): - """Ensure dev entry exists in versions list. +def release_versions(versions): + """Extract the sorted release versions held by a switcher list. Parameters ---------- @@ -59,83 +88,111 @@ def ensure_dev_entry(versions): Returns ------- - list - Updated versions list with dev entry. + list of str + Release version strings, newest first. The dev entry is excluded. """ - dev_exists = any(v.get("version") == "dev" for v in versions) - if not dev_exists: - versions.insert(0, {"name": "dev", "version": "dev", "url": f"{BASE_URL}/dev/"}) - return versions + releases = [ + v.get("version") + for v in versions + if parse_version(v.get("version", "")) is not None + ] + return sorted(set(releases), key=parse_version, reverse=True) + +def is_latest(versions, version): + """Tell whether a version is the newest release known to the switcher. -def ensure_stable_entry(versions): - """Ensure stable entry exists with preferred flag. + Used to decide whether a tag should be promoted to ``stable``, so that a + backport tag published after a newer release does not demote it. Parameters ---------- versions : list List of version entries. + version : str + Version string to test (e.g., ``"0.5.0"``). Returns ------- - list - Updated versions list with stable entry. + bool + True when no known release sorts above ``version``. """ - stable_idx = next( - (i for i, v in enumerate(versions) if v.get("version") == "stable"), None - ) - if stable_idx is not None: - versions[stable_idx]["preferred"] = True - else: - versions.append( - { - "name": "stable", - "version": "stable", - "url": f"{BASE_URL}/stable/", - "preferred": True, - } - ) - return versions - - -def add_version(versions, version): - """Add a new version entry to the versions list. + key = parse_version(version) + if key is None: + return False + return all(key >= parse_version(other) for other in release_versions(versions)) + + +def build_switcher(releases): + """Build a complete switcher list from a set of release versions. + + The newest release is the preferred entry and is served from the ``stable`` + alias; every other release keeps its own versioned URL. The dev entry comes + first and is never preferred. Parameters ---------- - versions : list - List of version entries. - version : str - Version string to add (e.g., "0.5.0"). + releases : iterable of str + Release version strings, in any order. Entries that are not dotted + numeric versions are ignored. Returns ------- list - Updated versions list. + Fully formed list of switcher entries. """ - # Remove 'preferred' from all existing entries - for v in versions: - v.pop("preferred", None) - - # Check if this version already exists - version_exists = any(v.get("version") == version for v in versions) - - if not version_exists: - new_entry = { - "name": version, - "version": version, - "url": f"{BASE_URL}/{version}/", + ordered = sorted( + {r for r in releases if parse_version(r) is not None}, + key=parse_version, + reverse=True, + ) + + entries = [ + { + "name": DEV_VERSION, + "version": DEV_VERSION, + "url": f"{BASE_URL}/{DEV_VERSION}/", } - # Find dev entry index to insert after it - dev_idx = next( - (i for i, v in enumerate(versions) if v.get("version") == "dev"), -1 - ) - if dev_idx >= 0: - versions.insert(dev_idx + 1, new_entry) + ] + + for index, release in enumerate(ordered): + if index == 0: + entries.append( + { + "name": f"{release}{STABLE_SUFFIX}", + "version": release, + "url": f"{BASE_URL}/stable/", + "preferred": True, + } + ) else: - versions.insert(0, new_entry) + entries.append( + { + "name": release, + "version": release, + "url": f"{BASE_URL}/{release}/", + } + ) + + return entries + + +def add_versions(versions, new_versions): + """Add releases to an existing switcher list and rebuild it. + + Parameters + ---------- + versions : list + List of existing version entries. + new_versions : iterable of str + Release versions to add (e.g., ``["0.5.0"]``). - return versions + Returns + ------- + list + Rebuilt list of version entries. + """ + return build_switcher([*release_versions(versions), *new_versions]) def main(): @@ -150,26 +207,40 @@ def main(): description="Update switcher.json for documentation version switching" ) parser.add_argument("switcher_path", type=Path, help="Path to switcher.json file") - parser.add_argument("--version", type=str, help="New version to add (e.g., 0.5.0)") + parser.add_argument( + "--version", + type=str, + action="append", + default=[], + dest="versions", + help="Release version to add (e.g., 0.5.0). Repeatable; the last " + "one given is the one reported by --github-output.", + ) + parser.add_argument( + "--rebuild", + action="store_true", + help="Ignore the existing file and rebuild it from --version values only", + ) + parser.add_argument( + "--github-output", + type=Path, + help="Path of a GitHub Actions output file to append is_latest to", + ) args = parser.parse_args() - # Load existing versions - versions = load_switcher(args.switcher_path) + existing = [] if args.rebuild else load_switcher(args.switcher_path) + versions = add_versions(existing, args.versions) - # Add new version if specified - if args.version: - versions = add_version(versions, args.version) + latest = is_latest(versions, args.versions[-1]) if args.versions else False - # Ensure required entries exist - versions = ensure_dev_entry(versions) - versions = ensure_stable_entry(versions) - - # Save updated switcher.json save_switcher(args.switcher_path, versions) - # Print result for CI logs - print(f"Updated {args.switcher_path}:") + if args.github_output: + with open(args.github_output, "a") as f: + f.write(f"is_latest={str(latest).lower()}\n") + + print(f"Updated {args.switcher_path} (is_latest={str(latest).lower()}):") print(json.dumps(versions, indent=4)) return 0 diff --git a/trx/tests/test_update_switcher.py b/trx/tests/test_update_switcher.py new file mode 100644 index 00000000..f45378de --- /dev/null +++ b/trx/tests/test_update_switcher.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- + +import importlib.util +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools" +SCRIPT = TOOLS_DIR / "update_switcher.py" + + +def _load_module(): + """Import update_switcher.py, which lives outside any importable package. + + Returns + ------- + module + The loaded ``update_switcher`` module. + """ + spec = importlib.util.spec_from_file_location("update_switcher", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +switcher = _load_module() + + +def _preferred(entries): + """Return the single preferred entry of a switcher list. + + Parameters + ---------- + entries : list + List of switcher entries. + + Returns + ------- + dict + The entry flagged as preferred. + """ + preferred = [e for e in entries if e.get("preferred")] + assert len(preferred) == 1 + return preferred[0] + + +@pytest.mark.parametrize( + "version, expected", + [ + ("0.3", (0, 3)), + ("0.5.0", (0, 5, 0)), + ("0.5.1.dev12+gabc123", (0, 5, 1)), + ("dev", None), + ("stable", None), + ("", None), + ], +) +def test_parse_version(version, expected): + assert switcher.parse_version(version) == expected + + +def test_build_switcher_orders_newest_first(): + entries = switcher.build_switcher(["0.3", "0.5.0", "0.4.0"]) + + assert [e["version"] for e in entries] == ["dev", "0.5.0", "0.4.0", "0.3"] + + +def test_build_switcher_promotes_newest_to_stable(): + entries = switcher.build_switcher(["0.3", "0.4.0", "0.5.0"]) + stable = _preferred(entries) + + assert stable["version"] == "0.5.0" + assert stable["name"] == "0.5.0 (stable)" + assert stable["url"] == f"{switcher.BASE_URL}/stable/" + + +def test_no_entry_uses_stable_as_a_version(): + # Regression test for gh-141: a preferred entry whose version is the + # unparsable string "stable" makes the theme show the version warning + # banner on every page, including the stable docs themselves. + entries = switcher.build_switcher(["0.4.0", "0.5.0"]) + + assert all(e["version"] != "stable" for e in entries) + + +def test_dev_entry_is_first_and_never_preferred(): + entries = switcher.build_switcher(["0.4.0", "0.5.0"]) + + assert entries[0]["version"] == "dev" + assert entries[0]["url"] == f"{switcher.BASE_URL}/dev/" + assert "preferred" not in entries[0] + + +def test_older_releases_keep_their_versioned_url(): + entries = switcher.build_switcher(["0.4.0", "0.5.0"]) + old = next(e for e in entries if e["version"] == "0.4.0") + + assert old["name"] == "0.4.0" + assert old["url"] == f"{switcher.BASE_URL}/0.4.0/" + assert "preferred" not in old + + +def test_add_versions_demotes_previous_stable(): + entries = switcher.build_switcher(["0.4.0"]) + entries = switcher.add_versions(entries, ["0.5.0"]) + + assert _preferred(entries)["version"] == "0.5.0" + old = next(e for e in entries if e["version"] == "0.4.0") + assert old["url"] == f"{switcher.BASE_URL}/0.4.0/" + + +def test_add_versions_keeps_stable_on_a_backport(): + entries = switcher.build_switcher(["0.4.0", "0.5.0"]) + entries = switcher.add_versions(entries, ["0.4.1"]) + + assert _preferred(entries)["version"] == "0.5.0" + assert any(e["version"] == "0.4.1" for e in entries) + + +def test_add_versions_is_idempotent(): + once = switcher.add_versions(switcher.build_switcher(["0.4.0"]), ["0.5.0"]) + twice = switcher.add_versions(once, ["0.5.0"]) + + assert once == twice + + +def test_add_versions_ignores_the_legacy_stable_entry(): + legacy = [ + {"name": "dev", "version": "dev", "url": f"{switcher.BASE_URL}/dev/"}, + {"name": "0.4.0", "version": "0.4.0", "url": f"{switcher.BASE_URL}/0.4.0/"}, + { + "name": "stable", + "version": "stable", + "url": f"{switcher.BASE_URL}/stable/", + "preferred": True, + }, + ] + entries = switcher.add_versions(legacy, ["0.5.0"]) + + assert all(e["version"] != "stable" for e in entries) + assert _preferred(entries)["version"] == "0.5.0" + + +@pytest.mark.parametrize( + "version, expected", + [("0.6.0", True), ("0.5.0", True), ("0.4.1", False), ("dev", False)], +) +def test_is_latest(version, expected): + entries = switcher.build_switcher(["0.3", "0.4.0", "0.5.0"]) + entries = switcher.add_versions(entries, [version]) + + assert switcher.is_latest(entries, version) is expected + + +def test_rebuild_matches_incremental_adds(tmp_path): + incremental = switcher.build_switcher([]) + for version in ["0.3", "0.4.0", "0.5.0"]: + incremental = switcher.add_versions(incremental, [version]) + + rebuilt = switcher.build_switcher(["0.5.0", "0.3", "0.4.0"]) + + assert incremental == rebuilt + + +def test_cli_writes_switcher_and_is_latest_output(tmp_path): + path = tmp_path / "switcher.json" + output = tmp_path / "github_output" + + subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(path), + "--rebuild", + "--version", + "0.4.0", + "--version", + "0.5.0", + "--github-output", + str(output), + ], + check=True, + capture_output=True, + ) + + entries = json.loads(path.read_text()) + assert _preferred(entries)["version"] == "0.5.0" + assert output.read_text() == "is_latest=true\n" + + +def test_cli_reports_a_backport_as_not_latest(tmp_path): + path = tmp_path / "switcher.json" + output = tmp_path / "github_output" + switcher.save_switcher(path, switcher.build_switcher(["0.4.0", "0.5.0"])) + + subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(path), + "--version", + "0.4.1", + "--github-output", + str(output), + ], + check=True, + capture_output=True, + ) + + assert output.read_text() == "is_latest=false\n" + assert _preferred(json.loads(path.read_text()))["version"] == "0.5.0"