Commit 387b381242
Verified · cmc ci/build: success ci/test: success
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 |
| 97 | drive the rail. Read access is all a bookmark needs. | 97 | drive the rail. Read access is all a bookmark needs. |
| 98 | 98 | ||
| 99 | A job's result is a property of the commit's *tree*, not its sha: a | ||
| 100 | rebase onto a base that touched nothing the branch did gives every | ||
| 101 | commit a new sha and the same tree, and a job that already passed for | ||
| 102 | that tree is not run again — the new commit gets the earlier result as | ||
| 103 | its =ci/<job>= status, naming the build it came from. Scheduled and tag | ||
| 104 | jobs are never reused this way; their trigger is the clock or the tag. | ||
| 105 | |||
| 99 | * Profiles | 106 | * Profiles |
| 100 | 107 | ||
| 101 | A profile is what =/{owner}= shows: a one-line description, a website, a | 108 | A 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). | ||
| 556 | func 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 | |||
| 60 | func TestRunnerNextSkipsOrphanedBuildAndClaimsNext(t *testing.T) { | 60 | func 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. |
| 114 | func TestRunnerNextClaimsReachableBuildNormally(t *testing.T) { | 114 | func 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. |
| 42 | func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON, image string, trusted bool) (int64, error) { | 43 | func (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 | ||
| 63 | const buildSelect = ` | 64 | const 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 | ||
| 67 | func scanBuild(row interface{ Scan(...any) error }) (Build, error) { | 68 | func 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. | ||
| 322 | func (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 | |||
| 318 | func (s *Store) SuccessBuildFor(repoID int64, sha, job string) (Build, bool, error) { | 334 | func (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). | ||
| 218 | func 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 @@ | |||
| 1 | DROP INDEX builds_by_tree; | ||
| 2 | ALTER 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. | ||
| 5 | ALTER TABLE builds ADD COLUMN tree TEXT NOT NULL DEFAULT ''; | ||
| 6 | CREATE 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', |