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