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/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..43161b8c0c 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -23,6 +23,11 @@ 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. +The current release workflows do not request availability badges. + 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..ed6f2a5d94 100644 --- a/fern/README.md +++ b/fern/README.md @@ -41,14 +41,17 @@ 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. | No status before v0.1.0. | -| `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. | +| 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. 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 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/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 396dd71268..a63f2a1876 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -91,8 +91,8 @@ def resolve_display_name( return slug -def resolve_availability(channel: str, override: str) -> str | None: - availability = override or ("beta" if channel == "dev" else "") +def resolve_availability(override: str) -> str | None: + availability = override if not availability: return None if availability not in VERSION_AVAILABILITIES: @@ -110,12 +110,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}") @@ -340,18 +334,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] + pinned = [by_slug[slug] for slug in ("latest", "dev") if slug in by_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] + 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]: @@ -492,7 +488,7 @@ def sync_docs(args: argparse.Namespace) -> None: ) slug = resolve_slug(channel, version_slug) display_name = resolve_display_name(channel, slug, source_ref, display_override) - availability = resolve_availability(channel, availability_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" @@ -503,9 +499,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, @@ -514,7 +507,7 @@ def sync_docs(args: argparse.Namespace) -> None: slug=slug, display_name=slug, path=f"./versions/{slug}.yml", - availability=stable_availability, + availability=availability, announcement=source_version_announcement( source_fern / "docs.yml", slug ), @@ -543,7 +536,7 @@ def sync_docs(args: argparse.Namespace) -> None: slug="latest", display_name=display_override or f"Latest ({slug})", path="./versions/latest.yml", - availability=stable_availability, + availability=availability, announcement=source_version_announcement( source_fern / "docs.yml", "latest" ), diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py index bef5ac995d..1f26cd15b1 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", @@ -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 }})" @@ -139,12 +142,11 @@ def test_resolve_display_name() -> None: 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" + 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("dev", "alpha") + sdw.resolve_availability("alpha") def test_parse_and_render_versions_preserves_version_settings() -> None: @@ -266,12 +268,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: @@ -398,7 +423,7 @@ def test_sync_docs_scopes_version_announcements_to_updated_channel( release_version="0.0.117.dev56", version_slug="", display_name="Dev", - availability="beta", + availability="", ) ) @@ -421,7 +446,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."}, }, ] @@ -463,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_preserves_other_version_availability(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) @@ -473,12 +499,18 @@ def test_sync_docs_preserves_other_version_availability(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", - } + }, ] } ), @@ -496,7 +528,7 @@ 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", + availability="", ) ) @@ -506,7 +538,6 @@ 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", @@ -589,6 +620,30 @@ 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", + }, + { + "display-name": "v0.0.116", + "path": "./versions/v0.0.116.yml", + "slug": "v0.0.116", + }, + ] + } + ), + encoding="utf-8", + ) sdw.sync_docs( Namespace( @@ -609,10 +664,14 @@ 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 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", @@ -892,6 +951,7 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: 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(