forked from web-platform-tests/wpt.fyi
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fix web-platform-tests#3906: Implement a /api/checks/ endpoint for Gi…
…tHub Actions This is very much a derivative of the implementation used for Azure Pipelines, perhaps unsurprising given the similarity between the two systems, but is very much different enough to justify a totally separate implementation. Like Azure Pipelines, we rely on a notification from the workflow that it is complete, rather than relying on webhooks from GitHub (as Taskcluster does), as this allows the workflow to provide metadata to endpoint, rather than having to hard code so many magic strings in wpt.fyi (notably, the name of the artifacts we're looking for).
- Loading branch information
Showing
5 changed files
with
351 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
// Copyright 2019 The WPT Dashboard Project. All rights reserved. | ||
// Use of this source code is governed by a BSD-style license that can be | ||
// found in the LICENSE file. | ||
|
||
package ghactions | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"net/http" | ||
"regexp" | ||
"strconv" | ||
|
||
mapset "github.com/deckarep/golang-set" | ||
"github.com/gobwas/glob" | ||
"github.com/google/go-github/v47/github" | ||
"github.com/gorilla/mux" | ||
uc "github.com/web-platform-tests/wpt.fyi/api/receiver/client" | ||
|
||
"github.com/web-platform-tests/wpt.fyi/shared" | ||
) | ||
|
||
const uploaderName = "github-actions" | ||
|
||
var ( | ||
prHeadRegex = regexp.MustCompile(`\baffected-tests$`) | ||
prBaseRegex = regexp.MustCompile(`\baffected-tests-without-changes$`) | ||
epochBranchesRegex = regexp.MustCompile("^epochs/.*") | ||
) | ||
|
||
func notifyHandler(w http.ResponseWriter, r *http.Request) { | ||
vars := mux.Vars(r) | ||
rawRunID := vars["run_id"] | ||
var runID int64 | ||
var err error | ||
if runID, err = strconv.ParseInt(rawRunID, 0, 0); err != nil { | ||
http.Error(w, fmt.Sprintf("Invalid run id: %s", rawRunID), http.StatusBadRequest) | ||
|
||
return | ||
} | ||
|
||
owner := vars["owner"] | ||
repo := vars["repo"] | ||
|
||
if owner != shared.WPTRepoOwner || repo != shared.WPTRepoName { | ||
http.Error(w, fmt.Sprintf("Invalid repo: %s/%s", owner, repo), http.StatusBadRequest) | ||
|
||
return | ||
} | ||
|
||
artifactName := vars["artifact_name"] | ||
artifactNameGlob, err := glob.Compile(artifactName) | ||
if err != nil { | ||
http.Error(w, fmt.Sprintf("Invalid artifact name: %s", artifactName), http.StatusBadRequest) | ||
|
||
return | ||
} | ||
|
||
ctx := r.Context() | ||
aeAPI := shared.NewAppEngineAPI(ctx) | ||
log := shared.GetLogger(ctx) | ||
|
||
ghClient, err := aeAPI.GetGitHubClient() | ||
if err != nil { | ||
log.Errorf("Failed to get GitHub client: %s", err.Error()) | ||
http.Error(w, err.Error(), http.StatusInternalServerError) | ||
|
||
return | ||
} | ||
|
||
processed, err := processBuild( | ||
ctx, | ||
aeAPI, | ||
ghClient, | ||
owner, | ||
repo, | ||
runID, | ||
artifactNameGlob, | ||
) | ||
|
||
if err != nil { | ||
log.Errorf("%v", err.Error()) | ||
http.Error(w, err.Error(), http.StatusInternalServerError) | ||
|
||
return | ||
} | ||
|
||
if processed { | ||
w.WriteHeader(http.StatusOK) | ||
fmt.Fprintln(w, "GitHub Actions workflow run artifacts retrieved successfully") | ||
} else { | ||
w.WriteHeader(http.StatusNoContent) | ||
fmt.Fprintln(w, "Notification of workflow run artifacts was ignored") | ||
} | ||
} | ||
|
||
func processBuild( | ||
ctx context.Context, | ||
aeAPI shared.AppEngineAPI, | ||
ghClient *github.Client, | ||
owner string, | ||
repo string, | ||
runID int64, | ||
artifactNameGlob glob.Glob, | ||
) (bool, error) { | ||
log := shared.GetLogger(ctx) | ||
|
||
workflowRun, _, err := ghClient.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) | ||
if err != nil { | ||
return false, err | ||
} | ||
|
||
headSha := workflowRun.HeadSHA | ||
|
||
opts := &github.ListOptions{PerPage: 100} | ||
|
||
uploadedAny := false | ||
errors := make(chan (error)) | ||
|
||
for { | ||
artifacts, resp, err := ghClient.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) | ||
|
||
if err != nil { | ||
return false, err | ||
} | ||
|
||
for _, artifact := range artifacts.Artifacts { | ||
|
||
if !artifactNameGlob.Match(*artifact.Name) { | ||
log.Infof("Skipping artifact %s", artifact.Name) | ||
|
||
continue | ||
} | ||
|
||
log.Infof("Uploading %s for %s/%s run %v...", artifact.Name, owner, repo, runID) | ||
|
||
labels := chooseLabels(workflowRun, *artifact.Name, owner, repo) | ||
|
||
uploader, err := aeAPI.GetUploader(uploaderName) | ||
if err != nil { | ||
return false, fmt.Errorf("failed to get uploader creds from Datastore: %w", err) | ||
} | ||
|
||
uploadClient := uc.NewClient(aeAPI) | ||
err = uploadClient.CreateRun( | ||
*headSha, | ||
uploader.Username, | ||
uploader.Password, | ||
// Uploading a ZIP with both results and screenshots is special-cased by the receiver. | ||
[]string{*artifact.ArchiveDownloadURL}, | ||
nil, | ||
shared.ToStringSlice(labels)) | ||
if err != nil { | ||
errors <- fmt.Errorf("failed to create run: %w", err) | ||
} else { | ||
uploadedAny = true | ||
} | ||
|
||
} | ||
|
||
if resp.NextPage == 0 { | ||
break | ||
} | ||
opts.Page = resp.NextPage | ||
} | ||
|
||
close(errors) | ||
for err := range errors { | ||
return uploadedAny, err | ||
} | ||
|
||
return uploadedAny, nil | ||
} | ||
|
||
func chooseLabels( | ||
workflowRun *github.WorkflowRun, | ||
artifactName string, | ||
owner string, | ||
repo string, | ||
) mapset.Set { | ||
labels := mapset.NewSet() | ||
|
||
if (*workflowRun.Event == "push" && | ||
*workflowRun.HeadRepository.Owner.Login == owner && | ||
*workflowRun.HeadRepository.Name == repo) && | ||
(*workflowRun.HeadBranch == "master" || | ||
epochBranchesRegex.MatchString(*workflowRun.HeadBranch)) { | ||
labels.Add(shared.MasterLabel) | ||
} | ||
|
||
if *workflowRun.Event == "pull_request" { | ||
if prHeadRegex.MatchString(artifactName) { | ||
labels.Add(shared.PRHeadLabel) | ||
} else if prBaseRegex.MatchString(artifactName) { | ||
labels.Add(shared.PRBaseLabel) | ||
} | ||
} | ||
|
||
return labels | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
//go:build small | ||
// +build small | ||
|
||
// Copyright 2018 The WPT Dashboard Project. All rights reserved. | ||
// Use of this source code is governed by a BSD-style license that can be | ||
// found in the LICENSE file. | ||
|
||
package ghactions | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/google/go-github/v47/github" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func PointerTo[T any](v T) *T { | ||
return &v | ||
} | ||
|
||
func TestArtifactRegexes(t *testing.T) { | ||
assert.True(t, prHeadRegex.MatchString("safari-preview-1-affected-tests")) | ||
assert.True(t, prBaseRegex.MatchString("safari-preview-1-affected-tests-without-changes")) | ||
|
||
// Base and Head could be confused with substring matching | ||
assert.False(t, prBaseRegex.MatchString("safari-preview-1-affected-tests")) | ||
assert.False(t, prHeadRegex.MatchString("safari-preview-1-affected-tests-without-changes")) | ||
} | ||
|
||
func TestEpochBranchesRegex(t *testing.T) { | ||
assert.True(t, epochBranchesRegex.MatchString("epochs/twelve_hourly")) | ||
assert.True(t, epochBranchesRegex.MatchString("epochs/six_hourly")) | ||
assert.True(t, epochBranchesRegex.MatchString("epochs/weekly")) | ||
assert.True(t, epochBranchesRegex.MatchString("epochs/daily")) | ||
|
||
assert.False(t, epochBranchesRegex.MatchString("weekly")) | ||
assert.False(t, epochBranchesRegex.MatchString("a/epochs/weekly")) | ||
} | ||
|
||
func TestChooseLabels(t *testing.T) { | ||
wptOrgUser := github.User{ | ||
Login: PointerTo("web-platform-tests"), | ||
} | ||
|
||
otherUser := github.User{ | ||
Login: PointerTo("xxx"), | ||
} | ||
|
||
wptRepo := github.Repository{ | ||
Name: PointerTo("wpt"), | ||
FullName: PointerTo("web-platform-tests/wpt"), | ||
Owner: &wptOrgUser, | ||
} | ||
|
||
otherRepo := github.Repository{ | ||
Name: PointerTo("wpt"), | ||
FullName: PointerTo("xxx/wpt"), | ||
Owner: &otherUser, | ||
} | ||
|
||
masterWorkflowRun := github.WorkflowRun{ | ||
HeadBranch: PointerTo("master"), | ||
Event: PointerTo("push"), | ||
Status: PointerTo("completed"), | ||
Conclusion: PointerTo("success"), | ||
HeadSHA: PointerTo("74dc6f6f5b2ba16940e6b6075f0faf311361dbb2"), | ||
Repository: &wptRepo, | ||
HeadRepository: &wptRepo, | ||
} | ||
|
||
masterOtherWorkflowRun := github.WorkflowRun{ | ||
HeadBranch: PointerTo("master"), | ||
Event: PointerTo("push"), | ||
Status: PointerTo("completed"), | ||
Conclusion: PointerTo("success"), | ||
HeadSHA: PointerTo("74dc6f6f5b2ba16940e6b6075f0faf311361dbb2"), | ||
Repository: &otherRepo, | ||
HeadRepository: &otherRepo, | ||
} | ||
|
||
prWorkflowRun := github.WorkflowRun{ | ||
HeadBranch: PointerTo("new-branch"), | ||
Event: PointerTo("pull_request"), | ||
Status: PointerTo("completed"), | ||
Conclusion: PointerTo("success"), | ||
HeadSHA: PointerTo("74dc6f6f5b2ba16940e6b6075f0faf311361dbb2"), | ||
Repository: &wptRepo, | ||
HeadRepository: &wptRepo, | ||
} | ||
|
||
prOtherWorkflowRun := github.WorkflowRun{ | ||
HeadBranch: PointerTo("master"), | ||
Event: PointerTo("pull_request"), | ||
Status: PointerTo("completed"), | ||
Conclusion: PointerTo("success"), | ||
HeadSHA: PointerTo("74dc6f6f5b2ba16940e6b6075f0faf311361dbb2"), | ||
Repository: &wptRepo, | ||
HeadRepository: &otherRepo, | ||
} | ||
|
||
assert.ElementsMatch( | ||
t, | ||
chooseLabels(&masterWorkflowRun, "results-safari-1", "web-platform-tests", "wpt").ToSlice(), | ||
[]string{"master"}, | ||
) | ||
|
||
assert.ElementsMatch(t, | ||
chooseLabels(&masterOtherWorkflowRun, "results-safari-1", "web-platform-tests", "wpt").ToSlice(), | ||
[]string{}, | ||
) | ||
|
||
assert.ElementsMatch(t, | ||
chooseLabels(&prWorkflowRun, "results-safari-1", "web-platform-tests", "wpt").ToSlice(), | ||
[]string{}, | ||
) | ||
|
||
assert.ElementsMatch(t, | ||
chooseLabels(&prOtherWorkflowRun, "results-safari-1", "web-platform-tests", "wpt").ToSlice(), | ||
[]string{}, | ||
) | ||
|
||
assert.ElementsMatch( | ||
t, | ||
chooseLabels(&prWorkflowRun, "results-safari-1-affected-tests", "web-platform-tests", "wpt").ToSlice(), | ||
[]string{"pr_head"}, | ||
) | ||
|
||
assert.ElementsMatch(t, | ||
chooseLabels(&prOtherWorkflowRun, "results-safari-1-affected-tests-without-changes", "web-platform-tests", "wpt").ToSlice(), | ||
[]string{"pr_base"}, | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
// Copyright 2019 The WPT Dashboard Project. All rights reserved. | ||
// Use of this source code is governed by a BSD-style license that can be | ||
// found in the LICENSE file. | ||
|
||
package ghactions | ||
|
||
import "github.com/web-platform-tests/wpt.fyi/shared" | ||
|
||
// RegisterRoutes adds all the api route handlers. | ||
func RegisterRoutes() { | ||
// notifyHandler exposes an endpoint for notifying wpt.fyi that it can collect | ||
// the results of an GitHub Actions workflow run. | ||
// The endpoint is insecure, because we'll only try to fetch (specifically) a | ||
// web-platform-tests/wpt build with the given ID. | ||
shared.AddRoute("/api/checks/github-actions/", "github-actions-notify", notifyHandler).Methods("POST") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters