forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdispatch_mobile_internal_builds.py
More file actions
204 lines (165 loc) · 7.63 KB
/
Copy pathdispatch_mobile_internal_builds.py
File metadata and controls
204 lines (165 loc) · 7.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
#!/usr/bin/env python3
"""Decide and dispatch the Codemagic internal mobile builds.
App changes land on main in bursts, and a build per merge is mostly wasted: the earlier one is
superseded minutes later. Codemagic's own triggering has no rate limit, so the push trigger is
replaced by this: a three-hourly batch that builds only when app code actually changed, plus an
immediate build for an allowlisted author who needs their own change on a device now.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from typing import Any, Iterable, Optional
BUILDS_API = "https://api.codemagic.io/builds"
MOBILE_WORKFLOWS = ("ios-internal-auto", "android-internal-auto")
APP_PATHS = ("app/",)
# Only a build that reached a decision may become the baseline. A failed, cancelled or timed-out
# build leaves its commit unbuilt, so advancing past it would strand a broken merge until the next
# app change happened along. `skipped` counts: Codemagic decided there was nothing to build.
BASELINE_STATUSES = frozenset({"finished", "success", "succeeded", "skipped"})
class DispatchError(Exception):
pass
def normalized_actors(raw: Optional[str]) -> set[str]:
return {actor.strip().lower() for actor in (raw or "").split(",") if actor.strip()}
def decide_dispatch(
*,
event: str,
actor: str,
commit_authors: Iterable[str],
instant_actors: set[str],
has_pending_app_commits: bool,
) -> tuple[bool, str]:
"""Return whether to dispatch now, and the reason recorded in the run summary."""
if event == "workflow_dispatch":
return True, "manual"
if event == "push":
who = {actor.lower()} | {author.lower() for author in commit_authors}
if who & instant_actors:
return True, "instant-actor"
return False, "batched: the three-hourly run picks this up"
if event == "schedule":
if has_pending_app_commits:
return True, "batch: app changes since the last build"
return False, "no app changes since the last build"
return False, f"unsupported event {event}"
def _api_get(url: str, token: str) -> dict[str, Any]:
request = urllib.request.Request(url, headers={"x-auth-token": token})
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode())
except urllib.error.URLError as error:
raise DispatchError(f"Codemagic API read failed: {error}") from error
def build_sha(build: dict[str, Any]) -> Optional[str]:
for key in ("commit", "commitId", "commitHash"):
value = build.get(key)
if isinstance(value, dict):
value = value.get("hash") or value.get("sha")
if isinstance(value, str) and value.strip():
return value.strip()
return None
def newest_built_sha(builds: Iterable[dict[str, Any]]) -> Optional[str]:
"""Commit of the most recent build that settled, by createdAt rather than list position.
The API's ordering is not part of any contract we rely on elsewhere, and reading the wrong
build here would compare against an old commit and dispatch on every scheduled run.
"""
candidates = [
(str(b.get("createdAt") or ""), sha)
for b in builds
if str(b.get("status") or "").lower() in BASELINE_STATUSES and (sha := build_sha(b))
]
if not candidates:
return None
return max(candidates, key=lambda item: item[0])[1]
def last_built_sha(app_id: str, workflow_id: str, token: str) -> Optional[str]:
payload = _api_get(f"{BUILDS_API}?appId={app_id}&workflowId={workflow_id}", token)
return newest_built_sha(payload.get("builds") or [])
def is_ancestor(sha: str) -> bool:
result = subprocess.run(
["git", "merge-base", "--is-ancestor", sha, "HEAD"], capture_output=True, check=False
)
return result.returncode == 0
def app_commits_since(sha: Optional[str]) -> list[str]:
"""App-path commits between ``sha`` and HEAD. No usable baseline means treat HEAD as pending."""
if not sha:
return ["HEAD"]
if not is_ancestor(sha):
# A rewritten or rewound main leaves a baseline off this history; the range would read
# empty and skip a batch that is genuinely pending.
return ["HEAD"]
revision_range = f"{sha}..HEAD"
result = subprocess.run(
["git", "log", "--format=%H", revision_range, "--", *APP_PATHS],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
# An unknown SHA (force-push, pruned history) must not silently stop builds.
return ["HEAD"]
return [line for line in result.stdout.split() if line]
def dispatch(app_id: str, workflow_id: str, token: str, branch: str) -> str:
payload = json.dumps({"appId": app_id, "workflowId": workflow_id, "branch": branch}).encode()
request = urllib.request.Request(
BUILDS_API,
data=payload,
headers={"Content-Type": "application/json", "x-auth-token": token},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = json.loads(response.read().decode())
except urllib.error.URLError as error:
raise DispatchError(f"Codemagic dispatch failed for {workflow_id}: {error}") from error
build_id = body.get("buildId")
if not build_id:
raise DispatchError(f"Codemagic returned no build id for {workflow_id}")
return str(build_id)
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--event", required=True)
parser.add_argument("--actor", default="")
parser.add_argument("--commit-authors", default="")
parser.add_argument("--branch", default="main")
parser.add_argument("--app-id", required=True)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
token = os.environ.get("CODEMAGIC_API_TOKEN", "")
instant_actors = normalized_actors(os.environ.get("MOBILE_INSTANT_BUILD_ACTORS"))
# Per workflow: iOS and Android drift apart whenever one is built on its own, and a shared
# baseline would let the newer platform suppress the other's build.
pending_by_workflow: dict[str, list[str]] = {}
if args.event == "schedule":
if not token:
raise DispatchError("CODEMAGIC_API_TOKEN is required to read the last built commit")
for workflow_id in MOBILE_WORKFLOWS:
pending_by_workflow[workflow_id] = app_commits_since(
last_built_sha(args.app_id, workflow_id, token)
)
should, reason = decide_dispatch(
event=args.event,
actor=args.actor,
commit_authors=[a for a in args.commit_authors.split(",") if a.strip()],
instant_actors=instant_actors,
has_pending_app_commits=any(pending_by_workflow.values()),
)
summary = [f"event={args.event}", f"actor={args.actor}", f"dispatch={should}", f"reason={reason}"]
for workflow_id, commits in pending_by_workflow.items():
summary.append(f"{workflow_id}_pending={len(commits)}")
print(" ".join(summary))
if not should or args.dry_run:
return 0
if not token:
raise DispatchError("CODEMAGIC_API_TOKEN is required to dispatch")
targets = [w for w, commits in pending_by_workflow.items() if commits] or list(MOBILE_WORKFLOWS)
for workflow_id in targets:
print(f"dispatched {workflow_id} build={dispatch(args.app_id, workflow_id, token, args.branch)}")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except DispatchError as error:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)