test(guest): results frames over the stream; docs: attribution for the ported code
Error texts of ported matcher validation use msgError (the runner's own wording), and THIRD_PARTY.md lists the ported L0 cases with the MIT notice.
- commit
- e59b2f074c1efad96d4225a2d1a5b95240ea4a8f
- author
- brice · · 2026-10-13 15:12 UTC
- parent
- 9116f1e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +146 -25
M README.md +4 -0
| @@ -78,6 +78,10 @@ The host name and port come from `ERMITE_RUNNER_URL` (port defaulted from the sc | |||
| 78 | 78 | ||
| 79 | 79 | The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel channel, not a context). The executor signals the script's process group like the official runner: SIGINT at once, SIGTERM after 7.5 s, SIGKILL 2.5 s later, with the timers stopped as soon as the shell exits and nothing signalled afterwards (the group id could be reused). Background children that keep the output pipes open are killed when the shell exits, after a short drain. Init forwards SIGTERM and SIGINT to the executor. | |
| 80 | 80 | ||
| 81 | + | ### Annotations, summaries and masks | |
| 82 | + | ||
| 83 | + | The executor ports the issue handling of the official runner: `::notice`, `::warning` and `::error` (with `title`, `file`, `line`, `endLine`, `col`, `endColumn`), problem matchers (`add-matcher`, `remove-matcher`, .NET regular expressions through `regexp2`, 1 s timeout, `GITHUB_ACTIONS_RUNNER_ISSUE_MATCHER_TIMEOUT`, removed after 3 timeouts) and every `##[error]` the runner itself writes become annotations, at most 10 per level per step, messages cut at 4096 characters; `GITHUB_STEP_SUMMARY` is read after each step (1 MiB). `::add-mask::` adds to the masker at run time, with the encodings of `ValueEncoders.cs`. The executor sends them to the host as `StepAnnotations` and `StepSummary` frames; the host checks the limits again, sends the annotations with the `UpdateJob` that completes the step (the list replaces the step's stored one) and the leftovers with `CompleteJob`, and the summaries with `UploadSummary`. Both are sent only when the server lists `ci.annotations` and `ci.summaries` in `GetVersion` (read once per job). Step `timeout-minutes` is enforced like `StepsRunner` does. Attribution for the ported code and tests: [THIRD_PARTY.md](THIRD_PARTY.md). | |
| 84 | + | ||
| 81 | 85 | ### Cleanup | |
| 82 | 86 | ||
| 83 | 87 | Everything a job creates (process, TAP or bridge, nftables rules, files, loop mounts, containers, networks, cgroups) is removed by `Stop`, which also runs with a cancelled context, aborts a `Start` in progress, and by `Reap` at start for what a crash left behind. A VM leaves a state record (`<state dir>/vms/<id>.json`) before its first resource. **The network policy and the TAP or bridge are never removed while the VMM or the container may still be running**: when a process cannot be killed or a container cannot be removed, `Stop` returns an error wrapping `sandbox.ErrLeaked`, the sandbox's slot stays taken, and `Driver.Reap` (which returns `sandbox.ErrBusy` while a sandbox is running) finishes the job and frees the slot. The host should stop taking jobs on a leaked slot until then. `Sandbox.ID()` names a sandbox's files and Docker objects. | |
| @@ -78,6 +78,10 @@ The host name and port come from `ERMITE_RUNNER_URL` (port defaulted from the sc | |||||
| 78 | 78 | ||||
| 79 | The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel channel, not a context). The executor signals the script's process group like the official runner: SIGINT at once, SIGTERM after 7.5 s, SIGKILL 2.5 s later, with the timers stopped as soon as the shell exits and nothing signalled afterwards (the group id could be reused). Background children that keep the output pipes open are killed when the shell exits, after a short drain. Init forwards SIGTERM and SIGINT to the executor. | 79 | The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel channel, not a context). The executor signals the script's process group like the official runner: SIGINT at once, SIGTERM after 7.5 s, SIGKILL 2.5 s later, with the timers stopped as soon as the shell exits and nothing signalled afterwards (the group id could be reused). Background children that keep the output pipes open are killed when the shell exits, after a short drain. Init forwards SIGTERM and SIGINT to the executor. | ||
| 80 | 80 | ||||
| 81 | + | ### Annotations, summaries and masks | |||
| 82 | + | ||||
| 83 | + | The executor ports the issue handling of the official runner: `::notice`, `::warning` and `::error` (with `title`, `file`, `line`, `endLine`, `col`, `endColumn`), problem matchers (`add-matcher`, `remove-matcher`, .NET regular expressions through `regexp2`, 1 s timeout, `GITHUB_ACTIONS_RUNNER_ISSUE_MATCHER_TIMEOUT`, removed after 3 timeouts) and every `##[error]` the runner itself writes become annotations, at most 10 per level per step, messages cut at 4096 characters; `GITHUB_STEP_SUMMARY` is read after each step (1 MiB). `::add-mask::` adds to the masker at run time, with the encodings of `ValueEncoders.cs`. The executor sends them to the host as `StepAnnotations` and `StepSummary` frames; the host checks the limits again, sends the annotations with the `UpdateJob` that completes the step (the list replaces the step's stored one) and the leftovers with `CompleteJob`, and the summaries with `UploadSummary`. Both are sent only when the server lists `ci.annotations` and `ci.summaries` in `GetVersion` (read once per job). Step `timeout-minutes` is enforced like `StepsRunner` does. Attribution for the ported code and tests: [THIRD_PARTY.md](THIRD_PARTY.md). | |||
| 84 | + | ||||
| 81 | ### Cleanup | 85 | ### Cleanup | ||
| 82 | 86 | ||||
| 83 | Everything a job creates (process, TAP or bridge, nftables rules, files, loop mounts, containers, networks, cgroups) is removed by `Stop`, which also runs with a cancelled context, aborts a `Start` in progress, and by `Reap` at start for what a crash left behind. A VM leaves a state record (`<state dir>/vms/<id>.json`) before its first resource. **The network policy and the TAP or bridge are never removed while the VMM or the container may still be running**: when a process cannot be killed or a container cannot be removed, `Stop` returns an error wrapping `sandbox.ErrLeaked`, the sandbox's slot stays taken, and `Driver.Reap` (which returns `sandbox.ErrBusy` while a sandbox is running) finishes the job and frees the slot. The host should stop taking jobs on a leaked slot until then. `Sandbox.ID()` names a sandbox's files and Docker objects. | 87 | Everything a job creates (process, TAP or bridge, nftables rules, files, loop mounts, containers, networks, cgroups) is removed by `Stop`, which also runs with a cancelled context, aborts a `Start` in progress, and by `Reap` at start for what a crash left behind. A VM leaves a state record (`<state dir>/vms/<id>.json`) before its first resource. **The network policy and the TAP or bridge are never removed while the VMM or the container may still be running**: when a process cannot be killed or a container cannot be removed, `Stop` returns an error wrapping `sandbox.ErrLeaked`, the sandbox's slot stays taken, and `Driver.Reap` (which returns `sandbox.ErrBusy` while a sandbox is running) finishes the job and frees the slot. The host should stop taking jobs on a leaked slot until then. `Sandbox.ID()` names a sandbox's files and Docker objects. | ||
A THIRD_PARTY.md +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | # Third-party material | |
| 2 | + | ||
| 3 | + | ermite-runner's executor (`internal/executor`) is a Go port of parts of | |
| 4 | + | [actions/runner](https://github.com/actions/runner) (C#), at commit `5f0b3dd`. | |
| 5 | + | Texts of messages, limits and the behaviour of the workflow commands, problem | |
| 6 | + | matchers, masker and step timeouts are taken from its source, and some unit | |
| 7 | + | tests are table versions of its L0 tests: | |
| 8 | + | ||
| 9 | + | | Test here | Source test in `src/Test/L0` | | |
| 10 | + | | --- | --- | | |
| 11 | + | | `TestValidateLinesAndColumns` | `Worker/ActionCommandManagerL0.cs` `IssueCommandInvalidColumns` | | |
| 12 | + | | `TestMatcherConfigValidate` | `Worker/IssueMatcherL0.cs` `Config_Validate_*` | | |
| 13 | + | | `TestMatcherLoopAccumulatesState`, `TestMatcherNonLoop`, `TestMatcherSinglePattern` | `Worker/IssueMatcherL0.cs` `Matcher_*` | | |
| 14 | + | | `TestDefaultSecretMaskers`, `TestSecretSectionMasking` | `HostContextL0.cs` `DefaultSecretMaskers`, `SecretSectionMasking` | | |
| 15 | + | | `TestAddMaskCommand` | `Worker/ActionCommandManagerL0.cs` `AddMaskWithMultilineValue` | | |
| 16 | + | | `TestCatastrophicMatcherIsRemovedAfterThreeTimeouts` | `Worker/OutputManagerL0.cs` `MatcherTimeout` | | |
| 17 | + | ||
| 18 | + | actions/runner is licensed under the MIT License: | |
| 19 | + | ||
| 20 | + | The MIT License (MIT) | |
| 21 | + | Copyright (c) 2019 GitHub | |
| 22 | + | ||
| 23 | + | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 24 | + | of this software and associated documentation files (the "Software"), to deal | |
| 25 | + | in the Software without restriction, including without limitation the rights | |
| 26 | + | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 27 | + | copies of the Software, and to permit persons to whom the Software is | |
| 28 | + | furnished to do so, subject to the following conditions: | |
| 29 | + | ||
| 30 | + | The above copyright notice and this permission notice shall be included in all | |
| 31 | + | copies or substantial portions of the Software. | |
| 32 | + | ||
| 33 | + | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 34 | + | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 35 | + | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 36 | + | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 37 | + | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 38 | + | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 39 | + | SOFTWARE. | |
| 40 | + | ||
| 41 | + | github.com/dlclark/regexp2 (MIT) is a dependency: a port of the .NET regular expression engine, used for problem matchers. | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | # Third-party material | |||
| 2 | + | ||||
| 3 | + | ermite-runner's executor (`internal/executor`) is a Go port of parts of | |||
| 4 | + | [actions/runner](https://github.com/actions/runner) (C#), at commit `5f0b3dd`. | |||
| 5 | + | Texts of messages, limits and the behaviour of the workflow commands, problem | |||
| 6 | + | matchers, masker and step timeouts are taken from its source, and some unit | |||
| 7 | + | tests are table versions of its L0 tests: | |||
| 8 | + | ||||
| 9 | + | | Test here | Source test in `src/Test/L0` | | |||
| 10 | + | | --- | --- | | |||
| 11 | + | | `TestValidateLinesAndColumns` | `Worker/ActionCommandManagerL0.cs` `IssueCommandInvalidColumns` | | |||
| 12 | + | | `TestMatcherConfigValidate` | `Worker/IssueMatcherL0.cs` `Config_Validate_*` | | |||
| 13 | + | | `TestMatcherLoopAccumulatesState`, `TestMatcherNonLoop`, `TestMatcherSinglePattern` | `Worker/IssueMatcherL0.cs` `Matcher_*` | | |||
| 14 | + | | `TestDefaultSecretMaskers`, `TestSecretSectionMasking` | `HostContextL0.cs` `DefaultSecretMaskers`, `SecretSectionMasking` | | |||
| 15 | + | | `TestAddMaskCommand` | `Worker/ActionCommandManagerL0.cs` `AddMaskWithMultilineValue` | | |||
| 16 | + | | `TestCatastrophicMatcherIsRemovedAfterThreeTimeouts` | `Worker/OutputManagerL0.cs` `MatcherTimeout` | | |||
| 17 | + | ||||
| 18 | + | actions/runner is licensed under the MIT License: | |||
| 19 | + | ||||
| 20 | + | The MIT License (MIT) | |||
| 21 | + | Copyright (c) 2019 GitHub | |||
| 22 | + | ||||
| 23 | + | Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| 24 | + | of this software and associated documentation files (the "Software"), to deal | |||
| 25 | + | in the Software without restriction, including without limitation the rights | |||
| 26 | + | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||
| 27 | + | copies of the Software, and to permit persons to whom the Software is | |||
| 28 | + | furnished to do so, subject to the following conditions: | |||
| 29 | + | ||||
| 30 | + | The above copyright notice and this permission notice shall be included in all | |||
| 31 | + | copies or substantial portions of the Software. | |||
| 32 | + | ||||
| 33 | + | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||
| 34 | + | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||
| 35 | + | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||
| 36 | + | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||
| 37 | + | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||
| 38 | + | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| 39 | + | SOFTWARE. | |||
| 40 | + | ||||
| 41 | + | github.com/dlclark/regexp2 (MIT) is a dependency: a port of the .NET regular expression engine, used for problem matchers. | |||
M internal/executor/issues_test.go +0 -12
| @@ -4,8 +4,6 @@ package executor | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | 7 | "strings" | |
| 10 | 8 | "sync" | |
| 11 | 9 | "testing" | |
| @@ -226,13 +224,3 @@ func TestSaveStateInRunStep(t *testing.T) { | |||
| 226 | 224 | } | |
| 227 | 225 | } | |
| 228 | 226 | } | |
| 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 | - | } | |
| @@ -4,8 +4,6 @@ package executor | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | - | "os" | |||
| 8 | - | "path/filepath" | |||
| 9 | "strings" | 7 | "strings" | ||
| 10 | "sync" | 8 | "sync" | ||
| 11 | "testing" | 9 | "testing" | ||
| @@ -226,13 +224,3 @@ func TestSaveStateInRunStep(t *testing.T) { | |||||
| 226 | } | 224 | } | ||
| 227 | } | 225 | } | ||
| 228 | } | 226 | } | ||
| 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/matchers.go +12 -13
| @@ -4,7 +4,6 @@ package executor | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "encoding/json" | |
| 7 | - | "errors" | |
| 8 | 7 | "fmt" | |
| 9 | 8 | "strings" | |
| 10 | 9 | "sync" | |
| @@ -32,7 +31,7 @@ func (o *optInt) UnmarshalJSON(b []byte) error { | |||
| 32 | 31 | s = strings.Trim(s, `"`) | |
| 33 | 32 | var n int | |
| 34 | 33 | 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)) | |
| 34 | + | return ghErrorf("Error converting value %s to type 'System.Nullable`1[System.Int32]'.", string(b)) | |
| 36 | 35 | } | |
| 37 | 36 | o.set, o.v = true, n | |
| 38 | 37 | return nil | |
| @@ -48,7 +47,7 @@ func (o *optBool) UnmarshalJSON(b []byte) error { | |||
| 48 | 47 | case "false", "null": | |
| 49 | 48 | *o = false | |
| 50 | 49 | default: | |
| 51 | - | return fmt.Errorf("Error converting value %s to type 'System.Boolean'.", string(b)) | |
| 50 | + | return ghErrorf("Error converting value %s to type 'System.Boolean'.", string(b)) | |
| 52 | 51 | } | |
| 53 | 52 | return nil | |
| 54 | 53 | } | |
| @@ -113,7 +112,7 @@ func (c *matchersConfig) validate() error { | |||
| 113 | 112 | } | |
| 114 | 113 | lo := strings.ToLower(m.Owner) | |
| 115 | 114 | if owners[lo] { | |
| 116 | - | return fmt.Errorf("Duplicate owner name '%s'", m.Owner) | |
| 115 | + | return ghErrorf("Duplicate owner name '%s'", m.Owner) | |
| 117 | 116 | } | |
| 118 | 117 | owners[lo] = true | |
| 119 | 118 | } | |
| @@ -123,15 +122,15 @@ func (c *matchersConfig) validate() error { | |||
| 123 | 122 | // validate is IssueMatcherConfig.Validate (IssueMatcher.cs:376). | |
| 124 | 123 | func (m *matcherConfig) validate() error { | |
| 125 | 124 | if m.Owner == "" { | |
| 126 | - | return errors.New("Owner must not be empty") | |
| 125 | + | return ghErrorf("Owner must not be empty") | |
| 127 | 126 | } | |
| 128 | 127 | switch strings.ToUpper(m.Severity) { | |
| 129 | 128 | case "", "ERROR", "WARNING", "NOTICE": | |
| 130 | 129 | default: | |
| 131 | - | return fmt.Errorf("Matcher '%s' contains unexpected default severity '%s'", m.Owner, m.Severity) | |
| 130 | + | return ghErrorf("Matcher '%s' contains unexpected default severity '%s'", m.Owner, m.Severity) | |
| 132 | 131 | } | |
| 133 | 132 | if len(m.Patterns) == 0 { | |
| 134 | - | return fmt.Errorf("Matcher '%s' does not contain any patterns", m.Owner) | |
| 133 | + | return ghErrorf("Matcher '%s' does not contain any patterns", m.Owner) | |
| 135 | 134 | } | |
| 136 | 135 | var file, line, column, severity, code, message, fromPath *int | |
| 137 | 136 | for i := range m.Patterns { | |
| @@ -141,7 +140,7 @@ func (m *matcherConfig) validate() error { | |||
| 141 | 140 | } | |
| 142 | 141 | } | |
| 143 | 142 | if message == nil { | |
| 144 | - | return errors.New("At least one pattern must set 'message'") | |
| 143 | + | return ghErrorf("At least one pattern must set 'message'") | |
| 145 | 144 | } | |
| 146 | 145 | return nil | |
| 147 | 146 | } | |
| @@ -149,10 +148,10 @@ func (m *matcherConfig) validate() error { | |||
| 149 | 148 | // validate is IssuePatternConfig.Validate (IssueMatcher.cs:495). | |
| 150 | 149 | func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, severity, code, message, fromPath **int) error { | |
| 151 | 150 | if bool(p.Loop) && (isFirst || !isLast) { | |
| 152 | - | return errors.New("Only the last pattern in a multiline matcher may set 'loop'") | |
| 151 | + | return ghErrorf("Only the last pattern in a multiline matcher may set 'loop'") | |
| 153 | 152 | } | |
| 154 | 153 | if bool(p.Loop) && !p.Message.set { | |
| 155 | - | return errors.New("The loop pattern must set 'message'") | |
| 154 | + | return ghErrorf("The loop pattern must set 'message'") | |
| 156 | 155 | } | |
| 157 | 156 | re, err := compilePattern(p.Regexp) | |
| 158 | 157 | if err != nil { | |
| @@ -172,10 +171,10 @@ func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, sever | |||
| 172 | 171 | continue | |
| 173 | 172 | } | |
| 174 | 173 | if *f.to != nil { | |
| 175 | - | return fmt.Errorf("The property '%s' is set twice", f.name) | |
| 174 | + | return ghErrorf("The property '%s' is set twice", f.name) | |
| 176 | 175 | } | |
| 177 | 176 | 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) | |
| 177 | + | return ghErrorf("The property '%s' is set to %d which is out of range", f.name, f.v.v) | |
| 179 | 178 | } | |
| 180 | 179 | n := f.v.v | |
| 181 | 180 | *f.to = &n | |
| @@ -184,7 +183,7 @@ func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, sever | |||
| 184 | 183 | } | |
| 185 | 184 | ||
| 186 | 185 | // 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.") | |
| 186 | + | var errMatchTimeout = msgError("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 | 187 | ||
| 189 | 188 | // issuePattern is IssuePattern. | |
| 190 | 189 | type issuePattern struct { | |
| @@ -4,7 +4,6 @@ package executor | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | - | "errors" | |||
| 8 | "fmt" | 7 | "fmt" | ||
| 9 | "strings" | 8 | "strings" | ||
| 10 | "sync" | 9 | "sync" | ||
| @@ -32,7 +31,7 @@ func (o *optInt) UnmarshalJSON(b []byte) error { | |||||
| 32 | s = strings.Trim(s, `"`) | 31 | s = strings.Trim(s, `"`) | ||
| 33 | var n int | 32 | var n int | ||
| 34 | if _, err := fmt.Sscanf(s, "%d", &n); err != nil { | 33 | 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)) | 34 | + | return ghErrorf("Error converting value %s to type 'System.Nullable`1[System.Int32]'.", string(b)) |
| 36 | } | 35 | } | ||
| 37 | o.set, o.v = true, n | 36 | o.set, o.v = true, n | ||
| 38 | return nil | 37 | return nil | ||
| @@ -48,7 +47,7 @@ func (o *optBool) UnmarshalJSON(b []byte) error { | |||||
| 48 | case "false", "null": | 47 | case "false", "null": | ||
| 49 | *o = false | 48 | *o = false | ||
| 50 | default: | 49 | default: | ||
| 51 | - | return fmt.Errorf("Error converting value %s to type 'System.Boolean'.", string(b)) | 50 | + | return ghErrorf("Error converting value %s to type 'System.Boolean'.", string(b)) |
| 52 | } | 51 | } | ||
| 53 | return nil | 52 | return nil | ||
| 54 | } | 53 | } | ||
| @@ -113,7 +112,7 @@ func (c *matchersConfig) validate() error { | |||||
| 113 | } | 112 | } | ||
| 114 | lo := strings.ToLower(m.Owner) | 113 | lo := strings.ToLower(m.Owner) | ||
| 115 | if owners[lo] { | 114 | if owners[lo] { | ||
| 116 | - | return fmt.Errorf("Duplicate owner name '%s'", m.Owner) | 115 | + | return ghErrorf("Duplicate owner name '%s'", m.Owner) |
| 117 | } | 116 | } | ||
| 118 | owners[lo] = true | 117 | owners[lo] = true | ||
| 119 | } | 118 | } | ||
| @@ -123,15 +122,15 @@ func (c *matchersConfig) validate() error { | |||||
| 123 | // validate is IssueMatcherConfig.Validate (IssueMatcher.cs:376). | 122 | // validate is IssueMatcherConfig.Validate (IssueMatcher.cs:376). | ||
| 124 | func (m *matcherConfig) validate() error { | 123 | func (m *matcherConfig) validate() error { | ||
| 125 | if m.Owner == "" { | 124 | if m.Owner == "" { | ||
| 126 | - | return errors.New("Owner must not be empty") | 125 | + | return ghErrorf("Owner must not be empty") |
| 127 | } | 126 | } | ||
| 128 | switch strings.ToUpper(m.Severity) { | 127 | switch strings.ToUpper(m.Severity) { | ||
| 129 | case "", "ERROR", "WARNING", "NOTICE": | 128 | case "", "ERROR", "WARNING", "NOTICE": | ||
| 130 | default: | 129 | default: | ||
| 131 | - | return fmt.Errorf("Matcher '%s' contains unexpected default severity '%s'", m.Owner, m.Severity) | 130 | + | return ghErrorf("Matcher '%s' contains unexpected default severity '%s'", m.Owner, m.Severity) |
| 132 | } | 131 | } | ||
| 133 | if len(m.Patterns) == 0 { | 132 | if len(m.Patterns) == 0 { | ||
| 134 | - | return fmt.Errorf("Matcher '%s' does not contain any patterns", m.Owner) | 133 | + | return ghErrorf("Matcher '%s' does not contain any patterns", m.Owner) |
| 135 | } | 134 | } | ||
| 136 | var file, line, column, severity, code, message, fromPath *int | 135 | var file, line, column, severity, code, message, fromPath *int | ||
| 137 | for i := range m.Patterns { | 136 | for i := range m.Patterns { | ||
| @@ -141,7 +140,7 @@ func (m *matcherConfig) validate() error { | |||||
| 141 | } | 140 | } | ||
| 142 | } | 141 | } | ||
| 143 | if message == nil { | 142 | if message == nil { | ||
| 144 | - | return errors.New("At least one pattern must set 'message'") | 143 | + | return ghErrorf("At least one pattern must set 'message'") |
| 145 | } | 144 | } | ||
| 146 | return nil | 145 | return nil | ||
| 147 | } | 146 | } | ||
| @@ -149,10 +148,10 @@ func (m *matcherConfig) validate() error { | |||||
| 149 | // validate is IssuePatternConfig.Validate (IssueMatcher.cs:495). | 148 | // validate is IssuePatternConfig.Validate (IssueMatcher.cs:495). | ||
| 150 | func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, severity, code, message, fromPath **int) error { | 149 | func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, severity, code, message, fromPath **int) error { | ||
| 151 | if bool(p.Loop) && (isFirst || !isLast) { | 150 | if bool(p.Loop) && (isFirst || !isLast) { | ||
| 152 | - | return errors.New("Only the last pattern in a multiline matcher may set 'loop'") | 151 | + | return ghErrorf("Only the last pattern in a multiline matcher may set 'loop'") |
| 153 | } | 152 | } | ||
| 154 | if bool(p.Loop) && !p.Message.set { | 153 | if bool(p.Loop) && !p.Message.set { | ||
| 155 | - | return errors.New("The loop pattern must set 'message'") | 154 | + | return ghErrorf("The loop pattern must set 'message'") |
| 156 | } | 155 | } | ||
| 157 | re, err := compilePattern(p.Regexp) | 156 | re, err := compilePattern(p.Regexp) | ||
| 158 | if err != nil { | 157 | if err != nil { | ||
| @@ -172,10 +171,10 @@ func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, sever | |||||
| 172 | continue | 171 | continue | ||
| 173 | } | 172 | } | ||
| 174 | if *f.to != nil { | 173 | if *f.to != nil { | ||
| 175 | - | return fmt.Errorf("The property '%s' is set twice", f.name) | 174 | + | return ghErrorf("The property '%s' is set twice", f.name) |
| 176 | } | 175 | } | ||
| 177 | if f.v.v < 0 || f.v.v >= groupCount { | 176 | 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) | 177 | + | return ghErrorf("The property '%s' is set to %d which is out of range", f.name, f.v.v) |
| 179 | } | 178 | } | ||
| 180 | n := f.v.v | 179 | n := f.v.v | ||
| 181 | *f.to = &n | 180 | *f.to = &n | ||
| @@ -184,7 +183,7 @@ func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, sever | |||||
| 184 | } | 183 | } | ||
| 185 | 184 | ||||
| 186 | // errMatchTimeout is the RegexMatchTimeoutException; its message is .NET's. | 185 | // 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.") | 186 | + | var errMatchTimeout = msgError("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 | 187 | ||||
| 189 | // issuePattern is IssuePattern. | 188 | // issuePattern is IssuePattern. | ||
| 190 | type issuePattern struct { | 189 | type issuePattern struct { | ||
M internal/executor/results_test.go +15 -0
| @@ -323,3 +323,18 @@ func TestStepTimeoutExpressionError(t *testing.T) { | |||
| 323 | 323 | t.Fatal(res) | |
| 324 | 324 | } | |
| 325 | 325 | } | |
| 326 | + | ||
| 327 | + | // save-state keeps the state per step, for the post phase of an action (F); | |
| 328 | + | // names are case-insensitive (IntraActionState uses OrdinalIgnoreCase). | |
| 329 | + | func TestSaveStateIsKeptPerStep(t *testing.T) { | |
| 330 | + | j := &job{} | |
| 331 | + | j.saveState("2", "Foo", "bar") | |
| 332 | + | j.saveState("2", "FOO", "baz") | |
| 333 | + | j.saveState("3", "x", "y") | |
| 334 | + | if got := j.intraActionState("2"); len(got) != 1 || got["foo"] != "baz" { | |
| 335 | + | t.Errorf("%v", got) | |
| 336 | + | } | |
| 337 | + | if got := j.intraActionState("4"); len(got) != 0 { | |
| 338 | + | t.Errorf("%v", got) | |
| 339 | + | } | |
| 340 | + | } | |
| @@ -323,3 +323,18 @@ func TestStepTimeoutExpressionError(t *testing.T) { | |||||
| 323 | t.Fatal(res) | 323 | t.Fatal(res) | ||
| 324 | } | 324 | } | ||
| 325 | } | 325 | } | ||
| 326 | + | ||||
| 327 | + | // save-state keeps the state per step, for the post phase of an action (F); | |||
| 328 | + | // names are case-insensitive (IntraActionState uses OrdinalIgnoreCase). | |||
| 329 | + | func TestSaveStateIsKeptPerStep(t *testing.T) { | |||
| 330 | + | j := &job{} | |||
| 331 | + | j.saveState("2", "Foo", "bar") | |||
| 332 | + | j.saveState("2", "FOO", "baz") | |||
| 333 | + | j.saveState("3", "x", "y") | |||
| 334 | + | if got := j.intraActionState("2"); len(got) != 1 || got["foo"] != "baz" { | |||
| 335 | + | t.Errorf("%v", got) | |||
| 336 | + | } | |||
| 337 | + | if got := j.intraActionState("4"); len(got) != 0 { | |||
| 338 | + | t.Errorf("%v", got) | |||
| 339 | + | } | |||
| 340 | + | } | |||
M internal/guest/jobexec_test.go +74 -0
| @@ -257,3 +257,77 @@ func TestFailSetupReportsAFailedFirstStep(t *testing.T) { | |||
| 257 | 257 | t.Fatalf("%s", g) | |
| 258 | 258 | } | |
| 259 | 259 | } | |
| 260 | + | ||
| 261 | + | // The executor over the stream: annotations, summary and outcome frames | |
| 262 | + | // arrive before the StepCompleted of their step, only when the host asked. | |
| 263 | + | func TestExecSendsResultsFrames(t *testing.T) { | |
| 264 | + | requireBash(t) | |
| 265 | + | yaml := "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: |\n echo '::warning title=T::careful'\n echo '# sum' >> \"$GITHUB_STEP_SUMMARY\"\n exit 1\n continue-on-error: true\n" | |
| 266 | + | for _, on := range []bool{true, false} { | |
| 267 | + | sock := filepath.Join(t.TempDir(), "s") | |
| 268 | + | ln, err := net.Listen("unix", sock) | |
| 269 | + | if err != nil { | |
| 270 | + | t.Skip(err) | |
| 271 | + | } | |
| 272 | + | home := t.TempDir() | |
| 273 | + | t.Setenv("HOME", home) | |
| 274 | + | t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(home, "toolcache")) | |
| 275 | + | done := make(chan struct{}) | |
| 276 | + | go func() { | |
| 277 | + | defer close(done) | |
| 278 | + | _, _ = Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", RunTimeout: 5 * time.Second, | |
| 279 | + | TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 300 * time.Millisecond}) | |
| 280 | + | }() | |
| 281 | + | conn, err := ln.Accept() | |
| 282 | + | if err != nil { | |
| 283 | + | t.Fatal(err) | |
| 284 | + | } | |
| 285 | + | var order []string | |
| 286 | + | var anns *execpb.StepAnnotations | |
| 287 | + | var sum *execpb.StepSummary | |
| 288 | + | var done2 *execpb.StepCompleted | |
| 289 | + | _, _, err = execstream.DriveJob(conn, &execpb.Run{Job: &execpb.JobSpec{ | |
| 290 | + | WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "r", RunnerVersion: "v", | |
| 291 | + | Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j"}}`, Matrix: "{}", | |
| 292 | + | SendAnnotations: on, SendSummaries: on, | |
| 293 | + | }}, nil, execstream.JobHandler{ | |
| 294 | + | StepAnnotations: func(a *execpb.StepAnnotations) { order = append(order, "ann"); anns = a }, | |
| 295 | + | StepSummary: func(s *execpb.StepSummary) { order = append(order, "sum"); sum = s }, | |
| 296 | + | StepCompleted: func(s *execpb.StepCompleted) { | |
| 297 | + | if s.Number == 2 { | |
| 298 | + | order = append(order, "done") | |
| 299 | + | done2 = s | |
| 300 | + | } | |
| 301 | + | }, | |
| 302 | + | }) | |
| 303 | + | _ = conn.Close() | |
| 304 | + | _ = ln.Close() | |
| 305 | + | <-done | |
| 306 | + | if err != nil { | |
| 307 | + | t.Fatal(err) | |
| 308 | + | } | |
| 309 | + | if done2 == nil || done2.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || done2.Outcome != execpb.StepConclusion_STEP_CONCLUSION_FAILURE { | |
| 310 | + | t.Fatalf("on=%v: %+v", on, done2) | |
| 311 | + | } | |
| 312 | + | if !on { | |
| 313 | + | if len(order) != 1 { | |
| 314 | + | t.Errorf("frames without the features: %v", order) | |
| 315 | + | } | |
| 316 | + | continue | |
| 317 | + | } | |
| 318 | + | if strings.Join(order, ",") != "ann,sum,done" { | |
| 319 | + | t.Errorf("order %v", order) | |
| 320 | + | } | |
| 321 | + | // the failing exit code is also an annotation | |
| 322 | + | var msgs []string | |
| 323 | + | for _, a := range anns.Annotations { | |
| 324 | + | msgs = append(msgs, a.Level.String()+":"+a.Title+":"+a.Message) | |
| 325 | + | } | |
| 326 | + | if anns.Step != 2 || len(msgs) != 2 || msgs[0] != "ANNOTATION_LEVEL_WARNING:T:careful" || msgs[1] != "ANNOTATION_LEVEL_FAILURE::Process completed with exit code 1." { | |
| 327 | + | t.Errorf("annotations %v", msgs) | |
| 328 | + | } | |
| 329 | + | if sum == nil || sum.Step != 2 || sum.Markdown != "# sum\n" { | |
| 330 | + | t.Errorf("summary %+v", sum) | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | } | |
| @@ -257,3 +257,77 @@ func TestFailSetupReportsAFailedFirstStep(t *testing.T) { | |||||
| 257 | t.Fatalf("%s", g) | 257 | t.Fatalf("%s", g) | ||
| 258 | } | 258 | } | ||
| 259 | } | 259 | } | ||
| 260 | + | ||||
| 261 | + | // The executor over the stream: annotations, summary and outcome frames | |||
| 262 | + | // arrive before the StepCompleted of their step, only when the host asked. | |||
| 263 | + | func TestExecSendsResultsFrames(t *testing.T) { | |||
| 264 | + | requireBash(t) | |||
| 265 | + | yaml := "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: |\n echo '::warning title=T::careful'\n echo '# sum' >> \"$GITHUB_STEP_SUMMARY\"\n exit 1\n continue-on-error: true\n" | |||
| 266 | + | for _, on := range []bool{true, false} { | |||
| 267 | + | sock := filepath.Join(t.TempDir(), "s") | |||
| 268 | + | ln, err := net.Listen("unix", sock) | |||
| 269 | + | if err != nil { | |||
| 270 | + | t.Skip(err) | |||
| 271 | + | } | |||
| 272 | + | home := t.TempDir() | |||
| 273 | + | t.Setenv("HOME", home) | |||
| 274 | + | t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(home, "toolcache")) | |||
| 275 | + | done := make(chan struct{}) | |||
| 276 | + | go func() { | |||
| 277 | + | defer close(done) | |||
| 278 | + | _, _ = Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", RunTimeout: 5 * time.Second, | |||
| 279 | + | TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 300 * time.Millisecond}) | |||
| 280 | + | }() | |||
| 281 | + | conn, err := ln.Accept() | |||
| 282 | + | if err != nil { | |||
| 283 | + | t.Fatal(err) | |||
| 284 | + | } | |||
| 285 | + | var order []string | |||
| 286 | + | var anns *execpb.StepAnnotations | |||
| 287 | + | var sum *execpb.StepSummary | |||
| 288 | + | var done2 *execpb.StepCompleted | |||
| 289 | + | _, _, err = execstream.DriveJob(conn, &execpb.Run{Job: &execpb.JobSpec{ | |||
| 290 | + | WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "r", RunnerVersion: "v", | |||
| 291 | + | Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j"}}`, Matrix: "{}", | |||
| 292 | + | SendAnnotations: on, SendSummaries: on, | |||
| 293 | + | }}, nil, execstream.JobHandler{ | |||
| 294 | + | StepAnnotations: func(a *execpb.StepAnnotations) { order = append(order, "ann"); anns = a }, | |||
| 295 | + | StepSummary: func(s *execpb.StepSummary) { order = append(order, "sum"); sum = s }, | |||
| 296 | + | StepCompleted: func(s *execpb.StepCompleted) { | |||
| 297 | + | if s.Number == 2 { | |||
| 298 | + | order = append(order, "done") | |||
| 299 | + | done2 = s | |||
| 300 | + | } | |||
| 301 | + | }, | |||
| 302 | + | }) | |||
| 303 | + | _ = conn.Close() | |||
| 304 | + | _ = ln.Close() | |||
| 305 | + | <-done | |||
| 306 | + | if err != nil { | |||
| 307 | + | t.Fatal(err) | |||
| 308 | + | } | |||
| 309 | + | if done2 == nil || done2.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || done2.Outcome != execpb.StepConclusion_STEP_CONCLUSION_FAILURE { | |||
| 310 | + | t.Fatalf("on=%v: %+v", on, done2) | |||
| 311 | + | } | |||
| 312 | + | if !on { | |||
| 313 | + | if len(order) != 1 { | |||
| 314 | + | t.Errorf("frames without the features: %v", order) | |||
| 315 | + | } | |||
| 316 | + | continue | |||
| 317 | + | } | |||
| 318 | + | if strings.Join(order, ",") != "ann,sum,done" { | |||
| 319 | + | t.Errorf("order %v", order) | |||
| 320 | + | } | |||
| 321 | + | // the failing exit code is also an annotation | |||
| 322 | + | var msgs []string | |||
| 323 | + | for _, a := range anns.Annotations { | |||
| 324 | + | msgs = append(msgs, a.Level.String()+":"+a.Title+":"+a.Message) | |||
| 325 | + | } | |||
| 326 | + | if anns.Step != 2 || len(msgs) != 2 || msgs[0] != "ANNOTATION_LEVEL_WARNING:T:careful" || msgs[1] != "ANNOTATION_LEVEL_FAILURE::Process completed with exit code 1." { | |||
| 327 | + | t.Errorf("annotations %v", msgs) | |||
| 328 | + | } | |||
| 329 | + | if sum == nil || sum.Step != 2 || sum.Markdown != "# sum\n" { | |||
| 330 | + | t.Errorf("summary %+v", sum) | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | } | |||