detached e59b2f0

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
7878
7979 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.
8080
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+
8185 ### Cleanup
8286
8387 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 ### Cleanup85 ### 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
44
55 import (
66 "fmt"
7- "os"
8- "path/filepath"
97 "strings"
108 "sync"
119 "testing"
@@ -226,13 +224,3 @@ func TestSaveStateInRunStep(t *testing.T) {
226224 }
227225 }
228226 }
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
44
55 import (
66 "encoding/json"
7- "errors"
87 "fmt"
98 "strings"
109 "sync"
@@ -32,7 +31,7 @@ func (o *optInt) UnmarshalJSON(b []byte) error {
3231 s = strings.Trim(s, `"`)
3332 var n int
3433 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))
3635 }
3736 o.set, o.v = true, n
3837 return nil
@@ -48,7 +47,7 @@ func (o *optBool) UnmarshalJSON(b []byte) error {
4847 case "false", "null":
4948 *o = false
5049 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))
5251 }
5352 return nil
5453 }
@@ -113,7 +112,7 @@ func (c *matchersConfig) validate() error {
113112 }
114113 lo := strings.ToLower(m.Owner)
115114 if owners[lo] {
116- return fmt.Errorf("Duplicate owner name '%s'", m.Owner)
115+ return ghErrorf("Duplicate owner name '%s'", m.Owner)
117116 }
118117 owners[lo] = true
119118 }
@@ -123,15 +122,15 @@ func (c *matchersConfig) validate() error {
123122 // validate is IssueMatcherConfig.Validate (IssueMatcher.cs:376).
124123 func (m *matcherConfig) validate() error {
125124 if m.Owner == "" {
126- return errors.New("Owner must not be empty")
125+ return ghErrorf("Owner must not be empty")
127126 }
128127 switch strings.ToUpper(m.Severity) {
129128 case "", "ERROR", "WARNING", "NOTICE":
130129 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)
132131 }
133132 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)
135134 }
136135 var file, line, column, severity, code, message, fromPath *int
137136 for i := range m.Patterns {
@@ -141,7 +140,7 @@ func (m *matcherConfig) validate() error {
141140 }
142141 }
143142 if message == nil {
144- return errors.New("At least one pattern must set 'message'")
143+ return ghErrorf("At least one pattern must set 'message'")
145144 }
146145 return nil
147146 }
@@ -149,10 +148,10 @@ func (m *matcherConfig) validate() error {
149148 // validate is IssuePatternConfig.Validate (IssueMatcher.cs:495).
150149 func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, severity, code, message, fromPath **int) error {
151150 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'")
153152 }
154153 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'")
156155 }
157156 re, err := compilePattern(p.Regexp)
158157 if err != nil {
@@ -172,10 +171,10 @@ func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, sever
172171 continue
173172 }
174173 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)
176175 }
177176 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)
179178 }
180179 n := f.v.v
181180 *f.to = &n
@@ -184,7 +183,7 @@ func (p *patternConfig) validate(isFirst, isLast bool, file, line, column, sever
184183 }
185184
186185 // 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.")
188187
189188 // issuePattern is IssuePattern.
190189 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 int32 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, n36 o.set, o.v = true, n
38 return nil37 return nil
@@ -48,7 +47,7 @@ func (o *optBool) UnmarshalJSON(b []byte) error {
48 case "false", "null":47 case "false", "null":
49 *o = false48 *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 nil52 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] = true117 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 *int135 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 nil145 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 continue171 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.v179 n := f.v.v
181 *f.to = &n180 *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) {
323323 t.Fatal(res)
324324 }
325325 }
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) {
257257 t.Fatalf("%s", g)
258258 }
259259 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close