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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||