diff --git a/.agents/skills/release_updates/SKILL.md b/.agents/skills/release_updates/SKILL.md
new file mode 100644
index 00000000..c14b7d63
--- /dev/null
+++ b/.agents/skills/release_updates/SKILL.md
@@ -0,0 +1,231 @@
+---
+name: release_updates
+description: >-
+ Run weekly release docs updates with standalone scripts for changelog,
+ licenses, and telemetry, plus Linux/Oz Warp artifact preparation. Defaults to
+ running all tasks in order, and supports running only selected tasks.
+---
+
+# Release updates
+
+Use this skill to update docs for weekly releases.
+
+The scripts are designed for Oz cloud runs (Linux) and local testing.
+They support the following:
+
+- docs repo checkouts in different locations
+ (`/docs`, sibling repo, current repo)
+- optional channel-versions repo checkouts
+ (`/channel-versions`, sibling repo)
+- running one task or all tasks in the required order
+
+## Environment requirements (Oz cloud)
+
+### Required
+
+- **Repo**: docs repo (this repo) containing the `release_updates` skill.
+- **Runtime**: glibc-based Linux image (Debian/Ubuntu-style image recommended).
+- **Commands**: `python3`, `git`.
+- **Network access**: `releases.warp.dev` (channel versions fallback) and
+ `app.warp.dev` (Warp AppImage download).
+
+### Required for PR mode
+
+- **Command**: `gh` CLI
+- **Auth**: `gh auth status` must be healthy in the run environment.
+- **GitHub repo write access** for branch push + PR create/update.
+
+### Required for on-call reviewer assignment
+
+- Resolver script path (default):
+ `.agents/skills/release_updates/scripts/resolve_oncall_reviewers.py`
+- `GRAFANA_API_KEY` environment variable.
+
+### Recommended
+
+- Local checkout of `warpdotdev/channel-versions` so changelog updates read local
+ `channel_versions.json` instead of URL fallback.
+
+## Bootstrap/check the environment
+
+Use this helper script before running release updates:
+
+```bash
+python3 .agents/skills/release_updates/scripts/setup_environment.py \
+ --docs-repo /docs \
+ --clone-channel-versions-if-missing \
+ --require-pr-flow
+```
+
+If you also want automatic reviewer assignment checks:
+
+```bash
+python3 .agents/skills/release_updates/scripts/setup_environment.py \
+ --docs-repo /docs \
+ --clone-channel-versions-if-missing \
+ --require-pr-flow \
+ --require-oncall-reviewer
+```
+
+## Scripts
+
+All scripts are in `.agents/skills/release_updates/scripts/`:
+- `setup_environment.py` - Validate/prepare repos, CLI auth, and reviewer
+ assignment prerequisites before release runs
+- `resolve_oncall_reviewers.py` - Resolve primary/secondary Grafana on-call
+ users to GitHub reviewers
+- `update_warp_app.py` - Download latest stable + preview Linux AppImages and
+ build a manifest for downstream tasks. On Linux, it preflights
+ `libasound.so.2` before telemetry usage.
+- `update_changelog.py` - Incrementally update
+ `src/content/docs/changelog/{year}.mdx` from channel versions
+- `update_licenses.py` - Regenerate
+ `src/content/docs/support-and-community/community/open-source-licenses.mdx`
+- `update_telemetry.py` - Regenerate
+ `src/content/docs/support-and-community/privacy-and-security/privacy.mdx`
+ telemetry table
+- `run_release_updates.py` - Orchestrates selected tasks (defaults to all, in
+ order)
+
+## Default workflow (all tasks, ordered)
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py
+```
+
+Default order:
+
+1. `warp_app_update`
+2. `changelog`
+3. `licenses`
+4. `telemetry`
+
+## Run only selected tasks
+
+Changelog-only (useful while rolling out incrementally):
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py \
+ --tasks changelog
+```
+
+Specific subset:
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py \
+ --tasks warp_app_update changelog
+```
+
+## Useful options
+
+### Local testing
+
+On non-Linux machines, skip AppImage extraction:
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py \
+ --skip-warp-app-extract \
+ --tasks changelog
+```
+
+On Linux/Oz, let `warp_app_update` auto-install a missing ALSA runtime package:
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py \
+ --tasks warp_app_update \
+ --auto-install-missing-dependency
+```
+
+If your environment already guarantees dependencies, you can skip the check:
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py \
+ --tasks warp_app_update \
+ --skip-dependency-preflight
+```
+
+Dry run:
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py --dry-run
+```
+
+### Create or update a PR at the end
+
+`run_release_updates.py` can commit generated changes, push the branch, and
+create/update a PR automatically:
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py \
+ --create-pr \
+ --pr-base main
+```
+
+You can customize commit/PR metadata:
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py \
+ --create-pr \
+ --commit-message "docs: weekly release updates" \
+ --pr-title "docs: weekly release updates" \
+ --pr-body-file /tmp/release-pr-body.md
+```
+
+### Assign primary and secondary client on-call as reviewers (Grafana schedules)
+
+To resolve and assign both reviewers automatically, pass both schedules:
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py \
+ --create-pr \
+ --assign-oncall-reviewer \
+ --oncall-schedule-id CLIENT_PRIMARY_SCHEDULE_ID \
+ --oncall-schedule-id CLIENT_SECONDARY_SCHEDULE_ID
+```
+
+Notes:
+
+- Requires `GRAFANA_API_KEY` in the environment.
+- Uses resolver script (by default):
+ `.agents/skills/release_updates/scripts/resolve_oncall_reviewers.py`
+- Repeat `--oncall-schedule-id` to resolve one reviewer per schedule, in order.
+- Override with `--oncall-resolver-script` if needed.
+
+To verify reviewer resolution without mutating PR assignments:
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py \
+ --tasks changelog \
+ --create-pr \
+ --assign-oncall-reviewer \
+ --oncall-schedule-id CLIENT_PRIMARY_SCHEDULE_ID \
+ --oncall-schedule-id CLIENT_SECONDARY_SCHEDULE_ID \
+ --dry-run
+```
+
+### Explicit repo paths
+
+If auto-detection is not enough:
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py \
+ --docs-repo /docs \
+ --channel-versions-repo /channel-versions
+```
+
+Or point directly to a specific channel versions file:
+
+```bash
+python3 .agents/skills/release_updates/scripts/run_release_updates.py \
+ --channel-versions-file /channel-versions/channel_versions.json
+```
+
+## Artifact handoff between scripts
+
+`update_warp_app.py` writes a manifest at:
+
+`/tmp/release-updates/warp_artifacts.json` (by default)
+
+`update_licenses.py` and `update_telemetry.py` read that manifest unless
+explicit input paths are provided.
+
diff --git a/.agents/skills/release_updates/scripts/common.py b/.agents/skills/release_updates/scripts/common.py
new file mode 100644
index 00000000..e580a1f0
--- /dev/null
+++ b/.agents/skills/release_updates/scripts/common.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import sys
+from datetime import datetime
+from datetime import timezone
+from pathlib import Path
+from typing import Any
+from urllib.request import Request
+from urllib.request import urlopen
+
+USER_AGENT = "Mozilla/5.0 (release-updates-skill)"
+DEFAULT_WORK_DIR = Path("/tmp/release-updates")
+DEFAULT_ONCALL_RESOLVER_SCRIPT = (
+ Path(__file__).resolve().parent / "resolve_oncall_reviewers.py"
+).resolve()
+
+
+def eprint(message: str) -> None:
+ print(message, file=sys.stderr)
+
+
+def utc_now_iso() -> str:
+ return datetime.now(tz=timezone.utc).replace(microsecond=0).isoformat()
+
+
+def script_dir() -> Path:
+ return Path(__file__).resolve().parent
+
+
+def docs_repo_root(explicit_docs_repo: str | None = None) -> Path:
+ if explicit_docs_repo:
+ docs_root = Path(explicit_docs_repo).expanduser().resolve()
+ if not docs_root.exists():
+ raise FileNotFoundError(
+ f"--docs-repo path does not exist: {docs_root}",
+ )
+ return docs_root
+
+ for parent in Path(__file__).resolve().parents:
+ if (parent / "package.json").exists() and (parent / "src/content/docs").exists():
+ return parent
+
+ # Fallback for unexpected layout:
+ return Path(__file__).resolve().parents[4]
+
+
+def ensure_parent_dir(path: Path) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+
+def read_json_file(path: Path) -> dict[str, Any]:
+ with path.open("r", encoding="utf-8") as handle:
+ data = json.load(handle)
+ if not isinstance(data, dict):
+ raise ValueError(f"Expected a JSON object at {path}")
+ return data
+
+
+def write_json_file(path: Path, payload: dict[str, Any]) -> None:
+ ensure_parent_dir(path=path)
+ with path.open("w", encoding="utf-8") as handle:
+ json.dump(payload, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+
+
+def load_json_from_url(url: str) -> dict[str, Any]:
+ request = Request(url=url, headers={"User-Agent": USER_AGENT})
+ with urlopen(request, timeout=60) as response: # nosec B310
+ payload = json.load(response)
+ if not isinstance(payload, dict):
+ raise ValueError(f"Expected JSON object from {url}")
+ return payload
+
+
+def resolve_channel_versions_file(
+ docs_root: Path,
+ explicit_file: str | None = None,
+ explicit_repo: str | None = None,
+) -> Path | None:
+ candidates: list[Path] = []
+
+ if explicit_file:
+ candidates.append(Path(explicit_file).expanduser().resolve())
+
+ if explicit_repo:
+ repo_path = Path(explicit_repo).expanduser().resolve()
+ candidates.extend(
+ [
+ repo_path / "channel_versions.json",
+ repo_path / "channel-versions" / "channel_versions.json",
+ ],
+ )
+
+ candidates.extend(
+ [
+ docs_root / "channel_versions.json",
+ docs_root / "channel-versions" / "channel_versions.json",
+ docs_root.parent / "channel-versions" / "channel_versions.json",
+ docs_root.parent / "channel_versions" / "channel_versions.json",
+ Path("/channel-versions/channel_versions.json"),
+ Path("/channel_versions/channel_versions.json"),
+ docs_root.parent.parent / "channel-versions" / "channel_versions.json",
+ docs_root.parent.parent / "channel_versions" / "channel_versions.json",
+ docs_root.parent / "src/channel-versions/channel_versions.json",
+ ],
+ )
+
+ seen: set[Path] = set()
+ for candidate in candidates:
+ resolved = candidate.resolve()
+ if resolved in seen:
+ continue
+ seen.add(resolved)
+ if resolved.exists():
+ return resolved
+ return None
+
+
+def sanitize_table_cell(value: str) -> str:
+ return value.replace("|", "\\|").replace("\n", "
").strip()
+
diff --git a/.agents/skills/release_updates/scripts/resolve_oncall_reviewers.py b/.agents/skills/release_updates/scripts/resolve_oncall_reviewers.py
new file mode 100644
index 00000000..3d43258f
--- /dev/null
+++ b/.agents/skills/release_updates/scripts/resolve_oncall_reviewers.py
@@ -0,0 +1,399 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+import urllib.request
+from typing import Any
+
+
+def chunks(value: str) -> list[str]:
+ return [chunk.lower() for chunk in re.findall(r"[a-zA-Z0-9]+", value)]
+
+
+def contains_all_chunks(haystack: str, needle_chunks: list[str]) -> bool:
+ normalized_haystack = haystack.lower()
+ return all(chunk in normalized_haystack for chunk in needle_chunks)
+
+
+def chunks_equal(left: list[str], right: list[str]) -> bool:
+ return set(left) == set(right)
+
+
+def _load_email_to_github_overrides() -> dict[str, str]:
+ raw_value = os.environ.get("ONCALL_EMAIL_TO_GITHUB_OVERRIDES")
+ if not raw_value:
+ return {}
+
+ try:
+ payload = json.loads(raw_value)
+ except json.JSONDecodeError:
+ print(
+ "Warning: ONCALL_EMAIL_TO_GITHUB_OVERRIDES must be valid JSON; "
+ "ignoring overrides.",
+ file=sys.stderr,
+ )
+ return {}
+
+ if not isinstance(payload, dict):
+ print(
+ "Warning: ONCALL_EMAIL_TO_GITHUB_OVERRIDES must be a JSON object "
+ "mapping email -> GitHub login; ignoring overrides.",
+ file=sys.stderr,
+ )
+ return {}
+
+ overrides: dict[str, str] = {}
+ for email, login in payload.items():
+ normalized_email = str(email).strip().lower()
+ normalized_login = str(login).strip()
+ if normalized_email and normalized_login:
+ overrides[normalized_email] = normalized_login
+ return overrides
+
+
+def _normalize_username(username: str) -> str:
+ if "@" in username:
+ return username.split("@", 1)[0]
+ return username
+
+
+def matches_member(
+ *,
+ gh_login: str,
+ gh_name: str,
+ grafana_email_local: str,
+ grafana_username: str,
+) -> bool:
+ login = gh_login.lower()
+ name = gh_name.lower()
+ local = grafana_email_local.lower()
+ user = _normalize_username(grafana_username).lower()
+
+ if local and (local in login or local in name):
+ return True
+ if user and (user in login or user in name):
+ return True
+
+ if login and (login in local or (user and login in user)):
+ return True
+
+ login_chunks = chunks(gh_login)
+ local_chunks = chunks(grafana_email_local)
+ user_chunks = chunks(user)
+
+ if not login_chunks:
+ return False
+
+ if local_chunks and contains_all_chunks(login, local_chunks):
+ return True
+ if user_chunks and contains_all_chunks(login, user_chunks):
+ return True
+
+ if local and contains_all_chunks(local, login_chunks):
+ return True
+ if user and contains_all_chunks(user, login_chunks):
+ return True
+
+ if local_chunks and chunks_equal(login_chunks, local_chunks):
+ return True
+ if user_chunks and chunks_equal(login_chunks, user_chunks):
+ return True
+
+ return False
+
+
+def _run_command(command: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
+ result = subprocess.run( # nosec B603
+ command,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ if check and result.returncode != 0:
+ stderr_or_stdout = result.stderr.strip() or result.stdout.strip()
+ raise RuntimeError(
+ "Command failed "
+ f"({' '.join(command)}):\n"
+ f"{stderr_or_stdout}",
+ )
+ return result
+
+
+def get_oncall_users(
+ *,
+ schedule_id: str,
+ api_url: str,
+ grafana_url: str,
+ api_key: str,
+) -> list[dict[str, str]]:
+ url = f"{api_url}/api/v1/schedules/{schedule_id}/current_oncall/"
+ request = urllib.request.Request(
+ url=url,
+ headers={
+ "Authorization": api_key,
+ "X-Grafana-URL": grafana_url,
+ },
+ )
+ with urllib.request.urlopen(request) as response: # nosec B310
+ payload = json.loads(response.read())
+
+ users = payload.get("users", [])
+ if not isinstance(users, list):
+ return []
+
+ normalized: list[dict[str, str]] = []
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ email = str(user.get("email", "")).strip().lower()
+ username = str(user.get("username", "")).strip()
+ if email or username:
+ normalized.append(
+ {
+ "email": email,
+ "username": username,
+ },
+ )
+ return normalized
+
+
+def search_github_email(email: str) -> str | None:
+ result = _run_command(
+ [
+ "gh",
+ "api",
+ "-X",
+ "GET",
+ "/search/users",
+ "-f",
+ f"q={email} in:email",
+ "--jq",
+ ".items[0].login // empty",
+ ],
+ check=False,
+ )
+ if result.returncode != 0:
+ return None
+
+ login = result.stdout.strip()
+ return login or None
+
+
+def get_org_members(org: str) -> list[dict[str, Any]]:
+ query = (
+ "query($org:String!,$cursor:String){"
+ "organization(login:$org){"
+ "membersWithRole(first:100, after:$cursor){"
+ "nodes{login name}"
+ "pageInfo{hasNextPage endCursor}"
+ "}"
+ "}"
+ "}"
+ )
+
+ cursor: str | None = None
+ members: list[dict[str, Any]] = []
+ seen_logins: set[str] = set()
+ while True:
+ command = [
+ "gh",
+ "api",
+ "graphql",
+ "-f",
+ f"query={query}",
+ "-F",
+ f"org={org}",
+ "--jq",
+ ".data.organization.membersWithRole",
+ ]
+ if cursor is not None:
+ command.extend(["-F", f"cursor={cursor}"])
+
+ result = _run_command(command)
+ payload = json.loads(result.stdout)
+ if not isinstance(payload, dict):
+ break
+
+ nodes = payload.get("nodes", [])
+ if isinstance(nodes, list):
+ for item in nodes:
+ if not isinstance(item, dict):
+ continue
+ login = str(item.get("login", "")).strip()
+ if not login or login in seen_logins:
+ continue
+ seen_logins.add(login)
+ members.append(item)
+
+ page_info = payload.get("pageInfo", {})
+ if not isinstance(page_info, dict) or not page_info.get("hasNextPage"):
+ break
+
+ next_cursor = page_info.get("endCursor")
+ if not isinstance(next_cursor, str) or not next_cursor.strip():
+ break
+ cursor = next_cursor
+
+ return members
+
+
+def resolve_user_to_login(
+ *,
+ user: dict[str, str],
+ members: list[dict[str, Any]],
+ email_to_github_overrides: dict[str, str],
+) -> tuple[str | None, dict[str, Any] | None]:
+ email = user.get("email", "").lower()
+ username = user.get("username", "")
+ if email in email_to_github_overrides:
+ return email_to_github_overrides[email], None
+
+ if email:
+ from_email = search_github_email(email)
+ if from_email:
+ return from_email, None
+
+ email_local = email.split("@", 1)[0] if "@" in email else email
+ matched = sorted(
+ {
+ str(member.get("login", ""))
+ for member in members
+ if str(member.get("login", "")).strip()
+ and matches_member(
+ gh_login=str(member.get("login", "")),
+ gh_name=str(member.get("name", "") or ""),
+ grafana_email_local=email_local,
+ grafana_username=username,
+ )
+ },
+ )
+
+ if len(matched) == 1:
+ return matched[0], None
+
+ unresolved = {
+ "oncall_email": email,
+ "oncall_username": username,
+ "matched_candidates": matched,
+ }
+ return None, unresolved
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description=(
+ "Resolve current Grafana on-call users to GitHub reviewers. "
+ "By default returns up to two reviewers (primary + secondary)."
+ ),
+ )
+ parser.add_argument("schedule_id", help="Grafana IRM schedule ID")
+ parser.add_argument(
+ "--max-reviewers",
+ type=int,
+ default=2,
+ help="Maximum number of on-call users to resolve (default: 2).",
+ )
+ parser.add_argument(
+ "--oncall-api-url",
+ default=os.environ.get(
+ "ONCALL_API_URL",
+ "https://oncall-prod-us-central-0.grafana.net/oncall",
+ ),
+ help="Grafana OnCall API base URL",
+ )
+ parser.add_argument(
+ "--grafana-url",
+ default=os.environ.get("GRAFANA_URL", "https://warp.grafana.net"),
+ help="Grafana URL for X-Grafana-URL header",
+ )
+ parser.add_argument(
+ "--github-org",
+ default=os.environ.get("GITHUB_ORG", "warpdotdev"),
+ help="GitHub org used for fuzzy member matching",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ api_key = os.environ.get("GRAFANA_API_KEY")
+ if not api_key:
+ print("GRAFANA_API_KEY env var required", file=sys.stderr)
+ return 1
+
+ users = get_oncall_users(
+ schedule_id=args.schedule_id,
+ api_url=args.oncall_api_url,
+ grafana_url=args.grafana_url,
+ api_key=api_key,
+ )
+ if not users:
+ print(
+ f"no users currently on-call for schedule {args.schedule_id}",
+ file=sys.stderr,
+ )
+ return 1
+
+ max_reviewers = max(1, int(args.max_reviewers))
+ selected_users = users[:max_reviewers]
+ members = get_org_members(args.github_org)
+ email_to_github_overrides = _load_email_to_github_overrides()
+
+ reviewers: list[str] = []
+ unresolved_users: list[dict[str, Any]] = []
+ for user in selected_users:
+ reviewer, unresolved = resolve_user_to_login(
+ user=user,
+ members=members,
+ email_to_github_overrides=email_to_github_overrides,
+ )
+ if reviewer:
+ if reviewer not in reviewers:
+ reviewers.append(reviewer)
+ elif unresolved:
+ unresolved_users.append(unresolved)
+
+ if unresolved_users:
+ payload = {
+ "schedule_id": args.schedule_id,
+ "reviewers": reviewers,
+ "unresolved_users": unresolved_users,
+ "candidates": [
+ {
+ "login": str(member.get("login", "")),
+ "name": str(member.get("name", "") or ""),
+ }
+ for member in members
+ if str(member.get("login", "")).strip()
+ ],
+ }
+ print(json.dumps(payload, indent=2))
+ return 2
+
+ if not reviewers:
+ print(
+ "could not resolve any on-call users to GitHub reviewers",
+ file=sys.stderr,
+ )
+ return 1
+
+ print(
+ json.dumps(
+ {
+ "schedule_id": args.schedule_id,
+ "reviewers": reviewers,
+ "oncall_users": selected_users,
+ },
+ indent=2,
+ ),
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.agents/skills/release_updates/scripts/run_release_updates.py b/.agents/skills/release_updates/scripts/run_release_updates.py
new file mode 100644
index 00000000..df04c47b
--- /dev/null
+++ b/.agents/skills/release_updates/scripts/run_release_updates.py
@@ -0,0 +1,736 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+from datetime import datetime
+from datetime import timezone
+from pathlib import Path
+from typing import Sequence
+
+from common import DEFAULT_WORK_DIR
+from common import DEFAULT_ONCALL_RESOLVER_SCRIPT
+from common import docs_repo_root
+from common import eprint
+
+TASK_ORDER = ["warp_app_update", "changelog", "licenses", "telemetry"]
+COAUTHOR_LINE = "Co-Authored-By: Oz "
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Run release update scripts in order, with optional task selection.",
+ )
+ parser.add_argument(
+ "--tasks",
+ nargs="+",
+ choices=TASK_ORDER,
+ default=TASK_ORDER.copy(),
+ help="Subset of tasks to run (default: all tasks in order).",
+ )
+ parser.add_argument(
+ "--docs-repo",
+ default=None,
+ help="Path to docs repo root (auto-detected if omitted).",
+ )
+ parser.add_argument(
+ "--work-dir",
+ default=str(DEFAULT_WORK_DIR),
+ help=f"Working directory for intermediate artifacts (default: {DEFAULT_WORK_DIR})",
+ )
+ parser.add_argument(
+ "--manifest",
+ default=None,
+ help="Artifact manifest path (default: /warp_artifacts.json).",
+ )
+ parser.add_argument(
+ "--channel-versions-file",
+ default=None,
+ help="Path to channel_versions.json for changelog update.",
+ )
+ parser.add_argument(
+ "--channel-versions-repo",
+ default=None,
+ help="Path to channel-versions repo for changelog update.",
+ )
+ parser.add_argument(
+ "--channel-versions-url",
+ default=None,
+ help="Fallback URL for channel versions if no local file is found.",
+ )
+ parser.add_argument(
+ "--licenses-file",
+ default=None,
+ help="Explicit THIRD_PARTY_LICENSES.txt path for licenses update.",
+ )
+ parser.add_argument(
+ "--telemetry-json-file",
+ default=None,
+ help="Explicit telemetry JSON path for telemetry update.",
+ )
+ parser.add_argument(
+ "--telemetry-command",
+ default=None,
+ help="Explicit telemetry command string for telemetry update.",
+ )
+ parser.add_argument(
+ "--skip-warp-app-extract",
+ action="store_true",
+ help="Skip AppImage extraction in warp_app_update (useful for non-Linux local tests).",
+ )
+ parser.add_argument(
+ "--skip-dependency-preflight",
+ action="store_true",
+ help="Skip Linux telemetry dependency preflight in warp_app_update.",
+ )
+ parser.add_argument(
+ "--auto-install-missing-dependency",
+ action="store_true",
+ help=(
+ "If Linux telemetry dependency preflight fails, attempt package-manager "
+ "installation (for supported distros)."
+ ),
+ )
+ parser.add_argument(
+ "--create-pr",
+ action="store_true",
+ help=(
+ "After tasks finish, commit changes, push branch, and create or update a "
+ "pull request."
+ ),
+ )
+ parser.add_argument(
+ "--allow-dirty-repo",
+ action="store_true",
+ help=(
+ "Allow running with pre-existing uncommitted changes when --create-pr is set. "
+ "Use with caution."
+ ),
+ )
+ parser.add_argument(
+ "--skip-pr-commit",
+ action="store_true",
+ help="Skip creating a commit before PR creation (requires no uncommitted changes).",
+ )
+ parser.add_argument(
+ "--skip-pr-push",
+ action="store_true",
+ help="Skip pushing the branch before PR creation.",
+ )
+ parser.add_argument(
+ "--commit-message",
+ default=None,
+ help="Commit subject to use when --create-pr creates a commit.",
+ )
+ parser.add_argument(
+ "--pr-base",
+ default="main",
+ help="Base branch for PR create/update (default: main).",
+ )
+ parser.add_argument(
+ "--pr-title",
+ default=None,
+ help="Explicit PR title (defaults to a dated release-updates title).",
+ )
+ parser.add_argument(
+ "--pr-body-file",
+ default=None,
+ help="Path to PR body markdown file (defaults to autogenerated body).",
+ )
+ parser.add_argument(
+ "--pr-draft",
+ action="store_true",
+ help="Create pull requests as draft.",
+ )
+ parser.add_argument(
+ "--assign-oncall-reviewer",
+ "--assign-oncall-reviewers",
+ dest="assign_oncall_reviewers",
+ action="store_true",
+ help=(
+ "Resolve on-call reviewers (primary + secondary when available) and "
+ "assign them to the PR."
+ ),
+ )
+ parser.add_argument(
+ "--oncall-schedule-id",
+ dest="oncall_schedule_ids",
+ action="append",
+ default=[],
+ help=(
+ "Grafana IRM schedule ID used to resolve on-call reviewers. "
+ "Repeat this flag to resolve reviewers from multiple schedules "
+ "(for example, primary + secondary)."
+ ),
+ )
+ parser.add_argument(
+ "--oncall-max-reviewers",
+ type=int,
+ default=2,
+ help="Maximum number of on-call users to assign as reviewers (default: 2).",
+ )
+ parser.add_argument(
+ "--oncall-resolver-script",
+ default=str(DEFAULT_ONCALL_RESOLVER_SCRIPT),
+ help=(
+ "Path to on-call reviewer resolver helper "
+ f"(default: {DEFAULT_ONCALL_RESOLVER_SCRIPT})."
+ ),
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Run all scripts in dry-run mode.",
+ )
+ return parser.parse_args()
+
+
+def _run_script(script_path: Path, script_args: list[str]) -> None:
+ command = [sys.executable, str(script_path), *script_args]
+ eprint(f"Running: {' '.join(command)}")
+ subprocess.run(command, check=True) # nosec B603
+
+
+def _run_command(
+ command: list[str],
+ *,
+ cwd: Path,
+ check: bool = True,
+) -> subprocess.CompletedProcess[str]:
+ result = subprocess.run( # nosec B603
+ command,
+ cwd=cwd,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ if check and result.returncode != 0:
+ stderr_or_stdout = result.stderr.strip() or result.stdout.strip()
+ raise RuntimeError(
+ "Command failed "
+ f"({' '.join(command)}):\n"
+ f"{stderr_or_stdout}",
+ )
+ return result
+
+
+def _git_porcelain_status(repo_path: Path) -> list[str]:
+ result = _run_command(
+ ["git", "--no-pager", "status", "--porcelain"],
+ cwd=repo_path,
+ )
+ return [line for line in result.stdout.splitlines() if line.strip()]
+
+
+def _git_changed_files(repo_path: Path) -> list[str]:
+ changed_files: list[str] = []
+ for line in _git_porcelain_status(repo_path=repo_path):
+ if len(line) < 4:
+ continue
+ changed_files.append(line[3:].strip())
+ return changed_files
+
+
+def _git_current_branch(repo_path: Path) -> str:
+ result = _run_command(
+ ["git", "--no-pager", "rev-parse", "--abbrev-ref", "HEAD"],
+ cwd=repo_path,
+ )
+ branch = result.stdout.strip()
+ if not branch:
+ raise RuntimeError("Could not determine current git branch.")
+ return branch
+
+
+def _ahead_commit_count(repo_path: Path, base_branch: str) -> int:
+ base_ref = f"origin/{base_branch}"
+ fetch_result = _run_command(
+ ["git", "fetch", "origin", base_branch],
+ cwd=repo_path,
+ check=False,
+ )
+ if fetch_result.returncode != 0:
+ base_ref = base_branch
+
+ result = _run_command(
+ ["git", "--no-pager", "rev-list", "--left-right", "--count", f"{base_ref}...HEAD"],
+ cwd=repo_path,
+ )
+ counts = result.stdout.strip().split()
+ if len(counts) != 2:
+ raise RuntimeError(f"Unexpected rev-list output: {result.stdout.strip()}")
+ return int(counts[1])
+
+
+def _commit_all_changes(repo_path: Path, commit_message: str) -> None:
+ _run_command(["git", "add", "-A"], cwd=repo_path)
+ _run_command(
+ ["git", "commit", "-m", commit_message, "-m", COAUTHOR_LINE],
+ cwd=repo_path,
+ )
+
+
+def _push_branch(repo_path: Path, branch_name: str) -> None:
+ _run_command(
+ ["git", "push", "--set-upstream", "origin", branch_name],
+ cwd=repo_path,
+ )
+
+
+def _find_existing_pr_url(repo_path: Path) -> str | None:
+ result = _run_command(
+ ["gh", "pr", "view", "--json", "url", "--jq", ".url"],
+ cwd=repo_path,
+ check=False,
+ )
+ if result.returncode != 0:
+ return None
+ pr_url = result.stdout.strip()
+ return pr_url or None
+
+
+def _ensure_coauthor_line(body: str) -> str:
+ trimmed = body.rstrip()
+ if COAUTHOR_LINE not in trimmed:
+ trimmed = f"{trimmed}\n\n{COAUTHOR_LINE}"
+ return trimmed + "\n"
+
+
+def _default_pr_title() -> str:
+ date_stamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
+ return f"docs: weekly release updates ({date_stamp})"
+
+
+def _default_pr_body(*, ordered_tasks: Sequence[str], changed_files: Sequence[str]) -> str:
+ lines: list[str] = [
+ "## Summary",
+ f"- Ran `release_updates` tasks in order: {', '.join(ordered_tasks)}.",
+ ]
+
+ if changed_files:
+ lines.append("- Updated files:")
+ for changed_file in changed_files[:25]:
+ lines.append(f" - `{changed_file}`")
+ if len(changed_files) > 25:
+ lines.append(f" - ...and {len(changed_files) - 25} more")
+ else:
+ lines.append("- No working-tree file changes were detected in this run.")
+
+ lines.extend(
+ [
+ "",
+ "## Validation",
+ "- Ran the release update scripts successfully.",
+ ],
+ )
+ return _ensure_coauthor_line("\n".join(lines))
+
+
+def _load_pr_body(
+ *,
+ pr_body_file: str | None,
+ ordered_tasks: Sequence[str],
+ changed_files: Sequence[str],
+) -> str:
+ if pr_body_file:
+ body_path = Path(pr_body_file).expanduser().resolve()
+ if not body_path.exists():
+ raise FileNotFoundError(f"--pr-body-file not found: {body_path}")
+ body = body_path.read_text(encoding="utf-8")
+ return _ensure_coauthor_line(body)
+
+ return _default_pr_body(
+ ordered_tasks=ordered_tasks,
+ changed_files=changed_files,
+ )
+
+
+def _create_or_update_pull_request(
+ *,
+ repo_path: Path,
+ base_branch: str,
+ pr_title: str,
+ pr_body: str,
+ pr_draft: bool,
+) -> str:
+ existing_pr_url = _find_existing_pr_url(repo_path=repo_path)
+ if existing_pr_url:
+ _run_command(
+ [
+ "gh",
+ "pr",
+ "edit",
+ existing_pr_url,
+ "--base",
+ base_branch,
+ "--title",
+ pr_title,
+ "--body",
+ pr_body,
+ ],
+ cwd=repo_path,
+ )
+ return existing_pr_url
+
+ command = [
+ "gh",
+ "pr",
+ "create",
+ "--base",
+ base_branch,
+ "--title",
+ pr_title,
+ "--body",
+ pr_body,
+ ]
+ if pr_draft:
+ command.append("--draft")
+ result = _run_command(command, cwd=repo_path)
+ for line in reversed(result.stdout.splitlines()):
+ candidate = line.strip()
+ if candidate.startswith("http://") or candidate.startswith("https://"):
+ return candidate
+ raise RuntimeError(
+ "Could not parse PR URL from gh output.\n"
+ f"{result.stdout.strip()}",
+ )
+
+
+def _resolve_oncall_reviewers(
+ *,
+ resolver_script: Path,
+ schedule_ids: Sequence[str],
+ max_reviewers: int,
+ repo_path: Path,
+) -> list[str]:
+ if not resolver_script.exists():
+ raise FileNotFoundError(
+ "On-call resolver script not found: "
+ f"{resolver_script}",
+ )
+
+ normalized_schedule_ids: list[str] = []
+ for raw_schedule_id in schedule_ids:
+ for candidate in str(raw_schedule_id).split(","):
+ schedule_id = candidate.strip()
+ if schedule_id and schedule_id not in normalized_schedule_ids:
+ normalized_schedule_ids.append(schedule_id)
+
+ if not normalized_schedule_ids:
+ raise RuntimeError("No on-call schedule IDs were provided.")
+
+ per_schedule_reviewer_limit = max(1, max_reviewers)
+ if len(normalized_schedule_ids) > 1:
+ per_schedule_reviewer_limit = 1
+
+ resolved_reviewers: list[str] = []
+ for schedule_id in normalized_schedule_ids:
+ result = _run_command(
+ [
+ sys.executable,
+ str(resolver_script),
+ schedule_id,
+ "--max-reviewers",
+ str(per_schedule_reviewer_limit),
+ ],
+ cwd=repo_path,
+ check=False,
+ )
+ if result.returncode == 0:
+ raw_stdout = result.stdout.strip()
+ if not raw_stdout:
+ raise RuntimeError(
+ "On-call resolver succeeded but returned no stdout "
+ f"(schedule: {schedule_id}).",
+ )
+ payload = json.loads(raw_stdout)
+ reviewers_raw = payload.get("reviewers")
+ if not isinstance(reviewers_raw, list):
+ raise RuntimeError(
+ "On-call resolver succeeded but did not return reviewer list "
+ f"(schedule: {schedule_id}).",
+ )
+ reviewers = [
+ str(reviewer).strip()
+ for reviewer in reviewers_raw
+ if str(reviewer).strip()
+ ]
+ if not reviewers:
+ raise RuntimeError(
+ "On-call resolver succeeded but returned empty reviewer list "
+ f"(schedule: {schedule_id}).",
+ )
+ for reviewer in reviewers:
+ if reviewer not in resolved_reviewers:
+ resolved_reviewers.append(reviewer)
+ continue
+
+ stderr = result.stderr.strip()
+ stdout = result.stdout.strip()
+ details = "\n".join(part for part in [stderr, stdout] if part)
+ if result.returncode == 2:
+ raise RuntimeError(
+ "On-call reviewer resolution was ambiguous "
+ f"(schedule: {schedule_id}). "
+ "Please resolve manually or update matching overrides.\n"
+ f"{details}",
+ )
+ raise RuntimeError(
+ "On-call reviewer resolution failed "
+ f"(schedule: {schedule_id}).\n"
+ f"{details}",
+ )
+
+ if not resolved_reviewers:
+ raise RuntimeError("Could not resolve any on-call reviewers.")
+ return resolved_reviewers[: max(1, max_reviewers)]
+
+
+def _assign_reviewers(
+ *,
+ repo_path: Path,
+ pr_url: str,
+ reviewers: Sequence[str],
+) -> None:
+ for reviewer in reviewers:
+ _run_command(
+ ["gh", "pr", "edit", pr_url, "--add-reviewer", reviewer],
+ cwd=repo_path,
+ )
+
+
+def _validate_args(args: argparse.Namespace) -> None:
+ normalized_schedule_ids: list[str] = []
+ for raw_schedule_id in args.oncall_schedule_ids:
+ for candidate in str(raw_schedule_id).split(","):
+ schedule_id = candidate.strip()
+ if schedule_id and schedule_id not in normalized_schedule_ids:
+ normalized_schedule_ids.append(schedule_id)
+ args.oncall_schedule_ids = normalized_schedule_ids
+ if args.assign_oncall_reviewers and not args.create_pr:
+ raise ValueError("--assign-oncall-reviewer requires --create-pr.")
+ if args.assign_oncall_reviewers and not args.oncall_schedule_ids:
+ raise ValueError(
+ "--assign-oncall-reviewer requires --oncall-schedule-id.",
+ )
+
+
+def _maybe_create_pr(
+ *,
+ args: argparse.Namespace,
+ docs_root: Path,
+ ordered_tasks: Sequence[str],
+) -> None:
+ if not args.create_pr:
+ return
+
+ branch_name = _git_current_branch(repo_path=docs_root)
+ if branch_name == args.pr_base:
+ raise RuntimeError(
+ f"Current branch ({branch_name}) matches --pr-base ({args.pr_base}). "
+ "Switch to a feature branch before creating a PR.",
+ )
+
+ changed_files = _git_changed_files(repo_path=docs_root)
+ changed_files_for_pr_body = changed_files.copy()
+ if changed_files and args.skip_pr_commit:
+ raise RuntimeError(
+ "--skip-pr-commit was set, but there are uncommitted file changes. "
+ "Commit changes first or rerun without --skip-pr-commit.",
+ )
+
+ if changed_files and not args.skip_pr_commit and not args.dry_run:
+ commit_message = (
+ args.commit_message
+ or f"docs: weekly release updates ({datetime.now(tz=timezone.utc).strftime('%Y-%m-%d')})"
+ )
+ _commit_all_changes(
+ repo_path=docs_root,
+ commit_message=commit_message,
+ )
+ changed_files = _git_changed_files(repo_path=docs_root)
+
+ ahead_count = _ahead_commit_count(
+ repo_path=docs_root,
+ base_branch=args.pr_base,
+ )
+ if ahead_count == 0:
+ eprint(
+ "No commits ahead of base branch; skipping PR create/update.",
+ )
+ return
+
+ reviewers: list[str] = []
+ if args.assign_oncall_reviewers:
+ reviewers = _resolve_oncall_reviewers(
+ resolver_script=Path(args.oncall_resolver_script).expanduser().resolve(),
+ schedule_ids=args.oncall_schedule_ids,
+ max_reviewers=int(args.oncall_max_reviewers),
+ repo_path=docs_root,
+ )
+ eprint(
+ "Resolved on-call reviewers: "
+ + ", ".join(f"@{reviewer}" for reviewer in reviewers),
+ )
+
+ pr_title = args.pr_title or _default_pr_title()
+ pr_body = _load_pr_body(
+ pr_body_file=args.pr_body_file,
+ ordered_tasks=ordered_tasks,
+ changed_files=changed_files_for_pr_body,
+ )
+
+ if args.dry_run:
+ if not args.skip_pr_push:
+ eprint(f"[dry-run] Would push branch: {branch_name}")
+ eprint(
+ "[dry-run] Would create or update pull request "
+ f"(base={args.pr_base}, title={pr_title!r})",
+ )
+ if reviewers:
+ eprint(
+ "[dry-run] Would assign reviewers: "
+ + ", ".join(f"@{reviewer}" for reviewer in reviewers),
+ )
+ return
+
+ if not args.skip_pr_push:
+ _push_branch(repo_path=docs_root, branch_name=branch_name)
+
+ pr_url = _create_or_update_pull_request(
+ repo_path=docs_root,
+ base_branch=args.pr_base,
+ pr_title=pr_title,
+ pr_body=pr_body,
+ pr_draft=args.pr_draft,
+ )
+ eprint(f"Pull request ready: {pr_url}")
+
+ if reviewers:
+ _assign_reviewers(
+ repo_path=docs_root,
+ pr_url=pr_url,
+ reviewers=reviewers,
+ )
+ eprint(
+ "Assigned reviewers to PR: "
+ + ", ".join(f"@{reviewer}" for reviewer in reviewers),
+ )
+
+
+def main() -> int:
+ args = parse_args()
+ _validate_args(args=args)
+ selected = set(args.tasks)
+ ordered_tasks = [task for task in TASK_ORDER if task in selected]
+ scripts_dir = Path(__file__).resolve().parent
+ docs_root = docs_repo_root(explicit_docs_repo=args.docs_repo)
+ work_dir = Path(args.work_dir).expanduser().resolve()
+ manifest_path = (
+ Path(args.manifest).expanduser().resolve()
+ if args.manifest
+ else work_dir / "warp_artifacts.json"
+ )
+
+ if not ordered_tasks:
+ eprint("No tasks selected.")
+ return 0
+ if args.create_pr and not args.allow_dirty_repo and _git_porcelain_status(
+ repo_path=docs_root,
+ ):
+ raise RuntimeError(
+ "Repository has uncommitted changes before running release updates. "
+ "Commit or stash them, or rerun with --allow-dirty-repo.",
+ )
+
+ for task in ordered_tasks:
+ if task == "warp_app_update":
+ script_args = [
+ "--work-dir",
+ str(work_dir),
+ "--manifest",
+ str(manifest_path),
+ ]
+ if args.skip_warp_app_extract:
+ script_args.append("--skip-extract")
+ if args.skip_dependency_preflight:
+ script_args.append("--skip-dependency-preflight")
+ if args.auto_install_missing_dependency:
+ script_args.append("--auto-install-missing-dependency")
+ if args.dry_run:
+ script_args.append("--dry-run")
+ _run_script(
+ script_path=scripts_dir / "update_warp_app.py",
+ script_args=script_args,
+ )
+
+ elif task == "changelog":
+ script_args = ["--docs-repo", str(docs_root)]
+ if args.channel_versions_file:
+ script_args.extend(["--channel-versions-file", args.channel_versions_file])
+ if args.channel_versions_repo:
+ script_args.extend(["--channel-versions-repo", args.channel_versions_repo])
+ if args.channel_versions_url:
+ script_args.extend(["--channel-versions-url", args.channel_versions_url])
+ if args.dry_run:
+ script_args.append("--dry-run")
+ _run_script(
+ script_path=scripts_dir / "update_changelog.py",
+ script_args=script_args,
+ )
+
+ elif task == "licenses":
+ script_args = [
+ "--docs-repo",
+ str(docs_root),
+ "--work-dir",
+ str(work_dir),
+ "--manifest",
+ str(manifest_path),
+ ]
+ if args.licenses_file:
+ script_args.extend(["--licenses-file", args.licenses_file])
+ if args.dry_run:
+ script_args.append("--dry-run")
+ _run_script(
+ script_path=scripts_dir / "update_licenses.py",
+ script_args=script_args,
+ )
+
+ elif task == "telemetry":
+ script_args = [
+ "--docs-repo",
+ str(docs_root),
+ "--work-dir",
+ str(work_dir),
+ "--manifest",
+ str(manifest_path),
+ ]
+ if args.telemetry_json_file:
+ script_args.extend(["--telemetry-json-file", args.telemetry_json_file])
+ if args.telemetry_command:
+ script_args.extend(["--telemetry-command", args.telemetry_command])
+ if args.dry_run:
+ script_args.append("--dry-run")
+ _run_script(
+ script_path=scripts_dir / "update_telemetry.py",
+ script_args=script_args,
+ )
+
+ eprint(f"Completed tasks: {', '.join(ordered_tasks)}")
+ _maybe_create_pr(
+ args=args,
+ docs_root=docs_root,
+ ordered_tasks=ordered_tasks,
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except Exception as exc: # noqa: BLE001
+ eprint(f"ERROR: {exc}")
+ raise SystemExit(1) from exc
diff --git a/.agents/skills/release_updates/scripts/setup_environment.py b/.agents/skills/release_updates/scripts/setup_environment.py
new file mode 100644
index 00000000..9832f19b
--- /dev/null
+++ b/.agents/skills/release_updates/scripts/setup_environment.py
@@ -0,0 +1,296 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import os
+import shutil
+import subprocess
+from pathlib import Path
+from typing import Any
+
+from common import DEFAULT_ONCALL_RESOLVER_SCRIPT
+from common import DEFAULT_WORK_DIR
+from common import docs_repo_root
+from common import eprint
+from common import resolve_channel_versions_file
+from common import utc_now_iso
+from common import write_json_file
+
+DEFAULT_CHANNEL_VERSIONS_REPO_URL = "https://github.com/warpdotdev/channel-versions.git"
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description=(
+ "Preflight and optionally bootstrap repositories + credentials for "
+ "release_updates skill runs."
+ ),
+ )
+ parser.add_argument(
+ "--docs-repo",
+ default=None,
+ help="Path to docs repo root (auto-detected if omitted).",
+ )
+ parser.add_argument(
+ "--channel-versions-repo",
+ default=None,
+ help=(
+ "Path to a local channel-versions checkout. "
+ "If omitted, auto-detection is used."
+ ),
+ )
+ parser.add_argument(
+ "--channel-versions-url",
+ default=DEFAULT_CHANNEL_VERSIONS_REPO_URL,
+ help=(
+ "Git clone URL for channel-versions repo when "
+ "--clone-channel-versions-if-missing is set."
+ ),
+ )
+ parser.add_argument(
+ "--clone-channel-versions-if-missing",
+ action="store_true",
+ help=(
+ "Clone channel-versions repo if not detected locally "
+ "(target: --channel-versions-repo or sibling directory)."
+ ),
+ )
+ parser.add_argument(
+ "--require-local-channel-versions",
+ action="store_true",
+ help="Fail if local channel_versions.json is not present.",
+ )
+ parser.add_argument(
+ "--require-pr-flow",
+ action="store_true",
+ help=(
+ "Validate PR prerequisites (gh CLI availability + authenticated session)."
+ ),
+ )
+ parser.add_argument(
+ "--require-oncall-reviewer",
+ action="store_true",
+ help=(
+ "Validate on-call reviewer prerequisites "
+ "(resolver script + GRAFANA_API_KEY)."
+ ),
+ )
+ parser.add_argument(
+ "--oncall-resolver-script",
+ default=str(DEFAULT_ONCALL_RESOLVER_SCRIPT),
+ help=(
+ "Path to local on-call reviewer resolver script "
+ f"(default: {DEFAULT_ONCALL_RESOLVER_SCRIPT})."
+ ),
+ )
+ parser.add_argument(
+ "--report-file",
+ default=None,
+ help=(
+ "Where to write environment report JSON "
+ "(default: /environment_report.json)."
+ ),
+ )
+ parser.add_argument(
+ "--work-dir",
+ default=str(DEFAULT_WORK_DIR),
+ help=f"Working directory for generated setup artifacts (default: {DEFAULT_WORK_DIR})",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Print actions without cloning repositories.",
+ )
+ return parser.parse_args()
+
+
+def _run_command(
+ command: list[str],
+ *,
+ cwd: Path | None = None,
+ check: bool = True,
+) -> subprocess.CompletedProcess[str]:
+ result = subprocess.run( # nosec B603
+ command,
+ cwd=cwd,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ if check and result.returncode != 0:
+ stderr_or_stdout = result.stderr.strip() or result.stdout.strip()
+ raise RuntimeError(
+ "Command failed "
+ f"({' '.join(command)}):\n"
+ f"{stderr_or_stdout}",
+ )
+ return result
+
+
+def _clone_repo(
+ *,
+ repo_url: str,
+ destination: Path,
+ dry_run: bool,
+) -> None:
+ if destination.exists():
+ return
+
+ if dry_run:
+ eprint(f"[dry-run] Would clone {repo_url} -> {destination}")
+ return
+
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ _run_command(
+ ["git", "clone", repo_url, str(destination)],
+ check=True,
+ )
+ eprint(f"Cloned repository: {destination}")
+
+
+def _gh_authenticated() -> tuple[bool, str]:
+ result = _run_command(
+ ["gh", "auth", "status"],
+ check=False,
+ )
+ details = result.stderr.strip() or result.stdout.strip()
+ if result.returncode == 0:
+ return True, details
+
+ normalized = details.lower()
+ if "active account: true" in normalized and "timeout trying to log in" in normalized:
+ return True, details
+
+ return False, details
+
+
+def main() -> int:
+ args = parse_args()
+ docs_root = docs_repo_root(explicit_docs_repo=args.docs_repo)
+ work_dir = Path(args.work_dir).expanduser().resolve()
+ report_path = (
+ Path(args.report_file).expanduser().resolve()
+ if args.report_file
+ else work_dir / "environment_report.json"
+ )
+
+ errors: list[str] = []
+ warnings: list[str] = []
+ checks: dict[str, Any] = {
+ "commands": {},
+ "gh_authenticated": None,
+ "grafana_api_key_present": None,
+ "oncall_resolver_exists": None,
+ "local_channel_versions_present": None,
+ }
+
+ required_commands = ["python3", "git"]
+ if args.require_pr_flow:
+ required_commands.append("gh")
+
+ for command_name in required_commands:
+ available = shutil.which(command_name) is not None
+ checks["commands"][command_name] = available
+ if not available:
+ errors.append(f"Missing required command: {command_name}")
+
+ channel_versions_repo_path = (
+ Path(args.channel_versions_repo).expanduser().resolve()
+ if args.channel_versions_repo
+ else docs_root.parent / "channel-versions"
+ )
+ channel_versions_file = resolve_channel_versions_file(
+ docs_root=docs_root,
+ explicit_repo=args.channel_versions_repo,
+ )
+
+ if channel_versions_file is None and args.clone_channel_versions_if_missing:
+ _clone_repo(
+ repo_url=args.channel_versions_url,
+ destination=channel_versions_repo_path,
+ dry_run=args.dry_run,
+ )
+ channel_versions_file = resolve_channel_versions_file(
+ docs_root=docs_root,
+ explicit_repo=str(channel_versions_repo_path),
+ )
+
+ checks["local_channel_versions_present"] = channel_versions_file is not None
+ if channel_versions_file is None:
+ message = (
+ "Local channel_versions.json not found. "
+ "Changelog updates can still run using URL fallback."
+ )
+ if args.require_local_channel_versions and not (
+ args.dry_run and args.clone_channel_versions_if_missing
+ ):
+ errors.append(message)
+ else:
+ warnings.append(message)
+
+ if args.require_pr_flow and checks["commands"].get("gh"):
+ gh_ok, gh_details = _gh_authenticated()
+ checks["gh_authenticated"] = gh_ok
+ if not gh_ok:
+ errors.append(
+ "GitHub CLI is not authenticated. Run `gh auth login` before PR mode.",
+ )
+ if gh_details:
+ warnings.append(gh_details)
+ elif gh_details and "timeout trying to log in" in gh_details.lower():
+ warnings.append(
+ "gh auth status reported a keyring timeout but Active account is true; "
+ "continuing.",
+ )
+
+ resolver_path = Path(args.oncall_resolver_script).expanduser().resolve()
+
+ checks["oncall_resolver_exists"] = resolver_path.exists()
+ if args.require_oncall_reviewer and not resolver_path.exists():
+ errors.append(
+ "On-call resolver script not found at "
+ f"{resolver_path}. Pass --oncall-resolver-script.",
+ )
+
+ grafana_api_key_present = bool(os.environ.get("GRAFANA_API_KEY"))
+ checks["grafana_api_key_present"] = grafana_api_key_present
+ if args.require_oncall_reviewer and not grafana_api_key_present:
+ errors.append("Missing required env var for reviewer assignment: GRAFANA_API_KEY")
+
+ report: dict[str, Any] = {
+ "generated_at_utc": utc_now_iso(),
+ "docs_repo": str(docs_root),
+ "channel_versions_repo": str(channel_versions_repo_path),
+ "channel_versions_file": str(channel_versions_file) if channel_versions_file else None,
+ "oncall_resolver_script": str(resolver_path),
+ "checks": checks,
+ "warnings": warnings,
+ "errors": errors,
+ "dry_run": args.dry_run,
+ }
+
+ if args.dry_run:
+ eprint(f"[dry-run] Would write setup report: {report_path}")
+ else:
+ write_json_file(path=report_path, payload=report)
+ eprint(f"Wrote setup report: {report_path}")
+
+ if warnings:
+ for warning in warnings:
+ eprint(f"WARNING: {warning}")
+
+ if errors:
+ for error in errors:
+ eprint(f"ERROR: {error}")
+ raise RuntimeError("Environment setup checks failed.")
+
+ eprint("Environment setup checks passed.")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except Exception as exc: # noqa: BLE001
+ eprint(f"ERROR: {exc}")
+ raise SystemExit(1) from exc
diff --git a/.agents/skills/release_updates/scripts/update_changelog.py b/.agents/skills/release_updates/scripts/update_changelog.py
new file mode 100644
index 00000000..fbb4266e
--- /dev/null
+++ b/.agents/skills/release_updates/scripts/update_changelog.py
@@ -0,0 +1,461 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import re
+from datetime import datetime
+from datetime import timezone
+from html import escape
+from pathlib import Path
+from typing import Any
+from urllib.parse import urlparse
+
+from common import docs_repo_root
+from common import eprint
+from common import load_json_from_url
+from common import resolve_channel_versions_file
+
+DEFAULT_CHANNEL_VERSIONS_URL = "https://releases.warp.dev/channel_versions.json"
+RE_CHANGELOG_DATE = re.compile(r"^### (\d{4}\.\d{2}\.\d{2})\s", re.MULTILINE)
+RE_BARE_CURLY_PATTERN = re.compile(
+ r"(? argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Incrementally update docs changelog from channel_versions.json.",
+ )
+ parser.add_argument(
+ "--docs-repo",
+ default=None,
+ help="Path to docs repo root (auto-detected if omitted).",
+ )
+ parser.add_argument(
+ "--channel-versions-file",
+ default=None,
+ help="Path to channel_versions.json.",
+ )
+ parser.add_argument(
+ "--channel-versions-repo",
+ default=None,
+ help="Path to channel-versions repo or directory containing channel_versions.json.",
+ )
+ parser.add_argument(
+ "--channel-versions-url",
+ default=DEFAULT_CHANNEL_VERSIONS_URL,
+ help=f"Fallback URL when no local channel_versions.json is found (default: {DEFAULT_CHANNEL_VERSIONS_URL})",
+ )
+ parser.add_argument(
+ "--year",
+ type=int,
+ default=None,
+ help="Target changelog year (defaults to current UTC year).",
+ )
+ parser.add_argument(
+ "--output-file",
+ default=None,
+ help="Override changelog output path.",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Compute and print summary without writing files.",
+ )
+ return parser.parse_args()
+
+
+def _parse_datetime(value: str) -> datetime:
+ candidates = [
+ "%Y-%m-%dT%H:%M:%S%z",
+ "%Y-%m-%dT%H:%M:%S.%f%z",
+ ]
+ normalized = value.replace("Z", "+00:00")
+ try:
+ return datetime.fromisoformat(normalized)
+ except ValueError:
+ for fmt in candidates:
+ try:
+ return datetime.strptime(value, fmt)
+ except ValueError:
+ continue
+ raise ValueError(f"Unable to parse changelog date: {value}")
+
+
+def _display_version(version_key: str) -> str:
+ if "." not in version_key:
+ return version_key
+ return version_key.rsplit(".", 1)[0]
+
+
+def _extract_intro_and_body(existing_content: str) -> tuple[str, str, str | None]:
+ date_match = RE_CHANGELOG_DATE.search(existing_content)
+ if date_match:
+ intro = existing_content[: date_match.start()].rstrip() + "\n\n"
+ body = existing_content[date_match.start() :].lstrip("\n")
+ cutoff_date = date_match.group(1)
+ return intro, body, cutoff_date
+ return existing_content.rstrip() + "\n\n", "", None
+
+
+def _ensure_year_in_changelog_index(index_file: Path, year: int, dry_run: bool) -> bool:
+ if not index_file.exists():
+ return False
+
+ content = index_file.read_text(encoding="utf-8")
+ year_line = f"* [**{year}**](/changelog/{year}/)"
+ if year_line in content:
+ return False
+
+ lines = content.splitlines()
+ insert_at = next(
+ (idx for idx, line in enumerate(lines) if line.startswith("* [**")),
+ len(lines),
+ )
+ lines.insert(insert_at, year_line)
+ updated = "\n".join(lines).rstrip() + "\n"
+ if dry_run:
+ eprint(f"[dry-run] Would add {year} to {index_file}")
+ else:
+ index_file.write_text(updated, encoding="utf-8")
+ return True
+
+
+def _ensure_year_in_sidebar(sidebar_file: Path, year: int, dry_run: bool) -> bool:
+ if not sidebar_file.exists():
+ return False
+
+ content = sidebar_file.read_text(encoding="utf-8")
+ updated = content
+ changed = False
+
+ year_item_pattern = re.compile(
+ rf"(?m)^\s*\{{ slug: 'changelog/{year}', label: '{year}' \}},$",
+ )
+ if not year_item_pattern.search(updated):
+ all_years_match = re.search(
+ r"(?m)^(\s*)\{ slug: 'changelog', label: 'All years' \},$",
+ updated,
+ )
+ if all_years_match:
+ indent = all_years_match.group(1)
+ insertion = f"\n{indent}{{ slug: 'changelog/{year}', label: '{year}' }},"
+ updated = (
+ updated[: all_years_match.end()]
+ + insertion
+ + updated[all_years_match.end() :]
+ )
+ changed = True
+
+ desired_link = f"/changelog/{year}/"
+ if desired_link not in updated:
+ updated, link_count = re.subn(
+ r"(label:\s*'Changelog',\s*\n\s*link:\s*'/changelog/)\d{4}(/',)",
+ rf"\g<1>{year}\g<2>",
+ updated,
+ count=1,
+ )
+ if link_count > 0:
+ changed = True
+
+ if not changed:
+ return False
+
+ if dry_run:
+ eprint(f"[dry-run] Would update changelog sidebar year navigation in {sidebar_file}")
+ else:
+ sidebar_file.write_text(updated, encoding="utf-8")
+ return True
+
+
+def _coerce_markdown_sections(changelog: dict[str, Any]) -> list[dict[str, str]]:
+ raw_sections = changelog.get("markdown_sections")
+ if isinstance(raw_sections, list):
+ sections: list[dict[str, str]] = []
+ for section in raw_sections:
+ if not isinstance(section, dict):
+ continue
+ title = str(section.get("title", "")).strip()
+ markdown = str(section.get("markdown", "")).strip("\n")
+ if title:
+ sections.append({"title": title, "markdown": markdown})
+ if sections:
+ return sections
+
+ legacy_sections = changelog.get("sections")
+ converted: list[dict[str, str]] = []
+ if isinstance(legacy_sections, list):
+ for section in legacy_sections:
+ if not isinstance(section, dict):
+ continue
+ title = str(section.get("title", "")).strip()
+ items = section.get("items")
+ if not title or not isinstance(items, list):
+ continue
+ lines = [f"* {str(item).strip()}" for item in items if str(item).strip()]
+ converted.append({"title": title, "markdown": "\n".join(lines)})
+ return converted
+
+
+def _normalize_bullets(markdown_blob: str) -> list[str]:
+ lines: list[str] = []
+ for raw_line in markdown_blob.splitlines():
+ line = raw_line.strip()
+ if not line:
+ continue
+ if line.startswith("* "):
+ lines.append(line)
+ elif line.startswith("- "):
+ lines.append(f"* {line[2:].strip()}")
+ else:
+ lines.append(f"* {line}")
+ return lines
+
+
+def _normalize_changelog_prose(text: str) -> str:
+ normalized = RE_FIXES_TENSE.sub(r"\1Fixed ", text)
+ parts: list[str] = re.split(r"(`[^`]+`)", normalized)
+ output: list[str] = []
+ for part in parts:
+ if part.startswith("`") and part.endswith("`"):
+ output.append(part)
+ continue
+ candidate = RE_SLASH_COMMAND.sub(r"`\1`", part)
+ candidate = RE_CLI_FLAG.sub(r"`\1`", candidate)
+ output.append(candidate)
+ return "".join(output)
+
+
+def _wrap_curly_braces_in_backticks(text: str) -> str:
+ parts: list[str] = re.split(r"(`[^`]+`)", text)
+ output: list[str] = []
+ for part in parts:
+ if part.startswith("`") and part.endswith("`"):
+ output.append(part)
+ continue
+ output.append(RE_BARE_CURLY_PATTERN.sub(r"`\1`", part))
+ return "".join(output)
+
+
+def _sanitize_image_url(image_url: Any) -> str | None:
+ if not isinstance(image_url, str):
+ return None
+ candidate = image_url.strip()
+ if not candidate:
+ return None
+ parsed = urlparse(candidate)
+ if parsed.scheme != "https" or not parsed.netloc:
+ return None
+ return candidate
+
+
+def _render_entry(version_key: str, changelog: dict[str, Any]) -> str:
+ display_date = _parse_datetime(str(changelog["date"])).strftime("%Y.%m.%d")
+ display_version = _display_version(version_key=version_key)
+ lines: list[str] = [f"### {display_date} ({display_version})", ""]
+ image_url = _sanitize_image_url(changelog.get("image_url"))
+ sections = _coerce_markdown_sections(changelog=changelog)
+ for section in sections:
+ title = section["title"].strip()
+ if title == "Coming soon":
+ continue
+ bullets = _normalize_bullets(section["markdown"])
+ if not bullets:
+ continue
+ lines.append(f"**{title}**")
+ lines.append("")
+ if title == "New features" and image_url:
+ safe_image_url = escape(image_url, quote=True)
+ safe_alt_text = escape(f"Release image for {display_date}", quote=True)
+ lines.append(
+ f"
",
+ )
+ lines.append("")
+ lines.extend(bullets)
+ lines.append("")
+
+ oz_updates = changelog.get("oz_updates")
+ if isinstance(oz_updates, list):
+ bullets = [
+ f"* {str(item).strip().lstrip('* ').strip()}"
+ for item in oz_updates
+ if str(item).strip()
+ ]
+ if bullets:
+ lines.append("**Oz updates**")
+ lines.append("")
+ lines.extend(bullets)
+ lines.append("")
+
+ rendered = "\n".join(lines).rstrip() + "\n"
+ rendered = _normalize_changelog_prose(text=rendered)
+ rendered = _wrap_curly_braces_in_backticks(text=rendered)
+ return rendered
+
+
+def _new_entries(
+ stable_changelogs: dict[str, Any],
+ cutoff_date: str | None,
+ year: int,
+) -> list[str]:
+ sortable: list[tuple[datetime, str, dict[str, Any]]] = []
+ for version_key, payload in stable_changelogs.items():
+ if not isinstance(payload, dict) or "date" not in payload:
+ continue
+ try:
+ parsed_date = _parse_datetime(str(payload["date"]))
+ except ValueError:
+ continue
+ sortable.append((parsed_date, version_key, payload))
+ sortable.sort(key=lambda item: (item[0], item[1]), reverse=True)
+
+ seen_dates: set[str] = set()
+ entries: list[str] = []
+ for parsed_date, version_key, payload in sortable:
+ if parsed_date.astimezone(timezone.utc).year != year:
+ continue
+ display_date = parsed_date.strftime("%Y.%m.%d")
+ if cutoff_date is not None and display_date <= cutoff_date:
+ continue
+ if display_date in seen_dates:
+ continue
+ seen_dates.add(display_date)
+ entries.append(_render_entry(version_key=version_key, changelog=payload))
+ return entries
+
+
+def _normalize_changelog_body(body: str) -> str:
+ if not body.strip():
+ return body
+ first_entry = RE_CHANGELOG_DATE.search(body)
+ if not first_entry:
+ return body
+ next_entry = RE_CHANGELOG_DATE.search(body, first_entry.end())
+ if next_entry:
+ latest_entry_block = body[: next_entry.start()]
+ remaining_body = body[next_entry.start() :]
+ else:
+ latest_entry_block = body
+ remaining_body = ""
+ normalized_latest_entry = _normalize_changelog_prose(text=latest_entry_block)
+ normalized_latest_entry = _wrap_curly_braces_in_backticks(
+ text=normalized_latest_entry,
+ )
+ if remaining_body:
+ return normalized_latest_entry.rstrip() + "\n\n" + remaining_body.lstrip("\n")
+ return normalized_latest_entry
+
+
+def _load_channel_versions(
+ docs_root: Path,
+ channel_versions_file: str | None,
+ channel_versions_repo: str | None,
+ fallback_url: str,
+) -> dict[str, Any]:
+ resolved_file = resolve_channel_versions_file(
+ docs_root=docs_root,
+ explicit_file=channel_versions_file,
+ explicit_repo=channel_versions_repo,
+ )
+ if resolved_file:
+ eprint(f"Using local channel versions file: {resolved_file}")
+ import json
+
+ return json.loads(resolved_file.read_text(encoding="utf-8"))
+
+ eprint(f"No local channel versions file found; fetching: {fallback_url}")
+ return load_json_from_url(url=fallback_url)
+
+
+def main() -> int:
+ args = parse_args()
+ docs_root = docs_repo_root(explicit_docs_repo=args.docs_repo)
+ year = args.year or datetime.now(tz=timezone.utc).year
+
+ output_file = (
+ Path(args.output_file).expanduser().resolve()
+ if args.output_file
+ else docs_root / "src/content/docs/changelog" / f"{year}.mdx"
+ )
+ output_file.parent.mkdir(parents=True, exist_ok=True)
+
+ channel_versions_payload = _load_channel_versions(
+ docs_root=docs_root,
+ channel_versions_file=args.channel_versions_file,
+ channel_versions_repo=args.channel_versions_repo,
+ fallback_url=args.channel_versions_url,
+ )
+ stable_changelogs = (
+ channel_versions_payload.get("changelogs", {})
+ .get("stable", {})
+ )
+ if not isinstance(stable_changelogs, dict):
+ raise ValueError("Invalid channel versions payload: changelogs.stable missing or invalid")
+
+ created_new_file = not output_file.exists()
+ if output_file.exists():
+ existing_content = output_file.read_text(encoding="utf-8")
+ intro, existing_body, cutoff_date = _extract_intro_and_body(
+ existing_content=existing_content,
+ )
+ else:
+ intro = (
+ "---\n"
+ f"title: \"Changelog — {year}\"\n"
+ "description: >-\n"
+ f" Warp release notes for {year}. Updates ship weekly, typically on Thursdays.\n"
+ "---\n\n"
+ "Submit bugs and feature requests on our [GitHub board!](https://github.com/warpdotdev/Warp/issues/new/choose)\n\n"
+ )
+ existing_body = ""
+ cutoff_date = None
+
+ entries = _new_entries(
+ stable_changelogs=stable_changelogs,
+ cutoff_date=cutoff_date,
+ year=year,
+ )
+ if entries:
+ merged_body = "".join(entries)
+ if existing_body.strip():
+ merged_body = merged_body.rstrip() + "\n\n" + existing_body.lstrip()
+ else:
+ merged_body = existing_body
+ merged_body = _normalize_changelog_body(body=merged_body)
+
+ final_content = intro.rstrip() + "\n\n"
+ if merged_body.strip():
+ final_content += merged_body.rstrip() + "\n"
+
+ if args.dry_run:
+ eprint(
+ f"[dry-run] Would write {output_file} with {len(entries)} new entr"
+ f"{'y' if len(entries) == 1 else 'ies'}.",
+ )
+ else:
+ output_file.write_text(final_content, encoding="utf-8")
+ eprint(f"Wrote changelog file: {output_file}")
+
+ index_file = docs_root / "src/content/docs/changelog/index.mdx"
+ sidebar_file = docs_root / "src/sidebar.ts"
+ if created_new_file:
+ _ensure_year_in_changelog_index(
+ index_file=index_file,
+ year=year,
+ dry_run=args.dry_run,
+ )
+ _ensure_year_in_sidebar(
+ sidebar_file=sidebar_file,
+ year=year,
+ dry_run=args.dry_run,
+ )
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
diff --git a/.agents/skills/release_updates/scripts/update_licenses.py b/.agents/skills/release_updates/scripts/update_licenses.py
new file mode 100644
index 00000000..a08fffe6
--- /dev/null
+++ b/.agents/skills/release_updates/scripts/update_licenses.py
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import re
+from pathlib import Path
+
+from common import DEFAULT_WORK_DIR
+from common import docs_repo_root
+from common import eprint
+from common import read_json_file
+
+SEPARATOR_RE = re.compile(r"^-{10,}$")
+DEP_RE = re.compile(r"^ - (.+)$")
+ALT_RE = re.compile(r"^(.+?)\s+\(([^)]+)\)$")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Regenerate open-source-licenses.mdx from THIRD_PARTY_LICENSES.txt.",
+ )
+ parser.add_argument(
+ "--docs-repo",
+ default=None,
+ help="Path to docs repo root (auto-detected if omitted).",
+ )
+ parser.add_argument(
+ "--work-dir",
+ default=str(DEFAULT_WORK_DIR),
+ help=f"Working directory used by update_warp_app.py (default: {DEFAULT_WORK_DIR})",
+ )
+ parser.add_argument(
+ "--manifest",
+ default=None,
+ help="Artifact manifest path (default: /warp_artifacts.json)",
+ )
+ parser.add_argument(
+ "--licenses-file",
+ default=None,
+ help="Explicit path to THIRD_PARTY_LICENSES.txt.",
+ )
+ parser.add_argument(
+ "--output-file",
+ default=None,
+ help="Output MDX file (default: docs open-source-licenses path).",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Compute and print summary without writing files.",
+ )
+ return parser.parse_args()
+
+
+def _extract_intro(content: str) -> str:
+ marker = "\n## Dependencies"
+ index = content.find(marker)
+ if index == -1:
+ return content.rstrip() + "\n"
+ return content[:index].rstrip() + "\n"
+
+
+def _parse_groups(content: str) -> list[tuple[str, list[str], str]]:
+ lines = content.splitlines()
+ total = len(lines)
+ i = 0
+ groups: list[tuple[str, list[str], str]] = []
+
+ def is_dep_group_start(index: int) -> bool:
+ if index + 1 >= total:
+ return False
+ line = lines[index]
+ return bool(
+ line.strip()
+ and not line.startswith(" ")
+ and not SEPARATOR_RE.match(line)
+ and not line.startswith("=")
+ and DEP_RE.match(lines[index + 1]),
+ )
+
+ def is_alt_group_start(index: int) -> bool:
+ if index + 1 >= total:
+ return False
+ return bool(
+ ALT_RE.match(lines[index]) and SEPARATOR_RE.match(lines[index + 1]),
+ )
+
+ while i < total and not is_dep_group_start(i):
+ i += 1
+
+ while i < total:
+ if not lines[i].strip():
+ i += 1
+ continue
+ if is_alt_group_start(i):
+ break
+ if not is_dep_group_start(i):
+ i += 1
+ continue
+
+ license_type = lines[i].strip()
+ i += 1
+ deps: list[str] = []
+ while i < total:
+ match = DEP_RE.match(lines[i])
+ if not match:
+ break
+ deps.append(match.group(1))
+ i += 1
+
+ if i < total and SEPARATOR_RE.match(lines[i]):
+ i += 1
+
+ body_lines: list[str] = []
+ while i < total:
+ if is_dep_group_start(i) or is_alt_group_start(i):
+ break
+ if lines[i].strip() == "" and (i + 1 >= total or lines[i + 1].strip() == ""):
+ break
+ body_lines.append(lines[i])
+ i += 1
+ while i < total and lines[i].strip() == "":
+ i += 1
+ groups.append((license_type, deps, "\n".join(body_lines).strip()))
+
+ while i < total:
+ line = lines[i]
+ if not line.strip():
+ i += 1
+ continue
+
+ alt_match = ALT_RE.match(line)
+ if not alt_match:
+ i += 1
+ continue
+
+ dep_name = alt_match.group(1).strip()
+ alt_license_type = alt_match.group(2).strip()
+ i += 1
+ if i < total and SEPARATOR_RE.match(lines[i]):
+ i += 1
+ body_lines: list[str] = []
+ while i < total:
+ if lines[i].strip() == "" and (i + 1 >= total or lines[i + 1].strip() == ""):
+ break
+ body_lines.append(lines[i])
+ i += 1
+ while i < total and lines[i].strip() == "":
+ i += 1
+ groups.append((alt_license_type, [dep_name], "\n".join(body_lines).strip()))
+
+ return groups
+
+
+def _render_licenses_markdown(content: str) -> tuple[str, int]:
+ groups = _parse_groups(content=content)
+ lines: list[str] = [
+ "## Dependencies",
+ "",
+ "| Dependency | License |",
+ "|---|---|",
+ ]
+ dep_count = 0
+ for license_type, deps, _ in groups:
+ for dep in deps:
+ dep_count += 1
+ lines.append(f"| {dep} | {license_type} |")
+
+ lines.extend(
+ [
+ "",
+ "## Full License Text",
+ "",
+ "```text",
+ content.strip(),
+ "```",
+ "",
+ ],
+ )
+ return "\n".join(lines).rstrip() + "\n", dep_count
+
+
+def _licenses_path_from_manifest(manifest_path: Path) -> Path | None:
+ if not manifest_path.exists():
+ return None
+ manifest = read_json_file(path=manifest_path)
+ apps = manifest.get("apps", {})
+ if not isinstance(apps, dict):
+ return None
+ preview = apps.get("preview", {})
+ if not isinstance(preview, dict):
+ return None
+ path_value = preview.get("third_party_licenses_path")
+ if isinstance(path_value, str) and path_value.strip():
+ candidate = Path(path_value).expanduser().resolve()
+ if candidate.exists():
+ return candidate
+ return None
+
+
+def main() -> int:
+ args = parse_args()
+ docs_root = docs_repo_root(explicit_docs_repo=args.docs_repo)
+ work_dir = Path(args.work_dir).expanduser().resolve()
+ manifest_path = (
+ Path(args.manifest).expanduser().resolve()
+ if args.manifest
+ else work_dir / "warp_artifacts.json"
+ )
+
+ output_file = (
+ Path(args.output_file).expanduser().resolve()
+ if args.output_file
+ else docs_root
+ / "src/content/docs/support-and-community/community/open-source-licenses.mdx"
+ )
+
+ licenses_path: Path | None = None
+ if args.licenses_file:
+ candidate = Path(args.licenses_file).expanduser().resolve()
+ if not candidate.exists():
+ raise FileNotFoundError(f"--licenses-file not found: {candidate}")
+ licenses_path = candidate
+ else:
+ licenses_path = _licenses_path_from_manifest(manifest_path=manifest_path)
+
+ if licenses_path is None:
+ if args.dry_run:
+ eprint(
+ "[dry-run] No THIRD_PARTY_LICENSES source found from manifest or "
+ "--licenses-file. Skipping source-dependent license preview.",
+ )
+ eprint(
+ f"[dry-run] Would write {output_file} once a licenses source is available.",
+ )
+ return 0
+ raise FileNotFoundError(
+ "Unable to find THIRD_PARTY_LICENSES.txt. "
+ "Run update_warp_app.py first, or pass --licenses-file explicitly.",
+ )
+
+ licenses_content = licenses_path.read_text(encoding="utf-8")
+ rendered_body, dep_count = _render_licenses_markdown(content=licenses_content)
+
+ existing = output_file.read_text(encoding="utf-8") if output_file.exists() else ""
+ intro = _extract_intro(content=existing)
+ final_output = intro.rstrip() + "\n\n" + rendered_body
+
+ if args.dry_run:
+ eprint(
+ f"[dry-run] Would write {output_file} using {licenses_path} "
+ f"({dep_count} dependency rows).",
+ )
+ else:
+ output_file.write_text(final_output, encoding="utf-8")
+ eprint(f"Wrote licenses doc: {output_file}")
+ eprint(f"Source licenses file: {licenses_path}")
+ eprint(f"Dependency rows written: {dep_count}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
diff --git a/.agents/skills/release_updates/scripts/update_telemetry.py b/.agents/skills/release_updates/scripts/update_telemetry.py
new file mode 100644
index 00000000..873d551c
--- /dev/null
+++ b/.agents/skills/release_updates/scripts/update_telemetry.py
@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+import shlex
+import subprocess
+from pathlib import Path
+from typing import Any
+
+from common import DEFAULT_WORK_DIR
+from common import docs_repo_root
+from common import eprint
+from common import read_json_file
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Regenerate privacy.mdx telemetry table from telemetry JSON.",
+ )
+ parser.add_argument(
+ "--docs-repo",
+ default=None,
+ help="Path to docs repo root (auto-detected if omitted).",
+ )
+ parser.add_argument(
+ "--work-dir",
+ default=str(DEFAULT_WORK_DIR),
+ help=f"Working directory used by update_warp_app.py (default: {DEFAULT_WORK_DIR})",
+ )
+ parser.add_argument(
+ "--manifest",
+ default=None,
+ help="Artifact manifest path (default: /warp_artifacts.json).",
+ )
+ parser.add_argument(
+ "--telemetry-json-file",
+ default=None,
+ help="Use an existing telemetry JSON file instead of running a command.",
+ )
+ parser.add_argument(
+ "--telemetry-command",
+ default=None,
+ help="Command string that prints telemetry JSON to stdout.",
+ )
+ parser.add_argument(
+ "--telemetry-output-file",
+ default=None,
+ help="Where to store fetched telemetry JSON (default: /telemetry.json).",
+ )
+ parser.add_argument(
+ "--output-file",
+ default=None,
+ help="Output privacy doc path (default: docs privacy path).",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Compute and print summary without writing files.",
+ )
+ return parser.parse_args()
+
+
+def _extract_intro(content: str) -> str:
+ marker = "\n### Exhaustive Telemetry Table"
+ index = content.find(marker)
+ if index == -1:
+ return content.rstrip() + "\n"
+ return content[:index].rstrip() + "\n"
+
+
+def _table_markdown(events: dict[str, Any]) -> str:
+ lines: list[str] = [
+ "### Exhaustive Telemetry Table",
+ "",
+ "| Event Name | Description |",
+ "|---|---|",
+ ]
+ for event_name, event_description in events.items():
+ event_name_text = str(event_name).strip()
+ if event_description is None:
+ event_description_text = ""
+ else:
+ event_description_text = str(event_description).rstrip()
+ lines.append(f"| `{event_name_text}` | {event_description_text} |")
+ lines.append("")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def _events_from_manifest(manifest_path: Path) -> tuple[list[str] | None, Path | None]:
+ if not manifest_path.exists():
+ return None, None
+ manifest = read_json_file(path=manifest_path)
+ apps = manifest.get("apps", {})
+ if not isinstance(apps, dict):
+ return None, None
+ preview = apps.get("preview", {})
+ if not isinstance(preview, dict):
+ return None, None
+
+ telemetry_command = preview.get("telemetry_command")
+ command_list: list[str] | None = None
+ if isinstance(telemetry_command, list) and all(
+ isinstance(item, str) for item in telemetry_command
+ ):
+ command_list = list(telemetry_command)
+
+ telemetry_json_path: Path | None = None
+ telemetry_json_value = preview.get("telemetry_json_path")
+ if isinstance(telemetry_json_value, str) and telemetry_json_value.strip():
+ candidate = Path(telemetry_json_value).expanduser().resolve()
+ if candidate.exists():
+ telemetry_json_path = candidate
+ return command_list, telemetry_json_path
+
+
+def _load_events_from_file(path: Path) -> dict[str, Any]:
+ payload = read_json_file(path=path)
+ return payload
+
+
+def _run_telemetry_command(command: list[str]) -> dict[str, Any]:
+ result = subprocess.run( # nosec B603
+ command,
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=180,
+ )
+ payload = json.loads(result.stdout)
+ if not isinstance(payload, dict):
+ raise ValueError("Telemetry command did not return a JSON object.")
+ return payload
+
+
+def main() -> int:
+ args = parse_args()
+ docs_root = docs_repo_root(explicit_docs_repo=args.docs_repo)
+ work_dir = Path(args.work_dir).expanduser().resolve()
+ manifest_path = (
+ Path(args.manifest).expanduser().resolve()
+ if args.manifest
+ else work_dir / "warp_artifacts.json"
+ )
+
+ output_privacy_file = (
+ Path(args.output_file).expanduser().resolve()
+ if args.output_file
+ else docs_root
+ / "src/content/docs/support-and-community/privacy-and-security/privacy.mdx"
+ )
+ telemetry_output_file = (
+ Path(args.telemetry_output_file).expanduser().resolve()
+ if args.telemetry_output_file
+ else work_dir / "telemetry.json"
+ )
+
+ manifest_command, manifest_telemetry_json = _events_from_manifest(
+ manifest_path=manifest_path,
+ )
+
+ events: dict[str, Any] | None = None
+ source_description = ""
+
+ if args.telemetry_json_file:
+ telemetry_json_path = Path(args.telemetry_json_file).expanduser().resolve()
+ if not telemetry_json_path.exists():
+ raise FileNotFoundError(f"--telemetry-json-file not found: {telemetry_json_path}")
+ events = _load_events_from_file(path=telemetry_json_path)
+ source_description = str(telemetry_json_path)
+ elif manifest_telemetry_json is not None:
+ events = _load_events_from_file(path=manifest_telemetry_json)
+ source_description = str(manifest_telemetry_json)
+ else:
+ command: list[str] | None = None
+ if args.telemetry_command:
+ command = shlex.split(args.telemetry_command)
+ elif manifest_command:
+ command = manifest_command
+
+ if command:
+ if args.dry_run:
+ eprint(
+ "[dry-run] Would execute telemetry command: "
+ f"{shlex.join(command)}",
+ )
+ eprint(
+ f"[dry-run] Would write telemetry JSON file: {telemetry_output_file}",
+ )
+ eprint(
+ f"[dry-run] Would write {output_privacy_file} "
+ "from telemetry command output.",
+ )
+ return 0
+ events = _run_telemetry_command(command=command)
+ source_description = "telemetry command output"
+ if args.dry_run:
+ eprint(
+ f"[dry-run] Would write telemetry JSON file: {telemetry_output_file}",
+ )
+ else:
+ telemetry_output_file.parent.mkdir(parents=True, exist_ok=True)
+ telemetry_output_file.write_text(
+ json.dumps(events, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ elif telemetry_output_file.exists():
+ events = _load_events_from_file(path=telemetry_output_file)
+ source_description = str(telemetry_output_file)
+
+ if events is None:
+ if args.dry_run:
+ eprint(
+ "[dry-run] No telemetry source available from manifest, "
+ "--telemetry-command, --telemetry-json-file, or telemetry.json.",
+ )
+ eprint(
+ f"[dry-run] Would write {output_privacy_file} "
+ "once telemetry source input is available.",
+ )
+ return 0
+ raise RuntimeError(
+ "No telemetry source available. "
+ "Run update_warp_app.py first, pass --telemetry-command, "
+ "or pass --telemetry-json-file.",
+ )
+
+ existing_privacy = output_privacy_file.read_text(encoding="utf-8")
+ intro = _extract_intro(content=existing_privacy)
+ telemetry_section = _table_markdown(events=events)
+ final_output = intro.rstrip() + "\n\n" + telemetry_section
+
+ if args.dry_run:
+ eprint(
+ f"[dry-run] Would write {output_privacy_file} with {len(events)} telemetry rows "
+ f"from {source_description}.",
+ )
+ else:
+ output_privacy_file.write_text(final_output, encoding="utf-8")
+ eprint(f"Wrote telemetry doc: {output_privacy_file}")
+ eprint(f"Telemetry source: {source_description}")
+ eprint(f"Telemetry rows written: {len(events)}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
diff --git a/.agents/skills/release_updates/scripts/update_warp_app.py b/.agents/skills/release_updates/scripts/update_warp_app.py
new file mode 100644
index 00000000..b5aa5707
--- /dev/null
+++ b/.agents/skills/release_updates/scripts/update_warp_app.py
@@ -0,0 +1,370 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import ctypes
+import platform
+import shutil
+import subprocess
+from pathlib import Path
+from typing import Any
+from urllib.request import Request
+from urllib.request import urlopen
+
+from common import DEFAULT_WORK_DIR
+from common import USER_AGENT
+from common import eprint
+from common import utc_now_iso
+from common import write_json_file
+
+APP_DOWNLOAD_CONFIG = {
+ "stable": {
+ "channel": "stable",
+ "package_x86_64": "appimage",
+ "package_arm64": "appimage_arm64",
+ },
+ "preview": {
+ "channel": "preview",
+ "package_x86_64": "appimage",
+ "package_arm64": "appimage_arm64",
+ },
+}
+
+
+def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
+ return subprocess.run( # nosec B603
+ command,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+
+def _has_libasound() -> bool:
+ try:
+ ctypes.CDLL("libasound.so.2")
+ return True
+ except OSError:
+ return False
+
+
+def _install_with_apt() -> str:
+ update_result = _run_command(["apt-get", "update"])
+ if update_result.returncode != 0:
+ raise RuntimeError(
+ "apt-get update failed while preflighting libasound dependency:\n"
+ f"{update_result.stderr.strip() or update_result.stdout.strip()}",
+ )
+
+ for package_name in ("libasound2", "libasound2t64"):
+ install_result = _run_command(["apt-get", "install", "-y", package_name])
+ if install_result.returncode == 0:
+ return package_name
+
+ raise RuntimeError(
+ "Unable to install an apt package providing libasound.so.2. "
+ "Tried: libasound2, libasound2t64.",
+ )
+
+
+def _install_with_dnf_like(package_manager: str) -> str:
+ package_name = "alsa-lib"
+ install_result = _run_command([package_manager, "install", "-y", package_name])
+ if install_result.returncode != 0:
+ raise RuntimeError(
+ f"{package_manager} install failed while preflighting libasound:\n"
+ f"{install_result.stderr.strip() or install_result.stdout.strip()}",
+ )
+ return package_name
+
+
+def _install_with_apk() -> str:
+ package_name = "alsa-lib"
+ install_result = _run_command(["apk", "add", "--no-cache", package_name])
+ if install_result.returncode != 0:
+ raise RuntimeError(
+ "apk add failed while preflighting libasound:\n"
+ f"{install_result.stderr.strip() or install_result.stdout.strip()}",
+ )
+ return package_name
+
+
+def _install_with_pacman() -> str:
+ package_name = "alsa-lib"
+ install_result = _run_command(["pacman", "-Sy", "--noconfirm", package_name])
+ if install_result.returncode != 0:
+ raise RuntimeError(
+ "pacman install failed while preflighting libasound:\n"
+ f"{install_result.stderr.strip() or install_result.stdout.strip()}",
+ )
+ return package_name
+
+
+def _install_libasound_dependency() -> str:
+ if shutil.which("apt-get"):
+ return _install_with_apt()
+ if shutil.which("dnf"):
+ return _install_with_dnf_like(package_manager="dnf")
+ if shutil.which("yum"):
+ return _install_with_dnf_like(package_manager="yum")
+ if shutil.which("apk"):
+ return _install_with_apk()
+ if shutil.which("pacman"):
+ return _install_with_pacman()
+
+ raise RuntimeError(
+ "Could not detect a supported package manager to install libasound "
+ "(tried apt-get, dnf, yum, apk, pacman).",
+ )
+
+
+def _preflight_telemetry_dependency(
+ *,
+ enabled: bool,
+ auto_install: bool,
+ dry_run: bool,
+) -> None:
+ if not enabled:
+ return
+
+ if platform.system() != "Linux":
+ return
+
+ if _has_libasound():
+ eprint("Preflight passed: libasound.so.2 is available.")
+ return
+
+ if dry_run:
+ eprint(
+ "[dry-run] Preflight detected missing libasound.so.2. "
+ "Install it or rerun with --auto-install-missing-dependency.",
+ )
+ return
+
+ if not auto_install:
+ raise RuntimeError(
+ "Missing required runtime dependency: libasound.so.2. "
+ "Install an ALSA package for your distro "
+ "(apt commonly: libasound2 or libasound2t64), "
+ "or rerun with --auto-install-missing-dependency.",
+ )
+
+ installed_package = _install_libasound_dependency()
+ eprint(
+ "Preflight installed missing ALSA runtime package: "
+ f"{installed_package}",
+ )
+ if not _has_libasound():
+ raise RuntimeError(
+ "Attempted to install libasound dependency, but libasound.so.2 "
+ "is still unavailable.",
+ )
+ eprint("Preflight passed after installation: libasound.so.2 is available.")
+
+
+def _detect_arch() -> str:
+ machine = platform.machine().lower()
+ if machine in {"aarch64", "arm64"}:
+ return "arm64"
+ return "x86_64"
+
+
+def _download_url(package_name: str, channel: str) -> str:
+ if channel == "preview":
+ return f"https://app.warp.dev/download?package={package_name}&channel=preview"
+ return f"https://app.warp.dev/download?package={package_name}"
+
+
+def _download_file(url: str, output_path: Path, dry_run: bool) -> str | None:
+ if dry_run:
+ eprint(f"[dry-run] Would download: {url} -> {output_path}")
+ return None
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ request = Request(url=url, headers={"User-Agent": USER_AGENT})
+ with urlopen(request, timeout=180) as response: # nosec B310
+ resolved_url = response.geturl()
+ with output_path.open("wb") as handle:
+ while True:
+ chunk = response.read(1024 * 1024)
+ if not chunk:
+ break
+ handle.write(chunk)
+ output_path.chmod(0o755)
+ return resolved_url
+
+
+def _extract_appimage(appimage_path: Path, destination_dir: Path, dry_run: bool) -> Path | None:
+ if dry_run:
+ eprint(f"[dry-run] Would extract AppImage: {appimage_path}")
+ return None
+
+ destination_dir.mkdir(parents=True, exist_ok=True)
+ subprocess.run( # nosec B603
+ [str(appimage_path), "--appimage-extract"],
+ cwd=destination_dir,
+ check=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ extracted_root = destination_dir / "squashfs-root"
+ if not extracted_root.exists():
+ return None
+ return extracted_root
+
+
+def _find_file(root: Path, filename: str) -> Path | None:
+ for candidate in root.rglob(filename):
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+def _build_telemetry_command(appimage_path: Path) -> list[str]:
+ return [
+ str(appimage_path),
+ "--appimage-extract-and-run",
+ "--print-telemetry-events",
+ ]
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Download and prepare latest Warp Linux artifacts for release docs updates.",
+ )
+ parser.add_argument(
+ "--work-dir",
+ default=str(DEFAULT_WORK_DIR),
+ help=f"Working directory for downloaded artifacts (default: {DEFAULT_WORK_DIR})",
+ )
+ parser.add_argument(
+ "--manifest",
+ default=None,
+ help="Path to write artifact manifest JSON (default: /warp_artifacts.json)",
+ )
+ parser.add_argument(
+ "--apps",
+ nargs="+",
+ choices=["stable", "preview"],
+ default=["stable", "preview"],
+ help="Which app channels to prepare",
+ )
+ parser.add_argument(
+ "--skip-extract",
+ action="store_true",
+ help="Skip AppImage extraction even on Linux.",
+ )
+ parser.add_argument(
+ "--skip-dependency-preflight",
+ action="store_true",
+ help=(
+ "Skip preflight checks for Linux telemetry runtime dependencies "
+ "(libasound.so.2)."
+ ),
+ )
+ parser.add_argument(
+ "--auto-install-missing-dependency",
+ action="store_true",
+ help=(
+ "If libasound.so.2 is missing on Linux, attempt automatic installation "
+ "using an available package manager."
+ ),
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Print actions without downloading or extracting.",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ work_dir = Path(args.work_dir).expanduser().resolve()
+ manifest_path = (
+ Path(args.manifest).expanduser().resolve()
+ if args.manifest
+ else work_dir / "warp_artifacts.json"
+ )
+
+ arch = _detect_arch()
+ is_linux = platform.system() == "Linux"
+ should_extract = is_linux and not args.skip_extract
+ if not is_linux and not args.skip_extract:
+ eprint(
+ "Non-Linux system detected; extraction is skipped by default. "
+ "Use Linux/Oz for full extraction + telemetry/license discovery.",
+ )
+ _preflight_telemetry_dependency(
+ enabled=("preview" in args.apps and not args.skip_dependency_preflight),
+ auto_install=args.auto_install_missing_dependency,
+ dry_run=args.dry_run,
+ )
+
+ manifest: dict[str, Any] = {
+ "generated_at_utc": utc_now_iso(),
+ "platform": platform.platform(),
+ "system": platform.system(),
+ "arch": arch,
+ "work_dir": str(work_dir),
+ "apps": {},
+ }
+
+ for app_name in args.apps:
+ config = APP_DOWNLOAD_CONFIG[app_name]
+ package_key = "package_arm64" if arch == "arm64" else "package_x86_64"
+ package_name = config[package_key]
+ channel = config["channel"]
+ download_url = _download_url(package_name=package_name, channel=channel)
+
+ app_dir = work_dir / app_name
+ artifact_path = app_dir / f"{app_name}.AppImage"
+ eprint(f"Preparing {app_name}: {download_url}")
+ resolved_url = _download_file(
+ url=download_url,
+ output_path=artifact_path,
+ dry_run=args.dry_run,
+ )
+
+ extracted_root: Path | None = None
+ licenses_path: Path | None = None
+ if should_extract and not args.dry_run:
+ extracted_root = _extract_appimage(
+ appimage_path=artifact_path,
+ destination_dir=app_dir / "extracted",
+ dry_run=False,
+ )
+ if extracted_root:
+ licenses_path = _find_file(
+ root=extracted_root,
+ filename="THIRD_PARTY_LICENSES.txt",
+ )
+
+ manifest["apps"][app_name] = {
+ "channel": channel,
+ "package": package_name,
+ "download_url": download_url,
+ "resolved_url": resolved_url,
+ "artifact_path": str(artifact_path),
+ "extracted_dir": str(extracted_root) if extracted_root else None,
+ "third_party_licenses_path": str(licenses_path) if licenses_path else None,
+ "telemetry_command": (
+ _build_telemetry_command(appimage_path=artifact_path)
+ if app_name == "preview"
+ else None
+ ),
+ }
+
+ if args.dry_run:
+ eprint(f"[dry-run] Would write manifest: {manifest_path}")
+ else:
+ write_json_file(path=manifest_path, payload=manifest)
+ eprint(f"Wrote manifest: {manifest_path}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
diff --git a/src/content/docs/changelog/2026.mdx b/src/content/docs/changelog/2026.mdx
index aea71495..a5fd33fb 100644
--- a/src/content/docs/changelog/2026.mdx
+++ b/src/content/docs/changelog/2026.mdx
@@ -6,6 +6,82 @@ description: >-
Submit bugs and feature requests on our [GitHub board!](https://github.com/warpdotdev/Warp/issues/new/choose)
+### 2026.06.03 (v0.2026.06.03.09.49)
+
+**New features**
+
+* Added the installation path into de Windows App Paths Registry ([#10805](https://github.com/warpdotdev/warp/pull/10805)) — [@Cocodrulo](https://github.com/Cocodrulo) ✨
+* Queue multiple follow-up prompts while the agent is working either by using the /queue command, prompt queueing mode (activated via a chip in the warping line), and/or by changing your default prompt submission mode to "Queue until response finishes" in settings. ([#12081](https://github.com/warpdotdev/warp/pull/12081))
+
+**Improvements**
+
+* Add configurable absolute and relative line numbers for code editors. ([#10012](https://github.com/warpdotdev/warp/pull/10012))
+* Added support for warp://action/open_file_editor URIs to open files at specific locations from external apps ([#10233](https://github.com/warpdotdev/warp/pull/10233))
+* Agent requests now include PR and repository metadata, enabling better context-aware responses ([#10391](https://github.com/warpdotdev/warp/pull/10391))
+* Cloud environment creation modal now auto-focuses the name field for quicker setup ([#11233](https://github.com/warpdotdev/warp/pull/11233))
+* Authentication secrets for agent orchestration can now be deleted directly from the selector menu ([#11241](https://github.com/warpdotdev/warp/pull/11241))
+* Conversation usage breakdown now surfaces inference vs. platform credit split ([#11441](https://github.com/warpdotdev/warp/pull/11441))
+* Added command palette entries for toggle settings that were missing across Appearance, Features, Code, Privacy, AI, and other settings pages ([#11512](https://github.com/warpdotdev/warp/pull/11512))
+* Cloud agent setup mode now uses the improved queued prompt UI ([#11547](https://github.com/warpdotdev/warp/pull/11547))
+* Cloud agent sessions can now be started without an initial prompt ([#11573](https://github.com/warpdotdev/warp/pull/11573))
+* Updated /compact-and and /fork-and-compact commands to use the new queued prompts UI ([#11575](https://github.com/warpdotdev/warp/pull/11575))
+* Polished the orchestration pill bar and segmented control UI to better match design specs ([#11578](https://github.com/warpdotdev/warp/pull/11578))
+* Remote project skill locations are now correctly propagated through the UI ([#11581](https://github.com/warpdotdev/warp/pull/11581))
+* Cloud agent conversations now always display the fast-forward chip as enabled, reflecting actual behavior ([#11690](https://github.com/warpdotdev/warp/pull/11690))
+* Added setting to control whether mid-conversation prompts interrupt or queue the agent ([#11746](https://github.com/warpdotdev/warp/pull/11746))
+* Added missing flag completions for `gh pr merge`. ([#11764](https://github.com/warpdotdev/warp/pull/11764))
+* Enabled handoff for locally orchestrated conversations. ([#11768](https://github.com/warpdotdev/warp/pull/11768))
+* Maximized orchestration pane now stays maximized when opening sub-agent conversations ([#11776](https://github.com/warpdotdev/warp/pull/11776))
+* Improved lightbox UX: background click now dismisses correctly, and light theme button rendering is fixed ([#11783](https://github.com/warpdotdev/warp/pull/11783))
+* Reordered the tools panel to show the project explorer first and conversation list second ([#11843](https://github.com/warpdotdev/warp/pull/11843))
+* Remote project skill locations are now correctly resolved in tool output ([#11863](https://github.com/warpdotdev/warp/pull/11863))
+* Added 'Send Now' button to queued prompts for immediate execution ([#11880](https://github.com/warpdotdev/warp/pull/11880))
+* Vertical tab agent icons now reflect active child-agent status for orchestration sessions. ([#11895](https://github.com/warpdotdev/warp/pull/11895))
+* Added dotnet CLI completions ([#11919](https://github.com/warpdotdev/warp/pull/11919))
+* Improved queued prompt hover behavior and drag handle UX ([#12067](https://github.com/warpdotdev/warp/pull/12067))
+
+**Bug fixes**
+
+* Fixed custom themes not syncing correctly across machines when using Settings Sync ([#9728](https://github.com/warpdotdev/warp/pull/9728)) — [@nisavid](https://github.com/nisavid) ✨
+* Fixed MCP install/update modal primary buttons using incorrect button styling ([#10586](https://github.com/warpdotdev/warp/pull/10586))
+* AI responses that contain Markdown tables now render as structured tables in Stable instead of monospaced pipe-table blocks. ([#10683](https://github.com/warpdotdev/warp/pull/10683))
+* Fixed MCP server log files growing unbounded; logs are now rotated to cap disk usage ([#10874](https://github.com/warpdotdev/warp/pull/10874)) — [@david-engelmann](https://github.com/david-engelmann) ✨
+* Fix Windows PowerShell command discovery when localized executable names are emitted in a non-UTF-8 code page. ([#11203](https://github.com/warpdotdev/warp/pull/11203)) — [@cesaryuan](https://github.com/cesaryuan) ✨
+* Fixed 'Maximize Pane' option missing from the overflow menu in Rendered Markdown mode ([#11258](https://github.com/warpdotdev/warp/pull/11258)) — [@wzc520pyfm](https://github.com/wzc520pyfm) ✨
+* Artifact upload failures now surface actionable quota-limit details when upload limits are reached. ([#11386](https://github.com/warpdotdev/warp/pull/11386))
+* Fixed Tab configs: pressing Space while a parameter dropdown is focused now selects the item instead of switching fields ([#11412](https://github.com/warpdotdev/warp/pull/11412))
+* Fixed find highlights lingering on AI blocks after closing the find bar. ([#11458](https://github.com/warpdotdev/warp/pull/11458))
+* Fixed custom LLM models being reset to defaults during preference reconciliation on app startup ([#11655](https://github.com/warpdotdev/warp/pull/11655))
+* Fixed new model choices popup banner overflowing on mobile/thin devices ([#11685](https://github.com/warpdotdev/warp/pull/11685))
+* Shared cloud-agent session details now show a muted no-access notice when run metadata is restricted instead of displaying a raw permission error. ([#11686](https://github.com/warpdotdev/warp/pull/11686))
+* Fixed the team settings owner badge being unreadable on some themes. ([#11689](https://github.com/warpdotdev/warp/pull/11689))
+* Fixed terminal input being incorrectly active during GitHub Actions session shares ([#11691](https://github.com/warpdotdev/warp/pull/11691))
+* Fixed Windows terminal startup failures caused by REG_MULTI_SZ registry environment variables being passed to CreateProcessW ([#11714](https://github.com/warpdotdev/warp/pull/11714))
+* Fixed orchestration session restoration so the pill bar, child transcript name resolution, restored remote-child cloud transcripts, and cloud-parent panes all work correctly after a Warp restart. ([#11722](https://github.com/warpdotdev/warp/pull/11722))
+* Fixed conversation creator profiles not displaying correctly in shared conversations ([#11725](https://github.com/warpdotdev/warp/pull/11725))
+* Fixed a crash when rendering AI reasoning or summaries that contain multiple markdown images. ([#11766](https://github.com/warpdotdev/warp/pull/11766))
+* Clarify BYOK / Custom inference settings copy: API keys are stored only on your device, never on Warp's servers, and used to make requests to your chosen model provider. ([#11780](https://github.com/warpdotdev/warp/pull/11780))
+* Fixed `/app` in Warp-on-Web opening the wrong Agent experience instead of the Cloud Agent start page. ([#11781](https://github.com/warpdotdev/warp/pull/11781))
+* Fixed working directories not being cleaned up correctly after code review sessions ([#11789](https://github.com/warpdotdev/warp/pull/11789))
+* Fixed duplicate codebase indexing entries appearing for remote server connections ([#11792](https://github.com/warpdotdev/warp/pull/11792))
+* Fixed orchestration pill bar hover card showing the wrong harness for non-Warp child agents (e.g. Claude Code). ([#11800](https://github.com/warpdotdev/warp/pull/11800))
+* Fixed Continue button remaining active when the New API key form is incomplete ([#11802](https://github.com/warpdotdev/warp/pull/11802))
+* Removed built-in feedback skill; the /feedback command now consistently opens the external feedback form ([#11806](https://github.com/warpdotdev/warp/pull/11806))
+* Fixed a crash when the agent's context window limit is exceeded ([#11813](https://github.com/warpdotdev/warp/pull/11813))
+* Fixed agent conversations sometimes failing to restore after restarting Warp ([#11814](https://github.com/warpdotdev/warp/pull/11814))
+* Fix find match highlight position in initial /agent queries ([#11823](https://github.com/warpdotdev/warp/pull/11823)) — [@AndreKalberer](https://github.com/AndreKalberer) ✨
+* Fixed queued prompts panel appearing alongside inline menus, causing visual overlap ([#11848](https://github.com/warpdotdev/warp/pull/11848))
+* Fixed symlinked gitignored paths not being handled correctly in code review ([#11856](https://github.com/warpdotdev/warp/pull/11856))
+* Fixed duplicated leading characters in wrapped zsh command blocks when using the Warp prompt. ([#11868](https://github.com/warpdotdev/warp/pull/11868))
+* Fixed the Accept button staying enabled in the multi-agent run card when "New API key" was selected in the harness API key picker without a key being created. ([#11904](https://github.com/warpdotdev/warp/pull/11904))
+* Fixed billing usage display showing incorrect per-user base credit limits ([#11910](https://github.com/warpdotdev/warp/pull/11910))
+* Fixed apply-diff/edit-file sending full file contents instead of only changed ranges after accepting edits ([#11987](https://github.com/warpdotdev/warp/pull/11987))
+* Fixed duplicate command palette entries for mouse reporting. ([#12011](https://github.com/warpdotdev/warp/pull/12011))
+* Fixed project skill refreshes causing UI stalls when unrelated files change in large repositories. ([#12040](https://github.com/warpdotdev/warp/pull/12040))
+* Fixed the orchestration pill bar's scrollbar overlapping the agent chips. ([#12045](https://github.com/warpdotdev/warp/pull/12045))
+* Fixed a crash when trying to use the microphone on macOS ([#12074](https://github.com/warpdotdev/warp/pull/12074))
+* Fixed several queued prompt issues: /queue command behavior in empty conversations and other paper cuts ([#12105](https://github.com/warpdotdev/warp/pull/12105))
+
### 2026.05.27 (v0.2026.05.27.15.44)
**New features**
diff --git a/src/content/docs/support-and-community/community/open-source-licenses.mdx b/src/content/docs/support-and-community/community/open-source-licenses.mdx
index 161c3711..340e9f93 100644
--- a/src/content/docs/support-and-community/community/open-source-licenses.mdx
+++ b/src/content/docs/support-and-community/community/open-source-licenses.mdx
@@ -6,7 +6,6 @@ sidebar:
label: "Open Source Licenses"
---
-
## Dependencies
| Dependency | License |
@@ -24,6 +23,7 @@ sidebar:
| ctutils 0.4.2 | Apache License 2.0 |
| embed_plist 1.2.2 | Apache License 2.0 |
| encoding_rs 0.8.35 | Apache License 2.0 |
+| fragile 2.0.0 | Apache License 2.0 |
| hound 3.5.1 | Apache License 2.0 |
| iri-string 0.7.8 | Apache License 2.0 |
| kurbo 0.13.0 | Apache License 2.0 |
@@ -125,6 +125,7 @@ sidebar:
| bytecount 0.6.9 | Apache License 2.0 |
| notify-rust 4.11.7 | Apache License 2.0 |
| pinned 0.1.0 | Apache License 2.0 |
+| predicates 3.1.3 | Apache License 2.0 |
| unicode-general-category 1.1.0 | Apache License 2.0 |
| winapi 0.3.9 | Apache License 2.0 |
| wio 0.2.2 | Apache License 2.0 |
@@ -341,8 +342,9 @@ sidebar:
| markup5ever 0.35.0 | Apache License 2.0 |
| markup5ever_rcdom 0.35.0+unofficial | Apache License 2.0 |
| memo-map 0.3.2 | Apache License 2.0 |
-| metal 0.33.0 | Apache License 2.0 |
| mime 0.3.17 | Apache License 2.0 |
+| mockall 0.13.1 | Apache License 2.0 |
+| mockall_derive 0.13.1 | Apache License 2.0 |
| num-bigint 0.4.6 | Apache License 2.0 |
| num-complex 0.4.6 | Apache License 2.0 |
| num-derive 0.4.2 | Apache License 2.0 |
@@ -649,7 +651,8 @@ sidebar:
| num_enum_derive 0.7.4 | Apache License 2.0 |
| objc2-app-kit 0.3.2 | Apache License 2.0 |
| objc2-audio-toolbox 0.3.0 | Apache License 2.0 |
-| objc2-avf-audio 0.3.0 | Apache License 2.0 |
+| objc2-av-foundation 0.3.2 | Apache License 2.0 |
+| objc2-avf-audio 0.3.2 | Apache License 2.0 |
| objc2-core-audio-types 0.3.2 | Apache License 2.0 |
| objc2-core-audio 0.3.2 | Apache License 2.0 |
| objc2-core-foundation 0.3.2 | Apache License 2.0 |
@@ -670,6 +673,8 @@ sidebar:
| pin-project 1.1.3 | Apache License 2.0 |
| portable-atomic-util 0.2.4 | Apache License 2.0 |
| portable-atomic 1.11.1 | Apache License 2.0 |
+| predicates-core 1.0.6 | Apache License 2.0 |
+| predicates-tree 1.0.9 | Apache License 2.0 |
| prettyplease 0.2.37 | Apache License 2.0 |
| proc-macro2 1.0.106 | Apache License 2.0 |
| process-wrap 9.1.0 | Apache License 2.0 |
@@ -823,6 +828,7 @@ sidebar:
| kqueue-sys 1.0.4 | MIT License |
| kqueue 1.0.8 | MIT License |
| async-tungstenite 0.28.2 | MIT License |
+| termtree 0.4.1 | MIT License |
| memoffset 0.7.1 | MIT License |
| memoffset 0.9.0 | MIT License |
| redox_syscall 0.2.16 | MIT License |
@@ -1031,6 +1037,7 @@ sidebar:
| crossterm_winapi 0.9.1 | MIT License |
| line-span 0.1.5 | MIT License |
| fluent-uri 0.1.4 | MIT License |
+| downcast 0.11.0 | MIT License |
| endi 1.1.0 | MIT License |
| is-terminal 0.4.17 | MIT License |
| unsafe-libyaml 0.2.11 | MIT License |
@@ -2454,6 +2461,7 @@ Apache License 2.0
- ctutils 0.4.2
- embed_plist 1.2.2
- encoding_rs 0.8.35
+ - fragile 2.0.0
- hound 3.5.1
- iri-string 0.7.8
- kurbo 0.13.0
@@ -6443,6 +6451,7 @@ Apache License 2.0
- bytecount 0.6.9
- notify-rust 4.11.7
- pinned 0.1.0
+ - predicates 3.1.3
- unicode-general-category 1.1.0
- winapi 0.3.9
- wio 0.2.2
@@ -12573,8 +12582,9 @@ Apache License 2.0
- markup5ever 0.35.0
- markup5ever_rcdom 0.35.0+unofficial
- memo-map 0.3.2
- - metal 0.33.0
- mime 0.3.17
+ - mockall 0.13.1
+ - mockall_derive 0.13.1
- num-bigint 0.4.6
- num-complex 0.4.6
- num-derive 0.4.2
@@ -17725,7 +17735,8 @@ Apache License 2.0
- num_enum_derive 0.7.4
- objc2-app-kit 0.3.2
- objc2-audio-toolbox 0.3.0
- - objc2-avf-audio 0.3.0
+ - objc2-av-foundation 0.3.2
+ - objc2-avf-audio 0.3.2
- objc2-core-audio-types 0.3.2
- objc2-core-audio 0.3.2
- objc2-core-foundation 0.3.2
@@ -17746,6 +17757,8 @@ Apache License 2.0
- pin-project 1.1.3
- portable-atomic-util 0.2.4
- portable-atomic 1.11.1
+ - predicates-core 1.0.6
+ - predicates-tree 1.0.9
- prettyplease 0.2.37
- proc-macro2 1.0.106
- process-wrap 9.1.0
@@ -19424,6 +19437,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+MIT License
+ - termtree 0.4.1
+--------------------------------------------------------------------------------
+Copyright (c) 2017 Doug Tangren
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
MIT License
- memoffset 0.7.1
- memoffset 0.9.0
@@ -21859,6 +21897,20 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+MIT License
+ - downcast 0.11.0
+--------------------------------------------------------------------------------
+MIT License (MIT)
+
+Copyright (c) 2017 Felix Köpge
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
MIT License
- endi 1.1.0
- is-terminal 0.4.17
diff --git a/src/content/docs/support-and-community/privacy-and-security/privacy.mdx b/src/content/docs/support-and-community/privacy-and-security/privacy.mdx
index f5fd1ceb..05c7ab30 100644
--- a/src/content/docs/support-and-community/privacy-and-security/privacy.mdx
+++ b/src/content/docs/support-and-community/privacy-and-security/privacy.mdx
@@ -66,7 +66,6 @@ Deletion jobs run every 24 hours, so if you deleted your account and want to sig
If you're a [Team](/knowledge-and-collaboration/teams/) admin, the deletion flow will require that you assign a team member as the new admin.
:::
-
### Exhaustive Telemetry Table
| Event Name | Description |
@@ -176,7 +175,7 @@ If you're a [Team](/knowledge-and-collaboration/teams/) admin, the deletion flow
| `AgentMode.SyncCodebaseContext.BuildTree.Success` | Successfully built merkle tree for codebase context |
| `AgentMode.SyncCodebaseContext.Failed` | Failed to sync codebase context |
| `AgentMode.SyncCodebaseContext.Success` | Successfully synced codebase context |
-| `AgentMode.ToggleAutoDetectionSetting` | Toggled the setting that enables or disables natural language auto-detection in the input. |
+| `AgentMode.ToggleAutoDetectionSetting` | Toggled the setting that enables or disables natural language auto-detection in the input. |
| `AgentNotification.Shown` | An agent notification was shown to the user (toast or mailbox) |
| `AgentTip Clicked` | User clicked a link or action in an Agent Tip |
| `AgentTip Shown` | Selected an Agent Tip to show in the Agent Mode status bar |
@@ -380,6 +379,7 @@ If you're a [Team](/knowledge-and-collaboration/teams/) admin, the deletion flow
| `Dismiss Welcome Tips` | Dismissed Welcome tips |
| `Don't Show Sharer Grant Modal Again` | When you check don't show again on the confirmation modal for granting a role |
| `Drag and Drop Tab` | Tab dragged and dropped |
+| `Drag and Drop Tab Group` | Tab group dragged and dropped |
| `Duplicate Object` | Cloned a Warp Drive object |
| `Edited Input Before Precmd` | Input edited before precmd hook completes |
| `Edited Workflow Alias Argument` | Edited an argument in a Warp Drive workflow alias |
@@ -503,6 +503,10 @@ If you're a [Team](/knowledge-and-collaboration/teams/) admin, the deletion flow
| `Prompt Edited` | Edited the prompt using the built-in prompt editor |
| `Prompt Editor Opened` | Opened the prompt editor |
| `Pty Spawned` | Tracks the manner by which we create a new shell process (new codepath vs. old codepath). Used to ensure nothing breaks as we change parts of our infrastructure. |
+| `QueuedPrompt.Deleted` | User deleted a queued prompt row |
+| `QueuedPrompt.Edited` | User committed a non-empty edit to a queued prompt row |
+| `QueuedPrompt.PanelCollapseToggled` | User toggled the queued prompts panel collapse state |
+| `QueuedPrompt.Reordered` | User reordered a queued prompt row via drag-and-drop |
| `Quit Modal Cancel Pressed` | `Cancel` button on the alert modal was pressed |
| `Quit Modal Disabled` | The quit modal dialog has been disabled and will not popup when a user closes Warp while a session is running |
| `Quit Modal Shown` | Showed an alert modal to warn the user about closing the app/window with a running process |
@@ -687,4 +691,3 @@ If you're a [Team](/knowledge-and-collaboration/teams/) admin, the deletion flow
| `revenue.AutoReloadModalClosed` | User closed the auto-reload modal (either dismissed or enabled auto-reload) |
| `revenue.AutoReloadToggledFromBillingSettings` | User toggled auto-reload in Billing & Usage settings |
| `revenue.OutOfCreditsBannerClosed` | User closed the 'Out of credits' banner (dismissed or purchased credits) |
-