Skip to content

Commit df635d9

Browse files
committed
added gpsw with bin/wait-for-github-actions
1 parent 1610d1b commit df635d9

File tree

3 files changed

+273
-0
lines changed

3 files changed

+273
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ bin/hub
22
.DS_Store
33
/gource/*
44
scm_breeze
5+
.github-token

bashrc/github.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,29 @@ gpsp() (
2222
[ -f scripts/wait_for_ci_build ] && scripts/wait_for_ci_build
2323
)
2424

25+
# Push and wait for CI (CircleCI if available, otherwise GitHub Actions)
26+
gpsw() (
27+
set -euo pipefail
28+
git push "$@"
29+
30+
if [ -f scripts/wait_for_ci_build ]; then
31+
scripts/wait_for_ci_build
32+
return
33+
fi
34+
35+
if [ -d .github/workflows ]; then
36+
if command -v wait-for-github-actions >/dev/null 2>&1; then
37+
wait-for-github-actions
38+
else
39+
echo "gpsw: wait-for-github-actions script not found in PATH" >&2
40+
return 1
41+
fi
42+
return
43+
fi
44+
45+
echo "No CI actions detected"
46+
)
47+
2548
# Push, open PR, approve staging deploy, and wait for CI build to finish
2649
gpsps() (
2750
set -euo pipefail

bin/wait-for-github-actions

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
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

Comments
 (0)