Merge pull request 'feat(runner): match github-hosted runner output and contexts' (#5) from feat/runner-parity into main
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. | |||
| 94 | 94 | | `ERMITE_RUNNER_URL` | base URL of ermite, as in a browser (the runner adds `/-/api` itself) | | |
| 95 | 95 | | `ERMITE_RUNNER_NAME` | runner name (default: the host name) | | |
| 96 | 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 | 98 | | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` (see above) | | |
| 98 | 99 | | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only) | | |
| 99 | 100 | | `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 | |||
| 3 | 3 | go 1.27.1 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | bnema.dev/ermite-actions v0.0.0-20261012165742-b6c4c3a2f7ff | |
| 6 | + | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 | |
| 7 | 7 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | |
| 8 | 8 | connectrpc.com/connect v1.21.0 | |
| 9 | 9 | github.com/bnema/zerowrap v1.4.1 | |
| @@ -3,7 +3,7 @@ module bnema.dev/ermite-runner | |||||
| 3 | go 1.27.1 | 3 | go 1.27.1 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | bnema.dev/ermite-actions v0.0.0-20261012165742-b6c4c3a2f7ff | 6 | + | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 |
| 7 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | 7 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | ||
| 8 | connectrpc.com/connect v1.21.0 | 8 | connectrpc.com/connect v1.21.0 | ||
| 9 | github.com/bnema/zerowrap v1.4.1 | 9 | 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 ( | |||
| 78 | 78 | // Machine-dependent parts of the recordings, like the normalisation rules of | |
| 79 | 79 | // ermite-conformance: random script names and the repository name. | |
| 80 | 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 | 86 | func normalize(l string) string { | |
| 87 | + | l = versionRe.ReplaceAllString(l, "${1}<runner-version>${2}") | |
| 84 | 88 | l = repoRe.ReplaceAllString(l, "/work/<repo>/<repo>") | |
| 85 | 89 | return uuidRe.ReplaceAllString(l, "<uuid>") | |
| 86 | 90 | } | |
| @@ -215,8 +219,10 @@ func contains(l []string, s string) bool { | |||
| 215 | 219 | } | |
| 216 | 220 | ||
| 217 | 221 | // 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. | |
| 220 | 226 | func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string) { | |
| 221 | 227 | if got.Complete == nil { | |
| 222 | 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 | 264 | type es = recStep | |
| 259 | 265 | var wantSteps, gotSteps []es | |
| 260 | 266 | for _, s := range want.Steps { | |
| 261 | - | if s.Name == "Set up job" || s.Name == "ermite-dump" { | |
| 267 | + | if s.Name == "ermite-dump" { | |
| 262 | 268 | continue | |
| 263 | 269 | } | |
| 264 | 270 | wantSteps = append(wantSteps, s) | |
| @@ -269,9 +275,6 @@ func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string) | |||
| 269 | 275 | } | |
| 270 | 276 | for _, n := range steps { | |
| 271 | 277 | u := got.Steps[n] | |
| 272 | - | if u.Name == "Set up job" { | |
| 273 | - | continue | |
| 274 | - | } | |
| 275 | 278 | if u.State != ermite.StepState_STEP_STATE_COMPLETED || u.StartedAt == nil || u.CompletedAt == nil { | |
| 276 | 279 | t.Errorf("step %d %q is not a complete snapshot: %v", n, u.Name, u) | |
| 277 | 280 | } | |
| @@ -78,9 +78,13 @@ var ( | |||||
| 78 | // Machine-dependent parts of the recordings, like the normalisation rules of | 78 | // 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 for | 221 | // 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 | 222 | + | // 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 = recStep | 264 | type es = recStep | ||
| 259 | var wantSteps, gotSteps []es | 265 | 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 | continue | 268 | 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 { | |||
| 57 | 57 | int32 timeout_minutes = 7; | |
| 58 | 58 | string runner_name = 8; | |
| 59 | 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 | 61 | string runner_environment = 10; | |
| 62 | 62 | bool debug = 11; // ACTIONS_STEP_DEBUG | |
| 63 | 63 | // 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_DEBUG | 62 | bool debug = 11; // ACTIONS_STEP_DEBUG | ||
| 63 | // deadline_ms is what is left of the job timeout when the host sends this | 63 | // 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 { | |||
| 77 | 77 | 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 | 109 | func str(d *expr.Dictionary, key string) string { | |
| 81 | 110 | if v, ok := d.Get(key); ok { | |
| 82 | 111 | if s, ok := v.(expr.String); ok { | |
| @@ -127,6 +156,11 @@ type RunnerInfo struct { | |||
| 127 | 156 | 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 | 164 | func (r RunnerInfo) arch() string { | |
| 131 | 165 | if r.Arch != "" { | |
| 132 | 166 | return r.Arch | |
| @@ -163,8 +197,13 @@ type stepCtx struct { | |||
| 163 | 197 | type State struct { | |
| 164 | 198 | layout Layout | |
| 165 | 199 | 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 | |
| 168 | 207 | // other holds needs, strategy, matrix, inputs, vars: copied from the server. | |
| 169 | 208 | other map[string]expr.Value | |
| 170 | 209 | // env is Global.EnvironmentVariables: the job-level environment and what | |
| @@ -216,8 +255,12 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac | |||
| 216 | 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 | 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 | 265 | repo := repoName(str(gh, "repository")) | |
| 223 | 266 | if repo == "" { | |
| @@ -228,7 +271,7 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac | |||
| 228 | 271 | } | |
| 229 | 272 | home := "/home/runner" | |
| 230 | 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 | 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 | 279 | // must be called before the first step. | |
| 237 | 280 | func (s *State) WithLayout(l Layout) { | |
| 238 | 281 | 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 | 283 | s.runner = setKey(s.runner, "temp", expr.String(l.Temp)) | |
| 241 | 284 | s.runner = setKey(s.runner, "tool_cache", expr.String(l.ToolCache)) | |
| 242 | 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 | 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 | 311 | // A context the job does not have is an empty object, like the server's | |
| 261 | 312 | // ("needs": {}, "inputs": {}, "vars": {}); matrix and strategy are null | |
| 262 | 313 | // when absent (recordings: "matrix": null). | |
| @@ -302,7 +353,7 @@ func (s *State) initRest(root *expr.Dictionary, matrixJSON string, r RunnerInfo) | |||
| 302 | 353 | func (s *State) Layout() Layout { return s.layout } | |
| 303 | 354 | ||
| 304 | 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 | 358 | // GitHub returns a string key of the github context ("" when absent). | |
| 308 | 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 | 465 | 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 | 481 | // githubEnvAllowlist is GitHubContext._contextEnvAllowlist (GitHubContext.cs:9). | |
| 418 | 482 | var githubEnvAllowlist = map[string]bool{ | |
| 419 | 483 | "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 out | 77 | 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 string | 156 | 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.Arch | 166 | return r.Arch | ||
| @@ -163,8 +197,13 @@ type stepCtx struct { | |||||
| 163 | type State struct { | 197 | type State struct { | ||
| 164 | layout Layout | 198 | layout Layout | ||
| 165 | github *expr.Dictionary | 199 | github *expr.Dictionary | ||
| 166 | - | runner *expr.Dictionary | 200 | + | // serverGitHub is the github context as the server sent it, before the |
| 167 | - | job *expr.Dictionary | 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 | |||
| 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.Value | 208 | other map[string]expr.Value | ||
| 170 | // env is Global.EnvironmentVariables: the job-level environment and what | 209 | // 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 = l | 281 | 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's | 311 | // A context the job does not have is an empty object, like the server's | ||
| 261 | // ("needs": {}, "inputs": {}, "vars": {}); matrix and strategy are null | 312 | // ("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 m | 465 | 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) { | |||
| 102 | 102 | t.Errorf("context %s missing", k) | |
| 103 | 103 | } | |
| 104 | 104 | } | |
| 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. | |
| 107 | 107 | s2, _, err := NewState(`{"github":{"repository":"o/r"}}`, "{}", "j", RunnerInfo{}, "") | |
| 108 | 108 | if err != nil { | |
| 109 | 109 | t.Fatal(err) | |
| 110 | 110 | } | |
| 111 | 111 | 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"]) | |
| 114 | 122 | } | |
| 115 | 123 | if d, ok := v2["needs"].(*expr.Dictionary); !ok || d.Len() != 0 { | |
| 116 | 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 | + | } | |
| @@ -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 set | 105 | + | // 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 ( | |||
| 181 | 181 | shellPathRe = regexp.MustCompile(`^shell: \S*/(\w+) `) | |
| 182 | 182 | startPathRe = regexp.MustCompile(`process '\S*/(\w+)'`) | |
| 183 | 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 | 187 | // normalizeLine maps the machine-dependent parts of a line to the form of the | |
| @@ -190,6 +191,7 @@ var ( | |||
| 190 | 191 | func normalizeLine(l, home string) string { | |
| 191 | 192 | l = shellPathRe.ReplaceAllString(l, "shell: /usr/bin/$1 ") | |
| 192 | 193 | l = startPathRe.ReplaceAllString(l, "process '/usr/bin/$1'") | |
| 194 | + | l = versionRe.ReplaceAllString(l, "${1}<runner-version>${2}") | |
| 193 | 195 | l = strings.ReplaceAll(l, home, "/home/runner") | |
| 194 | 196 | l = strings.ReplaceAll(l, "/work/hello/hello", "/work/<repo>/<repo>") | |
| 195 | 197 | return uuidRe.ReplaceAllString(l, "<uuid>") | |
| @@ -204,9 +206,9 @@ func shIsBash() bool { | |||
| 204 | 206 | } | |
| 205 | 207 | ||
| 206 | 208 | // 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). | |
| 210 | 212 | func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) { | |
| 211 | 213 | t.Helper() | |
| 212 | 214 | needTools(t, "bash", "sh") | |
| @@ -243,16 +245,13 @@ func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) { | |||
| 243 | 245 | } | |
| 244 | 246 | var want []es | |
| 245 | 247 | for _, s := range jr.Steps { | |
| 246 | - | if s.Name == SetUpJobName || s.Name == "ermite-dump" { | |
| 248 | + | if s.Name == "ermite-dump" { | |
| 247 | 249 | continue | |
| 248 | 250 | } | |
| 249 | 251 | want = append(want, es{s.Name, s.Conclusion, s.Log}) | |
| 250 | 252 | } | |
| 251 | 253 | var have []es | |
| 252 | 254 | for _, s := range got.steps { | |
| 253 | - | if s.Name == SetUpJobName { | |
| 254 | - | continue | |
| 255 | - | } | |
| 256 | 255 | var log []string | |
| 257 | 256 | for _, l := range s.Log { | |
| 258 | 257 | 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 the | 187 | // 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 checks | 208 | // 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 | 209 | + | // 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 are | 210 | + | // 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 []es | 246 | 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 | continue | 249 | 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 []es | 253 | 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 []string | 255 | 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 { | |||
| 91 | 91 | for _, c := range fileCommands { | |
| 92 | 92 | j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)]) | |
| 93 | 93 | } | |
| 94 | - | j.state.SetGitHub("action", st.ID) | |
| 95 | 94 | j.state.SetGitHub("action_repository", "") | |
| 96 | 95 | j.state.SetGitHub("action_ref", "") | |
| 97 | 96 | 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 ( | |||
| 30 | 30 | cmdStepSummary = "step_summary" | |
| 31 | 31 | cmdState = "state" | |
| 32 | 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 | 40 | // fileCommand describes one file command like the IFileCommandExtension of | |
| @@ -47,10 +52,21 @@ var fileCommands = []fileCommand{ | |||
| 47 | 52 | {cmdStepSummary, "step_summary_"}, // CreateStepSummaryCommand (:221) | |
| 48 | 53 | {cmdState, "save_state_"}, // SaveStateFileCommand (:295) | |
| 49 | 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 | 67 | // 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 | |
| 54 | 70 | // (GitHubContext.GetRuntimeEnvironmentVariables, GitHubContext.cs:52). | |
| 55 | 71 | func envVarName(contextName string) string { return "GITHUB_" + strings.ToUpper(contextName) } | |
| 56 | 72 | ||
| @@ -166,6 +182,8 @@ func (f *FileCommands) Process(sink Sink) { | |||
| 166 | 182 | err = processStepSummary(sink, f.root, p) | |
| 167 | 183 | case cmdState: | |
| 168 | 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 | 187 | case cmdOutput: | |
| 170 | 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 | } | |
| @@ -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 of | 40 | // 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_SUMMARY | 68 | + | // 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) { | |||
| 104 | 104 | if err != nil { | |
| 105 | 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 | 108 | p := env[name] | |
| 109 | 109 | if p == "" || filepath.Dir(p) != dir { | |
| 110 | 110 | t.Fatalf("%s = %q", name, p) | |
| @@ -116,6 +116,9 @@ func TestFileCommandsInit(t *testing.T) { | |||
| 116 | 116 | if !strings.HasPrefix(filepath.Base(env["GITHUB_ENV"]), "set_env_") || !strings.HasPrefix(filepath.Base(env["GITHUB_PATH"]), "add_path_") { | |
| 117 | 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 | 122 | // A second Init deletes the previous files and uses new names. | |
| 120 | 123 | env2, _ := fc.Init() | |
| 121 | 124 | 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 ( | |||
| 16 | 16 | "bnema.dev/ermite-actions/expr" | |
| 17 | 17 | "bnema.dev/ermite-actions/template" | |
| 18 | 18 | "bnema.dev/ermite-actions/workflow" | |
| 19 | + | "github.com/bnema/zerowrap" | |
| 19 | 20 | "github.com/google/uuid" | |
| 20 | 21 | ) | |
| 21 | 22 | ||
| @@ -75,6 +76,9 @@ type Config struct { | |||
| 75 | 76 | Debug bool | |
| 76 | 77 | ||
| 77 | 78 | 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 | 83 | // Credential, when set, is the user the steps run as. | |
| 80 | 84 | Credential *syscall.Credential | |
| @@ -324,11 +328,17 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) { | |||
| 324 | 328 | func (j *job) setUp() ([]*workflow.Step, error) { | |
| 325 | 329 | cfg := j.cfg | |
| 326 | 330 | 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 | + | } | |
| 332 | 342 | } | |
| 333 | 343 | ||
| 334 | 344 | wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML) | |
| @@ -373,6 +383,8 @@ func (j *job) setUp() ([]*workflow.Step, error) { | |||
| 373 | 383 | return nil, errors.New("not supported yet: jobs.<id>.services") | |
| 374 | 384 | } | |
| 375 | 385 | ||
| 386 | + | j.printTokenInfo() | |
| 387 | + | ||
| 376 | 388 | // Directories (TempDirectoryManager, PipelineDirectoryManager). | |
| 377 | 389 | j.out(1, "Prepare workflow directory") | |
| 378 | 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 | 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 | 457 | name := cfg.JobName | |
| 443 | 458 | if name == "" { | |
| 444 | - | name = cfg.JobKey | |
| 459 | + | name = j.jobDisplayName() | |
| 445 | 460 | } | |
| 446 | 461 | if strings.TrimSpace(name) != "" { | |
| 447 | 462 | j.out(1, "Complete job name: "+name) | |
| @@ -552,3 +567,139 @@ func (j *job) completeJob(num int) map[string]string { | |||
| 552 | 567 | rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now()) | |
| 553 | 568 | 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 | + | } | |
| @@ -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 bool | 76 | Debug bool | ||
| 76 | 77 | ||||
| 77 | Reporter Reporter | 78 | 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.Credential | 84 | 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.cfg | 329 | 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.JobName | 457 | name := cfg.JobName | ||
| 443 | if name == "" { | 458 | if name == "" { | ||
| 444 | - | name = cfg.JobKey | 459 | + | 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 outputs | 568 | 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 @@ | |||
| 3 | 3 | package executor | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bytes" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "syscall" | |
| 10 | 11 | "testing" | |
| 11 | 12 | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zerowrap" | |
| 12 | 15 | ) | |
| 13 | 16 | ||
| 14 | 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 | 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 | 38 | if !strings.Contains(setup, w) { | |
| 36 | 39 | t.Errorf("Set up job lacks %q:\n%s", w, setup) | |
| 37 | 40 | } | |
| @@ -619,3 +622,230 @@ func TestDeadlineOverridesTheTimeout(t *testing.T) { | |||
| 619 | 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 | + | } | |
| @@ -3,12 +3,15 @@ | |||||
| 3 | package executor | 3 | 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) { | |||
| 130 | 130 | // Environment of the step (StepsRunner.cs:108-139): job env, then the step's | |
| 131 | 131 | // own, evaluated against the job env. | |
| 132 | 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 | 137 | jobEnv := j.state.JobEnv() | |
| 134 | 138 | j.mu.Unlock() | |
| 135 | 139 | 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's | 130 | // 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 ( | |||
| 12 | 12 | "syscall" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/zerowrap" | |
| 16 | + | ||
| 15 | 17 | "bnema.dev/ermite-runner/internal/execstream" | |
| 16 | 18 | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 17 | 19 | "bnema.dev/ermite-runner/internal/executor" | |
| @@ -204,7 +206,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||
| 204 | 206 | Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env}, | |
| 205 | 207 | RunnerVersion: spec.GetRunnerVersion(), | |
| 206 | 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 | 210 | TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, | |
| 209 | 211 | }) | |
| 210 | 212 | 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 ( | |||
| 20 | 20 | // (ERMITE_RUNNER_ISOLATION, _CAPACITY, _IMAGE, _JOB_*) are read by package | |
| 21 | 21 | // sandbox. | |
| 22 | 22 | 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" | |
| 26 | 29 | EnvRegisterToken = "ERMITE_RUNNER_REGISTER_TOKEN" | |
| 27 | 30 | // EnvInsecureHTTP allows a plain http server URL that is not loopback. The | |
| 28 | 31 | // runner token and the job tokens then travel in clear: tests only. | |
| 29 | 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 | 41 | // Config is the host part of the runner configuration. | |
| 33 | 42 | type Config struct { | |
| 34 | 43 | // URL is the base URL of the ermite server. | |
| @@ -43,6 +52,14 @@ type Config struct { | |||
| 43 | 52 | Capacity int | |
| 44 | 53 | // Version is the version of the runner binary. | |
| 45 | 54 | 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 | 63 | // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1). | |
| 47 | 64 | InsecureHTTP bool | |
| 48 | 65 | } | |
| @@ -55,6 +72,15 @@ func (c Config) Client(url, token string) ermiteconnect.RunnerServiceClient { | |||
| 55 | 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 | 84 | var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) | |
| 59 | 85 | ||
| 60 | 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 | 91 | if c.Name == "" { | |
| 66 | 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 | 102 | switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) { | |
| 69 | 103 | case "", "0", "false", "no": | |
| 70 | 104 | case "1", "true", "yes": | |
| @@ -20,15 +20,24 @@ import ( | |||||
| 20 | // (ERMITE_RUNNER_ISOLATION, _CAPACITY, _IMAGE, _JOB_*) are read by package | 20 | // (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. The | 30 | // 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 int | 52 | Capacity int | ||
| 44 | // Version is the version of the runner binary. | 53 | // Version is the version of the runner binary. | ||
| 45 | Version string | 54 | 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 bool | 64 | 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) { | |||
| 634 | 634 | WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey, | |
| 635 | 635 | Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()), | |
| 636 | 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 | 639 | _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{ | |
| 640 | 640 | 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) { | |||
| 80 | 80 | h.run(t) | |
| 81 | 81 | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |
| 82 | 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 | 84 | t.Fatalf("%v", sawJobSpec) | |
| 85 | 85 | } | |
| 86 | 86 | if strings.Contains(sawJobSpec.String(), "ermitejob_") || strings.Contains(sawJobSpec.String(), "ermiterun_") { | |
| @@ -785,6 +785,18 @@ func TestLoadConfig(t *testing.T) { | |||
| 785 | 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 | 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 | 800 | env[EnvName] = "bad name" | |
| 789 | 801 | if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil { | |
| 790 | 802 | 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") | ||