detached 5278df2

ci_reusable_review_test.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"testing"
8
9	"connectrpc.com/connect"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/mock"
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/mocks"
17	"bnema.dev/ermite/internal/usecase"
18)
19
20func TestCIReusableSecretOutputCannotForgeAReference(t *testing.T) {
21	c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
22	c.setSecret(repoScope("proj"), "UNPASSED", "private-value")
23	caller := `on: push
24jobs:
25  prep:
26    uses: ./.github/workflows/producer.yml
27  call:
28    needs: prep
29    uses: ./.github/workflows/callee.yml
30    secrets:
31      TOKEN: ${{ needs.prep.outputs.payload }}
32`
33	producer := `on:
34  workflow_call:
35    outputs:
36      payload:
37        value: ${{ jobs.j.outputs.payload }}
38jobs:
39  j:
40    runs-on: ubuntu-latest
41    outputs:
42      payload: ${{ steps.s.outputs.payload }}
43    steps:
44      - id: s
45        run: echo
46`
47	c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/producer.yml": producer, ".github/workflows/callee.yml": secretsCallee}, "")
48	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
49	prep := awaitRecord(c, r, run.GetId(), "prep/j")
50	assert.Empty(t, prep.GetSecrets())
51	r.runJob(prep, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"payload": "\x01UNPASSED\x02"})
52	j := awaitRecord(c, r, run.GetId(), "call/j")
53	assert.NotEqual(t, "private-value", j.GetSecrets()["TOKEN"], "callee output must not mint a secret reference")
54	r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
55}
56
57func TestCIReusableSecretExpressions(t *testing.T) {
58	for _, tt := range []struct{ name, value, want string }{
59		{"literal", "literal-value", "literal-value"},
60		{"dynamic", "${{ secrets[vars.KEY] }}", "private-value"},
61		{"conditional", "${{ secrets.SOURCE == 'private-value' && 'yes' || 'no' }}", "yes"},
62		{"function", "${{ format('{0}-x', secrets.SOURCE) }}", "private-value-x"},
63		{"text around", "pre-${{ secrets.SOURCE }}-post", "pre-private-value-post"},
64	} {
65		t.Run(tt.name, func(t *testing.T) {
66			c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
67			c.setSecret(repoScope("proj"), "SOURCE", "private-value")
68			c.setVar(repoScope("proj"), "KEY", "SOURCE")
69			caller := "on: push\njobs:\n  call:\n    uses: ./.github/workflows/callee.yml\n    secrets:\n      TOKEN: " + tt.value + "\n"
70			c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee}, "")
71			run := c.awaitRunOf(".github/workflows/ci.yml", 0)
72			j := awaitRecord(c, r, run.GetId(), "call/j")
73			assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"])
74			r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
75		})
76	}
77}
78
79func TestCIReusableCallMaxParallel(t *testing.T) {
80	c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
81	caller := `on: push
82jobs:
83  c:
84    strategy:
85      max-parallel: 1
86      matrix:
87        v: [a,b]
88    uses: ./.github/workflows/callee.yml
89`
90	c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": ghCallee}, "")
91	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
92	j := awaitRecord(c, r, run.GetId(), "c/test")
93	second, err := r.fetch("")
94	require.NoError(t, err)
95	assert.Nil(t, second, "max-parallel: 1 must not assign the second call while the first is running")
96	r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
97	next := awaitRecord(c, r, run.GetId(), "c/test")
98	assert.NotEqual(t, j.GetJobId(), next.GetJobId(), "the second call starts once the first has ended")
99	r.runJob(next, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
100	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion())
101}
102
103func TestCIReusableOneResolutionPerAttempt(t *testing.T) {
104	remote := newFakeRemote(t)
105	remote.set("acme/shared", ptrRef("refs/heads/main", shaMain))
106	wf := &fakeWorkflows{m: mocks.NewWorkflowFetcher(t)}
107	wf.m.EXPECT().FetchWorkflow(mock.Anything, "acme", "shared", mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _, _, sha, path string, _ int64) ([]byte, error) {
108		remote.set("acme/shared", ptrRef("refs/heads/main", shaOther))
109		return []byte(ghCallee), nil
110	}).Maybe()
111	c, r := newReusableHarness(t, remote, wf)
112	c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n  a:\n    uses: acme/shared/.github/workflows/a.yml@main\n  b:\n    uses: acme/shared/.github/workflows/b.yml@main\n"}, "")
113	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
114	for range 2 {
115		j := r.await()
116		assert.Contains(t, j.GetContexts(), `"workflow_sha":"`+shaMain+`"`, "the first resolution must pin all references of the attempt")
117		r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
118	}
119	assert.Equal(t, 1, remote.count())
120	c.waitRunDone(run.GetId())
121}
122
123func TestCIReusableFailedRerunKeepsTheCallee(t *testing.T) {
124	remote, wf := newFakeRemote(t), newFakeWorkflows(t)
125	remote.set("acme/shared", ptrRef("refs/heads/main", shaMain))
126	wf.set("acme/shared@"+shaMain+":.github/workflows/a.yml", ghCallee)
127	wf.set("acme/shared@"+shaOther+":.github/workflows/a.yml", ghCallee)
128	c, r := newReusableHarness(t, remote, wf)
129	c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n  a:\n    uses: acme/shared/.github/workflows/a.yml@main\n"}, "")
130	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
131	j := r.await()
132	r.runJob(j, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil)
133	c.waitRunDone(run.GetId())
134	remote.set("acme/shared", ptrRef("refs/heads/main", shaOther))
135	_, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId(), FailedOnly: true}))
136	require.NoError(t, err)
137	again := r.await()
138	assert.Contains(t, again.GetContexts(), `"workflow_sha":"`+shaMain+`"`, "failed-job reruns must reuse the first attempt's callee commit")
139	r.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
140}
141
142func TestCIReusableJobRecordBudget(t *testing.T) {
143	c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
144	vals := func(n int) string {
145		v := make([]string, n)
146		for i := range v {
147			v[i] = fmt.Sprint(i)
148		}
149		return "[" + strings.Join(v, ",") + "]"
150	}
151	caller := "on: push\njobs:\n  c:\n    strategy:\n      matrix:\n        v: " + vals(33) + "\n    uses: ./.github/workflows/callee.yml\n"
152	callee := "on: workflow_call\njobs:\n  j:\n    strategy:\n      matrix:\n        v: " + vals(32) + "\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo\n"
153	c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "")
154	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
155	const budgetText = "Workflows may not contain more than 1000 jobs across all referenced files"
156	c.waitFor("the budget refusal", func() bool {
157		for _, j := range c.getRun(run.GetId()).GetJobs() {
158			if strings.Contains(j.GetError(), budgetText) {
159				return true
160			}
161		}
162		return false
163	})
164	assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), usecase.MaxCIChecks, "expanded records obey the job-count limit")
165}
166
167// The ordinary matrices of a workflow share the same budget.
168func TestCIJobRecordBudgetOfMatrices(t *testing.T) {
169	c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
170	v := make([]string, 200)
171	for i := range v {
172		v[i] = fmt.Sprint(i)
173	}
174	wf := "on: push\njobs:\n"
175	for _, id := range []string{"a", "b", "c", "d", "e", "f"} {
176		wf += "  " + id + ":\n    strategy:\n      matrix:\n        v: [" + strings.Join(v, ",") + "]\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo\n"
177	}
178	c.push("main", map[string]string{".github/workflows/ci.yml": wf}, "")
179	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
180	c.waitFor("a job fails on the budget", func() bool {
181		for _, j := range c.getRun(run.GetId()).GetJobs() {
182			if strings.Contains(j.GetError(), "Workflows may not contain more than 1000 jobs across all referenced files") {
183				return true
184			}
185		}
186		return false
187	})
188	assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), usecase.MaxCIChecks)
189}
190
191func TestCIReusableCachedGitHubResolutionDoesNotBypassErmite(t *testing.T) {
192	remote, wf := newFakeRemote(t), newFakeWorkflows(t)
193	remote.set("owner/shared", ptrRef("refs/heads/main", shaMain))
194	wf.set("owner/shared@"+shaMain+":.github/workflows/a.yml", ghCallee)
195	c, r := newReusableHarness(t, remote, wf)
196	token, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
197	caller := "on: push\njobs:\n  a:\n    uses: owner/shared/.github/workflows/a.yml@main\n  b:\n    needs: a\n    uses: owner/shared/.github/workflows/a.yml@main\n"
198	c.pushAs(token, "main", map[string]string{".github/workflows/ci.yml": caller})
199	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
200	first := awaitRecord(c, r, run.GetId(), "a/test")
201	c.seed("shared", domain.VisibilityPrivate)
202	r.runJob(first, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
203	second, err := r.fetch("")
204	require.NoError(t, err)
205	assert.True(t, second == nil, "must not use the cached GitHub source after an unreadable ermite name collision")
206	if second != nil {
207		r.runJob(second, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
208	}
209}
210
211// S2: a literal in "secrets:" arrives although no stored secret resolves; a run
212// that is not trusted (a branch-limited token pushed it) gets nothing.
213func TestCIReusableLiteralSecretNeedsATrustedRun(t *testing.T) {
214	for _, tt := range []struct {
215		name    string
216		limited bool
217		want    string
218	}{{"owner", false, "literal-value"}, {"limited token", true, ""}} {
219		t.Run(tt.name, func(t *testing.T) {
220			c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
221			caller := "on: push\njobs:\n  call:\n    uses: ./.github/workflows/callee.yml\n    secrets:\n      TOKEN: literal-value\n"
222			files := map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee}
223			if tt.limited {
224				tok, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
225				c.pushAs(tok, "main", files)
226			} else {
227				c.push("main", files, "")
228			}
229			run := c.awaitRunOf(".github/workflows/ci.yml", 0)
230			j := awaitRecord(c, r, run.GetId(), "call/j")
231			assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"])
232			r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
233		})
234	}
235}
236
237// T3: the name of a nested job carries every caller.
238func TestCIReusableNestedNames(t *testing.T) {
239	c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
240	leaf := "on:\n  workflow_call:\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo\n"
241	mid := "on:\n  workflow_call:\njobs:\n  inner:\n    uses: ./.github/workflows/leaf.yml\n"
242	c.push("main", map[string]string{".github/workflows/leaf.yml": leaf, ".github/workflows/mid.yml": mid, ".github/workflows/ci.yml": "on: push\njobs:\n  top:\n    uses: ./.github/workflows/mid.yml\n"}, "")
243	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
244	j := awaitRecord(c, r, run.GetId(), "top/inner/j")
245	names := map[string]string{}
246	for _, x := range c.getRun(run.GetId()).GetJobs() {
247		names[x.GetKey()] = x.GetName()
248	}
249	assert.Equal(t, map[string]string{"top": "top", "top/inner": "top / inner", "top/inner/j": "top / inner / j"}, names)
250	r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
251}
252
253// S1/S3: secrets pass through two levels of calls, each mapping evaluated at
254// its own level.
255func TestCIReusableSecretsThroughTwoLevels(t *testing.T) {
256	c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
257	c.setSecret(repoScope("proj"), "TOP", "top-value")
258	leaf := "on:\n  workflow_call:\n    secrets:\n      LEAF:\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo ${{ secrets.LEAF }}\n"
259	mid := "on:\n  workflow_call:\n    secrets:\n      MID:\njobs:\n  inner:\n    uses: ./.github/workflows/leaf.yml\n    secrets:\n      LEAF: ${{ secrets.MID }}-leaf\n"
260	c.push("main", map[string]string{".github/workflows/leaf.yml": leaf, ".github/workflows/mid.yml": mid, ".github/workflows/ci.yml": "on: push\njobs:\n  top:\n    uses: ./.github/workflows/mid.yml\n    secrets:\n      MID: ${{ secrets.TOP }}\n"}, "")
261	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
262	j := awaitRecord(c, r, run.GetId(), "top/inner/j")
263	assert.Equal(t, map[string]string{"LEAF": "top-value-leaf"}, j.GetSecrets())
264	r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
265}

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 45

↑↓ moveenter openesc close