Skip to content

Commit 325dfcd

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 6b479c5 commit 325dfcd

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
@@ -333,6 +333,93 @@ func (c *Client) CreateDeploymentStatus(installationID int64, repo string, deplo
333333
return httpclient.Do(c.httpClient, http.MethodPost, apiURL, headers, payload, http.StatusCreated)
334334
}
335335

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

3147
// CommitInfo holds metadata about a single Git commit retrieved from the GitHub API.

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)