Commit bb5dfd7d6c
Verified · cmc ci/build: success
e2e/ci_test.go +44
| @@ -170,6 +170,50 @@ func TestCI(t *testing.T) { | ||
| 170 | 170 | t.Fatal("secret remove failed") |
| 171 | 171 | } |
| 172 | 172 | |
| 173 | // --- tag-triggered jobs --- | |
| 174 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte( | |
| 175 | "jobs:\n test:\n steps:\n - echo branch build\n publish:\n tags: \"v*\"\n steps:\n - echo publishing $GITBAY_REF\n"), 0o644) | |
| 176 | mustGit(t, dir, env, "add", ".") | |
| 177 | mustGit(t, dir, env, "commit", "-q", "-m", "tag job") | |
| 178 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 179 | // The branch push queued only the branch job. | |
| 180 | out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app") | |
| 181 | if strings.Contains(out, "publish") { | |
| 182 | t.Fatalf("tag job queued on branch push:\n%s", out) | |
| 183 | } | |
| 184 | // An annotated tag queues the tag job, with the peeled commit as sha. | |
| 185 | mustGit(t, dir, env, "tag", "-a", "-m", "rel", "v1.0.0") | |
| 186 | mustGit(t, dir, env, "push", "-q", "origin", "v1.0.0") | |
| 187 | headSHA := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) | |
| 188 | out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app") | |
| 189 | if !strings.Contains(out, "publish\tpending\t"+headSHA[:10]) || !strings.Contains(out, "v1.0.0") { | |
| 190 | t.Fatalf("tag build missing or unpeeled:\n%s", out) | |
| 191 | } | |
| 192 | // A non-matching tag queues nothing. | |
| 193 | mustGit(t, dir, env, "tag", "nightly-1") | |
| 194 | mustGit(t, dir, env, "push", "-q", "origin", "nightly-1") | |
| 195 | out2, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app") | |
| 196 | if strings.Count(out2, "publish") != strings.Count(out, "publish") { | |
| 197 | t.Fatalf("non-matching tag queued a build:\n%s", out2) | |
| 198 | } | |
| 199 | inst.runnerOnce(t, runnerKey) // branch "test" job | |
| 200 | inst.runnerOnce(t, runnerKey) // tag "publish" job | |
| 201 | out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app") | |
| 202 | if !strings.Contains(out, "publish\tsuccess") { | |
| 203 | t.Fatalf("tag build did not run:\n%s", out) | |
| 204 | } | |
| 205 | // schedule and tags together are refused. | |
| 206 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte( | |
| 207 | "jobs:\n both:\n schedule: \"0 6 * * *\"\n tags: \"v*\"\n steps: [echo x]\n"), 0o644) | |
| 208 | mustGit(t, dir, env, "add", ".") | |
| 209 | mustGit(t, dir, env, "commit", "-q", "-m", "both triggers") | |
| 210 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 211 | shaBoth := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) | |
| 212 | out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", shaBoth) | |
| 213 | if !strings.Contains(out, "ci/config") || !strings.Contains(out, "failure") { | |
| 214 | t.Fatalf("mutually exclusive triggers not refused:\n%s", out) | |
| 215 | } | |
| 216 | ||
| 173 | 217 | // A broken ci.yml surfaces as a failed ci/config status. |
| 174 | 218 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs: {bad name: {steps: [x]}}\n"), 0o644) |
| 175 | 219 | mustGit(t, dir, env, "add", ".") |
internal/ci/ci.go +12 −1
| @@ -11,6 +11,7 @@ package ci | ||
| 11 | 11 | |
| 12 | 12 | import ( |
| 13 | 13 | "fmt" |
| 14 | "path" | |
| 14 | 15 | "regexp" |
| 15 | 16 | "sort" |
| 16 | 17 | |
| @@ -32,6 +33,7 @@ type Job struct { | ||
| 32 | 33 | Name string |
| 33 | 34 | Steps []string |
| 34 | 35 | Schedule string // cron expression; scheduled jobs run on schedule, not on push |
| 36 | Tags string // tag glob (e.g. "v*"); tag jobs run on matching tag pushes only | |
| 35 | 37 | } |
| 36 | 38 | |
| 37 | 39 | // Parse returns the jobs in name order, or an error describing the first |
| @@ -41,6 +43,7 @@ func Parse(raw []byte) ([]Job, error) { | ||
| 41 | 43 | Jobs map[string]struct { |
| 42 | 44 | Steps []string `yaml:"steps"` |
| 43 | 45 | Schedule string `yaml:"schedule"` |
| 46 | Tags string `yaml:"tags"` | |
| 44 | 47 | } `yaml:"jobs"` |
| 45 | 48 | } |
| 46 | 49 | if err := yaml.Unmarshal(raw, &doc); err != nil { |
| @@ -73,7 +76,15 @@ func Parse(raw []byte) ([]Job, error) { | ||
| 73 | 76 | return nil, fmt.Errorf("job %q: %v", name, err) |
| 74 | 77 | } |
| 75 | 78 | } |
| 76 | jobs = append(jobs, Job{Name: name, Steps: j.Steps, Schedule: j.Schedule}) | |
| 79 | if j.Tags != "" { | |
| 80 | if _, err := path.Match(j.Tags, "x"); err != nil { | |
| 81 | return nil, fmt.Errorf("job %q: bad tag pattern %q", name, j.Tags) | |
| 82 | } | |
| 83 | if j.Schedule != "" { | |
| 84 | return nil, fmt.Errorf("job %q: schedule and tags are mutually exclusive", name) | |
| 85 | } | |
| 86 | } | |
| 87 | jobs = append(jobs, Job{Name: name, Steps: j.Steps, Schedule: j.Schedule, Tags: j.Tags}) | |
| 77 | 88 | } |
| 78 | 89 | sort.Slice(jobs, func(i, k int) bool { return jobs[i].Name < jobs[k].Name }) |
| 79 | 90 | return jobs, nil |
internal/gitutil/gitutil.go +10
| @@ -100,6 +100,16 @@ func RevList(dir, ref string, limit int) ([]string, error) { | ||
| 100 | 100 | return shas, nil |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | // PeelToCommit resolves a ref or object to its commit — annotated tags | |
| 104 | // peel to the commit they point at. | |
| 105 | func PeelToCommit(dir, ref string) (string, error) { | |
| 106 | out, err := exec.Command("git", "-C", dir, "rev-parse", ref+"^{commit}").Output() | |
| 107 | if err != nil { | |
| 108 | return "", fmt.Errorf("rev-parse %s^{commit}: %w", ref, err) | |
| 109 | } | |
| 110 | return strings.TrimSpace(string(out)), nil | |
| 111 | } | |
| 112 | ||
| 103 | 113 | // ReadCommit returns the raw commit object bytes. |
| 104 | 114 | func ReadCommit(dir, sha string) ([]byte, error) { |
| 105 | 115 | cmd := exec.Command("git", "-C", dir, "cat-file", "commit", sha) |
internal/hookd/hookd.go +49 −2
| @@ -15,7 +15,9 @@ import ( | ||
| 15 | 15 | "log/slog" |
| 16 | 16 | "net" |
| 17 | 17 | "os" |
| 18 | "path" | |
| 18 | 19 | "path/filepath" |
| 20 | "strings" | |
| 19 | 21 | "time" |
| 20 | 22 | |
| 21 | 23 | "gitbay.org/gitbay/internal/ci" |
| @@ -175,6 +177,14 @@ func (s *Server) postReceive(req Request) { | ||
| 175 | 177 | `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`, |
| 176 | 178 | u.Ref, u.Old, u.New, u.IsForce, u.IsDelete)) |
| 177 | 179 | |
| 180 | // Any ref update — branch or tag — schedules the push mirrors. | |
| 181 | s.st.MarkMirrorsDirty(req.RepoID, "push") | |
| 182 | ||
| 183 | // Tag pushes run the tag-triggered CI jobs. | |
| 184 | if tag, ok := strings.CutPrefix(u.Ref, "refs/tags/"); ok && !u.IsDelete && pushedRepoErr == nil { | |
| 185 | s.queueTagBuilds(pushedRepo, req.UserID, tag, u.New) | |
| 186 | } | |
| 187 | ||
| 178 | 188 | branch, ok := cutHeads(u.Ref) |
| 179 | 189 | if !ok { |
| 180 | 190 | continue |
| @@ -190,8 +200,6 @@ func (s *Server) postReceive(req Request) { | ||
| 190 | 200 | if pushedRepoErr == nil && !u.IsDelete { |
| 191 | 201 | s.queueBuilds(pushedRepo, req.UserID, branch, u.New) |
| 192 | 202 | } |
| 193 | // Any branch/tag update schedules the push mirrors. | |
| 194 | s.st.MarkMirrorsDirty(req.RepoID, "push") | |
| 195 | 203 | if u.IsForce { |
| 196 | 204 | s.st.Audit(req.UserID, "push.forced", map[string]any{ |
| 197 | 205 | "repo": req.RepoID, "ref": u.Ref, "old": u.Old, "new": u.New}) |
| @@ -250,6 +258,10 @@ func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string) | ||
| 250 | 258 | now := time.Now() |
| 251 | 259 | var schedules []store.Schedule |
| 252 | 260 | for _, j := range jobs { |
| 261 | // Tag jobs run on matching tag pushes only. | |
| 262 | if j.Tags != "" { | |
| 263 | continue | |
| 264 | } | |
| 253 | 265 | // Scheduled jobs run on their cron, not on push; a default-branch |
| 254 | 266 | // push (re)registers them. |
| 255 | 267 | if j.Schedule != "" { |
| @@ -277,6 +289,41 @@ func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string) | ||
| 277 | 289 | } |
| 278 | 290 | } |
| 279 | 291 | |
| 292 | // queueTagBuilds runs the jobs whose tag pattern matches a pushed tag. | |
| 293 | // The build records the tag as its ref and the peeled commit as its sha, | |
| 294 | // so statuses land on the commit, not an annotated tag object. | |
| 295 | func (s *Server) queueTagBuilds(repo store.Repo, userID int64, tag, pushed string) { | |
| 296 | dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 297 | sha, err := gitutil.PeelToCommit(dir, pushed) | |
| 298 | if err != nil { | |
| 299 | return | |
| 300 | } | |
| 301 | raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16) | |
| 302 | if err != nil { | |
| 303 | return | |
| 304 | } | |
| 305 | jobs, err := ci.Parse(raw) | |
| 306 | if err != nil { | |
| 307 | return // the branch push already reported ci/config | |
| 308 | } | |
| 309 | for _, j := range jobs { | |
| 310 | if j.Tags == "" { | |
| 311 | continue | |
| 312 | } | |
| 313 | if ok, _ := path.Match(j.Tags, tag); !ok { | |
| 314 | continue | |
| 315 | } | |
| 316 | steps, _ := json.Marshal(j.Steps) | |
| 317 | n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps)) | |
| 318 | if err != nil { | |
| 319 | slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err) | |
| 320 | continue | |
| 321 | } | |
| 322 | url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n) | |
| 323 | s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "tag "+tag, url, userID) | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| 280 | 327 | func cutHeads(ref string) (string, bool) { |
| 281 | 328 | const p = "refs/heads/" |
| 282 | 329 | if len(ref) > len(p) && ref[:len(p)] == p { |