detached fda3c2b

ci_refevents_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"fmt"
7	"testing"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11
12	pb "bnema.dev/ermite-api/gen/ermite"
13	"bnema.dev/ermite/internal/domain"
14)
15
16// CI-4 D6: the create and delete events.
17
18const refEventWorkflow = `name: %s
19on: %s
20jobs:
21  j:
22    runs-on: ubuntu-latest
23    steps:
24      - run: echo ${{ secrets.API_TOKEN }} %s
25`
26
27func refEventWF(event, mark string) map[string]string {
28	return map[string]string{".github/workflows/" + event + ".yml": fmt.Sprintf(refEventWorkflow, event, event, mark)}
29}
30
31// newRefEventHarness is a harness with a repository secret, the workflow of
32// event on main and the create run of main itself finished when event is create.
33func newRefEventHarness(t *testing.T, event string) (*ciHarness, *fakeRunner, string) {
34	t.Helper()
35	c := newSecretsHarness(t, secretsKeyA)
36	runner := c.registerRunner("alpha", "ubuntu-latest")
37	c.setSecret(repoScope("proj"), "API_TOKEN", "repo-token")
38	main := c.push("main", refEventWF(event, "from-main"), "")
39	if event == "create" {
40		job := c.finish(runner)
41		assert.Equal(t, "refs/heads/main", job.GetRef(), "creating main is a create event")
42	}
43	return c, runner, main
44}
45
46func eventOf(c *ciHarness, j *pb.Job) map[string]any {
47	return c.ghContext(j)["event"].(map[string]any)
48}
49
50func TestCreateRunsOnTheCreatedRef(t *testing.T) {
51	c, runner, _ := newRefEventHarness(t, "create")
52
53	// A new branch: its own workflow file runs, on its commit.
54	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
55	feat := c.push("feat", refEventWF("create", "from-feat"), "")
56	job := c.finish(runner)
57	assert.Contains(t, job.GetWorkflowYaml(), "from-feat")
58	assert.Equal(t, "refs/heads/feat", job.GetRef())
59	assert.Equal(t, feat, job.GetSha())
60	assert.True(t, job.GetTrusted())
61	assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
62	gh := c.ghContext(job)
63	assert.Equal(t, "create", gh["event_name"])
64	assert.Equal(t, "refs/heads/feat", gh["ref"])
65	assert.Equal(t, "feat", gh["ref_name"])
66	assert.Equal(t, "branch", gh["ref_type"])
67	ev := eventOf(c, job)
68	assert.Equal(t, "feat", ev["ref"])
69	assert.Equal(t, "branch", ev["ref_type"])
70	assert.Equal(t, "main", ev["master_branch"])
71	assert.Equal(t, "user", ev["pusher_type"])
72	assert.Contains(t, ev, "description")
73	assert.Nil(t, ev["description"])
74	assert.NotNil(t, ev["repository"])
75	assert.NotNil(t, ev["sender"])
76	assert.NotContains(t, ev, "commits", "not a push payload")
77	assert.Equal(t, "refs/heads/feat", job.GetCache().GetWriteRef(), "a trusted run saves under its ref")
78
79	// A tag.
80	c.mustGit(c.wt, "tag", "v1")
81	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
82	job = c.finish(runner)
83	assert.Equal(t, "refs/tags/v1", job.GetRef())
84	ev = eventOf(c, job)
85	assert.Equal(t, "v1", ev["ref"])
86	assert.Equal(t, "tag", ev["ref_type"])
87
88	// Moving an existing branch creates nothing.
89	c.push("feat", map[string]string{"b.txt": "b\n"}, "")
90	c.mustGit(c.wt, "checkout", "-q", "main")
91	c.push("main", map[string]string{"sentinel.txt": "s\n"}, "")
92	c.mustGit(c.wt, "checkout", "-q", "-b", "sentinel")
93	c.push("sentinel", map[string]string{"s.txt": "s\n"}, "")
94	job = c.finish(runner)
95	assert.Equal(t, "refs/heads/sentinel", job.GetRef(), "the update of feat and main started no run")
96	for _, r := range c.runs() {
97		assert.Equal(t, "create", r.GetEvent())
98	}
99	assert.Len(t, c.runs(), 4, "main, feat, v1, sentinel")
100}
101
102// More than three tags at once create no event, as on GitHub.
103func TestCreateSkipsMoreThanThreeTags(t *testing.T) {
104	c, runner, _ := newRefEventHarness(t, "create")
105	for _, tag := range []string{"t1", "t2", "t3", "t4"} {
106		c.mustGit(c.wt, "tag", tag)
107	}
108	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "t1", "t2", "t3", "t4")
109	c.mustGit(c.wt, "checkout", "-q", "-b", "sentinel")
110	c.push("sentinel", map[string]string{"s.txt": "s\n"}, "")
111	job := c.finish(runner)
112	assert.Equal(t, "refs/heads/sentinel", job.GetRef())
113	assert.Len(t, c.runs(), 2, "main and sentinel")
114}
115
116func TestDeleteRunsTheDefaultBranchWorkflow(t *testing.T) {
117	c, runner, main := newRefEventHarness(t, "delete")
118	// The deleted branch carries another version of the workflow: it is not
119	// the one that runs.
120	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
121	c.push("feat", refEventWF("delete", "from-feat"), "")
122	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":feat")
123
124	job := c.finish(runner)
125	assert.Contains(t, job.GetWorkflowYaml(), "from-main")
126	assert.Equal(t, "refs/heads/main", job.GetRef())
127	assert.Equal(t, main, job.GetSha())
128	assert.True(t, job.GetTrusted())
129	assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
130	gh := c.ghContext(job)
131	assert.Equal(t, "delete", gh["event_name"])
132	assert.Equal(t, "refs/heads/main", gh["ref"])
133	assert.Equal(t, main, gh["sha"])
134	ev := eventOf(c, job)
135	assert.Equal(t, "feat", ev["ref"])
136	assert.Equal(t, "branch", ev["ref_type"])
137	assert.Equal(t, "user", ev["pusher_type"])
138	assert.NotContains(t, ev, "master_branch")
139	assert.NotNil(t, ev["repository"])
140	assert.NotNil(t, ev["sender"])
141	assert.Equal(t, "refs/heads/main", job.GetCache().GetWriteRef())
142
143	runs := c.runs()
144	require.Len(t, runs, 1)
145	assert.Equal(t, "delete", runs[0].GetEvent())
146	assert.Equal(t, "refs/heads/main", runs[0].GetRef())
147
148	// A tag.
149	c.mustGit(c.wt, "tag", "v1", "main")
150	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
151	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":refs/tags/v1")
152	ev = eventOf(c, c.finish(runner))
153	assert.Equal(t, "v1", ev["ref"])
154	assert.Equal(t, "tag", ev["ref_type"])
155}
156
157// An agent creating or deleting runs untrusted; an owner's delete runs
158// untrusted when the default tip was not pushed by a trusted principal.
159func TestCreateAndDeleteTrust(t *testing.T) {
160	c := newSecretsHarness(t, secretsKeyA)
161	runner := c.registerRunner("alpha", "ubuntu-latest")
162	c.setSecret(repoScope("proj"), "API_TOKEN", "repo-token")
163	files := refEventWF("create", "c")
164	for k, v := range refEventWF("delete", "d") {
165		files[k] = v
166	}
167	main := c.push("main", files, "")
168	require.True(t, c.finish(runner).GetTrusted(), "the owner created main")
169	secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
170	agentURL := c.urlAuth(secret, "/proj.git")
171
172	// The agent creates a branch: untrusted.
173	c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
174	c.writeFile(c.wt, "x.txt", []byte("x\n"))
175	c.mustGit(c.wt, "push", "-q", agentURL, "agent/x")
176	job := c.finish(runner)
177	assert.Equal(t, "create", c.ghContext(job)["event_name"])
178	assert.False(t, job.GetTrusted())
179	assert.Empty(t, job.GetSecrets())
180	assert.Equal(t, "refs/untrusted/heads/agent/x", job.GetCache().GetWriteRef())
181
182	// The agent deletes it: untrusted, and the default branch is not written.
183	c.mustGit(c.wt, "push", "-q", agentURL, ":agent/x")
184	job = c.finish(runner)
185	assert.Equal(t, "delete", c.ghContext(job)["event_name"])
186	assert.Equal(t, main, job.GetSha())
187	assert.False(t, job.GetTrusted())
188	assert.Empty(t, job.GetSecrets())
189	assert.Empty(t, job.GetCache().GetWriteRef(), "an untrusted run never saves under the default branch")
190
191	// The owner deletes a branch the agent pushed: trusted, the deleted ref's
192	// record does not matter (it is gone with the ref).
193	c.mustGit(c.wt, "push", "-q", agentURL, "agent/x")
194	c.finish(runner) // create
195	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":agent/x")
196	job = c.finish(runner)
197	assert.Equal(t, "delete", c.ghContext(job)["event_name"])
198	assert.True(t, job.GetTrusted())
199	assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
200
201	// The owner deletes while main's record says an untrusted principal set
202	// it: the default tip's code runs, so untrusted.
203	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "agent/x:refs/heads/other")
204	c.finish(runner) // create
205	require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
206		Repo: c.name("proj"), Ref: "refs/heads/main", Sha: main, Actor: "claude", Trusted: false,
207	}))
208	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":other")
209	job = c.finish(runner)
210	assert.Equal(t, "delete", c.ghContext(job)["event_name"])
211	assert.False(t, job.GetTrusted())
212	assert.Empty(t, job.GetSecrets())
213}

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