Skip to content

Commit 5fc9195

Browse files
committed
feat(buildrunner): add GitHub Actions backend
1 parent 9adffc9 commit 5fc9195

8 files changed

Lines changed: 990 additions & 5 deletions

File tree

example/submitqueue/orchestrator/server/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ go_library(
2121
"//submitqueue/core/consumer",
2222
"//submitqueue/entity",
2323
"//submitqueue/extension/buildrunner",
24+
"//submitqueue/extension/buildrunner/githubactions",
2425
"//submitqueue/extension/buildrunner/noop",
2526
"//submitqueue/extension/changeprovider",
2627
"//submitqueue/extension/changeprovider/github",

example/submitqueue/orchestrator/server/main.go

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import (
2020
"errors"
2121
"fmt"
2222
"net"
23+
"net/http"
2324
"os"
2425
"os/signal"
26+
"strings"
2527
"sync"
2628
"syscall"
2729
"time"
@@ -40,6 +42,7 @@ import (
4042
"github.com/uber/submitqueue/submitqueue/core/consumer"
4143
"github.com/uber/submitqueue/submitqueue/entity"
4244
"github.com/uber/submitqueue/submitqueue/extension/buildrunner"
45+
"github.com/uber/submitqueue/submitqueue/extension/buildrunner/githubactions"
4346
buildnoop "github.com/uber/submitqueue/submitqueue/extension/buildrunner/noop"
4447
"github.com/uber/submitqueue/submitqueue/extension/changeprovider"
4548
githubprovider "github.com/uber/submitqueue/submitqueue/extension/changeprovider/github"
@@ -226,8 +229,12 @@ func run() error {
226229
}
227230

228231
// Create build runner. The noop runner is the pass-through default
229-
// (every build immediately succeeds) until a real backend is wired in.
230-
br := buildnoop.New()
232+
// (every build immediately succeeds); BUILD_RUNNER=githubactions enables
233+
// the GitHub Actions proof-of-architecture backend.
234+
br, err := newBuildRunnerFactory(logger)
235+
if err != nil {
236+
return fmt.Errorf("failed to create build runner: %w", err)
237+
}
231238

232239
// Register controllers
233240
if err := registerControllers(c, logger.Sugar(), scope, registry, mc, cp, psh, br, cnt, store); err != nil {
@@ -456,7 +463,7 @@ type conflictFactory struct{ impl conflict.Analyzer }
456463

457464
func (f conflictFactory) For(conflict.Config) (conflict.Analyzer, error) { return f.impl, nil }
458465

459-
func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, registry consumer.TopicRegistry, mc mergechecker.MergeChecker, cp changeprovider.ChangeProvider, psh pusher.Pusher, br buildrunner.BuildRunner, cnt counter.Counter, store storage.Storage) error {
466+
func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, registry consumer.TopicRegistry, mc mergechecker.MergeChecker, cp changeprovider.ChangeProvider, psh pusher.Pusher, br buildrunner.Factory, cnt counter.Counter, store storage.Storage) error {
460467
requestController := start.NewController(
461468
logger,
462469
scope,
@@ -553,7 +560,7 @@ func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope t
553560
logger,
554561
scope,
555562
store,
556-
buildRunnerFactory{impl: br},
563+
br,
557564
registry,
558565
consumer.TopicKeyBuild,
559566
"orchestrator-build",
@@ -566,7 +573,7 @@ func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope t
566573
logger,
567574
scope,
568575
store,
569-
buildRunnerFactory{impl: br},
576+
br,
570577
registry,
571578
consumer.TopicKeyBuildSignal,
572579
"orchestrator-buildsignal",
@@ -701,6 +708,82 @@ func newPusher(logger *zap.Logger, scope tally.Scope) (pusher.Pusher, error) {
701708
}), nil
702709
}
703710

711+
// newBuildRunnerFactory creates the BuildRunner factory selected by
712+
// BUILD_RUNNER. Defaults to noop for local development. Use
713+
// BUILD_RUNNER=githubactions to dispatch a GitHub Actions workflow via
714+
// workflow_dispatch.
715+
func newBuildRunnerFactory(logger *zap.Logger) (buildrunner.Factory, error) {
716+
switch strings.ToLower(getEnv("BUILD_RUNNER", "noop")) {
717+
case "noop":
718+
logger.Info("using noop build runner")
719+
return buildRunnerFactory{impl: buildnoop.New()}, nil
720+
case "githubactions", "github-actions":
721+
client, err := newGitHubHTTPClient()
722+
if err != nil {
723+
return nil, err
724+
}
725+
f, err := githubactions.NewFactory(githubactions.FactoryParams{
726+
HTTPClient: client,
727+
Logger: logger.Sugar(),
728+
Owner: os.Getenv("GITHUB_ACTIONS_OWNER"),
729+
Repo: os.Getenv("GITHUB_ACTIONS_REPO"),
730+
WorkflowID: os.Getenv("GITHUB_ACTIONS_WORKFLOW"),
731+
Ref: getEnv("GITHUB_ACTIONS_REF", "main"),
732+
ExtraInputs: parseKeyValueList(
733+
os.Getenv("GITHUB_ACTIONS_EXTRA_INPUTS"),
734+
),
735+
})
736+
if err != nil {
737+
return nil, err
738+
}
739+
logger.Info("using GitHub Actions build runner",
740+
zap.String("owner", os.Getenv("GITHUB_ACTIONS_OWNER")),
741+
zap.String("repo", os.Getenv("GITHUB_ACTIONS_REPO")),
742+
zap.String("workflow", os.Getenv("GITHUB_ACTIONS_WORKFLOW")),
743+
zap.String("ref", getEnv("GITHUB_ACTIONS_REF", "main")),
744+
)
745+
return f, nil
746+
default:
747+
return nil, fmt.Errorf("unsupported BUILD_RUNNER %q (supported: noop, githubactions)", os.Getenv("BUILD_RUNNER"))
748+
}
749+
}
750+
751+
func newGitHubHTTPClient() (*http.Client, error) {
752+
client, err := httpclient.NewClient(getEnv("GITHUB_BASE_URL", "https://api.github.com"))
753+
if err != nil {
754+
return nil, fmt.Errorf("failed to build GitHub HTTP client: %w", err)
755+
}
756+
757+
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
758+
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
759+
client.Transport = &oauth2.Transport{Source: ts, Base: client.Transport}
760+
}
761+
762+
client.Timeout = parseTimeout(os.Getenv("GITHUB_TIMEOUT"), 30*time.Second)
763+
return client, nil
764+
}
765+
766+
// parseKeyValueList parses comma-separated key=value entries. Invalid entries
767+
// are ignored so optional configuration cannot prevent the server from
768+
// starting.
769+
func parseKeyValueList(raw string) map[string]string {
770+
if raw == "" {
771+
return nil
772+
}
773+
out := make(map[string]string)
774+
for _, part := range strings.Split(raw, ",") {
775+
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
776+
if !ok || key == "" {
777+
continue
778+
}
779+
out[key] = value
780+
}
781+
if len(out) == 0 {
782+
return nil
783+
}
784+
return out
785+
}
786+
704787
// noopPusher is a fallback Pusher used when PUSHER_CHECKOUT_PATH is not
705788
// configured. It returns an error on every Push so the merge controller
706789
// (which treats non-ErrConflict errors as transient and nacks the message)

submitqueue/extension/buildrunner/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,11 @@ See [`doc/rfc/submitqueue/build-runner.md`](../../../doc/rfc/submitqueue/build-r
1010
2. Map the `base` and `head` change slices onto the backend's build primitives (apply `base`, apply `head`, validate the result).
1111
3. Map the runner's lifecycle states down to the `BuildStatus` values: `Accepted` (accepted for execution), `Running` (executing), and the terminal `Succeeded` / `Failed` / `Cancelled`.
1212
4. Implement internal reconnect / retry so transient failures surface as plain errors without blocking the caller.
13+
14+
## Backends
15+
16+
- `noop`: local-development backend that immediately succeeds every build.
17+
- `githubactions`: proof-of-architecture backend that dispatches a GitHub
18+
Actions workflow. See [`githubactions/README.md`](githubactions/README.md)
19+
for the workflow inputs and example orchestrator environment variables.
20+
- `buildkite`: Buildkite-backed backend.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "githubactions",
5+
srcs = [
6+
"client.go",
7+
"githubactions.go",
8+
],
9+
importpath = "github.com/uber/submitqueue/submitqueue/extension/buildrunner/githubactions",
10+
visibility = ["//visibility:public"],
11+
deps = [
12+
"//submitqueue/entity",
13+
"//submitqueue/extension/buildrunner",
14+
"@org_uber_go_zap//:zap",
15+
],
16+
)
17+
18+
go_test(
19+
name = "githubactions_test",
20+
srcs = ["githubactions_test.go"],
21+
embed = [":githubactions"],
22+
deps = [
23+
"//core/httpclient",
24+
"//submitqueue/entity",
25+
"//submitqueue/extension/buildrunner",
26+
"@com_github_stretchr_testify//assert",
27+
"@com_github_stretchr_testify//require",
28+
"@org_uber_go_zap//:zap",
29+
],
30+
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# GitHub Actions BuildRunner
2+
3+
`githubactions` implements `buildrunner.BuildRunner` with GitHub Actions
4+
`workflow_dispatch`. It is intended to prove the SubmitQueue BuildRunner
5+
architecture against a common CI system without adding local state.
6+
7+
## How it works
8+
9+
1. `Trigger` dispatches the configured workflow on a trusted ref, usually
10+
`main`, and returns GitHub's workflow run ID as the SubmitQueue build ID.
11+
2. SubmitQueue passes these workflow inputs:
12+
- `sq_build_id`
13+
- `sq_base_uris`
14+
- `sq_head_uris`
15+
- `sq_queue`
16+
- `sq_metadata`
17+
3. `Status` calls GitHub's get-workflow-run endpoint with that run ID.
18+
4. `Cancel` calls GitHub's cancel-workflow-run endpoint with that run ID.
19+
20+
`sq_build_id` is a generated SubmitQueue trace ID passed to the workflow for
21+
logs and human correlation. It is not used to poll or cancel the run.
22+
23+
## Minimal workflow
24+
25+
Create a workflow on the target repository's default branch:
26+
27+
```yaml
28+
name: SubmitQueue CI
29+
run-name: SubmitQueue ${{ inputs.sq_build_id }}
30+
31+
on:
32+
workflow_dispatch:
33+
inputs:
34+
sq_build_id:
35+
required: true
36+
type: string
37+
sq_base_uris:
38+
required: true
39+
type: string
40+
sq_head_uris:
41+
required: true
42+
type: string
43+
sq_queue:
44+
required: true
45+
type: string
46+
sq_metadata:
47+
required: false
48+
type: string
49+
50+
permissions:
51+
contents: read
52+
53+
jobs:
54+
test:
55+
runs-on: ubuntu-latest
56+
steps:
57+
- uses: actions/checkout@v4
58+
- name: Inspect SubmitQueue payload
59+
run: |
60+
echo '${{ inputs.sq_base_uris }}'
61+
echo '${{ inputs.sq_head_uris }}'
62+
echo '${{ inputs.sq_queue }}'
63+
# Prototype: add a script here that applies sq_base_uris, then
64+
# sq_head_uris, then runs the repository's real CI command.
65+
```
66+
67+
The workflow definition should live on a trusted ref. The untrusted changes
68+
should be represented by `sq_base_uris` and `sq_head_uris` and applied inside
69+
the job.
70+
71+
## Example server configuration
72+
73+
Set these environment variables on the example orchestrator:
74+
75+
```sh
76+
BUILD_RUNNER=githubactions
77+
GITHUB_BASE_URL=https://api.github.com
78+
GITHUB_TOKEN=<token with actions:read/actions:write>
79+
GITHUB_ACTIONS_OWNER=uber
80+
GITHUB_ACTIONS_REPO=submitqueue
81+
GITHUB_ACTIONS_WORKFLOW=submitqueue-ci.yml
82+
GITHUB_ACTIONS_REF=main
83+
GITHUB_ACTIONS_EXTRA_INPUTS=runner=ubuntu-latest,test_command=make test
84+
```
85+
86+
`GITHUB_ACTIONS_REF` defaults to `main`. `GITHUB_ACTIONS_EXTRA_INPUTS` is
87+
optional comma-separated `key=value` data copied into every dispatch request;
88+
use it for workflow-specific knobs like runner labels or test commands.
89+
90+
## Practical setup notes
91+
92+
- Use a trusted workflow definition from the target repository's default branch.
93+
- Include `sq_build_id` in `run-name` if you want the generated SubmitQueue
94+
trace ID visible in the Actions UI.
95+
- Keep workflow permissions minimal. The job may apply untrusted changes before
96+
running tests, so avoid broad secrets in this workflow.
97+
- The backend proves the BuildRunner architecture. It does not prescribe how
98+
your workflow materializes `sq_base_uris` and `sq_head_uris`; wire that to the
99+
repository's existing patch/PR application logic.

0 commit comments

Comments
 (0)