From ce3bfcaaf4e5d0b4d20def484a4a1bcb4c1ddc38 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 15:34:40 -0700 Subject: [PATCH 1/8] feat(docs): add version availability labels Signed-off-by: Piotr Mlocek --- .github/workflows/sync-docs.yml | 11 +++- architecture/build.md | 2 + tasks/scripts/sync_docs_website.py | 39 +++++++++-- tasks/scripts/sync_docs_website_test.py | 88 +++++++++++++++++++++++++ tasks/test.toml | 6 +- 5 files changed, 136 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index db2f29f736..1ff20d798a 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -31,7 +31,11 @@ on: required: false type: string display_name: - description: "Optional version selector display name" + description: "Optional selector name, e.g. dev (0.0.117.dev56)" + required: false + type: string + availability: + description: "Optional Fern status: beta, deprecated, ga, or stable" required: false type: string @@ -96,6 +100,7 @@ jobs: uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 with: version: "0.10.12" + python-version: "3.14.5" - name: Update docs snapshot # Inputs flow in as quoted env vars to avoid shell injection; see the @@ -106,6 +111,7 @@ jobs: SOURCE_REF: ${{ inputs.source_ref }} VERSION_SLUG: ${{ inputs.version_slug }} DISPLAY_NAME: ${{ inputs.display_name }} + AVAILABILITY: ${{ inputs.availability }} run: | uv run automation/tasks/scripts/sync_docs_website.py \ --operation "$OPERATION" \ @@ -114,7 +120,8 @@ jobs: --channel "$CHANNEL" \ --source-ref "$SOURCE_REF" \ --version-slug "$VERSION_SLUG" \ - --display-name "$DISPLAY_NAME" + --display-name "$DISPLAY_NAME" \ + --availability "$AVAILABILITY" - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/architecture/build.md b/architecture/build.md index 393eb9e468..08f60487b4 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -410,6 +410,8 @@ preview. PR previews are produced by `.github/workflows/branch-docs.yml` when Fern credentials are available. Production docs publish from the release tag workflow. +Versioned site snapshots live on the generated `docs-website` branch. Maintainers use `.github/workflows/sync-docs.yml` to refresh or remove one snapshot while preserving the others. Each generated version entry keeps its stable URL slug, selector display name, and optional Fern availability status. `.github/workflows/publish-docs-website.yml` validates that branch and publishes either a preview or, when explicitly selected, the production site. + ## Validation Expectations - Run `mise run pre-commit` before committing. diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index 0e04e8528e..eb8c44b066 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -23,6 +23,7 @@ import yaml SLUG_RE = re.compile(r"^[A-Za-z0-9._-]+$") +VERSION_AVAILABILITIES = {"beta", "deprecated", "ga", "stable"} YamlMapping = dict[str, object] @@ -31,6 +32,7 @@ class VersionEntry: slug: str display_name: str path: str + availability: str | None = None def parse_args() -> argparse.Namespace: @@ -46,6 +48,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--source-ref", default="") parser.add_argument("--version-slug", default="") parser.add_argument("--display-name", default="") + parser.add_argument("--availability", default="") return parser.parse_args() @@ -79,6 +82,18 @@ 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 ensure_existing(path: Path, label: str) -> None: if not path.exists(): raise FileNotFoundError(f"{label} does not exist: {path}") @@ -179,13 +194,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 +233,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]: @@ -277,8 +303,10 @@ def sync_docs(args: argparse.Namespace) -> None: raise ValueError("--source-ref is required when --operation=sync") version_slug = clean_input(args.version_slug) display_override = clean_input(args.display_name) + availability_override = clean_input(args.availability) slug = resolve_slug(channel, version_slug) display_name = resolve_display_name(channel, slug, source_ref, display_override) + availability = resolve_availability(channel, availability_override) pages_dir = f"pages-{slug}" refresh_shared = channel in {"dev", "latest"} @@ -312,6 +340,7 @@ def sync_docs(args: argparse.Namespace) -> None: slug=slug, display_name=display_name, path=f"./versions/{slug}.yml", + availability=availability, ), target_fern, ) diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py index 722f3f48f6..8b266c687a 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -55,6 +55,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"), @@ -128,6 +160,7 @@ def test_sync_docs_creates_snapshot(tmp_path: Path) -> None: source_ref="main", version_slug="", display_name="", + availability="", ) ) @@ -142,9 +175,62 @@ 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", + version_slug="", + display_name="dev (0.0.117.dev56)", + availability="beta", + ) + ) + + versions = read_yaml(docs_yml_path)["versions"] + assert versions == [ + { + "display-name": "dev (0.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_remove_docs_drops_snapshot(tmp_path: Path) -> None: source = tmp_path / "source" website = tmp_path / "docs-website" @@ -159,6 +245,7 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: source_ref="v0.0.36", version_slug="v0.0.36", display_name="", + availability="deprecated", ) sdw.sync_docs(base) @@ -175,6 +262,7 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: source_ref="", version_slug="v0.0.36", display_name="", + availability="", ) ) 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" From af56af897802b058eb683c2b615bf1d9561e9675 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 15:37:24 -0700 Subject: [PATCH 2/8] fix(docs): use supported Python for sync Signed-off-by: Piotr Mlocek --- .github/workflows/sync-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 1ff20d798a..077d7ecdd3 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -100,7 +100,7 @@ jobs: uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 with: version: "0.10.12" - python-version: "3.14.5" + python-version: "3.13" - name: Update docs snapshot # Inputs flow in as quoted env vars to avoid shell injection; see the From ae8cdab569df899c1c2df14dfde1804429159e15 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 15:51:57 -0700 Subject: [PATCH 3/8] feat(docs): publish versioned docs from releases Signed-off-by: Piotr Mlocek --- .github/workflows/release-dev.yml | 30 ++++++++++--- .github/workflows/release-tag.yml | 34 +++++--------- .github/workflows/sync-docs.yml | 46 +++++++++++++++++++ architecture/build.md | 2 +- tasks/scripts/sync_docs_website_test.py | 59 +++++++++++++++++++++++-- 5 files changed, 138 insertions(+), 33 deletions(-) diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 18b5f33a2e..d3c4ef7ede 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,21 @@ jobs: release-kind: dev pin-sha: ${{ github.sha }} + publish-fern-docs: + name: Sync and Publish Fern Docs + needs: [compute-versions, release-dev] + permissions: + contents: write + uses: ./.github/workflows/sync-docs.yml + with: + operation: sync + channel: dev + source_ref: ${{ github.sha }} + display_name: dev (${{ 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..e7fe7a2396 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -664,31 +664,19 @@ jobs: if-no-files-found: error publish-fern-docs: - name: Publish Fern Docs + name: Sync and Publish Fern Docs needs: [compute-versions, release] 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: latest + source_ref: ${{ needs.compute-versions.outputs.source_sha }} + 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 077d7ecdd3..0cbffffe4e 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -4,6 +4,40 @@ 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 + 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 + secrets: + FERN_TOKEN: + required: false workflow_dispatch: inputs: operation: @@ -38,6 +72,11 @@ on: 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 permissions: contents: write @@ -167,3 +206,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/architecture/build.md b/architecture/build.md index 08f60487b4..321a8f8368 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -410,7 +410,7 @@ preview. PR previews are produced by `.github/workflows/branch-docs.yml` when Fern credentials are available. Production docs publish from the release tag workflow. -Versioned site snapshots live on the generated `docs-website` branch. Maintainers use `.github/workflows/sync-docs.yml` to refresh or remove one snapshot while preserving the others. Each generated version entry keeps its stable URL slug, selector display name, and optional Fern availability status. `.github/workflows/publish-docs-website.yml` validates that branch and publishes either a preview or, when explicitly selected, the production site. +Versioned site snapshots live on the generated `docs-website` branch. Maintainers use `.github/workflows/sync-docs.yml` to refresh or remove one snapshot while preserving the others. Each generated version entry keeps its stable URL slug, selector display name, and optional Fern availability status. Release Dev calls this workflow once to sync the commit to `dev` with its development version and Beta status, then publishes that checkout. Stable Release Tag runs call it once to sync the tag to `latest` with its release version, then publish. The workflow serializes the branch update and production publish under the `docs-website` concurrency group. `.github/workflows/publish-docs-website.yml` remains available for explicit preview or production publishing of the generated branch. ## Validation Expectations diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py index 8b266c687a..ea8a48d629 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -11,20 +11,71 @@ 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"] + assert dev_job["uses"] == "./.github/workflows/sync-docs.yml" + assert dev_job["with"]["channel"] == "dev" + assert dev_job["with"]["publish"] == "true" + assert "docs_version" in dev_job["with"]["display_name"] + + assert tag_job["needs"] == ["compute-versions", "release"] + assert tag_job["uses"] == "./.github/workflows/sync-docs.yml" + assert tag_job["with"]["channel"] == "latest" + 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" + + 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" + + def test_resolve_slug_channels() -> None: assert sdw.resolve_slug("dev", "") == "dev" assert sdw.resolve_slug("latest", "") == "latest" From 4ec419adf4f3c716a841b316fddc39d7529640eb Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 15:56:10 -0700 Subject: [PATCH 4/8] fix(docs): format dev version label Signed-off-by: Piotr Mlocek --- .github/workflows/release-dev.yml | 2 +- .github/workflows/sync-docs.yml | 2 +- tasks/scripts/sync_docs_website_test.py | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index d3c4ef7ede..31ee59a15d 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -643,7 +643,7 @@ jobs: operation: sync channel: dev source_ref: ${{ github.sha }} - display_name: dev (${{ needs.compute-versions.outputs.docs_version }}) + display_name: Dev (v${{ needs.compute-versions.outputs.docs_version }}) availability: beta publish: true secrets: inherit diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 0cbffffe4e..0c6f275e9b 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -65,7 +65,7 @@ on: required: false type: string display_name: - description: "Optional selector name, e.g. dev (0.0.117.dev56)" + description: "Optional selector name, e.g. Dev (v0.0.117.dev56)" required: false type: string availability: diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py index ea8a48d629..5d13c54ae3 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -38,7 +38,10 @@ def test_release_workflows_sync_and_publish_docs_once() -> None: assert dev_job["uses"] == "./.github/workflows/sync-docs.yml" assert dev_job["with"]["channel"] == "dev" assert dev_job["with"]["publish"] == "true" - assert "docs_version" in dev_job["with"]["display_name"] + assert ( + dev_job["with"]["display_name"] + == "Dev (v${{ needs.compute-versions.outputs.docs_version }})" + ) assert tag_job["needs"] == ["compute-versions", "release"] assert tag_job["uses"] == "./.github/workflows/sync-docs.yml" @@ -260,7 +263,7 @@ def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None: channel="dev", source_ref="main", version_slug="", - display_name="dev (0.0.117.dev56)", + display_name="Dev (v0.0.117.dev56)", availability="beta", ) ) @@ -268,7 +271,7 @@ def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None: versions = read_yaml(docs_yml_path)["versions"] assert versions == [ { - "display-name": "dev (0.0.117.dev56)", + "display-name": "Dev (v0.0.117.dev56)", "path": "./versions/dev.yml", "slug": "dev", "availability": "beta", From 5dcc3bd0ca45a36cf4ffc73f3872dd0a9ca6af06 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 16:09:13 -0700 Subject: [PATCH 5/8] chore(docs): upgrade Fern CLI to 5.112.0 Signed-off-by: Piotr Mlocek --- fern/fern.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } From 606f9f2922bbda38b3b7f09c4aa2e20e40adfa25 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 17:58:02 -0700 Subject: [PATCH 6/8] fix(docs): make release publishing monotonic Signed-off-by: Piotr Mlocek --- .github/workflows/publish-docs-website.yml | 1 + .github/workflows/release-dev.yml | 3 +- .github/workflows/release-tag.yml | 6 +- .github/workflows/sync-docs.yml | 39 ++- architecture/build.md | 2 +- tasks/scripts/sync_docs_website.py | 284 ++++++++++++++++++--- tasks/scripts/sync_docs_website_test.py | 265 ++++++++++++++++++- 7 files changed, 554 insertions(+), 46 deletions(-) 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 31ee59a15d..10f0cf5319 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -635,7 +635,7 @@ jobs: publish-fern-docs: name: Sync and Publish Fern Docs - needs: [compute-versions, release-dev] + needs: [compute-versions, release-dev, release-helm, trigger-wheel-publish] permissions: contents: write uses: ./.github/workflows/sync-docs.yml @@ -643,6 +643,7 @@ jobs: 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 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index e7fe7a2396..d02bf8eb5a 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -665,15 +665,17 @@ jobs: publish-fern-docs: name: Sync and Publish Fern Docs - needs: [compute-versions, release] + needs: [compute-versions, release, publish-sdk-typescript, release-helm, trigger-wheel-publish] if: needs.compute-versions.outputs.is_prerelease != 'true' permissions: contents: write uses: ./.github/workflows/sync-docs.yml with: operation: sync - channel: latest + 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 diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 0c6f275e9b..f00e6cab2e 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -18,6 +18,10 @@ on: 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 @@ -35,6 +39,11 @@ on: 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 @@ -55,11 +64,16 @@ 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 @@ -77,6 +91,11 @@ on: 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 @@ -84,6 +103,7 @@ permissions: concurrency: group: docs-website cancel-in-progress: false + queue: max defaults: run: @@ -108,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 @@ -115,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 @@ -148,19 +173,27 @@ 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: | + 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" \ + --release-version "$RELEASE_VERSION" \ --version-slug "$VERSION_SLUG" \ --display-name "$DISPLAY_NAME" \ - --availability "$AVAILABILITY" + --availability "$AVAILABILITY" \ + "${rollback_args[@]}" - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/architecture/build.md b/architecture/build.md index 321a8f8368..ba4548d33b 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -410,7 +410,7 @@ preview. PR previews are produced by `.github/workflows/branch-docs.yml` when Fern credentials are available. Production docs publish from the release tag workflow. -Versioned site snapshots live on the generated `docs-website` branch. Maintainers use `.github/workflows/sync-docs.yml` to refresh or remove one snapshot while preserving the others. Each generated version entry keeps its stable URL slug, selector display name, and optional Fern availability status. Release Dev calls this workflow once to sync the commit to `dev` with its development version and Beta status, then publishes that checkout. Stable Release Tag runs call it once to sync the tag to `latest` with its release version, then publish. The workflow serializes the branch update and production publish under the `docs-website` concurrency group. `.github/workflows/publish-docs-website.yml` remains available for explicit preview or production publishing of the generated branch. +Versioned site snapshots live on the generated `docs-website` branch. Maintainers use `.github/workflows/sync-docs.yml` to refresh or remove snapshots while preserving the others. The generated branch records each managed snapshot's source commit and release version. Release Dev updates `dev` only when its development version is not older than the current snapshot, and it owns the shared Fern configuration, components, theme assets, and CSS. A stable Release Tag run creates an immutable `vX.Y.Z` snapshot and updates `latest` only when the release is not older than the current Latest version. Releases starting with v0.1.0 receive Fern's Stable availability status. Both release workflows wait for their other publication jobs, then call the docs workflow once. The workflow queues and serializes the branch update and one production publish attempt under the `docs-website` concurrency group. `.github/workflows/publish-docs-website.yml` remains available for explicit preview or production publishing of the generated branch. ## Validation Expectations diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index eb8c44b066..dcb70b3652 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -1,14 +1,15 @@ #!/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 @@ -21,9 +22,12 @@ 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] @@ -37,18 +41,20 @@ class VersionEntry: 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("--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() @@ -62,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}" @@ -94,6 +102,19 @@ def resolve_availability(channel: str, override: str) -> str | None: 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}") @@ -153,6 +174,100 @@ 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") + version = snapshot.get("version") + if not isinstance(source_ref, str) or not isinstance(version, str): + raise ValueError(f"invalid snapshot metadata for {raw_slug} in {path}") + snapshots[raw_slug] = {"source-ref": source_ref, "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": "", + "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_ref: str, +) -> None: + existing = snapshots.get(slug) + if existing is not None: + if existing["source-ref"] != source_ref: + raise ValueError( + f"immutable snapshot {slug} already points to " + f"{existing['source-ref']}, not {source_ref}" + ) + 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_ref: 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-ref"]) + and existing["source-ref"] != source_ref + and not allow_rollback + ): + raise ValueError( + f"snapshot {slug} version {release_version} already points to " + f"{existing['source-ref']}, not {source_ref}" + ) + return True + + def prefix_path(value: object, pages_dir: str) -> object: if not isinstance(value, str): return value @@ -272,6 +387,39 @@ 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, + preserve_components=not refresh_shared, + ) + 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 = [ @@ -301,53 +449,112 @@ 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") + release_version = clean_input(getattr(args, "release_version", "")) version_slug = clean_input(args.version_slug) display_override = clean_input(args.display_name) availability_override = clean_input(args.availability) + if channel in {"dev", "latest", "stable"} and not release_version: + raise ValueError( + "--release-version is required for dev, latest, and stable channels" + ) slug = resolve_slug(channel, version_slug) display_name = resolve_display_name(channel, slug, source_ref, display_override) availability = resolve_availability(channel, availability_override) - 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" + 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_ref) + 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, + "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_ref, + 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, + "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_ref, + 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_ref) + 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, + "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: @@ -369,6 +576,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 5d13c54ae3..ad7b39a902 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -34,18 +34,41 @@ def test_release_workflows_sync_and_publish_docs_once() -> None: dev_job = dev["jobs"]["publish-fern-docs"] tag_job = tag["jobs"]["publish-fern-docs"] - assert dev_job["needs"] == ["compute-versions", "release-dev"] + 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"] + 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"] == "latest" + 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"] @@ -68,6 +91,9 @@ def test_sync_workflow_serializes_sync_and_publish() -> None: 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] @@ -82,6 +108,7 @@ def test_sync_workflow_serializes_sync_and_publish() -> None: 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" @@ -212,6 +239,7 @@ def test_sync_docs_creates_snapshot(tmp_path: Path) -> None: docs_website_root=website, channel="dev", source_ref="main", + release_version="0.0.117.dev56", version_slug="", display_name="", availability="", @@ -262,6 +290,7 @@ def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None: docs_website_root=website, channel="dev", source_ref="main", + release_version="0.0.117.dev56", version_slug="", display_name="Dev (v0.0.117.dev56)", availability="beta", @@ -285,6 +314,236 @@ def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None: ] +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="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": "new-sha", "version": "0.2.0"} + assert snapshots["v0.2.0"] == { + "source-ref": "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_ref, 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=source_ref, + 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": "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="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": "", "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_ref: str, version: str) -> None: + sdw.sync_docs( + Namespace( + source_root=source, + docs_website_root=website, + channel="dev", + source_ref=source_ref, + 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="release-sha", + release_version="0.2.0", + version_slug="v0.2.0", + display_name="", + availability="", + allow_rollback=False, + ) + sdw.sync_docs(args) + + args.source_ref = "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="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="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" From 6908f5fa162d21d69b05233aba01942f6426909e Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 18:40:51 -0700 Subject: [PATCH 7/8] fix(docs): preserve snapshot release identity Signed-off-by: Piotr Mlocek --- .github/workflows/sync-docs.yml | 5 + tasks/scripts/sync_docs_website.py | 60 ++++++------ tasks/scripts/sync_docs_website_test.py | 122 +++++++++++++++++++++--- 3 files changed, 145 insertions(+), 42 deletions(-) diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index f00e6cab2e..25b4e81581 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -179,6 +179,10 @@ jobs: 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) @@ -189,6 +193,7 @@ jobs: --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" \ diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index dcb70b3652..62000394d3 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -16,7 +16,6 @@ import re import shutil import sys -import tempfile from dataclasses import dataclass from pathlib import Path from typing import cast @@ -50,6 +49,7 @@ def parse_args() -> argparse.Namespace: "--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="") @@ -120,19 +120,11 @@ def ensure_existing(path: Path, label: str) -> None: 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: @@ -190,10 +182,19 @@ def read_snapshot_metadata(path: Path) -> dict[str, dict[str, str]]: 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(version, str): + 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, "version": version} + snapshots[raw_slug] = { + "source-ref": source_ref, + "source-sha": source_sha, + "version": version, + } return snapshots @@ -214,6 +215,7 @@ def seed_mutable_snapshot_metadata( if match is not None: snapshots[slug] = { "source-ref": "", + "source-sha": "", "version": str(parse_release_version(match.group(1))), } return @@ -223,14 +225,14 @@ def ensure_immutable_snapshot( snapshots: dict[str, dict[str, str]], target_fern: Path, slug: str, - source_ref: str, + source_sha: str, ) -> None: existing = snapshots.get(slug) if existing is not None: - if existing["source-ref"] != source_ref: + if existing["source-sha"] != source_sha: raise ValueError( f"immutable snapshot {slug} already points to " - f"{existing['source-ref']}, not {source_ref}" + f"{existing['source-sha']}, not {source_sha}" ) return if (target_fern / f"pages-{slug}").exists(): @@ -242,7 +244,7 @@ def ensure_immutable_snapshot( def ensure_monotonic_snapshot( snapshots: dict[str, dict[str, str]], slug: str, - source_ref: str, + source_sha: str, release_version: str, *, allow_rollback: bool, @@ -257,13 +259,13 @@ def ensure_monotonic_snapshot( return False if ( incoming_version == existing_version - and bool(existing["source-ref"]) - and existing["source-ref"] != source_ref + 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-ref']}, not {source_ref}" + f"{existing['source-sha']}, not {source_sha}" ) return True @@ -396,11 +398,7 @@ def write_snapshot( refresh_shared: bool, ) -> None: pages_dir = f"pages-{entry.slug}" - reset_directory( - source_docs, - target_fern / pages_dir, - preserve_components=not refresh_shared, - ) + reset_directory(source_docs, target_fern / pages_dir) if refresh_shared: merge_directory(source_fern / "assets", target_fern / "assets", overwrite=True) merge_directory( @@ -449,6 +447,9 @@ 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) @@ -469,7 +470,7 @@ def sync_docs(args: argparse.Namespace) -> None: 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_ref) + ensure_immutable_snapshot(snapshots, target_fern, slug, source_sha) stable_availability = availability or default_stable_availability( release_version ) @@ -487,6 +488,7 @@ def sync_docs(args: argparse.Namespace) -> None: ) snapshots[slug] = { "source-ref": source_ref, + "source-sha": source_sha, "version": str(parsed_version), } @@ -494,7 +496,7 @@ def sync_docs(args: argparse.Namespace) -> None: if ensure_monotonic_snapshot( snapshots, "latest", - source_ref, + source_sha, release_version, allow_rollback=bool(getattr(args, "allow_rollback", False)), ): @@ -512,6 +514,7 @@ def sync_docs(args: argparse.Namespace) -> None: ) snapshots["latest"] = { "source-ref": source_ref, + "source-sha": source_sha, "version": str(parsed_version), } write_snapshot_metadata(metadata_path, snapshots) @@ -523,7 +526,7 @@ def sync_docs(args: argparse.Namespace) -> None: if not ensure_monotonic_snapshot( snapshots, slug, - source_ref, + source_sha, release_version, allow_rollback=bool(getattr(args, "allow_rollback", False)), ): @@ -533,7 +536,7 @@ def sync_docs(args: argparse.Namespace) -> None: ) return else: - ensure_immutable_snapshot(snapshots, target_fern, slug, source_ref) + ensure_immutable_snapshot(snapshots, target_fern, slug, source_sha) release_version = release_version or slug.removeprefix("v") write_snapshot( @@ -550,6 +553,7 @@ def sync_docs(args: argparse.Namespace) -> None: ) snapshots[slug] = { "source-ref": source_ref, + "source-sha": source_sha, "version": release_version, } write_snapshot_metadata(metadata_path, snapshots) diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py index ad7b39a902..b1a3d45e15 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -103,6 +103,9 @@ def test_sync_workflow_serializes_sync_and_publish() -> None: 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: @@ -239,6 +242,7 @@ 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="", @@ -290,6 +294,7 @@ def test_sync_docs_preserves_other_version_availability(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="Dev (v0.0.117.dev56)", @@ -327,7 +332,8 @@ def test_stable_sync_creates_immutable_version_and_promotes_latest( source_root=source, docs_website_root=website, channel="stable", - source_ref="new-sha", + source_ref="v0.2.0", + source_sha="new-sha", release_version="0.2.0", version_slug="v0.2.0", display_name="", @@ -345,9 +351,14 @@ def test_stable_sync_creates_immutable_version_and_promotes_latest( assert versions[0]["availability"] == "stable" assert versions[1]["availability"] == "stable" snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] - assert snapshots["latest"] == {"source-ref": "new-sha", "version": "0.2.0"} + assert snapshots["latest"] == { + "source-ref": "v0.2.0", + "source-sha": "new-sha", + "version": "0.2.0", + } assert snapshots["v0.2.0"] == { - "source-ref": "new-sha", + "source-ref": "v0.2.0", + "source-sha": "new-sha", "version": "0.2.0", } @@ -362,7 +373,7 @@ def test_n_minus_one_sync_does_not_move_latest_backwards(tmp_path: Path) -> None (maintenance / "docs" / "intro.mdx").write_text("# Maintenance\n", encoding="utf-8") _make_docs_website_tree(website) - for source, source_ref, version in ( + for source, source_sha, version in ( (current, "current-sha", "0.3.1"), (maintenance, "maintenance-sha", "0.2.7"), ): @@ -371,7 +382,8 @@ def test_n_minus_one_sync_does_not_move_latest_backwards(tmp_path: Path) -> None source_root=source, docs_website_root=website, channel="stable", - source_ref=source_ref, + source_ref=f"v{version}", + source_sha=source_sha, release_version=version, version_slug=f"v{version}", display_name="", @@ -389,7 +401,8 @@ def test_n_minus_one_sync_does_not_move_latest_backwards(tmp_path: Path) -> None ) == "# Maintenance\n" snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] assert snapshots["latest"] == { - "source-ref": "current-sha", + "source-ref": "v0.3.1", + "source-sha": "current-sha", "version": "0.3.1", } @@ -426,7 +439,8 @@ def test_stable_sync_preserves_newer_legacy_latest_without_metadata( source_root=source, docs_website_root=website, channel="stable", - source_ref="maintenance-sha", + source_ref="v0.2.7", + source_sha="maintenance-sha", release_version="0.2.7", version_slug="v0.2.7", display_name="", @@ -439,7 +453,11 @@ def test_stable_sync_preserves_newer_legacy_latest_without_metadata( encoding="utf-8" ) == "# Existing latest\n" snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] - assert snapshots["latest"] == {"source-ref": "", "version": "0.3.1"} + assert snapshots["latest"] == { + "source-ref": "", + "source-sha": "", + "version": "0.3.1", + } def test_dev_sync_rejects_stale_or_conflicting_updates(tmp_path: Path) -> None: @@ -452,13 +470,14 @@ def test_dev_sync_rejects_stale_or_conflicting_updates(tmp_path: Path) -> None: (stale / "docs" / "intro.mdx").write_text("# Stale\n", encoding="utf-8") _make_docs_website_tree(website) - def sync(source: Path, source_ref: str, version: str) -> None: + 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=source_ref, + source_ref="main", + source_sha=source_sha, release_version=version, version_slug="", display_name=f"Dev (v{version})", @@ -485,7 +504,8 @@ def test_immutable_snapshot_cannot_change_source(tmp_path: Path) -> None: source_root=source, docs_website_root=website, channel="stable", - source_ref="release-sha", + source_ref="main", + source_sha="release-sha", release_version="0.2.0", version_slug="v0.2.0", display_name="", @@ -494,7 +514,7 @@ def test_immutable_snapshot_cannot_change_source(tmp_path: Path) -> None: ) sdw.sync_docs(args) - args.source_ref = "different-sha" + args.source_sha = "different-sha" with pytest.raises(ValueError, match=r"immutable snapshot v0\.2\.0"): sdw.sync_docs(args) @@ -518,7 +538,8 @@ def test_only_dev_refreshes_shared_fern_files(tmp_path: Path) -> None: source_root=dev, docs_website_root=website, channel="dev", - source_ref="dev-sha", + source_ref="main", + source_sha="dev-sha", release_version="0.2.1.dev1", version_slug="", display_name="Dev (v0.2.1.dev1)", @@ -531,7 +552,8 @@ def test_only_dev_refreshes_shared_fern_files(tmp_path: Path) -> None: source_root=release, docs_website_root=website, channel="stable", - source_ref="release-sha", + source_ref="v0.2.0", + source_sha="release-sha", release_version="0.2.0", version_slug="v0.2.0", display_name="", @@ -556,6 +578,7 @@ 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", @@ -583,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" From 6734f1f7eb42b322168662b3903a50a8797053d8 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 2 Sep 2026 18:42:08 -0700 Subject: [PATCH 8/8] docs(fern): document versioned publishing Signed-off-by: Piotr Mlocek --- AGENTS.md | 4 +-- CONTRIBUTING.md | 4 +-- architecture/build.md | 10 +------ fern/README.md | 68 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 fern/README.md 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 ba4548d33b..7f1e3b71d5 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -402,15 +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. - -Versioned site snapshots live on the generated `docs-website` branch. Maintainers use `.github/workflows/sync-docs.yml` to refresh or remove snapshots while preserving the others. The generated branch records each managed snapshot's source commit and release version. Release Dev updates `dev` only when its development version is not older than the current snapshot, and it owns the shared Fern configuration, components, theme assets, and CSS. A stable Release Tag run creates an immutable `vX.Y.Z` snapshot and updates `latest` only when the release is not older than the current Latest version. Releases starting with v0.1.0 receive Fern's Stable availability status. Both release workflows wait for their other publication jobs, then call the docs workflow once. The workflow queues and serializes the branch update and one production publish attempt under the `docs-website` concurrency group. `.github/workflows/publish-docs-website.yml` remains available for explicit preview or production publishing of the generated branch. +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 +```