Skip to content

Commit c99b9ee

Browse files
committed
Report build status via the GitHub Checks API using the halide-ci App
GitHubStatusPush only ever POSTs to the legacy Statuses API, which has no "in progress" state, so a running build shows a static pending dot instead of GitHub's spinner. Add GitHubAppCheckPush, a small subclass that reports through the Checks API instead, authenticating as the halide-ci GitHub App (the Checks API rejects plain PATs). AppInstallationToken mints and caches installation access tokens as a buildbot IRenderable, so it plugs into GitHubStatusPush's existing `token=` argument unchanged.
1 parent d17cbc5 commit c99b9ee

6 files changed

Lines changed: 147 additions & 12 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
venv/
44
.venv/
55
secrets/*.txt
6+
secrets/*.pem
67
http.log
78
twistd.hostname
89
twistd.log

.pre-commit-config.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,17 @@ repos:
1010
hooks:
1111
- id: ruff-check
1212
args: [--fix]
13-
files: ^(master/(master\.cfg|custom_steps\.py|buildbot\.tac)|worker/buildbot\.tac)$
13+
files: ^(master/(master\.cfg|custom_steps\.py|github_app_check_push\.py|buildbot\.tac)|worker/buildbot\.tac)$
1414
types_or: [python, text]
1515
- id: ruff-format
16-
files: ^(master/(master\.cfg|custom_steps\.py|buildbot\.tac)|worker/buildbot\.tac)$
16+
files: ^(master/(master\.cfg|custom_steps\.py|github_app_check_push\.py|buildbot\.tac)|worker/buildbot\.tac)$
1717
types_or: [python, text]
1818

1919
- repo: https://github.com/PyCQA/bandit
2020
rev: 1.9.3
2121
hooks:
2222
- id: bandit
23-
args: ["-c", "pyproject.toml", "master/master.cfg", "master/custom_steps.py", "master/buildbot.tac", "worker/buildbot.tac"]
23+
args: ["-c", "pyproject.toml", "master/master.cfg", "master/custom_steps.py", "master/github_app_check_push.py", "master/buildbot.tac", "worker/buildbot.tac"]
2424
pass_filenames: false
2525
always_run: true
2626
additional_dependencies: [ "bandit[toml]" ]
@@ -34,18 +34,18 @@ repos:
3434
rev: v2.14
3535
hooks:
3636
- id: vulture
37-
args: ["master/master.cfg", "master/custom_steps.py", "master/buildbot.tac", "worker/buildbot.tac"]
37+
args: ["master/master.cfg", "master/custom_steps.py", "master/github_app_check_push.py", "master/buildbot.tac", "worker/buildbot.tac"]
3838
pass_filenames: false
3939
always_run: true
4040

4141
- repo: local
4242
hooks:
4343
- id: ty-check
4444
name: ty
45-
entry: uv run --package master ty check --error-on-warning master/master.cfg master/custom_steps.py
45+
entry: uv run --package master ty check --error-on-warning master/master.cfg master/custom_steps.py master/github_app_check_push.py
4646
language: system
4747
pass_filenames: false
48-
files: ^master/(master\.cfg|custom_steps\.py)$
48+
files: ^master/(master\.cfg|custom_steps\.py|github_app_check_push\.py)$
4949

5050
# caddy-bin ships the caddy binary via pip, so no Docker is required.
5151
# `caddy fmt --diff` prints the diff and exits 1 if the file isn't

docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ services:
2929
secrets:
3030
- db_password.txt
3131
- github_token.txt
32+
- github_app_private_key.pem
3233
- halide_bb_pass.txt
3334
- webhook_token.txt
3435
- buildbot_www_pass.txt
@@ -70,6 +71,8 @@ secrets:
7071
file: ${HALIDE_BB_MASTER_SECRETS_DIR:-./secrets}/db_password.txt
7172
github_token.txt:
7273
file: ${HALIDE_BB_MASTER_SECRETS_DIR:-./secrets}/github_token.txt
74+
github_app_private_key.pem:
75+
file: ${HALIDE_BB_MASTER_SECRETS_DIR:-./secrets}/github_app_private_key.pem
7376
halide_bb_pass.txt:
7477
file: ${HALIDE_BB_MASTER_SECRETS_DIR:-./secrets}/halide_bb_pass.txt
7578
webhook_token.txt:

master/github_app_check_push.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import time
2+
3+
import jwt
4+
import requests
5+
from buildbot.interfaces import IRenderable
6+
from buildbot.reporters.github import GitHubStatusPush
7+
from twisted.internet import defer, threads
8+
from zope.interface import implementer
9+
10+
__all__ = ["AppInstallationToken", "GitHubAppCheckPush"]
11+
12+
13+
@implementer(IRenderable)
14+
class AppInstallationToken:
15+
"""Renders to a GitHub App installation access token, refreshed as needed. Pass an instance
16+
as GitHubAppCheckPush's `token=`; buildbot re-renders it on every request, so refreshes
17+
happen transparently.
18+
"""
19+
20+
def __init__(self, client_id, private_key, installation_id):
21+
self._client_id = client_id
22+
self._private_key = private_key
23+
self._installation_id = installation_id
24+
self._token = None
25+
self._expires = 0
26+
self._lock = defer.DeferredLock()
27+
28+
async def getRenderingFor(self, _iprops):
29+
if time.time() > self._expires:
30+
async with self._lock:
31+
if time.time() > self._expires:
32+
self._token = await threads.deferToThread(self._fetch)
33+
self._expires = time.time() + 55 * 60
34+
return self._token
35+
36+
def _fetch(self):
37+
now = int(time.time())
38+
app_jwt = jwt.encode(
39+
{"iat": now - 60, "exp": now + 570, "iss": self._client_id},
40+
self._private_key,
41+
algorithm="RS256",
42+
)
43+
resp = requests.post(
44+
f"https://api.github.com/app/installations/{self._installation_id}/access_tokens",
45+
headers={"Authorization": f"Bearer {app_jwt}", "Accept": "application/vnd.github+json"},
46+
timeout=10,
47+
)
48+
resp.raise_for_status()
49+
return resp.json()["token"]
50+
51+
52+
class GitHubAppCheckPush(GitHubStatusPush):
53+
"""Like GitHubStatusPush, but reports through the Checks API instead of the legacy Statuses
54+
API, so a build in progress shows GitHub's spinner instead of a static pending dot. Requires
55+
a GitHub App: pass an AppInstallationToken as `token=` (the Statuses API's PAT-based token
56+
doesn't work here; only the Checks API used by this class requires App auth).
57+
"""
58+
59+
@defer.inlineCallbacks
60+
def _get_auth_header(self, props):
61+
token = yield props.render(self.token)
62+
return {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
63+
64+
@defer.inlineCallbacks
65+
def createStatus(
66+
self, repo_user, repo_name, sha, state, props, target_url=None, context=None, issue=None, description=None
67+
):
68+
headers = yield self._get_auth_header(props)
69+
base = f"/repos/{repo_user}/{repo_name}/check-runs"
70+
output = {"title": context, "summary": description or ""}
71+
72+
if state == "pending":
73+
payload = {
74+
"name": context,
75+
"head_sha": sha,
76+
"status": "in_progress",
77+
"details_url": target_url,
78+
"output": output,
79+
"external_id": issue,
80+
}
81+
return (yield self._http.post(base, json=payload, headers=headers))
82+
83+
# The check run's id isn't threaded through from the "pending" call above, so look it up
84+
# by name instead of tracking build-run state; one extra GET, but no persisted state.
85+
resp = yield self._http.get(
86+
f"/repos/{repo_user}/{repo_name}/commits/{sha}/check-runs",
87+
params={"check_name": context},
88+
headers=headers,
89+
)
90+
runs = (yield resp.json())["check_runs"]
91+
if not runs:
92+
return None
93+
94+
# GitHubStatusPush.sendMessage() already collapsed several build results into "error";
95+
# both "failure" and "error" map to the same GitHub conclusion.
96+
conclusion = "success" if state == "success" else "failure"
97+
payload = {
98+
"status": "completed",
99+
"conclusion": conclusion,
100+
"details_url": target_url,
101+
"output": output,
102+
}
103+
# HTTPSession has no patch() wrapper (only get/put/post/delete); the Checks API update
104+
# endpoint is PATCH-only, so fall through to the generic dispatcher it's built on.
105+
return (
106+
yield self._http.http._do_request(
107+
self._http, "patch", f"{base}/{runs[0]['id']}", json=payload, headers=headers
108+
)
109+
)

master/master.cfg

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ from buildbot.config import BuilderConfig
1818
from buildbot.locks import WorkerLock
1919
from buildbot.process.factory import BuildFactory
2020
from buildbot.process.properties import Interpolate, Properties, Property, Transform, renderer
21-
from buildbot.reporters.github import GitHubStatusPush
2221
from buildbot.schedulers.basic import AnyBranchScheduler
2322
from buildbot.schedulers.canceller import OldBuildCanceller
2423
from buildbot.schedulers.forcesched import ForceScheduler
@@ -35,6 +34,7 @@ from buildbot.www.authz import Authz
3534
from buildbot.www.authz.roles import RolesFromUsername
3635
from buildbot.www.hooks.github import GitHubEventHandler
3736
from custom_steps import CTest
37+
from github_app_check_push import AppInstallationToken, GitHubAppCheckPush
3838
from twisted.internet.defer import inlineCallbacks
3939
from twisted.python import log
4040

@@ -78,9 +78,15 @@ SECRETS_DIR = REPO_DIR / os.environ.get("HALIDE_BB_MASTER_SECRETS_DIR", "secrets
7878

7979
BUILDBOT_WWW_PASS = (SECRETS_DIR / "buildbot_www_pass.txt").read_text().strip()
8080
GITHUB_TOKEN = (SECRETS_DIR / "github_token.txt").read_text().strip()
81+
GITHUB_APP_PRIVATE_KEY = (SECRETS_DIR / "github_app_private_key.pem").read_text().strip()
8182
HALIDE_BB_PASS = (SECRETS_DIR / "halide_bb_pass.txt").read_text().strip()
8283
WEBHOOK_TOKEN = (SECRETS_DIR / "webhook_token.txt").read_text().strip()
8384

85+
# Not secret (the client ID and installation ID are public identifiers, visible in the app's
86+
# installation URL), but specific to the "halide-ci" GitHub App installation on the halide org.
87+
GITHUB_APP_CLIENT_ID = "Iv23licXRWCliqhA0UNM"
88+
GITHUB_APP_INSTALLATION_ID = 114824380
89+
8490
DB_URL = os.environ.get("HALIDE_BB_MASTER_DB_URL", "sqlite:///state.sqlite")
8591
if "{DB_PASSWORD}" in DB_URL:
8692
DB_PASSWORD = (SECRETS_DIR / "db_password.txt").read_text().strip()
@@ -1352,8 +1358,12 @@ c["logCompressionMethod"] = "zstd"
13521358
# GitHub Integration
13531359

13541360
c["services"] = [
1355-
GitHubStatusPush(
1356-
token=GITHUB_TOKEN,
1361+
GitHubAppCheckPush(
1362+
token=AppInstallationToken(
1363+
client_id=GITHUB_APP_CLIENT_ID,
1364+
private_key=GITHUB_APP_PRIVATE_KEY,
1365+
installation_id=GITHUB_APP_INSTALLATION_ID,
1366+
),
13571367
verbose=True,
13581368
),
13591369
OldBuildCanceller(

pyproject.toml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,28 @@ ignore = [
2222
extra-paths = ["master"]
2323

2424
[tool.ty.src]
25-
include = ["master/custom_steps.py"]
25+
include = ["master/custom_steps.py", "master/github_app_check_push.py"]
2626

2727
[tool.bandit]
28-
targets = ["master/master.cfg", "master/custom_steps.py", "master/buildbot.tac", "worker/buildbot.tac"]
28+
targets = [
29+
"master/master.cfg",
30+
"master/custom_steps.py",
31+
"master/github_app_check_push.py",
32+
"master/buildbot.tac",
33+
"worker/buildbot.tac",
34+
]
2935
skips = [
3036
"B101", # assert_used: asserts are intentional for config validation
3137
]
3238

3339
[tool.vulture]
34-
paths = ["master/master.cfg", "master/custom_steps.py", "master/buildbot.tac", "worker/buildbot.tac"]
40+
paths = [
41+
"master/master.cfg",
42+
"master/custom_steps.py",
43+
"master/github_app_check_push.py",
44+
"master/buildbot.tac",
45+
"worker/buildbot.tac",
46+
]
3547
min_confidence = 80
3648

3749
[tool.codespell]

0 commit comments

Comments
 (0)