DataDesigner/fern/scripts/fern-release-version.py
Andre Manoel 765fccfcb0
docs: fix Fern versioned publishing (#656)
* docs: fix Fern versioned plugin docs

* docs: guard Fern release version content

* docs: dedupe latest Fern release pages

* ci: require latest Fern nav on release

* docs: document Fern release prep

* ci: automate Fern release sync

* ci: publish Fern snapshots from docs branch

* docs: keep Fern archive on docs branch

* docs: harden Fern docs branch publishing

* ci: preview Fern docs from archive branch

* docs: include utility modules in Fern API reference

* ci: harden Fern devnotes publishing

* docs: keep Fern latest label stable

* docs: normalize Fern latest preview label

* docs: align Fern code reference nav

* docs: sync Fern code reference across versions

* docs: materialize Fern version pages

* ci: record Fern publish provenance

* docs: fix Fern generated API MDX

* docs: escape generated Fern API example

* ci: use stable Fern preview URL

* docs: flatten Fern API nav roots

* docs: use generated API overview pages

* ci: allow branch-dispatched Fern publish tests

* docs: update Fern CLI pin

* docs: dedupe release nav validation paths

* docs: address Fern review nits
2026-05-15 17:09:59 -03:00

345 lines
12 KiB
Python

#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Prepare or verify Fern release version entries."""
from __future__ import annotations
import argparse
import re
import shutil
import sys
from pathlib import Path
VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:[-.][0-9A-Za-z]+)*")
AS_OF_VERSION_RE = re.compile(rf"As of Data Designer\s+\[?v?({VERSION_RE.pattern})")
NAV_PATH_RE = re.compile(r"^\s*path:\s+\./([^#\s]+)\s*$")
VERSIONED_PAGES_RE = re.compile(r"\./(?P<root>latest|v[0-9][^/\s#]*)/pages/")
class ReleaseVersionError(RuntimeError):
pass
def normalize_version(value: str) -> str:
version = value.strip()
if version.startswith("refs/tags/"):
version = version.removeprefix("refs/tags/")
version = version.removeprefix("v")
if not VERSION_RE.fullmatch(version):
raise ReleaseVersionError(f"Invalid version '{value}'. Expected X.Y.Z or vX.Y.Z, with optional suffix.")
return version
def version_slug(version: str) -> str:
return f"v{normalize_version(version)}"
def version_key(value: str) -> tuple[int, int, int, int, str]:
version = normalize_version(value)
match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(.*)", version)
if not match:
raise ReleaseVersionError(f"Invalid version '{value}'")
suffix = match.group(4)
return (int(match.group(1)), int(match.group(2)), int(match.group(3)), int(not suffix), suffix)
def parse_yaml_value(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1]
return value
def find_top_level_block(lines: list[str], name: str) -> tuple[int, int]:
start = next((i for i, line in enumerate(lines) if line == f"{name}:\n"), -1)
if start == -1:
raise ReleaseVersionError(f"Missing top-level '{name}:' block")
end = len(lines)
for i in range(start + 1, len(lines)):
if re.match(r"^[A-Za-z0-9_-]+:", lines[i]):
end = i
break
return start, end
def read_docs_lines(root: Path) -> list[str]:
docs_yml = root / "docs.yml"
if not docs_yml.exists():
raise ReleaseVersionError(f"Missing {docs_yml}")
return docs_yml.read_text().splitlines(keepends=True)
def versions_block_text(root: Path) -> str:
lines = read_docs_lines(root)
start, end = find_top_level_block(lines, "versions")
return "".join(lines[start:end])
def version_entries(root: Path) -> list[dict[str, str]]:
entries: list[dict[str, str]] = []
current: dict[str, str] = {}
for line in versions_block_text(root).splitlines():
stripped = line.strip()
if stripped.startswith("- display-name:"):
if current:
entries.append(current)
current = {"display_name": parse_yaml_value(stripped.split(":", 1)[1])}
elif current and stripped.startswith("path:"):
current["path"] = parse_yaml_value(stripped.split(":", 1)[1])
elif current and stripped.startswith("slug:"):
current["slug"] = parse_yaml_value(stripped.split(":", 1)[1])
if current:
entries.append(current)
return entries
def has_version_entry(root: Path, slug: str) -> bool:
block = versions_block_text(root)
return re.search(rf"^\s+slug:\s+{re.escape(slug)}\s*$", block, re.MULTILINE) is not None
def check_latest_display_name(root: Path) -> list[str]:
entries = version_entries(root)
latest = next((entry for entry in entries if entry.get("slug") == "latest"), None)
if latest is None:
return []
if latest.get("display_name") != "Latest":
return ['Latest version display name must be "Latest"']
return []
def referenced_mdx_paths(nav: Path) -> list[Path]:
versions_dir = nav.parent
seen: set[Path] = set()
paths: list[Path] = []
for line in nav.read_text().splitlines():
match = NAV_PATH_RE.match(line)
if match:
path = versions_dir / match.group(1)
if path.suffix == ".mdx" and path.exists() and path not in seen:
seen.add(path)
paths.append(path)
return paths
def check_as_of_versions(root: Path) -> list[str]:
errors: list[str] = []
for nav in sorted((root / "versions").glob("v*.yml")):
nav_slug = nav.stem
nav_version = version_key(nav_slug)
for path in referenced_mdx_paths(nav):
for match in AS_OF_VERSION_RE.finditer(path.read_text()):
content_slug = version_slug(match.group(1))
if version_key(content_slug) > nav_version:
rel_path = path.relative_to(root)
errors.append(f"{nav.name} references {rel_path}, which declares {content_slug}")
return errors
def check_latest_matches_release(root: Path, slug: str) -> list[str]:
latest_nav = root / "versions" / "latest.yml"
release_nav = root / "versions" / f"{slug}.yml"
if not latest_nav.exists() or not release_nav.exists():
return []
latest_content = strip_leading_comment_block(latest_nav.read_text())
release_content = strip_leading_comment_block(release_nav.read_text())
if latest_content != release_content:
return [f"{latest_nav} must match {release_nav} when publishing {slug}"]
return []
def update_docs_yml(root: Path, slug: str) -> None:
docs_yml = root / "docs.yml"
lines = read_docs_lines(root)
start, end = find_top_level_block(lines, "versions")
latest_index = next(
(i for i in range(start + 1, end) if lines[i].startswith("- display-name:") and "Latest" in lines[i]),
-1,
)
if latest_index == -1:
raise ReleaseVersionError("Missing latest version entry in docs.yml")
lines[latest_index] = '- display-name: "Latest"\n'
if not has_version_entry(root, slug):
insert_index = end
for i in range(latest_index + 1, end):
if lines[i].startswith("- display-name:"):
insert_index = i
break
lines[insert_index:insert_index] = [
f'- display-name: "{slug}"\n',
f" path: versions/{slug}.yml\n",
f" slug: {slug}\n",
]
docs_yml.write_text("".join(lines))
def strip_leading_comment_block(content: str) -> str:
lines = content.splitlines(keepends=True)
index = 0
while index < len(lines) and (lines[index].startswith("#") or not lines[index].strip()):
index += 1
return "".join(lines[index:])
def referenced_page_roots(content: str) -> list[str]:
roots: list[str] = []
seen: set[str] = set()
for match in VERSIONED_PAGES_RE.finditer(content):
root = match.group("root")
if root not in seen:
roots.append(root)
seen.add(root)
return roots
def rewrite_versioned_page_roots(content: str, slug: str) -> str:
return VERSIONED_PAGES_RE.sub(f"./{slug}/pages/", content)
def copy_referenced_pages(root: Path, source_roots: list[str], slug: str, force: bool) -> bool:
source_roots = [source_root for source_root in source_roots if source_root != slug]
if not source_roots:
return False
versions_dir = root / "versions"
source_pages_dirs = [(source_root, versions_dir / source_root / "pages") for source_root in source_roots]
for source_root, source_pages in source_pages_dirs:
if not source_pages.exists():
raise ReleaseVersionError(f"{source_pages} is referenced by latest.yml but does not exist")
target_pages = versions_dir / slug / "pages"
if target_pages.exists() and not force:
raise ReleaseVersionError(f"{target_pages} already exists. Pass --force to replace it.")
if target_pages.exists():
shutil.rmtree(target_pages)
for _source_root, source_pages in source_pages_dirs:
shutil.copytree(source_pages, target_pages, dirs_exist_ok=True)
return True
def write_release_nav(root: Path, slug: str, force: bool) -> bool:
versions_dir = root / "versions"
source = versions_dir / "latest.yml"
target = versions_dir / f"{slug}.yml"
if not source.exists():
raise ReleaseVersionError(f"Missing {source}")
if target.exists() and not force:
raise ReleaseVersionError(f"{target} already exists. Pass --force to replace it.")
content = source.read_text()
copied_pages = copy_referenced_pages(root, referenced_page_roots(content), slug, force)
content = rewrite_versioned_page_roots(content, slug)
release_comment = f"# Frozen {slug} release nav. Pages are materialized under ./{slug}/pages/.\n"
target.write_text(release_comment + strip_leading_comment_block(content))
return copied_pages
def update_latest_nav(root: Path, slug: str) -> bool:
latest_nav = root / "versions" / "latest.yml"
content = latest_nav.read_text()
updated = rewrite_versioned_page_roots(content, slug)
if updated == content:
return False
latest_nav.write_text(updated)
return True
def check_release(root: Path, slug: str, require_latest_matches_release: bool = False) -> list[str]:
errors: list[str] = []
block = versions_block_text(root)
nav = root / "versions" / f"{slug}.yml"
expected = {
"latest display name": r'^- display-name:\s+["\']Latest["\']\s*$',
"version display name": rf'^- display-name:\s+["\']{re.escape(slug)}["\']\s*$',
"version path": rf"^\s+path:\s+versions/{re.escape(slug)}\.yml\s*$",
"version slug": rf"^\s+slug:\s+{re.escape(slug)}\s*$",
}
for label, pattern in expected.items():
if not re.search(pattern, block, re.MULTILINE):
errors.append(f"Missing {label} for {slug} in docs.yml")
if not nav.exists():
errors.append(f"Missing {nav}")
elif "navigation:" not in nav.read_text():
errors.append(f"{nav} does not look like a Fern version nav file")
errors.extend(check_latest_display_name(root))
errors.extend(check_as_of_versions(root))
if require_latest_matches_release:
errors.extend(check_latest_matches_release(root, slug))
return errors
def prepare(args: argparse.Namespace) -> int:
root = Path(args.root)
slug = version_slug(args.version)
copied_pages = write_release_nav(root, slug, args.force)
updated_latest = update_latest_nav(root, slug)
update_docs_yml(root, slug)
print(f"Prepared Fern release {slug}")
if copied_pages:
print(f"Copied referenced pages into {root / 'versions' / slug / 'pages'}")
else:
print("No referenced pages needed copying")
if updated_latest:
print(f"Updated latest.yml to point at {slug} page copies")
print("Review reused page paths before publishing the release.")
return 0
def check(args: argparse.Namespace) -> int:
root = Path(args.root)
slug = version_slug(args.version)
errors = check_release(root, slug, args.require_latest_matches_release)
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print(f"Fern release version is prepared for {slug}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", default="fern", help="Fern docs root")
subparsers = parser.add_subparsers(required=True)
prepare_parser = subparsers.add_parser("prepare", help="Prepare Fern files for a release")
prepare_parser.add_argument("--version", required=True, help="Release version, e.g. 0.5.10")
prepare_parser.add_argument("--force", action="store_true", help="Overwrite existing release files")
prepare_parser.set_defaults(func=prepare)
check_parser = subparsers.add_parser("check", help="Check Fern files include a release")
check_parser.add_argument("--version", required=True, help="Release version or tag, e.g. v0.5.10")
check_parser.add_argument(
"--require-latest-matches-release",
action="store_true",
help="Fail unless latest.yml matches the requested release nav, ignoring leading comments",
)
check_parser.set_defaults(func=check)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
return args.func(args)
except ReleaseVersionError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())