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"
\"{safe_alt_text}\"
", + ) + 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()) +