Commit f3c562dedb
Verified · cmc ci/build: success ci/test: failure
Layout: unified · split
.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org +1 −1
| @@ -23,7 +23,7 @@ commit instead of failing silently. | |||
| 23 | 1. *Queue.* The post-receive hook calls =queueJobs= | 23 | 1. *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=. |
| 29 | 2. *Claim.* A runner calls =runner next= over SSH | 29 | 2. *Claim.* A runner calls =runner next= over SSH |
.gitbay/wiki/CI.org +15 −8
| @@ -2,10 +2,13 @@ | |||
| 2 | 2 | ||
| 3 | Three mechanisms decide what a push does to CI, and they interact: | 3 | Three 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. |
| 439 | func (s *Store) BuildsForCommit(repoID int64, sha string) (map[string]Build, error) { | 439 | func (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. | ||
| 446 | func (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 | |||
| 450 | func (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. |
| 498 | func (s *Store) SuccessBuildForTree(repoID int64, tree, job, image string) (Build, bool, error) { | 511 | func (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). | ||
| 219 | func TestSuccessBuildForTree(t *testing.T) { | 220 | func 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 { |