v0.33.0 cf26365

ci_reusable_secrets_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"fmt"
7	"strings"
8	"testing"
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)
17
18const producerWorkflow = `on:
19  workflow_call:
20    outputs:
21      payload:
22        value: ${{ jobs.j.outputs.payload }}
23jobs:
24  j:
25    runs-on: ubuntu-latest
26    outputs:
27      payload: ${{ steps.s.outputs.payload }}
28    steps:
29      - id: s
30        run: echo
31`
32
33// secretCall runs a push whose caller maps TOKEN with value and returns the job
34// of the callee that was assigned.
35func secretCall(t *testing.T, setup func(c *ciHarness), value string) (*ciHarness, *fakeRunner, *pb.Job) {
36	t.Helper()
37	c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
38	if setup != nil {
39		setup(c)
40	}
41	caller := "on: push\njobs:\n  call:\n    uses: ./.github/workflows/callee.yml\n    secrets:\n      TOKEN: " + value + "\n"
42	c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee}, "")
43	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
44	return c, r, awaitRecord(c, r, run.GetId(), "call/j")
45}
46
47// GO-009: a string that an expression produces is data. It is not read again as
48// an expression, so it cannot name a secret.
49func TestCIReusableEscapedExpressionStaysALiteral(t *testing.T) {
50	c, r, j := secretCall(t, func(c *ciHarness) {
51		c.setSecret(repoScope("proj"), "UNPASSED", "private-value")
52	}, "${{ '${{ secrets.UNPASSED }}' }}")
53	assert.Equal(t, "${{ secrets.UNPASSED }}", j.GetSecrets()["TOKEN"])
54	assert.NotContains(t, c.logs.String(), "private-value")
55	r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
56}
57
58// GO-009: nor does a name taken from a context select a secret.
59func TestCIReusableContextDataCannotSelectASecret(t *testing.T) {
60	for _, tt := range []struct{ name, token, payload, want string }{
61		{"a text that looks like an expression", "${{ needs.prep.outputs.payload }}", "${{ secrets.UNPASSED }}", "${{ secrets.UNPASSED }}"},
62		{"a former marker", "${{ needs.prep.outputs.payload }}", "\x01UNPASSED\x02", "\x01UNPASSED\x02"},
63		{"an escaped expression over json", "${{ '${{ secrets[fromJSON(needs.prep.outputs.payload).name] }}' }}", `{"name":"UNPASSED"}`, "${{ secrets[fromJSON(needs.prep.outputs.payload).name] }}"},
64	} {
65		t.Run(tt.name, func(t *testing.T) {
66			c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
67			c.setSecret(repoScope("proj"), "UNPASSED", "private-value")
68			caller := "on: push\njobs:\n  prep:\n    uses: ./.github/workflows/producer.yml\n  call:\n    needs: prep\n    uses: ./.github/workflows/callee.yml\n    secrets:\n      TOKEN: " + tt.token + "\n"
69			c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/producer.yml": producerWorkflow, ".github/workflows/callee.yml": secretsCallee}, "")
70			run := c.awaitRunOf(".github/workflows/ci.yml", 0)
71			prep := awaitRecord(c, r, run.GetId(), "prep/j")
72			assert.Empty(t, prep.GetSecrets())
73			r.runJob(prep, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"payload": tt.payload})
74			j := awaitRecord(c, r, run.GetId(), "call/j")
75			assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"])
76			assert.NotEqual(t, "private-value", j.GetSecrets()["TOKEN"])
77			r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
78		})
79	}
80}
81
82// GO-009: an expression that selects a secret by data of the caller (an input of
83// the workflow, here) does use the secret it names.
84func TestCIReusableMappingUsesPlainContexts(t *testing.T) {
85	_, r, j := secretCall(t, func(c *ciHarness) {
86		c.setSecret(repoScope("proj"), "SOURCE", "private-value")
87		c.setVar(repoScope("proj"), "KEY", "SOURCE")
88	}, "${{ secrets[vars.KEY] }}")
89	assert.Equal(t, "private-value", j.GetSecrets()["TOKEN"])
90	r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
91}
92
93// GO-010: an expression error never reaches the log: format() puts its
94// arguments, here a secret, in its message.
95func TestCIReusableSecretEvaluationErrorIsNotLogged(t *testing.T) {
96	secret := "private-value{bad-format}"
97	c, r, j := secretCall(t, func(c *ciHarness) {
98		c.setSecret(repoScope("proj"), "SOURCE", secret)
99	}, "${{ format(secrets.SOURCE) }}")
100	assert.Empty(t, j.GetSecrets()["TOKEN"])
101	logs := c.logs.String()
102	assert.NotContains(t, logs, secret)
103	assert.NotContains(t, logs, "private-value")
104	assert.Contains(t, logs, "could not evaluate", "the mapping is named, with a fixed reason")
105	r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
106}
107
108// GO-011: planning does not evaluate the mapping, so a value that needs a real
109// secret (here a JSON one) is not refused for lack of it.
110func TestCIReusableMappingNeedsNoSecretAtPlanning(t *testing.T) {
111	c, r, j := secretCall(t, func(c *ciHarness) {
112		c.setSecret(repoScope("proj"), "JSON", `{"token":"private-value"}`)
113	}, "${{ fromJSON(secrets.JSON).token }}")
114	assert.Equal(t, "private-value", j.GetSecrets()["TOKEN"])
115	r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
116	_ = c
117}
118
119// GO-012: the mapping of a call reads the vars of the calling scope, not those
120// of the environment of the job that is assigned.
121func TestCIReusableMappingUsesTheCallersVars(t *testing.T) {
122	c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
123	c.setSecret(repoScope("proj"), "PUBLIC", "public-value")
124	c.setSecret(repoScope("proj"), "PRIVATE", "private-value")
125	c.setVar(repoScope("proj"), "KEY", "PUBLIC")
126	_, err := c.root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: "prod"}))
127	require.NoError(t, err)
128	c.setVar(envScope("proj", "prod"), "KEY", "PRIVATE")
129	caller := "on: push\njobs:\n  call:\n    uses: ./.github/workflows/callee.yml\n    secrets:\n      TOKEN: ${{ secrets[vars.KEY] }}\n"
130	callee := strings.Replace(secretsCallee, "    runs-on: ubuntu-latest", "    runs-on: ubuntu-latest\n    environment: prod", 1)
131	c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "")
132	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
133	j := awaitRecord(c, r, run.GetId(), "call/j")
134	assert.Equal(t, "public-value", j.GetSecrets()["TOKEN"])
135	r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
136}
137
138// An input handed down as an escaped expression is data in a callee that
139// inherits secrets, and an untrusted run gets nothing.
140func TestCIReusableInheritScopeTrustAndInputData(t *testing.T) {
141	for _, limited := range []bool{false, true} {
142		t.Run(map[bool]string{false: "trusted", true: "untrusted"}[limited], func(t *testing.T) {
143			c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
144			c.setSecret(repoScope("proj"), "TOP", "top-value")
145			c.setSecret(repoScope("proj"), "UNPASSED", "private-value")
146			leaf := "on:\n  workflow_call:\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo ${{ toJSON(secrets) }}\n"
147			mid := "on:\n  workflow_call:\n    inputs:\n      text:\n        type: string\n    secrets:\n      MID:\njobs:\n  inner:\n    uses: ./.github/workflows/leaf.yml\n    secrets: inherit\n"
148			caller := "on: push\njobs:\n  top:\n    uses: ./.github/workflows/mid.yml\n    with:\n      text: '${{ ''${{ secrets.UNPASSED }}'' }}'\n    secrets:\n      MID: ${{ secrets.TOP }}\n"
149			files := map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/mid.yml": mid, ".github/workflows/leaf.yml": leaf}
150			if limited {
151				tok, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
152				c.pushAs(tok, "main", files)
153			} else {
154				c.push("main", files, "")
155			}
156			run := c.awaitRunOf(".github/workflows/ci.yml", 0)
157			j := awaitRecord(c, r, run.GetId(), "top/inner/j")
158			if limited {
159				assert.Empty(t, j.GetSecrets())
160			} else {
161				assert.Equal(t, map[string]string{"MID": "top-value"}, j.GetSecrets())
162			}
163			r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
164		})
165	}
166}
167
168// A secret cannot be handed to the callee as an input.
169func TestCIReusableSecretsAreRefusedInWith(t *testing.T) {
170	c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
171	c.setSecret(repoScope("proj"), "SOURCE", "private-value")
172	caller := "on: push\njobs:\n  call:\n    uses: ./.github/workflows/callee.yml\n    with:\n      go-version: ${{ secrets.SOURCE }}\n"
173	c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": ghCallee}, "")
174	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
175	c.waitRunDone(run.GetId())
176	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.getRun(run.GetId()).GetRun().GetConclusion())
177	assert.NotContains(t, c.logs.String(), "private-value")
178}
179
180// A re-run of everything resolves the reference of a called workflow afresh.
181func TestCIReusableRerunAllRefreshesTheCallee(t *testing.T) {
182	remote, wf := newFakeRemote(t), newFakeWorkflows(t)
183	remote.set("acme/shared", ptrRef("refs/heads/main", shaMain))
184	wf.set("acme/shared@"+shaMain+":.github/workflows/a.yml", ghCallee)
185	wf.set("acme/shared@"+shaOther+":.github/workflows/a.yml", ghCallee)
186	c, r := newReusableHarness(t, remote, wf)
187	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"}, "")
188	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
189	first := r.await()
190	assert.Contains(t, first.GetContexts(), `"workflow_sha":"`+shaMain+`"`)
191	r.runJob(first, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil)
192	c.waitRunDone(run.GetId())
193
194	remote.set("acme/shared", ptrRef("refs/heads/main", shaOther))
195	_, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId()}))
196	require.NoError(t, err)
197	again := r.await()
198	assert.Contains(t, again.GetContexts(), `"workflow_sha":"`+shaOther+`"`, "the reference is resolved again")
199	r.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
200	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion())
201}
202
203// A matrix of calls with max-parallel and both concurrency groups progresses
204// one call at a time to the end, and the groups are free for the next run.
205func TestCIReusableMaxParallelWithBothGroups(t *testing.T) {
206	c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
207	caller := "on: push\njobs:\n  c:\n    strategy:\n      max-parallel: 1\n      matrix:\n        v: [a, b, c]\n    concurrency: caller-group\n    uses: ./.github/workflows/callee.yml\n"
208	callee := strings.Replace(ghCallee, "jobs:\n", "concurrency: callee-group\njobs:\n", 1)
209	c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "")
210	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
211	for range 3 {
212		j := awaitRecord(c, r, run.GetId(), "c/test")
213		c.forge.CI.ReconcileConcurrency(c.ctx)
214		second, err := r.fetch("")
215		require.NoError(t, err)
216		assert.Nil(t, second, "one call at a time")
217		r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
218	}
219	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion())
220	c.push("main", map[string]string{"x": "second"}, "")
221	next := c.awaitRunOf(".github/workflows/ci.yml", run.GetId())
222	for range 3 {
223		j := awaitRecord(c, r, next.GetId(), "c/test")
224		r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
225	}
226	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(next.GetId()).GetConclusion())
227}
228
229// The job-record budget holds again after a re-run of the failed jobs.
230func TestCIReusableBudgetHoldsOnRerun(t *testing.T) {
231	c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
232	vals := func(n int) string {
233		v := make([]string, n)
234		for i := range v {
235			v[i] = fmt.Sprint(i)
236		}
237		return "[" + strings.Join(v, ",") + "]"
238	}
239	caller := "on: push\njobs:\n  c:\n    strategy:\n      matrix:\n        v: " + vals(33) + "\n    uses: ./.github/workflows/callee.yml\n"
240	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"
241	c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "")
242	run := c.awaitRunOf(".github/workflows/ci.yml", 0)
243	refused := func() bool {
244		for _, j := range c.getRun(run.GetId()).GetJobs() {
245			if strings.Contains(j.GetError(), "Workflows may not contain more than 1000 jobs across all referenced files") {
246				return true
247			}
248		}
249		return false
250	}
251	c.waitFor("the budget refusal", refused)
252	assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), 1000)
253	_, _ = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
254	c.waitRunDone(run.GetId())
255	_, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId(), FailedOnly: true}))
256	require.NoError(t, err)
257	c.waitFor("the refusal again", refused)
258	assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), 1000)
259}

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 3

Tags 43

↑↓ moveenter openesc close