detached ac7c7b5

feat(executor): build the job contexts and the default GITHUB_* and RUNNER_* environment

commit
ac7c7b5b490d8c410de835707f9b5ef57217815f
author
brice · · 2026-10-12 17:48 UTC
parent
e582440
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +713 -0
M go.mod +1 -0
@@ -3,6 +3,7 @@ module bnema.dev/ermite-runner
33 go 1.27.1
44
55 require (
6+ bnema.dev/ermite-actions v0.0.0-20261012165742-b6c4c3a2f7ff
67 github.com/bnema/zerowrap v1.4.1
78 github.com/containerd/errdefs v1.0.0
89 github.com/google/uuid v1.6.0
@@ -3,6 +3,7 @@ module bnema.dev/ermite-runner
3 go 1.27.13 go 1.27.1
4 4
5 require (5 require (
6+ bnema.dev/ermite-actions v0.0.0-20261012165742-b6c4c3a2f7ff
6 github.com/bnema/zerowrap v1.4.17 github.com/bnema/zerowrap v1.4.1
7 github.com/containerd/errdefs v1.0.08 github.com/containerd/errdefs v1.0.0
8 github.com/google/uuid v1.6.09 github.com/google/uuid v1.6.0
M go.sum +2 -0 generated file: collapsed
A internal/executor/contexts.go +465 -0
@@ -0,0 +1,465 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "path/filepath"
8+ "runtime"
9+ "slices"
10+ "strings"
11+
12+ "bnema.dev/ermite-actions/expr"
13+)
14+
15+// ordered is a string map that keeps the position of a key when it is set
16+// again, like the Dictionary<string,string> the official runner uses for
17+// Global.EnvironmentVariables and the step environment (the order shows in the
18+// "env:" block of the log). Keys are case-sensitive, as on Linux
19+// (VarUtil.EnvironmentVariableKeyComparer).
20+type ordered struct {
21+ keys []string
22+ vals map[string]string
23+}
24+
25+func newOrdered() *ordered { return &ordered{vals: map[string]string{}} }
26+
27+func (o *ordered) Set(k, v string) {
28+ if _, ok := o.vals[k]; !ok {
29+ o.keys = append(o.keys, k)
30+ }
31+ o.vals[k] = v
32+}
33+
34+func (o *ordered) Get(k string) (string, bool) { v, ok := o.vals[k]; return v, ok }
35+func (o *ordered) Len() int { return len(o.keys) }
36+func (o *ordered) Keys() []string { return slices.Clone(o.keys) }
37+
38+func (o *ordered) Clone() *ordered {
39+ c := newOrdered()
40+ for _, k := range o.keys {
41+ c.Set(k, o.vals[k])
42+ }
43+ return c
44+}
45+
46+// Dict is the value of the env context. The context of the official runner is
47+// case-sensitive on Linux (CaseSensitiveDictionaryContextData); package expr
48+// has only case-insensitive dictionaries, so two names that differ by case
49+// collapse to the first in expressions (they stay distinct in the process
50+// environment). A known deviation.
51+func (o *ordered) Dict() *expr.Dictionary {
52+ d := expr.NewDictionary()
53+ for _, k := range o.keys {
54+ d.Add(k, expr.String(o.vals[k]))
55+ }
56+ return d
57+}
58+
59+// setKey returns d with key set to v: the value is replaced in place when the
60+// key (compared case-insensitively) exists, else appended.
61+func setKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
62+ out := expr.NewDictionary()
63+ done := false
64+ for _, p := range d.Pairs() {
65+ if strings.EqualFold(p.Key, key) {
66+ if !done {
67+ out.Add(p.Key, v)
68+ done = true
69+ }
70+ continue
71+ }
72+ out.Add(p.Key, p.Value)
73+ }
74+ if !done {
75+ out.Add(key, v)
76+ }
77+ return out
78+}
79+
80+func str(d *expr.Dictionary, key string) string {
81+ if v, ok := d.Get(key); ok {
82+ if s, ok := v.(expr.String); ok {
83+ return string(s)
84+ }
85+ }
86+ return ""
87+}
88+
89+// Layout is the directory layout of a job, like a hosted runner's
90+// (/home/runner/work/<repo>/<repo>, RUNNER_TEMP, RUNNER_TOOL_CACHE).
91+type Layout struct {
92+ Home string // HOME of the runner user
93+ // WorkRoot is the _work directory: <Home>/work.
94+ WorkRoot string
95+ // Temp is RUNNER_TEMP: <WorkRoot>/_temp.
96+ Temp string
97+ // ToolCache is RUNNER_TOOL_CACHE.
98+ ToolCache string
99+ // RunnerWorkspace is RUNNER_WORKSPACE: <WorkRoot>/<repo>.
100+ RunnerWorkspace string
101+ // Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>.
102+ Workspace string
103+}
104+
105+// DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image.
106+const DefaultToolCache = "/opt/hostedtoolcache"
107+
108+// NewLayout lays out a job of the repository repo (its name, without owner)
109+// under home. An empty toolCache means DefaultToolCache.
110+func NewLayout(home, repo, toolCache string) Layout {
111+ if toolCache == "" {
112+ toolCache = DefaultToolCache
113+ }
114+ work := filepath.Join(home, "work")
115+ return Layout{
116+ Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache,
117+ RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo),
118+ }
119+}
120+
121+// RunnerInfo describes the runner for the runner context.
122+type RunnerInfo struct {
123+ Name string
124+ // Environment is "self-hosted" (the default) or "github-hosted".
125+ Environment string
126+ // Arch is "X64" or "ARM64"; empty means the architecture of this process.
127+ Arch string
128+}
129+
130+func (r RunnerInfo) arch() string {
131+ if r.Arch != "" {
132+ return r.Arch
133+ }
134+ switch runtime.GOARCH {
135+ case "amd64":
136+ return "X64"
137+ case "arm64":
138+ return "ARM64"
139+ case "arm":
140+ return "ARM"
141+ }
142+ return "X86"
143+}
144+
145+// repoName returns the repository name of "owner/name".
146+func repoName(full string) string {
147+ if i := strings.LastIndexByte(full, '/'); i >= 0 {
148+ return full[i+1:]
149+ }
150+ return full
151+}
152+
153+// stepCtx is one entry of the steps context.
154+type stepCtx struct {
155+ outputs *ordered
156+ outcome string // "" until the step completes
157+ concl string
158+}
159+
160+// State holds the contexts of a job as the official runner does
161+// (ExecutionContext.ExpressionValues): the contexts the server computed, and
162+// github, runner, job, env and steps, which the runner owns.
163+type State struct {
164+ layout Layout
165+ github *expr.Dictionary
166+ runner *expr.Dictionary
167+ job *expr.Dictionary
168+ // other holds needs, strategy, matrix, inputs, vars: copied from the server.
169+ other map[string]expr.Value
170+ // env is Global.EnvironmentVariables: the job-level environment and what
171+ // GITHUB_ENV adds. A step's own env is not in it.
172+ env *ordered
173+ // steps is the steps context, in order of first mention.
174+ stepOrder []string
175+ steps map[string]*stepCtx
176+ // prepend is Global.PrependPath in the order of the GITHUB_PATH entries.
177+ prepend []string
178+ // status is job.status: "success", "failure" or "cancelled".
179+ status string
180+}
181+
182+// serverContexts are the keys of the contexts JSON that the runner reads.
183+var serverContexts = []string{"needs", "strategy", "matrix", "inputs", "vars"}
184+
185+// NewState builds the contexts of a job from the JSON the server sent (the
186+// "contexts" field of the job) and the matrix JSON, with the context
187+// initialisation of JobRunner.cs:142-177 and ExecutionContext.InitializeJob
188+// (ExecutionContext.cs:1001-1056).
189+func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallback string) (*State, Layout, error) {
190+ root := expr.NewDictionary()
191+ if strings.TrimSpace(contextsJSON) != "" {
192+ v, err := expr.FromJSON([]byte(contextsJSON))
193+ if err != nil {
194+ return nil, Layout{}, fmt.Errorf("job contexts: %w", err)
195+ }
196+ d, ok := v.(*expr.Dictionary)
197+ if !ok {
198+ return nil, Layout{}, fmt.Errorf("job contexts: want a JSON object")
199+ }
200+ root = d
201+ }
202+ s := &State{other: map[string]expr.Value{}, env: newOrdered(), steps: map[string]*stepCtx{}, status: "success"}
203+
204+ // github: token first, then what the server gave (ExecutionContext.cs:1023-1037).
205+ gh := expr.NewDictionary()
206+ gh.Add("token", expr.String(""))
207+ if v, ok := root.Get("github"); ok {
208+ d, ok := v.(*expr.Dictionary)
209+ if !ok {
210+ return nil, Layout{}, fmt.Errorf("job contexts: github must be an object")
211+ }
212+ for _, p := range d.Pairs() {
213+ if strings.EqualFold(p.Key, "token") {
214+ continue
215+ }
216+ gh.Add(p.Key, p.Value)
217+ }
218+ }
219+ if str(gh, "job") == "" && jobKey != "" {
220+ gh = setKey(gh, "job", expr.String(jobKey))
221+ }
222+ repo := repoName(str(gh, "repository"))
223+ if repo == "" {
224+ repo = repoFallback
225+ }
226+ if repo == "" {
227+ return nil, Layout{}, fmt.Errorf("job contexts: github.repository is missing")
228+ }
229+ home := "/home/runner"
230+ s.layout = NewLayout(home, repo, "")
231+ s.github = setKey(gh, "workspace", expr.String(s.layout.Workspace))
232+ return s, s.layout, s.initRest(root, matrixJSON, r)
233+}
234+
235+// WithLayout replaces the directory layout (tests and non-default homes). It
236+// must be called before the first step.
237+func (s *State) WithLayout(l Layout) {
238+ s.layout = l
239+ s.github = setKey(s.github, "workspace", expr.String(l.Workspace))
240+ s.runner = setKey(s.runner, "temp", expr.String(l.Temp))
241+ s.runner = setKey(s.runner, "tool_cache", expr.String(l.ToolCache))
242+ s.runner = setKey(s.runner, "workspace", expr.String(l.RunnerWorkspace))
243+}
244+
245+func (s *State) initRest(root *expr.Dictionary, matrixJSON string, r RunnerInfo) error {
246+ for _, k := range serverContexts {
247+ if v, ok := root.Get(k); ok {
248+ s.other[k] = v
249+ }
250+ }
251+ if matrixJSON != "" {
252+ if v, err := expr.FromJSON([]byte(matrixJSON)); err == nil {
253+ if d, ok := v.(*expr.Dictionary); ok && d.Len() > 0 {
254+ s.other["matrix"] = d
255+ }
256+ } else {
257+ return fmt.Errorf("job matrix: %w", err)
258+ }
259+ }
260+ // A context the job does not have is an empty object, like the server's
261+ // ("needs": {}, "inputs": {}, "vars": {}); matrix and strategy are null
262+ // when absent (recordings: "matrix": null).
263+ for _, k := range []string{"needs", "inputs", "vars"} {
264+ if _, ok := s.other[k]; !ok {
265+ s.other[k] = expr.NewDictionary()
266+ }
267+ }
268+
269+ // runner: os, arch, name, environment, tool_cache, temp (JobRunner.cs:165-176,
270+ // TempDirectoryManager.cs:31) and workspace (JobExtension.cs:215).
271+ env := r.Environment
272+ if env == "" {
273+ env = "self-hosted"
274+ }
275+ rc := expr.NewDictionary()
276+ rc.Add("os", expr.String("Linux"))
277+ rc.Add("arch", expr.String(r.arch()))
278+ rc.Add("name", expr.String(r.Name))
279+ rc.Add("environment", expr.String(env))
280+ rc.Add("tool_cache", expr.String(s.layout.ToolCache))
281+ rc.Add("temp", expr.String(s.layout.Temp))
282+ rc.Add("workspace", expr.String(s.layout.RunnerWorkspace))
283+ s.runner = rc
284+
285+ // job: status first (StepsRunner.cs:53), then what the server gave.
286+ jc := expr.NewDictionary()
287+ jc.Add("status", expr.String("success"))
288+ if v, ok := root.Get("job"); ok {
289+ if d, ok := v.(*expr.Dictionary); ok {
290+ for _, p := range d.Pairs() {
291+ if !strings.EqualFold(p.Key, "status") {
292+ jc.Add(p.Key, p.Value)
293+ }
294+ }
295+ }
296+ }
297+ s.job = jc
298+ return nil
299+}
300+
301+// Layout returns the directory layout.
302+func (s *State) Layout() Layout { return s.layout }
303+
304+// SetGitHub sets a key of the github context (SetGitHubContext).
305+func (s *State) SetGitHub(k, v string) { s.github = setKey(s.github, k, expr.String(v)) }
306+
307+// GitHub returns a string key of the github context ("" when absent).
308+func (s *State) GitHub(k string) string { return str(s.github, k) }
309+
310+// Event returns github.event, or nil.
311+func (s *State) Event() expr.Value { v, _ := s.github.Get("event"); return v }
312+
313+// SetEnv sets a variable of the job environment (Global.EnvironmentVariables
314+// and the env context).
315+func (s *State) SetEnv(k, v string) { s.env.Set(k, v) }
316+
317+// JobEnv returns a copy of the job environment.
318+func (s *State) JobEnv() *ordered { return s.env.Clone() }
319+
320+// AddPath records a GITHUB_PATH entry: a repeated entry moves to the end
321+// (AddPathFileCommand, FileCommandManager.cs:152).
322+func (s *State) AddPath(p string) {
323+ s.prepend = slices.DeleteFunc(s.prepend, func(x string) bool { return x == p })
324+ s.prepend = append(s.prepend, p)
325+}
326+
327+// Prepend returns the GITHUB_PATH entries in the order added.
328+func (s *State) Prepend() []string { return slices.Clone(s.prepend) }
329+
330+// SetStatus sets job.status ("success", "failure" or "cancelled").
331+func (s *State) SetStatus(status string) {
332+ s.status = status
333+ s.job = setKey(s.job, "status", expr.String(status))
334+}
335+
336+// Status returns job.status.
337+func (s *State) Status() string { return s.status }
338+
339+// stepIDVisible reports whether a step with this id appears in the steps
340+// context: generated ids start with "__" and do not (ExecutionContext.cs:644).
341+func stepIDVisible(id string) bool { return id != "" && !strings.HasPrefix(id, "__") }
342+
343+func (s *State) step(id string) *stepCtx {
344+ c, ok := s.steps[id]
345+ if !ok {
346+ c = &stepCtx{outputs: newOrdered()}
347+ s.steps[id] = c
348+ s.stepOrder = append(s.stepOrder, id)
349+ }
350+ return c
351+}
352+
353+// SetOutput sets an output of step id (StepsContext.SetOutput). Steps with a
354+// generated id keep no outputs.
355+func (s *State) SetOutput(id, name, value string) {
356+ if !stepIDVisible(id) {
357+ return
358+ }
359+ s.step(id).outputs.Set(name, value)
360+}
361+
362+// SetStepResult records outcome and conclusion of a completed step
363+// (ExecutionContext.UpdateGlobalStepsContext).
364+func (s *State) SetStepResult(id, outcome, conclusion string) {
365+ if !stepIDVisible(id) {
366+ return
367+ }
368+ c := s.step(id)
369+ c.outcome, c.concl = outcome, conclusion
370+}
371+
372+// Output returns an output of a step ("" when absent).
373+func (s *State) Output(id, name string) string {
374+ if c, ok := s.steps[id]; ok {
375+ v, _ := c.outputs.Get(name)
376+ return v
377+ }
378+ return ""
379+}
380+
381+func (s *State) stepsValue() *expr.Dictionary {
382+ d := expr.NewDictionary()
383+ for _, id := range s.stepOrder {
384+ c := s.steps[id]
385+ sd := expr.NewDictionary()
386+ sd.Add("outputs", c.outputs.Dict())
387+ if c.outcome != "" {
388+ sd.Add("outcome", expr.String(c.outcome))
389+ sd.Add("conclusion", expr.String(c.concl))
390+ }
391+ d.Add(id, sd)
392+ }
393+ return d
394+}
395+
396+// Values returns the contexts for expression evaluation. stepEnv, when not
397+// nil, is the env context of a step (job environment with the step's own
398+// variables); otherwise the env context is the job environment.
399+func (s *State) Values(env *ordered) map[string]expr.Value {
400+ if env == nil {
401+ env = s.env
402+ }
403+ m := map[string]expr.Value{
404+ "github": s.github,
405+ "runner": s.runner,
406+ "job": s.job,
407+ "env": env.Dict(),
408+ "steps": s.stepsValue(),
409+ "secrets": expr.NewDictionary(),
410+ }
411+ for k, v := range s.other {
412+ m[k] = v
413+ }
414+ return m
415+}
416+
417+// githubEnvAllowlist is GitHubContext._contextEnvAllowlist (GitHubContext.cs:9).
418+var githubEnvAllowlist = map[string]bool{
419+ "action_path": true, "action_ref": true, "action_repository": true, "action": true, "actor": true,
420+ "actor_id": true, "api_url": true, "artifacts": true, "artifacts_list": true, "base_ref": true,
421+ "env": true, "event_name": true, "event_path": true, "graphql_url": true, "head_ref": true,
422+ "job": true, "output": true, "path": true, "ref_name": true, "ref_protected": true, "ref_type": true,
423+ "ref": true, "repository": true, "repository_id": true, "repository_owner": true,
424+ "repository_owner_id": true, "retention_days": true, "run_attempt": true, "run_id": true,
425+ "run_number": true, "server_url": true, "sha": true, "state": true, "step_summary": true,
426+ "triggering_actor": true, "workflow": true, "workflow_ref": true, "workflow_sha": true, "workspace": true,
427+}
428+
429+// RuntimeEnv is the environment that the contexts add to every step: the
430+// allow-listed keys of github as GITHUB_<KEY> (strings and booleans only, as in
431+// GitHubContext.GetRuntimeEnvironmentVariables), and every key of runner as
432+// RUNNER_<KEY> (RunnerContext.cs:11). Numbers and objects are skipped: the
433+// server must send GitHub's string forms ("run_id": "123").
434+func (s *State) RuntimeEnv() *ordered {
435+ out := newOrdered()
436+ for _, p := range s.github.Pairs() {
437+ if !githubEnvAllowlist[strings.ToLower(p.Key)] {
438+ continue
439+ }
440+ switch v := p.Value.(type) {
441+ case expr.String:
442+ out.Set("GITHUB_"+strings.ToUpper(p.Key), string(v))
443+ case expr.Boolean:
444+ out.Set("GITHUB_"+strings.ToUpper(p.Key), map[bool]string{true: "true", false: "false"}[bool(v)])
445+ }
446+ }
447+ for _, p := range s.runner.Pairs() {
448+ if v, ok := p.Value.(expr.String); ok {
449+ out.Set("RUNNER_"+strings.ToUpper(p.Key), string(v))
450+ }
451+ }
452+ return out
453+}
454+
455+// EventJSON returns github.event as JSON for event.json, or "" without event.
456+func (s *State) EventJSON() string {
457+ v := s.Event()
458+ if v == nil {
459+ return ""
460+ }
461+ if _, isNull := v.(expr.Null); isNull {
462+ return ""
463+ }
464+ return expr.ToJSON(v)
465+}
@@ -0,0 +1,465 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "path/filepath"
8+ "runtime"
9+ "slices"
10+ "strings"
11+
12+ "bnema.dev/ermite-actions/expr"
13+)
14+
15+// ordered is a string map that keeps the position of a key when it is set
16+// again, like the Dictionary<string,string> the official runner uses for
17+// Global.EnvironmentVariables and the step environment (the order shows in the
18+// "env:" block of the log). Keys are case-sensitive, as on Linux
19+// (VarUtil.EnvironmentVariableKeyComparer).
20+type ordered struct {
21+ keys []string
22+ vals map[string]string
23+}
24+
25+func newOrdered() *ordered { return &ordered{vals: map[string]string{}} }
26+
27+func (o *ordered) Set(k, v string) {
28+ if _, ok := o.vals[k]; !ok {
29+ o.keys = append(o.keys, k)
30+ }
31+ o.vals[k] = v
32+}
33+
34+func (o *ordered) Get(k string) (string, bool) { v, ok := o.vals[k]; return v, ok }
35+func (o *ordered) Len() int { return len(o.keys) }
36+func (o *ordered) Keys() []string { return slices.Clone(o.keys) }
37+
38+func (o *ordered) Clone() *ordered {
39+ c := newOrdered()
40+ for _, k := range o.keys {
41+ c.Set(k, o.vals[k])
42+ }
43+ return c
44+}
45+
46+// Dict is the value of the env context. The context of the official runner is
47+// case-sensitive on Linux (CaseSensitiveDictionaryContextData); package expr
48+// has only case-insensitive dictionaries, so two names that differ by case
49+// collapse to the first in expressions (they stay distinct in the process
50+// environment). A known deviation.
51+func (o *ordered) Dict() *expr.Dictionary {
52+ d := expr.NewDictionary()
53+ for _, k := range o.keys {
54+ d.Add(k, expr.String(o.vals[k]))
55+ }
56+ return d
57+}
58+
59+// setKey returns d with key set to v: the value is replaced in place when the
60+// key (compared case-insensitively) exists, else appended.
61+func setKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
62+ out := expr.NewDictionary()
63+ done := false
64+ for _, p := range d.Pairs() {
65+ if strings.EqualFold(p.Key, key) {
66+ if !done {
67+ out.Add(p.Key, v)
68+ done = true
69+ }
70+ continue
71+ }
72+ out.Add(p.Key, p.Value)
73+ }
74+ if !done {
75+ out.Add(key, v)
76+ }
77+ return out
78+}
79+
80+func str(d *expr.Dictionary, key string) string {
81+ if v, ok := d.Get(key); ok {
82+ if s, ok := v.(expr.String); ok {
83+ return string(s)
84+ }
85+ }
86+ return ""
87+}
88+
89+// Layout is the directory layout of a job, like a hosted runner's
90+// (/home/runner/work/<repo>/<repo>, RUNNER_TEMP, RUNNER_TOOL_CACHE).
91+type Layout struct {
92+ Home string // HOME of the runner user
93+ // WorkRoot is the _work directory: <Home>/work.
94+ WorkRoot string
95+ // Temp is RUNNER_TEMP: <WorkRoot>/_temp.
96+ Temp string
97+ // ToolCache is RUNNER_TOOL_CACHE.
98+ ToolCache string
99+ // RunnerWorkspace is RUNNER_WORKSPACE: <WorkRoot>/<repo>.
100+ RunnerWorkspace string
101+ // Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>.
102+ Workspace string
103+}
104+
105+// DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image.
106+const DefaultToolCache = "/opt/hostedtoolcache"
107+
108+// NewLayout lays out a job of the repository repo (its name, without owner)
109+// under home. An empty toolCache means DefaultToolCache.
110+func NewLayout(home, repo, toolCache string) Layout {
111+ if toolCache == "" {
112+ toolCache = DefaultToolCache
113+ }
114+ work := filepath.Join(home, "work")
115+ return Layout{
116+ Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache,
117+ RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo),
118+ }
119+}
120+
121+// RunnerInfo describes the runner for the runner context.
122+type RunnerInfo struct {
123+ Name string
124+ // Environment is "self-hosted" (the default) or "github-hosted".
125+ Environment string
126+ // Arch is "X64" or "ARM64"; empty means the architecture of this process.
127+ Arch string
128+}
129+
130+func (r RunnerInfo) arch() string {
131+ if r.Arch != "" {
132+ return r.Arch
133+ }
134+ switch runtime.GOARCH {
135+ case "amd64":
136+ return "X64"
137+ case "arm64":
138+ return "ARM64"
139+ case "arm":
140+ return "ARM"
141+ }
142+ return "X86"
143+}
144+
145+// repoName returns the repository name of "owner/name".
146+func repoName(full string) string {
147+ if i := strings.LastIndexByte(full, '/'); i >= 0 {
148+ return full[i+1:]
149+ }
150+ return full
151+}
152+
153+// stepCtx is one entry of the steps context.
154+type stepCtx struct {
155+ outputs *ordered
156+ outcome string // "" until the step completes
157+ concl string
158+}
159+
160+// State holds the contexts of a job as the official runner does
161+// (ExecutionContext.ExpressionValues): the contexts the server computed, and
162+// github, runner, job, env and steps, which the runner owns.
163+type State struct {
164+ layout Layout
165+ github *expr.Dictionary
166+ runner *expr.Dictionary
167+ job *expr.Dictionary
168+ // other holds needs, strategy, matrix, inputs, vars: copied from the server.
169+ other map[string]expr.Value
170+ // env is Global.EnvironmentVariables: the job-level environment and what
171+ // GITHUB_ENV adds. A step's own env is not in it.
172+ env *ordered
173+ // steps is the steps context, in order of first mention.
174+ stepOrder []string
175+ steps map[string]*stepCtx
176+ // prepend is Global.PrependPath in the order of the GITHUB_PATH entries.
177+ prepend []string
178+ // status is job.status: "success", "failure" or "cancelled".
179+ status string
180+}
181+
182+// serverContexts are the keys of the contexts JSON that the runner reads.
183+var serverContexts = []string{"needs", "strategy", "matrix", "inputs", "vars"}
184+
185+// NewState builds the contexts of a job from the JSON the server sent (the
186+// "contexts" field of the job) and the matrix JSON, with the context
187+// initialisation of JobRunner.cs:142-177 and ExecutionContext.InitializeJob
188+// (ExecutionContext.cs:1001-1056).
189+func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallback string) (*State, Layout, error) {
190+ root := expr.NewDictionary()
191+ if strings.TrimSpace(contextsJSON) != "" {
192+ v, err := expr.FromJSON([]byte(contextsJSON))
193+ if err != nil {
194+ return nil, Layout{}, fmt.Errorf("job contexts: %w", err)
195+ }
196+ d, ok := v.(*expr.Dictionary)
197+ if !ok {
198+ return nil, Layout{}, fmt.Errorf("job contexts: want a JSON object")
199+ }
200+ root = d
201+ }
202+ s := &State{other: map[string]expr.Value{}, env: newOrdered(), steps: map[string]*stepCtx{}, status: "success"}
203+
204+ // github: token first, then what the server gave (ExecutionContext.cs:1023-1037).
205+ gh := expr.NewDictionary()
206+ gh.Add("token", expr.String(""))
207+ if v, ok := root.Get("github"); ok {
208+ d, ok := v.(*expr.Dictionary)
209+ if !ok {
210+ return nil, Layout{}, fmt.Errorf("job contexts: github must be an object")
211+ }
212+ for _, p := range d.Pairs() {
213+ if strings.EqualFold(p.Key, "token") {
214+ continue
215+ }
216+ gh.Add(p.Key, p.Value)
217+ }
218+ }
219+ if str(gh, "job") == "" && jobKey != "" {
220+ gh = setKey(gh, "job", expr.String(jobKey))
221+ }
222+ repo := repoName(str(gh, "repository"))
223+ if repo == "" {
224+ repo = repoFallback
225+ }
226+ if repo == "" {
227+ return nil, Layout{}, fmt.Errorf("job contexts: github.repository is missing")
228+ }
229+ home := "/home/runner"
230+ s.layout = NewLayout(home, repo, "")
231+ s.github = setKey(gh, "workspace", expr.String(s.layout.Workspace))
232+ return s, s.layout, s.initRest(root, matrixJSON, r)
233+}
234+
235+// WithLayout replaces the directory layout (tests and non-default homes). It
236+// must be called before the first step.
237+func (s *State) WithLayout(l Layout) {
238+ s.layout = l
239+ s.github = setKey(s.github, "workspace", expr.String(l.Workspace))
240+ s.runner = setKey(s.runner, "temp", expr.String(l.Temp))
241+ s.runner = setKey(s.runner, "tool_cache", expr.String(l.ToolCache))
242+ s.runner = setKey(s.runner, "workspace", expr.String(l.RunnerWorkspace))
243+}
244+
245+func (s *State) initRest(root *expr.Dictionary, matrixJSON string, r RunnerInfo) error {
246+ for _, k := range serverContexts {
247+ if v, ok := root.Get(k); ok {
248+ s.other[k] = v
249+ }
250+ }
251+ if matrixJSON != "" {
252+ if v, err := expr.FromJSON([]byte(matrixJSON)); err == nil {
253+ if d, ok := v.(*expr.Dictionary); ok && d.Len() > 0 {
254+ s.other["matrix"] = d
255+ }
256+ } else {
257+ return fmt.Errorf("job matrix: %w", err)
258+ }
259+ }
260+ // A context the job does not have is an empty object, like the server's
261+ // ("needs": {}, "inputs": {}, "vars": {}); matrix and strategy are null
262+ // when absent (recordings: "matrix": null).
263+ for _, k := range []string{"needs", "inputs", "vars"} {
264+ if _, ok := s.other[k]; !ok {
265+ s.other[k] = expr.NewDictionary()
266+ }
267+ }
268+
269+ // runner: os, arch, name, environment, tool_cache, temp (JobRunner.cs:165-176,
270+ // TempDirectoryManager.cs:31) and workspace (JobExtension.cs:215).
271+ env := r.Environment
272+ if env == "" {
273+ env = "self-hosted"
274+ }
275+ rc := expr.NewDictionary()
276+ rc.Add("os", expr.String("Linux"))
277+ rc.Add("arch", expr.String(r.arch()))
278+ rc.Add("name", expr.String(r.Name))
279+ rc.Add("environment", expr.String(env))
280+ rc.Add("tool_cache", expr.String(s.layout.ToolCache))
281+ rc.Add("temp", expr.String(s.layout.Temp))
282+ rc.Add("workspace", expr.String(s.layout.RunnerWorkspace))
283+ s.runner = rc
284+
285+ // job: status first (StepsRunner.cs:53), then what the server gave.
286+ jc := expr.NewDictionary()
287+ jc.Add("status", expr.String("success"))
288+ if v, ok := root.Get("job"); ok {
289+ if d, ok := v.(*expr.Dictionary); ok {
290+ for _, p := range d.Pairs() {
291+ if !strings.EqualFold(p.Key, "status") {
292+ jc.Add(p.Key, p.Value)
293+ }
294+ }
295+ }
296+ }
297+ s.job = jc
298+ return nil
299+}
300+
301+// Layout returns the directory layout.
302+func (s *State) Layout() Layout { return s.layout }
303+
304+// SetGitHub sets a key of the github context (SetGitHubContext).
305+func (s *State) SetGitHub(k, v string) { s.github = setKey(s.github, k, expr.String(v)) }
306+
307+// GitHub returns a string key of the github context ("" when absent).
308+func (s *State) GitHub(k string) string { return str(s.github, k) }
309+
310+// Event returns github.event, or nil.
311+func (s *State) Event() expr.Value { v, _ := s.github.Get("event"); return v }
312+
313+// SetEnv sets a variable of the job environment (Global.EnvironmentVariables
314+// and the env context).
315+func (s *State) SetEnv(k, v string) { s.env.Set(k, v) }
316+
317+// JobEnv returns a copy of the job environment.
318+func (s *State) JobEnv() *ordered { return s.env.Clone() }
319+
320+// AddPath records a GITHUB_PATH entry: a repeated entry moves to the end
321+// (AddPathFileCommand, FileCommandManager.cs:152).
322+func (s *State) AddPath(p string) {
323+ s.prepend = slices.DeleteFunc(s.prepend, func(x string) bool { return x == p })
324+ s.prepend = append(s.prepend, p)
325+}
326+
327+// Prepend returns the GITHUB_PATH entries in the order added.
328+func (s *State) Prepend() []string { return slices.Clone(s.prepend) }
329+
330+// SetStatus sets job.status ("success", "failure" or "cancelled").
331+func (s *State) SetStatus(status string) {
332+ s.status = status
333+ s.job = setKey(s.job, "status", expr.String(status))
334+}
335+
336+// Status returns job.status.
337+func (s *State) Status() string { return s.status }
338+
339+// stepIDVisible reports whether a step with this id appears in the steps
340+// context: generated ids start with "__" and do not (ExecutionContext.cs:644).
341+func stepIDVisible(id string) bool { return id != "" && !strings.HasPrefix(id, "__") }
342+
343+func (s *State) step(id string) *stepCtx {
344+ c, ok := s.steps[id]
345+ if !ok {
346+ c = &stepCtx{outputs: newOrdered()}
347+ s.steps[id] = c
348+ s.stepOrder = append(s.stepOrder, id)
349+ }
350+ return c
351+}
352+
353+// SetOutput sets an output of step id (StepsContext.SetOutput). Steps with a
354+// generated id keep no outputs.
355+func (s *State) SetOutput(id, name, value string) {
356+ if !stepIDVisible(id) {
357+ return
358+ }
359+ s.step(id).outputs.Set(name, value)
360+}
361+
362+// SetStepResult records outcome and conclusion of a completed step
363+// (ExecutionContext.UpdateGlobalStepsContext).
364+func (s *State) SetStepResult(id, outcome, conclusion string) {
365+ if !stepIDVisible(id) {
366+ return
367+ }
368+ c := s.step(id)
369+ c.outcome, c.concl = outcome, conclusion
370+}
371+
372+// Output returns an output of a step ("" when absent).
373+func (s *State) Output(id, name string) string {
374+ if c, ok := s.steps[id]; ok {
375+ v, _ := c.outputs.Get(name)
376+ return v
377+ }
378+ return ""
379+}
380+
381+func (s *State) stepsValue() *expr.Dictionary {
382+ d := expr.NewDictionary()
383+ for _, id := range s.stepOrder {
384+ c := s.steps[id]
385+ sd := expr.NewDictionary()
386+ sd.Add("outputs", c.outputs.Dict())
387+ if c.outcome != "" {
388+ sd.Add("outcome", expr.String(c.outcome))
389+ sd.Add("conclusion", expr.String(c.concl))
390+ }
391+ d.Add(id, sd)
392+ }
393+ return d
394+}
395+
396+// Values returns the contexts for expression evaluation. stepEnv, when not
397+// nil, is the env context of a step (job environment with the step's own
398+// variables); otherwise the env context is the job environment.
399+func (s *State) Values(env *ordered) map[string]expr.Value {
400+ if env == nil {
401+ env = s.env
402+ }
403+ m := map[string]expr.Value{
404+ "github": s.github,
405+ "runner": s.runner,
406+ "job": s.job,
407+ "env": env.Dict(),
408+ "steps": s.stepsValue(),
409+ "secrets": expr.NewDictionary(),
410+ }
411+ for k, v := range s.other {
412+ m[k] = v
413+ }
414+ return m
415+}
416+
417+// githubEnvAllowlist is GitHubContext._contextEnvAllowlist (GitHubContext.cs:9).
418+var githubEnvAllowlist = map[string]bool{
419+ "action_path": true, "action_ref": true, "action_repository": true, "action": true, "actor": true,
420+ "actor_id": true, "api_url": true, "artifacts": true, "artifacts_list": true, "base_ref": true,
421+ "env": true, "event_name": true, "event_path": true, "graphql_url": true, "head_ref": true,
422+ "job": true, "output": true, "path": true, "ref_name": true, "ref_protected": true, "ref_type": true,
423+ "ref": true, "repository": true, "repository_id": true, "repository_owner": true,
424+ "repository_owner_id": true, "retention_days": true, "run_attempt": true, "run_id": true,
425+ "run_number": true, "server_url": true, "sha": true, "state": true, "step_summary": true,
426+ "triggering_actor": true, "workflow": true, "workflow_ref": true, "workflow_sha": true, "workspace": true,
427+}
428+
429+// RuntimeEnv is the environment that the contexts add to every step: the
430+// allow-listed keys of github as GITHUB_<KEY> (strings and booleans only, as in
431+// GitHubContext.GetRuntimeEnvironmentVariables), and every key of runner as
432+// RUNNER_<KEY> (RunnerContext.cs:11). Numbers and objects are skipped: the
433+// server must send GitHub's string forms ("run_id": "123").
434+func (s *State) RuntimeEnv() *ordered {
435+ out := newOrdered()
436+ for _, p := range s.github.Pairs() {
437+ if !githubEnvAllowlist[strings.ToLower(p.Key)] {
438+ continue
439+ }
440+ switch v := p.Value.(type) {
441+ case expr.String:
442+ out.Set("GITHUB_"+strings.ToUpper(p.Key), string(v))
443+ case expr.Boolean:
444+ out.Set("GITHUB_"+strings.ToUpper(p.Key), map[bool]string{true: "true", false: "false"}[bool(v)])
445+ }
446+ }
447+ for _, p := range s.runner.Pairs() {
448+ if v, ok := p.Value.(expr.String); ok {
449+ out.Set("RUNNER_"+strings.ToUpper(p.Key), string(v))
450+ }
451+ }
452+ return out
453+}
454+
455+// EventJSON returns github.event as JSON for event.json, or "" without event.
456+func (s *State) EventJSON() string {
457+ v := s.Event()
458+ if v == nil {
459+ return ""
460+ }
461+ if _, isNull := v.(expr.Null); isNull {
462+ return ""
463+ }
464+ return expr.ToJSON(v)
465+}
A internal/executor/contexts_test.go +245 -0
@@ -0,0 +1,245 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "reflect"
7+ "strings"
8+ "testing"
9+
10+ "bnema.dev/ermite-actions/expr"
11+)
12+
13+const testContexts = `{
14+ "github": {"repository":"octo/hello","repository_owner":"octo","run_id":"42","run_number":"7","run_attempt":"1",
15+ "ref":"refs/heads/main","ref_name":"main","ref_type":"branch","sha":"abc","event_name":"push","job":"build",
16+ "ref_protected":false,"workflow":"CI","actor":"octo","server_url":"https://ermite.example",
17+ "event":{"ref":"refs/heads/main","n":1},"retention_days":"90","unknown_key":"x"},
18+ "needs": {"a":{"result":"success","outputs":{"o":"1"}}},
19+ "strategy": {"fail-fast":true,"job-index":0,"job-total":1,"max-parallel":1},
20+ "matrix": {"os":"linux"},
21+ "inputs": {},
22+ "vars": {"V":"1"}
23+}`
24+
25+func newTestState(t *testing.T) *State {
26+ t.Helper()
27+ s, _, err := NewState(testContexts, `{"os":"linux"}`, "build", RunnerInfo{Name: "r1"}, "")
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ return s
32+}
33+
34+func TestNewStateLayoutAndGitHub(t *testing.T) {
35+ s := newTestState(t)
36+ l := s.Layout()
37+ if l.Workspace != "/home/runner/work/hello/hello" || l.RunnerWorkspace != "/home/runner/work/hello" ||
38+ l.Temp != "/home/runner/work/_temp" || l.ToolCache != "/opt/hostedtoolcache" || l.Home != "/home/runner" {
39+ t.Fatalf("%+v", l)
40+ }
41+ if s.GitHub("workspace") != l.Workspace || s.GitHub("job") != "build" {
42+ t.Fatalf("workspace %q job %q", s.GitHub("workspace"), s.GitHub("job"))
43+ }
44+ // token comes first (ExecutionContext.cs:1023): an empty token, no GITHUB_TOKEN yet.
45+ gh := s.Values(nil)["github"].(*expr.Dictionary)
46+ if gh.Keys()[0] != "token" {
47+ t.Fatalf("keys %v", gh.Keys())
48+ }
49+}
50+
51+func TestRuntimeEnv(t *testing.T) {
52+ s := newTestState(t)
53+ env := s.RuntimeEnv()
54+ want := map[string]string{
55+ "GITHUB_REPOSITORY": "octo/hello", "GITHUB_REPOSITORY_OWNER": "octo", "GITHUB_RUN_ID": "42",
56+ "GITHUB_RUN_NUMBER": "7", "GITHUB_RUN_ATTEMPT": "1", "GITHUB_REF": "refs/heads/main",
57+ "GITHUB_REF_NAME": "main", "GITHUB_REF_TYPE": "branch", "GITHUB_SHA": "abc", "GITHUB_EVENT_NAME": "push",
58+ "GITHUB_JOB": "build", "GITHUB_REF_PROTECTED": "false", "GITHUB_WORKFLOW": "CI", "GITHUB_ACTOR": "octo",
59+ "GITHUB_SERVER_URL": "https://ermite.example", "GITHUB_RETENTION_DAYS": "90",
60+ "GITHUB_WORKSPACE": "/home/runner/work/hello/hello",
61+ "RUNNER_OS": "Linux", "RUNNER_NAME": "r1", "RUNNER_ENVIRONMENT": "self-hosted",
62+ "RUNNER_TEMP": "/home/runner/work/_temp", "RUNNER_TOOL_CACHE": "/opt/hostedtoolcache",
63+ "RUNNER_WORKSPACE": "/home/runner/work/hello",
64+ }
65+ for k, v := range want {
66+ if got, ok := env.Get(k); !ok || got != v {
67+ t.Errorf("%s = %q (%v), want %q", k, got, ok, v)
68+ }
69+ }
70+ // Only allow-listed string/boolean keys; objects are not exported.
71+ for _, k := range []string{"GITHUB_EVENT", "GITHUB_UNKNOWN_KEY", "GITHUB_TOKEN"} {
72+ if _, ok := env.Get(k); ok {
73+ t.Errorf("%s must not be set", k)
74+ }
75+ }
76+ if a, _ := env.Get("RUNNER_ARCH"); a != "X64" && a != "ARM64" {
77+ t.Errorf("RUNNER_ARCH = %q", a)
78+ }
79+}
80+
81+func TestRunnerContextOrderAndValues(t *testing.T) {
82+ s := newTestState(t)
83+ r := s.Values(nil)["runner"].(*expr.Dictionary)
84+ if got := strings.Join(r.Keys(), ","); got != "os,arch,name,environment,tool_cache,temp,workspace" {
85+ t.Fatal(got)
86+ }
87+ s2, _, _ := NewState(testContexts, "", "build", RunnerInfo{Name: "h", Environment: "github-hosted", Arch: "ARM64"}, "")
88+ r2 := s2.Values(nil)["runner"].(*expr.Dictionary)
89+ if str(r2, "environment") != "github-hosted" || str(r2, "arch") != "ARM64" {
90+ t.Fatal(expr.ToJSON(r2))
91+ }
92+}
93+
94+func TestServerContextsAndDefaults(t *testing.T) {
95+ s := newTestState(t)
96+ v := s.Values(nil)
97+ if expr.ToJSON(v["matrix"]) != "{\n \"os\": \"linux\"\n}" {
98+ t.Fatal(expr.ToJSON(v["matrix"]))
99+ }
100+ for _, k := range []string{"needs", "strategy", "inputs", "vars", "steps", "env", "job", "secrets"} {
101+ if v[k] == nil {
102+ t.Errorf("context %s missing", k)
103+ }
104+ }
105+ // Absent contexts: needs/inputs/vars are empty objects, matrix is not set
106+ // (null in expressions), as the recordings show.
107+ s2, _, err := NewState(`{"github":{"repository":"o/r"}}`, "{}", "j", RunnerInfo{}, "")
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ v2 := s2.Values(nil)
112+ if _, ok := v2["matrix"]; ok {
113+ t.Error("matrix must be absent without a matrix")
114+ }
115+ if d, ok := v2["needs"].(*expr.Dictionary); !ok || d.Len() != 0 {
116+ t.Errorf("needs %v", v2["needs"])
117+ }
118+}
119+
120+func TestNewStateErrors(t *testing.T) {
121+ for _, tc := range []struct{ ctx, matrix, repo, want string }{
122+ {`[]`, "", "", "want a JSON object"},
123+ {`{`, "", "", "job contexts"},
124+ {`{"github":1}`, "", "", "github must be an object"},
125+ {`{"github":{}}`, "", "", "github.repository is missing"},
126+ {`{"github":{"repository":"o/r"}}`, "{", "", "job matrix"},
127+ } {
128+ _, _, err := NewState(tc.ctx, tc.matrix, "j", RunnerInfo{}, tc.repo)
129+ if err == nil || !strings.Contains(err.Error(), tc.want) {
130+ t.Errorf("%q: %v, want %q", tc.ctx, err, tc.want)
131+ }
132+ }
133+ // The repository name can come from the job when the context lacks it.
134+ if _, _, err := NewState(`{"github":{}}`, "", "j", RunnerInfo{}, "hello"); err != nil {
135+ t.Fatal(err)
136+ }
137+}
138+
139+func TestJobStatus(t *testing.T) {
140+ s := newTestState(t)
141+ job := func() *expr.Dictionary { return s.Values(nil)["job"].(*expr.Dictionary) }
142+ if str(job(), "status") != "success" || job().Keys()[0] != "status" {
143+ t.Fatal(expr.ToJSON(job()))
144+ }
145+ s.SetStatus("failure")
146+ if str(job(), "status") != "failure" || s.Status() != "failure" {
147+ t.Fatal(expr.ToJSON(job()))
148+ }
149+}
150+
151+func TestEnvOrderAndOverride(t *testing.T) {
152+ o := newOrdered()
153+ o.Set("A", "1")
154+ o.Set("B", "2")
155+ o.Set("A", "3") // keeps its position
156+ if !reflect.DeepEqual(o.Keys(), []string{"A", "B"}) || o.vals["A"] != "3" {
157+ t.Fatalf("%v", o)
158+ }
159+ c := o.Clone()
160+ c.Set("C", "4")
161+ if o.Len() != 2 || c.Len() != 3 {
162+ t.Fatal("Clone shares state")
163+ }
164+ // Case-sensitive keys, like Linux.
165+ o.Set("a", "x")
166+ if o.Len() != 3 {
167+ t.Fatal("keys must be case-sensitive")
168+ }
169+}
170+
171+func TestStepsContext(t *testing.T) {
172+ s := newTestState(t)
173+ s.SetOutput("one", "out", "v")
174+ s.SetOutput("__run", "ignored", "x") // generated ids are not in the context
175+ s.SetStepResult("one", "failure", "success")
176+ s.SetStepResult("__run_2", "success", "success")
177+ s.SetStepResult("skipped", "skipped", "skipped")
178+ steps := s.Values(nil)["steps"].(*expr.Dictionary)
179+ if got := strings.Join(steps.Keys(), ","); got != "one,skipped" {
180+ t.Fatalf("keys %s", got)
181+ }
182+ one, _ := steps.Get("one")
183+ od := one.(*expr.Dictionary)
184+ if str(od, "outcome") != "failure" || str(od, "conclusion") != "success" {
185+ t.Fatal(expr.ToJSON(one))
186+ }
187+ outs, _ := od.Get("outputs")
188+ if str(outs.(*expr.Dictionary), "out") != "v" || s.Output("one", "out") != "v" || s.Output("none", "x") != "" {
189+ t.Fatal(expr.ToJSON(outs))
190+ }
191+ // A step that has output but has not completed has no outcome yet.
192+ s.SetOutput("running", "k", "v")
193+ r, _ := s.Values(nil)["steps"].(*expr.Dictionary).Get("running")
194+ if _, ok := r.(*expr.Dictionary).Get("outcome"); ok {
195+ t.Fatal("outcome before completion")
196+ }
197+}
198+
199+func TestAddPathMovesRepeatedEntryToTheEnd(t *testing.T) {
200+ s := newTestState(t)
201+ for _, p := range []string{"/a", "/b", "/a"} {
202+ s.AddPath(p)
203+ }
204+ if !reflect.DeepEqual(s.Prepend(), []string{"/b", "/a"}) {
205+ t.Fatal(s.Prepend())
206+ }
207+}
208+
209+func TestEnvContextForSteps(t *testing.T) {
210+ s := newTestState(t)
211+ s.SetEnv("JOB", "j")
212+ step := s.JobEnv()
213+ step.Set("STEP", "s")
214+ env := s.Values(step)["env"].(*expr.Dictionary)
215+ if str(env, "JOB") != "j" || str(env, "STEP") != "s" {
216+ t.Fatal(expr.ToJSON(env))
217+ }
218+ // The job environment is untouched by the step's copy.
219+ if _, ok := s.JobEnv().Get("STEP"); ok {
220+ t.Fatal("step env leaked into the job env")
221+ }
222+}
223+
224+func TestEventJSON(t *testing.T) {
225+ s := newTestState(t)
226+ if got := s.EventJSON(); !strings.Contains(got, `"ref": "refs/heads/main"`) {
227+ t.Fatal(got)
228+ }
229+ s2, _, _ := NewState(`{"github":{"repository":"o/r"}}`, "", "j", RunnerInfo{}, "")
230+ if s2.EventJSON() != "" {
231+ t.Fatal("no event, no event.json")
232+ }
233+}
234+
235+func TestWithLayout(t *testing.T) {
236+ s := newTestState(t)
237+ l := NewLayout("/tmp/h", "hello", "/tmp/tc")
238+ s.WithLayout(l)
239+ env := s.RuntimeEnv()
240+ for k, v := range map[string]string{"GITHUB_WORKSPACE": "/tmp/h/work/hello/hello", "RUNNER_TEMP": "/tmp/h/work/_temp", "RUNNER_TOOL_CACHE": "/tmp/tc"} {
241+ if got, _ := env.Get(k); got != v {
242+ t.Errorf("%s = %q", k, got)
243+ }
244+ }
245+}
@@ -0,0 +1,245 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "reflect"
7+ "strings"
8+ "testing"
9+
10+ "bnema.dev/ermite-actions/expr"
11+)
12+
13+const testContexts = `{
14+ "github": {"repository":"octo/hello","repository_owner":"octo","run_id":"42","run_number":"7","run_attempt":"1",
15+ "ref":"refs/heads/main","ref_name":"main","ref_type":"branch","sha":"abc","event_name":"push","job":"build",
16+ "ref_protected":false,"workflow":"CI","actor":"octo","server_url":"https://ermite.example",
17+ "event":{"ref":"refs/heads/main","n":1},"retention_days":"90","unknown_key":"x"},
18+ "needs": {"a":{"result":"success","outputs":{"o":"1"}}},
19+ "strategy": {"fail-fast":true,"job-index":0,"job-total":1,"max-parallel":1},
20+ "matrix": {"os":"linux"},
21+ "inputs": {},
22+ "vars": {"V":"1"}
23+}`
24+
25+func newTestState(t *testing.T) *State {
26+ t.Helper()
27+ s, _, err := NewState(testContexts, `{"os":"linux"}`, "build", RunnerInfo{Name: "r1"}, "")
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ return s
32+}
33+
34+func TestNewStateLayoutAndGitHub(t *testing.T) {
35+ s := newTestState(t)
36+ l := s.Layout()
37+ if l.Workspace != "/home/runner/work/hello/hello" || l.RunnerWorkspace != "/home/runner/work/hello" ||
38+ l.Temp != "/home/runner/work/_temp" || l.ToolCache != "/opt/hostedtoolcache" || l.Home != "/home/runner" {
39+ t.Fatalf("%+v", l)
40+ }
41+ if s.GitHub("workspace") != l.Workspace || s.GitHub("job") != "build" {
42+ t.Fatalf("workspace %q job %q", s.GitHub("workspace"), s.GitHub("job"))
43+ }
44+ // token comes first (ExecutionContext.cs:1023): an empty token, no GITHUB_TOKEN yet.
45+ gh := s.Values(nil)["github"].(*expr.Dictionary)
46+ if gh.Keys()[0] != "token" {
47+ t.Fatalf("keys %v", gh.Keys())
48+ }
49+}
50+
51+func TestRuntimeEnv(t *testing.T) {
52+ s := newTestState(t)
53+ env := s.RuntimeEnv()
54+ want := map[string]string{
55+ "GITHUB_REPOSITORY": "octo/hello", "GITHUB_REPOSITORY_OWNER": "octo", "GITHUB_RUN_ID": "42",
56+ "GITHUB_RUN_NUMBER": "7", "GITHUB_RUN_ATTEMPT": "1", "GITHUB_REF": "refs/heads/main",
57+ "GITHUB_REF_NAME": "main", "GITHUB_REF_TYPE": "branch", "GITHUB_SHA": "abc", "GITHUB_EVENT_NAME": "push",
58+ "GITHUB_JOB": "build", "GITHUB_REF_PROTECTED": "false", "GITHUB_WORKFLOW": "CI", "GITHUB_ACTOR": "octo",
59+ "GITHUB_SERVER_URL": "https://ermite.example", "GITHUB_RETENTION_DAYS": "90",
60+ "GITHUB_WORKSPACE": "/home/runner/work/hello/hello",
61+ "RUNNER_OS": "Linux", "RUNNER_NAME": "r1", "RUNNER_ENVIRONMENT": "self-hosted",
62+ "RUNNER_TEMP": "/home/runner/work/_temp", "RUNNER_TOOL_CACHE": "/opt/hostedtoolcache",
63+ "RUNNER_WORKSPACE": "/home/runner/work/hello",
64+ }
65+ for k, v := range want {
66+ if got, ok := env.Get(k); !ok || got != v {
67+ t.Errorf("%s = %q (%v), want %q", k, got, ok, v)
68+ }
69+ }
70+ // Only allow-listed string/boolean keys; objects are not exported.
71+ for _, k := range []string{"GITHUB_EVENT", "GITHUB_UNKNOWN_KEY", "GITHUB_TOKEN"} {
72+ if _, ok := env.Get(k); ok {
73+ t.Errorf("%s must not be set", k)
74+ }
75+ }
76+ if a, _ := env.Get("RUNNER_ARCH"); a != "X64" && a != "ARM64" {
77+ t.Errorf("RUNNER_ARCH = %q", a)
78+ }
79+}
80+
81+func TestRunnerContextOrderAndValues(t *testing.T) {
82+ s := newTestState(t)
83+ r := s.Values(nil)["runner"].(*expr.Dictionary)
84+ if got := strings.Join(r.Keys(), ","); got != "os,arch,name,environment,tool_cache,temp,workspace" {
85+ t.Fatal(got)
86+ }
87+ s2, _, _ := NewState(testContexts, "", "build", RunnerInfo{Name: "h", Environment: "github-hosted", Arch: "ARM64"}, "")
88+ r2 := s2.Values(nil)["runner"].(*expr.Dictionary)
89+ if str(r2, "environment") != "github-hosted" || str(r2, "arch") != "ARM64" {
90+ t.Fatal(expr.ToJSON(r2))
91+ }
92+}
93+
94+func TestServerContextsAndDefaults(t *testing.T) {
95+ s := newTestState(t)
96+ v := s.Values(nil)
97+ if expr.ToJSON(v["matrix"]) != "{\n \"os\": \"linux\"\n}" {
98+ t.Fatal(expr.ToJSON(v["matrix"]))
99+ }
100+ for _, k := range []string{"needs", "strategy", "inputs", "vars", "steps", "env", "job", "secrets"} {
101+ if v[k] == nil {
102+ t.Errorf("context %s missing", k)
103+ }
104+ }
105+ // Absent contexts: needs/inputs/vars are empty objects, matrix is not set
106+ // (null in expressions), as the recordings show.
107+ s2, _, err := NewState(`{"github":{"repository":"o/r"}}`, "{}", "j", RunnerInfo{}, "")
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ v2 := s2.Values(nil)
112+ if _, ok := v2["matrix"]; ok {
113+ t.Error("matrix must be absent without a matrix")
114+ }
115+ if d, ok := v2["needs"].(*expr.Dictionary); !ok || d.Len() != 0 {
116+ t.Errorf("needs %v", v2["needs"])
117+ }
118+}
119+
120+func TestNewStateErrors(t *testing.T) {
121+ for _, tc := range []struct{ ctx, matrix, repo, want string }{
122+ {`[]`, "", "", "want a JSON object"},
123+ {`{`, "", "", "job contexts"},
124+ {`{"github":1}`, "", "", "github must be an object"},
125+ {`{"github":{}}`, "", "", "github.repository is missing"},
126+ {`{"github":{"repository":"o/r"}}`, "{", "", "job matrix"},
127+ } {
128+ _, _, err := NewState(tc.ctx, tc.matrix, "j", RunnerInfo{}, tc.repo)
129+ if err == nil || !strings.Contains(err.Error(), tc.want) {
130+ t.Errorf("%q: %v, want %q", tc.ctx, err, tc.want)
131+ }
132+ }
133+ // The repository name can come from the job when the context lacks it.
134+ if _, _, err := NewState(`{"github":{}}`, "", "j", RunnerInfo{}, "hello"); err != nil {
135+ t.Fatal(err)
136+ }
137+}
138+
139+func TestJobStatus(t *testing.T) {
140+ s := newTestState(t)
141+ job := func() *expr.Dictionary { return s.Values(nil)["job"].(*expr.Dictionary) }
142+ if str(job(), "status") != "success" || job().Keys()[0] != "status" {
143+ t.Fatal(expr.ToJSON(job()))
144+ }
145+ s.SetStatus("failure")
146+ if str(job(), "status") != "failure" || s.Status() != "failure" {
147+ t.Fatal(expr.ToJSON(job()))
148+ }
149+}
150+
151+func TestEnvOrderAndOverride(t *testing.T) {
152+ o := newOrdered()
153+ o.Set("A", "1")
154+ o.Set("B", "2")
155+ o.Set("A", "3") // keeps its position
156+ if !reflect.DeepEqual(o.Keys(), []string{"A", "B"}) || o.vals["A"] != "3" {
157+ t.Fatalf("%v", o)
158+ }
159+ c := o.Clone()
160+ c.Set("C", "4")
161+ if o.Len() != 2 || c.Len() != 3 {
162+ t.Fatal("Clone shares state")
163+ }
164+ // Case-sensitive keys, like Linux.
165+ o.Set("a", "x")
166+ if o.Len() != 3 {
167+ t.Fatal("keys must be case-sensitive")
168+ }
169+}
170+
171+func TestStepsContext(t *testing.T) {
172+ s := newTestState(t)
173+ s.SetOutput("one", "out", "v")
174+ s.SetOutput("__run", "ignored", "x") // generated ids are not in the context
175+ s.SetStepResult("one", "failure", "success")
176+ s.SetStepResult("__run_2", "success", "success")
177+ s.SetStepResult("skipped", "skipped", "skipped")
178+ steps := s.Values(nil)["steps"].(*expr.Dictionary)
179+ if got := strings.Join(steps.Keys(), ","); got != "one,skipped" {
180+ t.Fatalf("keys %s", got)
181+ }
182+ one, _ := steps.Get("one")
183+ od := one.(*expr.Dictionary)
184+ if str(od, "outcome") != "failure" || str(od, "conclusion") != "success" {
185+ t.Fatal(expr.ToJSON(one))
186+ }
187+ outs, _ := od.Get("outputs")
188+ if str(outs.(*expr.Dictionary), "out") != "v" || s.Output("one", "out") != "v" || s.Output("none", "x") != "" {
189+ t.Fatal(expr.ToJSON(outs))
190+ }
191+ // A step that has output but has not completed has no outcome yet.
192+ s.SetOutput("running", "k", "v")
193+ r, _ := s.Values(nil)["steps"].(*expr.Dictionary).Get("running")
194+ if _, ok := r.(*expr.Dictionary).Get("outcome"); ok {
195+ t.Fatal("outcome before completion")
196+ }
197+}
198+
199+func TestAddPathMovesRepeatedEntryToTheEnd(t *testing.T) {
200+ s := newTestState(t)
201+ for _, p := range []string{"/a", "/b", "/a"} {
202+ s.AddPath(p)
203+ }
204+ if !reflect.DeepEqual(s.Prepend(), []string{"/b", "/a"}) {
205+ t.Fatal(s.Prepend())
206+ }
207+}
208+
209+func TestEnvContextForSteps(t *testing.T) {
210+ s := newTestState(t)
211+ s.SetEnv("JOB", "j")
212+ step := s.JobEnv()
213+ step.Set("STEP", "s")
214+ env := s.Values(step)["env"].(*expr.Dictionary)
215+ if str(env, "JOB") != "j" || str(env, "STEP") != "s" {
216+ t.Fatal(expr.ToJSON(env))
217+ }
218+ // The job environment is untouched by the step's copy.
219+ if _, ok := s.JobEnv().Get("STEP"); ok {
220+ t.Fatal("step env leaked into the job env")
221+ }
222+}
223+
224+func TestEventJSON(t *testing.T) {
225+ s := newTestState(t)
226+ if got := s.EventJSON(); !strings.Contains(got, `"ref": "refs/heads/main"`) {
227+ t.Fatal(got)
228+ }
229+ s2, _, _ := NewState(`{"github":{"repository":"o/r"}}`, "", "j", RunnerInfo{}, "")
230+ if s2.EventJSON() != "" {
231+ t.Fatal("no event, no event.json")
232+ }
233+}
234+
235+func TestWithLayout(t *testing.T) {
236+ s := newTestState(t)
237+ l := NewLayout("/tmp/h", "hello", "/tmp/tc")
238+ s.WithLayout(l)
239+ env := s.RuntimeEnv()
240+ for k, v := range map[string]string{"GITHUB_WORKSPACE": "/tmp/h/work/hello/hello", "RUNNER_TEMP": "/tmp/h/work/_temp", "RUNNER_TOOL_CACHE": "/tmp/tc"} {
241+ if got, _ := env.Get(k); got != v {
242+ t.Errorf("%s = %q", k, got)
243+ }
244+ }
245+}

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