Commit bb5dfd7d6c

bb5dfd7d6cde91f8d9c4607a0372e93ba828c0ba

parent: 71b6697774

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-25 18:54 UTC

Tag-push CI triggers

A job with tags: "v*" runs when a matching tag is pushed — and only
then; branch pushes skip it. The build records the tag as its ref and
the peeled commit as its sha, so statuses land on commits, not
annotated tag objects. schedule and tags are mutually exclusive.

Tag-only pushes now also mark push mirrors dirty; previously a tag
push without a branch update never scheduled a mirror sync.

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 {