Tag-push CI triggers !52

merged merged by cmc on 2026-08-25 18:54 UTC · krz/gitbay:tag-triggers into main

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
12import ( 12import (
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.
105func 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.
104func ReadCommit(dir, sha string) ([]byte, error) { 114func 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.
295func (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
280func cutHeads(ref string) (string, bool) { 327func 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 {