v0.17.0 ab8e178

ci_schedule_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"sync"
7	"testing"
8	"time"
9
10	"connectrpc.com/connect"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	pb "bnema.dev/ermite-api/gen/ermite"
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17	"bnema.dev/ermite/internal/usecase"
18)
19
20// CI-3 D5, AC5: schedule, with an injected clock.
21
22// testClock is a clock the test moves.
23type testClock struct {
24	mu sync.Mutex
25	t  time.Time
26}
27
28func (c *testClock) Now() time.Time {
29	c.mu.Lock()
30	defer c.mu.Unlock()
31	return c.t
32}
33
34func (c *testClock) set(s string) {
35	c.mu.Lock()
36	defer c.mu.Unlock()
37	t, err := time.Parse("2006-01-02 15:04:05", s)
38	if err != nil {
39		panic(err)
40	}
41	c.t = t
42}
43
44func scheduleWorkflow(crons ...string) map[string]string {
45	on := "on:\n  schedule:\n"
46	for _, c := range crons {
47		on += "    - cron: '" + c + "'\n"
48	}
49	return map[string]string{".github/workflows/nightly.yml": "name: nightly\n" + on + "jobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"}
50}
51
52func newScheduleHarness(t *testing.T, start string) (*ciHarness, *testClock, *fakeRunner) {
53	t.Helper()
54	clk := &testClock{}
55	clk.set(start)
56	c := newCIHarness(t, func(cfg *usecase.CIConfig) {
57		cfg.Clock = clk
58		cfg.ScheduleTick = time.Hour // the tests call ScheduleNow
59	})
60	return c, clk, c.registerRunner("alpha", "ubuntu-latest")
61}
62
63func createRepoParams(name domain.RepoName) ports.CreateRepoParams {
64	return ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate}
65}
66
67func (c *ciHarness) scheduled() []*pb.Run { return scheduleRuns(c.runsOf("nightly")) }
68
69func scheduleRuns(runs []*pb.Run) []*pb.Run {
70	var out []*pb.Run
71	for _, r := range runs {
72		if r.GetEvent() == "schedule" {
73			out = append(out, r)
74		}
75	}
76	return out
77}
78
79func TestScheduleFiresOncePerMatchingMinuteWithoutCatchUp(t *testing.T) {
80	c, clk, runner := newScheduleHarness(t, "2026-03-04 10:00:30")
81	c.push("main", scheduleWorkflow("*/5 * * * *"), "")
82	ci := c.forge.CI
83	tick := func(at string) {
84		clk.set(at)
85		ci.ScheduleNow(c.ctx)
86	}
87
88	tick("2026-03-04 10:00:40")
89	assert.Empty(t, c.scheduled(), "the minute ermite started in belongs to the process before it")
90
91	tick("2026-03-04 10:05:10")
92	require.Len(t, c.scheduled(), 1)
93	run := c.scheduled()[0]
94	assert.Equal(t, "schedule", run.GetEvent())
95	assert.Equal(t, "refs/heads/main", run.GetRef())
96	assert.Equal(t, c.ref("refs/heads/main"), run.GetSha(), "the latest commit of the default branch")
97	assert.Equal(t, "root", run.GetActor(), "whoever pushed the tip (the root token here)")
98	assert.True(t, run.GetTrusted())
99
100	tick("2026-03-04 10:05:50")
101	tick("2026-03-04 10:05:59")
102	assert.Len(t, c.scheduled(), 1, "a minute fires once, however many ticks it holds")
103	tick("2026-03-04 10:06:00")
104	tick("2026-03-04 10:09:00")
105	assert.Len(t, c.scheduled(), 1, "minutes that do not match fire nothing")
106
107	// ermite was busy or down for the minutes 10:10 and 10:15: they are not made up.
108	tick("2026-03-04 10:20:00")
109	assert.Len(t, c.scheduled(), 2, "only the minute the clock reached fires, not the ones skipped")
110	assert.Equal(t, time.Date(2026, 3, 4, 10, 20, 0, 0, time.UTC), ci.SchedulePassed())
111
112	job := runner.await()
113	gh := c.ghContext(job)
114	assert.Equal(t, "schedule", gh["event_name"])
115	assert.Equal(t, "refs/heads/main", gh["ref"])
116	assert.Equal(t, "*/5 * * * *", gh["event"].(map[string]any)["schedule"], "the payload is the cron entry")
117	assert.True(t, job.GetTrusted())
118}
119
120// Several cron entries of one workflow that match the same minute start one
121// run, with the first matching entry as payload; an entry that matches another
122// minute starts its own.
123func TestScheduleSeveralMatchingCronsOfOneWorkflowStartOneRun(t *testing.T) {
124	c, clk, runner := newScheduleHarness(t, "2026-03-04 10:00:30")
125	c.push("main", scheduleWorkflow("*/2 * * * *", "* * * * *", "7 10 * * *"), "")
126	ci := c.forge.CI
127
128	clk.set("2026-03-04 10:02:00") // the first and the second entries match
129	ci.ScheduleNow(c.ctx)
130	require.Len(t, c.scheduled(), 1, "one run for the workflow, not one per entry")
131	job := runner.await()
132	assert.Equal(t, "*/2 * * * *", c.ghContext(job)["event"].(map[string]any)["schedule"], "the first entry that matches")
133	runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
134
135	clk.set("2026-03-04 10:03:00") // only the second
136	ci.ScheduleNow(c.ctx)
137	require.Len(t, c.scheduled(), 2)
138	job = runner.await()
139	assert.Equal(t, "* * * * *", c.ghContext(job)["event"].(map[string]any)["schedule"])
140	runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
141}
142
143func TestScheduleReadsAgainAfterAPushToTheDefaultBranch(t *testing.T) {
144	c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30")
145	c.push("main", scheduleWorkflow("*/5 * * * *"), "")
146	ci := c.forge.CI
147	tick := func(at string) {
148		clk.set(at)
149		ci.ScheduleNow(c.ctx)
150	}
151	tick("2026-03-04 10:05:00")
152	require.Len(t, c.scheduled(), 1)
153	assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")), "the schedules are cached")
154
155	// 10:07 matches no */5 entry; the new file fires every minute.
156	c.push("main", scheduleWorkflow("* * * * *"), "")
157	tick("2026-03-04 10:06:00")
158	require.Len(t, c.scheduled(), 2, "the pushed workflow is the one scheduled")
159	assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
160
161	// A push to another branch changes nothing; a push that removes the schedule
162	// ends it.
163	c.mustGit(c.wt, "checkout", "-q", "-b", "side")
164	c.push("side", scheduleWorkflow("0 0 1 1 *"), "")
165	tick("2026-03-04 10:07:00")
166	require.Len(t, c.scheduled(), 3, "the default branch's workflow still fires every minute")
167
168	c.mustGit(c.wt, "checkout", "-q", "main")
169	c.push("main", map[string]string{".github/workflows/nightly.yml": "name: nightly\non: push\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"}, "")
170	tick("2026-03-04 10:08:00")
171	c.waitFor("the push run of the new file", func() bool { return len(c.runs()) == 4 })
172	assert.Len(t, scheduleRuns(c.runs()), 3, "no schedule left on the default branch")
173	assert.Equal(t, 0, ci.CachedSchedules(c.name("proj")))
174}
175
176func TestScheduleIsRemovedWithTheRepository(t *testing.T) {
177	c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30")
178	c.push("main", scheduleWorkflow("* * * * *"), "")
179	ci := c.forge.CI
180	clk.set("2026-03-04 10:01:00")
181	ci.ScheduleNow(c.ctx)
182	require.Len(t, c.scheduled(), 1)
183	require.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
184
185	_, err := c.root.repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
186	require.NoError(t, err)
187	assert.Equal(t, -1, ci.CachedSchedules(c.name("proj")), "the cached schedules go with the repository")
188
189	for _, at := range []string{"2026-03-04 10:02:00", "2026-03-04 10:03:00"} {
190		clk.set(at)
191		ci.ScheduleNow(c.ctx)
192	}
193	assert.Equal(t, -1, ci.CachedSchedules(c.name("proj")), "never scheduled again")
194
195	// The same name created again starts empty: no schedule until it has a workflow.
196	_, err = c.forge.Repos.Create(c.ctx, domain.Root(), createRepoParams(c.name("proj")))
197	require.NoError(t, err)
198	clk.set("2026-03-04 10:04:00")
199	ci.ScheduleNow(c.ctx)
200	assert.Empty(t, c.scheduled())
201}
202
203// More repositories than one tick can handle, and a new minute at every tick:
204// each pass is dropped after its first repository, and the cursor makes the next
205// one start after it, so that none is starved.
206func TestScheduleDoesNotStarveRepositoriesBeyondTheBudget(t *testing.T) {
207	clk := &testClock{}
208	clk.set("2026-03-04 10:00:30")
209	visited := map[domain.RepoName]bool{}
210	c := newCIHarness(t, func(cfg *usecase.CIConfig) {
211		cfg.Clock = clk
212		cfg.ScheduleTick = time.Hour
213		cfg.ScheduleBudget = 1
214		cfg.ScheduleVisit = func(r domain.RepoName) { visited[r] = true }
215	})
216	c.registerRunner("alpha", "ubuntu-latest")
217	names := []domain.RepoName{c.name("proj")}
218	for _, n := range []string{"aa", "zz", "mm", "bb"} {
219		names = append(names, c.name(n))
220		_, err := c.forge.Repos.Create(c.ctx, domain.Root(), createRepoParams(c.name(n)))
221		require.NoError(t, err)
222	}
223	ci := c.forge.CI
224	seen := func() int { return len(visited) }
225	minutes := []string{"10:01:00", "10:02:00", "10:03:00", "10:04:00", "10:05:00"}
226	for i, m := range minutes {
227		clk.set("2026-03-04 " + m)
228		ci.ScheduleNow(c.ctx) // one repository, then the next minute drops the pass
229		assert.Equal(t, i+1, seen(), "each minute reaches a repository no earlier minute reached")
230	}
231	assert.Equal(t, len(names), seen(), "every repository was processed")
232}
233
234func TestScheduleBoundsTheWorkPerTick(t *testing.T) {
235	clk := &testClock{}
236	clk.set("2026-03-04 10:00:30")
237	c := newCIHarness(t, func(cfg *usecase.CIConfig) {
238		cfg.Clock = clk
239		cfg.ScheduleTick = time.Hour
240		cfg.ScheduleBudget = 1
241	})
242	c.registerRunner("alpha", "ubuntu-latest")
243	c.push("main", scheduleWorkflow("* * * * *"), "")
244	for _, name := range []string{"two", "three"} {
245		_, err := c.forge.Repos.Create(c.ctx, domain.Root(), createRepoParams(c.name(name)))
246		require.NoError(t, err)
247	}
248	ci := c.forge.CI
249	clk.set("2026-03-04 10:01:00")
250	ci.ScheduleNow(c.ctx)
251	assert.True(t, ci.SchedulePassed().IsZero(), "one repository per tick: the pass is not over")
252	ci.ScheduleNow(c.ctx)
253	assert.True(t, ci.SchedulePassed().IsZero())
254	ci.ScheduleNow(c.ctx)
255	assert.Equal(t, time.Date(2026, 3, 4, 10, 1, 0, 0, time.UTC), ci.SchedulePassed(), "three ticks go through three repositories")
256	assert.Len(t, c.scheduled(), 1)
257}
258
259// CI-4 D6: the timezone of an entry is the wall clock its cron reads.
260func TestScheduleTimezone(t *testing.T) {
261	c, clk, _ := newScheduleHarness(t, "2026-06-01 06:58:30")
262	wf := map[string]string{".github/workflows/nightly.yml": "name: nightly\non:\n  schedule:\n    - cron: '0 9 * * *'\n      timezone: Europe/Paris\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"}
263	c.push("main", wf, "")
264	tick := func(at string) {
265		clk.set(at)
266		c.forge.CI.ScheduleNow(c.ctx)
267	}
268	tick("2026-06-01 06:59:10")
269	tick("2026-06-01 09:00:10")
270	assert.Empty(t, c.scheduled(), "09:00 UTC is 11:00 in Paris")
271	tick("2026-06-02 07:00:10")
272	require.Len(t, c.scheduled(), 1, "09:00 in Paris")
273}
274
275// A timezone that is no IANA name makes the workflow invalid: its push records
276// a failed run with the error, and it is never scheduled.
277func TestScheduleInvalidTimezone(t *testing.T) {
278	c, clk, _ := newScheduleHarness(t, "2026-06-01 06:58:30")
279	wf := map[string]string{".github/workflows/nightly.yml": "name: nightly\non:\n  push:\n  schedule:\n    - cron: '* * * * *'\n      timezone: Mars/Olympus\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"}
280	c.push("main", wf, "")
281	c.waitFor("the invalid run", func() bool { return len(c.runs()) == 1 })
282	run := c.runs()[0]
283	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run.GetConclusion())
284	assert.Equal(t, ".github/workflows/nightly.yml", run.GetName())
285	assert.Contains(t, c.getRun(run.GetId()).GetRun().GetError(), `.github/workflows/nightly.yml: invalid schedule timezone "Mars/Olympus"`)
286	clk.set("2026-06-01 07:00:10")
287	c.forge.CI.ScheduleNow(c.ctx)
288	assert.Empty(t, c.scheduled())
289}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close