From 42a164e3f32dc1903cce03fb64fbc0d1b64a3d53 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Fri, 5 Jun 2026 16:52:09 -0700 Subject: [PATCH 1/2] ci(docs): add docs website automation --- .github/workflows/publish-docs-website.yml | 73 +++++ .github/workflows/sync-docs.yml | 136 +++++++++ tasks/scripts/sync_docs_website.py | 338 +++++++++++++++++++++ 3 files changed, 547 insertions(+) create mode 100644 .github/workflows/publish-docs-website.yml create mode 100644 .github/workflows/sync-docs.yml create mode 100644 tasks/scripts/sync_docs_website.py diff --git a/.github/workflows/publish-docs-website.yml b/.github/workflows/publish-docs-website.yml new file mode 100644 index 000000000..27f1a90b8 --- /dev/null +++ b/.github/workflows/publish-docs-website.yml @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Publish Docs Website + +on: + workflow_dispatch: + inputs: + mode: + description: "Fern publish mode" + required: true + default: preview + type: choice + options: + - preview + - production + preview_id: + description: "Optional Fern preview id when mode=preview" + required: false + type: string + +permissions: + contents: read + +concurrency: + group: docs-website + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout docs website branch + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: docs-website + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "24" + + - name: Install Fern CLI + run: | + FERN_VERSION=$(node -p "require('./fern/fern.config.json').version") + npm install -g "fern-api@${FERN_VERSION}" + + - name: Validate docs website + working-directory: ./fern + run: fern check + + - name: Generate Fern preview + if: ${{ inputs.mode == 'preview' }} + env: + FERN_TOKEN: ${{ secrets.FERN_TOKEN }} + INPUT_PREVIEW_ID: ${{ inputs.preview_id }} + working-directory: ./fern + run: | + set -euo pipefail + PREVIEW_ID="${INPUT_PREVIEW_ID:-docs-website-${{ github.run_id }}}" + fern generate --docs --preview --id "$PREVIEW_ID" + + - name: Publish Fern docs + if: ${{ inputs.mode == 'production' }} + env: + FERN_TOKEN: ${{ secrets.FERN_TOKEN }} + working-directory: ./fern + run: fern generate --docs diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml new file mode 100644 index 000000000..faec653f5 --- /dev/null +++ b/.github/workflows/sync-docs.yml @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Sync Docs Website + +on: + workflow_dispatch: + inputs: + operation: + description: "Whether to sync or remove a docs snapshot" + required: true + default: sync + type: choice + options: + - sync + - remove + channel: + description: "Docs channel to update or remove" + required: true + type: choice + options: + - dev + - latest + - version + source_ref: + description: "Source commit SHA, branch, or tag to snapshot when operation=sync" + required: false + type: string + version_slug: + description: "Version slug when channel=version, e.g. v0.0.36" + required: false + type: string + display_name: + description: "Optional version selector display name" + required: false + type: string + +permissions: + contents: write + +concurrency: + group: docs-website + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout automation + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + path: automation + + - name: Validate inputs + run: | + set -euo pipefail + if [[ "${{ inputs.operation }}" == "sync" && -z "${{ inputs.source_ref }}" ]]; then + echo "source_ref is required when operation=sync" >&2 + exit 1 + fi + if [[ "${{ inputs.channel }}" == "version" && -z "${{ inputs.version_slug }}" ]]; then + echo "version_slug is required when channel=version" >&2 + exit 1 + fi + + - name: Checkout source docs + if: ${{ inputs.operation == 'sync' }} + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ inputs.source_ref }} + fetch-depth: 0 + path: source + + - name: Checkout docs website branch + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: docs-website + fetch-depth: 0 + path: docs-website + + - name: Install uv + run: python3 -m pip install "uv==0.10.12" + + - name: Update docs snapshot + run: | + uv run automation/tasks/scripts/sync_docs_website.py \ + --operation "${{ inputs.operation }}" \ + --source-root source \ + --docs-website-root docs-website \ + --channel "${{ inputs.channel }}" \ + --source-ref "${{ inputs.source_ref }}" \ + --version-slug "${{ inputs.version_slug }}" \ + --display-name "${{ inputs.display_name }}" + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "24" + + - name: Install Fern CLI + working-directory: docs-website + run: | + FERN_VERSION=$(node -p "require('./fern/fern.config.json').version") + npm install -g "fern-api@${FERN_VERSION}" + + - name: Validate docs website + working-directory: docs-website/fern + run: fern check + + - name: Commit docs website changes + working-directory: docs-website + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + # https://api.github.com/users/github-actions%5Bbot%5D + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add . + if git diff --cached --quiet; then + echo "No docs website changes to commit." + exit 0 + fi + target="${{ inputs.version_slug }}" + if [[ -z "${target}" ]]; then + target="${{ inputs.channel }}" + fi + if [[ "${{ inputs.operation }}" == "sync" ]]; then + git commit -m "docs(website): sync ${target} docs from ${{ inputs.source_ref }}" + else + git commit -m "docs(website): remove ${target} docs" + fi + git push origin HEAD:docs-website diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py new file mode 100644 index 000000000..7809ed071 --- /dev/null +++ b/tasks/scripts/sync_docs_website.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "PyYAML==6.0.2", +# ] +# /// + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import argparse +import re +import shutil +import sys +import tempfile +from collections.abc import MutableMapping, MutableSequence +from dataclasses import dataclass +from pathlib import Path + +import yaml + +SLUG_RE = re.compile(r"^[A-Za-z0-9._-]+$") + + +@dataclass +class VersionEntry: + slug: str + display_name: str + path: str + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Sync or remove one docs snapshot 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", "version"]) + parser.add_argument("--source-ref", default="") + parser.add_argument("--version-slug", default="") + parser.add_argument("--display-name", default="") + 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=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 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, *, preserve_components: bool) -> None: + ensure_existing(src, "source directory") + preserved_components: Path | None = None + if preserve_components and (dst / "_components").is_dir(): + preserved_components = Path(tempfile.mkdtemp()) / "_components" + shutil.copytree(dst / "_components", preserved_components) + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) + if preserved_components is not None: + if (dst / "_components").exists(): + shutil.rmtree(dst / "_components") + shutil.copytree(preserved_components, dst / "_components") + + +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) -> dict: + 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 data + + +def write_yaml(path: Path, data: dict) -> None: + path.write_text( + yaml.safe_dump(data, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + + +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, MutableMapping): + for key in ("path", "folder"): + if key in value: + value[key] = prefix_path(value[key], pages_dir) + for child in value.values(): + prefix_navigation_paths(child, pages_dir) + elif isinstance(value, MutableSequence): + for child in value: + prefix_navigation_paths(child, pages_dir) + return value + + +def version_navigation(source_index: Path, pages_dir: str) -> dict: + return prefix_navigation_paths(read_yaml(source_index), pages_dir) + + +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 raw_versions: + if not isinstance(raw, dict): + continue + slug = raw.get("slug") + display_name = raw.get("display-name") + path = raw.get("path") + if isinstance(slug, str) and isinstance(display_name, str) and isinstance(path, str): + entries.append( + VersionEntry(slug=slug, display_name=display_name, path=path) + ) + 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]]: + return [ + { + "display-name": entry.display_name, + "path": entry.path, + "slug": entry.slug, + } + for entry in entries + ] + + +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 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") + version_slug = clean_input(args.version_slug) + display_override = clean_input(args.display_name) + slug = resolve_slug(channel, version_slug) + display_name = resolve_display_name(channel, slug, source_ref, display_override) + pages_dir = f"pages-{slug}" + refresh_shared = channel in {"dev", "latest"} + + reset_directory( + source_docs, + target_fern / pages_dir, + preserve_components=not refresh_shared, + ) + merge_directory( + source_fern / "assets", target_fern / "assets", overwrite=refresh_shared + ) + merge_directory( + source_fern / "components", target_fern / "components", overwrite=refresh_shared + ) + if refresh_shared: + 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"{slug}.yml", + version_navigation(source_docs / "index.yml", pages_dir), + ) + + update_docs_yml( + target_fern / "docs.yml", + VersionEntry( + slug=slug, + display_name=display_name, + path=f"./versions/{slug}.yml", + ), + target_fern, + ) + + print( + f"Synced {channel} docs from {source_ref} to fern/{pages_dir} " + f"({display_name})" + ) + + +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) + + 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() From b94a83b8d4ddb47df729bddedd65c7f97f314f7f Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Fri, 5 Jun 2026 17:23:12 -0700 Subject: [PATCH 2/2] ci(docs): harden sync-docs inputs and add script tests Route free-form workflow_dispatch inputs through env vars to avoid shell injection, switch uv install to the approved setup-uv action, and add a pytest suite for sync_docs_website.py wired into the test task. --- .github/workflows/sync-docs.yml | 50 +++++-- tasks/scripts/sync_docs_website_test.py | 181 ++++++++++++++++++++++++ tasks/test.toml | 9 +- 3 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 tasks/scripts/sync_docs_website_test.py diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index faec653f5..90626d5ff 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -57,13 +57,22 @@ jobs: path: automation - name: Validate inputs + # Pass dispatch inputs through env vars rather than interpolating + # ${{ inputs.* }} directly into the script. source_ref/version_slug are + # free-form strings, so inlining them would allow shell injection into + # the runner (e.g. version_slug = "$(...)"). Quoted env vars are inert. + env: + OPERATION: ${{ inputs.operation }} + CHANNEL: ${{ inputs.channel }} + SOURCE_REF: ${{ inputs.source_ref }} + VERSION_SLUG: ${{ inputs.version_slug }} run: | set -euo pipefail - if [[ "${{ inputs.operation }}" == "sync" && -z "${{ inputs.source_ref }}" ]]; then + if [[ "$OPERATION" == "sync" && -z "$SOURCE_REF" ]]; then echo "source_ref is required when operation=sync" >&2 exit 1 fi - if [[ "${{ inputs.channel }}" == "version" && -z "${{ inputs.version_slug }}" ]]; then + if [[ "$CHANNEL" == "version" && -z "$VERSION_SLUG" ]]; then echo "version_slug is required when channel=version" >&2 exit 1 fi @@ -84,18 +93,28 @@ jobs: path: docs-website - name: Install uv - run: python3 -m pip install "uv==0.10.12" + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + version: "0.10.12" - name: Update docs snapshot + # Inputs flow in as quoted env vars to avoid shell injection; see the + # Validate inputs step above. + env: + OPERATION: ${{ inputs.operation }} + CHANNEL: ${{ inputs.channel }} + SOURCE_REF: ${{ inputs.source_ref }} + VERSION_SLUG: ${{ inputs.version_slug }} + DISPLAY_NAME: ${{ inputs.display_name }} run: | uv run automation/tasks/scripts/sync_docs_website.py \ - --operation "${{ inputs.operation }}" \ + --operation "$OPERATION" \ --source-root source \ --docs-website-root docs-website \ - --channel "${{ inputs.channel }}" \ - --source-ref "${{ inputs.source_ref }}" \ - --version-slug "${{ inputs.version_slug }}" \ - --display-name "${{ inputs.display_name }}" + --channel "$CHANNEL" \ + --source-ref "$SOURCE_REF" \ + --version-slug "$VERSION_SLUG" \ + --display-name "$DISPLAY_NAME" - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 @@ -114,6 +133,13 @@ jobs: - name: Commit docs website changes working-directory: docs-website + # Inputs flow in as quoted env vars to avoid shell injection (the commit + # message embeds free-form source_ref/version_slug); see Validate inputs. + env: + OPERATION: ${{ inputs.operation }} + CHANNEL: ${{ inputs.channel }} + SOURCE_REF: ${{ inputs.source_ref }} + VERSION_SLUG: ${{ inputs.version_slug }} run: | set -euo pipefail git config user.name "github-actions[bot]" @@ -124,12 +150,12 @@ jobs: echo "No docs website changes to commit." exit 0 fi - target="${{ inputs.version_slug }}" + target="$VERSION_SLUG" if [[ -z "${target}" ]]; then - target="${{ inputs.channel }}" + target="$CHANNEL" fi - if [[ "${{ inputs.operation }}" == "sync" ]]; then - git commit -m "docs(website): sync ${target} docs from ${{ inputs.source_ref }}" + if [[ "$OPERATION" == "sync" ]]; then + git commit -m "docs(website): sync ${target} docs from ${SOURCE_REF}" else git commit -m "docs(website): remove ${target} docs" fi diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py new file mode 100644 index 000000000..ac2dee75b --- /dev/null +++ b/tasks/scripts/sync_docs_website_test.py @@ -0,0 +1,181 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for tasks/scripts/sync_docs_website.py. + +Run via `mise run test:docs-website`, which provides pytest + PyYAML through +`uv run --with ...`. pytest puts this file's directory on sys.path, so the +sibling script imports directly as `sync_docs_website`. +""" + +from __future__ import annotations + +from argparse import Namespace +from typing import TYPE_CHECKING + +import pytest +import sync_docs_website as sdw +import yaml + +if TYPE_CHECKING: + from pathlib import Path + + +def read_yaml(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def test_resolve_slug_channels() -> None: + assert sdw.resolve_slug("dev", "") == "dev" + assert sdw.resolve_slug("latest", "") == "latest" + assert sdw.resolve_slug("version", "v0.0.36") == "v0.0.36" + + +def test_resolve_slug_version_requires_slug() -> None: + with pytest.raises(ValueError): + sdw.resolve_slug("version", "") + + +def test_resolve_slug_rejects_unsafe_characters() -> None: + # Guards the slug that becomes a directory name (pages-). + with pytest.raises(ValueError): + sdw.resolve_slug("version", "../escape") + with pytest.raises(ValueError): + sdw.resolve_slug("version", "v1 0") + + +def test_resolve_display_name() -> None: + assert sdw.resolve_display_name("dev", "dev", "main", "") == "dev" + assert ( + sdw.resolve_display_name("latest", "latest", "v0.0.57", "") + == "Latest (v0.0.57)" + ) + assert sdw.resolve_display_name("latest", "latest", "abc123", "") == "Latest" + assert sdw.resolve_display_name("version", "v0.0.36", "v0.0.36", "") == "v0.0.36" + assert sdw.resolve_display_name("dev", "dev", "main", "Custom") == "Custom" + + +def test_ordered_entries_pins_latest_then_dev() -> None: + existing = [ + sdw.VersionEntry("v0.0.36", "v0.0.36", "./versions/v0.0.36.yml"), + sdw.VersionEntry("dev", "dev", "./versions/dev.yml"), + ] + updated = sdw.VersionEntry("latest", "Latest", "./versions/latest.yml") + ordered = [entry.slug for entry in sdw.ordered_entries(existing, updated)] + assert ordered == ["latest", "dev", "v0.0.36"] + + +def test_prefix_navigation_paths() -> None: + nav = { + "navigation": [ + {"page": "Intro", "path": "intro.mdx"}, + { + "section": "Guide", + "folder": "guide", + "contents": [{"path": "guide/a.mdx"}], + }, + {"page": "External", "path": "https://example.com"}, + ] + } + sdw.prefix_navigation_paths(nav, "pages-dev") + assert nav["navigation"][0]["path"] == "../pages-dev/intro.mdx" + assert nav["navigation"][1]["folder"] == "../pages-dev/guide" + assert nav["navigation"][1]["contents"][0]["path"] == "../pages-dev/guide/a.mdx" + # Absolute URLs are left untouched. + assert nav["navigation"][2]["path"] == "https://example.com" + + +def _make_source_tree(root: Path) -> None: + docs = root / "docs" + docs.mkdir(parents=True) + (docs / "intro.mdx").write_text("# Intro\n", encoding="utf-8") + (docs / "index.yml").write_text( + yaml.safe_dump({"navigation": [{"page": "Intro", "path": "intro.mdx"}]}), + encoding="utf-8", + ) + fern = root / "fern" + (fern / "assets").mkdir(parents=True) + (fern / "assets" / "logo.svg").write_text("", encoding="utf-8") + (fern / "components").mkdir(parents=True) + (fern / "components" / "Card.tsx").write_text( + "export const Card = 1;\n", encoding="utf-8" + ) + (fern / "main.css").write_text("body{}\n", encoding="utf-8") + (fern / "fern.config.json").write_text('{"version": "0.0.0"}\n', encoding="utf-8") + + +def _make_docs_website_tree(root: Path) -> None: + fern = root / "fern" + fern.mkdir(parents=True) + (fern / "docs.yml").write_text(yaml.safe_dump({"versions": []}), encoding="utf-8") + + +def test_sync_docs_creates_snapshot(tmp_path: Path) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + + sdw.sync_docs( + Namespace( + operation="sync", + source_root=source, + docs_website_root=website, + channel="dev", + source_ref="main", + version_slug="", + display_name="", + ) + ) + + fern = website / "fern" + assert (fern / "pages-dev" / "intro.mdx").is_file() + assert (fern / "assets" / "logo.svg").is_file() + + version_nav = read_yaml(fern / "versions" / "dev.yml") + assert version_nav["navigation"][0]["path"] == "../pages-dev/intro.mdx" + + docs_yml = read_yaml(fern / "docs.yml") + slugs = [entry["slug"] for entry in docs_yml["versions"]] + assert slugs == ["dev"] + assert docs_yml["versions"][0]["path"] == "./versions/dev.yml" + assert "./components" in docs_yml["experimental"]["mdx-components"] + + +def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + + base = Namespace( + operation="sync", + source_root=source, + docs_website_root=website, + channel="version", + source_ref="v0.0.36", + version_slug="v0.0.36", + display_name="", + ) + sdw.sync_docs(base) + + fern = website / "fern" + assert (fern / "pages-v0.0.36").is_dir() + assert (fern / "versions" / "v0.0.36.yml").is_file() + + sdw.remove_docs( + Namespace( + operation="remove", + source_root=None, + docs_website_root=website, + channel="version", + source_ref="", + version_slug="v0.0.36", + display_name="", + ) + ) + + assert not (fern / "pages-v0.0.36").exists() + assert not (fern / "versions" / "v0.0.36.yml").exists() + docs_yml = read_yaml(fern / "docs.yml") + assert [entry["slug"] for entry in docs_yml["versions"]] == [] diff --git a/tasks/test.toml b/tasks/test.toml index 51f24f1be..3751c5942 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -5,7 +5,14 @@ [test] description = "Run all tests (Rust + Python)" -depends = ["test:rust", "test:python", "test:install-sh", "test:packaging-assets"] +depends = ["test:rust", "test:python", "test:install-sh", "test:packaging-assets", "test:docs-website"] + +["test:docs-website"] +description = "Test the docs-website sync script" +# --no-project skips the workspace (maturin) build; --with supplies pytest and +# the script's runtime dep (PyYAML), which live outside the project env. +run = "uv run --no-project --with pytest --with pyyaml pytest tasks/scripts/sync_docs_website_test.py" +hide = true ["test:install-sh"] description = "Run focused install.sh shell tests"