diff options
| -rw-r--r-- | AGENTS.md | 28 | ||||
| -rw-r--r-- | justfile | 4 | ||||
| -rw-r--r-- | scripts/changelog.py | 212 | ||||
| -rw-r--r-- | scripts/test_changelog.py | 101 | ||||
| -rw-r--r-- | src/update.rs | 93 | ||||
| -rw-r--r-- | website/latest.json | 1 |
6 files changed, 391 insertions, 48 deletions
@@ -39,6 +39,34 @@ Default release flow: ```bash just check just release 0.x.y +# wait for the GitHub release and all four binary assets to exist +just update-latest-json 0.x.y ``` `just release 0.x.y` prepares the changelog entry, bumps `Cargo.toml`, runs tests, commits, tags, and pushes. GitHub Actions builds the binaries after the tag is pushed. + +`just update-latest-json 0.x.y` is the post-release website step. It uses `gh release view` against `ogulcancelik/herdr`, verifies the published release exists, rejects draft or prerelease releases, requires a non-empty release body, requires these four assets to exist, and only then rewrites `website/latest.json`: + +- `herdr-linux-x86_64` +- `herdr-linux-aarch64` +- `herdr-macos-x86_64` +- `herdr-macos-aarch64` + +It also refuses to run if `website/latest.json` is already at the same or newer version. It leaves the file unstaged and prints the next commands to review, commit, and push. + +`website/latest.json` is the shipped updater source of truth. Keep its schema aligned with `src/update.rs`: + +```json +{ + "version": "0.x.y", + "notes": "### ...", + "assets": { + "linux-x86_64": "...", + "linux-aarch64": "...", + "macos-x86_64": "...", + "macos-aarch64": "..." + } +} +``` + +The app update check and the in-app **What's New** flow both depend on that exact manifest shape. @@ -42,6 +42,10 @@ release version: git push --follow-tags @echo "v{{version}} released — GitHub Actions building binaries" +# Update website/latest.json from a published GitHub release (usage: just update-latest-json 0.1.1) +update-latest-json version: + python3 scripts/changelog.py sync-latest-json --version {{version}} --output website/latest.json + # Print default config default-config: cargo run --release -- --default-config diff --git a/scripts/changelog.py b/scripts/changelog.py index 5a87d465..97bbd752 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -2,14 +2,26 @@ from __future__ import annotations import argparse +import json import re +import subprocess import sys from dataclasses import dataclass from datetime import date from pathlib import Path +from typing import Any SECTION_RE = re.compile(r"^##\s+(?:\[(?P<bracketed>[^\]]+)\]|(?P<plain>.+?))\s*$", re.MULTILINE) VERSION_WITH_DATE_RE = re.compile(r"^(?P<version>.+?)\s+-\s+\d{4}-\d{2}-\d{2}$") +DEFAULT_RELEASE_REPO = "ogulcancelik/herdr" +DEFAULT_LATEST_JSON_PATH = Path("website/latest.json") +ASSET_TARGETS = ( + "linux-x86_64", + "linux-aarch64", + "macos-x86_64", + "macos-aarch64", +) +EXPECTED_ASSET_NAMES = {target: f"herdr-{target}" for target in ASSET_TARGETS} @dataclass(frozen=True) @@ -34,6 +46,21 @@ def normalize_title(raw_title: str) -> str: return title +def normalize_version(version: str) -> str: + return version.strip().removeprefix("v") + + +def parse_version(version: str) -> tuple[int, int, int]: + normalized = normalize_version(version) + parts = normalized.split(".") + if len(parts) != 3: + raise ChangelogError(f"invalid version: {version}") + try: + return tuple(int(part) for part in parts) # type: ignore[return-value] + except ValueError as exc: + raise ChangelogError(f"invalid version: {version}") from exc + + def parse_sections(text: str) -> list[Section]: matches = list(SECTION_RE.finditer(text)) sections: list[Section] = [] @@ -95,28 +122,169 @@ def prepare_release(text: str, version: str, release_date: str) -> str: return rebuilt + "\n" +def build_latest_json(version: str, notes: str, assets: dict[str, str]) -> str: + normalized_version = normalize_version(version) + normalized_notes = notes.strip() + if not normalized_notes: + raise ChangelogError("release notes are empty") + + missing_targets = [target for target in ASSET_TARGETS if target not in assets] + if missing_targets: + raise ChangelogError(f"missing asset targets: {', '.join(missing_targets)}") + + ordered_assets = {target: assets[target] for target in ASSET_TARGETS} + + return json.dumps( + { + "version": normalized_version, + "notes": normalized_notes, + "assets": ordered_assets, + }, + indent=2, + ) + "\n" + + +def default_release_assets(version: str, repo: str = DEFAULT_RELEASE_REPO) -> dict[str, str]: + normalized_version = normalize_version(version) + tag = f"v{normalized_version}" + return { + target: f"https://github.com/{repo}/releases/download/{tag}/{EXPECTED_ASSET_NAMES[target]}" + for target in ASSET_TARGETS + } + + +def manifest_from_release_payload(payload: dict[str, Any], version: str) -> dict[str, Any]: + normalized_version = normalize_version(version) + tag_name = str(payload.get("tagName") or "") + if normalize_version(tag_name) != normalized_version: + raise ChangelogError( + f"GitHub release tag mismatch: expected v{normalized_version}, got {tag_name or '<missing>'}" + ) + if payload.get("isDraft"): + raise ChangelogError(f"GitHub release v{normalized_version} is still a draft") + if payload.get("isPrerelease"): + raise ChangelogError(f"GitHub release v{normalized_version} is a prerelease") + + notes = str(payload.get("body") or "").strip() + if not notes: + raise ChangelogError(f"GitHub release v{normalized_version} has empty release notes") + + assets_list = payload.get("assets") + if not isinstance(assets_list, list): + raise ChangelogError("GitHub release response is missing assets") + + release_assets: dict[str, Any] = {} + for asset in assets_list: + if isinstance(asset, dict): + name = asset.get("name") + if isinstance(name, str) and name not in release_assets: + release_assets[name] = asset + + manifest_assets: dict[str, str] = {} + for target, asset_name in EXPECTED_ASSET_NAMES.items(): + asset = release_assets.get(asset_name) + if not isinstance(asset, dict): + raise ChangelogError(f"GitHub release v{normalized_version} is missing asset {asset_name}") + url = str(asset.get("url") or "").strip() + if not url: + raise ChangelogError(f"GitHub release asset {asset_name} is missing a download URL") + manifest_assets[target] = url + + return { + "version": normalized_version, + "notes": notes, + "assets": manifest_assets, + } + + def load_text(path: Path) -> str: try: return path.read_text(encoding="utf-8") except FileNotFoundError as exc: - raise ChangelogError(f"changelog not found: {path}") from exc + raise ChangelogError(f"file not found: {path}") from exc + + +def load_json(path: Path) -> dict[str, Any]: + try: + content = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise ChangelogError(f"file not found: {path}") from exc + + try: + data = json.loads(content) + except json.JSONDecodeError as exc: + raise ChangelogError(f"invalid JSON in {path}: {exc}") from exc + + if not isinstance(data, dict): + raise ChangelogError(f"expected JSON object in {path}") + return data def write_text(path: Path, text: str) -> None: path.write_text(text, encoding="utf-8") +def fetch_release_payload(version: str, repo: str) -> dict[str, Any]: + normalized_version = normalize_version(version) + command = [ + "gh", + "release", + "view", + f"v{normalized_version}", + "--repo", + repo, + "--json", + "tagName,isDraft,isPrerelease,body,assets", + ] + result = subprocess.run(command, capture_output=True, text=True, check=False) + if result.returncode != 0: + stderr = result.stderr.strip() or result.stdout.strip() or "unknown gh error" + raise ChangelogError(f"failed to read GitHub release v{normalized_version}: {stderr}") + + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise ChangelogError(f"invalid JSON from gh release view: {exc}") from exc + + if not isinstance(payload, dict): + raise ChangelogError("unexpected GitHub release payload shape") + return payload + + +def ensure_manifest_is_outdated(current_manifest: dict[str, Any], version: str) -> None: + current_version = current_manifest.get("version") + if not isinstance(current_version, str): + raise ChangelogError("website/latest.json is missing a string version") + + if parse_version(current_version) >= parse_version(version): + raise ChangelogError( + f"website/latest.json is already at v{normalize_version(current_version)}; expected something older than v{normalize_version(version)}" + ) + + +def git_status_lines(path: Path) -> list[str]: + result = subprocess.run( + ["git", "status", "--short", "--", str(path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return [] + return [line for line in result.stdout.splitlines() if line.strip()] + + def cmd_prepare(args: argparse.Namespace) -> int: path = Path(args.path) original = load_text(path) - updated = prepare_release(original, args.version, args.date) + updated = prepare_release(original, normalize_version(args.version), args.date) write_text(path, updated) return 0 def cmd_extract(args: argparse.Namespace) -> int: path = Path(args.path) - body = extract_section_body(load_text(path), args.version) + body = extract_section_body(load_text(path), normalize_version(args.version)) if args.output: write_text(Path(args.output), body) else: @@ -124,6 +292,35 @@ def cmd_extract(args: argparse.Namespace) -> int: return 0 +def cmd_sync_latest_json(args: argparse.Namespace) -> int: + manifest_path = Path(args.output) + version = normalize_version(args.version) + + current_manifest = load_json(manifest_path) + ensure_manifest_is_outdated(current_manifest, version) + + release_payload = fetch_release_payload(version, args.repo) + new_manifest = manifest_from_release_payload(release_payload, version) + output = build_latest_json(version, str(new_manifest["notes"]), dict(new_manifest["assets"])) + write_text(manifest_path, output) + + print(f"updated {manifest_path} from GitHub release v{version}") + status_lines = git_status_lines(manifest_path) + print("files changed:") + if status_lines: + for line in status_lines: + print(f" {line}") + else: + print(f" (no git status output for {manifest_path})") + + print("next:") + print(f" git diff -- {manifest_path}") + print(f" git add {manifest_path}") + print(f" git commit -m \"docs: update website manifest for v{version}\"") + print(" git push") + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Prepare and extract changelog release notes") subparsers = parser.add_subparsers(dest="command", required=True) @@ -140,6 +337,15 @@ def build_parser() -> argparse.ArgumentParser: extract.add_argument("--output") extract.set_defaults(func=cmd_extract) + sync_latest_json = subparsers.add_parser( + "sync-latest-json", + help="Update website/latest.json from a published GitHub release", + ) + sync_latest_json.add_argument("--version", required=True) + sync_latest_json.add_argument("--repo", default=DEFAULT_RELEASE_REPO) + sync_latest_json.add_argument("--output", default=str(DEFAULT_LATEST_JSON_PATH)) + sync_latest_json.set_defaults(func=cmd_sync_latest_json) + return parser diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index cf9e23ac..6821dc38 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -1,8 +1,17 @@ from __future__ import annotations +import json import unittest -from scripts.changelog import extract_section_body, prepare_release +from scripts.changelog import ( + ChangelogError, + build_latest_json, + default_release_assets, + ensure_manifest_is_outdated, + extract_section_body, + manifest_from_release_payload, + prepare_release, +) class ChangelogScriptTests(unittest.TestCase): @@ -30,6 +39,96 @@ class ChangelogScriptTests(unittest.TestCase): self.assertEqual(body, "### Fixed\n- Smoothed Claude flapping.\n") + def test_build_latest_json_trims_notes(self) -> None: + manifest = json.loads( + build_latest_json( + "0.1.1", + "\n### Fixed\n- One\n\n", + default_release_assets("0.1.1"), + ) + ) + + self.assertEqual(manifest["notes"], "### Fixed\n- One") + + def test_build_latest_json_embeds_notes_and_release_assets(self) -> None: + manifest = json.loads( + build_latest_json( + "v0.1.1", + "### Fixed\n- Smoothed Claude flapping.\n", + default_release_assets("0.1.1"), + ) + ) + + self.assertEqual(manifest["version"], "0.1.1") + self.assertEqual(manifest["notes"], "### Fixed\n- Smoothed Claude flapping.") + self.assertEqual( + manifest["assets"], + { + "linux-x86_64": "https://github.com/ogulcancelik/herdr/releases/download/v0.1.1/herdr-linux-x86_64", + "linux-aarch64": "https://github.com/ogulcancelik/herdr/releases/download/v0.1.1/herdr-linux-aarch64", + "macos-x86_64": "https://github.com/ogulcancelik/herdr/releases/download/v0.1.1/herdr-macos-x86_64", + "macos-aarch64": "https://github.com/ogulcancelik/herdr/releases/download/v0.1.1/herdr-macos-aarch64", + }, + ) + + def test_manifest_from_release_payload_uses_release_body_and_asset_urls(self) -> None: + manifest = manifest_from_release_payload( + { + "tagName": "v0.1.1", + "isDraft": False, + "isPrerelease": False, + "body": "### Fixed\n- One\n", + "assets": [ + {"name": "herdr-linux-x86_64", "url": "https://example.com/linux-x86_64"}, + {"name": "herdr-linux-aarch64", "url": "https://example.com/linux-aarch64"}, + {"name": "herdr-macos-x86_64", "url": "https://example.com/macos-x86_64"}, + {"name": "herdr-macos-aarch64", "url": "https://example.com/macos-aarch64"}, + ], + }, + "0.1.1", + ) + + self.assertEqual( + manifest, + { + "version": "0.1.1", + "notes": "### Fixed\n- One", + "assets": { + "linux-x86_64": "https://example.com/linux-x86_64", + "linux-aarch64": "https://example.com/linux-aarch64", + "macos-x86_64": "https://example.com/macos-x86_64", + "macos-aarch64": "https://example.com/macos-aarch64", + }, + }, + ) + + def test_manifest_from_release_payload_rejects_missing_asset(self) -> None: + with self.assertRaisesRegex(ChangelogError, "missing asset herdr-macos-aarch64"): + manifest_from_release_payload( + { + "tagName": "v0.1.1", + "isDraft": False, + "isPrerelease": False, + "body": "### Fixed\n- One\n", + "assets": [ + {"name": "herdr-linux-x86_64", "url": "https://example.com/linux-x86_64"}, + {"name": "herdr-linux-aarch64", "url": "https://example.com/linux-aarch64"}, + {"name": "herdr-macos-x86_64", "url": "https://example.com/macos-x86_64"}, + ], + }, + "0.1.1", + ) + + def test_ensure_manifest_is_outdated_rejects_same_or_newer_version(self) -> None: + with self.assertRaisesRegex(ChangelogError, "already at v0.1.1"): + ensure_manifest_is_outdated({"version": "0.1.1"}, "0.1.1") + + with self.assertRaisesRegex(ChangelogError, "already at v0.1.2"): + ensure_manifest_is_outdated({"version": "0.1.2"}, "0.1.1") + + def test_ensure_manifest_is_outdated_allows_older_version(self) -> None: + ensure_manifest_is_outdated({"version": "0.1.0"}, "0.1.1") + if __name__ == "__main__": unittest.main() diff --git a/src/update.rs b/src/update.rs index bcc3c2b9..4be993dc 100644 --- a/src/update.rs +++ b/src/update.rs @@ -65,10 +65,7 @@ impl std::fmt::Display for Version { #[derive(Deserialize)] struct UpdateManifest { version: String, - #[serde(default)] notes: String, - #[serde(default)] - notes_url: String, assets: BTreeMap<String, String>, } @@ -78,17 +75,7 @@ impl UpdateManifest { } fn notes_body(&self) -> String { - let notes = self.notes.trim(); - if !notes.is_empty() { - return notes.to_string(); - } - - let notes_url = self.notes_url.trim(); - if notes_url.is_empty() { - return String::new(); - } - - format!("### Release notes\n- {notes_url}") + self.notes.trim().to_string() } } @@ -135,6 +122,11 @@ fn check_latest() -> Result<Option<ReleaseInfo>, String> { return Ok(None); // up to date } + let notes_body = manifest.notes_body(); + if notes_body.is_empty() { + return Err("update manifest notes are empty".into()); + } + let (os, arch) = platform_target(); let download_url = manifest .download_url_for(os, arch) @@ -143,7 +135,7 @@ fn check_latest() -> Result<Option<ReleaseInfo>, String> { Ok(Some(ReleaseInfo { version: latest, download_url, - notes_body: manifest.notes_body(), + notes_body, })) } @@ -401,17 +393,18 @@ mod tests { #[test] fn update_manifest_deserializes() { - let json = r#"{ - "version": "0.2.0", - "notes_url": "https://example.com/releases/v0.2.0", - "assets": { - "linux-x86_64": "https://example.com/herdr-linux-x86_64", - "macos-aarch64": "https://example.com/herdr-macos-aarch64" - } - }"#; + let json = "{\n\ + \"version\": \"0.2.0\",\n\ + \"notes\": \"### Changed\\n- One\",\n\ + \"assets\": {\n\ + \"linux-x86_64\": \"https://example.com/herdr-linux-x86_64\",\n\ + \"macos-aarch64\": \"https://example.com/herdr-macos-aarch64\"\n\ + }\n\ + }"; let manifest: UpdateManifest = serde_json::from_str(json).unwrap(); assert_eq!(manifest.version, "0.2.0"); assert_eq!(manifest.assets.len(), 2); + assert_eq!(manifest.notes_body(), "### Changed\n- One"); assert_eq!( manifest.download_url_for("linux", "x86_64").as_deref(), Some("https://example.com/herdr-linux-x86_64") @@ -419,29 +412,43 @@ mod tests { } #[test] - fn update_manifest_prefers_embedded_notes() { - let manifest = UpdateManifest { - version: "0.2.0".into(), - notes: "### Changed\n- One".into(), - notes_url: "https://example.com/releases/v0.2.0".into(), - assets: BTreeMap::new(), - }; + fn update_manifest_requires_notes_field() { + let json = r#"{ + "version": "0.2.0", + "assets": { + "linux-x86_64": "https://example.com/herdr-linux-x86_64" + } + }"#; - assert_eq!(manifest.notes_body(), "### Changed\n- One"); + assert!(serde_json::from_str::<UpdateManifest>(json).is_err()); } #[test] - fn update_manifest_falls_back_to_notes_url() { - let manifest = UpdateManifest { - version: "0.2.0".into(), - notes: String::new(), - notes_url: "https://example.com/releases/v0.2.0".into(), - assets: BTreeMap::new(), - }; - - assert_eq!( - manifest.notes_body(), - "### Release notes\n- https://example.com/releases/v0.2.0" - ); + fn checked_in_website_manifest_matches_update_schema() { + let manifest: UpdateManifest = serde_json::from_str(include_str!("../website/latest.json")) + .expect("website/latest.json should match updater schema"); + + assert!(!manifest.notes_body().is_empty()); + assert_eq!(manifest.assets.len(), 4); + + for target in [ + "linux-x86_64", + "linux-aarch64", + "macos-x86_64", + "macos-aarch64", + ] { + let url = manifest + .assets + .get(target) + .unwrap_or_else(|| panic!("missing asset URL for {target}")); + assert!( + url.contains(&format!("/releases/download/v{}/", manifest.version)), + "unexpected release URL for {target}: {url}" + ); + assert!( + url.ends_with(&format!("herdr-{target}")), + "unexpected asset name for {target}: {url}" + ); + } } } diff --git a/website/latest.json b/website/latest.json index 19498dee..67690fe4 100644 --- a/website/latest.json +++ b/website/latest.json @@ -1,7 +1,6 @@ { "version": "0.4.2", "notes": "### Added\n- The expanded sidebar agent panel can now switch between the current workspace and all workspaces, so you can scan and jump to agents across the whole session.\n- The collapsed sidebar now shows compact per-pane agent indicators, so you can keep an eye on agent activity without reopening the full sidebar.\n\n### Changed\n- The sidebar now handles larger workspace sets more cleanly: the workspace section has headers, its own scrolling, better-aligned drag/drop slots, and manual width changes persist across restarts. Double-clicking the divider resets it to the configured default width.\n- Pane scrollback is now configured with `advanced.scrollback_limit_bytes`, matching Ghostty's byte-based scrollback limit. Set it to `0` to disable pane scrollback entirely. The old `advanced.scrollback_lines` key is still accepted as an alias, but it now uses the same byte-based value.\n- Linux release binaries now ship with libghostty SIMD enabled again without reintroducing the musl startup issue, restoring the optimized Linux build path.\n\n### Fixed\n- Typing in pane terminals on macOS is responsive again after the Ghostty migration, by keeping a persistent per-pane Ghostty key encoder instead of rebuilding it on every keypress.\n- The collapsed sidebar expand toggle works again.\n- Creating a new tab now waits until you confirm the dialog, so cancelling the new-tab flow no longer leaves behind an unwanted tab.\n- Copying selected pane text now uses Ghostty's native selection extraction, which preserves wrapped text and wide characters more accurately.\n- Session restore is more tolerant of older and current snapshot formats, including pre-tab session files.", - "notes_url": "https://github.com/ogulcancelik/herdr/releases/tag/v0.4.2", "assets": { "linux-x86_64": "https://github.com/ogulcancelik/herdr/releases/download/v0.4.2/herdr-linux-x86_64", "linux-aarch64": "https://github.com/ogulcancelik/herdr/releases/download/v0.4.2/herdr-linux-aarch64", |
