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
9696=/bookmarks=. Bookmarks are public and counted; pins are private and
9797drive the rail. Read access is all a bookmark needs.
9898
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
99106* Profiles
100107
101108A 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) {
101101 continue
102102 }
103103 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)
105105 if err != nil {
106106 slog.Error("scheduler: queueing build", "repo", repo.Path(), "job", job.Name, "err", err)
107107 continue
internal/control/build.go +15 −2
@@ -227,7 +227,8 @@ func runBuildTrigger(c *Ctx, args []string) int {
227227 continue
228228 }
229229 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)
231232 if err != nil {
232233 return c.fail(protocol.ExitFailure, "%v", err)
233234 }
@@ -613,6 +614,12 @@ func queueJobs(
613614 if err != nil {
614615 built = nil
615616 }
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)
616623 // The changed-file list a job's path filters run against, computed
617624 // once and only if some job actually declares one. When the diff
618625 // base does not exist or the diff itself fails, filtered stays
@@ -669,6 +676,12 @@ func queueJobs(
669676 if b, ok := built[j.Name]; ok && (b.Status == "success" || b.Status == "pending" || b.Status == "running") {
670677 continue
671678 }
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 }
672685 // Scheduled jobs run on their cron, not on push; a default-branch
673686 // push (re)registers them.
674687 if j.Schedule != "" {
@@ -688,7 +701,7 @@ func queueJobs(
688701 continue
689702 }
690703 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)
692705 if err != nil {
693706 slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
694707 continue
internal/control/build_test.go +57 −1
@@ -432,7 +432,7 @@ func TestQueueBranchBuildsAlreadyBuiltJobRecordsNoSkippedStatus(t *testing.T) {
432432 // The same commit already has a build for "unit" from another branch,
433433 // still pending. Its filter would exclude this push too, so the only
434434 // 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 {
436436 t.Fatal(err)
437437 }
438438
@@ -549,3 +549,59 @@ func TestQueueBranchBuildsRebaseFiltersAgainstMergeBase(t *testing.T) {
549549 t.Fatal("a rebased branch whose commits change code queued no build")
550550 }
551551}
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
6060func TestRunnerNextSkipsOrphanedBuildAndClaimsNext(t *testing.T) {
6161 st, repo, uid, root, baseSHA, orphanSHA := setupOrphanRepo(t)
6262
63 orphanedID, err := st.CreateBuild(repo.ID, "unit", orphanSHA, "main", "[]", "", true)
63 orphanedID, err := st.CreateBuild(repo.ID, "unit", orphanSHA, "main", "[]", "", "", true)
6464 if err != nil {
6565 t.Fatal(err)
6666 }
6767 if err := st.SetCommitStatus(repo.ID, orphanSHA, "ci/unit", "pending", "queued", "https://x.test", uid); err != nil {
6868 t.Fatal(err)
6969 }
70 realID, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", true)
70 realID, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", true)
7171 if err != nil {
7272 t.Fatal(err)
7373 }
@@ -113,7 +113,7 @@ func TestRunnerNextSkipsOrphanedBuildAndClaimsNext(t *testing.T) {
113113// the reachability check must never reject a healthy build.
114114func TestRunnerNextClaimsReachableBuildNormally(t *testing.T) {
115115 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)
117117 if err != nil {
118118 t.Fatal(err)
119119 }
@@ -147,7 +147,7 @@ func TestRunnerNextOrphanedQueuePastCapReportsNoPendingBuilds(t *testing.T) {
147147 st, repo, uid, root, _, orphanSHA := setupOrphanRepo(t)
148148 total := maxOrphanSkip + 1
149149 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 {
151151 t.Fatal(err)
152152 }
153153 }
@@ -194,7 +194,7 @@ func TestRunnerNextClaimsBuildWhenReachabilityCannotBeChecked(t *testing.T) {
194194 // No RepoDir created on disk at all: Reachable will fail to even stat
195195 // the repository, which must not be read as "orphaned".
196196 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)
198198 if err != nil {
199199 t.Fatal(err)
200200 }
internal/hookd/hookd.go +1 −1
@@ -302,7 +302,7 @@ func (s *Server) queueTagBuilds(repo store.Repo, userID int64, tag, pushed strin
302302 continue
303303 }
304304 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)
306306 if err != nil {
307307 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
308308 continue
internal/store/builds.go +21 −5
@@ -19,6 +19,7 @@ type Build struct {
1919 Ref string
2020 Steps string // JSON array of shell commands
2121 Image string // container image for the steps; "" means the runner default
22 Tree string // the commit's tree; "" when not deduplicated by tree
2223 Status string // pending|running|success|failure
2324 CreatedAt string
2425 StartedAt string
@@ -39,7 +40,7 @@ var truncNotice = []byte("\n[log truncated: reached the " +
3940
4041// CreateBuild allocates the per-repo build number in the same transaction
4142// 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) {
4344 tx, err := s.DB.Begin()
4445 if err != nil {
4546 return 0, err
@@ -53,21 +54,21 @@ func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON, image string
5354 return 0, err
5455 }
5556 if _, err := tx.Exec(
56 "INSERT INTO builds (repo_id, number, job, sha, ref, steps, image, trusted) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
57 repoID, n, job, sha, ref, stepsJSON, image, trusted); err != nil {
57 "INSERT INTO builds (repo_id, number, job, sha, ref, steps, image, tree, trusted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
58 repoID, n, job, sha, ref, stepsJSON, image, tree, trusted); err != nil {
5859 return 0, err
5960 }
6061 return n, tx.Commit()
6162}
6263
6364const 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
6566 FROM builds`
6667
6768func scanBuild(row interface{ Scan(...any) error }) (Build, error) {
6869 var b Build
6970 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,
7172 &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt, &trusted)
7273 b.Trusted = trusted != 0
7374 return b, err
@@ -315,6 +316,21 @@ func (s *Store) CancelBuild(id int64) error {
315316
316317// SuccessBuildFor finds a passed build of the commit for the job, on any
317318// 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
318334func (s *Store) SuccessBuildFor(repoID int64, sha, job string) (Build, bool, error) {
319335 b, err := scanBuild(s.DB.QueryRow(buildSelect+
320336 " 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) {
2121 t.Fatal(err)
2222 }
2323
24 stuck, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, "", true)
24 stuck, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, "", "", true)
2525 if err != nil {
2626 t.Fatal(err)
2727 }
28 fresh, err := s.CreateBuild(1, "pages", "abc123", "main", `["true"]`, "", true)
28 fresh, err := s.CreateBuild(1, "pages", "abc123", "main", `["true"]`, "", "", true)
2929 if err != nil {
3030 t.Fatal(err)
3131 }
@@ -87,11 +87,11 @@ func TestBuildsForCommitTiming(t *testing.T) {
8787 }
8888 // Two runs of the same job on one commit: the retry is what counts.
8989 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 {
9191 t.Fatal(err)
9292 }
9393 }
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 {
9595 t.Fatal(err)
9696 }
9797 if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z',
@@ -140,10 +140,10 @@ func TestClaimBuildScopedToRepos(t *testing.T) {
140140 t.Fatal(err)
141141 }
142142 // 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 {
144144 t.Fatal(err)
145145 }
146 wanted, err := s.CreateBuild(mine, "deploy", "def456", "main", `["true"]`, "", true)
146 wanted, err := s.CreateBuild(mine, "deploy", "def456", "main", `["true"]`, "", "", true)
147147 if err != nil {
148148 t.Fatal(err)
149149 }
@@ -182,7 +182,7 @@ func TestBuildLogSaysWhenItTruncates(t *testing.T) {
182182 if _, err := s.CreateRepo("user", uid, "orgo", "public"); err != nil {
183183 t.Fatal(err)
184184 }
185 id, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, "", true)
185 id, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, "", "", true)
186186 if err != nil {
187187 t.Fatal(err)
188188 }
@@ -212,3 +212,39 @@ func TestBuildLogSaysWhenItTruncates(t *testing.T) {
212212 t.Errorf("log grew to %d, past the cap plus one chunk", len(log))
213213 }
214214}
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) {
1717 if err != nil {
1818 t.Fatal(err)
1919 }
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 {
2121 t.Fatal(err)
2222 }
2323 if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z',