Commit 8246c58f81
Verified · cmc ci/build: success
cmd/gitbay-runner/main.go +11 −7
| @@ -23,13 +23,14 @@ import ( | ||
| 23 | 23 | ) |
| 24 | 24 | |
| 25 | 25 | 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"` | |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | type runner struct { |
| @@ -155,6 +156,9 @@ func (r *runner) run(j job) bool { | ||
| 155 | 156 | cmd.Dir = dir |
| 156 | 157 | cmd.Env = append(os.Environ(), |
| 157 | 158 | "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 | } | |
| 158 | 162 | cmd.Stdout, cmd.Stderr = sink, sink |
| 159 | 163 | if err := cmd.Start(); err != nil { |
| 160 | 164 | fmt.Fprintf(sink, "start: %v\n", err) |
cmd/gitbay/main.go +6
| @@ -34,6 +34,7 @@ func main() { | ||
| 34 | 34 | pass("list", "recent builds: <owner/name>", passOpts{server: []string{"build", "list"}, needsRepo: true}), |
| 35 | 35 | pass("show", "one build: <owner/name> <n>", passOpts{server: []string{"build", "show"}, needsRepo: true}), |
| 36 | 36 | 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}), | |
| 37 | 38 | ), |
| 38 | 39 | repoCmd(), |
| 39 | 40 | issueCmd(), |
| @@ -258,6 +259,11 @@ func repoCmd() *cobra.Command { | ||
| 258 | 259 | pass("remove", "remove a mirror: <id>", passOpts{server: []string{"repo", "mirror", "remove"}, needsRepo: true}), |
| 259 | 260 | pass("sync", "schedule an immediate sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}), |
| 260 | 261 | ), |
| 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 | ), | |
| 261 | 267 | group("domain", "custom domains for the pages branch", |
| 262 | 268 | pass("add", "claim a domain (verify with a DNS TXT record): <domain>", passOpts{server: []string{"repo", "domain", "add"}, needsRepo: true}), |
| 263 | 269 | 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 ( | ||
| 19 | 19 | "golang.org/x/crypto/ssh" |
| 20 | 20 | |
| 21 | 21 | "gitbay.org/gitbay/internal/config" |
| 22 | "gitbay.org/gitbay/internal/ci" | |
| 22 | 23 | "gitbay.org/gitbay/internal/control" |
| 23 | 24 | "gitbay.org/gitbay/internal/mail" |
| 24 | 25 | "gitbay.org/gitbay/internal/mirror" |
| @@ -139,6 +140,10 @@ func serveCmd() *cobra.Command { | ||
| 139 | 140 | go notify.New(st, cfg, retryBase).Run(whCtx) |
| 140 | 141 | } |
| 141 | 142 | 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) | |
| 142 | 147 | |
| 143 | 148 | errCh := make(chan error, 3) |
| 144 | 149 | if cfg.SSH.Mode == "embedded" { |
e2e/ci_test.go +48
| @@ -122,6 +122,54 @@ func TestCI(t *testing.T) { | ||
| 122 | 122 | t.Fatalf("build log page:\n%s", body) |
| 123 | 123 | } |
| 124 | 124 | |
| 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 | ||
| 125 | 173 | // A broken ci.yml surfaces as a failed ci/config status. |
| 126 | 174 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs: {bad name: {steps: [x]}}\n"), 0o644) |
| 127 | 175 | mustGit(t, dir, env, "add", ".") |
internal/ci/ci.go +11 −4
| @@ -29,8 +29,9 @@ const ( | ||
| 29 | 29 | var jobName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`) |
| 30 | 30 | |
| 31 | 31 | 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 | |
| 34 | 35 | } |
| 35 | 36 | |
| 36 | 37 | // Parse returns the jobs in name order, or an error describing the first |
| @@ -38,7 +39,8 @@ type Job struct { | ||
| 38 | 39 | func Parse(raw []byte) ([]Job, error) { |
| 39 | 40 | var doc struct { |
| 40 | 41 | Jobs map[string]struct { |
| 41 | Steps []string `yaml:"steps"` | |
| 42 | Steps []string `yaml:"steps"` | |
| 43 | Schedule string `yaml:"schedule"` | |
| 42 | 44 | } `yaml:"jobs"` |
| 43 | 45 | } |
| 44 | 46 | if err := yaml.Unmarshal(raw, &doc); err != nil { |
| @@ -66,7 +68,12 @@ func Parse(raw []byte) ([]Job, error) { | ||
| 66 | 68 | return nil, fmt.Errorf("job %q has a step over %d bytes", name, maxStepSize) |
| 67 | 69 | } |
| 68 | 70 | } |
| 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}) | |
| 70 | 77 | } |
| 71 | 78 | sort.Slice(jobs, func(i, k int) bool { return jobs[i].Name < jobs[k].Name }) |
| 72 | 79 | return jobs, nil |
internal/ci/cron.go added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | package ci | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 14 | type 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 * * *". | |
| 20 | func 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 | ||
| 46 | func 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). | |
| 89 | func (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). | |
| 109 | func (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 @@ | ||
| 1 | package ci | |
| 2 | ||
| 3 | import ( | |
| 4 | "testing" | |
| 5 | "time" | |
| 6 | ) | |
| 7 | ||
| 8 | func 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 | ||
| 16 | func 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 | ||
| 45 | func 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 @@ | ||
| 1 | package ci | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 16 | func 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. | |
| 20 | func 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. | |
| 33 | type 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. | |
| 41 | func (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. | |
| 62 | func (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 @@ | ||
| 1 | package ci | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 16 | func 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 | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "encoding/json" |
| 5 | "errors" | |
| 5 | 6 | "fmt" |
| 6 | 7 | "io" |
| 8 | "regexp" | |
| 7 | 9 | "strconv" |
| 10 | "strings" | |
| 8 | 11 | |
| 12 | "gitbay.org/gitbay/internal/ci" | |
| 13 | "gitbay.org/gitbay/internal/gitutil" | |
| 9 | 14 | "gitbay.org/gitbay/internal/policy" |
| 10 | 15 | "gitbay.org/gitbay/internal/protocol" |
| 11 | 16 | "gitbay.org/gitbay/internal/store" |
| @@ -19,6 +24,19 @@ func init() { | ||
| 19 | 24 | register(Command{Path: []string{"build", "log"}, |
| 20 | 25 | Summary: "print a build's log: build log <owner/name> <n>", ReadOnly: true, Run: runBuildLog}) |
| 21 | 26 | |
| 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 | ||
| 22 | 40 | // Runner commands: the claim/report loop for gitbay-runner. Admin-only — |
| 23 | 41 | // a runner executes arbitrary repo code, so handing out jobs is the |
| 24 | 42 | // instance operator's call. |
| @@ -114,6 +132,113 @@ func runBuildLog(c *Ctx, args []string) int { | ||
| 114 | 132 | return protocol.ExitOK |
| 115 | 133 | } |
| 116 | 134 | |
| 135 | func 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. | |
| 175 | var secretName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]{0,63}$`) | |
| 176 | ||
| 177 | func 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 | ||
| 204 | func 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 | ||
| 223 | func 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 | ||
| 117 | 242 | func requireRunner(c *Ctx) int { |
| 118 | 243 | if !c.User.IsAdmin { |
| 119 | 244 | return c.fail(protocol.ExitDenied, "runner commands are for instance-admin runner accounts") |
| @@ -138,15 +263,22 @@ func runRunnerNext(c *Ctx, args []string) int { | ||
| 138 | 263 | } |
| 139 | 264 | var steps []string |
| 140 | 265 | 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 | } | |
| 141 | 272 | 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} | |
| 150 | 282 | return c.emit(d, func(w io.Writer) { |
| 151 | 283 | fmt.Fprintf(w, "build %d: %s %s @ %.10s\n", d.ID, d.Repo, d.Job, d.SHA) |
| 152 | 284 | }) |
internal/hookd/hookd.go +19
| @@ -16,6 +16,7 @@ import ( | ||
| 16 | 16 | "net" |
| 17 | 17 | "os" |
| 18 | 18 | "path/filepath" |
| 19 | "time" | |
| 19 | 20 | |
| 20 | 21 | "gitbay.org/gitbay/internal/ci" |
| 21 | 22 | "gitbay.org/gitbay/internal/config" |
| @@ -246,7 +247,20 @@ func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string) | ||
| 246 | 247 | s.st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID) |
| 247 | 248 | return |
| 248 | 249 | } |
| 250 | now := time.Now() | |
| 251 | var schedules []store.Schedule | |
| 249 | 252 | 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 | } | |
| 250 | 264 | steps, _ := json.Marshal(j.Steps) |
| 251 | 265 | n, err := s.st.CreateBuild(repo.ID, j.Name, sha, branch, string(steps)) |
| 252 | 266 | if err != nil { |
| @@ -256,6 +270,11 @@ func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string) | ||
| 256 | 270 | url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n) |
| 257 | 271 | s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID) |
| 258 | 272 | } |
| 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 | } | |
| 259 | 278 | } |
| 260 | 279 | |
| 261 | 280 | func cutHeads(ref string) (string, bool) { |
internal/store/cisecrets.go added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | // SetBuildSecret stores or replaces one secret. The value never leaves the | |
| 4 | // server except inside a claimed build's environment. | |
| 5 | func (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 | ||
| 13 | func (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. | |
| 25 | func (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. | |
| 43 | func (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. | |
| 61 | type 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. | |
| 70 | func (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. | |
| 113 | func (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. | |
| 132 | func (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 | ||
| 138 | func (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 @@ | ||
| 1 | DROP TABLE build_secrets; | |
| 2 | DROP 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. | |
| 3 | CREATE 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. | |
| 11 | CREATE 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 { | ||
| 10 | 10 | } |
| 11 | 11 | |
| 12 | 12 | // SetCommitStatus upserts the latest state for one context on one commit. |
| 13 | // A zero creatorID records no creator (system actions like the scheduler). | |
| 13 | 14 | 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 | } | |
| 14 | 19 | _, err := s.DB.Exec(` |
| 15 | 20 | INSERT INTO commit_statuses (repo_id, commit_sha, context, state, description, target_url, creator_id) |
| 16 | 21 | VALUES (?, ?, ?, ?, ?, ?, ?) |
| @@ -18,7 +23,7 @@ func (s *Store) SetCommitStatus(repoID int64, sha, context, state, description, | ||
| 18 | 23 | state = excluded.state, description = excluded.description, |
| 19 | 24 | target_url = excluded.target_url, creator_id = excluded.creator_id, |
| 20 | 25 | 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) | |
| 22 | 27 | return err |
| 23 | 28 | } |
| 24 | 29 | |