A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 2e62f52baa

2e62f52baae22d14056deefc922dc1b9ec9b7bc5

parent: b7fc5924ee

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-25T16:48:55Z

CI: builds, runner protocol, and gitbay-runner

.gitbay/ci.yml declares jobs (name -> shell steps). A branch push queues
one pending build per job with a pending ci/<job> commit status; a broken
config becomes a failed ci/config status instead of silence.

gitbay-runner polls over SSH — runner next/log/done, admin-only since a
runner executes arbitrary repo code — clones the repo, runs steps with
sh -c, streams the log, and resolves the status with a link to the build
page. Statuses feed the existing require-checks merge gate.

Web: builds tab with list and log pages. CLI: build list/show/log.
Migration 0022 adds builds and repos.build_counter.

Closes #13
cmd/gitbay-runner/main.go added +192
@@ -0,0 +1,192 @@
1// gitbay-runner executes CI builds queued by a gitbay server. It polls over
2// SSH — the same authenticated channel everything else uses — claims one
3// build at a time, clones the repo, runs each step with `sh -c`, streams the
4// combined output back, and reports success or failure.
5//
6// The account behind the runner's key must be an instance admin: a runner
7// executes arbitrary repo code, so handing out jobs is the operator's call.
8// v1 runs steps directly on the host under this process's user; run it as a
9// dedicated unprivileged user.
10package main
11
12import (
13 "encoding/json"
14 "flag"
15 "fmt"
16 "io"
17 "log"
18 "os"
19 "os/exec"
20 "path/filepath"
21 "strings"
22 "time"
23)
24
25type job struct {
26 ID int64 `json:"id"`
27 Repo string `json:"repo"`
28 Number int64 `json:"number"`
29 Job string `json:"job"`
30 SHA string `json:"sha"`
31 Ref string `json:"ref"`
32 Steps []string `json:"steps"`
33}
34
35type runner struct {
36 remote string // ssh destination, e.g. git@gitbay.org
37 sshOpts []string
38 cloneBase string // e.g. ssh://git@gitbay.org
39 workdir string
40 timeout time.Duration
41}
42
43func main() {
44 var (
45 remote = flag.String("remote", "git@gitbay.org", "ssh destination of the gitbay server")
46 sshOpts = flag.String("ssh-opts", "", "extra ssh options, space-separated (also used for git clone)")
47 cloneBase = flag.String("clone-base", "", "clone URL prefix (default ssh://<remote>)")
48 workdir = flag.String("workdir", filepath.Join(os.TempDir(), "gitbay-runner"), "build workspace root")
49 poll = flag.Duration("poll", 5*time.Second, "idle poll interval")
50 timeout = flag.Duration("timeout", 30*time.Minute, "per-build time limit")
51 once = flag.Bool("once", false, "process at most one build, then exit")
52 )
53 flag.Parse()
54 r := &runner{
55 remote: *remote,
56 cloneBase: *cloneBase,
57 workdir: *workdir,
58 timeout: *timeout,
59 }
60 if *sshOpts != "" {
61 r.sshOpts = strings.Fields(*sshOpts)
62 }
63 if r.cloneBase == "" {
64 r.cloneBase = "ssh://" + *remote
65 }
66 if err := os.MkdirAll(r.workdir, 0o755); err != nil {
67 log.Fatal(err)
68 }
69 for {
70 ran, err := r.step()
71 if err != nil {
72 log.Printf("runner: %v", err)
73 }
74 if *once {
75 return
76 }
77 if !ran {
78 time.Sleep(*poll)
79 }
80 }
81}
82
83// step claims and executes at most one build. ran reports whether there was
84// one, so the caller knows when to idle.
85func (r *runner) step() (bool, error) {
86 out, err := r.ssh(nil, "runner", "next", "--json")
87 if err != nil {
88 return false, fmt.Errorf("claiming build: %w (%s)", err, out)
89 }
90 var env struct {
91 Data job `json:"data"`
92 }
93 if err := json.Unmarshal([]byte(out), &env); err != nil {
94 return false, fmt.Errorf("parsing job: %w", err)
95 }
96 if env.Data.ID == 0 {
97 return false, nil
98 }
99 j := env.Data
100 log.Printf("build %d: %s %s @ %.10s", j.ID, j.Repo, j.Job, j.SHA)
101 status := "failure"
102 if r.run(j) {
103 status = "success"
104 }
105 if out, err := r.ssh(nil, "runner", "done", fmt.Sprint(j.ID), status); err != nil {
106 return true, fmt.Errorf("reporting build %d: %w (%s)", j.ID, err, out)
107 }
108 log.Printf("build %d: %s", j.ID, status)
109 return true, nil
110}
111
112// run clones, checks out, and executes the steps, streaming output to the
113// server. Returns whether every step succeeded.
114func (r *runner) run(j job) bool {
115 dir := filepath.Join(r.workdir, fmt.Sprintf("build-%d", j.ID))
116 defer os.RemoveAll(dir)
117
118 // One long-lived `runner log` session receives the whole stream.
119 logCmd := exec.Command("ssh", append(r.sshOpts, r.remote, "runner", "log", fmt.Sprint(j.ID))...)
120 sink, err := logCmd.StdinPipe()
121 if err != nil {
122 log.Printf("build %d: log pipe: %v", j.ID, err)
123 return false
124 }
125 logCmd.Stdout, logCmd.Stderr = io.Discard, io.Discard
126 if err := logCmd.Start(); err != nil {
127 log.Printf("build %d: log stream: %v", j.ID, err)
128 return false
129 }
130 defer func() {
131 sink.Close()
132 logCmd.Wait()
133 }()
134
135 gitSSH := strings.TrimSpace("ssh " + strings.Join(r.sshOpts, " "))
136 cloneURL := r.cloneBase + "/" + j.Repo + ".git"
137 fmt.Fprintf(sink, "$ git clone %s (%.10s)\n", cloneURL, j.SHA)
138 for _, args := range [][]string{
139 {"clone", "-q", cloneURL, dir},
140 {"-C", dir, "checkout", "-q", j.SHA},
141 } {
142 cmd := exec.Command("git", args...)
143 cmd.Env = append(os.Environ(), "GIT_SSH_COMMAND="+gitSSH, "GIT_TERMINAL_PROMPT=0")
144 cmd.Stdout, cmd.Stderr = sink, sink
145 if err := cmd.Run(); err != nil {
146 fmt.Fprintf(sink, "git %s: %v\n", args[0], err)
147 return false
148 }
149 }
150
151 deadline := time.Now().Add(r.timeout)
152 for _, step := range j.Steps {
153 fmt.Fprintf(sink, "$ %s\n", step)
154 cmd := exec.Command("sh", "-c", step)
155 cmd.Dir = dir
156 cmd.Env = append(os.Environ(),
157 "GITBAY_REPO="+j.Repo, "GITBAY_SHA="+j.SHA, "GITBAY_REF="+j.Ref, "GITBAY_JOB="+j.Job, "CI=true")
158 cmd.Stdout, cmd.Stderr = sink, sink
159 if err := cmd.Start(); err != nil {
160 fmt.Fprintf(sink, "start: %v\n", err)
161 return false
162 }
163 done := make(chan error, 1)
164 go func() { done <- cmd.Wait() }()
165 select {
166 case err := <-done:
167 if err != nil {
168 fmt.Fprintf(sink, "step failed: %v\n", err)
169 return false
170 }
171 case <-time.After(time.Until(deadline)):
172 cmd.Process.Kill()
173 fmt.Fprintf(sink, "build timed out after %s\n", r.timeout)
174 return false
175 }
176 }
177 return true
178}
179
180// ssh runs one control command against the server and returns stdout.
181func (r *runner) ssh(stdin io.Reader, args ...string) (string, error) {
182 cmd := exec.Command("ssh", append(append(r.sshOpts, r.remote), args...)...)
183 if stdin != nil {
184 cmd.Stdin = stdin
185 }
186 var out, errOut strings.Builder
187 cmd.Stdout, cmd.Stderr = &out, &errOut
188 if err := cmd.Run(); err != nil {
189 return out.String() + errOut.String(), err
190 }
191 return out.String(), nil
192}
cmd/gitbay/main.go +5
@@ -30,6 +30,11 @@ func main() {
3030 pass("set", "report a status: <sha> --context <c> --state <s> [--description d] [--url u]", passOpts{server: []string{"status", "set"}, needsRepo: true}),
3131 pass("list", "statuses on a commit: <sha>", passOpts{server: []string{"status", "list"}, needsRepo: true}),
3232 ),
33 group("build", "CI builds",
34 pass("list", "recent builds: <owner/name>", passOpts{server: []string{"build", "list"}, needsRepo: true}),
35 pass("show", "one build: <owner/name> <n>", passOpts{server: []string{"build", "show"}, needsRepo: true}),
36 pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}),
37 ),
3338 repoCmd(),
3439 issueCmd(),
3540 milestoneCmd(),
e2e/ci_test.go added +135
@@ -0,0 +1,135 @@
1package e2e
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12func buildRunner(t *testing.T) string {
13 t.Helper()
14 bin := filepath.Join(t.TempDir(), "gitbay-runner")
15 cmd := exec.Command("go", "build", "-o", bin, "gitbay.org/gitbay/cmd/gitbay-runner")
16 cmd.Dir = ".."
17 if out, err := cmd.CombinedOutput(); err != nil {
18 t.Fatalf("build gitbay-runner: %v\n%s", err, out)
19 }
20 return bin
21}
22
23// runnerOnce processes at most one pending build with the given key.
24func (i *instance) runnerOnce(t *testing.T, key string) string {
25 t.Helper()
26 opts := fmt.Sprintf("-p %d -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
27 i.port, key, filepath.Join(i.sshDir, "known_hosts"))
28 cmd := exec.Command(i.runner, "-once",
29 "-remote", "git@127.0.0.1",
30 "-ssh-opts", opts,
31 "-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", i.port),
32 "-workdir", t.TempDir())
33 cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
34 out, err := cmd.CombinedOutput()
35 if err != nil {
36 t.Fatalf("runner: %v\n%s", err, out)
37 }
38 return string(out)
39}
40
41func TestCI(t *testing.T) {
42 inst := startInstance(t)
43 inst.runner = buildRunner(t)
44 aliceKey := inst.newKey(t, "alice")
45 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
46 runnerKey := inst.newKey(t, "ci")
47 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
48
49 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
50 t.Fatalf("repo create: %s", errOut)
51 }
52 work := t.TempDir()
53 env := inst.gitEnv(aliceKey)
54 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
55 dir := filepath.Join(work, "w")
56 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
57 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
58 "jobs:\n ok:\n steps:\n - echo hello from $GITBAY_JOB\n broken:\n steps:\n - \"false\"\n"), 0o644)
59 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x\n"), 0o644)
60 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
61 mustGit(t, dir, env, "add", ".")
62 mustGit(t, dir, env, "commit", "-q", "-m", "base")
63 mustGit(t, dir, env, "push", "-q", "origin", "main")
64 sha := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
65
66 // The push queued one pending build per job, with pending statuses.
67 out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
68 if !strings.Contains(out, "broken\tpending") || !strings.Contains(out, "ok\tpending") {
69 t.Fatalf("builds not queued:\n%s", out)
70 }
71 out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha)
72 if !strings.Contains(out, "ci/ok") || !strings.Contains(out, "pending") {
73 t.Fatalf("pending statuses missing:\n%s", out)
74 }
75
76 // Non-admins cannot claim jobs.
77 if _, _, code := inst.ssh(t, aliceKey, "", "runner", "next"); code != 4 {
78 t.Fatalf("non-admin claimed a build: exit %d", code)
79 }
80
81 // The runner processes both jobs ("broken" sorts first).
82 inst.runnerOnce(t, runnerKey)
83 inst.runnerOnce(t, runnerKey)
84
85 out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
86 if !strings.Contains(out, "ok\tsuccess") || !strings.Contains(out, "broken\tfailure") {
87 t.Fatalf("build outcomes wrong:\n%s", out)
88 }
89 // Logs captured the step output and the failure.
90 var okN, brokenN string
91 for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
92 f := strings.Split(l, "\t")
93 if f[1] == "ok" {
94 okN = f[0]
95 } else {
96 brokenN = f[0]
97 }
98 }
99 out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", okN)
100 if !strings.Contains(out, "hello from ok") {
101 t.Fatalf("ok log:\n%s", out)
102 }
103 out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN)
104 if !strings.Contains(out, "step failed") {
105 t.Fatalf("broken log:\n%s", out)
106 }
107 // Statuses resolved, with target URLs pointing at the build pages.
108 out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha, "--json")
109 if !strings.Contains(out, `"ci/ok","state":"success"`) && !strings.Contains(out, `"state":"success"`) {
110 t.Fatalf("status not success:\n%s", out)
111 }
112 if !strings.Contains(out, "/alice/app/builds/") {
113 t.Fatalf("status target url missing:\n%s", out)
114 }
115
116 // Web: list page and log page.
117 status, body := inst.get(t, "/alice/app/builds")
118 if status != 200 || !strings.Contains(body, "ok") || !strings.Contains(body, "failure") {
119 t.Fatalf("builds page: %d\n%s", status, body)
120 }
121 if _, body = inst.get(t, "/alice/app/builds/"+okN); !strings.Contains(body, "hello from ok") {
122 t.Fatalf("build log page:\n%s", body)
123 }
124
125 // A broken ci.yml surfaces as a failed ci/config status.
126 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs: {bad name: {steps: [x]}}\n"), 0o644)
127 mustGit(t, dir, env, "add", ".")
128 mustGit(t, dir, env, "commit", "-q", "-m", "break config")
129 mustGit(t, dir, env, "push", "-q", "origin", "main")
130 sha2 := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
131 out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha2)
132 if !strings.Contains(out, "ci/config") || !strings.Contains(out, "failure") {
133 t.Fatalf("config failure status missing:\n%s", out)
134 }
135}
e2e/ssh_test.go +2 −1
@@ -14,7 +14,8 @@ import (
1414 )
1515
1616 type instance struct {
17 gitbayd string // path to built binary
17 gitbayd string // path to built binary
18 runner string // path to built gitbay-runner (CI tests)
1819 root string
1920 config string
2021 port int
internal/ci/ci.go added +73
@@ -0,0 +1,73 @@
1// Package ci parses .gitbay/ci.yml, the per-repo build configuration:
2//
3// jobs:
4// test:
5// steps:
6// - go test ./...
7//
8// Each job becomes one build per push; each step is a shell command the
9// runner executes with `sh -c`, stopping at the first failure.
10package ci
11
12import (
13 "fmt"
14 "regexp"
15 "sort"
16
17 yaml "go.yaml.in/yaml/v3"
18)
19
20// ConfigPath is where the build configuration lives in a repository.
21const ConfigPath = ".gitbay/ci.yml"
22
23const (
24 maxJobs = 10
25 maxSteps = 50
26 maxStepSize = 4096
27)
28
29var jobName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`)
30
31type Job struct {
32 Name string
33 Steps []string
34}
35
36// Parse returns the jobs in name order, or an error describing the first
37// problem so the pusher can fix the file.
38func Parse(raw []byte) ([]Job, error) {
39 var doc struct {
40 Jobs map[string]struct {
41 Steps []string `yaml:"steps"`
42 } `yaml:"jobs"`
43 }
44 if err := yaml.Unmarshal(raw, &doc); err != nil {
45 return nil, fmt.Errorf("parsing %s: %w", ConfigPath, err)
46 }
47 if len(doc.Jobs) == 0 {
48 return nil, fmt.Errorf("%s defines no jobs", ConfigPath)
49 }
50 if len(doc.Jobs) > maxJobs {
51 return nil, fmt.Errorf("%s defines %d jobs; max %d", ConfigPath, len(doc.Jobs), maxJobs)
52 }
53 var jobs []Job
54 for name, j := range doc.Jobs {
55 if !jobName.MatchString(name) {
56 return nil, fmt.Errorf("bad job name %q: lowercase letters, digits, - and _; max 40 chars", name)
57 }
58 if len(j.Steps) == 0 {
59 return nil, fmt.Errorf("job %q has no steps", name)
60 }
61 if len(j.Steps) > maxSteps {
62 return nil, fmt.Errorf("job %q has %d steps; max %d", name, len(j.Steps), maxSteps)
63 }
64 for _, s := range j.Steps {
65 if len(s) > maxStepSize {
66 return nil, fmt.Errorf("job %q has a step over %d bytes", name, maxStepSize)
67 }
68 }
69 jobs = append(jobs, Job{Name: name, Steps: j.Steps})
70 }
71 sort.Slice(jobs, func(i, k int) bool { return jobs[i].Name < jobs[k].Name })
72 return jobs, nil
73}
internal/control/build.go added +214
@@ -0,0 +1,214 @@
1package control
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "strconv"
8
9 "gitbay.org/gitbay/internal/policy"
10 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store"
12)
13
14func init() {
15 register(Command{Path: []string{"build", "list"},
16 Summary: "list recent builds: build list <owner/name>", ReadOnly: true, Run: runBuildList})
17 register(Command{Path: []string{"build", "show"},
18 Summary: "show one build: build show <owner/name> <n>", ReadOnly: true, Run: runBuildShow})
19 register(Command{Path: []string{"build", "log"},
20 Summary: "print a build's log: build log <owner/name> <n>", ReadOnly: true, Run: runBuildLog})
21
22 // Runner commands: the claim/report loop for gitbay-runner. Admin-only —
23 // a runner executes arbitrary repo code, so handing out jobs is the
24 // instance operator's call.
25 register(Command{Path: []string{"runner", "next"},
26 Summary: "claim the oldest pending build (runner protocol)", SSHOnly: true, Run: runRunnerNext})
27 register(Command{Path: []string{"runner", "log"},
28 Summary: "append a build's log from stdin: runner log <build-id>", SSHOnly: true, ReadsStdin: true, Run: runRunnerLog})
29 register(Command{Path: []string{"runner", "done"},
30 Summary: "finish a build: runner done <build-id> success|failure", SSHOnly: true, Run: runRunnerDone})
31}
32
33type buildOut struct {
34 Number int64 `json:"number"`
35 Job string `json:"job"`
36 Status string `json:"status"`
37 SHA string `json:"sha"`
38 Ref string `json:"ref"`
39 CreatedAt string `json:"created_at"`
40 FinishedAt string `json:"finished_at,omitempty"`
41}
42
43func buildToOut(b store.Build) buildOut {
44 return buildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}
45}
46
47func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
48 if len(args) != 2 {
49 return store.Repo{}, store.Build{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
50 }
51 repo, code := resolveRepo(c, args[0], policy.CanRead)
52 if code >= 0 {
53 return repo, store.Build{}, code
54 }
55 n, err := strconv.ParseInt(args[1], 10, 64)
56 if err != nil {
57 return repo, store.Build{}, c.fail(protocol.ExitUsage, "bad build number %q", args[1])
58 }
59 b, err := c.Store.BuildByNumber(repo.ID, n)
60 if err != nil {
61 return repo, b, c.fail(protocol.ExitNotFound, "no build %d on %s", n, repo.Path())
62 }
63 return repo, b, -1
64}
65
66func runBuildList(c *Ctx, args []string) int {
67 if len(args) != 1 {
68 return c.fail(protocol.ExitUsage, "usage: build list <owner/name>")
69 }
70 repo, code := resolveRepo(c, args[0], policy.CanRead)
71 if code >= 0 {
72 return code
73 }
74 builds, err := c.Store.ListBuilds(repo.ID, 50)
75 if err != nil {
76 return c.fail(protocol.ExitFailure, "%v", err)
77 }
78 var ds []buildOut
79 for _, b := range builds {
80 ds = append(ds, buildToOut(b))
81 }
82 return c.emit(ds, func(w io.Writer) {
83 for _, d := range ds {
84 fmt.Fprintf(w, "%d\t%s\t%s\t%.10s\t%s\n", d.Number, d.Job, d.Status, d.SHA, d.Ref)
85 }
86 })
87}
88
89func runBuildShow(c *Ctx, args []string) int {
90 _, b, code := buildRef(c, args)
91 if code >= 0 {
92 return code
93 }
94 d := buildToOut(b)
95 return c.emit(d, func(w io.Writer) {
96 fmt.Fprintf(w, "build %d\t%s\t%s\n%.10s on %s\nqueued %s", d.Number, d.Job, d.Status, d.SHA, d.Ref, d.CreatedAt)
97 if d.FinishedAt != "" {
98 fmt.Fprintf(w, ", finished %s", d.FinishedAt)
99 }
100 fmt.Fprintln(w)
101 })
102}
103
104func runBuildLog(c *Ctx, args []string) int {
105 _, b, code := buildRef(c, args)
106 if code >= 0 {
107 return code
108 }
109 log, err := c.Store.BuildLog(b.ID)
110 if err != nil {
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
113 c.Stdout.Write(log)
114 return protocol.ExitOK
115}
116
117func requireRunner(c *Ctx) int {
118 if !c.User.IsAdmin {
119 return c.fail(protocol.ExitDenied, "runner commands are for instance-admin runner accounts")
120 }
121 return -1
122}
123
124func runRunnerNext(c *Ctx, args []string) int {
125 if code := requireRunner(c); code >= 0 {
126 return code
127 }
128 b, ok, err := c.Store.ClaimBuild()
129 if err != nil {
130 return c.fail(protocol.ExitFailure, "%v", err)
131 }
132 if !ok {
133 return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
134 }
135 repo, err := c.Store.RepoByID(b.RepoID)
136 if err != nil {
137 return c.fail(protocol.ExitFailure, "%v", err)
138 }
139 var steps []string
140 json.Unmarshal([]byte(b.Steps), &steps)
141 d := struct {
142 ID int64 `json:"id"`
143 Repo string `json:"repo"`
144 Number int64 `json:"number"`
145 Job string `json:"job"`
146 SHA string `json:"sha"`
147 Ref string `json:"ref"`
148 Steps []string `json:"steps"`
149 }{b.ID, repo.Path(), b.Number, b.Job, b.SHA, b.Ref, steps}
150 return c.emit(d, func(w io.Writer) {
151 fmt.Fprintf(w, "build %d: %s %s @ %.10s\n", d.ID, d.Repo, d.Job, d.SHA)
152 })
153}
154
155func runRunnerLog(c *Ctx, args []string) int {
156 if code := requireRunner(c); code >= 0 {
157 return code
158 }
159 if len(args) != 1 {
160 return c.fail(protocol.ExitUsage, "usage: runner log <build-id> (chunk on stdin)")
161 }
162 id, err := strconv.ParseInt(args[0], 10, 64)
163 if err != nil {
164 return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
165 }
166 // Stream stdin into the log in chunks so long builds appear live.
167 buf := make([]byte, 64<<10)
168 for {
169 n, rerr := c.Stdin.Read(buf)
170 if n > 0 {
171 if err := c.Store.AppendBuildLog(id, buf[:n]); err != nil {
172 return c.fail(protocol.ExitFailure, "%v", err)
173 }
174 }
175 if rerr != nil {
176 break
177 }
178 }
179 return c.emit(map[string]string{"log": "ok"}, func(w io.Writer) {})
180}
181
182func runRunnerDone(c *Ctx, args []string) int {
183 if code := requireRunner(c); code >= 0 {
184 return code
185 }
186 if len(args) != 2 || (args[1] != "success" && args[1] != "failure") {
187 return c.fail(protocol.ExitUsage, "usage: runner done <build-id> success|failure")
188 }
189 id, err := strconv.ParseInt(args[0], 10, 64)
190 if err != nil {
191 return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
192 }
193 b, err := c.Store.BuildByID(id)
194 if err != nil {
195 return c.fail(protocol.ExitNotFound, "no build %d", id)
196 }
197 if err := c.Store.FinishBuild(id, args[1]); err != nil {
198 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
199 }
200 repo, err := c.Store.RepoByID(b.RepoID)
201 if err != nil {
202 return c.fail(protocol.ExitFailure, "%v", err)
203 }
204 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
205 desc := "build " + args[1]
206 if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, args[1], desc, url, c.User.ID); err != nil {
207 return c.fail(protocol.ExitFailure, "%v", err)
208 }
209 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1],
210 fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
211 return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) {
212 fmt.Fprintf(w, "build %d %s\n", b.Number, args[1])
213 })
214}
internal/hookd/hookd.go +31
@@ -17,6 +17,7 @@ import (
1717 "os"
1818 "path/filepath"
1919
20 "gitbay.org/gitbay/internal/ci"
2021 "gitbay.org/gitbay/internal/config"
2122 "gitbay.org/gitbay/internal/control"
2223 "gitbay.org/gitbay/internal/gitutil"
@@ -184,6 +185,10 @@ func (s *Server) postReceive(req Request) {
184185 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New)
185186 control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
186187 }
188 // A branch push with a .gitbay/ci.yml queues one build per job.
189 if pushedRepoErr == nil && !u.IsDelete {
190 s.queueBuilds(pushedRepo, req.UserID, branch, u.New)
191 }
187192 // Any branch/tag update schedules the push mirrors.
188193 s.st.MarkMirrorsDirty(req.RepoID, "push")
189194 if u.IsForce {
@@ -227,6 +232,32 @@ func (s *Server) postReceive(req Request) {
227232 }
228233 }
229234
235// queueBuilds reads .gitbay/ci.yml at the pushed commit and creates one
236// pending build per job, with a pending commit status the runner resolves.
237// A broken config surfaces as a failed "ci/config" status, not silence.
238func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string) {
239 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
240 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
241 if err != nil {
242 return // no CI config at this commit
243 }
244 jobs, err := ci.Parse(raw)
245 if err != nil {
246 s.st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID)
247 return
248 }
249 for _, j := range jobs {
250 steps, _ := json.Marshal(j.Steps)
251 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, branch, string(steps))
252 if err != nil {
253 slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
254 continue
255 }
256 url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
257 s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID)
258 }
259}
260
230261 func cutHeads(ref string) (string, bool) {
231262 const p = "refs/heads/"
232263 if len(ref) > len(p) && ref[:len(p)] == p {
internal/httpd/builds.go added +45
@@ -0,0 +1,45 @@
1package httpd
2
3import (
4 "net/http"
5 "strconv"
6
7 "gitbay.org/gitbay/internal/store"
8)
9
10func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
11 p, ok := s.repoFor(w, r, "")
12 if !ok {
13 return
14 }
15 p.Tab = "builds"
16 builds, _ := s.st.ListBuilds(p.Repo.ID, 50)
17 s.render(w, "builds.html", struct {
18 repoPage
19 Builds []store.Build
20 }{p, builds})
21}
22
23func (s *Server) build(w http.ResponseWriter, r *http.Request) {
24 p, ok := s.repoFor(w, r, "")
25 if !ok {
26 return
27 }
28 p.Tab = "builds"
29 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
30 if err != nil {
31 s.notFound(w, r)
32 return
33 }
34 b, err := s.st.BuildByNumber(p.Repo.ID, n)
35 if err != nil {
36 s.notFound(w, r)
37 return
38 }
39 log, _ := s.st.BuildLog(b.ID)
40 s.render(w, "build.html", struct {
41 repoPage
42 Build store.Build
43 Log string
44 }{p, b, string(log)})
45}
internal/httpd/routes.go +2
@@ -47,6 +47,8 @@ func (s *Server) Routes() []Route {
4747 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw},
4848 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki},
4949 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases},
50 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
51 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds/{n}", Handler: s.build},
5052 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset},
5153 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw},
5254 Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log},
internal/store/builds.go added +149
@@ -0,0 +1,149 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6)
7
8// Build is one CI job execution for one commit.
9type Build struct {
10 ID int64
11 RepoID int64
12 Number int64
13 Job string
14 SHA string
15 Ref string
16 Steps string // JSON array of shell commands
17 Status string // pending|running|success|failure
18 CreatedAt string
19 StartedAt string
20 FinishedAt string
21}
22
23// MaxBuildLog caps a build's stored log; appends past it are dropped.
24const MaxBuildLog = 2 << 20
25
26// CreateBuild allocates the per-repo build number in the same transaction
27// as the insert, like issue and MR numbers.
28func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON string) (int64, error) {
29 tx, err := s.DB.Begin()
30 if err != nil {
31 return 0, err
32 }
33 defer tx.Rollback()
34 if _, err := tx.Exec("UPDATE repos SET build_counter = build_counter + 1 WHERE id = ?", repoID); err != nil {
35 return 0, err
36 }
37 var n int64
38 if err := tx.QueryRow("SELECT build_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil {
39 return 0, err
40 }
41 if _, err := tx.Exec(
42 "INSERT INTO builds (repo_id, number, job, sha, ref, steps) VALUES (?, ?, ?, ?, ?, ?)",
43 repoID, n, job, sha, ref, stepsJSON); err != nil {
44 return 0, err
45 }
46 return n, tx.Commit()
47}
48
49const buildSelect = `
50 SELECT id, repo_id, number, job, sha, ref, steps, status, created_at, started_at, finished_at
51 FROM builds`
52
53func scanBuild(row interface{ Scan(...any) error }) (Build, error) {
54 var b Build
55 err := row.Scan(&b.ID, &b.RepoID, &b.Number, &b.Job, &b.SHA, &b.Ref, &b.Steps,
56 &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt)
57 return b, err
58}
59
60// ClaimBuild atomically hands the oldest pending build to a runner.
61func (s *Store) ClaimBuild() (Build, bool, error) {
62 tx, err := s.DB.Begin()
63 if err != nil {
64 return Build{}, false, err
65 }
66 defer tx.Rollback()
67 var id int64
68 err = tx.QueryRow("SELECT id FROM builds WHERE status = 'pending' ORDER BY id LIMIT 1").Scan(&id)
69 if errors.Is(err, sql.ErrNoRows) {
70 return Build{}, false, nil
71 }
72 if err != nil {
73 return Build{}, false, err
74 }
75 if _, err := tx.Exec(
76 "UPDATE builds SET status = 'running', started_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ?", id); err != nil {
77 return Build{}, false, err
78 }
79 b, err := scanBuild(tx.QueryRow(buildSelect+" WHERE id = ?", id))
80 if err != nil {
81 return Build{}, false, err
82 }
83 return b, true, tx.Commit()
84}
85
86// AppendBuildLog adds a chunk to the build's log, dropping bytes past the cap.
87func (s *Store) AppendBuildLog(id int64, chunk []byte) error {
88 _, err := s.DB.Exec(`
89 UPDATE builds SET log = log || ?
90 WHERE id = ? AND length(log) < ?`, chunk, id, MaxBuildLog)
91 return err
92}
93
94// FinishBuild records the outcome of a running build.
95func (s *Store) FinishBuild(id int64, status string) error {
96 res, err := s.DB.Exec(`
97 UPDATE builds SET status = ?, finished_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')
98 WHERE id = ? AND status = 'running'`, status, id)
99 if err != nil {
100 return err
101 }
102 if n, _ := res.RowsAffected(); n == 0 {
103 return ErrNotFound
104 }
105 return nil
106}
107
108func (s *Store) BuildByID(id int64) (Build, error) {
109 b, err := scanBuild(s.DB.QueryRow(buildSelect+" WHERE id = ?", id))
110 if errors.Is(err, sql.ErrNoRows) {
111 return b, ErrNotFound
112 }
113 return b, err
114}
115
116func (s *Store) BuildByNumber(repoID, number int64) (Build, error) {
117 b, err := scanBuild(s.DB.QueryRow(buildSelect+" WHERE repo_id = ? AND number = ?", repoID, number))
118 if errors.Is(err, sql.ErrNoRows) {
119 return b, ErrNotFound
120 }
121 return b, err
122}
123
124func (s *Store) ListBuilds(repoID int64, limit int) ([]Build, error) {
125 rows, err := s.DB.Query(buildSelect+" WHERE repo_id = ? ORDER BY number DESC LIMIT ?", repoID, limit)
126 if err != nil {
127 return nil, err
128 }
129 defer rows.Close()
130 var out []Build
131 for rows.Next() {
132 b, err := scanBuild(rows)
133 if err != nil {
134 return nil, err
135 }
136 out = append(out, b)
137 }
138 return out, rows.Err()
139}
140
141// BuildLog returns the stored log bytes.
142func (s *Store) BuildLog(id int64) ([]byte, error) {
143 var log []byte
144 err := s.DB.QueryRow("SELECT log FROM builds WHERE id = ?", id).Scan(&log)
145 if errors.Is(err, sql.ErrNoRows) {
146 return nil, ErrNotFound
147 }
148 return log, err
149}
internal/store/migrations/0022_builds.down.sql added +2
@@ -0,0 +1,2 @@
1DROP TABLE builds;
2ALTER TABLE repos DROP COLUMN build_counter;
internal/store/migrations/0022_builds.up.sql added +17
@@ -0,0 +1,17 @@
1ALTER TABLE repos ADD COLUMN build_counter INTEGER NOT NULL DEFAULT 0;
2CREATE TABLE builds (
3 id INTEGER PRIMARY KEY,
4 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
5 number INTEGER NOT NULL,
6 job TEXT NOT NULL,
7 sha TEXT NOT NULL,
8 ref TEXT NOT NULL,
9 steps TEXT NOT NULL, -- JSON array of shell commands
10 status TEXT NOT NULL DEFAULT 'pending', -- pending|running|success|failure
11 log BLOB NOT NULL DEFAULT x'',
12 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
13 started_at TEXT NOT NULL DEFAULT '',
14 finished_at TEXT NOT NULL DEFAULT '',
15 UNIQUE (repo_id, number)
16);
17CREATE INDEX builds_pending ON builds(status);
internal/web/static/style.css +5
@@ -581,6 +581,11 @@ pre.diff .meta { color: var(--muted); }
581581 .badge.check-success { --chip: var(--ok); }
582582 .badge.check-pending { --chip: var(--warn); }
583583 .badge.check-failure, .badge.check-error { --chip: var(--bad); }
584.badge.check-running, .chip.check-running { --chip: var(--warn); }
585.chip.check-success { --chip: var(--ok); }
586.chip.check-pending { --chip: var(--warn); }
587.chip.check-failure { --chip: var(--bad); }
588pre.buildlog { max-height: 40rem; overflow: auto; }
584589 span.check-success { color: var(--ok); }
585590 span.check-pending { color: var(--warn); }
586591 span.check-failure, span.check-error { color: var(--bad); }
internal/web/templates/build.html added +9
@@ -0,0 +1,9 @@
1{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3{{template "repoheader" .}}
4<div class="headrow">
5<h1>build {{.Build.Number}} <span class="chip check-{{.Build.Status}}">{{.Build.Status}}</span></h1>
6</div>
7<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p>
8{{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}
9{{end}}
internal/web/templates/builds.html added +17
@@ -0,0 +1,17 @@
1{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3{{template "repoheader" .}}
4<ul class="loglist">
5{{range .Builds}}<li>
6 <div class="commitmain">
7 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/builds/{{.Number}}">#{{.Number}} {{.Job}}</a></p>
8 <p class="meta">{{.Ref}} · {{when .CreatedAt}}</p>
9 </div>
10 <div class="commitside">
11 <span class="badge check-{{.Status}}">{{.Status}}</span>
12 <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{printf "%.10s" .SHA}}</a></code>
13 </div>
14</li>
15{{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}}
16</ul>
17{{end}}
internal/web/templates/layout.html +1
@@ -39,6 +39,7 @@
3939 <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
4040 <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>
4141 <a {{if eq .Tab "releases"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/releases">releases</a>
42 <a {{if eq .Tab "builds"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds">builds</a>
4243 <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a>
4344 <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a>
4445 {{if .HasWiki}}<a {{if eq .Tab "wiki"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/wiki">wiki</a>