detached e1538eb

feat(executor): match the github-hosted Set up job log and name the job like GitHub

commit
e1538ebe3bbc3055531548394ae592a04267ac90
author
brice · · 2026-10-12 22:50 UTC
parent
40f1da3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +250 -23
M go.mod +1 -1
@@ -3,7 +3,7 @@ module bnema.dev/ermite-runner
33 go 1.27.1
44
55 require (
6- bnema.dev/ermite-actions v0.0.0-20261012165742-b6c4c3a2f7ff
6+ bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4
77 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
88 connectrpc.com/connect v1.21.0
99 github.com/bnema/zerowrap v1.4.1
@@ -3,7 +3,7 @@ module bnema.dev/ermite-runner
3 go 1.27.13 go 1.27.1
4 4
5 require (5 require (
6- bnema.dev/ermite-actions v0.0.0-20261012165742-b6c4c3a2f7ff6+ bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4
7 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c7 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
8 connectrpc.com/connect v1.21.08 connectrpc.com/connect v1.21.0
9 github.com/bnema/zerowrap v1.4.19 github.com/bnema/zerowrap v1.4.1
M go.sum +2 -2
@@ -1,5 +1,5 @@
1-bnema.dev/ermite-actions v0.0.0-20261012165742-b6c4c3a2f7ff h1:pL7zBg/EGJdrmuXpmHTiVekqDu0bwDFQeYDOZlXF5rI=
2-bnema.dev/ermite-actions v0.0.0-20261012165742-b6c4c3a2f7ff/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
1+bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 h1:3TBeF1oKAan41P2AiU1KLGYc4ZTG5KweCe5klMMIkxk=
2+bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
33 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c h1:XFaUL8e/4reQ/0M3JUwi/4dmTWspxgwXXg3UQKmFoXQ=
44 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
55 buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU=
@@ -1,5 +1,5 @@
1-bnema.dev/ermite-actions v0.0.0-20261012165742-b6c4c3a2f7ff h1:pL7zBg/EGJdrmuXpmHTiVekqDu0bwDFQeYDOZlXF5rI=1+bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 h1:3TBeF1oKAan41P2AiU1KLGYc4ZTG5KweCe5klMMIkxk=
2-bnema.dev/ermite-actions v0.0.0-20261012165742-b6c4c3a2f7ff/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=2+bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c h1:XFaUL8e/4reQ/0M3JUwi/4dmTWspxgwXXg3UQKmFoXQ=3 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c h1:XFaUL8e/4reQ/0M3JUwi/4dmTWspxgwXXg3UQKmFoXQ=
4 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=4 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
5 buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU=5 buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU=
M internal/e2e/chain_test.go +9 -6
@@ -78,9 +78,13 @@ var (
7878 // Machine-dependent parts of the recordings, like the normalisation rules of
7979 // ermite-conformance: random script names and the repository name.
8080 repoRe = regexp.MustCompile(`/work/hello/hello`)
81+ // The runner version moves with each release (rule runner-version of
82+ // ermite-conformance).
83+ versionRe = regexp.MustCompile(`(Current runner version: ')[^']*(')`)
8184 )
8285
8386 func normalize(l string) string {
87+ l = versionRe.ReplaceAllString(l, "${1}<runner-version>${2}")
8488 l = repoRe.ReplaceAllString(l, "/work/<repo>/<repo>")
8589 return uuidRe.ReplaceAllString(l, "<uuid>")
8690 }
@@ -215,8 +219,10 @@ func contains(l []string, s string) bool {
215219 }
216220
217221 // compareJob checks the steps, conclusions, outputs and log lines reported for
218-// a job against the recording. "Set up job" (runner and image details) and the
219-// ermite-dump step that the harness appends to every job are not compared.
222+// a job against the recording. The ermite-dump step that the harness appends to
223+// every job is not compared, and neither are the three groups of "Set up job"
224+// that describe GitHub's fleet and image (ermite-conformance drops them from
225+// the recordings, rule infra-groups): the other lines of "Set up job" are.
220226 func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string) {
221227 if got.Complete == nil {
222228 t.Fatal("the job did not complete")
@@ -258,7 +264,7 @@ func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string)
258264 type es = recStep
259265 var wantSteps, gotSteps []es
260266 for _, s := range want.Steps {
261- if s.Name == "Set up job" || s.Name == "ermite-dump" {
267+ if s.Name == "ermite-dump" {
262268 continue
263269 }
264270 wantSteps = append(wantSteps, s)
@@ -269,9 +275,6 @@ func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string)
269275 }
270276 for _, n := range steps {
271277 u := got.Steps[n]
272- if u.Name == "Set up job" {
273- continue
274- }
275278 if u.State != ermite.StepState_STEP_STATE_COMPLETED || u.StartedAt == nil || u.CompletedAt == nil {
276279 t.Errorf("step %d %q is not a complete snapshot: %v", n, u.Name, u)
277280 }
@@ -78,9 +78,13 @@ var (
78 // Machine-dependent parts of the recordings, like the normalisation rules of78 // Machine-dependent parts of the recordings, like the normalisation rules of
79 // ermite-conformance: random script names and the repository name.79 // ermite-conformance: random script names and the repository name.
80 repoRe = regexp.MustCompile(`/work/hello/hello`)80 repoRe = regexp.MustCompile(`/work/hello/hello`)
81+ // The runner version moves with each release (rule runner-version of
82+ // ermite-conformance).
83+ versionRe = regexp.MustCompile(`(Current runner version: ')[^']*(')`)
81 )84 )
82 85
83 func normalize(l string) string {86 func normalize(l string) string {
87+ l = versionRe.ReplaceAllString(l, "${1}<runner-version>${2}")
84 l = repoRe.ReplaceAllString(l, "/work/<repo>/<repo>")88 l = repoRe.ReplaceAllString(l, "/work/<repo>/<repo>")
85 return uuidRe.ReplaceAllString(l, "<uuid>")89 return uuidRe.ReplaceAllString(l, "<uuid>")
86 }90 }
@@ -215,8 +219,10 @@ func contains(l []string, s string) bool {
215 }219 }
216 220
217 // compareJob checks the steps, conclusions, outputs and log lines reported for221 // compareJob checks the steps, conclusions, outputs and log lines reported for
218-// a job against the recording. "Set up job" (runner and image details) and the222+// a job against the recording. The ermite-dump step that the harness appends to
219-// ermite-dump step that the harness appends to every job are not compared.223+// every job is not compared, and neither are the three groups of "Set up job"
224+// that describe GitHub's fleet and image (ermite-conformance drops them from
225+// the recordings, rule infra-groups): the other lines of "Set up job" are.
220 func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string) {226 func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string) {
221 if got.Complete == nil {227 if got.Complete == nil {
222 t.Fatal("the job did not complete")228 t.Fatal("the job did not complete")
@@ -258,7 +264,7 @@ func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string)
258 type es = recStep264 type es = recStep
259 var wantSteps, gotSteps []es265 var wantSteps, gotSteps []es
260 for _, s := range want.Steps {266 for _, s := range want.Steps {
261- if s.Name == "Set up job" || s.Name == "ermite-dump" {267+ if s.Name == "ermite-dump" {
262 continue268 continue
263 }269 }
264 wantSteps = append(wantSteps, s)270 wantSteps = append(wantSteps, s)
@@ -269,9 +275,6 @@ func compareJob(t *testing.T, got hosttest.Result, want recJob, scenario string)
269 }275 }
270 for _, n := range steps {276 for _, n := range steps {
271 u := got.Steps[n]277 u := got.Steps[n]
272- if u.Name == "Set up job" {
273- continue
274- }
275 if u.State != ermite.StepState_STEP_STATE_COMPLETED || u.StartedAt == nil || u.CompletedAt == nil {278 if u.State != ermite.StepState_STEP_STATE_COMPLETED || u.StartedAt == nil || u.CompletedAt == nil {
276 t.Errorf("step %d %q is not a complete snapshot: %v", n, u.Name, u)279 t.Errorf("step %d %q is not a complete snapshot: %v", n, u.Name, u)
277 }280 }
M internal/executor/contexts.go +5 -0
@@ -127,6 +127,11 @@ type RunnerInfo struct {
127127 Arch string
128128 }
129129
130+// githubHosted reports whether the runner.environment is "github-hosted". An
131+// empty Environment is "self-hosted", the default of the official runner's
132+// runner context.
133+func (r RunnerInfo) githubHosted() bool { return r.Environment == "github-hosted" }
134+
130135 func (r RunnerInfo) arch() string {
131136 if r.Arch != "" {
132137 return r.Arch
@@ -127,6 +127,11 @@ type RunnerInfo struct {
127 Arch string127 Arch string
128 }128 }
129 129
130+// githubHosted reports whether the runner.environment is "github-hosted". An
131+// empty Environment is "self-hosted", the default of the official runner's
132+// runner context.
133+func (r RunnerInfo) githubHosted() bool { return r.Environment == "github-hosted" }
134+
130 func (r RunnerInfo) arch() string {135 func (r RunnerInfo) arch() string {
131 if r.Arch != "" {136 if r.Arch != "" {
132 return r.Arch137 return r.Arch
M internal/executor/corpus_test.go +6 -7
@@ -181,6 +181,7 @@ var (
181181 shellPathRe = regexp.MustCompile(`^shell: \S*/(\w+) `)
182182 startPathRe = regexp.MustCompile(`process '\S*/(\w+)'`)
183183 uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
184+ versionRe = regexp.MustCompile(`(Current runner version: ')[^']*(')`)
184185 )
185186
186187 // normalizeLine maps the machine-dependent parts of a line to the form of the
@@ -190,6 +191,7 @@ var (
190191 func normalizeLine(l, home string) string {
191192 l = shellPathRe.ReplaceAllString(l, "shell: /usr/bin/$1 ")
192193 l = startPathRe.ReplaceAllString(l, "process '/usr/bin/$1'")
194+ l = versionRe.ReplaceAllString(l, "${1}<runner-version>${2}")
193195 l = strings.ReplaceAll(l, home, "/home/runner")
194196 l = strings.ReplaceAll(l, "/work/hello/hello", "/work/<repo>/<repo>")
195197 return uuidRe.ReplaceAllString(l, "<uuid>")
@@ -204,9 +206,9 @@ func shIsBash() bool {
204206 }
205207
206208 // compareWithRecording runs every job without needs of a scenario and checks
207-// that the steps, conclusions and logs equal the recording of github.com. Set
208-// up job (runner and image details) and the dump step the harness appends are
209-// not compared.
209+// that the steps, conclusions and logs equal the recording of github.com. The
210+// dump step the harness appends is not compared (the recordings have no fleet
211+// and image groups in Set up job, rule infra-groups of ermite-conformance).
210212 func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) {
211213 t.Helper()
212214 needTools(t, "bash", "sh")
@@ -243,16 +245,13 @@ func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) {
243245 }
244246 var want []es
245247 for _, s := range jr.Steps {
246- if s.Name == SetUpJobName || s.Name == "ermite-dump" {
248+ if s.Name == "ermite-dump" {
247249 continue
248250 }
249251 want = append(want, es{s.Name, s.Conclusion, s.Log})
250252 }
251253 var have []es
252254 for _, s := range got.steps {
253- if s.Name == SetUpJobName {
254- continue
255- }
256255 var log []string
257256 for _, l := range s.Log {
258257 log = append(log, normalizeLine(l, home))
@@ -181,6 +181,7 @@ var (
181 shellPathRe = regexp.MustCompile(`^shell: \S*/(\w+) `)181 shellPathRe = regexp.MustCompile(`^shell: \S*/(\w+) `)
182 startPathRe = regexp.MustCompile(`process '\S*/(\w+)'`)182 startPathRe = regexp.MustCompile(`process '\S*/(\w+)'`)
183 uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)183 uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
184+ versionRe = regexp.MustCompile(`(Current runner version: ')[^']*(')`)
184 )185 )
185 186
186 // normalizeLine maps the machine-dependent parts of a line to the form of the187 // normalizeLine maps the machine-dependent parts of a line to the form of the
@@ -190,6 +191,7 @@ var (
190 func normalizeLine(l, home string) string {191 func normalizeLine(l, home string) string {
191 l = shellPathRe.ReplaceAllString(l, "shell: /usr/bin/$1 ")192 l = shellPathRe.ReplaceAllString(l, "shell: /usr/bin/$1 ")
192 l = startPathRe.ReplaceAllString(l, "process '/usr/bin/$1'")193 l = startPathRe.ReplaceAllString(l, "process '/usr/bin/$1'")
194+ l = versionRe.ReplaceAllString(l, "${1}<runner-version>${2}")
193 l = strings.ReplaceAll(l, home, "/home/runner")195 l = strings.ReplaceAll(l, home, "/home/runner")
194 l = strings.ReplaceAll(l, "/work/hello/hello", "/work/<repo>/<repo>")196 l = strings.ReplaceAll(l, "/work/hello/hello", "/work/<repo>/<repo>")
195 return uuidRe.ReplaceAllString(l, "<uuid>")197 return uuidRe.ReplaceAllString(l, "<uuid>")
@@ -204,9 +206,9 @@ func shIsBash() bool {
204 }206 }
205 207
206 // compareWithRecording runs every job without needs of a scenario and checks208 // compareWithRecording runs every job without needs of a scenario and checks
207-// that the steps, conclusions and logs equal the recording of github.com. Set209+// that the steps, conclusions and logs equal the recording of github.com. The
208-// up job (runner and image details) and the dump step the harness appends are210+// dump step the harness appends is not compared (the recordings have no fleet
209-// not compared.211+// and image groups in Set up job, rule infra-groups of ermite-conformance).
210 func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) {212 func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) {
211 t.Helper()213 t.Helper()
212 needTools(t, "bash", "sh")214 needTools(t, "bash", "sh")
@@ -243,16 +245,13 @@ func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) {
243 }245 }
244 var want []es246 var want []es
245 for _, s := range jr.Steps {247 for _, s := range jr.Steps {
246- if s.Name == SetUpJobName || s.Name == "ermite-dump" {248+ if s.Name == "ermite-dump" {
247 continue249 continue
248 }250 }
249 want = append(want, es{s.Name, s.Conclusion, s.Log})251 want = append(want, es{s.Name, s.Conclusion, s.Log})
250 }252 }
251 var have []es253 var have []es
252 for _, s := range got.steps {254 for _, s := range got.steps {
253- if s.Name == SetUpJobName {
254- continue
255- }
256 var log []string255 var log []string
257 for _, l := range s.Log {256 for _, l := range s.Log {
258 log = append(log, normalizeLine(l, home))257 log = append(log, normalizeLine(l, home))
M internal/executor/job.go +120 -6
@@ -324,11 +324,17 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
324324 func (j *job) setUp() ([]*workflow.Step, error) {
325325 cfg := j.cfg
326326 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))
327+ // Only a self-hosted runner prints its name and machine (JobExtension.cs:
328+ // 88-98: the runner is identified by its OAuth client id); the recordings
329+ // of github-hosted runners have neither line. The runner group name
330+ // (system.runnerGroupName) is not known to ermite.
331+ if !cfg.Runner.githubHosted() {
332+ if cfg.Runner.Name != "" {
333+ j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))
334+ }
335+ if host, err := os.Hostname(); err == nil {
336+ j.out(1, fmt.Sprintf("Machine name: '%s'", host))
337+ }
332338 }
333339
334340 wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML)
@@ -373,6 +379,8 @@ func (j *job) setUp() ([]*workflow.Step, error) {
373379 return nil, errors.New("not supported yet: jobs.<id>.services")
374380 }
375381
382+ j.printTokenInfo()
383+
376384 // Directories (TempDirectoryManager, PipelineDirectoryManager).
377385 j.out(1, "Prepare workflow directory")
378386 for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} {
@@ -439,9 +447,12 @@ func (j *job) setUp() ([]*workflow.Step, error) {
439447 }
440448
441449 j.out(1, "Prepare all required actions")
450+ // JobExtension.cs:418-422: the display name of the job, which the server
451+ // computes there (message.JobDisplayName). ermite's Job message has no such
452+ // field, so the executor evaluates it (see jobDisplayName).
442453 name := cfg.JobName
443454 if name == "" {
444- name = cfg.JobKey
455+ name = j.jobDisplayName()
445456 }
446457 if strings.TrimSpace(name) != "" {
447458 j.out(1, "Complete job name: "+name)
@@ -552,3 +563,106 @@ func (j *job) completeJob(num int) map[string]string {
552563 rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())
553564 return outputs
554565 }
566+
567+// Defaults of the lines "Set up job" prints between the runner version and
568+// "Prepare workflow directory" (JobExtension.cs:150-182). The server gives
569+// GitHub's runner these as the variables system.github.token.permissions and
570+// actions_cache_mode and the github context key secret_source; ermite's Job
571+// message carries none of them, so the values recorded on github.com for a
572+// push to a public repository are used. Every one of the 221 recorded jobs
573+// has exactly these lines.
574+const (
575+ defaultSecretSource = "Actions"
576+ defaultCacheMode = "write"
577+)
578+
579+// defaultTokenPermissions is the GITHUB_TOKEN of a workflow without a
580+// "permissions" key, as recorded (JobExtension.cs:157-166 prints the
581+// permissions in the order of the server's JSON).
582+var defaultTokenPermissions = [][2]string{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}}
583+
584+// printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source
585+// and the cache mode. A workflow or job that sets "permissions" gets no group:
586+// the effective permissions depend on what the server grants, which the runner
587+// cannot know (it would print the default, which is wrong for such a workflow;
588+// GitHub's runner prints nothing when the server sends none, JobExtension.cs:
589+// 153). To print the real ones, runner.proto's Job would need the effective
590+// GITHUB_TOKEN permissions (scope and level, in display order).
591+func (j *job) printTokenInfo() {
592+ if j.wf.Permissions == nil && j.jb.Permissions == nil {
593+ j.out(1, "##[group]GITHUB_TOKEN Permissions")
594+ for _, p := range defaultTokenPermissions {
595+ j.out(1, p[0]+": "+p[1])
596+ }
597+ j.out(1, "##[endgroup]")
598+ }
599+ // secret_source is a github context key (JobExtension.cs:172); the context
600+ // of a job from ermite lacks it, so it is added with the recorded value.
601+ src := j.state.GitHub("secret_source")
602+ if src == "" {
603+ src = defaultSecretSource
604+ j.state.SetGitHub("secret_source", src)
605+ }
606+ j.out(1, "Secret source: "+src)
607+ j.out(1, "Cache mode: "+defaultCacheMode)
608+}
609+
610+// jobDisplayName is the name of the job as GitHub shows it and the runner
611+// prints it after "Complete job name:": the "name" of the job (the job key when
612+// it has none) evaluated with github, needs, strategy, matrix, vars and inputs
613+// (the contexts the schema allows there), followed by the matrix values in
614+// parentheses unless the name reads the matrix (recorded: matrix-job-name, see
615+// workflow.Job.DisplayName). The combination of this job is the one at
616+// strategy.job-index, else the one whose values equal the matrix. On any
617+// failure the job key is used: the name is not worth failing the job for.
618+func (j *job) jobDisplayName() string {
619+ ctx := context.Background()
620+ all := j.evalEnv(newOrdered())
621+ env := all
622+ env.Contexts = map[string]expr.Value{}
623+ for _, k := range []string{"github", "needs", "strategy", "matrix", "vars", "inputs"} {
624+ if v, ok := all.Contexts[k]; ok {
625+ env.Contexts[k] = v
626+ }
627+ }
628+ // The strategy itself sees neither the strategy nor the matrix context.
629+ senv := env
630+ senv.Contexts = map[string]expr.Value{}
631+ for _, k := range []string{"github", "needs", "vars", "inputs"} {
632+ if v, ok := env.Contexts[k]; ok {
633+ senv.Contexts[k] = v
634+ }
635+ }
636+ st, err := j.jb.EvaluateStrategy(ctx, senv)
637+ if err != nil {
638+ return j.cfg.JobKey
639+ }
640+ idx := 0
641+ if st.HasMatrix {
642+ idx = -1
643+ if d, ok := env.Contexts["strategy"].(*expr.Dictionary); ok {
644+ if n, ok := d.Get("job-index"); ok {
645+ if f, ok := n.(expr.Number); ok && int(f) >= 0 && int(f) < len(st.Combos) {
646+ idx = int(f)
647+ }
648+ }
649+ }
650+ if idx < 0 {
651+ want := expr.ToJSON(env.Contexts["matrix"])
652+ for i, c := range st.Combos {
653+ if expr.ToJSON(c.Values) == want {
654+ idx = i
655+ break
656+ }
657+ }
658+ }
659+ if idx < 0 {
660+ return j.cfg.JobKey
661+ }
662+ }
663+ name, err := j.jb.DisplayName(ctx, env, st, idx)
664+ if err != nil {
665+ return j.cfg.JobKey
666+ }
667+ return name
668+}
@@ -324,11 +324,17 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
324 func (j *job) setUp() ([]*workflow.Step, error) {324 func (j *job) setUp() ([]*workflow.Step, error) {
325 cfg := j.cfg325 cfg := j.cfg
326 j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))326 j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))
327- if cfg.Runner.Name != "" {327+ // Only a self-hosted runner prints its name and machine (JobExtension.cs:
328- j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))328+ // 88-98: the runner is identified by its OAuth client id); the recordings
329- }329+ // of github-hosted runners have neither line. The runner group name
330- if host, err := os.Hostname(); err == nil {330+ // (system.runnerGroupName) is not known to ermite.
331- j.out(1, fmt.Sprintf("Machine name: '%s'", host))331+ if !cfg.Runner.githubHosted() {
332+ if cfg.Runner.Name != "" {
333+ j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))
334+ }
335+ if host, err := os.Hostname(); err == nil {
336+ j.out(1, fmt.Sprintf("Machine name: '%s'", host))
337+ }
332 }338 }
333 339
334 wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML)340 wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML)
@@ -373,6 +379,8 @@ func (j *job) setUp() ([]*workflow.Step, error) {
373 return nil, errors.New("not supported yet: jobs.<id>.services")379 return nil, errors.New("not supported yet: jobs.<id>.services")
374 }380 }
375 381
382+ j.printTokenInfo()
383+
376 // Directories (TempDirectoryManager, PipelineDirectoryManager).384 // Directories (TempDirectoryManager, PipelineDirectoryManager).
377 j.out(1, "Prepare workflow directory")385 j.out(1, "Prepare workflow directory")
378 for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} {386 for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} {
@@ -439,9 +447,12 @@ func (j *job) setUp() ([]*workflow.Step, error) {
439 }447 }
440 448
441 j.out(1, "Prepare all required actions")449 j.out(1, "Prepare all required actions")
450+ // JobExtension.cs:418-422: the display name of the job, which the server
451+ // computes there (message.JobDisplayName). ermite's Job message has no such
452+ // field, so the executor evaluates it (see jobDisplayName).
442 name := cfg.JobName453 name := cfg.JobName
443 if name == "" {454 if name == "" {
444- name = cfg.JobKey455+ name = j.jobDisplayName()
445 }456 }
446 if strings.TrimSpace(name) != "" {457 if strings.TrimSpace(name) != "" {
447 j.out(1, "Complete job name: "+name)458 j.out(1, "Complete job name: "+name)
@@ -552,3 +563,106 @@ func (j *job) completeJob(num int) map[string]string {
552 rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())563 rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())
553 return outputs564 return outputs
554 }565 }
566+
567+// Defaults of the lines "Set up job" prints between the runner version and
568+// "Prepare workflow directory" (JobExtension.cs:150-182). The server gives
569+// GitHub's runner these as the variables system.github.token.permissions and
570+// actions_cache_mode and the github context key secret_source; ermite's Job
571+// message carries none of them, so the values recorded on github.com for a
572+// push to a public repository are used. Every one of the 221 recorded jobs
573+// has exactly these lines.
574+const (
575+ defaultSecretSource = "Actions"
576+ defaultCacheMode = "write"
577+)
578+
579+// defaultTokenPermissions is the GITHUB_TOKEN of a workflow without a
580+// "permissions" key, as recorded (JobExtension.cs:157-166 prints the
581+// permissions in the order of the server's JSON).
582+var defaultTokenPermissions = [][2]string{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}}
583+
584+// printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source
585+// and the cache mode. A workflow or job that sets "permissions" gets no group:
586+// the effective permissions depend on what the server grants, which the runner
587+// cannot know (it would print the default, which is wrong for such a workflow;
588+// GitHub's runner prints nothing when the server sends none, JobExtension.cs:
589+// 153). To print the real ones, runner.proto's Job would need the effective
590+// GITHUB_TOKEN permissions (scope and level, in display order).
591+func (j *job) printTokenInfo() {
592+ if j.wf.Permissions == nil && j.jb.Permissions == nil {
593+ j.out(1, "##[group]GITHUB_TOKEN Permissions")
594+ for _, p := range defaultTokenPermissions {
595+ j.out(1, p[0]+": "+p[1])
596+ }
597+ j.out(1, "##[endgroup]")
598+ }
599+ // secret_source is a github context key (JobExtension.cs:172); the context
600+ // of a job from ermite lacks it, so it is added with the recorded value.
601+ src := j.state.GitHub("secret_source")
602+ if src == "" {
603+ src = defaultSecretSource
604+ j.state.SetGitHub("secret_source", src)
605+ }
606+ j.out(1, "Secret source: "+src)
607+ j.out(1, "Cache mode: "+defaultCacheMode)
608+}
609+
610+// jobDisplayName is the name of the job as GitHub shows it and the runner
611+// prints it after "Complete job name:": the "name" of the job (the job key when
612+// it has none) evaluated with github, needs, strategy, matrix, vars and inputs
613+// (the contexts the schema allows there), followed by the matrix values in
614+// parentheses unless the name reads the matrix (recorded: matrix-job-name, see
615+// workflow.Job.DisplayName). The combination of this job is the one at
616+// strategy.job-index, else the one whose values equal the matrix. On any
617+// failure the job key is used: the name is not worth failing the job for.
618+func (j *job) jobDisplayName() string {
619+ ctx := context.Background()
620+ all := j.evalEnv(newOrdered())
621+ env := all
622+ env.Contexts = map[string]expr.Value{}
623+ for _, k := range []string{"github", "needs", "strategy", "matrix", "vars", "inputs"} {
624+ if v, ok := all.Contexts[k]; ok {
625+ env.Contexts[k] = v
626+ }
627+ }
628+ // The strategy itself sees neither the strategy nor the matrix context.
629+ senv := env
630+ senv.Contexts = map[string]expr.Value{}
631+ for _, k := range []string{"github", "needs", "vars", "inputs"} {
632+ if v, ok := env.Contexts[k]; ok {
633+ senv.Contexts[k] = v
634+ }
635+ }
636+ st, err := j.jb.EvaluateStrategy(ctx, senv)
637+ if err != nil {
638+ return j.cfg.JobKey
639+ }
640+ idx := 0
641+ if st.HasMatrix {
642+ idx = -1
643+ if d, ok := env.Contexts["strategy"].(*expr.Dictionary); ok {
644+ if n, ok := d.Get("job-index"); ok {
645+ if f, ok := n.(expr.Number); ok && int(f) >= 0 && int(f) < len(st.Combos) {
646+ idx = int(f)
647+ }
648+ }
649+ }
650+ if idx < 0 {
651+ want := expr.ToJSON(env.Contexts["matrix"])
652+ for i, c := range st.Combos {
653+ if expr.ToJSON(c.Values) == want {
654+ idx = i
655+ break
656+ }
657+ }
658+ }
659+ if idx < 0 {
660+ return j.cfg.JobKey
661+ }
662+ }
663+ name, err := j.jb.DisplayName(ctx, env, st, idx)
664+ if err != nil {
665+ return j.cfg.JobKey
666+ }
667+ return name
668+}
M internal/executor/job_test.go +107 -1
@@ -31,7 +31,7 @@ func TestImplicitStepsAndNumbering(t *testing.T) {
3131 }
3232 }
3333 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"} {
34+ for _, w := range []string{"Current runner version: 'test'", "Prepare workflow directory", "Prepare all required actions", "Complete job name: j"} {
3535 if !strings.Contains(setup, w) {
3636 t.Errorf("Set up job lacks %q:\n%s", w, setup)
3737 }
@@ -664,3 +664,109 @@ func TestMatrixContextWithoutMatrixIsNull(t *testing.T) {
664664 t.Errorf("%q", got)
665665 }
666666 }
667+
668+func setupLog(t *testing.T, yaml, job string, tweak func(*Config)) string {
669+ t.Helper()
670+ rec, res := runLocal(t, yaml, job, tweak)
671+ if res.Conclusion != ConclusionSuccess {
672+ t.Fatalf("%+v", res)
673+ }
674+ return logOf(rec.steps[0])
675+}
676+
677+func TestSetUpJobMatchesGitHubHosted(t *testing.T) {
678+ needTools(t, "bash")
679+ want := strings.Join([]string{"Current runner version: 'test'", "##[group]GITHUB_TOKEN Permissions", "Contents: read", "Metadata: read",
680+ "Packages: read", "##[endgroup]", "Secret source: Actions", "Cache mode: write", "Prepare workflow directory",
681+ "Prepare all required actions", "Complete job name: j"}, "\n")
682+ if got := setupLog(t, wfHead+" steps:\n - run: echo hi\n", "j", nil); got != want {
683+ t.Errorf("github-hosted:\n%s", got)
684+ }
685+}
686+
687+func TestSetUpJobSelfHostedNamesTheRunner(t *testing.T) {
688+ needTools(t, "bash")
689+ got := setupLog(t, wfHead+" steps:\n - run: echo hi\n", "j", func(c *Config) { c.Runner.Environment = "self-hosted" })
690+ host, _ := os.Hostname()
691+ if !strings.Contains(got, "Runner name: 'test-runner'\nMachine name: '"+host+"'\n##[group]GITHUB_TOKEN") {
692+ t.Errorf("self-hosted:\n%s", got)
693+ }
694+}
695+
696+// Permissions the workflow sets are not the default ones: no group is printed
697+// rather than a wrong one.
698+func TestSetUpJobWithPermissionsPrintsNoDefaultGroup(t *testing.T) {
699+ needTools(t, "bash")
700+ for name, yaml := range map[string]string{
701+ "workflow": "permissions:\n contents: write\n" + wfHead + " steps:\n - run: echo hi\n",
702+ "job": wfHead + " permissions: read-all\n steps:\n - run: echo hi\n",
703+ } {
704+ got := setupLog(t, yaml, "j", nil)
705+ if strings.Contains(got, "GITHUB_TOKEN Permissions") || !strings.Contains(got, "Secret source: Actions\nCache mode: write") {
706+ t.Errorf("%s:\n%s", name, got)
707+ }
708+ }
709+}
710+
711+func TestSetUpJobNamesTheJobLikeGitHub(t *testing.T) {
712+ needTools(t, "bash")
713+ // recordings/matrix-job-name.json
714+ yaml := `on: push
715+jobs:
716+ default-name:
717+ runs-on: x
718+ strategy:
719+ matrix:
720+ os: [a, b]
721+ n: [1, 2]
722+ steps:
723+ - run: echo hi
724+ custom-name:
725+ name: "custom ${{ matrix.os }} / ${{ matrix.n }} / ${{ github.event_name }}"
726+ runs-on: x
727+ strategy:
728+ matrix:
729+ os: [a, b]
730+ n: [1]
731+ steps:
732+ - run: echo hi
733+ static-name:
734+ name: same name for every leg
735+ runs-on: x
736+ strategy:
737+ matrix:
738+ n: [1, 2]
739+ steps:
740+ - run: echo hi
741+ plain:
742+ name: "plain ${{ github.event_name }}"
743+ runs-on: x
744+ steps:
745+ - run: echo hi
746+`
747+ for _, tc := range []struct{ job, matrix, strategy, want string }{
748+ {"default-name", `{"os":"b","n":1}`, `{"job-index":2}`, "default-name (b, 1)"},
749+ {"default-name", `{"os":"a","n":2}`, `{"job-index":1}`, "default-name (a, 2)"},
750+ {"default-name", `{"os":"b","n":2}`, ``, "default-name (b, 2)"},
751+ {"custom-name", `{"os":"b","n":1}`, `{"job-index":1}`, "custom b / 1 / push"},
752+ {"static-name", `{"n":2}`, `{"job-index":1}`, "same name for every leg (2)"},
753+ {"plain", `{}`, ``, "plain push"},
754+ {"static-name", `{"n":9}`, ``, "static-name"}, // not one of the combinations: the job key
755+ } {
756+ t.Run(tc.want, func(t *testing.T) {
757+ got := setupLog(t, yaml, tc.job, func(c *Config) {
758+ c.MatrixJSON = tc.matrix
759+ ctx := testGitHubContext(tc.job)
760+ ctx = strings.TrimSuffix(ctx, "}")
761+ ctx += `,"matrix":` + tc.matrix
762+ if tc.strategy != "" {
763+ ctx += `,"strategy":` + tc.strategy
764+ }
765+ c.ContextsJSON = ctx + "}"
766+ })
767+ if !strings.HasSuffix(got, "Complete job name: "+tc.want) {
768+ t.Errorf("got:\n%s", got)
769+ }
770+ })
771+ }
772+}
@@ -31,7 +31,7 @@ func TestImplicitStepsAndNumbering(t *testing.T) {
31 }31 }
32 }32 }
33 setup := logOf(rec.steps[0])33 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"} {34+ 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) {35 if !strings.Contains(setup, w) {
36 t.Errorf("Set up job lacks %q:\n%s", w, setup)36 t.Errorf("Set up job lacks %q:\n%s", w, setup)
37 }37 }
@@ -664,3 +664,109 @@ func TestMatrixContextWithoutMatrixIsNull(t *testing.T) {
664 t.Errorf("%q", got)664 t.Errorf("%q", got)
665 }665 }
666 }666 }
667+
668+func setupLog(t *testing.T, yaml, job string, tweak func(*Config)) string {
669+ t.Helper()
670+ rec, res := runLocal(t, yaml, job, tweak)
671+ if res.Conclusion != ConclusionSuccess {
672+ t.Fatalf("%+v", res)
673+ }
674+ return logOf(rec.steps[0])
675+}
676+
677+func TestSetUpJobMatchesGitHubHosted(t *testing.T) {
678+ needTools(t, "bash")
679+ want := strings.Join([]string{"Current runner version: 'test'", "##[group]GITHUB_TOKEN Permissions", "Contents: read", "Metadata: read",
680+ "Packages: read", "##[endgroup]", "Secret source: Actions", "Cache mode: write", "Prepare workflow directory",
681+ "Prepare all required actions", "Complete job name: j"}, "\n")
682+ if got := setupLog(t, wfHead+" steps:\n - run: echo hi\n", "j", nil); got != want {
683+ t.Errorf("github-hosted:\n%s", got)
684+ }
685+}
686+
687+func TestSetUpJobSelfHostedNamesTheRunner(t *testing.T) {
688+ needTools(t, "bash")
689+ got := setupLog(t, wfHead+" steps:\n - run: echo hi\n", "j", func(c *Config) { c.Runner.Environment = "self-hosted" })
690+ host, _ := os.Hostname()
691+ if !strings.Contains(got, "Runner name: 'test-runner'\nMachine name: '"+host+"'\n##[group]GITHUB_TOKEN") {
692+ t.Errorf("self-hosted:\n%s", got)
693+ }
694+}
695+
696+// Permissions the workflow sets are not the default ones: no group is printed
697+// rather than a wrong one.
698+func TestSetUpJobWithPermissionsPrintsNoDefaultGroup(t *testing.T) {
699+ needTools(t, "bash")
700+ for name, yaml := range map[string]string{
701+ "workflow": "permissions:\n contents: write\n" + wfHead + " steps:\n - run: echo hi\n",
702+ "job": wfHead + " permissions: read-all\n steps:\n - run: echo hi\n",
703+ } {
704+ got := setupLog(t, yaml, "j", nil)
705+ if strings.Contains(got, "GITHUB_TOKEN Permissions") || !strings.Contains(got, "Secret source: Actions\nCache mode: write") {
706+ t.Errorf("%s:\n%s", name, got)
707+ }
708+ }
709+}
710+
711+func TestSetUpJobNamesTheJobLikeGitHub(t *testing.T) {
712+ needTools(t, "bash")
713+ // recordings/matrix-job-name.json
714+ yaml := `on: push
715+jobs:
716+ default-name:
717+ runs-on: x
718+ strategy:
719+ matrix:
720+ os: [a, b]
721+ n: [1, 2]
722+ steps:
723+ - run: echo hi
724+ custom-name:
725+ name: "custom ${{ matrix.os }} / ${{ matrix.n }} / ${{ github.event_name }}"
726+ runs-on: x
727+ strategy:
728+ matrix:
729+ os: [a, b]
730+ n: [1]
731+ steps:
732+ - run: echo hi
733+ static-name:
734+ name: same name for every leg
735+ runs-on: x
736+ strategy:
737+ matrix:
738+ n: [1, 2]
739+ steps:
740+ - run: echo hi
741+ plain:
742+ name: "plain ${{ github.event_name }}"
743+ runs-on: x
744+ steps:
745+ - run: echo hi
746+`
747+ for _, tc := range []struct{ job, matrix, strategy, want string }{
748+ {"default-name", `{"os":"b","n":1}`, `{"job-index":2}`, "default-name (b, 1)"},
749+ {"default-name", `{"os":"a","n":2}`, `{"job-index":1}`, "default-name (a, 2)"},
750+ {"default-name", `{"os":"b","n":2}`, ``, "default-name (b, 2)"},
751+ {"custom-name", `{"os":"b","n":1}`, `{"job-index":1}`, "custom b / 1 / push"},
752+ {"static-name", `{"n":2}`, `{"job-index":1}`, "same name for every leg (2)"},
753+ {"plain", `{}`, ``, "plain push"},
754+ {"static-name", `{"n":9}`, ``, "static-name"}, // not one of the combinations: the job key
755+ } {
756+ t.Run(tc.want, func(t *testing.T) {
757+ got := setupLog(t, yaml, tc.job, func(c *Config) {
758+ c.MatrixJSON = tc.matrix
759+ ctx := testGitHubContext(tc.job)
760+ ctx = strings.TrimSuffix(ctx, "}")
761+ ctx += `,"matrix":` + tc.matrix
762+ if tc.strategy != "" {
763+ ctx += `,"strategy":` + tc.strategy
764+ }
765+ c.ContextsJSON = ctx + "}"
766+ })
767+ if !strings.HasSuffix(got, "Complete job name: "+tc.want) {
768+ t.Errorf("got:\n%s", got)
769+ }
770+ })
771+ }
772+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close