#!/usr/bin/env python3 """ Manage Rerun documentation and examples in GCS for the rerun.io website. The website (rerun-io/landing) reads its docs and examples from GCS at `gs://rerun-docs/prose/`, exposed publicly as `https://ref.rerun.io/prose/`. Subcommands: upload Build and upload a version (files + index.json), optionally promote to `latest`, and trigger revalidation. delete Remove a version (files + entry in versions.json) and trigger revalidation. Usable both from CI and locally. Run from anywhere; paths are resolved relative to the script's monorepo location. Install dependencies (already present in this repo's uv workspace): uv sync Examples: uv run scripts/ci/upload_docs.py upload --version 0.21.0 --mark-latest --purge-token "$ISR_BYPASS_TOKEN" uv run scripts/ci/upload_docs.py upload --version pr-1234 --skip-purge uv run scripts/ci/upload_docs.py delete --version test-local --skip-purge """ from __future__ import annotations import argparse import gzip import io import json import mimetypes import re import subprocess import sys from collections.abc import Iterable from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any import requests import requests.adapters import yaml from google.cloud import storage SCHEMA_VERSION = 1 BUCKET_NAME = "rerun-docs" GCS_PREFIX = "prose" DEFAULT_SITE_URL = "https://rerun.io" # Files that should be uploaded for each subtree. The website only reads # these paths; uploading anything else just wastes space. DOC_CONTENT_GLOBS = ("**/*.md",) DOC_CONTENT_EXTRA_FILES = ("_redirects.yaml",) SNIPPET_FILES = ("snippets.toml",) SNIPPET_SOURCE_EXTS = (".py", ".rs", ".cpp") EXAMPLE_FILES = ("manifest.toml",) EXAMPLE_README_GLOB = "*/*/README.md" PR_VERSION_RE = re.compile(r"^pr-\d+$") # --------------------------------------------------------------------------- # index.json construction # --------------------------------------------------------------------------- @dataclass class FileEntry: """A single file to upload, plus its archive-relative path.""" archive_path: str # e.g. "docs/content/getting-started/quick-start.md" source: Path # absolute path on disk is_doc_markdown: bool # true for docs/content/**.md (parse frontmatter) def collect_files(rerun_root: Path) -> list[FileEntry]: files: list[FileEntry] = [] docs_content = rerun_root / "docs" / "content" for pattern in DOC_CONTENT_GLOBS: for md in docs_content.glob(pattern): if md.is_file(): rel = md.relative_to(rerun_root).as_posix() files.append(FileEntry(rel, md, is_doc_markdown=True)) for name in DOC_CONTENT_EXTRA_FILES: path = docs_content / name if path.is_file(): rel = path.relative_to(rerun_root).as_posix() files.append(FileEntry(rel, path, is_doc_markdown=False)) snippets_dir = rerun_root / "docs" / "snippets" for name in SNIPPET_FILES: path = snippets_dir / name if path.is_file(): rel = path.relative_to(rerun_root).as_posix() files.append(FileEntry(rel, path, is_doc_markdown=False)) snippets_all = snippets_dir / "all" if snippets_all.is_dir(): for path in snippets_all.rglob("*"): if path.is_file() and path.suffix in SNIPPET_SOURCE_EXTS: rel = path.relative_to(rerun_root).as_posix() files.append(FileEntry(rel, path, is_doc_markdown=False)) examples_dir = rerun_root / "examples" for name in EXAMPLE_FILES: path = examples_dir / name if path.is_file(): rel = path.relative_to(rerun_root).as_posix() files.append(FileEntry(rel, path, is_doc_markdown=False)) for readme in examples_dir.glob(EXAMPLE_README_GLOB): if readme.is_file(): rel = readme.relative_to(rerun_root).as_posix() files.append(FileEntry(rel, readme, is_doc_markdown=False)) return files def parse_doc_frontmatter(md_path: Path) -> dict[str, Any]: """Parse YAML frontmatter from a docs markdown file. Returns the metadata dict expected by the website: title, order, sort_children, hidden, expand, redirect. """ text = md_path.read_text(encoding="utf-8") if not text.startswith("---"): raise ValueError(f"{md_path}: missing YAML frontmatter") end = text.find("\n---", 3) if end == -1: raise ValueError(f"{md_path}: unterminated YAML frontmatter") raw = text[3:end].strip() fm = yaml.safe_load(raw) or {} if not isinstance(fm, dict): raise ValueError(f"{md_path}: frontmatter is not a mapping") if "title" not in fm: raise ValueError(f"{md_path}: frontmatter missing required 'title'") metadata: dict[str, Any] = { "title": fm["title"], "hidden": bool(fm.get("hidden", False)), "expand": bool(fm.get("expand", False)), } if "order" in fm and fm["order"] is not None: metadata["order"] = fm["order"] if "sort_children" in fm and fm["sort_children"] is not None: metadata["sort_children"] = fm["sort_children"] if "redirect" in fm and fm["redirect"] is not None: metadata["redirect"] = fm["redirect"] return metadata def load_redirects(rerun_root: Path) -> dict[str, str]: path = rerun_root / "docs" / "content" / "_redirects.yaml" if not path.is_file(): return {} data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} if not isinstance(data, dict): raise ValueError(f"{path}: top-level value must be a mapping") return {str(k): str(v) for k, v in data.items()} def build_index( *, version: str, rerun_commit: str, files: list[FileEntry], redirects: dict[str, str], ) -> dict[str, Any]: entries: dict[str, dict[str, Any]] = {} dir_children: dict[str, set[str]] = {} def ensure_dir(dir_path: str) -> None: """Make sure `dir_path` (ending in '/') and all its parents exist in the entries map, and that each parent dir lists the child.""" if dir_path in entries: return # Add this dir. entries[dir_path] = {"kind": "dir"} dir_children.setdefault(dir_path, set()) # Recurse up to root and register this as a child of its parent. without_trailing = dir_path[:-1] if "/" in without_trailing: parent = without_trailing.rsplit("/", 1)[0] + "/" ensure_dir(parent) child_basename = without_trailing.rsplit("/", 1)[1] + "/" dir_children[parent].add(child_basename) # Top-level dirs have no parent in the index. for f in files: # Register all parent directories. if "/" in f.archive_path: parent = f.archive_path.rsplit("/", 1)[0] + "/" ensure_dir(parent) dir_children[parent].add(f.archive_path.rsplit("/", 1)[1]) entry: dict[str, Any] = {"kind": "file"} if f.is_doc_markdown: entry["metadata"] = parse_doc_frontmatter(f.source) entries[f.archive_path] = entry # Flush children lists into the dir entries. for dir_path, children in dir_children.items(): entries[dir_path]["children"] = sorted(children) return { "schema_version": SCHEMA_VERSION, "version": version, "last_update": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "rerun_commit": rerun_commit, "entries": entries, "redirects": redirects, } # --------------------------------------------------------------------------- # Upload # --------------------------------------------------------------------------- def content_type_for(path: str) -> str: if path.endswith(".md"): return "text/markdown; charset=utf-8" if path.endswith(".toml"): return "application/toml; charset=utf-8" if path.endswith((".yaml", ".yml")): return "application/yaml; charset=utf-8" if path.endswith(".json"): return "application/json; charset=utf-8" if path.endswith((".py", ".rs", ".cpp")): return "text/plain; charset=utf-8" guessed, _ = mimetypes.guess_type(path) return guessed or "application/octet-stream" def gzip_bytes(data: bytes) -> bytes: buf = io.BytesIO() # mtime=0 so re-uploading the same content produces identical bytes. with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gz: gz.write(data) return buf.getvalue() def upload_blob( bucket: storage.Bucket, *, gcs_path: str, payload: bytes, content_type: str, cache_control: str = "no-cache", ) -> None: """Upload `payload` (already gzipped) under `gcs_path`.""" blob = bucket.blob(gcs_path) blob.content_encoding = "gzip" blob.content_type = content_type blob.cache_control = cache_control blob.upload_from_string(payload, content_type=content_type) def upload_files( bucket: storage.Bucket, version: str, files: list[FileEntry], *, dry_run: bool, concurrency: int = 128, ) -> None: prefix = f"{GCS_PREFIX}/{version}/" def upload_one(f: FileEntry) -> str: gcs_path = prefix + f.archive_path if dry_run: return f"DRY-RUN: {gcs_path}" payload = gzip_bytes(f.source.read_bytes()) upload_blob( bucket, gcs_path=gcs_path, payload=payload, content_type=content_type_for(f.archive_path), ) return gcs_path with ThreadPoolExecutor(max_workers=concurrency) as pool: futures = [pool.submit(upload_one, f) for f in files] for i, fut in enumerate(as_completed(futures), 1): path = fut.result() if i % 50 == 0 or i == len(futures): print(f" [{i}/{len(futures)}] {path}") def upload_index(bucket: storage.Bucket, version: str, index: dict[str, Any], *, dry_run: bool) -> None: gcs_path = f"{GCS_PREFIX}/{version}/index.json" if dry_run: print(f"DRY-RUN: would write {gcs_path} ({len(index['entries'])} entries)") return payload = gzip_bytes(json.dumps(index, indent=2).encode("utf-8")) upload_blob( bucket, gcs_path=gcs_path, payload=payload, content_type="application/json; charset=utf-8", ) print(f"wrote {gcs_path}") def update_versions_manifest( bucket: storage.Bucket, *, version: str, rerun_commit: str, mark_latest: bool, dry_run: bool, ) -> None: gcs_path = f"{GCS_PREFIX}/versions.json" blob = bucket.blob(gcs_path) if blob.exists(): # GCS auto-decompresses on download. raw = blob.download_as_bytes() manifest = json.loads(raw) else: manifest = {"schema_version": SCHEMA_VERSION, "latest": version, "versions": {}} manifest.setdefault("schema_version", SCHEMA_VERSION) manifest.setdefault("versions", {}) manifest["versions"][version] = {"rerun_commit": rerun_commit} if mark_latest or "latest" not in manifest: manifest["latest"] = version if dry_run: print(f"DRY-RUN: would update {gcs_path}: {json.dumps(manifest, indent=2)}") return payload = gzip_bytes(json.dumps(manifest, indent=2).encode("utf-8")) upload_blob( bucket, gcs_path=gcs_path, payload=payload, content_type="application/json; charset=utf-8", ) print(f"wrote {gcs_path} (latest={manifest['latest']})") # --------------------------------------------------------------------------- # Revalidation webhook # --------------------------------------------------------------------------- def trigger_revalidate( *, site_url: str, token: str, target: dict[str, Any], dry_run: bool, ) -> None: endpoint = site_url.rstrip("/") + "/api/revalidate" if dry_run: print(f"DRY-RUN: would POST {endpoint} body={target}") return resp = requests.post( endpoint, json=target, headers={"Authorization": f"Bearer {token}"}, timeout=30, ) resp.raise_for_status() print(f"revalidation OK: {resp.status_code}") def delete_version_files(bucket: storage.Bucket, version: str, *, dry_run: bool, concurrency: int = 128) -> int: prefix = f"{GCS_PREFIX}/{version}/" blobs = list(bucket.list_blobs(prefix=prefix)) if not blobs: print(f" no objects under {prefix}") return 0 def delete_one(blob: storage.Blob) -> str: if dry_run: return f"DRY-RUN: {blob.name}" blob.delete() return str(blob.name) with ThreadPoolExecutor(max_workers=concurrency) as pool: futures = [pool.submit(delete_one, b) for b in blobs] for i, fut in enumerate(as_completed(futures), 1): name = fut.result() if i % 100 == 0 or i == len(futures): print(f" [{i}/{len(futures)}] {name}") return len(blobs) def remove_version_from_manifest(bucket: storage.Bucket, *, version: str, dry_run: bool) -> bool: """Remove `version` from versions.json. Returns True if `latest` was affected (caller should also purge the unversioned routes).""" gcs_path = f"{GCS_PREFIX}/versions.json" blob = bucket.blob(gcs_path) if not blob.exists(): print(f" {gcs_path} does not exist; nothing to update") return False manifest = json.loads(blob.download_as_bytes()) versions = manifest.get("versions", {}) if version not in versions: print(f" {version} not in versions.json; nothing to update") return False if manifest.get("latest") == version: raise SystemExit( f"refusing to delete {version}: it is currently `latest`. " f"Promote another version with `upload --mark-latest` first." ) del versions[version] if dry_run: print(f"DRY-RUN: would update {gcs_path}: {json.dumps(manifest, indent=2)}") return False payload = gzip_bytes(json.dumps(manifest, indent=2).encode("utf-8")) upload_blob( bucket, gcs_path=gcs_path, payload=payload, content_type="application/json; charset=utf-8", ) print(f"wrote {gcs_path} (removed {version})") return False # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def make_storage_client(pool_size: int) -> storage.Client: """Storage client whose HTTP session has a connection pool big enough for `pool_size` concurrent in-flight requests. The default `requests` pool is 10, so without this the worker threads serialize behind it.""" client = storage.Client() adapter = requests.adapters.HTTPAdapter(pool_connections=pool_size, pool_maxsize=pool_size, max_retries=3) client._http.mount("https://", adapter) client._http.mount("http://", adapter) return client def detect_rerun_root(script_path: Path) -> Path: # scripts/ci/upload_docs.py -> ../../ return script_path.resolve().parent.parent.parent def detect_rerun_commit(rerun_root: Path) -> str: try: sha = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=rerun_root, text=True).strip() return sha except Exception as e: raise SystemExit(f"failed to detect rerun commit via git: {e}") def add_common_args(p: argparse.ArgumentParser) -> None: p.add_argument("--version", required=True, help="version label, e.g. 0.21.0 / main / nightly / pr-1234") p.add_argument("--purge-token", help="bearer token for the website's /api/revalidate endpoint") p.add_argument("--skip-purge", action="store_true", help="skip the revalidation webhook (local testing)") p.add_argument("--site-url", default=DEFAULT_SITE_URL, help=f"website base URL (default: {DEFAULT_SITE_URL})") p.add_argument("--bucket", default=BUCKET_NAME, help=f"GCS bucket (default: {BUCKET_NAME})") p.add_argument("--concurrency", type=int, default=128, help="parallel GCS operations (default: 128)") p.add_argument("--dry-run", action="store_true", help="do not upload, delete, or call any webhook") def cmd_upload(args: argparse.Namespace) -> int: is_pr_preview = bool(PR_VERSION_RE.match(args.version)) if is_pr_preview and args.mark_latest: raise SystemExit("--mark-latest is incompatible with a pr-* version") rerun_root = Path(args.rerun_root).resolve() if args.rerun_root else detect_rerun_root(Path(__file__)) if not (rerun_root / "docs" / "content").is_dir(): raise SystemExit(f"could not locate docs/content under {rerun_root}") rerun_commit = args.rerun_commit or detect_rerun_commit(rerun_root) print(f"rerun root: {rerun_root}") print(f"version: {args.version}{' (PR preview)' if is_pr_preview else ''}") print(f"rerun commit: {rerun_commit}") print(f"bucket: gs://{args.bucket}/{GCS_PREFIX}/{args.version}/") print(f"site: {args.site_url}") print(f"dry-run: {args.dry_run}") print() print("scanning files…") files = collect_files(rerun_root) print(f" found {len(files)} files") print("building index.json…") redirects = load_redirects(rerun_root) index = build_index( version=args.version, rerun_commit=rerun_commit, files=files, redirects=redirects, ) print(f" {len(index['entries'])} entries, {len(redirects)} redirects") client = make_storage_client(args.concurrency) bucket = client.bucket(args.bucket) print("uploading files…") upload_files(bucket, args.version, files, dry_run=args.dry_run, concurrency=args.concurrency) print("uploading index.json…") upload_index(bucket, args.version, index, dry_run=args.dry_run) if is_pr_preview: print("PR preview: skipping versions.json update") else: print("updating versions.json…") update_versions_manifest( bucket, version=args.version, rerun_commit=rerun_commit, mark_latest=args.mark_latest, dry_run=args.dry_run, ) if args.skip_purge: print("skipping revalidation webhook (--skip-purge)") else: target: dict[str, Any] = ( {"target": "latest"} if args.mark_latest else {"target": "version", "version": args.version} ) print("triggering revalidation…") trigger_revalidate(site_url=args.site_url, token=args.purge_token, target=target, dry_run=args.dry_run) print("done.") return 0 def cmd_delete(args: argparse.Namespace) -> int: is_pr_preview = bool(PR_VERSION_RE.match(args.version)) print(f"version: {args.version}{' (PR preview)' if is_pr_preview else ''}") print(f"bucket: gs://{args.bucket}/{GCS_PREFIX}/{args.version}/") print(f"site: {args.site_url}") print(f"dry-run: {args.dry_run}") print() client = make_storage_client(args.concurrency) bucket = client.bucket(args.bucket) # Update manifest first so concurrent readers stop discovering the # version before we start tearing down its files. (PR previews aren't # in versions.json, so this is a no-op for them.) if not is_pr_preview: print("updating versions.json…") remove_version_from_manifest(bucket, version=args.version, dry_run=args.dry_run) print("deleting files…") deleted = delete_version_files(bucket, args.version, dry_run=args.dry_run, concurrency=args.concurrency) print(f" deleted {deleted} objects") if args.skip_purge: print("skipping revalidation webhook (--skip-purge)") else: print("triggering revalidation…") trigger_revalidate( site_url=args.site_url, token=args.purge_token, target={"target": "version", "version": args.version}, dry_run=args.dry_run, ) print("done.") return 0 def main(argv: Iterable[str] | None = None) -> int: p = argparse.ArgumentParser(description="Manage Rerun docs/examples in GCS for the rerun.io website.") sub = p.add_subparsers(dest="command", required=True) pu = sub.add_parser("upload", help="build & upload a version") add_common_args(pu) pu.add_argument("--mark-latest", action="store_true", help="set this version as `latest` in versions.json") pu.add_argument("--rerun-commit", help="override the rerun commit SHA (default: git HEAD)") pu.add_argument( "--rerun-root", help="path to a rerun checkout to read docs/examples from (default: this monorepo's rerun/)" ) pd = sub.add_parser("delete", help="delete a version") add_common_args(pd) args = p.parse_args(list(argv) if argv is not None else None) if not args.skip_purge and not args.purge_token: raise SystemExit("either --purge-token or --skip-purge is required") if args.command == "upload": return cmd_upload(args) if args.command == "delete": return cmd_delete(args) raise SystemExit(f"unknown command: {args.command}") if __name__ == "__main__": sys.exit(main())