detached 388d3be

ci_workflow_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"testing"
7	"time"
8
9	"connectrpc.com/connect"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12
13	pb "bnema.dev/ermite-api/gen/ermite"
14	"bnema.dev/ermite/internal/domain"
15	"bnema.dev/ermite/internal/usecase"
16)
17
18// `ermite workflow disable|enable|list` (P8, D5): a disabled workflow starts no
19// run for any event, and enabling it restores it.
20
21const releaseWF = ".github/workflows/release.yml"
22
23// wfOn is a workflow file with the given "on:" block (indented under on:).
24func wfOn(name, on string) string {
25	return "name: " + name + "\non:\n" + on + "jobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"
26}
27
28func (c *ciHarness) setWorkflow(api *clients, disable bool, file string) (*pb.Workflow, error) {
29	c.t.Helper()
30	if disable {
31		res, err := api.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: c.repo, Workflow: file}))
32		if err != nil {
33			return nil, err
34		}
35		return res.Msg.GetWorkflow(), nil
36	}
37	res, err := api.run.EnableWorkflow(c.ctx, connect.NewRequest(&pb.EnableWorkflowRequest{Repo: c.repo, Workflow: file}))
38	if err != nil {
39		return nil, err
40	}
41	return res.Msg.GetWorkflow(), nil
42}
43
44func (c *ciHarness) listWorkflows(api *clients) map[string]*pb.Workflow {
45	c.t.Helper()
46	res, err := api.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo}))
47	require.NoError(c.t, err)
48	out := map[string]*pb.Workflow{}
49	for _, w := range res.Msg.GetWorkflows() {
50		out[w.GetPath()] = w
51	}
52	return out
53}
54
55func runsOfWorkflow(runs []*pb.Run, path string) []*pb.Run {
56	var out []*pb.Run
57	for _, r := range runs {
58		if r.GetWorkflowPath() == path {
59			out = append(out, r)
60		}
61	}
62	return out
63}
64
65// Disabled before the first push, on an empty repository where the file does
66// not exist yet: the push of many refs starts nothing of it, the other
67// workflows still run, and enabling restores it.
68func TestWorkflowDisabledBeforeFirstPushStartsNoRun(t *testing.T) {
69	c := newCIHarness(t, nil)
70	runner := c.registerRunner("alpha", "ubuntu-latest")
71
72	w, err := c.setWorkflow(c.root, true, "release.yml")
73	require.NoError(t, err, "the repository is empty and has no such file")
74	assert.Equal(t, releaseWF, w.GetPath(), "stored by its normalized path")
75	assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, w.GetState())
76	_, err = c.setWorkflow(c.root, true, releaseWF)
77	require.NoError(t, err, "idempotent, by path too")
78
79	// The list shows it though no branch holds the file.
80	listed := c.listWorkflows(c.root)
81	require.Len(t, listed, 1)
82	assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState())
83	assert.Empty(t, listed[releaseWF].GetName(), "no file, no name")
84
85	push := wfOn("release", "  push:\n    tags: ['v*']\n    branches: [main]\n")
86	c.push("main", map[string]string{
87		releaseWF:                      push,
88		".github/workflows/z-ctrl.yml": wfOn("control", "  push:\n    branches: [main]\n"),
89	}, "")
90	c.waitFor("the control run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 1 })
91	assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF), "a disabled workflow starts no push run")
92	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ctrl")
93
94	// Tags: many at once.
95	for _, tag := range []string{"v1", "v2", "v3"} {
96		c.mustGit(c.wt, "tag", tag)
97	}
98	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/"+c.repo+".git"), "v1", "v2", "v3")
99	c.push("main", map[string]string{"x.txt": "x"}, "")
100	c.waitFor("the control run of the next push", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 2 })
101	assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF))
102
103	listed = c.listWorkflows(c.root)
104	assert.Equal(t, "release", listed[releaseWF].GetName(), "the file now exists and has a name")
105	assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState())
106	assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, listed[".github/workflows/z-ctrl.yml"].GetState())
107
108	// Enabling restores it.
109	w, err = c.setWorkflow(c.root, false, "release.yml")
110	require.NoError(t, err)
111	assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, w.GetState())
112	_, err = c.setWorkflow(c.root, false, "release.yml")
113	require.NoError(t, err, "idempotent")
114	assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, c.listWorkflows(c.root)[releaseWF].GetState())
115	c.push("main", map[string]string{"y.txt": "y"}, "")
116	c.waitFor("the release run", func() bool { return len(runsOfWorkflow(c.runs(), releaseWF)) == 1 })
117}
118
119// A disabled workflow does not fire on its schedule, and fires again once
120// enabled, with no reload of the schedules.
121func TestWorkflowDisabledStartsNoScheduledRun(t *testing.T) {
122	c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30")
123	c.push("main", scheduleWorkflow("*/5 * * * *"), "")
124	ci := c.forge.CI
125	tick := func(at string) {
126		clk.set(at)
127		ci.ScheduleNow(c.ctx)
128	}
129	tick("2026-03-04 10:05:00")
130	require.Len(t, c.scheduled(), 1, "enabled: fires")
131	assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
132
133	_, err := c.setWorkflow(c.root, true, "nightly.yml")
134	require.NoError(t, err)
135	assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")), "the cache holds every workflow: the state is read when it fires")
136	tick("2026-03-04 10:10:00")
137	tick("2026-03-04 10:15:00")
138	assert.Len(t, c.scheduled(), 1, "disabled: no run for the minutes 10:10 and 10:15")
139
140	// A push of the file while disabled changes nothing.
141	c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "again")
142	tick("2026-03-04 10:20:00")
143	assert.Len(t, c.scheduled(), 1)
144
145	_, err = c.setWorkflow(c.root, false, "nightly.yml")
146	require.NoError(t, err)
147	tick("2026-03-04 10:25:00")
148	assert.Len(t, c.scheduled(), 2, "enabled again: fires, without catching up the missed minutes")
149}
150
151// workflow_dispatch and pull_request are refused/skipped too, per file.
152func TestWorkflowDisabledStartsNoDispatchOrPullRequestRun(t *testing.T) {
153	c := newCIHarness(t, nil)
154	runner := c.registerRunner("alpha", "ubuntu-latest")
155	c.push("main", map[string]string{
156		".github/workflows/deploy.yml": dispatchWorkflow,
157		".github/workflows/pr.yml":     wfOn("pr", "  pull_request:\n"),
158		".github/workflows/other.yml":  wfOn("other", "  pull_request:\n"),
159	}, "")
160	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
161	c.push("feat", map[string]string{"f.txt": "f"}, "")
162
163	_, err := c.setWorkflow(c.root, true, "deploy.yml")
164	require.NoError(t, err)
165	_, err = c.setWorkflow(c.root, true, ".github/workflows/pr.yml")
166	require.NoError(t, err)
167
168	_, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
169	assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "a disabled workflow is not dispatched")
170	assert.Contains(t, err.Error(), "disabled")
171
172	c.openMR(c.root, "feat")
173	c.waitFor("the other workflow's run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/other.yml")) == 1 })
174	assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/pr.yml"), "no pull_request run")
175	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "other")
176
177	_, err = c.setWorkflow(c.root, false, "deploy.yml")
178	require.NoError(t, err)
179	run, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
180	require.NoError(t, err)
181	assert.Equal(t, "workflow_dispatch", run.GetEvent())
182}
183
184// A disabled workflow does not start a workflow_run workflow.
185func TestWorkflowDisabledStartsNoWorkflowRunRun(t *testing.T) {
186	c := newCIHarness(t, nil)
187	runner := c.registerRunner("alpha", "ubuntu-latest")
188	c.push("main", map[string]string{
189		".github/workflows/ci.yml":     wfOn("ci", "  push:\n"),
190		".github/workflows/after.yml":  wfOn("after", "  workflow_run:\n    workflows: [ci]\n    types: [completed]\n"),
191		".github/workflows/after2.yml": wfOn("after2", "  workflow_run:\n    workflows: [ci]\n    types: [completed]\n"),
192	}, "")
193	_, err := c.setWorkflow(c.root, true, "after.yml")
194	require.NoError(t, err)
195	// Pushed before the disable took effect? The first push ran ci: finish it.
196	job := runner.await()
197	runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ci")
198	c.waitFor("after2 runs", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/after2.yml")) == 1 })
199	assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/after.yml"))
200}
201
202// Scopes: repo:admin changes the state, repo:read lists, ci:write and
203// repo:write do not change it; the shape is checked, not the existence.
204func TestWorkflowStateAccessAndValidation(t *testing.T) {
205	c := newCIHarness(t, nil)
206	admin := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), "proj"))
207	writer := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), "proj"))
208	reader := c.api(c.token(scopes(domain.ScopeRepoRead), "proj"))
209	stranger := c.api(c.token(scopes(domain.ScopeRepoAdmin), "other"))
210	limited, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/"}, "proj")
211
212	_, err := c.setWorkflow(admin, true, "release.yml")
213	require.NoError(t, err)
214	_, err = c.setWorkflow(writer, true, "aur.yml")
215	assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "repo:write and ci:write are not enough")
216	_, err = c.setWorkflow(writer, false, "release.yml")
217	assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
218	_, err = c.setWorkflow(reader, true, "aur.yml")
219	assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
220	_, err = c.setWorkflow(c.api(limited), true, "aur.yml")
221	assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "a token limited to branches may not")
222	_, err = c.setWorkflow(stranger, true, "aur.yml")
223	assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "a repository the token cannot read")
224
225	for _, bad := range []string{"", "release", "release.txt", "sub/release.yml", ".github/workflows/sub/release.yml", "../release.yml", ".github/other/release.yml"} {
226		_, err = c.setWorkflow(admin, true, bad)
227		assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "%q", bad)
228	}
229
230	assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, c.listWorkflows(reader)[releaseWF].GetState(), "repo:read lists")
231	assert.Len(t, c.listWorkflows(reader), 1, "the refused changes left nothing")
232	_, err = stranger.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo}))
233	assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
234	_, err = c.setWorkflow(admin, true, ".github/workflows/Odd Name.YAML")
235	require.NoError(t, err, "only the shape is checked")
236
237	_, err = admin.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: "nope", Workflow: "a.yml"}))
238	assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
239}
240
241// GO-001: the state is enforced where the run is stored. A dispatch that passed
242// the check and is paused after resolving its ref (DispatchResolved) while the
243// workflow is disabled creates no run.
244func TestWorkflowDisabledBetweenDispatchCheckAndCreation(t *testing.T) {
245	var c *ciHarness
246	var disable func()
247	c = newCIHarness(t, func(cfg *usecase.CIConfig) {
248		cfg.DispatchResolved = func() {
249			if disable != nil {
250				disable()
251			}
252		}
253	})
254	c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
255	before := len(c.runs())
256
257	disable = func() {
258		disable = nil
259		_, err := c.setWorkflow(c.root, true, "deploy.yml")
260		require.NoError(t, err)
261	}
262	_, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
263	assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "the disable landed after the check")
264	assert.Contains(t, err.Error(), "disabled")
265	assert.Len(t, c.runs(), before, "no run was created")
266
267	_, err = c.setWorkflow(c.root, false, "deploy.yml")
268	require.NoError(t, err)
269	_, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
270	require.NoError(t, err, "enabled again")
271}
272
273// GO-001: an invalid workflow file gets no failed run either while it is
274// disabled, on a push and on the failed-run path of a disable that races.
275func TestWorkflowDisabledInvalidFileStartsNoFailedRun(t *testing.T) {
276	c := newCIHarness(t, nil)
277	c.registerRunner("alpha", "ubuntu-latest")
278	_, err := c.setWorkflow(c.root, true, "broken.yml")
279	require.NoError(t, err)
280	c.push("main", map[string]string{
281		".github/workflows/broken.yml": "on: push\njobs: [this is not a workflow\n",
282		".github/workflows/bad2.yml":   "on: push\njobs: [this is not a workflow\n",
283	}, "")
284	c.waitFor("the invalid enabled file fails", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/bad2.yml")) == 1 })
285	assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/broken.yml"), "a disabled invalid file reports nothing")
286}
287
288// GO-002: a schedule load that is overtaken by an enable (or a disable) neither
289// loses the workflow nor fires a disabled one: the cache holds every workflow
290// and the state is read when the schedule fires.
291func TestScheduleLoadRacingWithEnableAndDisable(t *testing.T) {
292	clk := &testClock{}
293	clk.set("2026-03-04 10:00:30")
294	var c *ciHarness
295	var inLoad func()
296	c = newCIHarness(t, func(cfg *usecase.CIConfig) {
297		cfg.Clock = clk
298		cfg.ScheduleTick = time.Hour
299		cfg.ScheduleLoaded = func(domain.RepoName) {
300			if f := inLoad; f != nil {
301				inLoad = nil
302				f()
303			}
304		}
305	})
306	c.registerRunner("alpha", "ubuntu-latest")
307	ci := c.forge.CI
308	tick := func(at string) {
309		clk.set(at)
310		ci.ScheduleNow(c.ctx)
311	}
312	// Disabled before the first push; the load of the schedules sees the file,
313	// and an enable lands while it is being cached.
314	_, err := c.setWorkflow(c.root, true, "nightly.yml")
315	require.NoError(t, err)
316	c.push("main", scheduleWorkflow("*/5 * * * *"), "")
317	inLoad = func() {
318		_, err := c.setWorkflow(c.root, false, "nightly.yml")
319		require.NoError(t, err)
320	}
321	tick("2026-03-04 10:05:00")
322	require.Len(t, c.scheduled(), 1, "enabled while the load was running: the workflow is there")
323	assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
324
325	// And the other way: disabled while a load is running.
326	require.NoError(t, func() error { _, err := c.setWorkflow(c.root, false, "nightly.yml"); return err }())
327	c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "second") // the tip moves: a new load
328	inLoad = func() {
329		_, err := c.setWorkflow(c.root, true, "nightly.yml")
330		require.NoError(t, err)
331	}
332	tick("2026-03-04 10:10:00")
333	assert.Len(t, c.scheduled(), 1, "disabled while the load was running: it does not fire")
334	_, err = c.setWorkflow(c.root, false, "nightly.yml")
335	require.NoError(t, err)
336	tick("2026-03-04 10:15:00")
337	assert.Len(t, c.scheduled(), 2, "enabled again: fires with the cached entry, nothing missing")
338}
339
340// GO-003: one file has one key. Non-canonical spellings are refused by the API,
341// for disable, enable and dispatch alike.
342func TestWorkflowNonCanonicalPathsAreRefused(t *testing.T) {
343	c := newCIHarness(t, nil)
344	c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
345	for _, bad := range []string{
346		".github/workflows//release.yml", "./.github/workflows/release.yml", ".github/workflows/sub/../release.yml",
347		".github/workflows/./release.yml",
348	} {
349		_, err := c.setWorkflow(c.root, true, bad)
350		assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "disable %q", bad)
351		_, err = c.setWorkflow(c.root, false, bad)
352		assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "enable %q", bad)
353		_, err = c.dispatch(c.root, bad, "", nil)
354		assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "dispatch %q", bad)
355	}
356	assert.Empty(t, c.listWorkflows(c.root)[".github/workflows/release.yml"], "nothing was stored")
357	_, err := c.dispatch(c.root, ".github/workflows/deploy.yml", "", map[string]string{"version": "1"})
358	require.NoError(t, err, "the canonical path still dispatches")
359}

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 4

Tags 45

↑↓ moveenter openesc close