|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Wait for GitHub Actions associated with the current HEAD commit.""" |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import json |
| 6 | +import os |
| 7 | +import re |
| 8 | +import shutil |
| 9 | +import subprocess |
| 10 | +import sys |
| 11 | +import time |
| 12 | +from pathlib import Path |
| 13 | +from typing import Dict, List, Tuple |
| 14 | +from urllib.error import HTTPError, URLError |
| 15 | +from urllib.request import Request, urlopen |
| 16 | + |
| 17 | +POLL_INTERVAL = max(int(os.getenv("POLL_INTERVAL", "10")), 1) |
| 18 | +POLL_TIMEOUT = max(int(os.getenv("POLL_TIMEOUT", "0")), 0) |
| 19 | +GIT_REMOTE = os.getenv("GIT_REMOTE", "origin") |
| 20 | +SUCCESS_CONCLUSIONS = {"success", "neutral", "skipped"} |
| 21 | + |
| 22 | +DEVNULL = subprocess.DEVNULL |
| 23 | + |
| 24 | + |
| 25 | +def run_git(*args: str) -> str: |
| 26 | + try: |
| 27 | + return subprocess.check_output(["git", *args], text=True).strip() |
| 28 | + except subprocess.CalledProcessError: |
| 29 | + print("wait-for-github-actions: failed to run git", file=sys.stderr) |
| 30 | + sys.exit(1) |
| 31 | + |
| 32 | + |
| 33 | +def ensure_git_repo() -> None: |
| 34 | + try: |
| 35 | + subprocess.check_call( |
| 36 | + ["git", "rev-parse", "--is-inside-work-tree"], |
| 37 | + stdout=DEVNULL, |
| 38 | + stderr=DEVNULL, |
| 39 | + ) |
| 40 | + except subprocess.CalledProcessError: |
| 41 | + print("wait-for-github-actions: not inside a git repository", file=sys.stderr) |
| 42 | + sys.exit(1) |
| 43 | + |
| 44 | + |
| 45 | +def resolve_repo() -> Tuple[str, str]: |
| 46 | + remote_url = run_git("remote", "get-url", GIT_REMOTE) |
| 47 | + match = re.search(r"github.com[:/]+([^/]+)/(.+)$", remote_url) |
| 48 | + if not match: |
| 49 | + print( |
| 50 | + f"wait-for-github-actions: remote '{remote_url}' is not a GitHub repository", |
| 51 | + file=sys.stderr, |
| 52 | + ) |
| 53 | + sys.exit(1) |
| 54 | + |
| 55 | + owner = match.group(1) |
| 56 | + repo = match.group(2) |
| 57 | + if repo.endswith(".git"): |
| 58 | + repo = repo[:-4] |
| 59 | + return owner, repo |
| 60 | + |
| 61 | + |
| 62 | +def resolve_dotfiles_path(script_path: Path) -> Path: |
| 63 | + env_path = os.getenv("DOTFILES_PATH") |
| 64 | + if env_path: |
| 65 | + return Path(env_path) |
| 66 | + return script_path.parent.parent |
| 67 | + |
| 68 | + |
| 69 | +def load_token(dotfiles_path: Path) -> str: |
| 70 | + token = os.getenv("GITHUB_TOKEN") |
| 71 | + if token: |
| 72 | + return token.strip() |
| 73 | + |
| 74 | + token_path = dotfiles_path / ".github-token" |
| 75 | + if token_path.exists(): |
| 76 | + return token_path.read_text(encoding="utf-8").strip() |
| 77 | + |
| 78 | + print( |
| 79 | + "wait-for-github-actions: missing GitHub token. Set GITHUB_TOKEN or create " |
| 80 | + f"{token_path}", |
| 81 | + file=sys.stderr, |
| 82 | + ) |
| 83 | + sys.exit(1) |
| 84 | + |
| 85 | + |
| 86 | +def github_request(url: str, token: str) -> Dict: |
| 87 | + headers = { |
| 88 | + "Authorization": f"Bearer {token}", |
| 89 | + "Accept": "application/vnd.github+json", |
| 90 | + "User-Agent": "wait-for-github-actions", |
| 91 | + } |
| 92 | + |
| 93 | + request = Request(url, headers=headers) |
| 94 | + try: |
| 95 | + with urlopen(request) as response: |
| 96 | + body = response.read() |
| 97 | + status_code = response.getcode() |
| 98 | + except HTTPError as exc: # pragma: no cover - network failure path |
| 99 | + body = exc.read() |
| 100 | + print( |
| 101 | + f"wait-for-github-actions: GitHub API returned status {exc.code}", |
| 102 | + file=sys.stderr, |
| 103 | + ) |
| 104 | + if body: |
| 105 | + print(body.decode("utf-8", errors="replace"), file=sys.stderr) |
| 106 | + sys.exit(1) |
| 107 | + except URLError as exc: # pragma: no cover - network failure path |
| 108 | + print(f"wait-for-github-actions: failed to reach GitHub: {exc}", file=sys.stderr) |
| 109 | + sys.exit(1) |
| 110 | + |
| 111 | + if status_code >= 400: |
| 112 | + print( |
| 113 | + f"wait-for-github-actions: GitHub API returned status {status_code}", |
| 114 | + file=sys.stderr, |
| 115 | + ) |
| 116 | + print(body.decode("utf-8", errors="replace"), file=sys.stderr) |
| 117 | + sys.exit(1) |
| 118 | + |
| 119 | + try: |
| 120 | + return json.loads(body.decode("utf-8")) |
| 121 | + except json.JSONDecodeError: |
| 122 | + print("wait-for-github-actions: failed to parse GitHub response", file=sys.stderr) |
| 123 | + print(body.decode("utf-8", errors="replace"), file=sys.stderr) |
| 124 | + sys.exit(1) |
| 125 | + |
| 126 | + |
| 127 | +def build_summary(runs: List[Dict]) -> str: |
| 128 | + lines: List[str] = [] |
| 129 | + for run in runs: |
| 130 | + name = run.get("name") or run.get("display_title") or str(run.get("id")) |
| 131 | + event = run.get("event") or "?" |
| 132 | + status = run.get("status") or "?" |
| 133 | + conclusion = run.get("conclusion") or "?" |
| 134 | + url = run.get("html_url") or "" |
| 135 | + lines.append( |
| 136 | + f"- {name} [{event}] status={status} conclusion={conclusion}" |
| 137 | + + (f" -> {url}" if url else "") |
| 138 | + ) |
| 139 | + return "\n".join(lines) if lines else "Waiting for workflow runs to start..." |
| 140 | + |
| 141 | + |
| 142 | +def compute_state(runs: List[Dict]) -> str: |
| 143 | + if not runs: |
| 144 | + return "empty" |
| 145 | + |
| 146 | + all_completed = True |
| 147 | + has_failure = False |
| 148 | + |
| 149 | + for run in runs: |
| 150 | + status = (run.get("status") or "").lower() |
| 151 | + conclusion = (run.get("conclusion") or "").lower() |
| 152 | + |
| 153 | + if status != "completed": |
| 154 | + all_completed = False |
| 155 | + elif conclusion not in SUCCESS_CONCLUSIONS: |
| 156 | + has_failure = True |
| 157 | + |
| 158 | + if all_completed: |
| 159 | + return "failure" if has_failure else "success" |
| 160 | + return "in_progress" |
| 161 | + |
| 162 | + |
| 163 | +def maybe_play_sound(dotfiles_path: Path, sound_name: str) -> None: |
| 164 | + player = shutil.which("afplay") |
| 165 | + if not player: |
| 166 | + return |
| 167 | + |
| 168 | + sound_map = { |
| 169 | + "success": dotfiles_path / "sounds" / "alert.mp3", |
| 170 | + "failure": dotfiles_path / "sounds" / "bark.aiff", |
| 171 | + } |
| 172 | + sound_path = sound_map.get(sound_name) |
| 173 | + if not sound_path or not sound_path.exists(): |
| 174 | + return |
| 175 | + |
| 176 | + subprocess.Popen([player, str(sound_path)], stdout=DEVNULL, stderr=DEVNULL) |
| 177 | + |
| 178 | + |
| 179 | +def maybe_notify(title: str, message: str) -> None: |
| 180 | + notifier = shutil.which("osascript") |
| 181 | + if not notifier: |
| 182 | + return |
| 183 | + |
| 184 | + title_escaped = title.replace("\"", "\\\"") |
| 185 | + message_escaped = message.replace("\"", "\\\"") |
| 186 | + script = f'display notification "{message_escaped}" with title "{title_escaped}"' |
| 187 | + subprocess.Popen([notifier, "-e", script], stdout=DEVNULL, stderr=DEVNULL) |
| 188 | + |
| 189 | + |
| 190 | +def main() -> int: |
| 191 | + ensure_git_repo() |
| 192 | + |
| 193 | + current_branch = run_git("rev-parse", "--abbrev-ref", "HEAD") |
| 194 | + current_sha = run_git("rev-parse", "HEAD") |
| 195 | + |
| 196 | + owner, repo = resolve_repo() |
| 197 | + |
| 198 | + script_path = Path(__file__).resolve() |
| 199 | + dotfiles_path = resolve_dotfiles_path(script_path) |
| 200 | + token = load_token(dotfiles_path) |
| 201 | + |
| 202 | + print( |
| 203 | + f"Waiting for GitHub Actions on {owner}/{repo} @ {current_sha[:7]} ({current_branch})" |
| 204 | + ) |
| 205 | + |
| 206 | + api_url = ( |
| 207 | + f"https://api.github.com/repos/{owner}/{repo}/actions/runs" |
| 208 | + f"?per_page=50&head_sha={current_sha}" |
| 209 | + ) |
| 210 | + |
| 211 | + start_time = time.monotonic() |
| 212 | + last_summary: str | None = None |
| 213 | + |
| 214 | + while True: |
| 215 | + data = github_request(api_url, token) |
| 216 | + runs = [ |
| 217 | + run |
| 218 | + for run in data.get("workflow_runs", []) |
| 219 | + if run.get("head_sha") == current_sha |
| 220 | + ] |
| 221 | + |
| 222 | + state = compute_state(runs) |
| 223 | + summary = build_summary(runs) if runs else "Waiting for workflow runs to start..." |
| 224 | + |
| 225 | + if summary != last_summary: |
| 226 | + print(summary) |
| 227 | + last_summary = summary |
| 228 | + |
| 229 | + if state == "success": |
| 230 | + maybe_notify("GitHub Actions", f"✔ {owner}/{repo} {current_branch} succeeded") |
| 231 | + maybe_play_sound(dotfiles_path, "success") |
| 232 | + return 0 |
| 233 | + if state == "failure": |
| 234 | + maybe_notify("GitHub Actions", f"✖ {owner}/{repo} {current_branch} failed") |
| 235 | + maybe_play_sound(dotfiles_path, "failure") |
| 236 | + return 1 |
| 237 | + |
| 238 | + if POLL_TIMEOUT and time.monotonic() - start_time >= POLL_TIMEOUT: |
| 239 | + print( |
| 240 | + f"wait-for-github-actions: timed out after {POLL_TIMEOUT} seconds", |
| 241 | + file=sys.stderr, |
| 242 | + ) |
| 243 | + return 1 |
| 244 | + |
| 245 | + time.sleep(POLL_INTERVAL) |
| 246 | + |
| 247 | + |
| 248 | +if __name__ == "__main__": |
| 249 | + sys.exit(main()) |
0 commit comments