Skip to content

Commit 8934b74

Browse files
authored
fix(docs): sync redirects with versioned snapshots (#3754)
* fix(docs): sync redirects with versioned snapshots Signed-off-by: Johnny Greco <jogreco@nvidia.com> * fix(docs): make redirect validation types explicit Signed-off-by: Johnny Greco <jogreco@nvidia.com> --------- Signed-off-by: Johnny Greco <jogreco@nvidia.com>
1 parent c9da461 commit 8934b74

5 files changed

Lines changed: 192 additions & 3 deletions

File tree

‎.agents/skills/update-docs/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ When updating an existing page:
121121
- Add content in the logical place within the existing structure.
122122
- Do not reorganize sections unless the change requires it.
123123
- Update any cross-references or "Next Steps" links if relevant.
124+
- 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.
124125

125126
When creating a new page:
126127

‎architecture/build.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,11 @@ configuration, components, theme assets, and publish settings live in `fern/`.
587587
Use `mise run docs` for strict validation and `mise run docs:serve` for local
588588
preview. PR previews are produced by `.github/workflows/branch-docs.yml` when
589589
Fern credentials are available. Production docs publish from the release tag
590-
workflow.
590+
workflow. Redirect rules follow the mutable snapshot that owns their source URL
591+
(or destination for unversioned aliases). Syncing replaces that channel's rules,
592+
including deletions; `dev` owns shared fallback rules. Stable promotion updates
593+
`latest` routing together with its content, while older maintenance releases
594+
preserve both.
591595

592596
## Validation Expectations
593597

‎fern/README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,15 @@ The sync workflow still accepts an optional Fern availability badge, but the cur
5555

5656
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.
5757

58-
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.
58+
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.
5959

6060
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.
6161

62+
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.
63+
6264
## Manual maintenance and publishing
6365

64-
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.
66+
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.
6567

6668
`.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.
6769

‎tasks/scripts/sync_docs_website.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from dataclasses import dataclass
2020
from pathlib import Path
2121
from typing import cast
22+
from urllib.parse import urlsplit
2223

2324
import yaml
2425
from packaging.version import InvalidVersion, Version
@@ -380,6 +381,56 @@ def sync_global_announcement(source_docs_yml: Path, target_docs_yml: Path) -> No
380381
write_yaml(target_docs_yml, target_data)
381382

382383

384+
def sync_redirects(source_docs_yml: Path, target_docs_yml: Path, slug: str) -> None:
385+
"""Refresh routing alongside its mutable snapshot, including deleted rules."""
386+
source_data = read_yaml(source_docs_yml)
387+
target_data = read_yaml(target_docs_yml)
388+
version_slugs = {"dev", "latest"} | {
389+
entry.slug
390+
for data in (source_data, target_data)
391+
for entry in parse_versions(data.get("versions"))
392+
}
393+
394+
def redirects(data: YamlMapping) -> list[YamlMapping]:
395+
value = data.get("redirects", [])
396+
rules = cast("list[YamlMapping]", value)
397+
if not isinstance(value, list) or any(
398+
not isinstance(rule, dict)
399+
or not isinstance(rule.get("source"), str)
400+
or not isinstance(rule.get("destination"), str)
401+
for rule in rules
402+
):
403+
raise ValueError(
404+
"docs.yml redirects must be a list of source/destination mappings"
405+
)
406+
return rules
407+
408+
def owner(rule: YamlMapping) -> str | None:
409+
# A versioned source owns its redirect even when it targets another
410+
# version. Unversioned aliases belong to their destination's version.
411+
for field in ("source", "destination"):
412+
url = urlsplit(cast("str", rule[field]))
413+
if not url.netloc and url.path.startswith("/openshell/"):
414+
version = url.path.removeprefix("/openshell/").split("/", 1)[0]
415+
if version in version_slugs:
416+
return version
417+
# Dev owns shared rules such as the legacy .html URL normalization.
418+
return None
419+
420+
def selected(rule: YamlMapping) -> bool:
421+
channel = owner(rule)
422+
return channel == slug or (channel is None and slug == "dev")
423+
424+
retained = [rule for rule in redirects(target_data) if not selected(rule)]
425+
updated = [rule for rule in redirects(source_data) if selected(rule)]
426+
# Keep source ordering (explicit rules before wildcards), and place the
427+
# refreshed channel's rules before shared fallback rules.
428+
target_data["redirects"] = sorted(
429+
updated + retained, key=lambda rule: owner(rule) is None
430+
)
431+
write_yaml(target_docs_yml, target_data)
432+
433+
383434
def source_version_announcement(docs_yml: Path, slug: str) -> YamlMapping | None:
384435
entries = parse_versions(read_yaml(docs_yml).get("versions"))
385436
for entry in entries:
@@ -437,6 +488,9 @@ def write_snapshot(
437488
)
438489
sync_global_announcement(source_fern / "docs.yml", target_fern / "docs.yml")
439490

491+
if entry.slug in {"dev", "latest"}:
492+
sync_redirects(source_fern / "docs.yml", target_fern / "docs.yml", entry.slug)
493+
440494
versions_dir = target_fern / "versions"
441495
versions_dir.mkdir(parents=True, exist_ok=True)
442496
write_yaml(

‎tasks/scripts/sync_docs_website_test.py‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,3 +1041,131 @@ def test_stable_promotion_replaces_latest_page_components(tmp_path: Path) -> Non
10411041

10421042
widget = website / "fern" / "pages-latest" / "_components" / "Widget.tsx"
10431043
assert widget.read_text(encoding="utf-8") == "export const Widget = 'new';\n"
1044+
1045+
1046+
@pytest.mark.parametrize("channel", ["latest", "stable"])
1047+
def test_sync_replaces_latest_redirects_with_snapshot(
1048+
tmp_path: Path, channel: str
1049+
) -> None:
1050+
source = tmp_path / "source"
1051+
website = tmp_path / "docs-website"
1052+
_make_source_tree(source)
1053+
_make_docs_website_tree(website)
1054+
source_config = source / "fern" / "docs.yml"
1055+
target_config = website / "fern" / "docs.yml"
1056+
aliases = [
1057+
{
1058+
"source": "/openshell/tutorials",
1059+
"destination": "/openshell/latest/tutorials",
1060+
},
1061+
{
1062+
"source": "/openshell/tutorials/:path*",
1063+
"destination": "/openshell/latest/tutorials/:path*",
1064+
},
1065+
]
1066+
preserved = [
1067+
# Source ownership wins over the destination channel.
1068+
{"source": "/openshell/dev/retired", "destination": "/openshell/latest"},
1069+
{"source": "/openshell/v0.0.116/old", "destination": "/openshell/v0.0.116/new"},
1070+
{"source": "/openshell/:path*.html", "destination": "/openshell/:path*"},
1071+
]
1072+
old_aliases = [
1073+
{
1074+
"source": rule["source"],
1075+
"destination": rule["destination"].replace(
1076+
"/latest/tutorials", "/latest/get-started/tutorials"
1077+
),
1078+
}
1079+
for rule in aliases
1080+
]
1081+
stale = [
1082+
{
1083+
"source": rule["source"].replace("/openshell/", "/openshell/latest/", 1),
1084+
"destination": rule["destination"],
1085+
}
1086+
for rule in old_aliases
1087+
]
1088+
sdw.write_yaml(source_config, {"versions": [], "redirects": aliases})
1089+
sdw.write_yaml(
1090+
target_config,
1091+
{
1092+
"versions": [
1093+
{
1094+
"slug": "v0.0.116",
1095+
"display-name": "v0.0.116",
1096+
"path": "./versions/v0.0.116.yml",
1097+
}
1098+
],
1099+
"redirects": stale + old_aliases + preserved,
1100+
},
1101+
)
1102+
args = Namespace(
1103+
source_root=source,
1104+
docs_website_root=website,
1105+
channel=channel,
1106+
source_ref="v0.1.1",
1107+
source_sha="release-sha",
1108+
release_version="0.1.1",
1109+
version_slug="v0.1.1" if channel == "stable" else "",
1110+
display_name="",
1111+
availability="",
1112+
allow_rollback=False,
1113+
)
1114+
# Repeating the same snapshot can repair routing without changing content.
1115+
for _ in range(2):
1116+
sdw.sync_docs(args)
1117+
assert read_yaml(target_config)["redirects"] == aliases + preserved
1118+
assert (website / "fern" / "pages-latest" / "intro.mdx").is_file()
1119+
1120+
# A maintenance release must not restore the stale redirects.
1121+
sdw.write_yaml(source_config, {"versions": [], "redirects": stale + old_aliases})
1122+
args.source_ref = "v0.0.117"
1123+
args.source_sha = "maintenance-sha"
1124+
args.release_version = "0.0.117"
1125+
args.version_slug = "v0.0.117" if channel == "stable" else ""
1126+
sdw.sync_docs(args)
1127+
assert read_yaml(target_config)["redirects"] == aliases + preserved
1128+
1129+
1130+
def test_dev_sync_updates_own_and_shared_redirects_only(tmp_path: Path) -> None:
1131+
source = tmp_path / "source"
1132+
website = tmp_path / "docs-website"
1133+
_make_source_tree(source)
1134+
_make_docs_website_tree(website)
1135+
source_config = source / "fern" / "docs.yml"
1136+
target_config = website / "fern" / "docs.yml"
1137+
latest = {
1138+
"source": "/openshell/latest/index.html",
1139+
"destination": "/openshell/latest",
1140+
}
1141+
dev = {"source": "/openshell/dev/old", "destination": "/openshell/dev/new#section"}
1142+
shared = {
1143+
"source": "/openshell/:path*/index.html",
1144+
"destination": "/openshell/:path*",
1145+
}
1146+
stale = {
1147+
"source": "/openshell/dev/removed",
1148+
"destination": "/openshell/dev/deleted",
1149+
}
1150+
sdw.write_yaml(target_config, {"versions": [], "redirects": [latest, stale]})
1151+
sdw.write_yaml(source_config, {"versions": [], "redirects": [dev, shared]})
1152+
args = Namespace(
1153+
source_root=source,
1154+
docs_website_root=website,
1155+
channel="dev",
1156+
source_ref="main",
1157+
source_sha="dev-sha",
1158+
release_version="0.2.0.dev1",
1159+
version_slug="",
1160+
display_name="",
1161+
availability="",
1162+
allow_rollback=False,
1163+
)
1164+
sdw.sync_docs(args)
1165+
# Keep the explicit latest/index.html rule ahead of the shared wildcard.
1166+
assert read_yaml(target_config)["redirects"] == [dev, latest, shared]
1167+
1168+
# Removing the entire field removes only dev/shared rules.
1169+
sdw.write_yaml(source_config, {"versions": []})
1170+
sdw.sync_docs(args)
1171+
assert read_yaml(target_config)["redirects"] == [latest]

0 commit comments

Comments
 (0)