From cc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Wed, 2 Sep 2026 02:45:20 +0300 Subject: feat: add stable client endpoint compatibility (#3509) * feat: add stable client endpoint compatibility * fix: keep portable-pty patch checks composable * ci: run maintenance checks in pull requests * fix: reject endpoint errors from terminal clients * feat: keep compatible servers running across updates --- scripts/changelog.py | 42 ++++++++++++++++++++++++++++++++++++++++-- scripts/preview.py | 16 ++++++++++++++++ scripts/test_changelog.py | 4 ++++ scripts/test_preview.py | 8 ++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) (limited to 'scripts') diff --git a/scripts/changelog.py b/scripts/changelog.py index 9fcae9cd..46c3688d 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -19,6 +19,7 @@ DEFAULT_RELEASE_REPO = "herdrdev/herdr" DEFAULT_LATEST_JSON_PATH = Path("distribution/latest.json") DEFAULT_PRODUCT_ANNOUNCEMENT_PATH = Path("docs/next/product-announcement.json") PROTOCOL_SOURCE_PATH = Path("src/protocol/wire.rs") +ENDPOINT_PROTOCOL_SOURCE_PATH = Path("src/protocol/endpoint.rs") CORE_ASSET_TARGETS = ( "linux-x86_64", "linux-aarch64", @@ -138,6 +139,18 @@ def read_protocol_version(source_path: Path = PROTOCOL_SOURCE_PATH) -> int: return int(match.group(1)) +def read_endpoint_protocol_generation( + source_path: Path = ENDPOINT_PROTOCOL_SOURCE_PATH, +) -> int: + content = source_path.read_text(encoding="utf-8") + match = re.search(r"pub const ENDPOINT_PROTOCOL_GENERATION: u32 = (\d+);", content) + if not match: + raise ChangelogError( + f"could not read ENDPOINT_PROTOCOL_GENERATION from {source_path}" + ) + return int(match.group(1)) + + def normalize_announcement(value: Any, label: str) -> dict[str, str] | None: if value is None: return None @@ -225,7 +238,14 @@ def normalize_release_metadata(value: Any, label: str, version: str) -> dict[str if not isinstance(value, dict): raise ChangelogError(f"{label} must be an object") - allowed_keys = {"notes", "announcement", "assets", "sha256", "protocol"} + allowed_keys = { + "notes", + "announcement", + "assets", + "sha256", + "protocol", + "endpoint_generation", + } extra_keys = sorted(set(value) - allowed_keys) if extra_keys: raise ChangelogError(f"{label} has unsupported field(s): {', '.join(extra_keys)}") @@ -244,6 +264,11 @@ def normalize_release_metadata(value: Any, label: str, version: str) -> dict[str inferred_protocol = infer_protocol_from_notes(notes) if inferred_protocol is not None: metadata["protocol"] = inferred_protocol + endpoint_generation = value.get("endpoint_generation") + if endpoint_generation is not None: + if not isinstance(endpoint_generation, int): + raise ChangelogError(f"{label}.endpoint_generation must be an integer") + metadata["endpoint_generation"] = endpoint_generation if "assets" in value: metadata["assets"] = normalize_assets( value.get("assets"), @@ -305,9 +330,11 @@ def build_latest_json( ordered_sha256 = normalize_sha256(sha256, "sha256") normalized_announcement = normalize_announcement(announcement, "root") archived_releases = normalize_releases(releases) + endpoint_generation = read_endpoint_protocol_generation() current_metadata: dict[str, Any] = { "notes": normalized_notes, "protocol": protocol, + "endpoint_generation": endpoint_generation, "assets": ordered_assets, } current_metadata["sha256"] = ordered_sha256 @@ -322,6 +349,7 @@ def build_latest_json( manifest: dict[str, Any] = { "version": normalized_version, "protocol": protocol, + "endpoint_generation": endpoint_generation, "notes": normalized_notes, "assets": ordered_assets, } @@ -397,6 +425,7 @@ def manifest_from_release_payload( return { "version": normalized_version, "protocol": protocol if protocol is not None else read_protocol_version(), + "endpoint_generation": read_endpoint_protocol_generation(), "notes": notes, "assets": manifest_assets, "sha256": manifest_sha256, @@ -415,6 +444,9 @@ def canonicalize_manifest(manifest: dict[str, Any], label: str) -> dict[str, Any protocol = manifest.get("protocol") if not isinstance(protocol, int): raise ChangelogError(f"{label} is missing an integer protocol") + endpoint_generation = manifest.get("endpoint_generation") + if endpoint_generation is not None and not isinstance(endpoint_generation, int): + raise ChangelogError(f"{label} endpoint_generation must be an integer") assets = manifest.get("assets") if not isinstance(assets, dict): @@ -423,13 +455,16 @@ def canonicalize_manifest(manifest: dict[str, Any], label: str) -> dict[str, Any normalized_assets = normalize_assets(assets, f"{label} assets") normalized_sha256 = normalize_sha256(manifest.get("sha256"), f"{label} sha256") - return { + canonical = { "version": normalize_version(version), "protocol": protocol, "notes": notes.strip(), "assets": normalized_assets, "sha256": normalized_sha256, } + if endpoint_generation is not None: + canonical["endpoint_generation"] = endpoint_generation + return canonical def ensure_manifest_matches_expected( @@ -495,6 +530,9 @@ def archived_releases_from_current_manifest(manifest: dict[str, Any]) -> dict[st protocol = manifest.get("protocol") if isinstance(protocol, int): metadata["protocol"] = protocol + endpoint_generation = manifest.get("endpoint_generation") + if isinstance(endpoint_generation, int): + metadata["endpoint_generation"] = endpoint_generation assets = manifest.get("assets") if isinstance(assets, dict): metadata["assets"] = normalize_assets( diff --git a/scripts/preview.py b/scripts/preview.py index 16c52b6e..935ee9b7 100644 --- a/scripts/preview.py +++ b/scripts/preview.py @@ -38,6 +38,7 @@ TYPE_HEADINGS = { } TYPE_ORDER = ("Added", "Fixed", "Performance", "Maintenance", "Other") COMMIT_RE = re.compile(r"^(?P[a-z]+)(?:\([^)]+\))?!?:\s+(?P.+)$") +ENDPOINT_PROTOCOL_SOURCE_PATH = Path("src/protocol/endpoint.rs") def run_git(args: list[str]) -> str: @@ -48,6 +49,18 @@ def normalize_version(version: str) -> str: return version.strip().removeprefix("v") +def read_endpoint_protocol_generation( + source_path: Path = ENDPOINT_PROTOCOL_SOURCE_PATH, +) -> int: + content = source_path.read_text(encoding="utf-8") + match = re.search(r"pub const ENDPOINT_PROTOCOL_GENERATION: u32 = (\d+);", content) + if not match: + raise ValueError( + f"could not read ENDPOINT_PROTOCOL_GENERATION from {source_path}" + ) + return int(match.group(1)) + + def latest_stable_tag(ref: str | None = None) -> str: args = ["describe", "--tags", "--match", "v[0-9]*", "--abbrev=0"] if ref: @@ -215,11 +228,13 @@ def build_manifest( current = read_json(output) or {} builds = current.get("builds") if isinstance(current.get("builds"), dict) else {} builds = dict(builds) + endpoint_generation = read_endpoint_protocol_generation() builds[build_id] = { "base_version": normalize_version(base_version), "commit": commit, "built_at": built_at, "protocol": protocol, + "endpoint_generation": endpoint_generation, "tag": tag, "assets": assets, } @@ -239,6 +254,7 @@ def build_manifest( "commit": commit, "built_at": built_at, "protocol": protocol, + "endpoint_generation": endpoint_generation, "notes": notes.strip(), "assets": assets, "builds": ordered_builds, diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index 6a9d12dd..f08088a3 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -20,6 +20,7 @@ from scripts.changelog import ( load_product_announcement, manifest_from_release_payload, prepare_release, + read_endpoint_protocol_generation, read_protocol_version, ) @@ -346,6 +347,7 @@ class ChangelogScriptTests(unittest.TestCase): { "version": "0.1.1", "protocol": read_protocol_version(), + "endpoint_generation": read_endpoint_protocol_generation(), "notes": "### Fixed\n- One", "assets": { "linux-x86_64": "https://example.com/linux-x86_64", @@ -436,6 +438,7 @@ class ChangelogScriptTests(unittest.TestCase): actual = { "version": "v0.1.1", "protocol": read_protocol_version(), + "endpoint_generation": read_endpoint_protocol_generation(), "notes": "\n### Fixed\n- One\n", "assets": { "linux-x86_64": " https://example.com/linux-x86_64 ", @@ -449,6 +452,7 @@ class ChangelogScriptTests(unittest.TestCase): expected = { "version": "0.1.1", "protocol": read_protocol_version(), + "endpoint_generation": read_endpoint_protocol_generation(), "notes": "### Fixed\n- One", "assets": { "linux-x86_64": "https://example.com/linux-x86_64", diff --git a/scripts/test_preview.py b/scripts/test_preview.py index 0f586437..d3613d83 100644 --- a/scripts/test_preview.py +++ b/scripts/test_preview.py @@ -48,6 +48,10 @@ class PreviewNotesTests(unittest.TestCase): data = json.loads(content) self.assertEqual(data["channel"], "preview") self.assertEqual(data["build_id"], "2026-06-02-abcdef123456") + self.assertEqual( + data["endpoint_generation"], + preview.read_endpoint_protocol_generation(), + ) self.assertEqual( data["assets"]["linux-x86_64"]["sha256"], "deadbeef", @@ -62,6 +66,10 @@ class PreviewNotesTests(unittest.TestCase): ) self.assertEqual(data["assets"]["windows-x86_64"]["format"], "zip") self.assertIn("2026-06-02-abcdef123456", data["builds"]) + self.assertEqual( + data["builds"]["2026-06-02-abcdef123456"]["endpoint_generation"], + preview.read_endpoint_protocol_generation(), + ) def test_windows_preview_asset_requires_sha256(self): with tempfile.TemporaryDirectory() as tmp: -- cgit v1.2.3