1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "slices"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "syscall"
16 "time"
17
18 "bnema.dev/ermite-actions/expr"
19 "bnema.dev/ermite-actions/template"
20 "bnema.dev/ermite-actions/workflow"
21 "github.com/bnema/zerowrap"
22 "github.com/google/uuid"
23)
24
25// Names of the implicit steps (JobExtension.cs:70 and :15).
26const (
27 SetUpJobName = "Set up job"
28 CompleteJobName = "Complete job"
29)
30
31// DefaultTimeoutMinutes is the timeout of a job without timeout-minutes.
32const DefaultTimeoutMinutes = 360
33
34// MaxOutputBytes is the limit of ermite on the job outputs, names and values
35// together (1 MiB).
36const MaxOutputBytes = 1 << 20
37
38// maxIssueMessageLength is ExecutionContext._maxIssueMessageLength.
39const maxIssueMessageLength = 4096
40
41// Config describes the job to run.
42type Config struct {
43 // WorkflowPath and WorkflowYAML are the workflow file, which the executor
44 // parses itself; JobKey selects the job.
45 WorkflowPath string
46 WorkflowYAML []byte
47 JobKey string
48 // JobName is the display name of the job, for "Complete job name: ...".
49 // Empty means the job key.
50 JobName string
51 // ContextsJSON and MatrixJSON are the fields of the job message.
52 ContextsJSON string
53 MatrixJSON string
54 // Repo is the repository name used when the github context lacks one.
55 Repo string
56 // TimeoutMinutes is the job timeout; 0 means DefaultTimeoutMinutes. It is
57 // used for the message and, without Deadline, for the timer.
58 TimeoutMinutes int
59 // Deadline is the time left of the job timeout when Run is called, when the
60 // timeout started counting earlier (at the receipt of the job by the host);
61 // 0 means TimeoutMinutes from now.
62 Deadline time.Duration
63
64 Runner RunnerInfo
65 RunnerVersion string
66 // Layout overrides the directory layout (tests); nil means the hosted layout
67 // under Home.
68 Layout *Layout
69 // Home is the home directory of the user of the steps; empty means
70 // /home/runner.
71 Home string
72 // ToolCache is RUNNER_TOOL_CACHE; empty means DefaultToolCache. The official
73 // runner reads it from its own environment (HostContext.cs:411).
74 ToolCache string
75 // BaseEnv is the environment steps inherit; nil means os.Environ().
76 BaseEnv []string
77 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
78 Debug bool
79 // Actions configures the actions of the job (resolution, downloads, Node.js,
80 // docker).
81 Actions ActionsConfig
82 // SendAnnotations and SendSummaries say that the server keeps annotations
83 // and step summaries (features ci.annotations and ci.summaries): the
84 // Reporter then gets them with StepResults.
85 SendAnnotations, SendSummaries bool
86 // Getenv reads the environment of the executor (the matcher timeout); nil
87 // means os.Getenv.
88 Getenv func(string) string
89 // StepTimeoutSkew is added to every step timeout (tests use a negative
90 // value to run a timeout of one minute in milliseconds).
91 StepTimeoutSkew time.Duration
92
93 Reporter Reporter
94 // Log receives the runner's own diagnostics (not the job log); the zero value
95 // discards them.
96 Log zerowrap.Logger
97
98 // Credential, when set, is the user the steps run as.
99 Credential *syscall.Credential
100 // Cancellation timings and test hooks, see procSpec.
101 TermAfter, KillAfter, WaitDelay time.Duration
102 Signal func(pid int, sig syscall.Signal)
103 // Now returns the current time; nil means time.Now.
104 Now func() time.Time
105}
106
107type stepResult int
108
109// Values are ordered like TaskResult so that merge picks the worst
110// (TaskResultUtil.MergeTaskResults).
111const (
112 resSucceeded stepResult = iota
113 resFailed
114 resCanceled
115 resSkipped
116)
117
118func (r stepResult) conclusion() Conclusion {
119 switch r {
120 case resFailed:
121 return ConclusionFailure
122 case resCanceled:
123 return ConclusionCancelled
124 case resSkipped:
125 return ConclusionSkipped
126 }
127 return ConclusionSuccess
128}
129
130// merge is TaskResultUtil.MergeTaskResults (Runner.Common/Util/TaskResultUtil.cs:40).
131func merge(cur *stepResult, coming stepResult) stepResult {
132 if cur == nil {
133 return coming
134 }
135 if *cur > resFailed {
136 return *cur
137 }
138 if coming >= *cur {
139 return coming
140 }
141 return *cur
142}
143
144type job struct {
145 cfg Config
146 cancel *Canceller
147 wf *workflow.Workflow
148 jb *workflow.Job
149 fc *FileCommands
150 // tempRoot is RUNNER_TEMP, opened before the first step (see FileCommands).
151 tempRoot *os.Root
152 trackID string
153
154 mu sync.Mutex // serialises the Reporter and guards state and result
155 state *State
156 result *stepResult // nil until a step fails or the job is cancelled (Global result)
157 cancelled bool
158 runDefaults struct{ shell, workingDirectory string }
159 baseEnv []string
160 lookupPath string
161 before map[string]bool
162 // masker hides the secrets in everything reported; it is set once the
163 // contexts are built, before any step runs.
164 masker atomic.Pointer[masker]
165 // actions holds what the pre-job phase loaded (see prepareActions).
166 actions *actionState
167 // postStack is the post steps registered so far, last registered last.
168 postStack []qstep
169 // upgradedToNode24 are the actions that asked for node20 and got node24.
170 upgradedToNode24 []string
171 jobCtx context.Context
172 // cs is the containers of the job (nil container and no services: none).
173 cs *containerSet
174
175 // Results of the commands (see issues.go, matchers.go, results.go).
176 matchers *matcherRoot
177 issuesMu sync.Mutex
178 collectors map[int]*issueCollector // by step number
179 outcomes map[int]Conclusion
180 summaries map[int]string
181 logLines map[int]int // lines written to the log of each step, under mu
182 states map[string]*intraState // intra-action state, see saveState
183}
184
185func (j *job) mask(s string) string { return j.masker.Load().mask(s) }
186
187func (j *job) now() time.Time {
188 if j.cfg.Now != nil {
189 return j.cfg.Now()
190 }
191 return time.Now()
192}
193
194// Run executes one job and reports to cfg.Reporter. It always ends with a
195// JobCompleted call and returns the same Result. cancel may be nil.
196func Run(cancel *Canceller, cfg Config) Result {
197 if cancel == nil {
198 cancel = NewCanceller()
199 }
200 j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString(), matchers: &matcherRoot{}}
201 j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter}
202 defer func() {
203 if j.fc != nil {
204 _ = j.fc.Close()
205 }
206 if j.tempRoot != nil {
207 _ = j.tempRoot.Close()
208 }
209 }()
210 res := j.run()
211 res.Error = j.mask(res.Error)
212 j.cfg.Reporter.JobCompleted(res)
213 return res
214}
215
216func (j *job) run() Result {
217 // Whatever happens below (a panic, a shutdown that skips the post steps),
218 // the containers, the network and the credentials directories are removed.
219 defer j.fallbackCleanup()
220 limit := j.cfg.Deadline
221 if limit <= 0 {
222 limit = time.Duration(j.timeoutMinutes()) * time.Minute
223 }
224 timer := time.AfterFunc(limit, func() { j.cancel.Cancel(CancelTimeout) })
225 defer timer.Stop()
226
227 // ---- Set up job (JobExtension.InitializeJob) ----
228 rep := j.cfg.Reporter
229 rep.StepStarted(1, SetUpJobName, j.now())
230 var cancelCtx context.CancelFunc
231 j.jobCtx, cancelCtx = context.WithCancel(context.Background())
232 defer cancelCtx()
233 go func() {
234 select {
235 case <-j.cancel.Done():
236 cancelCtx()
237 case <-j.jobCtx.Done():
238 }
239 }()
240 steps, err := j.setUp()
241 if err != nil {
242 j.errorf(1, "%s", err.Error())
243 rep.StepCompleted(1, SetUpJobName, ConclusionFailure, j.now())
244 // The job ends here, without "Complete job" (JobRunner.cs:107).
245 return Result{Conclusion: ConclusionFailure, Error: err.Error()}
246 }
247 rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now())
248
249 // ---- the steps (StepsRunner) ----
250 // The queue is the pre-job steps then the steps of the job; the post steps
251 // registered while they run follow, last registered first
252 // (StepsRunner.cs:62-75).
253 num := 2
254 for _, q := range steps {
255 j.runStep(num, q)
256 num++
257 }
258 for {
259 j.mu.Lock()
260 if len(j.postStack) == 0 {
261 j.mu.Unlock()
262 break
263 }
264 q := j.postStack[len(j.postStack)-1]
265 j.postStack = j.postStack[:len(j.postStack)-1]
266 j.mu.Unlock()
267 j.runStep(num, q)
268 num++
269 }
270
271 // ---- Complete job (JobExtension.FinalizeJob) ----
272 outputs := j.completeJob(num)
273
274 return j.finish(outputs)
275}
276
277func (j *job) finish(outputs map[string]string) Result {
278 res := Result{Outputs: outputs}
279 j.mu.Lock()
280 defer j.mu.Unlock()
281 r := resSucceeded
282 if j.result != nil {
283 r = *j.result
284 }
285 switch {
286 case j.cancel.Reason() == CancelTimeout && j.cancelled:
287 res.Conclusion = ConclusionTimedOut
288 res.Error = fmt.Sprintf("The job has exceeded the maximum execution time of %d minutes.", j.timeoutMinutes())
289 case r == resCanceled:
290 res.Conclusion = ConclusionCancelled
291 res.RunnerShutdown = j.cancel.Reason() == CancelShutdown
292 case r == resFailed:
293 res.Conclusion = ConclusionFailure
294 default:
295 res.Conclusion = ConclusionSuccess
296 }
297 return res
298}
299
300func (j *job) timeoutMinutes() int {
301 if j.cfg.TimeoutMinutes > 0 {
302 return j.cfg.TimeoutMinutes
303 }
304 return DefaultTimeoutMinutes
305}
306
307// ---------------------------------------------------------------- logging
308
309// write sends one log line to the Reporter, in order. It returns the 1-based
310// number of the line in the log of the step num, what the runner calls
311// logFileLineNumber (_logger.TotalLines + 1 in ExecutionContext.Write). The
312// steps inside a composite action write to the log of the step that uses it,
313// so they count in the same sequence.
314func (j *job) write(num int, line string) int {
315 j.mu.Lock()
316 defer j.mu.Unlock()
317 if j.logLines == nil {
318 j.logLines = map[int]int{}
319 }
320 n := j.logLines[num] + 1
321 // A line with a line feed in it is several lines of the log (Logging.cs:
322 // 90-101); the host splits the text the same way.
323 j.logLines[num] += 1 + strings.Count(line, "\n")
324 j.cfg.Reporter.Log(num, j.now(), line)
325 return n
326}
327
328// out writes a message that may hold several lines (ExecutionContext.Output).
329func (j *job) out(num int, text string) {
330 for _, l := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
331 j.write(num, l)
332 }
333}
334
335// tagged writes tag+message; only the first line carries the tag. The tags of
336// issues (##[error], ##[warning], ##[notice]) go through AddIssue: they are
337// logged and become annotations, like the runner's IExecutionContext.Error
338// and Warning.
339func (j *job) tagged(num int, tag, msg string) {
340 if level, ok := levelOfTag(tag); ok {
341 j.addIssue(num, nil, false, issue{Level: level, Message: msg})
342 return
343 }
344 j.logTagged(num, tag, j.mask(msg))
345}
346
347func (j *job) errorf(num int, format string, a ...any) {
348 j.tagged(num, "##[error]", fmt.Sprintf(format, a...))
349}
350
351func (j *job) debug(num int, msg string) {
352 if j.cfg.Debug {
353 for _, l := range strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") {
354 j.write(num, "##[debug]"+l)
355 }
356 }
357}
358
359// ---------------------------------------------------------------- set up
360
361// evalEnv is the environment for expressions of the job and step tokens.
362func (j *job) evalEnv(env *ordered) template.Env {
363 return template.Env{
364 Contexts: j.state.Values(env),
365 Funcs: map[string]expr.Func{"hashFiles": j.hashFilesFunc()},
366 }
367}
368
369func (j *job) evalString(tok template.Token, env *ordered) (string, error) {
370 if tok == nil {
371 return "", nil
372 }
373 return template.EvaluateString(context.Background(), tok, j.evalEnv(env))
374}
375
376// evalMap evaluates a mapping of strings (EvaluateStepEnvironment): values
377// are converted like GitHub converts them (null is the empty string).
378func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
379 if m == nil {
380 return nil, nil
381 }
382 v, err := template.Evaluate(context.Background(), m, j.evalEnv(env))
383 if err != nil {
384 return nil, err
385 }
386 d, ok := v.(*expr.Dictionary)
387 if !ok {
388 return nil, nil
389 }
390 var out []Pair
391 for _, p := range d.Pairs() {
392 if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
393 return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k)
394 }
395 out = append(out, Pair{p.Key, expr.CoerceString(p.Value)})
396 }
397 return out, nil
398}
399
400// setUp parses the workflow and prepares the job: JobExtension.InitializeJob
401// (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its
402// log lines belong to step 1.
403func (j *job) setUp() ([]qstep, error) {
404 cfg := j.cfg
405 j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))
406 // Only a self-hosted runner prints its name and machine (JobExtension.cs:
407 // 88-98: the runner is identified by its OAuth client id); the recordings
408 // of github-hosted runners have neither line. The runner group name
409 // (system.runnerGroupName) is not known to ermite.
410 if !cfg.Runner.githubHosted() {
411 if cfg.Runner.Name != "" {
412 j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))
413 }
414 if host, err := os.Hostname(); err == nil {
415 j.out(1, fmt.Sprintf("Machine name: '%s'", host))
416 }
417 }
418
419 wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML)
420 if len(errs) > 0 {
421 var msgs []string
422 for _, e := range errs {
423 msgs = append(msgs, e.Error())
424 }
425 return nil, ghErrorf("The workflow is not valid. %s", strings.Join(msgs, "\n"))
426 }
427 jb := wf.Job(cfg.JobKey)
428 if jb == nil {
429 return nil, ghErrorf("The workflow has no job '%s'.", cfg.JobKey)
430 }
431 j.wf, j.jb = wf, jb
432
433 state, layout, err := NewState(cfg.ContextsJSON, cfg.MatrixJSON, cfg.JobKey, cfg.Runner, cfg.Repo)
434 if err != nil {
435 return nil, err
436 }
437 switch {
438 case cfg.Layout != nil:
439 state.WithLayout(*cfg.Layout)
440 layout = *cfg.Layout
441 case (cfg.Home != "" && cfg.Home != layout.Home) || cfg.ToolCache != "":
442 home := cfg.Home
443 if home == "" {
444 home = layout.Home
445 }
446 layout = NewLayout(home, filepath.Base(layout.Workspace), cfg.ToolCache)
447 state.WithLayout(layout)
448 }
449 j.state = state
450 j.masker.Store(newMasker(state.Secrets()...))
451
452 // Features that arrive in later phases fail the job with a clear message.
453 switch {
454 case jb.IsReusable():
455 return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)")
456 }
457
458 j.printTokenInfo()
459
460 // Directories (TempDirectoryManager, PipelineDirectoryManager).
461 j.out(1, "Prepare workflow directory")
462 for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} {
463 if err := os.MkdirAll(d, 0o755); err != nil {
464 return nil, fmt.Errorf("create %s: %w", d, err)
465 }
466 }
467 clearDir(layout.Temp)
468 if err := os.MkdirAll(layout.Temp, 0o755); err != nil {
469 return nil, err
470 }
471 fc, err := NewFileCommands(filepath.Join(layout.Temp, "_runner_file_commands"))
472 if err != nil {
473 return nil, err
474 }
475 if c := cfg.Credential; c != nil {
476 fc.SetOwner(int(c.Uid), int(c.Gid))
477 }
478 j.fc = fc
479 // Everything the executor writes below the temp directory later goes through
480 // this root (see FileCommands): the user of the steps owns the directory.
481 if j.tempRoot, err = os.OpenRoot(layout.Temp); err != nil {
482 return nil, err
483 }
484 for _, d := range []string{layout.WorkRoot, layout.RunnerWorkspace, layout.Workspace, layout.Temp, fc.dir} {
485 j.own(d)
486 }
487
488 j.baseEnv = cfg.BaseEnv
489 if j.baseEnv == nil {
490 j.baseEnv = os.Environ()
491 }
492 if !hasEnv(j.baseEnv, "HOME") {
493 j.baseEnv = append(append([]string(nil), j.baseEnv...), "HOME="+layout.Home)
494 }
495 j.lookupPath = envValue(j.baseEnv, "PATH")
496
497 // Job-level environment: workflow env, then job env, each evaluated with an
498 // empty env context (JobExtension.cs:38-49, ExecutionContext.cs:1048).
499 for _, m := range []*template.Mapping{wf.Env, jb.Env} {
500 pairs, err := j.evalMap(m, newOrdered())
501 if err != nil {
502 return nil, err
503 }
504 for _, p := range pairs {
505 j.state.SetEnv(p.Key, p.Value)
506 }
507 }
508
509 // defaults.run: a job-level "run" replaces the workflow's whole (JobExtension.cs:78-96).
510 for _, d := range []*workflow.Defaults{wf.Defaults, jb.Defaults} {
511 if d == nil || d.Run == nil {
512 continue
513 }
514 shell, err := j.evalString(d.Run.Shell, newOrdered())
515 if err != nil {
516 return nil, err
517 }
518 wd, err := j.evalString(d.Run.WorkingDirectory, newOrdered())
519 if err != nil {
520 return nil, err
521 }
522 j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd
523 }
524
525 // JobExtension.cs:250-275: the job container and the services are
526 // evaluated after the job environment, before the actions are prepared.
527 j.debug(1, "Evaluating job container")
528 j.debug(1, "Evaluating job service containers")
529 jc, svcs, err := j.evalContainers()
530 if err != nil {
531 return nil, err
532 }
533 if jc != nil || len(svcs) > 0 {
534 j.cs = &containerSet{job: jc, services: svcs}
535 }
536
537 j.out(1, "Prepare all required actions")
538 queue, err := j.prepareJob()
539 if err != nil {
540 return nil, err
541 }
542 // JobExtension.cs:418-422: the display name of the job, which the server
543 // computes there (message.JobDisplayName). ermite's Job message has no such
544 // field, so the executor evaluates it (see jobDisplayName).
545 name := cfg.JobName
546 if name == "" {
547 name = j.jobDisplayName()
548 }
549 if strings.TrimSpace(name) != "" {
550 j.out(1, "Complete job name: "+name)
551 }
552
553 // Process tracking for the orphan cleanup of "Complete job".
554 j.before = map[string]bool{}
555 for _, p := range snapshotProcesses() {
556 j.before[p.key()] = true
557 }
558 return queue, nil
559}
560
561// own gives a file or directory created by the executor to the user the steps
562// run as, so that scripts can write to RUNNER_TEMP, the workspace and the file
563// command files. Best effort: without Credential nothing is done.
564func (j *job) own(path string) {
565 if c := j.cfg.Credential; c != nil {
566 _ = os.Lchown(path, int(c.Uid), int(c.Gid))
567 }
568}
569
570func clearDir(dir string) {
571 ents, err := os.ReadDir(dir)
572 if err != nil {
573 return
574 }
575 for _, e := range ents {
576 _ = os.RemoveAll(filepath.Join(dir, e.Name()))
577 }
578}
579
580func hasEnv(env []string, key string) bool {
581 for _, kv := range env {
582 if strings.HasPrefix(kv, key+"=") {
583 return true
584 }
585 }
586 return false
587}
588
589func envValue(env []string, key string) string {
590 v := ""
591 for _, kv := range env {
592 if k, val, ok := strings.Cut(kv, "="); ok && k == key {
593 v = val
594 }
595 }
596 return v
597}
598
599// ---------------------------------------------------------------- complete
600
601// completeJob is JobExtension.FinalizeJob (JobExtension.cs:9-190): job outputs
602// and the cleanup of orphan processes.
603func (j *job) completeJob(num int) map[string]string {
604 rep := j.cfg.Reporter
605 rep.StepStarted(num, CompleteJobName, j.now())
606 result := resSucceeded
607 outputs := map[string]string{}
608
609 if j.jb.Outputs != nil {
610 // The runner of the official service prints nothing here: the recordings
611 // of github.com show only "Cleaning up orphan processes" in "Complete
612 // job", although the jobs have outputs (the log lines of
613 // JobExtension.cs:48,68 belong to the legacy message that carries
614 // JobOutputs).
615 j.mu.Lock()
616 env := j.state.JobEnv()
617 j.mu.Unlock()
618 pairs, err := j.evalMap(j.jb.Outputs, env)
619 if err != nil {
620 result = resFailed
621 j.errorf(num, "Fail to evaluate job outputs")
622 j.errorf(num, "%s", err.Error())
623 j.mu.Lock()
624 m := merge(j.result, resFailed)
625 j.result = &m
626 j.mu.Unlock()
627 }
628 total := 0
629 for _, p := range pairs {
630 if p.Value == "" {
631 j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key))
632 continue
633 }
634 // An output that holds a secret is dropped (JobExtension.cs:819-824).
635 if j.masker.Load().has(p.Value) {
636 j.tagged(num, "##[warning]", fmt.Sprintf("Skip output '%s' since it may contain secret.", p.Key))
637 continue
638 }
639 // ermite refuses outputs above 1 MiB in total (runner.proto,
640 // CompleteJobRequest.outputs), and they would not fit a frame of the
641 // stream either: the job fails without outputs.
642 if total += len(p.Key) + len(p.Value); total > MaxOutputBytes {
643 result = resFailed
644 j.errorf(num, "Fail to evaluate job outputs")
645 j.errorf(num, "The job outputs are larger than 1 MiB in total.")
646 j.mu.Lock()
647 m := merge(j.result, resFailed)
648 j.result = &m
649 j.mu.Unlock()
650 outputs = map[string]string{}
651 break
652 }
653 outputs[p.Key] = p.Value
654 }
655 }
656
657 j.out(num, "Cleaning up orphan processes")
658 killOrphans(j.trackID, j.before, func(l string) { j.out(num, l) })
659
660 // The actions that asked for node20 and got node24 (JobExtension.cs:1043-1049).
661 j.mu.Lock()
662 up := slices.SortedFunc(slices.Values(j.upgradedToNode24), func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })
663 j.mu.Unlock()
664 if len(up) > 0 {
665 j.tagged(num, "##[warning]", "Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: "+
666 strings.Join(up, ", ")+". For more information see: "+node20DeprecationURL)
667 }
668
669 rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())
670 return outputs
671}
672
673// Defaults of the lines "Set up job" prints between the runner version and
674// "Prepare workflow directory" (JobExtension.cs:150-182). The server gives
675// GitHub's runner these as the variables system.github.token.permissions and
676// actions_cache_mode and the github context key secret_source; ermite's Job
677// message carries none of them, so the values recorded on github.com for a
678// push to a public repository are used. Every one of the 221 recorded jobs
679// has exactly these lines.
680const (
681 defaultSecretSource = "Actions"
682 defaultCacheMode = "write"
683)
684
685// printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source
686// and the cache mode.
687//
688// GitHub's runner prints what the server sends as
689// system.github.token.permissions (JobExtension.cs:154-166); ermite's Job
690// message does not carry it, so the group is worked out from the workflow
691// with the rules recorded on github.com (tokenPermissionLines). It is always
692// printed: Metadata is listed even for "permissions: {}".
693func (j *job) printTokenInfo() {
694 j.out(1, "##[group]GITHUB_TOKEN Permissions")
695 for _, p := range tokenPermissionLines(j.wf, j.jb) {
696 j.out(1, p.name+": "+p.level)
697 }
698 j.out(1, "##[endgroup]")
699 // secret_source is a github context key (JobExtension.cs:172); the context
700 // of a job from ermite lacks it, so it is added with the recorded value.
701 src := j.state.GitHub("secret_source")
702 if src == "" {
703 src = defaultSecretSource
704 j.state.SetGitHub("secret_source", src)
705 }
706 j.out(1, "Secret source: "+src)
707 j.out(1, "Cache mode: "+defaultCacheMode)
708}
709
710// jobDisplayName is the name of the job as GitHub shows it and the runner
711// prints it after "Complete job name:": the "name" of the job (the job key when
712// it has none) evaluated with github, needs, strategy, matrix, vars and inputs
713// as the server has them (State.ServerContexts: github without the keys the
714// runner adds), followed by the matrix values in parentheses unless the name
715// reads the matrix (recorded: matrix-job-name, see workflow.Job.DisplayName).
716// When the name cannot be worked out the job key is used and a warning is
717// logged: the name is not worth failing the job for.
718func (j *job) jobDisplayName() string {
719 ctx := context.Background()
720 fallback := func(why string, err error) string {
721 j.cfg.Log.Warn().Err(err).Str("job_key", j.cfg.JobKey).Msg("job display name: " + why + ", using the job key")
722 return j.cfg.JobKey
723 }
724 j.mu.Lock()
725 all := j.state.ServerContexts()
726 j.mu.Unlock()
727 env := j.evalEnv(newOrdered())
728 env.Contexts = all
729 // The strategy itself sees neither the strategy nor the matrix context.
730 senv := env
731 senv.Contexts = map[string]expr.Value{}
732 for _, k := range []string{"github", "needs", "vars", "inputs"} {
733 senv.Contexts[k] = all[k]
734 }
735 st, err := j.jb.EvaluateStrategy(ctx, senv)
736 if err != nil {
737 return fallback("the strategy does not evaluate", err)
738 }
739 idx, ok := comboIndex(st, all["strategy"], all["matrix"])
740 if !ok {
741 return fallback("the matrix of the job is not one of the combinations of its strategy", nil)
742 }
743 name, err := j.jb.DisplayName(ctx, env, st, idx)
744 if err != nil {
745 return fallback("the name does not evaluate", err)
746 }
747 // GitHub shows "Display name of", not "Display name of " (recorded:
748 // matrix-include-exclude, an empty last matrix value). The recordings only
749 // show trailing spaces; both ends are trimmed, as ermite's server does.
750 return strings.TrimSpace(name)
751}
752
753// comboIndex finds the combination of the strategy that is this job. Without a
754// matrix it is the only one. The index of the strategy context is trusted only
755// when that combination has the matrix of the job; otherwise the first
756// combination with equal values is taken. ok is false when there is none.
757func comboIndex(st *workflow.Strategy, strategyCtx, matrix expr.Value) (idx int, ok bool) {
758 if !st.HasMatrix {
759 return 0, true
760 }
761 m, isDict := matrix.(*expr.Dictionary)
762 if !isDict {
763 return 0, false
764 }
765 if d, isDict := strategyCtx.(*expr.Dictionary); isDict {
766 if n, found := d.Get("job-index"); found {
767 if f, isNum := n.(expr.Number); isNum && f >= 0 && int(f) < len(st.Combos) && sameMatrix(st.Combos[int(f)].Values, m) {
768 return int(f), true
769 }
770 }
771 }
772 for i, c := range st.Combos {
773 if sameMatrix(c.Values, m) {
774 return i, true
775 }
776 }
777 return 0, false
778}
779
780// sameMatrix compares two matrix values: the same keys (ignoring case and
781// order) with equal values.
782func sameMatrix(a, b *expr.Dictionary) bool {
783 if a.Len() != b.Len() {
784 return false
785 }
786 for _, p := range a.Pairs() {
787 v, found := b.Get(p.Key)
788 if !found || expr.ToJSON(v) != expr.ToJSON(p.Value) {
789 return false
790 }
791 }
792 return true
793}