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) {
170170 t.Fatal("secret remove failed")
171171 }
172172
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
173217 // A broken ci.yml surfaces as a failed ci/config status.
174218 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs: {bad name: {steps: [x]}}\n"), 0o644)
175219 mustGit(t, dir, env, "add", ".")
internal/ci/ci.go +12 −1
@@ -11,6 +11,7 @@ package ci
1111
1212import (
1313 "fmt"
14 "path"
1415 "regexp"
1516 "sort"
1617
@@ -32,6 +33,7 @@ type Job struct {
3233 Name string
3334 Steps []string
3435 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
3537}
3638
3739// Parse returns the jobs in name order, or an error describing the first
@@ -41,6 +43,7 @@ func Parse(raw []byte) ([]Job, error) {
4143 Jobs map[string]struct {
4244 Steps []string `yaml:"steps"`
4345 Schedule string `yaml:"schedule"`
46 Tags string `yaml:"tags"`
4447 } `yaml:"jobs"`
4548 }
4649 if err := yaml.Unmarshal(raw, &doc); err != nil {
@@ -73,7 +76,15 @@ func Parse(raw []byte) ([]Job, error) {
7376 return nil, fmt.Errorf("job %q: %v", name, err)
7477 }
7578 }
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})
7788 }
7889 sort.Slice(jobs, func(i, k int) bool { return jobs[i].Name < jobs[k].Name })
7990 return jobs, nil
internal/gitutil/gitutil.go +10
@@ -100,6 +100,16 @@ func RevList(dir, ref string, limit int) ([]string, error) {
100100 return shas, nil
101101}
102102
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
103113// ReadCommit returns the raw commit object bytes.
104114func ReadCommit(dir, sha string) ([]byte, error) {
105115 cmd := exec.Command("git", "-C", dir, "cat-file", "commit", sha)
internal/hookd/hookd.go +49 −2
@@ -15,7 +15,9 @@ import (
1515 "log/slog"
1616 "net"
1717 "os"
18 "path"
1819 "path/filepath"
20 "strings"
1921 "time"
2022
2123 "gitbay.org/gitbay/internal/ci"
@@ -175,6 +177,14 @@ func (s *Server) postReceive(req Request) {
175177 `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
176178 u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
177179
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
178188 branch, ok := cutHeads(u.Ref)
179189 if !ok {
180190 continue
@@ -190,8 +200,6 @@ func (s *Server) postReceive(req Request) {
190200 if pushedRepoErr == nil && !u.IsDelete {
191201 s.queueBuilds(pushedRepo, req.UserID, branch, u.New)
192202 }
193 // Any branch/tag update schedules the push mirrors.
194 s.st.MarkMirrorsDirty(req.RepoID, "push")
195203 if u.IsForce {
196204 s.st.Audit(req.UserID, "push.forced", map[string]any{
197205 "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)
250258 now := time.Now()
251259 var schedules []store.Schedule
252260 for _, j := range jobs {
261 // Tag jobs run on matching tag pushes only.
262 if j.Tags != "" {
263 continue
264 }
253265 // Scheduled jobs run on their cron, not on push; a default-branch
254266 // push (re)registers them.
255267 if j.Schedule != "" {
@@ -277,6 +289,41 @@ func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string)
277289 }
278290}
279291
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
280327func cutHeads(ref string) (string, bool) {
281328 const p = "refs/heads/"
282329 if len(ref) > len(p) && ref[:len(p)] == p {