1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "fmt"
7 "path/filepath"
8 "strings"
9 "sync"
10)
11
12// execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
13func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
14 num, st := s.num, s.st
15 if st.Uses != nil {
16 return j.runAction(s, stepCancel)
17 }
18 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
19 if err := j.containerReady(); err != nil {
20 return fail(err)
21 }
22 failTemplate := func(err error) stepResult {
23 j.errorf(num, "%s", templateNotValid(err, true))
24 return resFailed
25 }
26
27 if err := j.prepareFileCommands(s); err != nil {
28 return fail(err)
29 }
30 // ProcessFiles runs after the handler, even when it failed (a finally block).
31 defer s.fc.Process(s)
32
33 in, err := j.scriptInputs(s)
34 if err != nil {
35 return failTemplate(err)
36 }
37 plan, err := j.printHeader(s, in)
38 if err != nil {
39 return fail(err) // the group stays open, like the original
40 }
41 if c := j.stepContainer(); c != nil {
42 argv, err := j.containerScript(c, plan, in, s)
43 if err != nil {
44 return fail(err)
45 }
46 return j.runProc(s, argv[0], j.state.Layout().WorkRoot, argv, "", stepCancel)
47 }
48 dir, argv, err := j.scriptCommand(plan, in)
49 if err != nil {
50 return fail(err)
51 }
52 return j.startProcess(s, plan, dir, argv, stepCancel)
53}
54
55// scriptIn is what a run step evaluates before it prints its header.
56type scriptIn struct {
57 script, shell, workingDirectory string
58 hasWorkingDirectory bool
59}
60
61// scriptInputs evaluates the run, shell and working-directory keys of the step
62// (ActionRunner.cs:174-185). The shell and the directory fall back to
63// defaults.run (ScriptHandler.cs:155-180).
64func (j *job) scriptInputs(s *stepRun) (scriptIn, error) {
65 st := s.st
66 var in scriptIn
67 var err error
68 if in.script, err = j.evalString(st.Run, s.env); err != nil {
69 return in, err
70 }
71 if in.shell, err = j.evalString(st.Shell, s.env); err != nil {
72 return in, err
73 }
74 // defaults.run applies to the steps of the job only, not to the ones of a
75 // composite action (ScriptHandler.cs:158-181: ScopeName is empty).
76 top := s.parent == nil
77 if in.shell == "" && top {
78 in.shell = j.runDefaults.shell
79 }
80 if st.WorkingDirectory != nil {
81 in.hasWorkingDirectory = true
82 if in.workingDirectory, err = j.evalString(st.WorkingDirectory, s.env); err != nil {
83 return in, err
84 }
85 }
86 if !in.hasWorkingDirectory && top {
87 in.workingDirectory = j.runDefaults.workingDirectory
88 }
89 return in, nil
90}
91
92// prepareFileCommands creates the files of the step, puts their paths in the
93// github context (FileCommandManager.InitializeFiles) and writes event.json
94// (ExecutionContext.WriteWebhookPayload).
95func (j *job) prepareFileCommands(s *stepRun) error {
96 j.mu.Lock()
97 defer j.mu.Unlock()
98 if s.fc == nil {
99 s.fc = j.fc
100 }
101 files, err := s.fc.Init()
102 if err != nil {
103 return err
104 }
105 for _, c := range fileCommands {
106 j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
107 }
108 if s.n == nil || s.n.st.Uses == nil {
109 // ActionRunner.cs:149-165 sets them for every action step, a run step
110 // included (its reference is a script, so they are empty).
111 j.state.SetGitHub("action_repository", "")
112 j.state.SetGitHub("action_ref", "")
113 }
114 if ev := j.state.EventJSON(); ev != "" {
115 if p, err := j.writeEvent(ev); err == nil {
116 j.state.SetGitHub("event_path", p)
117 }
118 }
119 return nil
120}
121
122// printHeader prints the group of the step as ScriptHandler.PrintActionDetails
123// does (ScriptHandler.cs:27-137): the first line of the script, the script in
124// bright cyan, the shell, the env block of the step, and the end of the group.
125func (j *job) printHeader(s *stepRun, in scriptIn) (ShellPlan, error) {
126 num := s.num
127 j.out(num, "##[group]Run "+firstLine(in.script))
128 for _, l := range strings.Split(strings.TrimRight(strings.ReplaceAll(in.script, "\r\n", "\n"), "\n"), "\n") {
129 j.write(num, "\x1b[36;1m"+l+"\x1b[0m")
130 }
131 j.mu.Lock()
132 lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}
133 j.mu.Unlock()
134 var plan ShellPlan
135 var err error
136 if j.stepContainer() != nil {
137 plan, err = PlanShellInContainer(in.shell)
138 } else {
139 plan, err = PlanShell(in.shell, lookup)
140 }
141 if err != nil {
142 return plan, err
143 }
144 j.out(num, "shell: "+plan.ShellLine())
145 if s.env.Len() > 0 {
146 j.write(num, "env:")
147 for _, k := range s.env.Keys() {
148 v, _ := s.env.Get(k)
149 j.out(num, fmt.Sprintf(" %s: %s", k, v))
150 }
151 }
152 j.write(num, "##[endgroup]")
153 return plan, nil
154}
155
156// scriptCommand writes the script file and returns the working directory and
157// the command line (ScriptHandler.RunAsync, :140-285).
158func (j *job) scriptCommand(plan ShellPlan, in scriptIn) (dir string, argv []string, err error) {
159 j.mu.Lock()
160 workspace := j.state.GitHub("workspace")
161 j.mu.Unlock()
162 sc, err := WriteScript(j.tempRoot, plan, in.script)
163 if err != nil {
164 return "", nil, err
165 }
166 return ResolveWorkingDirectory(workspace, in.workingDirectory), sc.Argv, nil
167}
168
169// processEnv builds the environment of the process: the inherited one, the
170// step's environment, PATH with GITHUB_PATH in front, then the runtime variables
171// of the contexts, GITHUB_ACTIONS, CI and the tracking id.
172func (j *job) processEnv(stepEnv *ordered) []string {
173 j.mu.Lock()
174 runtime := j.state.RuntimeEnv()
175 prepend := j.state.Prepend()
176 j.mu.Unlock()
177 penv := newOrdered()
178 for _, kv := range j.baseEnv {
179 if k, v, ok := strings.Cut(kv, "="); ok {
180 penv.Set(k, v)
181 }
182 }
183 for _, k := range stepEnv.Keys() {
184 v, _ := stepEnv.Get(k)
185 penv.Set(k, v)
186 }
187 if len(prepend) > 0 {
188 orig, ok := stepEnv.Get("PATH")
189 if !ok {
190 orig = j.lookupPath
191 }
192 penv.Set("PATH", PrependedPath(prepend, orig))
193 }
194 for _, k := range runtime.Keys() {
195 v, _ := runtime.Get(k)
196 penv.Set(k, v)
197 }
198 penv.Set("GITHUB_ACTIONS", "true") // ProcessInvoker.cs:283
199 if _, ok := penv.Get("CI"); !ok {
200 penv.Set("CI", "true")
201 }
202 penv.Set(ProcessTrackingID, j.trackID)
203 var list []string
204 for _, k := range penv.Keys() {
205 v, _ := penv.Get(k)
206 list = append(list, k+"="+v)
207 }
208 return list
209}
210
211// startProcess runs the script and turns how it ended into the result of the
212// step.
213func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult {
214 return j.runProc(s, plan.CommandPath, dir, argv, "", stepCancel)
215}
216
217// runProc starts a process (exitDebug, when set, is the format of the debug line that
218// replaces the error about the exit code) for the step and streams its output through the
219// commands of the step. A script reports its exit code (ScriptHandler.cs:
220// 267-275); the handlers of actions only mark the step failed (NodeScriptAction
221// Handler.cs:178-184), a failing action says why itself.
222func (j *job) runProc(s *stepRun, shown, dir string, argv []string, exitDebug string, stepCancel <-chan struct{}) stepResult {
223 num := s.num
224 om := newOutputManager(s, stepCancel)
225 defer om.dispose()
226 // The process is stopped when the step is cancelled or when the output
227 // handler throws (an invalid stop-commands token).
228 procCancel := make(chan struct{})
229 fatal := make(chan struct{})
230 var fatalOnce sync.Once
231 s.cmds.onFatal = func() { fatalOnce.Do(func() { close(fatal) }) }
232 defer func() { s.cmds.onFatal = nil }()
233 watchDone := make(chan struct{})
234 defer func() { close(watchDone) }()
235 go func() {
236 select {
237 case <-stepCancel:
238 case <-fatal:
239 case <-watchDone:
240 return
241 }
242 close(procCancel)
243 }()
244 res, err := runProcess(procSpec{
245 Argv: argv, Dir: dir, Env: s.procEnv(), Credential: j.cfg.Credential,
246 OnLine: om.onData,
247 Cancel: procCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter,
248 WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
249 })
250 if err != nil {
251 j.errorf(num, "%s", startError(shown, dir, err))
252 return resFailed
253 }
254 if err := s.cmds.fatal; err != nil {
255 // An exception of the output handler (an invalid stop-commands token)
256 // ends ExecuteAsync; StepsRunner logs its message and fails the step.
257 j.errorf(num, "%s", err.Error())
258 return resFailed
259 }
260 if res.Cancelled {
261 // The process was stopped on cancellation: ExecuteAsync throws
262 // (ProcessInvoker.cs:369) and StepsRunner logs the exception
263 // (StepsRunner.cs:333-337).
264 s.logCanceled()
265 return resCanceled
266 }
267 if exitDebug != "" {
268 j.debug(num, fmt.Sprintf(exitDebug, res.ExitCode))
269 }
270 if res.ExitCode != 0 {
271 if exitDebug == "" {
272 j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
273 }
274 return resFailed
275 }
276 return resSucceeded
277}
278
279// writeEvent writes the webhook payload to RUNNER_TEMP/_github_workflow/event.json
280// (ExecutionContext.WriteWebhookPayload) and returns its path. The directory
281// is the user's: the file is replaced, never followed (see FileCommands).
282func (j *job) writeEvent(payload string) (string, error) {
283 const dir, name = "_github_workflow", "event.json"
284 if err := j.tempRoot.MkdirAll(dir, 0o755); err != nil {
285 return "", err
286 }
287 if c := j.cfg.Credential; c != nil {
288 _ = j.tempRoot.Lchown(dir, int(c.Uid), int(c.Gid))
289 }
290 sub, err := j.tempRoot.OpenRoot(dir)
291 if err != nil {
292 return "", err
293 }
294 defer func() { _ = sub.Close() }()
295 var owner *[2]int
296 if c := j.cfg.Credential; c != nil {
297 owner = &[2]int{int(c.Uid), int(c.Gid)}
298 }
299 if err := createExcl(sub, name, []byte(payload), 0o644, owner); err != nil {
300 return "", err
301 }
302 return filepath.Join(j.state.Layout().Temp, dir, name), nil
303}