detached 760ddf2

refactor(executor): split steps.go into job.go, step.go and exec_step.go (pure move)

commit
760ddf2848d4d7070a517909ed43bafe3b09154c
author
brice · · 2026-10-12 19:21 UTC
parent
96d8ce3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +462 -439
A internal/executor/exec_step.go +185 -0
@@ -0,0 +1,185 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+)
12+
13+// execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
14+func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) (result stepResult) {
15+ num, st := s.num, s.st
16+ if msg := notSupported(st); msg != "" {
17+ if st.Uses != nil {
18+ j.out(num, "##[group]Run "+st.Uses.Value)
19+ j.out(num, "##[endgroup]")
20+ }
21+ j.errorf(num, "%s", msg)
22+ return resFailed
23+ }
24+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
25+
26+ // File commands for this step: the github context carries the paths
27+ // (FileCommandManager.InitializeFiles), event.json is written
28+ // (ExecutionContext.WriteWebhookPayload).
29+ j.mu.Lock()
30+ files, ferr := j.fc.Init()
31+ if ferr == nil {
32+ for _, c := range fileCommands {
33+ j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
34+ }
35+ j.state.SetGitHub("action", st.ID)
36+ j.state.SetGitHub("action_repository", "")
37+ j.state.SetGitHub("action_ref", "")
38+ if ev := j.state.EventJSON(); ev != "" {
39+ dir := filepath.Join(j.state.Layout().Temp, "_github_workflow")
40+ if err := os.MkdirAll(dir, 0o755); err == nil {
41+ j.own(dir)
42+ p := filepath.Join(dir, "event.json")
43+ if err := os.WriteFile(p, []byte(ev), 0o644); err == nil {
44+ j.own(p)
45+ j.state.SetGitHub("event_path", p)
46+ }
47+ }
48+ }
49+ }
50+ j.mu.Unlock()
51+ if ferr != nil {
52+ return fail(ferr)
53+ }
54+ defer func() {
55+ // ProcessFiles runs after the handler, even when it failed (finally).
56+ j.fc.Process(s)
57+ }()
58+
59+ // Inputs (ActionRunner.cs:174-185).
60+ script, err := j.evalString(st.Run, s.env)
61+ if err != nil {
62+ return fail(err)
63+ }
64+ shellInput, err := j.evalString(st.Shell, s.env)
65+ if err != nil {
66+ return fail(err)
67+ }
68+ var wdInput string
69+ var hasWD bool
70+ if st.WorkingDirectory != nil {
71+ hasWD = true
72+ if wdInput, err = j.evalString(st.WorkingDirectory, s.env); err != nil {
73+ return fail(err)
74+ }
75+ }
76+
77+ // Header (ScriptHandler.PrintActionDetails, ScriptHandler.cs:27-137).
78+ j.out(num, "##[group]Run "+firstLine(script))
79+ for _, l := range strings.Split(strings.TrimRight(strings.ReplaceAll(script, "\r\n", "\n"), "\n"), "\n") {
80+ j.write(num, "\x1b[36;1m"+l+"\x1b[0m") // bright cyan
81+ }
82+ shell := shellInput
83+ if shell == "" {
84+ shell = j.runDefaults.shell
85+ }
86+ j.mu.Lock()
87+ lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}
88+ j.mu.Unlock()
89+ plan, err := PlanShell(shell, lookup)
90+ if err != nil {
91+ return fail(err) // the group stays open, like the original
92+ }
93+ j.out(num, "shell: "+plan.ShellLine())
94+ if s.env.Len() > 0 {
95+ j.write(num, "env:")
96+ for _, k := range s.env.Keys() {
97+ v, _ := s.env.Get(k)
98+ j.out(num, fmt.Sprintf(" %s: %s", k, v))
99+ }
100+ }
101+ j.write(num, "##[endgroup]")
102+
103+ // Run (ScriptHandler.RunAsync, :140-358).
104+ wd := wdInput
105+ if !hasWD {
106+ wd = j.runDefaults.workingDirectory
107+ }
108+ j.mu.Lock()
109+ workspace := j.state.GitHub("workspace")
110+ temp := j.state.Layout().Temp
111+ j.mu.Unlock()
112+ dir := ResolveWorkingDirectory(workspace, wd)
113+ sc, err := WriteScript(temp, plan, script)
114+ if err != nil {
115+ return fail(err)
116+ }
117+
118+ // Process environment: the inherited one, the step's environment, PATH with
119+ // GITHUB_PATH in front, then the runtime variables of the contexts.
120+ j.mu.Lock()
121+ runtime := j.state.RuntimeEnv()
122+ prepend := j.state.Prepend()
123+ j.mu.Unlock()
124+ penv := newOrdered()
125+ for _, kv := range j.baseEnv {
126+ if k, v, ok := strings.Cut(kv, "="); ok {
127+ penv.Set(k, v)
128+ }
129+ }
130+ for _, k := range s.env.Keys() {
131+ v, _ := s.env.Get(k)
132+ penv.Set(k, v)
133+ }
134+ if len(prepend) > 0 {
135+ orig, ok := s.env.Get("PATH")
136+ if !ok {
137+ orig = j.lookupPath
138+ }
139+ penv.Set("PATH", PrependedPath(prepend, orig))
140+ }
141+ for _, k := range runtime.Keys() {
142+ v, _ := runtime.Get(k)
143+ penv.Set(k, v)
144+ }
145+ penv.Set("GITHUB_ACTIONS", "true") // ProcessInvoker.cs:283
146+ if _, ok := penv.Get("CI"); !ok {
147+ penv.Set("CI", "true")
148+ }
149+ penv.Set(ProcessTrackingID, j.trackID)
150+ var envList []string
151+ for _, k := range penv.Keys() {
152+ v, _ := penv.Get(k)
153+ envList = append(envList, k+"="+v)
154+ }
155+
156+ res, err := runProcess(procSpec{
157+ Argv: sc.Argv, Dir: dir, Env: envList, Credential: j.cfg.Credential,
158+ OnLine: func(line string) {
159+ if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") {
160+ line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`)
161+ }
162+ if (strings.Contains(line, "##[") || strings.Contains(line, "::")) && s.cmds.TryProcess(line) {
163+ return
164+ }
165+ j.write(num, line)
166+ },
167+ Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter,
168+ WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
169+ })
170+ if err != nil {
171+ return fail(errors.New(startError(plan.CommandPath, dir, err)))
172+ }
173+ if res.Cancelled {
174+ // The process was stopped on cancellation: ExecuteAsync throws
175+ // (ProcessInvoker.cs:369) and StepsRunner logs the exception
176+ // (StepsRunner.cs:333-337).
177+ j.errorf(num, "The operation was canceled.")
178+ return resCanceled
179+ }
180+ if res.ExitCode != 0 {
181+ j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
182+ return resFailed
183+ }
184+ return resSucceeded
185+}
@@ -0,0 +1,185 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+)
12+
13+// execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
14+func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) (result stepResult) {
15+ num, st := s.num, s.st
16+ if msg := notSupported(st); msg != "" {
17+ if st.Uses != nil {
18+ j.out(num, "##[group]Run "+st.Uses.Value)
19+ j.out(num, "##[endgroup]")
20+ }
21+ j.errorf(num, "%s", msg)
22+ return resFailed
23+ }
24+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
25+
26+ // File commands for this step: the github context carries the paths
27+ // (FileCommandManager.InitializeFiles), event.json is written
28+ // (ExecutionContext.WriteWebhookPayload).
29+ j.mu.Lock()
30+ files, ferr := j.fc.Init()
31+ if ferr == nil {
32+ for _, c := range fileCommands {
33+ j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
34+ }
35+ j.state.SetGitHub("action", st.ID)
36+ j.state.SetGitHub("action_repository", "")
37+ j.state.SetGitHub("action_ref", "")
38+ if ev := j.state.EventJSON(); ev != "" {
39+ dir := filepath.Join(j.state.Layout().Temp, "_github_workflow")
40+ if err := os.MkdirAll(dir, 0o755); err == nil {
41+ j.own(dir)
42+ p := filepath.Join(dir, "event.json")
43+ if err := os.WriteFile(p, []byte(ev), 0o644); err == nil {
44+ j.own(p)
45+ j.state.SetGitHub("event_path", p)
46+ }
47+ }
48+ }
49+ }
50+ j.mu.Unlock()
51+ if ferr != nil {
52+ return fail(ferr)
53+ }
54+ defer func() {
55+ // ProcessFiles runs after the handler, even when it failed (finally).
56+ j.fc.Process(s)
57+ }()
58+
59+ // Inputs (ActionRunner.cs:174-185).
60+ script, err := j.evalString(st.Run, s.env)
61+ if err != nil {
62+ return fail(err)
63+ }
64+ shellInput, err := j.evalString(st.Shell, s.env)
65+ if err != nil {
66+ return fail(err)
67+ }
68+ var wdInput string
69+ var hasWD bool
70+ if st.WorkingDirectory != nil {
71+ hasWD = true
72+ if wdInput, err = j.evalString(st.WorkingDirectory, s.env); err != nil {
73+ return fail(err)
74+ }
75+ }
76+
77+ // Header (ScriptHandler.PrintActionDetails, ScriptHandler.cs:27-137).
78+ j.out(num, "##[group]Run "+firstLine(script))
79+ for _, l := range strings.Split(strings.TrimRight(strings.ReplaceAll(script, "\r\n", "\n"), "\n"), "\n") {
80+ j.write(num, "\x1b[36;1m"+l+"\x1b[0m") // bright cyan
81+ }
82+ shell := shellInput
83+ if shell == "" {
84+ shell = j.runDefaults.shell
85+ }
86+ j.mu.Lock()
87+ lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}
88+ j.mu.Unlock()
89+ plan, err := PlanShell(shell, lookup)
90+ if err != nil {
91+ return fail(err) // the group stays open, like the original
92+ }
93+ j.out(num, "shell: "+plan.ShellLine())
94+ if s.env.Len() > 0 {
95+ j.write(num, "env:")
96+ for _, k := range s.env.Keys() {
97+ v, _ := s.env.Get(k)
98+ j.out(num, fmt.Sprintf(" %s: %s", k, v))
99+ }
100+ }
101+ j.write(num, "##[endgroup]")
102+
103+ // Run (ScriptHandler.RunAsync, :140-358).
104+ wd := wdInput
105+ if !hasWD {
106+ wd = j.runDefaults.workingDirectory
107+ }
108+ j.mu.Lock()
109+ workspace := j.state.GitHub("workspace")
110+ temp := j.state.Layout().Temp
111+ j.mu.Unlock()
112+ dir := ResolveWorkingDirectory(workspace, wd)
113+ sc, err := WriteScript(temp, plan, script)
114+ if err != nil {
115+ return fail(err)
116+ }
117+
118+ // Process environment: the inherited one, the step's environment, PATH with
119+ // GITHUB_PATH in front, then the runtime variables of the contexts.
120+ j.mu.Lock()
121+ runtime := j.state.RuntimeEnv()
122+ prepend := j.state.Prepend()
123+ j.mu.Unlock()
124+ penv := newOrdered()
125+ for _, kv := range j.baseEnv {
126+ if k, v, ok := strings.Cut(kv, "="); ok {
127+ penv.Set(k, v)
128+ }
129+ }
130+ for _, k := range s.env.Keys() {
131+ v, _ := s.env.Get(k)
132+ penv.Set(k, v)
133+ }
134+ if len(prepend) > 0 {
135+ orig, ok := s.env.Get("PATH")
136+ if !ok {
137+ orig = j.lookupPath
138+ }
139+ penv.Set("PATH", PrependedPath(prepend, orig))
140+ }
141+ for _, k := range runtime.Keys() {
142+ v, _ := runtime.Get(k)
143+ penv.Set(k, v)
144+ }
145+ penv.Set("GITHUB_ACTIONS", "true") // ProcessInvoker.cs:283
146+ if _, ok := penv.Get("CI"); !ok {
147+ penv.Set("CI", "true")
148+ }
149+ penv.Set(ProcessTrackingID, j.trackID)
150+ var envList []string
151+ for _, k := range penv.Keys() {
152+ v, _ := penv.Get(k)
153+ envList = append(envList, k+"="+v)
154+ }
155+
156+ res, err := runProcess(procSpec{
157+ Argv: sc.Argv, Dir: dir, Env: envList, Credential: j.cfg.Credential,
158+ OnLine: func(line string) {
159+ if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") {
160+ line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`)
161+ }
162+ if (strings.Contains(line, "##[") || strings.Contains(line, "::")) && s.cmds.TryProcess(line) {
163+ return
164+ }
165+ j.write(num, line)
166+ },
167+ Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter,
168+ WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
169+ })
170+ if err != nil {
171+ return fail(errors.New(startError(plan.CommandPath, dir, err)))
172+ }
173+ if res.Cancelled {
174+ // The process was stopped on cancellation: ExecuteAsync throws
175+ // (ProcessInvoker.cs:369) and StepsRunner logs the exception
176+ // (StepsRunner.cs:333-337).
177+ j.errorf(num, "The operation was canceled.")
178+ return resCanceled
179+ }
180+ if res.ExitCode != 0 {
181+ j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
182+ return resFailed
183+ }
184+ return resSucceeded
185+}
R internal/executor/steps.go → internal/executor/job.go +1 -439 renamed, 55% similar
@@ -13,14 +13,12 @@ import (
1313 "syscall"
1414 "time"
1515
16- "github.com/google/uuid"
17-
1816 "bnema.dev/ermite-actions/expr"
1917 "bnema.dev/ermite-actions/template"
2018 "bnema.dev/ermite-actions/workflow"
19+ "github.com/google/uuid"
2120 )
2221
23-// Names of the implicit steps (JobExtension.cs:70 and :15).
2422 const (
2523 SetUpJobName = "Set up job"
2624 CompleteJobName = "Complete job"
@@ -472,442 +470,6 @@ func envValue(env []string, key string) string {
472470 return v
473471 }
474472
475-// ---------------------------------------------------------------- steps
476-
477-// stepRun is the state of one step being run; it implements Sink and
478-// commandHost.
479-type stepRun struct {
480- j *job
481- num int
482- st *workflow.Step
483- env *ordered // the step's environment: job env + step env
484- cmdResult *stepResult
485- outcome *stepResult
486- cmds *commandManager
487-}
488-
489-func (s *stepRun) Out(text string) { s.j.out(s.num, text) }
490-func (s *stepRun) Tagged(tag, msg string) { s.j.tagged(s.num, tag, msg) }
491-func (s *stepRun) Debug(msg string) { s.j.debug(s.num, msg) }
492-func (s *stepRun) Error(msg string) { s.j.errorf(s.num, "%s", msg) }
493-func (s *stepRun) CommandFailed() { f := resFailed; s.cmdResult = &f }
494-func (s *stepRun) SetOutput(name, value string) {
495- s.j.mu.Lock()
496- s.j.state.SetOutput(s.st.ID, name, value)
497- s.j.mu.Unlock()
498-}
499-func (s *stepRun) SaveState(name, value string) {}
500-func (s *stepRun) SetEnv(name, value string) {
501- s.j.mu.Lock()
502- s.j.state.SetEnv(name, value)
503- s.j.mu.Unlock()
504-}
505-func (s *stepRun) AddPath(p string) {
506- s.j.mu.Lock()
507- s.j.state.AddPath(p)
508- s.j.mu.Unlock()
509-}
510-
511-// EnvFlag reads a boolean variable of the env context, then of the process
512-// (ActionCommandManager.cs:236-246).
513-func (s *stepRun) EnvFlag(name string) bool {
514- v := strings.ToLower(os.Getenv(name))
515- if v != "true" {
516- if e, ok := s.env.Get(name); ok {
517- v = strings.ToLower(e)
518- }
519- }
520- return v == "true"
521-}
522-
523-func firstLine(contents string) string {
524- l := strings.TrimLeft(contents, " \t\r\n")
525- if i := strings.IndexAny(l, "\r\n"); i >= 0 {
526- l = l[:i]
527- }
528- return l
529-}
530-
531-// displayName is the name shown for the step (ActionRunner.GenerateDisplayName,
532-// ActionRunner.cs:363): the evaluated name, or "Run " and the first line of
533-// the script or the action reference.
534-func (j *job) displayName(st *workflow.Step, env *ordered) string {
535- tok := st.Name
536- prefix := ""
537- switch {
538- case tok != nil:
539- case st.Run != nil:
540- tok, prefix = st.Run, "Run "
541- case st.Uses != nil:
542- tok, prefix = st.Uses, "Run "
543- default:
544- return ""
545- }
546- name, err := j.evalString(tok, env)
547- if err != nil {
548- name = tokenSource(tok)
549- }
550- return formatStepName(prefix, name)
551-}
552-
553-func tokenSource(t template.Token) string {
554- if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {
555- return be.Source
556- }
557- return t.String()
558-}
559-
560-// formatStepName is ActionRunner.FormatStepName (ActionRunner.cs:447).
561-func formatStepName(prefix, name string) string {
562- if name == "" {
563- return ""
564- }
565- return prefix + firstLine(name)
566-}
567-
568-func (j *job) statusFuncs() workflow.StatusFuncs {
569- st := j.state.Status()
570- return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
571-}
572-
573-// markCancelled records the cancellation of the job: job result Canceled and
574-// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
575-func (j *job) markCancelledLocked() {
576- if j.cancelled {
577- return
578- }
579- j.cancelled = true
580- c := resCanceled
581- j.result = &c
582- j.state.SetStatus("cancelled")
583-}
584-
585-func (j *job) runStep(num int, st *workflow.Step) {
586- rep := j.cfg.Reporter
587- s := &stepRun{j: j, num: num, st: st}
588- s.cmds = newCommandManager(s, j.cfg.Debug)
589-
590- // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
591- // own, evaluated against the job env.
592- j.mu.Lock()
593- jobEnv := j.state.JobEnv()
594- j.mu.Unlock()
595- s.env = jobEnv.Clone()
596- pairs, envErr := j.evalMap(st.Env, jobEnv)
597- if envErr == nil {
598- for _, p := range pairs {
599- s.env.Set(p.Key, p.Value)
600- }
601- }
602- name := j.displayName(st, s.env)
603- rep.StepStarted(num, name, j.now())
604-
605- complete := func(r stepResult) {
606- res := r
607- j.mu.Lock()
608- // Update the steps context (ExecutionContext.UpdateGlobalStepsContext).
609- outcome := res
610- if s.outcome != nil {
611- outcome = *s.outcome
612- }
613- j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
614- if res == resFailed {
615- m := merge(j.result, resFailed)
616- j.result = &m
617- j.state.SetStatus(statusName(m))
618- }
619- j.mu.Unlock()
620- rep.StepCompleted(num, name, res.conclusion(), j.now())
621- }
622-
623- if envErr != nil {
624- j.errorf(num, "%s", envErr.Error())
625- complete(resFailed)
626- return
627- }
628-
629- // Cancellation seen before the step starts (StepsRunner.cs:188-197).
630- cancelledAtStart := false
631- select {
632- case <-j.cancel.Done():
633- cancelledAtStart = true
634- j.mu.Lock()
635- j.markCancelledLocked()
636- j.mu.Unlock()
637- default:
638- }
639-
640- // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not
641- // evaluated and the step is skipped.
642- cond := func() (bool, error) {
643- j.mu.Lock()
644- defer j.mu.Unlock()
645- return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs())
646- }
647- if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
648- complete(resSkipped)
649- return
650- }
651- ok, err := cond()
652- switch {
653- case err != nil:
654- j.errorf(num, "%s", err.Error())
655- complete(resFailed)
656- return
657- case !ok:
658- complete(resSkipped)
659- return
660- }
661-
662- // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
663- // cancelled and the step is stopped unless its condition still holds
664- // (always()).
665- stepCancel := make(chan struct{})
666- stopWatch := make(chan struct{})
667- var watch sync.WaitGroup
668- if !cancelledAtStart {
669- watch.Add(1)
670- go func() {
671- defer watch.Done()
672- select {
673- case <-j.cancel.Done():
674- case <-stopWatch:
675- return
676- }
677- j.mu.Lock()
678- j.markCancelledLocked()
679- j.mu.Unlock()
680- keep := false
681- if j.cancel.Reason() != CancelShutdown {
682- keep, _ = cond()
683- }
684- if !keep {
685- close(stepCancel)
686- }
687- }()
688- }
689-
690- r := j.execute(s, stepCancel)
691- close(stopWatch)
692- watch.Wait()
693-
694- // Merge the result of the commands (StepsRunner.cs:347-351).
695- if s.cmdResult != nil {
696- m := merge(&r, *s.cmdResult)
697- r = m
698- }
699- // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
700- if r == resFailed && st.ContinueOnError != nil {
701- coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
702- if cerr != nil {
703- j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
704- j.errorf(num, "%s", cerr.Error())
705- }
706- if coe && cerr == nil {
707- o := resFailed
708- s.outcome = &o
709- r = resSucceeded
710- }
711- }
712- complete(r)
713-}
714-
715-// statusName is the job.status for a result (TaskResult.ToActionResult).
716-func statusName(r stepResult) string {
717- switch r {
718- case resFailed:
719- return "failure"
720- case resCanceled:
721- return "cancelled"
722- }
723- return "success"
724-}
725-
726-// notSupported is the failure of a step that uses a feature of a later phase.
727-func notSupported(st *workflow.Step) string {
728- switch {
729- case st.Uses != nil:
730- return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value)
731- case st.TimeoutMinutes != nil:
732- return "not supported yet: timeout-minutes"
733- }
734- return ""
735-}
736-
737-// execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
738-func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) (result stepResult) {
739- num, st := s.num, s.st
740- if msg := notSupported(st); msg != "" {
741- if st.Uses != nil {
742- j.out(num, "##[group]Run "+st.Uses.Value)
743- j.out(num, "##[endgroup]")
744- }
745- j.errorf(num, "%s", msg)
746- return resFailed
747- }
748- fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
749-
750- // File commands for this step: the github context carries the paths
751- // (FileCommandManager.InitializeFiles), event.json is written
752- // (ExecutionContext.WriteWebhookPayload).
753- j.mu.Lock()
754- files, ferr := j.fc.Init()
755- if ferr == nil {
756- for _, c := range fileCommands {
757- j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
758- }
759- j.state.SetGitHub("action", st.ID)
760- j.state.SetGitHub("action_repository", "")
761- j.state.SetGitHub("action_ref", "")
762- if ev := j.state.EventJSON(); ev != "" {
763- dir := filepath.Join(j.state.Layout().Temp, "_github_workflow")
764- if err := os.MkdirAll(dir, 0o755); err == nil {
765- j.own(dir)
766- p := filepath.Join(dir, "event.json")
767- if err := os.WriteFile(p, []byte(ev), 0o644); err == nil {
768- j.own(p)
769- j.state.SetGitHub("event_path", p)
770- }
771- }
772- }
773- }
774- j.mu.Unlock()
775- if ferr != nil {
776- return fail(ferr)
777- }
778- defer func() {
779- // ProcessFiles runs after the handler, even when it failed (finally).
780- j.fc.Process(s)
781- }()
782-
783- // Inputs (ActionRunner.cs:174-185).
784- script, err := j.evalString(st.Run, s.env)
785- if err != nil {
786- return fail(err)
787- }
788- shellInput, err := j.evalString(st.Shell, s.env)
789- if err != nil {
790- return fail(err)
791- }
792- var wdInput string
793- var hasWD bool
794- if st.WorkingDirectory != nil {
795- hasWD = true
796- if wdInput, err = j.evalString(st.WorkingDirectory, s.env); err != nil {
797- return fail(err)
798- }
799- }
800-
801- // Header (ScriptHandler.PrintActionDetails, ScriptHandler.cs:27-137).
802- j.out(num, "##[group]Run "+firstLine(script))
803- for _, l := range strings.Split(strings.TrimRight(strings.ReplaceAll(script, "\r\n", "\n"), "\n"), "\n") {
804- j.write(num, "\x1b[36;1m"+l+"\x1b[0m") // bright cyan
805- }
806- shell := shellInput
807- if shell == "" {
808- shell = j.runDefaults.shell
809- }
810- j.mu.Lock()
811- lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}
812- j.mu.Unlock()
813- plan, err := PlanShell(shell, lookup)
814- if err != nil {
815- return fail(err) // the group stays open, like the original
816- }
817- j.out(num, "shell: "+plan.ShellLine())
818- if s.env.Len() > 0 {
819- j.write(num, "env:")
820- for _, k := range s.env.Keys() {
821- v, _ := s.env.Get(k)
822- j.out(num, fmt.Sprintf(" %s: %s", k, v))
823- }
824- }
825- j.write(num, "##[endgroup]")
826-
827- // Run (ScriptHandler.RunAsync, :140-358).
828- wd := wdInput
829- if !hasWD {
830- wd = j.runDefaults.workingDirectory
831- }
832- j.mu.Lock()
833- workspace := j.state.GitHub("workspace")
834- temp := j.state.Layout().Temp
835- j.mu.Unlock()
836- dir := ResolveWorkingDirectory(workspace, wd)
837- sc, err := WriteScript(temp, plan, script)
838- if err != nil {
839- return fail(err)
840- }
841-
842- // Process environment: the inherited one, the step's environment, PATH with
843- // GITHUB_PATH in front, then the runtime variables of the contexts.
844- j.mu.Lock()
845- runtime := j.state.RuntimeEnv()
846- prepend := j.state.Prepend()
847- j.mu.Unlock()
848- penv := newOrdered()
849- for _, kv := range j.baseEnv {
850- if k, v, ok := strings.Cut(kv, "="); ok {
851- penv.Set(k, v)
852- }
853- }
854- for _, k := range s.env.Keys() {
855- v, _ := s.env.Get(k)
856- penv.Set(k, v)
857- }
858- if len(prepend) > 0 {
859- orig, ok := s.env.Get("PATH")
860- if !ok {
861- orig = j.lookupPath
862- }
863- penv.Set("PATH", PrependedPath(prepend, orig))
864- }
865- for _, k := range runtime.Keys() {
866- v, _ := runtime.Get(k)
867- penv.Set(k, v)
868- }
869- penv.Set("GITHUB_ACTIONS", "true") // ProcessInvoker.cs:283
870- if _, ok := penv.Get("CI"); !ok {
871- penv.Set("CI", "true")
872- }
873- penv.Set(ProcessTrackingID, j.trackID)
874- var envList []string
875- for _, k := range penv.Keys() {
876- v, _ := penv.Get(k)
877- envList = append(envList, k+"="+v)
878- }
879-
880- res, err := runProcess(procSpec{
881- Argv: sc.Argv, Dir: dir, Env: envList, Credential: j.cfg.Credential,
882- OnLine: func(line string) {
883- if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") {
884- line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`)
885- }
886- if (strings.Contains(line, "##[") || strings.Contains(line, "::")) && s.cmds.TryProcess(line) {
887- return
888- }
889- j.write(num, line)
890- },
891- Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter,
892- WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
893- })
894- if err != nil {
895- return fail(errors.New(startError(plan.CommandPath, dir, err)))
896- }
897- if res.Cancelled {
898- // The process was stopped on cancellation: ExecuteAsync throws
899- // (ProcessInvoker.cs:369) and StepsRunner logs the exception
900- // (StepsRunner.cs:333-337).
901- j.errorf(num, "The operation was canceled.")
902- return resCanceled
903- }
904- if res.ExitCode != 0 {
905- j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
906- return resFailed
907- }
908- return resSucceeded
909-}
910-
911473 // ---------------------------------------------------------------- complete
912474
913475 // completeJob is JobExtension.FinalizeJob (JobExtension.cs:9-190): job outputs
@@ -13,14 +13,12 @@ import (
13 "syscall"13 "syscall"
14 "time"14 "time"
15 15
16- "github.com/google/uuid"
17-
18 "bnema.dev/ermite-actions/expr"16 "bnema.dev/ermite-actions/expr"
19 "bnema.dev/ermite-actions/template"17 "bnema.dev/ermite-actions/template"
20 "bnema.dev/ermite-actions/workflow"18 "bnema.dev/ermite-actions/workflow"
19+ "github.com/google/uuid"
21 )20 )
22 21
23-// Names of the implicit steps (JobExtension.cs:70 and :15).
24 const (22 const (
25 SetUpJobName = "Set up job"23 SetUpJobName = "Set up job"
26 CompleteJobName = "Complete job"24 CompleteJobName = "Complete job"
@@ -472,442 +470,6 @@ func envValue(env []string, key string) string {
472 return v470 return v
473 }471 }
474 472
475-// ---------------------------------------------------------------- steps
476-
477-// stepRun is the state of one step being run; it implements Sink and
478-// commandHost.
479-type stepRun struct {
480- j *job
481- num int
482- st *workflow.Step
483- env *ordered // the step's environment: job env + step env
484- cmdResult *stepResult
485- outcome *stepResult
486- cmds *commandManager
487-}
488-
489-func (s *stepRun) Out(text string) { s.j.out(s.num, text) }
490-func (s *stepRun) Tagged(tag, msg string) { s.j.tagged(s.num, tag, msg) }
491-func (s *stepRun) Debug(msg string) { s.j.debug(s.num, msg) }
492-func (s *stepRun) Error(msg string) { s.j.errorf(s.num, "%s", msg) }
493-func (s *stepRun) CommandFailed() { f := resFailed; s.cmdResult = &f }
494-func (s *stepRun) SetOutput(name, value string) {
495- s.j.mu.Lock()
496- s.j.state.SetOutput(s.st.ID, name, value)
497- s.j.mu.Unlock()
498-}
499-func (s *stepRun) SaveState(name, value string) {}
500-func (s *stepRun) SetEnv(name, value string) {
501- s.j.mu.Lock()
502- s.j.state.SetEnv(name, value)
503- s.j.mu.Unlock()
504-}
505-func (s *stepRun) AddPath(p string) {
506- s.j.mu.Lock()
507- s.j.state.AddPath(p)
508- s.j.mu.Unlock()
509-}
510-
511-// EnvFlag reads a boolean variable of the env context, then of the process
512-// (ActionCommandManager.cs:236-246).
513-func (s *stepRun) EnvFlag(name string) bool {
514- v := strings.ToLower(os.Getenv(name))
515- if v != "true" {
516- if e, ok := s.env.Get(name); ok {
517- v = strings.ToLower(e)
518- }
519- }
520- return v == "true"
521-}
522-
523-func firstLine(contents string) string {
524- l := strings.TrimLeft(contents, " \t\r\n")
525- if i := strings.IndexAny(l, "\r\n"); i >= 0 {
526- l = l[:i]
527- }
528- return l
529-}
530-
531-// displayName is the name shown for the step (ActionRunner.GenerateDisplayName,
532-// ActionRunner.cs:363): the evaluated name, or "Run " and the first line of
533-// the script or the action reference.
534-func (j *job) displayName(st *workflow.Step, env *ordered) string {
535- tok := st.Name
536- prefix := ""
537- switch {
538- case tok != nil:
539- case st.Run != nil:
540- tok, prefix = st.Run, "Run "
541- case st.Uses != nil:
542- tok, prefix = st.Uses, "Run "
543- default:
544- return ""
545- }
546- name, err := j.evalString(tok, env)
547- if err != nil {
548- name = tokenSource(tok)
549- }
550- return formatStepName(prefix, name)
551-}
552-
553-func tokenSource(t template.Token) string {
554- if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {
555- return be.Source
556- }
557- return t.String()
558-}
559-
560-// formatStepName is ActionRunner.FormatStepName (ActionRunner.cs:447).
561-func formatStepName(prefix, name string) string {
562- if name == "" {
563- return ""
564- }
565- return prefix + firstLine(name)
566-}
567-
568-func (j *job) statusFuncs() workflow.StatusFuncs {
569- st := j.state.Status()
570- return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
571-}
572-
573-// markCancelled records the cancellation of the job: job result Canceled and
574-// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
575-func (j *job) markCancelledLocked() {
576- if j.cancelled {
577- return
578- }
579- j.cancelled = true
580- c := resCanceled
581- j.result = &c
582- j.state.SetStatus("cancelled")
583-}
584-
585-func (j *job) runStep(num int, st *workflow.Step) {
586- rep := j.cfg.Reporter
587- s := &stepRun{j: j, num: num, st: st}
588- s.cmds = newCommandManager(s, j.cfg.Debug)
589-
590- // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
591- // own, evaluated against the job env.
592- j.mu.Lock()
593- jobEnv := j.state.JobEnv()
594- j.mu.Unlock()
595- s.env = jobEnv.Clone()
596- pairs, envErr := j.evalMap(st.Env, jobEnv)
597- if envErr == nil {
598- for _, p := range pairs {
599- s.env.Set(p.Key, p.Value)
600- }
601- }
602- name := j.displayName(st, s.env)
603- rep.StepStarted(num, name, j.now())
604-
605- complete := func(r stepResult) {
606- res := r
607- j.mu.Lock()
608- // Update the steps context (ExecutionContext.UpdateGlobalStepsContext).
609- outcome := res
610- if s.outcome != nil {
611- outcome = *s.outcome
612- }
613- j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
614- if res == resFailed {
615- m := merge(j.result, resFailed)
616- j.result = &m
617- j.state.SetStatus(statusName(m))
618- }
619- j.mu.Unlock()
620- rep.StepCompleted(num, name, res.conclusion(), j.now())
621- }
622-
623- if envErr != nil {
624- j.errorf(num, "%s", envErr.Error())
625- complete(resFailed)
626- return
627- }
628-
629- // Cancellation seen before the step starts (StepsRunner.cs:188-197).
630- cancelledAtStart := false
631- select {
632- case <-j.cancel.Done():
633- cancelledAtStart = true
634- j.mu.Lock()
635- j.markCancelledLocked()
636- j.mu.Unlock()
637- default:
638- }
639-
640- // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not
641- // evaluated and the step is skipped.
642- cond := func() (bool, error) {
643- j.mu.Lock()
644- defer j.mu.Unlock()
645- return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs())
646- }
647- if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
648- complete(resSkipped)
649- return
650- }
651- ok, err := cond()
652- switch {
653- case err != nil:
654- j.errorf(num, "%s", err.Error())
655- complete(resFailed)
656- return
657- case !ok:
658- complete(resSkipped)
659- return
660- }
661-
662- // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
663- // cancelled and the step is stopped unless its condition still holds
664- // (always()).
665- stepCancel := make(chan struct{})
666- stopWatch := make(chan struct{})
667- var watch sync.WaitGroup
668- if !cancelledAtStart {
669- watch.Add(1)
670- go func() {
671- defer watch.Done()
672- select {
673- case <-j.cancel.Done():
674- case <-stopWatch:
675- return
676- }
677- j.mu.Lock()
678- j.markCancelledLocked()
679- j.mu.Unlock()
680- keep := false
681- if j.cancel.Reason() != CancelShutdown {
682- keep, _ = cond()
683- }
684- if !keep {
685- close(stepCancel)
686- }
687- }()
688- }
689-
690- r := j.execute(s, stepCancel)
691- close(stopWatch)
692- watch.Wait()
693-
694- // Merge the result of the commands (StepsRunner.cs:347-351).
695- if s.cmdResult != nil {
696- m := merge(&r, *s.cmdResult)
697- r = m
698- }
699- // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
700- if r == resFailed && st.ContinueOnError != nil {
701- coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
702- if cerr != nil {
703- j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
704- j.errorf(num, "%s", cerr.Error())
705- }
706- if coe && cerr == nil {
707- o := resFailed
708- s.outcome = &o
709- r = resSucceeded
710- }
711- }
712- complete(r)
713-}
714-
715-// statusName is the job.status for a result (TaskResult.ToActionResult).
716-func statusName(r stepResult) string {
717- switch r {
718- case resFailed:
719- return "failure"
720- case resCanceled:
721- return "cancelled"
722- }
723- return "success"
724-}
725-
726-// notSupported is the failure of a step that uses a feature of a later phase.
727-func notSupported(st *workflow.Step) string {
728- switch {
729- case st.Uses != nil:
730- return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value)
731- case st.TimeoutMinutes != nil:
732- return "not supported yet: timeout-minutes"
733- }
734- return ""
735-}
736-
737-// execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
738-func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) (result stepResult) {
739- num, st := s.num, s.st
740- if msg := notSupported(st); msg != "" {
741- if st.Uses != nil {
742- j.out(num, "##[group]Run "+st.Uses.Value)
743- j.out(num, "##[endgroup]")
744- }
745- j.errorf(num, "%s", msg)
746- return resFailed
747- }
748- fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
749-
750- // File commands for this step: the github context carries the paths
751- // (FileCommandManager.InitializeFiles), event.json is written
752- // (ExecutionContext.WriteWebhookPayload).
753- j.mu.Lock()
754- files, ferr := j.fc.Init()
755- if ferr == nil {
756- for _, c := range fileCommands {
757- j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
758- }
759- j.state.SetGitHub("action", st.ID)
760- j.state.SetGitHub("action_repository", "")
761- j.state.SetGitHub("action_ref", "")
762- if ev := j.state.EventJSON(); ev != "" {
763- dir := filepath.Join(j.state.Layout().Temp, "_github_workflow")
764- if err := os.MkdirAll(dir, 0o755); err == nil {
765- j.own(dir)
766- p := filepath.Join(dir, "event.json")
767- if err := os.WriteFile(p, []byte(ev), 0o644); err == nil {
768- j.own(p)
769- j.state.SetGitHub("event_path", p)
770- }
771- }
772- }
773- }
774- j.mu.Unlock()
775- if ferr != nil {
776- return fail(ferr)
777- }
778- defer func() {
779- // ProcessFiles runs after the handler, even when it failed (finally).
780- j.fc.Process(s)
781- }()
782-
783- // Inputs (ActionRunner.cs:174-185).
784- script, err := j.evalString(st.Run, s.env)
785- if err != nil {
786- return fail(err)
787- }
788- shellInput, err := j.evalString(st.Shell, s.env)
789- if err != nil {
790- return fail(err)
791- }
792- var wdInput string
793- var hasWD bool
794- if st.WorkingDirectory != nil {
795- hasWD = true
796- if wdInput, err = j.evalString(st.WorkingDirectory, s.env); err != nil {
797- return fail(err)
798- }
799- }
800-
801- // Header (ScriptHandler.PrintActionDetails, ScriptHandler.cs:27-137).
802- j.out(num, "##[group]Run "+firstLine(script))
803- for _, l := range strings.Split(strings.TrimRight(strings.ReplaceAll(script, "\r\n", "\n"), "\n"), "\n") {
804- j.write(num, "\x1b[36;1m"+l+"\x1b[0m") // bright cyan
805- }
806- shell := shellInput
807- if shell == "" {
808- shell = j.runDefaults.shell
809- }
810- j.mu.Lock()
811- lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}
812- j.mu.Unlock()
813- plan, err := PlanShell(shell, lookup)
814- if err != nil {
815- return fail(err) // the group stays open, like the original
816- }
817- j.out(num, "shell: "+plan.ShellLine())
818- if s.env.Len() > 0 {
819- j.write(num, "env:")
820- for _, k := range s.env.Keys() {
821- v, _ := s.env.Get(k)
822- j.out(num, fmt.Sprintf(" %s: %s", k, v))
823- }
824- }
825- j.write(num, "##[endgroup]")
826-
827- // Run (ScriptHandler.RunAsync, :140-358).
828- wd := wdInput
829- if !hasWD {
830- wd = j.runDefaults.workingDirectory
831- }
832- j.mu.Lock()
833- workspace := j.state.GitHub("workspace")
834- temp := j.state.Layout().Temp
835- j.mu.Unlock()
836- dir := ResolveWorkingDirectory(workspace, wd)
837- sc, err := WriteScript(temp, plan, script)
838- if err != nil {
839- return fail(err)
840- }
841-
842- // Process environment: the inherited one, the step's environment, PATH with
843- // GITHUB_PATH in front, then the runtime variables of the contexts.
844- j.mu.Lock()
845- runtime := j.state.RuntimeEnv()
846- prepend := j.state.Prepend()
847- j.mu.Unlock()
848- penv := newOrdered()
849- for _, kv := range j.baseEnv {
850- if k, v, ok := strings.Cut(kv, "="); ok {
851- penv.Set(k, v)
852- }
853- }
854- for _, k := range s.env.Keys() {
855- v, _ := s.env.Get(k)
856- penv.Set(k, v)
857- }
858- if len(prepend) > 0 {
859- orig, ok := s.env.Get("PATH")
860- if !ok {
861- orig = j.lookupPath
862- }
863- penv.Set("PATH", PrependedPath(prepend, orig))
864- }
865- for _, k := range runtime.Keys() {
866- v, _ := runtime.Get(k)
867- penv.Set(k, v)
868- }
869- penv.Set("GITHUB_ACTIONS", "true") // ProcessInvoker.cs:283
870- if _, ok := penv.Get("CI"); !ok {
871- penv.Set("CI", "true")
872- }
873- penv.Set(ProcessTrackingID, j.trackID)
874- var envList []string
875- for _, k := range penv.Keys() {
876- v, _ := penv.Get(k)
877- envList = append(envList, k+"="+v)
878- }
879-
880- res, err := runProcess(procSpec{
881- Argv: sc.Argv, Dir: dir, Env: envList, Credential: j.cfg.Credential,
882- OnLine: func(line string) {
883- if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") {
884- line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`)
885- }
886- if (strings.Contains(line, "##[") || strings.Contains(line, "::")) && s.cmds.TryProcess(line) {
887- return
888- }
889- j.write(num, line)
890- },
891- Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter,
892- WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
893- })
894- if err != nil {
895- return fail(errors.New(startError(plan.CommandPath, dir, err)))
896- }
897- if res.Cancelled {
898- // The process was stopped on cancellation: ExecuteAsync throws
899- // (ProcessInvoker.cs:369) and StepsRunner logs the exception
900- // (StepsRunner.cs:333-337).
901- j.errorf(num, "The operation was canceled.")
902- return resCanceled
903- }
904- if res.ExitCode != 0 {
905- j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
906- return resFailed
907- }
908- return resSucceeded
909-}
910-
911 // ---------------------------------------------------------------- complete473 // ---------------------------------------------------------------- complete
912 474
913 // completeJob is JobExtension.FinalizeJob (JobExtension.cs:9-190): job outputs475 // completeJob is JobExtension.FinalizeJob (JobExtension.cs:9-190): job outputs
R internal/executor/steps_test.go → internal/executor/job_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

A internal/executor/step.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "strings"
10+ "sync"
11+
12+ "bnema.dev/ermite-actions/template"
13+ "bnema.dev/ermite-actions/workflow"
14+)
15+
16+// ---------------------------------------------------------------- steps
17+
18+// stepRun is the state of one step being run; it implements Sink and
19+// commandHost.
20+type stepRun struct {
21+ j *job
22+ num int
23+ st *workflow.Step
24+ env *ordered // the step's environment: job env + step env
25+ cmdResult *stepResult
26+ outcome *stepResult
27+ cmds *commandManager
28+}
29+
30+func (s *stepRun) Out(text string) { s.j.out(s.num, text) }
31+func (s *stepRun) Tagged(tag, msg string) { s.j.tagged(s.num, tag, msg) }
32+func (s *stepRun) Debug(msg string) { s.j.debug(s.num, msg) }
33+func (s *stepRun) Error(msg string) { s.j.errorf(s.num, "%s", msg) }
34+func (s *stepRun) CommandFailed() { f := resFailed; s.cmdResult = &f }
35+func (s *stepRun) SetOutput(name, value string) {
36+ s.j.mu.Lock()
37+ s.j.state.SetOutput(s.st.ID, name, value)
38+ s.j.mu.Unlock()
39+}
40+func (s *stepRun) SaveState(name, value string) {}
41+func (s *stepRun) SetEnv(name, value string) {
42+ s.j.mu.Lock()
43+ s.j.state.SetEnv(name, value)
44+ s.j.mu.Unlock()
45+}
46+func (s *stepRun) AddPath(p string) {
47+ s.j.mu.Lock()
48+ s.j.state.AddPath(p)
49+ s.j.mu.Unlock()
50+}
51+
52+// EnvFlag reads a boolean variable of the env context, then of the process
53+// (ActionCommandManager.cs:236-246).
54+func (s *stepRun) EnvFlag(name string) bool {
55+ v := strings.ToLower(os.Getenv(name))
56+ if v != "true" {
57+ if e, ok := s.env.Get(name); ok {
58+ v = strings.ToLower(e)
59+ }
60+ }
61+ return v == "true"
62+}
63+
64+func firstLine(contents string) string {
65+ l := strings.TrimLeft(contents, " \t\r\n")
66+ if i := strings.IndexAny(l, "\r\n"); i >= 0 {
67+ l = l[:i]
68+ }
69+ return l
70+}
71+
72+// displayName is the name shown for the step (ActionRunner.GenerateDisplayName,
73+// ActionRunner.cs:363): the evaluated name, or "Run " and the first line of
74+// the script or the action reference.
75+func (j *job) displayName(st *workflow.Step, env *ordered) string {
76+ tok := st.Name
77+ prefix := ""
78+ switch {
79+ case tok != nil:
80+ case st.Run != nil:
81+ tok, prefix = st.Run, "Run "
82+ case st.Uses != nil:
83+ tok, prefix = st.Uses, "Run "
84+ default:
85+ return ""
86+ }
87+ name, err := j.evalString(tok, env)
88+ if err != nil {
89+ name = tokenSource(tok)
90+ }
91+ return formatStepName(prefix, name)
92+}
93+
94+func tokenSource(t template.Token) string {
95+ if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {
96+ return be.Source
97+ }
98+ return t.String()
99+}
100+
101+// formatStepName is ActionRunner.FormatStepName (ActionRunner.cs:447).
102+func formatStepName(prefix, name string) string {
103+ if name == "" {
104+ return ""
105+ }
106+ return prefix + firstLine(name)
107+}
108+
109+func (j *job) statusFuncs() workflow.StatusFuncs {
110+ st := j.state.Status()
111+ return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
112+}
113+
114+// markCancelled records the cancellation of the job: job result Canceled and
115+// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
116+func (j *job) markCancelledLocked() {
117+ if j.cancelled {
118+ return
119+ }
120+ j.cancelled = true
121+ c := resCanceled
122+ j.result = &c
123+ j.state.SetStatus("cancelled")
124+}
125+
126+func (j *job) runStep(num int, st *workflow.Step) {
127+ rep := j.cfg.Reporter
128+ s := &stepRun{j: j, num: num, st: st}
129+ s.cmds = newCommandManager(s, j.cfg.Debug)
130+
131+ // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
132+ // own, evaluated against the job env.
133+ j.mu.Lock()
134+ jobEnv := j.state.JobEnv()
135+ j.mu.Unlock()
136+ s.env = jobEnv.Clone()
137+ pairs, envErr := j.evalMap(st.Env, jobEnv)
138+ if envErr == nil {
139+ for _, p := range pairs {
140+ s.env.Set(p.Key, p.Value)
141+ }
142+ }
143+ name := j.displayName(st, s.env)
144+ rep.StepStarted(num, name, j.now())
145+
146+ complete := func(r stepResult) {
147+ res := r
148+ j.mu.Lock()
149+ // Update the steps context (ExecutionContext.UpdateGlobalStepsContext).
150+ outcome := res
151+ if s.outcome != nil {
152+ outcome = *s.outcome
153+ }
154+ j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
155+ if res == resFailed {
156+ m := merge(j.result, resFailed)
157+ j.result = &m
158+ j.state.SetStatus(statusName(m))
159+ }
160+ j.mu.Unlock()
161+ rep.StepCompleted(num, name, res.conclusion(), j.now())
162+ }
163+
164+ if envErr != nil {
165+ j.errorf(num, "%s", envErr.Error())
166+ complete(resFailed)
167+ return
168+ }
169+
170+ // Cancellation seen before the step starts (StepsRunner.cs:188-197).
171+ cancelledAtStart := false
172+ select {
173+ case <-j.cancel.Done():
174+ cancelledAtStart = true
175+ j.mu.Lock()
176+ j.markCancelledLocked()
177+ j.mu.Unlock()
178+ default:
179+ }
180+
181+ // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not
182+ // evaluated and the step is skipped.
183+ cond := func() (bool, error) {
184+ j.mu.Lock()
185+ defer j.mu.Unlock()
186+ return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs())
187+ }
188+ if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
189+ complete(resSkipped)
190+ return
191+ }
192+ ok, err := cond()
193+ switch {
194+ case err != nil:
195+ j.errorf(num, "%s", err.Error())
196+ complete(resFailed)
197+ return
198+ case !ok:
199+ complete(resSkipped)
200+ return
201+ }
202+
203+ // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
204+ // cancelled and the step is stopped unless its condition still holds
205+ // (always()).
206+ stepCancel := make(chan struct{})
207+ stopWatch := make(chan struct{})
208+ var watch sync.WaitGroup
209+ if !cancelledAtStart {
210+ watch.Add(1)
211+ go func() {
212+ defer watch.Done()
213+ select {
214+ case <-j.cancel.Done():
215+ case <-stopWatch:
216+ return
217+ }
218+ j.mu.Lock()
219+ j.markCancelledLocked()
220+ j.mu.Unlock()
221+ keep := false
222+ if j.cancel.Reason() != CancelShutdown {
223+ keep, _ = cond()
224+ }
225+ if !keep {
226+ close(stepCancel)
227+ }
228+ }()
229+ }
230+
231+ r := j.execute(s, stepCancel)
232+ close(stopWatch)
233+ watch.Wait()
234+
235+ // Merge the result of the commands (StepsRunner.cs:347-351).
236+ if s.cmdResult != nil {
237+ m := merge(&r, *s.cmdResult)
238+ r = m
239+ }
240+ // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
241+ if r == resFailed && st.ContinueOnError != nil {
242+ coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
243+ if cerr != nil {
244+ j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
245+ j.errorf(num, "%s", cerr.Error())
246+ }
247+ if coe && cerr == nil {
248+ o := resFailed
249+ s.outcome = &o
250+ r = resSucceeded
251+ }
252+ }
253+ complete(r)
254+}
255+
256+// statusName is the job.status for a result (TaskResult.ToActionResult).
257+func statusName(r stepResult) string {
258+ switch r {
259+ case resFailed:
260+ return "failure"
261+ case resCanceled:
262+ return "cancelled"
263+ }
264+ return "success"
265+}
266+
267+// notSupported is the failure of a step that uses a feature of a later phase.
268+func notSupported(st *workflow.Step) string {
269+ switch {
270+ case st.Uses != nil:
271+ return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value)
272+ case st.TimeoutMinutes != nil:
273+ return "not supported yet: timeout-minutes"
274+ }
275+ return ""
276+}
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "strings"
10+ "sync"
11+
12+ "bnema.dev/ermite-actions/template"
13+ "bnema.dev/ermite-actions/workflow"
14+)
15+
16+// ---------------------------------------------------------------- steps
17+
18+// stepRun is the state of one step being run; it implements Sink and
19+// commandHost.
20+type stepRun struct {
21+ j *job
22+ num int
23+ st *workflow.Step
24+ env *ordered // the step's environment: job env + step env
25+ cmdResult *stepResult
26+ outcome *stepResult
27+ cmds *commandManager
28+}
29+
30+func (s *stepRun) Out(text string) { s.j.out(s.num, text) }
31+func (s *stepRun) Tagged(tag, msg string) { s.j.tagged(s.num, tag, msg) }
32+func (s *stepRun) Debug(msg string) { s.j.debug(s.num, msg) }
33+func (s *stepRun) Error(msg string) { s.j.errorf(s.num, "%s", msg) }
34+func (s *stepRun) CommandFailed() { f := resFailed; s.cmdResult = &f }
35+func (s *stepRun) SetOutput(name, value string) {
36+ s.j.mu.Lock()
37+ s.j.state.SetOutput(s.st.ID, name, value)
38+ s.j.mu.Unlock()
39+}
40+func (s *stepRun) SaveState(name, value string) {}
41+func (s *stepRun) SetEnv(name, value string) {
42+ s.j.mu.Lock()
43+ s.j.state.SetEnv(name, value)
44+ s.j.mu.Unlock()
45+}
46+func (s *stepRun) AddPath(p string) {
47+ s.j.mu.Lock()
48+ s.j.state.AddPath(p)
49+ s.j.mu.Unlock()
50+}
51+
52+// EnvFlag reads a boolean variable of the env context, then of the process
53+// (ActionCommandManager.cs:236-246).
54+func (s *stepRun) EnvFlag(name string) bool {
55+ v := strings.ToLower(os.Getenv(name))
56+ if v != "true" {
57+ if e, ok := s.env.Get(name); ok {
58+ v = strings.ToLower(e)
59+ }
60+ }
61+ return v == "true"
62+}
63+
64+func firstLine(contents string) string {
65+ l := strings.TrimLeft(contents, " \t\r\n")
66+ if i := strings.IndexAny(l, "\r\n"); i >= 0 {
67+ l = l[:i]
68+ }
69+ return l
70+}
71+
72+// displayName is the name shown for the step (ActionRunner.GenerateDisplayName,
73+// ActionRunner.cs:363): the evaluated name, or "Run " and the first line of
74+// the script or the action reference.
75+func (j *job) displayName(st *workflow.Step, env *ordered) string {
76+ tok := st.Name
77+ prefix := ""
78+ switch {
79+ case tok != nil:
80+ case st.Run != nil:
81+ tok, prefix = st.Run, "Run "
82+ case st.Uses != nil:
83+ tok, prefix = st.Uses, "Run "
84+ default:
85+ return ""
86+ }
87+ name, err := j.evalString(tok, env)
88+ if err != nil {
89+ name = tokenSource(tok)
90+ }
91+ return formatStepName(prefix, name)
92+}
93+
94+func tokenSource(t template.Token) string {
95+ if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {
96+ return be.Source
97+ }
98+ return t.String()
99+}
100+
101+// formatStepName is ActionRunner.FormatStepName (ActionRunner.cs:447).
102+func formatStepName(prefix, name string) string {
103+ if name == "" {
104+ return ""
105+ }
106+ return prefix + firstLine(name)
107+}
108+
109+func (j *job) statusFuncs() workflow.StatusFuncs {
110+ st := j.state.Status()
111+ return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
112+}
113+
114+// markCancelled records the cancellation of the job: job result Canceled and
115+// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
116+func (j *job) markCancelledLocked() {
117+ if j.cancelled {
118+ return
119+ }
120+ j.cancelled = true
121+ c := resCanceled
122+ j.result = &c
123+ j.state.SetStatus("cancelled")
124+}
125+
126+func (j *job) runStep(num int, st *workflow.Step) {
127+ rep := j.cfg.Reporter
128+ s := &stepRun{j: j, num: num, st: st}
129+ s.cmds = newCommandManager(s, j.cfg.Debug)
130+
131+ // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
132+ // own, evaluated against the job env.
133+ j.mu.Lock()
134+ jobEnv := j.state.JobEnv()
135+ j.mu.Unlock()
136+ s.env = jobEnv.Clone()
137+ pairs, envErr := j.evalMap(st.Env, jobEnv)
138+ if envErr == nil {
139+ for _, p := range pairs {
140+ s.env.Set(p.Key, p.Value)
141+ }
142+ }
143+ name := j.displayName(st, s.env)
144+ rep.StepStarted(num, name, j.now())
145+
146+ complete := func(r stepResult) {
147+ res := r
148+ j.mu.Lock()
149+ // Update the steps context (ExecutionContext.UpdateGlobalStepsContext).
150+ outcome := res
151+ if s.outcome != nil {
152+ outcome = *s.outcome
153+ }
154+ j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
155+ if res == resFailed {
156+ m := merge(j.result, resFailed)
157+ j.result = &m
158+ j.state.SetStatus(statusName(m))
159+ }
160+ j.mu.Unlock()
161+ rep.StepCompleted(num, name, res.conclusion(), j.now())
162+ }
163+
164+ if envErr != nil {
165+ j.errorf(num, "%s", envErr.Error())
166+ complete(resFailed)
167+ return
168+ }
169+
170+ // Cancellation seen before the step starts (StepsRunner.cs:188-197).
171+ cancelledAtStart := false
172+ select {
173+ case <-j.cancel.Done():
174+ cancelledAtStart = true
175+ j.mu.Lock()
176+ j.markCancelledLocked()
177+ j.mu.Unlock()
178+ default:
179+ }
180+
181+ // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not
182+ // evaluated and the step is skipped.
183+ cond := func() (bool, error) {
184+ j.mu.Lock()
185+ defer j.mu.Unlock()
186+ return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs())
187+ }
188+ if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
189+ complete(resSkipped)
190+ return
191+ }
192+ ok, err := cond()
193+ switch {
194+ case err != nil:
195+ j.errorf(num, "%s", err.Error())
196+ complete(resFailed)
197+ return
198+ case !ok:
199+ complete(resSkipped)
200+ return
201+ }
202+
203+ // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
204+ // cancelled and the step is stopped unless its condition still holds
205+ // (always()).
206+ stepCancel := make(chan struct{})
207+ stopWatch := make(chan struct{})
208+ var watch sync.WaitGroup
209+ if !cancelledAtStart {
210+ watch.Add(1)
211+ go func() {
212+ defer watch.Done()
213+ select {
214+ case <-j.cancel.Done():
215+ case <-stopWatch:
216+ return
217+ }
218+ j.mu.Lock()
219+ j.markCancelledLocked()
220+ j.mu.Unlock()
221+ keep := false
222+ if j.cancel.Reason() != CancelShutdown {
223+ keep, _ = cond()
224+ }
225+ if !keep {
226+ close(stepCancel)
227+ }
228+ }()
229+ }
230+
231+ r := j.execute(s, stepCancel)
232+ close(stopWatch)
233+ watch.Wait()
234+
235+ // Merge the result of the commands (StepsRunner.cs:347-351).
236+ if s.cmdResult != nil {
237+ m := merge(&r, *s.cmdResult)
238+ r = m
239+ }
240+ // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
241+ if r == resFailed && st.ContinueOnError != nil {
242+ coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
243+ if cerr != nil {
244+ j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
245+ j.errorf(num, "%s", cerr.Error())
246+ }
247+ if coe && cerr == nil {
248+ o := resFailed
249+ s.outcome = &o
250+ r = resSucceeded
251+ }
252+ }
253+ complete(r)
254+}
255+
256+// statusName is the job.status for a result (TaskResult.ToActionResult).
257+func statusName(r stepResult) string {
258+ switch r {
259+ case resFailed:
260+ return "failure"
261+ case resCanceled:
262+ return "cancelled"
263+ }
264+ return "success"
265+}
266+
267+// notSupported is the failure of a step that uses a feature of a later phase.
268+func notSupported(st *workflow.Step) string {
269+ switch {
270+ case st.Uses != nil:
271+ return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value)
272+ case st.TimeoutMinutes != nil:
273+ return "not supported yet: timeout-minutes"
274+ }
275+ return ""
276+}

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