From 4aacb2ee88f51da231887acd8339e134ec8362d9 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sun, 27 Sep 2026 23:05:59 +0000 Subject: [PATCH 1/2] fix(docs): sync redirects with versioned snapshots Signed-off-by: Johnny Greco --- .agents/skills/update-docs/SKILL.md | 1 + architecture/build.md | 6 +- fern/README.md | 6 +- tasks/scripts/sync_docs_website.py | 53 ++++++++++ tasks/scripts/sync_docs_website_test.py | 128 ++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 3 deletions(-) diff --git a/.agents/skills/update-docs/SKILL.md b/.agents/skills/update-docs/SKILL.md index 99feaefc54..8229ada34b 100644 --- a/.agents/skills/update-docs/SKILL.md +++ b/.agents/skills/update-docs/SKILL.md @@ -121,6 +121,7 @@ When updating an existing page: - Add content in the logical place within the existing structure. - Do not reorganize sections unless the change requires it. - Update any cross-references or "Next Steps" links if relevant. +- When moving published URLs, update `fern/docs.yml` redirects and run `mise run test:docs-website`. Redirects reach production through the owning channel's snapshot sync; changing source configuration alone does not republish existing snapshots. See `fern/README.md` for channel ownership and repair instructions. When creating a new page: diff --git a/architecture/build.md b/architecture/build.md index a164f9aaa7..11765e2706 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -587,7 +587,11 @@ 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. +workflow. Redirect rules follow the mutable snapshot that owns their source URL +(or destination for unversioned aliases). Syncing replaces that channel's rules, +including deletions; `dev` owns shared fallback rules. Stable promotion updates +`latest` routing together with its content, while older maintenance releases +preserve both. ## Validation Expectations diff --git a/fern/README.md b/fern/README.md index ed6f2a5d94..f3a0671d7b 100644 --- a/fern/README.md +++ b/fern/README.md @@ -55,13 +55,15 @@ The sync workflow still accepts an optional Fern availability badge, but the cur 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`. 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. +The `dev` snapshot also owns the shared Fern configuration, components, assets, and CSS on `docs-website`. The `latest` snapshot copies its documentation, navigation, and redirects but does not replace shared components, assets, or CSS. 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. +Redirects are synchronized with their mutable snapshot. A redirect whose source starts with `/openshell/dev/` or `/openshell/latest/` belongs to that channel. An unversioned alias to a versioned destination belongs to the destination channel; other shared rules belong to `dev`. Each sync replaces that channel's rules, including removing rules absent from the source. A stable release updates `latest` redirects only when it promotes `latest`, so an older maintenance release cannot roll back live routing. Redirects owned by other versions remain unchanged. + ## 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. +Maintainers can run `.github/workflows/sync-docs.yml` manually to add, refresh, or remove a historical version snapshot. To repair stale redirects for a mutable channel without changing its content, sync that channel from its recorded source commit and release version in `fern/.docs-snapshots.yml`, retaining its display name and availability from `fern/docs.yml`. Use the updated automation from `main`; select production publishing only when ready to publish the repair. 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. diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index a63f2a1876..0ad26ab8ed 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -19,6 +19,7 @@ from dataclasses import dataclass from pathlib import Path from typing import cast +from urllib.parse import urlsplit import yaml from packaging.version import InvalidVersion, Version @@ -380,6 +381,55 @@ def sync_global_announcement(source_docs_yml: Path, target_docs_yml: Path) -> No write_yaml(target_docs_yml, target_data) +def sync_redirects(source_docs_yml: Path, target_docs_yml: Path, slug: str) -> None: + """Refresh routing alongside its mutable snapshot, including deleted rules.""" + source_data = read_yaml(source_docs_yml) + target_data = read_yaml(target_docs_yml) + version_slugs = {"dev", "latest"} | { + entry.slug + for data in (source_data, target_data) + for entry in parse_versions(data.get("versions")) + } + + def redirects(data: YamlMapping) -> list[YamlMapping]: + value = data.get("redirects", []) + if not isinstance(value, list) or any( + not isinstance(rule, dict) + or not isinstance(rule.get("source"), str) + or not isinstance(rule.get("destination"), str) + for rule in value + ): + raise ValueError( + "docs.yml redirects must be a list of source/destination mappings" + ) + return cast("list[YamlMapping]", value) + + def owner(rule: YamlMapping) -> str | None: + # A versioned source owns its redirect even when it targets another + # version. Unversioned aliases belong to their destination's version. + for field in ("source", "destination"): + url = urlsplit(cast("str", rule[field])) + if not url.netloc and url.path.startswith("/openshell/"): + version = url.path.removeprefix("/openshell/").split("/", 1)[0] + if version in version_slugs: + return version + # Dev owns shared rules such as the legacy .html URL normalization. + return None + + def selected(rule: YamlMapping) -> bool: + channel = owner(rule) + return channel == slug or (channel is None and slug == "dev") + + retained = [rule for rule in redirects(target_data) if not selected(rule)] + updated = [rule for rule in redirects(source_data) if selected(rule)] + # Keep source ordering (explicit rules before wildcards), and place the + # refreshed channel's rules before shared fallback rules. + target_data["redirects"] = sorted( + updated + retained, key=lambda rule: owner(rule) is None + ) + 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: @@ -437,6 +487,9 @@ def write_snapshot( ) sync_global_announcement(source_fern / "docs.yml", target_fern / "docs.yml") + if entry.slug in {"dev", "latest"}: + sync_redirects(source_fern / "docs.yml", target_fern / "docs.yml", entry.slug) + versions_dir = target_fern / "versions" versions_dir.mkdir(parents=True, exist_ok=True) write_yaml( diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py index 1f26cd15b1..ed270c533c 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -1041,3 +1041,131 @@ def test_stable_promotion_replaces_latest_page_components(tmp_path: Path) -> Non widget = website / "fern" / "pages-latest" / "_components" / "Widget.tsx" assert widget.read_text(encoding="utf-8") == "export const Widget = 'new';\n" + + +@pytest.mark.parametrize("channel", ["latest", "stable"]) +def test_sync_replaces_latest_redirects_with_snapshot( + tmp_path: Path, channel: str +) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + source_config = source / "fern" / "docs.yml" + target_config = website / "fern" / "docs.yml" + aliases = [ + { + "source": "/openshell/tutorials", + "destination": "/openshell/latest/tutorials", + }, + { + "source": "/openshell/tutorials/:path*", + "destination": "/openshell/latest/tutorials/:path*", + }, + ] + preserved = [ + # Source ownership wins over the destination channel. + {"source": "/openshell/dev/retired", "destination": "/openshell/latest"}, + {"source": "/openshell/v0.0.116/old", "destination": "/openshell/v0.0.116/new"}, + {"source": "/openshell/:path*.html", "destination": "/openshell/:path*"}, + ] + old_aliases = [ + { + "source": rule["source"], + "destination": rule["destination"].replace( + "/latest/tutorials", "/latest/get-started/tutorials" + ), + } + for rule in aliases + ] + stale = [ + { + "source": rule["source"].replace("/openshell/", "/openshell/latest/", 1), + "destination": rule["destination"], + } + for rule in old_aliases + ] + sdw.write_yaml(source_config, {"versions": [], "redirects": aliases}) + sdw.write_yaml( + target_config, + { + "versions": [ + { + "slug": "v0.0.116", + "display-name": "v0.0.116", + "path": "./versions/v0.0.116.yml", + } + ], + "redirects": stale + old_aliases + preserved, + }, + ) + args = Namespace( + source_root=source, + docs_website_root=website, + channel=channel, + source_ref="v0.1.1", + source_sha="release-sha", + release_version="0.1.1", + version_slug="v0.1.1" if channel == "stable" else "", + display_name="", + availability="", + allow_rollback=False, + ) + # Repeating the same snapshot can repair routing without changing content. + for _ in range(2): + sdw.sync_docs(args) + assert read_yaml(target_config)["redirects"] == aliases + preserved + assert (website / "fern" / "pages-latest" / "intro.mdx").is_file() + + # A maintenance release must not restore the stale redirects. + sdw.write_yaml(source_config, {"versions": [], "redirects": stale + old_aliases}) + args.source_ref = "v0.0.117" + args.source_sha = "maintenance-sha" + args.release_version = "0.0.117" + args.version_slug = "v0.0.117" if channel == "stable" else "" + sdw.sync_docs(args) + assert read_yaml(target_config)["redirects"] == aliases + preserved + + +def test_dev_sync_updates_own_and_shared_redirects_only(tmp_path: Path) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + source_config = source / "fern" / "docs.yml" + target_config = website / "fern" / "docs.yml" + latest = { + "source": "/openshell/latest/index.html", + "destination": "/openshell/latest", + } + dev = {"source": "/openshell/dev/old", "destination": "/openshell/dev/new#section"} + shared = { + "source": "/openshell/:path*/index.html", + "destination": "/openshell/:path*", + } + stale = { + "source": "/openshell/dev/removed", + "destination": "/openshell/dev/deleted", + } + sdw.write_yaml(target_config, {"versions": [], "redirects": [latest, stale]}) + sdw.write_yaml(source_config, {"versions": [], "redirects": [dev, shared]}) + args = Namespace( + source_root=source, + docs_website_root=website, + channel="dev", + source_ref="main", + source_sha="dev-sha", + release_version="0.2.0.dev1", + version_slug="", + display_name="", + availability="", + allow_rollback=False, + ) + sdw.sync_docs(args) + # Keep the explicit latest/index.html rule ahead of the shared wildcard. + assert read_yaml(target_config)["redirects"] == [dev, latest, shared] + + # Removing the entire field removes only dev/shared rules. + sdw.write_yaml(source_config, {"versions": []}) + sdw.sync_docs(args) + assert read_yaml(target_config)["redirects"] == [latest] From cf4520fc16f5700c78be8a07f83dbe47a334130a Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Sun, 27 Sep 2026 23:23:16 +0000 Subject: [PATCH 2/2] fix(docs): make redirect validation types explicit Signed-off-by: Johnny Greco --- tasks/scripts/sync_docs_website.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index 0ad26ab8ed..03abee3ce6 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -393,16 +393,17 @@ def sync_redirects(source_docs_yml: Path, target_docs_yml: Path, slug: str) -> N def redirects(data: YamlMapping) -> list[YamlMapping]: value = data.get("redirects", []) + rules = cast("list[YamlMapping]", value) if not isinstance(value, list) or any( not isinstance(rule, dict) or not isinstance(rule.get("source"), str) or not isinstance(rule.get("destination"), str) - for rule in value + for rule in rules ): raise ValueError( "docs.yml redirects must be a list of source/destination mappings" ) - return cast("list[YamlMapping]", value) + return rules def owner(rule: YamlMapping) -> str | None: # A versioned source owns its redirect even when it targets another