A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit bb5dfd7d6c

bb5dfd7d6cde91f8d9c4607a0372e93ba828c0ba

parent: 71b6697774

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-25T18:54:13Z

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.
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
1212 import (
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.
104114 func 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
280327 func cutHeads(ref string) (string, bool) {
281328 const p = "refs/heads/"
282329 if len(ref) > len(p) && ref[:len(p)] == p {