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.
3939 (=cmd/gitbay-runner/isolate.go=).
40404. *Log.* =runner log <id>= streams stdin into the build row; the server
4141 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,
4344 records an event and mails the repository's watchers a log tail on
4445 failure (=build.go=).
45466. *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.
6464A restart ends every open follow with a message saying so, rather
6565than holding the drain; follow again once the daemon is back.
6666
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
6778* What a build can reach
6879
6980Builds 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.
217217| build show (one build) | yes | yes | yes |
218218| build log | yes | yes | yes |
219219| 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 |
220223| build jobs | yes | yes | yes |
221224| build trigger | yes | yes | yes |
222225| 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,
558558and turns the gate on. =build list= takes =--ref=,
559559=--status= and =--job= to narrow the listing, combinable; the builds
560560tab 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
562564with =sh -c= on the instance's runner, stopping at the first failure;
563565a broken config surfaces as a failed =ci/config= status. Environment:
564566=GITBAY_REPO=,
CHANGELOG.org +6
@@ -142,6 +142,12 @@ for the eighteen commands whose CLI path differs from the registry's
142142 =deploy/runner-podman-setup.sh= (it installs nftables) before =make
143143 deploy-runner=. Deploy gitbayd, then the runner, after validating on
144144 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)
145151
146152* v1.36.0 — 2026-09-23
147153
cmd/gitbay-runner/isolate.go +17 −17
@@ -76,25 +76,25 @@ func (r *runner) checkIsolation() error {
7676 }
7777}
7878
79// runSteps executes a job's steps and reports whether all succeeded. The
80// clone has already happened, outside any container and with the runner's
81// key: the container never sees GIT_SSH_COMMAND, the key, or the runner's
82// environment — it gets the workspace and nothing else.
79// runSteps executes a job's steps. Returns nil when every step succeeded.
80// The clone has already happened, outside any container and with the
81// runner's key: the container never sees GIT_SSH_COMMAND, the key, or the
82// runner's environment — it gets the workspace and nothing else.
8383type stepRunner func(cmd *exec.Cmd, deadline time.Time) (bool, string)
8484
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 {
8686 if r.isolation == isolationNone {
87 for _, step := range j.Steps {
87 for i, step := range j.Steps {
8888 fmt.Fprintf(sink, "$ %s\n", step)
8989 cmd := exec.Command(toolpath.Look("sh"), "-c", step)
9090 cmd.Dir, cmd.Env = dir, env
9191 cmd.Stdout, cmd.Stderr = sink, sink
9292 if ok, why := runStep(cmd, deadline); !ok {
93 fmt.Fprintf(sink, "%s\n", why)
94 return false
93 fmt.Fprintf(sink, "step %d/%d failed: %s\n", i+1, len(j.Steps), why)
94 return &failure{Step: i + 1, Reason: why}
9595 }
9696 }
97 return true
97 return nil
9898 }
9999 return r.runStepsPodman(j, dir, env, sink, deadline, runStep)
100100}
@@ -103,7 +103,7 @@ func (r *runner) runSteps(j job, dir string, env []string, sink io.Writer, deadl
103103// step in it with `podman exec`. One container per job, not per step,
104104// because steps share state — a build step writes what a test step reads
105105// — 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 {
107107 podman := toolpath.Look("podman")
108108 image := j.Image
109109 if image == "" {
@@ -124,7 +124,7 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
124124 envFile := filepath.Join(r.workdir, fmt.Sprintf("env-%d", j.ID))
125125 if err := writeEnvFile(envFile, fileEnv); err != nil {
126126 fmt.Fprintf(sink, "preparing the build environment: %v\n", err)
127 return false
127 return &failure{Reason: "preparing the build environment failed"}
128128 }
129129 defer os.Remove(envFile)
130130
@@ -137,7 +137,7 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
137137 dir, f, err := r.cgroups.create(j.ID, r.memory, r.cpus)
138138 if err != nil {
139139 fmt.Fprintf(sink, "preparing the build cgroup: %v\n", err)
140 return false
140 return &failure{Reason: "preparing the build cgroup failed"}
141141 }
142142 cgroupFD = f
143143 defer f.Close()
@@ -179,22 +179,22 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
179179 fmt.Fprintf(sink, "\nThis runner does not pull images. Ask an operator to provision %s "+
180180 "on the runner host (podman pull, or podman build) before a job names it.\n", image)
181181 }
182 return false
182 return &failure{Reason: "starting the build container failed"}
183183 }
184184 defer exec.Command(podman, append(r.podmanGlobal(), "rm", "--force", name)...).Run()
185185
186 for _, step := range j.Steps {
186 for i, step := range j.Steps {
187187 fmt.Fprintf(sink, "$ %s\n", step)
188188 cmd := exec.Command(podman, append(r.podmanGlobal(), "exec", "--workdir", "/workspace", name, "sh", "-c", step)...)
189189 cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + r.podmanHome()}
190190 intoCgroup(cmd, cgroupFD)
191191 cmd.Stdout, cmd.Stderr = sink, sink
192192 if ok, why := runStep(cmd, deadline); !ok {
193 fmt.Fprintf(sink, "%s\n", why)
194 return false
193 fmt.Fprintf(sink, "step %d/%d failed: %s\n", i+1, len(j.Steps), why)
194 return &failure{Step: i + 1, Reason: why}
195195 }
196196 }
197 return true
197 return nil
198198}
199199
200200// podmanGlobal are the flags every podman invocation needs, before the
cmd/gitbay-runner/main.go +33 −11
@@ -11,6 +11,7 @@ package main
1111
1212import (
1313 "encoding/json"
14 "errors"
1415 "flag"
1516 "fmt"
1617 "io"
@@ -274,11 +275,12 @@ func (r *runner) step() (bool, error) {
274275 }
275276 j := env.Data
276277 log.Printf("build %d: %s %s @ %.10s", j.ID, j.Repo, j.Job, j.SHA)
277 status := "failure"
278 if r.run(j) {
279 status = "success"
278 f := r.run(j)
279 status := "success"
280 if f != nil {
281 status = "failure"
280282 }
281 if err := r.reportDone(j.ID, status); err != nil {
283 if err := r.reportDone(j.ID, status, f); err != nil {
282284 return true, err
283285 }
284286 log.Printf("build %d: %s", j.ID, status)
@@ -315,16 +317,36 @@ func (s *logSink) broken() bool {
315317 return s.w == nil
316318}
317319
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
318339// run clones, checks out, and executes the steps, streaming output to the
319// server. Returns whether every step succeeded.
320func (r *runner) run(j job) bool {
340// server. Returns nil when every step succeeded, else where the build
341// stopped.
342func (r *runner) run(j job) *failure {
321343 dir := filepath.Join(r.workdir, fmt.Sprintf("build-%d", j.ID))
322344 defer os.RemoveAll(dir)
323345
324346 home, doneHome, err := buildHome(r.workdir, j)
325347 if err != nil {
326348 log.Printf("build %d: build home: %v", j.ID, err)
327 return false
349 return &failure{Reason: "preparing the build home failed"}
328350 }
329351 defer doneHome()
330352
@@ -333,13 +355,13 @@ func (r *runner) run(j job) bool {
333355 pipe, err := logCmd.StdinPipe()
334356 if err != nil {
335357 log.Printf("build %d: log pipe: %v", j.ID, err)
336 return false
358 return &failure{Reason: "opening the log stream failed"}
337359 }
338360 sink := &logSink{w: pipe}
339361 logCmd.Stdout, logCmd.Stderr = io.Discard, io.Discard
340362 if err := logCmd.Start(); err != nil {
341363 log.Printf("build %d: log stream: %v", j.ID, err)
342 return false
364 return &failure{Reason: "opening the log stream failed"}
343365 }
344366 // The server ends the log session with exit 3 when the build is
345367 // cancelled; any other end is a lost stream, which the sink absorbs.
@@ -384,7 +406,7 @@ func (r *runner) run(j job) bool {
384406 select {
385407 case err := <-done:
386408 if err != nil {
387 return false, fmt.Sprintf("step failed: %v", err)
409 return false, exitReason(err)
388410 }
389411 return true, ""
390412 case <-cancelled:
@@ -425,7 +447,7 @@ func (r *runner) run(j job) bool {
425447 cmd.Stdout, cmd.Stderr = sink, sink
426448 if ok, why := runStep(cmd, deadline); !ok {
427449 fmt.Fprintf(sink, "git %s: %s\n", args[0], why)
428 return false
450 return &failure{Reason: "git " + args[0] + ": " + why}
429451 }
430452 }
431453
cmd/gitbay-runner/report.go +22 −2
@@ -4,6 +4,8 @@ import (
44 "errors"
55 "fmt"
66 "os/exec"
7 "strconv"
8 "strings"
79 "time"
810)
911
@@ -16,12 +18,30 @@ import (
1618// Only a connection-level failure is retried — ssh exits 255 for those.
1719// Any other exit is the server's answer, and asking again would not
1820// 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)
2023 return reportWithRetry(func() (string, error) {
21 return r.ssh(nil, "runner", "done", fmt.Sprint(id), status)
24 return r.ssh(nil, args...)
2225 }, id, retryDelays)
2326}
2427
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
2545var retryDelays = []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second}
2646
2747func reportWithRetry(report func() (string, error), id int64, delays []time.Duration) error {
cmd/gitbay-runner/report_test.go +23
@@ -3,8 +3,11 @@ package main
33import (
44 "errors"
55 "os/exec"
6 "strings"
67 "testing"
78 "time"
9
10 "gitbay.org/gitbay/internal/protocol"
811)
912
1013// exitErr fabricates the error ssh returns for a given exit status.
@@ -61,3 +64,23 @@ func TestReportGivesUp(t *testing.T) {
6164 t.Fatalf("err=%v calls=%d, want three attempts then an error", err, calls)
6265 }
6366}
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) {
144144 t.Fatalf("ok log:\n%s", out)
145145 }
146146 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") {
148148 t.Fatalf("broken log:\n%s", out)
149149 }
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 }
150160 // Statuses resolved, with target URLs pointing at the build pages.
151161 out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha, "--json")
152162 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() {
4040 ReadOnly: true, Run: runBuildShow})
4141 register(Command{Path: []string{"build", "log"},
4242 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>]",
4444 Flags: []Flag{
4545 {"--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", ""},
4648 },
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"},
4850 ReadOnly: true, Run: runBuildLog})
4951
5052 register(Command{Path: []string{"build", "jobs"},
@@ -101,9 +103,13 @@ func init() {
101103 Examples: []string{"runner log 431"},
102104 ReadsStdin: true, Run: runRunnerLog})
103105 register(Command{Path: []string{"runner", "done"},
104 Summary: "finish a build",
105 Usage: "runner done <build-id> success|failure",
106 Examples: []string{"runner done 431 success"},
106 Summary: "finish a build",
107 Usage: "runner done <build-id> success|failure [--step <n>] [--reason <text>]",
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'"},
107113 Run: runRunnerDone})
108114}
109115
@@ -119,11 +125,22 @@ type BuildOut struct {
119125 // names what it ran on rather than only its sha (#241). It is empty
120126 // when the commit is no longer in the repository.
121127 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"`
122137}
123138
124139func buildToOut(b store.Build) BuildOut {
125140 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)}
127144}
128145
129146func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
@@ -225,7 +242,22 @@ func runBuildShow(c *Ctx, args []string) int {
225242 return code
226243 }
227244 d := buildToOut(b)
245 json.Unmarshal([]byte(b.Steps), &d.Steps)
228246 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 }
229261 v := c.view(w)
230262 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status)
231263 v.fields(
@@ -233,13 +265,16 @@ func runBuildShow(c *Ctx, args []string) int {
233265 "ref", d.Ref,
234266 "queued", c.when(d.CreatedAt),
235267 "finished", c.when(d.FinishedAt),
268 "duration", duration,
269 "failed step", failedStep,
270 "failed", failed,
236271 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)),
237272 )
238273 })
239274}
240275
241276func 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})
243278 if err != nil {
244279 return c.fail(protocol.ExitUsage, "%v", err)
245280 }
@@ -248,12 +283,49 @@ func runBuildLog(c *Ctx, args []string) int {
248283 return code
249284 }
250285 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 }
251289 return followBuildLog(c, repo, b)
252290 }
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 }
253297 log, err := c.Store.BuildLog(b.ID)
254298 if err != nil {
255299 return c.fail(protocol.ExitFailure, "%v", err)
256300 }
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 }
257329 c.Stdout.Write(log)
258330 return protocol.ExitOK
259331}
@@ -675,12 +747,17 @@ func runRunnerDone(c *Ctx, args []string) int {
675747 if code >= 0 {
676748 return code
677749 }
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") {
679755 return c.usage()
680756 }
681 id, err := strconv.ParseInt(args[0], 10, 64)
757 outcome := f.Pos[1]
758 id, err := strconv.ParseInt(f.Pos[0], 10, 64)
682759 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])
684761 }
685762 b, err := c.Store.BuildByID(id)
686763 if err != nil {
@@ -699,7 +776,20 @@ func runRunnerDone(c *Ctx, args []string) int {
699776 fmt.Fprintf(w, "build %d was cancelled\n", b.Number)
700777 })
701778 }
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 {
703793 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
704794 }
705795 c.Store.RunnerDone(key.ID)
@@ -708,15 +798,15 @@ func runRunnerDone(c *Ctx, args []string) int {
708798 return c.fail(protocol.ExitFailure, "%v", err)
709799 }
710800 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
711 desc := "build " + args[1]
712 if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, args[1], desc, url, c.User.ID); err != nil {
801 desc := "build " + outcome
802 if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, outcome, desc, url, c.User.ID); err != nil {
713803 return c.fail(protocol.ExitFailure, "%v", err)
714804 }
715 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1],
805 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+outcome,
716806 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
717807 // A red build mails the repo's notify targets with the log tail — a
718808 // failed scheduled job must not wait to be noticed.
719 if args[1] == "failure" {
809 if outcome == "failure" {
720810 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
721811 tail := ""
722812 if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 {
@@ -732,11 +822,21 @@ func runRunnerDone(c *Ctx, args []string) int {
732822 path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)})
733823 }
734824 }
735 return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) {
736 fmt.Fprintf(w, "build %d %s\n", b.Number, args[1])
825 return c.emit(map[string]any{"build": b.Number, "status": outcome}, func(w io.Writer) {
826 fmt.Fprintf(w, "build %d %s\n", b.Number, outcome)
737827 })
738828}
739829
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
740840// QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending
741841// build per push job, with a pending commit status the runner resolves.
742842// 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) {
295295 }
296296 }
297297}
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
198198 }
199199 return sb.String()
200200}
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 (
1010 "slices"
1111 "strconv"
1212 "strings"
13 "time"
1314
1415 "gitbay.org/gitbay/internal/control"
1516 "gitbay.org/gitbay/internal/protocol"
@@ -282,18 +283,58 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) {
282283 return
283284 }
284285 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 }
285290 s.render(w, "build.html", v)
286291}
287292
288293type buildView struct {
289294 repoPage
290 Build control.BuildOut
291 Log string
295 Build control.BuildOut
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
292302 Live bool
293303 CanWrite bool
294304 Notice string
295305}
296306
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
297338// liveLogMarker stands in for the log when build.html is rendered for a
298339// live build; streamBuild splits the page there and streams the log into
299340// 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 {
3030 // Trusted is false for a merge request head fetched from another
3131 // repository: its steps run without the target's secrets.
3232 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
3338}
3439
3540// 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
6570}
6671
6772const 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
6975 FROM builds`
7076
7177func scanBuild(row interface{ Scan(...any) error }) (Build, error) {
7278 var b Build
7379 var trusted int
7480 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)
7683 b.Trusted = trusted != 0
7784 return b, err
7885}
@@ -263,6 +270,21 @@ func (s *Store) FinishBuild(id int64, status string) error {
263270 return nil
264271}
265272
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
266288func (s *Store) BuildByID(id int64) (Build, error) {
267289 b, err := scanBuild(s.DB.QueryRow(buildSelect+" WHERE id = ?", id))
268290 if errors.Is(err, sql.ErrNoRows) {
internal/store/builds_test.go +35
@@ -1,6 +1,7 @@
11package store
22
33import (
4 "errors"
45 "strings"
56 "testing"
67)
@@ -566,3 +567,37 @@ func TestBuildLogFrom(t *testing.T) {
566567 t.Fatalf("missing build: %v", err)
567568 }
568569}
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
11131113p.pager { margin-top: var(--sp-4); }
11141114.snippetfile { margin-bottom: var(--sp-5); }
11151115p.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; }
11171119
11181120/* ---- rendered prose ---- */
11191121pre {
internal/web/templates/build.html +6 −1
@@ -11,8 +11,13 @@
1111</form>
1212{{end}}
1313</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>
1515{{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>
1616<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}}
1722{{else if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}
1823{{end}}