detached a6671fa

feat(host): resolve actions for the executor and give the job token to github.token

commit
a6671fa0b094e8c5e44fd3f4f42897f03900c42c
author
brice · · 2026-10-13 15:16 UTC
parent
1396410
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +803 -36
M internal/execstream/exec.proto +32 -0
@@ -25,6 +25,8 @@ message Frame {
2525 StepCompleted step_completed = 7; // executor -> host
2626 Log log = 8; // executor -> host
2727 JobResult job_result = 9; // executor -> host, before Exit
28+ ResolveRequest resolve_request = 10; // executor -> host
29+ ResolveResponse resolve_response = 11; // host -> executor, answers a ResolveRequest
2830 }
2931 }
3032
@@ -145,3 +147,33 @@ message JobResult {
145147 string error = 3;
146148 bool runner_shutdown = 4;
147149 }
150+
151+// ResolveRequest asks the host to resolve actions with RunnerService.
152+// ResolveActions: the job token stays in the host, so the executor cannot call
153+// it. The executor sends one per level of composite actions (the runner's own
154+// "one more round per level") and waits for the ResolveResponse with the same id.
155+message ResolveRequest {
156+ int64 id = 1;
157+ repeated string uses = 2; // "owner/repo[/path]@ref", at most 100
158+}
159+
160+// ResolveResponse answers a ResolveRequest. error is set when the call itself
161+// failed (the executor fails the job); actions has one entry per uses otherwise.
162+message ResolveResponse {
163+ int64 id = 1;
164+ string error = 2;
165+ repeated ResolvedAction actions = 3;
166+}
167+
168+// ResolvedAction mirrors ermite.ResolvedAction (the token is a secret: the
169+// executor masks it).
170+message ResolvedAction {
171+ string ref = 1;
172+ string name_with_owner = 2;
173+ string path = 3;
174+ string resolved_sha = 4;
175+ string tarball_url = 5;
176+ string token = 6;
177+ string source = 7;
178+ string error = 8;
179+}
@@ -25,6 +25,8 @@ message Frame {
25 StepCompleted step_completed = 7; // executor -> host25 StepCompleted step_completed = 7; // executor -> host
26 Log log = 8; // executor -> host26 Log log = 8; // executor -> host
27 JobResult job_result = 9; // executor -> host, before Exit27 JobResult job_result = 9; // executor -> host, before Exit
28+ ResolveRequest resolve_request = 10; // executor -> host
29+ ResolveResponse resolve_response = 11; // host -> executor, answers a ResolveRequest
28 }30 }
29 }31 }
30 32
@@ -145,3 +147,33 @@ message JobResult {
145 string error = 3;147 string error = 3;
146 bool runner_shutdown = 4;148 bool runner_shutdown = 4;
147 }149 }
150+
151+// ResolveRequest asks the host to resolve actions with RunnerService.
152+// ResolveActions: the job token stays in the host, so the executor cannot call
153+// it. The executor sends one per level of composite actions (the runner's own
154+// "one more round per level") and waits for the ResolveResponse with the same id.
155+message ResolveRequest {
156+ int64 id = 1;
157+ repeated string uses = 2; // "owner/repo[/path]@ref", at most 100
158+}
159+
160+// ResolveResponse answers a ResolveRequest. error is set when the call itself
161+// failed (the executor fails the job); actions has one entry per uses otherwise.
162+message ResolveResponse {
163+ int64 id = 1;
164+ string error = 2;
165+ repeated ResolvedAction actions = 3;
166+}
167+
168+// ResolvedAction mirrors ermite.ResolvedAction (the token is a secret: the
169+// executor masks it).
170+message ResolvedAction {
171+ string ref = 1;
172+ string name_with_owner = 2;
173+ string path = 3;
174+ string resolved_sha = 4;
175+ string tarball_url = 5;
176+ string token = 6;
177+ string source = 7;
178+ string error = 8;
179+}
M internal/execstream/execpb/exec.pb.go +313 -33 generated file: collapsed
M internal/execstream/frame.go +14 -0
@@ -115,6 +115,10 @@ type JobHandler struct {
115115 StepCompleted func(*execpb.StepCompleted)
116116 Log func(*execpb.Log)
117117 JobResult func(*execpb.JobResult)
118+ // Resolve answers the executor's ResolveRequest frames. It is called in its
119+ // own goroutine (the call goes to the server), the answer is sent as a
120+ // ResolveResponse frame carrying the id of the request.
121+ Resolve func(*execpb.ResolveRequest) *execpb.ResolveResponse
118122 }
119123
120124 // DriveJob is Drive for a session that carries a job: besides Output frames it
@@ -179,6 +183,16 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h
179183 if h.JobResult != nil {
180184 h.JobResult(b.JobResult)
181185 }
186+ case *execpb.Frame_ResolveRequest:
187+ req := b.ResolveRequest
188+ go func() {
189+ resp := &execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"}
190+ if h.Resolve != nil {
191+ resp = h.Resolve(req)
192+ resp.Id = req.Id
193+ }
194+ _ = c.Send(&execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: resp}})
195+ }()
182196 case *execpb.Frame_Exit:
183197 return hello, b.Exit, nil
184198 }
@@ -115,6 +115,10 @@ type JobHandler struct {
115 StepCompleted func(*execpb.StepCompleted)115 StepCompleted func(*execpb.StepCompleted)
116 Log func(*execpb.Log)116 Log func(*execpb.Log)
117 JobResult func(*execpb.JobResult)117 JobResult func(*execpb.JobResult)
118+ // Resolve answers the executor's ResolveRequest frames. It is called in its
119+ // own goroutine (the call goes to the server), the answer is sent as a
120+ // ResolveResponse frame carrying the id of the request.
121+ Resolve func(*execpb.ResolveRequest) *execpb.ResolveResponse
118 }122 }
119 123
120 // DriveJob is Drive for a session that carries a job: besides Output frames it124 // DriveJob is Drive for a session that carries a job: besides Output frames it
@@ -179,6 +183,16 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h
179 if h.JobResult != nil {183 if h.JobResult != nil {
180 h.JobResult(b.JobResult)184 h.JobResult(b.JobResult)
181 }185 }
186+ case *execpb.Frame_ResolveRequest:
187+ req := b.ResolveRequest
188+ go func() {
189+ resp := &execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"}
190+ if h.Resolve != nil {
191+ resp = h.Resolve(req)
192+ resp.Id = req.Id
193+ }
194+ _ = c.Send(&execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: resp}})
195+ }()
182 case *execpb.Frame_Exit:196 case *execpb.Frame_Exit:
183 return hello, b.Exit, nil197 return hello, b.Exit, nil
184 }198 }
A internal/executor/actions_types.go +134 -0
@@ -0,0 +1,134 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/action"
11+ "bnema.dev/ermite-actions/workflow"
12+)
13+
14+// ResolvedAction is where to download one action (ermite.ResolvedAction, see
15+// the host). The Token is a secret: the executor masks it.
16+type ResolvedAction struct {
17+ Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string
18+}
19+
20+// Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It
21+// is the host's call (the job token stays out of the sandbox); the result has
22+// one entry per value, in order.
23+type Resolver func(ctx context.Context, uses []string) ([]ResolvedAction, error)
24+
25+// ActionsConfig is what the executor needs to run actions.
26+type ActionsConfig struct {
27+ // Resolver is required to run actions from repositories; without it a job
28+ // that uses one fails "Set up job".
29+ Resolver Resolver
30+ // HTTPClient downloads the archives; nil means a client with the default
31+ // transport. It must not follow a redirect to another host with the
32+ // credentials (net/http does not).
33+ HTTPClient *http.Client
34+ // ExternalsDir is where Node.js lives (<dir>/node20/bin/node); empty means
35+ // /home/runner/externals.
36+ ExternalsDir string
37+ // DockerPath is the docker CLI; empty means the one found in PATH.
38+ DockerPath string
39+ // Node24ByDefault is the server flag actions.runner.usenode24bydefault
40+ // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no
41+ // feature flags, so the default is pinned here.
42+ Node24ByDefault bool
43+}
44+
45+const defaultExternalsDir = "/home/runner/externals"
46+
47+// stage is ActionRunStage.
48+type stage int
49+
50+const (
51+ stageMain stage = iota
52+ stagePre
53+ stagePost
54+)
55+
56+// compositeMaxDepth is Constants.CompositeActionsMaxDepth
57+// (Runner.Common/Constants.cs:46).
58+const compositeMaxDepth = 9
59+
60+// actionDef is a loaded action: its directory and manifest (ActionManager.
61+// LoadAction's Definition). A docker:// step has no manifest and no directory.
62+type actionDef struct {
63+ uses action.Uses
64+ // ref is "Name[/Path]@Ref" for repository actions, the path for local ones.
65+ ref string
66+ dir string
67+ act *action.Action // nil for docker:// and Dockerfile-only actions
68+ // image is what a container action runs: "docker://x" steps and docker://
69+ // manifests give the image to pull, Dockerfile ones the path of the file.
70+ image string
71+ dockerfile bool
72+ // children are the loaded actions of a composite's steps, by step.
73+ children map[*workflow.Step]*actionDef
74+}
75+
76+func (d *actionDef) runs() *action.Runs {
77+ if d.act == nil {
78+ return nil
79+ }
80+ return d.act.Runs
81+}
82+
83+// hasPre and hasPost are ActionExecutionData.HasPre/HasPost.
84+func (d *actionDef) hasPre() bool {
85+ r := d.runs()
86+ switch {
87+ case r == nil:
88+ return false
89+ case r.Kind == action.RunsComposite:
90+ return d.anyChild(func(c *actionDef) bool { return c.hasPre() && !c.local() })
91+ }
92+ return r.HasPre()
93+}
94+
95+func (d *actionDef) hasPost() bool {
96+ r := d.runs()
97+ switch {
98+ case r == nil:
99+ return false
100+ case r.Kind == action.RunsComposite:
101+ return d.anyChild(func(c *actionDef) bool { return c.local() || c.hasPost() })
102+ }
103+ return r.HasPost()
104+}
105+
106+func (d *actionDef) anyChild(f func(*actionDef) bool) bool {
107+ for _, c := range d.children {
108+ if c != nil && f(c) {
109+ return true
110+ }
111+ }
112+ return false
113+}
114+
115+func (d *actionDef) local() bool { return d.uses.Kind == action.UsesLocal }
116+
117+// preIf and postIf are InitCondition and CleanupCondition: always() unless the
118+// manifest says otherwise (action.Runs keeps them as written).
119+func (d *actionDef) preIf() string { return condOrAlways(d.runs(), true) }
120+func (d *actionDef) postIf() string { return condOrAlways(d.runs(), false) }
121+
122+func condOrAlways(r *action.Runs, pre bool) string {
123+ if r == nil || r.Kind == action.RunsComposite {
124+ return "always()"
125+ }
126+ c := r.PostIf
127+ if pre {
128+ c = r.PreIf
129+ }
130+ if strings.TrimSpace(c) == "" {
131+ return "always()"
132+ }
133+ return c
134+}
@@ -0,0 +1,134 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/action"
11+ "bnema.dev/ermite-actions/workflow"
12+)
13+
14+// ResolvedAction is where to download one action (ermite.ResolvedAction, see
15+// the host). The Token is a secret: the executor masks it.
16+type ResolvedAction struct {
17+ Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string
18+}
19+
20+// Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It
21+// is the host's call (the job token stays out of the sandbox); the result has
22+// one entry per value, in order.
23+type Resolver func(ctx context.Context, uses []string) ([]ResolvedAction, error)
24+
25+// ActionsConfig is what the executor needs to run actions.
26+type ActionsConfig struct {
27+ // Resolver is required to run actions from repositories; without it a job
28+ // that uses one fails "Set up job".
29+ Resolver Resolver
30+ // HTTPClient downloads the archives; nil means a client with the default
31+ // transport. It must not follow a redirect to another host with the
32+ // credentials (net/http does not).
33+ HTTPClient *http.Client
34+ // ExternalsDir is where Node.js lives (<dir>/node20/bin/node); empty means
35+ // /home/runner/externals.
36+ ExternalsDir string
37+ // DockerPath is the docker CLI; empty means the one found in PATH.
38+ DockerPath string
39+ // Node24ByDefault is the server flag actions.runner.usenode24bydefault
40+ // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no
41+ // feature flags, so the default is pinned here.
42+ Node24ByDefault bool
43+}
44+
45+const defaultExternalsDir = "/home/runner/externals"
46+
47+// stage is ActionRunStage.
48+type stage int
49+
50+const (
51+ stageMain stage = iota
52+ stagePre
53+ stagePost
54+)
55+
56+// compositeMaxDepth is Constants.CompositeActionsMaxDepth
57+// (Runner.Common/Constants.cs:46).
58+const compositeMaxDepth = 9
59+
60+// actionDef is a loaded action: its directory and manifest (ActionManager.
61+// LoadAction's Definition). A docker:// step has no manifest and no directory.
62+type actionDef struct {
63+ uses action.Uses
64+ // ref is "Name[/Path]@Ref" for repository actions, the path for local ones.
65+ ref string
66+ dir string
67+ act *action.Action // nil for docker:// and Dockerfile-only actions
68+ // image is what a container action runs: "docker://x" steps and docker://
69+ // manifests give the image to pull, Dockerfile ones the path of the file.
70+ image string
71+ dockerfile bool
72+ // children are the loaded actions of a composite's steps, by step.
73+ children map[*workflow.Step]*actionDef
74+}
75+
76+func (d *actionDef) runs() *action.Runs {
77+ if d.act == nil {
78+ return nil
79+ }
80+ return d.act.Runs
81+}
82+
83+// hasPre and hasPost are ActionExecutionData.HasPre/HasPost.
84+func (d *actionDef) hasPre() bool {
85+ r := d.runs()
86+ switch {
87+ case r == nil:
88+ return false
89+ case r.Kind == action.RunsComposite:
90+ return d.anyChild(func(c *actionDef) bool { return c.hasPre() && !c.local() })
91+ }
92+ return r.HasPre()
93+}
94+
95+func (d *actionDef) hasPost() bool {
96+ r := d.runs()
97+ switch {
98+ case r == nil:
99+ return false
100+ case r.Kind == action.RunsComposite:
101+ return d.anyChild(func(c *actionDef) bool { return c.local() || c.hasPost() })
102+ }
103+ return r.HasPost()
104+}
105+
106+func (d *actionDef) anyChild(f func(*actionDef) bool) bool {
107+ for _, c := range d.children {
108+ if c != nil && f(c) {
109+ return true
110+ }
111+ }
112+ return false
113+}
114+
115+func (d *actionDef) local() bool { return d.uses.Kind == action.UsesLocal }
116+
117+// preIf and postIf are InitCondition and CleanupCondition: always() unless the
118+// manifest says otherwise (action.Runs keeps them as written).
119+func (d *actionDef) preIf() string { return condOrAlways(d.runs(), true) }
120+func (d *actionDef) postIf() string { return condOrAlways(d.runs(), false) }
121+
122+func condOrAlways(r *action.Runs, pre bool) string {
123+ if r == nil || r.Kind == action.RunsComposite {
124+ return "always()"
125+ }
126+ c := r.PostIf
127+ if pre {
128+ c = r.PreIf
129+ }
130+ if strings.TrimSpace(c) == "" {
131+ return "always()"
132+ }
133+ return c
134+}
M internal/executor/contexts.go +3 -0
@@ -130,6 +130,8 @@ type Layout struct {
130130 RunnerWorkspace string
131131 // Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>.
132132 Workspace string
133+ // Actions is where the actions of the job are downloaded: <WorkRoot>/_actions.
134+ Actions string
133135 }
134136
135137 // DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image.
@@ -145,6 +147,7 @@ func NewLayout(home, repo, toolCache string) Layout {
145147 return Layout{
146148 Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache,
147149 RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo),
150+ Actions: filepath.Join(work, "_actions"),
148151 }
149152 }
150153
@@ -130,6 +130,8 @@ type Layout struct {
130 RunnerWorkspace string130 RunnerWorkspace string
131 // Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>.131 // Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>.
132 Workspace string132 Workspace string
133+ // Actions is where the actions of the job are downloaded: <WorkRoot>/_actions.
134+ Actions string
133 }135 }
134 136
135 // DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image.137 // DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image.
@@ -145,6 +147,7 @@ func NewLayout(home, repo, toolCache string) Layout {
145 return Layout{147 return Layout{
146 Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache,148 Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache,
147 RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo),149 RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo),
150+ Actions: filepath.Join(work, "_actions"),
148 }151 }
149 }152 }
150 153
M internal/executor/job.go +3 -0
@@ -75,6 +75,9 @@ type Config struct {
7575 BaseEnv []string
7676 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
7777 Debug bool
78+ // Actions configures the actions of the job (resolution, downloads, Node.js,
79+ // docker).
80+ Actions ActionsConfig
7881
7982 Reporter Reporter
8083 // Log receives the runner's own diagnostics (not the job log); the zero value
@@ -75,6 +75,9 @@ type Config struct {
75 BaseEnv []string75 BaseEnv []string
76 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).76 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
77 Debug bool77 Debug bool
78+ // Actions configures the actions of the job (resolution, downloads, Node.js,
79+ // docker).
80+ Actions ActionsConfig
78 81
79 Reporter Reporter82 Reporter Reporter
80 // Log receives the runner's own diagnostics (not the job log); the zero value83 // Log receives the runner's own diagnostics (not the job log); the zero value
M internal/guest/jobexec.go +7 -3
@@ -166,6 +166,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
166166 }
167167 canceller := executor.NewCanceller()
168168 rep := &frameReporter{conn: conn, failed: make(chan struct{})}
169+ res := newResolver(conn)
169170
170171 // Frames from the host while the job runs: a Cancel stops the job; a lost
171172 // host does too, like a runner shutdown. The reader ends with the stream: the
@@ -183,6 +184,9 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
183184 if c := f.GetCancel(); c != nil {
184185 canceller.Cancel(cancelKind(c.GetKind()))
185186 }
187+ if r := f.GetResolveResponse(); r != nil {
188+ res.deliver(r)
189+ }
186190 }
187191 }()
188192 // The executor's own context (SIGTERM forwarded by init: the sandbox is
@@ -199,17 +203,17 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
199203 }()
200204
201205 env := spec.GetRunnerEnvironment()
202- res := executor.Run(canceller, executor.Config{
206+ result := executor.Run(canceller, executor.Config{
203207 WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(),
204208 ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(),
205209 TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond,
206210 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
207211 RunnerVersion: spec.GetRunnerVersion(),
208212 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
209- Debug: spec.GetDebug(), Reporter: rep, Log: zerowrap.FromCtx(ctx),
213+ Debug: spec.GetDebug(), Reporter: rep, Actions: executor.ActionsConfig{Resolver: res.resolve}, Log: zerowrap.FromCtx(ctx),
210214 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
211215 })
212- if res.Conclusion == executor.ConclusionSuccess {
216+ if result.Conclusion == executor.ConclusionSuccess {
213217 return 0
214218 }
215219 return 1
@@ -166,6 +166,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
166 }166 }
167 canceller := executor.NewCanceller()167 canceller := executor.NewCanceller()
168 rep := &frameReporter{conn: conn, failed: make(chan struct{})}168 rep := &frameReporter{conn: conn, failed: make(chan struct{})}
169+ res := newResolver(conn)
169 170
170 // Frames from the host while the job runs: a Cancel stops the job; a lost171 // Frames from the host while the job runs: a Cancel stops the job; a lost
171 // host does too, like a runner shutdown. The reader ends with the stream: the172 // host does too, like a runner shutdown. The reader ends with the stream: the
@@ -183,6 +184,9 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
183 if c := f.GetCancel(); c != nil {184 if c := f.GetCancel(); c != nil {
184 canceller.Cancel(cancelKind(c.GetKind()))185 canceller.Cancel(cancelKind(c.GetKind()))
185 }186 }
187+ if r := f.GetResolveResponse(); r != nil {
188+ res.deliver(r)
189+ }
186 }190 }
187 }()191 }()
188 // The executor's own context (SIGTERM forwarded by init: the sandbox is192 // The executor's own context (SIGTERM forwarded by init: the sandbox is
@@ -199,17 +203,17 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
199 }()203 }()
200 204
201 env := spec.GetRunnerEnvironment()205 env := spec.GetRunnerEnvironment()
202- res := executor.Run(canceller, executor.Config{206+ result := executor.Run(canceller, executor.Config{
203 WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(),207 WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(),
204 ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(),208 ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(),
205 TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond,209 TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond,
206 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},210 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
207 RunnerVersion: spec.GetRunnerVersion(),211 RunnerVersion: spec.GetRunnerVersion(),
208 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,212 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
209- Debug: spec.GetDebug(), Reporter: rep, Log: zerowrap.FromCtx(ctx),213+ Debug: spec.GetDebug(), Reporter: rep, Actions: executor.ActionsConfig{Resolver: res.resolve}, Log: zerowrap.FromCtx(ctx),
210 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,214 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
211 })215 })
212- if res.Conclusion == executor.ConclusionSuccess {216+ if result.Conclusion == executor.ConclusionSuccess {
213 return 0217 return 0
214 }218 }
215 return 1219 return 1
A internal/guest/resolver.go +69 -0
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "errors"
8+ "sync"
9+
10+ "bnema.dev/ermite-runner/internal/execstream"
11+ "bnema.dev/ermite-runner/internal/execstream/execpb"
12+ "bnema.dev/ermite-runner/internal/executor"
13+)
14+
15+// resolver sends ResolveRequest frames to the host and matches the
16+// ResolveResponse frames that come back (the job token stays in the host).
17+type resolver struct {
18+ conn *execstream.Conn
19+ mu sync.Mutex
20+ next int64
21+ wait map[int64]chan *execpb.ResolveResponse
22+}
23+
24+func newResolver(conn *execstream.Conn) *resolver {
25+ return &resolver{conn: conn, wait: map[int64]chan *execpb.ResolveResponse{}}
26+}
27+
28+func (r *resolver) deliver(resp *execpb.ResolveResponse) {
29+ r.mu.Lock()
30+ ch := r.wait[resp.Id]
31+ delete(r.wait, resp.Id)
32+ r.mu.Unlock()
33+ if ch != nil {
34+ ch <- resp
35+ }
36+}
37+
38+// resolve is executor.Config.Resolver.
39+func (r *resolver) resolve(ctx context.Context, uses []string) ([]executor.ResolvedAction, error) {
40+ ch := make(chan *execpb.ResolveResponse, 1)
41+ r.mu.Lock()
42+ r.next++
43+ id := r.next
44+ r.wait[id] = ch
45+ r.mu.Unlock()
46+ forget := func() { r.mu.Lock(); delete(r.wait, id); r.mu.Unlock() }
47+ req := &execpb.ResolveRequest{Id: id, Uses: uses}
48+ if err := r.conn.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: req}}); err != nil {
49+ forget()
50+ return nil, err
51+ }
52+ select {
53+ case resp := <-ch:
54+ if resp.Error != "" {
55+ return nil, errors.New(resp.Error)
56+ }
57+ out := make([]executor.ResolvedAction, len(resp.Actions))
58+ for i, a := range resp.Actions {
59+ out[i] = executor.ResolvedAction{
60+ Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha,
61+ TarballURL: a.TarballUrl, Token: a.Token, Error: a.Error,
62+ }
63+ }
64+ return out, nil
65+ case <-ctx.Done():
66+ forget()
67+ return nil, ctx.Err()
68+ }
69+}
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "errors"
8+ "sync"
9+
10+ "bnema.dev/ermite-runner/internal/execstream"
11+ "bnema.dev/ermite-runner/internal/execstream/execpb"
12+ "bnema.dev/ermite-runner/internal/executor"
13+)
14+
15+// resolver sends ResolveRequest frames to the host and matches the
16+// ResolveResponse frames that come back (the job token stays in the host).
17+type resolver struct {
18+ conn *execstream.Conn
19+ mu sync.Mutex
20+ next int64
21+ wait map[int64]chan *execpb.ResolveResponse
22+}
23+
24+func newResolver(conn *execstream.Conn) *resolver {
25+ return &resolver{conn: conn, wait: map[int64]chan *execpb.ResolveResponse{}}
26+}
27+
28+func (r *resolver) deliver(resp *execpb.ResolveResponse) {
29+ r.mu.Lock()
30+ ch := r.wait[resp.Id]
31+ delete(r.wait, resp.Id)
32+ r.mu.Unlock()
33+ if ch != nil {
34+ ch <- resp
35+ }
36+}
37+
38+// resolve is executor.Config.Resolver.
39+func (r *resolver) resolve(ctx context.Context, uses []string) ([]executor.ResolvedAction, error) {
40+ ch := make(chan *execpb.ResolveResponse, 1)
41+ r.mu.Lock()
42+ r.next++
43+ id := r.next
44+ r.wait[id] = ch
45+ r.mu.Unlock()
46+ forget := func() { r.mu.Lock(); delete(r.wait, id); r.mu.Unlock() }
47+ req := &execpb.ResolveRequest{Id: id, Uses: uses}
48+ if err := r.conn.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: req}}); err != nil {
49+ forget()
50+ return nil, err
51+ }
52+ select {
53+ case resp := <-ch:
54+ if resp.Error != "" {
55+ return nil, errors.New(resp.Error)
56+ }
57+ out := make([]executor.ResolvedAction, len(resp.Actions))
58+ for i, a := range resp.Actions {
59+ out[i] = executor.ResolvedAction{
60+ Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha,
61+ TarballURL: a.TarballUrl, Token: a.Token, Error: a.Error,
62+ }
63+ }
64+ return out, nil
65+ case <-ctx.Done():
66+ forget()
67+ return nil, ctx.Err()
68+ }
69+}
M internal/host/job.go +6 -0
@@ -31,6 +31,7 @@ type jobClient interface {
3131 Heartbeat(context.Context, *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error)
3232 WatchJob(context.Context, *connect.Request[ermite.WatchJobRequest]) (*connect.ServerStreamForClient[ermite.WatchJobResponse], error)
3333 CompleteJob(context.Context, *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error)
34+ ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error)
3435 }
3536
3637 var _ jobClient = ermiteconnect.RunnerServiceClient(nil)
@@ -570,6 +571,10 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct
570571 j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err))
571572 return failure()
572573 }
574+ if j.contexts, err = SetGitHubToken(j.contexts, j.job.GithubToken); err != nil {
575+ j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err))
576+ return failure()
577+ }
573578 sb, err := rt.Driver.New()
574579 if err != nil {
575580 j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err))
@@ -648,6 +653,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
648653 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
649654 Log: j.logLine,
650655 JobResult: func(r *execpb.JobResult) { j.mu.Lock(); j.result = r; j.mu.Unlock() },
656+ Resolve: j.resolveActions,
651657 })
652658 return exit, err
653659 }
@@ -31,6 +31,7 @@ type jobClient interface {
31 Heartbeat(context.Context, *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error)31 Heartbeat(context.Context, *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error)
32 WatchJob(context.Context, *connect.Request[ermite.WatchJobRequest]) (*connect.ServerStreamForClient[ermite.WatchJobResponse], error)32 WatchJob(context.Context, *connect.Request[ermite.WatchJobRequest]) (*connect.ServerStreamForClient[ermite.WatchJobResponse], error)
33 CompleteJob(context.Context, *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error)33 CompleteJob(context.Context, *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error)
34+ ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error)
34 }35 }
35 36
36 var _ jobClient = ermiteconnect.RunnerServiceClient(nil)37 var _ jobClient = ermiteconnect.RunnerServiceClient(nil)
@@ -570,6 +571,10 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct
570 j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err))571 j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err))
571 return failure()572 return failure()
572 }573 }
574+ if j.contexts, err = SetGitHubToken(j.contexts, j.job.GithubToken); err != nil {
575+ j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err))
576+ return failure()
577+ }
573 sb, err := rt.Driver.New()578 sb, err := rt.Driver.New()
574 if err != nil {579 if err != nil {
575 j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err))580 j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err))
@@ -648,6 +653,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
648 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,653 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
649 Log: j.logLine,654 Log: j.logLine,
650 JobResult: func(r *execpb.JobResult) { j.mu.Lock(); j.result = r; j.mu.Unlock() },655 JobResult: func(r *execpb.JobResult) { j.mu.Lock(); j.result = r; j.mu.Unlock() },
656+ Resolve: j.resolveActions,
651 })657 })
652 return exit, err658 return exit, err
653 }659 }
A internal/host/resolve.go +121 -0
@@ -0,0 +1,121 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+
10+ "connectrpc.com/connect"
11+
12+ ermite "bnema.dev/ermite-api/gen/ermite"
13+
14+ "bnema.dev/ermite-runner/internal/execstream/execpb"
15+)
16+
17+// maxResolveRefs is the limit of ResolveActionsRequest.refs.
18+const maxResolveRefs = 100
19+
20+// resolveActions answers a ResolveRequest of the executor with
21+// RunnerService.ResolveActions. The job token stays here: the executor only
22+// gets what the server returns (the per-archive tokens included, which it
23+// masks). A call that fails without a verdict is retried like the other job
24+// calls; a failure of the call itself is reported to the executor, which fails
25+// "Set up job" (ActionManager: FailedToResolveActionDownloadInfoException).
26+func (j *jobRun) resolveActions(req *execpb.ResolveRequest) *execpb.ResolveResponse {
27+ if len(req.Uses) > maxResolveRefs {
28+ return &execpb.ResolveResponse{Error: fmt.Sprintf("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs)}
29+ }
30+ refs := make([]*ermite.ActionRef, len(req.Uses))
31+ for i, u := range req.Uses {
32+ refs[i] = &ermite.ActionRef{Uses: u}
33+ }
34+ b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax}
35+ for attempt := 1; ; attempt++ {
36+ cctx, cancel := context.WithTimeout(j.ctx, j.t.CallTimeout)
37+ resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs}))
38+ cancel()
39+ if err == nil {
40+ return resolveResponse(resp.Msg, len(req.Uses))
41+ }
42+ if j.ctx.Err() != nil {
43+ return &execpb.ResolveResponse{Error: "the job was cancelled while its actions were resolved"}
44+ }
45+ switch classify(err) {
46+ case kindEnded, kindAbandoned:
47+ j.abandon(err)
48+ return &execpb.ResolveResponse{Error: "the server ended the job"}
49+ case kindPermanent:
50+ return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))}
51+ }
52+ j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed")
53+ if attempt >= 3 || !sleep(j.ctx, b.next()) {
54+ return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))}
55+ }
56+ }
57+}
58+
59+func resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse {
60+ if len(m.Actions) != want {
61+ return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)}
62+ }
63+ out := &execpb.ResolveResponse{}
64+ for _, a := range m.Actions {
65+ out.Actions = append(out.Actions, &execpb.ResolvedAction{
66+ Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha,
67+ TarballUrl: a.TarballUrl, Token: a.Token, Source: a.Source.String(), Error: a.Error,
68+ })
69+ }
70+ return out
71+}
72+
73+// SetGitHubToken returns the contexts JSON of a job with github.token set to
74+// token (the job repository token, Job.github_token). The key is added first,
75+// like the runner does (ExecutionContext.cs:1023: token, then what the server
76+// gave); an empty token leaves the contexts as they are.
77+func SetGitHubToken(contexts, token string) (string, error) {
78+ if token == "" {
79+ return contexts, nil
80+ }
81+ top, err := parseObject([]byte(contexts))
82+ if err != nil {
83+ return "", fmt.Errorf("job contexts: %w", err)
84+ }
85+ gi := -1
86+ for i, m := range top {
87+ if m.key == "github" {
88+ gi = i
89+ }
90+ }
91+ var gh []jsonMember
92+ if gi >= 0 {
93+ if gh, err = parseObject(top[gi].val); err != nil {
94+ return "", fmt.Errorf("job contexts: github: %w", err)
95+ }
96+ }
97+ tv, err := json.Marshal(token)
98+ if err != nil {
99+ return "", err
100+ }
101+ set := false
102+ for i, m := range gh {
103+ if m.key == "token" {
104+ gh[i].val, set = tv, true
105+ }
106+ }
107+ if !set {
108+ gh = append([]jsonMember{{"token", tv}}, gh...)
109+ }
110+ ghv, err := encodeObject(gh)
111+ if err != nil {
112+ return "", err
113+ }
114+ if gi >= 0 {
115+ top[gi].val = ghv
116+ } else {
117+ top = append(top, jsonMember{"github", ghv})
118+ }
119+ out, err := encodeObject(top)
120+ return string(out), err
121+}
@@ -0,0 +1,121 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+
10+ "connectrpc.com/connect"
11+
12+ ermite "bnema.dev/ermite-api/gen/ermite"
13+
14+ "bnema.dev/ermite-runner/internal/execstream/execpb"
15+)
16+
17+// maxResolveRefs is the limit of ResolveActionsRequest.refs.
18+const maxResolveRefs = 100
19+
20+// resolveActions answers a ResolveRequest of the executor with
21+// RunnerService.ResolveActions. The job token stays here: the executor only
22+// gets what the server returns (the per-archive tokens included, which it
23+// masks). A call that fails without a verdict is retried like the other job
24+// calls; a failure of the call itself is reported to the executor, which fails
25+// "Set up job" (ActionManager: FailedToResolveActionDownloadInfoException).
26+func (j *jobRun) resolveActions(req *execpb.ResolveRequest) *execpb.ResolveResponse {
27+ if len(req.Uses) > maxResolveRefs {
28+ return &execpb.ResolveResponse{Error: fmt.Sprintf("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs)}
29+ }
30+ refs := make([]*ermite.ActionRef, len(req.Uses))
31+ for i, u := range req.Uses {
32+ refs[i] = &ermite.ActionRef{Uses: u}
33+ }
34+ b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax}
35+ for attempt := 1; ; attempt++ {
36+ cctx, cancel := context.WithTimeout(j.ctx, j.t.CallTimeout)
37+ resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs}))
38+ cancel()
39+ if err == nil {
40+ return resolveResponse(resp.Msg, len(req.Uses))
41+ }
42+ if j.ctx.Err() != nil {
43+ return &execpb.ResolveResponse{Error: "the job was cancelled while its actions were resolved"}
44+ }
45+ switch classify(err) {
46+ case kindEnded, kindAbandoned:
47+ j.abandon(err)
48+ return &execpb.ResolveResponse{Error: "the server ended the job"}
49+ case kindPermanent:
50+ return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))}
51+ }
52+ j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed")
53+ if attempt >= 3 || !sleep(j.ctx, b.next()) {
54+ return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))}
55+ }
56+ }
57+}
58+
59+func resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse {
60+ if len(m.Actions) != want {
61+ return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)}
62+ }
63+ out := &execpb.ResolveResponse{}
64+ for _, a := range m.Actions {
65+ out.Actions = append(out.Actions, &execpb.ResolvedAction{
66+ Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha,
67+ TarballUrl: a.TarballUrl, Token: a.Token, Source: a.Source.String(), Error: a.Error,
68+ })
69+ }
70+ return out
71+}
72+
73+// SetGitHubToken returns the contexts JSON of a job with github.token set to
74+// token (the job repository token, Job.github_token). The key is added first,
75+// like the runner does (ExecutionContext.cs:1023: token, then what the server
76+// gave); an empty token leaves the contexts as they are.
77+func SetGitHubToken(contexts, token string) (string, error) {
78+ if token == "" {
79+ return contexts, nil
80+ }
81+ top, err := parseObject([]byte(contexts))
82+ if err != nil {
83+ return "", fmt.Errorf("job contexts: %w", err)
84+ }
85+ gi := -1
86+ for i, m := range top {
87+ if m.key == "github" {
88+ gi = i
89+ }
90+ }
91+ var gh []jsonMember
92+ if gi >= 0 {
93+ if gh, err = parseObject(top[gi].val); err != nil {
94+ return "", fmt.Errorf("job contexts: github: %w", err)
95+ }
96+ }
97+ tv, err := json.Marshal(token)
98+ if err != nil {
99+ return "", err
100+ }
101+ set := false
102+ for i, m := range gh {
103+ if m.key == "token" {
104+ gh[i].val, set = tv, true
105+ }
106+ }
107+ if !set {
108+ gh = append([]jsonMember{{"token", tv}}, gh...)
109+ }
110+ ghv, err := encodeObject(gh)
111+ if err != nil {
112+ return "", err
113+ }
114+ if gi >= 0 {
115+ top[gi].val = ghv
116+ } else {
117+ top = append(top, jsonMember{"github", ghv})
118+ }
119+ out, err := encodeObject(top)
120+ return string(out), err
121+}
A internal/host/resolve_test.go +97 -0
@@ -0,0 +1,97 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "connectrpc.com/connect"
13+ "github.com/bnema/zerowrap"
14+
15+ ermite "bnema.dev/ermite-api/gen/ermite"
16+
17+ "bnema.dev/ermite-runner/internal/execstream/execpb"
18+)
19+
20+type resolveStub struct {
21+ stubClient
22+ got *ermite.ResolveActionsRequest
23+ resp *ermite.ResolveActionsResponse
24+ err error
25+}
26+
27+func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) {
28+ c.got = r.Msg
29+ if c.err != nil {
30+ return nil, c.err
31+ }
32+ return connect.NewResponse(c.resp), nil
33+}
34+
35+func resolveJob(c jobClient) *jobRun {
36+ return &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{},
37+ t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}}
38+}
39+
40+func TestResolveActions(t *testing.T) {
41+ c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{
42+ {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://x/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE},
43+ {Ref: "a/c@v1", Error: "boom"},
44+ }}}
45+ resp := resolveJob(c).resolveActions(&execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}})
46+ if resp.Error != "" || len(resp.Actions) != 2 {
47+ t.Fatalf("%+v", resp)
48+ }
49+ if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" {
50+ t.Errorf("request %+v", c.got)
51+ }
52+ if a := resp.Actions[0]; a.Token != "tok" || a.TarballUrl != "https://x/t" || a.NameWithOwner != "a/b" {
53+ t.Errorf("%+v", a)
54+ }
55+ if resp.Actions[1].Error != "boom" {
56+ t.Errorf("%+v", resp.Actions[1])
57+ }
58+}
59+
60+func TestResolveActionsFailures(t *testing.T) {
61+ c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)}
62+ if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") {
63+ t.Errorf("%+v", r)
64+ }
65+ c = &resolveStub{resp: &ermite.ResolveActionsResponse{}}
66+ if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" {
67+ t.Errorf("a short answer must fail: %+v", r)
68+ }
69+ many := make([]string, maxResolveRefs+1)
70+ if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: many}); r.Error == "" {
71+ t.Error("more than 100 refs must fail")
72+ }
73+}
74+
75+func TestSetGitHubToken(t *testing.T) {
76+ cases := map[string]string{
77+ `{"github":{"job":"j","token":""},"needs":{}}`: `{"github":{"job":"j","token":"T"},"needs":{}}`,
78+ `{"github":{"job":"j"}}`: `{"github":{"token":"T","job":"j"}}`,
79+ `{"needs":{}}`: `{"needs":{},"github":{"token":"T"}}`,
80+ }
81+ for in, want := range cases {
82+ got, err := SetGitHubToken(in, "T")
83+ if err != nil || got != want {
84+ t.Errorf("%s: %s %v, want %s", in, got, err, want)
85+ }
86+ }
87+ if got, _ := SetGitHubToken(`{"a":1}`, ""); got != `{"a":1}` {
88+ t.Errorf("empty token changed the contexts: %s", got)
89+ }
90+ if _, err := SetGitHubToken(`[]`, "T"); err == nil {
91+ t.Error("not an object")
92+ }
93+ var m map[string]any
94+ if got, _ := SetGitHubToken(`{}`, `a"b\`); json.Unmarshal([]byte(got), &m) != nil {
95+ t.Errorf("invalid JSON: %s", got)
96+ }
97+}
@@ -0,0 +1,97 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "connectrpc.com/connect"
13+ "github.com/bnema/zerowrap"
14+
15+ ermite "bnema.dev/ermite-api/gen/ermite"
16+
17+ "bnema.dev/ermite-runner/internal/execstream/execpb"
18+)
19+
20+type resolveStub struct {
21+ stubClient
22+ got *ermite.ResolveActionsRequest
23+ resp *ermite.ResolveActionsResponse
24+ err error
25+}
26+
27+func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) {
28+ c.got = r.Msg
29+ if c.err != nil {
30+ return nil, c.err
31+ }
32+ return connect.NewResponse(c.resp), nil
33+}
34+
35+func resolveJob(c jobClient) *jobRun {
36+ return &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{},
37+ t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}}
38+}
39+
40+func TestResolveActions(t *testing.T) {
41+ c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{
42+ {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://x/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE},
43+ {Ref: "a/c@v1", Error: "boom"},
44+ }}}
45+ resp := resolveJob(c).resolveActions(&execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}})
46+ if resp.Error != "" || len(resp.Actions) != 2 {
47+ t.Fatalf("%+v", resp)
48+ }
49+ if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" {
50+ t.Errorf("request %+v", c.got)
51+ }
52+ if a := resp.Actions[0]; a.Token != "tok" || a.TarballUrl != "https://x/t" || a.NameWithOwner != "a/b" {
53+ t.Errorf("%+v", a)
54+ }
55+ if resp.Actions[1].Error != "boom" {
56+ t.Errorf("%+v", resp.Actions[1])
57+ }
58+}
59+
60+func TestResolveActionsFailures(t *testing.T) {
61+ c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)}
62+ if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") {
63+ t.Errorf("%+v", r)
64+ }
65+ c = &resolveStub{resp: &ermite.ResolveActionsResponse{}}
66+ if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" {
67+ t.Errorf("a short answer must fail: %+v", r)
68+ }
69+ many := make([]string, maxResolveRefs+1)
70+ if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: many}); r.Error == "" {
71+ t.Error("more than 100 refs must fail")
72+ }
73+}
74+
75+func TestSetGitHubToken(t *testing.T) {
76+ cases := map[string]string{
77+ `{"github":{"job":"j","token":""},"needs":{}}`: `{"github":{"job":"j","token":"T"},"needs":{}}`,
78+ `{"github":{"job":"j"}}`: `{"github":{"token":"T","job":"j"}}`,
79+ `{"needs":{}}`: `{"needs":{},"github":{"token":"T"}}`,
80+ }
81+ for in, want := range cases {
82+ got, err := SetGitHubToken(in, "T")
83+ if err != nil || got != want {
84+ t.Errorf("%s: %s %v, want %s", in, got, err, want)
85+ }
86+ }
87+ if got, _ := SetGitHubToken(`{"a":1}`, ""); got != `{"a":1}` {
88+ t.Errorf("empty token changed the contexts: %s", got)
89+ }
90+ if _, err := SetGitHubToken(`[]`, "T"); err == nil {
91+ t.Error("not an object")
92+ }
93+ var m map[string]any
94+ if got, _ := SetGitHubToken(`{}`, `a"b\`); json.Unmarshal([]byte(got), &m) != nil {
95+ t.Errorf("invalid JSON: %s", got)
96+ }
97+}
M internal/host/shutdown_test.go +4 -0
@@ -46,6 +46,10 @@ func (c *stubClient) WatchJob(context.Context, *connect.Request[ermite.WatchJobR
4646 return nil, connect.NewError(connect.CodeUnimplemented, nil)
4747 }
4848
49+func (c *stubClient) ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) {
50+ return nil, connect.NewError(connect.CodeUnimplemented, nil)
51+}
52+
4953 func (c *stubClient) CompleteJob(ctx context.Context, _ *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) {
5054 c.mu.Lock()
5155 c.deadline, _ = ctx.Deadline()
@@ -46,6 +46,10 @@ func (c *stubClient) WatchJob(context.Context, *connect.Request[ermite.WatchJobR
46 return nil, connect.NewError(connect.CodeUnimplemented, nil)46 return nil, connect.NewError(connect.CodeUnimplemented, nil)
47 }47 }
48 48
49+func (c *stubClient) ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) {
50+ return nil, connect.NewError(connect.CodeUnimplemented, nil)
51+}
52+
49 func (c *stubClient) CompleteJob(ctx context.Context, _ *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) {53 func (c *stubClient) CompleteJob(ctx context.Context, _ *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) {
50 c.mu.Lock()54 c.mu.Lock()
51 c.deadline, _ = ctx.Deadline()55 c.deadline, _ = ctx.Deadline()

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 11

↑↓ moveenter openesc close