Repository navigation
Expand file tree
/
Copy pathtest-requests.py
More file actions
executable file
·468 lines (402 loc) · 22.4 KB
/
Copy pathtest-requests.py
File metadata and controls
executable file
·468 lines (402 loc) · 22.4 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
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
#!/usr/bin/env python3
"""Handles "/cpu-tests" and "/gpu-tests" requests on pull requests for test-requests.yml.
test-requests.py decide decide on the issue_comment event in GITHUB_EVENT_PATH and
write decision, command, sha, backends, cuda-versions,
accelerators, macos-windows and reply to GITHUB_OUTPUT
test-requests.py reply post REPLY on the pull request
test-requests.py accepted set the pending COMMAND-tests status on SHA and post REPLY
test-requests.py report set the final status from RESULT and SUMMARY (the called
workflow's result and, for GPU runs, its one-line test
summary) and comment, adding REPORT (each GPU leg's result)
The commit status is the result; in accepted and report a comment that fails is only a
warning. A failed API call becomes an annotation of the run, which, unlike the job log,
can be read without logging in.
decide() depends only on the event, the settings and the API answers. With
AF_CI_REQUEST_FIXTURES naming a JSON file of API answers, those replace GitHub and
nothing is written; that is honoured only in act runs (ACT=true).
"""
import io
import json
import os
import re
import secrets
import sys
import urllib.error
import urllib.parse
import urllib.request
# A request may end with the full SHA of the head commit it is for. The head is read when the
# request is decided, a few seconds after the comment, so a push in between would otherwise
# be tested in its place. A named SHA that is no longer the head refuses the request; that
# matters most for a maintainer's /cpu-tests, whose macOS and Windows jobs build on the runner
# itself, where the code could reach the cache credentials of master's scope. Only the full
# SHA counts: the author knows the head in advance and could make a commit that shares a
# short prefix.
COMMANDS = {"cpu": re.compile(r"/cpu-tests(?:\s+(all))?(?:\s+([0-9a-fA-F]{40}))?"),
"gpu": re.compile(r"/gpu-tests(?:\s+(cuda|opencl|all))?(?:\s+cuda=(\d+\.\d+))?"
r"(?:\s+([0-9a-fA-F]{40}))?")}
NAMED_SHA = {"cpu": 2, "gpu": 3} # the group of COMMANDS[command] that holds the named SHA
USAGE = {"cpu": "Usage: `/cpu-tests [all] [<full head SHA>]`. `all` adds the CUDA, OpenCL and oneAPI "
"builds; a named SHA must still be the head.",
"gpu": "Usage: `/gpu-tests [cuda|opencl|all] [cuda=<major.minor>] [<full head SHA>]`. Without "
"`cuda`, `opencl` or `all`, the changed files choose the backends; `cuda=13.4` tests "
"CUDA 13.4 only, and `all cuda=13.4` adds OpenCL. A named SHA must still be the head."}
DEFAULT_CUDA_VERSION = "12.9" # without AF_CI_CUDA_VERSIONS
BACKENDS = {"cuda": ("cuda",), "opencl": ("opencl",), "all": ("cuda", "opencl")}
# The GPU backends a changed file can affect; the first matching rule wins, and a file
# that matches none affects both. Markdown, the docs, CI, the CPU and oneAPI backends
# and the examples (which the GPU builds skip) affect neither. Shared backend code,
# the APIs, the headers, the tests, CMake and the dependency manifests affect both.
FILE_RULES = [
(re.compile(r".*\.md|docs/.*|\.github/.*|examples/.*|src/backend/(cpu|oneapi)/.*|include/af/oneapi\.h"
r"|CMakeModules/(CMake\w*SYCL\w*|FindAF_MKL\.cmake)|LICENSES?(/.*)?"
r"|\.(gitignore|gitattributes|coveralls\.yml)"), ()),
(re.compile(r"src/backend/cuda/.*|include/af/cuda\.h|test/cuda\.cu"
r"|CMakeModules/(AFcuda_helpers\.cmake|select_compute_arch\.cmake|launch-nvcc\.in"
r"|FindcuDNN\.cmake)"), ("cuda",)),
(re.compile(r"src/backend/opencl/.*|include/af/opencl\.h|test/(ocl_\w*|interop_opencl_\w*)\.cpp"
r"|CMakeModules/(FindOpenCL|build_CLBlast|build_cl2hpp|build_clFFT)\.cmake"), ("opencl",)),
]
BACKEND_NAMES = {"cuda": "CUDA", "opencl": "OpenCL"}
LIGHT_CHECKS = ("lint", "docs", "workflows") # checks.yml, run on every push
WRITE_ROLES = {"admin", "maintain", "write"}
# Commenters who get an answer besides the pull request's author; others are
# ignored, so that nobody can make the workflow spam a pull request.
KNOWN = {"OWNER", "MEMBER", "COLLABORATOR"}
# The account of GitHub Actions' own token, which posts this workflow's comments: they are
# ignored.
ACTIONS_BOT = "github-actions[bot]"
def decision(action, reason, reply="", command="", sha="", backends="", cuda_versions="",
accelerators=False, macos_windows=False):
return {"action": action, "reason": reason, "reply": reply, "command": command, "sha": sha,
"backends": backends, "cuda_versions": cuda_versions, "accelerators": accelerators,
"macos_windows": macos_windows}
def affected(path):
"""The GPU backends that a change to path can affect."""
for pattern, backends in FILE_RULES:
if pattern.fullmatch(path):
return backends
return ("cuda", "opencl")
def select(files):
"""The backends that the changed files affect, each with the first file that affects it."""
chosen = {}
for file in files:
for path in (file["filename"], file.get("previous_filename")):
for backend in affected(path) if path else ():
chosen.setdefault(backend, path)
return chosen
def legs(backends, cuda_versions):
"""The GPU runs, as "CUDA 12.9, CUDA 13.4 and OpenCL"."""
names = [f"CUDA {version}" for version in cuda_versions.split()] if "cuda" in backends.split() else []
names += ["OpenCL"] if "opencl" in backends.split() else []
return ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else "".join(names)
def decide(event, settings, api):
"""Returns the decision on one issue_comment event; asks api only what it needs."""
issue = event.get("issue") or {}
comment = event.get("comment") or {}
login = (comment.get("user") or {}).get("login", "")
lines = (comment.get("body") or "").splitlines()
first = lines[0].strip() if lines else ""
if not issue.get("pull_request"):
return decision("ignore", "not-pull-request")
if login == ACTIONS_BOT:
return decision("ignore", "own-comment")
word = re.match(r"/(cpu|gpu)-tests(\s|$)", first)
if not word:
return decision("ignore", "not-a-request")
command = word.group(1)
is_author = login == (issue.get("user") or {}).get("login")
if not (is_author or comment.get("author_association") in KNOWN):
return decision("ignore", "stranger", command=command)
def reply(reason, text):
return decision("reply", reason, text, command)
if command == "gpu":
if settings["gpu_tests"] != "true":
return reply("disabled", "GPU tests are disabled in this repository.")
if not settings["gpu_runner"]:
return reply("no-runner", "GPU tests can't run here: no GPU runner is configured.")
match = COMMANDS[command].fullmatch(first)
if not match or command == "gpu" and match.group(1) == "opencl" and match.group(2):
return reply("usage", USAGE[command])
writer = api.permission(login) in WRITE_ROLES
if command == "gpu" and not writer:
return reply("not-allowed",
f"Sorry @{login}, only maintainers with write access can request GPU tests.")
if command == "cpu" and not (writer or is_author):
return reply("not-allowed",
f"Sorry @{login}, CPU tests can be requested by maintainers with write access "
"and by the pull request's author.")
number = issue["number"]
pull = api.pull(number)
sha = pull["head"]["sha"]
named = (match.group(NAMED_SHA[command]) or "").lower()
if named and named != sha:
return reply("head-moved",
f"Not starting {command.upper()} tests: the request names `{named}`, but the head "
f"of this pull request is now `{sha}`. Review the new commits, then ask again "
"with the new SHA.")
if command == "cpu":
if not writer:
checks = latest_light_checks(api, sha)
pending = [name for name in LIGHT_CHECKS if (checks.get(name) or {}).get("conclusion") is None]
if pending:
return reply("light-checks-pending",
f"Not starting CPU tests for `{sha}` yet: the checks that run on every "
f"push haven't run on it ({', '.join(pending)}). A maintainer may need "
"to approve the workflow runs first.")
accelerators = match.group(1) == "all"
what = "CPU tests"
if accelerators:
what += (" and the accelerator builds" if settings["accelerator_builds"] == "true" else
" (the accelerator builds are disabled in this repository)")
if not writer:
what += " on Linux (macOS and Windows run in the merge queue)"
return decision("accept", "accepted",
f"{what} requested by @{login} for `{sha}`, the head of this pull request "
"now. Later pushes are not part of this run." + behind_note(api, pull),
command, sha, accelerators=accelerators, macos_windows=writer)
files = api.files(number)
chosen = select(files)
asked, version = match.group(1), match.group(2)
if asked or version:
backends = BACKENDS[asked or "cuda"]
why = "as asked"
if chosen:
why += "; the changed GPU code includes " + " and ".join(
f"`{path}` ({BACKEND_NAMES[backend]})" for backend, path in chosen.items())
elif files:
why += f", although no changed file affects the GPU backends (`{files[0]['filename']}` changed)"
elif chosen:
backends = tuple(backend for backend in ("cuda", "opencl") if backend in chosen)
why = "chosen by the changed files: " + " and ".join(
f"{BACKEND_NAMES[backend]} for `{chosen[backend]}`" for backend in backends)
else:
example = f" (`{files[0]['filename']}` changed)" if files else ""
return reply("no-gpu-changes",
f"Not starting GPU tests for `{sha}`: no changed file affects the CUDA or OpenCL "
f"backends{example}. Comment `/gpu-tests all` to run them anyway.")
cuda_versions = ""
if "cuda" in backends:
versions = [version] if version else settings["cuda_versions"].split() or [DEFAULT_CUDA_VERSION]
if not all(re.fullmatch(r"\d+\.\d+", v) for v in versions):
return reply("bad-cuda-versions",
"GPU tests can't run here: the repository variable AF_CI_CUDA_VERSIONS must "
f"list CUDA versions like `12.9 13.4`, not `{settings['cuda_versions']}`.")
cuda_versions = " ".join(dict.fromkeys(versions))
backends = " ".join(backends)
return decision("accept", "accepted",
f"GPU tests requested by @{login} for `{sha}`, the head of this pull request "
f"now: {legs(backends, cuda_versions)}, {why}. The builds run first; the GPU "
"jobs then wait for one maintainer approval. Later pushes are not part of this run."
+ behind_note(api, pull),
command, sha, backends, cuda_versions)
def behind_note(api, pull):
"""A paragraph for the accepted reply when the pull request's head lacks commits of its
base branch, such as CI fixes; empty when it doesn't, or when GitHub can't say, since a
request never waits for this."""
base = (pull.get("base") or {}).get("ref") or "master"
try:
behind = api.compare(base, pull["head"]["sha"])["behind_by"]
except urllib.error.HTTPError:
return "" # GitHub.call already annotated it as a warning
except (OSError, KeyError, TypeError, ValueError) as error:
annotate("warning", f"Not checking whether the branch is behind {base}: {error}")
return ""
if not behind:
return ""
return (f"\n\nThis branch is {behind} commit{'s' if behind != 1 else ''} behind `{base}`; CI "
f"fixes made there since aren't in this test. Use \"Update branch\" (or merge "
f"`{base}`) to include them.")
def latest_light_checks(api, sha):
"""The latest GitHub Actions check run of each light check on sha, by name."""
latest = {}
for run in api.check_runs(sha):
if (run["name"] in LIGHT_CHECKS and (run.get("app") or {}).get("slug") == "github-actions"
and run["id"] > latest.get(run["name"], {}).get("id", -1)):
latest[run["name"]] = run
return latest
def annotate(level, text):
"""Prints text as an annotation of the run (level is error or warning)."""
text = text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
print(f"::{level}::{text}", flush=True)
def describe(method, path, error):
"""A failed API call: the request, the HTTP status and the message of GitHub's JSON
error body. Never the request's headers, which hold the token."""
try:
message = json.loads(error.read().decode("utf-8", "replace")).get("message")
except (OSError, ValueError, AttributeError):
message = None
message = " ".join(str(message or error.reason).split())
if len(message) > 200:
message = message[:197] + "..."
return f"GitHub API {method} {path} failed: HTTP {error.code}: {message}"
class GitHub:
"""The REST calls of the policy and the reports."""
def __init__(self, repo, token):
self.repo = repo
self.token = token
def call(self, method, path, body=None, level="error", quiet=()):
"""An HTTP error becomes an annotation at level, unless its status is in quiet."""
request = urllib.request.Request(
f"https://api.github.com/repos/{self.repo}/{path}", method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {self.token}",
"X-GitHub-Api-Version": "2022-11-28"})
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code not in quiet:
annotate(level, describe(method, path, error))
raise
def permission(self, user):
try:
answer = self.call("GET", f"collaborators/{urllib.parse.quote(user)}/permission",
quiet=(404,))
except urllib.error.HTTPError as error:
if error.code == 404:
return "none"
raise
return answer.get("role_name") or answer.get("permission") or "none"
def pull(self, number):
return self.call("GET", f"pulls/{number}")
def check_runs(self, sha):
return self.call("GET", f"commits/{sha}/check-runs?per_page=100")["check_runs"]
def compare(self, base, head):
"""How far head is ahead of and behind base; a failure is only a warning."""
return self.call("GET", f"compare/{urllib.parse.quote(base, safe='')}...{head}", level="warning")
def files(self, number):
"""The pull request's changed files; GitHub lists at most 3000."""
files = []
for page in range(1, 31):
batch = self.call("GET", f"pulls/{number}/files?per_page=100&page={page}")
files += batch
if len(batch) < 100:
break
return files
def comment(self, number, body, level="error"):
self.call("POST", f"issues/{number}/comments", {"body": body}, level)
def set_status(self, sha, context, state, description, url):
self.call("POST", f"statuses/{sha}", {"state": state, "description": description[:140],
"target_url": url, "context": context})
class Fixtures:
"""API answers from a JSON object instead of GitHub; writes are printed, not sent.
An entry "fail": {"comment": {"status": 403, "body": {"message": ...}}} makes that
write fail as GitHub.call fails, with that HTTP status and JSON error body."""
def __init__(self, answers):
self.answers = answers
self.calls = []
def fail(self, name, method, path, level="error"):
failure = self.answers.get("fail", {}).get(name)
if failure:
error = urllib.error.HTTPError(path, failure["status"], "fixture", None,
io.BytesIO(json.dumps(failure.get("body", {})).encode()))
annotate(level, describe(method, path, error))
raise error
def permission(self, user):
self.calls.append(f"permission {user}")
return self.answers.get("permissions", {}).get(user, "none")
def pull(self, number):
self.calls.append(f"pull {number}")
return self.answers["pull"]
def check_runs(self, sha):
self.calls.append(f"check-runs {sha}")
return self.answers.get("check_runs", {}).get(sha, [])
def files(self, number):
self.calls.append(f"files {number}")
return self.answers.get("files", [])
def compare(self, base, head):
self.calls.append(f"compare {base}...{head}")
self.fail("compare", "GET", f"compare/{base}...{head}", level="warning")
return {"behind_by": self.answers.get("behind_by", 0)}
def comment(self, number, body, level="error"):
self.fail("comment", "POST", f"issues/{number}/comments", level)
print(f"[fixture] comment on #{number}:\n{body}")
def set_status(self, sha, context, state, description, url):
self.fail("set_status", "POST", f"statuses/{sha}")
print(f"[fixture] status {context}={state} on {sha}: {description} ({url})")
def api_from_env():
fixtures = os.environ.get("AF_CI_REQUEST_FIXTURES")
if fixtures:
if os.environ.get("ACT") != "true":
sys.exit("AF_CI_REQUEST_FIXTURES is honoured only in act runs (ACT=true)")
with open(fixtures) as file:
return Fixtures(json.load(file))
return GitHub(os.environ["GITHUB_REPOSITORY"], os.environ["GH_TOKEN"])
def settings_from_env():
return {"gpu_tests": os.environ.get("AF_CI_GPU_TESTS", ""),
"gpu_runner": os.environ.get("AF_CI_GPU_RUNNER", ""),
"accelerator_builds": os.environ.get("AF_CI_ACCELERATOR_BUILDS", ""),
"cuda_versions": os.environ.get("AF_CI_CUDA_VERSIONS", "")}
def write_outputs(values):
delimiter = f"END_{secrets.token_hex(16)}"
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
for key, value in values.items():
output.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
def final_state(command, result, summary):
"""The commit status state and text for the called workflow's result."""
if result == "success" and command == "gpu" and not summary:
return "error", "did not run: gpu.yml's jobs were skipped"
if result == "success":
return "success", f"passed: {summary}" if summary else "passed"
if result == "failure":
return "failure", f"failed: {summary}" if summary else "failed"
if result == "cancelled":
return "error", (f"cancelled, for example replaced in the queue by a later request; "
f"ask again with /{command}-tests")
return "error", f"did not complete (result: {result or 'unknown'})"
def comment_after_status(api, number, body):
"""Posts a comment whose failure doesn't fail the job: the commit status, already set,
is the result, and the comment is a courtesy."""
try:
api.comment(number, body, level="warning")
except urllib.error.HTTPError:
print(f"Not commenting on #{number}; the commit status is set.")
except OSError as error:
annotate("warning", f"Not commenting on #{number}; the commit status is set: {error}")
def main(command):
with open(os.environ["GITHUB_EVENT_PATH"]) as file:
event = json.load(file)
number = (event.get("issue") or {}).get("number")
api = api_from_env()
run_url = (f"{os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/"
f"{os.environ.get('GITHUB_REPOSITORY', '')}/actions/runs/{os.environ.get('GITHUB_RUN_ID', '')}")
if command == "decide":
result = decide(event, settings_from_env(), api)
print(f"decision: {result['action']} ({result['reason']}) command={result['command'] or '-'} "
f"sha={result['sha'] or '-'} backends={result['backends'] or '-'} "
f"cuda-versions={result['cuda_versions'] or '-'} "
f"accelerators={str(result['accelerators']).lower()} "
f"macos-windows={str(result['macos_windows']).lower()}")
if result["reply"]:
print(f"reply: {result['reply']}")
write_outputs({"decision": result["action"], "reason": result["reason"],
"command": result["command"], "sha": result["sha"],
"backends": result["backends"], "cuda-versions": result["cuda_versions"],
"accelerators": str(result["accelerators"]).lower(),
"macos-windows": str(result["macos_windows"]).lower(),
"reply": result["reply"]})
elif command == "reply":
api.comment(number, os.environ["REPLY"])
elif command == "accepted":
kind, sha = os.environ["COMMAND"], os.environ["SHA"]
if kind == "gpu":
text = (f"GPU tests ({legs(os.environ['BACKENDS'], os.environ['CUDA_VERSIONS'])}): "
"building, then waiting for a maintainer's approval")
else:
text = "CPU tests queued"
api.set_status(sha, f"{kind}-tests", "pending", text, run_url)
comment_after_status(api, number, f"{os.environ['REPLY']} [Run]({run_url})")
elif command == "report":
kind, sha = os.environ["COMMAND"], os.environ["SHA"]
state, text = final_state(kind, os.environ.get("RESULT", ""), os.environ.get("SUMMARY", ""))
what = (f"GPU tests ({legs(os.environ['BACKENDS'], os.environ['CUDA_VERSIONS'])})"
if kind == "gpu" else "CPU tests")
report = os.environ.get("REPORT", "").strip()
api.set_status(sha, f"{kind}-tests", state, f"{what} {text}", run_url)
comment_after_status(api, number, f"{what} of `{sha}` {text} [Run]({run_url})"
+ (f"\n\n{report}" if report else ""))
else:
sys.exit(f"unknown command: {command}")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "")