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
74 changes: 56 additions & 18 deletions .github/workflows/docbuild.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
- '*'
pull_request:
branches: [ master ]
workflow_dispatch:

permissions:
contents: write
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
12 changes: 3 additions & 9 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----------------------------------------------------

Expand Down
209 changes: 140 additions & 69 deletions tools/update_switcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
----------
Expand All @@ -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():
Expand All @@ -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
Expand Down
Loading
Loading