Commit 6421e2fd3b

6421e2fd3b3cc9cf56ef1d33db010cce5d61d6ac

parent: b4e14d4383

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 07:43 UTC

runner done: record the failed step and reason

Ref #266

Layout: unified · split

internal/control/build.go +46 −13
@@ -101,9 +101,13 @@ func init() {
101 Examples: []string{"runner log 431"}, 101 Examples: []string{"runner log 431"},
102 ReadsStdin: true, Run: runRunnerLog}) 102 ReadsStdin: true, Run: runRunnerLog})
103 register(Command{Path: []string{"runner", "done"}, 103 register(Command{Path: []string{"runner", "done"},
104 Summary: "finish a build", 104 Summary: "finish a build",
105 Usage: "runner done <build-id> success|failure", 105 Usage: "runner done <build-id> success|failure [--step <n>] [--reason <text>]",
106 Examples: []string{"runner done 431 success"}, 106 Flags: []Flag{
107 {"--step", "<n>", "the 1-based step a failed build stopped at", ""},
108 {"--reason", "<text>", "how it failed, one line", ""},
109 },
110 Examples: []string{"runner done 431 success", "runner done 431 failure --step 3 --reason 'exit 1'"},
107 Run: runRunnerDone}) 111 Run: runRunnerDone})
108} 112}
109 113
@@ -675,12 +679,18 @@ func runRunnerDone(c *Ctx, args []string) int {
675 if code >= 0 { 679 if code >= 0 {
676 return code 680 return code
677 } 681 }
678 if len(args) != 2 || (args[1] != "success" && args[1] != "failure") { 682 f, err := parseFlags(args, flagSpec{Values: []string{"--step", "--reason"}, MaxPos: 2,
683 Usage: "runner done <build-id> success|failure [--step <n>] [--reason <text>]"})
684 if err != nil {
685 return c.fail(protocol.ExitUsage, "%v", err)
686 }
687 if len(f.Pos) != 2 || (f.Pos[1] != "success" && f.Pos[1] != "failure") {
679 return c.usage() 688 return c.usage()
680 } 689 }
681 id, err := strconv.ParseInt(args[0], 10, 64) 690 outcome := f.Pos[1]
691 id, err := strconv.ParseInt(f.Pos[0], 10, 64)
682 if err != nil { 692 if err != nil {
683 return c.fail(protocol.ExitUsage, "bad build id %q", args[0]) 693 return c.fail(protocol.ExitUsage, "bad build id %q", f.Pos[0])
684 } 694 }
685 b, err := c.Store.BuildByID(id) 695 b, err := c.Store.BuildByID(id)
686 if err != nil { 696 if err != nil {
@@ -699,7 +709,20 @@ func runRunnerDone(c *Ctx, args []string) int {
699 fmt.Fprintf(w, "build %d was cancelled\n", b.Number) 709 fmt.Fprintf(w, "build %d was cancelled\n", b.Number)
700 }) 710 })
701 } 711 }
702 if err := c.Store.FinishBuild(id, args[1]); err != nil { 712 if outcome == "failure" {
713 // A step the job does not have is recorded as none rather than
714 // refused: refusing would lose the outcome over a detail (#266).
715 var steps []string
716 json.Unmarshal([]byte(b.Steps), &steps)
717 step, _ := strconv.Atoi(f.Value("--step"))
718 if step < 0 || step > len(steps) {
719 step = 0
720 }
721 if err := c.Store.SetBuildFailure(id, step, failureReason(f.Value("--reason"))); err != nil && !errors.Is(err, store.ErrNotFound) {
722 return c.fail(protocol.ExitFailure, "recording build %d's failure: %v", id, err)
723 }
724 }
725 if err := c.Store.FinishBuild(id, outcome); err != nil {
703 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err) 726 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
704 } 727 }
705 c.Store.RunnerDone(key.ID) 728 c.Store.RunnerDone(key.ID)
@@ -708,15 +731,15 @@ func runRunnerDone(c *Ctx, args []string) int {
708 return c.fail(protocol.ExitFailure, "%v", err) 731 return c.fail(protocol.ExitFailure, "%v", err)
709 } 732 }
710 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number) 733 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
711 desc := "build " + args[1] 734 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 { 735 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) 736 return c.fail(protocol.ExitFailure, "%v", err)
714 } 737 }
715 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1], 738 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)) 739 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 740 // A red build mails the repo's notify targets with the log tail — a
718 // failed scheduled job must not wait to be noticed. 741 // failed scheduled job must not wait to be noticed.
719 if args[1] == "failure" { 742 if outcome == "failure" {
720 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { 743 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
721 tail := "" 744 tail := ""
722 if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 { 745 if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 {
@@ -732,11 +755,21 @@ func runRunnerDone(c *Ctx, args []string) int {
732 path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)}) 755 path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)})
733 } 756 }
734 } 757 }
735 return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) { 758 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]) 759 fmt.Fprintf(w, "build %d %s\n", b.Number, outcome)
737 }) 760 })
738} 761}
739 762
763// failureReason keeps a runner's reason to one line of at most 200
764// bytes: it is shown on the build page and by build show.
765func failureReason(s string) string {
766 s = strings.Join(strings.Fields(s), " ")
767 if len(s) > 200 {
768 s = s[:200]
769 }
770 return strings.ToValidUTF8(s, "")
771}
772
740// QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending 773// QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending
741// build per push job, with a pending commit status the runner resolves. 774// 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. 775// A broken config surfaces as a failed "ci/config" status, not silence.
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}