detached 0cc0cf3

Merge pull request 'feat(runner): match github-hosted runner output and contexts' (#5) from feat/runner-parity into main

commit
0cc0cf352e136e4a0f3d6133ca5d02a5e8a551c1
author
bnema · · 2026-10-12 23:14 UTC
merge of
81b458d + 117121a · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
19 files · +590 -44
M README.md +1 -0
@@ -94,6 +94,7 @@ the host process; the sandbox receives the workflow and its contexts only.
9494 | `ERMITE_RUNNER_URL` | base URL of ermite, as in a browser (the runner adds `/-/api` itself) |
9595 | `ERMITE_RUNNER_NAME` | runner name (default: the host name) |
9696 | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |
97+| `ERMITE_RUNNER_ENVIRONMENT` | `github-hosted` (default) or `self-hosted`: the `runner.environment` value the jobs see. ermite's runners stand in for GitHub-hosted ones, so workflows written for github.com (`runner.environment == 'github-hosted'`) behave the same; `self-hosted` also makes "Set up job" print the runner and machine name, as GitHub does for self-hosted runners |
9798 | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` (see above) |
9899 | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only) |
99100 | `ERMITE_RUNNER_LOG_LEVEL` | `debug`, `info` (default), `warn`, `error` |
@@ -94,6 +94,7 @@ the host process; the sandbox receives the workflow and its contexts only.
94 | `ERMITE_RUNNER_URL` | base URL of ermite, as in a browser (the runner adds `/-/api` itself) |94 | `ERMITE_RUNNER_URL` | base URL of ermite, as in a browser (the runner adds `/-/api` itself) |
95 | `ERMITE_RUNNER_NAME` | runner name (default: the host name) |95 | `ERMITE_RUNNER_NAME` | runner name (default: the host name) |
96 | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |96 | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |
97+| `ERMITE_RUNNER_ENVIRONMENT` | `github-hosted` (default) or `self-hosted`: the `runner.environment` value the jobs see. ermite's runners stand in for GitHub-hosted ones, so workflows written for github.com (`runner.environment == 'github-hosted'`) behave the same; `self-hosted` also makes "Set up job" print the runner and machine name, as GitHub does for self-hosted runners |
97 | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` (see above) |98 | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` (see above) |
98 | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only) |99 | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only) |
99 | `ERMITE_RUNNER_LOG_LEVEL` | `debug`, `info` (default), `warn`, `error` |100 | `ERMITE_RUNNER_LOG_LEVEL` | `debug`, `info` (default), `warn`, `error` |
M go.mod +1 -1
@@ -3,7 +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
6+ bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4
77 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
88 connectrpc.com/connect v1.21.0
99 github.com/bnema/zerowrap v1.4.1
@@ -3,7 +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-b6c4c3a2f7ff6+ bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4
7 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c7 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
8 connectrpc.com/connect v1.21.08 connectrpc.com/connect v1.21.0
9 github.com/bnema/zerowrap v1.4.19 github.com/bnema/zerowrap v1.4.1
M go.sum +2 -2 generated file: collapsed
M internal/e2e/chain_test.go +9 -6
@@ -78,9 +78,13 @@ var (
7878 // Machine-dependent parts of the recordings, like the normalisation rules of
7979 // ermite-conformance: random script names and the repository name.
8080 repoRe = regexp.MustCompile(`/work/hello/hello`)
81+ // The runner version moves with each release (rule runner-version of
82+ // ermite-conformance).
83+ versionRe = regexp.MustCompile(`(Current runner version: ')[^']*(')`)
8184 )
8285
8386 func normalize(l string) string {
87+ l = versionRe.ReplaceAllString(l, "${1}<runner-version>${2}")
8488 l = repoRe.ReplaceAllString(l, "/work/<repo>/<repo>")
8589 return uuidRe.ReplaceAllString(l, "<uuid>")
8690 }
@@ -215,8 +219,10 @@ func contains(l []string, s string) bool {
215219 }
216220
217221 // compareJob checks the steps, conclusions, outputs and log lines reported for
218-// a job against the recording. "Set up job" (runner and image details) and the
219-// ermite-dump step that the harness appends to every job are not compared.
222+// a job against the recording. The ermite-dump step that the harness appends to
223+// every job is not compared, and neither are the three groups of "Set up job"
224+// that describe GitHub's fleet and image (ermite-conformance drops them from
225+// the recordings, rule infra-groups): the other lines of "Set up job" are.
220226 func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string) {
221227 if got.Complete == nil {
222228 t.Fatal("the job did not complete")
@@ -258,7 +264,7 @@ func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string)
258264 type es = recStep
259265 var wantSteps, gotSteps []es
260266 for _, s := range want.Steps {
261- if s.Name == "Set up job" || s.Name == "ermite-dump" {
267+ if s.Name == "ermite-dump" {
262268 continue
263269 }
264270 wantSteps = append(wantSteps, s)
@@ -269,9 +275,6 @@ func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string)
269275 }
270276 for _, n := range steps {
271277 u := got.Steps[n]
272- if u.Name == "Set up job" {
273- continue
274- }
275278 if u.State != ermite.StepState_STEP_STATE_COMPLETED || u.StartedAt == nil || u.CompletedAt == nil {
276279 t.Errorf("step %d %q is not a complete snapshot: %v", n, u.Name, u)
277280 }
@@ -78,9 +78,13 @@ var (
78 // Machine-dependent parts of the recordings, like the normalisation rules of78 // Machine-dependent parts of the recordings, like the normalisation rules of
79 // ermite-conformance: random script names and the repository name.79 // ermite-conformance: random script names and the repository name.
80 repoRe = regexp.MustCompile(`/work/hello/hello`)80 repoRe = regexp.MustCompile(`/work/hello/hello`)
81+ // The runner version moves with each release (rule runner-version of
82+ // ermite-conformance).
83+ versionRe = regexp.MustCompile(`(Current runner version: ')[^']*(')`)
81 )84 )
82 85
83 func normalize(l string) string {86 func normalize(l string) string {
87+ l = versionRe.ReplaceAllString(l, "${1}<runner-version>${2}")
84 l = repoRe.ReplaceAllString(l, "/work/<repo>/<repo>")88 l = repoRe.ReplaceAllString(l, "/work/<repo>/<repo>")
85 return uuidRe.ReplaceAllString(l, "<uuid>")89 return uuidRe.ReplaceAllString(l, "<uuid>")
86 }90 }
@@ -215,8 +219,10 @@ func contains(l []string, s string) bool {
215 }219 }
216 220
217 // compareJob checks the steps, conclusions, outputs and log lines reported for221 // compareJob checks the steps, conclusions, outputs and log lines reported for
218-// a job against the recording. "Set up job" (runner and image details) and the222+// a job against the recording. The ermite-dump step that the harness appends to
219-// ermite-dump step that the harness appends to every job are not compared.223+// every job is not compared, and neither are the three groups of "Set up job"
224+// that describe GitHub's fleet and image (ermite-conformance drops them from
225+// the recordings, rule infra-groups): the other lines of "Set up job" are.
220 func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string) {226 func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string) {
221 if got.Complete == nil {227 if got.Complete == nil {
222 t.Fatal("the job did not complete")228 t.Fatal("the job did not complete")
@@ -258,7 +264,7 @@ func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string)
258 type es = recStep264 type es = recStep
259 var wantSteps, gotSteps []es265 var wantSteps, gotSteps []es
260 for _, s := range want.Steps {266 for _, s := range want.Steps {
261- if s.Name == "Set up job" || s.Name == "ermite-dump" {267+ if s.Name == "ermite-dump" {
262 continue268 continue
263 }269 }
264 wantSteps = append(wantSteps, s)270 wantSteps = append(wantSteps, s)
@@ -269,9 +275,6 @@ func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string)
269 }275 }
270 for _, n := range steps {276 for _, n := range steps {
271 u := got.Steps[n]277 u := got.Steps[n]
272- if u.Name == "Set up job" {
273- continue
274- }
275 if u.State != ermite.StepState_STEP_STATE_COMPLETED || u.StartedAt == nil || u.CompletedAt == nil {278 if u.State != ermite.StepState_STEP_STATE_COMPLETED || u.StartedAt == nil || u.CompletedAt == nil {
276 t.Errorf("step %d %q is not a complete snapshot: %v", n, u.Name, u)279 t.Errorf("step %d %q is not a complete snapshot: %v", n, u.Name, u)
277 }280 }
M internal/execstream/exec.proto +1 -1
@@ -57,7 +57,7 @@ message JobSpec {
5757 int32 timeout_minutes = 7;
5858 string runner_name = 8;
5959 string runner_version = 9;
60- // runner_environment is the runner.environment value ("self-hosted").
60+ // runner_environment is the runner.environment value ("github-hosted" or "self-hosted").
6161 string runner_environment = 10;
6262 bool debug = 11; // ACTIONS_STEP_DEBUG
6363 // deadline_ms is what is left of the job timeout when the host sends this
@@ -57,7 +57,7 @@ message JobSpec {
57 int32 timeout_minutes = 7;57 int32 timeout_minutes = 7;
58 string runner_name = 8;58 string runner_name = 8;
59 string runner_version = 9;59 string runner_version = 9;
60- // runner_environment is the runner.environment value ("self-hosted").60+ // runner_environment is the runner.environment value ("github-hosted" or "self-hosted").
61 string runner_environment = 10;61 string runner_environment = 10;
62 bool debug = 11; // ACTIONS_STEP_DEBUG62 bool debug = 11; // ACTIONS_STEP_DEBUG
63 // deadline_ms is what is left of the job timeout when the host sends this63 // deadline_ms is what is left of the job timeout when the host sends this
M internal/execstream/execpb/exec.pb.go +1 -1 generated file: collapsed
M internal/executor/contexts.go +70 -6
@@ -77,6 +77,35 @@ func setKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
7777 return out
7878 }
7979
80+// setKeySorted is setKey for the github context: a key that is not there yet
81+// goes before the first key that sorts after it (bytewise; "token" stays first)
82+// instead of at the end, so that the keys the runner adds (workspace,
83+// secret_source, the file commands...) sit where the ermite-conformance
84+// recordings list them. Note that the recordings are normalised with sorted
85+// keys, so they show the sorted order, not necessarily the insertion order of
86+// GitHub's runner (SetGitHubContext appends). Keys that exist are replaced in
87+// place.
88+func setKeySorted(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
89+ for _, p := range d.Pairs() {
90+ if strings.EqualFold(p.Key, key) {
91+ return setKey(d, key, v)
92+ }
93+ }
94+ out := expr.NewDictionary()
95+ done := false
96+ for _, p := range d.Pairs() {
97+ if !done && p.Key > key && !strings.EqualFold(p.Key, "token") {
98+ out.Add(key, v)
99+ done = true
100+ }
101+ out.Add(p.Key, p.Value)
102+ }
103+ if !done {
104+ out.Add(key, v)
105+ }
106+ return out
107+}
108+
80109 func str(d *expr.Dictionary, key string) string {
81110 if v, ok := d.Get(key); ok {
82111 if s, ok := v.(expr.String); ok {
@@ -127,6 +156,11 @@ type RunnerInfo struct {
127156 Arch string
128157 }
129158
159+// githubHosted reports whether the runner.environment is "github-hosted". An
160+// empty Environment is "self-hosted", the default of the official runner's
161+// runner context.
162+func (r RunnerInfo) githubHosted() bool { return r.Environment == "github-hosted" }
163+
130164 func (r RunnerInfo) arch() string {
131165 if r.Arch != "" {
132166 return r.Arch
@@ -163,8 +197,13 @@ type stepCtx struct {
163197 type State struct {
164198 layout Layout
165199 github *expr.Dictionary
166- runner *expr.Dictionary
167- job *expr.Dictionary
200+ // serverGitHub is the github context as the server sent it, before the
201+ // runner added its keys (token, workspace, the file commands, secret_source,
202+ // action...): the contexts that the server itself evaluates the job name
203+ // with (see ServerContexts).
204+ serverGitHub *expr.Dictionary
205+ runner *expr.Dictionary
206+ job *expr.Dictionary
168207 // other holds needs, strategy, matrix, inputs, vars: copied from the server.
169208 other map[string]expr.Value
170209 // env is Global.EnvironmentVariables: the job-level environment and what
@@ -216,8 +255,12 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac
216255 gh.Add(p.Key, p.Value)
217256 }
218257 }
258+ s.serverGitHub = expr.NewDictionary()
259+ if v, ok := root.Get("github"); ok {
260+ s.serverGitHub = v.(*expr.Dictionary)
261+ }
219262 if str(gh, "job") == "" && jobKey != "" {
220- gh = setKey(gh, "job", expr.String(jobKey))
263+ gh = setKeySorted(gh, "job", expr.String(jobKey))
221264 }
222265 repo := repoName(str(gh, "repository"))
223266 if repo == "" {
@@ -228,7 +271,7 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac
228271 }
229272 home := "/home/runner"
230273 s.layout = NewLayout(home, repo, "")
231- s.github = setKey(gh, "workspace", expr.String(s.layout.Workspace))
274+ s.github = setKeySorted(gh, "workspace", expr.String(s.layout.Workspace))
232275 return s, s.layout, s.initRest(root, matrixJSON, r)
233276 }
234277
@@ -236,7 +279,7 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac
236279 // must be called before the first step.
237280 func (s *State) WithLayout(l Layout) {
238281 s.layout = l
239- s.github = setKey(s.github, "workspace", expr.String(l.Workspace))
282+ s.github = setKeySorted(s.github, "workspace", expr.String(l.Workspace))
240283 s.runner = setKey(s.runner, "temp", expr.String(l.Temp))
241284 s.runner = setKey(s.runner, "tool_cache", expr.String(l.ToolCache))
242285 s.runner = setKey(s.runner, "workspace", expr.String(l.RunnerWorkspace))
@@ -257,6 +300,14 @@ func (s *State) initRest(root *expr.Dictionary, matrixJSON string, r RunnerInfo)
257300 return fmt.Errorf("job matrix: %w", err)
258301 }
259302 }
303+ // A job without a matrix has the matrix context null on github.com, not an
304+ // empty object (recordings: "matrix": null in the 176 of 221 jobs that have
305+ // none, and toJSON(matrix) prints null). The server sends "{}" for such a
306+ // job (runner.proto: Job.matrix), so an empty matrix becomes null here.
307+ // matrix.x still works: a property of null is null, as for a missing key.
308+ if d, ok := s.other["matrix"].(*expr.Dictionary); !ok || d.Len() == 0 {
309+ s.other["matrix"] = expr.Null{}
310+ }
260311 // A context the job does not have is an empty object, like the server's
261312 // ("needs": {}, "inputs": {}, "vars": {}); matrix and strategy are null
262313 // when absent (recordings: "matrix": null).
@@ -302,7 +353,7 @@ func (s *State) initRest(root *expr.Dictionary, matrixJSON string, r RunnerInfo)
302353 func (s *State) Layout() Layout { return s.layout }
303354
304355 // 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)) }
356+func (s *State) SetGitHub(k, v string) { s.github = setKeySorted(s.github, k, expr.String(v)) }
306357
307358 // GitHub returns a string key of the github context ("" when absent).
308359 func (s *State) GitHub(k string) string { return str(s.github, k) }
@@ -414,6 +465,19 @@ func (s *State) Values(env *ordered) map[string]expr.Value {
414465 return m
415466 }
416467
468+// ServerContexts returns the contexts that the server has when it evaluates
469+// the name of a job: github as received (without the keys the runner adds),
470+// needs, strategy, matrix, vars and inputs.
471+func (s *State) ServerContexts() map[string]expr.Value {
472+ m := map[string]expr.Value{"github": s.serverGitHub}
473+ for _, k := range []string{"needs", "strategy", "matrix", "vars", "inputs"} {
474+ if v, ok := s.other[k]; ok {
475+ m[k] = v
476+ }
477+ }
478+ return m
479+}
480+
417481 // githubEnvAllowlist is GitHubContext._contextEnvAllowlist (GitHubContext.cs:9).
418482 var githubEnvAllowlist = map[string]bool{
419483 "action_path": true, "action_ref": true, "action_repository": true, "action": true, "actor": true,
@@ -77,6 +77,35 @@ func setKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
77 return out77 return out
78 }78 }
79 79
80+// setKeySorted is setKey for the github context: a key that is not there yet
81+// goes before the first key that sorts after it (bytewise; "token" stays first)
82+// instead of at the end, so that the keys the runner adds (workspace,
83+// secret_source, the file commands...) sit where the ermite-conformance
84+// recordings list them. Note that the recordings are normalised with sorted
85+// keys, so they show the sorted order, not necessarily the insertion order of
86+// GitHub's runner (SetGitHubContext appends). Keys that exist are replaced in
87+// place.
88+func setKeySorted(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
89+ for _, p := range d.Pairs() {
90+ if strings.EqualFold(p.Key, key) {
91+ return setKey(d, key, v)
92+ }
93+ }
94+ out := expr.NewDictionary()
95+ done := false
96+ for _, p := range d.Pairs() {
97+ if !done && p.Key > key && !strings.EqualFold(p.Key, "token") {
98+ out.Add(key, v)
99+ done = true
100+ }
101+ out.Add(p.Key, p.Value)
102+ }
103+ if !done {
104+ out.Add(key, v)
105+ }
106+ return out
107+}
108+
80 func str(d *expr.Dictionary, key string) string {109 func str(d *expr.Dictionary, key string) string {
81 if v, ok := d.Get(key); ok {110 if v, ok := d.Get(key); ok {
82 if s, ok := v.(expr.String); ok {111 if s, ok := v.(expr.String); ok {
@@ -127,6 +156,11 @@ type RunnerInfo struct {
127 Arch string156 Arch string
128 }157 }
129 158
159+// githubHosted reports whether the runner.environment is "github-hosted". An
160+// empty Environment is "self-hosted", the default of the official runner's
161+// runner context.
162+func (r RunnerInfo) githubHosted() bool { return r.Environment == "github-hosted" }
163+
130 func (r RunnerInfo) arch() string {164 func (r RunnerInfo) arch() string {
131 if r.Arch != "" {165 if r.Arch != "" {
132 return r.Arch166 return r.Arch
@@ -163,8 +197,13 @@ type stepCtx struct {
163 type State struct {197 type State struct {
164 layout Layout198 layout Layout
165 github *expr.Dictionary199 github *expr.Dictionary
166- runner *expr.Dictionary200+ // serverGitHub is the github context as the server sent it, before the
167- job *expr.Dictionary201+ // runner added its keys (token, workspace, the file commands, secret_source,
202+ // action...): the contexts that the server itself evaluates the job name
203+ // with (see ServerContexts).
204+ serverGitHub *expr.Dictionary
205+ runner *expr.Dictionary
206+ job *expr.Dictionary
168 // other holds needs, strategy, matrix, inputs, vars: copied from the server.207 // other holds needs, strategy, matrix, inputs, vars: copied from the server.
169 other map[string]expr.Value208 other map[string]expr.Value
170 // env is Global.EnvironmentVariables: the job-level environment and what209 // env is Global.EnvironmentVariables: the job-level environment and what
@@ -216,8 +255,12 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac
216 gh.Add(p.Key, p.Value)255 gh.Add(p.Key, p.Value)
217 }256 }
218 }257 }
258+ s.serverGitHub = expr.NewDictionary()
259+ if v, ok := root.Get("github"); ok {
260+ s.serverGitHub = v.(*expr.Dictionary)
261+ }
219 if str(gh, "job") == "" && jobKey != "" {262 if str(gh, "job") == "" && jobKey != "" {
220- gh = setKey(gh, "job", expr.String(jobKey))263+ gh = setKeySorted(gh, "job", expr.String(jobKey))
221 }264 }
222 repo := repoName(str(gh, "repository"))265 repo := repoName(str(gh, "repository"))
223 if repo == "" {266 if repo == "" {
@@ -228,7 +271,7 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac
228 }271 }
229 home := "/home/runner"272 home := "/home/runner"
230 s.layout = NewLayout(home, repo, "")273 s.layout = NewLayout(home, repo, "")
231- s.github = setKey(gh, "workspace", expr.String(s.layout.Workspace))274+ s.github = setKeySorted(gh, "workspace", expr.String(s.layout.Workspace))
232 return s, s.layout, s.initRest(root, matrixJSON, r)275 return s, s.layout, s.initRest(root, matrixJSON, r)
233 }276 }
234 277
@@ -236,7 +279,7 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac
236 // must be called before the first step.279 // must be called before the first step.
237 func (s *State) WithLayout(l Layout) {280 func (s *State) WithLayout(l Layout) {
238 s.layout = l281 s.layout = l
239- s.github = setKey(s.github, "workspace", expr.String(l.Workspace))282+ s.github = setKeySorted(s.github, "workspace", expr.String(l.Workspace))
240 s.runner = setKey(s.runner, "temp", expr.String(l.Temp))283 s.runner = setKey(s.runner, "temp", expr.String(l.Temp))
241 s.runner = setKey(s.runner, "tool_cache", expr.String(l.ToolCache))284 s.runner = setKey(s.runner, "tool_cache", expr.String(l.ToolCache))
242 s.runner = setKey(s.runner, "workspace", expr.String(l.RunnerWorkspace))285 s.runner = setKey(s.runner, "workspace", expr.String(l.RunnerWorkspace))
@@ -257,6 +300,14 @@ func (s *State) initRest(root *expr.Dictionary, matrixJSON string, r RunnerInfo)
257 return fmt.Errorf("job matrix: %w", err)300 return fmt.Errorf("job matrix: %w", err)
258 }301 }
259 }302 }
303+ // A job without a matrix has the matrix context null on github.com, not an
304+ // empty object (recordings: "matrix": null in the 176 of 221 jobs that have
305+ // none, and toJSON(matrix) prints null). The server sends "{}" for such a
306+ // job (runner.proto: Job.matrix), so an empty matrix becomes null here.
307+ // matrix.x still works: a property of null is null, as for a missing key.
308+ if d, ok := s.other["matrix"].(*expr.Dictionary); !ok || d.Len() == 0 {
309+ s.other["matrix"] = expr.Null{}
310+ }
260 // A context the job does not have is an empty object, like the server's311 // A context the job does not have is an empty object, like the server's
261 // ("needs": {}, "inputs": {}, "vars": {}); matrix and strategy are null312 // ("needs": {}, "inputs": {}, "vars": {}); matrix and strategy are null
262 // when absent (recordings: "matrix": null).313 // when absent (recordings: "matrix": null).
@@ -302,7 +353,7 @@ func (s *State) initRest(root *expr.Dictionary, matrixJSON string, r RunnerInfo)
302 func (s *State) Layout() Layout { return s.layout }353 func (s *State) Layout() Layout { return s.layout }
303 354
304 // SetGitHub sets a key of the github context (SetGitHubContext).355 // 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)) }356+func (s *State) SetGitHub(k, v string) { s.github = setKeySorted(s.github, k, expr.String(v)) }
306 357
307 // GitHub returns a string key of the github context ("" when absent).358 // GitHub returns a string key of the github context ("" when absent).
308 func (s *State) GitHub(k string) string { return str(s.github, k) }359 func (s *State) GitHub(k string) string { return str(s.github, k) }
@@ -414,6 +465,19 @@ func (s *State) Values(env *ordered) map[string]expr.Value {
414 return m465 return m
415 }466 }
416 467
468+// ServerContexts returns the contexts that the server has when it evaluates
469+// the name of a job: github as received (without the keys the runner adds),
470+// needs, strategy, matrix, vars and inputs.
471+func (s *State) ServerContexts() map[string]expr.Value {
472+ m := map[string]expr.Value{"github": s.serverGitHub}
473+ for _, k := range []string{"needs", "strategy", "matrix", "vars", "inputs"} {
474+ if v, ok := s.other[k]; ok {
475+ m[k] = v
476+ }
477+ }
478+ return m
479+}
480+
417 // githubEnvAllowlist is GitHubContext._contextEnvAllowlist (GitHubContext.cs:9).481 // githubEnvAllowlist is GitHubContext._contextEnvAllowlist (GitHubContext.cs:9).
418 var githubEnvAllowlist = map[string]bool{482 var githubEnvAllowlist = map[string]bool{
419 "action_path": true, "action_ref": true, "action_repository": true, "action": true, "actor": true,483 "action_path": true, "action_ref": true, "action_repository": true, "action": true, "actor": true,
M internal/executor/contexts_test.go +30 -4
@@ -102,15 +102,23 @@ func TestServerContextsAndDefaults(t *testing.T) {
102102 t.Errorf("context %s missing", k)
103103 }
104104 }
105- // Absent contexts: needs/inputs/vars are empty objects, matrix is not set
106- // (null in expressions), as the recordings show.
105+ // Absent contexts: needs/inputs/vars are empty objects, matrix is null
106+ // (also when the server sends {}), as the recordings show.
107107 s2, _, err := NewState(`{"github":{"repository":"o/r"}}`, "{}", "j", RunnerInfo{}, "")
108108 if err != nil {
109109 t.Fatal(err)
110110 }
111111 v2 := s2.Values(nil)
112- if _, ok := v2["matrix"]; ok {
113- t.Error("matrix must be absent without a matrix")
112+ if _, ok := v2["matrix"].(expr.Null); !ok {
113+ t.Errorf("matrix without a matrix: %v, want null", v2["matrix"])
114+ }
115+ // The server's "{}" in the contexts is null too.
116+ s3, _, err := NewState(`{"github":{"repository":"o/r"},"matrix":{}}`, "{}", "j", RunnerInfo{}, "")
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ if _, ok := s3.Values(nil)["matrix"].(expr.Null); !ok {
121+ t.Errorf("empty matrix: %v, want null", s3.Values(nil)["matrix"])
114122 }
115123 if d, ok := v2["needs"].(*expr.Dictionary); !ok || d.Len() != 0 {
116124 t.Errorf("needs %v", v2["needs"])
@@ -243,3 +251,21 @@ func TestWithLayout(t *testing.T) {
243251 }
244252 }
245253 }
254+
255+func TestGitHubKeysAddedByTheRunnerKeepTheSortedOrder(t *testing.T) {
256+ // A server that lists its keys sorted, as the recordings do: the keys the
257+ // runner adds land at their sorted place, replaced keys stay, token is first.
258+ s, _, err := NewState(`{"github":{"actor":"a","event_name":"push","job":"j","repository":"o/r","sha":"s","zzz":"1"}}`, "", "j", RunnerInfo{}, "")
259+ if err != nil {
260+ t.Fatal(err)
261+ }
262+ for _, k := range []string{"secret_source", "artifacts", "action", "event_path"} {
263+ s.SetGitHub(k, "x")
264+ }
265+ s.SetGitHub("sha", "t")
266+ got := strings.Join(s.Values(nil)["github"].(*expr.Dictionary).Keys(), ",")
267+ want := "token,action,actor,artifacts,event_name,event_path,job,repository,secret_source,sha,workspace,zzz"
268+ if got != want {
269+ t.Errorf("keys\n got %s\nwant %s", got, want)
270+ }
271+}
@@ -102,15 +102,23 @@ func TestServerContextsAndDefaults(t *testing.T) {
102 t.Errorf("context %s missing", k)102 t.Errorf("context %s missing", k)
103 }103 }
104 }104 }
105- // Absent contexts: needs/inputs/vars are empty objects, matrix is not set105+ // Absent contexts: needs/inputs/vars are empty objects, matrix is null
106- // (null in expressions), as the recordings show.106+ // (also when the server sends {}), as the recordings show.
107 s2, _, err := NewState(`{"github":{"repository":"o/r"}}`, "{}", "j", RunnerInfo{}, "")107 s2, _, err := NewState(`{"github":{"repository":"o/r"}}`, "{}", "j", RunnerInfo{}, "")
108 if err != nil {108 if err != nil {
109 t.Fatal(err)109 t.Fatal(err)
110 }110 }
111 v2 := s2.Values(nil)111 v2 := s2.Values(nil)
112- if _, ok := v2["matrix"]; ok {112+ if _, ok := v2["matrix"].(expr.Null); !ok {
113- t.Error("matrix must be absent without a matrix")113+ t.Errorf("matrix without a matrix: %v, want null", v2["matrix"])
114+ }
115+ // The server's "{}" in the contexts is null too.
116+ s3, _, err := NewState(`{"github":{"repository":"o/r"},"matrix":{}}`, "{}", "j", RunnerInfo{}, "")
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ if _, ok := s3.Values(nil)["matrix"].(expr.Null); !ok {
121+ t.Errorf("empty matrix: %v, want null", s3.Values(nil)["matrix"])
114 }122 }
115 if d, ok := v2["needs"].(*expr.Dictionary); !ok || d.Len() != 0 {123 if d, ok := v2["needs"].(*expr.Dictionary); !ok || d.Len() != 0 {
116 t.Errorf("needs %v", v2["needs"])124 t.Errorf("needs %v", v2["needs"])
@@ -243,3 +251,21 @@ func TestWithLayout(t *testing.T) {
243 }251 }
244 }252 }
245 }253 }
254+
255+func TestGitHubKeysAddedByTheRunnerKeepTheSortedOrder(t *testing.T) {
256+ // A server that lists its keys sorted, as the recordings do: the keys the
257+ // runner adds land at their sorted place, replaced keys stay, token is first.
258+ s, _, err := NewState(`{"github":{"actor":"a","event_name":"push","job":"j","repository":"o/r","sha":"s","zzz":"1"}}`, "", "j", RunnerInfo{}, "")
259+ if err != nil {
260+ t.Fatal(err)
261+ }
262+ for _, k := range []string{"secret_source", "artifacts", "action", "event_path"} {
263+ s.SetGitHub(k, "x")
264+ }
265+ s.SetGitHub("sha", "t")
266+ got := strings.Join(s.Values(nil)["github"].(*expr.Dictionary).Keys(), ",")
267+ want := "token,action,actor,artifacts,event_name,event_path,job,repository,secret_source,sha,workspace,zzz"
268+ if got != want {
269+ t.Errorf("keys\n got %s\nwant %s", got, want)
270+ }
271+}
M internal/executor/corpus_test.go +6 -7
@@ -181,6 +181,7 @@ var (
181181 shellPathRe = regexp.MustCompile(`^shell: \S*/(\w+) `)
182182 startPathRe = regexp.MustCompile(`process '\S*/(\w+)'`)
183183 uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
184+ versionRe = regexp.MustCompile(`(Current runner version: ')[^']*(')`)
184185 )
185186
186187 // normalizeLine maps the machine-dependent parts of a line to the form of the
@@ -190,6 +191,7 @@ var (
190191 func normalizeLine(l, home string) string {
191192 l = shellPathRe.ReplaceAllString(l, "shell: /usr/bin/$1 ")
192193 l = startPathRe.ReplaceAllString(l, "process '/usr/bin/$1'")
194+ l = versionRe.ReplaceAllString(l, "${1}<runner-version>${2}")
193195 l = strings.ReplaceAll(l, home, "/home/runner")
194196 l = strings.ReplaceAll(l, "/work/hello/hello", "/work/<repo>/<repo>")
195197 return uuidRe.ReplaceAllString(l, "<uuid>")
@@ -204,9 +206,9 @@ func shIsBash() bool {
204206 }
205207
206208 // compareWithRecording runs every job without needs of a scenario and checks
207-// that the steps, conclusions and logs equal the recording of github.com. Set
208-// up job (runner and image details) and the dump step the harness appends are
209-// not compared.
209+// that the steps, conclusions and logs equal the recording of github.com. The
210+// dump step the harness appends is not compared (the recordings have no fleet
211+// and image groups in Set up job, rule infra-groups of ermite-conformance).
210212 func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) {
211213 t.Helper()
212214 needTools(t, "bash", "sh")
@@ -243,16 +245,13 @@ func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) {
243245 }
244246 var want []es
245247 for _, s := range jr.Steps {
246- if s.Name == SetUpJobName || s.Name == "ermite-dump" {
248+ if s.Name == "ermite-dump" {
247249 continue
248250 }
249251 want = append(want, es{s.Name, s.Conclusion, s.Log})
250252 }
251253 var have []es
252254 for _, s := range got.steps {
253- if s.Name == SetUpJobName {
254- continue
255- }
256255 var log []string
257256 for _, l := range s.Log {
258257 log = append(log, normalizeLine(l, home))
@@ -181,6 +181,7 @@ var (
181 shellPathRe = regexp.MustCompile(`^shell: \S*/(\w+) `)181 shellPathRe = regexp.MustCompile(`^shell: \S*/(\w+) `)
182 startPathRe = regexp.MustCompile(`process '\S*/(\w+)'`)182 startPathRe = regexp.MustCompile(`process '\S*/(\w+)'`)
183 uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)183 uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
184+ versionRe = regexp.MustCompile(`(Current runner version: ')[^']*(')`)
184 )185 )
185 186
186 // normalizeLine maps the machine-dependent parts of a line to the form of the187 // normalizeLine maps the machine-dependent parts of a line to the form of the
@@ -190,6 +191,7 @@ var (
190 func normalizeLine(l, home string) string {191 func normalizeLine(l, home string) string {
191 l = shellPathRe.ReplaceAllString(l, "shell: /usr/bin/$1 ")192 l = shellPathRe.ReplaceAllString(l, "shell: /usr/bin/$1 ")
192 l = startPathRe.ReplaceAllString(l, "process '/usr/bin/$1'")193 l = startPathRe.ReplaceAllString(l, "process '/usr/bin/$1'")
194+ l = versionRe.ReplaceAllString(l, "${1}<runner-version>${2}")
193 l = strings.ReplaceAll(l, home, "/home/runner")195 l = strings.ReplaceAll(l, home, "/home/runner")
194 l = strings.ReplaceAll(l, "/work/hello/hello", "/work/<repo>/<repo>")196 l = strings.ReplaceAll(l, "/work/hello/hello", "/work/<repo>/<repo>")
195 return uuidRe.ReplaceAllString(l, "<uuid>")197 return uuidRe.ReplaceAllString(l, "<uuid>")
@@ -204,9 +206,9 @@ func shIsBash() bool {
204 }206 }
205 207
206 // compareWithRecording runs every job without needs of a scenario and checks208 // compareWithRecording runs every job without needs of a scenario and checks
207-// that the steps, conclusions and logs equal the recording of github.com. Set209+// that the steps, conclusions and logs equal the recording of github.com. The
208-// up job (runner and image details) and the dump step the harness appends are210+// dump step the harness appends is not compared (the recordings have no fleet
209-// not compared.211+// and image groups in Set up job, rule infra-groups of ermite-conformance).
210 func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) {212 func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) {
211 t.Helper()213 t.Helper()
212 needTools(t, "bash", "sh")214 needTools(t, "bash", "sh")
@@ -243,16 +245,13 @@ func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) {
243 }245 }
244 var want []es246 var want []es
245 for _, s := range jr.Steps {247 for _, s := range jr.Steps {
246- if s.Name == SetUpJobName || s.Name == "ermite-dump" {248+ if s.Name == "ermite-dump" {
247 continue249 continue
248 }250 }
249 want = append(want, es{s.Name, s.Conclusion, s.Log})251 want = append(want, es{s.Name, s.Conclusion, s.Log})
250 }252 }
251 var have []es253 var have []es
252 for _, s := range got.steps {254 for _, s := range got.steps {
253- if s.Name == SetUpJobName {
254- continue
255- }
256 var log []string255 var log []string
257 for _, l := range s.Log {256 for _, l := range s.Log {
258 log = append(log, normalizeLine(l, home))257 log = append(log, normalizeLine(l, home))
M internal/executor/exec_step.go +0 -1
@@ -91,7 +91,6 @@ func (j *job) prepareFileCommands(st *workflow.Step) error {
9191 for _, c := range fileCommands {
9292 j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
9393 }
94- j.state.SetGitHub("action", st.ID)
9594 j.state.SetGitHub("action_repository", "")
9695 j.state.SetGitHub("action_ref", "")
9796 if ev := j.state.EventJSON(); ev != "" {
@@ -91,7 +91,6 @@ func (j *job) prepareFileCommands(st *workflow.Step) error {
91 for _, c := range fileCommands {91 for _, c := range fileCommands {
92 j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])92 j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
93 }93 }
94- j.state.SetGitHub("action", st.ID)
95 j.state.SetGitHub("action_repository", "")94 j.state.SetGitHub("action_repository", "")
96 j.state.SetGitHub("action_ref", "")95 j.state.SetGitHub("action_ref", "")
97 if ev := j.state.EventJSON(); ev != "" {96 if ev := j.state.EventJSON(); ev != "" {
M internal/executor/filecmd.go +19 -1
@@ -30,6 +30,11 @@ const (
3030 cmdStepSummary = "step_summary"
3131 cmdState = "state"
3232 cmdOutput = "output"
33+ // The two artifact files exist on GitHub-hosted runners (recordings: the
34+ // github context has artifacts and artifacts_list, the steps GITHUB_ARTIFACTS
35+ // and GITHUB_ARTIFACTS_LIST). ermite does not process them, see below.
36+ cmdArtifacts = "artifacts"
37+ cmdArtifactsList = "artifacts_list"
3338 )
3439
3540 // fileCommand describes one file command like the IFileCommandExtension of
@@ -47,10 +52,21 @@ var fileCommands = []fileCommand{
4752 {cmdStepSummary, "step_summary_"}, // CreateStepSummaryCommand (:221)
4853 {cmdState, "save_state_"}, // SaveStateFileCommand (:295)
4954 {cmdOutput, "set_output_"}, // SetOutputFileCommand (:328)
55+ // CreateArtifactsFileCommand (CreateArtifactsFileCommand.cs:39) and
56+ // ArtifactsListFileCommand (ArtifactsListFileCommand.cs:37), registered in
57+ // ExtensionManager.cs:65-66. Both do nothing unless the feature flag
58+ // actions_runner_allow_artifacts_file or ACTIONS_RUNNER_ALLOW_ARTIFACTS_FILE
59+ // is on (CreateArtifactsFileCommand.cs:67-79); ermite has neither, so the
60+ // files are created empty (as the C# runner does when the feature is off)
61+ // and what a step writes to them is ignored. Artifact subjects are not
62+ // declared yet.
63+ {cmdArtifacts, "artifacts_"},
64+ {cmdArtifactsList, "artifacts_list_"},
5065 }
5166
5267 // EnvVarName returns the environment variable that holds the file of a command:
53-// GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, GITHUB_STATE, GITHUB_STEP_SUMMARY
68+// GITHUB_ARTIFACTS, GITHUB_ARTIFACTS_LIST, GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH,
69+// GITHUB_STATE, GITHUB_STEP_SUMMARY
5470 // (GitHubContext.GetRuntimeEnvironmentVariables, GitHubContext.cs:52).
5571 func envVarName(contextName string) string { return "GITHUB_" + strings.ToUpper(contextName) }
5672
@@ -166,6 +182,8 @@ func (f *FileCommands) Process(sink Sink) {
166182 err = processStepSummary(sink, f.root, p)
167183 case cmdState:
168184 err = processPairs(sink, f.root, p, sink.SaveState, func(k, v string) { sink.Debug(fmt.Sprintf("Save intra-action state %s = %s", k, v)) })
185+ case cmdArtifacts, cmdArtifactsList:
186+ // Disabled feature: nothing to process (see fileCommands).
169187 case cmdOutput:
170188 err = processPairs(sink, f.root, p, sink.SetOutput, func(k, v string) { sink.Debug(fmt.Sprintf("Set output %s = %s", k, v)) })
171189 }
@@ -30,6 +30,11 @@ const (
30 cmdStepSummary = "step_summary"30 cmdStepSummary = "step_summary"
31 cmdState = "state"31 cmdState = "state"
32 cmdOutput = "output"32 cmdOutput = "output"
33+ // The two artifact files exist on GitHub-hosted runners (recordings: the
34+ // github context has artifacts and artifacts_list, the steps GITHUB_ARTIFACTS
35+ // and GITHUB_ARTIFACTS_LIST). ermite does not process them, see below.
36+ cmdArtifacts = "artifacts"
37+ cmdArtifactsList = "artifacts_list"
33 )38 )
34 39
35 // fileCommand describes one file command like the IFileCommandExtension of40 // fileCommand describes one file command like the IFileCommandExtension of
@@ -47,10 +52,21 @@ var fileCommands = []fileCommand{
47 {cmdStepSummary, "step_summary_"}, // CreateStepSummaryCommand (:221)52 {cmdStepSummary, "step_summary_"}, // CreateStepSummaryCommand (:221)
48 {cmdState, "save_state_"}, // SaveStateFileCommand (:295)53 {cmdState, "save_state_"}, // SaveStateFileCommand (:295)
49 {cmdOutput, "set_output_"}, // SetOutputFileCommand (:328)54 {cmdOutput, "set_output_"}, // SetOutputFileCommand (:328)
55+ // CreateArtifactsFileCommand (CreateArtifactsFileCommand.cs:39) and
56+ // ArtifactsListFileCommand (ArtifactsListFileCommand.cs:37), registered in
57+ // ExtensionManager.cs:65-66. Both do nothing unless the feature flag
58+ // actions_runner_allow_artifacts_file or ACTIONS_RUNNER_ALLOW_ARTIFACTS_FILE
59+ // is on (CreateArtifactsFileCommand.cs:67-79); ermite has neither, so the
60+ // files are created empty (as the C# runner does when the feature is off)
61+ // and what a step writes to them is ignored. Artifact subjects are not
62+ // declared yet.
63+ {cmdArtifacts, "artifacts_"},
64+ {cmdArtifactsList, "artifacts_list_"},
50 }65 }
51 66
52 // EnvVarName returns the environment variable that holds the file of a command:67 // EnvVarName returns the environment variable that holds the file of a command:
53-// GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, GITHUB_STATE, GITHUB_STEP_SUMMARY68+// GITHUB_ARTIFACTS, GITHUB_ARTIFACTS_LIST, GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH,
69+// GITHUB_STATE, GITHUB_STEP_SUMMARY
54 // (GitHubContext.GetRuntimeEnvironmentVariables, GitHubContext.cs:52).70 // (GitHubContext.GetRuntimeEnvironmentVariables, GitHubContext.cs:52).
55 func envVarName(contextName string) string { return "GITHUB_" + strings.ToUpper(contextName) }71 func envVarName(contextName string) string { return "GITHUB_" + strings.ToUpper(contextName) }
56 72
@@ -166,6 +182,8 @@ func (f *FileCommands) Process(sink Sink) {
166 err = processStepSummary(sink, f.root, p)182 err = processStepSummary(sink, f.root, p)
167 case cmdState:183 case cmdState:
168 err = processPairs(sink, f.root, p, sink.SaveState, func(k, v string) { sink.Debug(fmt.Sprintf("Save intra-action state %s = %s", k, v)) })184 err = processPairs(sink, f.root, p, sink.SaveState, func(k, v string) { sink.Debug(fmt.Sprintf("Save intra-action state %s = %s", k, v)) })
185+ case cmdArtifacts, cmdArtifactsList:
186+ // Disabled feature: nothing to process (see fileCommands).
169 case cmdOutput:187 case cmdOutput:
170 err = processPairs(sink, f.root, p, sink.SetOutput, func(k, v string) { sink.Debug(fmt.Sprintf("Set output %s = %s", k, v)) })188 err = processPairs(sink, f.root, p, sink.SetOutput, func(k, v string) { sink.Debug(fmt.Sprintf("Set output %s = %s", k, v)) })
171 }189 }
M internal/executor/filecmd_test.go +4 -1
@@ -104,7 +104,7 @@ func TestFileCommandsInit(t *testing.T) {
104104 if err != nil {
105105 t.Fatal(err)
106106 }
107- for _, name := range []string{"GITHUB_PATH", "GITHUB_ENV", "GITHUB_STEP_SUMMARY", "GITHUB_STATE", "GITHUB_OUTPUT"} {
107+ for _, name := range []string{"GITHUB_PATH", "GITHUB_ENV", "GITHUB_STEP_SUMMARY", "GITHUB_STATE", "GITHUB_OUTPUT", "GITHUB_ARTIFACTS", "GITHUB_ARTIFACTS_LIST"} {
108108 p := env[name]
109109 if p == "" || filepath.Dir(p) != dir {
110110 t.Fatalf("%s = %q", name, p)
@@ -116,6 +116,9 @@ func TestFileCommandsInit(t *testing.T) {
116116 if !strings.HasPrefix(filepath.Base(env["GITHUB_ENV"]), "set_env_") || !strings.HasPrefix(filepath.Base(env["GITHUB_PATH"]), "add_path_") {
117117 t.Fatalf("prefixes: %v", env)
118118 }
119+ if !strings.HasPrefix(filepath.Base(env["GITHUB_ARTIFACTS"]), "artifacts_") || !strings.HasPrefix(filepath.Base(env["GITHUB_ARTIFACTS_LIST"]), "artifacts_list_") {
120+ t.Fatalf("artifact prefixes: %v", env)
121+ }
119122 // A second Init deletes the previous files and uses new names.
120123 env2, _ := fc.Init()
121124 if env2["GITHUB_ENV"] == env["GITHUB_ENV"] {
@@ -104,7 +104,7 @@ func TestFileCommandsInit(t *testing.T) {
104 if err != nil {104 if err != nil {
105 t.Fatal(err)105 t.Fatal(err)
106 }106 }
107- for _, name := range []string{"GITHUB_PATH", "GITHUB_ENV", "GITHUB_STEP_SUMMARY", "GITHUB_STATE", "GITHUB_OUTPUT"} {107+ for _, name := range []string{"GITHUB_PATH", "GITHUB_ENV", "GITHUB_STEP_SUMMARY", "GITHUB_STATE", "GITHUB_OUTPUT", "GITHUB_ARTIFACTS", "GITHUB_ARTIFACTS_LIST"} {
108 p := env[name]108 p := env[name]
109 if p == "" || filepath.Dir(p) != dir {109 if p == "" || filepath.Dir(p) != dir {
110 t.Fatalf("%s = %q", name, p)110 t.Fatalf("%s = %q", name, p)
@@ -116,6 +116,9 @@ func TestFileCommandsInit(t *testing.T) {
116 if !strings.HasPrefix(filepath.Base(env["GITHUB_ENV"]), "set_env_") || !strings.HasPrefix(filepath.Base(env["GITHUB_PATH"]), "add_path_") {116 if !strings.HasPrefix(filepath.Base(env["GITHUB_ENV"]), "set_env_") || !strings.HasPrefix(filepath.Base(env["GITHUB_PATH"]), "add_path_") {
117 t.Fatalf("prefixes: %v", env)117 t.Fatalf("prefixes: %v", env)
118 }118 }
119+ if !strings.HasPrefix(filepath.Base(env["GITHUB_ARTIFACTS"]), "artifacts_") || !strings.HasPrefix(filepath.Base(env["GITHUB_ARTIFACTS_LIST"]), "artifacts_list_") {
120+ t.Fatalf("artifact prefixes: %v", env)
121+ }
119 // A second Init deletes the previous files and uses new names.122 // A second Init deletes the previous files and uses new names.
120 env2, _ := fc.Init()123 env2, _ := fc.Init()
121 if env2["GITHUB_ENV"] == env["GITHUB_ENV"] {124 if env2["GITHUB_ENV"] == env["GITHUB_ENV"] {
M internal/executor/job.go +157 -6
@@ -16,6 +16,7 @@ import (
1616 "bnema.dev/ermite-actions/expr"
1717 "bnema.dev/ermite-actions/template"
1818 "bnema.dev/ermite-actions/workflow"
19+ "github.com/bnema/zerowrap"
1920 "github.com/google/uuid"
2021 )
2122
@@ -75,6 +76,9 @@ type Config struct {
7576 Debug bool
7677
7778 Reporter Reporter
79+ // Log receives the runner's own diagnostics (not the job log); the zero value
80+ // discards them.
81+ Log zerowrap.Logger
7882
7983 // Credential, when set, is the user the steps run as.
8084 Credential *syscall.Credential
@@ -324,11 +328,17 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
324328 func (j *job) setUp() ([]*workflow.Step, error) {
325329 cfg := j.cfg
326330 j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))
327- if cfg.Runner.Name != "" {
328- j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))
329- }
330- if host, err := os.Hostname(); err == nil {
331- j.out(1, fmt.Sprintf("Machine name: '%s'", host))
331+ // Only a self-hosted runner prints its name and machine (JobExtension.cs:
332+ // 88-98: the runner is identified by its OAuth client id); the recordings
333+ // of github-hosted runners have neither line. The runner group name
334+ // (system.runnerGroupName) is not known to ermite.
335+ if !cfg.Runner.githubHosted() {
336+ if cfg.Runner.Name != "" {
337+ j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))
338+ }
339+ if host, err := os.Hostname(); err == nil {
340+ j.out(1, fmt.Sprintf("Machine name: '%s'", host))
341+ }
332342 }
333343
334344 wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML)
@@ -373,6 +383,8 @@ func (j *job) setUp() ([]*workflow.Step, error) {
373383 return nil, errors.New("not supported yet: jobs.<id>.services")
374384 }
375385
386+ j.printTokenInfo()
387+
376388 // Directories (TempDirectoryManager, PipelineDirectoryManager).
377389 j.out(1, "Prepare workflow directory")
378390 for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} {
@@ -439,9 +451,12 @@ func (j *job) setUp() ([]*workflow.Step, error) {
439451 }
440452
441453 j.out(1, "Prepare all required actions")
454+ // JobExtension.cs:418-422: the display name of the job, which the server
455+ // computes there (message.JobDisplayName). ermite's Job message has no such
456+ // field, so the executor evaluates it (see jobDisplayName).
442457 name := cfg.JobName
443458 if name == "" {
444- name = cfg.JobKey
459+ name = j.jobDisplayName()
445460 }
446461 if strings.TrimSpace(name) != "" {
447462 j.out(1, "Complete job name: "+name)
@@ -552,3 +567,139 @@ func (j *job) completeJob(num int) map[string]string {
552567 rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())
553568 return outputs
554569 }
570+
571+// Defaults of the lines "Set up job" prints between the runner version and
572+// "Prepare workflow directory" (JobExtension.cs:150-182). The server gives
573+// GitHub's runner these as the variables system.github.token.permissions and
574+// actions_cache_mode and the github context key secret_source; ermite's Job
575+// message carries none of them, so the values recorded on github.com for a
576+// push to a public repository are used. Every one of the 221 recorded jobs
577+// has exactly these lines.
578+const (
579+ defaultSecretSource = "Actions"
580+ defaultCacheMode = "write"
581+)
582+
583+// defaultTokenPermissions is the GITHUB_TOKEN of a workflow without a
584+// "permissions" key, as recorded (JobExtension.cs:157-166 prints the
585+// permissions in the order of the server's JSON).
586+var defaultTokenPermissions = [][2]string{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}}
587+
588+// printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source
589+// and the cache mode.
590+//
591+// Without a "permissions" key the group is the recorded default. With one
592+// (including {} and read-all/write-all) the group is omitted, on purpose: the
593+// runner only prints what the server gives it (system.github.token.permissions,
594+// a JSON object, JobExtension.cs:154-166), and the sources at hand do not say
595+// what the server gives for those cases: the display names of the scopes
596+// ("Pull requests" or "PullRequests", "Id-token" or "IdToken"), their order, the
597+// levels the server grants on a push (Metadata is always read; write-all and
598+// id-token depend on the repository's policy, the Permissions class of the
599+// runner only models the workflow side, Sdk/WorkflowParser/Permissions.cs and
600+// Conversion/PermissionsHelper.cs) and whether "none" scopes are listed. Only
601+// the default is in the recordings (221 of 221 jobs; no corpus scenario sets
602+// permissions). A group made up from the workflow would be a guess; add the
603+// mapping once a recording of a scenario with permissions exists. The
604+// complete fix is the server sending the effective permissions in the Job
605+// message.
606+func (j *job) printTokenInfo() {
607+ if j.wf.Permissions == nil && j.jb.Permissions == nil {
608+ j.out(1, "##[group]GITHUB_TOKEN Permissions")
609+ for _, p := range defaultTokenPermissions {
610+ j.out(1, p[0]+": "+p[1])
611+ }
612+ j.out(1, "##[endgroup]")
613+ }
614+ // secret_source is a github context key (JobExtension.cs:172); the context
615+ // of a job from ermite lacks it, so it is added with the recorded value.
616+ src := j.state.GitHub("secret_source")
617+ if src == "" {
618+ src = defaultSecretSource
619+ j.state.SetGitHub("secret_source", src)
620+ }
621+ j.out(1, "Secret source: "+src)
622+ j.out(1, "Cache mode: "+defaultCacheMode)
623+}
624+
625+// jobDisplayName is the name of the job as GitHub shows it and the runner
626+// prints it after "Complete job name:": the "name" of the job (the job key when
627+// it has none) evaluated with github, needs, strategy, matrix, vars and inputs
628+// as the server has them (State.ServerContexts: github without the keys the
629+// runner adds), followed by the matrix values in parentheses unless the name
630+// reads the matrix (recorded: matrix-job-name, see workflow.Job.DisplayName).
631+// When the name cannot be worked out the job key is used and a warning is
632+// logged: the name is not worth failing the job for.
633+func (j *job) jobDisplayName() string {
634+ ctx := context.Background()
635+ fallback := func(why string, err error) string {
636+ j.cfg.Log.Warn().Err(err).Str("job_key", j.cfg.JobKey).Msg("job display name: " + why + ", using the job key")
637+ return j.cfg.JobKey
638+ }
639+ j.mu.Lock()
640+ all := j.state.ServerContexts()
641+ j.mu.Unlock()
642+ env := j.evalEnv(newOrdered())
643+ env.Contexts = all
644+ // The strategy itself sees neither the strategy nor the matrix context.
645+ senv := env
646+ senv.Contexts = map[string]expr.Value{}
647+ for _, k := range []string{"github", "needs", "vars", "inputs"} {
648+ senv.Contexts[k] = all[k]
649+ }
650+ st, err := j.jb.EvaluateStrategy(ctx, senv)
651+ if err != nil {
652+ return fallback("the strategy does not evaluate", err)
653+ }
654+ idx, ok := comboIndex(st, all["strategy"], all["matrix"])
655+ if !ok {
656+ return fallback("the matrix of the job is not one of the combinations of its strategy", nil)
657+ }
658+ name, err := j.jb.DisplayName(ctx, env, st, idx)
659+ if err != nil {
660+ return fallback("the name does not evaluate", err)
661+ }
662+ return name
663+}
664+
665+// comboIndex finds the combination of the strategy that is this job. Without a
666+// matrix it is the only one. The index of the strategy context is trusted only
667+// when that combination has the matrix of the job; otherwise the first
668+// combination with equal values is taken. ok is false when there is none.
669+func comboIndex(st *workflow.Strategy, strategyCtx, matrix expr.Value) (idx int, ok bool) {
670+ if !st.HasMatrix {
671+ return 0, true
672+ }
673+ m, isDict := matrix.(*expr.Dictionary)
674+ if !isDict {
675+ return 0, false
676+ }
677+ if d, isDict := strategyCtx.(*expr.Dictionary); isDict {
678+ if n, found := d.Get("job-index"); found {
679+ if f, isNum := n.(expr.Number); isNum && f >= 0 && int(f) < len(st.Combos) && sameMatrix(st.Combos[int(f)].Values, m) {
680+ return int(f), true
681+ }
682+ }
683+ }
684+ for i, c := range st.Combos {
685+ if sameMatrix(c.Values, m) {
686+ return i, true
687+ }
688+ }
689+ return 0, false
690+}
691+
692+// sameMatrix compares two matrix values: the same keys (ignoring case and
693+// order) with equal values.
694+func sameMatrix(a, b *expr.Dictionary) bool {
695+ if a.Len() != b.Len() {
696+ return false
697+ }
698+ for _, p := range a.Pairs() {
699+ v, found := b.Get(p.Key)
700+ if !found || expr.ToJSON(v) != expr.ToJSON(p.Value) {
701+ return false
702+ }
703+ }
704+ return true
705+}
@@ -16,6 +16,7 @@ import (
16 "bnema.dev/ermite-actions/expr"16 "bnema.dev/ermite-actions/expr"
17 "bnema.dev/ermite-actions/template"17 "bnema.dev/ermite-actions/template"
18 "bnema.dev/ermite-actions/workflow"18 "bnema.dev/ermite-actions/workflow"
19+ "github.com/bnema/zerowrap"
19 "github.com/google/uuid"20 "github.com/google/uuid"
20 )21 )
21 22
@@ -75,6 +76,9 @@ type Config struct {
75 Debug bool76 Debug bool
76 77
77 Reporter Reporter78 Reporter Reporter
79+ // Log receives the runner's own diagnostics (not the job log); the zero value
80+ // discards them.
81+ Log zerowrap.Logger
78 82
79 // Credential, when set, is the user the steps run as.83 // Credential, when set, is the user the steps run as.
80 Credential *syscall.Credential84 Credential *syscall.Credential
@@ -324,11 +328,17 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
324 func (j *job) setUp() ([]*workflow.Step, error) {328 func (j *job) setUp() ([]*workflow.Step, error) {
325 cfg := j.cfg329 cfg := j.cfg
326 j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))330 j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))
327- if cfg.Runner.Name != "" {331+ // Only a self-hosted runner prints its name and machine (JobExtension.cs:
328- j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))332+ // 88-98: the runner is identified by its OAuth client id); the recordings
329- }333+ // of github-hosted runners have neither line. The runner group name
330- if host, err := os.Hostname(); err == nil {334+ // (system.runnerGroupName) is not known to ermite.
331- j.out(1, fmt.Sprintf("Machine name: '%s'", host))335+ if !cfg.Runner.githubHosted() {
336+ if cfg.Runner.Name != "" {
337+ j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))
338+ }
339+ if host, err := os.Hostname(); err == nil {
340+ j.out(1, fmt.Sprintf("Machine name: '%s'", host))
341+ }
332 }342 }
333 343
334 wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML)344 wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML)
@@ -373,6 +383,8 @@ func (j *job) setUp() ([]*workflow.Step, error) {
373 return nil, errors.New("not supported yet: jobs.<id>.services")383 return nil, errors.New("not supported yet: jobs.<id>.services")
374 }384 }
375 385
386+ j.printTokenInfo()
387+
376 // Directories (TempDirectoryManager, PipelineDirectoryManager).388 // Directories (TempDirectoryManager, PipelineDirectoryManager).
377 j.out(1, "Prepare workflow directory")389 j.out(1, "Prepare workflow directory")
378 for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} {390 for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} {
@@ -439,9 +451,12 @@ func (j *job) setUp() ([]*workflow.Step, error) {
439 }451 }
440 452
441 j.out(1, "Prepare all required actions")453 j.out(1, "Prepare all required actions")
454+ // JobExtension.cs:418-422: the display name of the job, which the server
455+ // computes there (message.JobDisplayName). ermite's Job message has no such
456+ // field, so the executor evaluates it (see jobDisplayName).
442 name := cfg.JobName457 name := cfg.JobName
443 if name == "" {458 if name == "" {
444- name = cfg.JobKey459+ name = j.jobDisplayName()
445 }460 }
446 if strings.TrimSpace(name) != "" {461 if strings.TrimSpace(name) != "" {
447 j.out(1, "Complete job name: "+name)462 j.out(1, "Complete job name: "+name)
@@ -552,3 +567,139 @@ func (j *job) completeJob(num int) map[string]string {
552 rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())567 rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())
553 return outputs568 return outputs
554 }569 }
570+
571+// Defaults of the lines "Set up job" prints between the runner version and
572+// "Prepare workflow directory" (JobExtension.cs:150-182). The server gives
573+// GitHub's runner these as the variables system.github.token.permissions and
574+// actions_cache_mode and the github context key secret_source; ermite's Job
575+// message carries none of them, so the values recorded on github.com for a
576+// push to a public repository are used. Every one of the 221 recorded jobs
577+// has exactly these lines.
578+const (
579+ defaultSecretSource = "Actions"
580+ defaultCacheMode = "write"
581+)
582+
583+// defaultTokenPermissions is the GITHUB_TOKEN of a workflow without a
584+// "permissions" key, as recorded (JobExtension.cs:157-166 prints the
585+// permissions in the order of the server's JSON).
586+var defaultTokenPermissions = [][2]string{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}}
587+
588+// printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source
589+// and the cache mode.
590+//
591+// Without a "permissions" key the group is the recorded default. With one
592+// (including {} and read-all/write-all) the group is omitted, on purpose: the
593+// runner only prints what the server gives it (system.github.token.permissions,
594+// a JSON object, JobExtension.cs:154-166), and the sources at hand do not say
595+// what the server gives for those cases: the display names of the scopes
596+// ("Pull requests" or "PullRequests", "Id-token" or "IdToken"), their order, the
597+// levels the server grants on a push (Metadata is always read; write-all and
598+// id-token depend on the repository's policy, the Permissions class of the
599+// runner only models the workflow side, Sdk/WorkflowParser/Permissions.cs and
600+// Conversion/PermissionsHelper.cs) and whether "none" scopes are listed. Only
601+// the default is in the recordings (221 of 221 jobs; no corpus scenario sets
602+// permissions). A group made up from the workflow would be a guess; add the
603+// mapping once a recording of a scenario with permissions exists. The
604+// complete fix is the server sending the effective permissions in the Job
605+// message.
606+func (j *job) printTokenInfo() {
607+ if j.wf.Permissions == nil && j.jb.Permissions == nil {
608+ j.out(1, "##[group]GITHUB_TOKEN Permissions")
609+ for _, p := range defaultTokenPermissions {
610+ j.out(1, p[0]+": "+p[1])
611+ }
612+ j.out(1, "##[endgroup]")
613+ }
614+ // secret_source is a github context key (JobExtension.cs:172); the context
615+ // of a job from ermite lacks it, so it is added with the recorded value.
616+ src := j.state.GitHub("secret_source")
617+ if src == "" {
618+ src = defaultSecretSource
619+ j.state.SetGitHub("secret_source", src)
620+ }
621+ j.out(1, "Secret source: "+src)
622+ j.out(1, "Cache mode: "+defaultCacheMode)
623+}
624+
625+// jobDisplayName is the name of the job as GitHub shows it and the runner
626+// prints it after "Complete job name:": the "name" of the job (the job key when
627+// it has none) evaluated with github, needs, strategy, matrix, vars and inputs
628+// as the server has them (State.ServerContexts: github without the keys the
629+// runner adds), followed by the matrix values in parentheses unless the name
630+// reads the matrix (recorded: matrix-job-name, see workflow.Job.DisplayName).
631+// When the name cannot be worked out the job key is used and a warning is
632+// logged: the name is not worth failing the job for.
633+func (j *job) jobDisplayName() string {
634+ ctx := context.Background()
635+ fallback := func(why string, err error) string {
636+ j.cfg.Log.Warn().Err(err).Str("job_key", j.cfg.JobKey).Msg("job display name: " + why + ", using the job key")
637+ return j.cfg.JobKey
638+ }
639+ j.mu.Lock()
640+ all := j.state.ServerContexts()
641+ j.mu.Unlock()
642+ env := j.evalEnv(newOrdered())
643+ env.Contexts = all
644+ // The strategy itself sees neither the strategy nor the matrix context.
645+ senv := env
646+ senv.Contexts = map[string]expr.Value{}
647+ for _, k := range []string{"github", "needs", "vars", "inputs"} {
648+ senv.Contexts[k] = all[k]
649+ }
650+ st, err := j.jb.EvaluateStrategy(ctx, senv)
651+ if err != nil {
652+ return fallback("the strategy does not evaluate", err)
653+ }
654+ idx, ok := comboIndex(st, all["strategy"], all["matrix"])
655+ if !ok {
656+ return fallback("the matrix of the job is not one of the combinations of its strategy", nil)
657+ }
658+ name, err := j.jb.DisplayName(ctx, env, st, idx)
659+ if err != nil {
660+ return fallback("the name does not evaluate", err)
661+ }
662+ return name
663+}
664+
665+// comboIndex finds the combination of the strategy that is this job. Without a
666+// matrix it is the only one. The index of the strategy context is trusted only
667+// when that combination has the matrix of the job; otherwise the first
668+// combination with equal values is taken. ok is false when there is none.
669+func comboIndex(st *workflow.Strategy, strategyCtx, matrix expr.Value) (idx int, ok bool) {
670+ if !st.HasMatrix {
671+ return 0, true
672+ }
673+ m, isDict := matrix.(*expr.Dictionary)
674+ if !isDict {
675+ return 0, false
676+ }
677+ if d, isDict := strategyCtx.(*expr.Dictionary); isDict {
678+ if n, found := d.Get("job-index"); found {
679+ if f, isNum := n.(expr.Number); isNum && f >= 0 && int(f) < len(st.Combos) && sameMatrix(st.Combos[int(f)].Values, m) {
680+ return int(f), true
681+ }
682+ }
683+ }
684+ for i, c := range st.Combos {
685+ if sameMatrix(c.Values, m) {
686+ return i, true
687+ }
688+ }
689+ return 0, false
690+}
691+
692+// sameMatrix compares two matrix values: the same keys (ignoring case and
693+// order) with equal values.
694+func sameMatrix(a, b *expr.Dictionary) bool {
695+ if a.Len() != b.Len() {
696+ return false
697+ }
698+ for _, p := range a.Pairs() {
699+ v, found := b.Get(p.Key)
700+ if !found || expr.ToJSON(v) != expr.ToJSON(p.Value) {
701+ return false
702+ }
703+ }
704+ return true
705+}
M internal/executor/job_test.go +231 -1
@@ -3,12 +3,15 @@
33 package executor
44
55 import (
6+ "bytes"
67 "os"
78 "path/filepath"
89 "strings"
910 "syscall"
1011 "testing"
1112 "time"
13+
14+ "github.com/bnema/zerowrap"
1215 )
1316
1417 const wfHead = "on: push\njobs:\n j:\n runs-on: x\n"
@@ -31,7 +34,7 @@ func TestImplicitStepsAndNumbering(t *testing.T) {
3134 }
3235 }
3336 setup := logOf(rec.steps[0])
34- for _, w := range []string{"Current runner version: 'test'", "Runner name: 'test-runner'", "Prepare workflow directory", "Prepare all required actions", "Complete job name: j"} {
37+ for _, w := range []string{"Current runner version: 'test'", "Prepare workflow directory", "Prepare all required actions", "Complete job name: j"} {
3538 if !strings.Contains(setup, w) {
3639 t.Errorf("Set up job lacks %q:\n%s", w, setup)
3740 }
@@ -619,3 +622,230 @@ func TestDeadlineOverridesTheTimeout(t *testing.T) {
619622 t.Fatalf("took %v: %+v", time.Since(start), rec.steps[1])
620623 }
621624 }
625+
626+// The env of a step reads its own id in github.action (StepsRunner.cs:118 sets
627+// it before the env is evaluated), and the file command paths of the step
628+// before it, as the recordings of github.com show for the dump step.
629+func TestGithubActionInStepEnvIsTheStepsOwnID(t *testing.T) {
630+ needTools(t, "bash")
631+ rec, res := runLocal(t, wfHead+` steps:
632+ - run: echo one
633+ - id: named
634+ run: echo "$A|$GITHUB_ACTION|$GITHUB_ARTIFACTS" | sed 's#/[^|]*/##'
635+ env:
636+ A: ${{ github.action }}
637+ - run: echo "$A"
638+ env:
639+ A: ${{ github.action }}
640+`, "j", nil)
641+ if res.Conclusion != ConclusionSuccess {
642+ t.Fatalf("%+v", res)
643+ }
644+ if got := rec.steps[2].Log[len(rec.steps[2].Log)-1]; !strings.HasPrefix(got, "named|named|artifacts_") {
645+ t.Errorf("named step: %q", got)
646+ }
647+ if got := rec.steps[3].Log[len(rec.steps[3].Log)-1]; got != "__run_2" {
648+ t.Errorf("third step: %q", got)
649+ }
650+}
651+
652+// Without a matrix the matrix context is null, as on github.com (toJSON prints
653+// null), and matrix.x is still readable (null, so an empty string).
654+func TestMatrixContextWithoutMatrixIsNull(t *testing.T) {
655+ needTools(t, "bash")
656+ rec, res := runLocal(t, wfHead+` steps:
657+ - run: echo "json=$J x=[$X] eq=$E"
658+ env:
659+ J: ${{ toJSON(matrix) }}
660+ X: ${{ matrix.x }}
661+ E: ${{ matrix == null }}
662+`, "j", nil)
663+ if res.Conclusion != ConclusionSuccess {
664+ t.Fatalf("%+v %v", res, logOf(rec.steps[1]))
665+ }
666+ if got := rec.steps[1].Log[len(rec.steps[1].Log)-1]; got != "json=null x=[] eq=true" {
667+ t.Errorf("%q", got)
668+ }
669+}
670+
671+func setupLog(t *testing.T, yaml, job string, tweak func(*Config)) string {
672+ t.Helper()
673+ rec, res := runLocal(t, yaml, job, tweak)
674+ if res.Conclusion != ConclusionSuccess {
675+ t.Fatalf("%+v", res)
676+ }
677+ return logOf(rec.steps[0])
678+}
679+
680+func TestSetUpJobMatchesGitHubHosted(t *testing.T) {
681+ needTools(t, "bash")
682+ want := strings.Join([]string{"Current runner version: 'test'", "##[group]GITHUB_TOKEN Permissions", "Contents: read", "Metadata: read",
683+ "Packages: read", "##[endgroup]", "Secret source: Actions", "Cache mode: write", "Prepare workflow directory",
684+ "Prepare all required actions", "Complete job name: j"}, "\n")
685+ if got := setupLog(t, wfHead+" steps:\n - run: echo hi\n", "j", nil); got != want {
686+ t.Errorf("github-hosted:\n%s", got)
687+ }
688+}
689+
690+func TestSetUpJobSelfHostedNamesTheRunner(t *testing.T) {
691+ needTools(t, "bash")
692+ got := setupLog(t, wfHead+" steps:\n - run: echo hi\n", "j", func(c *Config) { c.Runner.Environment = "self-hosted" })
693+ host, _ := os.Hostname()
694+ if !strings.Contains(got, "Runner name: 'test-runner'\nMachine name: '"+host+"'\n##[group]GITHUB_TOKEN") {
695+ t.Errorf("self-hosted:\n%s", got)
696+ }
697+}
698+
699+// Permissions the workflow sets are not the default ones: no group is printed
700+// rather than a wrong one.
701+func TestSetUpJobWithPermissionsPrintsNoDefaultGroup(t *testing.T) {
702+ needTools(t, "bash")
703+ for name, yaml := range map[string]string{
704+ "workflow": "permissions:\n contents: write\n" + wfHead + " steps:\n - run: echo hi\n",
705+ "job": wfHead + " permissions: read-all\n steps:\n - run: echo hi\n",
706+ "empty": "permissions: {}\n" + wfHead + " steps:\n - run: echo hi\n",
707+ "write": "permissions: write-all\n" + wfHead + " steps:\n - run: echo hi\n",
708+ } {
709+ got := setupLog(t, yaml, "j", nil)
710+ if strings.Contains(got, "GITHUB_TOKEN Permissions") || !strings.Contains(got, "Secret source: Actions\nCache mode: write") {
711+ t.Errorf("%s:\n%s", name, got)
712+ }
713+ }
714+}
715+
716+func TestSetUpJobNamesTheJobLikeGitHub(t *testing.T) {
717+ needTools(t, "bash")
718+ // recordings/matrix-job-name.json
719+ yaml := `on: push
720+jobs:
721+ default-name:
722+ runs-on: x
723+ strategy:
724+ matrix:
725+ os: [a, b]
726+ n: [1, 2]
727+ steps:
728+ - run: echo hi
729+ custom-name:
730+ name: "custom ${{ matrix.os }} / ${{ matrix.n }} / ${{ github.event_name }}"
731+ runs-on: x
732+ strategy:
733+ matrix:
734+ os: [a, b]
735+ n: [1]
736+ steps:
737+ - run: echo hi
738+ static-name:
739+ name: same name for every leg
740+ runs-on: x
741+ strategy:
742+ matrix:
743+ n: [1, 2]
744+ steps:
745+ - run: echo hi
746+ plain:
747+ name: "plain ${{ github.event_name }}"
748+ runs-on: x
749+ steps:
750+ - run: echo hi
751+`
752+ for _, tc := range []struct{ job, matrix, strategy, want string }{
753+ {"default-name", `{"os":"b","n":1}`, `{"job-index":2}`, "default-name (b, 1)"},
754+ {"default-name", `{"os":"a","n":2}`, `{"job-index":1}`, "default-name (a, 2)"},
755+ {"default-name", `{"os":"b","n":2}`, ``, "default-name (b, 2)"},
756+ {"custom-name", `{"os":"b","n":1}`, `{"job-index":1}`, "custom b / 1 / push"},
757+ {"static-name", `{"n":2}`, `{"job-index":1}`, "same name for every leg (2)"},
758+ {"plain", `{}`, ``, "plain push"},
759+ {"static-name", `{"n":9}`, ``, "static-name"}, // not one of the combinations: the job key
760+ // job-index points to another combination: it is not trusted.
761+ {"default-name", `{"os":"a","n":2}`, `{"job-index":0}`, "default-name (a, 2)"},
762+ {"default-name", `{"os":"b","n":2}`, `{"job-index":7}`, "default-name (b, 2)"},
763+ } {
764+ t.Run(tc.want, func(t *testing.T) {
765+ got := setupLog(t, yaml, tc.job, func(c *Config) {
766+ c.MatrixJSON = tc.matrix
767+ ctx := testGitHubContext(tc.job)
768+ ctx = strings.TrimSuffix(ctx, "}")
769+ ctx += `,"matrix":` + tc.matrix
770+ if tc.strategy != "" {
771+ ctx += `,"strategy":` + tc.strategy
772+ }
773+ c.ContextsJSON = ctx + "}"
774+ })
775+ if !strings.HasSuffix(got, "Complete job name: "+tc.want) {
776+ t.Errorf("got:\n%s", got)
777+ }
778+ })
779+ }
780+}
781+
782+func TestJobDisplayNameReadsNeedsVarsAndIgnoresRunnerKeys(t *testing.T) {
783+ needTools(t, "bash")
784+ yaml := `on: push
785+jobs:
786+ j:
787+ name: "${{ needs.a.outputs.o }}-${{ vars.V }}-${{ github.workspace }}-${{ github.secret_source }}-${{ github.token }}-${{ github.event_name }}"
788+ runs-on: x
789+ steps:
790+ - run: echo hi
791+`
792+ got := setupLog(t, yaml, "j", func(c *Config) {
793+ c.ContextsJSON = `{"github":{"repository":"octo/hello","event_name":"push","job":"j"},"needs":{"a":{"outputs":{"o":"out"}}},"vars":{"V":"v1"}}`
794+ })
795+ // workspace, secret_source and token are added by the runner: empty for the name.
796+ if !strings.HasSuffix(got, "Complete job name: out-v1----push") {
797+ t.Errorf("got:\n%s", got)
798+ }
799+}
800+
801+func TestJobDisplayNameIncludeOnlyMatrix(t *testing.T) {
802+ needTools(t, "bash")
803+ yaml := `on: push
804+jobs:
805+ j:
806+ runs-on: x
807+ strategy:
808+ matrix:
809+ include:
810+ - os: a
811+ n: 1
812+ - os: b
813+ n: 2
814+ steps:
815+ - run: echo hi
816+`
817+ for matrix, want := range map[string]string{`{"os":"b","n":2}`: "j (b, 2)", `{"os":"a","n":1}`: "j (a, 1)"} {
818+ got := setupLog(t, yaml, "j", func(c *Config) {
819+ c.MatrixJSON = matrix
820+ c.ContextsJSON = strings.TrimSuffix(testGitHubContext("j"), "}") + `,"matrix":` + matrix + `}`
821+ })
822+ if !strings.HasSuffix(got, "Complete job name: "+want) {
823+ t.Errorf("%s:\n%s", matrix, got)
824+ }
825+ }
826+}
827+
828+func TestJobDisplayNameFallsBackToTheKeyWithAWarning(t *testing.T) {
829+ needTools(t, "bash")
830+ yaml := `on: push
831+jobs:
832+ j:
833+ runs-on: x
834+ strategy:
835+ matrix:
836+ n: [1, 2]
837+ steps:
838+ - run: echo hi
839+`
840+ var logs bytes.Buffer
841+ got := setupLog(t, yaml, "j", func(c *Config) {
842+ c.MatrixJSON = `{"n":5}`
843+ c.Log = zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})
844+ })
845+ if !strings.HasSuffix(got, "Complete job name: j") {
846+ t.Errorf("got:\n%s", got)
847+ }
848+ if !strings.Contains(logs.String(), `"level":"warn"`) || !strings.Contains(logs.String(), "using the job key") {
849+ t.Errorf("no warning: %q", logs.String())
850+ }
851+}
@@ -3,12 +3,15 @@
3 package executor3 package executor
4 4
5 import (5 import (
6+ "bytes"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
8 "strings"9 "strings"
9 "syscall"10 "syscall"
10 "testing"11 "testing"
11 "time"12 "time"
13+
14+ "github.com/bnema/zerowrap"
12 )15 )
13 16
14 const wfHead = "on: push\njobs:\n j:\n runs-on: x\n"17 const wfHead = "on: push\njobs:\n j:\n runs-on: x\n"
@@ -31,7 +34,7 @@ func TestImplicitStepsAndNumbering(t *testing.T) {
31 }34 }
32 }35 }
33 setup := logOf(rec.steps[0])36 setup := logOf(rec.steps[0])
34- for _, w := range []string{"Current runner version: 'test'", "Runner name: 'test-runner'", "Prepare workflow directory", "Prepare all required actions", "Complete job name: j"} {37+ for _, w := range []string{"Current runner version: 'test'", "Prepare workflow directory", "Prepare all required actions", "Complete job name: j"} {
35 if !strings.Contains(setup, w) {38 if !strings.Contains(setup, w) {
36 t.Errorf("Set up job lacks %q:\n%s", w, setup)39 t.Errorf("Set up job lacks %q:\n%s", w, setup)
37 }40 }
@@ -619,3 +622,230 @@ func TestDeadlineOverridesTheTimeout(t *testing.T) {
619 t.Fatalf("took %v: %+v", time.Since(start), rec.steps[1])622 t.Fatalf("took %v: %+v", time.Since(start), rec.steps[1])
620 }623 }
621 }624 }
625+
626+// The env of a step reads its own id in github.action (StepsRunner.cs:118 sets
627+// it before the env is evaluated), and the file command paths of the step
628+// before it, as the recordings of github.com show for the dump step.
629+func TestGithubActionInStepEnvIsTheStepsOwnID(t *testing.T) {
630+ needTools(t, "bash")
631+ rec, res := runLocal(t, wfHead+` steps:
632+ - run: echo one
633+ - id: named
634+ run: echo "$A|$GITHUB_ACTION|$GITHUB_ARTIFACTS" | sed 's#/[^|]*/##'
635+ env:
636+ A: ${{ github.action }}
637+ - run: echo "$A"
638+ env:
639+ A: ${{ github.action }}
640+`, "j", nil)
641+ if res.Conclusion != ConclusionSuccess {
642+ t.Fatalf("%+v", res)
643+ }
644+ if got := rec.steps[2].Log[len(rec.steps[2].Log)-1]; !strings.HasPrefix(got, "named|named|artifacts_") {
645+ t.Errorf("named step: %q", got)
646+ }
647+ if got := rec.steps[3].Log[len(rec.steps[3].Log)-1]; got != "__run_2" {
648+ t.Errorf("third step: %q", got)
649+ }
650+}
651+
652+// Without a matrix the matrix context is null, as on github.com (toJSON prints
653+// null), and matrix.x is still readable (null, so an empty string).
654+func TestMatrixContextWithoutMatrixIsNull(t *testing.T) {
655+ needTools(t, "bash")
656+ rec, res := runLocal(t, wfHead+` steps:
657+ - run: echo "json=$J x=[$X] eq=$E"
658+ env:
659+ J: ${{ toJSON(matrix) }}
660+ X: ${{ matrix.x }}
661+ E: ${{ matrix == null }}
662+`, "j", nil)
663+ if res.Conclusion != ConclusionSuccess {
664+ t.Fatalf("%+v %v", res, logOf(rec.steps[1]))
665+ }
666+ if got := rec.steps[1].Log[len(rec.steps[1].Log)-1]; got != "json=null x=[] eq=true" {
667+ t.Errorf("%q", got)
668+ }
669+}
670+
671+func setupLog(t *testing.T, yaml, job string, tweak func(*Config)) string {
672+ t.Helper()
673+ rec, res := runLocal(t, yaml, job, tweak)
674+ if res.Conclusion != ConclusionSuccess {
675+ t.Fatalf("%+v", res)
676+ }
677+ return logOf(rec.steps[0])
678+}
679+
680+func TestSetUpJobMatchesGitHubHosted(t *testing.T) {
681+ needTools(t, "bash")
682+ want := strings.Join([]string{"Current runner version: 'test'", "##[group]GITHUB_TOKEN Permissions", "Contents: read", "Metadata: read",
683+ "Packages: read", "##[endgroup]", "Secret source: Actions", "Cache mode: write", "Prepare workflow directory",
684+ "Prepare all required actions", "Complete job name: j"}, "\n")
685+ if got := setupLog(t, wfHead+" steps:\n - run: echo hi\n", "j", nil); got != want {
686+ t.Errorf("github-hosted:\n%s", got)
687+ }
688+}
689+
690+func TestSetUpJobSelfHostedNamesTheRunner(t *testing.T) {
691+ needTools(t, "bash")
692+ got := setupLog(t, wfHead+" steps:\n - run: echo hi\n", "j", func(c *Config) { c.Runner.Environment = "self-hosted" })
693+ host, _ := os.Hostname()
694+ if !strings.Contains(got, "Runner name: 'test-runner'\nMachine name: '"+host+"'\n##[group]GITHUB_TOKEN") {
695+ t.Errorf("self-hosted:\n%s", got)
696+ }
697+}
698+
699+// Permissions the workflow sets are not the default ones: no group is printed
700+// rather than a wrong one.
701+func TestSetUpJobWithPermissionsPrintsNoDefaultGroup(t *testing.T) {
702+ needTools(t, "bash")
703+ for name, yaml := range map[string]string{
704+ "workflow": "permissions:\n contents: write\n" + wfHead + " steps:\n - run: echo hi\n",
705+ "job": wfHead + " permissions: read-all\n steps:\n - run: echo hi\n",
706+ "empty": "permissions: {}\n" + wfHead + " steps:\n - run: echo hi\n",
707+ "write": "permissions: write-all\n" + wfHead + " steps:\n - run: echo hi\n",
708+ } {
709+ got := setupLog(t, yaml, "j", nil)
710+ if strings.Contains(got, "GITHUB_TOKEN Permissions") || !strings.Contains(got, "Secret source: Actions\nCache mode: write") {
711+ t.Errorf("%s:\n%s", name, got)
712+ }
713+ }
714+}
715+
716+func TestSetUpJobNamesTheJobLikeGitHub(t *testing.T) {
717+ needTools(t, "bash")
718+ // recordings/matrix-job-name.json
719+ yaml := `on: push
720+jobs:
721+ default-name:
722+ runs-on: x
723+ strategy:
724+ matrix:
725+ os: [a, b]
726+ n: [1, 2]
727+ steps:
728+ - run: echo hi
729+ custom-name:
730+ name: "custom ${{ matrix.os }} / ${{ matrix.n }} / ${{ github.event_name }}"
731+ runs-on: x
732+ strategy:
733+ matrix:
734+ os: [a, b]
735+ n: [1]
736+ steps:
737+ - run: echo hi
738+ static-name:
739+ name: same name for every leg
740+ runs-on: x
741+ strategy:
742+ matrix:
743+ n: [1, 2]
744+ steps:
745+ - run: echo hi
746+ plain:
747+ name: "plain ${{ github.event_name }}"
748+ runs-on: x
749+ steps:
750+ - run: echo hi
751+`
752+ for _, tc := range []struct{ job, matrix, strategy, want string }{
753+ {"default-name", `{"os":"b","n":1}`, `{"job-index":2}`, "default-name (b, 1)"},
754+ {"default-name", `{"os":"a","n":2}`, `{"job-index":1}`, "default-name (a, 2)"},
755+ {"default-name", `{"os":"b","n":2}`, ``, "default-name (b, 2)"},
756+ {"custom-name", `{"os":"b","n":1}`, `{"job-index":1}`, "custom b / 1 / push"},
757+ {"static-name", `{"n":2}`, `{"job-index":1}`, "same name for every leg (2)"},
758+ {"plain", `{}`, ``, "plain push"},
759+ {"static-name", `{"n":9}`, ``, "static-name"}, // not one of the combinations: the job key
760+ // job-index points to another combination: it is not trusted.
761+ {"default-name", `{"os":"a","n":2}`, `{"job-index":0}`, "default-name (a, 2)"},
762+ {"default-name", `{"os":"b","n":2}`, `{"job-index":7}`, "default-name (b, 2)"},
763+ } {
764+ t.Run(tc.want, func(t *testing.T) {
765+ got := setupLog(t, yaml, tc.job, func(c *Config) {
766+ c.MatrixJSON = tc.matrix
767+ ctx := testGitHubContext(tc.job)
768+ ctx = strings.TrimSuffix(ctx, "}")
769+ ctx += `,"matrix":` + tc.matrix
770+ if tc.strategy != "" {
771+ ctx += `,"strategy":` + tc.strategy
772+ }
773+ c.ContextsJSON = ctx + "}"
774+ })
775+ if !strings.HasSuffix(got, "Complete job name: "+tc.want) {
776+ t.Errorf("got:\n%s", got)
777+ }
778+ })
779+ }
780+}
781+
782+func TestJobDisplayNameReadsNeedsVarsAndIgnoresRunnerKeys(t *testing.T) {
783+ needTools(t, "bash")
784+ yaml := `on: push
785+jobs:
786+ j:
787+ name: "${{ needs.a.outputs.o }}-${{ vars.V }}-${{ github.workspace }}-${{ github.secret_source }}-${{ github.token }}-${{ github.event_name }}"
788+ runs-on: x
789+ steps:
790+ - run: echo hi
791+`
792+ got := setupLog(t, yaml, "j", func(c *Config) {
793+ c.ContextsJSON = `{"github":{"repository":"octo/hello","event_name":"push","job":"j"},"needs":{"a":{"outputs":{"o":"out"}}},"vars":{"V":"v1"}}`
794+ })
795+ // workspace, secret_source and token are added by the runner: empty for the name.
796+ if !strings.HasSuffix(got, "Complete job name: out-v1----push") {
797+ t.Errorf("got:\n%s", got)
798+ }
799+}
800+
801+func TestJobDisplayNameIncludeOnlyMatrix(t *testing.T) {
802+ needTools(t, "bash")
803+ yaml := `on: push
804+jobs:
805+ j:
806+ runs-on: x
807+ strategy:
808+ matrix:
809+ include:
810+ - os: a
811+ n: 1
812+ - os: b
813+ n: 2
814+ steps:
815+ - run: echo hi
816+`
817+ for matrix, want := range map[string]string{`{"os":"b","n":2}`: "j (b, 2)", `{"os":"a","n":1}`: "j (a, 1)"} {
818+ got := setupLog(t, yaml, "j", func(c *Config) {
819+ c.MatrixJSON = matrix
820+ c.ContextsJSON = strings.TrimSuffix(testGitHubContext("j"), "}") + `,"matrix":` + matrix + `}`
821+ })
822+ if !strings.HasSuffix(got, "Complete job name: "+want) {
823+ t.Errorf("%s:\n%s", matrix, got)
824+ }
825+ }
826+}
827+
828+func TestJobDisplayNameFallsBackToTheKeyWithAWarning(t *testing.T) {
829+ needTools(t, "bash")
830+ yaml := `on: push
831+jobs:
832+ j:
833+ runs-on: x
834+ strategy:
835+ matrix:
836+ n: [1, 2]
837+ steps:
838+ - run: echo hi
839+`
840+ var logs bytes.Buffer
841+ got := setupLog(t, yaml, "j", func(c *Config) {
842+ c.MatrixJSON = `{"n":5}`
843+ c.Log = zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})
844+ })
845+ if !strings.HasSuffix(got, "Complete job name: j") {
846+ t.Errorf("got:\n%s", got)
847+ }
848+ if !strings.Contains(logs.String(), `"level":"warn"`) || !strings.Contains(logs.String(), "using the job key") {
849+ t.Errorf("no warning: %q", logs.String())
850+ }
851+}
M internal/executor/step.go +4 -0
@@ -130,6 +130,10 @@ func (j *job) runStep(num int, st *workflow.Step) {
130130 // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
131131 // own, evaluated against the job env.
132132 j.mu.Lock()
133+ // github.action is the id of the step, set before the step's env is
134+ // evaluated (StepsRunner.cs:118), so that the env and the if of a step see
135+ // its own id (the dump step of the recordings reads __run_6, not __run_5).
136+ j.state.SetGitHub("action", st.ID)
133137 jobEnv := j.state.JobEnv()
134138 j.mu.Unlock()
135139 s.env = jobEnv.Clone()
@@ -130,6 +130,10 @@ func (j *job) runStep(num int, st *workflow.Step) {
130 // Environment of the step (StepsRunner.cs:108-139): job env, then the step's130 // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
131 // own, evaluated against the job env.131 // own, evaluated against the job env.
132 j.mu.Lock()132 j.mu.Lock()
133+ // github.action is the id of the step, set before the step's env is
134+ // evaluated (StepsRunner.cs:118), so that the env and the if of a step see
135+ // its own id (the dump step of the recordings reads __run_6, not __run_5).
136+ j.state.SetGitHub("action", st.ID)
133 jobEnv := j.state.JobEnv()137 jobEnv := j.state.JobEnv()
134 j.mu.Unlock()138 j.mu.Unlock()
135 s.env = jobEnv.Clone()139 s.env = jobEnv.Clone()
M internal/guest/jobexec.go +3 -1
@@ -12,6 +12,8 @@ import (
1212 "syscall"
1313 "time"
1414
15+ "github.com/bnema/zerowrap"
16+
1517 "bnema.dev/ermite-runner/internal/execstream"
1618 "bnema.dev/ermite-runner/internal/execstream/execpb"
1719 "bnema.dev/ermite-runner/internal/executor"
@@ -204,7 +206,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
204206 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
205207 RunnerVersion: spec.GetRunnerVersion(),
206208 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
207- Debug: spec.GetDebug(), Reporter: rep,
209+ Debug: spec.GetDebug(), Reporter: rep, Log: zerowrap.FromCtx(ctx),
208210 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
209211 })
210212 if res.Conclusion == executor.ConclusionSuccess {
@@ -12,6 +12,8 @@ import (
12 "syscall"12 "syscall"
13 "time"13 "time"
14 14
15+ "github.com/bnema/zerowrap"
16+
15 "bnema.dev/ermite-runner/internal/execstream"17 "bnema.dev/ermite-runner/internal/execstream"
16 "bnema.dev/ermite-runner/internal/execstream/execpb"18 "bnema.dev/ermite-runner/internal/execstream/execpb"
17 "bnema.dev/ermite-runner/internal/executor"19 "bnema.dev/ermite-runner/internal/executor"
@@ -204,7 +206,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
204 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},206 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
205 RunnerVersion: spec.GetRunnerVersion(),207 RunnerVersion: spec.GetRunnerVersion(),
206 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,208 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
207- Debug: spec.GetDebug(), Reporter: rep,209+ Debug: spec.GetDebug(), Reporter: rep, Log: zerowrap.FromCtx(ctx),
208 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,210 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
209 })211 })
210 if res.Conclusion == executor.ConclusionSuccess {212 if res.Conclusion == executor.ConclusionSuccess {
M internal/host/config.go +37 -3
@@ -20,15 +20,24 @@ import (
2020 // (ERMITE_RUNNER_ISOLATION, _CAPACITY, _IMAGE, _JOB_*) are read by package
2121 // sandbox.
2222 const (
23- EnvURL = "ERMITE_RUNNER_URL"
24- EnvName = "ERMITE_RUNNER_NAME"
25- EnvLabels = "ERMITE_RUNNER_LABELS"
23+ EnvURL = "ERMITE_RUNNER_URL"
24+ EnvName = "ERMITE_RUNNER_NAME"
25+ EnvLabels = "ERMITE_RUNNER_LABELS"
26+ // EnvEnvironment is the runner.environment value that jobs see: see
27+ // Config.Environment.
28+ EnvEnvironment = "ERMITE_RUNNER_ENVIRONMENT"
2629 EnvRegisterToken = "ERMITE_RUNNER_REGISTER_TOKEN"
2730 // EnvInsecureHTTP allows a plain http server URL that is not loopback. The
2831 // runner token and the job tokens then travel in clear: tests only.
2932 EnvInsecureHTTP = "ERMITE_RUNNER_INSECURE_HTTP"
3033 )
3134
35+// The values of Config.Environment, as GitHub spells them.
36+const (
37+ EnvGitHubHosted = "github-hosted"
38+ EnvSelfHosted = "self-hosted"
39+)
40+
3241 // Config is the host part of the runner configuration.
3342 type Config struct {
3443 // URL is the base URL of the ermite server.
@@ -43,6 +52,14 @@ type Config struct {
4352 Capacity int
4453 // Version is the version of the runner binary.
4554 Version string
55+ // Environment is the "runner.environment" value of the jobs and what decides
56+ // whether "Set up job" prints the runner and machine name: EnvGitHubHosted
57+ // (the default) or EnvSelfHosted. The runners of ermite stand in for the
58+ // GitHub-hosted ones: workflows written for github.com test
59+ // runner.environment == 'github-hosted' and the recordings of github.com
60+ // (ermite-conformance) carry that value, so it is the default; an operator
61+ // who wants jobs to see a self-hosted runner sets ERMITE_RUNNER_ENVIRONMENT.
62+ Environment string
4663 // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1).
4764 InsecureHTTP bool
4865 }
@@ -55,6 +72,15 @@ func (c Config) Client(url, token string) ermiteconnect.RunnerServiceClient {
5572 return NewClient(url, token, http.DefaultTransport)
5673 }
5774
75+// environment is Environment, the default when a Config was built without
76+// LoadConfig.
77+func (c Config) environment() string {
78+ if c.Environment == "" {
79+ return EnvGitHubHosted
80+ }
81+ return c.Environment
82+}
83+
5884 var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
5985
6086 // LoadConfig reads the host settings. hostname is the default runner name.
@@ -65,6 +91,14 @@ func LoadConfig(getenv func(string) string, hostname, stateDir string, capacity
6591 if c.Name == "" {
6692 c.Name = sanitizeName(hostname)
6793 }
94+ switch v := strings.ToLower(strings.TrimSpace(getenv(EnvEnvironment))); v {
95+ case "":
96+ c.Environment = EnvGitHubHosted
97+ case EnvGitHubHosted, EnvSelfHosted:
98+ c.Environment = v
99+ default:
100+ return c, fmt.Errorf("%s: %q is not %s or %s", EnvEnvironment, getenv(EnvEnvironment), EnvGitHubHosted, EnvSelfHosted)
101+ }
68102 switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) {
69103 case "", "0", "false", "no":
70104 case "1", "true", "yes":
@@ -20,15 +20,24 @@ import (
20 // (ERMITE_RUNNER_ISOLATION, _CAPACITY, _IMAGE, _JOB_*) are read by package20 // (ERMITE_RUNNER_ISOLATION, _CAPACITY, _IMAGE, _JOB_*) are read by package
21 // sandbox.21 // sandbox.
22 const (22 const (
23- EnvURL = "ERMITE_RUNNER_URL"23+ EnvURL = "ERMITE_RUNNER_URL"
24- EnvName = "ERMITE_RUNNER_NAME"24+ EnvName = "ERMITE_RUNNER_NAME"
25- EnvLabels = "ERMITE_RUNNER_LABELS"25+ EnvLabels = "ERMITE_RUNNER_LABELS"
26+ // EnvEnvironment is the runner.environment value that jobs see: see
27+ // Config.Environment.
28+ EnvEnvironment = "ERMITE_RUNNER_ENVIRONMENT"
26 EnvRegisterToken = "ERMITE_RUNNER_REGISTER_TOKEN"29 EnvRegisterToken = "ERMITE_RUNNER_REGISTER_TOKEN"
27 // EnvInsecureHTTP allows a plain http server URL that is not loopback. The30 // EnvInsecureHTTP allows a plain http server URL that is not loopback. The
28 // runner token and the job tokens then travel in clear: tests only.31 // runner token and the job tokens then travel in clear: tests only.
29 EnvInsecureHTTP = "ERMITE_RUNNER_INSECURE_HTTP"32 EnvInsecureHTTP = "ERMITE_RUNNER_INSECURE_HTTP"
30 )33 )
31 34
35+// The values of Config.Environment, as GitHub spells them.
36+const (
37+ EnvGitHubHosted = "github-hosted"
38+ EnvSelfHosted = "self-hosted"
39+)
40+
32 // Config is the host part of the runner configuration.41 // Config is the host part of the runner configuration.
33 type Config struct {42 type Config struct {
34 // URL is the base URL of the ermite server.43 // URL is the base URL of the ermite server.
@@ -43,6 +52,14 @@ type Config struct {
43 Capacity int52 Capacity int
44 // Version is the version of the runner binary.53 // Version is the version of the runner binary.
45 Version string54 Version string
55+ // Environment is the "runner.environment" value of the jobs and what decides
56+ // whether "Set up job" prints the runner and machine name: EnvGitHubHosted
57+ // (the default) or EnvSelfHosted. The runners of ermite stand in for the
58+ // GitHub-hosted ones: workflows written for github.com test
59+ // runner.environment == 'github-hosted' and the recordings of github.com
60+ // (ermite-conformance) carry that value, so it is the default; an operator
61+ // who wants jobs to see a self-hosted runner sets ERMITE_RUNNER_ENVIRONMENT.
62+ Environment string
46 // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1).63 // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1).
47 InsecureHTTP bool64 InsecureHTTP bool
48 }65 }
@@ -55,6 +72,15 @@ func (c Config) Client(url, token string) ermiteconnect.RunnerServiceClient {
55 return NewClient(url, token, http.DefaultTransport)72 return NewClient(url, token, http.DefaultTransport)
56 }73 }
57 74
75+// environment is Environment, the default when a Config was built without
76+// LoadConfig.
77+func (c Config) environment() string {
78+ if c.Environment == "" {
79+ return EnvGitHubHosted
80+ }
81+ return c.Environment
82+}
83+
58 var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)84 var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
59 85
60 // LoadConfig reads the host settings. hostname is the default runner name.86 // LoadConfig reads the host settings. hostname is the default runner name.
@@ -65,6 +91,14 @@ func LoadConfig(getenv func(string) string, hostname, stateDir string, capacity
65 if c.Name == "" {91 if c.Name == "" {
66 c.Name = sanitizeName(hostname)92 c.Name = sanitizeName(hostname)
67 }93 }
94+ switch v := strings.ToLower(strings.TrimSpace(getenv(EnvEnvironment))); v {
95+ case "":
96+ c.Environment = EnvGitHubHosted
97+ case EnvGitHubHosted, EnvSelfHosted:
98+ c.Environment = v
99+ default:
100+ return c, fmt.Errorf("%s: %q is not %s or %s", EnvEnvironment, getenv(EnvEnvironment), EnvGitHubHosted, EnvSelfHosted)
101+ }
68 switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) {102 switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) {
69 case "", "0", "false", "no":103 case "", "0", "false", "no":
70 case "1", "true", "yes":104 case "1", "true", "yes":
M internal/host/job.go +1 -1
@@ -634,7 +634,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
634634 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,
635635 Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),
636636 DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()),
637- RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: "self-hosted",
637+ RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(),
638638 }}
639639 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
640640 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
@@ -634,7 +634,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
634 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,634 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,
635 Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),635 Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),
636 DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()),636 DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()),
637- RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: "self-hosted",637+ RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(),
638 }}638 }}
639 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{639 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
640 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,640 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
M internal/host/runner_test.go +13 -1
@@ -80,7 +80,7 @@ func TestTheJobTokenAndTheRunnerTokenStayApart(t *testing.T) {
8080 h.run(t)
8181 eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
8282 // The sandbox gets the workflow and contexts, never a token.
83- if sawJobSpec == nil || sawJobSpec.JobKey != "j" || sawJobSpec.RunnerName != "r1" || sawJobSpec.RunnerEnvironment != "self-hosted" || sawJobSpec.TimeoutMinutes != 5 {
83+ if sawJobSpec == nil || sawJobSpec.JobKey != "j" || sawJobSpec.RunnerName != "r1" || sawJobSpec.RunnerEnvironment != "github-hosted" || sawJobSpec.TimeoutMinutes != 5 {
8484 t.Fatalf("%v", sawJobSpec)
8585 }
8686 if strings.Contains(sawJobSpec.String(), "ermitejob_") || strings.Contains(sawJobSpec.String(), "ermiterun_") {
@@ -785,6 +785,18 @@ func TestLoadConfig(t *testing.T) {
785785 if c.URL != "https://ermite.example" || c.Name != "my-host.local" || strings.Join(c.Labels, ",") != "ubuntu-latest,Ubuntu-24.04" || c.Capacity != 2 {
786786 t.Fatalf("%+v", c)
787787 }
788+ if c.Environment != "github-hosted" {
789+ t.Errorf("default environment %q", c.Environment)
790+ }
791+ env[EnvEnvironment] = " Self-Hosted "
792+ if c, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err != nil || c.Environment != "self-hosted" {
793+ t.Errorf("self-hosted: %q, %v", c.Environment, err)
794+ }
795+ env[EnvEnvironment] = "cloud"
796+ if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil || !strings.Contains(err.Error(), EnvEnvironment) {
797+ t.Errorf("an invalid environment was accepted: %v", err)
798+ }
799+ delete(env, EnvEnvironment)
788800 env[EnvName] = "bad name"
789801 if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil {
790802 t.Error("an invalid name was accepted")
@@ -80,7 +80,7 @@ func TestTheJobTokenAndTheRunnerTokenStayApart(t *testing.T) {
80 h.run(t)80 h.run(t)
81 eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })81 eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
82 // The sandbox gets the workflow and contexts, never a token.82 // The sandbox gets the workflow and contexts, never a token.
83- if sawJobSpec == nil || sawJobSpec.JobKey != "j" || sawJobSpec.RunnerName != "r1" || sawJobSpec.RunnerEnvironment != "self-hosted" || sawJobSpec.TimeoutMinutes != 5 {83+ if sawJobSpec == nil || sawJobSpec.JobKey != "j" || sawJobSpec.RunnerName != "r1" || sawJobSpec.RunnerEnvironment != "github-hosted" || sawJobSpec.TimeoutMinutes != 5 {
84 t.Fatalf("%v", sawJobSpec)84 t.Fatalf("%v", sawJobSpec)
85 }85 }
86 if strings.Contains(sawJobSpec.String(), "ermitejob_") || strings.Contains(sawJobSpec.String(), "ermiterun_") {86 if strings.Contains(sawJobSpec.String(), "ermitejob_") || strings.Contains(sawJobSpec.String(), "ermiterun_") {
@@ -785,6 +785,18 @@ func TestLoadConfig(t *testing.T) {
785 if c.URL != "https://ermite.example" || c.Name != "my-host.local" || strings.Join(c.Labels, ",") != "ubuntu-latest,Ubuntu-24.04" || c.Capacity != 2 {785 if c.URL != "https://ermite.example" || c.Name != "my-host.local" || strings.Join(c.Labels, ",") != "ubuntu-latest,Ubuntu-24.04" || c.Capacity != 2 {
786 t.Fatalf("%+v", c)786 t.Fatalf("%+v", c)
787 }787 }
788+ if c.Environment != "github-hosted" {
789+ t.Errorf("default environment %q", c.Environment)
790+ }
791+ env[EnvEnvironment] = " Self-Hosted "
792+ if c, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err != nil || c.Environment != "self-hosted" {
793+ t.Errorf("self-hosted: %q, %v", c.Environment, err)
794+ }
795+ env[EnvEnvironment] = "cloud"
796+ if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil || !strings.Contains(err.Error(), EnvEnvironment) {
797+ t.Errorf("an invalid environment was accepted: %v", err)
798+ }
799+ delete(env, EnvEnvironment)
788 env[EnvName] = "bad name"800 env[EnvName] = "bad name"
789 if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil {801 if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil {
790 t.Error("an invalid name was accepted")802 t.Error("an invalid name was accepted")

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