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}