Tag-push CI triggers !52
4 files changed, +115 −3
Layout: unified · split
e2e/ci_test.go +44
| @@ -170,6 +170,50 @@ func TestCI(t *testing.T) { | |||
| 170 | t.Fatal("secret remove failed") | 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 | // A broken ci.yml surfaces as a failed ci/config status. | 217 | // A broken ci.yml surfaces as a failed ci/config status. |
| 174 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs: {bad name: {steps: [x]}}\n"), 0o644) | 218 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs: {bad name: {steps: [x]}}\n"), 0o644) |
| 175 | mustGit(t, dir, env, "add", ".") | 219 | mustGit(t, dir, env, "add", ".") |
internal/ci/ci.go +12 −1
| @@ -11,6 +11,7 @@ package ci | |||
| 11 | 11 | ||
| 12 | import ( | 12 | import ( |
| 13 | "fmt" | 13 | "fmt" |
| 14 | "path" | ||
| 14 | "regexp" | 15 | "regexp" |
| 15 | "sort" | 16 | "sort" |
| 16 | 17 | ||
| @@ -32,6 +33,7 @@ type Job struct { | |||
| 32 | Name string | 33 | Name string |
| 33 | Steps []string | 34 | Steps []string |
| 34 | Schedule string // cron expression; scheduled jobs run on schedule, not on push | 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 | // Parse returns the jobs in name order, or an error describing the first | 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 | Jobs map[string]struct { | 43 | Jobs map[string]struct { |
| 42 | Steps []string `yaml:"steps"` | 44 | Steps []string `yaml:"steps"` |
| 43 | Schedule string `yaml:"schedule"` | 45 | Schedule string `yaml:"schedule"` |
| 46 | Tags string `yaml:"tags"` | ||
| 44 | } `yaml:"jobs"` | 47 | } `yaml:"jobs"` |
| 45 | } | 48 | } |
| 46 | if err := yaml.Unmarshal(raw, &doc); err != nil { | 49 | if err := yaml.Unmarshal(raw, &doc); err != nil { |
| @@ -73,7 +76,15 @@ func Parse(raw []byte) ([]Job, error) { | |||
| 73 | return nil, fmt.Errorf("job %q: %v", name, err) | 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 | sort.Slice(jobs, func(i, k int) bool { return jobs[i].Name < jobs[k].Name }) | 89 | sort.Slice(jobs, func(i, k int) bool { return jobs[i].Name < jobs[k].Name }) |
| 79 | return jobs, nil | 90 | return jobs, nil |
internal/gitutil/gitutil.go +10
| @@ -100,6 +100,16 @@ func RevList(dir, ref string, limit int) ([]string, error) { | |||
| 100 | return shas, nil | 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 | // ReadCommit returns the raw commit object bytes. | 113 | // ReadCommit returns the raw commit object bytes. |
| 104 | func ReadCommit(dir, sha string) ([]byte, error) { | 114 | func ReadCommit(dir, sha string) ([]byte, error) { |
| 105 | cmd := exec.Command("git", "-C", dir, "cat-file", "commit", sha) | 115 | cmd := exec.Command("git", "-C", dir, "cat-file", "commit", sha) |
internal/hookd/hookd.go +49 −2
| @@ -15,7 +15,9 @@ import ( | |||
| 15 | "log/slog" | 15 | "log/slog" |
| 16 | "net" | 16 | "net" |
| 17 | "os" | 17 | "os" |
| 18 | "path" | ||
| 18 | "path/filepath" | 19 | "path/filepath" |
| 20 | "strings" | ||
| 19 | "time" | 21 | "time" |
| 20 | 22 | ||
| 21 | "gitbay.org/gitbay/internal/ci" | 23 | "gitbay.org/gitbay/internal/ci" |
| @@ -175,6 +177,14 @@ func (s *Server) postReceive(req Request) { | |||
| 175 | `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`, | 177 | `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`, |
| 176 | u.Ref, u.Old, u.New, u.IsForce, u.IsDelete)) | 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 | branch, ok := cutHeads(u.Ref) | 188 | branch, ok := cutHeads(u.Ref) |
| 179 | if !ok { | 189 | if !ok { |
| 180 | continue | 190 | continue |
| @@ -190,8 +200,6 @@ func (s *Server) postReceive(req Request) { | |||
| 190 | if pushedRepoErr == nil && !u.IsDelete { | 200 | if pushedRepoErr == nil && !u.IsDelete { |
| 191 | s.queueBuilds(pushedRepo, req.UserID, branch, u.New) | 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 | if u.IsForce { | 203 | if u.IsForce { |
| 196 | s.st.Audit(req.UserID, "push.forced", map[string]any{ | 204 | s.st.Audit(req.UserID, "push.forced", map[string]any{ |
| 197 | "repo": req.RepoID, "ref": u.Ref, "old": u.Old, "new": u.New}) | 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 | now := time.Now() | 258 | now := time.Now() |
| 251 | var schedules []store.Schedule | 259 | var schedules []store.Schedule |
| 252 | for _, j := range jobs { | 260 | for _, j := range jobs { |
| 261 | // Tag jobs run on matching tag pushes only. | ||
| 262 | if j.Tags != "" { | ||
| 263 | continue | ||
| 264 | } | ||
| 253 | // Scheduled jobs run on their cron, not on push; a default-branch | 265 | // Scheduled jobs run on their cron, not on push; a default-branch |
| 254 | // push (re)registers them. | 266 | // push (re)registers them. |
| 255 | if j.Schedule != "" { | 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 | func cutHeads(ref string) (string, bool) { | 327 | func cutHeads(ref string) (string, bool) { |
| 281 | const p = "refs/heads/" | 328 | const p = "refs/heads/" |
| 282 | if len(ref) > len(p) && ref[:len(p)] == p { | 329 | if len(ref) > len(p) && ref[:len(p)] == p { |