v0.19.0 55a688e

ci_dispatch_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"encoding/json"
7	"testing"
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)
16
17// CI-3 D5, AC5: workflow_dispatch.
18
19const dispatchWorkflow = `name: deploy
20on:
21  workflow_dispatch:
22    inputs:
23      version:
24        type: string
25        required: true
26      dry:
27        type: boolean
28        default: false
29      count:
30        type: number
31        default: 3
32      level:
33        type: choice
34        options: [low, high]
35      target:
36        type: environment
37        required: false
38run-name: deploy ${{ inputs.version }} dry=${{ inputs.dry }}
39jobs:
40  j:
41    runs-on: ubuntu-latest
42    steps:
43      - run: echo hi
44`
45
46const pushOnlyWorkflow = "name: ci\non: push\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"
47
48const jobTokenDispatcher = `name: dispatcher
49on: push
50permissions:
51  actions: write
52  contents: read
53jobs:
54  with:
55    runs-on: ubuntu-latest
56    steps:
57      - run: echo hi
58  without:
59    runs-on: ubuntu-latest
60    permissions:
61      contents: read
62    steps:
63      - run: echo hi
64  actionsonly:
65    runs-on: ubuntu-latest
66    permissions:
67      actions: write
68    steps:
69      - run: echo hi
70  readonly:
71    runs-on: ubuntu-latest
72    permissions: {}
73    steps:
74      - run: echo hi
75`
76
77func (c *ciHarness) dispatch(api *clients, path, ref string, inputs map[string]string) (*pb.Run, error) {
78	c.t.Helper()
79	res, err := api.run.DispatchWorkflow(c.ctx, connect.NewRequest(&pb.DispatchWorkflowRequest{
80		Repo: c.repo, WorkflowPath: path, Ref: ref, Inputs: inputs,
81	}))
82	if err != nil {
83		return nil, err
84	}
85	return res.Msg.GetRun(), nil
86}
87
88func TestDispatchInputsAreValidatedAndTyped(t *testing.T) {
89	c := newCIHarness(t, nil)
90	runner := c.registerRunner("alpha", "ubuntu-latest")
91	c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow, ".github/workflows/ci.yml": pushOnlyWorkflow}, "")
92	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "push run")
93
94	_, err := c.dispatch(c.root, "deploy.yml", "", nil)
95	assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a required input is missing")
96	assert.Contains(t, err.Error(), "Required input 'version'")
97	_, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "nope": "x"})
98	assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "an undeclared input")
99	_, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "dry": "maybe"})
100	assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a boolean")
101	_, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "count": "0x10"})
102	assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a number")
103	_, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "level": "medium"})
104	assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a choice outside its options")
105	_, err = c.dispatch(c.root, "ci.yml", "", nil)
106	assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "no workflow_dispatch trigger")
107	_, err = c.dispatch(c.root, "missing.yml", "", nil)
108	assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
109	_, err = c.dispatch(c.root, "deploy.yml", "nobranch", map[string]string{"version": "1"})
110	assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "a ref that does not exist")
111	assert.Len(t, c.runs(), 1, "no refused dispatch made a run")
112
113	run, err := c.dispatch(c.root, ".github/workflows/deploy.yml", "main", map[string]string{"version": "1.2", "dry": "true"})
114	require.NoError(t, err)
115	assert.Equal(t, "workflow_dispatch", run.GetEvent())
116	assert.Equal(t, "refs/heads/main", run.GetRef())
117	assert.True(t, run.GetTrusted())
118	assert.Equal(t, "root", run.GetActor(), "the caller, as for a push")
119	assert.Equal(t, "deploy 1.2 dry=true", run.GetName(), "run-name sees the typed inputs")
120	assert.Equal(t, pb.CiStatus_CI_STATUS_QUEUED, run.GetStatus())
121
122	job := runner.await()
123	gh := c.ghContext(job)
124	assert.Equal(t, "workflow_dispatch", gh["event_name"])
125	assert.Equal(t, "refs/heads/main", gh["ref"])
126	ev := gh["event"].(map[string]any)
127	assert.Equal(t, map[string]any{"version": "1.2", "dry": "true", "count": "3", "level": "low"}, ev["inputs"], "github.event.inputs are strings, defaults applied")
128	assert.Equal(t, ".github/workflows/deploy.yml", ev["workflow"])
129	assert.Equal(t, "refs/heads/main", ev["ref"])
130
131	var ctxs map[string]json.RawMessage
132	require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
133	var inputs map[string]any
134	require.NoError(t, json.Unmarshal(ctxs["inputs"], &inputs))
135	assert.Equal(t, map[string]any{"version": "1.2", "dry": true, "count": float64(3), "level": "low", "target": ""}, inputs, "the inputs context is typed")
136	runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
137}
138
139func TestDispatchReadsTheWorkflowAtTheRef(t *testing.T) {
140	c := newCIHarness(t, nil)
141	runner := c.registerRunner("alpha", "ubuntu-latest")
142	c.push("main", map[string]string{".github/workflows/ci.yml": pushOnlyWorkflow}, "")
143	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "push run")
144	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
145	c.push("feat", map[string]string{".github/workflows/ci.yml": "name: ci\non: [push, workflow_dispatch]\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"}, "")
146	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "push run")
147
148	_, err := c.dispatch(c.root, "ci.yml", "", nil)
149	assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "main has no workflow_dispatch")
150	run, err := c.dispatch(c.root, "ci.yml", "feat", nil)
151	require.NoError(t, err)
152	assert.Equal(t, "refs/heads/feat", run.GetRef())
153	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
154}
155
156func TestDispatchAccess(t *testing.T) {
157	c := newCIHarness(t, nil)
158	runner := c.registerRunner("alpha", "ubuntu-latest")
159	c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
160	in := map[string]string{"version": "1"}
161
162	readOnly := c.api(c.token(scopes(domain.ScopeRepoRead), "proj"))
163	_, err := c.dispatch(readOnly, "deploy.yml", "", in)
164	assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "ci:write is needed")
165
166	// A token with ci:write but limited to branch prefixes: its run is not trusted,
167	// and it may only dispatch on a branch under its prefixes.
168	secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeCIWrite), []string{"agent/claude/"}, "proj")
169	limited := c.api(secret)
170	_, err = c.dispatch(limited, "deploy.yml", "main", in)
171	assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "main is outside the prefixes")
172	c.mustGit(c.wt, "checkout", "-q", "-b", "agent/claude/x")
173	c.push("agent/claude/x", nil, "")
174	run, err := c.dispatch(limited, "deploy.yml", "agent/claude/x", in)
175	require.NoError(t, err)
176	assert.False(t, run.GetTrusted(), "a branch-limited caller gets an untrusted run")
177	job := runner.await()
178	assert.False(t, job.GetTrusted())
179	assert.Empty(t, job.GetSecrets())
180}
181
182func TestDispatchWithAJobToken(t *testing.T) {
183	c := newCIHarness(t, nil)
184	runner := c.registerRunner("alpha", "ubuntu-latest")
185	c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow, ".github/workflows/d.yml": jobTokenDispatcher}, "")
186	jobs := runner.jobsByKey(4)
187	with, without := jobs["with"], jobs["without"]
188	require.NotNil(t, with)
189	require.NotNil(t, without)
190	onlyActions, none := jobs["actionsonly"], jobs["readonly"]
191
192	in := map[string]string{"version": "9"}
193	_, err := c.dispatch(c.api(without.GetGithubToken()), "deploy.yml", "", in)
194	assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "no actions: write")
195	run, err := c.dispatch(c.api(with.GetGithubToken()), "deploy.yml", "", in)
196	require.NoError(t, err, "actions: write lets a job token dispatch (GitHub's exception)")
197	assert.Equal(t, "workflow_dispatch", run.GetEvent())
198	assert.Equal(t, "github-actions[bot]", run.GetActor())
199	assert.True(t, run.GetTrusted(), "the dispatching run was trusted")
200
201	// The repository is private and this token has no contents permission at all:
202	// actions: write alone is enough, and a token without it is told nothing.
203	_, err = c.dispatch(c.api(none.GetGithubToken()), "deploy.yml", "", in)
204	assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "no permission at all on a private repository: concealed")
205	run, err = c.dispatch(c.api(onlyActions.GetGithubToken()), "deploy.yml", "", in)
206	require.NoError(t, err, "actions: write without contents: read")
207	assert.Equal(t, "workflow_dispatch", run.GetEvent())
208
209	for _, j := range []*pb.Job{with, without, onlyActions, none} {
210		runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
211	}
212	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "dispatched")
213	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "dispatched")
214}
215
216func TestWorkflowRunCommand(t *testing.T) {
217	c := newCIHarness(t, nil)
218	h := &cliHarness{harness: c.harness, url: c.srv.URL}
219	runner := c.registerRunner("alpha", "ubuntu-latest")
220	c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
221
222	bad := h.cli(t, adminToken, "workflow", "run", "deploy.yml", "--repo", "proj", "-f", "oops")
223	require.Error(t, bad.err)
224	missing := h.cli(t, adminToken, "workflow", "run", "deploy.yml", "--repo", "proj")
225	require.Error(t, missing.err)
226	assert.Contains(t, missing.err.Error(), "Required input 'version'")
227
228	ok := h.cli(t, adminToken, "workflow", "run", "deploy.yml", "--repo", "proj", "--ref", "main", "-f", "version=a=b", "-f", "dry=true")
229	require.NoError(t, ok.err, ok.stderr)
230	assert.Contains(t, ok.stderr, "started on main")
231	job := runner.await()
232	ev := c.ghContext(job)["event"].(map[string]any)
233	assert.Equal(t, "a=b", ev["inputs"].(map[string]any)["version"])
234
235	list := h.cli(t, adminToken, "run", "list", "proj")
236	require.NoError(t, list.err)
237	assert.Contains(t, list.stdout, "EVENT")
238	assert.Contains(t, list.stdout, "workflow_dispatch")
239}

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 44

↑↓ moveenter openesc close