A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 8246c58f81

8246c58f81115a972853af8d0ed7cbaa35bd1339

parent: 96f9270d24

Verified · cmc ci/build: success

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

CI secrets and scheduled builds

Secrets: repo secret set <owner/name> <NAME> takes the value on stdin
(never argv), lists names only, and injects values into the repo's
build environments via the admin-only runner claim — the same
discipline as mirror tokens.

Schedules: a job with schedule: "<cron>" runs on its cron instead of
on push. Default-branch pushes sync the schedule table; a daemon
ticker fires due entries against the default-branch head. Five-field
cron with lists, ranges, and steps; dom/dow OR per tradition; fired in
server-local time. build trigger <owner/name> <job> queues any job on
demand (write access).

Migration 0025 adds build_secrets and build_schedules.
cmd/gitbay-runner/main.go +11 −7
@@ -23,13 +23,14 @@ import (
2323 )
2424
2525 type job struct {
26 ID int64 `json:"id"`
27 Repo string `json:"repo"`
28 Number int64 `json:"number"`
29 Job string `json:"job"`
30 SHA string `json:"sha"`
31 Ref string `json:"ref"`
32 Steps []string `json:"steps"`
26 ID int64 `json:"id"`
27 Repo string `json:"repo"`
28 Number int64 `json:"number"`
29 Job string `json:"job"`
30 SHA string `json:"sha"`
31 Ref string `json:"ref"`
32 Steps []string `json:"steps"`
33 Secrets map[string]string `json:"secrets"`
3334 }
3435
3536 type runner struct {
@@ -155,6 +156,9 @@ func (r *runner) run(j job) bool {
155156 cmd.Dir = dir
156157 cmd.Env = append(os.Environ(),
157158 "GITBAY_REPO="+j.Repo, "GITBAY_SHA="+j.SHA, "GITBAY_REF="+j.Ref, "GITBAY_JOB="+j.Job, "CI=true")
159 for name, value := range j.Secrets {
160 cmd.Env = append(cmd.Env, name+"="+value)
161 }
158162 cmd.Stdout, cmd.Stderr = sink, sink
159163 if err := cmd.Start(); err != nil {
160164 fmt.Fprintf(sink, "start: %v\n", err)
cmd/gitbay/main.go +6
@@ -34,6 +34,7 @@ func main() {
3434 pass("list", "recent builds: <owner/name>", passOpts{server: []string{"build", "list"}, needsRepo: true}),
3535 pass("show", "one build: <owner/name> <n>", passOpts{server: []string{"build", "show"}, needsRepo: true}),
3636 pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}),
37 pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}),
3738 ),
3839 repoCmd(),
3940 issueCmd(),
@@ -258,6 +259,11 @@ func repoCmd() *cobra.Command {
258259 pass("remove", "remove a mirror: <id>", passOpts{server: []string{"repo", "mirror", "remove"}, needsRepo: true}),
259260 pass("sync", "schedule an immediate sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}),
260261 ),
262 group("secret", "build secrets (values on stdin, injected into build env)",
263 pass("set", "set a secret: <NAME> (value on stdin)", passOpts{server: []string{"repo", "secret", "set"}, needsRepo: true, stdinOK: true}),
264 pass("list", "list secret names", passOpts{server: []string{"repo", "secret", "list"}, needsRepo: true}),
265 pass("remove", "remove a secret: <NAME>", passOpts{server: []string{"repo", "secret", "remove"}, needsRepo: true}),
266 ),
261267 group("domain", "custom domains for the pages branch",
262268 pass("add", "claim a domain (verify with a DNS TXT record): <domain>", passOpts{server: []string{"repo", "domain", "add"}, needsRepo: true}),
263269 pass("verify", "check the DNS challenge and activate a claim: <domain>", passOpts{server: []string{"repo", "domain", "verify"}, needsRepo: true}),
cmd/gitbayd/main.go +5
@@ -19,6 +19,7 @@ import (
1919 "golang.org/x/crypto/ssh"
2020
2121 "gitbay.org/gitbay/internal/config"
22 "gitbay.org/gitbay/internal/ci"
2223 "gitbay.org/gitbay/internal/control"
2324 "gitbay.org/gitbay/internal/mail"
2425 "gitbay.org/gitbay/internal/mirror"
@@ -139,6 +140,10 @@ func serveCmd() *cobra.Command {
139140 go notify.New(st, cfg, retryBase).Run(whCtx)
140141 }
141142 go mirror.New(st, cfg).Run(whCtx)
143 go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL,
144 RepoDir: func(owner, name string) string {
145 return control.RepoDir(cfg.Server.Root, owner, name)
146 }}).Run(whCtx)
142147
143148 errCh := make(chan error, 3)
144149 if cfg.SSH.Mode == "embedded" {
e2e/ci_test.go +48
@@ -122,6 +122,54 @@ func TestCI(t *testing.T) {
122122 t.Fatalf("build log page:\n%s", body)
123123 }
124124
125 // --- secrets: stdin in, names-only out, injected into the build env ---
126 if _, errOut, code := inst.ssh(t, aliceKey, "hunter2\n", "repo", "secret", "set", "alice/app", "MY_TOKEN"); code != 0 {
127 t.Fatalf("secret set: %s", errOut)
128 }
129 if _, _, code := inst.ssh(t, aliceKey, "x\n", "repo", "secret", "set", "alice/app", "bad-name"); code != 2 {
130 t.Fatal("bad secret name accepted")
131 }
132 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "secret", "list", "alice/app")
133 if !strings.Contains(out, "MY_TOKEN") || strings.Contains(out, "hunter2") {
134 t.Fatalf("secret list leaked or missed: %s", out)
135 }
136
137 // --- schedules and manual trigger ---
138 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
139 "jobs:\n usesecret:\n steps:\n - echo token=$MY_TOKEN\n nightly:\n schedule: \"0 6 * * 1\"\n steps:\n - echo scheduled ran\n"), 0o644)
140 mustGit(t, dir, env, "add", ".")
141 mustGit(t, dir, env, "commit", "-q", "-m", "secrets and schedule")
142 mustGit(t, dir, env, "push", "-q", "origin", "main")
143
144 // The push queued only the unscheduled job.
145 out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
146 if !strings.Contains(out, "usesecret\tpending") || strings.Contains(out, "nightly") {
147 t.Fatalf("scheduled job queued on push:\n%s", out)
148 }
149 inst.runnerOnce(t, runnerKey)
150 out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
151 usecretN := strings.Split(out, "\t")[0]
152 out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", usecretN)
153 if !strings.Contains(out, "token=hunter2") {
154 t.Fatalf("secret not injected:\n%s", out)
155 }
156 // The scheduled job runs on demand via trigger.
157 if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "nightly"); code != 0 {
158 t.Fatalf("trigger: %s", errOut)
159 }
160 if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "nosuch"); code != 3 {
161 t.Fatal("triggered a job that does not exist")
162 }
163 inst.runnerOnce(t, runnerKey)
164 out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
165 if !strings.Contains(out, "nightly\tsuccess") {
166 t.Fatalf("triggered build did not run:\n%s", out)
167 }
168 // Removing the secret stops injection.
169 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "secret", "remove", "alice/app", "MY_TOKEN"); code != 0 {
170 t.Fatal("secret remove failed")
171 }
172
125173 // A broken ci.yml surfaces as a failed ci/config status.
126174 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs: {bad name: {steps: [x]}}\n"), 0o644)
127175 mustGit(t, dir, env, "add", ".")
internal/ci/ci.go +11 −4
@@ -29,8 +29,9 @@ const (
2929 var jobName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`)
3030
3131 type Job struct {
32 Name string
33 Steps []string
32 Name string
33 Steps []string
34 Schedule string // cron expression; scheduled jobs run on schedule, not on push
3435 }
3536
3637 // Parse returns the jobs in name order, or an error describing the first
@@ -38,7 +39,8 @@ type Job struct {
3839 func Parse(raw []byte) ([]Job, error) {
3940 var doc struct {
4041 Jobs map[string]struct {
41 Steps []string `yaml:"steps"`
42 Steps []string `yaml:"steps"`
43 Schedule string `yaml:"schedule"`
4244 } `yaml:"jobs"`
4345 }
4446 if err := yaml.Unmarshal(raw, &doc); err != nil {
@@ -66,7 +68,12 @@ func Parse(raw []byte) ([]Job, error) {
6668 return nil, fmt.Errorf("job %q has a step over %d bytes", name, maxStepSize)
6769 }
6870 }
69 jobs = append(jobs, Job{Name: name, Steps: j.Steps})
71 if j.Schedule != "" {
72 if _, err := ParseCron(j.Schedule); err != nil {
73 return nil, fmt.Errorf("job %q: %v", name, err)
74 }
75 }
76 jobs = append(jobs, Job{Name: name, Steps: j.Steps, Schedule: j.Schedule})
7077 }
7178 sort.Slice(jobs, func(i, k int) bool { return jobs[i].Name < jobs[k].Name })
7279 return jobs, nil
internal/ci/cron.go added +118
@@ -0,0 +1,118 @@
1package ci
2
3import (
4 "fmt"
5 "strconv"
6 "strings"
7 "time"
8)
9
10// Cron is a parsed five-field expression: minute, hour, day-of-month,
11// month, day-of-week. Supported per field: "*", N, A-B, */N, A-B/N, and
12// comma lists. Day-of-month and day-of-week combine with OR when both are
13// restricted, per traditional cron.
14type Cron struct {
15 min, hour, dom, mon, dow map[int]bool
16 domAny, dowAny bool
17}
18
19// ParseCron validates and compiles an expression like "17 11,23 * * *".
20func ParseCron(expr string) (Cron, error) {
21 fields := strings.Fields(expr)
22 if len(fields) != 5 {
23 return Cron{}, fmt.Errorf("cron %q: want 5 fields (min hour dom mon dow), got %d", expr, len(fields))
24 }
25 specs := []struct {
26 lo, hi int
27 }{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 7}}
28 var sets [5]map[int]bool
29 for i, f := range fields {
30 set, err := parseField(f, specs[i].lo, specs[i].hi)
31 if err != nil {
32 return Cron{}, fmt.Errorf("cron %q field %d: %w", expr, i+1, err)
33 }
34 sets[i] = set
35 }
36 // dow 7 is Sunday, same as 0.
37 if sets[4][7] {
38 sets[4][0] = true
39 }
40 return Cron{
41 min: sets[0], hour: sets[1], dom: sets[2], mon: sets[3], dow: sets[4],
42 domAny: fields[2] == "*", dowAny: fields[4] == "*",
43 }, nil
44}
45
46func parseField(f string, lo, hi int) (map[int]bool, error) {
47 set := map[int]bool{}
48 for _, part := range strings.Split(f, ",") {
49 rangePart, stepPart, hasStep := strings.Cut(part, "/")
50 step := 1
51 if hasStep {
52 s, err := strconv.Atoi(stepPart)
53 if err != nil || s < 1 {
54 return nil, fmt.Errorf("bad step %q", stepPart)
55 }
56 step = s
57 }
58 a, b := lo, hi
59 if rangePart != "*" {
60 loStr, hiStr, isRange := strings.Cut(rangePart, "-")
61 n, err := strconv.Atoi(loStr)
62 if err != nil {
63 return nil, fmt.Errorf("bad value %q", loStr)
64 }
65 a = n
66 if isRange {
67 m, err := strconv.Atoi(hiStr)
68 if err != nil {
69 return nil, fmt.Errorf("bad value %q", hiStr)
70 }
71 b = m
72 } else if hasStep {
73 b = hi // "N/step" means N..hi by step
74 } else {
75 b = n
76 }
77 }
78 if a < lo || b > hi || a > b {
79 return nil, fmt.Errorf("%q out of range %d-%d", part, lo, hi)
80 }
81 for v := a; v <= b; v += step {
82 set[v] = true
83 }
84 }
85 return set, nil
86}
87
88// Matches reports whether the expression fires at t (minute precision).
89func (c Cron) Matches(t time.Time) bool {
90 if !c.min[t.Minute()] || !c.hour[t.Hour()] || !c.mon[int(t.Month())] {
91 return false
92 }
93 domOK := c.dom[t.Day()]
94 dowOK := c.dow[int(t.Weekday())]
95 switch {
96 case c.domAny && c.dowAny:
97 return true
98 case c.domAny:
99 return dowOK
100 case c.dowAny:
101 return domOK
102 default:
103 return domOK || dowOK // both restricted: traditional OR
104 }
105}
106
107// Next returns the first firing time strictly after t, or the zero time if
108// none exists within a year (an impossible date like Feb 30).
109func (c Cron) Next(t time.Time) time.Time {
110 t = t.Truncate(time.Minute).Add(time.Minute)
111 limit := t.AddDate(1, 0, 1)
112 for ; t.Before(limit); t = t.Add(time.Minute) {
113 if c.Matches(t) {
114 return t
115 }
116 }
117 return time.Time{}
118}
internal/ci/cron_test.go added +54
@@ -0,0 +1,54 @@
1package ci
2
3import (
4 "testing"
5 "time"
6)
7
8func at(s string) time.Time {
9 t, err := time.Parse("2006-01-02 15:04", s)
10 if err != nil {
11 panic(err)
12 }
13 return t
14}
15
16func TestCronNext(t *testing.T) {
17 cases := []struct{ expr, from, want string }{
18 {"* * * * *", "2026-08-25 10:30", "2026-08-25 10:31"},
19 {"17 11,23 * * *", "2026-08-25 10:30", "2026-08-25 11:17"},
20 {"17 11,23 * * *", "2026-08-25 11:17", "2026-08-25 23:17"},
21 {"0 6 * * 1", "2026-08-25 00:00", "2026-08-31 06:00"}, // next Monday
22 {"*/15 * * * *", "2026-08-25 10:31", "2026-08-25 10:45"},
23 {"0 0 1 * *", "2026-08-25 10:00", "2026-09-01 00:00"},
24 {"30 4 1-7 * 0", "2026-08-25 10:00", "2026-08-30 04:30"}, // dom OR dow: Sunday wins
25 {"0 12 29 2 *", "2026-03-01 00:00", "2028-02-29 12:00"}, // leap day beyond a year -> zero
26 }
27 for _, c := range cases {
28 cr, err := ParseCron(c.expr)
29 if err != nil {
30 t.Fatalf("%q: %v", c.expr, err)
31 }
32 got := cr.Next(at(c.from))
33 if c.expr == "0 12 29 2 *" {
34 if !got.IsZero() {
35 t.Errorf("%q from %s: want zero, got %s", c.expr, c.from, got)
36 }
37 continue
38 }
39 if !got.Equal(at(c.want)) {
40 t.Errorf("%q from %s: got %s, want %s", c.expr, c.from, got, c.want)
41 }
42 }
43}
44
45func TestCronParseErrors(t *testing.T) {
46 for _, expr := range []string{
47 "", "* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * * 13 *",
48 "* * * * 8", "a * * * *", "*/0 * * * *", "5-2 * * * *",
49 } {
50 if _, err := ParseCron(expr); err == nil {
51 t.Errorf("%q parsed without error", expr)
52 }
53 }
54}
internal/ci/sched.go added +110
@@ -0,0 +1,110 @@
1package ci
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "os"
9 "time"
10
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// isoNow is the timestamp format schedules are compared in.
16func isoNow(t time.Time) string { return t.UTC().Format("2006-01-02T15:04:05Z") }
17
18// NextRun returns the next firing time for a cron expression as a stored
19// timestamp. The caller has already validated the expression.
20func NextRun(expr string, after time.Time) string {
21 c, err := ParseCron(expr)
22 if err != nil {
23 return isoNow(after.Add(24 * time.Hour)) // unreachable after Parse validation
24 }
25 n := c.Next(after)
26 if n.IsZero() {
27 return isoNow(after.AddDate(1, 0, 0))
28 }
29 return isoNow(n)
30}
31
32// Scheduler fires scheduled builds. repoDir maps a repo to its bare path.
33type Scheduler struct {
34 St *store.Store
35 RepoDir func(owner, name string) string
36 SiteURL string
37}
38
39// Run ticks until the context ends. GITBAY_SCHED_TICK overrides the
40// interval for tests.
41func (s *Scheduler) Run(ctx context.Context) {
42 tick := time.Minute
43 if v := os.Getenv("GITBAY_SCHED_TICK"); v != "" {
44 if d, err := time.ParseDuration(v); err == nil {
45 tick = d
46 }
47 }
48 t := time.NewTicker(tick)
49 defer t.Stop()
50 for {
51 select {
52 case <-ctx.Done():
53 return
54 case <-t.C:
55 s.RunDue(time.Now())
56 }
57 }
58}
59
60// RunDue queues every due scheduled build and advances its next_run. Split
61// from the ticker for tests.
62func (s *Scheduler) RunDue(now time.Time) {
63 due, err := s.St.DueSchedules(isoNow(now))
64 if err != nil {
65 slog.Error("scheduler: listing due builds", "err", err)
66 return
67 }
68 for _, e := range due {
69 repo, err := s.St.RepoByID(e.RepoID)
70 if err != nil {
71 s.St.RemoveSchedule(e.RepoID, e.Job) // repo gone
72 continue
73 }
74 // Always advance first so a broken repo cannot wedge the loop.
75 s.St.SetScheduleNext(e.RepoID, e.Job, NextRun(e.Cron, now))
76 dir := s.RepoDir(repo.OwnerName, repo.Name)
77 sha, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
78 if err != nil {
79 continue // empty repo
80 }
81 raw, err := gitutil.ReadBlob(dir, sha, ConfigPath, 1<<16)
82 if err != nil {
83 s.St.RemoveSchedule(e.RepoID, e.Job) // config removed
84 continue
85 }
86 jobs, err := Parse(raw)
87 if err != nil {
88 continue
89 }
90 var job *Job
91 for i := range jobs {
92 if jobs[i].Name == e.Job && jobs[i].Schedule != "" {
93 job = &jobs[i]
94 break
95 }
96 }
97 if job == nil {
98 s.St.RemoveSchedule(e.RepoID, e.Job)
99 continue
100 }
101 steps, _ := json.Marshal(job.Steps)
102 n, err := s.St.CreateBuild(repo.ID, job.Name, sha, repo.DefaultBranch, string(steps))
103 if err != nil {
104 slog.Error("scheduler: queueing build", "repo", repo.Path(), "job", job.Name, "err", err)
105 continue
106 }
107 url := fmt.Sprintf("%s/%s/builds/%d", s.SiteURL, repo.Path(), n)
108 s.St.SetCommitStatus(repo.ID, sha, "ci/"+job.Name, "pending", "scheduled", url, 0)
109 }
110}
internal/ci/sched_test.go added +104
@@ -0,0 +1,104 @@
1package ci
2
3import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "gitbay.org/gitbay/internal/store"
11)
12
13// TestSchedulerRunDue drives one scheduler pass against a real store and
14// bare repo: a due entry queues a build, sets a pending status, and
15// advances next_run; entries whose job vanished are dropped.
16func TestSchedulerRunDue(t *testing.T) {
17 st, err := store.Open(":memory:")
18 if err != nil {
19 t.Fatal(err)
20 }
21 defer st.Close()
22 if err := st.MigrateUp(); err != nil {
23 t.Fatal(err)
24 }
25 uid, err := st.CreateUser("alice", false)
26 if err != nil {
27 t.Fatal(err)
28 }
29 _ = uid
30 repoID, err := st.CreateRepo("user", uid, "app", "public")
31 if err != nil {
32 t.Fatal(err)
33 }
34
35 // A bare repo whose main holds a ci.yml with one scheduled job.
36 work := t.TempDir()
37 env := append(os.Environ(),
38 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
39 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
40 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
41 git := func(dir string, args ...string) {
42 t.Helper()
43 cmd := exec.Command("git", args...)
44 cmd.Dir = dir
45 cmd.Env = env
46 if out, err := cmd.CombinedOutput(); err != nil {
47 t.Fatalf("git %v: %v\n%s", args, err, out)
48 }
49 }
50 src := filepath.Join(work, "src")
51 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
52 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"),
53 []byte("jobs:\n nightly:\n schedule: \"0 6 * * *\"\n steps: [echo hi]\n"), 0o644)
54 git(work, "init", "-q", "-b", "main", "src")
55 git(src, "add", ".")
56 git(src, "commit", "-q", "-m", "base")
57 bare := filepath.Join(work, "bare.git")
58 git(work, "clone", "-q", "--bare", src, "bare.git")
59
60 now := time.Now()
61 past := now.Add(-time.Hour).UTC().Format("2006-01-02T15:04:05Z")
62 if err := st.SyncSchedules(repoID, []store.Schedule{
63 {RepoID: repoID, Job: "nightly", Cron: "0 6 * * *", NextRun: past},
64 {RepoID: repoID, Job: "gone", Cron: "0 7 * * *", NextRun: past},
65 }); err != nil {
66 t.Fatal(err)
67 }
68
69 s := &Scheduler{St: st, SiteURL: "https://x.test",
70 RepoDir: func(owner, name string) string { return bare }}
71 s.RunDue(now)
72
73 builds, err := st.ListBuilds(repoID, 10)
74 if err != nil || len(builds) != 1 {
75 t.Fatalf("builds after run: %v %v", builds, err)
76 }
77 if builds[0].Job != "nightly" || builds[0].Status != "pending" || builds[0].Ref != "main" {
78 t.Fatalf("queued build wrong: %+v", builds[0])
79 }
80 statuses, _ := st.ListCommitStatuses(repoID, builds[0].SHA)
81 if len(statuses) != 1 || statuses[0].Context != "ci/nightly" || statuses[0].State != "pending" {
82 t.Fatalf("status wrong: %+v", statuses)
83 }
84 // next_run advanced past now; the vanished job's entry is gone.
85 due, _ := st.DueSchedules(now.UTC().Format("2006-01-02T15:04:05Z"))
86 if len(due) != 0 {
87 t.Fatalf("still due after run: %+v", due)
88 }
89 all, _ := st.DueSchedules("9999-01-01T00:00:00Z")
90 if len(all) != 1 || all[0].Job != "nightly" {
91 t.Fatalf("schedule set after run: %+v", all)
92 }
93 // Cron fires in server-local time, stored as UTC.
94 cr, _ := ParseCron("0 6 * * *")
95 if want := cr.Next(now).UTC().Format("2006-01-02T15:04:05Z"); all[0].NextRun != want {
96 t.Fatalf("next_run = %s, want %s", all[0].NextRun, want)
97 }
98
99 // A second pass fires nothing: next_run is in the future.
100 s.RunDue(now)
101 if builds, _ = st.ListBuilds(repoID, 10); len(builds) != 1 {
102 t.Fatalf("second pass queued extra builds: %+v", builds)
103 }
104}
internal/control/build.go +140 −8
@@ -2,10 +2,15 @@ package control
22
33 import (
44 "encoding/json"
5 "errors"
56 "fmt"
67 "io"
8 "regexp"
79 "strconv"
10 "strings"
811
12 "gitbay.org/gitbay/internal/ci"
13 "gitbay.org/gitbay/internal/gitutil"
914 "gitbay.org/gitbay/internal/policy"
1015 "gitbay.org/gitbay/internal/protocol"
1116 "gitbay.org/gitbay/internal/store"
@@ -19,6 +24,19 @@ func init() {
1924 register(Command{Path: []string{"build", "log"},
2025 Summary: "print a build's log: build log <owner/name> <n>", ReadOnly: true, Run: runBuildLog})
2126
27 register(Command{Path: []string{"build", "trigger"},
28 Summary: "queue a job now (scheduled or not): build trigger <owner/name> <job>", Run: runBuildTrigger})
29 // Secrets: set over stdin, listed by name only, injected into the
30 // repo's builds as environment variables. Same discipline as mirror
31 // tokens — the value never appears in argv, logs, or output.
32 register(Command{Path: []string{"repo", "secret", "set"},
33 Summary: "set a build secret: repo secret set <owner/name> <NAME> (value on stdin)",
34 ReadsStdin: true, SSHOnly: true, Run: runSecretSet})
35 register(Command{Path: []string{"repo", "secret", "remove"},
36 Summary: "remove a build secret: repo secret remove <owner/name> <NAME>", Run: runSecretRemove})
37 register(Command{Path: []string{"repo", "secret", "list"},
38 Summary: "list build secret names: repo secret list <owner/name>", ReadOnly: true, Run: runSecretList})
39
2240 // Runner commands: the claim/report loop for gitbay-runner. Admin-only —
2341 // a runner executes arbitrary repo code, so handing out jobs is the
2442 // instance operator's call.
@@ -114,6 +132,113 @@ func runBuildLog(c *Ctx, args []string) int {
114132 return protocol.ExitOK
115133 }
116134
135func runBuildTrigger(c *Ctx, args []string) int {
136 if len(args) != 2 {
137 return c.fail(protocol.ExitUsage, "usage: build trigger <owner/name> <job>")
138 }
139 repo, code := resolveRepo(c, args[0], policy.CanWrite)
140 if code >= 0 {
141 return code
142 }
143 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
144 sha, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
145 if err != nil {
146 return c.fail(protocol.ExitFailure, "resolving %s: %v", repo.DefaultBranch, err)
147 }
148 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
149 if err != nil {
150 return c.fail(protocol.ExitNotFound, "%s has no %s on %s", repo.Path(), ci.ConfigPath, repo.DefaultBranch)
151 }
152 jobs, err := ci.Parse(raw)
153 if err != nil {
154 return c.fail(protocol.ExitUsage, "%v", err)
155 }
156 for _, j := range jobs {
157 if j.Name != args[1] {
158 continue
159 }
160 steps, _ := json.Marshal(j.Steps)
161 n, err := c.Store.CreateBuild(repo.ID, j.Name, sha, repo.DefaultBranch, string(steps))
162 if err != nil {
163 return c.fail(protocol.ExitFailure, "%v", err)
164 }
165 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), n)
166 c.Store.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "triggered", url, c.User.ID)
167 return c.emit(map[string]any{"build": n, "job": j.Name, "sha": sha}, func(w io.Writer) {
168 fmt.Fprintf(w, "queued build %d (%s @ %.10s)\n", n, j.Name, sha)
169 })
170 }
171 return c.fail(protocol.ExitNotFound, "no job %q in %s", args[1], ci.ConfigPath)
172}
173
174// secretName is env-var shaped: the value lands in the build environment.
175var secretName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]{0,63}$`)
176
177func runSecretSet(c *Ctx, args []string) int {
178 if len(args) != 2 {
179 return c.fail(protocol.ExitUsage, "usage: repo secret set <owner/name> <NAME> (value on stdin)")
180 }
181 if !secretName.MatchString(args[1]) {
182 return c.fail(protocol.ExitUsage, "secret names are env-var shaped: uppercase letters, digits, _")
183 }
184 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
185 if code >= 0 {
186 return code
187 }
188 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
189 if err != nil {
190 return c.fail(protocol.ExitFailure, "reading secret: %v", err)
191 }
192 value := strings.TrimRight(string(raw), "\n")
193 if value == "" {
194 return c.fail(protocol.ExitUsage, "no value on stdin (pipe it: printf %%s TOKEN | ...)")
195 }
196 if err := c.Store.SetBuildSecret(repo.ID, args[1], value); err != nil {
197 return c.fail(protocol.ExitFailure, "%v", err)
198 }
199 return c.emit(map[string]string{"secret": args[1]}, func(w io.Writer) {
200 fmt.Fprintf(w, "secret %s set on %s\n", args[1], repo.Path())
201 })
202}
203
204func runSecretRemove(c *Ctx, args []string) int {
205 if len(args) != 2 {
206 return c.fail(protocol.ExitUsage, "usage: repo secret remove <owner/name> <NAME>")
207 }
208 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
209 if code >= 0 {
210 return code
211 }
212 if err := c.Store.RemoveBuildSecret(repo.ID, args[1]); err != nil {
213 if errors.Is(err, store.ErrNotFound) {
214 return c.fail(protocol.ExitNotFound, "no secret %s on %s", args[1], repo.Path())
215 }
216 return c.fail(protocol.ExitFailure, "%v", err)
217 }
218 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
219 fmt.Fprintf(w, "removed %s\n", args[1])
220 })
221}
222
223func runSecretList(c *Ctx, args []string) int {
224 if len(args) != 1 {
225 return c.fail(protocol.ExitUsage, "usage: repo secret list <owner/name>")
226 }
227 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
228 if code >= 0 {
229 return code
230 }
231 names, err := c.Store.ListBuildSecretNames(repo.ID)
232 if err != nil {
233 return c.fail(protocol.ExitFailure, "%v", err)
234 }
235 return c.emit(names, func(w io.Writer) {
236 for _, n := range names {
237 fmt.Fprintln(w, n)
238 }
239 })
240}
241
117242 func requireRunner(c *Ctx) int {
118243 if !c.User.IsAdmin {
119244 return c.fail(protocol.ExitDenied, "runner commands are for instance-admin runner accounts")
@@ -138,15 +263,22 @@ func runRunnerNext(c *Ctx, args []string) int {
138263 }
139264 var steps []string
140265 json.Unmarshal([]byte(b.Steps), &steps)
266 // Secrets ride the claim: this channel is admin-only and the values
267 // land in the build's environment, nowhere else.
268 secrets, err := c.Store.BuildSecrets(b.RepoID)
269 if err != nil {
270 return c.fail(protocol.ExitFailure, "%v", err)
271 }
141272 d := struct {
142 ID int64 `json:"id"`
143 Repo string `json:"repo"`
144 Number int64 `json:"number"`
145 Job string `json:"job"`
146 SHA string `json:"sha"`
147 Ref string `json:"ref"`
148 Steps []string `json:"steps"`
149 }{b.ID, repo.Path(), b.Number, b.Job, b.SHA, b.Ref, steps}
273 ID int64 `json:"id"`
274 Repo string `json:"repo"`
275 Number int64 `json:"number"`
276 Job string `json:"job"`
277 SHA string `json:"sha"`
278 Ref string `json:"ref"`
279 Steps []string `json:"steps"`
280 Secrets map[string]string `json:"secrets,omitempty"`
281 }{b.ID, repo.Path(), b.Number, b.Job, b.SHA, b.Ref, steps, secrets}
150282 return c.emit(d, func(w io.Writer) {
151283 fmt.Fprintf(w, "build %d: %s %s @ %.10s\n", d.ID, d.Repo, d.Job, d.SHA)
152284 })
internal/hookd/hookd.go +19
@@ -16,6 +16,7 @@ import (
1616 "net"
1717 "os"
1818 "path/filepath"
19 "time"
1920
2021 "gitbay.org/gitbay/internal/ci"
2122 "gitbay.org/gitbay/internal/config"
@@ -246,7 +247,20 @@ func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string)
246247 s.st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID)
247248 return
248249 }
250 now := time.Now()
251 var schedules []store.Schedule
249252 for _, j := range jobs {
253 // Scheduled jobs run on their cron, not on push; a default-branch
254 // push (re)registers them.
255 if j.Schedule != "" {
256 if branch == repo.DefaultBranch {
257 schedules = append(schedules, store.Schedule{
258 RepoID: repo.ID, Job: j.Name, Cron: j.Schedule,
259 NextRun: ci.NextRun(j.Schedule, now),
260 })
261 }
262 continue
263 }
250264 steps, _ := json.Marshal(j.Steps)
251265 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, branch, string(steps))
252266 if err != nil {
@@ -256,6 +270,11 @@ func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string)
256270 url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
257271 s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID)
258272 }
273 if branch == repo.DefaultBranch {
274 if err := s.st.SyncSchedules(repo.ID, schedules); err != nil {
275 slog.Error("syncing schedules", "repo", repo.Path(), "err", err)
276 }
277 }
259278 }
260279
261280 func cutHeads(ref string) (string, bool) {
internal/store/cisecrets.go added +141
@@ -0,0 +1,141 @@
1package store
2
3// SetBuildSecret stores or replaces one secret. The value never leaves the
4// server except inside a claimed build's environment.
5func (s *Store) SetBuildSecret(repoID int64, name, value string) error {
6 _, err := s.DB.Exec(`
7 INSERT INTO build_secrets (repo_id, name, value) VALUES (?, ?, ?)
8 ON CONFLICT (repo_id, name) DO UPDATE SET value = excluded.value`,
9 repoID, name, value)
10 return err
11}
12
13func (s *Store) RemoveBuildSecret(repoID int64, name string) error {
14 res, err := s.DB.Exec("DELETE FROM build_secrets WHERE repo_id = ? AND name = ?", repoID, name)
15 if err != nil {
16 return err
17 }
18 if n, _ := res.RowsAffected(); n == 0 {
19 return ErrNotFound
20 }
21 return nil
22}
23
24// ListBuildSecretNames returns names only; values are for builds.
25func (s *Store) ListBuildSecretNames(repoID int64) ([]string, error) {
26 rows, err := s.DB.Query("SELECT name FROM build_secrets WHERE repo_id = ? ORDER BY name", repoID)
27 if err != nil {
28 return nil, err
29 }
30 defer rows.Close()
31 var out []string
32 for rows.Next() {
33 var n string
34 if err := rows.Scan(&n); err != nil {
35 return nil, err
36 }
37 out = append(out, n)
38 }
39 return out, rows.Err()
40}
41
42// BuildSecrets returns the values, for injection into a claimed build.
43func (s *Store) BuildSecrets(repoID int64) (map[string]string, error) {
44 rows, err := s.DB.Query("SELECT name, value FROM build_secrets WHERE repo_id = ?", repoID)
45 if err != nil {
46 return nil, err
47 }
48 defer rows.Close()
49 out := map[string]string{}
50 for rows.Next() {
51 var n, v string
52 if err := rows.Scan(&n, &v); err != nil {
53 return nil, err
54 }
55 out[n] = v
56 }
57 return out, rows.Err()
58}
59
60// Schedule is one repo job's cron entry.
61type Schedule struct {
62 RepoID int64
63 Job string
64 Cron string
65 NextRun string
66}
67
68// SyncSchedules replaces a repo's schedule set with the given entries,
69// preserving next_run for entries whose cron is unchanged.
70func (s *Store) SyncSchedules(repoID int64, entries []Schedule) error {
71 tx, err := s.DB.Begin()
72 if err != nil {
73 return err
74 }
75 defer tx.Rollback()
76 keep := map[string]bool{}
77 for _, e := range entries {
78 keep[e.Job] = true
79 if _, err := tx.Exec(`
80 INSERT INTO build_schedules (repo_id, job, cron, next_run) VALUES (?, ?, ?, ?)
81 ON CONFLICT (repo_id, job) DO UPDATE SET
82 next_run = CASE WHEN cron = excluded.cron THEN next_run ELSE excluded.next_run END,
83 cron = excluded.cron`,
84 repoID, e.Job, e.Cron, e.NextRun); err != nil {
85 return err
86 }
87 }
88 rows, err := tx.Query("SELECT job FROM build_schedules WHERE repo_id = ?", repoID)
89 if err != nil {
90 return err
91 }
92 var stale []string
93 for rows.Next() {
94 var j string
95 if err := rows.Scan(&j); err != nil {
96 rows.Close()
97 return err
98 }
99 if !keep[j] {
100 stale = append(stale, j)
101 }
102 }
103 rows.Close()
104 for _, j := range stale {
105 if _, err := tx.Exec("DELETE FROM build_schedules WHERE repo_id = ? AND job = ?", repoID, j); err != nil {
106 return err
107 }
108 }
109 return tx.Commit()
110}
111
112// DueSchedules returns entries whose next_run is at or before now.
113func (s *Store) DueSchedules(nowISO string) ([]Schedule, error) {
114 rows, err := s.DB.Query(
115 "SELECT repo_id, job, cron, next_run FROM build_schedules WHERE next_run <= ? ORDER BY next_run", nowISO)
116 if err != nil {
117 return nil, err
118 }
119 defer rows.Close()
120 var out []Schedule
121 for rows.Next() {
122 var e Schedule
123 if err := rows.Scan(&e.RepoID, &e.Job, &e.Cron, &e.NextRun); err != nil {
124 return nil, err
125 }
126 out = append(out, e)
127 }
128 return out, rows.Err()
129}
130
131// SetScheduleNext advances one entry's next firing time.
132func (s *Store) SetScheduleNext(repoID int64, job, nextRun string) error {
133 _, err := s.DB.Exec(
134 "UPDATE build_schedules SET next_run = ? WHERE repo_id = ? AND job = ?", nextRun, repoID, job)
135 return err
136}
137
138func (s *Store) RemoveSchedule(repoID int64, job string) error {
139 _, err := s.DB.Exec("DELETE FROM build_schedules WHERE repo_id = ? AND job = ?", repoID, job)
140 return err
141}
internal/store/migrations/0025_ci_secrets_cron.down.sql added +2
@@ -0,0 +1,2 @@
1DROP TABLE build_secrets;
2DROP TABLE build_schedules;
internal/store/migrations/0025_ci_secrets_cron.up.sql added +17
@@ -0,0 +1,17 @@
1- CI secrets: per-repo values injected into build environments. Stored
2- like mirror tokens server-side, set over stdin, never echoed back.
3CREATE TABLE build_secrets (
4 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
5 name TEXT NOT NULL,
6 value TEXT NOT NULL,
7 PRIMARY KEY (repo_id, name)
8);
9- Scheduled builds: jobs with a cron expression, synced from .gitbay/ci.yml
10- on push and fired by the daemon's scheduler.
11CREATE TABLE build_schedules (
12 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
13 job TEXT NOT NULL,
14 cron TEXT NOT NULL,
15 next_run TEXT NOT NULL,
16 PRIMARY KEY (repo_id, job)
17);
internal/store/statuses.go +6 −1
@@ -10,7 +10,12 @@ type CommitStatus struct {
1010 }
1111
1212 // SetCommitStatus upserts the latest state for one context on one commit.
13// A zero creatorID records no creator (system actions like the scheduler).
1314 func (s *Store) SetCommitStatus(repoID int64, sha, context, state, description, targetURL string, creatorID int64) error {
15 var creator any
16 if creatorID != 0 {
17 creator = creatorID
18 }
1419 _, err := s.DB.Exec(`
1520 INSERT INTO commit_statuses (repo_id, commit_sha, context, state, description, target_url, creator_id)
1621 VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -18,7 +23,7 @@ func (s *Store) SetCommitStatus(repoID int64, sha, context, state, description,
1823 state = excluded.state, description = excluded.description,
1924 target_url = excluded.target_url, creator_id = excluded.creator_id,
2025 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
21 repoID, sha, context, state, description, targetURL, creatorID)
26 repoID, sha, context, state, description, targetURL, creator)
2227 return err
2328 }
2429