diff --git a/.github/workflows/publish-docs-website.yml b/.github/workflows/publish-docs-website.yml index dfb24b9cea..5c99492b6e 100644 --- a/.github/workflows/publish-docs-website.yml +++ b/.github/workflows/publish-docs-website.yml @@ -25,6 +25,7 @@ permissions: concurrency: group: docs-website cancel-in-progress: false + queue: max defaults: run: diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 18b5f33a2e..10f0cf5319 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -28,6 +28,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} outputs: python_version: ${{ steps.v.outputs.python }} + docs_version: ${{ steps.v.outputs.docs_version }} cargo_version: ${{ steps.v.outputs.cargo }} deb_version: ${{ steps.v.outputs.deb }} rpm_version: ${{ steps.v.outputs.rpm_version }} @@ -47,11 +48,15 @@ jobs: id: v run: | set -euo pipefail - echo "python=$(uv run python tasks/scripts/release.py get-version --dev --python)" >> "$GITHUB_OUTPUT" - echo "cargo=$(uv run python tasks/scripts/release.py get-version --dev --cargo)" >> "$GITHUB_OUTPUT" - echo "deb=$(uv run python tasks/scripts/release.py get-version --dev --deb)" >> "$GITHUB_OUTPUT" - echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --dev --rpm-version)" >> "$GITHUB_OUTPUT" - echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --dev --rpm-release)" >> "$GITHUB_OUTPUT" + python_version=$(uv run python tasks/scripts/release.py get-version --dev --python) + { + echo "python=${python_version}" + echo "docs_version=${python_version%%+*}" + echo "cargo=$(uv run python tasks/scripts/release.py get-version --dev --cargo)" + echo "deb=$(uv run python tasks/scripts/release.py get-version --dev --deb)" + echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --dev --rpm-version)" + echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --dev --rpm-release)" + } >> "$GITHUB_OUTPUT" build-cli: needs: compute-versions @@ -628,6 +633,22 @@ jobs: release-kind: dev pin-sha: ${{ github.sha }} + publish-fern-docs: + name: Sync and Publish Fern Docs + needs: [compute-versions, release-dev, release-helm, trigger-wheel-publish] + permissions: + contents: write + uses: ./.github/workflows/sync-docs.yml + with: + operation: sync + channel: dev + source_ref: ${{ github.sha }} + release_version: ${{ needs.compute-versions.outputs.docs_version }} + display_name: Dev (v${{ needs.compute-versions.outputs.docs_version }}) + availability: beta + publish: true + secrets: inherit + trigger-wheel-publish: name: Trigger Wheel Publish needs: [compute-versions, release-dev] diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index b3c45c97fb..d02bf8eb5a 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -664,31 +664,21 @@ jobs: if-no-files-found: error publish-fern-docs: - name: Publish Fern Docs - needs: [compute-versions, release] + name: Sync and Publish Fern Docs + needs: [compute-versions, release, publish-sdk-typescript, release-helm, trigger-wheel-publish] if: needs.compute-versions.outputs.is_prerelease != 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - 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: Publish Fern docs - env: - FERN_TOKEN: ${{ secrets.FERN_TOKEN }} - working-directory: ./fern - run: fern generate --docs + permissions: + contents: write + uses: ./.github/workflows/sync-docs.yml + with: + operation: sync + channel: stable + source_ref: ${{ needs.compute-versions.outputs.source_sha }} + release_version: ${{ needs.compute-versions.outputs.semver }} + version_slug: v${{ needs.compute-versions.outputs.semver }} + display_name: Latest (v${{ needs.compute-versions.outputs.semver }}) + publish: true + secrets: inherit publish-sdk-typescript: name: Publish TypeScript SDK diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index db2f29f736..25b4e81581 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -4,6 +4,49 @@ name: Sync Docs Website on: + workflow_call: + inputs: + operation: + description: "Whether to sync or remove a docs snapshot" + required: true + type: string + channel: + description: "Docs channel to update or remove" + required: true + type: string + source_ref: + description: "Source commit SHA, branch, or tag to snapshot when operation=sync" + required: false + type: string + release_version: + description: "Release version used to order mutable channels" + required: false + type: string + version_slug: + description: "Version slug when channel=version" + required: false + type: string + display_name: + description: "Optional version selector display name" + required: false + type: string + availability: + description: "Optional Fern availability status" + required: false + type: string + publish: + description: "Publish production docs after syncing" + required: false + default: false + type: boolean + allow_rollback: + description: "Allow an explicitly requested mutable-channel rollback" + required: false + default: false + type: boolean + secrets: + FERN_TOKEN: + required: false workflow_dispatch: inputs: operation: @@ -21,19 +64,38 @@ on: options: - dev - latest + - stable - version source_ref: description: "Source commit SHA, branch, or tag to snapshot when operation=sync" required: false type: string + release_version: + description: "Release version, e.g. 0.1.2 or 0.1.3.dev4" + 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" + description: "Optional selector name, e.g. Dev (v0.0.117.dev56)" + required: false + type: string + availability: + description: "Optional Fern status: beta, deprecated, ga, or stable" required: false type: string + publish: + description: "Publish production docs after syncing" + required: false + default: false + type: boolean + allow_rollback: + description: "Allow an explicitly requested mutable-channel rollback" + required: false + default: false + type: boolean permissions: contents: write @@ -41,6 +103,7 @@ permissions: concurrency: group: docs-website cancel-in-progress: false + queue: max defaults: run: @@ -65,6 +128,7 @@ jobs: OPERATION: ${{ inputs.operation }} CHANNEL: ${{ inputs.channel }} SOURCE_REF: ${{ inputs.source_ref }} + RELEASE_VERSION: ${{ inputs.release_version }} VERSION_SLUG: ${{ inputs.version_slug }} run: | set -euo pipefail @@ -72,8 +136,12 @@ jobs: echo "source_ref is required when operation=sync" >&2 exit 1 fi - if [[ "$CHANNEL" == "version" && -z "$VERSION_SLUG" ]]; then - echo "version_slug is required when channel=version" >&2 + if [[ "$CHANNEL" =~ ^(dev|latest|stable)$ && -z "$RELEASE_VERSION" ]]; then + echo "release_version is required for dev, latest, and stable channels" >&2 + exit 1 + fi + if [[ "$CHANNEL" =~ ^(stable|version)$ && -z "$VERSION_SLUG" ]]; then + echo "version_slug is required for stable and version channels" >&2 exit 1 fi @@ -96,6 +164,7 @@ jobs: uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 with: version: "0.10.12" + python-version: "3.13" - name: Update docs snapshot # Inputs flow in as quoted env vars to avoid shell injection; see the @@ -104,17 +173,32 @@ jobs: OPERATION: ${{ inputs.operation }} CHANNEL: ${{ inputs.channel }} SOURCE_REF: ${{ inputs.source_ref }} + RELEASE_VERSION: ${{ inputs.release_version }} VERSION_SLUG: ${{ inputs.version_slug }} DISPLAY_NAME: ${{ inputs.display_name }} + AVAILABILITY: ${{ inputs.availability }} + ALLOW_ROLLBACK: ${{ inputs.allow_rollback }} run: | + SOURCE_SHA="" + if [[ "$OPERATION" == "sync" ]]; then + SOURCE_SHA=$(git -C source rev-parse HEAD) + fi + rollback_args=() + if [[ "$ALLOW_ROLLBACK" == "true" ]]; then + rollback_args+=(--allow-rollback) + fi uv run automation/tasks/scripts/sync_docs_website.py \ --operation "$OPERATION" \ --source-root source \ --docs-website-root docs-website \ --channel "$CHANNEL" \ --source-ref "$SOURCE_REF" \ + --source-sha "$SOURCE_SHA" \ + --release-version "$RELEASE_VERSION" \ --version-slug "$VERSION_SLUG" \ - --display-name "$DISPLAY_NAME" + --display-name "$DISPLAY_NAME" \ + --availability "$AVAILABILITY" \ + "${rollback_args[@]}" - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -160,3 +244,10 @@ jobs: git commit -m "docs(website): remove ${target} docs" fi git push origin HEAD:docs-website + + - name: Publish Fern docs + if: ${{ inputs.publish }} + env: + FERN_TOKEN: ${{ secrets.FERN_TOKEN }} + working-directory: docs-website/fern + run: fern generate --docs diff --git a/AGENTS.md b/AGENTS.md index 9a07ca0b7a..6847588838 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -257,9 +257,9 @@ When behavior, commands, or development workflows change, review the related age - When making changes, update the relevant documentation in the `architecture/` directory. - When changes affect user-facing behavior, update the relevant published docs pages under `docs/` and navigation in `docs/index.yml`. - When changing gateway TOML fields, driver-specific config options, config defaults, or Helm rendering of `gateway.toml`, update `docs/reference/gateway-config.mdx` in the same branch. -- `fern/` contains the Fern site config, components, preview workflow inputs, and publish settings. +- `fern/` contains the Fern site config, components, preview workflow inputs, publish settings, and publishing documentation in `fern/README.md`. - Follow the docs style guide in [docs/CONTRIBUTING.mdx](docs/CONTRIBUTING.mdx): active voice, minimal formatting, no filler introductions, `shell` fences for copyable commands, and no duplicate body H1. -- Fern PR previews run through `.github/workflows/branch-docs.yml`, and production publish runs through the `publish-fern-docs` job in `.github/workflows/release-tag.yml` for stable release tags. +- Fern PR previews run through `.github/workflows/branch-docs.yml`. Release Dev publishes `dev`, and Release Tag publishes an immutable stable version plus `latest`. Both production paths call `.github/workflows/sync-docs.yml` once. - Use the `update-docs-from-commits` skill to scan recent commits and draft doc updates. ### Architecture Docs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ee7d15cef..e7747212ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -470,9 +470,9 @@ mise run docs PRs that touch `docs/**` or `fern/**` are validated by `.github/workflows/branch-docs.yml`, and they get a preview when `FERN_TOKEN` is available to the workflow. -Fern docs publishing is handled by the `publish-fern-docs` job in `.github/workflows/release-tag.yml` when a stable release tag is created. +Release Dev publishes the `dev` docs version from `main`. Release Tag publishes an immutable stable version and updates `latest`. See [fern/README.md](fern/README.md) for the source layout, version model, and publishing workflows. -`docs/` is the source-of-truth docs tree. `fern/` contains the site config, components, and theme assets that publish those pages. +`docs/` is the source-of-truth docs tree. `fern/` contains the site configuration, components, theme assets, and its README. See [docs/CONTRIBUTING.mdx](docs/CONTRIBUTING.mdx) for the current docs authoring guide. diff --git a/architecture/build.md b/architecture/build.md index 393eb9e468..7f1e3b71d5 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -402,13 +402,7 @@ See `CI.md` for the contributor workflow, labels, and maintainer merge-queue wor ## Docs Site -Published docs live in `docs/`. Navigation lives in `docs/index.yml`. Fern site -configuration, components, theme assets, and publish settings live in `fern/`. - -Use `mise run docs` for strict validation and `mise run docs:serve` for local -preview. PR previews are produced by `.github/workflows/branch-docs.yml` when -Fern credentials are available. Production docs publish from the release tag -workflow. +Published docs live in `docs/`, and Fern site configuration lives in `fern/`. See [fern/README.md](../fern/README.md) for the source layout, local development commands, version model, and publishing workflows. ## Validation Expectations diff --git a/fern/README.md b/fern/README.md new file mode 100644 index 0000000000..3b42c29ced --- /dev/null +++ b/fern/README.md @@ -0,0 +1,68 @@ +# Fern documentation site + +OpenShell uses [Fern](https://buildwithfern.com/) to validate, preview, and publish the documentation at [docs.nvidia.com/openshell](https://docs.nvidia.com/openshell/). This directory contains the site configuration and presentation files. The documentation content lives in `docs/`. + +## Repository layout + +| Path | Purpose | +|---|---| +| `docs/` | MDX pages, navigation in `docs/index.yml`, and page-specific components. | +| `fern/docs.yml` | Site, theme, version, redirect, and navigation configuration. | +| `fern/fern.config.json` | Fern organization and pinned CLI version. | +| `fern/components/` | Shared site components. | +| `fern/assets/` | Logos and other shared assets. | +| `fern/main.css` | Site-wide styles. | + +In a normal source checkout, `fern/docs.yml` points the `latest` version at `docs/index.yml`. Release automation builds the multi-version configuration on the generated `docs-website` branch. + +## Local development + +Start a local Fern server from the repository root: + +```shell +mise run docs:serve +``` + +Validate the configuration, navigation, and links without starting a server: + +```shell +mise run docs +``` + +The tasks read the Fern CLI version from `fern/fern.config.json`, so local checks and GitHub Actions use the same version. See [docs/CONTRIBUTING.mdx](../docs/CONTRIBUTING.mdx) for the authoring and style guide. + +## Pull request previews + +`.github/workflows/branch-docs.yml` validates pull requests that change documentation or Fern configuration. When the workflow can access `FERN_TOKEN`, it publishes a Fern preview from the pull request checkout and adds the preview URL to the pull request. This path does not use or update the `docs-website` branch. + +## Versioned production site + +The generated `docs-website` branch contains the complete production input for Fern. Each version has an exact copy of its source commit's `docs/` tree under `fern/pages-/` and a navigation file under `fern/versions/`. `fern/.docs-snapshots.yml` records the original source ref, resolved source commit, and release version for each managed snapshot. + +The site uses these version types: + +| Version | Source | Update policy | Fern status | +|---|---|---|---| +| `dev` | The most recent successful Release Dev run from `main`. | Mutable. Automation rejects an older version or the same version from a different commit unless a maintainer explicitly allows a rollback. | Beta. | +| `latest` | The newest stable release. | Mutable. A maintenance release older than the current stable release cannot move it backward unless a maintainer explicitly allows a rollback. | Stable starting with v0.1.0. | +| `vX.Y.Z` | The matching stable release tag. | Immutable. Repeating the sync is allowed only when the tag resolves to the same commit. | Stable starting with v0.1.0. | + +Release Dev waits for the development artifacts and Helm chart, then calls `.github/workflows/sync-docs.yml` once. The reusable workflow updates `dev`, validates the generated site, commits and pushes the branch when needed, and publishes the production site once. + +Release Tag follows the same sequence for a non-prerelease tag after the release artifacts, SDK package, Helm chart, and wheel publication complete. It creates the immutable `vX.Y.Z` snapshot and updates `latest` when the release is not older than the current version. One call to `.github/workflows/sync-docs.yml` performs both changes and publishes the production site once. + +The sync and publish workflows share the `docs-website` concurrency group. This serializes writes and publication. Queued runs remain pending instead of replacing one another. + +The `dev` snapshot also owns the shared Fern configuration, components, assets, and CSS on `docs-website`. Stable snapshots copy their documentation and navigation but do not replace those shared files. This keeps the site configuration aligned with `main` while preserving the content captured for each release. + +## Manual maintenance and publishing + +Maintainers can run `.github/workflows/sync-docs.yml` manually to add, refresh, or remove a snapshot. The workflow preserves snapshots that were not selected. Production publishing is disabled by default for a manual sync. + +`.github/workflows/publish-docs-website.yml` validates and publishes the existing `docs-website` branch without syncing content. Its default mode creates a preview. Selecting production mode publishes the live site, so use it only for an intentional production republish. + +Run the automated sync tests after changing the version model or either publishing workflow: + +```shell +mise run test:docs-website +``` diff --git a/fern/fern.config.json b/fern/fern.config.json index 9ec6917e2e..635e3615c9 100644 --- a/fern/fern.config.json +++ b/fern/fern.config.json @@ -1,4 +1,4 @@ { "organization": "nvidia", - "version": "5.40.0" + "version": "5.112.0" } diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index 0e04e8528e..62000394d3 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -1,28 +1,32 @@ #!/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", # ] # /// -# 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 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] @@ -31,21 +35,26 @@ 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 one docs snapshot in the docs-website branch." + 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", "version"] + "--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() @@ -59,7 +68,9 @@ def resolve_slug(channel: str, version_slug: str) -> str: if channel == "latest": return "latest" if not version_slug: - raise ValueError("--version-slug is required when --channel=version") + 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}" @@ -79,24 +90,41 @@ def resolve_display_name( 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, *, preserve_components: bool) -> None: +def reset_directory(src: Path, dst: Path) -> 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: @@ -138,6 +166,110 @@ def write_yaml(path: Path, data: YamlMapping) -> None: ) +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 @@ -179,13 +311,21 @@ def parse_versions(raw_versions: object) -> list[VersionEntry]: 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) + VersionEntry( + slug=slug, + display_name=display_name, + path=path, + availability=availability + if isinstance(availability, str) + else None, + ) ) return entries @@ -210,14 +350,17 @@ def ordered_entries( def render_versions(entries: list[VersionEntry]) -> list[dict[str, str]]: - return [ - { + rendered: list[dict[str, str]] = [] + for entry in entries: + item = { "display-name": entry.display_name, "path": entry.path, "slug": entry.slug, } - for entry in entries - ] + if entry.availability is not None: + item["availability"] = entry.availability + rendered.append(item) + return rendered def component_dirs(fern_dir: Path) -> list[str]: @@ -246,6 +389,35 @@ def update_docs_yml(docs_yml: Path, updated: VersionEntry, fern_dir: Path) -> No 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 = [ @@ -275,50 +447,118 @@ def sync_docs(args: argparse.Namespace) -> None: 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) - 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" + 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), + } - 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), - ) + 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") - update_docs_yml( - target_fern / "docs.yml", + write_snapshot( + source_docs, + source_fern, + target_fern, VersionEntry( slug=slug, display_name=display_name, path=f"./versions/{slug}.yml", + availability=availability, ), - target_fern, + 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_dir} ({display_name})" - ) + print(f"Synced {channel} docs from {source_ref} to fern/pages-{slug}") def remove_docs(args: argparse.Namespace) -> None: @@ -340,6 +580,11 @@ def remove_docs(args: argparse.Namespace) -> None: 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") diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py index 722f3f48f6..b1a3d45e15 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -11,23 +11,107 @@ from __future__ import annotations from argparse import Namespace -from typing import TYPE_CHECKING, cast +from pathlib import Path +from typing import cast 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 read_workflow(name: str) -> dict: + path = Path(__file__).resolve().parents[2] / ".github" / "workflows" / name + return yaml.load(path.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + + +def test_release_workflows_sync_and_publish_docs_once() -> None: + dev = read_workflow("release-dev.yml") + tag = read_workflow("release-tag.yml") + dev_job = dev["jobs"]["publish-fern-docs"] + tag_job = tag["jobs"]["publish-fern-docs"] + + assert dev_job["needs"] == [ + "compute-versions", + "release-dev", + "release-helm", + "trigger-wheel-publish", + ] + assert dev_job["uses"] == "./.github/workflows/sync-docs.yml" + assert dev_job["with"]["channel"] == "dev" + assert ( + dev_job["with"]["release_version"] + == "${{ needs.compute-versions.outputs.docs_version }}" + ) + assert dev_job["with"]["publish"] == "true" + assert ( + dev_job["with"]["display_name"] + == "Dev (v${{ needs.compute-versions.outputs.docs_version }})" + ) + + assert tag_job["needs"] == [ + "compute-versions", + "release", + "publish-sdk-typescript", + "release-helm", + "trigger-wheel-publish", + ] + assert tag_job["uses"] == "./.github/workflows/sync-docs.yml" + assert tag_job["with"]["channel"] == "stable" + assert ( + tag_job["with"]["release_version"] + == "${{ needs.compute-versions.outputs.semver }}" + ) + assert ( + tag_job["with"]["version_slug"] + == "v${{ needs.compute-versions.outputs.semver }}" + ) + assert tag_job["with"]["publish"] == "true" + assert "is_prerelease != 'true'" in tag_job["if"] + + for workflow_name in ("release-dev.yml", "release-tag.yml"): + workflow_path = ( + Path(__file__).resolve().parents[2] + / ".github" + / "workflows" + / workflow_name + ) + workflow_text = workflow_path.read_text(encoding="utf-8") + assert workflow_text.count("uses: ./.github/workflows/sync-docs.yml") == 1 + assert "fern generate --docs" not in workflow_text + + +def test_sync_workflow_serializes_sync_and_publish() -> None: + workflow = read_workflow("sync-docs.yml") + triggers = workflow["on"] + publish_input = triggers["workflow_call"]["inputs"]["publish"] + assert publish_input["type"] == "boolean" + assert publish_input["default"] == "false" + assert workflow["concurrency"]["group"] == "docs-website" + assert workflow["concurrency"]["queue"] == "max" + publish_workflow = read_workflow("publish-docs-website.yml") + assert publish_workflow["concurrency"]["queue"] == "max" + + steps = workflow["jobs"]["sync"]["steps"] + step_names = [step["name"] for step in steps] + assert step_names.index("Commit docs website changes") < step_names.index( + "Publish Fern docs" + ) + publish_step = next(step for step in steps if step["name"] == "Publish Fern docs") + assert publish_step["if"] == "${{ inputs.publish }}" + assert publish_step["working-directory"] == "docs-website/fern" + update_step = next(step for step in steps if step["name"] == "Update docs snapshot") + assert "git -C source rev-parse HEAD" in update_step["run"] + assert '--source-sha "$SOURCE_SHA"' in update_step["run"] + + def test_resolve_slug_channels() -> None: assert sdw.resolve_slug("dev", "") == "dev" assert sdw.resolve_slug("latest", "") == "latest" + assert sdw.resolve_slug("stable", "v0.1.0") == "v0.1.0" assert sdw.resolve_slug("version", "v0.0.36") == "v0.0.36" @@ -55,6 +139,38 @@ def test_resolve_display_name() -> None: assert sdw.resolve_display_name("dev", "dev", "main", "Custom") == "Custom" +def test_resolve_availability() -> None: + assert sdw.resolve_availability("dev", "") == "beta" + assert sdw.resolve_availability("latest", "") is None + assert sdw.resolve_availability("version", "") is None + assert sdw.resolve_availability("version", "deprecated") == "deprecated" + with pytest.raises(ValueError): + sdw.resolve_availability("dev", "alpha") + + +def test_parse_and_render_versions_preserves_availability() -> None: + raw_versions = [ + { + "display-name": "v0.0.36", + "path": "./versions/v0.0.36.yml", + "slug": "v0.0.36", + "availability": "deprecated", + } + ] + + entries = sdw.parse_versions(raw_versions) + + assert entries == [ + sdw.VersionEntry( + "v0.0.36", + "v0.0.36", + "./versions/v0.0.36.yml", + "deprecated", + ) + ] + assert sdw.render_versions(entries) == raw_versions + + def test_ordered_entries_pins_latest_then_dev() -> None: existing = [ sdw.VersionEntry("v0.0.36", "v0.0.36", "./versions/v0.0.36.yml"), @@ -126,8 +242,11 @@ def test_sync_docs_creates_snapshot(tmp_path: Path) -> None: docs_website_root=website, channel="dev", source_ref="main", + source_sha="dev-sha", + release_version="0.0.117.dev56", version_slug="", display_name="", + availability="", ) ) @@ -142,9 +261,311 @@ def test_sync_docs_creates_snapshot(tmp_path: Path) -> None: slugs = [entry["slug"] for entry in docs_yml["versions"]] assert slugs == ["dev"] assert docs_yml["versions"][0]["path"] == "./versions/dev.yml" + assert docs_yml["versions"][0]["availability"] == "beta" assert "./components" in docs_yml["experimental"]["mdx-components"] +def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + docs_yml_path = website / "fern" / "docs.yml" + docs_yml_path.write_text( + yaml.safe_dump( + { + "versions": [ + { + "display-name": "v0.0.36", + "path": "./versions/v0.0.36.yml", + "slug": "v0.0.36", + "availability": "deprecated", + } + ] + } + ), + encoding="utf-8", + ) + + sdw.sync_docs( + Namespace( + operation="sync", + source_root=source, + docs_website_root=website, + channel="dev", + source_ref="main", + source_sha="dev-sha", + release_version="0.0.117.dev56", + version_slug="", + display_name="Dev (v0.0.117.dev56)", + availability="beta", + ) + ) + + versions = read_yaml(docs_yml_path)["versions"] + assert versions == [ + { + "display-name": "Dev (v0.0.117.dev56)", + "path": "./versions/dev.yml", + "slug": "dev", + "availability": "beta", + }, + { + "display-name": "v0.0.36", + "path": "./versions/v0.0.36.yml", + "slug": "v0.0.36", + "availability": "deprecated", + }, + ] + + +def test_stable_sync_creates_immutable_version_and_promotes_latest( + 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( + source_root=source, + docs_website_root=website, + channel="stable", + source_ref="v0.2.0", + source_sha="new-sha", + release_version="0.2.0", + version_slug="v0.2.0", + display_name="", + availability="", + allow_rollback=False, + ) + ) + + fern = website / "fern" + assert (fern / "pages-v0.2.0" / "intro.mdx").is_file() + assert (fern / "pages-latest" / "intro.mdx").is_file() + versions = read_yaml(fern / "docs.yml")["versions"] + assert [entry["slug"] for entry in versions] == ["latest", "v0.2.0"] + assert versions[0]["display-name"] == "Latest (v0.2.0)" + assert versions[0]["availability"] == "stable" + assert versions[1]["availability"] == "stable" + snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] + assert snapshots["latest"] == { + "source-ref": "v0.2.0", + "source-sha": "new-sha", + "version": "0.2.0", + } + assert snapshots["v0.2.0"] == { + "source-ref": "v0.2.0", + "source-sha": "new-sha", + "version": "0.2.0", + } + + +def test_n_minus_one_sync_does_not_move_latest_backwards(tmp_path: Path) -> None: + current = tmp_path / "current" + maintenance = tmp_path / "maintenance" + website = tmp_path / "docs-website" + _make_source_tree(current) + _make_source_tree(maintenance) + (current / "docs" / "intro.mdx").write_text("# Current\n", encoding="utf-8") + (maintenance / "docs" / "intro.mdx").write_text("# Maintenance\n", encoding="utf-8") + _make_docs_website_tree(website) + + for source, source_sha, version in ( + (current, "current-sha", "0.3.1"), + (maintenance, "maintenance-sha", "0.2.7"), + ): + sdw.sync_docs( + Namespace( + source_root=source, + docs_website_root=website, + channel="stable", + source_ref=f"v{version}", + source_sha=source_sha, + release_version=version, + version_slug=f"v{version}", + display_name="", + availability="", + allow_rollback=False, + ) + ) + + fern = website / "fern" + assert (fern / "pages-latest" / "intro.mdx").read_text( + encoding="utf-8" + ) == "# Current\n" + assert (fern / "pages-v0.2.7" / "intro.mdx").read_text( + encoding="utf-8" + ) == "# Maintenance\n" + snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] + assert snapshots["latest"] == { + "source-ref": "v0.3.1", + "source-sha": "current-sha", + "version": "0.3.1", + } + + +def test_stable_sync_preserves_newer_legacy_latest_without_metadata( + tmp_path: Path, +) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + fern = website / "fern" + (fern / "pages-latest").mkdir() + (fern / "pages-latest" / "intro.mdx").write_text( + "# Existing latest\n", encoding="utf-8" + ) + (fern / "docs.yml").write_text( + yaml.safe_dump( + { + "versions": [ + { + "display-name": "Latest (v0.3.1)", + "path": "./versions/latest.yml", + "slug": "latest", + } + ] + } + ), + encoding="utf-8", + ) + + sdw.sync_docs( + Namespace( + source_root=source, + docs_website_root=website, + channel="stable", + source_ref="v0.2.7", + source_sha="maintenance-sha", + release_version="0.2.7", + version_slug="v0.2.7", + display_name="", + availability="", + allow_rollback=False, + ) + ) + + assert (fern / "pages-latest" / "intro.mdx").read_text( + encoding="utf-8" + ) == "# Existing latest\n" + snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] + assert snapshots["latest"] == { + "source-ref": "", + "source-sha": "", + "version": "0.3.1", + } + + +def test_dev_sync_rejects_stale_or_conflicting_updates(tmp_path: Path) -> None: + current = tmp_path / "current" + stale = tmp_path / "stale" + website = tmp_path / "docs-website" + _make_source_tree(current) + _make_source_tree(stale) + (current / "docs" / "intro.mdx").write_text("# Current\n", encoding="utf-8") + (stale / "docs" / "intro.mdx").write_text("# Stale\n", encoding="utf-8") + _make_docs_website_tree(website) + + def sync(source: Path, source_sha: str, version: str) -> None: + sdw.sync_docs( + Namespace( + source_root=source, + docs_website_root=website, + channel="dev", + source_ref="main", + source_sha=source_sha, + release_version=version, + version_slug="", + display_name=f"Dev (v{version})", + availability="beta", + allow_rollback=False, + ) + ) + + sync(current, "current-sha", "0.3.2.dev10") + sync(stale, "stale-sha", "0.3.2.dev9") + intro = website / "fern" / "pages-dev" / "intro.mdx" + assert intro.read_text(encoding="utf-8") == "# Current\n" + + with pytest.raises(ValueError, match="already points to current-sha"): + sync(stale, "other-sha", "0.3.2.dev10") + + +def test_immutable_snapshot_cannot_change_source(tmp_path: Path) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + args = Namespace( + source_root=source, + docs_website_root=website, + channel="stable", + source_ref="main", + source_sha="release-sha", + release_version="0.2.0", + version_slug="v0.2.0", + display_name="", + availability="", + allow_rollback=False, + ) + sdw.sync_docs(args) + + args.source_sha = "different-sha" + with pytest.raises(ValueError, match=r"immutable snapshot v0\.2\.0"): + sdw.sync_docs(args) + + +def test_only_dev_refreshes_shared_fern_files(tmp_path: Path) -> None: + dev = tmp_path / "dev" + release = tmp_path / "release" + website = tmp_path / "docs-website" + _make_source_tree(dev) + _make_source_tree(release) + (dev / "fern" / "components" / "Card.tsx").write_text( + "export const Card = 'dev';\n", encoding="utf-8" + ) + (release / "fern" / "components" / "Card.tsx").write_text( + "export const Card = 'release';\n", encoding="utf-8" + ) + _make_docs_website_tree(website) + + sdw.sync_docs( + Namespace( + source_root=dev, + docs_website_root=website, + channel="dev", + source_ref="main", + source_sha="dev-sha", + release_version="0.2.1.dev1", + version_slug="", + display_name="Dev (v0.2.1.dev1)", + availability="beta", + allow_rollback=False, + ) + ) + sdw.sync_docs( + Namespace( + source_root=release, + docs_website_root=website, + channel="stable", + source_ref="v0.2.0", + source_sha="release-sha", + release_version="0.2.0", + version_slug="v0.2.0", + display_name="", + availability="", + allow_rollback=False, + ) + ) + + card = website / "fern" / "components" / "Card.tsx" + assert card.read_text(encoding="utf-8") == "export const Card = 'dev';\n" + + def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: source = tmp_path / "source" website = tmp_path / "docs-website" @@ -157,8 +578,10 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: docs_website_root=website, channel="version", source_ref="v0.0.36", + source_sha="release-sha", version_slug="v0.0.36", display_name="", + availability="deprecated", ) sdw.sync_docs(base) @@ -175,6 +598,7 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: source_ref="", version_slug="v0.0.36", display_name="", + availability="", ) ) @@ -182,3 +606,74 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: 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"]] == [] + + +@pytest.mark.parametrize("channel", ["stable", "version"]) +def test_immutable_snapshot_uses_resolved_commit_identity( + tmp_path: Path, channel: str +) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + args = Namespace( + source_root=source, + docs_website_root=website, + channel=channel, + source_ref="main", + source_sha="first-sha", + release_version="0.2.0" if channel == "stable" else "", + version_slug="v0.2.0", + display_name="", + availability="", + allow_rollback=False, + ) + sdw.sync_docs(args) + + (source / "docs" / "intro.mdx").write_text("# Changed\n", encoding="utf-8") + args.source_sha = "second-sha" + + with pytest.raises(ValueError, match=r"immutable snapshot v0\.2\.0"): + sdw.sync_docs(args) + assert (website / "fern" / "pages-v0.2.0" / "intro.mdx").read_text( + encoding="utf-8" + ) == "# Intro\n" + + +def test_stable_promotion_replaces_latest_page_components(tmp_path: Path) -> None: + old = tmp_path / "old" + new = tmp_path / "new" + website = tmp_path / "docs-website" + _make_source_tree(old) + _make_source_tree(new) + (old / "docs" / "_components").mkdir() + (old / "docs" / "_components" / "Widget.tsx").write_text( + "export const Widget = 'old';\n", encoding="utf-8" + ) + (new / "docs" / "_components").mkdir() + (new / "docs" / "_components" / "Widget.tsx").write_text( + "export const Widget = 'new';\n", encoding="utf-8" + ) + _make_docs_website_tree(website) + + for source, source_sha, version in ( + (old, "old-sha", "1.0.0"), + (new, "new-sha", "1.1.0"), + ): + sdw.sync_docs( + Namespace( + source_root=source, + docs_website_root=website, + channel="stable", + source_ref=f"v{version}", + source_sha=source_sha, + release_version=version, + version_slug=f"v{version}", + display_name="", + availability="", + allow_rollback=False, + ) + ) + + widget = website / "fern" / "pages-latest" / "_components" / "Widget.tsx" + assert widget.read_text(encoding="utf-8") == "export const Widget = 'new';\n" diff --git a/tasks/test.toml b/tasks/test.toml index 2fc3c565e4..ef0d55907a 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -19,9 +19,9 @@ depends = [ ["test:docs-website"] description = "Test the docs-website sync script" -# --no-project skips installing the OpenShell package; --with supplies pytest -# and the script's runtime dependency (PyYAML), which lives outside the project env. -run = "uv run --no-project --with pytest --with pyyaml pytest tasks/scripts/sync_docs_website_test.py" +# --no-project skips installing the OpenShell package; --with supplies the test +# dependencies and the script's runtime dependency, which live outside the project env. +run = "uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/sync_docs_website_test.py" ["test:sbom"] description = "Run SBOM tooling tests"