From 4e00594980d4396f98dc7a6c081cd268f1d3b489 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Fri, 25 Sep 2026 12:16:48 -0700 Subject: [PATCH 1/3] docs(fern): publish and order versioned release docs Signed-off-by: Piotr Mlocek --- .github/workflows/release-tag.yml | 3 +- architecture/build.md | 4 ++ fern/README.md | 5 +- tasks/scripts/sync_docs_website.py | 26 ++++----- tasks/scripts/sync_docs_website_test.py | 70 ++++++++++++++++++++++--- 5 files changed, 87 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index c569a2a615..59c2a542b4 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -758,9 +758,10 @@ jobs: uses: ./.github/workflows/sync-docs.yml with: operation: sync - channel: latest + channel: stable source_ref: ${{ needs.compute-versions.outputs.source_sha }} release_version: ${{ needs.compute-versions.outputs.semver }} + version_slug: v${{ needs.compute-versions.outputs.semver }} display_name: Latest (v${{ needs.compute-versions.outputs.semver }}) publish: true secrets: diff --git a/architecture/build.md b/architecture/build.md index d1faa3ea53..60dd1e4c10 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -23,6 +23,10 @@ OpenShell builds these main artifacts: | VM driver/runtime assets | `crates/openshell-driver-vm` | | Published docs site | `docs/` rendered by Fern config in `fern/` | +Release Tag publishes the same tagged docs commit as an immutable `vX.Y.Z` +snapshot and the mutable `latest` alias. Release Dev updates `dev`. The Fern +selector pins `latest` and `dev`, then lists versioned snapshots newest first. + Workload images are standard OCI images supplied by operators or users. ## Build Features diff --git a/fern/README.md b/fern/README.md index 8e86d83041..ce15cb42d1 100644 --- a/fern/README.md +++ b/fern/README.md @@ -43,12 +43,13 @@ The automated site uses these version types: | Version | Source | Update policy | Fern status | |---|---|---|---| -| `latest` | The newest stable release. | Mutable. A maintenance release older than the current stable release cannot move it backward unless a maintainer explicitly allows a rollback. | No status before v0.1.0. | +| `latest` | The newest stable release. | Mutable. A maintenance release older than the current stable release cannot move it backward unless a maintainer explicitly allows a rollback. | Stable from v0.1.0; no status before then. | | `dev` | The most recent successful Release Dev run from `main`. | Mutable. Automation rejects an older version or the same version from a different commit unless a maintainer explicitly allows a rollback. | Beta. | +| `vX.Y.Z` | The tagged release commit. | Immutable. A later sync cannot replace its source commit. | Stable from v0.1.0; earlier historical versions can omit a status. | Release Dev waits for the development artifacts and Helm chart, then calls `.github/workflows/sync-docs.yml` once. The reusable workflow updates `dev`, validates the generated site, commits and pushes the branch when needed, and publishes the production site once. -Release Tag follows the same sequence for a non-prerelease tag after the release artifacts, SDK package, Helm chart, and wheel publication complete. It updates `latest` when the release is not older than the current version, then publishes the production site once. +Release Tag follows the same sequence for a non-prerelease tag after the release artifacts, SDK package, Helm chart, and wheel publication complete. One sync copies the tagged source into both the immutable `vX.Y.Z` snapshot and `latest` when the release is not older than the current version, then publishes the production site once. The version selector pins `latest` and `dev` first, followed by versioned snapshots in descending version order. Other historical entries follow in their existing order. 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. diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index 396dd71268..ee10399b86 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -340,18 +340,20 @@ def ordered_entries( ) -> list[VersionEntry]: by_slug = {entry.slug: entry for entry in existing} by_slug[updated.slug] = updated - existing_order = [entry.slug for entry in existing if entry.slug != updated.slug] - - order: list[str] = [] - for slug in ("latest", "dev"): - if slug in by_slug: - order.append(slug) - for slug in existing_order: - if slug not in order and slug in by_slug: - order.append(slug) - if updated.slug not in order: - order.append(updated.slug) - return [by_slug[slug] for slug in order] + pinned = [by_slug[slug] for slug in ("latest", "dev") if slug in by_slug] + + versioned: list[tuple[Version, VersionEntry]] = [] + other: list[VersionEntry] = [] + for entry in by_slug.values(): + if entry.slug in {"latest", "dev"}: + continue + try: + versioned.append((parse_release_version(entry.slug), entry)) + except ValueError: + other.append(entry) + + versioned.sort(key=lambda item: item[0], reverse=True) + return pinned + [entry for _, entry in versioned] + other def render_versions(entries: list[VersionEntry]) -> list[YamlMapping]: diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py index bef5ac995d..9bec930570 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -58,12 +58,15 @@ def test_release_workflows_sync_and_publish_docs_once() -> None: "trigger-wheel-publish", ] assert tag_job["uses"] == "./.github/workflows/sync-docs.yml" - assert tag_job["with"]["channel"] == "latest" + assert tag_job["with"]["channel"] == "stable" assert ( tag_job["with"]["release_version"] == "${{ needs.compute-versions.outputs.semver }}" ) - assert "version_slug" not in tag_job["with"] + assert ( + tag_job["with"]["version_slug"] + == "v${{ needs.compute-versions.outputs.semver }}" + ) assert ( tag_job["with"]["display_name"] == "Latest (v${{ needs.compute-versions.outputs.semver }})" @@ -266,12 +269,35 @@ def test_source_version_announcement_maps_single_source_version_to_channel( def test_ordered_entries_pins_latest_then_dev() -> None: existing = [ - sdw.VersionEntry("v0.0.36", "v0.0.36", "./versions/v0.0.36.yml"), + sdw.VersionEntry("v0.0.116", "v0.0.116", "./versions/v0.0.116.yml"), sdw.VersionEntry("dev", "dev", "./versions/dev.yml"), + sdw.VersionEntry("v1.4.0", "v1.4.0", "./versions/v1.4.0.yml"), + sdw.VersionEntry("v1.4.2", "v1.4.2", "./versions/v1.4.2.yml"), + sdw.VersionEntry("v1.4.1", "v1.4.1", "./versions/v1.4.1.yml"), + sdw.VersionEntry("legacy", "legacy", "./versions/legacy.yml"), ] updated = sdw.VersionEntry("latest", "Latest", "./versions/latest.yml") ordered = [entry.slug for entry in sdw.ordered_entries(existing, updated)] - assert ordered == ["latest", "dev", "v0.0.36"] + assert ordered == [ + "latest", + "dev", + "v1.4.2", + "v1.4.1", + "v1.4.0", + "v0.0.116", + "legacy", + ] + + refreshed = sdw.VersionEntry("v1.4.1", "v1.4.1", "./versions/v1.4.1.yml") + refreshed_order = [entry.slug for entry in sdw.ordered_entries(existing, refreshed)] + assert refreshed_order == [ + "dev", + "v1.4.2", + "v1.4.1", + "v1.4.0", + "v0.0.116", + "legacy", + ] def test_prefix_navigation_paths() -> None: @@ -589,6 +615,31 @@ def test_stable_sync_creates_immutable_version_and_promotes_latest( website = tmp_path / "docs-website" _make_source_tree(source) _make_docs_website_tree(website) + (website / "fern" / "docs.yml").write_text( + yaml.safe_dump( + { + "versions": [ + { + "display-name": "Latest (v0.0.116)", + "path": "./versions/latest.yml", + "slug": "latest", + }, + { + "display-name": "Dev", + "path": "./versions/dev.yml", + "slug": "dev", + "availability": "beta", + }, + { + "display-name": "v0.0.116", + "path": "./versions/v0.0.116.yml", + "slug": "v0.0.116", + }, + ] + } + ), + encoding="utf-8", + ) sdw.sync_docs( Namespace( @@ -609,10 +660,17 @@ def test_stable_sync_creates_immutable_version_and_promotes_latest( assert (fern / "pages-v0.2.0" / "intro.mdx").is_file() assert (fern / "pages-latest" / "intro.mdx").is_file() versions = read_yaml(fern / "docs.yml")["versions"] - assert [entry["slug"] for entry in versions] == ["latest", "v0.2.0"] + assert [entry["slug"] for entry in versions] == [ + "latest", + "dev", + "v0.2.0", + "v0.0.116", + ] assert versions[0]["display-name"] == "Latest (v0.2.0)" assert versions[0]["availability"] == "stable" - assert versions[1]["availability"] == "stable" + assert versions[1]["availability"] == "beta" + assert versions[2]["availability"] == "stable" + assert "availability" not in versions[3] snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] assert snapshots["latest"] == { "source-ref": "v0.2.0", From 0735dc4966cbfb8146a207bfb391dfcf9d7cefaf Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Fri, 25 Sep 2026 12:30:28 -0700 Subject: [PATCH 2/3] docs(fern): remove version availability badges Signed-off-by: Piotr Mlocek --- .github/workflows/release-dev.yml | 1 - .github/workflows/sync-docs.yml | 10 ----- architecture/build.md | 1 + fern/README.md | 12 +++--- fern/docs.yml | 1 - tasks/scripts/sync_docs_website.py | 35 ----------------- tasks/scripts/sync_docs_website_test.py | 50 ++++++------------------- 7 files changed, 20 insertions(+), 90 deletions(-) diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index c188ba3dc6..371144bf7c 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -577,7 +577,6 @@ jobs: source_ref: ${{ github.sha }} release_version: ${{ needs.compute-versions.outputs.docs_version }} display_name: Dev - availability: beta publish: true secrets: FERN_TOKEN: ${{ secrets.FERN_TOKEN }} diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 63c4a6b8b8..6117383fce 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -30,10 +30,6 @@ on: description: "Optional version selector display name" required: false type: string - availability: - description: "Optional Fern availability status" - required: false - type: string publish: description: "Publish production docs after syncing" required: false @@ -82,10 +78,6 @@ on: description: "Optional selector name, e.g. Dev" required: false type: string - availability: - description: "Optional Fern status: beta, deprecated, ga, or stable" - required: false - type: string publish: description: "Publish production docs after syncing" required: false @@ -176,7 +168,6 @@ jobs: RELEASE_VERSION: ${{ inputs.release_version }} VERSION_SLUG: ${{ inputs.version_slug }} DISPLAY_NAME: ${{ inputs.display_name }} - AVAILABILITY: ${{ inputs.availability }} ALLOW_ROLLBACK: ${{ inputs.allow_rollback }} run: | SOURCE_SHA="" @@ -197,7 +188,6 @@ jobs: --release-version "$RELEASE_VERSION" \ --version-slug "$VERSION_SLUG" \ --display-name "$DISPLAY_NAME" \ - --availability "$AVAILABILITY" \ "${rollback_args[@]}" - name: Setup Node.js diff --git a/architecture/build.md b/architecture/build.md index 60dd1e4c10..b855a2cae5 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -26,6 +26,7 @@ OpenShell builds these main artifacts: Release Tag publishes the same tagged docs commit as an immutable `vX.Y.Z` snapshot and the mutable `latest` alias. Release Dev updates `dev`. The Fern selector pins `latest` and `dev`, then lists versioned snapshots newest first. +The selector does not show availability badges. Workload images are standard OCI images supplied by operators or users. diff --git a/fern/README.md b/fern/README.md index ce15cb42d1..733b653137 100644 --- a/fern/README.md +++ b/fern/README.md @@ -41,16 +41,18 @@ The generated `docs-website` branch contains the complete production input for F The automated site uses these version types: -| Version | Source | Update policy | Fern status | -|---|---|---|---| -| `latest` | The newest stable release. | Mutable. A maintenance release older than the current stable release cannot move it backward unless a maintainer explicitly allows a rollback. | Stable from v0.1.0; no status before then. | -| `dev` | The most recent successful Release Dev run from `main`. | Mutable. Automation rejects an older version or the same version from a different commit unless a maintainer explicitly allows a rollback. | Beta. | -| `vX.Y.Z` | The tagged release commit. | Immutable. A later sync cannot replace its source commit. | Stable from v0.1.0; earlier historical versions can omit a status. | +| Version | Source | Update policy | +|---|---|---| +| `latest` | The newest stable release. | Mutable. A maintenance release older than the current stable release cannot move it backward unless a maintainer explicitly allows a rollback. | +| `dev` | The most recent successful Release Dev run from `main`. | Mutable. Automation rejects an older version or the same version from a different commit unless a maintainer explicitly allows a rollback. | +| `vX.Y.Z` | The tagged release commit. | Immutable. A later sync cannot replace its source commit. | Release Dev waits for the development artifacts and Helm chart, then calls `.github/workflows/sync-docs.yml` once. The reusable workflow updates `dev`, validates the generated site, commits and pushes the branch when needed, and publishes the production site once. Release Tag follows the same sequence for a non-prerelease tag after the release artifacts, SDK package, Helm chart, and wheel publication complete. One sync copies the tagged source into both the immutable `vX.Y.Z` snapshot and `latest` when the release is not older than the current version, then publishes the production site once. The version selector pins `latest` and `dev` first, followed by versioned snapshots in descending version order. Other historical entries follow in their existing order. +The selector does not show availability badges. Each sync removes any existing `availability` fields from the generated version list, including the earlier `dev` Beta badge. + 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. diff --git a/fern/docs.yml b/fern/docs.yml index 7fd2f6aa84..d57c7af898 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -59,7 +59,6 @@ versions: - display-name: Dev path: ../docs/index.yml slug: dev - availability: beta announcement: message: 'New in OpenShell 0.1.0: a stable release cadence, new isolation primitives, an expanded extension surface, and new APIs. Read the 0.1.0 upgrade guide.' diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index ee10399b86..2f2204fc5d 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -25,7 +25,6 @@ SLUG_RE = re.compile(r"^[A-Za-z0-9._-]+$") DISPLAY_VERSION_RE = re.compile(r"\bv?(\d+\.\d+\.\d+(?:[.-]?[A-Za-z0-9]+)*)\b") -VERSION_AVAILABILITIES = {"beta", "deprecated", "ga", "stable"} SNAPSHOT_METADATA_FILE = ".docs-snapshots.yml" YamlMapping = dict[str, object] @@ -35,7 +34,6 @@ class VersionEntry: slug: str display_name: str path: str - availability: str | None = None announcement: YamlMapping | None = None @@ -54,7 +52,6 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--release-version", default="") parser.add_argument("--version-slug", default="") parser.add_argument("--display-name", default="") - parser.add_argument("--availability", default="") parser.add_argument("--allow-rollback", action="store_true") return parser.parse_args() @@ -91,18 +88,6 @@ def resolve_display_name( return slug -def resolve_availability(channel: str, override: str) -> str | None: - availability = override or ("beta" if channel == "dev" else "") - if not availability: - return None - if availability not in VERSION_AVAILABILITIES: - supported = ", ".join(sorted(VERSION_AVAILABILITIES)) - raise ValueError( - f"unsupported version availability {availability!r}; expected one of: {supported}" - ) - return availability - - def parse_release_version(value: str) -> Version: try: return Version(value.removeprefix("v")) @@ -110,12 +95,6 @@ def parse_release_version(value: str) -> Version: raise ValueError(f"invalid release version: {value}") from exc -def default_stable_availability(release_version: str) -> str | None: - if parse_release_version(release_version) >= Version("0.1.0"): - return "stable" - return None - - def ensure_existing(path: Path, label: str) -> None: if not path.exists(): raise FileNotFoundError(f"{label} does not exist: {path}") @@ -312,7 +291,6 @@ def parse_versions(raw_versions: object) -> list[VersionEntry]: slug = entry.get("slug") display_name = entry.get("display-name") path = entry.get("path") - availability = entry.get("availability") announcement = entry.get("announcement") if ( isinstance(slug, str) @@ -324,9 +302,6 @@ def parse_versions(raw_versions: object) -> list[VersionEntry]: slug=slug, display_name=display_name, path=path, - availability=availability - if isinstance(availability, str) - else None, announcement=cast("YamlMapping", announcement) if isinstance(announcement, dict) else None, @@ -364,8 +339,6 @@ def render_versions(entries: list[VersionEntry]) -> list[YamlMapping]: "path": entry.path, "slug": entry.slug, } - if entry.availability is not None: - item["availability"] = entry.availability if entry.announcement is not None: item["announcement"] = entry.announcement rendered.append(item) @@ -487,14 +460,12 @@ def sync_docs(args: argparse.Namespace) -> None: release_version = clean_input(getattr(args, "release_version", "")) version_slug = clean_input(args.version_slug) display_override = clean_input(args.display_name) - availability_override = clean_input(args.availability) if channel in {"dev", "latest", "stable"} and not release_version: raise ValueError( "--release-version is required for dev, latest, and stable channels" ) slug = resolve_slug(channel, version_slug) display_name = resolve_display_name(channel, slug, source_ref, display_override) - availability = resolve_availability(channel, availability_override) metadata_path = target_fern / SNAPSHOT_METADATA_FILE snapshots = read_snapshot_metadata(metadata_path) docs_yml = target_fern / "docs.yml" @@ -505,9 +476,6 @@ def sync_docs(args: argparse.Namespace) -> None: if slug != expected_slug: raise ValueError(f"stable version slug must be {expected_slug}, got {slug}") ensure_immutable_snapshot(snapshots, target_fern, slug, source_sha) - stable_availability = availability or default_stable_availability( - release_version - ) write_snapshot( source_docs, source_fern, @@ -516,7 +484,6 @@ def sync_docs(args: argparse.Namespace) -> None: slug=slug, display_name=slug, path=f"./versions/{slug}.yml", - availability=stable_availability, announcement=source_version_announcement( source_fern / "docs.yml", slug ), @@ -545,7 +512,6 @@ def sync_docs(args: argparse.Namespace) -> None: slug="latest", display_name=display_override or f"Latest ({slug})", path="./versions/latest.yml", - availability=stable_availability, announcement=source_version_announcement( source_fern / "docs.yml", "latest" ), @@ -587,7 +553,6 @@ def sync_docs(args: argparse.Namespace) -> None: slug=slug, 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 9bec930570..9b067c8251 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -48,7 +48,7 @@ def test_release_workflows_sync_and_publish_docs_once() -> None: ) assert dev_job["with"]["publish"] == "true" assert dev_job["with"]["display_name"] == "Dev" - assert dev_job["with"]["availability"] == "beta" + assert "availability" not in dev_job["with"] assert tag_job["needs"] == [ "compute-versions", @@ -141,16 +141,7 @@ def test_resolve_display_name() -> None: assert sdw.resolve_display_name("dev", "dev", "main", "Custom") == "Custom" -def test_resolve_availability() -> None: - assert sdw.resolve_availability("dev", "") == "beta" - assert sdw.resolve_availability("latest", "") is None - assert sdw.resolve_availability("version", "") is None - assert sdw.resolve_availability("version", "deprecated") == "deprecated" - with pytest.raises(ValueError): - sdw.resolve_availability("dev", "alpha") - - -def test_parse_and_render_versions_preserves_version_settings() -> None: +def test_parse_and_render_versions_removes_legacy_badges() -> None: raw_versions = [ { "display-name": "v0.0.36", @@ -168,11 +159,17 @@ def test_parse_and_render_versions_preserves_version_settings() -> None: "v0.0.36", "v0.0.36", "./versions/v0.0.36.yml", - "deprecated", {"message": "Upgrade to the latest version."}, ) ] - assert sdw.render_versions(entries) == raw_versions + assert sdw.render_versions(entries) == [ + { + "display-name": "v0.0.36", + "path": "./versions/v0.0.36.yml", + "slug": "v0.0.36", + "announcement": {"message": "Upgrade to the latest version."}, + } + ] def test_sync_global_announcement_applies_source_config(tmp_path: Path) -> None: @@ -395,7 +392,6 @@ def test_sync_docs_scopes_version_announcements_to_updated_channel( release_version="0.0.116", version_slug="", display_name="Latest (v0.0.116)", - availability="", ) ) (source / "fern" / "docs.yml").write_text( @@ -424,7 +420,6 @@ def test_sync_docs_scopes_version_announcements_to_updated_channel( release_version="0.0.117.dev56", version_slug="", display_name="Dev", - availability="beta", ) ) @@ -447,7 +442,6 @@ def test_sync_docs_scopes_version_announcements_to_updated_channel( "display-name": "Dev", "path": "./versions/dev.yml", "slug": "dev", - "availability": "beta", "announcement": {"message": "OpenShell 0.1.0 is coming soon."}, }, ] @@ -480,7 +474,6 @@ def test_sync_docs_scopes_version_announcements_to_updated_channel( release_version="0.1.0", version_slug="", display_name="Latest (v0.1.0)", - availability="", ) ) @@ -489,7 +482,7 @@ def test_sync_docs_scopes_version_announcements_to_updated_channel( assert versions[1]["announcement"] == {"message": "OpenShell 0.1.0 is coming soon."} -def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None: +def test_sync_docs_removes_existing_version_badges(tmp_path: Path) -> None: source = tmp_path / "source" website = tmp_path / "docs-website" _make_source_tree(source) @@ -522,7 +515,6 @@ def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None: release_version="0.0.117.dev56", version_slug="", display_name="Dev (v0.0.117.dev56)", - availability="beta", ) ) @@ -532,13 +524,11 @@ def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None: "display-name": "Dev (v0.0.117.dev56)", "path": "./versions/dev.yml", "slug": "dev", - "availability": "beta", }, { "display-name": "v0.0.36", "path": "./versions/v0.0.36.yml", "slug": "v0.0.36", - "availability": "deprecated", }, ] @@ -592,7 +582,6 @@ def test_latest_sync_updates_legacy_snapshot_announcement(tmp_path: Path) -> Non release_version="0.0.116", version_slug="", display_name="Latest (v0.0.116)", - availability="", ) ) @@ -651,7 +640,6 @@ def test_stable_sync_creates_immutable_version_and_promotes_latest( release_version="0.2.0", version_slug="v0.2.0", display_name="", - availability="", allow_rollback=False, ) ) @@ -667,10 +655,7 @@ def test_stable_sync_creates_immutable_version_and_promotes_latest( "v0.0.116", ] assert versions[0]["display-name"] == "Latest (v0.2.0)" - assert versions[0]["availability"] == "stable" - assert versions[1]["availability"] == "beta" - assert versions[2]["availability"] == "stable" - assert "availability" not in versions[3] + assert all("availability" not in entry for entry in versions) snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] assert snapshots["latest"] == { "source-ref": "v0.2.0", @@ -708,7 +693,6 @@ def test_n_minus_one_sync_does_not_move_latest_backwards(tmp_path: Path) -> None release_version=version, version_slug=f"v{version}", display_name="", - availability="", allow_rollback=False, ) ) @@ -765,7 +749,6 @@ def test_stable_sync_preserves_newer_legacy_latest_without_metadata( release_version="0.2.7", version_slug="v0.2.7", display_name="", - availability="", allow_rollback=False, ) ) @@ -802,7 +785,6 @@ def sync(source: Path, source_sha: str, version: str) -> None: release_version=version, version_slug="", display_name=f"Dev (v{version})", - availability="beta", allow_rollback=False, ) ) @@ -837,7 +819,6 @@ def sync(source: Path, source_sha: str, version: str, allow_rollback: bool) -> N release_version=version, version_slug="", display_name=f"Dev (v{version})", - availability="beta", allow_rollback=allow_rollback, ) ) @@ -871,7 +852,6 @@ def test_immutable_snapshot_cannot_change_source(tmp_path: Path) -> None: release_version="0.2.0", version_slug="v0.2.0", display_name="", - availability="", allow_rollback=False, ) sdw.sync_docs(args) @@ -905,7 +885,6 @@ def test_only_dev_refreshes_shared_fern_files(tmp_path: Path) -> None: release_version="0.2.1.dev1", version_slug="", display_name="Dev (v0.2.1.dev1)", - availability="beta", allow_rollback=False, ) ) @@ -919,7 +898,6 @@ def test_only_dev_refreshes_shared_fern_files(tmp_path: Path) -> None: release_version="0.2.0", version_slug="v0.2.0", display_name="", - availability="", allow_rollback=False, ) ) @@ -943,7 +921,6 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: source_sha="release-sha", version_slug="v0.0.36", display_name="", - availability="deprecated", ) sdw.sync_docs(base) @@ -960,7 +937,6 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: source_ref="", version_slug="v0.0.36", display_name="", - availability="", ) ) @@ -987,7 +963,6 @@ def test_immutable_snapshot_uses_resolved_commit_identity( release_version="0.2.0" if channel == "stable" else "", version_slug="v0.2.0", display_name="", - availability="", allow_rollback=False, ) sdw.sync_docs(args) @@ -1032,7 +1007,6 @@ def test_stable_promotion_replaces_latest_page_components(tmp_path: Path) -> Non release_version=version, version_slug=f"v{version}", display_name="", - availability="", allow_rollback=False, ) ) From 2d5b242e50d9625d709ef862b9f56f65628d773f Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Fri, 25 Sep 2026 12:43:56 -0700 Subject: [PATCH 3/3] docs(fern): keep version badges optional Signed-off-by: Piotr Mlocek --- .github/workflows/sync-docs.yml | 10 +++++ architecture/build.md | 2 +- fern/README.md | 2 +- tasks/scripts/sync_docs_website.py | 26 +++++++++++++ tasks/scripts/sync_docs_website_test.py | 52 +++++++++++++++++++------ 5 files changed, 78 insertions(+), 14 deletions(-) diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 6117383fce..63c4a6b8b8 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -30,6 +30,10 @@ on: description: "Optional version selector display name" required: false type: string + availability: + description: "Optional Fern availability status" + required: false + type: string publish: description: "Publish production docs after syncing" required: false @@ -78,6 +82,10 @@ on: description: "Optional selector name, e.g. Dev" required: false type: string + availability: + description: "Optional Fern status: beta, deprecated, ga, or stable" + required: false + type: string publish: description: "Publish production docs after syncing" required: false @@ -168,6 +176,7 @@ jobs: RELEASE_VERSION: ${{ inputs.release_version }} VERSION_SLUG: ${{ inputs.version_slug }} DISPLAY_NAME: ${{ inputs.display_name }} + AVAILABILITY: ${{ inputs.availability }} ALLOW_ROLLBACK: ${{ inputs.allow_rollback }} run: | SOURCE_SHA="" @@ -188,6 +197,7 @@ jobs: --release-version "$RELEASE_VERSION" \ --version-slug "$VERSION_SLUG" \ --display-name "$DISPLAY_NAME" \ + --availability "$AVAILABILITY" \ "${rollback_args[@]}" - name: Setup Node.js diff --git a/architecture/build.md b/architecture/build.md index b855a2cae5..43161b8c0c 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -26,7 +26,7 @@ OpenShell builds these main artifacts: Release Tag publishes the same tagged docs commit as an immutable `vX.Y.Z` snapshot and the mutable `latest` alias. Release Dev updates `dev`. The Fern selector pins `latest` and `dev`, then lists versioned snapshots newest first. -The selector does not show availability badges. +The current release workflows do not request availability badges. Workload images are standard OCI images supplied by operators or users. diff --git a/fern/README.md b/fern/README.md index 733b653137..ed6f2a5d94 100644 --- a/fern/README.md +++ b/fern/README.md @@ -51,7 +51,7 @@ Release Dev waits for the development artifacts and Helm chart, then calls `.git Release Tag follows the same sequence for a non-prerelease tag after the release artifacts, SDK package, Helm chart, and wheel publication complete. One sync copies the tagged source into both the immutable `vX.Y.Z` snapshot and `latest` when the release is not older than the current version, then publishes the production site once. The version selector pins `latest` and `dev` first, followed by versioned snapshots in descending version order. Other historical entries follow in their existing order. -The selector does not show availability badges. Each sync removes any existing `availability` fields from the generated version list, including the earlier `dev` Beta badge. +The sync workflow still accepts an optional Fern availability badge, but the current Release Dev and Release Tag jobs do not request one. The next `dev` sync replaces the existing `dev` Beta badge with an unbadged entry. Other entries retain their badge setting until that entry is synced or removed. 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. diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index 2f2204fc5d..a63f2a1876 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -25,6 +25,7 @@ SLUG_RE = re.compile(r"^[A-Za-z0-9._-]+$") DISPLAY_VERSION_RE = re.compile(r"\bv?(\d+\.\d+\.\d+(?:[.-]?[A-Za-z0-9]+)*)\b") +VERSION_AVAILABILITIES = {"beta", "deprecated", "ga", "stable"} SNAPSHOT_METADATA_FILE = ".docs-snapshots.yml" YamlMapping = dict[str, object] @@ -34,6 +35,7 @@ class VersionEntry: slug: str display_name: str path: str + availability: str | None = None announcement: YamlMapping | None = None @@ -52,6 +54,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--release-version", default="") parser.add_argument("--version-slug", default="") parser.add_argument("--display-name", default="") + parser.add_argument("--availability", default="") parser.add_argument("--allow-rollback", action="store_true") return parser.parse_args() @@ -88,6 +91,18 @@ def resolve_display_name( return slug +def resolve_availability(override: str) -> str | None: + availability = override + if not availability: + return None + if availability not in VERSION_AVAILABILITIES: + supported = ", ".join(sorted(VERSION_AVAILABILITIES)) + raise ValueError( + f"unsupported version availability {availability!r}; expected one of: {supported}" + ) + return availability + + def parse_release_version(value: str) -> Version: try: return Version(value.removeprefix("v")) @@ -291,6 +306,7 @@ def parse_versions(raw_versions: object) -> list[VersionEntry]: slug = entry.get("slug") display_name = entry.get("display-name") path = entry.get("path") + availability = entry.get("availability") announcement = entry.get("announcement") if ( isinstance(slug, str) @@ -302,6 +318,9 @@ def parse_versions(raw_versions: object) -> list[VersionEntry]: slug=slug, display_name=display_name, path=path, + availability=availability + if isinstance(availability, str) + else None, announcement=cast("YamlMapping", announcement) if isinstance(announcement, dict) else None, @@ -339,6 +358,8 @@ def render_versions(entries: list[VersionEntry]) -> list[YamlMapping]: "path": entry.path, "slug": entry.slug, } + if entry.availability is not None: + item["availability"] = entry.availability if entry.announcement is not None: item["announcement"] = entry.announcement rendered.append(item) @@ -460,12 +481,14 @@ def sync_docs(args: argparse.Namespace) -> None: release_version = clean_input(getattr(args, "release_version", "")) version_slug = clean_input(args.version_slug) display_override = clean_input(args.display_name) + availability_override = clean_input(args.availability) if channel in {"dev", "latest", "stable"} and not release_version: raise ValueError( "--release-version is required for dev, latest, and stable channels" ) slug = resolve_slug(channel, version_slug) display_name = resolve_display_name(channel, slug, source_ref, display_override) + availability = resolve_availability(availability_override) metadata_path = target_fern / SNAPSHOT_METADATA_FILE snapshots = read_snapshot_metadata(metadata_path) docs_yml = target_fern / "docs.yml" @@ -484,6 +507,7 @@ def sync_docs(args: argparse.Namespace) -> None: slug=slug, display_name=slug, path=f"./versions/{slug}.yml", + availability=availability, announcement=source_version_announcement( source_fern / "docs.yml", slug ), @@ -512,6 +536,7 @@ def sync_docs(args: argparse.Namespace) -> None: slug="latest", display_name=display_override or f"Latest ({slug})", path="./versions/latest.yml", + availability=availability, announcement=source_version_announcement( source_fern / "docs.yml", "latest" ), @@ -553,6 +578,7 @@ def sync_docs(args: argparse.Namespace) -> None: slug=slug, 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 9b067c8251..1f26cd15b1 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -141,7 +141,15 @@ def test_resolve_display_name() -> None: assert sdw.resolve_display_name("dev", "dev", "main", "Custom") == "Custom" -def test_parse_and_render_versions_removes_legacy_badges() -> None: +def test_resolve_availability() -> None: + assert sdw.resolve_availability("") is None + assert sdw.resolve_availability("beta") == "beta" + assert sdw.resolve_availability("deprecated") == "deprecated" + with pytest.raises(ValueError): + sdw.resolve_availability("alpha") + + +def test_parse_and_render_versions_preserves_version_settings() -> None: raw_versions = [ { "display-name": "v0.0.36", @@ -159,17 +167,11 @@ def test_parse_and_render_versions_removes_legacy_badges() -> None: "v0.0.36", "v0.0.36", "./versions/v0.0.36.yml", + "deprecated", {"message": "Upgrade to the latest version."}, ) ] - assert sdw.render_versions(entries) == [ - { - "display-name": "v0.0.36", - "path": "./versions/v0.0.36.yml", - "slug": "v0.0.36", - "announcement": {"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: @@ -392,6 +394,7 @@ def test_sync_docs_scopes_version_announcements_to_updated_channel( release_version="0.0.116", version_slug="", display_name="Latest (v0.0.116)", + availability="", ) ) (source / "fern" / "docs.yml").write_text( @@ -420,6 +423,7 @@ def test_sync_docs_scopes_version_announcements_to_updated_channel( release_version="0.0.117.dev56", version_slug="", display_name="Dev", + availability="", ) ) @@ -474,6 +478,7 @@ def test_sync_docs_scopes_version_announcements_to_updated_channel( release_version="0.1.0", version_slug="", display_name="Latest (v0.1.0)", + availability="", ) ) @@ -482,7 +487,9 @@ def test_sync_docs_scopes_version_announcements_to_updated_channel( assert versions[1]["announcement"] == {"message": "OpenShell 0.1.0 is coming soon."} -def test_sync_docs_removes_existing_version_badges(tmp_path: Path) -> None: +def test_sync_docs_clears_updated_badge_and_preserves_other_badges( + tmp_path: Path, +) -> None: source = tmp_path / "source" website = tmp_path / "docs-website" _make_source_tree(source) @@ -492,12 +499,18 @@ def test_sync_docs_removes_existing_version_badges(tmp_path: Path) -> None: yaml.safe_dump( { "versions": [ + { + "display-name": "Dev", + "path": "./versions/dev.yml", + "slug": "dev", + "availability": "beta", + }, { "display-name": "v0.0.36", "path": "./versions/v0.0.36.yml", "slug": "v0.0.36", "availability": "deprecated", - } + }, ] } ), @@ -515,6 +528,7 @@ def test_sync_docs_removes_existing_version_badges(tmp_path: Path) -> None: release_version="0.0.117.dev56", version_slug="", display_name="Dev (v0.0.117.dev56)", + availability="", ) ) @@ -529,6 +543,7 @@ def test_sync_docs_removes_existing_version_badges(tmp_path: Path) -> None: "display-name": "v0.0.36", "path": "./versions/v0.0.36.yml", "slug": "v0.0.36", + "availability": "deprecated", }, ] @@ -582,6 +597,7 @@ def test_latest_sync_updates_legacy_snapshot_announcement(tmp_path: Path) -> Non release_version="0.0.116", version_slug="", display_name="Latest (v0.0.116)", + availability="", ) ) @@ -617,7 +633,6 @@ def test_stable_sync_creates_immutable_version_and_promotes_latest( "display-name": "Dev", "path": "./versions/dev.yml", "slug": "dev", - "availability": "beta", }, { "display-name": "v0.0.116", @@ -640,6 +655,7 @@ def test_stable_sync_creates_immutable_version_and_promotes_latest( release_version="0.2.0", version_slug="v0.2.0", display_name="", + availability="", allow_rollback=False, ) ) @@ -693,6 +709,7 @@ def test_n_minus_one_sync_does_not_move_latest_backwards(tmp_path: Path) -> None release_version=version, version_slug=f"v{version}", display_name="", + availability="", allow_rollback=False, ) ) @@ -749,6 +766,7 @@ def test_stable_sync_preserves_newer_legacy_latest_without_metadata( release_version="0.2.7", version_slug="v0.2.7", display_name="", + availability="", allow_rollback=False, ) ) @@ -785,6 +803,7 @@ def sync(source: Path, source_sha: str, version: str) -> None: release_version=version, version_slug="", display_name=f"Dev (v{version})", + availability="beta", allow_rollback=False, ) ) @@ -819,6 +838,7 @@ def sync(source: Path, source_sha: str, version: str, allow_rollback: bool) -> N release_version=version, version_slug="", display_name=f"Dev (v{version})", + availability="beta", allow_rollback=allow_rollback, ) ) @@ -852,6 +872,7 @@ def test_immutable_snapshot_cannot_change_source(tmp_path: Path) -> None: release_version="0.2.0", version_slug="v0.2.0", display_name="", + availability="", allow_rollback=False, ) sdw.sync_docs(args) @@ -885,6 +906,7 @@ def test_only_dev_refreshes_shared_fern_files(tmp_path: Path) -> None: release_version="0.2.1.dev1", version_slug="", display_name="Dev (v0.2.1.dev1)", + availability="beta", allow_rollback=False, ) ) @@ -898,6 +920,7 @@ def test_only_dev_refreshes_shared_fern_files(tmp_path: Path) -> None: release_version="0.2.0", version_slug="v0.2.0", display_name="", + availability="", allow_rollback=False, ) ) @@ -921,12 +944,14 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: source_sha="release-sha", version_slug="v0.0.36", display_name="", + availability="deprecated", ) sdw.sync_docs(base) fern = website / "fern" assert (fern / "pages-v0.0.36").is_dir() assert (fern / "versions" / "v0.0.36.yml").is_file() + assert read_yaml(fern / "docs.yml")["versions"][0]["availability"] == "deprecated" sdw.remove_docs( Namespace( @@ -937,6 +962,7 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: source_ref="", version_slug="v0.0.36", display_name="", + availability="", ) ) @@ -963,6 +989,7 @@ def test_immutable_snapshot_uses_resolved_commit_identity( release_version="0.2.0" if channel == "stable" else "", version_slug="v0.2.0", display_name="", + availability="", allow_rollback=False, ) sdw.sync_docs(args) @@ -1007,6 +1034,7 @@ def test_stable_promotion_replaces_latest_page_components(tmp_path: Path) -> Non release_version=version, version_slug=f"v{version}", display_name="", + availability="", allow_rollback=False, ) )