builds: name the failed step and duration; jump to failure !489

merged merged by cmc on 2026-09-28 22:33 UTC · krz/gitbay:build-failure-report into main

23 files changed, +717 −56

Layout: unified · split

.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org +2 −1
@@ -39,7 +39,8 @@ commit instead of failing silently.
39 (=cmd/gitbay-runner/isolate.go=). 39 (=cmd/gitbay-runner/isolate.go=).
404. *Log.* =runner log <id>= streams stdin into the build row; the server 404. *Log.* =runner log <id>= streams stdin into the build row; the server
41 ends the stream if the build is cancelled (=build.go=). 41 ends the stream if the build is cancelled (=build.go=).
425. *Result.* =runner done <id> success|failure= sets the status, 425. *Result.* =runner done <id> success|failure [--step <n>] [--reason
43 <text>]= records where a failed build stopped, sets the status,
43 records an event and mails the repository's watchers a log tail on 44 records an event and mails the repository's watchers a log tail on
44 failure (=build.go=). 45 failure (=build.go=).
456. *Reap.* The scheduler fails a running build whose log stream closed 466. *Reap.* The scheduler fails a running build whose log stream closed
.gitbay/wiki/CI.org +11
@@ -64,6 +64,17 @@ seconds: a follower who loses it is told the repository is not found.
64A restart ends every open follow with a message saying so, rather 64A restart ends every open follow with a message saying so, rather
65than holding the drain; follow again once the daemon is back. 65than holding the drain; follow again once the daemon is back.
66 66
67A failed build names the step it stopped at: the log's last line reads
68=step 3/3 failed: exit 1=, and =build show= prints =failed step= (=3/3
69go test ./... (exit 1)=) and =duration=. =build log <owner/name> <n>
70--step failed= prints only that step's output, =--step 2= another one
71(=0= is the clone before the first step), and =--tail 40= the last forty
72lines of whichever was chosen; neither combines with =--follow=. The
73build page folds the finished log into one section per step, opens the
74failed one and links to it from the top as "Jump to failure". Builds
75from before this reported no step; their last section is taken as the
76failed one.
77
67* What a build can reach 78* What a build can reach
68 79
69Builds have outbound internet access, trusted and untrusted alike. On 80Builds have outbound internet access, trusted and untrusted alike. On
.gitbay/wiki/Parity.org +3
@@ -217,6 +217,9 @@ rather than the one the web page shows.
217| build show (one build) | yes | yes | yes | 217| build show (one build) | yes | yes | yes |
218| build log | yes | yes | yes | 218| build log | yes | yes | yes |
219| build log follow (until it ends) | yes | yes | no | 219| build log follow (until it ends) | yes | yes | no |
220| build failed step, duration | yes | yes | no |
221| build log one step | yes | yes | no |
222| build log tail | yes | no | no |
220| build jobs | yes | yes | yes | 223| build jobs | yes | yes | yes |
221| build trigger | yes | yes | yes | 224| build trigger | yes | yes | yes |
222| build cancel | yes | yes | yes | 225| build cancel | yes | yes | yes |
.gitbay/wiki/Users.org +3 −1
@@ -558,7 +558,9 @@ require-contexts= names statuses the gate waits for until they report,
558and turns the gate on. =build list= takes =--ref=, 558and turns the gate on. =build list= takes =--ref=,
559=--status= and =--job= to narrow the listing, combinable; the builds 559=--status= and =--job= to narrow the listing, combinable; the builds
560tab reads the same flags from its =?ref=, =?status= and =?job= query 560tab reads the same flags from its =?ref=, =?status= and =?job= query
561parameters and groups the result into one row per commit. Steps run 561parameters and groups the result into one row per commit. =build show=
562names a failed build's step and how long it ran, and =build log= takes
563=--step <n>|failed= and =--tail <lines>=. Steps run
562with =sh -c= on the instance's runner, stopping at the first failure; 564with =sh -c= on the instance's runner, stopping at the first failure;
563a broken config surfaces as a failed =ci/config= status. Environment: 565a broken config surfaces as a failed =ci/config= status. Environment:
564=GITBAY_REPO=, 566=GITBAY_REPO=,
CHANGELOG.org +6
@@ -142,6 +142,12 @@ for the eighteen commands whose CLI path differs from the registry's
142 =deploy/runner-podman-setup.sh= (it installs nftables) before =make 142 =deploy/runner-podman-setup.sh= (it installs nftables) before =make
143 deploy-runner=. Deploy gitbayd, then the runner, after validating on 143 deploy-runner=. Deploy gitbayd, then the runner, after validating on
144 a scratch repository per the CI page. (#260) 144 a scratch repository per the CI page. (#260)
145- =build show= names a failed build's step and duration; =build log
146 --step <n>|failed --tail <lines>= reads one step's output or the
147 last lines of the stored log. Deploy gitbayd before the runner: an
148 older server refuses the runner's =--step= and =--reason= (exit 2),
149 and its failed builds stay running until the reaper fails them.
150 (#266)
145 151
146* v1.36.0 — 2026-09-23 152* v1.36.0 — 2026-09-23
147 153
cmd/gitbay-runner/isolate.go +17 −17
@@ -76,25 +76,25 @@ func (r *runner) checkIsolation() error {
76 } 76 }
77} 77}
78 78
79// runSteps executes a job's steps and reports whether all succeeded. The 79// runSteps executes a job's steps. Returns nil when every step succeeded.
80// clone has already happened, outside any container and with the runner's 80// The clone has already happened, outside any container and with the
81// key: the container never sees GIT_SSH_COMMAND, the key, or the runner's 81// runner's key: the container never sees GIT_SSH_COMMAND, the key, or the
82// environment — it gets the workspace and nothing else. 82// runner's environment — it gets the workspace and nothing else.
83type stepRunner func(cmd *exec.Cmd, deadline time.Time) (bool, string) 83type stepRunner func(cmd *exec.Cmd, deadline time.Time) (bool, string)
84 84
85func (r *runner) runSteps(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) bool { 85func (r *runner) runSteps(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) *failure {
86 if r.isolation == isolationNone { 86 if r.isolation == isolationNone {
87 for _, step := range j.Steps { 87 for i, step := range j.Steps {
88 fmt.Fprintf(sink, "$ %s\n", step) 88 fmt.Fprintf(sink, "$ %s\n", step)
89 cmd := exec.Command(toolpath.Look("sh"), "-c", step) 89 cmd := exec.Command(toolpath.Look("sh"), "-c", step)
90 cmd.Dir, cmd.Env = dir, env 90 cmd.Dir, cmd.Env = dir, env
91 cmd.Stdout, cmd.Stderr = sink, sink 91 cmd.Stdout, cmd.Stderr = sink, sink
92 if ok, why := runStep(cmd, deadline); !ok { 92 if ok, why := runStep(cmd, deadline); !ok {
93 fmt.Fprintf(sink, "%s\n", why) 93 fmt.Fprintf(sink, "step %d/%d failed: %s\n", i+1, len(j.Steps), why)
94 return false 94 return &failure{Step: i + 1, Reason: why}
95 } 95 }
96 } 96 }
97 return true 97 return nil
98 } 98 }
99 return r.runStepsPodman(j, dir, env, sink, deadline, runStep) 99 return r.runStepsPodman(j, dir, env, sink, deadline, runStep)
100} 100}
@@ -103,7 +103,7 @@ func (r *runner) runSteps(j job, dir string, env []string, sink io.Writer, deadl
103// step in it with `podman exec`. One container per job, not per step, 103// step in it with `podman exec`. One container per job, not per step,
104// because steps share state — a build step writes what a test step reads 104// because steps share state — a build step writes what a test step reads
105// — and per-step containers would break that. 105// — and per-step containers would break that.
106func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) bool { 106func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) *failure {
107 podman := toolpath.Look("podman") 107 podman := toolpath.Look("podman")
108 image := j.Image 108 image := j.Image
109 if image == "" { 109 if image == "" {
@@ -124,7 +124,7 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
124 envFile := filepath.Join(r.workdir, fmt.Sprintf("env-%d", j.ID)) 124 envFile := filepath.Join(r.workdir, fmt.Sprintf("env-%d", j.ID))
125 if err := writeEnvFile(envFile, fileEnv); err != nil { 125 if err := writeEnvFile(envFile, fileEnv); err != nil {
126 fmt.Fprintf(sink, "preparing the build environment: %v\n", err) 126 fmt.Fprintf(sink, "preparing the build environment: %v\n", err)
127 return false 127 return &failure{Reason: "preparing the build environment failed"}
128 } 128 }
129 defer os.Remove(envFile) 129 defer os.Remove(envFile)
130 130
@@ -137,7 +137,7 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
137 dir, f, err := r.cgroups.create(j.ID, r.memory, r.cpus) 137 dir, f, err := r.cgroups.create(j.ID, r.memory, r.cpus)
138 if err != nil { 138 if err != nil {
139 fmt.Fprintf(sink, "preparing the build cgroup: %v\n", err) 139 fmt.Fprintf(sink, "preparing the build cgroup: %v\n", err)
140 return false 140 return &failure{Reason: "preparing the build cgroup failed"}
141 } 141 }
142 cgroupFD = f 142 cgroupFD = f
143 defer f.Close() 143 defer f.Close()
@@ -179,22 +179,22 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
179 fmt.Fprintf(sink, "\nThis runner does not pull images. Ask an operator to provision %s "+ 179 fmt.Fprintf(sink, "\nThis runner does not pull images. Ask an operator to provision %s "+
180 "on the runner host (podman pull, or podman build) before a job names it.\n", image) 180 "on the runner host (podman pull, or podman build) before a job names it.\n", image)
181 } 181 }
182 return false 182 return &failure{Reason: "starting the build container failed"}
183 } 183 }
184 defer exec.Command(podman, append(r.podmanGlobal(), "rm", "--force", name)...).Run() 184 defer exec.Command(podman, append(r.podmanGlobal(), "rm", "--force", name)...).Run()
185 185
186 for _, step := range j.Steps { 186 for i, step := range j.Steps {
187 fmt.Fprintf(sink, "$ %s\n", step) 187 fmt.Fprintf(sink, "$ %s\n", step)
188 cmd := exec.Command(podman, append(r.podmanGlobal(), "exec", "--workdir", "/workspace", name, "sh", "-c", step)...) 188 cmd := exec.Command(podman, append(r.podmanGlobal(), "exec", "--workdir", "/workspace", name, "sh", "-c", step)...)
189 cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + r.podmanHome()} 189 cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + r.podmanHome()}
190 intoCgroup(cmd, cgroupFD) 190 intoCgroup(cmd, cgroupFD)
191 cmd.Stdout, cmd.Stderr = sink, sink 191 cmd.Stdout, cmd.Stderr = sink, sink
192 if ok, why := runStep(cmd, deadline); !ok { 192 if ok, why := runStep(cmd, deadline); !ok {
193 fmt.Fprintf(sink, "%s\n", why) 193 fmt.Fprintf(sink, "step %d/%d failed: %s\n", i+1, len(j.Steps), why)
194 return false 194 return &failure{Step: i + 1, Reason: why}
195 } 195 }
196 } 196 }
197 return true 197 return nil
198} 198}
199 199
200// podmanGlobal are the flags every podman invocation needs, before the 200// podmanGlobal are the flags every podman invocation needs, before the
cmd/gitbay-runner/main.go +33 −11
@@ -11,6 +11,7 @@ package main
11 11
12import ( 12import (
13 "encoding/json" 13 "encoding/json"
14 "errors"
14 "flag" 15 "flag"
15 "fmt" 16 "fmt"
16 "io" 17 "io"
@@ -274,11 +275,12 @@ func (r *runner) step() (bool, error) {
274 } 275 }
275 j := env.Data 276 j := env.Data
276 log.Printf("build %d: %s %s @ %.10s", j.ID, j.Repo, j.Job, j.SHA) 277 log.Printf("build %d: %s %s @ %.10s", j.ID, j.Repo, j.Job, j.SHA)
277 status := "failure" 278 f := r.run(j)
278 if r.run(j) { 279 status := "success"
279 status = "success" 280 if f != nil {
281 status = "failure"
280 } 282 }
281 if err := r.reportDone(j.ID, status); err != nil { 283 if err := r.reportDone(j.ID, status, f); err != nil {
282 return true, err 284 return true, err
283 } 285 }
284 log.Printf("build %d: %s", j.ID, status) 286 log.Printf("build %d: %s", j.ID, status)
@@ -315,16 +317,36 @@ func (s *logSink) broken() bool {
315 return s.w == nil 317 return s.w == nil
316} 318}
317 319
320// failure says where a build stopped: Step is the 1-based step that
321// failed, 0 when the build stopped before its first step (the clone, the
322// container), and Reason is one short line (#266).
323type failure struct {
324 Step int
325 Reason string
326}
327
328// exitReason is how a finished command's failure reads in a build's log
329// and on the build: "exit 1" for a command that exited, the error
330// otherwise (a signal, a start failure).
331func exitReason(err error) string {
332 var ee *exec.ExitError
333 if errors.As(err, &ee) && ee.ExitCode() >= 0 {
334 return fmt.Sprintf("exit %d", ee.ExitCode())
335 }
336 return err.Error()
337}
338
318// run clones, checks out, and executes the steps, streaming output to the 339// run clones, checks out, and executes the steps, streaming output to the
319// server. Returns whether every step succeeded. 340// server. Returns nil when every step succeeded, else where the build
320func (r *runner) run(j job) bool { 341// stopped.
342func (r *runner) run(j job) *failure {
321 dir := filepath.Join(r.workdir, fmt.Sprintf("build-%d", j.ID)) 343 dir := filepath.Join(r.workdir, fmt.Sprintf("build-%d", j.ID))
322 defer os.RemoveAll(dir) 344 defer os.RemoveAll(dir)
323 345
324 home, doneHome, err := buildHome(r.workdir, j) 346 home, doneHome, err := buildHome(r.workdir, j)
325 if err != nil { 347 if err != nil {
326 log.Printf("build %d: build home: %v", j.ID, err) 348 log.Printf("build %d: build home: %v", j.ID, err)
327 return false 349 return &failure{Reason: "preparing the build home failed"}
328 } 350 }
329 defer doneHome() 351 defer doneHome()
330 352
@@ -333,13 +355,13 @@ func (r *runner) run(j job) bool {
333 pipe, err := logCmd.StdinPipe() 355 pipe, err := logCmd.StdinPipe()
334 if err != nil { 356 if err != nil {
335 log.Printf("build %d: log pipe: %v", j.ID, err) 357 log.Printf("build %d: log pipe: %v", j.ID, err)
336 return false 358 return &failure{Reason: "opening the log stream failed"}
337 } 359 }
338 sink := &logSink{w: pipe} 360 sink := &logSink{w: pipe}
339 logCmd.Stdout, logCmd.Stderr = io.Discard, io.Discard 361 logCmd.Stdout, logCmd.Stderr = io.Discard, io.Discard
340 if err := logCmd.Start(); err != nil { 362 if err := logCmd.Start(); err != nil {
341 log.Printf("build %d: log stream: %v", j.ID, err) 363 log.Printf("build %d: log stream: %v", j.ID, err)
342 return false 364 return &failure{Reason: "opening the log stream failed"}
343 } 365 }
344 // The server ends the log session with exit 3 when the build is 366 // The server ends the log session with exit 3 when the build is
345 // cancelled; any other end is a lost stream, which the sink absorbs. 367 // cancelled; any other end is a lost stream, which the sink absorbs.
@@ -384,7 +406,7 @@ func (r *runner) run(j job) bool {
384 select { 406 select {
385 case err := <-done: 407 case err := <-done:
386 if err != nil { 408 if err != nil {
387 return false, fmt.Sprintf("step failed: %v", err) 409 return false, exitReason(err)
388 } 410 }
389 return true, "" 411 return true, ""
390 case <-cancelled: 412 case <-cancelled:
@@ -425,7 +447,7 @@ func (r *runner) run(j job) bool {
425 cmd.Stdout, cmd.Stderr = sink, sink 447 cmd.Stdout, cmd.Stderr = sink, sink
426 if ok, why := runStep(cmd, deadline); !ok { 448 if ok, why := runStep(cmd, deadline); !ok {
427 fmt.Fprintf(sink, "git %s: %s\n", args[0], why) 449 fmt.Fprintf(sink, "git %s: %s\n", args[0], why)
428 return false 450 return &failure{Reason: "git " + args[0] + ": " + why}
429 } 451 }
430 } 452 }
431 453
cmd/gitbay-runner/report.go +22 −2
@@ -4,6 +4,8 @@ import (
4 "errors" 4 "errors"
5 "fmt" 5 "fmt"
6 "os/exec" 6 "os/exec"
7 "strconv"
8 "strings"
7 "time" 9 "time"
8) 10)
9 11
@@ -16,12 +18,30 @@ import (
16// Only a connection-level failure is retried — ssh exits 255 for those. 18// Only a connection-level failure is retried — ssh exits 255 for those.
17// Any other exit is the server's answer, and asking again would not 19// Any other exit is the server's answer, and asking again would not
18// change it. Four retries over about thirty seconds outlasts a restart. 20// change it. Four retries over about thirty seconds outlasts a restart.
19func (r *runner) reportDone(id int64, status string) error { 21func (r *runner) reportDone(id int64, status string, f *failure) error {
22 args := doneArgs(id, status, f)
20 return reportWithRetry(func() (string, error) { 23 return reportWithRetry(func() (string, error) {
21 return r.ssh(nil, "runner", "done", fmt.Sprint(id), status) 24 return r.ssh(nil, args...)
22 }, id, retryDelays) 25 }, id, retryDelays)
23} 26}
24 27
28// doneArgs is the runner done command for a build's outcome. The reason
29// is single-quoted: ssh joins arguments with spaces, and the server
30// splits the line again with POSIX rules.
31func doneArgs(id int64, status string, f *failure) []string {
32 args := []string{"runner", "done", fmt.Sprint(id), status}
33 if f == nil {
34 return args
35 }
36 if f.Step > 0 {
37 args = append(args, "--step", strconv.Itoa(f.Step))
38 }
39 if f.Reason != "" {
40 args = append(args, "--reason", "'"+strings.ReplaceAll(f.Reason, "'", `'\''`)+"'")
41 }
42 return args
43}
44
25var retryDelays = []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second} 45var retryDelays = []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second}
26 46
27func reportWithRetry(report func() (string, error), id int64, delays []time.Duration) error { 47func reportWithRetry(report func() (string, error), id int64, delays []time.Duration) error {
cmd/gitbay-runner/report_test.go +23
@@ -3,8 +3,11 @@ package main
3import ( 3import (
4 "errors" 4 "errors"
5 "os/exec" 5 "os/exec"
6 "strings"
6 "testing" 7 "testing"
7 "time" 8 "time"
9
10 "gitbay.org/gitbay/internal/protocol"
8) 11)
9 12
10// exitErr fabricates the error ssh returns for a given exit status. 13// exitErr fabricates the error ssh returns for a given exit status.
@@ -61,3 +64,23 @@ func TestReportGivesUp(t *testing.T) {
61 t.Fatalf("err=%v calls=%d, want three attempts then an error", err, calls) 64 t.Fatalf("err=%v calls=%d, want three attempts then an error", err, calls)
62 } 65 }
63} 66}
67
68// The reason survives the trip: ssh joins arguments with spaces and the
69// server splits the line again with POSIX rules (#266).
70func TestDoneArgsNameTheFailedStep(t *testing.T) {
71 got := doneArgs(7, "failure", &failure{Step: 3, Reason: "can't: exit 1"})
72 argv, err := protocol.Tokenize(strings.Join(got, " "))
73 if err != nil {
74 t.Fatal(err)
75 }
76 want := []string{"runner", "done", "7", "failure", "--step", "3", "--reason", "can't: exit 1"}
77 if strings.Join(argv, "|") != strings.Join(want, "|") {
78 t.Fatalf("server reads %q, want %q", argv, want)
79 }
80 if got := doneArgs(7, "success", nil); strings.Join(got, " ") != "runner done 7 success" {
81 t.Fatalf("success: %q", got)
82 }
83 if got := doneArgs(7, "failure", &failure{Reason: "git clone: exit 128"}); strings.Contains(strings.Join(got, " "), "--step") {
84 t.Fatalf("a failure before any step sent a step: %q", got)
85 }
86}
cmd/gitbay-runner/steps_test.go added +33
@@ -0,0 +1,33 @@
1package main
2
3import (
4 "os"
5 "os/exec"
6 "strings"
7 "testing"
8 "time"
9)
10
11// The failing step is named by number in the log and in the outcome
12// reported to the server (#266).
13func TestRunStepsNamesTheFailedStep(t *testing.T) {
14 r := &runner{isolation: isolationNone}
15 run := func(cmd *exec.Cmd, _ time.Time) (bool, string) {
16 if err := cmd.Run(); err != nil {
17 return false, exitReason(err)
18 }
19 return true, ""
20 }
21 env := []string{"PATH=" + os.Getenv("PATH")}
22 var log strings.Builder
23 f := r.runSteps(job{Steps: []string{"true", "exit 3", "true"}}, t.TempDir(), env, &log, time.Now().Add(time.Minute), run)
24 if f == nil || f.Step != 2 || f.Reason != "exit 3" {
25 t.Fatalf("failure %+v, want step 2, exit 3", f)
26 }
27 if !strings.Contains(log.String(), "step 2/3 failed: exit 3\n") {
28 t.Fatalf("log does not name the step:\n%s", log.String())
29 }
30 if f := r.runSteps(job{Steps: []string{"true"}}, t.TempDir(), env, &log, time.Now().Add(time.Minute), run); f != nil {
31 t.Fatalf("a passing job failed: %+v", f)
32 }
33}
e2e/ci_test.go +11 −1
@@ -144,9 +144,19 @@ func TestCI(t *testing.T) {
144 t.Fatalf("ok log:\n%s", out) 144 t.Fatalf("ok log:\n%s", out)
145 } 145 }
146 out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN) 146 out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN)
147 if !strings.Contains(out, "step failed") { 147 if !strings.Contains(out, "step 1/1 failed: exit 1") {
148 t.Fatalf("broken log:\n%s", out) 148 t.Fatalf("broken log:\n%s", out)
149 } 149 }
150 out, _, _ = inst.ssh(t, aliceKey, "", "build", "show", "alice/app", brokenN)
151 if !strings.Contains(out, "1/1 false (exit 1)") {
152 t.Fatalf("build show does not name the failed step:\n%s", out)
153 }
154 if out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN, "--step", "failed"); strings.Contains(out, "git clone") || !strings.Contains(out, "exit 1") {
155 t.Fatalf("build log --step failed:\n%s", out)
156 }
157 if _, body := inst.get(t, "/alice/app/builds/"+brokenN); !strings.Contains(body, `id="failed" open`) {
158 t.Fatalf("build page does not open the failed step:\n%s", body)
159 }
150 // Statuses resolved, with target URLs pointing at the build pages. 160 // Statuses resolved, with target URLs pointing at the build pages.
151 out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha, "--json") 161 out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha, "--json")
152 if !strings.Contains(out, `"ci/ok","state":"success"`) && !strings.Contains(out, `"state":"success"`) { 162 if !strings.Contains(out, `"ci/ok","state":"success"`) && !strings.Contains(out, `"state":"success"`) {
internal/control/build.go +117 −17
@@ -40,11 +40,13 @@ func init() {
40 ReadOnly: true, Run: runBuildShow}) 40 ReadOnly: true, Run: runBuildShow})
41 register(Command{Path: []string{"build", "log"}, 41 register(Command{Path: []string{"build", "log"},
42 Summary: "print a build's log, or follow it until the build ends", 42 Summary: "print a build's log, or follow it until the build ends",
43 Usage: "build log <owner/name> <n> [--follow]", 43 Usage: "build log <owner/name> <n> [--follow] [--step <step>|failed] [--tail <lines>]",
44 Flags: []Flag{ 44 Flags: []Flag{
45 {"--follow", "", "stream the log until the build ends", ""}, 45 {"--follow", "", "stream the log until the build ends", ""},
46 {"--step", "<step>|failed", "only one step's output: 0 for the setup, a step number, or the one that failed", ""},
47 {"--tail", "<lines>", "only the last lines", ""},
46 }, 48 },
47 Examples: []string{"build log krz/gitbay 431 --follow"}, 49 Examples: []string{"build log krz/gitbay 431 --follow", "build log krz/gitbay 431 --step failed --tail 40"},
48 ReadOnly: true, Run: runBuildLog}) 50 ReadOnly: true, Run: runBuildLog})
49 51
50 register(Command{Path: []string{"build", "jobs"}, 52 register(Command{Path: []string{"build", "jobs"},
@@ -101,9 +103,13 @@ func init() {
101 Examples: []string{"runner log 431"}, 103 Examples: []string{"runner log 431"},
102 ReadsStdin: true, Run: runRunnerLog}) 104 ReadsStdin: true, Run: runRunnerLog})
103 register(Command{Path: []string{"runner", "done"}, 105 register(Command{Path: []string{"runner", "done"},
104 Summary: "finish a build", 106 Summary: "finish a build",
105 Usage: "runner done <build-id> success|failure", 107 Usage: "runner done <build-id> success|failure [--step <n>] [--reason <text>]",
106 Examples: []string{"runner done 431 success"}, 108 Flags: []Flag{
109 {"--step", "<n>", "the 1-based step a failed build stopped at", ""},
110 {"--reason", "<text>", "how it failed, one line", ""},
111 },
112 Examples: []string{"runner done 431 success", "runner done 431 failure --step 3 --reason 'exit 1'"},
107 Run: runRunnerDone}) 113 Run: runRunnerDone})
108} 114}
109 115
@@ -119,11 +125,22 @@ type BuildOut struct {
119 // names what it ran on rather than only its sha (#241). It is empty 125 // names what it ran on rather than only its sha (#241). It is empty
120 // when the commit is no longer in the repository. 126 // when the commit is no longer in the repository.
121 Subject string `json:"subject,omitempty"` 127 Subject string `json:"subject,omitempty"`
128 // FailedStep is the 1-based step a failed build stopped at, 0 when
129 // none; FailedReason says how ("exit 1") (#266).
130 FailedStep int `json:"failed_step,omitempty"`
131 FailedReason string `json:"failed_reason,omitempty"`
132 // DurationS is how long the build ran, once it has a start and a
133 // finish.
134 DurationS int64 `json:"duration_s,omitempty"`
135 // Steps are the job's commands; build show only.
136 Steps []string `json:"steps,omitempty"`
122} 137}
123 138
124func buildToOut(b store.Build) BuildOut { 139func buildToOut(b store.Build) BuildOut {
125 return BuildOut{Number: b.Number, Job: b.Job, Status: b.Status, SHA: b.SHA, 140 return BuildOut{Number: b.Number, Job: b.Job, Status: b.Status, SHA: b.SHA,
126 Ref: b.Ref, CreatedAt: b.CreatedAt, FinishedAt: b.FinishedAt} 141 Ref: b.Ref, CreatedAt: b.CreatedAt, FinishedAt: b.FinishedAt,
142 FailedStep: b.FailedStep, FailedReason: b.FailedReason,
143 DurationS: int64(b.Elapsed() / time.Second)}
127} 144}
128 145
129func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { 146func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
@@ -225,7 +242,22 @@ func runBuildShow(c *Ctx, args []string) int {
225 return code 242 return code
226 } 243 }
227 d := buildToOut(b) 244 d := buildToOut(b)
245 json.Unmarshal([]byte(b.Steps), &d.Steps)
228 return c.emit(d, func(w io.Writer) { 246 return c.emit(d, func(w io.Writer) {
247 failedStep, failed := "", ""
248 if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) {
249 step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n")
250 failedStep = fmt.Sprintf("%d/%d %s", d.FailedStep, len(d.Steps), step)
251 if d.FailedReason != "" {
252 failedStep += " (" + d.FailedReason + ")"
253 }
254 } else {
255 failed = d.FailedReason
256 }
257 duration := ""
258 if d.DurationS > 0 {
259 duration = (time.Duration(d.DurationS) * time.Second).String()
260 }
229 v := c.view(w) 261 v := c.view(w)
230 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status) 262 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status)
231 v.fields( 263 v.fields(
@@ -233,13 +265,16 @@ func runBuildShow(c *Ctx, args []string) int {
233 "ref", d.Ref, 265 "ref", d.Ref,
234 "queued", c.when(d.CreatedAt), 266 "queued", c.when(d.CreatedAt),
235 "finished", c.when(d.FinishedAt), 267 "finished", c.when(d.FinishedAt),
268 "duration", duration,
269 "failed step", failedStep,
270 "failed", failed,
236 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)), 271 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)),
237 ) 272 )
238 }) 273 })
239} 274}
240 275
241func runBuildLog(c *Ctx, args []string) int { 276func runBuildLog(c *Ctx, args []string) int {
242 f, err := c.parseArgs(args, flagSpec{Bools: []string{"--follow"}, MaxPos: 2, Usage: c.Cmd.Usage}) 277 f, err := c.parseArgs(args, flagSpec{Bools: []string{"--follow"}, Values: []string{"--step", "--tail"}, MaxPos: 2, Usage: c.Cmd.Usage})
243 if err != nil { 278 if err != nil {
244 return c.fail(protocol.ExitUsage, "%v", err) 279 return c.fail(protocol.ExitUsage, "%v", err)
245 } 280 }
@@ -248,12 +283,49 @@ func runBuildLog(c *Ctx, args []string) int {
248 return code 283 return code
249 } 284 }
250 if f.Has("--follow") { 285 if f.Has("--follow") {
286 if f.Has("--step") || f.Has("--tail") {
287 return c.fail(protocol.ExitUsage, "--step and --tail read the stored log; drop --follow")
288 }
251 return followBuildLog(c, repo, b) 289 return followBuildLog(c, repo, b)
252 } 290 }
291 tail := 0
292 if f.Has("--tail") {
293 if tail, err = strconv.Atoi(f.Value("--tail")); err != nil || tail < 1 {
294 return c.fail(protocol.ExitUsage, "--tail takes a number of lines, 1 or more")
295 }
296 }
253 log, err := c.Store.BuildLog(b.ID) 297 log, err := c.Store.BuildLog(b.ID)
254 if err != nil { 298 if err != nil {
255 return c.fail(protocol.ExitFailure, "%v", err) 299 return c.fail(protocol.ExitFailure, "%v", err)
256 } 300 }
301 if f.Has("--step") {
302 var steps []string
303 json.Unmarshal([]byte(b.Steps), &steps)
304 sections := SplitBuildLog(string(log), steps)
305 at := -1
306 if want := f.Value("--step"); want == "failed" {
307 if at = FailedSection(sections, b.Status, b.FailedStep); at < 0 {
308 return c.fail(protocol.ExitNotFound, "build %d did not fail", b.Number)
309 }
310 } else {
311 n, err := strconv.Atoi(want)
312 if err != nil || n < 0 || n > len(steps) {
313 return c.fail(protocol.ExitUsage, "--step takes 0 (the setup) to %d, or failed", len(steps))
314 }
315 for i, s := range sections {
316 if s.N == n {
317 at = i
318 }
319 }
320 if at < 0 {
321 return c.fail(protocol.ExitNotFound, "build %d has no output for step %d", b.Number, n)
322 }
323 }
324 log = []byte(sections[at].Text)
325 }
326 if tail > 0 {
327 log = tailLines(log, tail)
328 }
257 c.Stdout.Write(log) 329 c.Stdout.Write(log)
258 return protocol.ExitOK 330 return protocol.ExitOK
259} 331}
@@ -675,12 +747,17 @@ func runRunnerDone(c *Ctx, args []string) int {
675 if code >= 0 { 747 if code >= 0 {
676 return code 748 return code
677 } 749 }
678 if len(args) != 2 || (args[1] != "success" && args[1] != "failure") { 750 f, err := c.parseArgs(args, flagSpec{Values: []string{"--step", "--reason"}, MaxPos: 2, Usage: c.Cmd.Usage})
751 if err != nil {
752 return c.fail(protocol.ExitUsage, "%v", err)
753 }
754 if len(f.Pos) != 2 || (f.Pos[1] != "success" && f.Pos[1] != "failure") {
679 return c.usage() 755 return c.usage()
680 } 756 }
681 id, err := strconv.ParseInt(args[0], 10, 64) 757 outcome := f.Pos[1]
758 id, err := strconv.ParseInt(f.Pos[0], 10, 64)
682 if err != nil { 759 if err != nil {
683 return c.fail(protocol.ExitUsage, "bad build id %q", args[0]) 760 return c.fail(protocol.ExitUsage, "bad build id %q", f.Pos[0])
684 } 761 }
685 b, err := c.Store.BuildByID(id) 762 b, err := c.Store.BuildByID(id)
686 if err != nil { 763 if err != nil {
@@ -699,7 +776,20 @@ func runRunnerDone(c *Ctx, args []string) int {
699 fmt.Fprintf(w, "build %d was cancelled\n", b.Number) 776 fmt.Fprintf(w, "build %d was cancelled\n", b.Number)
700 }) 777 })
701 } 778 }
702 if err := c.Store.FinishBuild(id, args[1]); err != nil { 779 if outcome == "failure" {
780 // A step the job does not have is recorded as none rather than
781 // refused: refusing would lose the outcome over a detail (#266).
782 var steps []string
783 json.Unmarshal([]byte(b.Steps), &steps)
784 step, _ := strconv.Atoi(f.Value("--step"))
785 if step < 0 || step > len(steps) {
786 step = 0
787 }
788 if err := c.Store.SetBuildFailure(id, step, failureReason(f.Value("--reason"))); err != nil && !errors.Is(err, store.ErrNotFound) {
789 return c.fail(protocol.ExitFailure, "recording build %d's failure: %v", id, err)
790 }
791 }
792 if err := c.Store.FinishBuild(id, outcome); err != nil {
703 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err) 793 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
704 } 794 }
705 c.Store.RunnerDone(key.ID) 795 c.Store.RunnerDone(key.ID)
@@ -708,15 +798,15 @@ func runRunnerDone(c *Ctx, args []string) int {
708 return c.fail(protocol.ExitFailure, "%v", err) 798 return c.fail(protocol.ExitFailure, "%v", err)
709 } 799 }
710 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number) 800 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
711 desc := "build " + args[1] 801 desc := "build " + outcome
712 if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, args[1], desc, url, c.User.ID); err != nil { 802 if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, outcome, desc, url, c.User.ID); err != nil {
713 return c.fail(protocol.ExitFailure, "%v", err) 803 return c.fail(protocol.ExitFailure, "%v", err)
714 } 804 }
715 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1], 805 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+outcome,
716 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA)) 806 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
717 // A red build mails the repo's notify targets with the log tail — a 807 // A red build mails the repo's notify targets with the log tail — a
718 // failed scheduled job must not wait to be noticed. 808 // failed scheduled job must not wait to be noticed.
719 if args[1] == "failure" { 809 if outcome == "failure" {
720 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { 810 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
721 tail := "" 811 tail := ""
722 if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 { 812 if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 {
@@ -732,11 +822,21 @@ func runRunnerDone(c *Ctx, args []string) int {
732 path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)}) 822 path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)})
733 } 823 }
734 } 824 }
735 return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) { 825 return c.emit(map[string]any{"build": b.Number, "status": outcome}, func(w io.Writer) {
736 fmt.Fprintf(w, "build %d %s\n", b.Number, args[1]) 826 fmt.Fprintf(w, "build %d %s\n", b.Number, outcome)
737 }) 827 })
738} 828}
739 829
830// failureReason keeps a runner's reason to one line of at most 200
831// bytes: it is shown on the build page and by build show.
832func failureReason(s string) string {
833 s = strings.Join(strings.Fields(s), " ")
834 if len(s) > 200 {
835 s = s[:200]
836 }
837 return strings.ToValidUTF8(s, "")
838}
839
740// QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending 840// QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending
741// build per push job, with a pending commit status the runner resolves. 841// build per push job, with a pending commit status the runner resolves.
742// A broken config surfaces as a failed "ci/config" status, not silence. 842// A broken config surfaces as a failed "ci/config" status, not silence.
internal/control/buildlog.go added +91
@@ -0,0 +1,91 @@
1package control
2
3import "strings"
4
5// LogSection is one part of a build log: the setup before the first
6// step (N 0), or one step and its output.
7type LogSection struct {
8 N int // 0 for the setup, else the 1-based step
9 Step string // the step's command; "" for the setup
10 Text string
11}
12
13// SplitBuildLog cuts a log at the "$ <step>" line the runner writes
14// before each step, matching the build's steps in order and only at a
15// line start. Output before the first step is the setup section, left
16// out when empty. A step with no line in the log — the build stopped
17// before it — has no section, and neither has any step after it.
18func SplitBuildLog(log string, steps []string) []LogSection {
19 var out []LogSection
20 cur := LogSection{}
21 start := 0
22 for i, step := range steps {
23 marker := "$ " + step + "\n"
24 at := findLine(log, marker, start)
25 if at < 0 {
26 break
27 }
28 cur.Text = log[start:at]
29 if cur.N > 0 || cur.Text != "" {
30 out = append(out, cur)
31 }
32 cur = LogSection{N: i + 1, Step: step}
33 start = at + len(marker)
34 }
35 cur.Text = log[start:]
36 if cur.N > 0 || cur.Text != "" {
37 out = append(out, cur)
38 }
39 return out
40}
41
42// findLine is the index of line in log at or after from where it starts
43// a line, or -1.
44func findLine(log, line string, from int) int {
45 for i := from; i <= len(log)-len(line); {
46 j := strings.Index(log[i:], line)
47 if j < 0 {
48 return -1
49 }
50 at := i + j
51 if at == 0 || log[at-1] == '\n' {
52 return at
53 }
54 i = at + 1
55 }
56 return -1
57}
58
59// FailedSection is the index of the section a failed build stopped in:
60// the step the runner named, or the last section when it named none (an
61// older runner, or a failure the runner could not tie to a step). -1
62// when the build did not fail or its log is empty.
63func FailedSection(sections []LogSection, status string, failedStep int) int {
64 if status != "failure" || len(sections) == 0 {
65 return -1
66 }
67 for i, s := range sections {
68 if failedStep > 0 && s.N == failedStep {
69 return i
70 }
71 }
72 return len(sections) - 1
73}
74
75// tailLines is the last n lines of b; a final newline ends the last line
76// rather than starting another.
77func tailLines(b []byte, n int) []byte {
78 end := len(b)
79 if end > 0 && b[end-1] == '\n' {
80 end--
81 }
82 for i := end - 1; i >= 0; i-- {
83 if b[i] == '\n' {
84 n--
85 if n == 0 {
86 return b[i+1:]
87 }
88 }
89 }
90 return b
91}
internal/control/buildlog_test.go added +131
@@ -0,0 +1,131 @@
1package control
2
3import (
4 "bytes"
5 "fmt"
6 "reflect"
7 "regexp"
8 "strings"
9 "testing"
10
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15func TestSplitBuildLog(t *testing.T) {
16 log := "$ git clone ssh://x/a.git (abc)\n" +
17 "$ go build ./...\n" +
18 "built\n" +
19 "$ go test ./...\n" +
20 "--- FAIL: TestX\n" +
21 "step 2/2 failed: exit 1\n"
22 got := SplitBuildLog(log, []string{"go build ./...", "go test ./..."})
23 want := []LogSection{
24 {N: 0, Text: "$ git clone ssh://x/a.git (abc)\n"},
25 {N: 1, Step: "go build ./...", Text: "built\n"},
26 {N: 2, Step: "go test ./...", Text: "--- FAIL: TestX\nstep 2/2 failed: exit 1\n"},
27 }
28 if !reflect.DeepEqual(got, want) {
29 t.Fatalf("got %+v\nwant %+v", got, want)
30 }
31}
32
33// A step's line inside other output, not at a line start, does not cut;
34// a build that stopped before a step has no section for it; an empty
35// setup is left out.
36func TestSplitBuildLogStopsAtMissingStep(t *testing.T) {
37 got := SplitBuildLog("$ make\nrunning: $ make test\nerror\n", []string{"make", "make test"})
38 want := []LogSection{{N: 1, Step: "make", Text: "running: $ make test\nerror\n"}}
39 if !reflect.DeepEqual(got, want) {
40 t.Fatalf("got %+v\nwant %+v", got, want)
41 }
42}
43
44func TestTailLines(t *testing.T) {
45 for _, tc := range []struct {
46 in string
47 n int
48 want string
49 }{
50 {"a\nb\nc\n", 2, "b\nc\n"},
51 {"a\nb\nc\n", 5, "a\nb\nc\n"},
52 {"a\nb", 1, "b"},
53 } {
54 if got := string(tailLines([]byte(tc.in), tc.n)); got != tc.want {
55 t.Errorf("tailLines(%q, %d) = %q, want %q", tc.in, tc.n, got, tc.want)
56 }
57 }
58}
59
60// failedBuild is a finished failure whose second of two steps failed,
61// having run 10m56s.
62func failedBuild(t *testing.T) (*store.Store, store.Repo, int64, int64) {
63 t.Helper()
64 st, repo, uid := newQueueTestRepo(t)
65 n, err := st.CreateBuild(repo.ID, "unit", "abc", "main", `["go build ./...","go test ./..."]`, "", "", true)
66 if err != nil {
67 t.Fatal(err)
68 }
69 b, ok, err := st.ClaimBuild([]int64{repo.ID}, false)
70 if err != nil || !ok {
71 t.Fatalf("claim: ok=%v err=%v", ok, err)
72 }
73 st.AppendBuildLog(b.ID, []byte("$ git clone x (abc)\n$ go build ./...\nok\n$ go test ./...\none\n--- FAIL: TestX\nstep 2/2 failed: exit 1\n"))
74 if err := st.SetBuildFailure(b.ID, 2, "exit 1"); err != nil {
75 t.Fatal(err)
76 }
77 if err := st.FinishBuild(b.ID, "failure"); err != nil {
78 t.Fatal(err)
79 }
80 if _, err := st.DB.Exec(`UPDATE builds SET started_at = '2026-09-27T10:00:00Z', finished_at = '2026-09-27T10:10:56Z' WHERE id = ?`, b.ID); err != nil {
81 t.Fatal(err)
82 }
83 return st, repo, uid, n
84}
85
86func TestBuildLogStepAndTail(t *testing.T) {
87 st, repo, uid, n := failedBuild(t)
88 run := func(args ...string) (string, int) {
89 t.Helper()
90 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
91 code := Dispatch(c, append([]string{"build", "log", repo.Path(), fmt.Sprint(n)}, args...))
92 return c.Stdout.(*bytes.Buffer).String() + errOut.String(), code
93 }
94 if out, _ := run("--step", "1"); out != "ok\n" {
95 t.Errorf("--step 1: %q", out)
96 }
97 if out, _ := run("--step", "failed", "--tail", "2"); out != "--- FAIL: TestX\nstep 2/2 failed: exit 1\n" {
98 t.Errorf("--step failed --tail 2: %q", out)
99 }
100 if out, _ := run("--tail", "1"); out != "step 2/2 failed: exit 1\n" {
101 t.Errorf("--tail 1: %q", out)
102 }
103 if _, code := run("--step", "3"); code != protocol.ExitUsage {
104 t.Errorf("--step past the job: exit %d", code)
105 }
106 if _, code := run("--follow", "--tail", "1"); code != protocol.ExitUsage {
107 t.Errorf("--follow with --tail: exit %d", code)
108 }
109}
110
111func TestBuildShowNamesFailedStepAndDuration(t *testing.T) {
112 st, repo, uid, n := failedBuild(t)
113 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
114 if code := Dispatch(c, []string{"build", "show", repo.Path(), fmt.Sprint(n)}); code != protocol.ExitOK {
115 t.Fatalf("exit %d: %s", code, errOut)
116 }
117 out := c.Stdout.(*bytes.Buffer).String()
118 for _, re := range []string{`failed step\s+2/2 go test \./\.\.\. \(exit 1\)`, `duration\s+10m56s`} {
119 if !regexp.MustCompile(re).MatchString(out) {
120 t.Errorf("build show missing %s:\n%s", re, out)
121 }
122 }
123 c, _ = pruneCtx(st, t.TempDir(), store.User{ID: uid})
124 c.JSON = true
125 Dispatch(c, []string{"build", "show", repo.Path(), fmt.Sprint(n)})
126 for _, want := range []string{`"failed_step":2`, `"failed_reason":"exit 1"`, `"duration_s":656`, `"steps":["go build ./...","go test ./..."]`} {
127 if !strings.Contains(c.Stdout.(*bytes.Buffer).String(), want) {
128 t.Errorf("build show --json missing %s", want)
129 }
130 }
131}
internal/control/runnernext_test.go +41
@@ -295,3 +295,44 @@ func TestRunnerNextCarriesPublicSSH(t *testing.T) {
295 } 295 }
296 } 296 }
297} 297}
298
299// runner done records the failed step and a one-line reason (#266).
300func TestRunnerDoneRecordsFailedStep(t *testing.T) {
301 st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t)
302 n, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", `["go build ./...","go test ./..."]`, "", "", true)
303 if err != nil {
304 t.Fatal(err)
305 }
306 b, ok, err := st.ClaimBuild([]int64{repo.ID}, false)
307 if err != nil || !ok {
308 t.Fatalf("claim: ok=%v err=%v", ok, err)
309 }
310 c, out := runnerCtx(st, uid, root)
311 if code := runRunnerDone(c, []string{fmt.Sprint(b.ID), "failure", "--step", "2", "--reason", "exit 1\n"}); code != protocol.ExitOK {
312 t.Fatalf("runner done: exit %d\n%s", code, out.String())
313 }
314 got, _ := st.BuildByNumber(repo.ID, n)
315 if got.Status != "failure" || got.FailedStep != 2 || got.FailedReason != "exit 1" {
316 t.Fatalf("status %s step %d reason %q", got.Status, got.FailedStep, got.FailedReason)
317 }
318}
319
320// A report with no flags — an older runner — or with a step past the
321// job's still finishes the build; the step is then recorded as 0.
322func TestRunnerDoneToleratesMissingOrBadStep(t *testing.T) {
323 st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t)
324 for _, extra := range [][]string{nil, {"--step", "9"}} {
325 n, _ := st.CreateBuild(repo.ID, "unit", baseSHA, "main", `["true"]`, "", "", true)
326 b, ok, err := st.ClaimBuild([]int64{repo.ID}, false)
327 if err != nil || !ok {
328 t.Fatalf("claim: ok=%v err=%v", ok, err)
329 }
330 c, out := runnerCtx(st, uid, root)
331 if code := runRunnerDone(c, append([]string{fmt.Sprint(b.ID), "failure"}, extra...)); code != protocol.ExitOK {
332 t.Fatalf("runner done %v: exit %d\n%s", extra, code, out.String())
333 }
334 if got, _ := st.BuildByNumber(repo.ID, n); got.Status != "failure" || got.FailedStep != 0 {
335 t.Fatalf("%v: status %s step %d", extra, got.Status, got.FailedStep)
336 }
337 }
338}
internal/httpd/buildpages_test.go +56
@@ -198,3 +198,59 @@ func renderBuilds(t *testing.T, builds []control.BuildOut, filter buildFilter, o
198 } 198 }
199 return sb.String() 199 return sb.String()
200} 200}
201
202// A failed build's page folds its log by step, opens the step that
203// failed and links to it; no JavaScript (#266).
204func TestBuildPageFoldsStepsAndOpensFailure(t *testing.T) {
205 b := control.BuildOut{Number: 61, Job: "test", Status: "failure",
206 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "main",
207 CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:53:50Z", DurationS: 656,
208 Steps: []string{"go build ./...", "go test ./..."}, FailedStep: 2, FailedReason: "exit 1"}
209 log := "$ git clone x (ff6271a9d4)\n$ go build ./...\n$ go test ./...\n--- FAIL: TestCLI\nstep 2/2 failed: exit 1\n"
210 v := buildView{repoPage: testRepoPage(), Build: b, Log: log, Duration: "10m56s"}
211 v.Steps, v.Failed = logSteps(log, b)
212 var sb strings.Builder
213 if err := web.Render(&sb, "build.html", v); err != nil {
214 t.Fatalf("render: %v", err)
215 }
216 out := sb.String()
217 for _, want := range []string{
218 `<details class="difffold buildstep" id="failed" open>`,
219 "step 2/2", "<code>go test ./...</code>", `href="#failed"`, "Jump to failure",
220 "ran 10m56s", "--- FAIL: TestCLI",
221 } {
222 if !strings.Contains(out, want) {
223 t.Errorf("build.html missing %q", want)
224 }
225 }
226 if n := strings.Count(out, `class="difffold buildstep"`); n != 3 {
227 t.Errorf("%d step folds, want 3 (setup and two steps)", n)
228 }
229 if n := strings.Count(out, `id="failed"`); n != 1 {
230 t.Errorf("%d failed anchors, want 1", n)
231 }
232}
233
234// A multi-line step's fold summary shows only its first line, as build
235// show does; the whole step is in the log (#266).
236func TestBuildPageStepSummaryIsFirstLine(t *testing.T) {
237 step := "set -e\ngo test ./..."
238 b := control.BuildOut{Number: 62, Job: "test", Status: "failure",
239 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "main",
240 CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:54Z", DurationS: 60,
241 Steps: []string{step}, FailedStep: 1, FailedReason: "exit 1"}
242 log := "$ git clone x (ff6271a9d4)\n$ " + step + "\n--- FAIL: TestCLI\nstep 1/1 failed: exit 1\n"
243 v := buildView{repoPage: testRepoPage(), Build: b, Log: log, Duration: "1m0s"}
244 v.Steps, v.Failed = logSteps(log, b)
245 var sb strings.Builder
246 if err := web.Render(&sb, "build.html", v); err != nil {
247 t.Fatalf("render: %v", err)
248 }
249 out := sb.String()
250 if !strings.Contains(out, "<code>set -e</code>") {
251 t.Errorf("summary does not show the step's first line:\n%s", out)
252 }
253 if strings.Contains(out, "<code>set -e\ngo test") {
254 t.Errorf("summary shows the whole multi-line step")
255 }
256}
internal/httpd/builds.go +43 −2
@@ -10,6 +10,7 @@ import (
10 "slices" 10 "slices"
11 "strconv" 11 "strconv"
12 "strings" 12 "strings"
13 "time"
13 14
14 "gitbay.org/gitbay/internal/control" 15 "gitbay.org/gitbay/internal/control"
15 "gitbay.org/gitbay/internal/protocol" 16 "gitbay.org/gitbay/internal/protocol"
@@ -282,18 +283,58 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) {
282 return 283 return
283 } 284 }
284 v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n}) 285 v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n})
286 v.Steps, v.Failed = logSteps(v.Log, b)
287 if b.DurationS > 0 {
288 v.Duration = (time.Duration(b.DurationS) * time.Second).String()
289 }
285 s.render(w, "build.html", v) 290 s.render(w, "build.html", v)
286} 291}
287 292
288type buildView struct { 293type buildView struct {
289 repoPage 294 repoPage
290 Build control.BuildOut 295 Build control.BuildOut
291 Log string 296 Log string
297 // Steps is the finished log cut at its steps, nil when there is no
298 // step to cut at; Failed says whether one of them is marked failed.
299 Steps []logStep
300 Failed bool
301 Duration string
292 Live bool 302 Live bool
293 CanWrite bool 303 CanWrite bool
294 Notice string 304 Notice string
295} 305}
296 306
307type logStep struct {
308 control.LogSection
309 // Head is the step's first line, which its fold's summary shows, as
310 // build show does; the full step is in the log below it.
311 Head string
312 Failed bool
313}
314
315// logSteps cuts a finished build's log at its steps and marks the one it
316// failed at. Nil when no step's line is in the log — a build that
317// stopped in the clone — which renders as one block.
318func logSteps(log string, b control.BuildOut) ([]logStep, bool) {
319 sections := control.SplitBuildLog(log, b.Steps)
320 stepped := false
321 for _, s := range sections {
322 if s.N > 0 {
323 stepped = true
324 }
325 }
326 if !stepped {
327 return nil, false
328 }
329 failed := control.FailedSection(sections, b.Status, b.FailedStep)
330 out := make([]logStep, len(sections))
331 for i, s := range sections {
332 head, _, _ := strings.Cut(s.Step, "\n")
333 out[i] = logStep{LogSection: s, Head: head, Failed: i == failed}
334 }
335 return out, failed >= 0
336}
337
297// liveLogMarker stands in for the log when build.html is rendered for a 338// liveLogMarker stands in for the log when build.html is rendered for a
298// live build; streamBuild splits the page there and streams the log into 339// live build; streamBuild splits the page there and streams the log into
299// the gap. Git refs, paths and job names cannot hold the control byte. 340// the gap. Git refs, paths and job names cannot hold the control byte.
internal/store/builds.go +24 −2
@@ -30,6 +30,11 @@ type Build struct {
30 // Trusted is false for a merge request head fetched from another 30 // Trusted is false for a merge request head fetched from another
31 // repository: its steps run without the target's secrets. 31 // repository: its steps run without the target's secrets.
32 Trusted bool 32 Trusted bool
33 // FailedStep is the 1-based step a failed build stopped at, 0 when it
34 // stopped before its first step or did not fail. FailedReason is the
35 // runner's one line: "exit 1", "build timed out after 45m0s".
36 FailedStep int
37 FailedReason string
33} 38}
34 39
35// MaxBuildLog caps a build's stored log; appends past it are dropped. 40// MaxBuildLog caps a build's stored log; appends past it are dropped.
@@ -65,14 +70,16 @@ func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON, image, tree
65} 70}
66 71
67const buildSelect = ` 72const buildSelect = `
68 SELECT id, repo_id, number, job, sha, ref, steps, image, tree, status, created_at, started_at, finished_at, log_closed_at, trusted 73 SELECT id, repo_id, number, job, sha, ref, steps, image, tree, status, created_at, started_at, finished_at, log_closed_at, trusted,
74 failed_step, failed_reason
69 FROM builds` 75 FROM builds`
70 76
71func scanBuild(row interface{ Scan(...any) error }) (Build, error) { 77func scanBuild(row interface{ Scan(...any) error }) (Build, error) {
72 var b Build 78 var b Build
73 var trusted int 79 var trusted int
74 err := row.Scan(&b.ID, &b.RepoID, &b.Number, &b.Job, &b.SHA, &b.Ref, &b.Steps, &b.Image, &b.Tree, 80 err := row.Scan(&b.ID, &b.RepoID, &b.Number, &b.Job, &b.SHA, &b.Ref, &b.Steps, &b.Image, &b.Tree,
75 &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt, &b.LogClosedAt, &trusted) 81 &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt, &b.LogClosedAt, &trusted,
82 &b.FailedStep, &b.FailedReason)
76 b.Trusted = trusted != 0 83 b.Trusted = trusted != 0
77 return b, err 84 return b, err
78} 85}
@@ -263,6 +270,21 @@ func (s *Store) FinishBuild(id int64, status string) error {
263 return nil 270 return nil
264} 271}
265 272
273// SetBuildFailure records where a running build failed. The runner
274// reports it with the outcome; it is written first, so a reader woken
275// by the finish sees both.
276func (s *Store) SetBuildFailure(id int64, step int, reason string) error {
277 res, err := s.DB.Exec(`UPDATE builds SET failed_step = ?, failed_reason = ?
278 WHERE id = ? AND status = 'running'`, step, reason, id)
279 if err != nil {
280 return err
281 }
282 if n, _ := res.RowsAffected(); n == 0 {
283 return ErrNotFound
284 }
285 return nil
286}
287
266func (s *Store) BuildByID(id int64) (Build, error) { 288func (s *Store) BuildByID(id int64) (Build, error) {
267 b, err := scanBuild(s.DB.QueryRow(buildSelect+" WHERE id = ?", id)) 289 b, err := scanBuild(s.DB.QueryRow(buildSelect+" WHERE id = ?", id))
268 if errors.Is(err, sql.ErrNoRows) { 290 if errors.Is(err, sql.ErrNoRows) {
internal/store/builds_test.go +35
@@ -1,6 +1,7 @@
1package store 1package store
2 2
3import ( 3import (
4 "errors"
4 "strings" 5 "strings"
5 "testing" 6 "testing"
6) 7)
@@ -566,3 +567,37 @@ func TestBuildLogFrom(t *testing.T) {
566 t.Fatalf("missing build: %v", err) 567 t.Fatalf("missing build: %v", err)
567 } 568 }
568} 569}
570
571// Where a failed build stopped is recorded while it runs, before the
572// outcome, and a finished build is not rewritten (#266).
573func TestSetBuildFailure(t *testing.T) {
574 s := open(t)
575 if err := s.MigrateUp(); err != nil {
576 t.Fatal(err)
577 }
578 uid, _ := s.CreateUser("cmc", true)
579 repoID, _ := s.CreateRepo("user", uid, "app", "public")
580 if _, err := s.CreateBuild(repoID, "unit", "abc", "main", `["true","false"]`, "", "", true); err != nil {
581 t.Fatal(err)
582 }
583 b, ok, err := s.ClaimBuild(nil, false)
584 if err != nil || !ok {
585 t.Fatalf("claim: ok=%v err=%v", ok, err)
586 }
587 if err := s.SetBuildFailure(b.ID, 2, "exit 1"); err != nil {
588 t.Fatal(err)
589 }
590 if err := s.FinishBuild(b.ID, "failure"); err != nil {
591 t.Fatal(err)
592 }
593 got, err := s.BuildByID(b.ID)
594 if err != nil {
595 t.Fatal(err)
596 }
597 if got.FailedStep != 2 || got.FailedReason != "exit 1" {
598 t.Fatalf("failed step %d reason %q", got.FailedStep, got.FailedReason)
599 }
600 if err := s.SetBuildFailure(b.ID, 1, "late"); !errors.Is(err, ErrNotFound) {
601 t.Fatalf("rewrote a finished build: %v", err)
602 }
603}
internal/store/migrations/0065_build_failure.down.sql added +2
@@ -0,0 +1,2 @@
1ALTER TABLE builds DROP COLUMN failed_reason;
2ALTER TABLE builds DROP COLUMN failed_step;
internal/store/migrations/0065_build_failure.up.sql added +4
@@ -0,0 +1,4 @@
1-- Where a failed build stopped: the 1-based step, 0 when it stopped
2-- before any step or did not fail, and the runner's one-line reason.
3ALTER TABLE builds ADD COLUMN failed_step INTEGER NOT NULL DEFAULT 0;
4ALTER TABLE builds ADD COLUMN failed_reason TEXT NOT NULL DEFAULT '';
internal/web/static/style.css +3 −1
@@ -1113,7 +1113,9 @@ pre.matchline mark { background: color-mix(in srgb, var(--mark) 25%, transparent
1113p.pager { margin-top: var(--sp-4); } 1113p.pager { margin-top: var(--sp-4); }
1114.snippetfile { margin-bottom: var(--sp-5); } 1114.snippetfile { margin-bottom: var(--sp-5); }
1115p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); } 1115p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); }
1116pre.buildlog { max-height: 40rem; overflow: auto; } 1116pre.buildlog { max-height: 40rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
1117details.buildstep pre.buildlog { margin: 0; border: 0; border-radius: 0; }
1118details.buildstep summary code { overflow-wrap: anywhere; }
1117 1119
1118/* ---- rendered prose ---- */ 1120/* ---- rendered prose ---- */
1119pre { 1121pre {
internal/web/templates/build.html +6 −1
@@ -11,8 +11,13 @@
11</form> 11</form>
12{{end}} 12{{end}}
13</div> 13</div>
14<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> 14<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}}{{with .Duration}} · ran {{.}}{{end}}{{end}}{{if .Failed}} · <a href="#failed">Jump to failure</a>{{end}}</p>
15{{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p> 15{{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p>
16<pre class="code buildlog" tabindex="0">{{.Log}}</pre> 16<pre class="code buildlog" tabindex="0">{{.Log}}</pre>
17{{else if .Steps}}{{$total := len .Build.Steps}}{{range .Steps}}
18<details class="difffold buildstep"{{if .Failed}} id="failed" open{{end}}>
19 <summary>{{if .N}}<span>step {{.N}}/{{$total}}</span> <code>{{.Head}}</code>{{else}}<span>setup</span>{{end}}{{if .Failed}} <span class="chip check-failure">failed</span>{{end}}</summary>
20 <pre class="code buildlog" tabindex="0">{{.Text}}</pre>
21</details>{{end}}
17{{else if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} 22{{else if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}
18{{end}} 23{{end}}