diff --git a/fern/README.md b/fern/README.md index b8e0ea3964..8e86d83041 100644 --- a/fern/README.md +++ b/fern/README.md @@ -13,7 +13,7 @@ OpenShell uses [Fern](https://buildwithfern.com/) to validate, preview, and publ | `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. +In a normal source checkout, `fern/docs.yml` points the `dev` version at `docs/index.yml`. Release automation builds the multi-version configuration on the generated `docs-website` branch and maps the source documentation to the channel being published. ## Local development @@ -54,6 +54,8 @@ The sync and publish workflows share the `docs-website` concurrency group. This The `dev` snapshot also owns the shared Fern configuration, components, assets, and CSS on `docs-website`. The `latest` snapshot copies its documentation and navigation but does not replace those shared files. This keeps the site configuration aligned with `main` while preserving the released content. +A `dev` sync copies the top-level `announcement` from the source `fern/docs.yml`. This announcement is the global fallback, and removing it from the source removes it from `docs-website`. Each snapshot sync copies the source version announcement only to the channel being updated. A version announcement overrides the global announcement for that version, so Release Dev cannot change the `latest` announcement and Release Tag cannot change the `dev` announcement. + ## Manual maintenance and publishing Maintainers can run `.github/workflows/sync-docs.yml` manually to add, refresh, or remove a historical version snapshot. The workflow preserves snapshots that were not selected. Production publishing is disabled by default for a manual sync. diff --git a/fern/docs.yml b/fern/docs.yml index 1161512029..ad6ea31a06 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Fern site config — merged with skills/convert-to-fern/assets/theme/nvidia/docs-theme.yml pattern # Production: https://docs.nvidia.com/openshell/ — preview + custom-domain share path prefix `openshell`. -# Internal MDX links use /latest/...; basepath-aware resolves them under /openshell/ on production. +# Source checkouts and pull request previews expose the current documentation at /dev. instances: - url: openshell.docs.buildwithfern.com/openshell custom-domain: docs.nvidia.com/openshell @@ -38,7 +38,7 @@ logo: dark: ./assets/NVIDIA_dark.svg light: ./assets/NVIDIA_light.svg height: 20 - href: /openshell/latest + href: /openshell right-text: OpenShell favicon: ./assets/NVIDIA_symbol.svg @@ -56,11 +56,10 @@ experimental: - ./components versions: - - display-name: Latest + - display-name: Dev path: ../docs/index.yml - slug: latest - announcement: - message: 'OpenShell 0.1.0 is coming soon. Track progress in the 0.1.0 milestone, read the prerelease documentation, or install a prerelease.' + slug: dev + availability: beta redirects: - source: "/openshell/latest/sandboxes/providers-v2" diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index 62000394d3..396dd71268 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -36,6 +36,7 @@ class VersionEntry: display_name: str path: str availability: str | None = None + announcement: YamlMapping | None = None def parse_args() -> argparse.Namespace: @@ -312,6 +313,7 @@ def parse_versions(raw_versions: object) -> list[VersionEntry]: display_name = entry.get("display-name") path = entry.get("path") availability = entry.get("availability") + announcement = entry.get("announcement") if ( isinstance(slug, str) and isinstance(display_name, str) @@ -325,6 +327,9 @@ def parse_versions(raw_versions: object) -> list[VersionEntry]: availability=availability if isinstance(availability, str) else None, + announcement=cast("YamlMapping", announcement) + if isinstance(announcement, dict) + else None, ) ) return entries @@ -349,8 +354,8 @@ def ordered_entries( return [by_slug[slug] for slug in order] -def render_versions(entries: list[VersionEntry]) -> list[dict[str, str]]: - rendered: list[dict[str, str]] = [] +def render_versions(entries: list[VersionEntry]) -> list[YamlMapping]: + rendered: list[YamlMapping] = [] for entry in entries: item = { "display-name": entry.display_name, @@ -359,10 +364,36 @@ def render_versions(entries: list[VersionEntry]) -> list[dict[str, str]]: } if entry.availability is not None: item["availability"] = entry.availability + if entry.announcement is not None: + item["announcement"] = entry.announcement rendered.append(item) return rendered +def sync_global_announcement(source_docs_yml: Path, target_docs_yml: Path) -> None: + source_data = read_yaml(source_docs_yml) + source_announcement = source_data.get("announcement") + if source_announcement is not None and not isinstance(source_announcement, dict): + raise ValueError("docs.yml announcement must be a mapping") + + target_data = read_yaml(target_docs_yml) + if source_announcement is None: + target_data.pop("announcement", None) + else: + target_data["announcement"] = source_announcement + write_yaml(target_docs_yml, target_data) + + +def source_version_announcement(docs_yml: Path, slug: str) -> YamlMapping | None: + entries = parse_versions(read_yaml(docs_yml).get("versions")) + for entry in entries: + if entry.slug == slug: + return entry.announcement + if len(entries) == 1: + return entries[0].announcement + return None + + def component_dirs(fern_dir: Path) -> list[str]: dirs: list[str] = [] preferred = ["pages-latest", "pages-dev"] @@ -408,6 +439,7 @@ def write_snapshot( copy_if_exists( source_fern / "fern.config.json", target_fern / "fern.config.json" ) + sync_global_announcement(source_fern / "docs.yml", target_fern / "docs.yml") versions_dir = target_fern / "versions" versions_dir.mkdir(parents=True, exist_ok=True) @@ -483,6 +515,9 @@ def sync_docs(args: argparse.Namespace) -> None: display_name=slug, path=f"./versions/{slug}.yml", availability=stable_availability, + announcement=source_version_announcement( + source_fern / "docs.yml", slug + ), ), refresh_shared=False, ) @@ -509,6 +544,9 @@ def sync_docs(args: argparse.Namespace) -> None: display_name=display_override or f"Latest ({slug})", path="./versions/latest.yml", availability=stable_availability, + announcement=source_version_announcement( + source_fern / "docs.yml", "latest" + ), ), refresh_shared=False, ) @@ -548,6 +586,7 @@ def sync_docs(args: argparse.Namespace) -> None: display_name=display_name, path=f"./versions/{slug}.yml", availability=availability, + announcement=source_version_announcement(source_fern / "docs.yml", slug), ), refresh_shared=channel == "dev", ) diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py index 076181ee39..bef5ac995d 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -147,13 +147,14 @@ def test_resolve_availability() -> None: sdw.resolve_availability("dev", "alpha") -def test_parse_and_render_versions_preserves_availability() -> None: +def test_parse_and_render_versions_preserves_version_settings() -> None: raw_versions = [ { "display-name": "v0.0.36", "path": "./versions/v0.0.36.yml", "slug": "v0.0.36", "availability": "deprecated", + "announcement": {"message": "Upgrade to the latest version."}, } ] @@ -165,11 +166,104 @@ def test_parse_and_render_versions_preserves_availability() -> None: "v0.0.36", "./versions/v0.0.36.yml", "deprecated", + {"message": "Upgrade to the latest version."}, ) ] assert sdw.render_versions(entries) == raw_versions +def test_sync_global_announcement_applies_source_config(tmp_path: Path) -> None: + source_docs_yml = tmp_path / "source.yml" + target_docs_yml = tmp_path / "target.yml" + source_docs_yml.write_text( + yaml.safe_dump( + { + "announcement": {"message": "Current global announcement."}, + "versions": [], + } + ), + encoding="utf-8", + ) + target_docs_yml.write_text( + yaml.safe_dump( + { + "announcement": {"message": "Stale global announcement."}, + "versions": [ + { + "display-name": "Latest (v0.0.116)", + "path": "./versions/latest.yml", + "slug": "latest", + }, + { + "display-name": "Dev", + "path": "./versions/dev.yml", + "slug": "dev", + "announcement": {"message": "Development docs."}, + }, + ], + } + ), + encoding="utf-8", + ) + + sdw.sync_global_announcement(source_docs_yml, target_docs_yml) + + target_data = read_yaml(target_docs_yml) + assert target_data["announcement"] == {"message": "Current global announcement."} + assert target_data["versions"] == [ + { + "display-name": "Latest (v0.0.116)", + "path": "./versions/latest.yml", + "slug": "latest", + }, + { + "display-name": "Dev", + "path": "./versions/dev.yml", + "slug": "dev", + "announcement": {"message": "Development docs."}, + }, + ] + + source_docs_yml.write_text( + yaml.safe_dump({"versions": []}), + encoding="utf-8", + ) + + sdw.sync_global_announcement(source_docs_yml, target_docs_yml) + + target_data = read_yaml(target_docs_yml) + assert "announcement" not in target_data + versions = target_data["versions"] + assert "announcement" not in versions[0] + assert versions[1]["announcement"] == {"message": "Development docs."} + + +def test_source_version_announcement_maps_single_source_version_to_channel( + tmp_path: Path, +) -> None: + docs_yml = tmp_path / "docs.yml" + docs_yml.write_text( + yaml.safe_dump( + { + "versions": [ + { + "display-name": "Dev", + "path": "../docs/index.yml", + "slug": "dev", + "announcement": {"message": "Snapshot announcement."}, + } + ] + } + ), + encoding="utf-8", + ) + + expected = {"message": "Snapshot announcement."} + assert sdw.source_version_announcement(docs_yml, "latest") == expected + assert sdw.source_version_announcement(docs_yml, "dev") == expected + assert sdw.source_version_announcement(docs_yml, "v0.0.116") == expected + + def test_ordered_entries_pins_latest_then_dev() -> None: existing = [ sdw.VersionEntry("v0.0.36", "v0.0.36", "./versions/v0.0.36.yml"), @@ -212,6 +306,8 @@ def _make_source_tree(root: Path) -> None: encoding="utf-8", ) fern = root / "fern" + fern.mkdir(parents=True) + (fern / "docs.yml").write_text(yaml.safe_dump({"versions": []}), encoding="utf-8") (fern / "assets").mkdir(parents=True) (fern / "assets" / "logo.svg").write_text("", encoding="utf-8") (fern / "components").mkdir(parents=True) @@ -228,11 +324,39 @@ def _make_docs_website_tree(root: Path) -> None: (fern / "docs.yml").write_text(yaml.safe_dump({"versions": []}), encoding="utf-8") -def test_sync_docs_creates_planned_latest_and_dev_selector(tmp_path: Path) -> None: +def test_sync_docs_scopes_version_announcements_to_updated_channel( + tmp_path: Path, +) -> None: source = tmp_path / "source" website = tmp_path / "docs-website" _make_source_tree(source) _make_docs_website_tree(website) + (source / "fern" / "docs.yml").write_text( + yaml.safe_dump( + { + "versions": [ + { + "display-name": "Latest", + "path": "../docs/index.yml", + "slug": "latest", + "announcement": { + "message": "Version 0.0.116 is the final alpha release." + }, + } + ] + } + ), + encoding="utf-8", + ) + (website / "fern" / "docs.yml").write_text( + yaml.safe_dump( + { + "announcement": {"message": "Stale global announcement."}, + "versions": [], + } + ), + encoding="utf-8", + ) sdw.sync_docs( Namespace( @@ -248,6 +372,21 @@ def test_sync_docs_creates_planned_latest_and_dev_selector(tmp_path: Path) -> No availability="", ) ) + (source / "fern" / "docs.yml").write_text( + yaml.safe_dump( + { + "versions": [ + { + "display-name": "Dev", + "path": "../docs/index.yml", + "slug": "dev", + "announcement": {"message": "OpenShell 0.1.0 is coming soon."}, + } + ] + } + ), + encoding="utf-8", + ) sdw.sync_docs( Namespace( operation="sync", @@ -276,16 +415,53 @@ def test_sync_docs_creates_planned_latest_and_dev_selector(tmp_path: Path) -> No "display-name": "Latest (v0.0.116)", "path": "./versions/latest.yml", "slug": "latest", + "announcement": {"message": "Version 0.0.116 is the final alpha release."}, }, { "display-name": "Dev", "path": "./versions/dev.yml", "slug": "dev", "availability": "beta", + "announcement": {"message": "OpenShell 0.1.0 is coming soon."}, }, ] + assert "announcement" not in docs_yml assert "./components" in docs_yml["experimental"]["mdx-components"] + (source / "fern" / "docs.yml").write_text( + yaml.safe_dump( + { + "versions": [ + { + "display-name": "Dev", + "path": "../docs/index.yml", + "slug": "dev", + "announcement": {"message": "OpenShell 0.1.0 is released."}, + } + ] + } + ), + encoding="utf-8", + ) + sdw.sync_docs( + Namespace( + operation="sync", + source_root=source, + docs_website_root=website, + channel="latest", + source_ref="release-0.1.0-sha", + source_sha="release-0.1.0-sha", + release_version="0.1.0", + version_slug="", + display_name="Latest (v0.1.0)", + availability="", + ) + ) + + versions = read_yaml(fern / "docs.yml")["versions"] + assert versions[0]["announcement"] == {"message": "OpenShell 0.1.0 is released."} + assert versions[1]["announcement"] == {"message": "OpenShell 0.1.0 is coming soon."} + def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None: source = tmp_path / "source" @@ -341,6 +517,71 @@ def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None: ] +def test_latest_sync_updates_legacy_snapshot_announcement(tmp_path: Path) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + (source / "fern" / "docs.yml").write_text( + yaml.safe_dump( + { + "versions": [ + { + "display-name": "Latest", + "path": "../docs/index.yml", + "slug": "latest", + "announcement": { + "message": "Version 0.0.116 is the final alpha release." + }, + } + ] + } + ), + encoding="utf-8", + ) + docs_yml_path = website / "fern" / "docs.yml" + docs_yml_path.write_text( + yaml.safe_dump( + { + "versions": [ + { + "display-name": "Latest (v0.0.116)", + "path": "./versions/latest.yml", + "slug": "latest", + } + ] + } + ), + encoding="utf-8", + ) + + sdw.sync_docs( + Namespace( + operation="sync", + source_root=source, + docs_website_root=website, + channel="latest", + source_ref="docs/v0.0.116-announcement", + source_sha="announcement-sha", + release_version="0.0.116", + version_slug="", + display_name="Latest (v0.0.116)", + availability="", + ) + ) + + latest = read_yaml(docs_yml_path)["versions"][0] + assert latest["announcement"] == { + "message": "Version 0.0.116 is the final alpha release." + } + snapshots = read_yaml(website / "fern" / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] + assert snapshots["latest"] == { + "source-ref": "docs/v0.0.116-announcement", + "source-sha": "announcement-sha", + "version": "0.0.116", + } + + def test_stable_sync_creates_immutable_version_and_promotes_latest( tmp_path: Path, ) -> None: