Skip to content

Commit d315940

Browse files
committed
feat: add GitHub Check Runs for deployment authorization PR visibility
GitHub Deployments only appear in the PR Environments section. This adds Check Runs via the Checks API so blocked deployments show a prominent yellow "action_required" badge in the PR checks list. On authorization, the check run is updated to green success.
1 parent b19d6e3 commit d315940

File tree

5 files changed

+153
-1
lines changed

5 files changed

+153
-1
lines changed

svc/ctrl/services/deployment/authorize_deployment.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func (s *Service) AuthorizeDeployment(ctx context.Context, req *connect.Request[
4141
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to fetch branch HEAD from GitHub: %w", err))
4242
}
4343

44-
// Re-derive all matching deploy contexts (same query handle_push uses)
44+
// Find all deploy contexts for this branch
4545
contexts, err := db.Query.ListRepoConnectionDeployContexts(ctx, s.db.RO(), db.ListRepoConnectionDeployContextsParams{
4646
InstallationID: repoConn.InstallationID,
4747
RepositoryID: repoConn.RepositoryID,
@@ -178,5 +178,23 @@ func (s *Service) AuthorizeDeployment(ctx context.Context, req *connect.Request[
178178
)
179179
}
180180

181+
// Mark the PR check run as green now that the deployment is authorized
182+
checkRuns, listErr := s.github.ListCheckRunsForRef(repoConn.InstallationID, repoConn.RepositoryFullName, headCommit.SHA, "Unkey Deployment Authorization")
183+
if listErr == nil {
184+
for _, cr := range checkRuns {
185+
if updateErr := s.github.UpdateCheckRun(
186+
repoConn.InstallationID,
187+
repoConn.RepositoryFullName,
188+
cr.ID,
189+
"completed",
190+
"success",
191+
"Deployment authorized",
192+
"Deployment authorized and started by a project member.",
193+
); updateErr != nil {
194+
logger.Error("failed to update check run to success", "check_run_id", cr.ID, "error", updateErr)
195+
}
196+
}
197+
}
198+
181199
return connect.NewResponse(&ctrlv1.AuthorizeDeploymentResponse{}), nil
182200
}

svc/ctrl/worker/github/client.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,93 @@ func (c *Client) CreateDeploymentStatus(installationID int64, repo string, deplo
331331
return httpclient.Do(c.httpClient, http.MethodPost, apiURL, headers, payload, http.StatusCreated)
332332
}
333333

334+
// CreateCheckRun creates a GitHub Check Run on a commit SHA. The check run
335+
// appears in the PR checks list. Returns the check run ID.
336+
func (c *Client) CreateCheckRun(installationID int64, repo string, headSHA string, name string, status string, conclusion string, title string, summary string, detailsURL string) (int64, error) {
337+
headers, err := c.ghHeaders(installationID)
338+
if err != nil {
339+
return 0, err
340+
}
341+
342+
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/check-runs", repo)
343+
344+
type ghCheckRunResponse struct {
345+
ID int64 `json:"id"`
346+
}
347+
348+
payload := map[string]interface{}{
349+
"name": name,
350+
"head_sha": headSHA,
351+
"status": status,
352+
"conclusion": conclusion,
353+
"output": map[string]string{
354+
"title": title,
355+
"summary": summary,
356+
},
357+
}
358+
if detailsURL != "" {
359+
payload["details_url"] = detailsURL
360+
}
361+
362+
result, err := httpclient.Request[ghCheckRunResponse](c.httpClient, http.MethodPost, apiURL, headers, payload, http.StatusCreated)
363+
if err != nil {
364+
return 0, err
365+
}
366+
367+
return result.ID, nil
368+
}
369+
370+
// UpdateCheckRun updates an existing GitHub Check Run's status and conclusion.
371+
func (c *Client) UpdateCheckRun(installationID int64, repo string, checkRunID int64, status string, conclusion string, title string, summary string) error {
372+
headers, err := c.ghHeaders(installationID)
373+
if err != nil {
374+
return err
375+
}
376+
377+
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/check-runs/%d", repo, checkRunID)
378+
379+
return httpclient.Do(c.httpClient, http.MethodPatch, apiURL, headers, map[string]interface{}{
380+
"status": status,
381+
"conclusion": conclusion,
382+
"output": map[string]string{
383+
"title": title,
384+
"summary": summary,
385+
},
386+
}, http.StatusOK)
387+
}
388+
389+
// ListCheckRunsForRef lists check runs for a commit ref, optionally filtered by name.
390+
func (c *Client) ListCheckRunsForRef(installationID int64, repo string, ref string, checkName string) ([]CheckRun, error) {
391+
headers, err := c.ghHeaders(installationID)
392+
if err != nil {
393+
return nil, err
394+
}
395+
396+
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/commits/%s/check-runs", repo, url.PathEscape(ref))
397+
if checkName != "" {
398+
apiURL += "?check_name=" + url.QueryEscape(checkName)
399+
}
400+
401+
type ghCheckRun struct {
402+
ID int64 `json:"id"`
403+
Name string `json:"name"`
404+
}
405+
type ghCheckRunsResponse struct {
406+
CheckRuns []ghCheckRun `json:"check_runs"`
407+
}
408+
409+
result, err := httpclient.Request[ghCheckRunsResponse](c.httpClient, http.MethodGet, apiURL, headers, nil, http.StatusOK)
410+
if err != nil {
411+
return nil, err
412+
}
413+
414+
runs := make([]CheckRun, len(result.CheckRuns))
415+
for i, r := range result.CheckRuns {
416+
runs[i] = CheckRun{ID: r.ID, Name: r.Name}
417+
}
418+
return runs, nil
419+
}
420+
334421
// IsCollaborator checks whether a GitHub user is a collaborator on a repository.
335422
// Results are cached for 5 minutes to avoid redundant API calls for the same user.
336423
func (c *Client) IsCollaborator(installationID int64, repo string, username string) (bool, error) {

svc/ctrl/worker/github/interface.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ type GitHubClient interface {
2626

2727
// IsCollaborator checks whether a GitHub user is a collaborator on a repository.
2828
IsCollaborator(installationID int64, repo string, username string) (bool, error)
29+
30+
// CreateCheckRun creates a GitHub Check Run on a commit SHA. Returns the
31+
// check run ID. Check runs appear in the PR checks list with prominent visibility.
32+
CreateCheckRun(installationID int64, repo string, headSHA string, name string, status string, conclusion string, title string, summary string, detailsURL string) (int64, error)
33+
34+
// UpdateCheckRun updates an existing GitHub Check Run.
35+
UpdateCheckRun(installationID int64, repo string, checkRunID int64, status string, conclusion string, title string, summary string) error
36+
37+
// ListCheckRunsForRef lists check runs for a given ref, filtered by check name.
38+
ListCheckRunsForRef(installationID int64, repo string, ref string, checkName string) ([]CheckRun, error)
2939
}
3040

3141
// CommitInfo holds metadata about a single Git commit retrieved from the GitHub API.
@@ -37,6 +47,12 @@ type CommitInfo struct {
3747
Timestamp time.Time
3848
}
3949

50+
// CheckRun holds the minimal fields of a GitHub Check Run needed for updates.
51+
type CheckRun struct {
52+
ID int64
53+
Name string
54+
}
55+
4056
// InstallationToken represents a GitHub installation access token. The token
4157
// provides repository access for a specific App installation and expires after
4258
// 1 hour.

svc/ctrl/worker/github/noop.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ func (n *Noop) IsCollaborator(_ int64, _ string, _ string) (bool, error) {
4949
return false, errNotConfigured
5050
}
5151

52+
// CreateCheckRun returns an error indicating GitHub is not configured.
53+
func (n *Noop) CreateCheckRun(_ int64, _ string, _ string, _ string, _ string, _ string, _ string, _ string, _ string) (int64, error) {
54+
return 0, errNotConfigured
55+
}
56+
57+
// UpdateCheckRun returns an error indicating GitHub is not configured.
58+
func (n *Noop) UpdateCheckRun(_ int64, _ string, _ int64, _ string, _ string, _ string, _ string) error {
59+
return errNotConfigured
60+
}
61+
62+
// ListCheckRunsForRef returns an error indicating GitHub is not configured.
63+
func (n *Noop) ListCheckRunsForRef(_ int64, _ string, _ string, _ string) ([]CheckRun, error) {
64+
return nil, errNotConfigured
65+
}
66+
5267
// GetBranchHeadCommitPublic retrieves the HEAD commit using the public GitHub
5368
// API without authentication. Works for public repositories even when GitHub
5469
// App credentials are not configured.

svc/ctrl/worker/githubwebhook/handle_push.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,22 @@ func (s *Service) HandlePush(ctx restate.ObjectContext, req *hydrav1.HandlePushR
151151
}, restate.WithName("github deployment status: failure (awaiting auth)"), restate.WithMaxRetryDuration(30*time.Second))
152152
}
153153

154+
// Create a GitHub Check Run for PR visibility (best-effort)
155+
_ = restate.RunVoid(ctx, func(_ restate.RunContext) error {
156+
_, crErr := s.github.CreateCheckRun(
157+
repo.InstallationID,
158+
req.GetRepositoryFullName(),
159+
req.GetAfter(),
160+
"Unkey Deployment Authorization",
161+
"completed",
162+
"action_required",
163+
"Awaiting authorization from a project member",
164+
fmt.Sprintf("An external contributor pushed to `%s`. A project member must authorize this deployment.", branch),
165+
logURL,
166+
)
167+
return crErr
168+
}, restate.WithName("create check run for authorization"), restate.WithMaxRetryDuration(30*time.Second))
169+
154170
logger.Info("deployment blocked for authorization",
155171
"project_id", project.ID,
156172
"app_id", app.ID,

0 commit comments

Comments
 (0)