Commit 387b381242

387b381242a77e37af3a58b7301f6365b176603b

parent: 3e75710732

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-07 02:32 UTC

store, control, wiki: reuse a job's success across commits with the same tree

A rebase gives every commit a new sha and the same tree. Dedupe keyed on
the sha re-ran every job on every rebase; keyed on the tree, a success
stands for the new commit, which gets it as its status naming the build.
Scheduled and tag builds carry no tree and are never reused.

Closes #177

Layout: unified · split

.gitbay/wiki/Users.org +7
@@ -96,6 +96,13 @@ repository header, the count in the facts bar, and the list at
96=/bookmarks=. Bookmarks are public and counted; pins are private and 96=/bookmarks=. Bookmarks are public and counted; pins are private and
97drive the rail. Read access is all a bookmark needs. 97drive the rail. Read access is all a bookmark needs.
98 98
99A job's result is a property of the commit's *tree*, not its sha: a
100rebase onto a base that touched nothing the branch did gives every
101commit a new sha and the same tree, and a job that already passed for
102that tree is not run again — the new commit gets the earlier result as
103its =ci/<job>= status, naming the build it came from. Scheduled and tag
104jobs are never reused this way; their trigger is the clock or the tag.
105
99* Profiles 106* Profiles
100 107
101A profile is what =/{owner}= shows: a one-line description, a website, a 108A profile is what =/{owner}= shows: a one-line description, a website, a
internal/ci/sched.go +1 −1
@@ -101,7 +101,7 @@ func (s *Scheduler) RunDue(now time.Time) {
101 continue 101 continue
102 } 102 }
103 steps, _ := json.Marshal(job.Steps) 103 steps, _ := json.Marshal(job.Steps)
104 n, err := s.St.CreateBuild(repo.ID, job.Name, sha, repo.DefaultBranch, string(steps), job.Image, true) 104 n, err := s.St.CreateBuild(repo.ID, job.Name, sha, repo.DefaultBranch, string(steps), job.Image, "", true)
105 if err != nil { 105 if err != nil {
106 slog.Error("scheduler: queueing build", "repo", repo.Path(), "job", job.Name, "err", err) 106 slog.Error("scheduler: queueing build", "repo", repo.Path(), "job", job.Name, "err", err)
107 continue 107 continue
internal/control/build.go +15 −2
@@ -227,7 +227,8 @@ func runBuildTrigger(c *Ctx, args []string) int {
227 continue 227 continue
228 } 228 }
229 steps, _ := json.Marshal(j.Steps) 229 steps, _ := json.Marshal(j.Steps)
230 n, err := c.Store.CreateBuild(repo.ID, j.Name, sha, repo.DefaultBranch, string(steps), j.Image, true) 230 tree, _ := gitutil.ResolveTree(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name), sha)
231 n, err := c.Store.CreateBuild(repo.ID, j.Name, sha, repo.DefaultBranch, string(steps), j.Image, tree, true)
231 if err != nil { 232 if err != nil {
232 return c.fail(protocol.ExitFailure, "%v", err) 233 return c.fail(protocol.ExitFailure, "%v", err)
233 } 234 }
@@ -613,6 +614,12 @@ func queueJobs(
613 if err != nil { 614 if err != nil {
614 built = nil 615 built = nil
615 } 616 }
617 // A job's result is a property of the tree, not the commit: a rebase
618 // onto a base that touched nothing the branch did gives every commit
619 // a new sha and the same tree, and re-running the suite over it
620 // proves nothing it did not already prove (#177). A success recorded
621 // against the tree stands for the new commit.
622 tree, _ := gitutil.ResolveTree(dir, sha)
616 // The changed-file list a job's path filters run against, computed 623 // The changed-file list a job's path filters run against, computed
617 // once and only if some job actually declares one. When the diff 624 // once and only if some job actually declares one. When the diff
618 // base does not exist or the diff itself fails, filtered stays 625 // base does not exist or the diff itself fails, filtered stays
@@ -669,6 +676,12 @@ func queueJobs(
669 if b, ok := built[j.Name]; ok && (b.Status == "success" || b.Status == "pending" || b.Status == "running") { 676 if b, ok := built[j.Name]; ok && (b.Status == "success" || b.Status == "pending" || b.Status == "running") {
670 continue 677 continue
671 } 678 }
679 if prev, ok, _ := st.SuccessBuildForTree(repo.ID, tree, j.Name); ok && prev.SHA != sha {
680 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), prev.Number)
681 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "success",
682 fmt.Sprintf("passed in build %d as %.10s, same tree", prev.Number, prev.SHA), url, userID)
683 continue
684 }
672 // Scheduled jobs run on their cron, not on push; a default-branch 685 // Scheduled jobs run on their cron, not on push; a default-branch
673 // push (re)registers them. 686 // push (re)registers them.
674 if j.Schedule != "" { 687 if j.Schedule != "" {
@@ -688,7 +701,7 @@ func queueJobs(
688 continue 701 continue
689 } 702 }
690 steps, _ := json.Marshal(j.Steps) 703 steps, _ := json.Marshal(j.Steps)
691 n, err := st.CreateBuild(repo.ID, j.Name, sha, ref, string(steps), j.Image, trusted) 704 n, err := st.CreateBuild(repo.ID, j.Name, sha, ref, string(steps), j.Image, tree, trusted)
692 if err != nil { 705 if err != nil {
693 slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err) 706 slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
694 continue 707 continue
internal/control/build_test.go +57 −1
@@ -432,7 +432,7 @@ func TestQueueBranchBuildsAlreadyBuiltJobRecordsNoSkippedStatus(t *testing.T) {
432 // The same commit already has a build for "unit" from another branch, 432 // The same commit already has a build for "unit" from another branch,
433 // still pending. Its filter would exclude this push too, so the only 433 // still pending. Its filter would exclude this push too, so the only
434 // way to tell the two paths apart is that this one must record nothing. 434 // way to tell the two paths apart is that this one must record nothing.
435 if _, err := st.CreateBuild(repo.ID, "unit", newSHA, "other", `["echo hi"]`, "", true); err != nil { 435 if _, err := st.CreateBuild(repo.ID, "unit", newSHA, "other", `["echo hi"]`, "", "", true); err != nil {
436 t.Fatal(err) 436 t.Fatal(err)
437 } 437 }
438 438
@@ -549,3 +549,59 @@ func TestQueueBranchBuildsRebaseFiltersAgainstMergeBase(t *testing.T) {
549 t.Fatal("a rebased branch whose commits change code queued no build") 549 t.Fatal("a rebased branch whose commits change code queued no build")
550 } 550 }
551} 551}
552
553// A rebase gives a commit a new sha and the same tree. A job that
554// succeeded for that tree has nothing left to prove, so the new commit
555// gets the earlier result as its status instead of a new build (#177).
556func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) {
557 st, repo, uid := newQueueTestRepo(t)
558 git := gitRunner(t)
559 root := t.TempDir()
560
561 src := filepath.Join(root, "src")
562 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
563 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
564 "jobs:\n unit:\n steps:\n - echo hi\n"), 0o644)
565 git(root, "init", "-q", "-b", "main", "src")
566 git(src, "add", ".")
567 git(src, "commit", "-q", "-m", "base")
568 first := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
569 // Same tree, new sha: what a rebase onto an unrelated base produces.
570 git(src, "commit", "-q", "--allow-empty", "-m", "rewritten")
571 second := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
572 if git(src, "rev-parse", first+"^{tree}") != git(src, "rev-parse", second+"^{tree}") {
573 t.Fatal("fixture: trees differ")
574 }
575
576 dir := RepoDir(root, repo.OwnerName, repo.Name)
577 os.MkdirAll(filepath.Dir(dir), 0o755)
578 git(root, "clone", "-q", "--bare", src, dir)
579
580 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", "", first, time.Now())
581 builds, _ := st.ListBuilds(repo.ID, 10)
582 if len(builds) != 1 {
583 t.Fatalf("first commit queued %d builds, want 1", len(builds))
584 }
585 if _, ok, err := st.ClaimBuild([]int64{repo.ID}); err != nil || !ok {
586 t.Fatalf("claim: ok=%v err=%v", ok, err)
587 }
588 if err := st.FinishBuild(builds[0].ID, "success"); err != nil {
589 t.Fatal(err)
590 }
591
592 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", first, second, time.Now())
593 builds, _ = st.ListBuilds(repo.ID, 10)
594 if len(builds) != 1 {
595 t.Fatalf("same tree queued a second build: %+v", builds)
596 }
597 statuses, _ := st.ListCommitStatuses(repo.ID, second)
598 found := false
599 for _, s := range statuses {
600 if s.Context == "ci/unit" && s.State == "success" && strings.Contains(s.Description, "same tree") {
601 found = true
602 }
603 }
604 if !found {
605 t.Fatalf("second commit has no success status from the first: %+v", statuses)
606 }
607}
internal/control/runnernext_test.go +5 −5
@@ -60,14 +60,14 @@ func setupOrphanRepo(t *testing.T) (*store.Store, store.Repo, int64, string, str
60func TestRunnerNextSkipsOrphanedBuildAndClaimsNext(t *testing.T) { 60func TestRunnerNextSkipsOrphanedBuildAndClaimsNext(t *testing.T) {
61 st, repo, uid, root, baseSHA, orphanSHA := setupOrphanRepo(t) 61 st, repo, uid, root, baseSHA, orphanSHA := setupOrphanRepo(t)
62 62
63 orphanedID, err := st.CreateBuild(repo.ID, "unit", orphanSHA, "main", "[]", "", true) 63 orphanedID, err := st.CreateBuild(repo.ID, "unit", orphanSHA, "main", "[]", "", "", true)
64 if err != nil { 64 if err != nil {
65 t.Fatal(err) 65 t.Fatal(err)
66 } 66 }
67 if err := st.SetCommitStatus(repo.ID, orphanSHA, "ci/unit", "pending", "queued", "https://x.test", uid); err != nil { 67 if err := st.SetCommitStatus(repo.ID, orphanSHA, "ci/unit", "pending", "queued", "https://x.test", uid); err != nil {
68 t.Fatal(err) 68 t.Fatal(err)
69 } 69 }
70 realID, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", true) 70 realID, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", true)
71 if err != nil { 71 if err != nil {
72 t.Fatal(err) 72 t.Fatal(err)
73 } 73 }
@@ -113,7 +113,7 @@ func TestRunnerNextSkipsOrphanedBuildAndClaimsNext(t *testing.T) {
113// the reachability check must never reject a healthy build. 113// the reachability check must never reject a healthy build.
114func TestRunnerNextClaimsReachableBuildNormally(t *testing.T) { 114func TestRunnerNextClaimsReachableBuildNormally(t *testing.T) {
115 st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t) 115 st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t)
116 id, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", true) 116 id, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", true)
117 if err != nil { 117 if err != nil {
118 t.Fatal(err) 118 t.Fatal(err)
119 } 119 }
@@ -147,7 +147,7 @@ func TestRunnerNextOrphanedQueuePastCapReportsNoPendingBuilds(t *testing.T) {
147 st, repo, uid, root, _, orphanSHA := setupOrphanRepo(t) 147 st, repo, uid, root, _, orphanSHA := setupOrphanRepo(t)
148 total := maxOrphanSkip + 1 148 total := maxOrphanSkip + 1
149 for i := 0; i < total; i++ { 149 for i := 0; i < total; i++ {
150 if _, err := st.CreateBuild(repo.ID, fmt.Sprintf("job%d", i), orphanSHA, "main", "[]", "", true); err != nil { 150 if _, err := st.CreateBuild(repo.ID, fmt.Sprintf("job%d", i), orphanSHA, "main", "[]", "", "", true); err != nil {
151 t.Fatal(err) 151 t.Fatal(err)
152 } 152 }
153 } 153 }
@@ -194,7 +194,7 @@ func TestRunnerNextClaimsBuildWhenReachabilityCannotBeChecked(t *testing.T) {
194 // No RepoDir created on disk at all: Reachable will fail to even stat 194 // No RepoDir created on disk at all: Reachable will fail to even stat
195 // the repository, which must not be read as "orphaned". 195 // the repository, which must not be read as "orphaned".
196 root := t.TempDir() 196 root := t.TempDir()
197 id, err := st.CreateBuild(repo.ID, "unit", strings.Repeat("a", 40), "main", "[]", "", true) 197 id, err := st.CreateBuild(repo.ID, "unit", strings.Repeat("a", 40), "main", "[]", "", "", true)
198 if err != nil { 198 if err != nil {
199 t.Fatal(err) 199 t.Fatal(err)
200 } 200 }
internal/hookd/hookd.go +1 −1
@@ -302,7 +302,7 @@ func (s *Server) queueTagBuilds(repo store.Repo, userID int64, tag, pushed strin
302 continue 302 continue
303 } 303 }
304 steps, _ := json.Marshal(j.Steps) 304 steps, _ := json.Marshal(j.Steps)
305 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), j.Image, true) 305 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), j.Image, "", true)
306 if err != nil { 306 if err != nil {
307 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err) 307 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
308 continue 308 continue
internal/store/builds.go +21 −5
@@ -19,6 +19,7 @@ type Build struct {
19 Ref string 19 Ref string
20 Steps string // JSON array of shell commands 20 Steps string // JSON array of shell commands
21 Image string // container image for the steps; "" means the runner default 21 Image string // container image for the steps; "" means the runner default
22 Tree string // the commit's tree; "" when not deduplicated by tree
22 Status string // pending|running|success|failure 23 Status string // pending|running|success|failure
23 CreatedAt string 24 CreatedAt string
24 StartedAt string 25 StartedAt string
@@ -39,7 +40,7 @@ var truncNotice = []byte("\n[log truncated: reached the " +
39 40
40// CreateBuild allocates the per-repo build number in the same transaction 41// CreateBuild allocates the per-repo build number in the same transaction
41// as the insert, like issue and MR numbers. 42// as the insert, like issue and MR numbers.
42func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON, image string, trusted bool) (int64, error) { 43func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON, image, tree string, trusted bool) (int64, error) {
43 tx, err := s.DB.Begin() 44 tx, err := s.DB.Begin()
44 if err != nil { 45 if err != nil {
45 return 0, err 46 return 0, err
@@ -53,21 +54,21 @@ func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON, image string
53 return 0, err 54 return 0, err
54 } 55 }
55 if _, err := tx.Exec( 56 if _, err := tx.Exec(
56 "INSERT INTO builds (repo_id, number, job, sha, ref, steps, image, trusted) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 57 "INSERT INTO builds (repo_id, number, job, sha, ref, steps, image, tree, trusted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
57 repoID, n, job, sha, ref, stepsJSON, image, trusted); err != nil { 58 repoID, n, job, sha, ref, stepsJSON, image, tree, trusted); err != nil {
58 return 0, err 59 return 0, err
59 } 60 }
60 return n, tx.Commit() 61 return n, tx.Commit()
61} 62}
62 63
63const buildSelect = ` 64const buildSelect = `
64 SELECT id, repo_id, number, job, sha, ref, steps, image, status, created_at, started_at, finished_at, trusted 65 SELECT id, repo_id, number, job, sha, ref, steps, image, tree, status, created_at, started_at, finished_at, trusted
65 FROM builds` 66 FROM builds`
66 67
67func scanBuild(row interface{ Scan(...any) error }) (Build, error) { 68func scanBuild(row interface{ Scan(...any) error }) (Build, error) {
68 var b Build 69 var b Build
69 var trusted int 70 var trusted int
70 err := row.Scan(&b.ID, &b.RepoID, &b.Number, &b.Job, &b.SHA, &b.Ref, &b.Steps, &b.Image, 71 err := row.Scan(&b.ID, &b.RepoID, &b.Number, &b.Job, &b.SHA, &b.Ref, &b.Steps, &b.Image, &b.Tree,
71 &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt, &trusted) 72 &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt, &trusted)
72 b.Trusted = trusted != 0 73 b.Trusted = trusted != 0
73 return b, err 74 return b, err
@@ -315,6 +316,21 @@ func (s *Store) CancelBuild(id int64) error {
315 316
316// SuccessBuildFor finds a passed build of the commit for the job, on any 317// SuccessBuildFor finds a passed build of the commit for the job, on any
317// ref: what a cancelled duplicate can point back at. 318// ref: what a cancelled duplicate can point back at.
319// SuccessBuildForTree is SuccessBuildFor keyed by tree rather than
320// commit: a rebase that changes nothing in the tree has already been
321// built (#177). An empty tree never matches.
322func (s *Store) SuccessBuildForTree(repoID int64, tree, job string) (Build, bool, error) {
323 if tree == "" {
324 return Build{}, false, nil
325 }
326 b, err := scanBuild(s.DB.QueryRow(buildSelect+
327 " WHERE repo_id = ? AND tree = ? AND job = ? AND status = 'success' ORDER BY number DESC LIMIT 1", repoID, tree, job))
328 if errors.Is(err, sql.ErrNoRows) {
329 return Build{}, false, nil
330 }
331 return b, err == nil, err
332}
333
318func (s *Store) SuccessBuildFor(repoID int64, sha, job string) (Build, bool, error) { 334func (s *Store) SuccessBuildFor(repoID int64, sha, job string) (Build, bool, error) {
319 b, err := scanBuild(s.DB.QueryRow(buildSelect+ 335 b, err := scanBuild(s.DB.QueryRow(buildSelect+
320 " WHERE repo_id = ? AND sha = ? AND job = ? AND status = 'success' ORDER BY number DESC LIMIT 1", repoID, sha, job)) 336 " WHERE repo_id = ? AND sha = ? AND job = ? AND status = 'success' ORDER BY number DESC LIMIT 1", repoID, sha, job))
internal/store/builds_test.go +43 −7
@@ -21,11 +21,11 @@ func TestReapStaleBuilds(t *testing.T) {
21 t.Fatal(err) 21 t.Fatal(err)
22 } 22 }
23 23
24 stuck, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, "", true) 24 stuck, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, "", "", true)
25 if err != nil { 25 if err != nil {
26 t.Fatal(err) 26 t.Fatal(err)
27 } 27 }
28 fresh, err := s.CreateBuild(1, "pages", "abc123", "main", `["true"]`, "", true) 28 fresh, err := s.CreateBuild(1, "pages", "abc123", "main", `["true"]`, "", "", true)
29 if err != nil { 29 if err != nil {
30 t.Fatal(err) 30 t.Fatal(err)
31 } 31 }
@@ -87,11 +87,11 @@ func TestBuildsForCommitTiming(t *testing.T) {
87 } 87 }
88 // Two runs of the same job on one commit: the retry is what counts. 88 // Two runs of the same job on one commit: the retry is what counts.
89 for range 2 { 89 for range 2 {
90 if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`, "", true); err != nil { 90 if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`, "", "", true); err != nil {
91 t.Fatal(err) 91 t.Fatal(err)
92 } 92 }
93 } 93 }
94 if _, err := s.CreateBuild(repoID, "lint", "def456", "main", `["true"]`, "", true); err != nil { 94 if _, err := s.CreateBuild(repoID, "lint", "def456", "main", `["true"]`, "", "", true); err != nil {
95 t.Fatal(err) 95 t.Fatal(err)
96 } 96 }
97 if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z', 97 if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z',
@@ -140,10 +140,10 @@ func TestClaimBuildScopedToRepos(t *testing.T) {
140 t.Fatal(err) 140 t.Fatal(err)
141 } 141 }
142 // Queued first, so an unscoped claim would take it. 142 // Queued first, so an unscoped claim would take it.
143 if _, err := s.CreateBuild(theirs, "evil", "abc123", "main", `["true"]`, "", true); err != nil { 143 if _, err := s.CreateBuild(theirs, "evil", "abc123", "main", `["true"]`, "", "", true); err != nil {
144 t.Fatal(err) 144 t.Fatal(err)
145 } 145 }
146 wanted, err := s.CreateBuild(mine, "deploy", "def456", "main", `["true"]`, "", true) 146 wanted, err := s.CreateBuild(mine, "deploy", "def456", "main", `["true"]`, "", "", true)
147 if err != nil { 147 if err != nil {
148 t.Fatal(err) 148 t.Fatal(err)
149 } 149 }
@@ -182,7 +182,7 @@ func TestBuildLogSaysWhenItTruncates(t *testing.T) {
182 if _, err := s.CreateRepo("user", uid, "orgo", "public"); err != nil { 182 if _, err := s.CreateRepo("user", uid, "orgo", "public"); err != nil {
183 t.Fatal(err) 183 t.Fatal(err)
184 } 184 }
185 id, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, "", true) 185 id, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, "", "", true)
186 if err != nil { 186 if err != nil {
187 t.Fatal(err) 187 t.Fatal(err)
188 } 188 }
@@ -212,3 +212,39 @@ func TestBuildLogSaysWhenItTruncates(t *testing.T) {
212 t.Errorf("log grew to %d, past the cap plus one chunk", len(log)) 212 t.Errorf("log grew to %d, past the cap plus one chunk", len(log))
213 } 213 }
214} 214}
215
216// A success is found by tree across commits; an empty tree never matches,
217// so builds queued without one (scheduled, tag) are never reused (#177).
218func TestSuccessBuildForTree(t *testing.T) {
219 s := open(t)
220 if err := s.MigrateUp(); err != nil {
221 t.Fatal(err)
222 }
223 uid, err := s.CreateUser("cmc", true)
224 if err != nil {
225 t.Fatal(err)
226 }
227 repoID, err := s.CreateRepo("user", uid, "app", "public")
228 if err != nil {
229 t.Fatal(err)
230 }
231 if _, err := s.CreateBuild(repoID, "unit", "aaa", "main", `["true"]`, "", "tree1", true); err != nil {
232 t.Fatal(err)
233 }
234 b, _ := s.BuildsForCommit(repoID, "aaa")
235 if _, ok, err := s.ClaimBuild([]int64{repoID}); err != nil || !ok {
236 t.Fatalf("claim: ok=%v err=%v", ok, err)
237 }
238 if err := s.FinishBuild(b["unit"].ID, "success"); err != nil {
239 t.Fatal(err)
240 }
241 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "unit"); !ok || prev.SHA != "aaa" {
242 t.Fatalf("success not found by tree: ok=%v prev=%+v", ok, prev)
243 }
244 if _, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "other"); ok {
245 t.Error("matched a different job")
246 }
247 if _, ok, _ := s.SuccessBuildForTree(repoID, "", "unit"); ok {
248 t.Error("an empty tree matched")
249 }
250}
internal/store/migrations/0045_build_tree.down.sql added +2
@@ -0,0 +1,2 @@
1DROP INDEX builds_by_tree;
2ALTER TABLE builds DROP COLUMN tree;
internal/store/migrations/0045_build_tree.up.sql added +6
@@ -0,0 +1,6 @@
1-- The tree a build's commit points at. A rebase gives a commit a new sha
2-- and the same tree; a job's result is a property of the tree, so a
3-- success recorded against it stands for the new commit too (#177).
4-- Empty for scheduled and tag builds, which are not deduplicated.
5ALTER TABLE builds ADD COLUMN tree TEXT NOT NULL DEFAULT '';
6CREATE INDEX builds_by_tree ON builds (repo_id, tree, job);
internal/store/statuses_test.go +1 −1
@@ -17,7 +17,7 @@ func TestChecksForCommit(t *testing.T) {
17 if err != nil { 17 if err != nil {
18 t.Fatal(err) 18 t.Fatal(err)
19 } 19 }
20 if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`, "", true); err != nil { 20 if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`, "", "", true); err != nil {
21 t.Fatal(err) 21 t.Fatal(err)
22 } 22 }
23 if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z', 23 if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z',