feat(executor): annotations, problem matchers, summaries, step outcome and timeout-minutes
Ports ExecutionContext.AddIssue, IssueCommandExtension, IssueMatcher, OutputManager, AddMask/AddMatcher/RemoveMatcher, save-state recording, CreateStepSummaryCommand and StepsRunner's step timeout. Adds github.com/dlclark/regexp2.
- commit
- 77f226209d5f1364282b862fbff76fadbae5781c
- author
- brice · · 2026-10-13 15:07 UTC
- parent
- b90459e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +2340 -41
M go.mod +1 -0
| @@ -8,6 +8,7 @@ require ( | |||
| 8 | 8 | connectrpc.com/connect v1.21.0 | |
| 9 | 9 | github.com/bnema/zerowrap v1.4.1 | |
| 10 | 10 | github.com/containerd/errdefs v1.0.0 | |
| 11 | + | github.com/dlclark/regexp2 v1.12.0 | |
| 11 | 12 | github.com/google/uuid v1.6.0 | |
| 12 | 13 | github.com/moby/moby/api v1.56.1 | |
| 13 | 14 | github.com/moby/moby/client v0.6.2 | |
| @@ -8,6 +8,7 @@ require ( | |||||
| 8 | connectrpc.com/connect v1.21.0 | 8 | connectrpc.com/connect v1.21.0 | ||
| 9 | github.com/bnema/zerowrap v1.4.1 | 9 | github.com/bnema/zerowrap v1.4.1 | ||
| 10 | github.com/containerd/errdefs v1.0.0 | 10 | github.com/containerd/errdefs v1.0.0 | ||
| 11 | + | github.com/dlclark/regexp2 v1.12.0 | |||
| 11 | github.com/google/uuid v1.6.0 | 12 | github.com/google/uuid v1.6.0 | ||
| 12 | github.com/moby/moby/api v1.56.1 | 13 | github.com/moby/moby/api v1.56.1 | ||
| 13 | github.com/moby/moby/client v0.6.2 | 14 | github.com/moby/moby/client v0.6.2 | ||
M go.sum +2 -0
| @@ -69,6 +69,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3 | |||
| 69 | 69 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | |
| 70 | 70 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | |
| 71 | 71 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | |
| 72 | + | github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= | |
| 73 | + | github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= | |
| 72 | 74 | github.com/docker/cli v29.8.0+incompatible h1:ih0c2jq/nN7QfES8zIfwSzIhRScjs4ehER+kZ60aeSk= | |
| 73 | 75 | github.com/docker/cli v29.8.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= | |
| 74 | 76 | github.com/docker/docker-credential-helpers v0.9.9 h1:BkydjIgZ46JnDbqyM2p2fc63KMw6y+KHL3Em/2AGJ7w= | |
| @@ -69,6 +69,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3 | |||||
| 69 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | 69 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | ||
| 70 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | 70 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | ||
| 71 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | 71 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | ||
| 72 | + | github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= | |||
| 73 | + | github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= | |||
| 72 | github.com/docker/cli v29.8.0+incompatible h1:ih0c2jq/nN7QfES8zIfwSzIhRScjs4ehER+kZ60aeSk= | 74 | github.com/docker/cli v29.8.0+incompatible h1:ih0c2jq/nN7QfES8zIfwSzIhRScjs4ehER+kZ60aeSk= | ||
| 73 | github.com/docker/cli v29.8.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= | 75 | github.com/docker/cli v29.8.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= | ||
| 74 | github.com/docker/docker-credential-helpers v0.9.9 h1:BkydjIgZ46JnDbqyM2p2fc63KMw6y+KHL3Em/2AGJ7w= | 76 | github.com/docker/docker-credential-helpers v0.9.9 h1:BkydjIgZ46JnDbqyM2p2fc63KMw6y+KHL3Em/2AGJ7w= | ||
M internal/executor/commands.go +134 -13
| @@ -3,15 +3,17 @@ | |||
| 3 | 3 | package executor | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 8 | + | "io/fs" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 7 | 11 | "strings" | |
| 8 | 12 | "unicode" | |
| 9 | 13 | ) | |
| 10 | 14 | ||
| 11 | 15 | // 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. | |
| 16 | + | // (Runner.Common) and of ActionCommandManager.cs. | |
| 15 | 17 | ||
| 16 | 18 | type escapeMapping struct{ token, replacement string } | |
| 17 | 19 | ||
| @@ -117,9 +119,7 @@ const ( | |||
| 117 | 119 | ) | |
| 118 | 120 | ||
| 119 | 121 | // 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. | |
| 122 | + | // (the names of the extensions at ActionCommandManager.cs). | |
| 123 | 123 | var commandNames = []string{ | |
| 124 | 124 | "set-env", "set-output", "save-state", "add-mask", "add-path", "add-matcher", "remove-matcher", | |
| 125 | 125 | "debug", "warning", "error", "notice", "group", "endgroup", "echo", "stop-commands", | |
| @@ -139,6 +139,17 @@ type commandHost interface { | |||
| 139 | 139 | CommandFailed() | |
| 140 | 140 | // EnvFlag reads a variable of the env context, then of the process. | |
| 141 | 141 | EnvFlag(name string) bool | |
| 142 | + | // AddIssue is ExecutionContext.AddIssue: it logs the issue and keeps it for | |
| 143 | + | // the annotations of the step. | |
| 144 | + | AddIssue(issue) | |
| 145 | + | // Workspace is github.workspace. | |
| 146 | + | Workspace() string | |
| 147 | + | // AddMask is SecretMasker.AddValue. | |
| 148 | + | AddMask(value string) | |
| 149 | + | // Matchers is the root of the problem matchers of the job. | |
| 150 | + | Matchers() *matcherRoot | |
| 151 | + | // ReadMatcherFile reads a problem matcher file (IOUtil.LoadObject). | |
| 152 | + | ReadMatcherFile(path string) ([]byte, error) | |
| 142 | 153 | } | |
| 143 | 154 | ||
| 144 | 155 | // commandManager is ActionCommandManager: one per step. | |
| @@ -192,6 +203,9 @@ func (m *commandManager) TryProcess(input string) bool { | |||
| 192 | 203 | } | |
| 193 | 204 | m.stopToken, m.stopped = cmd.Data, true | |
| 194 | 205 | m.registered[strings.ToLower(cmd.Data)] = true | |
| 206 | + | if len(m.stopToken) > 6 { // ActionCommandManager.cs:117 | |
| 207 | + | m.host.AddMask(m.stopToken) | |
| 208 | + | } | |
| 195 | 209 | m.host.Out(input) | |
| 196 | 210 | m.host.Debug("Paused processing commands until the token you called ::stopCommands:: with is received") | |
| 197 | 211 | return true | |
| @@ -233,10 +247,12 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||
| 233 | 247 | h.Out("##[endgroup]") | |
| 234 | 248 | case "debug": | |
| 235 | 249 | 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) | |
| 250 | + | case "warning": | |
| 251 | + | m.commandIssue(LevelWarning, name, c) | |
| 252 | + | case "error": | |
| 253 | + | m.commandIssue(LevelFailure, name, c) | |
| 254 | + | case "notice": | |
| 255 | + | m.commandIssue(LevelNotice, name, c) | |
| 240 | 256 | case "echo": | |
| 241 | 257 | if c.Data == "" { | |
| 242 | 258 | return ghErrorf("Value cannot be null or empty. (Parameter 'value')") | |
| @@ -289,9 +305,114 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||
| 289 | 305 | return ghErrorf("Value cannot be null or empty. (Parameter 'path')") | |
| 290 | 306 | } | |
| 291 | 307 | 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)) | |
| 308 | + | case "add-mask": | |
| 309 | + | m.addMask(c) | |
| 310 | + | case "add-matcher": | |
| 311 | + | return m.addMatcher(c) | |
| 312 | + | case "remove-matcher": | |
| 313 | + | return m.removeMatcher(c) | |
| 314 | + | } | |
| 315 | + | return nil | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // warn is IExecutionContext.Warning. | |
| 319 | + | func (m *commandManager) warn(msg string) { m.host.AddIssue(issue{Level: LevelWarning, Message: msg}) } | |
| 320 | + | ||
| 321 | + | // addMask is AddMaskCommandExtension (ActionCommandManager.cs:402-424). | |
| 322 | + | func (m *commandManager) addMask(c *actionCommand) { | |
| 323 | + | if strings.TrimFunc(c.Data, unicode.IsSpace) == "" { | |
| 324 | + | m.warn("Can't add secret mask for empty string in ##[add-mask] command.") | |
| 325 | + | return | |
| 326 | + | } | |
| 327 | + | if m.echo { | |
| 328 | + | m.host.Out("::add-mask::***") | |
| 329 | + | } | |
| 330 | + | m.host.AddMask(c.Data) | |
| 331 | + | // Also add each individual line. Typically individual lines are processed | |
| 332 | + | // from STDOUT of child processes. | |
| 333 | + | for _, item := range strings.FieldsFunc(c.Data, func(r rune) bool { return r == '\r' || r == '\n' }) { | |
| 334 | + | if item = strings.TrimFunc(item, unicode.IsSpace); item != "" { | |
| 335 | + | m.host.AddMask(item) | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // matcherPath roots a path of add-matcher and remove-matcher in the workspace. | |
| 341 | + | func (m *commandManager) matcherPath(file string) string { | |
| 342 | + | if !filepath.IsAbs(file) { | |
| 343 | + | return filepath.Join(m.host.Workspace(), file) | |
| 344 | + | } | |
| 345 | + | return file | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // loadMatchers is IOUtil.LoadObject<IssueMatchersConfig>. | |
| 349 | + | func (m *commandManager) loadMatchers(file string) (*matchersConfig, error) { | |
| 350 | + | b, err := m.host.ReadMatcherFile(file) | |
| 351 | + | if err != nil { | |
| 352 | + | return nil, dotnetFileError(file, err) | |
| 353 | + | } | |
| 354 | + | return parseMatchersConfig(b) | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | // dotnetFileError gives the error of a file that cannot be read the text of | |
| 358 | + | // the .NET exception (FileNotFoundException, DirectoryNotFoundException). | |
| 359 | + | func dotnetFileError(file string, err error) error { | |
| 360 | + | switch { | |
| 361 | + | case errors.Is(err, fs.ErrNotExist): | |
| 362 | + | if st, e := os.Stat(filepath.Dir(file)); e != nil || !st.IsDir() { | |
| 363 | + | return ghErrorf("Could not find a part of the path '%s'.", file) | |
| 364 | + | } | |
| 365 | + | return ghErrorf("Could not find file '%s'.", file) | |
| 366 | + | case errors.Is(err, fs.ErrPermission): | |
| 367 | + | return ghErrorf("Access to the path '%s' is denied.", file) | |
| 368 | + | } | |
| 369 | + | return err | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // addMatcher is AddMatcherCommandExtension (ActionCommandManager.cs:496-536). | |
| 373 | + | func (m *commandManager) addMatcher(c *actionCommand) error { | |
| 374 | + | if c.Data == "" { | |
| 375 | + | m.warn("File path must be specified.") | |
| 376 | + | return nil | |
| 377 | + | } | |
| 378 | + | cfg, err := m.loadMatchers(m.matcherPath(c.Data)) | |
| 379 | + | if err != nil { | |
| 380 | + | return err | |
| 381 | + | } | |
| 382 | + | if cfg != nil && len(cfg.Matchers) > 0 { | |
| 383 | + | if err := cfg.validate(); err != nil { | |
| 384 | + | return err | |
| 385 | + | } | |
| 386 | + | m.host.Debug(m.host.Matchers().add(cfg)) | |
| 387 | + | } | |
| 388 | + | return nil | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | // removeMatcher is RemoveMatcherCommandExtension (ActionCommandManager.cs:538-607). | |
| 392 | + | func (m *commandManager) removeMatcher(c *actionCommand) error { | |
| 393 | + | owner, file := c.prop("owner"), c.Data | |
| 394 | + | if owner != "" && file != "" { | |
| 395 | + | m.warn("Either specify an owner name or a file path in ##[remove-matcher] command. Both values cannot be set.") | |
| 396 | + | return nil | |
| 397 | + | } | |
| 398 | + | if owner == "" && file == "" { | |
| 399 | + | m.warn("Either an owner name or a file path must be specified in ##[remove-matcher] command.") | |
| 400 | + | return nil | |
| 401 | + | } | |
| 402 | + | if owner != "" { | |
| 403 | + | m.host.Debug(m.host.Matchers().remove([]string{owner})) | |
| 404 | + | return nil | |
| 405 | + | } | |
| 406 | + | cfg, err := m.loadMatchers(m.matcherPath(file)) | |
| 407 | + | if err != nil { | |
| 408 | + | return err | |
| 409 | + | } | |
| 410 | + | if cfg != nil && len(cfg.Matchers) > 0 { | |
| 411 | + | var owners []string | |
| 412 | + | for _, mc := range cfg.Matchers { | |
| 413 | + | owners = append(owners, mc.Owner) | |
| 414 | + | } | |
| 415 | + | m.host.Debug(m.host.Matchers().remove(owners)) | |
| 295 | 416 | } | |
| 296 | 417 | return nil | |
| 297 | 418 | } | |
| @@ -3,15 +3,17 @@ | |||||
| 3 | package executor | 3 | package executor | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 8 | + | "io/fs" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 7 | "strings" | 11 | "strings" | ||
| 8 | "unicode" | 12 | "unicode" | ||
| 9 | ) | 13 | ) | ||
| 10 | 14 | ||||
| 11 | // Workflow commands in the output of a step: a port of ActionCommand.cs | 15 | // 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. | 16 | + | // (Runner.Common) and of ActionCommandManager.cs. |
| 13 | - | // Masking (add-mask) and problem matchers are CI-2; they are announced in the | |||
| 14 | - | // log and otherwise ignored. | |||
| 15 | 17 | ||||
| 16 | type escapeMapping struct{ token, replacement string } | 18 | type escapeMapping struct{ token, replacement string } | ||
| 17 | 19 | ||||
| @@ -117,9 +119,7 @@ const ( | |||||
| 117 | ) | 119 | ) | ||
| 118 | 120 | ||||
| 119 | // commandNames are the commands the executor registers, in ActionCommandManager | 121 | // commandNames are the commands the executor registers, in ActionCommandManager | ||
| 120 | - | // (the names of the extensions at ActionCommandManager.cs). add-mask, | 122 | + | // (the names of the extensions at ActionCommandManager.cs). |
| 121 | - | // add-matcher and remove-matcher are recognised and reported as not supported | |||
| 122 | - | // yet. | |||
| 123 | var commandNames = []string{ | 123 | var commandNames = []string{ | ||
| 124 | "set-env", "set-output", "save-state", "add-mask", "add-path", "add-matcher", "remove-matcher", | 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", | 125 | "debug", "warning", "error", "notice", "group", "endgroup", "echo", "stop-commands", | ||
| @@ -139,6 +139,17 @@ type commandHost interface { | |||||
| 139 | CommandFailed() | 139 | CommandFailed() | ||
| 140 | // EnvFlag reads a variable of the env context, then of the process. | 140 | // EnvFlag reads a variable of the env context, then of the process. | ||
| 141 | EnvFlag(name string) bool | 141 | EnvFlag(name string) bool | ||
| 142 | + | // AddIssue is ExecutionContext.AddIssue: it logs the issue and keeps it for | |||
| 143 | + | // the annotations of the step. | |||
| 144 | + | AddIssue(issue) | |||
| 145 | + | // Workspace is github.workspace. | |||
| 146 | + | Workspace() string | |||
| 147 | + | // AddMask is SecretMasker.AddValue. | |||
| 148 | + | AddMask(value string) | |||
| 149 | + | // Matchers is the root of the problem matchers of the job. | |||
| 150 | + | Matchers() *matcherRoot | |||
| 151 | + | // ReadMatcherFile reads a problem matcher file (IOUtil.LoadObject). | |||
| 152 | + | ReadMatcherFile(path string) ([]byte, error) | |||
| 142 | } | 153 | } | ||
| 143 | 154 | ||||
| 144 | // commandManager is ActionCommandManager: one per step. | 155 | // commandManager is ActionCommandManager: one per step. | ||
| @@ -192,6 +203,9 @@ func (m *commandManager) TryProcess(input string) bool { | |||||
| 192 | } | 203 | } | ||
| 193 | m.stopToken, m.stopped = cmd.Data, true | 204 | m.stopToken, m.stopped = cmd.Data, true | ||
| 194 | m.registered[strings.ToLower(cmd.Data)] = true | 205 | m.registered[strings.ToLower(cmd.Data)] = true | ||
| 206 | + | if len(m.stopToken) > 6 { // ActionCommandManager.cs:117 | |||
| 207 | + | m.host.AddMask(m.stopToken) | |||
| 208 | + | } | |||
| 195 | m.host.Out(input) | 209 | m.host.Out(input) | ||
| 196 | m.host.Debug("Paused processing commands until the token you called ::stopCommands:: with is received") | 210 | m.host.Debug("Paused processing commands until the token you called ::stopCommands:: with is received") | ||
| 197 | return true | 211 | return true | ||
| @@ -233,10 +247,12 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||||
| 233 | h.Out("##[endgroup]") | 247 | h.Out("##[endgroup]") | ||
| 234 | case "debug": | 248 | case "debug": | ||
| 235 | h.Debug(c.Data) | 249 | h.Debug(c.Data) | ||
| 236 | - | case "warning", "error", "notice": | 250 | + | case "warning": |
| 237 | - | // IssueCommandExtension: the annotation is logged; ermite does not store | 251 | + | m.commandIssue(LevelWarning, name, c) |
| 238 | - | // annotations yet (CI-2), so file, line and title are dropped. | 252 | + | case "error": |
| 239 | - | h.Tagged("##["+name+"]", c.Data) | 253 | + | m.commandIssue(LevelFailure, name, c) |
| 254 | + | case "notice": | |||
| 255 | + | m.commandIssue(LevelNotice, name, c) | |||
| 240 | case "echo": | 256 | case "echo": | ||
| 241 | if c.Data == "" { | 257 | if c.Data == "" { | ||
| 242 | return ghErrorf("Value cannot be null or empty. (Parameter 'value')") | 258 | return ghErrorf("Value cannot be null or empty. (Parameter 'value')") | ||
| @@ -289,9 +305,114 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||||
| 289 | return ghErrorf("Value cannot be null or empty. (Parameter 'path')") | 305 | return ghErrorf("Value cannot be null or empty. (Parameter 'path')") | ||
| 290 | } | 306 | } | ||
| 291 | h.AddPath(c.Data) | 307 | h.AddPath(c.Data) | ||
| 292 | - | case "add-mask", "add-matcher", "remove-matcher": | 308 | + | case "add-mask": |
| 293 | - | // Not supported yet (CI-2). The value of add-mask is not echoed. | 309 | + | m.addMask(c) |
| 294 | - | h.Tagged("##[warning]", fmt.Sprintf("The `%s` command is not supported yet by this runner.", name)) | 310 | + | case "add-matcher": |
| 311 | + | return m.addMatcher(c) | |||
| 312 | + | case "remove-matcher": | |||
| 313 | + | return m.removeMatcher(c) | |||
| 314 | + | } | |||
| 315 | + | return nil | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | // warn is IExecutionContext.Warning. | |||
| 319 | + | func (m *commandManager) warn(msg string) { m.host.AddIssue(issue{Level: LevelWarning, Message: msg}) } | |||
| 320 | + | ||||
| 321 | + | // addMask is AddMaskCommandExtension (ActionCommandManager.cs:402-424). | |||
| 322 | + | func (m *commandManager) addMask(c *actionCommand) { | |||
| 323 | + | if strings.TrimFunc(c.Data, unicode.IsSpace) == "" { | |||
| 324 | + | m.warn("Can't add secret mask for empty string in ##[add-mask] command.") | |||
| 325 | + | return | |||
| 326 | + | } | |||
| 327 | + | if m.echo { | |||
| 328 | + | m.host.Out("::add-mask::***") | |||
| 329 | + | } | |||
| 330 | + | m.host.AddMask(c.Data) | |||
| 331 | + | // Also add each individual line. Typically individual lines are processed | |||
| 332 | + | // from STDOUT of child processes. | |||
| 333 | + | for _, item := range strings.FieldsFunc(c.Data, func(r rune) bool { return r == '\r' || r == '\n' }) { | |||
| 334 | + | if item = strings.TrimFunc(item, unicode.IsSpace); item != "" { | |||
| 335 | + | m.host.AddMask(item) | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // matcherPath roots a path of add-matcher and remove-matcher in the workspace. | |||
| 341 | + | func (m *commandManager) matcherPath(file string) string { | |||
| 342 | + | if !filepath.IsAbs(file) { | |||
| 343 | + | return filepath.Join(m.host.Workspace(), file) | |||
| 344 | + | } | |||
| 345 | + | return file | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | // loadMatchers is IOUtil.LoadObject<IssueMatchersConfig>. | |||
| 349 | + | func (m *commandManager) loadMatchers(file string) (*matchersConfig, error) { | |||
| 350 | + | b, err := m.host.ReadMatcherFile(file) | |||
| 351 | + | if err != nil { | |||
| 352 | + | return nil, dotnetFileError(file, err) | |||
| 353 | + | } | |||
| 354 | + | return parseMatchersConfig(b) | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | // dotnetFileError gives the error of a file that cannot be read the text of | |||
| 358 | + | // the .NET exception (FileNotFoundException, DirectoryNotFoundException). | |||
| 359 | + | func dotnetFileError(file string, err error) error { | |||
| 360 | + | switch { | |||
| 361 | + | case errors.Is(err, fs.ErrNotExist): | |||
| 362 | + | if st, e := os.Stat(filepath.Dir(file)); e != nil || !st.IsDir() { | |||
| 363 | + | return ghErrorf("Could not find a part of the path '%s'.", file) | |||
| 364 | + | } | |||
| 365 | + | return ghErrorf("Could not find file '%s'.", file) | |||
| 366 | + | case errors.Is(err, fs.ErrPermission): | |||
| 367 | + | return ghErrorf("Access to the path '%s' is denied.", file) | |||
| 368 | + | } | |||
| 369 | + | return err | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | // addMatcher is AddMatcherCommandExtension (ActionCommandManager.cs:496-536). | |||
| 373 | + | func (m *commandManager) addMatcher(c *actionCommand) error { | |||
| 374 | + | if c.Data == "" { | |||
| 375 | + | m.warn("File path must be specified.") | |||
| 376 | + | return nil | |||
| 377 | + | } | |||
| 378 | + | cfg, err := m.loadMatchers(m.matcherPath(c.Data)) | |||
| 379 | + | if err != nil { | |||
| 380 | + | return err | |||
| 381 | + | } | |||
| 382 | + | if cfg != nil && len(cfg.Matchers) > 0 { | |||
| 383 | + | if err := cfg.validate(); err != nil { | |||
| 384 | + | return err | |||
| 385 | + | } | |||
| 386 | + | m.host.Debug(m.host.Matchers().add(cfg)) | |||
| 387 | + | } | |||
| 388 | + | return nil | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | // removeMatcher is RemoveMatcherCommandExtension (ActionCommandManager.cs:538-607). | |||
| 392 | + | func (m *commandManager) removeMatcher(c *actionCommand) error { | |||
| 393 | + | owner, file := c.prop("owner"), c.Data | |||
| 394 | + | if owner != "" && file != "" { | |||
| 395 | + | m.warn("Either specify an owner name or a file path in ##[remove-matcher] command. Both values cannot be set.") | |||
| 396 | + | return nil | |||
| 397 | + | } | |||
| 398 | + | if owner == "" && file == "" { | |||
| 399 | + | m.warn("Either an owner name or a file path must be specified in ##[remove-matcher] command.") | |||
| 400 | + | return nil | |||
| 401 | + | } | |||
| 402 | + | if owner != "" { | |||
| 403 | + | m.host.Debug(m.host.Matchers().remove([]string{owner})) | |||
| 404 | + | return nil | |||
| 405 | + | } | |||
| 406 | + | cfg, err := m.loadMatchers(m.matcherPath(file)) | |||
| 407 | + | if err != nil { | |||
| 408 | + | return err | |||
| 409 | + | } | |||
| 410 | + | if cfg != nil && len(cfg.Matchers) > 0 { | |||
| 411 | + | var owners []string | |||
| 412 | + | for _, mc := range cfg.Matchers { | |||
| 413 | + | owners = append(owners, mc.Owner) | |||
| 414 | + | } | |||
| 415 | + | m.host.Debug(m.host.Matchers().remove(owners)) | |||
| 295 | } | 416 | } | ||
| 296 | return nil | 417 | return nil | ||
| 297 | } | 418 | } | ||
M internal/executor/exec_step.go +4 -10
| @@ -192,17 +192,11 @@ func (j *job) processEnv(stepEnv *ordered) []string { | |||
| 192 | 192 | // step. | |
| 193 | 193 | func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult { | |
| 194 | 194 | num := s.num | |
| 195 | + | om := newOutputManager(s) | |
| 196 | + | defer om.dispose() | |
| 195 | 197 | res, err := runProcess(procSpec{ | |
| 196 | 198 | Argv: argv, Dir: dir, Env: j.processEnv(s.env), Credential: j.cfg.Credential, | |
| 197 | - | OnLine: func(line string) { | |
| 198 | - | if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") { | |
| 199 | - | line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`) | |
| 200 | - | } | |
| 201 | - | if (strings.Contains(line, "##[") || strings.Contains(line, "::")) && s.cmds.TryProcess(line) { | |
| 202 | - | return | |
| 203 | - | } | |
| 204 | - | j.write(num, line) | |
| 205 | - | }, | |
| 199 | + | OnLine: om.onData, | |
| 206 | 200 | Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, | |
| 207 | 201 | WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | |
| 208 | 202 | }) | |
| @@ -214,7 +208,7 @@ func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string | |||
| 214 | 208 | // The process was stopped on cancellation: ExecuteAsync throws | |
| 215 | 209 | // (ProcessInvoker.cs:369) and StepsRunner logs the exception | |
| 216 | 210 | // (StepsRunner.cs:333-337). | |
| 217 | - | j.errorf(num, "The operation was canceled.") | |
| 211 | + | s.logCanceled() | |
| 218 | 212 | return resCanceled | |
| 219 | 213 | } | |
| 220 | 214 | if res.ExitCode != 0 { | |
| @@ -192,17 +192,11 @@ func (j *job) processEnv(stepEnv *ordered) []string { | |||||
| 192 | // step. | 192 | // step. | ||
| 193 | func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult { | 193 | func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult { | ||
| 194 | num := s.num | 194 | num := s.num | ||
| 195 | + | om := newOutputManager(s) | |||
| 196 | + | defer om.dispose() | |||
| 195 | res, err := runProcess(procSpec{ | 197 | res, err := runProcess(procSpec{ | ||
| 196 | Argv: argv, Dir: dir, Env: j.processEnv(s.env), Credential: j.cfg.Credential, | 198 | Argv: argv, Dir: dir, Env: j.processEnv(s.env), Credential: j.cfg.Credential, | ||
| 197 | - | OnLine: func(line string) { | 199 | + | OnLine: om.onData, |
| 198 | - | if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") { | |||
| 199 | - | line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`) | |||
| 200 | - | } | |||
| 201 | - | if (strings.Contains(line, "##[") || strings.Contains(line, "::")) && s.cmds.TryProcess(line) { | |||
| 202 | - | return | |||
| 203 | - | } | |||
| 204 | - | j.write(num, line) | |||
| 205 | - | }, | |||
| 206 | Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, | 200 | Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, | ||
| 207 | WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | 201 | WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | ||
| 208 | }) | 202 | }) | ||
| @@ -214,7 +208,7 @@ func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string | |||||
| 214 | // The process was stopped on cancellation: ExecuteAsync throws | 208 | // The process was stopped on cancellation: ExecuteAsync throws | ||
| 215 | // (ProcessInvoker.cs:369) and StepsRunner logs the exception | 209 | // (ProcessInvoker.cs:369) and StepsRunner logs the exception | ||
| 216 | // (StepsRunner.cs:333-337). | 210 | // (StepsRunner.cs:333-337). | ||
| 217 | - | j.errorf(num, "The operation was canceled.") | 211 | + | s.logCanceled() |
| 218 | return resCanceled | 212 | return resCanceled | ||
| 219 | } | 213 | } | ||
| 220 | if res.ExitCode != 0 { | 214 | if res.ExitCode != 0 { | ||
M internal/executor/filecmd.go +30 -4
| @@ -354,9 +354,10 @@ func readEnvFile(sink Sink, root *os.Root, name string) ([]Pair, error) { | |||
| 354 | 354 | return ParseEnvFile(text) | |
| 355 | 355 | } | |
| 356 | 356 | ||
| 357 | - | // processStepSummary is CreateStepSummaryCommand.ProcessCommand (:230) for | |
| 358 | - | // CI-1: the file is only checked (an empty file is ignored, a file above 1 MiB | |
| 359 | - | // is an error); ermite does not store summaries yet. | |
| 357 | + | // processStepSummary is CreateStepSummaryCommand.ProcessCommand | |
| 358 | + | // (FileCommandManager.cs:230-275): a missing or empty file is ignored, a file | |
| 359 | + | // above 1 MiB is an error; otherwise the lines are masked one by one and the | |
| 360 | + | // result goes to the sink when it keeps summaries. mask may be nil. | |
| 360 | 361 | func processStepSummary(sink Sink, root *os.Root, name string) error { | |
| 361 | 362 | fh, err := openRegular(root, name) | |
| 362 | 363 | if errors.Is(err, os.ErrNotExist) { | |
| @@ -370,9 +371,34 @@ func processStepSummary(sink Sink, root *os.Root, name string) error { | |||
| 370 | 371 | if err != nil || st.Size() == 0 { | |
| 371 | 372 | return nil | |
| 372 | 373 | } | |
| 374 | + | tooLarge := func(size int64) { | |
| 375 | + | sink.Error(fmt.Sprintf("$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of %dk, got %dk. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary", stepSummaryLimit/1024, size/1024)) | |
| 376 | + | } | |
| 373 | 377 | if st.Size() > stepSummaryLimit { | |
| 374 | - | sink.Error(fmt.Sprintf("$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of %dk, got %dk. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary", stepSummaryLimit/1024, st.Size()/1024)) | |
| 378 | + | tooLarge(st.Size()) | |
| 379 | + | return nil | |
| 380 | + | } | |
| 381 | + | ss, ok := sink.(summarySink) | |
| 382 | + | if !ok { | |
| 383 | + | return nil | |
| 384 | + | } | |
| 385 | + | text, ok, err := readText(root, name) | |
| 386 | + | if err != nil || !ok { | |
| 387 | + | return err | |
| 388 | + | } | |
| 389 | + | var mask func(string) string | |
| 390 | + | if m, has := sink.(interface{ MaskText(string) string }); has { | |
| 391 | + | mask = m.MaskText | |
| 392 | + | } else { | |
| 393 | + | mask = func(s string) string { return s } | |
| 394 | + | } | |
| 395 | + | content := summaryContent(text, mask) | |
| 396 | + | if len(content) > stepSummaryLimit { | |
| 397 | + | // Masking made it longer than the limit of the contract. | |
| 398 | + | tooLarge(int64(len(content))) | |
| 399 | + | return nil | |
| 375 | 400 | } | |
| 401 | + | ss.Summary(content) | |
| 376 | 402 | return nil | |
| 377 | 403 | } | |
| 378 | 404 | ||
| @@ -354,9 +354,10 @@ func readEnvFile(sink Sink, root *os.Root, name string) ([]Pair, error) { | |||||
| 354 | return ParseEnvFile(text) | 354 | return ParseEnvFile(text) | ||
| 355 | } | 355 | } | ||
| 356 | 356 | ||||
| 357 | - | // processStepSummary is CreateStepSummaryCommand.ProcessCommand (:230) for | 357 | + | // processStepSummary is CreateStepSummaryCommand.ProcessCommand |
| 358 | - | // CI-1: the file is only checked (an empty file is ignored, a file above 1 MiB | 358 | + | // (FileCommandManager.cs:230-275): a missing or empty file is ignored, a file |
| 359 | - | // is an error); ermite does not store summaries yet. | 359 | + | // above 1 MiB is an error; otherwise the lines are masked one by one and the |
| 360 | + | // result goes to the sink when it keeps summaries. mask may be nil. | |||
| 360 | func processStepSummary(sink Sink, root *os.Root, name string) error { | 361 | func processStepSummary(sink Sink, root *os.Root, name string) error { | ||
| 361 | fh, err := openRegular(root, name) | 362 | fh, err := openRegular(root, name) | ||
| 362 | if errors.Is(err, os.ErrNotExist) { | 363 | if errors.Is(err, os.ErrNotExist) { | ||
| @@ -370,9 +371,34 @@ func processStepSummary(sink Sink, root *os.Root, name string) error { | |||||
| 370 | if err != nil || st.Size() == 0 { | 371 | if err != nil || st.Size() == 0 { | ||
| 371 | return nil | 372 | return nil | ||
| 372 | } | 373 | } | ||
| 374 | + | tooLarge := func(size int64) { | |||
| 375 | + | sink.Error(fmt.Sprintf("$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of %dk, got %dk. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary", stepSummaryLimit/1024, size/1024)) | |||
| 376 | + | } | |||
| 373 | if st.Size() > stepSummaryLimit { | 377 | if st.Size() > stepSummaryLimit { | ||
| 374 | - | sink.Error(fmt.Sprintf("$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of %dk, got %dk. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary", stepSummaryLimit/1024, st.Size()/1024)) | 378 | + | tooLarge(st.Size()) |
| 379 | + | return nil | |||
| 380 | + | } | |||
| 381 | + | ss, ok := sink.(summarySink) | |||
| 382 | + | if !ok { | |||
| 383 | + | return nil | |||
| 384 | + | } | |||
| 385 | + | text, ok, err := readText(root, name) | |||
| 386 | + | if err != nil || !ok { | |||
| 387 | + | return err | |||
| 388 | + | } | |||
| 389 | + | var mask func(string) string | |||
| 390 | + | if m, has := sink.(interface{ MaskText(string) string }); has { | |||
| 391 | + | mask = m.MaskText | |||
| 392 | + | } else { | |||
| 393 | + | mask = func(s string) string { return s } | |||
| 394 | + | } | |||
| 395 | + | content := summaryContent(text, mask) | |||
| 396 | + | if len(content) > stepSummaryLimit { | |||
| 397 | + | // Masking made it longer than the limit of the contract. | |||
| 398 | + | tooLarge(int64(len(content))) | |||
| 399 | + | return nil | |||
| 375 | } | 400 | } | ||
| 401 | + | ss.Summary(content) | |||
| 376 | return nil | 402 | return nil | ||
| 377 | } | 403 | } | ||
| 378 | 404 | ||||
A internal/executor/issues.go +298 -0
| @@ -0,0 +1,298 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "unicode/utf8" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Annotations: a port of the issue handling of the official runner, | |
| 14 | + | // ExecutionContext.AddIssue (ExecutionContext.cs:796) and | |
| 15 | + | // IssueCommandExtension (ActionCommandManager.cs:660-770). | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | // maxCountPerIssueType is ExecutionContext._maxCountPerIssueType. | |
| 19 | + | maxCountPerIssueType = 10 | |
| 20 | + | // Limits of the contract (runner.proto, Annotation): the server refuses a | |
| 21 | + | // whole call that exceeds them, so the executor keeps within them. | |
| 22 | + | maxAnnotationTitle = 255 | |
| 23 | + | maxAnnotationFile = 1024 | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // issue is what AddIssue receives (DTWebApi.Issue and the log options). | |
| 27 | + | type issue struct { | |
| 28 | + | Level AnnotationLevel | |
| 29 | + | Message string | |
| 30 | + | // Props are the Data of the issue: title, file, line, endline, col, | |
| 31 | + | // endcolumn (keys in lower case), as the commands and matchers set them. | |
| 32 | + | Props map[string]string | |
| 33 | + | // LogText replaces the message in the log (ExecutionContextLogOptions | |
| 34 | + | // .LogMessageOverride, set by problem matchers). | |
| 35 | + | LogText string | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func tagOf(l AnnotationLevel) string { | |
| 39 | + | switch l { | |
| 40 | + | case LevelFailure: | |
| 41 | + | return "##[error]" | |
| 42 | + | case LevelWarning: | |
| 43 | + | return "##[warning]" | |
| 44 | + | } | |
| 45 | + | return "##[notice]" | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func levelOfTag(tag string) (AnnotationLevel, bool) { | |
| 49 | + | switch tag { | |
| 50 | + | case "##[error]": | |
| 51 | + | return LevelFailure, true | |
| 52 | + | case "##[warning]": | |
| 53 | + | return LevelWarning, true | |
| 54 | + | case "##[notice]": | |
| 55 | + | return LevelNotice, true | |
| 56 | + | } | |
| 57 | + | return "", false | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // issueCollector keeps the annotations of one step of the workflow, which is | |
| 61 | + | // what the server stores them under. The steps inside a composite action | |
| 62 | + | // (embedded contexts) add to the collector of the step that uses the action: | |
| 63 | + | // like the runner, their issues are held back and only counted against the | |
| 64 | + | // limit of ten per level when the outer step completes | |
| 65 | + | // (ExecutionContext.Complete, :528-540). | |
| 66 | + | type issueCollector struct { | |
| 67 | + | mu sync.Mutex | |
| 68 | + | own []Annotation | |
| 69 | + | counts map[AnnotationLevel]int | |
| 70 | + | embedded []Annotation | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func newIssueCollector() *issueCollector { | |
| 74 | + | return &issueCollector{counts: map[AnnotationLevel]int{}} | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (c *issueCollector) add(a Annotation, embedded bool) { | |
| 78 | + | c.mu.Lock() | |
| 79 | + | defer c.mu.Unlock() | |
| 80 | + | if embedded { | |
| 81 | + | c.embedded = append(c.embedded, a) | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | c.addLocked(a) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (c *issueCollector) addLocked(a Annotation) { | |
| 88 | + | if c.counts[a.Level] < maxCountPerIssueType { | |
| 89 | + | c.counts[a.Level]++ | |
| 90 | + | c.own = append(c.own, a) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // result returns the annotations of the step: its own, then those of the | |
| 95 | + | // embedded steps within what is left of the limits. | |
| 96 | + | func (c *issueCollector) result() []Annotation { | |
| 97 | + | c.mu.Lock() | |
| 98 | + | defer c.mu.Unlock() | |
| 99 | + | for _, a := range c.embedded { | |
| 100 | + | c.addLocked(a) | |
| 101 | + | } | |
| 102 | + | c.embedded = nil | |
| 103 | + | return append([]Annotation(nil), c.own...) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // collector returns the collector of a step of the workflow. | |
| 107 | + | func (j *job) collector(num int) *issueCollector { | |
| 108 | + | j.issuesMu.Lock() | |
| 109 | + | defer j.issuesMu.Unlock() | |
| 110 | + | if j.collectors == nil { | |
| 111 | + | j.collectors = map[int]*issueCollector{} | |
| 112 | + | } | |
| 113 | + | c := j.collectors[num] | |
| 114 | + | if c == nil { | |
| 115 | + | c = newIssueCollector() | |
| 116 | + | j.collectors[num] = c | |
| 117 | + | } | |
| 118 | + | return c | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // truncateUTF16 cuts s to n UTF-16 code units, the unit of .NET's | |
| 122 | + | // Substring(0, n), without splitting a character. | |
| 123 | + | func truncateUTF16(s string, n int) string { | |
| 124 | + | units := 0 | |
| 125 | + | for i, r := range s { | |
| 126 | + | w := 1 | |
| 127 | + | if r >= 0x10000 { | |
| 128 | + | w = 2 | |
| 129 | + | } | |
| 130 | + | if units+w > n { | |
| 131 | + | return s[:i] | |
| 132 | + | } | |
| 133 | + | units += w | |
| 134 | + | } | |
| 135 | + | return s | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func truncateRunes(s string, n int) string { | |
| 139 | + | if utf8.RuneCountInString(s) <= n { | |
| 140 | + | return s | |
| 141 | + | } | |
| 142 | + | i := 0 | |
| 143 | + | for k := range s { | |
| 144 | + | if i == n { | |
| 145 | + | return s[:k] | |
| 146 | + | } | |
| 147 | + | i++ | |
| 148 | + | } | |
| 149 | + | return s | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // addIssue is ExecutionContext.AddIssue: the message is masked and cut to | |
| 153 | + | // 4096 characters, the issue is kept for the annotations of the step (at most | |
| 154 | + | // ten per level) and written to the log. | |
| 155 | + | func (j *job) addIssue(num int, c *issueCollector, embedded bool, is issue) { | |
| 156 | + | msg := j.mask(is.Message) | |
| 157 | + | msg = truncateUTF16(msg, maxIssueMessageLength) | |
| 158 | + | if c == nil { | |
| 159 | + | c = j.collector(num) | |
| 160 | + | } | |
| 161 | + | if msg != "" { | |
| 162 | + | c.add(j.annotationOf(is, msg), embedded) | |
| 163 | + | } | |
| 164 | + | logMsg := msg | |
| 165 | + | if is.LogText != "" { | |
| 166 | + | logMsg = j.mask(is.LogText) | |
| 167 | + | } | |
| 168 | + | if logMsg != "" { // an empty message is not logged (:855) | |
| 169 | + | j.logTagged(num, tagOf(is.Level), logMsg) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // logTagged writes tag+message; only the first line carries the tag. | |
| 174 | + | func (j *job) logTagged(num int, tag, msg string) { | |
| 175 | + | lines := strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") | |
| 176 | + | j.write(num, tag+lines[0]) | |
| 177 | + | for _, l := range lines[1:] { | |
| 178 | + | j.write(num, l) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // parseInt is int.TryParse(string, out int): white space around, an optional | |
| 183 | + | // sign, digits, within 32 bits. | |
| 184 | + | func parseInt(s string) (int, bool) { | |
| 185 | + | n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 32) | |
| 186 | + | return int(n), err == nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // annotationOf converts an issue to the Annotation of the contract. The | |
| 190 | + | // runner puts the properties in Issue.Data and lets the service build the | |
| 191 | + | // check annotation; negative or non numeric numbers cannot be sent, and are | |
| 192 | + | // left out. The title and the file are masked too (the runner masks only the | |
| 193 | + | // message). | |
| 194 | + | func (j *job) annotationOf(is issue, msg string) Annotation { | |
| 195 | + | a := Annotation{Level: is.Level, Message: msg} | |
| 196 | + | p := is.Props | |
| 197 | + | a.Title = truncateRunes(j.mask(p["title"]), maxAnnotationTitle) | |
| 198 | + | if f := j.mask(p["file"]); utf8.RuneCountInString(f) <= maxAnnotationFile { | |
| 199 | + | a.File = f | |
| 200 | + | } | |
| 201 | + | num := func(k string) int32 { | |
| 202 | + | if n, ok := parseInt(p[k]); ok && n > 0 { | |
| 203 | + | return int32(n) | |
| 204 | + | } | |
| 205 | + | return 0 | |
| 206 | + | } | |
| 207 | + | a.StartLine, a.EndLine = num("line"), num("endline") | |
| 208 | + | a.StartColumn, a.EndColumn = num("col"), num("endcolumn") | |
| 209 | + | if a.EndLine != 0 && a.EndLine < a.StartLine { | |
| 210 | + | a.EndLine = 0 | |
| 211 | + | } | |
| 212 | + | return a | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // validateLinesAndColumns is IssueCommandExtension.ValidateLinesAndColumns | |
| 216 | + | // (ActionCommandManager.cs:731-785). props has lower-case keys; command is the | |
| 217 | + | // name in the debug messages. | |
| 218 | + | func validateLinesAndColumns(props map[string]string, command string, debug func(string)) { | |
| 219 | + | line, endLine, column, endColumn := props["line"], props["endline"], props["col"], props["endcolumn"] | |
| 220 | + | lineNumber, hasStartLine := parseInt(line) | |
| 221 | + | endLineNumber, hasEndLine := parseInt(endLine) | |
| 222 | + | columnNumber, hasStartColumn := parseInt(column) | |
| 223 | + | endColumnNumber, hasEndColumn := parseInt(endColumn) | |
| 224 | + | hasColumn := hasStartColumn || hasEndColumn | |
| 225 | + | msg := func(f string, a ...any) { | |
| 226 | + | debug(fmt.Sprintf("Invalid %s command value. ", command) + fmt.Sprintf(f, a...)) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | if hasEndLine && !hasStartLine { | |
| 230 | + | msg("'endLine' can only be set if 'line' is provided") | |
| 231 | + | props["line"] = endLine | |
| 232 | + | hasStartLine = true | |
| 233 | + | line = endLine | |
| 234 | + | lineNumber = endLineNumber | |
| 235 | + | } | |
| 236 | + | if hasEndColumn && !hasStartColumn { | |
| 237 | + | msg("'endColumn' can only be set if 'col' is provided") | |
| 238 | + | props["col"] = endColumn | |
| 239 | + | hasStartColumn = true | |
| 240 | + | column = endColumn | |
| 241 | + | columnNumber = endColumnNumber | |
| 242 | + | } | |
| 243 | + | if !hasStartLine && hasColumn { | |
| 244 | + | msg("'col' and 'endColumn' can only be set if 'line' value is provided.") | |
| 245 | + | delete(props, "col") | |
| 246 | + | delete(props, "endcolumn") | |
| 247 | + | } | |
| 248 | + | if hasEndLine && line != endLine && hasColumn { | |
| 249 | + | msg("'col' and 'endColumn' cannot be set if 'line' and 'endLine' are different values.") | |
| 250 | + | delete(props, "col") | |
| 251 | + | delete(props, "endcolumn") | |
| 252 | + | } | |
| 253 | + | if hasStartLine && hasEndLine && endLineNumber < lineNumber { | |
| 254 | + | msg("'endLine' cannot be less than 'line'.") | |
| 255 | + | delete(props, "line") | |
| 256 | + | delete(props, "endline") | |
| 257 | + | } | |
| 258 | + | if hasStartColumn && hasEndColumn && endColumnNumber < columnNumber { | |
| 259 | + | msg("'endColumn' cannot be less than 'col'.") | |
| 260 | + | delete(props, "col") | |
| 261 | + | delete(props, "endcolumn") | |
| 262 | + | } | |
| 263 | + | _ = column | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // makeRelative is IOUtil.MakeRelative on Linux: path relative to folder when | |
| 267 | + | // folder is a prefix of it at a directory boundary, else path unchanged. | |
| 268 | + | func makeRelative(path, folder string) string { | |
| 269 | + | if !strings.HasPrefix(path, folder) { | |
| 270 | + | return path | |
| 271 | + | } | |
| 272 | + | if len(path) == len(folder) { | |
| 273 | + | return "" | |
| 274 | + | } | |
| 275 | + | if folder != "" && folder[len(folder)-1] == '/' { | |
| 276 | + | return path[len(folder):] | |
| 277 | + | } | |
| 278 | + | if path[len(folder)] == '/' { | |
| 279 | + | return path[len(folder)+1:] | |
| 280 | + | } | |
| 281 | + | return path | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // commandIssue is IssueCommandExtension.ProcessCommand. | |
| 285 | + | func (m *commandManager) commandIssue(level AnnotationLevel, name string, c *actionCommand) { | |
| 286 | + | h := m.host | |
| 287 | + | props := map[string]string{} | |
| 288 | + | for k, v := range c.Props { | |
| 289 | + | props[k] = v | |
| 290 | + | } | |
| 291 | + | validateLinesAndColumns(props, name, h.Debug) | |
| 292 | + | if file := props["file"]; file != "" { | |
| 293 | + | if rel := makeRelative(file, h.Workspace()); rel != file && rel != "" { | |
| 294 | + | props["file"] = rel | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | h.AddIssue(issue{Level: level, Message: c.Data, Props: props}) | |
| 298 | + | } | |
| @@ -0,0 +1,298 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync" | |||
| 10 | + | "unicode/utf8" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Annotations: a port of the issue handling of the official runner, | |||
| 14 | + | // ExecutionContext.AddIssue (ExecutionContext.cs:796) and | |||
| 15 | + | // IssueCommandExtension (ActionCommandManager.cs:660-770). | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | // maxCountPerIssueType is ExecutionContext._maxCountPerIssueType. | |||
| 19 | + | maxCountPerIssueType = 10 | |||
| 20 | + | // Limits of the contract (runner.proto, Annotation): the server refuses a | |||
| 21 | + | // whole call that exceeds them, so the executor keeps within them. | |||
| 22 | + | maxAnnotationTitle = 255 | |||
| 23 | + | maxAnnotationFile = 1024 | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // issue is what AddIssue receives (DTWebApi.Issue and the log options). | |||
| 27 | + | type issue struct { | |||
| 28 | + | Level AnnotationLevel | |||
| 29 | + | Message string | |||
| 30 | + | // Props are the Data of the issue: title, file, line, endline, col, | |||
| 31 | + | // endcolumn (keys in lower case), as the commands and matchers set them. | |||
| 32 | + | Props map[string]string | |||
| 33 | + | // LogText replaces the message in the log (ExecutionContextLogOptions | |||
| 34 | + | // .LogMessageOverride, set by problem matchers). | |||
| 35 | + | LogText string | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func tagOf(l AnnotationLevel) string { | |||
| 39 | + | switch l { | |||
| 40 | + | case LevelFailure: | |||
| 41 | + | return "##[error]" | |||
| 42 | + | case LevelWarning: | |||
| 43 | + | return "##[warning]" | |||
| 44 | + | } | |||
| 45 | + | return "##[notice]" | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func levelOfTag(tag string) (AnnotationLevel, bool) { | |||
| 49 | + | switch tag { | |||
| 50 | + | case "##[error]": | |||
| 51 | + | return LevelFailure, true | |||
| 52 | + | case "##[warning]": | |||
| 53 | + | return LevelWarning, true | |||
| 54 | + | case "##[notice]": | |||
| 55 | + | return LevelNotice, true | |||
| 56 | + | } | |||
| 57 | + | return "", false | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // issueCollector keeps the annotations of one step of the workflow, which is | |||
| 61 | + | // what the server stores them under. The steps inside a composite action | |||
| 62 | + | // (embedded contexts) add to the collector of the step that uses the action: | |||
| 63 | + | // like the runner, their issues are held back and only counted against the | |||
| 64 | + | // limit of ten per level when the outer step completes | |||
| 65 | + | // (ExecutionContext.Complete, :528-540). | |||
| 66 | + | type issueCollector struct { | |||
| 67 | + | mu sync.Mutex | |||
| 68 | + | own []Annotation | |||
| 69 | + | counts map[AnnotationLevel]int | |||
| 70 | + | embedded []Annotation | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func newIssueCollector() *issueCollector { | |||
| 74 | + | return &issueCollector{counts: map[AnnotationLevel]int{}} | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (c *issueCollector) add(a Annotation, embedded bool) { | |||
| 78 | + | c.mu.Lock() | |||
| 79 | + | defer c.mu.Unlock() | |||
| 80 | + | if embedded { | |||
| 81 | + | c.embedded = append(c.embedded, a) | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | c.addLocked(a) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (c *issueCollector) addLocked(a Annotation) { | |||
| 88 | + | if c.counts[a.Level] < maxCountPerIssueType { | |||
| 89 | + | c.counts[a.Level]++ | |||
| 90 | + | c.own = append(c.own, a) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // result returns the annotations of the step: its own, then those of the | |||
| 95 | + | // embedded steps within what is left of the limits. | |||
| 96 | + | func (c *issueCollector) result() []Annotation { | |||
| 97 | + | c.mu.Lock() | |||
| 98 | + | defer c.mu.Unlock() | |||
| 99 | + | for _, a := range c.embedded { | |||
| 100 | + | c.addLocked(a) | |||
| 101 | + | } | |||
| 102 | + | c.embedded = nil | |||
| 103 | + | return append([]Annotation(nil), c.own...) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // collector returns the collector of a step of the workflow. | |||
| 107 | + | func (j *job) collector(num int) *issueCollector { | |||
| 108 | + | j.issuesMu.Lock() | |||
| 109 | + | defer j.issuesMu.Unlock() | |||
| 110 | + | if j.collectors == nil { | |||
| 111 | + | j.collectors = map[int]*issueCollector{} | |||
| 112 | + | } | |||
| 113 | + | c := j.collectors[num] | |||
| 114 | + | if c == nil { | |||
| 115 | + | c = newIssueCollector() | |||
| 116 | + | j.collectors[num] = c | |||
| 117 | + | } | |||
| 118 | + | return c | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // truncateUTF16 cuts s to n UTF-16 code units, the unit of .NET's | |||
| 122 | + | // Substring(0, n), without splitting a character. | |||
| 123 | + | func truncateUTF16(s string, n int) string { | |||
| 124 | + | units := 0 | |||
| 125 | + | for i, r := range s { | |||
| 126 | + | w := 1 | |||
| 127 | + | if r >= 0x10000 { | |||
| 128 | + | w = 2 | |||
| 129 | + | } | |||
| 130 | + | if units+w > n { | |||
| 131 | + | return s[:i] | |||
| 132 | + | } | |||
| 133 | + | units += w | |||
| 134 | + | } | |||
| 135 | + | return s | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func truncateRunes(s string, n int) string { | |||
| 139 | + | if utf8.RuneCountInString(s) <= n { | |||
| 140 | + | return s | |||
| 141 | + | } | |||
| 142 | + | i := 0 | |||
| 143 | + | for k := range s { | |||
| 144 | + | if i == n { | |||
| 145 | + | return s[:k] | |||
| 146 | + | } | |||
| 147 | + | i++ | |||
| 148 | + | } | |||
| 149 | + | return s | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // addIssue is ExecutionContext.AddIssue: the message is masked and cut to | |||
| 153 | + | // 4096 characters, the issue is kept for the annotations of the step (at most | |||
| 154 | + | // ten per level) and written to the log. | |||
| 155 | + | func (j *job) addIssue(num int, c *issueCollector, embedded bool, is issue) { | |||
| 156 | + | msg := j.mask(is.Message) | |||
| 157 | + | msg = truncateUTF16(msg, maxIssueMessageLength) | |||
| 158 | + | if c == nil { | |||
| 159 | + | c = j.collector(num) | |||
| 160 | + | } | |||
| 161 | + | if msg != "" { | |||
| 162 | + | c.add(j.annotationOf(is, msg), embedded) | |||
| 163 | + | } | |||
| 164 | + | logMsg := msg | |||
| 165 | + | if is.LogText != "" { | |||
| 166 | + | logMsg = j.mask(is.LogText) | |||
| 167 | + | } | |||
| 168 | + | if logMsg != "" { // an empty message is not logged (:855) | |||
| 169 | + | j.logTagged(num, tagOf(is.Level), logMsg) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // logTagged writes tag+message; only the first line carries the tag. | |||
| 174 | + | func (j *job) logTagged(num int, tag, msg string) { | |||
| 175 | + | lines := strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") | |||
| 176 | + | j.write(num, tag+lines[0]) | |||
| 177 | + | for _, l := range lines[1:] { | |||
| 178 | + | j.write(num, l) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // parseInt is int.TryParse(string, out int): white space around, an optional | |||
| 183 | + | // sign, digits, within 32 bits. | |||
| 184 | + | func parseInt(s string) (int, bool) { | |||
| 185 | + | n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 32) | |||
| 186 | + | return int(n), err == nil | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // annotationOf converts an issue to the Annotation of the contract. The | |||
| 190 | + | // runner puts the properties in Issue.Data and lets the service build the | |||
| 191 | + | // check annotation; negative or non numeric numbers cannot be sent, and are | |||
| 192 | + | // left out. The title and the file are masked too (the runner masks only the | |||
| 193 | + | // message). | |||
| 194 | + | func (j *job) annotationOf(is issue, msg string) Annotation { | |||
| 195 | + | a := Annotation{Level: is.Level, Message: msg} | |||
| 196 | + | p := is.Props | |||
| 197 | + | a.Title = truncateRunes(j.mask(p["title"]), maxAnnotationTitle) | |||
| 198 | + | if f := j.mask(p["file"]); utf8.RuneCountInString(f) <= maxAnnotationFile { | |||
| 199 | + | a.File = f | |||
| 200 | + | } | |||
| 201 | + | num := func(k string) int32 { | |||
| 202 | + | if n, ok := parseInt(p[k]); ok && n > 0 { | |||
| 203 | + | return int32(n) | |||
| 204 | + | } | |||
| 205 | + | return 0 | |||
| 206 | + | } | |||
| 207 | + | a.StartLine, a.EndLine = num("line"), num("endline") | |||
| 208 | + | a.StartColumn, a.EndColumn = num("col"), num("endcolumn") | |||
| 209 | + | if a.EndLine != 0 && a.EndLine < a.StartLine { | |||
| 210 | + | a.EndLine = 0 | |||
| 211 | + | } | |||
| 212 | + | return a | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // validateLinesAndColumns is IssueCommandExtension.ValidateLinesAndColumns | |||
| 216 | + | // (ActionCommandManager.cs:731-785). props has lower-case keys; command is the | |||
| 217 | + | // name in the debug messages. | |||
| 218 | + | func validateLinesAndColumns(props map[string]string, command string, debug func(string)) { | |||
| 219 | + | line, endLine, column, endColumn := props["line"], props["endline"], props["col"], props["endcolumn"] | |||
| 220 | + | lineNumber, hasStartLine := parseInt(line) | |||
| 221 | + | endLineNumber, hasEndLine := parseInt(endLine) | |||
| 222 | + | columnNumber, hasStartColumn := parseInt(column) | |||
| 223 | + | endColumnNumber, hasEndColumn := parseInt(endColumn) | |||
| 224 | + | hasColumn := hasStartColumn || hasEndColumn | |||
| 225 | + | msg := func(f string, a ...any) { | |||
| 226 | + | debug(fmt.Sprintf("Invalid %s command value. ", command) + fmt.Sprintf(f, a...)) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | if hasEndLine && !hasStartLine { | |||
| 230 | + | msg("'endLine' can only be set if 'line' is provided") | |||
| 231 | + | props["line"] = endLine | |||
| 232 | + | hasStartLine = true | |||
| 233 | + | line = endLine | |||
| 234 | + | lineNumber = endLineNumber | |||
| 235 | + | } | |||
| 236 | + | if hasEndColumn && !hasStartColumn { | |||
| 237 | + | msg("'endColumn' can only be set if 'col' is provided") | |||
| 238 | + | props["col"] = endColumn | |||
| 239 | + | hasStartColumn = true | |||
| 240 | + | column = endColumn | |||
| 241 | + | columnNumber = endColumnNumber | |||
| 242 | + | } | |||
| 243 | + | if !hasStartLine && hasColumn { | |||
| 244 | + | msg("'col' and 'endColumn' can only be set if 'line' value is provided.") | |||
| 245 | + | delete(props, "col") | |||
| 246 | + | delete(props, "endcolumn") | |||
| 247 | + | } | |||
| 248 | + | if hasEndLine && line != endLine && hasColumn { | |||
| 249 | + | msg("'col' and 'endColumn' cannot be set if 'line' and 'endLine' are different values.") | |||
| 250 | + | delete(props, "col") | |||
| 251 | + | delete(props, "endcolumn") | |||
| 252 | + | } | |||
| 253 | + | if hasStartLine && hasEndLine && endLineNumber < lineNumber { | |||
| 254 | + | msg("'endLine' cannot be less than 'line'.") | |||
| 255 | + | delete(props, "line") | |||
| 256 | + | delete(props, "endline") | |||
| 257 | + | } | |||
| 258 | + | if hasStartColumn && hasEndColumn && endColumnNumber < columnNumber { | |||
| 259 | + | msg("'endColumn' cannot be less than 'col'.") | |||
| 260 | + | delete(props, "col") | |||
| 261 | + | delete(props, "endcolumn") | |||
| 262 | + | } | |||
| 263 | + | _ = column | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // makeRelative is IOUtil.MakeRelative on Linux: path relative to folder when | |||
| 267 | + | // folder is a prefix of it at a directory boundary, else path unchanged. | |||
| 268 | + | func makeRelative(path, folder string) string { | |||
| 269 | + | if !strings.HasPrefix(path, folder) { | |||
| 270 | + | return path | |||
| 271 | + | } | |||
| 272 | + | if len(path) == len(folder) { | |||
| 273 | + | return "" | |||
| 274 | + | } | |||
| 275 | + | if folder != "" && folder[len(folder)-1] == '/' { | |||
| 276 | + | return path[len(folder):] | |||
| 277 | + | } | |||
| 278 | + | if path[len(folder)] == '/' { | |||
| 279 | + | return path[len(folder)+1:] | |||
| 280 | + | } | |||
| 281 | + | return path | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // commandIssue is IssueCommandExtension.ProcessCommand. | |||
| 285 | + | func (m *commandManager) commandIssue(level AnnotationLevel, name string, c *actionCommand) { | |||
| 286 | + | h := m.host | |||
| 287 | + | props := map[string]string{} | |||
| 288 | + | for k, v := range c.Props { | |||
| 289 | + | props[k] = v | |||
| 290 | + | } | |||
| 291 | + | validateLinesAndColumns(props, name, h.Debug) | |||
| 292 | + | if file := props["file"]; file != "" { | |||
| 293 | + | if rel := makeRelative(file, h.Workspace()); rel != file && rel != "" { | |||
| 294 | + | props["file"] = rel | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | h.AddIssue(issue{Level: level, Message: c.Data, Props: props}) | |||
| 298 | + | } | |||
A internal/executor/issues_test.go +238 -0
| @@ -0,0 +1,238 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // resultRecorder is a recorder that also keeps StepResults. | |
| 15 | + | type resultRecorder struct { | |
| 16 | + | *recorder | |
| 17 | + | rmu sync.Mutex | |
| 18 | + | results map[int]StepResults | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func newResultRecorder() *resultRecorder { | |
| 22 | + | return &resultRecorder{recorder: newRecorder(), results: map[int]StepResults{}} | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (r *resultRecorder) StepResults(n int, res StepResults) { | |
| 26 | + | r.rmu.Lock() | |
| 27 | + | defer r.rmu.Unlock() | |
| 28 | + | r.results[n] = res | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func runResults(t *testing.T, yaml string, tweak func(*Config)) (*resultRecorder, Result) { | |
| 32 | + | t.Helper() | |
| 33 | + | rr := newResultRecorder() | |
| 34 | + | // The recorder is wrapped: Reporter methods come from recorder, StepResults | |
| 35 | + | // from resultRecorder. | |
| 36 | + | _, res := runLocal(t, yaml, "j", func(c *Config) { | |
| 37 | + | c.Reporter = rr | |
| 38 | + | c.SendAnnotations, c.SendSummaries = true, true | |
| 39 | + | if tweak != nil { | |
| 40 | + | tweak(c) | |
| 41 | + | } | |
| 42 | + | }) | |
| 43 | + | return rr, res | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Cases of ActionCommandManagerL0.IssueCommandInvalidColumns (actions/runner, | |
| 47 | + | // MIT), as a table: the properties after ValidateLinesAndColumns. | |
| 48 | + | func TestValidateLinesAndColumns(t *testing.T) { | |
| 49 | + | reg := func(string) bool { return true } | |
| 50 | + | for _, tc := range []struct { | |
| 51 | + | name, cmd string | |
| 52 | + | want map[string]string | |
| 53 | + | }{ | |
| 54 | + | {"columns when lines differ", "::warning line=1,endLine=2,col=1,endColumn=2::m", map[string]string{"line": "1", "endline": "2"}}, | |
| 55 | + | {"no lines with columns", "::warning col=1,endColumn=2::m", map[string]string{}}, | |
| 56 | + | {"no line with endLine", "::warning endLine=1::m", map[string]string{"line": "1", "endline": "1"}}, | |
| 57 | + | {"no column with endColumn", "::warning line=1,endColumn=2::m", map[string]string{"line": "1", "col": "2", "endcolumn": "2"}}, | |
| 58 | + | {"empty strings", "::warning line=,endLine=3::m", map[string]string{"line": "3", "endline": "3"}}, | |
| 59 | + | {"end line before line", "::warning line=4,endLine=3::m", map[string]string{}}, | |
| 60 | + | {"end column before column", "::warning line=1,endLine=1,col=3,endColumn=2::m", map[string]string{"line": "1", "endline": "1"}}, | |
| 61 | + | {"valid", "::warning line=1,endLine=1,col=1,endColumn=2::m", map[string]string{"line": "1", "endline": "1", "col": "1", "endcolumn": "2"}}, | |
| 62 | + | {"backwards compatibility", "::warning line=1,col=1,file=test.txt::m", map[string]string{"line": "1", "col": "1", "file": "test.txt"}}, | |
| 63 | + | } { | |
| 64 | + | c, ok := parseCommandV2(tc.cmd, reg) | |
| 65 | + | if !ok { | |
| 66 | + | t.Fatalf("%s: not parsed", tc.name) | |
| 67 | + | } | |
| 68 | + | props := map[string]string{} | |
| 69 | + | for k, v := range c.Props { | |
| 70 | + | props[k] = v | |
| 71 | + | } | |
| 72 | + | validateLinesAndColumns(props, "warning", func(string) {}) | |
| 73 | + | if fmt.Sprint(props) != fmt.Sprint(tc.want) { | |
| 74 | + | t.Errorf("%s: %v, want %v", tc.name, props, tc.want) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestAnnotationsFromCommands(t *testing.T) { | |
| 80 | + | needTools(t, "bash") | |
| 81 | + | rr, res := runResults(t, wfHead+` steps: | |
| 82 | + | - run: | | |
| 83 | + | echo "::warning title=T,file=src/a.go,line=3,endLine=5,col=1,endColumn=2::careful" | |
| 84 | + | echo "::error::broken%0Aline two" | |
| 85 | + | echo "::notice::fyi" | |
| 86 | + | `, nil) | |
| 87 | + | if res.Conclusion != ConclusionSuccess { | |
| 88 | + | t.Fatalf("%+v", res) | |
| 89 | + | } | |
| 90 | + | got := rr.results[2].Annotations | |
| 91 | + | want := []Annotation{ | |
| 92 | + | {Level: LevelWarning, Title: "T", Message: "careful", File: "src/a.go", StartLine: 3, EndLine: 5}, | |
| 93 | + | {Level: LevelFailure, Message: "broken\nline two"}, | |
| 94 | + | {Level: LevelNotice, Message: "fyi"}, | |
| 95 | + | } | |
| 96 | + | // col and endColumn are dropped because the lines differ. | |
| 97 | + | if fmt.Sprint(got) != fmt.Sprint(want) { | |
| 98 | + | t.Errorf("annotations\n got %+v\nwant %+v", got, want) | |
| 99 | + | } | |
| 100 | + | log := logOf(rr.step(t, "Run echo \"::warning title=T,file=src/a.go,line=3,endLine=5,col=1,endColumn=2::careful\"")) | |
| 101 | + | for _, w := range []string{"##[warning]careful", "##[error]broken\nline two", "##[notice]fyi"} { | |
| 102 | + | if !strings.Contains(log, w) { | |
| 103 | + | t.Errorf("log lacks %q:\n%s", w, log) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // ExecutionContext.cs:144-146: ten per level per step, messages of 4096 | |
| 109 | + | // characters at most; the rest only reaches the log. | |
| 110 | + | func TestAnnotationLimits(t *testing.T) { | |
| 111 | + | needTools(t, "bash") | |
| 112 | + | rr, _ := runResults(t, wfHead+` steps: | |
| 113 | + | - name: many | |
| 114 | + | run: | | |
| 115 | + | for i in $(seq 1 12); do | |
| 116 | + | echo "::warning::w$i"; echo "::error::e$i"; echo "::notice::n$i" | |
| 117 | + | done | |
| 118 | + | - name: long | |
| 119 | + | run: | | |
| 120 | + | printf '::warning::%s\n' "$(head -c 5000 /dev/zero | tr '\0' x)" | |
| 121 | + | `, nil) | |
| 122 | + | count := map[AnnotationLevel]int{} | |
| 123 | + | for _, a := range rr.results[2].Annotations { | |
| 124 | + | count[a.Level]++ | |
| 125 | + | } | |
| 126 | + | if count[LevelWarning] != 10 || count[LevelFailure] != 10 || count[LevelNotice] != 10 { | |
| 127 | + | t.Errorf("counts %v", count) | |
| 128 | + | } | |
| 129 | + | if a := rr.results[2].Annotations; a[len(a)-1].Message == "w12" { | |
| 130 | + | t.Error("the eleventh was kept") | |
| 131 | + | } | |
| 132 | + | log := logOf(rr.step(t, "many")) | |
| 133 | + | if !strings.Contains(log, "##[warning]w12") || !strings.Contains(log, "##[error]e12") { | |
| 134 | + | t.Error("annotations over the limit are still logged") | |
| 135 | + | } | |
| 136 | + | long := rr.results[3].Annotations | |
| 137 | + | if len(long) != 1 || len(long[0].Message) != 4096 { | |
| 138 | + | t.Errorf("long message: %d annotations, len %d", len(long), len(long[0].Message)) | |
| 139 | + | } | |
| 140 | + | if l := logOf(rr.step(t, "long")); !strings.Contains(l, "##[warning]"+strings.Repeat("x", 4096)+"\n") && !strings.HasSuffix(l, "##[warning]"+strings.Repeat("x", 4096)) { | |
| 141 | + | t.Error("the log has the message cut to 4096 characters") | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Every ##[error] the runner itself writes is an issue and an annotation. | |
| 146 | + | func TestRunnerErrorsAreAnnotations(t *testing.T) { | |
| 147 | + | needTools(t, "bash") | |
| 148 | + | rr, res := runResults(t, wfHead+` steps: | |
| 149 | + | - run: exit 3 | |
| 150 | + | `, nil) | |
| 151 | + | if res.Conclusion != ConclusionFailure { | |
| 152 | + | t.Fatal(res) | |
| 153 | + | } | |
| 154 | + | a := rr.results[2].Annotations | |
| 155 | + | if len(a) != 1 || a[0].Level != LevelFailure || a[0].Message != "Process completed with exit code 3." { | |
| 156 | + | t.Errorf("%+v", a) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestAnnotationsAreMaskedAndOffWithoutTheFeature(t *testing.T) { | |
| 161 | + | needTools(t, "bash") | |
| 162 | + | rr, _ := runResults(t, wfHead+` steps: | |
| 163 | + | - run: | | |
| 164 | + | echo "::add-mask::hunter2hunter2" | |
| 165 | + | echo "::warning title=hunter2hunter2::pw hunter2hunter2" | |
| 166 | + | `, nil) | |
| 167 | + | a := rr.results[2].Annotations | |
| 168 | + | if len(a) != 1 || a[0].Message != "pw ***" || a[0].Title != "***" { | |
| 169 | + | t.Errorf("%+v", a) | |
| 170 | + | } | |
| 171 | + | rr2, _ := runResults(t, wfHead+" steps:\n - run: echo '::warning::w'\n", func(c *Config) { c.SendAnnotations = false }) | |
| 172 | + | if len(rr2.results[2].Annotations) != 0 { | |
| 173 | + | t.Error("annotations without the feature") | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func TestAddMaskCommand(t *testing.T) { | |
| 178 | + | needTools(t, "bash") | |
| 179 | + | rec, res := runLocal(t, wfHead+` steps: | |
| 180 | + | - name: step one | |
| 181 | + | run: | | |
| 182 | + | echo "::add-mask::abc%0Ddef%0Aghi%0D%0Ajkl" | |
| 183 | + | echo "::add-mask:: %0D %0A %0D%0A %0D" | |
| 184 | + | echo "::add-mask::" | |
| 185 | + | echo "def ghi jkl abc" | |
| 186 | + | echo "::echo::on" | |
| 187 | + | echo "::add-mask::echoed-value" | |
| 188 | + | echo "echoed-value" | |
| 189 | + | - name: step two | |
| 190 | + | run: echo "still masked jkl" | |
| 191 | + | `, "j", nil) | |
| 192 | + | if res.Conclusion != ConclusionSuccess { | |
| 193 | + | t.Fatalf("%+v", res) | |
| 194 | + | } | |
| 195 | + | log := logOf(rec.step(t, "step one")) | |
| 196 | + | _, body, _ := strings.Cut(log, "##[endgroup]\n") | |
| 197 | + | for _, w := range []string{"*** *** *** ***", "##[warning]Can't add secret mask for empty string in ##[add-mask] command.", "::add-mask::***", "\n***\n"} { | |
| 198 | + | if !strings.Contains(body+"\n", w) { | |
| 199 | + | t.Errorf("log lacks %q:\n%s", w, body) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | if strings.Contains(body, "echoed-value") && !strings.Contains(body, "::echo::on") { | |
| 203 | + | t.Errorf("%s", body) | |
| 204 | + | } | |
| 205 | + | if got := logOf(rec.step(t, "step two")); !strings.HasSuffix(got, "still masked ***") { | |
| 206 | + | t.Errorf("%s", got) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func TestSaveStateInRunStep(t *testing.T) { | |
| 211 | + | needTools(t, "bash") | |
| 212 | + | var j *job | |
| 213 | + | rec, res := runLocal(t, wfHead+` steps: | |
| 214 | + | - run: | | |
| 215 | + | echo "::save-state name=Foo::bar" | |
| 216 | + | echo "baz=qux" >> "$GITHUB_STATE" | |
| 217 | + | echo "::save-state::x" | |
| 218 | + | `, "j", func(c *Config) { _ = j }) | |
| 219 | + | if res.Conclusion != ConclusionFailure { | |
| 220 | + | t.Fatalf("a save-state without a name fails the step: %+v", res) | |
| 221 | + | } | |
| 222 | + | log := logOf(rec.steps[1]) | |
| 223 | + | for _, w := range []string{"##[warning]The `save-state` command is deprecated", "##[error]Required field 'name' is missing in ##[save-state] command."} { | |
| 224 | + | if !strings.Contains(log, w) { | |
| 225 | + | t.Errorf("log lacks %q:\n%s", w, log) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func writeFile(t *testing.T, p, s string) { | |
| 231 | + | t.Helper() | |
| 232 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |
| 233 | + | t.Fatal(err) | |
| 234 | + | } | |
| 235 | + | if err := os.WriteFile(p, []byte(s), 0o644); err != nil { | |
| 236 | + | t.Fatal(err) | |
| 237 | + | } | |
| 238 | + | } | |
| @@ -0,0 +1,238 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // resultRecorder is a recorder that also keeps StepResults. | |||
| 15 | + | type resultRecorder struct { | |||
| 16 | + | *recorder | |||
| 17 | + | rmu sync.Mutex | |||
| 18 | + | results map[int]StepResults | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func newResultRecorder() *resultRecorder { | |||
| 22 | + | return &resultRecorder{recorder: newRecorder(), results: map[int]StepResults{}} | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (r *resultRecorder) StepResults(n int, res StepResults) { | |||
| 26 | + | r.rmu.Lock() | |||
| 27 | + | defer r.rmu.Unlock() | |||
| 28 | + | r.results[n] = res | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func runResults(t *testing.T, yaml string, tweak func(*Config)) (*resultRecorder, Result) { | |||
| 32 | + | t.Helper() | |||
| 33 | + | rr := newResultRecorder() | |||
| 34 | + | // The recorder is wrapped: Reporter methods come from recorder, StepResults | |||
| 35 | + | // from resultRecorder. | |||
| 36 | + | _, res := runLocal(t, yaml, "j", func(c *Config) { | |||
| 37 | + | c.Reporter = rr | |||
| 38 | + | c.SendAnnotations, c.SendSummaries = true, true | |||
| 39 | + | if tweak != nil { | |||
| 40 | + | tweak(c) | |||
| 41 | + | } | |||
| 42 | + | }) | |||
| 43 | + | return rr, res | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Cases of ActionCommandManagerL0.IssueCommandInvalidColumns (actions/runner, | |||
| 47 | + | // MIT), as a table: the properties after ValidateLinesAndColumns. | |||
| 48 | + | func TestValidateLinesAndColumns(t *testing.T) { | |||
| 49 | + | reg := func(string) bool { return true } | |||
| 50 | + | for _, tc := range []struct { | |||
| 51 | + | name, cmd string | |||
| 52 | + | want map[string]string | |||
| 53 | + | }{ | |||
| 54 | + | {"columns when lines differ", "::warning line=1,endLine=2,col=1,endColumn=2::m", map[string]string{"line": "1", "endline": "2"}}, | |||
| 55 | + | {"no lines with columns", "::warning col=1,endColumn=2::m", map[string]string{}}, | |||
| 56 | + | {"no line with endLine", "::warning endLine=1::m", map[string]string{"line": "1", "endline": "1"}}, | |||
| 57 | + | {"no column with endColumn", "::warning line=1,endColumn=2::m", map[string]string{"line": "1", "col": "2", "endcolumn": "2"}}, | |||
| 58 | + | {"empty strings", "::warning line=,endLine=3::m", map[string]string{"line": "3", "endline": "3"}}, | |||
| 59 | + | {"end line before line", "::warning line=4,endLine=3::m", map[string]string{}}, | |||
| 60 | + | {"end column before column", "::warning line=1,endLine=1,col=3,endColumn=2::m", map[string]string{"line": "1", "endline": "1"}}, | |||
| 61 | + | {"valid", "::warning line=1,endLine=1,col=1,endColumn=2::m", map[string]string{"line": "1", "endline": "1", "col": "1", "endcolumn": "2"}}, | |||
| 62 | + | {"backwards compatibility", "::warning line=1,col=1,file=test.txt::m", map[string]string{"line": "1", "col": "1", "file": "test.txt"}}, | |||
| 63 | + | } { | |||
| 64 | + | c, ok := parseCommandV2(tc.cmd, reg) | |||
| 65 | + | if !ok { | |||
| 66 | + | t.Fatalf("%s: not parsed", tc.name) | |||
| 67 | + | } | |||
| 68 | + | props := map[string]string{} | |||
| 69 | + | for k, v := range c.Props { | |||
| 70 | + | props[k] = v | |||
| 71 | + | } | |||
| 72 | + | validateLinesAndColumns(props, "warning", func(string) {}) | |||
| 73 | + | if fmt.Sprint(props) != fmt.Sprint(tc.want) { | |||
| 74 | + | t.Errorf("%s: %v, want %v", tc.name, props, tc.want) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestAnnotationsFromCommands(t *testing.T) { | |||
| 80 | + | needTools(t, "bash") | |||
| 81 | + | rr, res := runResults(t, wfHead+` steps: | |||
| 82 | + | - run: | | |||
| 83 | + | echo "::warning title=T,file=src/a.go,line=3,endLine=5,col=1,endColumn=2::careful" | |||
| 84 | + | echo "::error::broken%0Aline two" | |||
| 85 | + | echo "::notice::fyi" | |||
| 86 | + | `, nil) | |||
| 87 | + | if res.Conclusion != ConclusionSuccess { | |||
| 88 | + | t.Fatalf("%+v", res) | |||
| 89 | + | } | |||
| 90 | + | got := rr.results[2].Annotations | |||
| 91 | + | want := []Annotation{ | |||
| 92 | + | {Level: LevelWarning, Title: "T", Message: "careful", File: "src/a.go", StartLine: 3, EndLine: 5}, | |||
| 93 | + | {Level: LevelFailure, Message: "broken\nline two"}, | |||
| 94 | + | {Level: LevelNotice, Message: "fyi"}, | |||
| 95 | + | } | |||
| 96 | + | // col and endColumn are dropped because the lines differ. | |||
| 97 | + | if fmt.Sprint(got) != fmt.Sprint(want) { | |||
| 98 | + | t.Errorf("annotations\n got %+v\nwant %+v", got, want) | |||
| 99 | + | } | |||
| 100 | + | log := logOf(rr.step(t, "Run echo \"::warning title=T,file=src/a.go,line=3,endLine=5,col=1,endColumn=2::careful\"")) | |||
| 101 | + | for _, w := range []string{"##[warning]careful", "##[error]broken\nline two", "##[notice]fyi"} { | |||
| 102 | + | if !strings.Contains(log, w) { | |||
| 103 | + | t.Errorf("log lacks %q:\n%s", w, log) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // ExecutionContext.cs:144-146: ten per level per step, messages of 4096 | |||
| 109 | + | // characters at most; the rest only reaches the log. | |||
| 110 | + | func TestAnnotationLimits(t *testing.T) { | |||
| 111 | + | needTools(t, "bash") | |||
| 112 | + | rr, _ := runResults(t, wfHead+` steps: | |||
| 113 | + | - name: many | |||
| 114 | + | run: | | |||
| 115 | + | for i in $(seq 1 12); do | |||
| 116 | + | echo "::warning::w$i"; echo "::error::e$i"; echo "::notice::n$i" | |||
| 117 | + | done | |||
| 118 | + | - name: long | |||
| 119 | + | run: | | |||
| 120 | + | printf '::warning::%s\n' "$(head -c 5000 /dev/zero | tr '\0' x)" | |||
| 121 | + | `, nil) | |||
| 122 | + | count := map[AnnotationLevel]int{} | |||
| 123 | + | for _, a := range rr.results[2].Annotations { | |||
| 124 | + | count[a.Level]++ | |||
| 125 | + | } | |||
| 126 | + | if count[LevelWarning] != 10 || count[LevelFailure] != 10 || count[LevelNotice] != 10 { | |||
| 127 | + | t.Errorf("counts %v", count) | |||
| 128 | + | } | |||
| 129 | + | if a := rr.results[2].Annotations; a[len(a)-1].Message == "w12" { | |||
| 130 | + | t.Error("the eleventh was kept") | |||
| 131 | + | } | |||
| 132 | + | log := logOf(rr.step(t, "many")) | |||
| 133 | + | if !strings.Contains(log, "##[warning]w12") || !strings.Contains(log, "##[error]e12") { | |||
| 134 | + | t.Error("annotations over the limit are still logged") | |||
| 135 | + | } | |||
| 136 | + | long := rr.results[3].Annotations | |||
| 137 | + | if len(long) != 1 || len(long[0].Message) != 4096 { | |||
| 138 | + | t.Errorf("long message: %d annotations, len %d", len(long), len(long[0].Message)) | |||
| 139 | + | } | |||
| 140 | + | if l := logOf(rr.step(t, "long")); !strings.Contains(l, "##[warning]"+strings.Repeat("x", 4096)+"\n") && !strings.HasSuffix(l, "##[warning]"+strings.Repeat("x", 4096)) { | |||
| 141 | + | t.Error("the log has the message cut to 4096 characters") | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // Every ##[error] the runner itself writes is an issue and an annotation. | |||
| 146 | + | func TestRunnerErrorsAreAnnotations(t *testing.T) { | |||
| 147 | + | needTools(t, "bash") | |||
| 148 | + | rr, res := runResults(t, wfHead+` steps: | |||
| 149 | + | - run: exit 3 | |||
| 150 | + | `, nil) | |||
| 151 | + | if res.Conclusion != ConclusionFailure { | |||
| 152 | + | t.Fatal(res) | |||
| 153 | + | } | |||
| 154 | + | a := rr.results[2].Annotations | |||
| 155 | + | if len(a) != 1 || a[0].Level != LevelFailure || a[0].Message != "Process completed with exit code 3." { | |||
| 156 | + | t.Errorf("%+v", a) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func TestAnnotationsAreMaskedAndOffWithoutTheFeature(t *testing.T) { | |||
| 161 | + | needTools(t, "bash") | |||
| 162 | + | rr, _ := runResults(t, wfHead+` steps: | |||
| 163 | + | - run: | | |||
| 164 | + | echo "::add-mask::hunter2hunter2" | |||
| 165 | + | echo "::warning title=hunter2hunter2::pw hunter2hunter2" | |||
| 166 | + | `, nil) | |||
| 167 | + | a := rr.results[2].Annotations | |||
| 168 | + | if len(a) != 1 || a[0].Message != "pw ***" || a[0].Title != "***" { | |||
| 169 | + | t.Errorf("%+v", a) | |||
| 170 | + | } | |||
| 171 | + | rr2, _ := runResults(t, wfHead+" steps:\n - run: echo '::warning::w'\n", func(c *Config) { c.SendAnnotations = false }) | |||
| 172 | + | if len(rr2.results[2].Annotations) != 0 { | |||
| 173 | + | t.Error("annotations without the feature") | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func TestAddMaskCommand(t *testing.T) { | |||
| 178 | + | needTools(t, "bash") | |||
| 179 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 180 | + | - name: step one | |||
| 181 | + | run: | | |||
| 182 | + | echo "::add-mask::abc%0Ddef%0Aghi%0D%0Ajkl" | |||
| 183 | + | echo "::add-mask:: %0D %0A %0D%0A %0D" | |||
| 184 | + | echo "::add-mask::" | |||
| 185 | + | echo "def ghi jkl abc" | |||
| 186 | + | echo "::echo::on" | |||
| 187 | + | echo "::add-mask::echoed-value" | |||
| 188 | + | echo "echoed-value" | |||
| 189 | + | - name: step two | |||
| 190 | + | run: echo "still masked jkl" | |||
| 191 | + | `, "j", nil) | |||
| 192 | + | if res.Conclusion != ConclusionSuccess { | |||
| 193 | + | t.Fatalf("%+v", res) | |||
| 194 | + | } | |||
| 195 | + | log := logOf(rec.step(t, "step one")) | |||
| 196 | + | _, body, _ := strings.Cut(log, "##[endgroup]\n") | |||
| 197 | + | for _, w := range []string{"*** *** *** ***", "##[warning]Can't add secret mask for empty string in ##[add-mask] command.", "::add-mask::***", "\n***\n"} { | |||
| 198 | + | if !strings.Contains(body+"\n", w) { | |||
| 199 | + | t.Errorf("log lacks %q:\n%s", w, body) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | if strings.Contains(body, "echoed-value") && !strings.Contains(body, "::echo::on") { | |||
| 203 | + | t.Errorf("%s", body) | |||
| 204 | + | } | |||
| 205 | + | if got := logOf(rec.step(t, "step two")); !strings.HasSuffix(got, "still masked ***") { | |||
| 206 | + | t.Errorf("%s", got) | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func TestSaveStateInRunStep(t *testing.T) { | |||
| 211 | + | needTools(t, "bash") | |||
| 212 | + | var j *job | |||
| 213 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 214 | + | - run: | | |||
| 215 | + | echo "::save-state name=Foo::bar" | |||
| 216 | + | echo "baz=qux" >> "$GITHUB_STATE" | |||
| 217 | + | echo "::save-state::x" | |||
| 218 | + | `, "j", func(c *Config) { _ = j }) | |||
| 219 | + | if res.Conclusion != ConclusionFailure { | |||
| 220 | + | t.Fatalf("a save-state without a name fails the step: %+v", res) | |||
| 221 | + | } | |||
| 222 | + | log := logOf(rec.steps[1]) | |||
| 223 | + | for _, w := range []string{"##[warning]The `save-state` command is deprecated", "##[error]Required field 'name' is missing in ##[save-state] command."} { | |||
| 224 | + | if !strings.Contains(log, w) { | |||
| 225 | + | t.Errorf("log lacks %q:\n%s", w, log) | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func writeFile(t *testing.T, p, s string) { | |||
| 231 | + | t.Helper() | |||
| 232 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |||
| 233 | + | t.Fatal(err) | |||
| 234 | + | } | |||
| 235 | + | if err := os.WriteFile(p, []byte(s), 0o644); err != nil { | |||
| 236 | + | t.Fatal(err) | |||
| 237 | + | } | |||
| 238 | + | } | |||
M internal/executor/job.go +27 -10
| @@ -75,6 +75,16 @@ type Config struct { | |||
| 75 | 75 | BaseEnv []string | |
| 76 | 76 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | |
| 77 | 77 | Debug bool | |
| 78 | + | // SendAnnotations and SendSummaries say that the server keeps annotations | |
| 79 | + | // and step summaries (features ci.annotations and ci.summaries): the | |
| 80 | + | // Reporter then gets them with StepResults. | |
| 81 | + | SendAnnotations, SendSummaries bool | |
| 82 | + | // Getenv reads the environment of the executor (the matcher timeout); nil | |
| 83 | + | // means os.Getenv. | |
| 84 | + | Getenv func(string) string | |
| 85 | + | // StepTimeoutSkew is added to every step timeout (tests use a negative | |
| 86 | + | // value to run a timeout of one minute in milliseconds). | |
| 87 | + | StepTimeoutSkew time.Duration | |
| 78 | 88 | ||
| 79 | 89 | Reporter Reporter | |
| 80 | 90 | // Log receives the runner's own diagnostics (not the job log); the zero value | |
| @@ -148,6 +158,14 @@ type job struct { | |||
| 148 | 158 | // masker hides the secrets in everything reported; it is set once the | |
| 149 | 159 | // contexts are built, before any step runs. | |
| 150 | 160 | masker atomic.Pointer[masker] | |
| 161 | + | ||
| 162 | + | // Results of the commands (see issues.go, matchers.go, results.go). | |
| 163 | + | matchers *matcherRoot | |
| 164 | + | issuesMu sync.Mutex | |
| 165 | + | collectors map[int]*issueCollector // by step number | |
| 166 | + | outcomes map[int]Conclusion | |
| 167 | + | summaries map[int]string | |
| 168 | + | states map[string]map[string]string // intra-action state, see saveState | |
| 151 | 169 | } | |
| 152 | 170 | ||
| 153 | 171 | func (j *job) mask(s string) string { return j.masker.Load().mask(s) } | |
| @@ -165,7 +183,7 @@ func Run(cancel *Canceller, cfg Config) Result { | |||
| 165 | 183 | if cancel == nil { | |
| 166 | 184 | cancel = NewCanceller() | |
| 167 | 185 | } | |
| 168 | - | j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()} | |
| 186 | + | j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString(), matchers: &matcherRoot{}} | |
| 169 | 187 | j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter} | |
| 170 | 188 | defer func() { | |
| 171 | 189 | if j.fc != nil { | |
| @@ -259,17 +277,16 @@ func (j *job) out(num int, text string) { | |||
| 259 | 277 | } | |
| 260 | 278 | } | |
| 261 | 279 | ||
| 262 | - | // tagged writes tag+message; only the first line carries the tag. | |
| 280 | + | // tagged writes tag+message; only the first line carries the tag. The tags of | |
| 281 | + | // issues (##[error], ##[warning], ##[notice]) go through AddIssue: they are | |
| 282 | + | // logged and become annotations, like the runner's IExecutionContext.Error | |
| 283 | + | // and Warning. | |
| 263 | 284 | func (j *job) tagged(num int, tag, msg string) { | |
| 264 | - | msg = j.mask(msg) // before the cut: a secret split by it would leak its start | |
| 265 | - | if len(msg) > maxIssueMessageLength && tag != "" { | |
| 266 | - | msg = msg[:maxIssueMessageLength] | |
| 267 | - | } | |
| 268 | - | lines := strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") | |
| 269 | - | j.write(num, tag+lines[0]) | |
| 270 | - | for _, l := range lines[1:] { | |
| 271 | - | j.write(num, l) | |
| 285 | + | if level, ok := levelOfTag(tag); ok { | |
| 286 | + | j.addIssue(num, nil, false, issue{Level: level, Message: msg}) | |
| 287 | + | return | |
| 272 | 288 | } | |
| 289 | + | j.logTagged(num, tag, j.mask(msg)) | |
| 273 | 290 | } | |
| 274 | 291 | ||
| 275 | 292 | func (j *job) errorf(num int, format string, a ...any) { | |
| @@ -75,6 +75,16 @@ type Config struct { | |||||
| 75 | BaseEnv []string | 75 | BaseEnv []string | ||
| 76 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | 76 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | ||
| 77 | Debug bool | 77 | Debug bool | ||
| 78 | + | // SendAnnotations and SendSummaries say that the server keeps annotations | |||
| 79 | + | // and step summaries (features ci.annotations and ci.summaries): the | |||
| 80 | + | // Reporter then gets them with StepResults. | |||
| 81 | + | SendAnnotations, SendSummaries bool | |||
| 82 | + | // Getenv reads the environment of the executor (the matcher timeout); nil | |||
| 83 | + | // means os.Getenv. | |||
| 84 | + | Getenv func(string) string | |||
| 85 | + | // StepTimeoutSkew is added to every step timeout (tests use a negative | |||
| 86 | + | // value to run a timeout of one minute in milliseconds). | |||
| 87 | + | StepTimeoutSkew time.Duration | |||
| 78 | 88 | ||||
| 79 | Reporter Reporter | 89 | Reporter Reporter | ||
| 80 | // Log receives the runner's own diagnostics (not the job log); the zero value | 90 | // Log receives the runner's own diagnostics (not the job log); the zero value | ||
| @@ -148,6 +158,14 @@ type job struct { | |||||
| 148 | // masker hides the secrets in everything reported; it is set once the | 158 | // masker hides the secrets in everything reported; it is set once the | ||
| 149 | // contexts are built, before any step runs. | 159 | // contexts are built, before any step runs. | ||
| 150 | masker atomic.Pointer[masker] | 160 | masker atomic.Pointer[masker] | ||
| 161 | + | ||||
| 162 | + | // Results of the commands (see issues.go, matchers.go, results.go). | |||
| 163 | + | matchers *matcherRoot | |||
| 164 | + | issuesMu sync.Mutex | |||
| 165 | + | collectors map[int]*issueCollector // by step number | |||
| 166 | + | outcomes map[int]Conclusion | |||
| 167 | + | summaries map[int]string | |||
| 168 | + | states map[string]map[string]string // intra-action state, see saveState | |||
| 151 | } | 169 | } | ||
| 152 | 170 | ||||
| 153 | func (j *job) mask(s string) string { return j.masker.Load().mask(s) } | 171 | func (j *job) mask(s string) string { return j.masker.Load().mask(s) } | ||
| @@ -165,7 +183,7 @@ func Run(cancel *Canceller, cfg Config) Result { | |||||
| 165 | if cancel == nil { | 183 | if cancel == nil { | ||
| 166 | cancel = NewCanceller() | 184 | cancel = NewCanceller() | ||
| 167 | } | 185 | } | ||
| 168 | - | j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()} | 186 | + | j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString(), matchers: &matcherRoot{}} |
| 169 | j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter} | 187 | j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter} | ||
| 170 | defer func() { | 188 | defer func() { | ||
| 171 | if j.fc != nil { | 189 | if j.fc != nil { | ||
| @@ -259,17 +277,16 @@ func (j *job) out(num int, text string) { | |||||
| 259 | } | 277 | } | ||
| 260 | } | 278 | } | ||
| 261 | 279 | ||||
| 262 | - | // tagged writes tag+message; only the first line carries the tag. | 280 | + | // tagged writes tag+message; only the first line carries the tag. The tags of |
| 281 | + | // issues (##[error], ##[warning], ##[notice]) go through AddIssue: they are | |||
| 282 | + | // logged and become annotations, like the runner's IExecutionContext.Error | |||
| 283 | + | // and Warning. | |||
| 263 | func (j *job) tagged(num int, tag, msg string) { | 284 | func (j *job) tagged(num int, tag, msg string) { | ||
| 264 | - | msg = j.mask(msg) // before the cut: a secret split by it would leak its start | 285 | + | if level, ok := levelOfTag(tag); ok { |
| 265 | - | if len(msg) > maxIssueMessageLength && tag != "" { | 286 | + | j.addIssue(num, nil, false, issue{Level: level, Message: msg}) |
| 266 | - | msg = msg[:maxIssueMessageLength] | 287 | + | return |
| 267 | - | } | |||
| 268 | - | lines := strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") | |||
| 269 | - | j.write(num, tag+lines[0]) | |||
| 270 | - | for _, l := range lines[1:] { | |||
| 271 | - | j.write(num, l) | |||
| 272 | } | 288 | } | ||
| 289 | + | j.logTagged(num, tag, j.mask(msg)) | |||
| 273 | } | 290 | } | ||
| 274 | 291 | ||||
| 275 | func (j *job) errorf(num int, format string, a ...any) { | 292 | func (j *job) errorf(num int, format string, a ...any) { | ||
M internal/executor/masker.go +2 -0
| @@ -301,6 +301,8 @@ func (r *maskReporter) Log(n int, at time.Time, line string) { | |||
| 301 | 301 | } | |
| 302 | 302 | ||
| 303 | 303 | func (r *maskReporter) StepCompleted(n int, name string, c Conclusion, at time.Time) { | |
| 304 | + | // The results of the step (outcome, annotations, summary) go first. | |
| 305 | + | r.j.flushStepResults(r.next, n, c) | |
| 304 | 306 | r.next.StepCompleted(n, r.j.mask(name), c, at) | |
| 305 | 307 | } | |
| 306 | 308 | ||
| @@ -301,6 +301,8 @@ func (r *maskReporter) Log(n int, at time.Time, line string) { | |||||
| 301 | } | 301 | } | ||
| 302 | 302 | ||||
| 303 | func (r *maskReporter) StepCompleted(n int, name string, c Conclusion, at time.Time) { | 303 | func (r *maskReporter) StepCompleted(n int, name string, c Conclusion, at time.Time) { | ||
| 304 | + | // The results of the step (outcome, annotations, summary) go first. | |||
| 305 | + | r.j.flushStepResults(r.next, n, c) | |||
| 304 | r.next.StepCompleted(n, r.j.mask(name), c, at) | 306 | r.next.StepCompleted(n, r.j.mask(name), c, at) | ||
| 305 | } | 307 | } | ||
| 306 | 308 | ||||
A internal/executor/matchers.go +413 -0
| @@ -0,0 +1,413 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/dlclark/regexp2" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Problem matchers: a port of Runner.Worker/IssueMatcher.cs (actions/runner, | |
| 17 | + | // MIT). The regular expressions are .NET's with RegexOptions.CultureInvariant | | |
| 18 | + | // RegexOptions.ECMAScript (IssueMatcher.cs:450); regexp2 is the .NET engine in | |
| 19 | + | // Go (RE2 has no lookarounds or backreferences, which real matchers use). | |
| 20 | + | ||
| 21 | + | // optInt is an int? of the JSON files: a number, a numeric string or null. | |
| 22 | + | type optInt struct { | |
| 23 | + | set bool | |
| 24 | + | v int | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (o *optInt) UnmarshalJSON(b []byte) error { | |
| 28 | + | s := strings.TrimSpace(string(b)) | |
| 29 | + | if s == "null" { | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | s = strings.Trim(s, `"`) | |
| 33 | + | var n int | |
| 34 | + | if _, err := fmt.Sscanf(s, "%d", &n); err != nil { | |
| 35 | + | return fmt.Errorf("Error converting value %s to type 'System.Nullable`1[System.Int32]'.", string(b)) | |
| 36 | + | } | |
| 37 | + | o.set, o.v = true, n | |
| 38 | + | return nil | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // optBool is a bool of the JSON files: a boolean or the string "true"/"false". | |
| 42 | + | type optBool bool | |
| 43 | + | ||
| 44 | + | func (o *optBool) UnmarshalJSON(b []byte) error { | |
| 45 | + | switch strings.ToLower(strings.Trim(strings.TrimSpace(string(b)), `"`)) { | |
| 46 | + | case "true": | |
| 47 | + | *o = true | |
| 48 | + | case "false", "null": | |
| 49 | + | *o = false | |
| 50 | + | default: | |
| 51 | + | return fmt.Errorf("Error converting value %s to type 'System.Boolean'.", string(b)) | |
| 52 | + | } | |
| 53 | + | return nil | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // patternConfig is IssuePatternConfig. | |
| 57 | + | type patternConfig struct { | |
| 58 | + | File optInt `json:"file"` | |
| 59 | + | Line optInt `json:"line"` | |
| 60 | + | Column optInt `json:"column"` | |
| 61 | + | Severity optInt `json:"severity"` | |
| 62 | + | Code optInt `json:"code"` | |
| 63 | + | Message optInt `json:"message"` | |
| 64 | + | FromPath optInt `json:"fromPath"` | |
| 65 | + | Loop optBool `json:"loop"` | |
| 66 | + | Regexp string `json:"regexp"` | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // matcherConfig is IssueMatcherConfig. | |
| 70 | + | type matcherConfig struct { | |
| 71 | + | Owner string `json:"owner"` | |
| 72 | + | Severity string `json:"severity"` | |
| 73 | + | Patterns []patternConfig `json:"pattern"` | |
| 74 | + | FromPath string `json:"fromPath"` | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // matchersConfig is IssueMatchersConfig. | |
| 78 | + | type matchersConfig struct { | |
| 79 | + | Matchers []*matcherConfig `json:"problemMatcher"` | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // parseMatchersConfig reads the JSON of a matchers file. | |
| 83 | + | func parseMatchersConfig(data []byte) (*matchersConfig, error) { | |
| 84 | + | data = []byte(strings.TrimPrefix(string(data), "\xef\xbb\xbf")) | |
| 85 | + | var c matchersConfig | |
| 86 | + | if len(strings.TrimSpace(string(data))) == 0 { | |
| 87 | + | return nil, nil // StringUtil.ConvertFromJson of an empty text is null | |
| 88 | + | } | |
| 89 | + | if err := json.Unmarshal(data, &c); err != nil { | |
| 90 | + | return nil, err | |
| 91 | + | } | |
| 92 | + | return &c, nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // regexOptions is IssuePatternConfig.RegexOptions. regexp2 has no | |
| 96 | + | // CultureInvariant: its matching is culture independent. | |
| 97 | + | const regexOptions = regexp2.ECMAScript | |
| 98 | + | ||
| 99 | + | func compilePattern(p string) (*regexp2.Regexp, error) { | |
| 100 | + | return regexp2.Compile(p, regexOptions) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // validate is IssueMatchersConfig.Validate (IssueMatcher.cs:262). The error | |
| 104 | + | // texts are the runner's. | |
| 105 | + | func (c *matchersConfig) validate() error { | |
| 106 | + | owners := map[string]bool{} | |
| 107 | + | for _, m := range c.Matchers { | |
| 108 | + | if m == nil { | |
| 109 | + | m = &matcherConfig{} | |
| 110 | + | } | |
| 111 | + | if err := m.validate(); err != nil { | |
| 112 | + | return err | |
| 113 | + | } | |
| 114 | + | lo := strings.ToLower(m.Owner) | |
| 115 | + | if owners[lo] { | |
| 116 | + | return fmt.Errorf("Duplicate owner name '%s'", m.Owner) | |
| 117 | + | } | |
| 118 | + | owners[lo] = true | |
| 119 | + | } | |
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // validate is IssueMatcherConfig.Validate (IssueMatcher.cs:376). | |
| 124 | + | func (m *matcherConfig) validate() error { | |
| 125 | + | if m.Owner == "" { | |
| 126 | + | return errors.New("Owner must not be empty") | |
| 127 | + | } | |
| 128 | + | switch strings.ToUpper(m.Severity) { | |
| 129 | + | case "", "ERROR", "WARNING", "NOTICE": | |
| 130 | + | default: | |
| 131 | + | return fmt.Errorf("Matcher '%s' contains unexpected default severity '%s'", m.Owner, m.Severity) | |
| 132 | + | } | |
| 133 | + | if len(m.Patterns) == 0 { | |
| 134 | + | return fmt.Errorf("Matcher '%s' does not contain any patterns", m.Owner) | |
| 135 | + | } | |
| 136 | + | var file, line, column, severity, code, message, fromPath *int | |
| 137 | + | for i := range m.Patterns { | |
| 138 | + | isFirst, isLast := i == 0, i == len(m.Patterns)-1 | |
| 139 | + | if err := m.Patterns[i].validate(isFirst, isLast, &file, &line, &column, &severity, &code, &message, &fromPath); err != nil { | |
| 140 | + | return err | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | if message == nil { | |
| 144 | + | return errors.New("At least one pattern must set 'message'") | |
| 145 | + | } | |
| 146 | + | return nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // validate is IssuePatternConfig.Validate (IssueMatcher.cs:495). | |
| 150 | + | func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, severity, code, message, fromPath **int) error { | |
| 151 | + | if bool(p.Loop) && (isFirst || !isLast) { | |
| 152 | + | return errors.New("Only the last pattern in a multiline matcher may set 'loop'") | |
| 153 | + | } | |
| 154 | + | if bool(p.Loop) && !p.Message.set { | |
| 155 | + | return errors.New("The loop pattern must set 'message'") | |
| 156 | + | } | |
| 157 | + | re, err := compilePattern(p.Regexp) | |
| 158 | + | if err != nil { | |
| 159 | + | return err | |
| 160 | + | } | |
| 161 | + | groupCount := len(re.GetGroupNumbers()) | |
| 162 | + | for _, f := range []struct { | |
| 163 | + | name string | |
| 164 | + | v optInt | |
| 165 | + | to **int | |
| 166 | + | }{ | |
| 167 | + | {"file", p.File, file}, {"line", p.Line, line}, {"column", p.Column, column}, | |
| 168 | + | {"severity", p.Severity, severity}, {"code", p.Code, code}, {"message", p.Message, message}, | |
| 169 | + | {"fromPath", p.FromPath, fromPath}, | |
| 170 | + | } { | |
| 171 | + | if !f.v.set { | |
| 172 | + | continue | |
| 173 | + | } | |
| 174 | + | if *f.to != nil { | |
| 175 | + | return fmt.Errorf("The property '%s' is set twice", f.name) | |
| 176 | + | } | |
| 177 | + | if f.v.v < 0 || f.v.v >= groupCount { | |
| 178 | + | return fmt.Errorf("The property '%s' is set to %d which is out of range", f.name, f.v.v) | |
| 179 | + | } | |
| 180 | + | n := f.v.v | |
| 181 | + | *f.to = &n | |
| 182 | + | } | |
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // errMatchTimeout is the RegexMatchTimeoutException; its message is .NET's. | |
| 187 | + | var errMatchTimeout = errors.New("The Regex engine has timed out while trying to match a pattern to an input string. This can occur for many reasons, including very large inputs or excessive backtracking caused by nested quantifiers, back-references and other factors.") | |
| 188 | + | ||
| 189 | + | // issuePattern is IssuePattern. | |
| 190 | + | type issuePattern struct { | |
| 191 | + | file, line, column, severity, code, message, fromPath optInt | |
| 192 | + | loop bool | |
| 193 | + | re *regexp2.Regexp | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // nstr is a string that can be null, as the values of IssueMatch are: a group | |
| 197 | + | // that the pattern does not name is null, a group that matched nothing is "". | |
| 198 | + | type nstr struct { | |
| 199 | + | s string | |
| 200 | + | ok bool | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (n nstr) or(o nstr) nstr { | |
| 204 | + | if n.ok { | |
| 205 | + | return n | |
| 206 | + | } | |
| 207 | + | return o | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // issueMatch is IssueMatch. | |
| 211 | + | type issueMatch struct { | |
| 212 | + | file, line, column, severity, code, message, fromPath nstr | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // issueMatcher is IssueMatcher: a matcher config with the state of the | |
| 216 | + | // multi-line patterns. | |
| 217 | + | type issueMatcher struct { | |
| 218 | + | owner, defaultSeverity, defaultFromPath string | |
| 219 | + | patterns []*issuePattern | |
| 220 | + | state []*issueMatch | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func newIssueMatcher(c *matcherConfig, timeout time.Duration) (*issueMatcher, error) { | |
| 224 | + | m := &issueMatcher{owner: c.Owner, defaultSeverity: c.Severity, defaultFromPath: c.FromPath} | |
| 225 | + | for _, pc := range c.Patterns { | |
| 226 | + | re, err := compilePattern(pc.Regexp) | |
| 227 | + | if err != nil { | |
| 228 | + | return nil, err | |
| 229 | + | } | |
| 230 | + | re.MatchTimeout = timeout | |
| 231 | + | m.patterns = append(m.patterns, &issuePattern{ | |
| 232 | + | file: pc.File, line: pc.Line, column: pc.Column, severity: pc.Severity, code: pc.Code, | |
| 233 | + | message: pc.Message, fromPath: pc.FromPath, loop: bool(pc.Loop), re: re, | |
| 234 | + | }) | |
| 235 | + | } | |
| 236 | + | m.reset() | |
| 237 | + | return m, nil | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func (m *issueMatcher) reset() { m.state = make([]*issueMatch, max(len(m.patterns)-1, 0)) } | |
| 241 | + | ||
| 242 | + | // groupValue is IssueMatch.GetValue: the text of a group, null when the | |
| 243 | + | // pattern does not name one or its number is out of range. | |
| 244 | + | func groupValue(match *regexp2.Match, idx optInt) nstr { | |
| 245 | + | if !idx.set || idx.v >= match.GroupCount() { | |
| 246 | + | return nstr{} | |
| 247 | + | } | |
| 248 | + | g := match.GroupByNumber(idx.v) | |
| 249 | + | if g == nil { | |
| 250 | + | return nstr{} | |
| 251 | + | } | |
| 252 | + | return nstr{g.String(), true} | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func newIssueMatch(running *issueMatch, p *issuePattern, g *regexp2.Match, defSeverity, defFromPath string) *issueMatch { | |
| 256 | + | r := running | |
| 257 | + | if r == nil { | |
| 258 | + | r = &issueMatch{} | |
| 259 | + | } | |
| 260 | + | im := &issueMatch{ | |
| 261 | + | file: r.file.or(groupValue(g, p.file)), | |
| 262 | + | line: r.line.or(groupValue(g, p.line)), | |
| 263 | + | column: r.column.or(groupValue(g, p.column)), | |
| 264 | + | severity: r.severity.or(groupValue(g, p.severity)), | |
| 265 | + | code: r.code.or(groupValue(g, p.code)), | |
| 266 | + | message: r.message.or(groupValue(g, p.message)), | |
| 267 | + | fromPath: r.fromPath.or(groupValue(g, p.fromPath)), | |
| 268 | + | } | |
| 269 | + | if im.severity.s == "" && defSeverity != "" { | |
| 270 | + | im.severity = nstr{defSeverity, true} | |
| 271 | + | } | |
| 272 | + | if im.fromPath.s == "" && defFromPath != "" { | |
| 273 | + | im.fromPath = nstr{defFromPath, true} | |
| 274 | + | } | |
| 275 | + | return im | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // match is IssueMatcher.Match. A timeout of the engine is returned as | |
| 279 | + | // errMatchTimeout. | |
| 280 | + | func (m *issueMatcher) match(line string) (*issueMatch, error) { | |
| 281 | + | find := func(p *issuePattern) (*regexp2.Match, error) { | |
| 282 | + | mt, err := p.re.FindStringMatch(line) | |
| 283 | + | if err != nil { | |
| 284 | + | return nil, errMatchTimeout | |
| 285 | + | } | |
| 286 | + | return mt, nil | |
| 287 | + | } | |
| 288 | + | if len(m.patterns) == 1 { | |
| 289 | + | p := m.patterns[0] | |
| 290 | + | mt, err := find(p) | |
| 291 | + | if err != nil || mt == nil { | |
| 292 | + | return nil, err | |
| 293 | + | } | |
| 294 | + | return newIssueMatch(nil, p, mt, m.defaultSeverity, m.defaultFromPath), nil | |
| 295 | + | } | |
| 296 | + | for i := len(m.patterns) - 1; i >= 0; i-- { | |
| 297 | + | var running *issueMatch | |
| 298 | + | if i > 0 { | |
| 299 | + | running = m.state[i-1] | |
| 300 | + | } | |
| 301 | + | if i != 0 && running == nil { | |
| 302 | + | continue | |
| 303 | + | } | |
| 304 | + | p := m.patterns[i] | |
| 305 | + | isLast := i == len(m.patterns)-1 | |
| 306 | + | mt, err := find(p) | |
| 307 | + | if err != nil { | |
| 308 | + | return nil, err | |
| 309 | + | } | |
| 310 | + | if mt != nil { | |
| 311 | + | if isLast { | |
| 312 | + | if p.loop { | |
| 313 | + | m.reset() | |
| 314 | + | m.state[i-1] = running | |
| 315 | + | } else { | |
| 316 | + | m.reset() | |
| 317 | + | } | |
| 318 | + | return newIssueMatch(running, p, mt, m.defaultSeverity, m.defaultFromPath), nil | |
| 319 | + | } | |
| 320 | + | m.state[i] = newIssueMatch(running, p, mt, "", "") | |
| 321 | + | } else if isLast { | |
| 322 | + | m.state[i-1] = nil | |
| 323 | + | } else { | |
| 324 | + | m.state[i] = nil | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | return nil, nil | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // matcherRoot holds the matchers of the job (ExecutionContext._matchers, on the | |
| 331 | + | // root context) and tells the output managers of the steps that run when they | |
| 332 | + | // change (OnMatcherChanged). | |
| 333 | + | type matcherRoot struct { | |
| 334 | + | mu sync.Mutex | |
| 335 | + | configs []*matcherConfig | |
| 336 | + | handlers map[*outputManager]struct{} | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | // snapshot returns the matchers now. | |
| 340 | + | func (r *matcherRoot) snapshot() []*matcherConfig { | |
| 341 | + | r.mu.Lock() | |
| 342 | + | defer r.mu.Unlock() | |
| 343 | + | return append([]*matcherConfig(nil), r.configs...) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func (r *matcherRoot) subscribe(o *outputManager) { | |
| 347 | + | r.mu.Lock() | |
| 348 | + | defer r.mu.Unlock() | |
| 349 | + | if r.handlers == nil { | |
| 350 | + | r.handlers = map[*outputManager]struct{}{} | |
| 351 | + | } | |
| 352 | + | r.handlers[o] = struct{}{} | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func (r *matcherRoot) unsubscribe(o *outputManager) { | |
| 356 | + | r.mu.Lock() | |
| 357 | + | defer r.mu.Unlock() | |
| 358 | + | delete(r.handlers, o) | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // add is ExecutionContext.AddMatchers (:1173): the new matchers go first, those | |
| 362 | + | // of the same owner (ignoring case) are replaced. It returns the debug line. | |
| 363 | + | func (r *matcherRoot) add(c *matchersConfig) string { | |
| 364 | + | r.mu.Lock() | |
| 365 | + | defer r.mu.Unlock() | |
| 366 | + | owners := map[string]bool{} | |
| 367 | + | var next []*matcherConfig | |
| 368 | + | for _, m := range c.Matchers { | |
| 369 | + | owners[strings.ToLower(m.Owner)] = true | |
| 370 | + | next = append(next, m) | |
| 371 | + | } | |
| 372 | + | for _, m := range r.configs { | |
| 373 | + | if !owners[strings.ToLower(m.Owner)] { | |
| 374 | + | next = append(next, m) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | r.configs = next | |
| 378 | + | var names []string | |
| 379 | + | for _, m := range c.Matchers { | |
| 380 | + | names = append(names, "'"+m.Owner+"'") | |
| 381 | + | for o := range r.handlers { | |
| 382 | + | o.matcherChanged(m) | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | return "Added matchers: " + strings.Join(names, ", ") + ". Problem matchers scan action output for known warning or error strings and report these inline." | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // remove is ExecutionContext.RemoveMatchers (:1212). It returns the debug line. | |
| 389 | + | func (r *matcherRoot) remove(owners []string) string { | |
| 390 | + | r.mu.Lock() | |
| 391 | + | defer r.mu.Unlock() | |
| 392 | + | distinct := map[string]bool{} | |
| 393 | + | for _, o := range owners { | |
| 394 | + | distinct[strings.ToLower(o)] = true | |
| 395 | + | } | |
| 396 | + | var kept, removed []*matcherConfig | |
| 397 | + | for _, m := range r.configs { | |
| 398 | + | if distinct[strings.ToLower(m.Owner)] { | |
| 399 | + | removed = append(removed, m) | |
| 400 | + | } else { | |
| 401 | + | kept = append(kept, m) | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | r.configs = kept | |
| 405 | + | var names []string | |
| 406 | + | for _, m := range removed { | |
| 407 | + | names = append(names, "'"+m.Owner+"'") | |
| 408 | + | for o := range r.handlers { | |
| 409 | + | o.matcherChanged(&matcherConfig{Owner: m.Owner}) | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | return "Removed matchers: " + strings.Join(names, ", ") | |
| 413 | + | } | |
| @@ -0,0 +1,413 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/dlclark/regexp2" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Problem matchers: a port of Runner.Worker/IssueMatcher.cs (actions/runner, | |||
| 17 | + | // MIT). The regular expressions are .NET's with RegexOptions.CultureInvariant | | |||
| 18 | + | // RegexOptions.ECMAScript (IssueMatcher.cs:450); regexp2 is the .NET engine in | |||
| 19 | + | // Go (RE2 has no lookarounds or backreferences, which real matchers use). | |||
| 20 | + | ||||
| 21 | + | // optInt is an int? of the JSON files: a number, a numeric string or null. | |||
| 22 | + | type optInt struct { | |||
| 23 | + | set bool | |||
| 24 | + | v int | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (o *optInt) UnmarshalJSON(b []byte) error { | |||
| 28 | + | s := strings.TrimSpace(string(b)) | |||
| 29 | + | if s == "null" { | |||
| 30 | + | return nil | |||
| 31 | + | } | |||
| 32 | + | s = strings.Trim(s, `"`) | |||
| 33 | + | var n int | |||
| 34 | + | if _, err := fmt.Sscanf(s, "%d", &n); err != nil { | |||
| 35 | + | return fmt.Errorf("Error converting value %s to type 'System.Nullable`1[System.Int32]'.", string(b)) | |||
| 36 | + | } | |||
| 37 | + | o.set, o.v = true, n | |||
| 38 | + | return nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // optBool is a bool of the JSON files: a boolean or the string "true"/"false". | |||
| 42 | + | type optBool bool | |||
| 43 | + | ||||
| 44 | + | func (o *optBool) UnmarshalJSON(b []byte) error { | |||
| 45 | + | switch strings.ToLower(strings.Trim(strings.TrimSpace(string(b)), `"`)) { | |||
| 46 | + | case "true": | |||
| 47 | + | *o = true | |||
| 48 | + | case "false", "null": | |||
| 49 | + | *o = false | |||
| 50 | + | default: | |||
| 51 | + | return fmt.Errorf("Error converting value %s to type 'System.Boolean'.", string(b)) | |||
| 52 | + | } | |||
| 53 | + | return nil | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // patternConfig is IssuePatternConfig. | |||
| 57 | + | type patternConfig struct { | |||
| 58 | + | File optInt `json:"file"` | |||
| 59 | + | Line optInt `json:"line"` | |||
| 60 | + | Column optInt `json:"column"` | |||
| 61 | + | Severity optInt `json:"severity"` | |||
| 62 | + | Code optInt `json:"code"` | |||
| 63 | + | Message optInt `json:"message"` | |||
| 64 | + | FromPath optInt `json:"fromPath"` | |||
| 65 | + | Loop optBool `json:"loop"` | |||
| 66 | + | Regexp string `json:"regexp"` | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // matcherConfig is IssueMatcherConfig. | |||
| 70 | + | type matcherConfig struct { | |||
| 71 | + | Owner string `json:"owner"` | |||
| 72 | + | Severity string `json:"severity"` | |||
| 73 | + | Patterns []patternConfig `json:"pattern"` | |||
| 74 | + | FromPath string `json:"fromPath"` | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // matchersConfig is IssueMatchersConfig. | |||
| 78 | + | type matchersConfig struct { | |||
| 79 | + | Matchers []*matcherConfig `json:"problemMatcher"` | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // parseMatchersConfig reads the JSON of a matchers file. | |||
| 83 | + | func parseMatchersConfig(data []byte) (*matchersConfig, error) { | |||
| 84 | + | data = []byte(strings.TrimPrefix(string(data), "\xef\xbb\xbf")) | |||
| 85 | + | var c matchersConfig | |||
| 86 | + | if len(strings.TrimSpace(string(data))) == 0 { | |||
| 87 | + | return nil, nil // StringUtil.ConvertFromJson of an empty text is null | |||
| 88 | + | } | |||
| 89 | + | if err := json.Unmarshal(data, &c); err != nil { | |||
| 90 | + | return nil, err | |||
| 91 | + | } | |||
| 92 | + | return &c, nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // regexOptions is IssuePatternConfig.RegexOptions. regexp2 has no | |||
| 96 | + | // CultureInvariant: its matching is culture independent. | |||
| 97 | + | const regexOptions = regexp2.ECMAScript | |||
| 98 | + | ||||
| 99 | + | func compilePattern(p string) (*regexp2.Regexp, error) { | |||
| 100 | + | return regexp2.Compile(p, regexOptions) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // validate is IssueMatchersConfig.Validate (IssueMatcher.cs:262). The error | |||
| 104 | + | // texts are the runner's. | |||
| 105 | + | func (c *matchersConfig) validate() error { | |||
| 106 | + | owners := map[string]bool{} | |||
| 107 | + | for _, m := range c.Matchers { | |||
| 108 | + | if m == nil { | |||
| 109 | + | m = &matcherConfig{} | |||
| 110 | + | } | |||
| 111 | + | if err := m.validate(); err != nil { | |||
| 112 | + | return err | |||
| 113 | + | } | |||
| 114 | + | lo := strings.ToLower(m.Owner) | |||
| 115 | + | if owners[lo] { | |||
| 116 | + | return fmt.Errorf("Duplicate owner name '%s'", m.Owner) | |||
| 117 | + | } | |||
| 118 | + | owners[lo] = true | |||
| 119 | + | } | |||
| 120 | + | return nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // validate is IssueMatcherConfig.Validate (IssueMatcher.cs:376). | |||
| 124 | + | func (m *matcherConfig) validate() error { | |||
| 125 | + | if m.Owner == "" { | |||
| 126 | + | return errors.New("Owner must not be empty") | |||
| 127 | + | } | |||
| 128 | + | switch strings.ToUpper(m.Severity) { | |||
| 129 | + | case "", "ERROR", "WARNING", "NOTICE": | |||
| 130 | + | default: | |||
| 131 | + | return fmt.Errorf("Matcher '%s' contains unexpected default severity '%s'", m.Owner, m.Severity) | |||
| 132 | + | } | |||
| 133 | + | if len(m.Patterns) == 0 { | |||
| 134 | + | return fmt.Errorf("Matcher '%s' does not contain any patterns", m.Owner) | |||
| 135 | + | } | |||
| 136 | + | var file, line, column, severity, code, message, fromPath *int | |||
| 137 | + | for i := range m.Patterns { | |||
| 138 | + | isFirst, isLast := i == 0, i == len(m.Patterns)-1 | |||
| 139 | + | if err := m.Patterns[i].validate(isFirst, isLast, &file, &line, &column, &severity, &code, &message, &fromPath); err != nil { | |||
| 140 | + | return err | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | if message == nil { | |||
| 144 | + | return errors.New("At least one pattern must set 'message'") | |||
| 145 | + | } | |||
| 146 | + | return nil | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // validate is IssuePatternConfig.Validate (IssueMatcher.cs:495). | |||
| 150 | + | func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, severity, code, message, fromPath **int) error { | |||
| 151 | + | if bool(p.Loop) && (isFirst || !isLast) { | |||
| 152 | + | return errors.New("Only the last pattern in a multiline matcher may set 'loop'") | |||
| 153 | + | } | |||
| 154 | + | if bool(p.Loop) && !p.Message.set { | |||
| 155 | + | return errors.New("The loop pattern must set 'message'") | |||
| 156 | + | } | |||
| 157 | + | re, err := compilePattern(p.Regexp) | |||
| 158 | + | if err != nil { | |||
| 159 | + | return err | |||
| 160 | + | } | |||
| 161 | + | groupCount := len(re.GetGroupNumbers()) | |||
| 162 | + | for _, f := range []struct { | |||
| 163 | + | name string | |||
| 164 | + | v optInt | |||
| 165 | + | to **int | |||
| 166 | + | }{ | |||
| 167 | + | {"file", p.File, file}, {"line", p.Line, line}, {"column", p.Column, column}, | |||
| 168 | + | {"severity", p.Severity, severity}, {"code", p.Code, code}, {"message", p.Message, message}, | |||
| 169 | + | {"fromPath", p.FromPath, fromPath}, | |||
| 170 | + | } { | |||
| 171 | + | if !f.v.set { | |||
| 172 | + | continue | |||
| 173 | + | } | |||
| 174 | + | if *f.to != nil { | |||
| 175 | + | return fmt.Errorf("The property '%s' is set twice", f.name) | |||
| 176 | + | } | |||
| 177 | + | if f.v.v < 0 || f.v.v >= groupCount { | |||
| 178 | + | return fmt.Errorf("The property '%s' is set to %d which is out of range", f.name, f.v.v) | |||
| 179 | + | } | |||
| 180 | + | n := f.v.v | |||
| 181 | + | *f.to = &n | |||
| 182 | + | } | |||
| 183 | + | return nil | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // errMatchTimeout is the RegexMatchTimeoutException; its message is .NET's. | |||
| 187 | + | var errMatchTimeout = errors.New("The Regex engine has timed out while trying to match a pattern to an input string. This can occur for many reasons, including very large inputs or excessive backtracking caused by nested quantifiers, back-references and other factors.") | |||
| 188 | + | ||||
| 189 | + | // issuePattern is IssuePattern. | |||
| 190 | + | type issuePattern struct { | |||
| 191 | + | file, line, column, severity, code, message, fromPath optInt | |||
| 192 | + | loop bool | |||
| 193 | + | re *regexp2.Regexp | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // nstr is a string that can be null, as the values of IssueMatch are: a group | |||
| 197 | + | // that the pattern does not name is null, a group that matched nothing is "". | |||
| 198 | + | type nstr struct { | |||
| 199 | + | s string | |||
| 200 | + | ok bool | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func (n nstr) or(o nstr) nstr { | |||
| 204 | + | if n.ok { | |||
| 205 | + | return n | |||
| 206 | + | } | |||
| 207 | + | return o | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // issueMatch is IssueMatch. | |||
| 211 | + | type issueMatch struct { | |||
| 212 | + | file, line, column, severity, code, message, fromPath nstr | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // issueMatcher is IssueMatcher: a matcher config with the state of the | |||
| 216 | + | // multi-line patterns. | |||
| 217 | + | type issueMatcher struct { | |||
| 218 | + | owner, defaultSeverity, defaultFromPath string | |||
| 219 | + | patterns []*issuePattern | |||
| 220 | + | state []*issueMatch | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func newIssueMatcher(c *matcherConfig, timeout time.Duration) (*issueMatcher, error) { | |||
| 224 | + | m := &issueMatcher{owner: c.Owner, defaultSeverity: c.Severity, defaultFromPath: c.FromPath} | |||
| 225 | + | for _, pc := range c.Patterns { | |||
| 226 | + | re, err := compilePattern(pc.Regexp) | |||
| 227 | + | if err != nil { | |||
| 228 | + | return nil, err | |||
| 229 | + | } | |||
| 230 | + | re.MatchTimeout = timeout | |||
| 231 | + | m.patterns = append(m.patterns, &issuePattern{ | |||
| 232 | + | file: pc.File, line: pc.Line, column: pc.Column, severity: pc.Severity, code: pc.Code, | |||
| 233 | + | message: pc.Message, fromPath: pc.FromPath, loop: bool(pc.Loop), re: re, | |||
| 234 | + | }) | |||
| 235 | + | } | |||
| 236 | + | m.reset() | |||
| 237 | + | return m, nil | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func (m *issueMatcher) reset() { m.state = make([]*issueMatch, max(len(m.patterns)-1, 0)) } | |||
| 241 | + | ||||
| 242 | + | // groupValue is IssueMatch.GetValue: the text of a group, null when the | |||
| 243 | + | // pattern does not name one or its number is out of range. | |||
| 244 | + | func groupValue(match *regexp2.Match, idx optInt) nstr { | |||
| 245 | + | if !idx.set || idx.v >= match.GroupCount() { | |||
| 246 | + | return nstr{} | |||
| 247 | + | } | |||
| 248 | + | g := match.GroupByNumber(idx.v) | |||
| 249 | + | if g == nil { | |||
| 250 | + | return nstr{} | |||
| 251 | + | } | |||
| 252 | + | return nstr{g.String(), true} | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func newIssueMatch(running *issueMatch, p *issuePattern, g *regexp2.Match, defSeverity, defFromPath string) *issueMatch { | |||
| 256 | + | r := running | |||
| 257 | + | if r == nil { | |||
| 258 | + | r = &issueMatch{} | |||
| 259 | + | } | |||
| 260 | + | im := &issueMatch{ | |||
| 261 | + | file: r.file.or(groupValue(g, p.file)), | |||
| 262 | + | line: r.line.or(groupValue(g, p.line)), | |||
| 263 | + | column: r.column.or(groupValue(g, p.column)), | |||
| 264 | + | severity: r.severity.or(groupValue(g, p.severity)), | |||
| 265 | + | code: r.code.or(groupValue(g, p.code)), | |||
| 266 | + | message: r.message.or(groupValue(g, p.message)), | |||
| 267 | + | fromPath: r.fromPath.or(groupValue(g, p.fromPath)), | |||
| 268 | + | } | |||
| 269 | + | if im.severity.s == "" && defSeverity != "" { | |||
| 270 | + | im.severity = nstr{defSeverity, true} | |||
| 271 | + | } | |||
| 272 | + | if im.fromPath.s == "" && defFromPath != "" { | |||
| 273 | + | im.fromPath = nstr{defFromPath, true} | |||
| 274 | + | } | |||
| 275 | + | return im | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // match is IssueMatcher.Match. A timeout of the engine is returned as | |||
| 279 | + | // errMatchTimeout. | |||
| 280 | + | func (m *issueMatcher) match(line string) (*issueMatch, error) { | |||
| 281 | + | find := func(p *issuePattern) (*regexp2.Match, error) { | |||
| 282 | + | mt, err := p.re.FindStringMatch(line) | |||
| 283 | + | if err != nil { | |||
| 284 | + | return nil, errMatchTimeout | |||
| 285 | + | } | |||
| 286 | + | return mt, nil | |||
| 287 | + | } | |||
| 288 | + | if len(m.patterns) == 1 { | |||
| 289 | + | p := m.patterns[0] | |||
| 290 | + | mt, err := find(p) | |||
| 291 | + | if err != nil || mt == nil { | |||
| 292 | + | return nil, err | |||
| 293 | + | } | |||
| 294 | + | return newIssueMatch(nil, p, mt, m.defaultSeverity, m.defaultFromPath), nil | |||
| 295 | + | } | |||
| 296 | + | for i := len(m.patterns) - 1; i >= 0; i-- { | |||
| 297 | + | var running *issueMatch | |||
| 298 | + | if i > 0 { | |||
| 299 | + | running = m.state[i-1] | |||
| 300 | + | } | |||
| 301 | + | if i != 0 && running == nil { | |||
| 302 | + | continue | |||
| 303 | + | } | |||
| 304 | + | p := m.patterns[i] | |||
| 305 | + | isLast := i == len(m.patterns)-1 | |||
| 306 | + | mt, err := find(p) | |||
| 307 | + | if err != nil { | |||
| 308 | + | return nil, err | |||
| 309 | + | } | |||
| 310 | + | if mt != nil { | |||
| 311 | + | if isLast { | |||
| 312 | + | if p.loop { | |||
| 313 | + | m.reset() | |||
| 314 | + | m.state[i-1] = running | |||
| 315 | + | } else { | |||
| 316 | + | m.reset() | |||
| 317 | + | } | |||
| 318 | + | return newIssueMatch(running, p, mt, m.defaultSeverity, m.defaultFromPath), nil | |||
| 319 | + | } | |||
| 320 | + | m.state[i] = newIssueMatch(running, p, mt, "", "") | |||
| 321 | + | } else if isLast { | |||
| 322 | + | m.state[i-1] = nil | |||
| 323 | + | } else { | |||
| 324 | + | m.state[i] = nil | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | return nil, nil | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // matcherRoot holds the matchers of the job (ExecutionContext._matchers, on the | |||
| 331 | + | // root context) and tells the output managers of the steps that run when they | |||
| 332 | + | // change (OnMatcherChanged). | |||
| 333 | + | type matcherRoot struct { | |||
| 334 | + | mu sync.Mutex | |||
| 335 | + | configs []*matcherConfig | |||
| 336 | + | handlers map[*outputManager]struct{} | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | // snapshot returns the matchers now. | |||
| 340 | + | func (r *matcherRoot) snapshot() []*matcherConfig { | |||
| 341 | + | r.mu.Lock() | |||
| 342 | + | defer r.mu.Unlock() | |||
| 343 | + | return append([]*matcherConfig(nil), r.configs...) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | func (r *matcherRoot) subscribe(o *outputManager) { | |||
| 347 | + | r.mu.Lock() | |||
| 348 | + | defer r.mu.Unlock() | |||
| 349 | + | if r.handlers == nil { | |||
| 350 | + | r.handlers = map[*outputManager]struct{}{} | |||
| 351 | + | } | |||
| 352 | + | r.handlers[o] = struct{}{} | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | func (r *matcherRoot) unsubscribe(o *outputManager) { | |||
| 356 | + | r.mu.Lock() | |||
| 357 | + | defer r.mu.Unlock() | |||
| 358 | + | delete(r.handlers, o) | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | // add is ExecutionContext.AddMatchers (:1173): the new matchers go first, those | |||
| 362 | + | // of the same owner (ignoring case) are replaced. It returns the debug line. | |||
| 363 | + | func (r *matcherRoot) add(c *matchersConfig) string { | |||
| 364 | + | r.mu.Lock() | |||
| 365 | + | defer r.mu.Unlock() | |||
| 366 | + | owners := map[string]bool{} | |||
| 367 | + | var next []*matcherConfig | |||
| 368 | + | for _, m := range c.Matchers { | |||
| 369 | + | owners[strings.ToLower(m.Owner)] = true | |||
| 370 | + | next = append(next, m) | |||
| 371 | + | } | |||
| 372 | + | for _, m := range r.configs { | |||
| 373 | + | if !owners[strings.ToLower(m.Owner)] { | |||
| 374 | + | next = append(next, m) | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | r.configs = next | |||
| 378 | + | var names []string | |||
| 379 | + | for _, m := range c.Matchers { | |||
| 380 | + | names = append(names, "'"+m.Owner+"'") | |||
| 381 | + | for o := range r.handlers { | |||
| 382 | + | o.matcherChanged(m) | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | return "Added matchers: " + strings.Join(names, ", ") + ". Problem matchers scan action output for known warning or error strings and report these inline." | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | // remove is ExecutionContext.RemoveMatchers (:1212). It returns the debug line. | |||
| 389 | + | func (r *matcherRoot) remove(owners []string) string { | |||
| 390 | + | r.mu.Lock() | |||
| 391 | + | defer r.mu.Unlock() | |||
| 392 | + | distinct := map[string]bool{} | |||
| 393 | + | for _, o := range owners { | |||
| 394 | + | distinct[strings.ToLower(o)] = true | |||
| 395 | + | } | |||
| 396 | + | var kept, removed []*matcherConfig | |||
| 397 | + | for _, m := range r.configs { | |||
| 398 | + | if distinct[strings.ToLower(m.Owner)] { | |||
| 399 | + | removed = append(removed, m) | |||
| 400 | + | } else { | |||
| 401 | + | kept = append(kept, m) | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | r.configs = kept | |||
| 405 | + | var names []string | |||
| 406 | + | for _, m := range removed { | |||
| 407 | + | names = append(names, "'"+m.Owner+"'") | |||
| 408 | + | for o := range r.handlers { | |||
| 409 | + | o.matcherChanged(&matcherConfig{Owner: m.Owner}) | |||
| 410 | + | } | |||
| 411 | + | } | |||
| 412 | + | return "Removed matchers: " + strings.Join(names, ", ") | |||
| 413 | + | } | |||
A internal/executor/matchers_test.go +195 -0
| @@ -0,0 +1,195 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func mustConfig(t *testing.T, js string) *matchersConfig { | |
| 12 | + | t.Helper() | |
| 13 | + | c, err := parseMatchersConfig([]byte(js)) | |
| 14 | + | if err != nil || c == nil { | |
| 15 | + | t.Fatalf("parse: %v", err) | |
| 16 | + | } | |
| 17 | + | return c | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Cases of actions/runner src/Test/L0/Worker/IssueMatcherL0.cs (MIT, (c) 2019 | |
| 21 | + | // GitHub; see THIRD_PARTY.md): Config_Validate_*. | |
| 22 | + | func TestMatcherConfigValidate(t *testing.T) { | |
| 23 | + | one := func(extra string) string { | |
| 24 | + | return `{"problemMatcher":[{"owner":"myMatcher","pattern":[` + extra + `]}]}` | |
| 25 | + | } | |
| 26 | + | for _, tc := range []struct{ name, js, err string }{ | |
| 27 | + | {"loop on a single pattern", one(`{"regexp":"^error: (.+)$","message":1,"loop":true}`), "Only the last pattern in a multiline matcher may set 'loop'"}, | |
| 28 | + | {"loop sanity", one(`{"regexp":"^file: (.+)$","file":1},{"regexp":"^error: (.+)$","message":1,"loop":true}`), ""}, | |
| 29 | + | {"loop only on the last", one(`{"regexp":"^(error)$","severity":1},{"regexp":"^file: (.+)$","file":1,"loop":true},{"regexp":"^error: (.+)$","message":1}`), "Only the last pattern in a multiline matcher may set 'loop'"}, | |
| 30 | + | {"loop must set message", one(`{"regexp":"^file: (.+)$","message":1},{"regexp":"^file: (.+)$","file":1,"loop":true}`), "The loop pattern must set 'message'"}, | |
| 31 | + | {"message in the first pattern", one(`{"regexp":"^file: (.+)$","message":1},{"regexp":"^error: (.+)$","file":1}`), ""}, | |
| 32 | + | {"message required", one(`{"regexp":"^error: (.+)$","file":1}`), "At least one pattern must set 'message'"}, | |
| 33 | + | {"owners distinct", `{"problemMatcher":[{"owner":"myMatcher","pattern":[{"regexp":"^error: (.+)$","message":1}]},{"owner":"MYmatcher","pattern":[{"regexp":"^ERR: (.+)$","message":1}]}]}`, "Duplicate owner name 'MYmatcher'"}, | |
| 34 | + | {"owner required", `{"problemMatcher":[{"pattern":[{"regexp":"^(.+)$","message":1}]}]}`, "Owner must not be empty"}, | |
| 35 | + | {"pattern required", `{"problemMatcher":[{"owner":"m"}]}`, "Matcher 'm' does not contain any patterns"}, | |
| 36 | + | {"property set twice", one(`{"regexp":"^(.+) (.+)$","file":1,"message":2},{"regexp":"^(.+)$","file":1}`), "The property 'file' is set twice"}, | |
| 37 | + | {"out of range", one(`{"regexp":"^(.+)$","message":2}`), "The property 'message' is set to 2 which is out of range"}, | |
| 38 | + | {"less than zero", one(`{"regexp":"^(.+)$","message":-1}`), "The property 'message' is set to -1 which is out of range"}, | |
| 39 | + | {"bad severity", `{"problemMatcher":[{"owner":"m","severity":"fatal","pattern":[{"regexp":"(.+)","message":1}]}]}`, "Matcher 'm' contains unexpected default severity 'fatal'"}, | |
| 40 | + | } { | |
| 41 | + | err := mustConfig(t, tc.js).validate() | |
| 42 | + | switch { | |
| 43 | + | case tc.err == "" && err != nil: | |
| 44 | + | t.Errorf("%s: %v", tc.name, err) | |
| 45 | + | case tc.err != "" && (err == nil || err.Error() != tc.err): | |
| 46 | + | t.Errorf("%s: %v, want %q", tc.name, err, tc.err) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func newMatcher(t *testing.T, js string) *issueMatcher { | |
| 52 | + | t.Helper() | |
| 53 | + | c := mustConfig(t, js) | |
| 54 | + | if err := c.validate(); err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | m, err := newIssueMatcher(c.Matchers[0], time.Second) | |
| 58 | + | if err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | return m | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func matchOf(t *testing.T, m *issueMatcher, line string) *issueMatch { | |
| 65 | + | t.Helper() | |
| 66 | + | r, err := m.match(line) | |
| 67 | + | if err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | return r | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // IssueMatcherL0.Matcher_MultiplePatterns_Loop_AccumulatesStatePerLine. | |
| 74 | + | func TestMatcherLoopAccumulatesState(t *testing.T) { | |
| 75 | + | m := newMatcher(t, `{"problemMatcher":[{"owner":"myMatcher","pattern":[ | |
| 76 | + | {"regexp":"^(.+)$","file":1},{"regexp":"^(.+)$","code":1},{"regexp":"^message:(.+)$","message":1,"loop":true}]}]}`) | |
| 77 | + | type want struct{ file, code, msg string } | |
| 78 | + | check := func(line string, w *want) { | |
| 79 | + | t.Helper() | |
| 80 | + | got := matchOf(t, m, line) | |
| 81 | + | switch { | |
| 82 | + | case w == nil && got != nil: | |
| 83 | + | t.Fatalf("%q: matched %+v", line, got) | |
| 84 | + | case w != nil && (got == nil || got.file.s != w.file || got.code.s != w.code || got.message.s != w.msg): | |
| 85 | + | t.Fatalf("%q: %+v, want %+v", line, got, w) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | check("file1", nil) | |
| 89 | + | check("code1", nil) | |
| 90 | + | check("message:message1", &want{"file1", "code1", "message1"}) | |
| 91 | + | check("message:message1-2", &want{"file1", "code1", "message1-2"}) // the loop keeps the running match | |
| 92 | + | check("abc", nil) // discarded | |
| 93 | + | check("file2", nil) | |
| 94 | + | check("code2", nil) | |
| 95 | + | check("message:message2", &want{"file2", "code2", "message2"}) | |
| 96 | + | check("abc", nil) | |
| 97 | + | check("abc", nil) | |
| 98 | + | check("file3", nil) | |
| 99 | + | check("code3", nil) | |
| 100 | + | check("message:message3", &want{"file3", "code3", "message3"}) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // IssueMatcherL0.Matcher_MultiplePatterns_NonLoop_*: no loop, a match clears | |
| 104 | + | // the state. | |
| 105 | + | func TestMatcherNonLoop(t *testing.T) { | |
| 106 | + | m := newMatcher(t, `{"problemMatcher":[{"owner":"m","pattern":[ | |
| 107 | + | {"regexp":"^(.+)$","file":1},{"regexp":"^message:(.+)$","message":1}]}]}`) | |
| 108 | + | if matchOf(t, m, "file1") != nil { | |
| 109 | + | t.Fatal("early match") | |
| 110 | + | } | |
| 111 | + | if got := matchOf(t, m, "message:m1"); got == nil || got.file.s != "file1" || got.message.s != "m1" { | |
| 112 | + | t.Fatalf("%+v", got) | |
| 113 | + | } | |
| 114 | + | // "message:m2" is also a file line (first pattern matches every line). | |
| 115 | + | if got := matchOf(t, m, "message:m2"); got != nil { | |
| 116 | + | t.Fatalf("a non loop pattern matched without a running match: %+v", got) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // IssueMatcherL0.Matcher_SinglePattern_ExtractsProperties, DefaultSeverity, | |
| 121 | + | // DefaultFromPath. | |
| 122 | + | func TestMatcherSinglePattern(t *testing.T) { | |
| 123 | + | m := newMatcher(t, `{"problemMatcher":[{"owner":"m","severity":"warning","fromPath":"web/package.json","pattern":[ | |
| 124 | + | {"regexp":"^(.+):(\\d+):(\\d+) (\\w+)? (\\w+) (.+)$","file":1,"line":2,"column":3,"severity":4,"code":5,"message":6}]}]}`) | |
| 125 | + | got := matchOf(t, m, "file.js:5:10 error E1 something bad") | |
| 126 | + | if got == nil || got.file.s != "file.js" || got.line.s != "5" || got.column.s != "10" || got.severity.s != "error" || got.code.s != "E1" || got.message.s != "something bad" || got.fromPath.s != "web/package.json" { | |
| 127 | + | t.Fatalf("%+v", got) | |
| 128 | + | } | |
| 129 | + | // A group that took no part gives "" and the default severity applies. | |
| 130 | + | got = matchOf(t, m, "file.js:5:10 E1 msg") | |
| 131 | + | if got == nil || got.severity.s != "warning" { | |
| 132 | + | t.Fatalf("%+v", got) | |
| 133 | + | } | |
| 134 | + | if matchOf(t, m, "nothing") != nil { | |
| 135 | + | t.Fatal("matched") | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // ECMAScript semantics (\d is ASCII only) and lookarounds, which RE2 lacks. | |
| 140 | + | func TestMatcherRegexFlavour(t *testing.T) { | |
| 141 | + | m := newMatcher(t, `{"problemMatcher":[{"owner":"m","pattern":[{"regexp":"^(?<=ERR )(\\d+) (?!ignored)(.+)$","line":1,"message":2}]}]}`) | |
| 142 | + | _ = m | |
| 143 | + | m = newMatcher(t, `{"problemMatcher":[{"owner":"m","pattern":[{"regexp":"^(\\d+) (.+)$","line":1,"message":2}]}]}`) | |
| 144 | + | if matchOf(t, m, "١٢ arabic digits") != nil { | |
| 145 | + | t.Error("\\d must be ASCII with RegexOptions.ECMAScript") | |
| 146 | + | } | |
| 147 | + | if matchOf(t, m, "12 ascii") == nil { | |
| 148 | + | t.Error("ASCII digits") | |
| 149 | + | } | |
| 150 | + | b := newMatcher(t, `{"problemMatcher":[{"owner":"m","pattern":[{"regexp":"^(a)\\1 (.+)$","message":2}]}]}`) | |
| 151 | + | if matchOf(t, b, "aa backreference") == nil { | |
| 152 | + | t.Error("backreference") | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // AC6b: a catastrophic pattern times out in about a second and does not stall | |
| 157 | + | // the step. | |
| 158 | + | func TestMatcherCatastrophicPatternTimesOut(t *testing.T) { | |
| 159 | + | m := newMatcher(t, `{"problemMatcher":[{"owner":"bad","pattern":[{"regexp":"^(a+)+$","message":1}]}]}`) | |
| 160 | + | start := time.Now() | |
| 161 | + | _, err := m.match(strings.Repeat("a", 30) + "!") | |
| 162 | + | took := time.Since(start) | |
| 163 | + | if err == nil || !strings.HasPrefix(err.Error(), "The Regex engine has timed out while trying to match a pattern to an input string.") { | |
| 164 | + | t.Fatalf("err %v after %v", err, took) | |
| 165 | + | } | |
| 166 | + | if took < 900*time.Millisecond || took > 3*time.Second { | |
| 167 | + | t.Errorf("timed out after %v, want about 1 s", took) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestParseTimeSpan(t *testing.T) { | |
| 172 | + | for in, want := range map[string]time.Duration{ | |
| 173 | + | "0:0:0.01": 10 * time.Millisecond, "00:00:05": 5 * time.Second, "0:2": 2 * time.Minute, "1.02:03:04": 26*time.Hour + 3*time.Minute + 4*time.Second, | |
| 174 | + | "1": 24 * time.Hour, | |
| 175 | + | } { | |
| 176 | + | if got, ok := parseTimeSpan(in); !ok || got != want { | |
| 177 | + | t.Errorf("%q: %v %v, want %v", in, got, ok, want) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | for _, in := range []string{"", "abc", "1:2:3:4", "25:00:00", "0:61"} { | |
| 181 | + | if _, ok := parseTimeSpan(in); ok { | |
| 182 | + | t.Errorf("%q parsed", in) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } | |
| 186 | + | if d := matcherTimeout(env(nil)); d != time.Second { | |
| 187 | + | t.Error(d) | |
| 188 | + | } | |
| 189 | + | if d := matcherTimeout(env(map[string]string{matcherTimeoutKey: "00:00:05"})); d != 5*time.Second { | |
| 190 | + | t.Error(d) | |
| 191 | + | } | |
| 192 | + | if d := matcherTimeout(env(map[string]string{matcherTimeoutKey: "-1"})); d != time.Second { | |
| 193 | + | t.Error(d) | |
| 194 | + | } | |
| 195 | + | } | |
| @@ -0,0 +1,195 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func mustConfig(t *testing.T, js string) *matchersConfig { | |||
| 12 | + | t.Helper() | |||
| 13 | + | c, err := parseMatchersConfig([]byte(js)) | |||
| 14 | + | if err != nil || c == nil { | |||
| 15 | + | t.Fatalf("parse: %v", err) | |||
| 16 | + | } | |||
| 17 | + | return c | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Cases of actions/runner src/Test/L0/Worker/IssueMatcherL0.cs (MIT, (c) 2019 | |||
| 21 | + | // GitHub; see THIRD_PARTY.md): Config_Validate_*. | |||
| 22 | + | func TestMatcherConfigValidate(t *testing.T) { | |||
| 23 | + | one := func(extra string) string { | |||
| 24 | + | return `{"problemMatcher":[{"owner":"myMatcher","pattern":[` + extra + `]}]}` | |||
| 25 | + | } | |||
| 26 | + | for _, tc := range []struct{ name, js, err string }{ | |||
| 27 | + | {"loop on a single pattern", one(`{"regexp":"^error: (.+)$","message":1,"loop":true}`), "Only the last pattern in a multiline matcher may set 'loop'"}, | |||
| 28 | + | {"loop sanity", one(`{"regexp":"^file: (.+)$","file":1},{"regexp":"^error: (.+)$","message":1,"loop":true}`), ""}, | |||
| 29 | + | {"loop only on the last", one(`{"regexp":"^(error)$","severity":1},{"regexp":"^file: (.+)$","file":1,"loop":true},{"regexp":"^error: (.+)$","message":1}`), "Only the last pattern in a multiline matcher may set 'loop'"}, | |||
| 30 | + | {"loop must set message", one(`{"regexp":"^file: (.+)$","message":1},{"regexp":"^file: (.+)$","file":1,"loop":true}`), "The loop pattern must set 'message'"}, | |||
| 31 | + | {"message in the first pattern", one(`{"regexp":"^file: (.+)$","message":1},{"regexp":"^error: (.+)$","file":1}`), ""}, | |||
| 32 | + | {"message required", one(`{"regexp":"^error: (.+)$","file":1}`), "At least one pattern must set 'message'"}, | |||
| 33 | + | {"owners distinct", `{"problemMatcher":[{"owner":"myMatcher","pattern":[{"regexp":"^error: (.+)$","message":1}]},{"owner":"MYmatcher","pattern":[{"regexp":"^ERR: (.+)$","message":1}]}]}`, "Duplicate owner name 'MYmatcher'"}, | |||
| 34 | + | {"owner required", `{"problemMatcher":[{"pattern":[{"regexp":"^(.+)$","message":1}]}]}`, "Owner must not be empty"}, | |||
| 35 | + | {"pattern required", `{"problemMatcher":[{"owner":"m"}]}`, "Matcher 'm' does not contain any patterns"}, | |||
| 36 | + | {"property set twice", one(`{"regexp":"^(.+) (.+)$","file":1,"message":2},{"regexp":"^(.+)$","file":1}`), "The property 'file' is set twice"}, | |||
| 37 | + | {"out of range", one(`{"regexp":"^(.+)$","message":2}`), "The property 'message' is set to 2 which is out of range"}, | |||
| 38 | + | {"less than zero", one(`{"regexp":"^(.+)$","message":-1}`), "The property 'message' is set to -1 which is out of range"}, | |||
| 39 | + | {"bad severity", `{"problemMatcher":[{"owner":"m","severity":"fatal","pattern":[{"regexp":"(.+)","message":1}]}]}`, "Matcher 'm' contains unexpected default severity 'fatal'"}, | |||
| 40 | + | } { | |||
| 41 | + | err := mustConfig(t, tc.js).validate() | |||
| 42 | + | switch { | |||
| 43 | + | case tc.err == "" && err != nil: | |||
| 44 | + | t.Errorf("%s: %v", tc.name, err) | |||
| 45 | + | case tc.err != "" && (err == nil || err.Error() != tc.err): | |||
| 46 | + | t.Errorf("%s: %v, want %q", tc.name, err, tc.err) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func newMatcher(t *testing.T, js string) *issueMatcher { | |||
| 52 | + | t.Helper() | |||
| 53 | + | c := mustConfig(t, js) | |||
| 54 | + | if err := c.validate(); err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | m, err := newIssueMatcher(c.Matchers[0], time.Second) | |||
| 58 | + | if err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | return m | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func matchOf(t *testing.T, m *issueMatcher, line string) *issueMatch { | |||
| 65 | + | t.Helper() | |||
| 66 | + | r, err := m.match(line) | |||
| 67 | + | if err != nil { | |||
| 68 | + | t.Fatal(err) | |||
| 69 | + | } | |||
| 70 | + | return r | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // IssueMatcherL0.Matcher_MultiplePatterns_Loop_AccumulatesStatePerLine. | |||
| 74 | + | func TestMatcherLoopAccumulatesState(t *testing.T) { | |||
| 75 | + | m := newMatcher(t, `{"problemMatcher":[{"owner":"myMatcher","pattern":[ | |||
| 76 | + | {"regexp":"^(.+)$","file":1},{"regexp":"^(.+)$","code":1},{"regexp":"^message:(.+)$","message":1,"loop":true}]}]}`) | |||
| 77 | + | type want struct{ file, code, msg string } | |||
| 78 | + | check := func(line string, w *want) { | |||
| 79 | + | t.Helper() | |||
| 80 | + | got := matchOf(t, m, line) | |||
| 81 | + | switch { | |||
| 82 | + | case w == nil && got != nil: | |||
| 83 | + | t.Fatalf("%q: matched %+v", line, got) | |||
| 84 | + | case w != nil && (got == nil || got.file.s != w.file || got.code.s != w.code || got.message.s != w.msg): | |||
| 85 | + | t.Fatalf("%q: %+v, want %+v", line, got, w) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | check("file1", nil) | |||
| 89 | + | check("code1", nil) | |||
| 90 | + | check("message:message1", &want{"file1", "code1", "message1"}) | |||
| 91 | + | check("message:message1-2", &want{"file1", "code1", "message1-2"}) // the loop keeps the running match | |||
| 92 | + | check("abc", nil) // discarded | |||
| 93 | + | check("file2", nil) | |||
| 94 | + | check("code2", nil) | |||
| 95 | + | check("message:message2", &want{"file2", "code2", "message2"}) | |||
| 96 | + | check("abc", nil) | |||
| 97 | + | check("abc", nil) | |||
| 98 | + | check("file3", nil) | |||
| 99 | + | check("code3", nil) | |||
| 100 | + | check("message:message3", &want{"file3", "code3", "message3"}) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // IssueMatcherL0.Matcher_MultiplePatterns_NonLoop_*: no loop, a match clears | |||
| 104 | + | // the state. | |||
| 105 | + | func TestMatcherNonLoop(t *testing.T) { | |||
| 106 | + | m := newMatcher(t, `{"problemMatcher":[{"owner":"m","pattern":[ | |||
| 107 | + | {"regexp":"^(.+)$","file":1},{"regexp":"^message:(.+)$","message":1}]}]}`) | |||
| 108 | + | if matchOf(t, m, "file1") != nil { | |||
| 109 | + | t.Fatal("early match") | |||
| 110 | + | } | |||
| 111 | + | if got := matchOf(t, m, "message:m1"); got == nil || got.file.s != "file1" || got.message.s != "m1" { | |||
| 112 | + | t.Fatalf("%+v", got) | |||
| 113 | + | } | |||
| 114 | + | // "message:m2" is also a file line (first pattern matches every line). | |||
| 115 | + | if got := matchOf(t, m, "message:m2"); got != nil { | |||
| 116 | + | t.Fatalf("a non loop pattern matched without a running match: %+v", got) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // IssueMatcherL0.Matcher_SinglePattern_ExtractsProperties, DefaultSeverity, | |||
| 121 | + | // DefaultFromPath. | |||
| 122 | + | func TestMatcherSinglePattern(t *testing.T) { | |||
| 123 | + | m := newMatcher(t, `{"problemMatcher":[{"owner":"m","severity":"warning","fromPath":"web/package.json","pattern":[ | |||
| 124 | + | {"regexp":"^(.+):(\\d+):(\\d+) (\\w+)? (\\w+) (.+)$","file":1,"line":2,"column":3,"severity":4,"code":5,"message":6}]}]}`) | |||
| 125 | + | got := matchOf(t, m, "file.js:5:10 error E1 something bad") | |||
| 126 | + | if got == nil || got.file.s != "file.js" || got.line.s != "5" || got.column.s != "10" || got.severity.s != "error" || got.code.s != "E1" || got.message.s != "something bad" || got.fromPath.s != "web/package.json" { | |||
| 127 | + | t.Fatalf("%+v", got) | |||
| 128 | + | } | |||
| 129 | + | // A group that took no part gives "" and the default severity applies. | |||
| 130 | + | got = matchOf(t, m, "file.js:5:10 E1 msg") | |||
| 131 | + | if got == nil || got.severity.s != "warning" { | |||
| 132 | + | t.Fatalf("%+v", got) | |||
| 133 | + | } | |||
| 134 | + | if matchOf(t, m, "nothing") != nil { | |||
| 135 | + | t.Fatal("matched") | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // ECMAScript semantics (\d is ASCII only) and lookarounds, which RE2 lacks. | |||
| 140 | + | func TestMatcherRegexFlavour(t *testing.T) { | |||
| 141 | + | m := newMatcher(t, `{"problemMatcher":[{"owner":"m","pattern":[{"regexp":"^(?<=ERR )(\\d+) (?!ignored)(.+)$","line":1,"message":2}]}]}`) | |||
| 142 | + | _ = m | |||
| 143 | + | m = newMatcher(t, `{"problemMatcher":[{"owner":"m","pattern":[{"regexp":"^(\\d+) (.+)$","line":1,"message":2}]}]}`) | |||
| 144 | + | if matchOf(t, m, "١٢ arabic digits") != nil { | |||
| 145 | + | t.Error("\\d must be ASCII with RegexOptions.ECMAScript") | |||
| 146 | + | } | |||
| 147 | + | if matchOf(t, m, "12 ascii") == nil { | |||
| 148 | + | t.Error("ASCII digits") | |||
| 149 | + | } | |||
| 150 | + | b := newMatcher(t, `{"problemMatcher":[{"owner":"m","pattern":[{"regexp":"^(a)\\1 (.+)$","message":2}]}]}`) | |||
| 151 | + | if matchOf(t, b, "aa backreference") == nil { | |||
| 152 | + | t.Error("backreference") | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // AC6b: a catastrophic pattern times out in about a second and does not stall | |||
| 157 | + | // the step. | |||
| 158 | + | func TestMatcherCatastrophicPatternTimesOut(t *testing.T) { | |||
| 159 | + | m := newMatcher(t, `{"problemMatcher":[{"owner":"bad","pattern":[{"regexp":"^(a+)+$","message":1}]}]}`) | |||
| 160 | + | start := time.Now() | |||
| 161 | + | _, err := m.match(strings.Repeat("a", 30) + "!") | |||
| 162 | + | took := time.Since(start) | |||
| 163 | + | if err == nil || !strings.HasPrefix(err.Error(), "The Regex engine has timed out while trying to match a pattern to an input string.") { | |||
| 164 | + | t.Fatalf("err %v after %v", err, took) | |||
| 165 | + | } | |||
| 166 | + | if took < 900*time.Millisecond || took > 3*time.Second { | |||
| 167 | + | t.Errorf("timed out after %v, want about 1 s", took) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestParseTimeSpan(t *testing.T) { | |||
| 172 | + | for in, want := range map[string]time.Duration{ | |||
| 173 | + | "0:0:0.01": 10 * time.Millisecond, "00:00:05": 5 * time.Second, "0:2": 2 * time.Minute, "1.02:03:04": 26*time.Hour + 3*time.Minute + 4*time.Second, | |||
| 174 | + | "1": 24 * time.Hour, | |||
| 175 | + | } { | |||
| 176 | + | if got, ok := parseTimeSpan(in); !ok || got != want { | |||
| 177 | + | t.Errorf("%q: %v %v, want %v", in, got, ok, want) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | for _, in := range []string{"", "abc", "1:2:3:4", "25:00:00", "0:61"} { | |||
| 181 | + | if _, ok := parseTimeSpan(in); ok { | |||
| 182 | + | t.Errorf("%q parsed", in) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } | |||
| 186 | + | if d := matcherTimeout(env(nil)); d != time.Second { | |||
| 187 | + | t.Error(d) | |||
| 188 | + | } | |||
| 189 | + | if d := matcherTimeout(env(map[string]string{matcherTimeoutKey: "00:00:05"})); d != 5*time.Second { | |||
| 190 | + | t.Error(d) | |||
| 191 | + | } | |||
| 192 | + | if d := matcherTimeout(env(map[string]string{matcherTimeoutKey: "-1"})); d != time.Second { | |||
| 193 | + | t.Error(d) | |||
| 194 | + | } | |||
| 195 | + | } | |||
A internal/executor/outputmgr.go +354 -0
| @@ -0,0 +1,354 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "time" | |
| 13 | + | "unicode" | |
| 14 | + | ||
| 15 | + | "github.com/dlclark/regexp2" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // outputManager is OutputManager (Runner.Worker/Handlers/OutputManager.cs): it | |
| 19 | + | // receives the lines a step prints, runs the workflow commands and the | |
| 20 | + | // problem matchers on them, and logs what is left. One per process of a step. | |
| 21 | + | ||
| 22 | + | const ( | |
| 23 | + | // maxMatcherAttempts is OutputManager._maxAttempts. | |
| 24 | + | maxMatcherAttempts = 3 | |
| 25 | + | // matcherTimeoutKey is OutputManager._timeoutKey. | |
| 26 | + | matcherTimeoutKey = "GITHUB_ACTIONS_RUNNER_ISSUE_MATCHER_TIMEOUT" | |
| 27 | + | // repoPathFailsafe is OutputManager._failsafe. | |
| 28 | + | repoPathFailsafe = 50 | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // colorCodeRegex is OutputManager._colorCodeRegex, @"\x0033\[[0-9;]*m?". In | |
| 32 | + | // .NET "\x00" takes exactly two hex digits, so the pattern is NUL, "33", "[" | |
| 33 | + | // and not ESC: the runner strips nothing from real terminal colors (its own | |
| 34 | + | // test writes "\033", which C# reads as NUL followed by "33"). Ported as is. | |
| 35 | + | var colorCodeRegex = regexp2.MustCompile("\x0033\\[[0-9;]*m?", 0) | |
| 36 | + | ||
| 37 | + | // colorCodePrefix is OutputManager._colorCodePrefix, "\033[": NUL, "33[". | |
| 38 | + | const colorCodePrefix = "\x0033[" | |
| 39 | + | ||
| 40 | + | // parseTimeSpan is TimeSpan.TryParse with the invariant culture: "d" days, | |
| 41 | + | // "hh:mm", "hh:mm:ss[.fffffff]" and "d.hh:mm:ss[.fffffff]", with an optional | |
| 42 | + | // leading minus. | |
| 43 | + | func parseTimeSpan(s string) (time.Duration, bool) { | |
| 44 | + | s = strings.TrimSpace(s) | |
| 45 | + | if s == "" { | |
| 46 | + | return 0, false | |
| 47 | + | } | |
| 48 | + | neg := strings.HasPrefix(s, "-") | |
| 49 | + | s = strings.TrimPrefix(s, "-") | |
| 50 | + | var days int64 | |
| 51 | + | parts := strings.Split(s, ":") | |
| 52 | + | if len(parts) == 1 { | |
| 53 | + | n, err := strconv.ParseInt(s, 10, 32) | |
| 54 | + | if err != nil { | |
| 55 | + | return 0, false | |
| 56 | + | } | |
| 57 | + | d := time.Duration(n) * 24 * time.Hour | |
| 58 | + | if neg { | |
| 59 | + | d = -d | |
| 60 | + | } | |
| 61 | + | return d, true | |
| 62 | + | } | |
| 63 | + | if len(parts) > 3 { | |
| 64 | + | return 0, false | |
| 65 | + | } | |
| 66 | + | if d, rest, ok := strings.Cut(parts[0], "."); ok { | |
| 67 | + | n, err := strconv.ParseInt(d, 10, 32) | |
| 68 | + | if err != nil { | |
| 69 | + | return 0, false | |
| 70 | + | } | |
| 71 | + | days, parts[0] = n, rest | |
| 72 | + | } | |
| 73 | + | num := func(x string) (int64, bool) { | |
| 74 | + | n, err := strconv.ParseInt(x, 10, 32) | |
| 75 | + | return n, err == nil && n >= 0 | |
| 76 | + | } | |
| 77 | + | h, ok := num(parts[0]) | |
| 78 | + | if !ok { | |
| 79 | + | return 0, false | |
| 80 | + | } | |
| 81 | + | m, ok := num(parts[1]) | |
| 82 | + | if !ok || m > 59 { | |
| 83 | + | return 0, false | |
| 84 | + | } | |
| 85 | + | var sec float64 | |
| 86 | + | if len(parts) == 3 { | |
| 87 | + | f, err := strconv.ParseFloat(parts[2], 64) | |
| 88 | + | if err != nil || f < 0 || f >= 60 || strings.ContainsAny(parts[2], "eE+-") { | |
| 89 | + | return 0, false | |
| 90 | + | } | |
| 91 | + | sec = f | |
| 92 | + | } | |
| 93 | + | if h > 23 { | |
| 94 | + | return 0, false | |
| 95 | + | } | |
| 96 | + | d := time.Duration(days)*24*time.Hour + time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(sec*float64(time.Second)) | |
| 97 | + | if neg { | |
| 98 | + | d = -d | |
| 99 | + | } | |
| 100 | + | return d, true | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // matcherTimeout is the constructor of OutputManager (:43-60): the variable of | |
| 104 | + | // the job, then the environment of the runner, then one second. ermite has no | |
| 105 | + | // job variables; the environment is the executor's own, as the runner's is its | |
| 106 | + | // worker's. | |
| 107 | + | func matcherTimeout(getenv func(string) string) time.Duration { | |
| 108 | + | if v := getenv(matcherTimeoutKey); v != "" { | |
| 109 | + | if d, ok := parseTimeSpan(v); ok && d > 0 { | |
| 110 | + | return d | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | return time.Second | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | type outputManager struct { | |
| 117 | + | s *stepRun | |
| 118 | + | root *matcherRoot | |
| 119 | + | timeout time.Duration | |
| 120 | + | ||
| 121 | + | mu sync.Mutex | |
| 122 | + | matchers []*issueMatcher | |
| 123 | + | dirMap map[string]string // directory -> repository path ("" = none) | |
| 124 | + | hasDir map[string]bool | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // newOutputManager subscribes to the matchers of the job. | |
| 128 | + | func newOutputManager(s *stepRun) *outputManager { | |
| 129 | + | o := &outputManager{s: s, root: s.j.matchers, timeout: matcherTimeout(s.j.getenv), dirMap: map[string]string{}, hasDir: map[string]bool{}} | |
| 130 | + | o.mu.Lock() | |
| 131 | + | o.root.subscribe(o) | |
| 132 | + | for _, c := range o.root.snapshot() { | |
| 133 | + | if m, err := newIssueMatcher(c, o.timeout); err == nil { | |
| 134 | + | o.matchers = append(o.matchers, m) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | o.mu.Unlock() | |
| 138 | + | return o | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (o *outputManager) dispose() { o.root.unsubscribe(o) } | |
| 142 | + | ||
| 143 | + | // matcherChanged is OnMatcherChanged: the matcher goes first, the others of | |
| 144 | + | // the same owner (ignoring case) go. A config without patterns only removes. | |
| 145 | + | func (o *outputManager) matcherChanged(c *matcherConfig) { | |
| 146 | + | o.mu.Lock() | |
| 147 | + | defer o.mu.Unlock() | |
| 148 | + | var next []*issueMatcher | |
| 149 | + | if len(c.Patterns) > 0 { | |
| 150 | + | if m, err := newIssueMatcher(c, o.timeout); err == nil { | |
| 151 | + | next = append(next, m) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | for _, m := range o.matchers { | |
| 155 | + | if !strings.EqualFold(m.owner, c.Owner) { | |
| 156 | + | next = append(next, m) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | o.matchers = next | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func (o *outputManager) removeMatcher(m *issueMatcher) { | |
| 163 | + | o.mu.Lock() | |
| 164 | + | defer o.mu.Unlock() | |
| 165 | + | var next []*issueMatcher | |
| 166 | + | for _, x := range o.matchers { | |
| 167 | + | if x != m { | |
| 168 | + | next = append(next, x) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | o.matchers = next | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // onData is OutputManager.OnDataReceived. | |
| 175 | + | func (o *outputManager) onData(line string) { | |
| 176 | + | s := o.s | |
| 177 | + | // ## commands | |
| 178 | + | if line != "" && (strings.Contains(line, "##[") || strings.Contains(line, "::")) { | |
| 179 | + | if s.cmds.TryProcess(line) { | |
| 180 | + | return | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | // Strip runner-controlled markers from user output to prevent injection. | |
| 184 | + | if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") { | |
| 185 | + | line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`) | |
| 186 | + | } | |
| 187 | + | // Problem matchers. | |
| 188 | + | o.mu.Lock() | |
| 189 | + | matchers := append([]*issueMatcher(nil), o.matchers...) | |
| 190 | + | o.mu.Unlock() | |
| 191 | + | if len(matchers) > 0 { | |
| 192 | + | stripped := line | |
| 193 | + | if strings.Contains(line, colorCodePrefix) { | |
| 194 | + | stripped, _ = colorCodeRegex.Replace(line, "", -1, -1) | |
| 195 | + | } | |
| 196 | + | for _, m := range matchers { | |
| 197 | + | var match *issueMatch | |
| 198 | + | for attempt := 1; attempt <= maxMatcherAttempts; attempt++ { | |
| 199 | + | mt, err := m.match(stripped) | |
| 200 | + | if err == nil { | |
| 201 | + | match = mt | |
| 202 | + | break | |
| 203 | + | } | |
| 204 | + | if attempt < maxMatcherAttempts { | |
| 205 | + | s.Debug(fmt.Sprintf("Timeout processing issue matcher '%s' against line '%s'. Exception: System.Text.RegularExpressions.RegexMatchTimeoutException: %s", m.owner, stripped, err.Error())) | |
| 206 | + | } else { | |
| 207 | + | s.j.addIssueFor(s, issue{Level: LevelWarning, Message: fmt.Sprintf("Removing issue matcher '%s'. Matcher failed %d times. Error: %s", m.owner, maxMatcherAttempts, err.Error())}) | |
| 208 | + | o.removeMatcher(m) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | if match != nil { | |
| 212 | + | // Reset other matchers. | |
| 213 | + | for _, other := range matchers { | |
| 214 | + | if other != m { | |
| 215 | + | other.reset() | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | if is, ok := o.convertToIssue(match); ok { | |
| 219 | + | is.LogText = stripped | |
| 220 | + | s.j.addIssueFor(s, is) | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | // Regular output. | |
| 227 | + | s.Out(line) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // convertToIssue is OutputManager.ConvertToIssue (:196-327). | |
| 231 | + | func (o *outputManager) convertToIssue(m *issueMatch) (issue, bool) { | |
| 232 | + | s := o.s | |
| 233 | + | if strings.TrimFunc(m.message.s, unicode.IsSpace) == "" { | |
| 234 | + | s.Debug("Skipping logging an issue for the matched line because the message is empty.") | |
| 235 | + | return issue{}, false | |
| 236 | + | } | |
| 237 | + | var level AnnotationLevel | |
| 238 | + | switch strings.ToLower(m.severity.s) { | |
| 239 | + | case "", "error": | |
| 240 | + | level = LevelFailure | |
| 241 | + | case "warning": | |
| 242 | + | level = LevelWarning | |
| 243 | + | case "notice": | |
| 244 | + | level = LevelNotice | |
| 245 | + | default: | |
| 246 | + | s.Debug(fmt.Sprintf("Skipped logging an issue for the matched line because the severity '%s' is not supported.", m.severity.s)) | |
| 247 | + | return issue{}, false | |
| 248 | + | } | |
| 249 | + | is := issue{Level: level, Message: m.message.s, Props: map[string]string{}} | |
| 250 | + | // unsigned parses like int.TryParse(NumberStyles.None): digits only. | |
| 251 | + | unsigned := func(v string) (string, bool) { | |
| 252 | + | for _, r := range v { | |
| 253 | + | if r < '0' || r > '9' { | |
| 254 | + | return "", false | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | n, err := strconv.ParseInt(v, 10, 32) | |
| 258 | + | if err != nil || v == "" { | |
| 259 | + | return "", false | |
| 260 | + | } | |
| 261 | + | return strconv.FormatInt(n, 10), true | |
| 262 | + | } | |
| 263 | + | if m.line.s != "" { | |
| 264 | + | if n, ok := unsigned(m.line.s); ok { | |
| 265 | + | is.Props["line"] = n | |
| 266 | + | } else { | |
| 267 | + | s.Debug(fmt.Sprintf("Unable to parse line number '%s'", m.line.s)) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | if m.column.s != "" { | |
| 271 | + | if n, ok := unsigned(m.column.s); ok { | |
| 272 | + | is.Props["col"] = n | |
| 273 | + | } else { | |
| 274 | + | s.Debug(fmt.Sprintf("Unable to parse column number '%s'", m.column.s)) | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | if c := strings.TrimFunc(m.code.s, unicode.IsSpace); c != "" { | |
| 278 | + | is.Props["code"] = c | |
| 279 | + | } | |
| 280 | + | // File. | |
| 281 | + | if strings.TrimFunc(m.file.s, unicode.IsSpace) != "" { | |
| 282 | + | file := m.file.s | |
| 283 | + | // Root using fromPath. | |
| 284 | + | if strings.TrimFunc(m.fromPath.s, unicode.IsSpace) != "" && !filepath.IsAbs(file) { | |
| 285 | + | if dir := filepath.Dir(m.fromPath.s); dir != "." && dir != "" { | |
| 286 | + | file = filepath.Join(dir, file) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | // Root using workspace. | |
| 290 | + | if !filepath.IsAbs(file) { | |
| 291 | + | file = filepath.Join(s.Workspace(), file) | |
| 292 | + | } | |
| 293 | + | file = filepath.Clean(file) | |
| 294 | + | if st, err := os.Stat(file); err == nil && !st.IsDir() { | |
| 295 | + | if repo := o.repositoryPath(file, 0); repo != "" { | |
| 296 | + | rel := strings.TrimLeft(file[len(repo):], "/") | |
| 297 | + | is.Props["file"] = rel | |
| 298 | + | } else { | |
| 299 | + | s.Debug(fmt.Sprintf("Dropping file value '%s'. Path is not under the workflow repo.", file)) | |
| 300 | + | } | |
| 301 | + | } else { | |
| 302 | + | s.Debug(fmt.Sprintf("Dropping file value '%s'. Path does not exist", file)) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | return is, true | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // repositoryPath is OutputManager.GetRepositoryPath: the closest directory | |
| 309 | + | // above filePath that has a .git/config naming the repository of the job. | |
| 310 | + | func (o *outputManager) repositoryPath(filePath string, recursion int) string { | |
| 311 | + | if len(o.dirMap) > 100 { | |
| 312 | + | o.dirMap, o.hasDir = map[string]string{}, map[string]bool{} | |
| 313 | + | } | |
| 314 | + | dir := filepath.Dir(filePath) | |
| 315 | + | if dir == filePath || dir == "." || recursion > repoPathFailsafe { | |
| 316 | + | return "" | |
| 317 | + | } | |
| 318 | + | if o.hasDir[dir] { | |
| 319 | + | return o.dirMap[dir] | |
| 320 | + | } | |
| 321 | + | repo := "" | |
| 322 | + | gitConfig := filepath.Join(dir, ".git", "config") | |
| 323 | + | if st, err := os.Stat(gitConfig); err == nil && !st.IsDir() { | |
| 324 | + | serverURL := o.s.j.githubValue("server_url") | |
| 325 | + | if serverURL == "" { | |
| 326 | + | serverURL = "https://github.com" | |
| 327 | + | } | |
| 328 | + | host := serverURL | |
| 329 | + | if i := strings.Index(host, "://"); i >= 0 { | |
| 330 | + | host = host[i+3:] | |
| 331 | + | } | |
| 332 | + | if i := strings.IndexAny(host, "/:"); i >= 0 { | |
| 333 | + | host = host[:i] | |
| 334 | + | } | |
| 335 | + | nameWithOwner := o.s.j.githubValue("repository") | |
| 336 | + | patterns := []string{"url = " + serverURL + "/" + nameWithOwner, "url = git@" + host + ":" + nameWithOwner + ".git"} | |
| 337 | + | if b, err := os.ReadFile(gitConfig); err == nil { | |
| 338 | + | for _, l := range strings.Split(string(b), "\n") { | |
| 339 | + | l = strings.TrimFunc(l, unicode.IsSpace) | |
| 340 | + | for _, p := range patterns { | |
| 341 | + | if strings.EqualFold(l, p) { | |
| 342 | + | repo = dir | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | } else { | |
| 347 | + | o.s.Debug(fmt.Sprintf("Error when attempting to determine whether the path '%s' is under the workflow repository: %v", filePath, err)) | |
| 348 | + | } | |
| 349 | + | } else { | |
| 350 | + | repo = o.repositoryPath(dir, recursion+1) | |
| 351 | + | } | |
| 352 | + | o.dirMap[dir], o.hasDir[dir] = repo, true | |
| 353 | + | return repo | |
| 354 | + | } | |
| @@ -0,0 +1,354 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | "time" | |||
| 13 | + | "unicode" | |||
| 14 | + | ||||
| 15 | + | "github.com/dlclark/regexp2" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // outputManager is OutputManager (Runner.Worker/Handlers/OutputManager.cs): it | |||
| 19 | + | // receives the lines a step prints, runs the workflow commands and the | |||
| 20 | + | // problem matchers on them, and logs what is left. One per process of a step. | |||
| 21 | + | ||||
| 22 | + | const ( | |||
| 23 | + | // maxMatcherAttempts is OutputManager._maxAttempts. | |||
| 24 | + | maxMatcherAttempts = 3 | |||
| 25 | + | // matcherTimeoutKey is OutputManager._timeoutKey. | |||
| 26 | + | matcherTimeoutKey = "GITHUB_ACTIONS_RUNNER_ISSUE_MATCHER_TIMEOUT" | |||
| 27 | + | // repoPathFailsafe is OutputManager._failsafe. | |||
| 28 | + | repoPathFailsafe = 50 | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // colorCodeRegex is OutputManager._colorCodeRegex, @"\x0033\[[0-9;]*m?". In | |||
| 32 | + | // .NET "\x00" takes exactly two hex digits, so the pattern is NUL, "33", "[" | |||
| 33 | + | // and not ESC: the runner strips nothing from real terminal colors (its own | |||
| 34 | + | // test writes "\033", which C# reads as NUL followed by "33"). Ported as is. | |||
| 35 | + | var colorCodeRegex = regexp2.MustCompile("\x0033\\[[0-9;]*m?", 0) | |||
| 36 | + | ||||
| 37 | + | // colorCodePrefix is OutputManager._colorCodePrefix, "\033[": NUL, "33[". | |||
| 38 | + | const colorCodePrefix = "\x0033[" | |||
| 39 | + | ||||
| 40 | + | // parseTimeSpan is TimeSpan.TryParse with the invariant culture: "d" days, | |||
| 41 | + | // "hh:mm", "hh:mm:ss[.fffffff]" and "d.hh:mm:ss[.fffffff]", with an optional | |||
| 42 | + | // leading minus. | |||
| 43 | + | func parseTimeSpan(s string) (time.Duration, bool) { | |||
| 44 | + | s = strings.TrimSpace(s) | |||
| 45 | + | if s == "" { | |||
| 46 | + | return 0, false | |||
| 47 | + | } | |||
| 48 | + | neg := strings.HasPrefix(s, "-") | |||
| 49 | + | s = strings.TrimPrefix(s, "-") | |||
| 50 | + | var days int64 | |||
| 51 | + | parts := strings.Split(s, ":") | |||
| 52 | + | if len(parts) == 1 { | |||
| 53 | + | n, err := strconv.ParseInt(s, 10, 32) | |||
| 54 | + | if err != nil { | |||
| 55 | + | return 0, false | |||
| 56 | + | } | |||
| 57 | + | d := time.Duration(n) * 24 * time.Hour | |||
| 58 | + | if neg { | |||
| 59 | + | d = -d | |||
| 60 | + | } | |||
| 61 | + | return d, true | |||
| 62 | + | } | |||
| 63 | + | if len(parts) > 3 { | |||
| 64 | + | return 0, false | |||
| 65 | + | } | |||
| 66 | + | if d, rest, ok := strings.Cut(parts[0], "."); ok { | |||
| 67 | + | n, err := strconv.ParseInt(d, 10, 32) | |||
| 68 | + | if err != nil { | |||
| 69 | + | return 0, false | |||
| 70 | + | } | |||
| 71 | + | days, parts[0] = n, rest | |||
| 72 | + | } | |||
| 73 | + | num := func(x string) (int64, bool) { | |||
| 74 | + | n, err := strconv.ParseInt(x, 10, 32) | |||
| 75 | + | return n, err == nil && n >= 0 | |||
| 76 | + | } | |||
| 77 | + | h, ok := num(parts[0]) | |||
| 78 | + | if !ok { | |||
| 79 | + | return 0, false | |||
| 80 | + | } | |||
| 81 | + | m, ok := num(parts[1]) | |||
| 82 | + | if !ok || m > 59 { | |||
| 83 | + | return 0, false | |||
| 84 | + | } | |||
| 85 | + | var sec float64 | |||
| 86 | + | if len(parts) == 3 { | |||
| 87 | + | f, err := strconv.ParseFloat(parts[2], 64) | |||
| 88 | + | if err != nil || f < 0 || f >= 60 || strings.ContainsAny(parts[2], "eE+-") { | |||
| 89 | + | return 0, false | |||
| 90 | + | } | |||
| 91 | + | sec = f | |||
| 92 | + | } | |||
| 93 | + | if h > 23 { | |||
| 94 | + | return 0, false | |||
| 95 | + | } | |||
| 96 | + | d := time.Duration(days)*24*time.Hour + time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(sec*float64(time.Second)) | |||
| 97 | + | if neg { | |||
| 98 | + | d = -d | |||
| 99 | + | } | |||
| 100 | + | return d, true | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // matcherTimeout is the constructor of OutputManager (:43-60): the variable of | |||
| 104 | + | // the job, then the environment of the runner, then one second. ermite has no | |||
| 105 | + | // job variables; the environment is the executor's own, as the runner's is its | |||
| 106 | + | // worker's. | |||
| 107 | + | func matcherTimeout(getenv func(string) string) time.Duration { | |||
| 108 | + | if v := getenv(matcherTimeoutKey); v != "" { | |||
| 109 | + | if d, ok := parseTimeSpan(v); ok && d > 0 { | |||
| 110 | + | return d | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | return time.Second | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | type outputManager struct { | |||
| 117 | + | s *stepRun | |||
| 118 | + | root *matcherRoot | |||
| 119 | + | timeout time.Duration | |||
| 120 | + | ||||
| 121 | + | mu sync.Mutex | |||
| 122 | + | matchers []*issueMatcher | |||
| 123 | + | dirMap map[string]string // directory -> repository path ("" = none) | |||
| 124 | + | hasDir map[string]bool | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // newOutputManager subscribes to the matchers of the job. | |||
| 128 | + | func newOutputManager(s *stepRun) *outputManager { | |||
| 129 | + | o := &outputManager{s: s, root: s.j.matchers, timeout: matcherTimeout(s.j.getenv), dirMap: map[string]string{}, hasDir: map[string]bool{}} | |||
| 130 | + | o.mu.Lock() | |||
| 131 | + | o.root.subscribe(o) | |||
| 132 | + | for _, c := range o.root.snapshot() { | |||
| 133 | + | if m, err := newIssueMatcher(c, o.timeout); err == nil { | |||
| 134 | + | o.matchers = append(o.matchers, m) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | o.mu.Unlock() | |||
| 138 | + | return o | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func (o *outputManager) dispose() { o.root.unsubscribe(o) } | |||
| 142 | + | ||||
| 143 | + | // matcherChanged is OnMatcherChanged: the matcher goes first, the others of | |||
| 144 | + | // the same owner (ignoring case) go. A config without patterns only removes. | |||
| 145 | + | func (o *outputManager) matcherChanged(c *matcherConfig) { | |||
| 146 | + | o.mu.Lock() | |||
| 147 | + | defer o.mu.Unlock() | |||
| 148 | + | var next []*issueMatcher | |||
| 149 | + | if len(c.Patterns) > 0 { | |||
| 150 | + | if m, err := newIssueMatcher(c, o.timeout); err == nil { | |||
| 151 | + | next = append(next, m) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | for _, m := range o.matchers { | |||
| 155 | + | if !strings.EqualFold(m.owner, c.Owner) { | |||
| 156 | + | next = append(next, m) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | o.matchers = next | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func (o *outputManager) removeMatcher(m *issueMatcher) { | |||
| 163 | + | o.mu.Lock() | |||
| 164 | + | defer o.mu.Unlock() | |||
| 165 | + | var next []*issueMatcher | |||
| 166 | + | for _, x := range o.matchers { | |||
| 167 | + | if x != m { | |||
| 168 | + | next = append(next, x) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | o.matchers = next | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // onData is OutputManager.OnDataReceived. | |||
| 175 | + | func (o *outputManager) onData(line string) { | |||
| 176 | + | s := o.s | |||
| 177 | + | // ## commands | |||
| 178 | + | if line != "" && (strings.Contains(line, "##[") || strings.Contains(line, "::")) { | |||
| 179 | + | if s.cmds.TryProcess(line) { | |||
| 180 | + | return | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | // Strip runner-controlled markers from user output to prevent injection. | |||
| 184 | + | if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") { | |||
| 185 | + | line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`) | |||
| 186 | + | } | |||
| 187 | + | // Problem matchers. | |||
| 188 | + | o.mu.Lock() | |||
| 189 | + | matchers := append([]*issueMatcher(nil), o.matchers...) | |||
| 190 | + | o.mu.Unlock() | |||
| 191 | + | if len(matchers) > 0 { | |||
| 192 | + | stripped := line | |||
| 193 | + | if strings.Contains(line, colorCodePrefix) { | |||
| 194 | + | stripped, _ = colorCodeRegex.Replace(line, "", -1, -1) | |||
| 195 | + | } | |||
| 196 | + | for _, m := range matchers { | |||
| 197 | + | var match *issueMatch | |||
| 198 | + | for attempt := 1; attempt <= maxMatcherAttempts; attempt++ { | |||
| 199 | + | mt, err := m.match(stripped) | |||
| 200 | + | if err == nil { | |||
| 201 | + | match = mt | |||
| 202 | + | break | |||
| 203 | + | } | |||
| 204 | + | if attempt < maxMatcherAttempts { | |||
| 205 | + | s.Debug(fmt.Sprintf("Timeout processing issue matcher '%s' against line '%s'. Exception: System.Text.RegularExpressions.RegexMatchTimeoutException: %s", m.owner, stripped, err.Error())) | |||
| 206 | + | } else { | |||
| 207 | + | s.j.addIssueFor(s, issue{Level: LevelWarning, Message: fmt.Sprintf("Removing issue matcher '%s'. Matcher failed %d times. Error: %s", m.owner, maxMatcherAttempts, err.Error())}) | |||
| 208 | + | o.removeMatcher(m) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | if match != nil { | |||
| 212 | + | // Reset other matchers. | |||
| 213 | + | for _, other := range matchers { | |||
| 214 | + | if other != m { | |||
| 215 | + | other.reset() | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | if is, ok := o.convertToIssue(match); ok { | |||
| 219 | + | is.LogText = stripped | |||
| 220 | + | s.j.addIssueFor(s, is) | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | // Regular output. | |||
| 227 | + | s.Out(line) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // convertToIssue is OutputManager.ConvertToIssue (:196-327). | |||
| 231 | + | func (o *outputManager) convertToIssue(m *issueMatch) (issue, bool) { | |||
| 232 | + | s := o.s | |||
| 233 | + | if strings.TrimFunc(m.message.s, unicode.IsSpace) == "" { | |||
| 234 | + | s.Debug("Skipping logging an issue for the matched line because the message is empty.") | |||
| 235 | + | return issue{}, false | |||
| 236 | + | } | |||
| 237 | + | var level AnnotationLevel | |||
| 238 | + | switch strings.ToLower(m.severity.s) { | |||
| 239 | + | case "", "error": | |||
| 240 | + | level = LevelFailure | |||
| 241 | + | case "warning": | |||
| 242 | + | level = LevelWarning | |||
| 243 | + | case "notice": | |||
| 244 | + | level = LevelNotice | |||
| 245 | + | default: | |||
| 246 | + | s.Debug(fmt.Sprintf("Skipped logging an issue for the matched line because the severity '%s' is not supported.", m.severity.s)) | |||
| 247 | + | return issue{}, false | |||
| 248 | + | } | |||
| 249 | + | is := issue{Level: level, Message: m.message.s, Props: map[string]string{}} | |||
| 250 | + | // unsigned parses like int.TryParse(NumberStyles.None): digits only. | |||
| 251 | + | unsigned := func(v string) (string, bool) { | |||
| 252 | + | for _, r := range v { | |||
| 253 | + | if r < '0' || r > '9' { | |||
| 254 | + | return "", false | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | n, err := strconv.ParseInt(v, 10, 32) | |||
| 258 | + | if err != nil || v == "" { | |||
| 259 | + | return "", false | |||
| 260 | + | } | |||
| 261 | + | return strconv.FormatInt(n, 10), true | |||
| 262 | + | } | |||
| 263 | + | if m.line.s != "" { | |||
| 264 | + | if n, ok := unsigned(m.line.s); ok { | |||
| 265 | + | is.Props["line"] = n | |||
| 266 | + | } else { | |||
| 267 | + | s.Debug(fmt.Sprintf("Unable to parse line number '%s'", m.line.s)) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | if m.column.s != "" { | |||
| 271 | + | if n, ok := unsigned(m.column.s); ok { | |||
| 272 | + | is.Props["col"] = n | |||
| 273 | + | } else { | |||
| 274 | + | s.Debug(fmt.Sprintf("Unable to parse column number '%s'", m.column.s)) | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | if c := strings.TrimFunc(m.code.s, unicode.IsSpace); c != "" { | |||
| 278 | + | is.Props["code"] = c | |||
| 279 | + | } | |||
| 280 | + | // File. | |||
| 281 | + | if strings.TrimFunc(m.file.s, unicode.IsSpace) != "" { | |||
| 282 | + | file := m.file.s | |||
| 283 | + | // Root using fromPath. | |||
| 284 | + | if strings.TrimFunc(m.fromPath.s, unicode.IsSpace) != "" && !filepath.IsAbs(file) { | |||
| 285 | + | if dir := filepath.Dir(m.fromPath.s); dir != "." && dir != "" { | |||
| 286 | + | file = filepath.Join(dir, file) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | // Root using workspace. | |||
| 290 | + | if !filepath.IsAbs(file) { | |||
| 291 | + | file = filepath.Join(s.Workspace(), file) | |||
| 292 | + | } | |||
| 293 | + | file = filepath.Clean(file) | |||
| 294 | + | if st, err := os.Stat(file); err == nil && !st.IsDir() { | |||
| 295 | + | if repo := o.repositoryPath(file, 0); repo != "" { | |||
| 296 | + | rel := strings.TrimLeft(file[len(repo):], "/") | |||
| 297 | + | is.Props["file"] = rel | |||
| 298 | + | } else { | |||
| 299 | + | s.Debug(fmt.Sprintf("Dropping file value '%s'. Path is not under the workflow repo.", file)) | |||
| 300 | + | } | |||
| 301 | + | } else { | |||
| 302 | + | s.Debug(fmt.Sprintf("Dropping file value '%s'. Path does not exist", file)) | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | return is, true | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // repositoryPath is OutputManager.GetRepositoryPath: the closest directory | |||
| 309 | + | // above filePath that has a .git/config naming the repository of the job. | |||
| 310 | + | func (o *outputManager) repositoryPath(filePath string, recursion int) string { | |||
| 311 | + | if len(o.dirMap) > 100 { | |||
| 312 | + | o.dirMap, o.hasDir = map[string]string{}, map[string]bool{} | |||
| 313 | + | } | |||
| 314 | + | dir := filepath.Dir(filePath) | |||
| 315 | + | if dir == filePath || dir == "." || recursion > repoPathFailsafe { | |||
| 316 | + | return "" | |||
| 317 | + | } | |||
| 318 | + | if o.hasDir[dir] { | |||
| 319 | + | return o.dirMap[dir] | |||
| 320 | + | } | |||
| 321 | + | repo := "" | |||
| 322 | + | gitConfig := filepath.Join(dir, ".git", "config") | |||
| 323 | + | if st, err := os.Stat(gitConfig); err == nil && !st.IsDir() { | |||
| 324 | + | serverURL := o.s.j.githubValue("server_url") | |||
| 325 | + | if serverURL == "" { | |||
| 326 | + | serverURL = "https://github.com" | |||
| 327 | + | } | |||
| 328 | + | host := serverURL | |||
| 329 | + | if i := strings.Index(host, "://"); i >= 0 { | |||
| 330 | + | host = host[i+3:] | |||
| 331 | + | } | |||
| 332 | + | if i := strings.IndexAny(host, "/:"); i >= 0 { | |||
| 333 | + | host = host[:i] | |||
| 334 | + | } | |||
| 335 | + | nameWithOwner := o.s.j.githubValue("repository") | |||
| 336 | + | patterns := []string{"url = " + serverURL + "/" + nameWithOwner, "url = git@" + host + ":" + nameWithOwner + ".git"} | |||
| 337 | + | if b, err := os.ReadFile(gitConfig); err == nil { | |||
| 338 | + | for _, l := range strings.Split(string(b), "\n") { | |||
| 339 | + | l = strings.TrimFunc(l, unicode.IsSpace) | |||
| 340 | + | for _, p := range patterns { | |||
| 341 | + | if strings.EqualFold(l, p) { | |||
| 342 | + | repo = dir | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | } else { | |||
| 347 | + | o.s.Debug(fmt.Sprintf("Error when attempting to determine whether the path '%s' is under the workflow repository: %v", filePath, err)) | |||
| 348 | + | } | |||
| 349 | + | } else { | |||
| 350 | + | repo = o.repositoryPath(dir, recursion+1) | |||
| 351 | + | } | |||
| 352 | + | o.dirMap[dir], o.hasDir[dir] = repo, true | |||
| 353 | + | return repo | |||
| 354 | + | } | |||
M internal/executor/report.go +43 -0
| @@ -48,3 +48,46 @@ type Result struct { | |||
| 48 | 48 | // told to stop (see ErrShutdown). | |
| 49 | 49 | RunnerShutdown bool | |
| 50 | 50 | } | |
| 51 | + | ||
| 52 | + | // AnnotationLevel is the severity of an annotation. | |
| 53 | + | type AnnotationLevel string | |
| 54 | + | ||
| 55 | + | // Levels, with the names of GitHub's check run annotations. | |
| 56 | + | const ( | |
| 57 | + | LevelNotice AnnotationLevel = "notice" | |
| 58 | + | LevelWarning AnnotationLevel = "warning" | |
| 59 | + | LevelFailure AnnotationLevel = "failure" // ::error and an error that the runner itself logs | |
| 60 | + | ) | |
| 61 | + | ||
| 62 | + | // Annotation is a message attached to a step, produced by ::notice, | |
| 63 | + | // ::warning, ::error, a problem matcher or an error the runner logs, and | |
| 64 | + | // already masked. The executor keeps at most 10 per level per step. | |
| 65 | + | type Annotation struct { | |
| 66 | + | Level AnnotationLevel | |
| 67 | + | Title, Message, File string | |
| 68 | + | StartLine, EndLine int32 | |
| 69 | + | StartColumn int32 | |
| 70 | + | EndColumn int32 | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // StepResults is what a step leaves besides its log, reported just before | |
| 74 | + | // StepCompleted. | |
| 75 | + | type StepResults struct { | |
| 76 | + | // Outcome is steps.<id>.outcome: the result before continue-on-error. | |
| 77 | + | Outcome Conclusion | |
| 78 | + | // Annotations is the whole list of the step, replacing any earlier one. It | |
| 79 | + | // is empty when ci.annotations is not available (Config.SendAnnotations). | |
| 80 | + | Annotations []Annotation | |
| 81 | + | // Summary is the masked GITHUB_STEP_SUMMARY of the step, HasSummary says | |
| 82 | + | // whether there is one. Empty when ci.summaries is not available | |
| 83 | + | // (Config.SendSummaries). | |
| 84 | + | Summary string | |
| 85 | + | HasSummary bool | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // StepResultReporter is implemented by a Reporter that wants the results of | |
| 89 | + | // the steps. StepResults(n) is called once for each step of the workflow, | |
| 90 | + | // before StepCompleted(n) and under the same serialisation. | |
| 91 | + | type StepResultReporter interface { | |
| 92 | + | StepResults(number int, r StepResults) | |
| 93 | + | } | |
| @@ -48,3 +48,46 @@ type Result struct { | |||||
| 48 | // told to stop (see ErrShutdown). | 48 | // told to stop (see ErrShutdown). | ||
| 49 | RunnerShutdown bool | 49 | RunnerShutdown bool | ||
| 50 | } | 50 | } | ||
| 51 | + | ||||
| 52 | + | // AnnotationLevel is the severity of an annotation. | |||
| 53 | + | type AnnotationLevel string | |||
| 54 | + | ||||
| 55 | + | // Levels, with the names of GitHub's check run annotations. | |||
| 56 | + | const ( | |||
| 57 | + | LevelNotice AnnotationLevel = "notice" | |||
| 58 | + | LevelWarning AnnotationLevel = "warning" | |||
| 59 | + | LevelFailure AnnotationLevel = "failure" // ::error and an error that the runner itself logs | |||
| 60 | + | ) | |||
| 61 | + | ||||
| 62 | + | // Annotation is a message attached to a step, produced by ::notice, | |||
| 63 | + | // ::warning, ::error, a problem matcher or an error the runner logs, and | |||
| 64 | + | // already masked. The executor keeps at most 10 per level per step. | |||
| 65 | + | type Annotation struct { | |||
| 66 | + | Level AnnotationLevel | |||
| 67 | + | Title, Message, File string | |||
| 68 | + | StartLine, EndLine int32 | |||
| 69 | + | StartColumn int32 | |||
| 70 | + | EndColumn int32 | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // StepResults is what a step leaves besides its log, reported just before | |||
| 74 | + | // StepCompleted. | |||
| 75 | + | type StepResults struct { | |||
| 76 | + | // Outcome is steps.<id>.outcome: the result before continue-on-error. | |||
| 77 | + | Outcome Conclusion | |||
| 78 | + | // Annotations is the whole list of the step, replacing any earlier one. It | |||
| 79 | + | // is empty when ci.annotations is not available (Config.SendAnnotations). | |||
| 80 | + | Annotations []Annotation | |||
| 81 | + | // Summary is the masked GITHUB_STEP_SUMMARY of the step, HasSummary says | |||
| 82 | + | // whether there is one. Empty when ci.summaries is not available | |||
| 83 | + | // (Config.SendSummaries). | |||
| 84 | + | Summary string | |||
| 85 | + | HasSummary bool | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // StepResultReporter is implemented by a Reporter that wants the results of | |||
| 89 | + | // the steps. StepResults(n) is called once for each step of the workflow, | |||
| 90 | + | // before StepCompleted(n) and under the same serialisation. | |||
| 91 | + | type StepResultReporter interface { | |||
| 92 | + | StepResults(number int, r StepResults) | |||
| 93 | + | } | |||
A internal/executor/results.go +246 -0
| @@ -0,0 +1,246 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "strings" | |
| 11 | + | "sync/atomic" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite-actions/template" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // What a step leaves besides its log: annotations, summary, outcome, saved | |
| 17 | + | // state. The pieces that the steps of phase F share (embedded steps of a | |
| 18 | + | // composite action, state of an action) are the fields marked below. | |
| 19 | + | ||
| 20 | + | // stepScope is what a step that runs inside another one (a step of a | |
| 21 | + | // composite action) shares with the step of the workflow that uses the action. | |
| 22 | + | // The zero value is a step of the workflow. | |
| 23 | + | type stepScope struct { | |
| 24 | + | // issues, when set, is the collector of the outer step: the issues of an | |
| 25 | + | // embedded step count against its limits (ExecutionContext.AddIssue, | |
| 26 | + | // :838). Embedded is true then. | |
| 27 | + | issues *issueCollector | |
| 28 | + | embedded bool | |
| 29 | + | // stateKey names the intra-action state the step saves to (SaveState). Empty | |
| 30 | + | // means the step's own number. | |
| 31 | + | stateKey string | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // AddIssue is ExecutionContext.AddIssue for the step. | |
| 35 | + | func (s *stepRun) AddIssue(is issue) { s.j.addIssueFor(s, is) } | |
| 36 | + | ||
| 37 | + | func (j *job) addIssueFor(s *stepRun, is issue) { | |
| 38 | + | j.addIssue(s.num, s.scope.issues, s.scope.embedded, is) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Workspace is github.workspace. | |
| 42 | + | func (s *stepRun) Workspace() string { return s.j.githubValue("workspace") } | |
| 43 | + | ||
| 44 | + | func (j *job) githubValue(k string) string { | |
| 45 | + | j.mu.Lock() | |
| 46 | + | defer j.mu.Unlock() | |
| 47 | + | return j.state.GitHub(k) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // AddMask is SecretMasker.AddValue. | |
| 51 | + | func (s *stepRun) AddMask(v string) { s.j.masker.Load().add(v) } | |
| 52 | + | ||
| 53 | + | // Matchers is the root of the problem matchers of the job. | |
| 54 | + | func (s *stepRun) Matchers() *matcherRoot { return s.j.matchers } | |
| 55 | + | ||
| 56 | + | // maxMatcherFile bounds a problem matcher file. | |
| 57 | + | const maxMatcherFile = 1 << 20 | |
| 58 | + | ||
| 59 | + | // ReadMatcherFile reads a problem matcher file. | |
| 60 | + | func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) { | |
| 61 | + | f, err := os.Open(path) | |
| 62 | + | if err != nil { | |
| 63 | + | return nil, err | |
| 64 | + | } | |
| 65 | + | defer func() { _ = f.Close() }() | |
| 66 | + | b, err := io.ReadAll(io.LimitReader(f, maxMatcherFile+1)) | |
| 67 | + | if err != nil { | |
| 68 | + | return nil, err | |
| 69 | + | } | |
| 70 | + | if len(b) > maxMatcherFile { | |
| 71 | + | return nil, ghErrorf("The problem matcher file is larger than %d bytes", maxMatcherFile) | |
| 72 | + | } | |
| 73 | + | return b, nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (j *job) getenv(k string) string { | |
| 77 | + | if j.cfg.Getenv != nil { | |
| 78 | + | return j.cfg.Getenv(k) | |
| 79 | + | } | |
| 80 | + | return os.Getenv(k) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // SaveState is the state of an action step (save-state and GITHUB_STATE): kept | |
| 84 | + | // per step so that the post step of the action can read it as STATE_<name> | |
| 85 | + | // (ActionCommandManager.cs:384-398). A step that is not an action only fills | |
| 86 | + | // it; the runner does the same. | |
| 87 | + | func (s *stepRun) SaveState(name, value string) { s.j.saveState(s.stateKey(), name, value) } | |
| 88 | + | ||
| 89 | + | func (s *stepRun) stateKey() string { | |
| 90 | + | if s.scope.stateKey != "" { | |
| 91 | + | return s.scope.stateKey | |
| 92 | + | } | |
| 93 | + | return fmt.Sprint(s.num) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (j *job) saveState(key, name, value string) { | |
| 97 | + | j.issuesMu.Lock() | |
| 98 | + | defer j.issuesMu.Unlock() | |
| 99 | + | if j.states == nil { | |
| 100 | + | j.states = map[string]map[string]string{} | |
| 101 | + | } | |
| 102 | + | m := j.states[key] | |
| 103 | + | if m == nil { | |
| 104 | + | m = map[string]string{} | |
| 105 | + | j.states[key] = m | |
| 106 | + | } | |
| 107 | + | // IntraActionState is a Dictionary with an OrdinalIgnoreCase comparer. | |
| 108 | + | m[strings.ToLower(name)] = value | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // IntraActionState returns the state saved under key (the number of the step, | |
| 112 | + | // or the stateKey of its scope), with lower-case names. | |
| 113 | + | func (j *job) intraActionState(key string) map[string]string { | |
| 114 | + | j.issuesMu.Lock() | |
| 115 | + | defer j.issuesMu.Unlock() | |
| 116 | + | out := map[string]string{} | |
| 117 | + | for k, v := range j.states[key] { | |
| 118 | + | out[k] = v | |
| 119 | + | } | |
| 120 | + | return out | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Summary receives GITHUB_STEP_SUMMARY after the step (see processStepSummary). | |
| 124 | + | func (s *stepRun) Summary(markdown string) { | |
| 125 | + | j := s.j | |
| 126 | + | j.issuesMu.Lock() | |
| 127 | + | defer j.issuesMu.Unlock() | |
| 128 | + | if j.summaries == nil { | |
| 129 | + | j.summaries = map[int]string{} | |
| 130 | + | } | |
| 131 | + | j.summaries[s.num] = markdown | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // MaskText masks text with the secrets known now. | |
| 135 | + | func (s *stepRun) MaskText(t string) string { return s.j.mask(t) } | |
| 136 | + | ||
| 137 | + | // summarySink is implemented by the Sink of a step that keeps summaries. | |
| 138 | + | type summarySink interface{ Summary(markdown string) } | |
| 139 | + | ||
| 140 | + | // recordOutcome keeps steps.<id>.outcome of a step for StepResults. | |
| 141 | + | func (j *job) recordOutcome(num int, c Conclusion) { | |
| 142 | + | j.issuesMu.Lock() | |
| 143 | + | defer j.issuesMu.Unlock() | |
| 144 | + | if j.outcomes == nil { | |
| 145 | + | j.outcomes = map[int]Conclusion{} | |
| 146 | + | } | |
| 147 | + | j.outcomes[num] = c | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // flushStepResults hands the results of step n to the reporter, before the step | |
| 151 | + | // completes. Called with the serialisation of the reporter not held. | |
| 152 | + | func (j *job) flushStepResults(next Reporter, n int, c Conclusion) { | |
| 153 | + | rr, ok := next.(StepResultReporter) | |
| 154 | + | if !ok { | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | j.issuesMu.Lock() | |
| 158 | + | outcome, has := j.outcomes[n] | |
| 159 | + | summary, hasSummary := j.summaries[n] | |
| 160 | + | delete(j.summaries, n) | |
| 161 | + | coll := j.collectors[n] | |
| 162 | + | j.issuesMu.Unlock() | |
| 163 | + | if !has { | |
| 164 | + | outcome = c | |
| 165 | + | } | |
| 166 | + | r := StepResults{Outcome: outcome} | |
| 167 | + | if coll != nil && j.cfg.SendAnnotations { | |
| 168 | + | r.Annotations = coll.result() | |
| 169 | + | } | |
| 170 | + | if hasSummary && j.cfg.SendSummaries { | |
| 171 | + | r.Summary, r.HasSummary = summary, true | |
| 172 | + | } | |
| 173 | + | rr.StepResults(n, r) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // logCanceled logs the cancellation of the process of a step | |
| 177 | + | // (StepsRunner.cs:333-337 logs the exception of a cancelled step), unless the | |
| 178 | + | // step timed out: then StepsRunner logs the timeout message instead. | |
| 179 | + | func (s *stepRun) logCanceled() { | |
| 180 | + | if s.timedOut.Load() && !s.j.cancelRequested() { | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | s.j.errorf(s.num, "The operation was canceled.") | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (j *job) cancelRequested() bool { | |
| 187 | + | select { | |
| 188 | + | case <-j.cancel.Done(): | |
| 189 | + | return true | |
| 190 | + | default: | |
| 191 | + | return false | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // ---- timeout-minutes | |
| 196 | + | ||
| 197 | + | // timedOutFlag is the marker of a step that reached its timeout. | |
| 198 | + | type timedOutFlag = atomic.Bool | |
| 199 | + | ||
| 200 | + | // stepTimeoutMessage is StepsRunner.cs:328. | |
| 201 | + | func stepTimeoutMessage(name string, minutes int) string { | |
| 202 | + | return fmt.Sprintf("The action '%s' has timed out after %d minutes.", name, minutes) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // ---- step summary | |
| 206 | + | ||
| 207 | + | // summaryContent is what CreateStepSummaryCommand uploads: the lines of the | |
| 208 | + | // file, each masked, each ended by a line feed (StreamReader.ReadLine and | |
| 209 | + | // StreamWriter.WriteLine; ProcessCommand, FileCommandManager.cs:253-261). | |
| 210 | + | func summaryContent(text string, mask func(string) string) string { | |
| 211 | + | var sb strings.Builder | |
| 212 | + | for _, l := range splitLines(text) { | |
| 213 | + | sb.WriteString(mask(l)) | |
| 214 | + | sb.WriteByte('\n') | |
| 215 | + | } | |
| 216 | + | return sb.String() | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // maxStepTimeoutMinutes bounds the timeout so that the duration fits. | |
| 220 | + | const maxStepTimeoutMinutes = 1 << 24 | |
| 221 | + | ||
| 222 | + | // stepTimeout is PipelineTemplateEvaluator.EvaluateStepTimeout and | |
| 223 | + | // ConvertToStepTimeout: the number is truncated to an int; zero, negative and | |
| 224 | + | // absent values mean no timeout. An expression that fails logs the runner's | |
| 225 | + | // two errors and the step runs without timeout (StepsRunner.cs:266-273). | |
| 226 | + | func (j *job) stepTimeout(s *stepRun) int { | |
| 227 | + | tok := s.st.TimeoutMinutes | |
| 228 | + | if tok == nil { | |
| 229 | + | return 0 | |
| 230 | + | } | |
| 231 | + | j.mu.Lock() | |
| 232 | + | f, err := template.EvaluateNumber(context.Background(), tok, j.evalEnv(s.env)) | |
| 233 | + | j.mu.Unlock() | |
| 234 | + | if err != nil { | |
| 235 | + | j.errorf(s.num, "An error occurred when attempting to determine the step timeout.") | |
| 236 | + | j.errorf(s.num, "%s", templateNotValid(err, true)) | |
| 237 | + | return 0 | |
| 238 | + | } | |
| 239 | + | switch { | |
| 240 | + | case f != f || f < 1: | |
| 241 | + | return 0 | |
| 242 | + | case f > maxStepTimeoutMinutes: | |
| 243 | + | return maxStepTimeoutMinutes | |
| 244 | + | } | |
| 245 | + | return int(f) | |
| 246 | + | } | |
| @@ -0,0 +1,246 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync/atomic" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite-actions/template" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // What a step leaves besides its log: annotations, summary, outcome, saved | |||
| 17 | + | // state. The pieces that the steps of phase F share (embedded steps of a | |||
| 18 | + | // composite action, state of an action) are the fields marked below. | |||
| 19 | + | ||||
| 20 | + | // stepScope is what a step that runs inside another one (a step of a | |||
| 21 | + | // composite action) shares with the step of the workflow that uses the action. | |||
| 22 | + | // The zero value is a step of the workflow. | |||
| 23 | + | type stepScope struct { | |||
| 24 | + | // issues, when set, is the collector of the outer step: the issues of an | |||
| 25 | + | // embedded step count against its limits (ExecutionContext.AddIssue, | |||
| 26 | + | // :838). Embedded is true then. | |||
| 27 | + | issues *issueCollector | |||
| 28 | + | embedded bool | |||
| 29 | + | // stateKey names the intra-action state the step saves to (SaveState). Empty | |||
| 30 | + | // means the step's own number. | |||
| 31 | + | stateKey string | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // AddIssue is ExecutionContext.AddIssue for the step. | |||
| 35 | + | func (s *stepRun) AddIssue(is issue) { s.j.addIssueFor(s, is) } | |||
| 36 | + | ||||
| 37 | + | func (j *job) addIssueFor(s *stepRun, is issue) { | |||
| 38 | + | j.addIssue(s.num, s.scope.issues, s.scope.embedded, is) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Workspace is github.workspace. | |||
| 42 | + | func (s *stepRun) Workspace() string { return s.j.githubValue("workspace") } | |||
| 43 | + | ||||
| 44 | + | func (j *job) githubValue(k string) string { | |||
| 45 | + | j.mu.Lock() | |||
| 46 | + | defer j.mu.Unlock() | |||
| 47 | + | return j.state.GitHub(k) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // AddMask is SecretMasker.AddValue. | |||
| 51 | + | func (s *stepRun) AddMask(v string) { s.j.masker.Load().add(v) } | |||
| 52 | + | ||||
| 53 | + | // Matchers is the root of the problem matchers of the job. | |||
| 54 | + | func (s *stepRun) Matchers() *matcherRoot { return s.j.matchers } | |||
| 55 | + | ||||
| 56 | + | // maxMatcherFile bounds a problem matcher file. | |||
| 57 | + | const maxMatcherFile = 1 << 20 | |||
| 58 | + | ||||
| 59 | + | // ReadMatcherFile reads a problem matcher file. | |||
| 60 | + | func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) { | |||
| 61 | + | f, err := os.Open(path) | |||
| 62 | + | if err != nil { | |||
| 63 | + | return nil, err | |||
| 64 | + | } | |||
| 65 | + | defer func() { _ = f.Close() }() | |||
| 66 | + | b, err := io.ReadAll(io.LimitReader(f, maxMatcherFile+1)) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return nil, err | |||
| 69 | + | } | |||
| 70 | + | if len(b) > maxMatcherFile { | |||
| 71 | + | return nil, ghErrorf("The problem matcher file is larger than %d bytes", maxMatcherFile) | |||
| 72 | + | } | |||
| 73 | + | return b, nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (j *job) getenv(k string) string { | |||
| 77 | + | if j.cfg.Getenv != nil { | |||
| 78 | + | return j.cfg.Getenv(k) | |||
| 79 | + | } | |||
| 80 | + | return os.Getenv(k) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // SaveState is the state of an action step (save-state and GITHUB_STATE): kept | |||
| 84 | + | // per step so that the post step of the action can read it as STATE_<name> | |||
| 85 | + | // (ActionCommandManager.cs:384-398). A step that is not an action only fills | |||
| 86 | + | // it; the runner does the same. | |||
| 87 | + | func (s *stepRun) SaveState(name, value string) { s.j.saveState(s.stateKey(), name, value) } | |||
| 88 | + | ||||
| 89 | + | func (s *stepRun) stateKey() string { | |||
| 90 | + | if s.scope.stateKey != "" { | |||
| 91 | + | return s.scope.stateKey | |||
| 92 | + | } | |||
| 93 | + | return fmt.Sprint(s.num) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func (j *job) saveState(key, name, value string) { | |||
| 97 | + | j.issuesMu.Lock() | |||
| 98 | + | defer j.issuesMu.Unlock() | |||
| 99 | + | if j.states == nil { | |||
| 100 | + | j.states = map[string]map[string]string{} | |||
| 101 | + | } | |||
| 102 | + | m := j.states[key] | |||
| 103 | + | if m == nil { | |||
| 104 | + | m = map[string]string{} | |||
| 105 | + | j.states[key] = m | |||
| 106 | + | } | |||
| 107 | + | // IntraActionState is a Dictionary with an OrdinalIgnoreCase comparer. | |||
| 108 | + | m[strings.ToLower(name)] = value | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // IntraActionState returns the state saved under key (the number of the step, | |||
| 112 | + | // or the stateKey of its scope), with lower-case names. | |||
| 113 | + | func (j *job) intraActionState(key string) map[string]string { | |||
| 114 | + | j.issuesMu.Lock() | |||
| 115 | + | defer j.issuesMu.Unlock() | |||
| 116 | + | out := map[string]string{} | |||
| 117 | + | for k, v := range j.states[key] { | |||
| 118 | + | out[k] = v | |||
| 119 | + | } | |||
| 120 | + | return out | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // Summary receives GITHUB_STEP_SUMMARY after the step (see processStepSummary). | |||
| 124 | + | func (s *stepRun) Summary(markdown string) { | |||
| 125 | + | j := s.j | |||
| 126 | + | j.issuesMu.Lock() | |||
| 127 | + | defer j.issuesMu.Unlock() | |||
| 128 | + | if j.summaries == nil { | |||
| 129 | + | j.summaries = map[int]string{} | |||
| 130 | + | } | |||
| 131 | + | j.summaries[s.num] = markdown | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // MaskText masks text with the secrets known now. | |||
| 135 | + | func (s *stepRun) MaskText(t string) string { return s.j.mask(t) } | |||
| 136 | + | ||||
| 137 | + | // summarySink is implemented by the Sink of a step that keeps summaries. | |||
| 138 | + | type summarySink interface{ Summary(markdown string) } | |||
| 139 | + | ||||
| 140 | + | // recordOutcome keeps steps.<id>.outcome of a step for StepResults. | |||
| 141 | + | func (j *job) recordOutcome(num int, c Conclusion) { | |||
| 142 | + | j.issuesMu.Lock() | |||
| 143 | + | defer j.issuesMu.Unlock() | |||
| 144 | + | if j.outcomes == nil { | |||
| 145 | + | j.outcomes = map[int]Conclusion{} | |||
| 146 | + | } | |||
| 147 | + | j.outcomes[num] = c | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // flushStepResults hands the results of step n to the reporter, before the step | |||
| 151 | + | // completes. Called with the serialisation of the reporter not held. | |||
| 152 | + | func (j *job) flushStepResults(next Reporter, n int, c Conclusion) { | |||
| 153 | + | rr, ok := next.(StepResultReporter) | |||
| 154 | + | if !ok { | |||
| 155 | + | return | |||
| 156 | + | } | |||
| 157 | + | j.issuesMu.Lock() | |||
| 158 | + | outcome, has := j.outcomes[n] | |||
| 159 | + | summary, hasSummary := j.summaries[n] | |||
| 160 | + | delete(j.summaries, n) | |||
| 161 | + | coll := j.collectors[n] | |||
| 162 | + | j.issuesMu.Unlock() | |||
| 163 | + | if !has { | |||
| 164 | + | outcome = c | |||
| 165 | + | } | |||
| 166 | + | r := StepResults{Outcome: outcome} | |||
| 167 | + | if coll != nil && j.cfg.SendAnnotations { | |||
| 168 | + | r.Annotations = coll.result() | |||
| 169 | + | } | |||
| 170 | + | if hasSummary && j.cfg.SendSummaries { | |||
| 171 | + | r.Summary, r.HasSummary = summary, true | |||
| 172 | + | } | |||
| 173 | + | rr.StepResults(n, r) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // logCanceled logs the cancellation of the process of a step | |||
| 177 | + | // (StepsRunner.cs:333-337 logs the exception of a cancelled step), unless the | |||
| 178 | + | // step timed out: then StepsRunner logs the timeout message instead. | |||
| 179 | + | func (s *stepRun) logCanceled() { | |||
| 180 | + | if s.timedOut.Load() && !s.j.cancelRequested() { | |||
| 181 | + | return | |||
| 182 | + | } | |||
| 183 | + | s.j.errorf(s.num, "The operation was canceled.") | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func (j *job) cancelRequested() bool { | |||
| 187 | + | select { | |||
| 188 | + | case <-j.cancel.Done(): | |||
| 189 | + | return true | |||
| 190 | + | default: | |||
| 191 | + | return false | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // ---- timeout-minutes | |||
| 196 | + | ||||
| 197 | + | // timedOutFlag is the marker of a step that reached its timeout. | |||
| 198 | + | type timedOutFlag = atomic.Bool | |||
| 199 | + | ||||
| 200 | + | // stepTimeoutMessage is StepsRunner.cs:328. | |||
| 201 | + | func stepTimeoutMessage(name string, minutes int) string { | |||
| 202 | + | return fmt.Sprintf("The action '%s' has timed out after %d minutes.", name, minutes) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // ---- step summary | |||
| 206 | + | ||||
| 207 | + | // summaryContent is what CreateStepSummaryCommand uploads: the lines of the | |||
| 208 | + | // file, each masked, each ended by a line feed (StreamReader.ReadLine and | |||
| 209 | + | // StreamWriter.WriteLine; ProcessCommand, FileCommandManager.cs:253-261). | |||
| 210 | + | func summaryContent(text string, mask func(string) string) string { | |||
| 211 | + | var sb strings.Builder | |||
| 212 | + | for _, l := range splitLines(text) { | |||
| 213 | + | sb.WriteString(mask(l)) | |||
| 214 | + | sb.WriteByte('\n') | |||
| 215 | + | } | |||
| 216 | + | return sb.String() | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // maxStepTimeoutMinutes bounds the timeout so that the duration fits. | |||
| 220 | + | const maxStepTimeoutMinutes = 1 << 24 | |||
| 221 | + | ||||
| 222 | + | // stepTimeout is PipelineTemplateEvaluator.EvaluateStepTimeout and | |||
| 223 | + | // ConvertToStepTimeout: the number is truncated to an int; zero, negative and | |||
| 224 | + | // absent values mean no timeout. An expression that fails logs the runner's | |||
| 225 | + | // two errors and the step runs without timeout (StepsRunner.cs:266-273). | |||
| 226 | + | func (j *job) stepTimeout(s *stepRun) int { | |||
| 227 | + | tok := s.st.TimeoutMinutes | |||
| 228 | + | if tok == nil { | |||
| 229 | + | return 0 | |||
| 230 | + | } | |||
| 231 | + | j.mu.Lock() | |||
| 232 | + | f, err := template.EvaluateNumber(context.Background(), tok, j.evalEnv(s.env)) | |||
| 233 | + | j.mu.Unlock() | |||
| 234 | + | if err != nil { | |||
| 235 | + | j.errorf(s.num, "An error occurred when attempting to determine the step timeout.") | |||
| 236 | + | j.errorf(s.num, "%s", templateNotValid(err, true)) | |||
| 237 | + | return 0 | |||
| 238 | + | } | |||
| 239 | + | switch { | |||
| 240 | + | case f != f || f < 1: | |||
| 241 | + | return 0 | |||
| 242 | + | case f > maxStepTimeoutMinutes: | |||
| 243 | + | return maxStepTimeoutMinutes | |||
| 244 | + | } | |||
| 245 | + | return int(f) | |||
| 246 | + | } | |||
A internal/executor/results_test.go +325 -0
| @@ -0,0 +1,325 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const matcherSetup = ` - name: prepare | |
| 13 | + | run: | | |
| 14 | + | mkdir -p src | |
| 15 | + | git init -q . | |
| 16 | + | git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" | |
| 17 | + | echo x > src/main.go | |
| 18 | + | cat > m.json <<'JSON' | |
| 19 | + | {"problemMatcher":[{"owner":"go","severity":"warning","pattern":[{"regexp":"^(.+):(\\d+):(\\d+): (error|warning)?: ?(.*)$","file":1,"line":2,"column":3,"severity":4,"message":5}]}]} | |
| 20 | + | JSON | |
| 21 | + | ` | |
| 22 | + | ||
| 23 | + | // The workspace of runLocal is hello/hello under the home; the setup step | |
| 24 | + | // runs in it, so the repository check of OutputManager.GetRepositoryPath finds | |
| 25 | + | // .git/config there. | |
| 26 | + | func TestProblemMatcherEndToEnd(t *testing.T) { | |
| 27 | + | needTools(t, "bash", "git") | |
| 28 | + | rr, res := runResults(t, wfHead+` steps: | |
| 29 | + | `+matcherSetup+` - name: match | |
| 30 | + | run: | | |
| 31 | + | echo "::add-matcher::m.json" | |
| 32 | + | echo "src/main.go:12:5: error: bad thing" | |
| 33 | + | echo "src/main.go:7:1: : just a warning" | |
| 34 | + | echo "src/missing.go:1:1: error: no such file" | |
| 35 | + | echo "nothing here" | |
| 36 | + | echo "::remove-matcher owner=go::" | |
| 37 | + | echo "src/main.go:12:5: error: not matched" | |
| 38 | + | `, nil) | |
| 39 | + | if res.Conclusion != ConclusionSuccess { | |
| 40 | + | t.Fatalf("%+v", res) | |
| 41 | + | } | |
| 42 | + | got := rr.results[3].Annotations | |
| 43 | + | want := []Annotation{ | |
| 44 | + | {Level: LevelFailure, Message: "bad thing", File: "src/main.go", StartLine: 12, StartColumn: 5}, | |
| 45 | + | {Level: LevelWarning, Message: "just a warning", File: "src/main.go", StartLine: 7, StartColumn: 1}, | |
| 46 | + | {Level: LevelFailure, Message: "no such file", StartLine: 1, StartColumn: 1}, // the file does not exist: dropped | |
| 47 | + | } | |
| 48 | + | if fmt.Sprint(got) != fmt.Sprint(want) { | |
| 49 | + | t.Errorf("annotations\n got %+v\nwant %+v", got, want) | |
| 50 | + | } | |
| 51 | + | log := logOf(rr.step(t, "match")) | |
| 52 | + | for _, w := range []string{"##[error]src/main.go:12:5: error: bad thing", "##[warning]src/main.go:7:1: : just a warning", "\nnothing here\n", "\nsrc/main.go:12:5: error: not matched"} { | |
| 53 | + | if !strings.Contains(log+"\n", w) { | |
| 54 | + | t.Errorf("log lacks %q:\n%s", w, log) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | // the matched lines are not logged a second time as plain text | |
| 58 | + | _, body, _ := strings.Cut(log, "##[endgroup]\n") | |
| 59 | + | if strings.Count(body, "bad thing") != 1 { | |
| 60 | + | t.Errorf("a matched line is logged twice:\n%s", log) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // AC6b through a whole step: the catastrophic matcher is retried 3 times (one | |
| 65 | + | // second each), removed with the runner's warning, and the step goes on; the | |
| 66 | + | // other matcher keeps working. OutputManager.Remove only edits the list of the | |
| 67 | + | // step (OutputManager.cs:300-313) while the next step starts from the matchers | |
| 68 | + | // of the job (ExecutionContext.GetMatchers), so the next step meets the | |
| 69 | + | // matcher again and removes it again after 3 more timeouts. | |
| 70 | + | func TestCatastrophicMatcherIsRemovedAfterThreeTimeouts(t *testing.T) { | |
| 71 | + | needTools(t, "bash") | |
| 72 | + | start := time.Now() | |
| 73 | + | rr, res := runResults(t, wfHead+` steps: | |
| 74 | + | - name: prepare | |
| 75 | + | run: | | |
| 76 | + | cat > bad.json <<'JSON' | |
| 77 | + | {"problemMatcher":[{"owner":"catastrophic","pattern":[{"regexp":"^((a+)+)$","message":1}]}]} | |
| 78 | + | JSON | |
| 79 | + | cat > good.json <<'JSON' | |
| 80 | + | {"problemMatcher":[{"owner":"good","severity":"warning","pattern":[{"regexp":"^good: (.+)$","message":1}]}]} | |
| 81 | + | JSON | |
| 82 | + | - name: catastrophic | |
| 83 | + | run: | | |
| 84 | + | echo "::add-matcher::bad.json" | |
| 85 | + | echo "::add-matcher::good.json" | |
| 86 | + | echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" | |
| 87 | + | echo "good: still alive" | |
| 88 | + | echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" | |
| 89 | + | - name: next step | |
| 90 | + | run: | | |
| 91 | + | echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" | |
| 92 | + | echo "good: next step" | |
| 93 | + | `, nil) | |
| 94 | + | took := time.Since(start) | |
| 95 | + | if res.Conclusion != ConclusionSuccess { | |
| 96 | + | t.Fatalf("%+v", res) | |
| 97 | + | } | |
| 98 | + | if took > 15*time.Second { | |
| 99 | + | t.Errorf("the step stalled: %v", took) | |
| 100 | + | } | |
| 101 | + | log := logOf(rr.step(t, "catastrophic")) | |
| 102 | + | const removing = "##[warning]Removing issue matcher 'catastrophic'. Matcher failed 3 times. Error: The Regex engine has timed out while trying to match a pattern to an input string." | |
| 103 | + | if strings.Count(log, removing) != 1 { | |
| 104 | + | t.Errorf("removal message missing or repeated:\n%s", log) | |
| 105 | + | } | |
| 106 | + | if !strings.Contains(log, "\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!\n##[warning]good: still alive") && !strings.Contains(log, "##[warning]good: still alive") { | |
| 107 | + | t.Errorf("the good matcher stopped working:\n%s", log) | |
| 108 | + | } | |
| 109 | + | // Only one removal: the second long line did not time out again. | |
| 110 | + | if n := len(rr.results[3].Annotations); n != 2 { | |
| 111 | + | t.Errorf("%d annotations: %+v", n, rr.results[3].Annotations) | |
| 112 | + | } | |
| 113 | + | next := logOf(rr.step(t, "next step")) | |
| 114 | + | if strings.Count(next, "Removing issue matcher 'catastrophic'") != 1 || !strings.Contains(next, "##[warning]good: next step") { | |
| 115 | + | t.Errorf("next step:\n%s", next) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestMatcherTimeoutFromEnvironment(t *testing.T) { | |
| 120 | + | needTools(t, "bash") | |
| 121 | + | start := time.Now() | |
| 122 | + | rr, _ := runResults(t, wfHead+` steps: | |
| 123 | + | - name: fast | |
| 124 | + | run: | | |
| 125 | + | echo '{"problemMatcher":[{"owner":"c","pattern":[{"regexp":"^((a+)+)$","message":1}]}]}' > bad.json | |
| 126 | + | echo "::add-matcher::bad.json" | |
| 127 | + | echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" | |
| 128 | + | `, func(c *Config) { | |
| 129 | + | c.Getenv = func(k string) string { return map[string]string{matcherTimeoutKey: "0:0:0.05"}[k] } | |
| 130 | + | }) | |
| 131 | + | if time.Since(start) > 2500*time.Millisecond { | |
| 132 | + | t.Errorf("the timeout of the environment was not used: %v", time.Since(start)) | |
| 133 | + | } | |
| 134 | + | if !strings.Contains(logOf(rr.step(t, "fast")), "Removing issue matcher 'c'") { | |
| 135 | + | t.Error(logOf(rr.step(t, "fast"))) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestAddMatcherErrors(t *testing.T) { | |
| 140 | + | needTools(t, "bash") | |
| 141 | + | rr, res := runResults(t, wfHead+` steps: | |
| 142 | + | - name: bad | |
| 143 | + | run: | | |
| 144 | + | echo "::add-matcher::" | |
| 145 | + | echo "::remove-matcher::" | |
| 146 | + | echo "::remove-matcher owner=x::file.json" | |
| 147 | + | echo "::add-matcher::missing.json" | |
| 148 | + | - name: invalid | |
| 149 | + | if: always() | |
| 150 | + | run: | | |
| 151 | + | echo '{"problemMatcher":[{"owner":"m","pattern":[{"regexp":"(","message":1}]}]}' > b.json | |
| 152 | + | echo "::add-matcher::b.json" | |
| 153 | + | `, nil) | |
| 154 | + | if res.Conclusion != ConclusionFailure { | |
| 155 | + | t.Fatalf("%+v", res) | |
| 156 | + | } | |
| 157 | + | log := logOf(rr.step(t, "bad")) | |
| 158 | + | for _, w := range []string{ | |
| 159 | + | "##[warning]File path must be specified.", | |
| 160 | + | "##[warning]Either an owner name or a file path must be specified in ##[remove-matcher] command.", | |
| 161 | + | "##[warning]Either specify an owner name or a file path in ##[remove-matcher] command. Both values cannot be set.", | |
| 162 | + | "##[error]Unable to process command '::add-matcher::missing.json' successfully.", | |
| 163 | + | "##[error]Could not find file '", | |
| 164 | + | } { | |
| 165 | + | if !strings.Contains(log, w) { | |
| 166 | + | t.Errorf("log lacks %q:\n%s", w, log) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | if !strings.Contains(logOf(rr.step(t, "invalid")), "##[error]Unable to process command '::add-matcher::b.json' successfully.") { | |
| 170 | + | t.Error(logOf(rr.step(t, "invalid"))) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestStepSummary(t *testing.T) { | |
| 175 | + | needTools(t, "bash") | |
| 176 | + | rr, _ := runResults(t, wfHead+` steps: | |
| 177 | + | - name: write | |
| 178 | + | run: | | |
| 179 | + | echo "::add-mask::sekret-value" | |
| 180 | + | printf '# Title\nvalue sekret-value\r\nlast line without newline' >> "$GITHUB_STEP_SUMMARY" | |
| 181 | + | - name: empty | |
| 182 | + | run: 'true' | |
| 183 | + | - name: huge | |
| 184 | + | run: head -c 1048577 /dev/zero | tr '\0' x >> "$GITHUB_STEP_SUMMARY" | |
| 185 | + | - name: exactly one MiB | |
| 186 | + | run: head -c 1048576 /dev/zero | tr '\0' x >> "$GITHUB_STEP_SUMMARY" | |
| 187 | + | `, nil) | |
| 188 | + | if r := rr.results[2]; !r.HasSummary || r.Summary != "# Title\nvalue ***\nlast line without newline\n" { | |
| 189 | + | t.Errorf("summary %q %v", r.Summary, r.HasSummary) | |
| 190 | + | } | |
| 191 | + | if rr.results[3].HasSummary { | |
| 192 | + | t.Error("no file content, no summary") | |
| 193 | + | } | |
| 194 | + | if rr.results[4].HasSummary { | |
| 195 | + | t.Error("an oversized summary is not sent") | |
| 196 | + | } | |
| 197 | + | if !strings.Contains(logOf(rr.step(t, "huge")), "##[error]$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of 1024k, got 1024k.") { | |
| 198 | + | t.Errorf("%s", logOf(rr.step(t, "huge"))[:200]) | |
| 199 | + | } | |
| 200 | + | // 1 MiB of x plus one line feed per line is over the limit once rewritten: | |
| 201 | + | // the runner adds the "\n"; the contract allows 1 MiB. | |
| 202 | + | if r := rr.results[5]; r.HasSummary && len(r.Summary) > stepSummaryLimit { | |
| 203 | + | t.Errorf("a summary above the contract limit is sent: %d", len(r.Summary)) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func TestSummaryOffWithoutTheFeature(t *testing.T) { | |
| 208 | + | needTools(t, "bash") | |
| 209 | + | rr, _ := runResults(t, wfHead+" steps:\n - run: echo hi >> \"$GITHUB_STEP_SUMMARY\"\n", func(c *Config) { c.SendSummaries = false }) | |
| 210 | + | if rr.results[2].HasSummary { | |
| 211 | + | t.Error("summary sent without ci.summaries") | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestStepOutcome(t *testing.T) { | |
| 216 | + | needTools(t, "bash") | |
| 217 | + | rr, res := runResults(t, wfHead+` steps: | |
| 218 | + | - run: exit 1 | |
| 219 | + | continue-on-error: true | |
| 220 | + | - run: 'true' | |
| 221 | + | - run: exit 2 | |
| 222 | + | `, nil) | |
| 223 | + | if res.Conclusion != ConclusionFailure { | |
| 224 | + | t.Fatal(res) | |
| 225 | + | } | |
| 226 | + | if r := rr.results[2]; r.Outcome != ConclusionFailure || rr.steps[1].Conclusion != ConclusionSuccess { | |
| 227 | + | t.Errorf("continue-on-error: outcome %v conclusion %v", r.Outcome, rr.steps[1].Conclusion) | |
| 228 | + | } | |
| 229 | + | if r := rr.results[3]; r.Outcome != ConclusionSuccess { | |
| 230 | + | t.Errorf("%+v", r) | |
| 231 | + | } | |
| 232 | + | if r := rr.results[4]; r.Outcome != ConclusionFailure { | |
| 233 | + | t.Errorf("%+v", r) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestStepTimeout(t *testing.T) { | |
| 238 | + | needTools(t, "bash") | |
| 239 | + | skew := -(60*time.Second - 700*time.Millisecond) // one minute becomes 0.7 s | |
| 240 | + | rr, res := runResults(t, wfHead+` steps: | |
| 241 | + | - name: slow | |
| 242 | + | id: slow | |
| 243 | + | timeout-minutes: 1 | |
| 244 | + | run: | | |
| 245 | + | trap 'echo got-int; exit 130' INT | |
| 246 | + | echo "before=written" >> "$GITHUB_OUTPUT" | |
| 247 | + | sleep 30 & wait | |
| 248 | + | - name: after success | |
| 249 | + | run: echo "ran after the timeout" | |
| 250 | + | - name: after failure | |
| 251 | + | if: failure() | |
| 252 | + | run: echo "outcome=${{ steps.slow.outcome }} conclusion=${{ steps.slow.conclusion }} status=${{ job.status }} out=${{ steps.slow.outputs.before }}" | |
| 253 | + | - name: cancelled() | |
| 254 | + | if: cancelled() | |
| 255 | + | run: echo "cancelled is true" | |
| 256 | + | - name: soft | |
| 257 | + | id: soft | |
| 258 | + | timeout-minutes: 1 | |
| 259 | + | continue-on-error: true | |
| 260 | + | if: always() | |
| 261 | + | run: sleep 30 | |
| 262 | + | - name: results | |
| 263 | + | if: always() | |
| 264 | + | run: echo "soft=${{ steps.soft.outcome }}/${{ steps.soft.conclusion }}" | |
| 265 | + | - name: fractional | |
| 266 | + | timeout-minutes: 0.9 | |
| 267 | + | run: sleep 1 | |
| 268 | + | - name: expression | |
| 269 | + | timeout-minutes: ${{ fromJSON('0') }} | |
| 270 | + | run: echo no timeout | |
| 271 | + | `, func(c *Config) { c.StepTimeoutSkew = skew }) | |
| 272 | + | if res.Conclusion != ConclusionFailure { | |
| 273 | + | t.Fatalf("%+v", res) | |
| 274 | + | } | |
| 275 | + | slow := rr.step(t, "slow") | |
| 276 | + | if slow.Conclusion != ConclusionFailure { | |
| 277 | + | t.Errorf("conclusion %v", slow.Conclusion) | |
| 278 | + | } | |
| 279 | + | log := logOf(slow) | |
| 280 | + | if !strings.Contains(log, "##[error]The action 'slow' has timed out after 1 minutes.") || strings.Contains(log, "The operation was canceled.") { | |
| 281 | + | t.Errorf("log:\n%s", log) | |
| 282 | + | } | |
| 283 | + | if !strings.Contains(log, "got-int") { | |
| 284 | + | t.Errorf("the step did not get SIGINT first:\n%s", log) | |
| 285 | + | } | |
| 286 | + | if a := rr.results[2].Annotations; len(a) == 0 || a[len(a)-1].Message != "The action 'slow' has timed out after 1 minutes." { | |
| 287 | + | t.Errorf("annotations %+v", a) | |
| 288 | + | } | |
| 289 | + | if rr.results[2].Outcome != ConclusionFailure { | |
| 290 | + | t.Error("outcome") | |
| 291 | + | } | |
| 292 | + | if rr.step(t, "after success").Conclusion != ConclusionSkipped { | |
| 293 | + | t.Error("success() is false after a timeout") | |
| 294 | + | } | |
| 295 | + | if l := logOf(rr.step(t, "after failure")); !strings.HasSuffix(l, "outcome=failure conclusion=failure status=failure out=written") { | |
| 296 | + | t.Errorf("after failure: %s", l) | |
| 297 | + | } | |
| 298 | + | if rr.step(t, "cancelled()").Conclusion != ConclusionSkipped { | |
| 299 | + | t.Error("a step timeout is not a cancellation") | |
| 300 | + | } | |
| 301 | + | if l := logOf(rr.step(t, "results")); !strings.HasSuffix(l, "soft=failure/success") { | |
| 302 | + | t.Errorf("results: %s", l) | |
| 303 | + | } | |
| 304 | + | if rr.step(t, "fractional").Conclusion != ConclusionSkipped && rr.step(t, "fractional").Conclusion != ConclusionSuccess { | |
| 305 | + | t.Error("fractional") | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func TestStepTimeoutExpressionError(t *testing.T) { | |
| 310 | + | needTools(t, "bash") | |
| 311 | + | rr, res := runResults(t, wfHead+` steps: | |
| 312 | + | - name: bad | |
| 313 | + | env: | |
| 314 | + | LIMIT: abc | |
| 315 | + | timeout-minutes: ${{ env.LIMIT }} | |
| 316 | + | run: echo ran | |
| 317 | + | `, nil) | |
| 318 | + | log := logOf(rr.step(t, "bad")) | |
| 319 | + | if !strings.Contains(log, "##[error]An error occurred when attempting to determine the step timeout.") || !strings.Contains(log, "\nran") { | |
| 320 | + | t.Errorf("%s", log) | |
| 321 | + | } | |
| 322 | + | if res.Conclusion != ConclusionFailure && res.Conclusion != ConclusionSuccess { | |
| 323 | + | t.Fatal(res) | |
| 324 | + | } | |
| 325 | + | } | |
| @@ -0,0 +1,325 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const matcherSetup = ` - name: prepare | |||
| 13 | + | run: | | |||
| 14 | + | mkdir -p src | |||
| 15 | + | git init -q . | |||
| 16 | + | git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" | |||
| 17 | + | echo x > src/main.go | |||
| 18 | + | cat > m.json <<'JSON' | |||
| 19 | + | {"problemMatcher":[{"owner":"go","severity":"warning","pattern":[{"regexp":"^(.+):(\\d+):(\\d+): (error|warning)?: ?(.*)$","file":1,"line":2,"column":3,"severity":4,"message":5}]}]} | |||
| 20 | + | JSON | |||
| 21 | + | ` | |||
| 22 | + | ||||
| 23 | + | // The workspace of runLocal is hello/hello under the home; the setup step | |||
| 24 | + | // runs in it, so the repository check of OutputManager.GetRepositoryPath finds | |||
| 25 | + | // .git/config there. | |||
| 26 | + | func TestProblemMatcherEndToEnd(t *testing.T) { | |||
| 27 | + | needTools(t, "bash", "git") | |||
| 28 | + | rr, res := runResults(t, wfHead+` steps: | |||
| 29 | + | `+matcherSetup+` - name: match | |||
| 30 | + | run: | | |||
| 31 | + | echo "::add-matcher::m.json" | |||
| 32 | + | echo "src/main.go:12:5: error: bad thing" | |||
| 33 | + | echo "src/main.go:7:1: : just a warning" | |||
| 34 | + | echo "src/missing.go:1:1: error: no such file" | |||
| 35 | + | echo "nothing here" | |||
| 36 | + | echo "::remove-matcher owner=go::" | |||
| 37 | + | echo "src/main.go:12:5: error: not matched" | |||
| 38 | + | `, nil) | |||
| 39 | + | if res.Conclusion != ConclusionSuccess { | |||
| 40 | + | t.Fatalf("%+v", res) | |||
| 41 | + | } | |||
| 42 | + | got := rr.results[3].Annotations | |||
| 43 | + | want := []Annotation{ | |||
| 44 | + | {Level: LevelFailure, Message: "bad thing", File: "src/main.go", StartLine: 12, StartColumn: 5}, | |||
| 45 | + | {Level: LevelWarning, Message: "just a warning", File: "src/main.go", StartLine: 7, StartColumn: 1}, | |||
| 46 | + | {Level: LevelFailure, Message: "no such file", StartLine: 1, StartColumn: 1}, // the file does not exist: dropped | |||
| 47 | + | } | |||
| 48 | + | if fmt.Sprint(got) != fmt.Sprint(want) { | |||
| 49 | + | t.Errorf("annotations\n got %+v\nwant %+v", got, want) | |||
| 50 | + | } | |||
| 51 | + | log := logOf(rr.step(t, "match")) | |||
| 52 | + | for _, w := range []string{"##[error]src/main.go:12:5: error: bad thing", "##[warning]src/main.go:7:1: : just a warning", "\nnothing here\n", "\nsrc/main.go:12:5: error: not matched"} { | |||
| 53 | + | if !strings.Contains(log+"\n", w) { | |||
| 54 | + | t.Errorf("log lacks %q:\n%s", w, log) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | // the matched lines are not logged a second time as plain text | |||
| 58 | + | _, body, _ := strings.Cut(log, "##[endgroup]\n") | |||
| 59 | + | if strings.Count(body, "bad thing") != 1 { | |||
| 60 | + | t.Errorf("a matched line is logged twice:\n%s", log) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // AC6b through a whole step: the catastrophic matcher is retried 3 times (one | |||
| 65 | + | // second each), removed with the runner's warning, and the step goes on; the | |||
| 66 | + | // other matcher keeps working. OutputManager.Remove only edits the list of the | |||
| 67 | + | // step (OutputManager.cs:300-313) while the next step starts from the matchers | |||
| 68 | + | // of the job (ExecutionContext.GetMatchers), so the next step meets the | |||
| 69 | + | // matcher again and removes it again after 3 more timeouts. | |||
| 70 | + | func TestCatastrophicMatcherIsRemovedAfterThreeTimeouts(t *testing.T) { | |||
| 71 | + | needTools(t, "bash") | |||
| 72 | + | start := time.Now() | |||
| 73 | + | rr, res := runResults(t, wfHead+` steps: | |||
| 74 | + | - name: prepare | |||
| 75 | + | run: | | |||
| 76 | + | cat > bad.json <<'JSON' | |||
| 77 | + | {"problemMatcher":[{"owner":"catastrophic","pattern":[{"regexp":"^((a+)+)$","message":1}]}]} | |||
| 78 | + | JSON | |||
| 79 | + | cat > good.json <<'JSON' | |||
| 80 | + | {"problemMatcher":[{"owner":"good","severity":"warning","pattern":[{"regexp":"^good: (.+)$","message":1}]}]} | |||
| 81 | + | JSON | |||
| 82 | + | - name: catastrophic | |||
| 83 | + | run: | | |||
| 84 | + | echo "::add-matcher::bad.json" | |||
| 85 | + | echo "::add-matcher::good.json" | |||
| 86 | + | echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" | |||
| 87 | + | echo "good: still alive" | |||
| 88 | + | echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" | |||
| 89 | + | - name: next step | |||
| 90 | + | run: | | |||
| 91 | + | echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" | |||
| 92 | + | echo "good: next step" | |||
| 93 | + | `, nil) | |||
| 94 | + | took := time.Since(start) | |||
| 95 | + | if res.Conclusion != ConclusionSuccess { | |||
| 96 | + | t.Fatalf("%+v", res) | |||
| 97 | + | } | |||
| 98 | + | if took > 15*time.Second { | |||
| 99 | + | t.Errorf("the step stalled: %v", took) | |||
| 100 | + | } | |||
| 101 | + | log := logOf(rr.step(t, "catastrophic")) | |||
| 102 | + | const removing = "##[warning]Removing issue matcher 'catastrophic'. Matcher failed 3 times. Error: The Regex engine has timed out while trying to match a pattern to an input string." | |||
| 103 | + | if strings.Count(log, removing) != 1 { | |||
| 104 | + | t.Errorf("removal message missing or repeated:\n%s", log) | |||
| 105 | + | } | |||
| 106 | + | if !strings.Contains(log, "\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!\n##[warning]good: still alive") && !strings.Contains(log, "##[warning]good: still alive") { | |||
| 107 | + | t.Errorf("the good matcher stopped working:\n%s", log) | |||
| 108 | + | } | |||
| 109 | + | // Only one removal: the second long line did not time out again. | |||
| 110 | + | if n := len(rr.results[3].Annotations); n != 2 { | |||
| 111 | + | t.Errorf("%d annotations: %+v", n, rr.results[3].Annotations) | |||
| 112 | + | } | |||
| 113 | + | next := logOf(rr.step(t, "next step")) | |||
| 114 | + | if strings.Count(next, "Removing issue matcher 'catastrophic'") != 1 || !strings.Contains(next, "##[warning]good: next step") { | |||
| 115 | + | t.Errorf("next step:\n%s", next) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestMatcherTimeoutFromEnvironment(t *testing.T) { | |||
| 120 | + | needTools(t, "bash") | |||
| 121 | + | start := time.Now() | |||
| 122 | + | rr, _ := runResults(t, wfHead+` steps: | |||
| 123 | + | - name: fast | |||
| 124 | + | run: | | |||
| 125 | + | echo '{"problemMatcher":[{"owner":"c","pattern":[{"regexp":"^((a+)+)$","message":1}]}]}' > bad.json | |||
| 126 | + | echo "::add-matcher::bad.json" | |||
| 127 | + | echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" | |||
| 128 | + | `, func(c *Config) { | |||
| 129 | + | c.Getenv = func(k string) string { return map[string]string{matcherTimeoutKey: "0:0:0.05"}[k] } | |||
| 130 | + | }) | |||
| 131 | + | if time.Since(start) > 2500*time.Millisecond { | |||
| 132 | + | t.Errorf("the timeout of the environment was not used: %v", time.Since(start)) | |||
| 133 | + | } | |||
| 134 | + | if !strings.Contains(logOf(rr.step(t, "fast")), "Removing issue matcher 'c'") { | |||
| 135 | + | t.Error(logOf(rr.step(t, "fast"))) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestAddMatcherErrors(t *testing.T) { | |||
| 140 | + | needTools(t, "bash") | |||
| 141 | + | rr, res := runResults(t, wfHead+` steps: | |||
| 142 | + | - name: bad | |||
| 143 | + | run: | | |||
| 144 | + | echo "::add-matcher::" | |||
| 145 | + | echo "::remove-matcher::" | |||
| 146 | + | echo "::remove-matcher owner=x::file.json" | |||
| 147 | + | echo "::add-matcher::missing.json" | |||
| 148 | + | - name: invalid | |||
| 149 | + | if: always() | |||
| 150 | + | run: | | |||
| 151 | + | echo '{"problemMatcher":[{"owner":"m","pattern":[{"regexp":"(","message":1}]}]}' > b.json | |||
| 152 | + | echo "::add-matcher::b.json" | |||
| 153 | + | `, nil) | |||
| 154 | + | if res.Conclusion != ConclusionFailure { | |||
| 155 | + | t.Fatalf("%+v", res) | |||
| 156 | + | } | |||
| 157 | + | log := logOf(rr.step(t, "bad")) | |||
| 158 | + | for _, w := range []string{ | |||
| 159 | + | "##[warning]File path must be specified.", | |||
| 160 | + | "##[warning]Either an owner name or a file path must be specified in ##[remove-matcher] command.", | |||
| 161 | + | "##[warning]Either specify an owner name or a file path in ##[remove-matcher] command. Both values cannot be set.", | |||
| 162 | + | "##[error]Unable to process command '::add-matcher::missing.json' successfully.", | |||
| 163 | + | "##[error]Could not find file '", | |||
| 164 | + | } { | |||
| 165 | + | if !strings.Contains(log, w) { | |||
| 166 | + | t.Errorf("log lacks %q:\n%s", w, log) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | if !strings.Contains(logOf(rr.step(t, "invalid")), "##[error]Unable to process command '::add-matcher::b.json' successfully.") { | |||
| 170 | + | t.Error(logOf(rr.step(t, "invalid"))) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestStepSummary(t *testing.T) { | |||
| 175 | + | needTools(t, "bash") | |||
| 176 | + | rr, _ := runResults(t, wfHead+` steps: | |||
| 177 | + | - name: write | |||
| 178 | + | run: | | |||
| 179 | + | echo "::add-mask::sekret-value" | |||
| 180 | + | printf '# Title\nvalue sekret-value\r\nlast line without newline' >> "$GITHUB_STEP_SUMMARY" | |||
| 181 | + | - name: empty | |||
| 182 | + | run: 'true' | |||
| 183 | + | - name: huge | |||
| 184 | + | run: head -c 1048577 /dev/zero | tr '\0' x >> "$GITHUB_STEP_SUMMARY" | |||
| 185 | + | - name: exactly one MiB | |||
| 186 | + | run: head -c 1048576 /dev/zero | tr '\0' x >> "$GITHUB_STEP_SUMMARY" | |||
| 187 | + | `, nil) | |||
| 188 | + | if r := rr.results[2]; !r.HasSummary || r.Summary != "# Title\nvalue ***\nlast line without newline\n" { | |||
| 189 | + | t.Errorf("summary %q %v", r.Summary, r.HasSummary) | |||
| 190 | + | } | |||
| 191 | + | if rr.results[3].HasSummary { | |||
| 192 | + | t.Error("no file content, no summary") | |||
| 193 | + | } | |||
| 194 | + | if rr.results[4].HasSummary { | |||
| 195 | + | t.Error("an oversized summary is not sent") | |||
| 196 | + | } | |||
| 197 | + | if !strings.Contains(logOf(rr.step(t, "huge")), "##[error]$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of 1024k, got 1024k.") { | |||
| 198 | + | t.Errorf("%s", logOf(rr.step(t, "huge"))[:200]) | |||
| 199 | + | } | |||
| 200 | + | // 1 MiB of x plus one line feed per line is over the limit once rewritten: | |||
| 201 | + | // the runner adds the "\n"; the contract allows 1 MiB. | |||
| 202 | + | if r := rr.results[5]; r.HasSummary && len(r.Summary) > stepSummaryLimit { | |||
| 203 | + | t.Errorf("a summary above the contract limit is sent: %d", len(r.Summary)) | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func TestSummaryOffWithoutTheFeature(t *testing.T) { | |||
| 208 | + | needTools(t, "bash") | |||
| 209 | + | rr, _ := runResults(t, wfHead+" steps:\n - run: echo hi >> \"$GITHUB_STEP_SUMMARY\"\n", func(c *Config) { c.SendSummaries = false }) | |||
| 210 | + | if rr.results[2].HasSummary { | |||
| 211 | + | t.Error("summary sent without ci.summaries") | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func TestStepOutcome(t *testing.T) { | |||
| 216 | + | needTools(t, "bash") | |||
| 217 | + | rr, res := runResults(t, wfHead+` steps: | |||
| 218 | + | - run: exit 1 | |||
| 219 | + | continue-on-error: true | |||
| 220 | + | - run: 'true' | |||
| 221 | + | - run: exit 2 | |||
| 222 | + | `, nil) | |||
| 223 | + | if res.Conclusion != ConclusionFailure { | |||
| 224 | + | t.Fatal(res) | |||
| 225 | + | } | |||
| 226 | + | if r := rr.results[2]; r.Outcome != ConclusionFailure || rr.steps[1].Conclusion != ConclusionSuccess { | |||
| 227 | + | t.Errorf("continue-on-error: outcome %v conclusion %v", r.Outcome, rr.steps[1].Conclusion) | |||
| 228 | + | } | |||
| 229 | + | if r := rr.results[3]; r.Outcome != ConclusionSuccess { | |||
| 230 | + | t.Errorf("%+v", r) | |||
| 231 | + | } | |||
| 232 | + | if r := rr.results[4]; r.Outcome != ConclusionFailure { | |||
| 233 | + | t.Errorf("%+v", r) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func TestStepTimeout(t *testing.T) { | |||
| 238 | + | needTools(t, "bash") | |||
| 239 | + | skew := -(60*time.Second - 700*time.Millisecond) // one minute becomes 0.7 s | |||
| 240 | + | rr, res := runResults(t, wfHead+` steps: | |||
| 241 | + | - name: slow | |||
| 242 | + | id: slow | |||
| 243 | + | timeout-minutes: 1 | |||
| 244 | + | run: | | |||
| 245 | + | trap 'echo got-int; exit 130' INT | |||
| 246 | + | echo "before=written" >> "$GITHUB_OUTPUT" | |||
| 247 | + | sleep 30 & wait | |||
| 248 | + | - name: after success | |||
| 249 | + | run: echo "ran after the timeout" | |||
| 250 | + | - name: after failure | |||
| 251 | + | if: failure() | |||
| 252 | + | run: echo "outcome=${{ steps.slow.outcome }} conclusion=${{ steps.slow.conclusion }} status=${{ job.status }} out=${{ steps.slow.outputs.before }}" | |||
| 253 | + | - name: cancelled() | |||
| 254 | + | if: cancelled() | |||
| 255 | + | run: echo "cancelled is true" | |||
| 256 | + | - name: soft | |||
| 257 | + | id: soft | |||
| 258 | + | timeout-minutes: 1 | |||
| 259 | + | continue-on-error: true | |||
| 260 | + | if: always() | |||
| 261 | + | run: sleep 30 | |||
| 262 | + | - name: results | |||
| 263 | + | if: always() | |||
| 264 | + | run: echo "soft=${{ steps.soft.outcome }}/${{ steps.soft.conclusion }}" | |||
| 265 | + | - name: fractional | |||
| 266 | + | timeout-minutes: 0.9 | |||
| 267 | + | run: sleep 1 | |||
| 268 | + | - name: expression | |||
| 269 | + | timeout-minutes: ${{ fromJSON('0') }} | |||
| 270 | + | run: echo no timeout | |||
| 271 | + | `, func(c *Config) { c.StepTimeoutSkew = skew }) | |||
| 272 | + | if res.Conclusion != ConclusionFailure { | |||
| 273 | + | t.Fatalf("%+v", res) | |||
| 274 | + | } | |||
| 275 | + | slow := rr.step(t, "slow") | |||
| 276 | + | if slow.Conclusion != ConclusionFailure { | |||
| 277 | + | t.Errorf("conclusion %v", slow.Conclusion) | |||
| 278 | + | } | |||
| 279 | + | log := logOf(slow) | |||
| 280 | + | if !strings.Contains(log, "##[error]The action 'slow' has timed out after 1 minutes.") || strings.Contains(log, "The operation was canceled.") { | |||
| 281 | + | t.Errorf("log:\n%s", log) | |||
| 282 | + | } | |||
| 283 | + | if !strings.Contains(log, "got-int") { | |||
| 284 | + | t.Errorf("the step did not get SIGINT first:\n%s", log) | |||
| 285 | + | } | |||
| 286 | + | if a := rr.results[2].Annotations; len(a) == 0 || a[len(a)-1].Message != "The action 'slow' has timed out after 1 minutes." { | |||
| 287 | + | t.Errorf("annotations %+v", a) | |||
| 288 | + | } | |||
| 289 | + | if rr.results[2].Outcome != ConclusionFailure { | |||
| 290 | + | t.Error("outcome") | |||
| 291 | + | } | |||
| 292 | + | if rr.step(t, "after success").Conclusion != ConclusionSkipped { | |||
| 293 | + | t.Error("success() is false after a timeout") | |||
| 294 | + | } | |||
| 295 | + | if l := logOf(rr.step(t, "after failure")); !strings.HasSuffix(l, "outcome=failure conclusion=failure status=failure out=written") { | |||
| 296 | + | t.Errorf("after failure: %s", l) | |||
| 297 | + | } | |||
| 298 | + | if rr.step(t, "cancelled()").Conclusion != ConclusionSkipped { | |||
| 299 | + | t.Error("a step timeout is not a cancellation") | |||
| 300 | + | } | |||
| 301 | + | if l := logOf(rr.step(t, "results")); !strings.HasSuffix(l, "soft=failure/success") { | |||
| 302 | + | t.Errorf("results: %s", l) | |||
| 303 | + | } | |||
| 304 | + | if rr.step(t, "fractional").Conclusion != ConclusionSkipped && rr.step(t, "fractional").Conclusion != ConclusionSuccess { | |||
| 305 | + | t.Error("fractional") | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func TestStepTimeoutExpressionError(t *testing.T) { | |||
| 310 | + | needTools(t, "bash") | |||
| 311 | + | rr, res := runResults(t, wfHead+` steps: | |||
| 312 | + | - name: bad | |||
| 313 | + | env: | |||
| 314 | + | LIMIT: abc | |||
| 315 | + | timeout-minutes: ${{ env.LIMIT }} | |||
| 316 | + | run: echo ran | |||
| 317 | + | `, nil) | |||
| 318 | + | log := logOf(rr.step(t, "bad")) | |||
| 319 | + | if !strings.Contains(log, "##[error]An error occurred when attempting to determine the step timeout.") || !strings.Contains(log, "\nran") { | |||
| 320 | + | t.Errorf("%s", log) | |||
| 321 | + | } | |||
| 322 | + | if res.Conclusion != ConclusionFailure && res.Conclusion != ConclusionSuccess { | |||
| 323 | + | t.Fatal(res) | |||
| 324 | + | } | |||
| 325 | + | } | |||
M internal/executor/step.go +28 -4
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "strings" | |
| 10 | 10 | "sync" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 12 | 13 | "bnema.dev/ermite-actions/expr" | |
| 13 | 14 | "bnema.dev/ermite-actions/template" | |
| @@ -26,6 +27,12 @@ type stepRun struct { | |||
| 26 | 27 | cmdResult *stepResult | |
| 27 | 28 | outcome *stepResult | |
| 28 | 29 | cmds *commandManager | |
| 30 | + | // scope is what the step shares with the step that contains it, see | |
| 31 | + | // stepScope. name is the display name; timedOut is set when the step | |
| 32 | + | // reached its timeout-minutes. | |
| 33 | + | scope stepScope | |
| 34 | + | name string | |
| 35 | + | timedOut timedOutFlag | |
| 29 | 36 | } | |
| 30 | 37 | ||
| 31 | 38 | func (s *stepRun) Out(text string) { s.j.out(s.num, text) } | |
| @@ -38,7 +45,6 @@ func (s *stepRun) SetOutput(name, value string) { | |||
| 38 | 45 | s.j.state.SetOutput(s.st.ID, name, value) | |
| 39 | 46 | s.j.mu.Unlock() | |
| 40 | 47 | } | |
| 41 | - | func (s *stepRun) SaveState(name, value string) {} | |
| 42 | 48 | func (s *stepRun) SetEnv(name, value string) { | |
| 43 | 49 | s.j.mu.Lock() | |
| 44 | 50 | s.j.state.SetEnv(name, value) | |
| @@ -240,6 +246,7 @@ func (j *job) runStep(num int, st *workflow.Step) { | |||
| 240 | 246 | } | |
| 241 | 247 | } | |
| 242 | 248 | name := j.displayName(st, s.env) | |
| 249 | + | s.name = name | |
| 243 | 250 | rep.StepStarted(num, name, j.now()) | |
| 244 | 251 | ||
| 245 | 252 | complete := func(r stepResult) { | |
| @@ -251,6 +258,7 @@ func (j *job) runStep(num int, st *workflow.Step) { | |||
| 251 | 258 | outcome = *s.outcome | |
| 252 | 259 | } | |
| 253 | 260 | j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion())) | |
| 261 | + | j.recordOutcome(num, outcome.conclusion()) | |
| 254 | 262 | if res == resFailed { | |
| 255 | 263 | m := merge(j.result, resFailed) | |
| 256 | 264 | j.result = &m | |
| @@ -303,6 +311,8 @@ func (j *job) runStep(num int, st *workflow.Step) { | |||
| 303 | 311 | // cancelled and the step is stopped unless its condition still holds | |
| 304 | 312 | // (always()). | |
| 305 | 313 | stepCancel := make(chan struct{}) | |
| 314 | + | var cancelOnce sync.Once | |
| 315 | + | closeCancel := func() { cancelOnce.Do(func() { close(stepCancel) }) } | |
| 306 | 316 | stopWatch := make(chan struct{}) | |
| 307 | 317 | var watch sync.WaitGroup | |
| 308 | 318 | if !cancelledAtStart { | |
| @@ -322,14 +332,30 @@ func (j *job) runStep(num int, st *workflow.Step) { | |||
| 322 | 332 | keep, _ = cond() | |
| 323 | 333 | } | |
| 324 | 334 | if !keep { | |
| 325 | - | close(stepCancel) | |
| 335 | + | closeCancel() | |
| 326 | 336 | } | |
| 327 | 337 | }() | |
| 328 | 338 | } | |
| 329 | 339 | ||
| 340 | + | // timeout-minutes (StepsRunner.RunStepAsync, :280-296). | |
| 341 | + | minutes := j.stepTimeout(s) | |
| 342 | + | if minutes > 0 { | |
| 343 | + | t := time.AfterFunc(time.Duration(minutes)*time.Minute+j.cfg.StepTimeoutSkew, func() { | |
| 344 | + | s.timedOut.Store(true) | |
| 345 | + | closeCancel() | |
| 346 | + | }) | |
| 347 | + | defer t.Stop() | |
| 348 | + | } | |
| 349 | + | ||
| 330 | 350 | r := j.execute(s, stepCancel) | |
| 331 | 351 | close(stopWatch) | |
| 332 | 352 | watch.Wait() | |
| 353 | + | if r == resCanceled && s.timedOut.Load() && !j.cancelRequested() { | |
| 354 | + | // The step's own token fired, not the job's: the step failed with a | |
| 355 | + | // message (StepsRunner.cs:323-330). | |
| 356 | + | j.errorf(num, "%s", stepTimeoutMessage(name, minutes)) | |
| 357 | + | r = resFailed | |
| 358 | + | } | |
| 333 | 359 | ||
| 334 | 360 | // Merge the result of the commands (StepsRunner.cs:347-351). | |
| 335 | 361 | if s.cmdResult != nil { | |
| @@ -368,8 +394,6 @@ func notSupported(st *workflow.Step) string { | |||
| 368 | 394 | switch { | |
| 369 | 395 | case st.Uses != nil: | |
| 370 | 396 | return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value) | |
| 371 | - | case st.TimeoutMinutes != nil: | |
| 372 | - | return "not supported yet: timeout-minutes" | |
| 373 | 397 | } | |
| 374 | 398 | return "" | |
| 375 | 399 | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | "sync" | 10 | "sync" | ||
| 11 | + | "time" | |||
| 11 | 12 | ||||
| 12 | "bnema.dev/ermite-actions/expr" | 13 | "bnema.dev/ermite-actions/expr" | ||
| 13 | "bnema.dev/ermite-actions/template" | 14 | "bnema.dev/ermite-actions/template" | ||
| @@ -26,6 +27,12 @@ type stepRun struct { | |||||
| 26 | cmdResult *stepResult | 27 | cmdResult *stepResult | ||
| 27 | outcome *stepResult | 28 | outcome *stepResult | ||
| 28 | cmds *commandManager | 29 | cmds *commandManager | ||
| 30 | + | // scope is what the step shares with the step that contains it, see | |||
| 31 | + | // stepScope. name is the display name; timedOut is set when the step | |||
| 32 | + | // reached its timeout-minutes. | |||
| 33 | + | scope stepScope | |||
| 34 | + | name string | |||
| 35 | + | timedOut timedOutFlag | |||
| 29 | } | 36 | } | ||
| 30 | 37 | ||||
| 31 | func (s *stepRun) Out(text string) { s.j.out(s.num, text) } | 38 | func (s *stepRun) Out(text string) { s.j.out(s.num, text) } | ||
| @@ -38,7 +45,6 @@ func (s *stepRun) SetOutput(name, value string) { | |||||
| 38 | s.j.state.SetOutput(s.st.ID, name, value) | 45 | s.j.state.SetOutput(s.st.ID, name, value) | ||
| 39 | s.j.mu.Unlock() | 46 | s.j.mu.Unlock() | ||
| 40 | } | 47 | } | ||
| 41 | - | func (s *stepRun) SaveState(name, value string) {} | |||
| 42 | func (s *stepRun) SetEnv(name, value string) { | 48 | func (s *stepRun) SetEnv(name, value string) { | ||
| 43 | s.j.mu.Lock() | 49 | s.j.mu.Lock() | ||
| 44 | s.j.state.SetEnv(name, value) | 50 | s.j.state.SetEnv(name, value) | ||
| @@ -240,6 +246,7 @@ func (j *job) runStep(num int, st *workflow.Step) { | |||||
| 240 | } | 246 | } | ||
| 241 | } | 247 | } | ||
| 242 | name := j.displayName(st, s.env) | 248 | name := j.displayName(st, s.env) | ||
| 249 | + | s.name = name | |||
| 243 | rep.StepStarted(num, name, j.now()) | 250 | rep.StepStarted(num, name, j.now()) | ||
| 244 | 251 | ||||
| 245 | complete := func(r stepResult) { | 252 | complete := func(r stepResult) { | ||
| @@ -251,6 +258,7 @@ func (j *job) runStep(num int, st *workflow.Step) { | |||||
| 251 | outcome = *s.outcome | 258 | outcome = *s.outcome | ||
| 252 | } | 259 | } | ||
| 253 | j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion())) | 260 | j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion())) | ||
| 261 | + | j.recordOutcome(num, outcome.conclusion()) | |||
| 254 | if res == resFailed { | 262 | if res == resFailed { | ||
| 255 | m := merge(j.result, resFailed) | 263 | m := merge(j.result, resFailed) | ||
| 256 | j.result = &m | 264 | j.result = &m | ||
| @@ -303,6 +311,8 @@ func (j *job) runStep(num int, st *workflow.Step) { | |||||
| 303 | // cancelled and the step is stopped unless its condition still holds | 311 | // cancelled and the step is stopped unless its condition still holds | ||
| 304 | // (always()). | 312 | // (always()). | ||
| 305 | stepCancel := make(chan struct{}) | 313 | stepCancel := make(chan struct{}) | ||
| 314 | + | var cancelOnce sync.Once | |||
| 315 | + | closeCancel := func() { cancelOnce.Do(func() { close(stepCancel) }) } | |||
| 306 | stopWatch := make(chan struct{}) | 316 | stopWatch := make(chan struct{}) | ||
| 307 | var watch sync.WaitGroup | 317 | var watch sync.WaitGroup | ||
| 308 | if !cancelledAtStart { | 318 | if !cancelledAtStart { | ||
| @@ -322,14 +332,30 @@ func (j *job) runStep(num int, st *workflow.Step) { | |||||
| 322 | keep, _ = cond() | 332 | keep, _ = cond() | ||
| 323 | } | 333 | } | ||
| 324 | if !keep { | 334 | if !keep { | ||
| 325 | - | close(stepCancel) | 335 | + | closeCancel() |
| 326 | } | 336 | } | ||
| 327 | }() | 337 | }() | ||
| 328 | } | 338 | } | ||
| 329 | 339 | ||||
| 340 | + | // timeout-minutes (StepsRunner.RunStepAsync, :280-296). | |||
| 341 | + | minutes := j.stepTimeout(s) | |||
| 342 | + | if minutes > 0 { | |||
| 343 | + | t := time.AfterFunc(time.Duration(minutes)*time.Minute+j.cfg.StepTimeoutSkew, func() { | |||
| 344 | + | s.timedOut.Store(true) | |||
| 345 | + | closeCancel() | |||
| 346 | + | }) | |||
| 347 | + | defer t.Stop() | |||
| 348 | + | } | |||
| 349 | + | ||||
| 330 | r := j.execute(s, stepCancel) | 350 | r := j.execute(s, stepCancel) | ||
| 331 | close(stopWatch) | 351 | close(stopWatch) | ||
| 332 | watch.Wait() | 352 | watch.Wait() | ||
| 353 | + | if r == resCanceled && s.timedOut.Load() && !j.cancelRequested() { | |||
| 354 | + | // The step's own token fired, not the job's: the step failed with a | |||
| 355 | + | // message (StepsRunner.cs:323-330). | |||
| 356 | + | j.errorf(num, "%s", stepTimeoutMessage(name, minutes)) | |||
| 357 | + | r = resFailed | |||
| 358 | + | } | |||
| 333 | 359 | ||||
| 334 | // Merge the result of the commands (StepsRunner.cs:347-351). | 360 | // Merge the result of the commands (StepsRunner.cs:347-351). | ||
| 335 | if s.cmdResult != nil { | 361 | if s.cmdResult != nil { | ||
| @@ -368,8 +394,6 @@ func notSupported(st *workflow.Step) string { | |||||
| 368 | switch { | 394 | switch { | ||
| 369 | case st.Uses != nil: | 395 | case st.Uses != nil: | ||
| 370 | return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value) | 396 | return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value) | ||
| 371 | - | case st.TimeoutMinutes != nil: | |||
| 372 | - | return "not supported yet: timeout-minutes" | |||
| 373 | } | 397 | } | ||
| 374 | return "" | 398 | return "" | ||
| 375 | } | 399 | } | ||