Commit 2e62f52baa
Verified · cmc
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. | |
| 10 | package main | |
| 11 | ||
| 12 | import ( | |
| 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 | ||
| 25 | type 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 | ||
| 35 | type 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 | ||
| 43 | func 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. | |
| 85 | func (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. | |
| 114 | func (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. | |
| 181 | func (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() { | ||
| 30 | 30 | pass("set", "report a status: <sha> --context <c> --state <s> [--description d] [--url u]", passOpts{server: []string{"status", "set"}, needsRepo: true}), |
| 31 | 31 | pass("list", "statuses on a commit: <sha>", passOpts{server: []string{"status", "list"}, needsRepo: true}), |
| 32 | 32 | ), |
| 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 | ), | |
| 33 | 38 | repoCmd(), |
| 34 | 39 | issueCmd(), |
| 35 | 40 | milestoneCmd(), |
e2e/ci_test.go added +135
| @@ -0,0 +1,135 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "os" | |
| 6 | "os/exec" | |
| 7 | "path/filepath" | |
| 8 | "strings" | |
| 9 | "testing" | |
| 10 | ) | |
| 11 | ||
| 12 | func 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. | |
| 24 | func (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 | ||
| 41 | func 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 ( | ||
| 14 | 14 | ) |
| 15 | 15 | |
| 16 | 16 | 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) | |
| 18 | 19 | root string |
| 19 | 20 | config string |
| 20 | 21 | 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. | |
| 10 | package ci | |
| 11 | ||
| 12 | import ( | |
| 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. | |
| 21 | const ConfigPath = ".gitbay/ci.yml" | |
| 22 | ||
| 23 | const ( | |
| 24 | maxJobs = 10 | |
| 25 | maxSteps = 50 | |
| 26 | maxStepSize = 4096 | |
| 27 | ) | |
| 28 | ||
| 29 | var jobName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`) | |
| 30 | ||
| 31 | type 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. | |
| 38 | func 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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 14 | func 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 | ||
| 33 | type 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 | ||
| 43 | func buildToOut(b store.Build) buildOut { | |
| 44 | return buildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt} | |
| 45 | } | |
| 46 | ||
| 47 | func 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 | ||
| 66 | func 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 | ||
| 89 | func 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 | ||
| 104 | func 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 | ||
| 117 | func 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 | ||
| 124 | func 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 | ||
| 155 | func 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 | ||
| 182 | func 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 ( | ||
| 17 | 17 | "os" |
| 18 | 18 | "path/filepath" |
| 19 | 19 | |
| 20 | "gitbay.org/gitbay/internal/ci" | |
| 20 | 21 | "gitbay.org/gitbay/internal/config" |
| 21 | 22 | "gitbay.org/gitbay/internal/control" |
| 22 | 23 | "gitbay.org/gitbay/internal/gitutil" |
| @@ -184,6 +185,10 @@ func (s *Server) postReceive(req Request) { | ||
| 184 | 185 | control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New) |
| 185 | 186 | control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New) |
| 186 | 187 | } |
| 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 | } | |
| 187 | 192 | // Any branch/tag update schedules the push mirrors. |
| 188 | 193 | s.st.MarkMirrorsDirty(req.RepoID, "push") |
| 189 | 194 | if u.IsForce { |
| @@ -227,6 +232,32 @@ func (s *Server) postReceive(req Request) { | ||
| 227 | 232 | } |
| 228 | 233 | } |
| 229 | 234 | |
| 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. | |
| 238 | func (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 | ||
| 230 | 261 | func cutHeads(ref string) (string, bool) { |
| 231 | 262 | const p = "refs/heads/" |
| 232 | 263 | if len(ref) > len(p) && ref[:len(p)] == p { |
internal/httpd/builds.go added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/http" | |
| 5 | "strconv" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/store" | |
| 8 | ) | |
| 9 | ||
| 10 | func (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 | ||
| 23 | func (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 { | ||
| 47 | 47 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw}, |
| 48 | 48 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki}, |
| 49 | 49 | 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}, | |
| 50 | 52 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset}, |
| 51 | 53 | Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, |
| 52 | 54 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, |
internal/store/builds.go added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "errors" | |
| 6 | ) | |
| 7 | ||
| 8 | // Build is one CI job execution for one commit. | |
| 9 | type 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. | |
| 24 | const 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. | |
| 28 | func (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 | ||
| 49 | const buildSelect = ` | |
| 50 | SELECT id, repo_id, number, job, sha, ref, steps, status, created_at, started_at, finished_at | |
| 51 | FROM builds` | |
| 52 | ||
| 53 | func 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. | |
| 61 | func (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. | |
| 87 | func (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. | |
| 95 | func (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 | ||
| 108 | func (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 | ||
| 116 | func (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 | ||
| 124 | func (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. | |
| 142 | func (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 @@ | ||
| 1 | DROP TABLE builds; | |
| 2 | ALTER TABLE repos DROP COLUMN build_counter; | |
internal/store/migrations/0022_builds.up.sql added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | ALTER TABLE repos ADD COLUMN build_counter INTEGER NOT NULL DEFAULT 0; | |
| 2 | CREATE 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 | ); | |
| 17 | CREATE INDEX builds_pending ON builds(status); | |
internal/web/static/style.css +5
| @@ -581,6 +581,11 @@ pre.diff .meta { color: var(--muted); } | ||
| 581 | 581 | .badge.check-success { --chip: var(--ok); } |
| 582 | 582 | .badge.check-pending { --chip: var(--warn); } |
| 583 | 583 | .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); } | |
| 588 | pre.buildlog { max-height: 40rem; overflow: auto; } | |
| 584 | 589 | span.check-success { color: var(--ok); } |
| 585 | 590 | span.check-pending { color: var(--warn); } |
| 586 | 591 | 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 @@ | ||
| 39 | 39 | <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> |
| 40 | 40 | <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a> |
| 41 | 41 | <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> | |
| 42 | 43 | <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a> |
| 43 | 44 | <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a> |
| 44 | 45 | {{if .HasWiki}}<a {{if eq .Tab "wiki"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/wiki">wiki</a> |