Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/update-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
6 changes: 5 additions & 1 deletion architecture/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions fern/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
54 changes: 54 additions & 0 deletions tasks/scripts/sync_docs_website.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -380,6 +381,56 @@ 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", [])
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 rules
):
raise ValueError(
"docs.yml redirects must be a list of source/destination mappings"
)
return rules

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)]
Comment thread
purp marked this conversation as resolved.
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:
Expand Down Expand Up @@ -437,6 +488,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(
Expand Down
128 changes: 128 additions & 0 deletions tasks/scripts/sync_docs_website_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading