Files
OpenShell/tasks/scripts/sync_docs_website.py
Piotr Mlocek a0814443f1 feat(docs): publish versioned release snapshots (#3149)
* feat(docs): add version availability labels

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* fix(docs): use supported Python for sync

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* feat(docs): publish versioned docs from releases

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* fix(docs): format dev version label

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* chore(docs): upgrade Fern CLI to 5.112.0

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* fix(docs): make release publishing monotonic

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* fix(docs): preserve snapshot release identity

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* docs(fern): document versioned publishing

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* test(docs): cover explicit snapshot rollback

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* refactor(docs): use Fern refs for versions

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* fix(docs): bundle components for ref versions

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* revert(docs): keep complete version copies

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* fix(docs): publish latest and dev channels

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

---------

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
2026-09-09 23:34:34 +00:00

606 lines
19 KiB
Python

#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "packaging==25.0",
# "PyYAML==6.0.2",
# ]
# ///
from __future__ import annotations
import argparse
import re
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import cast
import yaml
from packaging.version import InvalidVersion, Version
SLUG_RE = re.compile(r"^[A-Za-z0-9._-]+$")
DISPLAY_VERSION_RE = re.compile(r"\bv?(\d+\.\d+\.\d+(?:[.-]?[A-Za-z0-9]+)*)\b")
VERSION_AVAILABILITIES = {"beta", "deprecated", "ga", "stable"}
SNAPSHOT_METADATA_FILE = ".docs-snapshots.yml"
YamlMapping = dict[str, object]
@dataclass
class VersionEntry:
slug: str
display_name: str
path: str
availability: str | None = None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Sync or remove docs snapshots in the docs-website branch."
)
parser.add_argument("--operation", choices=["sync", "remove"], default="sync")
parser.add_argument("--source-root", type=Path)
parser.add_argument("--docs-website-root", required=True, type=Path)
parser.add_argument(
"--channel", required=True, choices=["dev", "latest", "stable", "version"]
)
parser.add_argument("--source-ref", default="")
parser.add_argument("--source-sha", default="")
parser.add_argument("--release-version", default="")
parser.add_argument("--version-slug", default="")
parser.add_argument("--display-name", default="")
parser.add_argument("--availability", default="")
parser.add_argument("--allow-rollback", action="store_true")
return parser.parse_args()
def clean_input(value: str | None) -> str:
return (value or "").strip()
def resolve_slug(channel: str, version_slug: str) -> str:
if channel == "dev":
return "dev"
if channel == "latest":
return "latest"
if not version_slug:
raise ValueError(
"--version-slug is required when --channel=stable or --channel=version"
)
if not SLUG_RE.fullmatch(version_slug):
raise ValueError(
f"version slug contains unsupported characters: {version_slug}"
)
return version_slug
def resolve_display_name(
channel: str, slug: str, source_ref: str, override: str
) -> str:
if override:
return override
if channel == "dev":
return "dev"
if channel == "latest":
return f"Latest ({source_ref})" if source_ref.startswith("v") else "Latest"
return slug
def resolve_availability(channel: str, override: str) -> str | None:
availability = override or ("beta" if channel == "dev" else "")
if not availability:
return None
if availability not in VERSION_AVAILABILITIES:
supported = ", ".join(sorted(VERSION_AVAILABILITIES))
raise ValueError(
f"unsupported version availability {availability!r}; expected one of: {supported}"
)
return availability
def parse_release_version(value: str) -> Version:
try:
return Version(value.removeprefix("v"))
except InvalidVersion as exc:
raise ValueError(f"invalid release version: {value}") from exc
def default_stable_availability(release_version: str) -> str | None:
if parse_release_version(release_version) >= Version("0.1.0"):
return "stable"
return None
def ensure_existing(path: Path, label: str) -> None:
if not path.exists():
raise FileNotFoundError(f"{label} does not exist: {path}")
def reset_directory(src: Path, dst: Path) -> None:
ensure_existing(src, "source directory")
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
def merge_directory(src: Path, dst: Path, *, overwrite: bool) -> None:
if not src.exists():
return
if overwrite:
shutil.copytree(src, dst, dirs_exist_ok=True)
return
for copied in src.rglob("*"):
relative = copied.relative_to(src)
target = dst / relative
if copied.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
if target.exists():
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(copied, target)
def copy_if_exists(src: Path, dst: Path) -> None:
if src.exists():
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
def read_yaml(path: Path) -> YamlMapping:
ensure_existing(path, "YAML file")
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"expected YAML mapping in {path}")
return cast("YamlMapping", data)
def write_yaml(path: Path, data: YamlMapping) -> None:
path.write_text(
yaml.safe_dump(data, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
def read_snapshot_metadata(path: Path) -> dict[str, dict[str, str]]:
if not path.exists():
return {}
data = read_yaml(path)
raw_snapshots = data.get("snapshots")
if raw_snapshots is None:
return {}
if not isinstance(raw_snapshots, dict):
raise ValueError(f"expected snapshots mapping in {path}")
snapshots: dict[str, dict[str, str]] = {}
for raw_slug, raw_snapshot in raw_snapshots.items():
if not isinstance(raw_slug, str) or not isinstance(raw_snapshot, dict):
raise ValueError(f"invalid snapshot metadata in {path}")
snapshot = cast("YamlMapping", raw_snapshot)
source_ref = snapshot.get("source-ref")
source_sha = snapshot.get("source-sha", "")
version = snapshot.get("version")
if (
not isinstance(source_ref, str)
or not isinstance(source_sha, str)
or not isinstance(version, str)
):
raise ValueError(f"invalid snapshot metadata for {raw_slug} in {path}")
snapshots[raw_slug] = {
"source-ref": source_ref,
"source-sha": source_sha,
"version": version,
}
return snapshots
def write_snapshot_metadata(path: Path, snapshots: dict[str, dict[str, str]]) -> None:
write_yaml(path, {"snapshots": snapshots})
def seed_mutable_snapshot_metadata(
snapshots: dict[str, dict[str, str]], docs_yml: Path, slug: str
) -> None:
if slug in snapshots:
return
data = read_yaml(docs_yml)
for entry in parse_versions(data.get("versions")):
if entry.slug != slug:
continue
match = DISPLAY_VERSION_RE.search(entry.display_name)
if match is not None:
snapshots[slug] = {
"source-ref": "",
"source-sha": "",
"version": str(parse_release_version(match.group(1))),
}
return
def ensure_immutable_snapshot(
snapshots: dict[str, dict[str, str]],
target_fern: Path,
slug: str,
source_sha: str,
) -> None:
existing = snapshots.get(slug)
if existing is not None:
if existing["source-sha"] != source_sha:
raise ValueError(
f"immutable snapshot {slug} already points to "
f"{existing['source-sha']}, not {source_sha}"
)
return
if (target_fern / f"pages-{slug}").exists():
raise ValueError(
f"immutable snapshot {slug} already exists without source metadata"
)
def ensure_monotonic_snapshot(
snapshots: dict[str, dict[str, str]],
slug: str,
source_sha: str,
release_version: str,
*,
allow_rollback: bool,
) -> bool:
existing = snapshots.get(slug)
if existing is None:
return True
incoming_version = parse_release_version(release_version)
existing_version = parse_release_version(existing["version"])
if incoming_version < existing_version and not allow_rollback:
return False
if (
incoming_version == existing_version
and bool(existing["source-sha"])
and existing["source-sha"] != source_sha
and not allow_rollback
):
raise ValueError(
f"snapshot {slug} version {release_version} already points to "
f"{existing['source-sha']}, not {source_sha}"
)
return True
def prefix_path(value: object, pages_dir: str) -> object:
if not isinstance(value, str):
return value
if value.startswith(("../", "/", "http://", "https://")):
return value
return f"../{pages_dir}/{value}"
def prefix_navigation_paths(value: object, pages_dir: str) -> object:
if isinstance(value, dict):
mapping = cast("YamlMapping", value)
for key in ("path", "folder"):
if key in mapping:
mapping[key] = prefix_path(mapping[key], pages_dir)
for child in mapping.values():
prefix_navigation_paths(child, pages_dir)
elif isinstance(value, list):
for child in cast("list[object]", value):
prefix_navigation_paths(child, pages_dir)
return value
def version_navigation(source_index: Path, pages_dir: str) -> YamlMapping:
data = read_yaml(source_index)
prefix_navigation_paths(data, pages_dir)
return data
def parse_versions(raw_versions: object) -> list[VersionEntry]:
if raw_versions is None:
return []
if not isinstance(raw_versions, list):
raise ValueError("docs.yml versions must be a list")
entries: list[VersionEntry] = []
for raw in cast("list[object]", raw_versions):
if not isinstance(raw, dict):
continue
entry = cast("YamlMapping", raw)
slug = entry.get("slug")
display_name = entry.get("display-name")
path = entry.get("path")
availability = entry.get("availability")
if (
isinstance(slug, str)
and isinstance(display_name, str)
and isinstance(path, str)
):
entries.append(
VersionEntry(
slug=slug,
display_name=display_name,
path=path,
availability=availability
if isinstance(availability, str)
else None,
)
)
return entries
def ordered_entries(
existing: list[VersionEntry], updated: VersionEntry
) -> list[VersionEntry]:
by_slug = {entry.slug: entry for entry in existing}
by_slug[updated.slug] = updated
existing_order = [entry.slug for entry in existing if entry.slug != updated.slug]
order: list[str] = []
for slug in ("latest", "dev"):
if slug in by_slug:
order.append(slug)
for slug in existing_order:
if slug not in order and slug in by_slug:
order.append(slug)
if updated.slug not in order:
order.append(updated.slug)
return [by_slug[slug] for slug in order]
def render_versions(entries: list[VersionEntry]) -> list[dict[str, str]]:
rendered: list[dict[str, str]] = []
for entry in entries:
item = {
"display-name": entry.display_name,
"path": entry.path,
"slug": entry.slug,
}
if entry.availability is not None:
item["availability"] = entry.availability
rendered.append(item)
return rendered
def component_dirs(fern_dir: Path) -> list[str]:
dirs: list[str] = []
preferred = ["pages-latest", "pages-dev"]
all_page_dirs = sorted(
path.name for path in fern_dir.glob("pages-*") if path.is_dir()
)
for name in preferred + all_page_dirs:
path = fern_dir / name / "_components"
component = f"./{name}/_components"
if path.is_dir() and component not in dirs:
dirs.append(component)
dirs.append("./components")
return dirs
def update_docs_yml(docs_yml: Path, updated: VersionEntry, fern_dir: Path) -> None:
data = read_yaml(docs_yml)
data["experimental"] = {
"mdx-components": component_dirs(fern_dir),
}
data["versions"] = render_versions(
ordered_entries(parse_versions(data.get("versions")), updated)
)
write_yaml(docs_yml, data)
def write_snapshot(
source_docs: Path,
source_fern: Path,
target_fern: Path,
entry: VersionEntry,
*,
refresh_shared: bool,
) -> None:
pages_dir = f"pages-{entry.slug}"
reset_directory(source_docs, target_fern / pages_dir)
if refresh_shared:
merge_directory(source_fern / "assets", target_fern / "assets", overwrite=True)
merge_directory(
source_fern / "components", target_fern / "components", overwrite=True
)
copy_if_exists(source_fern / "main.css", target_fern / "main.css")
copy_if_exists(
source_fern / "fern.config.json", target_fern / "fern.config.json"
)
versions_dir = target_fern / "versions"
versions_dir.mkdir(parents=True, exist_ok=True)
write_yaml(
versions_dir / f"{entry.slug}.yml",
version_navigation(source_docs / "index.yml", pages_dir),
)
update_docs_yml(target_fern / "docs.yml", entry, target_fern)
def remove_docs_yml_entry(docs_yml: Path, slug: str, fern_dir: Path) -> None:
data = read_yaml(docs_yml)
entries = [
entry for entry in parse_versions(data.get("versions")) if entry.slug != slug
]
data["experimental"] = {
"mdx-components": component_dirs(fern_dir),
}
data["versions"] = render_versions(entries)
write_yaml(docs_yml, data)
def sync_docs(args: argparse.Namespace) -> None:
if args.source_root is None:
raise ValueError("--source-root is required when --operation=sync")
source_root = args.source_root.resolve()
docs_root = args.docs_website_root.resolve()
source_docs = source_root / "docs"
source_fern = source_root / "fern"
target_fern = docs_root / "fern"
ensure_existing(source_docs, "source docs")
ensure_existing(source_fern, "source fern config")
ensure_existing(target_fern, "docs website fern directory")
channel = clean_input(args.channel)
source_ref = clean_input(args.source_ref)
if not source_ref:
raise ValueError("--source-ref is required when --operation=sync")
source_sha = clean_input(getattr(args, "source_sha", ""))
if not source_sha:
raise ValueError("--source-sha is required when --operation=sync")
release_version = clean_input(getattr(args, "release_version", ""))
version_slug = clean_input(args.version_slug)
display_override = clean_input(args.display_name)
availability_override = clean_input(args.availability)
if channel in {"dev", "latest", "stable"} and not release_version:
raise ValueError(
"--release-version is required for dev, latest, and stable channels"
)
slug = resolve_slug(channel, version_slug)
display_name = resolve_display_name(channel, slug, source_ref, display_override)
availability = resolve_availability(channel, availability_override)
metadata_path = target_fern / SNAPSHOT_METADATA_FILE
snapshots = read_snapshot_metadata(metadata_path)
docs_yml = target_fern / "docs.yml"
if channel == "stable":
parsed_version = parse_release_version(release_version)
expected_slug = f"v{parsed_version}"
if slug != expected_slug:
raise ValueError(f"stable version slug must be {expected_slug}, got {slug}")
ensure_immutable_snapshot(snapshots, target_fern, slug, source_sha)
stable_availability = availability or default_stable_availability(
release_version
)
write_snapshot(
source_docs,
source_fern,
target_fern,
VersionEntry(
slug=slug,
display_name=slug,
path=f"./versions/{slug}.yml",
availability=stable_availability,
),
refresh_shared=False,
)
snapshots[slug] = {
"source-ref": source_ref,
"source-sha": source_sha,
"version": str(parsed_version),
}
seed_mutable_snapshot_metadata(snapshots, docs_yml, "latest")
if ensure_monotonic_snapshot(
snapshots,
"latest",
source_sha,
release_version,
allow_rollback=bool(getattr(args, "allow_rollback", False)),
):
write_snapshot(
source_docs,
source_fern,
target_fern,
VersionEntry(
slug="latest",
display_name=display_override or f"Latest ({slug})",
path="./versions/latest.yml",
availability=stable_availability,
),
refresh_shared=False,
)
snapshots["latest"] = {
"source-ref": source_ref,
"source-sha": source_sha,
"version": str(parsed_version),
}
write_snapshot_metadata(metadata_path, snapshots)
print(f"Synced immutable {slug} docs from {source_ref}")
return
if channel in {"dev", "latest"}:
seed_mutable_snapshot_metadata(snapshots, docs_yml, slug)
if not ensure_monotonic_snapshot(
snapshots,
slug,
source_sha,
release_version,
allow_rollback=bool(getattr(args, "allow_rollback", False)),
):
print(
f"Skipped stale {slug} docs {release_version}; "
f"current version is {snapshots[slug]['version']}"
)
return
else:
ensure_immutable_snapshot(snapshots, target_fern, slug, source_sha)
release_version = release_version or slug.removeprefix("v")
write_snapshot(
source_docs,
source_fern,
target_fern,
VersionEntry(
slug=slug,
display_name=display_name,
path=f"./versions/{slug}.yml",
availability=availability,
),
refresh_shared=channel == "dev",
)
snapshots[slug] = {
"source-ref": source_ref,
"source-sha": source_sha,
"version": release_version,
}
write_snapshot_metadata(metadata_path, snapshots)
print(f"Synced {channel} docs from {source_ref} to fern/pages-{slug}")
def remove_docs(args: argparse.Namespace) -> None:
docs_root = args.docs_website_root.resolve()
target_fern = docs_root / "fern"
ensure_existing(target_fern, "docs website fern directory")
channel = clean_input(args.channel)
version_slug = clean_input(args.version_slug)
slug = resolve_slug(channel, version_slug)
pages_dir = target_fern / f"pages-{slug}"
if pages_dir.exists():
shutil.rmtree(pages_dir)
version_file = target_fern / "versions" / f"{slug}.yml"
if version_file.exists():
version_file.unlink()
remove_docs_yml_entry(target_fern / "docs.yml", slug, target_fern)
metadata_path = target_fern / SNAPSHOT_METADATA_FILE
snapshots = read_snapshot_metadata(metadata_path)
if slug in snapshots:
del snapshots[slug]
write_snapshot_metadata(metadata_path, snapshots)
print(f"Removed {slug} docs from docs website branch")
def main() -> None:
try:
args = parse_args()
if args.operation == "sync":
sync_docs(args)
else:
remove_docs(args)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
if __name__ == "__main__":
main()