Commit f3c562dedb

f3c562dedb9f3c6989727a7fe5dcf6d18e7068d6

parent: c07024b730

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

cmc <hello@cleberg.net> · 2026-10-19 04:53 UTC

ci: reuse a build only on the ref it ran for

queueJobs let a build of the same commit, or a passed build of the same
tree, on any ref stand for a push. A job sees GITBAY_REF, so a fast-forward
or a squash merge onto the default branch reused a branch build that had
not done what the default-branch build would: krz/orgo's pages job no-ops
off main and was never run after a merge.

Both lookups now take the ref (BuildsForCommitOnRef, SuccessBuildForTree).
A filter skip still leaves alone a status another ref's build reported on
the same commit. New push-shape row for the squash merge; the fast-forward
row now queues.

Closes #335

Layout: unified · split

.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org +1 −1
@@ -23,7 +23,7 @@ commit instead of failing silently.
231. *Queue.* The post-receive hook calls =queueJobs= 231. *Queue.* The post-receive hook calls =queueJobs=
24 (=internal/control/build.go=). Each job gets a =ci/<job>= status: 24 (=internal/control/build.go=). Each job gets a =ci/<job>= status:
25 =pending= when queued, =skipped= when path filters exclude it, or 25 =pending= when queued, =skipped= when path filters exclude it, or
26 =success= copied from an earlier trusted build of the same tree on the same image (#177, #258). 26 =success= copied from an earlier trusted build of the same tree on the same ref and image (#177, #258).
27 Merge requests from forks are queued against the target repository 27 Merge requests from forks are queued against the target repository
28 with =trusted = false=. 28 with =trusted = false=.
292. *Claim.* A runner calls =runner next= over SSH 292. *Claim.* A runner calls =runner next= over SSH
.gitbay/wiki/CI.org +15 −8
@@ -2,10 +2,13 @@
2 2
3Three mechanisms decide what a push does to CI, and they interact: 3Three mechanisms decide what a push does to CI, and they interact:
4 4
5- *Dedupe.* A job's result is a property of the commit's tree. A commit 5- *Dedupe.* A job's result is a property of the commit's tree and the
6 that already has a passed, queued or running build for a job is not 6 ref it ran for, since a job sees =GITBAY_REF=. A commit that already
7 queued again; a commit whose tree already passed a job gets that 7 has a passed, queued or running build for a job on this ref is not
8 result as its status, naming the build it came from (#177). Only a 8 queued again; a commit whose tree already passed a job on this ref
9 gets that result as its status, naming the build it came from (#177).
10 A merge onto the default branch is built there even when the branch
11 passed. Only a
9 trusted build counts, and for tree reuse only one on the image the 12 trusted build counts, and for tree reuse only one on the image the
10 job names: a fork's green build does not stand for the repository's 13 job names: a fork's green build does not stand for the repository's
11 own, so its commit is built again when it lands on a branch (#258). A 14 own, so its commit is built again when it lands on a branch (#258). A
@@ -168,7 +171,8 @@ push produced for that job; the commit's =ci/<job>= status follows:
168| a new branch, no old sha | queued | queued | skipped | — | — | — | 171| a new branch, no old sha | queued | queued | skipped | — | — | — |
169| rebase onto a moved default branch, old not an ancestor | queued | queued | skipped | — | — | — | 172| rebase onto a moved default branch, old not an ancestor | queued | queued | skipped | — | — | — |
170| rewritten commit, same tree as a passed build | reused | reused | skipped | — | — | — | 173| rewritten commit, same tree as a passed build | reused | reused | skipped | — | — | — |
171| fast-forward of a commit built on another branch | — | — | — | registered | — | — | 174| fast-forward of a commit built on another branch | queued | queued | — | registered | — | — |
175| squash merge onto the default branch, same tree as a branch build | queued | queued | skipped | registered | — | — |
172| a commit whose earlier build failed, on a new branch | queued | queued | — | — | — | — | 176| a commit whose earlier build failed, on a new branch | queued | queued | — | — | — | — |
173| merge request head from a fork | queued, untrusted | queued, untrusted | queued, untrusted | — | — | — | 177| merge request head from a fork | queued, untrusted | queued, untrusted | queued, untrusted | — | — | — |
174| tag push | — | — | — | — | queued | — | 178| tag push | — | — | — | — | queued | — |
@@ -190,9 +194,12 @@ Rows worth a second look:
190 because the tree check comes before the filter. =notapp= never had a 194 because the tree check comes before the filter. =notapp= never had a
191 passed build to reuse, and the push changed nothing, so the filter 195 passed build to reuse, and the push changed nothing, so the filter
192 skips it again. 196 skips it again.
193- A fast-forward of a commit built elsewhere queues nothing: the 197- A build stands for its commit, or its tree, only on the ref it ran
194 builds belong to the commit, not the branch. The default-branch push 198 for: a job sees =GITBAY_REF= and may act on it, as one that publishes
195 still registers the schedule. 199 only from the default branch does. A fast-forward or a squash merge
200 onto the default branch is built there although the branch passed.
201 =notapp= on the fast-forward keeps the skipped status the branch push
202 gave the same commit.
196- A failed build does not stand for its commit. The same commit pushed 203- A failed build does not stand for its commit. The same commit pushed
197 to another branch runs again; =notapp= keeps its skipped status. 204 to another branch runs again; =notapp= keeps its skipped status.
198- A merge request head from a fork has no diff base and, unlike a 205- A merge request head from a fork has no diff base and, unlike a
internal/control/build.go +29 −15
@@ -1020,20 +1020,28 @@ func queueJobs(
1020 st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID) 1020 st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID)
1021 return 1021 return
1022 } 1022 }
1023 // A build is a fact about a commit, not a ref: a job has no branch 1023 // A build is a fact about a commit on a ref: a job sees GITBAY_REF and
1024 // filter, so a commit that already passed a job on another branch has 1024 // may act on it, as a job that publishes only from the default branch
1025 // nothing left to prove when a fast-forward lands it here, and one 1025 // does, so a build for another ref does not stand for this one. A
1026 // still queued or running there will say soon enough. A failed, 1026 // commit already built for this ref, or queued or running for it, has
1027 // abandoned or cancelled build does not count; that commit runs again. 1027 // nothing left to prove. A failed, abandoned or cancelled build does
1028 built, err := st.BuildsForCommit(repo.ID, sha) 1028 // not count; that commit runs again.
1029 built, err := st.BuildsForCommitOnRef(repo.ID, sha, ref)
1029 if err != nil { 1030 if err != nil {
1030 built = nil 1031 built = nil
1031 } 1032 }
1032 // A job's result is a property of the tree, not the commit: a rebase 1033 // The ci/<job> status belongs to the commit, so a build of it for
1033 // onto a base that touched nothing the branch did gives every commit 1034 // another ref reports there too, and a skip here must not overwrite it.
1034 // a new sha and the same tree, and re-running the suite over it 1035 elsewhere, err := st.BuildsForCommit(repo.ID, sha)
1035 // proves nothing it did not already prove (#177). A success recorded 1036 if err != nil {
1036 // against the tree stands for the new commit. 1037 elsewhere = nil
1038 }
1039 // A job's result is a property of the tree and the ref, not the
1040 // commit: a rebase onto a base that touched nothing the branch did
1041 // gives every commit a new sha and the same tree, and re-running the
1042 // suite over it proves nothing it did not already prove (#177). A
1043 // success recorded against the tree on this ref stands for the new
1044 // commit; a squash merge onto another branch is built there.
1037 tree, _ := gitutil.ResolveTree(dir, sha) 1045 tree, _ := gitutil.ResolveTree(dir, sha)
1038 // The changed-file list a job's path filters run against, computed 1046 // The changed-file list a job's path filters run against, computed
1039 // once and only if some job actually declares one. When the diff 1047 // once and only if some job actually declares one. When the diff
@@ -1092,11 +1100,15 @@ func queueJobs(
1092 // stands for it — unless this queue is trusted and that build was 1100 // stands for it — unless this queue is trusted and that build was
1093 // not: a fork's head that lands on a branch is built again as the 1101 // not: a fork's head that lands on a branch is built again as the
1094 // repository's own (#258). 1102 // repository's own (#258).
1095 if b, ok := built[j.Name]; ok && (b.Trusted || !trusted) && 1103 stands := func(m map[string]store.Build) bool {
1096 (b.Status == "success" || b.Status == "pending" || b.Status == "running") { 1104 b, ok := m[j.Name]
1105 return ok && (b.Trusted || !trusted) &&
1106 (b.Status == "success" || b.Status == "pending" || b.Status == "running")
1107 }
1108 if stands(built) {
1097 continue 1109 continue
1098 } 1110 }
1099 if prev, ok, _ := st.SuccessBuildForTree(repo.ID, tree, j.Name, j.Image); ok && prev.SHA != sha { 1111 if prev, ok, _ := st.SuccessBuildForTree(repo.ID, tree, ref, j.Name, j.Image); ok && prev.SHA != sha {
1100 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), prev.Number) 1112 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), prev.Number)
1101 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "success", 1113 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "success",
1102 fmt.Sprintf("passed in build %d as %.10s, same tree", prev.Number, prev.SHA), url, userID) 1114 fmt.Sprintf("passed in build %d as %.10s, same tree", prev.Number, prev.SHA), url, userID)
@@ -1117,7 +1129,9 @@ func queueJobs(
1117 // require_checks with a skipped status instead of leaving the 1129 // require_checks with a skipped status instead of leaving the
1118 // commit with none at all, which the gate refuses outright (#172). 1130 // commit with none at all, which the gate refuses outright (#172).
1119 if filtered && !ci.Selected(j, changed) { 1131 if filtered && !ci.Selected(j, changed) {
1120 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "skipped", skipReason(j, changed), "", userID) 1132 if !stands(elsewhere) {
1133 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "skipped", skipReason(j, changed), "", userID)
1134 }
1121 continue 1135 continue
1122 } 1136 }
1123 steps, _ := json.Marshal(j.Steps) 1137 steps, _ := json.Marshal(j.Steps)
internal/hookd/pushshapes_test.go +14 −1
@@ -330,7 +330,20 @@ var pushShapes = []pushShape{
330 f.git(f.src, "merge", "-q", "--ff-only", "feat") 330 f.git(f.src, "merge", "-q", "--ff-only", "feat")
331 f.mark(c1) 331 f.mark(c1)
332 f.push("main", f.base, c1) 332 f.push("main", f.base, c1)
333 }, []string{"—", "—", "—", "registered", "—", "—"}}, 333 }, []string{"queued", "queued", "—", "registered", "—", "—"}},
334
335 {"squash merge onto the default branch, same tree as a branch build", func(f *shapeFixture) {
336 f.git(f.src, "checkout", "-q", "-b", "feat")
337 c1 := f.appCommit("more")
338 f.push("feat", zeroSHA40, c1)
339 f.finish("success")
340 f.git(f.src, "checkout", "-q", "main")
341 f.git(f.src, "merge", "-q", "--squash", "feat")
342 f.git(f.src, "commit", "-q", "-m", "squashed")
343 c2 := f.sha("HEAD")
344 f.mark(c2)
345 f.push("main", f.base, c2)
346 }, []string{"queued", "queued", "skipped", "registered", "—", "—"}},
334 347
335 {"a commit whose earlier build failed, on a new branch", func(f *shapeFixture) { 348 {"a commit whose earlier build failed, on a new branch", func(f *shapeFixture) {
336 f.git(f.src, "checkout", "-q", "-b", "feat") 349 f.git(f.src, "checkout", "-q", "-b", "feat")
internal/store/builds.go +18 −5
@@ -437,7 +437,18 @@ func (s *Store) LatestBuild(repoID int64, job string) (Build, error) {
437// request's checks are ci/<job> statuses; this is where their timing comes 437// request's checks are ci/<job> statuses; this is where their timing comes
438// from, in one query rather than one per check. 438// from, in one query rather than one per check.
439func (s *Store) BuildsForCommit(repoID int64, sha string) (map[string]Build, error) { 439func (s *Store) BuildsForCommit(repoID int64, sha string) (map[string]Build, error) {
440 rows, err := s.DB.Query(buildSelect+" WHERE repo_id = ? AND sha = ? ORDER BY number ASC", repoID, sha) 440 return s.newestPerJob(" WHERE repo_id = ? AND sha = ? ORDER BY number ASC", repoID, sha)
441}
442
443// BuildsForCommitOnRef returns the newest build per job for one commit
444// made for one ref. A job sees GITBAY_REF, so a build for another ref
445// says nothing about what the job does on this one.
446func (s *Store) BuildsForCommitOnRef(repoID int64, sha, ref string) (map[string]Build, error) {
447 return s.newestPerJob(" WHERE repo_id = ? AND sha = ? AND ref = ? ORDER BY number ASC", repoID, sha, ref)
448}
449
450func (s *Store) newestPerJob(where string, args ...any) (map[string]Build, error) {
451 rows, err := s.DB.Query(buildSelect+where, args...)
441 if err != nil { 452 if err != nil {
442 return nil, err 453 return nil, err
443 } 454 }
@@ -490,18 +501,20 @@ func (s *Store) CancelBuild(id int64) error {
490 501
491// SuccessBuildForTree finds a passed build of the job for a tree rather 502// SuccessBuildForTree finds a passed build of the job for a tree rather
492// than a commit: a rebase that changes nothing in the tree has already 503// than a commit: a rebase that changes nothing in the tree has already
493// been built (#177). Only a trusted build on the image the job names 504// been built (#177). Only a build for the same ref counts, since a job
505// sees GITBAY_REF and may do something else on another one. Only a
506// trusted build on the image the job names
494// counts: a fork's result, or one from an image the job has left, does 507// counts: a fork's result, or one from an image the job has left, does
495// not stand for the repository's own (#258). A job naming no image 508// not stand for the repository's own (#258). A job naming no image
496// matches builds that named none, whichever default the runner used; 509// matches builds that named none, whichever default the runner used;
497// the CI wiki page says so. An empty tree never matches. 510// the CI wiki page says so. An empty tree never matches.
498func (s *Store) SuccessBuildForTree(repoID int64, tree, job, image string) (Build, bool, error) { 511func (s *Store) SuccessBuildForTree(repoID int64, tree, ref, job, image string) (Build, bool, error) {
499 if tree == "" { 512 if tree == "" {
500 return Build{}, false, nil 513 return Build{}, false, nil
501 } 514 }
502 b, err := scanBuild(s.DB.QueryRow(buildSelect+ 515 b, err := scanBuild(s.DB.QueryRow(buildSelect+
503 " WHERE repo_id = ? AND tree = ? AND job = ? AND image = ? AND trusted = 1 AND status = 'success'"+ 516 " WHERE repo_id = ? AND tree = ? AND ref = ? AND job = ? AND image = ? AND trusted = 1 AND status = 'success'"+
504 " ORDER BY number DESC LIMIT 1", repoID, tree, job, image)) 517 " ORDER BY number DESC LIMIT 1", repoID, tree, ref, job, image))
505 if errors.Is(err, sql.ErrNoRows) { 518 if errors.Is(err, sql.ErrNoRows) {
506 return Build{}, false, nil 519 return Build{}, false, nil
507 } 520 }
internal/store/builds_test.go +11 −7
@@ -214,8 +214,9 @@ func TestBuildLogSaysWhenItTruncates(t *testing.T) {
214 } 214 }
215} 215}
216 216
217// A success is found by tree across commits; an empty tree never matches, 217// A success is found by tree across commits on one ref; an empty tree
218// so builds queued without one (scheduled, tag) are never reused (#177). 218// never matches, so builds queued without one (scheduled, tag) are never
219// reused (#177).
219func TestSuccessBuildForTree(t *testing.T) { 220func TestSuccessBuildForTree(t *testing.T) {
220 s := open(t) 221 s := open(t)
221 if err := s.MigrateUp(); err != nil { 222 if err := s.MigrateUp(); err != nil {
@@ -239,13 +240,16 @@ func TestSuccessBuildForTree(t *testing.T) {
239 if err := s.FinishBuild(b["unit"].ID, "success"); err != nil { 240 if err := s.FinishBuild(b["unit"].ID, "success"); err != nil {
240 t.Fatal(err) 241 t.Fatal(err)
241 } 242 }
242 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "unit", ""); !ok || prev.SHA != "aaa" { 243 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "main", "unit", ""); !ok || prev.SHA != "aaa" {
243 t.Fatalf("success not found by tree: ok=%v prev=%+v", ok, prev) 244 t.Fatalf("success not found by tree: ok=%v prev=%+v", ok, prev)
244 } 245 }
245 if _, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "other", ""); ok { 246 if _, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "main", "other", ""); ok {
246 t.Error("matched a different job") 247 t.Error("matched a different job")
247 } 248 }
248 if _, ok, _ := s.SuccessBuildForTree(repoID, "", "unit", ""); ok { 249 if _, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "feat", "unit", ""); ok {
250 t.Error("matched a build for another ref")
251 }
252 if _, ok, _ := s.SuccessBuildForTree(repoID, "", "main", "unit", ""); ok {
249 t.Error("an empty tree matched") 253 t.Error("an empty tree matched")
250 } 254 }
251} 255}
@@ -278,10 +282,10 @@ func TestSuccessReuseNeedsTrustAndImage(t *testing.T) {
278 t.Fatal(err) 282 t.Fatal(err)
279 } 283 }
280 } 284 }
281 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "unit", ""); ok { 285 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "main", "unit", ""); ok {
282 t.Fatalf("reused build %d: untrusted, or on another image", prev.Number) 286 t.Fatalf("reused build %d: untrusted, or on another image", prev.Number)
283 } 287 }
284 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "unit", "localhost/old:1"); !ok || prev.SHA != "bbb" { 288 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "main", "unit", "localhost/old:1"); !ok || prev.SHA != "bbb" {
285 t.Fatalf("trusted build on the same image not found: ok=%v prev=%+v", ok, prev) 289 t.Fatalf("trusted build on the same image not found: ok=%v prev=%+v", ok, prev)
286 } 290 }
287 if _, ok, _ := s.SuccessBuildFor(repoID, "aaa", "unit"); ok { 291 if _, ok, _ := s.SuccessBuildFor(repoID, "aaa", "unit"); ok {