aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md28
-rw-r--r--justfile4
-rw-r--r--scripts/changelog.py212
-rw-r--r--scripts/test_changelog.py101
-rw-r--r--src/update.rs93
-rw-r--r--website/latest.json1
6 files changed, 391 insertions, 48 deletions
diff --git a/AGENTS.md b/AGENTS.md
index df94c1b2..7ae8b983 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/justfile b/justfile
index 505217ff..f04269e6 100644
--- a/justfile
+++ b/justfile
@@ -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",