detached 41c9e28

feat(executor): run the run steps of a job: contexts, conditions, env, file commands, workflow commands, cancellation

commit
41c9e28ad1513a51e846398e618e820fd7244425
author
brice · · 2026-10-12 18:14 UTC
parent
ac7c7b5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +1612 -0
A internal/executor/cancel.go +52 -0
@@ -0,0 +1,52 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "sync"
6+
7+// CancelReason says why a job is stopped.
8+type CancelReason int
9+
10+// Cancel reasons.
11+const (
12+ // CancelUser is a cancellation by the owner or by fail-fast: the job ends
13+ // cancelled.
14+ CancelUser CancelReason = iota + 1
15+ // CancelTimeout is the job timeout: the job ends timed_out.
16+ CancelTimeout
17+ // CancelShutdown is the runner stopping: the job ends cancelled with
18+ // RunnerShutdown set, and the steps that did not start are skipped.
19+ CancelShutdown
20+)
21+
22+// Canceller stops a job. The first Cancel wins; later calls are ignored (the
23+// server only sends the first reason too).
24+type Canceller struct {
25+ once sync.Once
26+ ch chan struct{}
27+ mu sync.Mutex
28+ reason CancelReason
29+}
30+
31+// NewCanceller returns a Canceller that has not fired.
32+func NewCanceller() *Canceller { return &Canceller{ch: make(chan struct{})} }
33+
34+// Cancel stops the job for reason.
35+func (c *Canceller) Cancel(reason CancelReason) {
36+ c.once.Do(func() {
37+ c.mu.Lock()
38+ c.reason = reason
39+ c.mu.Unlock()
40+ close(c.ch)
41+ })
42+}
43+
44+// Done is closed when the job is cancelled.
45+func (c *Canceller) Done() <-chan struct{} { return c.ch }
46+
47+// Reason returns why the job was cancelled; zero before Done is closed.
48+func (c *Canceller) Reason() CancelReason {
49+ c.mu.Lock()
50+ defer c.mu.Unlock()
51+ return c.reason
52+}
@@ -0,0 +1,52 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "sync"
6+
7+// CancelReason says why a job is stopped.
8+type CancelReason int
9+
10+// Cancel reasons.
11+const (
12+ // CancelUser is a cancellation by the owner or by fail-fast: the job ends
13+ // cancelled.
14+ CancelUser CancelReason = iota + 1
15+ // CancelTimeout is the job timeout: the job ends timed_out.
16+ CancelTimeout
17+ // CancelShutdown is the runner stopping: the job ends cancelled with
18+ // RunnerShutdown set, and the steps that did not start are skipped.
19+ CancelShutdown
20+)
21+
22+// Canceller stops a job. The first Cancel wins; later calls are ignored (the
23+// server only sends the first reason too).
24+type Canceller struct {
25+ once sync.Once
26+ ch chan struct{}
27+ mu sync.Mutex
28+ reason CancelReason
29+}
30+
31+// NewCanceller returns a Canceller that has not fired.
32+func NewCanceller() *Canceller { return &Canceller{ch: make(chan struct{})} }
33+
34+// Cancel stops the job for reason.
35+func (c *Canceller) Cancel(reason CancelReason) {
36+ c.once.Do(func() {
37+ c.mu.Lock()
38+ c.reason = reason
39+ c.mu.Unlock()
40+ close(c.ch)
41+ })
42+}
43+
44+// Done is closed when the job is cancelled.
45+func (c *Canceller) Done() <-chan struct{} { return c.ch }
46+
47+// Reason returns why the job was cancelled; zero before Done is closed.
48+func (c *Canceller) Reason() CancelReason {
49+ c.mu.Lock()
50+ defer c.mu.Unlock()
51+ return c.reason
52+}
A internal/executor/commands.go +297 -0
@@ -0,0 +1,297 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "unicode"
9+)
10+
11+// Workflow commands in the output of a step: a port of ActionCommand.cs
12+// (Runner.Common) and of the parts of ActionCommandManager.cs that CI-1 needs.
13+// Masking (add-mask) and problem matchers are CI-2; they are announced in the
14+// log and otherwise ignored.
15+
16+type escapeMapping struct{ token, replacement string }
17+
18+// _escapeMappings, _escapeDataMappings, _escapePropertyMappings of
19+// ActionCommand.cs:11-35, in order: the replacements run one after the other.
20+var (
21+ escapeMappings = []escapeMapping{{";", "%3B"}, {"\r", "%0D"}, {"\n", "%0A"}, {"]", "%5D"}, {"%", "%25"}}
22+ escapeData = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {"%", "%25"}}
23+ escapeProperty = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {":", "%3A"}, {",", "%2C"}, {"%", "%25"}}
24+)
25+
26+func unescape(s string, maps []escapeMapping) string {
27+ for _, m := range maps {
28+ s = strings.ReplaceAll(s, m.replacement, m.token)
29+ }
30+ return s
31+}
32+
33+// actionCommand is a parsed workflow command.
34+type actionCommand struct {
35+ Name string
36+ Props map[string]string // keys compared case-insensitively (lower-cased)
37+ Data string
38+}
39+
40+func (c *actionCommand) prop(name string) string { return c.Props[strings.ToLower(name)] }
41+
42+const commandKey = "::"
43+
44+// parseCommandV2 is ActionCommand.TryParseV2 (ActionCommand.cs:57): "::name
45+// key=value,key=value::data".
46+func parseCommandV2(message string, registered func(string) bool) (*actionCommand, bool) {
47+ // .NET TrimStart() trims Unicode white space.
48+ message = strings.TrimLeftFunc(message, unicode.IsSpace)
49+ if !strings.HasPrefix(message, commandKey) {
50+ return nil, false
51+ }
52+ end := strings.Index(message[len(commandKey):], commandKey)
53+ if end < 0 {
54+ return nil, false
55+ }
56+ end += len(commandKey)
57+ info := message[len(commandKey):end]
58+ name, rest, hasSpace := strings.Cut(info, " ")
59+ if !registered(name) {
60+ return nil, false
61+ }
62+ cmd := &actionCommand{Name: name, Props: map[string]string{}}
63+ if hasSpace && len(info) > len(name) { // spaceIndex > 0
64+ for _, p := range strings.Split(strings.TrimSpace(rest), ",") {
65+ if p == "" {
66+ continue
67+ }
68+ k, v, ok := strings.Cut(p, "=")
69+ if ok && k != "" && v != "" { // Split(RemoveEmptyEntries): both parts non-empty
70+ cmd.Props[strings.ToLower(k)] = unescape(v, escapeProperty)
71+ }
72+ }
73+ }
74+ cmd.Data = unescape(message[end+len(commandKey):], escapeData)
75+ return cmd, true
76+}
77+
78+// parseCommandV1 is ActionCommand.TryParse (ActionCommand.cs:127): the legacy
79+// "##[name key=value;key=value]data".
80+func parseCommandV1(message string, registered func(string) bool) (*actionCommand, bool) {
81+ const prefix = "##["
82+ pi := strings.Index(message, prefix)
83+ if pi < 0 {
84+ return nil, false
85+ }
86+ rb := strings.IndexByte(message[pi:], ']')
87+ if rb < 0 {
88+ return nil, false
89+ }
90+ rb += pi
91+ info := message[pi+len(prefix) : rb]
92+ name, rest, hasSpace := strings.Cut(info, " ")
93+ if !registered(name) {
94+ return nil, false
95+ }
96+ cmd := &actionCommand{Name: name, Props: map[string]string{}}
97+ if hasSpace && len(info) > len(name) {
98+ for _, p := range strings.Split(rest, ";") {
99+ if p == "" {
100+ continue
101+ }
102+ k, v, ok := strings.Cut(p, "=")
103+ if ok && k != "" && v != "" {
104+ cmd.Props[strings.ToLower(k)] = unescape(v, escapeMappings)
105+ }
106+ }
107+ }
108+ cmd.Data = unescape(message[rb+1:], escapeMappings)
109+ return cmd, true
110+}
111+
112+// Messages of Constants.cs:234-236.
113+const (
114+ unsupportedCommandMessage = "The `%s` command is deprecated and will be disabled soon. Please upgrade to using Environment Files. For more information see: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/"
115+ unsupportedCommandMessageDisabled = "The `%s` command is disabled. Please upgrade to using Environment Files or opt into unsecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_COMMANDS` environment variable to `true`. For more information see: https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/"
116+ unsupportedStopTokenDisabled = "You cannot use a endToken that is an empty string, the string 'pause-logging', or another workflow command. For more information see: https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#example-stopping-and-starting-workflow-commands or opt into insecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS` environment variable to `true`."
117+)
118+
119+// commandNames are the commands the executor registers, in ActionCommandManager
120+// (the names of the extensions at ActionCommandManager.cs). add-mask,
121+// add-matcher and remove-matcher are recognised and reported as not supported
122+// yet.
123+var commandNames = []string{
124+ "set-env", "set-output", "save-state", "add-mask", "add-path", "add-matcher", "remove-matcher",
125+ "debug", "warning", "error", "notice", "group", "endgroup", "echo", "stop-commands",
126+}
127+
128+// commandHost is what commands act on: the step that is running.
129+type commandHost interface {
130+ // Out writes a log line (several when it contains line feeds).
131+ Out(text string)
132+ // Tagged writes a ##[tag]message line.
133+ Tagged(tag, message string)
134+ Debug(msg string)
135+ SetOutput(name, value string)
136+ SaveState(name, value string)
137+ SetEnv(name, value string)
138+ AddPath(path string)
139+ CommandFailed()
140+ // EnvFlag reads a variable of the env context, then of the process.
141+ EnvFlag(name string) bool
142+}
143+
144+// commandManager is ActionCommandManager: one per step.
145+type commandManager struct {
146+ host commandHost
147+ registered map[string]bool
148+ stopped bool
149+ stopToken string
150+ echo bool
151+}
152+
153+func newCommandManager(h commandHost, echo bool) *commandManager {
154+ m := &commandManager{host: h, registered: map[string]bool{}, echo: echo}
155+ for _, n := range commandNames {
156+ m.registered[strings.ToLower(n)] = true
157+ }
158+ return m
159+}
160+
161+func (m *commandManager) isRegistered(name string) bool { return m.registered[strings.ToLower(name)] }
162+
163+// TryProcess handles one line of output. It returns true when the line was a
164+// command and must not be logged as plain text (ActionCommandManager.cs:61).
165+func (m *commandManager) TryProcess(input string) bool {
166+ if input == "" {
167+ return false
168+ }
169+ cmd, ok := parseCommandV2(input, m.isRegistered)
170+ if !ok {
171+ cmd, ok = parseCommandV1(input, m.isRegistered)
172+ }
173+ if !ok {
174+ return false
175+ }
176+ name := strings.ToLower(cmd.Name)
177+ if m.stopped {
178+ if m.stopToken != "" && strings.EqualFold(cmd.Name, m.stopToken) {
179+ m.host.Out(input)
180+ m.host.Debug("Resume processing commands")
181+ delete(m.registered, strings.ToLower(m.stopToken))
182+ m.stopped, m.stopToken = false, ""
183+ return true
184+ }
185+ m.host.Debug(fmt.Sprintf("Process commands has been stopped and waiting for '##[%s]' to resume.", m.stopToken))
186+ return false
187+ }
188+ if name == "stop-commands" {
189+ if err := m.validateStopToken(cmd.Data); err != nil {
190+ m.fail(input, false, err)
191+ return true
192+ }
193+ m.stopToken, m.stopped = cmd.Data, true
194+ m.registered[strings.ToLower(cmd.Data)] = true
195+ m.host.Out(input)
196+ m.host.Debug("Paused processing commands until the token you called ::stopCommands:: with is received")
197+ return true
198+ }
199+ omitEcho := name == "debug" || name == "warning" || name == "error" || name == "notice" || name == "add-mask"
200+ if m.echo && !omitEcho {
201+ m.host.Out(input)
202+ }
203+ if err := m.run(name, cmd); err != nil {
204+ m.fail(input, omitEcho, err)
205+ }
206+ return true
207+}
208+
209+func (m *commandManager) fail(input string, omitEcho bool, err error) {
210+ info := input
211+ if omitEcho {
212+ info = ""
213+ }
214+ m.host.Tagged("##[error]", fmt.Sprintf("Unable to process command '%s' successfully.", info))
215+ m.host.Tagged("##[error]", err.Error())
216+ m.host.CommandFailed()
217+}
218+
219+func (m *commandManager) validateStopToken(token string) error {
220+ invalid := m.isRegistered(token) || token == "" || strings.EqualFold(token, "pause-logging")
221+ if invalid && !m.host.EnvFlag("ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS") {
222+ return fmt.Errorf("%s", unsupportedStopTokenDisabled)
223+ }
224+ return nil
225+}
226+
227+func (m *commandManager) run(name string, c *actionCommand) error {
228+ h := m.host
229+ switch name {
230+ case "group":
231+ h.Out("##[group]" + c.Data) // GroupingCommandExtension, ActionCommandManager.cs:802
232+ case "endgroup":
233+ h.Out("##[endgroup]")
234+ case "debug":
235+ h.Debug(c.Data)
236+ case "warning", "error", "notice":
237+ // IssueCommandExtension: the annotation is logged; ermite does not store
238+ // annotations yet (CI-2), so file, line and title are dropped.
239+ h.Tagged("##["+name+"]", c.Data)
240+ case "echo":
241+ if c.Data == "" {
242+ return fmt.Errorf("Value cannot be null or empty. (Parameter 'value')")
243+ }
244+ switch strings.ToUpper(strings.TrimSpace(c.Data)) {
245+ case "ON":
246+ m.echo = true
247+ h.Debug("Setting echo command value to 'on'")
248+ case "OFF":
249+ m.echo = false
250+ h.Debug("Setting echo command value to 'off'")
251+ default:
252+ return fmt.Errorf("Invalid echo command value. Possible values can be: 'on', 'off'. Current value is: '%s'.", c.Data)
253+ }
254+ case "set-output":
255+ h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
256+ n := c.prop("name")
257+ if n == "" {
258+ return fmt.Errorf("Required field 'name' is missing in ##[set-output] command.")
259+ }
260+ h.SetOutput(n, c.Data)
261+ h.Debug(fmt.Sprintf("steps.<id>.outputs.%s='%s'", n, c.Data))
262+ case "save-state":
263+ h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
264+ n := c.prop("name")
265+ if n == "" {
266+ return fmt.Errorf("Required field 'name' is missing in ##[save-state] command.")
267+ }
268+ h.SaveState(n, c.Data)
269+ h.Debug(fmt.Sprintf("Save intra-action state %s = %s", n, c.Data))
270+ case "set-env":
271+ if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
272+ return fmt.Errorf(unsupportedCommandMessageDisabled, name)
273+ }
274+ n := c.prop("name")
275+ if n == "" {
276+ return fmt.Errorf("Required field 'name' is missing in ##[set-env] command.")
277+ }
278+ if b := blockedEnvName(n); b != "" {
279+ h.Tagged("##[error]", fmt.Sprintf("Can't update %s environment variable using ::set-env:: command.", b))
280+ return nil
281+ }
282+ h.SetEnv(n, c.Data)
283+ h.Debug(fmt.Sprintf("%s='%s'", n, c.Data))
284+ case "add-path":
285+ if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
286+ return fmt.Errorf(unsupportedCommandMessageDisabled, name)
287+ }
288+ if c.Data == "" {
289+ return fmt.Errorf("Value cannot be null or empty. (Parameter 'path')")
290+ }
291+ h.AddPath(c.Data)
292+ case "add-mask", "add-matcher", "remove-matcher":
293+ // Not supported yet (CI-2). The value of add-mask is not echoed.
294+ h.Tagged("##[warning]", fmt.Sprintf("The `%s` command is not supported yet by this runner.", name))
295+ }
296+ return nil
297+}
@@ -0,0 +1,297 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "unicode"
9+)
10+
11+// Workflow commands in the output of a step: a port of ActionCommand.cs
12+// (Runner.Common) and of the parts of ActionCommandManager.cs that CI-1 needs.
13+// Masking (add-mask) and problem matchers are CI-2; they are announced in the
14+// log and otherwise ignored.
15+
16+type escapeMapping struct{ token, replacement string }
17+
18+// _escapeMappings, _escapeDataMappings, _escapePropertyMappings of
19+// ActionCommand.cs:11-35, in order: the replacements run one after the other.
20+var (
21+ escapeMappings = []escapeMapping{{";", "%3B"}, {"\r", "%0D"}, {"\n", "%0A"}, {"]", "%5D"}, {"%", "%25"}}
22+ escapeData = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {"%", "%25"}}
23+ escapeProperty = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {":", "%3A"}, {",", "%2C"}, {"%", "%25"}}
24+)
25+
26+func unescape(s string, maps []escapeMapping) string {
27+ for _, m := range maps {
28+ s = strings.ReplaceAll(s, m.replacement, m.token)
29+ }
30+ return s
31+}
32+
33+// actionCommand is a parsed workflow command.
34+type actionCommand struct {
35+ Name string
36+ Props map[string]string // keys compared case-insensitively (lower-cased)
37+ Data string
38+}
39+
40+func (c *actionCommand) prop(name string) string { return c.Props[strings.ToLower(name)] }
41+
42+const commandKey = "::"
43+
44+// parseCommandV2 is ActionCommand.TryParseV2 (ActionCommand.cs:57): "::name
45+// key=value,key=value::data".
46+func parseCommandV2(message string, registered func(string) bool) (*actionCommand, bool) {
47+ // .NET TrimStart() trims Unicode white space.
48+ message = strings.TrimLeftFunc(message, unicode.IsSpace)
49+ if !strings.HasPrefix(message, commandKey) {
50+ return nil, false
51+ }
52+ end := strings.Index(message[len(commandKey):], commandKey)
53+ if end < 0 {
54+ return nil, false
55+ }
56+ end += len(commandKey)
57+ info := message[len(commandKey):end]
58+ name, rest, hasSpace := strings.Cut(info, " ")
59+ if !registered(name) {
60+ return nil, false
61+ }
62+ cmd := &actionCommand{Name: name, Props: map[string]string{}}
63+ if hasSpace && len(info) > len(name) { // spaceIndex > 0
64+ for _, p := range strings.Split(strings.TrimSpace(rest), ",") {
65+ if p == "" {
66+ continue
67+ }
68+ k, v, ok := strings.Cut(p, "=")
69+ if ok && k != "" && v != "" { // Split(RemoveEmptyEntries): both parts non-empty
70+ cmd.Props[strings.ToLower(k)] = unescape(v, escapeProperty)
71+ }
72+ }
73+ }
74+ cmd.Data = unescape(message[end+len(commandKey):], escapeData)
75+ return cmd, true
76+}
77+
78+// parseCommandV1 is ActionCommand.TryParse (ActionCommand.cs:127): the legacy
79+// "##[name key=value;key=value]data".
80+func parseCommandV1(message string, registered func(string) bool) (*actionCommand, bool) {
81+ const prefix = "##["
82+ pi := strings.Index(message, prefix)
83+ if pi < 0 {
84+ return nil, false
85+ }
86+ rb := strings.IndexByte(message[pi:], ']')
87+ if rb < 0 {
88+ return nil, false
89+ }
90+ rb += pi
91+ info := message[pi+len(prefix) : rb]
92+ name, rest, hasSpace := strings.Cut(info, " ")
93+ if !registered(name) {
94+ return nil, false
95+ }
96+ cmd := &actionCommand{Name: name, Props: map[string]string{}}
97+ if hasSpace && len(info) > len(name) {
98+ for _, p := range strings.Split(rest, ";") {
99+ if p == "" {
100+ continue
101+ }
102+ k, v, ok := strings.Cut(p, "=")
103+ if ok && k != "" && v != "" {
104+ cmd.Props[strings.ToLower(k)] = unescape(v, escapeMappings)
105+ }
106+ }
107+ }
108+ cmd.Data = unescape(message[rb+1:], escapeMappings)
109+ return cmd, true
110+}
111+
112+// Messages of Constants.cs:234-236.
113+const (
114+ unsupportedCommandMessage = "The `%s` command is deprecated and will be disabled soon. Please upgrade to using Environment Files. For more information see: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/"
115+ unsupportedCommandMessageDisabled = "The `%s` command is disabled. Please upgrade to using Environment Files or opt into unsecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_COMMANDS` environment variable to `true`. For more information see: https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/"
116+ unsupportedStopTokenDisabled = "You cannot use a endToken that is an empty string, the string 'pause-logging', or another workflow command. For more information see: https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#example-stopping-and-starting-workflow-commands or opt into insecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS` environment variable to `true`."
117+)
118+
119+// commandNames are the commands the executor registers, in ActionCommandManager
120+// (the names of the extensions at ActionCommandManager.cs). add-mask,
121+// add-matcher and remove-matcher are recognised and reported as not supported
122+// yet.
123+var commandNames = []string{
124+ "set-env", "set-output", "save-state", "add-mask", "add-path", "add-matcher", "remove-matcher",
125+ "debug", "warning", "error", "notice", "group", "endgroup", "echo", "stop-commands",
126+}
127+
128+// commandHost is what commands act on: the step that is running.
129+type commandHost interface {
130+ // Out writes a log line (several when it contains line feeds).
131+ Out(text string)
132+ // Tagged writes a ##[tag]message line.
133+ Tagged(tag, message string)
134+ Debug(msg string)
135+ SetOutput(name, value string)
136+ SaveState(name, value string)
137+ SetEnv(name, value string)
138+ AddPath(path string)
139+ CommandFailed()
140+ // EnvFlag reads a variable of the env context, then of the process.
141+ EnvFlag(name string) bool
142+}
143+
144+// commandManager is ActionCommandManager: one per step.
145+type commandManager struct {
146+ host commandHost
147+ registered map[string]bool
148+ stopped bool
149+ stopToken string
150+ echo bool
151+}
152+
153+func newCommandManager(h commandHost, echo bool) *commandManager {
154+ m := &commandManager{host: h, registered: map[string]bool{}, echo: echo}
155+ for _, n := range commandNames {
156+ m.registered[strings.ToLower(n)] = true
157+ }
158+ return m
159+}
160+
161+func (m *commandManager) isRegistered(name string) bool { return m.registered[strings.ToLower(name)] }
162+
163+// TryProcess handles one line of output. It returns true when the line was a
164+// command and must not be logged as plain text (ActionCommandManager.cs:61).
165+func (m *commandManager) TryProcess(input string) bool {
166+ if input == "" {
167+ return false
168+ }
169+ cmd, ok := parseCommandV2(input, m.isRegistered)
170+ if !ok {
171+ cmd, ok = parseCommandV1(input, m.isRegistered)
172+ }
173+ if !ok {
174+ return false
175+ }
176+ name := strings.ToLower(cmd.Name)
177+ if m.stopped {
178+ if m.stopToken != "" && strings.EqualFold(cmd.Name, m.stopToken) {
179+ m.host.Out(input)
180+ m.host.Debug("Resume processing commands")
181+ delete(m.registered, strings.ToLower(m.stopToken))
182+ m.stopped, m.stopToken = false, ""
183+ return true
184+ }
185+ m.host.Debug(fmt.Sprintf("Process commands has been stopped and waiting for '##[%s]' to resume.", m.stopToken))
186+ return false
187+ }
188+ if name == "stop-commands" {
189+ if err := m.validateStopToken(cmd.Data); err != nil {
190+ m.fail(input, false, err)
191+ return true
192+ }
193+ m.stopToken, m.stopped = cmd.Data, true
194+ m.registered[strings.ToLower(cmd.Data)] = true
195+ m.host.Out(input)
196+ m.host.Debug("Paused processing commands until the token you called ::stopCommands:: with is received")
197+ return true
198+ }
199+ omitEcho := name == "debug" || name == "warning" || name == "error" || name == "notice" || name == "add-mask"
200+ if m.echo && !omitEcho {
201+ m.host.Out(input)
202+ }
203+ if err := m.run(name, cmd); err != nil {
204+ m.fail(input, omitEcho, err)
205+ }
206+ return true
207+}
208+
209+func (m *commandManager) fail(input string, omitEcho bool, err error) {
210+ info := input
211+ if omitEcho {
212+ info = ""
213+ }
214+ m.host.Tagged("##[error]", fmt.Sprintf("Unable to process command '%s' successfully.", info))
215+ m.host.Tagged("##[error]", err.Error())
216+ m.host.CommandFailed()
217+}
218+
219+func (m *commandManager) validateStopToken(token string) error {
220+ invalid := m.isRegistered(token) || token == "" || strings.EqualFold(token, "pause-logging")
221+ if invalid && !m.host.EnvFlag("ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS") {
222+ return fmt.Errorf("%s", unsupportedStopTokenDisabled)
223+ }
224+ return nil
225+}
226+
227+func (m *commandManager) run(name string, c *actionCommand) error {
228+ h := m.host
229+ switch name {
230+ case "group":
231+ h.Out("##[group]" + c.Data) // GroupingCommandExtension, ActionCommandManager.cs:802
232+ case "endgroup":
233+ h.Out("##[endgroup]")
234+ case "debug":
235+ h.Debug(c.Data)
236+ case "warning", "error", "notice":
237+ // IssueCommandExtension: the annotation is logged; ermite does not store
238+ // annotations yet (CI-2), so file, line and title are dropped.
239+ h.Tagged("##["+name+"]", c.Data)
240+ case "echo":
241+ if c.Data == "" {
242+ return fmt.Errorf("Value cannot be null or empty. (Parameter 'value')")
243+ }
244+ switch strings.ToUpper(strings.TrimSpace(c.Data)) {
245+ case "ON":
246+ m.echo = true
247+ h.Debug("Setting echo command value to 'on'")
248+ case "OFF":
249+ m.echo = false
250+ h.Debug("Setting echo command value to 'off'")
251+ default:
252+ return fmt.Errorf("Invalid echo command value. Possible values can be: 'on', 'off'. Current value is: '%s'.", c.Data)
253+ }
254+ case "set-output":
255+ h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
256+ n := c.prop("name")
257+ if n == "" {
258+ return fmt.Errorf("Required field 'name' is missing in ##[set-output] command.")
259+ }
260+ h.SetOutput(n, c.Data)
261+ h.Debug(fmt.Sprintf("steps.<id>.outputs.%s='%s'", n, c.Data))
262+ case "save-state":
263+ h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
264+ n := c.prop("name")
265+ if n == "" {
266+ return fmt.Errorf("Required field 'name' is missing in ##[save-state] command.")
267+ }
268+ h.SaveState(n, c.Data)
269+ h.Debug(fmt.Sprintf("Save intra-action state %s = %s", n, c.Data))
270+ case "set-env":
271+ if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
272+ return fmt.Errorf(unsupportedCommandMessageDisabled, name)
273+ }
274+ n := c.prop("name")
275+ if n == "" {
276+ return fmt.Errorf("Required field 'name' is missing in ##[set-env] command.")
277+ }
278+ if b := blockedEnvName(n); b != "" {
279+ h.Tagged("##[error]", fmt.Sprintf("Can't update %s environment variable using ::set-env:: command.", b))
280+ return nil
281+ }
282+ h.SetEnv(n, c.Data)
283+ h.Debug(fmt.Sprintf("%s='%s'", n, c.Data))
284+ case "add-path":
285+ if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
286+ return fmt.Errorf(unsupportedCommandMessageDisabled, name)
287+ }
288+ if c.Data == "" {
289+ return fmt.Errorf("Value cannot be null or empty. (Parameter 'path')")
290+ }
291+ h.AddPath(c.Data)
292+ case "add-mask", "add-matcher", "remove-matcher":
293+ // Not supported yet (CI-2). The value of add-mask is not echoed.
294+ h.Tagged("##[warning]", fmt.Sprintf("The `%s` command is not supported yet by this runner.", name))
295+ }
296+ return nil
297+}
A internal/executor/proc.go +290 -0
@@ -0,0 +1,290 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "sort"
13+ "strconv"
14+ "strings"
15+ "sync"
16+ "syscall"
17+ "time"
18+ "unicode/utf8"
19+)
20+
21+// Cancellation timings of the official runner (Runner.Sdk/ProcessInvoker.cs:32):
22+// SIGINT at once, SIGTERM after 7.5 s, kill 2.5 s later.
23+const (
24+ DefaultTermAfter = 7500 * time.Millisecond
25+ DefaultKillAfter = 2500 * time.Millisecond
26+ // DefaultWaitDelay is how long the executor waits for the output pipes to
27+ // close after the shell exited, before it gives up on background children
28+ // that still hold them: ProcessInvoker.ProcessExitedHandler waits 5 s
29+ // (ProcessInvoker.cs:491).
30+ DefaultWaitDelay = 5 * time.Second
31+)
32+
33+// lineWriter turns a byte stream into lines like StreamReader.ReadLine: "\n",
34+// "\r" and "\r\n" end a line, and invalid UTF-8 becomes U+FFFD. It calls fn
35+// for every complete line, in order, from the goroutine that writes.
36+type lineWriter struct {
37+ fn func(line string)
38+ buf []byte
39+ pending bool // the last byte seen was "\r": a following "\n" is part of it
40+}
41+
42+func (w *lineWriter) Write(p []byte) (int, error) {
43+ n := len(p)
44+ for len(p) > 0 {
45+ if w.pending {
46+ w.pending = false
47+ if p[0] == '\n' {
48+ p = p[1:]
49+ continue
50+ }
51+ }
52+ i := bytes.IndexAny(p, "\r\n")
53+ if i < 0 {
54+ w.buf = append(w.buf, p...)
55+ break
56+ }
57+ w.buf = append(w.buf, p[:i]...)
58+ w.emit()
59+ w.pending = p[i] == '\r'
60+ p = p[i+1:]
61+ }
62+ return n, nil
63+}
64+
65+func (w *lineWriter) emit() {
66+ b := w.buf
67+ if !utf8.Valid(b) {
68+ b = bytes.ToValidUTF8(b, []byte("\ufffd"))
69+ }
70+ line := string(b)
71+ w.buf = w.buf[:0]
72+ w.fn(line)
73+}
74+
75+// Flush emits the last line when the stream ended without a terminator.
76+func (w *lineWriter) Flush() {
77+ if len(w.buf) > 0 {
78+ w.emit()
79+ }
80+}
81+
82+// procSpec describes one process to run.
83+type procSpec struct {
84+ Argv []string
85+ Dir string
86+ Env []string
87+ // Credential, when set, is the user the process runs as.
88+ Credential *syscall.Credential
89+ // OnLine receives the lines of stdout and stderr (one stream, in order).
90+ OnLine func(line string)
91+ // Cancel starts the SIGINT, SIGTERM, SIGKILL escalation when closed.
92+ Cancel <-chan struct{}
93+
94+ TermAfter, KillAfter, WaitDelay time.Duration
95+ // Signal delivers a signal to a process group; tests replace it.
96+ Signal func(pgid int, sig syscall.Signal)
97+}
98+
99+// procResult is how a process ended.
100+type procResult struct {
101+ ExitCode int
102+ Cancelled bool // Cancel was closed while the process ran
103+}
104+
105+// runProcess starts the process in its own process group, streams its output
106+// and waits. It is ProcessInvoker.ExecuteAsync for one process: stdin is
107+// closed, and a closed Cancel starts the escalation. After the shell exits the
108+// group is not touched (background children survive, like on a hosted runner
109+// until the orphan cleanup of "Complete job").
110+func runProcess(spec procSpec) (procResult, error) {
111+ if spec.TermAfter == 0 {
112+ spec.TermAfter = DefaultTermAfter
113+ }
114+ if spec.KillAfter == 0 {
115+ spec.KillAfter = DefaultKillAfter
116+ }
117+ if spec.WaitDelay == 0 {
118+ spec.WaitDelay = DefaultWaitDelay
119+ }
120+ if spec.Signal == nil {
121+ spec.Signal = func(pgid int, sig syscall.Signal) { _ = syscall.Kill(-pgid, sig) }
122+ }
123+ cmd := exec.Command(spec.Argv[0], spec.Argv[1:]...)
124+ cmd.Dir = spec.Dir
125+ cmd.Env = spec.Env
126+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: spec.Credential}
127+ lw := &lineWriter{fn: spec.OnLine}
128+ cmd.Stdout, cmd.Stderr = lw, lw // the same writer: one pipe, order kept
129+ cmd.WaitDelay = spec.WaitDelay
130+ if err := cmd.Start(); err != nil {
131+ return procResult{}, err
132+ }
133+ pid := cmd.Process.Pid
134+ setOOMScoreAdj(pid)
135+
136+ var mu sync.Mutex
137+ done := false
138+ cancelled := false
139+ var timers []*time.Timer
140+ stopWatch := make(chan struct{})
141+ watchDone := make(chan struct{})
142+ go func() {
143+ defer close(watchDone)
144+ select {
145+ case <-spec.Cancel:
146+ case <-stopWatch:
147+ return
148+ }
149+ mu.Lock()
150+ defer mu.Unlock()
151+ if done {
152+ return
153+ }
154+ cancelled = true
155+ spec.Signal(pid, syscall.SIGINT)
156+ send := func(sig syscall.Signal) func() {
157+ return func() {
158+ mu.Lock()
159+ defer mu.Unlock()
160+ if !done { // the group id could be reused once the shell is reaped
161+ spec.Signal(pid, sig)
162+ }
163+ }
164+ }
165+ timers = append(timers,
166+ time.AfterFunc(spec.TermAfter, send(syscall.SIGTERM)),
167+ time.AfterFunc(spec.TermAfter+spec.KillAfter, send(syscall.SIGKILL)))
168+ }()
169+
170+ err := cmd.Wait()
171+ mu.Lock()
172+ done = true
173+ for _, t := range timers {
174+ t.Stop()
175+ }
176+ was := cancelled
177+ mu.Unlock()
178+ close(stopWatch)
179+ <-watchDone
180+ lw.Flush()
181+
182+ res := procResult{Cancelled: was}
183+ var ee *exec.ExitError
184+ switch {
185+ case err == nil, errors.Is(err, exec.ErrWaitDelay):
186+ case errors.As(err, &ee):
187+ res.ExitCode = ee.ExitCode()
188+ if res.ExitCode < 0 { // killed by a signal: 128+signal, as .NET reports it
189+ if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
190+ res.ExitCode = 128 + int(ws.Signal())
191+ } else {
192+ res.ExitCode = 1
193+ }
194+ }
195+ default:
196+ return res, err
197+ }
198+ return res, nil
199+}
200+
201+// setOOMScoreAdj makes the step process a likelier OOM victim than the
202+// executor (ProcessInvoker.DecreaseProcessPriority, ProcessInvoker.cs:575).
203+func setOOMScoreAdj(pid int) {
204+ _ = os.WriteFile(fmt.Sprintf("/proc/%d/oom_score_adj", pid), []byte("500"), 0o644)
205+}
206+
207+// startError turns the error of a failed start into the text of .NET's
208+// Win32Exception, which the official runner logs (the corpus recording
209+// "working directory that does not exist" has it).
210+func startError(shellPath, dir string, err error) string {
211+ if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOENT) {
212+ return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. No such file or directory", shellPath, dir)
213+ }
214+ if errors.Is(err, syscall.ENOTDIR) {
215+ return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. Not a directory", shellPath, dir)
216+ }
217+ if errors.Is(err, os.ErrPermission) {
218+ return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. Permission denied", shellPath, dir)
219+ }
220+ return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. %v", shellPath, dir, err)
221+}
222+
223+// ProcessTrackingID is the variable that marks every process of a job so that
224+// "Complete job" can find the orphans (Constants.ProcessTrackingId).
225+const ProcessTrackingID = "RUNNER_TRACKING_ID"
226+
227+// procEntry is a process seen in /proc.
228+type procEntry struct {
229+ PID int
230+ Name string // comm, as .NET's ProcessName
231+}
232+
233+func (p procEntry) key() string { return strconv.Itoa(p.PID) + "_" + p.Name }
234+
235+// snapshotProcesses lists the processes (JobExtension.SnapshotProcesses).
236+func snapshotProcesses() []procEntry {
237+ ents, err := os.ReadDir("/proc")
238+ if err != nil {
239+ return nil
240+ }
241+ var out []procEntry
242+ for _, e := range ents {
243+ pid, err := strconv.Atoi(e.Name())
244+ if err != nil {
245+ continue
246+ }
247+ comm, err := os.ReadFile("/proc/" + e.Name() + "/comm")
248+ if err != nil {
249+ continue
250+ }
251+ out = append(out, procEntry{pid, strings.TrimSuffix(string(comm), "\n")})
252+ }
253+ sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
254+ return out
255+}
256+
257+// processTrackingValue reads RUNNER_TRACKING_ID from the environment of a
258+// process; "" when it cannot be read.
259+func processTrackingValue(pid int) string {
260+ b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ")
261+ if err != nil {
262+ return ""
263+ }
264+ prefix := []byte(ProcessTrackingID + "=")
265+ for _, kv := range bytes.Split(b, []byte{0}) {
266+ if bytes.HasPrefix(kv, prefix) {
267+ return string(kv[len(prefix):])
268+ }
269+ }
270+ return ""
271+}
272+
273+// killOrphans kills the processes that carry the tracking id and were not
274+// there before the job (JobExtension.FinalizeJob, JobExtension.cs:132-180).
275+// logf receives the "Terminate orphan process" lines.
276+func killOrphans(id string, before map[string]bool, logf func(string)) {
277+ self := os.Getpid()
278+ for _, p := range snapshotProcesses() {
279+ if p.PID == self || before[p.key()] {
280+ continue
281+ }
282+ if !strings.EqualFold(processTrackingValue(p.PID), id) {
283+ continue
284+ }
285+ logf(fmt.Sprintf("Terminate orphan process: pid (%d) (%s)", p.PID, p.Name))
286+ _ = syscall.Kill(p.PID, syscall.SIGKILL)
287+ }
288+}
289+
290+var _ io.Writer = (*lineWriter)(nil)
@@ -0,0 +1,290 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "sort"
13+ "strconv"
14+ "strings"
15+ "sync"
16+ "syscall"
17+ "time"
18+ "unicode/utf8"
19+)
20+
21+// Cancellation timings of the official runner (Runner.Sdk/ProcessInvoker.cs:32):
22+// SIGINT at once, SIGTERM after 7.5 s, kill 2.5 s later.
23+const (
24+ DefaultTermAfter = 7500 * time.Millisecond
25+ DefaultKillAfter = 2500 * time.Millisecond
26+ // DefaultWaitDelay is how long the executor waits for the output pipes to
27+ // close after the shell exited, before it gives up on background children
28+ // that still hold them: ProcessInvoker.ProcessExitedHandler waits 5 s
29+ // (ProcessInvoker.cs:491).
30+ DefaultWaitDelay = 5 * time.Second
31+)
32+
33+// lineWriter turns a byte stream into lines like StreamReader.ReadLine: "\n",
34+// "\r" and "\r\n" end a line, and invalid UTF-8 becomes U+FFFD. It calls fn
35+// for every complete line, in order, from the goroutine that writes.
36+type lineWriter struct {
37+ fn func(line string)
38+ buf []byte
39+ pending bool // the last byte seen was "\r": a following "\n" is part of it
40+}
41+
42+func (w *lineWriter) Write(p []byte) (int, error) {
43+ n := len(p)
44+ for len(p) > 0 {
45+ if w.pending {
46+ w.pending = false
47+ if p[0] == '\n' {
48+ p = p[1:]
49+ continue
50+ }
51+ }
52+ i := bytes.IndexAny(p, "\r\n")
53+ if i < 0 {
54+ w.buf = append(w.buf, p...)
55+ break
56+ }
57+ w.buf = append(w.buf, p[:i]...)
58+ w.emit()
59+ w.pending = p[i] == '\r'
60+ p = p[i+1:]
61+ }
62+ return n, nil
63+}
64+
65+func (w *lineWriter) emit() {
66+ b := w.buf
67+ if !utf8.Valid(b) {
68+ b = bytes.ToValidUTF8(b, []byte("\ufffd"))
69+ }
70+ line := string(b)
71+ w.buf = w.buf[:0]
72+ w.fn(line)
73+}
74+
75+// Flush emits the last line when the stream ended without a terminator.
76+func (w *lineWriter) Flush() {
77+ if len(w.buf) > 0 {
78+ w.emit()
79+ }
80+}
81+
82+// procSpec describes one process to run.
83+type procSpec struct {
84+ Argv []string
85+ Dir string
86+ Env []string
87+ // Credential, when set, is the user the process runs as.
88+ Credential *syscall.Credential
89+ // OnLine receives the lines of stdout and stderr (one stream, in order).
90+ OnLine func(line string)
91+ // Cancel starts the SIGINT, SIGTERM, SIGKILL escalation when closed.
92+ Cancel <-chan struct{}
93+
94+ TermAfter, KillAfter, WaitDelay time.Duration
95+ // Signal delivers a signal to a process group; tests replace it.
96+ Signal func(pgid int, sig syscall.Signal)
97+}
98+
99+// procResult is how a process ended.
100+type procResult struct {
101+ ExitCode int
102+ Cancelled bool // Cancel was closed while the process ran
103+}
104+
105+// runProcess starts the process in its own process group, streams its output
106+// and waits. It is ProcessInvoker.ExecuteAsync for one process: stdin is
107+// closed, and a closed Cancel starts the escalation. After the shell exits the
108+// group is not touched (background children survive, like on a hosted runner
109+// until the orphan cleanup of "Complete job").
110+func runProcess(spec procSpec) (procResult, error) {
111+ if spec.TermAfter == 0 {
112+ spec.TermAfter = DefaultTermAfter
113+ }
114+ if spec.KillAfter == 0 {
115+ spec.KillAfter = DefaultKillAfter
116+ }
117+ if spec.WaitDelay == 0 {
118+ spec.WaitDelay = DefaultWaitDelay
119+ }
120+ if spec.Signal == nil {
121+ spec.Signal = func(pgid int, sig syscall.Signal) { _ = syscall.Kill(-pgid, sig) }
122+ }
123+ cmd := exec.Command(spec.Argv[0], spec.Argv[1:]...)
124+ cmd.Dir = spec.Dir
125+ cmd.Env = spec.Env
126+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: spec.Credential}
127+ lw := &lineWriter{fn: spec.OnLine}
128+ cmd.Stdout, cmd.Stderr = lw, lw // the same writer: one pipe, order kept
129+ cmd.WaitDelay = spec.WaitDelay
130+ if err := cmd.Start(); err != nil {
131+ return procResult{}, err
132+ }
133+ pid := cmd.Process.Pid
134+ setOOMScoreAdj(pid)
135+
136+ var mu sync.Mutex
137+ done := false
138+ cancelled := false
139+ var timers []*time.Timer
140+ stopWatch := make(chan struct{})
141+ watchDone := make(chan struct{})
142+ go func() {
143+ defer close(watchDone)
144+ select {
145+ case <-spec.Cancel:
146+ case <-stopWatch:
147+ return
148+ }
149+ mu.Lock()
150+ defer mu.Unlock()
151+ if done {
152+ return
153+ }
154+ cancelled = true
155+ spec.Signal(pid, syscall.SIGINT)
156+ send := func(sig syscall.Signal) func() {
157+ return func() {
158+ mu.Lock()
159+ defer mu.Unlock()
160+ if !done { // the group id could be reused once the shell is reaped
161+ spec.Signal(pid, sig)
162+ }
163+ }
164+ }
165+ timers = append(timers,
166+ time.AfterFunc(spec.TermAfter, send(syscall.SIGTERM)),
167+ time.AfterFunc(spec.TermAfter+spec.KillAfter, send(syscall.SIGKILL)))
168+ }()
169+
170+ err := cmd.Wait()
171+ mu.Lock()
172+ done = true
173+ for _, t := range timers {
174+ t.Stop()
175+ }
176+ was := cancelled
177+ mu.Unlock()
178+ close(stopWatch)
179+ <-watchDone
180+ lw.Flush()
181+
182+ res := procResult{Cancelled: was}
183+ var ee *exec.ExitError
184+ switch {
185+ case err == nil, errors.Is(err, exec.ErrWaitDelay):
186+ case errors.As(err, &ee):
187+ res.ExitCode = ee.ExitCode()
188+ if res.ExitCode < 0 { // killed by a signal: 128+signal, as .NET reports it
189+ if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
190+ res.ExitCode = 128 + int(ws.Signal())
191+ } else {
192+ res.ExitCode = 1
193+ }
194+ }
195+ default:
196+ return res, err
197+ }
198+ return res, nil
199+}
200+
201+// setOOMScoreAdj makes the step process a likelier OOM victim than the
202+// executor (ProcessInvoker.DecreaseProcessPriority, ProcessInvoker.cs:575).
203+func setOOMScoreAdj(pid int) {
204+ _ = os.WriteFile(fmt.Sprintf("/proc/%d/oom_score_adj", pid), []byte("500"), 0o644)
205+}
206+
207+// startError turns the error of a failed start into the text of .NET's
208+// Win32Exception, which the official runner logs (the corpus recording
209+// "working directory that does not exist" has it).
210+func startError(shellPath, dir string, err error) string {
211+ if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOENT) {
212+ return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. No such file or directory", shellPath, dir)
213+ }
214+ if errors.Is(err, syscall.ENOTDIR) {
215+ return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. Not a directory", shellPath, dir)
216+ }
217+ if errors.Is(err, os.ErrPermission) {
218+ return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. Permission denied", shellPath, dir)
219+ }
220+ return fmt.Sprintf("An error occurred trying to start process '%s' with working directory '%s'. %v", shellPath, dir, err)
221+}
222+
223+// ProcessTrackingID is the variable that marks every process of a job so that
224+// "Complete job" can find the orphans (Constants.ProcessTrackingId).
225+const ProcessTrackingID = "RUNNER_TRACKING_ID"
226+
227+// procEntry is a process seen in /proc.
228+type procEntry struct {
229+ PID int
230+ Name string // comm, as .NET's ProcessName
231+}
232+
233+func (p procEntry) key() string { return strconv.Itoa(p.PID) + "_" + p.Name }
234+
235+// snapshotProcesses lists the processes (JobExtension.SnapshotProcesses).
236+func snapshotProcesses() []procEntry {
237+ ents, err := os.ReadDir("/proc")
238+ if err != nil {
239+ return nil
240+ }
241+ var out []procEntry
242+ for _, e := range ents {
243+ pid, err := strconv.Atoi(e.Name())
244+ if err != nil {
245+ continue
246+ }
247+ comm, err := os.ReadFile("/proc/" + e.Name() + "/comm")
248+ if err != nil {
249+ continue
250+ }
251+ out = append(out, procEntry{pid, strings.TrimSuffix(string(comm), "\n")})
252+ }
253+ sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
254+ return out
255+}
256+
257+// processTrackingValue reads RUNNER_TRACKING_ID from the environment of a
258+// process; "" when it cannot be read.
259+func processTrackingValue(pid int) string {
260+ b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ")
261+ if err != nil {
262+ return ""
263+ }
264+ prefix := []byte(ProcessTrackingID + "=")
265+ for _, kv := range bytes.Split(b, []byte{0}) {
266+ if bytes.HasPrefix(kv, prefix) {
267+ return string(kv[len(prefix):])
268+ }
269+ }
270+ return ""
271+}
272+
273+// killOrphans kills the processes that carry the tracking id and were not
274+// there before the job (JobExtension.FinalizeJob, JobExtension.cs:132-180).
275+// logf receives the "Terminate orphan process" lines.
276+func killOrphans(id string, before map[string]bool, logf func(string)) {
277+ self := os.Getpid()
278+ for _, p := range snapshotProcesses() {
279+ if p.PID == self || before[p.key()] {
280+ continue
281+ }
282+ if !strings.EqualFold(processTrackingValue(p.PID), id) {
283+ continue
284+ }
285+ logf(fmt.Sprintf("Terminate orphan process: pid (%d) (%s)", p.PID, p.Name))
286+ _ = syscall.Kill(p.PID, syscall.SIGKILL)
287+ }
288+}
289+
290+var _ io.Writer = (*lineWriter)(nil)
A internal/executor/report.go +50 -0
@@ -0,0 +1,50 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "time"
6+
7+// Conclusion is how a step or a job ended, with the names of the protocol
8+// (CiConclusion in ermite-api).
9+type Conclusion string
10+
11+// Conclusions.
12+const (
13+ ConclusionSuccess Conclusion = "success"
14+ ConclusionFailure Conclusion = "failure"
15+ ConclusionCancelled Conclusion = "cancelled"
16+ ConclusionSkipped Conclusion = "skipped"
17+ ConclusionTimedOut Conclusion = "timed_out" // jobs only
18+)
19+
20+// Reporter receives what happens in a job. The executor calls it from several
21+// goroutines but never concurrently: calls are serialised.
22+//
23+// Steps are numbered like GitHub's log: "Set up job" is 1, the steps of the
24+// workflow follow, and "Complete job" is last. Every log line belongs to the
25+// step that is running.
26+type Reporter interface {
27+ // StepStarted announces a step that begins to run, with its display name.
28+ // A step skipped by its condition gets StepStarted and StepCompleted too.
29+ StepStarted(number int, name string, at time.Time)
30+ // Log is one line of the job log, without its line terminator.
31+ Log(number int, at time.Time, line string)
32+ // StepCompleted ends a step.
33+ StepCompleted(number int, name string, conclusion Conclusion, at time.Time)
34+ // JobCompleted is the last call: the result of the job.
35+ JobCompleted(Result)
36+}
37+
38+// Result is the outcome of a job.
39+type Result struct {
40+ Conclusion Conclusion
41+ // Outputs are the job outputs, evaluated at the end of the job. Empty values
42+ // are left out, like the official runner does.
43+ Outputs map[string]string
44+ // Error is a job-level error (a timeout, a workflow that cannot be run);
45+ // step errors are in the log.
46+ Error string
47+ // RunnerShutdown is true when the job was cut short because the runner was
48+ // told to stop (see ErrShutdown).
49+ RunnerShutdown bool
50+}
@@ -0,0 +1,50 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "time"
6+
7+// Conclusion is how a step or a job ended, with the names of the protocol
8+// (CiConclusion in ermite-api).
9+type Conclusion string
10+
11+// Conclusions.
12+const (
13+ ConclusionSuccess Conclusion = "success"
14+ ConclusionFailure Conclusion = "failure"
15+ ConclusionCancelled Conclusion = "cancelled"
16+ ConclusionSkipped Conclusion = "skipped"
17+ ConclusionTimedOut Conclusion = "timed_out" // jobs only
18+)
19+
20+// Reporter receives what happens in a job. The executor calls it from several
21+// goroutines but never concurrently: calls are serialised.
22+//
23+// Steps are numbered like GitHub's log: "Set up job" is 1, the steps of the
24+// workflow follow, and "Complete job" is last. Every log line belongs to the
25+// step that is running.
26+type Reporter interface {
27+ // StepStarted announces a step that begins to run, with its display name.
28+ // A step skipped by its condition gets StepStarted and StepCompleted too.
29+ StepStarted(number int, name string, at time.Time)
30+ // Log is one line of the job log, without its line terminator.
31+ Log(number int, at time.Time, line string)
32+ // StepCompleted ends a step.
33+ StepCompleted(number int, name string, conclusion Conclusion, at time.Time)
34+ // JobCompleted is the last call: the result of the job.
35+ JobCompleted(Result)
36+}
37+
38+// Result is the outcome of a job.
39+type Result struct {
40+ Conclusion Conclusion
41+ // Outputs are the job outputs, evaluated at the end of the job. Empty values
42+ // are left out, like the official runner does.
43+ Outputs map[string]string
44+ // Error is a job-level error (a timeout, a workflow that cannot be run);
45+ // step errors are in the log.
46+ Error string
47+ // RunnerShutdown is true when the job was cut short because the runner was
48+ // told to stop (see ErrShutdown).
49+ RunnerShutdown bool
50+}
A internal/executor/steps.go +923 -0
@@ -0,0 +1,923 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "sync"
13+ "syscall"
14+ "time"
15+
16+ "github.com/google/uuid"
17+
18+ "bnema.dev/ermite-actions/expr"
19+ "bnema.dev/ermite-actions/template"
20+ "bnema.dev/ermite-actions/workflow"
21+)
22+
23+// Names of the implicit steps (JobExtension.cs:70 and :15).
24+const (
25+ SetUpJobName = "Set up job"
26+ CompleteJobName = "Complete job"
27+)
28+
29+// DefaultTimeoutMinutes is the timeout of a job without timeout-minutes.
30+const DefaultTimeoutMinutes = 360
31+
32+// maxIssueMessageLength is ExecutionContext._maxIssueMessageLength.
33+const maxIssueMessageLength = 4096
34+
35+// Config describes the job to run.
36+type Config struct {
37+ // WorkflowPath and WorkflowYAML are the workflow file, which the executor
38+ // parses itself; JobKey selects the job.
39+ WorkflowPath string
40+ WorkflowYAML []byte
41+ JobKey string
42+ // JobName is the display name of the job, for "Complete job name: ...".
43+ // Empty means the job key.
44+ JobName string
45+ // ContextsJSON and MatrixJSON are the fields of the job message.
46+ ContextsJSON string
47+ MatrixJSON string
48+ // Repo is the repository name used when the github context lacks one.
49+ Repo string
50+ // TimeoutMinutes is the job timeout; 0 means DefaultTimeoutMinutes.
51+ TimeoutMinutes int
52+
53+ Runner RunnerInfo
54+ RunnerVersion string
55+ // Layout overrides the directory layout (tests); nil means the hosted layout.
56+ Layout *Layout
57+ // BaseEnv is the environment steps inherit; nil means os.Environ().
58+ BaseEnv []string
59+ // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
60+ Debug bool
61+
62+ Reporter Reporter
63+
64+ // Credential, when set, is the user the steps run as.
65+ Credential *syscall.Credential
66+ // Cancellation timings and test hooks, see procSpec.
67+ TermAfter, KillAfter, WaitDelay time.Duration
68+ Signal func(pgid int, sig syscall.Signal)
69+ // Now returns the current time; nil means time.Now.
70+ Now func() time.Time
71+}
72+
73+type stepResult int
74+
75+// Values are ordered like TaskResult so that merge picks the worst
76+// (TaskResultUtil.MergeTaskResults).
77+const (
78+ resSucceeded stepResult = iota
79+ resFailed
80+ resCanceled
81+ resSkipped
82+)
83+
84+func (r stepResult) conclusion() Conclusion {
85+ switch r {
86+ case resFailed:
87+ return ConclusionFailure
88+ case resCanceled:
89+ return ConclusionCancelled
90+ case resSkipped:
91+ return ConclusionSkipped
92+ }
93+ return ConclusionSuccess
94+}
95+
96+// merge is TaskResultUtil.MergeTaskResults (Runner.Common/Util/TaskResultUtil.cs:40).
97+func merge(cur *stepResult, coming stepResult) stepResult {
98+ if cur == nil {
99+ return coming
100+ }
101+ if *cur > resFailed {
102+ return *cur
103+ }
104+ if coming >= *cur {
105+ return coming
106+ }
107+ return *cur
108+}
109+
110+type job struct {
111+ cfg Config
112+ cancel *Canceller
113+ wf *workflow.Workflow
114+ jb *workflow.Job
115+ fc *FileCommands
116+ trackID string
117+
118+ mu sync.Mutex // serialises the Reporter and guards state and result
119+ state *State
120+ result *stepResult // nil until a step fails or the job is cancelled (Global result)
121+ cancelled bool
122+ runDefaults struct{ shell, workingDirectory string }
123+ baseEnv []string
124+ lookupPath string
125+ before map[string]bool
126+ outputsSet bool
127+}
128+
129+func (j *job) now() time.Time {
130+ if j.cfg.Now != nil {
131+ return j.cfg.Now()
132+ }
133+ return time.Now()
134+}
135+
136+// Run executes one job and reports to cfg.Reporter. It always ends with a
137+// JobCompleted call and returns the same Result. cancel may be nil.
138+func Run(cancel *Canceller, cfg Config) Result {
139+ if cancel == nil {
140+ cancel = NewCanceller()
141+ }
142+ j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()}
143+ res := j.run()
144+ cfg.Reporter.JobCompleted(res)
145+ return res
146+}
147+
148+func (j *job) run() Result {
149+ minutes := j.cfg.TimeoutMinutes
150+ if minutes <= 0 {
151+ minutes = DefaultTimeoutMinutes
152+ }
153+ timer := time.AfterFunc(time.Duration(minutes)*time.Minute, func() { j.cancel.Cancel(CancelTimeout) })
154+ defer timer.Stop()
155+
156+ // ---- Set up job (JobExtension.InitializeJob) ----
157+ rep := j.cfg.Reporter
158+ rep.StepStarted(1, SetUpJobName, j.now())
159+ steps, err := j.setUp()
160+ if err != nil {
161+ j.errorf(1, "%s", err.Error())
162+ rep.StepCompleted(1, SetUpJobName, ConclusionFailure, j.now())
163+ // The job ends here, without "Complete job" (JobRunner.cs:107).
164+ return Result{Conclusion: ConclusionFailure, Error: err.Error()}
165+ }
166+ rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now())
167+
168+ // ---- the steps (StepsRunner) ----
169+ for i, st := range steps {
170+ j.runStep(i+2, st)
171+ }
172+
173+ // ---- Complete job (JobExtension.FinalizeJob) ----
174+ last := len(steps) + 2
175+ outputs := j.completeJob(last)
176+
177+ return j.finish(outputs)
178+}
179+
180+func (j *job) finish(outputs map[string]string) Result {
181+ res := Result{Outputs: outputs}
182+ j.mu.Lock()
183+ defer j.mu.Unlock()
184+ r := resSucceeded
185+ if j.result != nil {
186+ r = *j.result
187+ }
188+ switch {
189+ case j.cancel.Reason() == CancelTimeout && j.cancelled:
190+ res.Conclusion = ConclusionTimedOut
191+ res.Error = fmt.Sprintf("The job has exceeded the maximum execution time of %d minutes.", j.timeoutMinutes())
192+ case r == resCanceled:
193+ res.Conclusion = ConclusionCancelled
194+ res.RunnerShutdown = j.cancel.Reason() == CancelShutdown
195+ case r == resFailed:
196+ res.Conclusion = ConclusionFailure
197+ default:
198+ res.Conclusion = ConclusionSuccess
199+ }
200+ return res
201+}
202+
203+func (j *job) timeoutMinutes() int {
204+ if j.cfg.TimeoutMinutes > 0 {
205+ return j.cfg.TimeoutMinutes
206+ }
207+ return DefaultTimeoutMinutes
208+}
209+
210+// ---------------------------------------------------------------- logging
211+
212+// write sends one log line to the Reporter, in order.
213+func (j *job) write(num int, line string) {
214+ j.mu.Lock()
215+ defer j.mu.Unlock()
216+ j.cfg.Reporter.Log(num, j.now(), line)
217+}
218+
219+// out writes a message that may hold several lines (ExecutionContext.Output).
220+func (j *job) out(num int, text string) {
221+ for _, l := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
222+ j.write(num, l)
223+ }
224+}
225+
226+// tagged writes tag+message; only the first line carries the tag.
227+func (j *job) tagged(num int, tag, msg string) {
228+ if len(msg) > maxIssueMessageLength && tag != "" {
229+ msg = msg[:maxIssueMessageLength]
230+ }
231+ lines := strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n")
232+ j.write(num, tag+lines[0])
233+ for _, l := range lines[1:] {
234+ j.write(num, l)
235+ }
236+}
237+
238+func (j *job) errorf(num int, format string, a ...any) {
239+ j.tagged(num, "##[error]", fmt.Sprintf(format, a...))
240+}
241+
242+func (j *job) debug(num int, msg string) {
243+ if j.cfg.Debug {
244+ for _, l := range strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") {
245+ j.write(num, "##[debug]"+l)
246+ }
247+ }
248+}
249+
250+// ---------------------------------------------------------------- set up
251+
252+// evalEnv is the environment for expressions of the job and step tokens.
253+func (j *job) evalEnv(env *ordered) template.Env {
254+ return template.Env{
255+ Contexts: j.state.Values(env),
256+ Funcs: map[string]expr.Func{
257+ // hashFiles is a CI-2 feature: the schema allows it in steps, so it
258+ // parses, and fails when evaluated.
259+ "hashFiles": func(context.Context, []expr.Value) (expr.Value, error) {
260+ return nil, errors.New("not supported yet: hashFiles")
261+ },
262+ },
263+ }
264+}
265+
266+func (j *job) evalString(tok template.Token, env *ordered) (string, error) {
267+ if tok == nil {
268+ return "", nil
269+ }
270+ return template.EvaluateString(context.Background(), tok, j.evalEnv(env))
271+}
272+
273+// evalMap evaluates a mapping of strings (EvaluateStepEnvironment): values
274+// are converted like GitHub converts them (null is the empty string).
275+func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
276+ if m == nil {
277+ return nil, nil
278+ }
279+ v, err := template.Evaluate(context.Background(), m, j.evalEnv(env))
280+ if err != nil {
281+ return nil, err
282+ }
283+ d, ok := v.(*expr.Dictionary)
284+ if !ok {
285+ return nil, nil
286+ }
287+ var out []Pair
288+ for _, p := range d.Pairs() {
289+ if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
290+ return nil, fmt.Errorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k)
291+ }
292+ out = append(out, Pair{p.Key, expr.CoerceString(p.Value)})
293+ }
294+ return out, nil
295+}
296+
297+// setUp parses the workflow and prepares the job: JobExtension.InitializeJob
298+// (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its
299+// log lines belong to step 1.
300+func (j *job) setUp() ([]*workflow.Step, error) {
301+ cfg := j.cfg
302+ j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))
303+ if cfg.Runner.Name != "" {
304+ j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))
305+ }
306+ if host, err := os.Hostname(); err == nil {
307+ j.out(1, fmt.Sprintf("Machine name: '%s'", host))
308+ }
309+
310+ wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML)
311+ if len(errs) > 0 {
312+ var msgs []string
313+ for _, e := range errs {
314+ msgs = append(msgs, e.Error())
315+ }
316+ return nil, fmt.Errorf("The workflow is not valid. %s", strings.Join(msgs, "\n"))
317+ }
318+ jb := wf.Job(cfg.JobKey)
319+ if jb == nil {
320+ return nil, fmt.Errorf("The workflow has no job '%s'.", cfg.JobKey)
321+ }
322+ j.wf, j.jb = wf, jb
323+
324+ state, layout, err := NewState(cfg.ContextsJSON, cfg.MatrixJSON, cfg.JobKey, cfg.Runner, cfg.Repo)
325+ if err != nil {
326+ return nil, err
327+ }
328+ if cfg.Layout != nil {
329+ state.WithLayout(*cfg.Layout)
330+ layout = *cfg.Layout
331+ }
332+ j.state = state
333+
334+ // Features that arrive in later phases fail the job with a clear message.
335+ switch {
336+ case jb.IsReusable():
337+ return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)")
338+ case jb.Container != nil:
339+ return nil, errors.New("not supported yet: jobs.<id>.container")
340+ case jb.Services != nil:
341+ return nil, errors.New("not supported yet: jobs.<id>.services")
342+ }
343+
344+ // Directories (TempDirectoryManager, PipelineDirectoryManager).
345+ j.out(1, "Prepare workflow directory")
346+ for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} {
347+ if err := os.MkdirAll(d, 0o755); err != nil {
348+ return nil, fmt.Errorf("create %s: %w", d, err)
349+ }
350+ }
351+ clearDir(layout.Temp)
352+ if err := os.MkdirAll(layout.Temp, 0o755); err != nil {
353+ return nil, err
354+ }
355+ fc, err := NewFileCommands(filepath.Join(layout.Temp, "_runner_file_commands"))
356+ if err != nil {
357+ return nil, err
358+ }
359+ j.fc = fc
360+
361+ j.baseEnv = cfg.BaseEnv
362+ if j.baseEnv == nil {
363+ j.baseEnv = os.Environ()
364+ }
365+ if !hasEnv(j.baseEnv, "HOME") {
366+ j.baseEnv = append(append([]string(nil), j.baseEnv...), "HOME="+layout.Home)
367+ }
368+ j.lookupPath = envValue(j.baseEnv, "PATH")
369+
370+ // Job-level environment: workflow env, then job env, each evaluated with an
371+ // empty env context (JobExtension.cs:38-49, ExecutionContext.cs:1048).
372+ for _, m := range []*template.Mapping{wf.Env, jb.Env} {
373+ pairs, err := j.evalMap(m, newOrdered())
374+ if err != nil {
375+ return nil, err
376+ }
377+ for _, p := range pairs {
378+ j.state.SetEnv(p.Key, p.Value)
379+ }
380+ }
381+
382+ // defaults.run: a job-level "run" replaces the workflow's whole (JobExtension.cs:78-96).
383+ for _, d := range []*workflow.Defaults{wf.Defaults, jb.Defaults} {
384+ if d == nil || d.Run == nil {
385+ continue
386+ }
387+ shell, err := j.evalString(d.Run.Shell, newOrdered())
388+ if err != nil {
389+ return nil, err
390+ }
391+ wd, err := j.evalString(d.Run.WorkingDirectory, newOrdered())
392+ if err != nil {
393+ return nil, err
394+ }
395+ j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd
396+ }
397+
398+ j.out(1, "Prepare all required actions")
399+ name := cfg.JobName
400+ if name == "" {
401+ name = cfg.JobKey
402+ }
403+ if strings.TrimSpace(name) != "" {
404+ j.out(1, "Complete job name: "+name)
405+ }
406+
407+ // Process tracking for the orphan cleanup of "Complete job".
408+ j.before = map[string]bool{}
409+ for _, p := range snapshotProcesses() {
410+ j.before[p.key()] = true
411+ }
412+ return jb.Steps, nil
413+}
414+
415+func clearDir(dir string) {
416+ ents, err := os.ReadDir(dir)
417+ if err != nil {
418+ return
419+ }
420+ for _, e := range ents {
421+ _ = os.RemoveAll(filepath.Join(dir, e.Name()))
422+ }
423+}
424+
425+func hasEnv(env []string, key string) bool {
426+ for _, kv := range env {
427+ if strings.HasPrefix(kv, key+"=") {
428+ return true
429+ }
430+ }
431+ return false
432+}
433+
434+func envValue(env []string, key string) string {
435+ v := ""
436+ for _, kv := range env {
437+ if k, val, ok := strings.Cut(kv, "="); ok && k == key {
438+ v = val
439+ }
440+ }
441+ return v
442+}
443+
444+// ---------------------------------------------------------------- steps
445+
446+// stepRun is the state of one step being run; it implements Sink and
447+// commandHost.
448+type stepRun struct {
449+ j *job
450+ num int
451+ st *workflow.Step
452+ env *ordered // the step's environment: job env + step env
453+ result *stepResult
454+ cmdResult *stepResult
455+ outcome *stepResult
456+ cmds *commandManager
457+ secretEnvLogged bool
458+}
459+
460+func (s *stepRun) Out(text string) { s.j.out(s.num, text) }
461+func (s *stepRun) Tagged(tag, msg string) { s.j.tagged(s.num, tag, msg) }
462+func (s *stepRun) Debug(msg string) { s.j.debug(s.num, msg) }
463+func (s *stepRun) Error(msg string) { s.j.errorf(s.num, "%s", msg) }
464+func (s *stepRun) CommandFailed() { f := resFailed; s.cmdResult = &f }
465+func (s *stepRun) SetOutput(name, value string) {
466+ s.j.mu.Lock()
467+ s.j.state.SetOutput(s.st.ID, name, value)
468+ s.j.mu.Unlock()
469+}
470+func (s *stepRun) SaveState(name, value string) {}
471+func (s *stepRun) SetEnv(name, value string) {
472+ s.j.mu.Lock()
473+ s.j.state.SetEnv(name, value)
474+ s.j.mu.Unlock()
475+}
476+func (s *stepRun) AddPath(p string) {
477+ s.j.mu.Lock()
478+ s.j.state.AddPath(p)
479+ s.j.mu.Unlock()
480+}
481+
482+// EnvFlag reads a boolean variable of the env context, then of the process
483+// (ActionCommandManager.cs:236-246).
484+func (s *stepRun) EnvFlag(name string) bool {
485+ v := strings.ToLower(os.Getenv(name))
486+ if v != "true" {
487+ if e, ok := s.env.Get(name); ok {
488+ v = strings.ToLower(e)
489+ }
490+ }
491+ return v == "true"
492+}
493+
494+func firstLine(contents string) string {
495+ l := strings.TrimLeft(contents, " \t\r\n")
496+ if i := strings.IndexAny(l, "\r\n"); i >= 0 {
497+ l = l[:i]
498+ }
499+ return l
500+}
501+
502+// displayName is the name shown for the step (ActionRunner.GenerateDisplayName,
503+// ActionRunner.cs:363): the evaluated name, or "Run " and the first line of
504+// the script or the action reference.
505+func (j *job) displayName(st *workflow.Step, env *ordered) string {
506+ tok := st.Name
507+ prefix := ""
508+ switch {
509+ case tok != nil:
510+ case st.Run != nil:
511+ tok, prefix = st.Run, "Run "
512+ case st.Uses != nil:
513+ tok, prefix = st.Uses, "Run "
514+ default:
515+ return ""
516+ }
517+ name, err := j.evalString(tok, env)
518+ if err != nil {
519+ name = tokenSource(tok)
520+ }
521+ return formatStepName(prefix, name)
522+}
523+
524+func tokenSource(t template.Token) string {
525+ if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {
526+ return be.Source
527+ }
528+ return t.String()
529+}
530+
531+// formatStepName is ActionRunner.FormatStepName (ActionRunner.cs:447).
532+func formatStepName(prefix, name string) string {
533+ if name == "" {
534+ return ""
535+ }
536+ return prefix + firstLine(name)
537+}
538+
539+func (j *job) statusFuncs() workflow.StatusFuncs {
540+ st := j.state.Status()
541+ return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
542+}
543+
544+// markCancelled records the cancellation of the job: job result Canceled and
545+// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
546+func (j *job) markCancelledLocked() {
547+ if j.cancelled {
548+ return
549+ }
550+ j.cancelled = true
551+ c := resCanceled
552+ j.result = &c
553+ j.state.SetStatus("cancelled")
554+}
555+
556+func (j *job) runStep(num int, st *workflow.Step) {
557+ rep := j.cfg.Reporter
558+ s := &stepRun{j: j, num: num, st: st}
559+ s.cmds = newCommandManager(s, j.cfg.Debug)
560+
561+ // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
562+ // own, evaluated against the job env.
563+ j.mu.Lock()
564+ jobEnv := j.state.JobEnv()
565+ j.mu.Unlock()
566+ s.env = jobEnv.Clone()
567+ pairs, envErr := j.evalMap(st.Env, jobEnv)
568+ if envErr == nil {
569+ for _, p := range pairs {
570+ s.env.Set(p.Key, p.Value)
571+ }
572+ }
573+ name := j.displayName(st, s.env)
574+ rep.StepStarted(num, name, j.now())
575+
576+ complete := func(r stepResult) {
577+ res := r
578+ j.mu.Lock()
579+ // Update the steps context (ExecutionContext.UpdateGlobalStepsContext).
580+ outcome := res
581+ if s.outcome != nil {
582+ outcome = *s.outcome
583+ }
584+ j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
585+ if res == resFailed {
586+ m := merge(j.result, resFailed)
587+ j.result = &m
588+ j.state.SetStatus(statusName(m))
589+ }
590+ j.mu.Unlock()
591+ rep.StepCompleted(num, name, res.conclusion(), j.now())
592+ }
593+
594+ if envErr != nil {
595+ j.errorf(num, "%s", envErr.Error())
596+ complete(resFailed)
597+ return
598+ }
599+
600+ // Cancellation seen before the step starts (StepsRunner.cs:188-197).
601+ cancelledAtStart := false
602+ select {
603+ case <-j.cancel.Done():
604+ cancelledAtStart = true
605+ j.mu.Lock()
606+ j.markCancelledLocked()
607+ j.mu.Unlock()
608+ default:
609+ }
610+
611+ // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not
612+ // evaluated and the step is skipped.
613+ cond := func() (bool, error) {
614+ j.mu.Lock()
615+ defer j.mu.Unlock()
616+ return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs())
617+ }
618+ if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
619+ complete(resSkipped)
620+ return
621+ }
622+ ok, err := cond()
623+ switch {
624+ case err != nil:
625+ j.errorf(num, "%s", err.Error())
626+ complete(resFailed)
627+ return
628+ case !ok:
629+ complete(resSkipped)
630+ return
631+ }
632+
633+ // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
634+ // cancelled and the step is stopped unless its condition still holds
635+ // (always()).
636+ stepCancel := make(chan struct{})
637+ stopWatch := make(chan struct{})
638+ var watch sync.WaitGroup
639+ if !cancelledAtStart {
640+ watch.Add(1)
641+ go func() {
642+ defer watch.Done()
643+ select {
644+ case <-j.cancel.Done():
645+ case <-stopWatch:
646+ return
647+ }
648+ j.mu.Lock()
649+ j.markCancelledLocked()
650+ j.mu.Unlock()
651+ keep := false
652+ if j.cancel.Reason() != CancelShutdown {
653+ keep, _ = cond()
654+ }
655+ if !keep {
656+ close(stepCancel)
657+ }
658+ }()
659+ }
660+
661+ r := j.execute(s, stepCancel)
662+ close(stopWatch)
663+ watch.Wait()
664+
665+ // Merge the result of the commands (StepsRunner.cs:347-351).
666+ if s.cmdResult != nil {
667+ m := merge(&r, *s.cmdResult)
668+ r = m
669+ }
670+ // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
671+ if r == resFailed && st.ContinueOnError != nil {
672+ coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
673+ if cerr != nil {
674+ j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
675+ j.errorf(num, "%s", cerr.Error())
676+ }
677+ if coe && cerr == nil {
678+ o := resFailed
679+ s.outcome = &o
680+ r = resSucceeded
681+ }
682+ }
683+ complete(r)
684+}
685+
686+// statusName is the job.status for a result (TaskResult.ToActionResult).
687+func statusName(r stepResult) string {
688+ switch r {
689+ case resFailed:
690+ return "failure"
691+ case resCanceled:
692+ return "cancelled"
693+ }
694+ return "success"
695+}
696+
697+// notSupported is the failure of a step that uses a feature of a later phase.
698+func notSupported(st *workflow.Step) string {
699+ switch {
700+ case st.Uses != nil:
701+ return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value)
702+ case st.TimeoutMinutes != nil:
703+ return "not supported yet: timeout-minutes"
704+ }
705+ return ""
706+}
707+
708+// execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
709+func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) (result stepResult) {
710+ num, st := s.num, s.st
711+ if msg := notSupported(st); msg != "" {
712+ if st.Uses != nil {
713+ j.out(num, "##[group]Run "+st.Uses.Value)
714+ j.out(num, "##[endgroup]")
715+ }
716+ j.errorf(num, "%s", msg)
717+ return resFailed
718+ }
719+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
720+
721+ // File commands for this step: the github context carries the paths
722+ // (FileCommandManager.InitializeFiles), event.json is written
723+ // (ExecutionContext.WriteWebhookPayload).
724+ j.mu.Lock()
725+ files, ferr := j.fc.Init()
726+ if ferr == nil {
727+ for _, c := range fileCommands {
728+ j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
729+ }
730+ j.state.SetGitHub("action", st.ID)
731+ j.state.SetGitHub("action_repository", "")
732+ j.state.SetGitHub("action_ref", "")
733+ if ev := j.state.EventJSON(); ev != "" {
734+ dir := filepath.Join(j.state.Layout().Temp, "_github_workflow")
735+ if err := os.MkdirAll(dir, 0o755); err == nil {
736+ p := filepath.Join(dir, "event.json")
737+ if err := os.WriteFile(p, []byte(ev), 0o644); err == nil {
738+ j.state.SetGitHub("event_path", p)
739+ }
740+ }
741+ }
742+ }
743+ j.mu.Unlock()
744+ if ferr != nil {
745+ return fail(ferr)
746+ }
747+ defer func() {
748+ // ProcessFiles runs after the handler, even when it failed (finally).
749+ j.fc.Process(s)
750+ }()
751+
752+ // Inputs (ActionRunner.cs:174-185).
753+ script, err := j.evalString(st.Run, s.env)
754+ if err != nil {
755+ return fail(err)
756+ }
757+ shellInput, err := j.evalString(st.Shell, s.env)
758+ if err != nil {
759+ return fail(err)
760+ }
761+ var wdInput string
762+ var hasWD bool
763+ if st.WorkingDirectory != nil {
764+ hasWD = true
765+ if wdInput, err = j.evalString(st.WorkingDirectory, s.env); err != nil {
766+ return fail(err)
767+ }
768+ }
769+
770+ // Header (ScriptHandler.PrintActionDetails, ScriptHandler.cs:27-137).
771+ j.out(num, "##[group]Run "+firstLine(script))
772+ for _, l := range strings.Split(strings.TrimRight(strings.ReplaceAll(script, "\r\n", "\n"), "\n"), "\n") {
773+ j.write(num, "\x1b[36;1m"+l+"\x1b[0m") // bright cyan
774+ }
775+ shell := shellInput
776+ if shell == "" {
777+ shell = j.runDefaults.shell
778+ }
779+ j.mu.Lock()
780+ lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}
781+ j.mu.Unlock()
782+ plan, err := PlanShell(shell, lookup)
783+ if err != nil {
784+ return fail(err) // the group stays open, like the original
785+ }
786+ j.out(num, "shell: "+plan.ShellLine())
787+ if s.env.Len() > 0 {
788+ j.write(num, "env:")
789+ for _, k := range s.env.Keys() {
790+ v, _ := s.env.Get(k)
791+ j.out(num, fmt.Sprintf(" %s: %s", k, v))
792+ }
793+ }
794+ j.write(num, "##[endgroup]")
795+
796+ // Run (ScriptHandler.RunAsync, :140-358).
797+ wd := wdInput
798+ if !hasWD {
799+ wd = j.runDefaults.workingDirectory
800+ }
801+ j.mu.Lock()
802+ workspace := j.state.GitHub("workspace")
803+ temp := j.state.Layout().Temp
804+ j.mu.Unlock()
805+ dir := ResolveWorkingDirectory(workspace, wd)
806+ sc, err := WriteScript(temp, plan, script)
807+ if err != nil {
808+ return fail(err)
809+ }
810+
811+ // Process environment: the inherited one, the step's environment, PATH with
812+ // GITHUB_PATH in front, then the runtime variables of the contexts.
813+ j.mu.Lock()
814+ runtime := j.state.RuntimeEnv()
815+ prepend := j.state.Prepend()
816+ j.mu.Unlock()
817+ penv := newOrdered()
818+ for _, kv := range j.baseEnv {
819+ if k, v, ok := strings.Cut(kv, "="); ok {
820+ penv.Set(k, v)
821+ }
822+ }
823+ for _, k := range s.env.Keys() {
824+ v, _ := s.env.Get(k)
825+ penv.Set(k, v)
826+ }
827+ if len(prepend) > 0 {
828+ orig, ok := s.env.Get("PATH")
829+ if !ok {
830+ orig = j.lookupPath
831+ }
832+ penv.Set("PATH", PrependedPath(prepend, orig))
833+ }
834+ for _, k := range runtime.Keys() {
835+ v, _ := runtime.Get(k)
836+ penv.Set(k, v)
837+ }
838+ penv.Set("GITHUB_ACTIONS", "true") // ProcessInvoker.cs:283
839+ if _, ok := penv.Get("CI"); !ok {
840+ penv.Set("CI", "true")
841+ }
842+ penv.Set(ProcessTrackingID, j.trackID)
843+ var envList []string
844+ for _, k := range penv.Keys() {
845+ v, _ := penv.Get(k)
846+ envList = append(envList, k+"="+v)
847+ }
848+
849+ res, err := runProcess(procSpec{
850+ Argv: sc.Argv, Dir: dir, Env: envList, Credential: j.cfg.Credential,
851+ OnLine: func(line string) {
852+ if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") {
853+ line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`)
854+ }
855+ if (strings.Contains(line, "##[") || strings.Contains(line, "::")) && s.cmds.TryProcess(line) {
856+ return
857+ }
858+ j.write(num, line)
859+ },
860+ Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter,
861+ WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
862+ })
863+ if err != nil {
864+ return fail(errors.New(startError(plan.CommandPath, dir, err)))
865+ }
866+ if res.Cancelled {
867+ // The process was stopped on cancellation: ExecuteAsync throws
868+ // (ProcessInvoker.cs:369) and StepsRunner logs the exception
869+ // (StepsRunner.cs:333-337).
870+ j.errorf(num, "The operation was canceled.")
871+ return resCanceled
872+ }
873+ if res.ExitCode != 0 {
874+ j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
875+ return resFailed
876+ }
877+ return resSucceeded
878+}
879+
880+// ---------------------------------------------------------------- complete
881+
882+// completeJob is JobExtension.FinalizeJob (JobExtension.cs:9-190): job outputs
883+// and the cleanup of orphan processes.
884+func (j *job) completeJob(num int) map[string]string {
885+ rep := j.cfg.Reporter
886+ rep.StepStarted(num, CompleteJobName, j.now())
887+ result := resSucceeded
888+ outputs := map[string]string{}
889+
890+ if j.jb.Outputs != nil {
891+ // The runner of the official service prints nothing here: the recordings
892+ // of github.com show only "Cleaning up orphan processes" in "Complete
893+ // job", although the jobs have outputs (the log lines of
894+ // JobExtension.cs:48,68 belong to the legacy message that carries
895+ // JobOutputs).
896+ j.mu.Lock()
897+ env := j.state.JobEnv()
898+ j.mu.Unlock()
899+ pairs, err := j.evalMap(j.jb.Outputs, env)
900+ if err != nil {
901+ result = resFailed
902+ j.errorf(num, "Fail to evaluate job outputs")
903+ j.errorf(num, "%s", err.Error())
904+ j.mu.Lock()
905+ m := merge(j.result, resFailed)
906+ j.result = &m
907+ j.mu.Unlock()
908+ }
909+ for _, p := range pairs {
910+ if p.Value == "" {
911+ j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key))
912+ continue
913+ }
914+ outputs[p.Key] = p.Value
915+ }
916+ }
917+
918+ j.out(num, "Cleaning up orphan processes")
919+ killOrphans(j.trackID, j.before, func(l string) { j.out(num, l) })
920+
921+ rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())
922+ return outputs
923+}
@@ -0,0 +1,923 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "sync"
13+ "syscall"
14+ "time"
15+
16+ "github.com/google/uuid"
17+
18+ "bnema.dev/ermite-actions/expr"
19+ "bnema.dev/ermite-actions/template"
20+ "bnema.dev/ermite-actions/workflow"
21+)
22+
23+// Names of the implicit steps (JobExtension.cs:70 and :15).
24+const (
25+ SetUpJobName = "Set up job"
26+ CompleteJobName = "Complete job"
27+)
28+
29+// DefaultTimeoutMinutes is the timeout of a job without timeout-minutes.
30+const DefaultTimeoutMinutes = 360
31+
32+// maxIssueMessageLength is ExecutionContext._maxIssueMessageLength.
33+const maxIssueMessageLength = 4096
34+
35+// Config describes the job to run.
36+type Config struct {
37+ // WorkflowPath and WorkflowYAML are the workflow file, which the executor
38+ // parses itself; JobKey selects the job.
39+ WorkflowPath string
40+ WorkflowYAML []byte
41+ JobKey string
42+ // JobName is the display name of the job, for "Complete job name: ...".
43+ // Empty means the job key.
44+ JobName string
45+ // ContextsJSON and MatrixJSON are the fields of the job message.
46+ ContextsJSON string
47+ MatrixJSON string
48+ // Repo is the repository name used when the github context lacks one.
49+ Repo string
50+ // TimeoutMinutes is the job timeout; 0 means DefaultTimeoutMinutes.
51+ TimeoutMinutes int
52+
53+ Runner RunnerInfo
54+ RunnerVersion string
55+ // Layout overrides the directory layout (tests); nil means the hosted layout.
56+ Layout *Layout
57+ // BaseEnv is the environment steps inherit; nil means os.Environ().
58+ BaseEnv []string
59+ // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
60+ Debug bool
61+
62+ Reporter Reporter
63+
64+ // Credential, when set, is the user the steps run as.
65+ Credential *syscall.Credential
66+ // Cancellation timings and test hooks, see procSpec.
67+ TermAfter, KillAfter, WaitDelay time.Duration
68+ Signal func(pgid int, sig syscall.Signal)
69+ // Now returns the current time; nil means time.Now.
70+ Now func() time.Time
71+}
72+
73+type stepResult int
74+
75+// Values are ordered like TaskResult so that merge picks the worst
76+// (TaskResultUtil.MergeTaskResults).
77+const (
78+ resSucceeded stepResult = iota
79+ resFailed
80+ resCanceled
81+ resSkipped
82+)
83+
84+func (r stepResult) conclusion() Conclusion {
85+ switch r {
86+ case resFailed:
87+ return ConclusionFailure
88+ case resCanceled:
89+ return ConclusionCancelled
90+ case resSkipped:
91+ return ConclusionSkipped
92+ }
93+ return ConclusionSuccess
94+}
95+
96+// merge is TaskResultUtil.MergeTaskResults (Runner.Common/Util/TaskResultUtil.cs:40).
97+func merge(cur *stepResult, coming stepResult) stepResult {
98+ if cur == nil {
99+ return coming
100+ }
101+ if *cur > resFailed {
102+ return *cur
103+ }
104+ if coming >= *cur {
105+ return coming
106+ }
107+ return *cur
108+}
109+
110+type job struct {
111+ cfg Config
112+ cancel *Canceller
113+ wf *workflow.Workflow
114+ jb *workflow.Job
115+ fc *FileCommands
116+ trackID string
117+
118+ mu sync.Mutex // serialises the Reporter and guards state and result
119+ state *State
120+ result *stepResult // nil until a step fails or the job is cancelled (Global result)
121+ cancelled bool
122+ runDefaults struct{ shell, workingDirectory string }
123+ baseEnv []string
124+ lookupPath string
125+ before map[string]bool
126+ outputsSet bool
127+}
128+
129+func (j *job) now() time.Time {
130+ if j.cfg.Now != nil {
131+ return j.cfg.Now()
132+ }
133+ return time.Now()
134+}
135+
136+// Run executes one job and reports to cfg.Reporter. It always ends with a
137+// JobCompleted call and returns the same Result. cancel may be nil.
138+func Run(cancel *Canceller, cfg Config) Result {
139+ if cancel == nil {
140+ cancel = NewCanceller()
141+ }
142+ j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()}
143+ res := j.run()
144+ cfg.Reporter.JobCompleted(res)
145+ return res
146+}
147+
148+func (j *job) run() Result {
149+ minutes := j.cfg.TimeoutMinutes
150+ if minutes <= 0 {
151+ minutes = DefaultTimeoutMinutes
152+ }
153+ timer := time.AfterFunc(time.Duration(minutes)*time.Minute, func() { j.cancel.Cancel(CancelTimeout) })
154+ defer timer.Stop()
155+
156+ // ---- Set up job (JobExtension.InitializeJob) ----
157+ rep := j.cfg.Reporter
158+ rep.StepStarted(1, SetUpJobName, j.now())
159+ steps, err := j.setUp()
160+ if err != nil {
161+ j.errorf(1, "%s", err.Error())
162+ rep.StepCompleted(1, SetUpJobName, ConclusionFailure, j.now())
163+ // The job ends here, without "Complete job" (JobRunner.cs:107).
164+ return Result{Conclusion: ConclusionFailure, Error: err.Error()}
165+ }
166+ rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now())
167+
168+ // ---- the steps (StepsRunner) ----
169+ for i, st := range steps {
170+ j.runStep(i+2, st)
171+ }
172+
173+ // ---- Complete job (JobExtension.FinalizeJob) ----
174+ last := len(steps) + 2
175+ outputs := j.completeJob(last)
176+
177+ return j.finish(outputs)
178+}
179+
180+func (j *job) finish(outputs map[string]string) Result {
181+ res := Result{Outputs: outputs}
182+ j.mu.Lock()
183+ defer j.mu.Unlock()
184+ r := resSucceeded
185+ if j.result != nil {
186+ r = *j.result
187+ }
188+ switch {
189+ case j.cancel.Reason() == CancelTimeout && j.cancelled:
190+ res.Conclusion = ConclusionTimedOut
191+ res.Error = fmt.Sprintf("The job has exceeded the maximum execution time of %d minutes.", j.timeoutMinutes())
192+ case r == resCanceled:
193+ res.Conclusion = ConclusionCancelled
194+ res.RunnerShutdown = j.cancel.Reason() == CancelShutdown
195+ case r == resFailed:
196+ res.Conclusion = ConclusionFailure
197+ default:
198+ res.Conclusion = ConclusionSuccess
199+ }
200+ return res
201+}
202+
203+func (j *job) timeoutMinutes() int {
204+ if j.cfg.TimeoutMinutes > 0 {
205+ return j.cfg.TimeoutMinutes
206+ }
207+ return DefaultTimeoutMinutes
208+}
209+
210+// ---------------------------------------------------------------- logging
211+
212+// write sends one log line to the Reporter, in order.
213+func (j *job) write(num int, line string) {
214+ j.mu.Lock()
215+ defer j.mu.Unlock()
216+ j.cfg.Reporter.Log(num, j.now(), line)
217+}
218+
219+// out writes a message that may hold several lines (ExecutionContext.Output).
220+func (j *job) out(num int, text string) {
221+ for _, l := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
222+ j.write(num, l)
223+ }
224+}
225+
226+// tagged writes tag+message; only the first line carries the tag.
227+func (j *job) tagged(num int, tag, msg string) {
228+ if len(msg) > maxIssueMessageLength && tag != "" {
229+ msg = msg[:maxIssueMessageLength]
230+ }
231+ lines := strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n")
232+ j.write(num, tag+lines[0])
233+ for _, l := range lines[1:] {
234+ j.write(num, l)
235+ }
236+}
237+
238+func (j *job) errorf(num int, format string, a ...any) {
239+ j.tagged(num, "##[error]", fmt.Sprintf(format, a...))
240+}
241+
242+func (j *job) debug(num int, msg string) {
243+ if j.cfg.Debug {
244+ for _, l := range strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") {
245+ j.write(num, "##[debug]"+l)
246+ }
247+ }
248+}
249+
250+// ---------------------------------------------------------------- set up
251+
252+// evalEnv is the environment for expressions of the job and step tokens.
253+func (j *job) evalEnv(env *ordered) template.Env {
254+ return template.Env{
255+ Contexts: j.state.Values(env),
256+ Funcs: map[string]expr.Func{
257+ // hashFiles is a CI-2 feature: the schema allows it in steps, so it
258+ // parses, and fails when evaluated.
259+ "hashFiles": func(context.Context, []expr.Value) (expr.Value, error) {
260+ return nil, errors.New("not supported yet: hashFiles")
261+ },
262+ },
263+ }
264+}
265+
266+func (j *job) evalString(tok template.Token, env *ordered) (string, error) {
267+ if tok == nil {
268+ return "", nil
269+ }
270+ return template.EvaluateString(context.Background(), tok, j.evalEnv(env))
271+}
272+
273+// evalMap evaluates a mapping of strings (EvaluateStepEnvironment): values
274+// are converted like GitHub converts them (null is the empty string).
275+func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
276+ if m == nil {
277+ return nil, nil
278+ }
279+ v, err := template.Evaluate(context.Background(), m, j.evalEnv(env))
280+ if err != nil {
281+ return nil, err
282+ }
283+ d, ok := v.(*expr.Dictionary)
284+ if !ok {
285+ return nil, nil
286+ }
287+ var out []Pair
288+ for _, p := range d.Pairs() {
289+ if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
290+ return nil, fmt.Errorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k)
291+ }
292+ out = append(out, Pair{p.Key, expr.CoerceString(p.Value)})
293+ }
294+ return out, nil
295+}
296+
297+// setUp parses the workflow and prepares the job: JobExtension.InitializeJob
298+// (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its
299+// log lines belong to step 1.
300+func (j *job) setUp() ([]*workflow.Step, error) {
301+ cfg := j.cfg
302+ j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))
303+ if cfg.Runner.Name != "" {
304+ j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))
305+ }
306+ if host, err := os.Hostname(); err == nil {
307+ j.out(1, fmt.Sprintf("Machine name: '%s'", host))
308+ }
309+
310+ wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML)
311+ if len(errs) > 0 {
312+ var msgs []string
313+ for _, e := range errs {
314+ msgs = append(msgs, e.Error())
315+ }
316+ return nil, fmt.Errorf("The workflow is not valid. %s", strings.Join(msgs, "\n"))
317+ }
318+ jb := wf.Job(cfg.JobKey)
319+ if jb == nil {
320+ return nil, fmt.Errorf("The workflow has no job '%s'.", cfg.JobKey)
321+ }
322+ j.wf, j.jb = wf, jb
323+
324+ state, layout, err := NewState(cfg.ContextsJSON, cfg.MatrixJSON, cfg.JobKey, cfg.Runner, cfg.Repo)
325+ if err != nil {
326+ return nil, err
327+ }
328+ if cfg.Layout != nil {
329+ state.WithLayout(*cfg.Layout)
330+ layout = *cfg.Layout
331+ }
332+ j.state = state
333+
334+ // Features that arrive in later phases fail the job with a clear message.
335+ switch {
336+ case jb.IsReusable():
337+ return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)")
338+ case jb.Container != nil:
339+ return nil, errors.New("not supported yet: jobs.<id>.container")
340+ case jb.Services != nil:
341+ return nil, errors.New("not supported yet: jobs.<id>.services")
342+ }
343+
344+ // Directories (TempDirectoryManager, PipelineDirectoryManager).
345+ j.out(1, "Prepare workflow directory")
346+ for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} {
347+ if err := os.MkdirAll(d, 0o755); err != nil {
348+ return nil, fmt.Errorf("create %s: %w", d, err)
349+ }
350+ }
351+ clearDir(layout.Temp)
352+ if err := os.MkdirAll(layout.Temp, 0o755); err != nil {
353+ return nil, err
354+ }
355+ fc, err := NewFileCommands(filepath.Join(layout.Temp, "_runner_file_commands"))
356+ if err != nil {
357+ return nil, err
358+ }
359+ j.fc = fc
360+
361+ j.baseEnv = cfg.BaseEnv
362+ if j.baseEnv == nil {
363+ j.baseEnv = os.Environ()
364+ }
365+ if !hasEnv(j.baseEnv, "HOME") {
366+ j.baseEnv = append(append([]string(nil), j.baseEnv...), "HOME="+layout.Home)
367+ }
368+ j.lookupPath = envValue(j.baseEnv, "PATH")
369+
370+ // Job-level environment: workflow env, then job env, each evaluated with an
371+ // empty env context (JobExtension.cs:38-49, ExecutionContext.cs:1048).
372+ for _, m := range []*template.Mapping{wf.Env, jb.Env} {
373+ pairs, err := j.evalMap(m, newOrdered())
374+ if err != nil {
375+ return nil, err
376+ }
377+ for _, p := range pairs {
378+ j.state.SetEnv(p.Key, p.Value)
379+ }
380+ }
381+
382+ // defaults.run: a job-level "run" replaces the workflow's whole (JobExtension.cs:78-96).
383+ for _, d := range []*workflow.Defaults{wf.Defaults, jb.Defaults} {
384+ if d == nil || d.Run == nil {
385+ continue
386+ }
387+ shell, err := j.evalString(d.Run.Shell, newOrdered())
388+ if err != nil {
389+ return nil, err
390+ }
391+ wd, err := j.evalString(d.Run.WorkingDirectory, newOrdered())
392+ if err != nil {
393+ return nil, err
394+ }
395+ j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd
396+ }
397+
398+ j.out(1, "Prepare all required actions")
399+ name := cfg.JobName
400+ if name == "" {
401+ name = cfg.JobKey
402+ }
403+ if strings.TrimSpace(name) != "" {
404+ j.out(1, "Complete job name: "+name)
405+ }
406+
407+ // Process tracking for the orphan cleanup of "Complete job".
408+ j.before = map[string]bool{}
409+ for _, p := range snapshotProcesses() {
410+ j.before[p.key()] = true
411+ }
412+ return jb.Steps, nil
413+}
414+
415+func clearDir(dir string) {
416+ ents, err := os.ReadDir(dir)
417+ if err != nil {
418+ return
419+ }
420+ for _, e := range ents {
421+ _ = os.RemoveAll(filepath.Join(dir, e.Name()))
422+ }
423+}
424+
425+func hasEnv(env []string, key string) bool {
426+ for _, kv := range env {
427+ if strings.HasPrefix(kv, key+"=") {
428+ return true
429+ }
430+ }
431+ return false
432+}
433+
434+func envValue(env []string, key string) string {
435+ v := ""
436+ for _, kv := range env {
437+ if k, val, ok := strings.Cut(kv, "="); ok && k == key {
438+ v = val
439+ }
440+ }
441+ return v
442+}
443+
444+// ---------------------------------------------------------------- steps
445+
446+// stepRun is the state of one step being run; it implements Sink and
447+// commandHost.
448+type stepRun struct {
449+ j *job
450+ num int
451+ st *workflow.Step
452+ env *ordered // the step's environment: job env + step env
453+ result *stepResult
454+ cmdResult *stepResult
455+ outcome *stepResult
456+ cmds *commandManager
457+ secretEnvLogged bool
458+}
459+
460+func (s *stepRun) Out(text string) { s.j.out(s.num, text) }
461+func (s *stepRun) Tagged(tag, msg string) { s.j.tagged(s.num, tag, msg) }
462+func (s *stepRun) Debug(msg string) { s.j.debug(s.num, msg) }
463+func (s *stepRun) Error(msg string) { s.j.errorf(s.num, "%s", msg) }
464+func (s *stepRun) CommandFailed() { f := resFailed; s.cmdResult = &f }
465+func (s *stepRun) SetOutput(name, value string) {
466+ s.j.mu.Lock()
467+ s.j.state.SetOutput(s.st.ID, name, value)
468+ s.j.mu.Unlock()
469+}
470+func (s *stepRun) SaveState(name, value string) {}
471+func (s *stepRun) SetEnv(name, value string) {
472+ s.j.mu.Lock()
473+ s.j.state.SetEnv(name, value)
474+ s.j.mu.Unlock()
475+}
476+func (s *stepRun) AddPath(p string) {
477+ s.j.mu.Lock()
478+ s.j.state.AddPath(p)
479+ s.j.mu.Unlock()
480+}
481+
482+// EnvFlag reads a boolean variable of the env context, then of the process
483+// (ActionCommandManager.cs:236-246).
484+func (s *stepRun) EnvFlag(name string) bool {
485+ v := strings.ToLower(os.Getenv(name))
486+ if v != "true" {
487+ if e, ok := s.env.Get(name); ok {
488+ v = strings.ToLower(e)
489+ }
490+ }
491+ return v == "true"
492+}
493+
494+func firstLine(contents string) string {
495+ l := strings.TrimLeft(contents, " \t\r\n")
496+ if i := strings.IndexAny(l, "\r\n"); i >= 0 {
497+ l = l[:i]
498+ }
499+ return l
500+}
501+
502+// displayName is the name shown for the step (ActionRunner.GenerateDisplayName,
503+// ActionRunner.cs:363): the evaluated name, or "Run " and the first line of
504+// the script or the action reference.
505+func (j *job) displayName(st *workflow.Step, env *ordered) string {
506+ tok := st.Name
507+ prefix := ""
508+ switch {
509+ case tok != nil:
510+ case st.Run != nil:
511+ tok, prefix = st.Run, "Run "
512+ case st.Uses != nil:
513+ tok, prefix = st.Uses, "Run "
514+ default:
515+ return ""
516+ }
517+ name, err := j.evalString(tok, env)
518+ if err != nil {
519+ name = tokenSource(tok)
520+ }
521+ return formatStepName(prefix, name)
522+}
523+
524+func tokenSource(t template.Token) string {
525+ if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {
526+ return be.Source
527+ }
528+ return t.String()
529+}
530+
531+// formatStepName is ActionRunner.FormatStepName (ActionRunner.cs:447).
532+func formatStepName(prefix, name string) string {
533+ if name == "" {
534+ return ""
535+ }
536+ return prefix + firstLine(name)
537+}
538+
539+func (j *job) statusFuncs() workflow.StatusFuncs {
540+ st := j.state.Status()
541+ return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
542+}
543+
544+// markCancelled records the cancellation of the job: job result Canceled and
545+// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
546+func (j *job) markCancelledLocked() {
547+ if j.cancelled {
548+ return
549+ }
550+ j.cancelled = true
551+ c := resCanceled
552+ j.result = &c
553+ j.state.SetStatus("cancelled")
554+}
555+
556+func (j *job) runStep(num int, st *workflow.Step) {
557+ rep := j.cfg.Reporter
558+ s := &stepRun{j: j, num: num, st: st}
559+ s.cmds = newCommandManager(s, j.cfg.Debug)
560+
561+ // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
562+ // own, evaluated against the job env.
563+ j.mu.Lock()
564+ jobEnv := j.state.JobEnv()
565+ j.mu.Unlock()
566+ s.env = jobEnv.Clone()
567+ pairs, envErr := j.evalMap(st.Env, jobEnv)
568+ if envErr == nil {
569+ for _, p := range pairs {
570+ s.env.Set(p.Key, p.Value)
571+ }
572+ }
573+ name := j.displayName(st, s.env)
574+ rep.StepStarted(num, name, j.now())
575+
576+ complete := func(r stepResult) {
577+ res := r
578+ j.mu.Lock()
579+ // Update the steps context (ExecutionContext.UpdateGlobalStepsContext).
580+ outcome := res
581+ if s.outcome != nil {
582+ outcome = *s.outcome
583+ }
584+ j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
585+ if res == resFailed {
586+ m := merge(j.result, resFailed)
587+ j.result = &m
588+ j.state.SetStatus(statusName(m))
589+ }
590+ j.mu.Unlock()
591+ rep.StepCompleted(num, name, res.conclusion(), j.now())
592+ }
593+
594+ if envErr != nil {
595+ j.errorf(num, "%s", envErr.Error())
596+ complete(resFailed)
597+ return
598+ }
599+
600+ // Cancellation seen before the step starts (StepsRunner.cs:188-197).
601+ cancelledAtStart := false
602+ select {
603+ case <-j.cancel.Done():
604+ cancelledAtStart = true
605+ j.mu.Lock()
606+ j.markCancelledLocked()
607+ j.mu.Unlock()
608+ default:
609+ }
610+
611+ // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not
612+ // evaluated and the step is skipped.
613+ cond := func() (bool, error) {
614+ j.mu.Lock()
615+ defer j.mu.Unlock()
616+ return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs())
617+ }
618+ if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
619+ complete(resSkipped)
620+ return
621+ }
622+ ok, err := cond()
623+ switch {
624+ case err != nil:
625+ j.errorf(num, "%s", err.Error())
626+ complete(resFailed)
627+ return
628+ case !ok:
629+ complete(resSkipped)
630+ return
631+ }
632+
633+ // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
634+ // cancelled and the step is stopped unless its condition still holds
635+ // (always()).
636+ stepCancel := make(chan struct{})
637+ stopWatch := make(chan struct{})
638+ var watch sync.WaitGroup
639+ if !cancelledAtStart {
640+ watch.Add(1)
641+ go func() {
642+ defer watch.Done()
643+ select {
644+ case <-j.cancel.Done():
645+ case <-stopWatch:
646+ return
647+ }
648+ j.mu.Lock()
649+ j.markCancelledLocked()
650+ j.mu.Unlock()
651+ keep := false
652+ if j.cancel.Reason() != CancelShutdown {
653+ keep, _ = cond()
654+ }
655+ if !keep {
656+ close(stepCancel)
657+ }
658+ }()
659+ }
660+
661+ r := j.execute(s, stepCancel)
662+ close(stopWatch)
663+ watch.Wait()
664+
665+ // Merge the result of the commands (StepsRunner.cs:347-351).
666+ if s.cmdResult != nil {
667+ m := merge(&r, *s.cmdResult)
668+ r = m
669+ }
670+ // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
671+ if r == resFailed && st.ContinueOnError != nil {
672+ coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
673+ if cerr != nil {
674+ j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
675+ j.errorf(num, "%s", cerr.Error())
676+ }
677+ if coe && cerr == nil {
678+ o := resFailed
679+ s.outcome = &o
680+ r = resSucceeded
681+ }
682+ }
683+ complete(r)
684+}
685+
686+// statusName is the job.status for a result (TaskResult.ToActionResult).
687+func statusName(r stepResult) string {
688+ switch r {
689+ case resFailed:
690+ return "failure"
691+ case resCanceled:
692+ return "cancelled"
693+ }
694+ return "success"
695+}
696+
697+// notSupported is the failure of a step that uses a feature of a later phase.
698+func notSupported(st *workflow.Step) string {
699+ switch {
700+ case st.Uses != nil:
701+ return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value)
702+ case st.TimeoutMinutes != nil:
703+ return "not supported yet: timeout-minutes"
704+ }
705+ return ""
706+}
707+
708+// execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
709+func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) (result stepResult) {
710+ num, st := s.num, s.st
711+ if msg := notSupported(st); msg != "" {
712+ if st.Uses != nil {
713+ j.out(num, "##[group]Run "+st.Uses.Value)
714+ j.out(num, "##[endgroup]")
715+ }
716+ j.errorf(num, "%s", msg)
717+ return resFailed
718+ }
719+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
720+
721+ // File commands for this step: the github context carries the paths
722+ // (FileCommandManager.InitializeFiles), event.json is written
723+ // (ExecutionContext.WriteWebhookPayload).
724+ j.mu.Lock()
725+ files, ferr := j.fc.Init()
726+ if ferr == nil {
727+ for _, c := range fileCommands {
728+ j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
729+ }
730+ j.state.SetGitHub("action", st.ID)
731+ j.state.SetGitHub("action_repository", "")
732+ j.state.SetGitHub("action_ref", "")
733+ if ev := j.state.EventJSON(); ev != "" {
734+ dir := filepath.Join(j.state.Layout().Temp, "_github_workflow")
735+ if err := os.MkdirAll(dir, 0o755); err == nil {
736+ p := filepath.Join(dir, "event.json")
737+ if err := os.WriteFile(p, []byte(ev), 0o644); err == nil {
738+ j.state.SetGitHub("event_path", p)
739+ }
740+ }
741+ }
742+ }
743+ j.mu.Unlock()
744+ if ferr != nil {
745+ return fail(ferr)
746+ }
747+ defer func() {
748+ // ProcessFiles runs after the handler, even when it failed (finally).
749+ j.fc.Process(s)
750+ }()
751+
752+ // Inputs (ActionRunner.cs:174-185).
753+ script, err := j.evalString(st.Run, s.env)
754+ if err != nil {
755+ return fail(err)
756+ }
757+ shellInput, err := j.evalString(st.Shell, s.env)
758+ if err != nil {
759+ return fail(err)
760+ }
761+ var wdInput string
762+ var hasWD bool
763+ if st.WorkingDirectory != nil {
764+ hasWD = true
765+ if wdInput, err = j.evalString(st.WorkingDirectory, s.env); err != nil {
766+ return fail(err)
767+ }
768+ }
769+
770+ // Header (ScriptHandler.PrintActionDetails, ScriptHandler.cs:27-137).
771+ j.out(num, "##[group]Run "+firstLine(script))
772+ for _, l := range strings.Split(strings.TrimRight(strings.ReplaceAll(script, "\r\n", "\n"), "\n"), "\n") {
773+ j.write(num, "\x1b[36;1m"+l+"\x1b[0m") // bright cyan
774+ }
775+ shell := shellInput
776+ if shell == "" {
777+ shell = j.runDefaults.shell
778+ }
779+ j.mu.Lock()
780+ lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}
781+ j.mu.Unlock()
782+ plan, err := PlanShell(shell, lookup)
783+ if err != nil {
784+ return fail(err) // the group stays open, like the original
785+ }
786+ j.out(num, "shell: "+plan.ShellLine())
787+ if s.env.Len() > 0 {
788+ j.write(num, "env:")
789+ for _, k := range s.env.Keys() {
790+ v, _ := s.env.Get(k)
791+ j.out(num, fmt.Sprintf(" %s: %s", k, v))
792+ }
793+ }
794+ j.write(num, "##[endgroup]")
795+
796+ // Run (ScriptHandler.RunAsync, :140-358).
797+ wd := wdInput
798+ if !hasWD {
799+ wd = j.runDefaults.workingDirectory
800+ }
801+ j.mu.Lock()
802+ workspace := j.state.GitHub("workspace")
803+ temp := j.state.Layout().Temp
804+ j.mu.Unlock()
805+ dir := ResolveWorkingDirectory(workspace, wd)
806+ sc, err := WriteScript(temp, plan, script)
807+ if err != nil {
808+ return fail(err)
809+ }
810+
811+ // Process environment: the inherited one, the step's environment, PATH with
812+ // GITHUB_PATH in front, then the runtime variables of the contexts.
813+ j.mu.Lock()
814+ runtime := j.state.RuntimeEnv()
815+ prepend := j.state.Prepend()
816+ j.mu.Unlock()
817+ penv := newOrdered()
818+ for _, kv := range j.baseEnv {
819+ if k, v, ok := strings.Cut(kv, "="); ok {
820+ penv.Set(k, v)
821+ }
822+ }
823+ for _, k := range s.env.Keys() {
824+ v, _ := s.env.Get(k)
825+ penv.Set(k, v)
826+ }
827+ if len(prepend) > 0 {
828+ orig, ok := s.env.Get("PATH")
829+ if !ok {
830+ orig = j.lookupPath
831+ }
832+ penv.Set("PATH", PrependedPath(prepend, orig))
833+ }
834+ for _, k := range runtime.Keys() {
835+ v, _ := runtime.Get(k)
836+ penv.Set(k, v)
837+ }
838+ penv.Set("GITHUB_ACTIONS", "true") // ProcessInvoker.cs:283
839+ if _, ok := penv.Get("CI"); !ok {
840+ penv.Set("CI", "true")
841+ }
842+ penv.Set(ProcessTrackingID, j.trackID)
843+ var envList []string
844+ for _, k := range penv.Keys() {
845+ v, _ := penv.Get(k)
846+ envList = append(envList, k+"="+v)
847+ }
848+
849+ res, err := runProcess(procSpec{
850+ Argv: sc.Argv, Dir: dir, Env: envList, Credential: j.cfg.Credential,
851+ OnLine: func(line string) {
852+ if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") {
853+ line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`)
854+ }
855+ if (strings.Contains(line, "##[") || strings.Contains(line, "::")) && s.cmds.TryProcess(line) {
856+ return
857+ }
858+ j.write(num, line)
859+ },
860+ Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter,
861+ WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
862+ })
863+ if err != nil {
864+ return fail(errors.New(startError(plan.CommandPath, dir, err)))
865+ }
866+ if res.Cancelled {
867+ // The process was stopped on cancellation: ExecuteAsync throws
868+ // (ProcessInvoker.cs:369) and StepsRunner logs the exception
869+ // (StepsRunner.cs:333-337).
870+ j.errorf(num, "The operation was canceled.")
871+ return resCanceled
872+ }
873+ if res.ExitCode != 0 {
874+ j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
875+ return resFailed
876+ }
877+ return resSucceeded
878+}
879+
880+// ---------------------------------------------------------------- complete
881+
882+// completeJob is JobExtension.FinalizeJob (JobExtension.cs:9-190): job outputs
883+// and the cleanup of orphan processes.
884+func (j *job) completeJob(num int) map[string]string {
885+ rep := j.cfg.Reporter
886+ rep.StepStarted(num, CompleteJobName, j.now())
887+ result := resSucceeded
888+ outputs := map[string]string{}
889+
890+ if j.jb.Outputs != nil {
891+ // The runner of the official service prints nothing here: the recordings
892+ // of github.com show only "Cleaning up orphan processes" in "Complete
893+ // job", although the jobs have outputs (the log lines of
894+ // JobExtension.cs:48,68 belong to the legacy message that carries
895+ // JobOutputs).
896+ j.mu.Lock()
897+ env := j.state.JobEnv()
898+ j.mu.Unlock()
899+ pairs, err := j.evalMap(j.jb.Outputs, env)
900+ if err != nil {
901+ result = resFailed
902+ j.errorf(num, "Fail to evaluate job outputs")
903+ j.errorf(num, "%s", err.Error())
904+ j.mu.Lock()
905+ m := merge(j.result, resFailed)
906+ j.result = &m
907+ j.mu.Unlock()
908+ }
909+ for _, p := range pairs {
910+ if p.Value == "" {
911+ j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key))
912+ continue
913+ }
914+ outputs[p.Key] = p.Value
915+ }
916+ }
917+
918+ j.out(num, "Cleaning up orphan processes")
919+ killOrphans(j.trackID, j.before, func(l string) { j.out(num, l) })
920+
921+ rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())
922+ return outputs
923+}

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