diff --git a/.agents/skills/release_updates/SKILL.md b/.agents/skills/release_updates/SKILL.md
new file mode 100644
index 0000000..c14b7d6
--- /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 0000000..e580a1f
--- /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 0000000..3d43258
--- /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 0000000..df04c47
--- /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 0000000..9832f19
--- /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 0000000..af5e016
--- /dev/null
+++ b/.agents/skills/release_updates/scripts/update_changelog.py
@@ -0,0 +1,404 @@
+#!/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)
+
+
+def parse_args() -> 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 _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("")
+
+ return "\n".join(lines).rstrip() + "\n"
+
+
+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 _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
+
+ 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 0000000..a08fffe
--- /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 0000000..7108dd8
--- /dev/null
+++ b/.agents/skills/release_updates/scripts/update_telemetry.py
@@ -0,0 +1,247 @@
+#!/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
+from common import sanitize_table_cell
+
+
+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():
+ safe_name = sanitize_table_cell(str(event_name))
+ safe_desc = sanitize_table_cell(str(event_description))
+ lines.append(f"| `{safe_name}` | {safe_desc} |")
+ 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.rstrip() + "\n"
+
+ 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 0000000..b5aa570
--- /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())
+