Commit b1f4bf1aaf

b1f4bf1aaf238771f6213b3af624c4bb20a70ec1

parent: b022fedfc0

Verified · cmc

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

ci: reuse only trusted results on the job's image

Ref #258

Layout: unified · split

internal/control/build.go +7 −2
@@ -857,10 +857,15 @@ func queueJobs(
857 if j.Tags != "" { 857 if j.Tags != "" {
858 continue 858 continue
859 } 859 }
860 if b, ok := built[j.Name]; ok && (b.Status == "success" || b.Status == "pending" || b.Status == "running") { 860 // A build of this commit that passed, or is queued or running,
861 // stands for it — unless this queue is trusted and that build was
862 // not: a fork's head that lands on a branch is built again as the
863 // repository's own (#258).
864 if b, ok := built[j.Name]; ok && (b.Trusted || !trusted) &&
865 (b.Status == "success" || b.Status == "pending" || b.Status == "running") {
861 continue 866 continue
862 } 867 }
863 if prev, ok, _ := st.SuccessBuildForTree(repo.ID, tree, j.Name); ok && prev.SHA != sha { 868 if prev, ok, _ := st.SuccessBuildForTree(repo.ID, tree, j.Name, j.Image); ok && prev.SHA != sha {
864 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), prev.Number) 869 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), prev.Number)
865 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "success", 870 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "success",
866 fmt.Sprintf("passed in build %d as %.10s, same tree", prev.Number, prev.SHA), url, userID) 871 fmt.Sprintf("passed in build %d as %.10s, same tree", prev.Number, prev.SHA), url, userID)
internal/control/build_test.go +47
@@ -669,3 +669,50 @@ func TestBuildListFlagsFilter(t *testing.T) {
669 t.Fatalf("bad --status error does not name the valid states: %s", errOut.String()) 669 t.Fatalf("bad --status error does not name the valid states: %s", errOut.String())
670 } 670 }
671} 671}
672
673// A fork's green build of a commit does not stand for the repository's
674// own: the same commit landing on a branch, or a commit with the same
675// tree, is built again as trusted (#258).
676func TestQueueBranchBuildsRebuildsWhatOnlyAForkBuilt(t *testing.T) {
677 st, repo, uid := newQueueTestRepo(t)
678 git := gitRunner(t)
679 root := t.TempDir()
680
681 src := filepath.Join(root, "src")
682 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
683 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
684 "jobs:\n unit:\n steps:\n - echo hi\n"), 0o644)
685 git(root, "init", "-q", "-b", "main", "src")
686 git(src, "add", ".")
687 git(src, "commit", "-q", "-m", "base")
688 first := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
689 git(src, "commit", "-q", "--allow-empty", "-m", "same tree")
690 second := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
691
692 dir := RepoDir(root, repo.OwnerName, repo.Name)
693 os.MkdirAll(filepath.Dir(dir), 0o755)
694 git(root, "clone", "-q", "--bare", src, dir)
695
696 // A fork's merge request head, built untrusted and green.
697 QueueMRBuilds(st, root, "https://x.test", repo, uid, 1, first)
698 b, ok, err := st.ClaimBuild([]int64{repo.ID}, true)
699 if err != nil || !ok || b.Trusted {
700 t.Fatalf("claim: ok=%v trusted=%v err=%v", ok, b.Trusted, err)
701 }
702 if err := st.FinishBuild(b.ID, "success"); err != nil {
703 t.Fatal(err)
704 }
705
706 // The same commit lands on main, then a commit with the same tree.
707 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", "", first, time.Now())
708 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", first, second, time.Now())
709 pending, _ := st.ListBuilds(repo.ID, store.BuildFilter{Status: "pending"}, 10)
710 if len(pending) != 2 {
711 t.Fatalf("queued %d builds, want 2 (one per commit): %+v", len(pending), pending)
712 }
713 for _, p := range pending {
714 if !p.Trusted {
715 t.Errorf("build %d queued untrusted on a branch push", p.Number)
716 }
717 }
718}
internal/store/builds.go +13 −8
@@ -450,26 +450,31 @@ func (s *Store) CancelBuild(id int64) error {
450 return nil 450 return nil
451} 451}
452 452
453// SuccessBuildFor finds a passed build of the commit for the job, on any 453// SuccessBuildForTree finds a passed build of the job for a tree rather
454// ref: what a cancelled duplicate can point back at. 454// than a commit: a rebase that changes nothing in the tree has already
455// SuccessBuildForTree is SuccessBuildFor keyed by tree rather than 455// been built (#177). Only a trusted build on the image the job names
456// commit: a rebase that changes nothing in the tree has already been 456// counts: a fork's result, or one from an image the job has left, does
457// built (#177). An empty tree never matches. 457// not stand for the repository's own (#258). A job naming no image
458func (s *Store) SuccessBuildForTree(repoID int64, tree, job string) (Build, bool, error) { 458// matches builds that named none, whichever default the runner used;
459// the CI wiki page says so. An empty tree never matches.
460func (s *Store) SuccessBuildForTree(repoID int64, tree, job, image string) (Build, bool, error) {
459 if tree == "" { 461 if tree == "" {
460 return Build{}, false, nil 462 return Build{}, false, nil
461 } 463 }
462 b, err := scanBuild(s.DB.QueryRow(buildSelect+ 464 b, err := scanBuild(s.DB.QueryRow(buildSelect+
463 " WHERE repo_id = ? AND tree = ? AND job = ? AND status = 'success' ORDER BY number DESC LIMIT 1", repoID, tree, job)) 465 " WHERE repo_id = ? AND tree = ? AND job = ? AND image = ? AND trusted = 1 AND status = 'success'"+
466 " ORDER BY number DESC LIMIT 1", repoID, tree, job, image))
464 if errors.Is(err, sql.ErrNoRows) { 467 if errors.Is(err, sql.ErrNoRows) {
465 return Build{}, false, nil 468 return Build{}, false, nil
466 } 469 }
467 return b, err == nil, err 470 return b, err == nil, err
468} 471}
469 472
473// SuccessBuildFor finds a passed trusted build of the commit for the job,
474// on any ref: what a cancelled duplicate can point back at.
470func (s *Store) SuccessBuildFor(repoID int64, sha, job string) (Build, bool, error) { 475func (s *Store) SuccessBuildFor(repoID int64, sha, job string) (Build, bool, error) {
471 b, err := scanBuild(s.DB.QueryRow(buildSelect+ 476 b, err := scanBuild(s.DB.QueryRow(buildSelect+
472 " WHERE repo_id = ? AND sha = ? AND job = ? AND status = 'success' ORDER BY number DESC LIMIT 1", repoID, sha, job)) 477 " WHERE repo_id = ? AND sha = ? AND job = ? AND trusted = 1 AND status = 'success' ORDER BY number DESC LIMIT 1", repoID, sha, job))
473 if errors.Is(err, sql.ErrNoRows) { 478 if errors.Is(err, sql.ErrNoRows) {
474 return Build{}, false, nil 479 return Build{}, false, nil
475 } 480 }
internal/store/builds_test.go +42 −3
@@ -238,17 +238,56 @@ func TestSuccessBuildForTree(t *testing.T) {
238 if err := s.FinishBuild(b["unit"].ID, "success"); err != nil { 238 if err := s.FinishBuild(b["unit"].ID, "success"); err != nil {
239 t.Fatal(err) 239 t.Fatal(err)
240 } 240 }
241 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "unit"); !ok || prev.SHA != "aaa" { 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) 242 t.Fatalf("success not found by tree: ok=%v prev=%+v", ok, prev)
243 } 243 }
244 if _, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "other"); ok { 244 if _, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "other", ""); ok {
245 t.Error("matched a different job") 245 t.Error("matched a different job")
246 } 246 }
247 if _, ok, _ := s.SuccessBuildForTree(repoID, "", "unit"); ok { 247 if _, ok, _ := s.SuccessBuildForTree(repoID, "", "unit", ""); ok {
248 t.Error("an empty tree matched") 248 t.Error("an empty tree matched")
249 } 249 }
250} 250}
251 251
252// A result stands for another commit only when it came from a trusted
253// build on the same image: a fork's green build, or one on an image the
254// job has since left, proves nothing about the repository's own (#258).
255func TestSuccessReuseNeedsTrustAndImage(t *testing.T) {
256 s := open(t)
257 if err := s.MigrateUp(); err != nil {
258 t.Fatal(err)
259 }
260 uid, _ := s.CreateUser("cmc", true)
261 repoID, _ := s.CreateRepo("user", uid, "app", "public")
262 for _, b := range []struct {
263 sha, image string
264 trusted bool
265 }{
266 {"aaa", "", false},
267 {"bbb", "localhost/old:1", true},
268 } {
269 if _, err := s.CreateBuild(repoID, "unit", b.sha, "main", `["true"]`, b.image, "tree1", b.trusted); err != nil {
270 t.Fatal(err)
271 }
272 claimed, ok, err := s.ClaimBuild([]int64{repoID}, true)
273 if err != nil || !ok {
274 t.Fatalf("claim: ok=%v err=%v", ok, err)
275 }
276 if err := s.FinishBuild(claimed.ID, "success"); err != nil {
277 t.Fatal(err)
278 }
279 }
280 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "unit", ""); ok {
281 t.Fatalf("reused build %d: untrusted, or on another image", prev.Number)
282 }
283 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "unit", "localhost/old:1"); !ok || prev.SHA != "bbb" {
284 t.Fatalf("trusted build on the same image not found: ok=%v prev=%+v", ok, prev)
285 }
286 if _, ok, _ := s.SuccessBuildFor(repoID, "aaa", "unit"); ok {
287 t.Error("an untrusted success stood for its commit")
288 }
289}
290
252// A running build whose log stream ended is reaped after StaleLogGrace, 291// A running build whose log stream ended is reaped after StaleLogGrace,
253// well before the deadline; one whose stream is still open is not (#179). 292// well before the deadline; one whose stream is still open is not (#179).
254func TestReapStaleBuildsAfterLogClosed(t *testing.T) { 293func TestReapStaleBuildsAfterLogClosed(t *testing.T) {