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: