detached 388d3be

ci_workflowrun_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"fmt"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/assert"
11
12	pb "bnema.dev/ermite-api/gen/ermite"
13	"bnema.dev/ermite/internal/domain"
14	"bnema.dev/ermite/internal/usecase"
15)
16
17// CI-3 D5, AC5: workflow_run.
18
19const triggeringWorkflow = "name: ci\non: push\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"
20
21func followWorkflow(name, on string) map[string]string {
22	return map[string]string{".github/workflows/" + name + ".yml": fmt.Sprintf(
23		"name: %s\non:\n  workflow_run:\n%s\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n", name, on)}
24}
25
26func merge(maps ...map[string]string) map[string]string {
27	out := map[string]string{}
28	for _, m := range maps {
29		for k, v := range m {
30			out[k] = v
31		}
32	}
33	return out
34}
35
36func (c *ciHarness) runsOf(workflowName string) []*pb.Run {
37	var out []*pb.Run
38	for _, r := range c.runs() {
39		if r.GetWorkflowName() == workflowName {
40			out = append(out, r)
41		}
42	}
43	return out
44}
45
46func TestWorkflowRunRequestedCompletedFiltersAndPayload(t *testing.T) {
47	c := newCIHarness(t, nil)
48	runner := c.registerRunner("alpha", "ubuntu-latest")
49	c.push("main", merge(
50		map[string]string{".github/workflows/ci.yml": triggeringWorkflow},
51		followWorkflow("after", "    workflows: [ci]\n    types: [completed]\n    branches: [main]"),
52		followWorkflow("started", "    workflows: [ci]\n    types: [requested]"),
53		followWorkflow("release-only", "    workflows: [ci]\n    branches: [release]"),
54		followWorkflow("other-name", "    workflows: [nobody]"),
55	), "")
56	c.waitFor("ci and the run it requested", func() bool { return len(c.runs()) == 2 })
57	assert.Len(t, c.runsOf("started"), 1, "requested fires when the run is created")
58	assert.Empty(t, c.runsOf("after"), "completed has not happened yet")
59
60	// Both jobs are given to the runner: finish them.
61	for range 2 {
62		j := runner.await()
63		runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "ok")
64	}
65	c.waitFor("the completed run", func() bool { return len(c.runsOf("after")) == 1 })
66	assert.Empty(t, c.runsOf("release-only"), "the branches filter applies to the head branch of the triggering run")
67	assert.Empty(t, c.runsOf("other-name"))
68	assert.Len(t, c.runsOf("started"), 1, "requested does not fire on completion")
69
70	after := c.runsOf("after")[0]
71	assert.Equal(t, "workflow_run", after.GetEvent())
72	assert.Equal(t, "refs/heads/main", after.GetRef(), "it runs on the default branch")
73	assert.True(t, after.GetTrusted())
74	main := c.ref("refs/heads/main")
75	assert.Equal(t, main, after.GetSha())
76
77	job := runner.await()
78	gh := c.ghContext(job)
79	assert.Equal(t, "workflow_run", gh["event_name"])
80	ev := gh["event"].(map[string]any)
81	assert.Equal(t, "completed", ev["action"])
82	wr := ev["workflow_run"].(map[string]any)
83	trig := c.runsOf("ci")[0]
84	assert.EqualValues(t, trig.GetId(), wr["id"])
85	assert.Equal(t, "ci", wr["name"])
86	assert.Equal(t, "main", wr["head_branch"])
87	assert.Equal(t, main, wr["head_sha"])
88	assert.Equal(t, "push", wr["event"])
89	assert.Equal(t, "completed", wr["status"])
90	assert.Equal(t, "failure", wr["conclusion"], "the conclusion of the triggering run")
91	assert.EqualValues(t, trig.GetNumber(), wr["run_number"])
92	assert.EqualValues(t, 1, wr["run_attempt"])
93	assert.Equal(t, ".github/workflows/ci.yml", wr["path"])
94	assert.NotEmpty(t, wr["actor"].(map[string]any)["login"])
95	wf := ev["workflow"].(map[string]any)
96	assert.Equal(t, "ci", wf["name"])
97	assert.Equal(t, ".github/workflows/ci.yml", wf["path"])
98	runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
99}
100
101func TestWorkflowRunInheritsTrustAndNeverRaisesIt(t *testing.T) {
102	c := newCIHarness(t, nil)
103	runner := c.registerRunner("alpha", "ubuntu-latest")
104	c.push("main", merge(
105		map[string]string{".github/workflows/ci.yml": triggeringWorkflow},
106		followWorkflow("after", "    workflows: [ci]\n    types: [completed]"),
107	), "")
108	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
109	c.waitFor("the owner's workflow_run", func() bool { return len(c.runsOf("after")) == 1 })
110	assert.True(t, c.runsOf("after")[0].GetTrusted())
111	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
112
113	// An agent pushes a branch: its push run is untrusted, and so is the
114	// workflow_run it triggers, though that workflow is the owner's, on main.
115	secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/claude/"}, "proj")
116	c.mustGit(c.wt, "checkout", "-q", "-b", "agent/claude/x")
117	c.writeFile(c.wt, "a.txt", []byte("a"))
118	c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/claude/x")
119	c.waitFor("the agent's push run", func() bool { return len(c.runsOf("ci")) == 2 })
120	agentRun := c.runsOf("ci")[0]
121	assert.False(t, agentRun.GetTrusted())
122	job := runner.await()
123	runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
124	c.waitFor("the agent's workflow_run", func() bool { return len(c.runsOf("after")) == 2 })
125	follow := c.runsOf("after")[0]
126	assert.False(t, follow.GetTrusted(), "never raised")
127	assert.Equal(t, agentRun.GetActor(), follow.GetActor(), "the actor of the triggering run")
128	next := runner.await()
129	assert.False(t, next.GetTrusted())
130	assert.Empty(t, next.GetSecrets())
131	assert.Equal(t, "agent/claude/x", c.ghContext(next)["event"].(map[string]any)["workflow_run"].(map[string]any)["head_branch"])
132	runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
133}
134
135func TestWorkflowRunChainStopsAtDepthThree(t *testing.T) {
136	// handled tells when the workflows waiting for an event have been looked at:
137	// a barrier instead of a wait for something that must not happen.
138	handled := make(chan [2]any, 64)
139	c := newCIHarness(t, func(cfg *usecase.CIConfig) {
140		cfg.RunEventHandled = func(id int64, action string) { handled <- [2]any{id, action} }
141	})
142	runner := c.registerRunner("alpha", "ubuntu-latest")
143	files := map[string]string{".github/workflows/a.yml": "name: a\non: push\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"}
144	prev := "a"
145	for _, next := range []string{"b", "c", "d", "e"} {
146		files = merge(files, followWorkflow(next, "    workflows: ["+prev+"]\n    types: [completed]"))
147		prev = next
148	}
149	c.push("main", files, "")
150	for _, name := range []string{"a", "b", "c", "d"} {
151		c.waitFor("the run of "+name, func() bool { return len(c.runsOf(name)) == 1 })
152		runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
153	}
154	c.waitFor("d completes", func() bool { return c.runsOf("d")[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
155	// The next level would be e: three workflow_run levels (b, c, d) are the most.
156	// Wait until the "completed" event of d was handled, then look: e did not start.
157	dID := c.runsOf("d")[0].GetId()
158	for done := false; !done; {
159		select {
160		case ev := <-handled:
161			done = ev == [2]any{dID, "completed"}
162		case <-time.After(15 * time.Second):
163			t.Fatal("the completed event of d was never handled")
164		}
165	}
166	assert.Empty(t, c.runsOf("e"), "a chain of more than three levels stops")
167	assert.Len(t, c.runs(), 4)
168}

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