detached 298aaf7

fix(executor): run node12, node16 and node20 actions on Node 24 as GitHub does

Node 24 is required like on github.com today (NodeUtil requireNode24 phase), whatever FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 or ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION say. Adds a test that a matcher removed after 3 timeouts is removed again in the next step, as recorded.
commit
298aaf799765625c68f17d9baccd5bc717f0633c
author
brice · · 2026-10-14 07:55 UTC
committer
bnema · · 2026-10-14 07:55 UTC
parent
f09c6ed
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
6 files · +140 -4
M README.md +1 -0
@@ -42,6 +42,7 @@ The host provides Node.js to the jobs **outside the image**, like its own execut
4242 | `node20_alpine` | 20.20.2 | `github.com/actions/alpine_nodejs` release `v20.20.2` | x64 |
4343 | `node24_alpine` | 24.21.0 | `github.com/actions/alpine_nodejs` release `v24.21.0` | x64 |
4444
45+- **Node versions of actions** follow github.com today (`RequireNode24` and `Node24ByDefault` of the executor's `ActionsConfig`, both set by the sandbox through `DefaultActionsConfig`): `using: node12`, `node16` and `node20` actions run on `node24`, whatever `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24` and `ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION` say, and `Complete job` ends with `##[warning]Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: <actions, sorted ignoring case>. For more information see: …` (recordings `act-node-*`). Both flags off give the older phases of the runner.
4546 - **Download.** `ermite-runner run` (and `sandbox-check`) fetch what is missing at start, into `$ERMITE_RUNNER_STATE_DIR/externals/<name>-<version>-<arch>/`, in the background; the start waits for them at most 2 minutes (a runner with a network normally takes its first job with everything in place, one without never waits long). The archive is the `.tar.gz` (the Go standard library has no xz); its SHA-256 is checked **before** anything is unpacked. Redirects must stay on https.
4647 - **Extraction** keeps the top directory out and refuses absolute names, `..`, devices and fifos, symbolic links that are absolute or climb out of the tree, entries below a link, hard links to anything but an unpacked file, and archives over 200,000 entries or 2 GiB; writes go through `os.Root`. The tree is built in a temporary directory and renamed into place, so a directory under its final name is always complete.
4748 - **Only pinned versions are kept**: at every start everything else in `externals/` (other versions, other architectures, interrupted downloads) is removed.
@@ -42,6 +42,7 @@ The host provides Node.js to the jobs **outside the image**, like its own execut
42 | `node20_alpine` | 20.20.2 | `github.com/actions/alpine_nodejs` release `v20.20.2` | x64 |42 | `node20_alpine` | 20.20.2 | `github.com/actions/alpine_nodejs` release `v20.20.2` | x64 |
43 | `node24_alpine` | 24.21.0 | `github.com/actions/alpine_nodejs` release `v24.21.0` | x64 |43 | `node24_alpine` | 24.21.0 | `github.com/actions/alpine_nodejs` release `v24.21.0` | x64 |
44 44
45+- **Node versions of actions** follow github.com today (`RequireNode24` and `Node24ByDefault` of the executor's `ActionsConfig`, both set by the sandbox through `DefaultActionsConfig`): `using: node12`, `node16` and `node20` actions run on `node24`, whatever `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24` and `ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION` say, and `Complete job` ends with `##[warning]Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: <actions, sorted ignoring case>. For more information see: …` (recordings `act-node-*`). Both flags off give the older phases of the runner.
45 - **Download.** `ermite-runner run` (and `sandbox-check`) fetch what is missing at start, into `$ERMITE_RUNNER_STATE_DIR/externals/<name>-<version>-<arch>/`, in the background; the start waits for them at most 2 minutes (a runner with a network normally takes its first job with everything in place, one without never waits long). The archive is the `.tar.gz` (the Go standard library has no xz); its SHA-256 is checked **before** anything is unpacked. Redirects must stay on https.46 - **Download.** `ermite-runner run` (and `sandbox-check`) fetch what is missing at start, into `$ERMITE_RUNNER_STATE_DIR/externals/<name>-<version>-<arch>/`, in the background; the start waits for them at most 2 minutes (a runner with a network normally takes its first job with everything in place, one without never waits long). The archive is the `.tar.gz` (the Go standard library has no xz); its SHA-256 is checked **before** anything is unpacked. Redirects must stay on https.
46 - **Extraction** keeps the top directory out and refuses absolute names, `..`, devices and fifos, symbolic links that are absolute or climb out of the tree, entries below a link, hard links to anything but an unpacked file, and archives over 200,000 entries or 2 GiB; writes go through `os.Root`. The tree is built in a temporary directory and renamed into place, so a directory under its final name is always complete.47 - **Extraction** keeps the top directory out and refuses absolute names, `..`, devices and fifos, symbolic links that are absolute or climb out of the tree, entries below a link, hard links to anything but an unpacked file, and archives over 200,000 entries or 2 GiB; writes go through `os.Root`. The tree is built in a temporary directory and renamed into place, so a directory under its final name is always complete.
47 - **Only pinned versions are kept**: at every start everything else in `externals/` (other versions, other architectures, interrupted downloads) is removed.48 - **Only pinned versions are kept**: at every start everything else in `externals/` (other versions, other architectures, interrupted downloads) is removed.
M internal/executor/actions_node.go +9 -3
@@ -51,11 +51,17 @@ func (j *job) nodeEnvFlag(name string, wf *ordered) envFlag {
5151
5252 // nodeVersionDefault is NodeUtil.DetermineActionsNodeVersion
5353 // (NodeUtil.cs:50-110) for an action that says node20: the version the action
54-// runs on and the warning of a conflicting configuration. useNode24ByDefault is
55-// the server's flag, ActionsConfig.Node24ByDefault here.
54+// runs on and the warning of a conflicting configuration. useNode24ByDefault and
55+// requireNode24 are the server's flags, ActionsConfig.Node24ByDefault and
56+// RequireNode24 here.
5657 func (j *job) nodeVersionDefault(wf *ordered) (version, warning string) {
5758 force := j.nodeEnvFlag(forceNode24Var, wf)
5859 allow := j.nodeEnvFlag(allowUnsecureVar, wf)
60+ if j.cfg.Actions.RequireNode24 {
61+ // Phase 3 (NodeUtil.cs:60-63): node24 whatever the variables say, and
62+ // no warning about them.
63+ return node24Name, ""
64+ }
5965 byDefault := j.cfg.Actions.Node24ByDefault
6066 bothWorkflow := force.on && allow.on && force.fromWorkflow && allow.fromWorkflow
6167 bothSystem := force.on && allow.on && force.fromSystem && allow.fromSystem
@@ -106,7 +112,7 @@ func (j *job) nodeVersionFor(s *stepRun, def *actionDef) error {
106112 j.mu.Lock()
107113 j.upgradedToNode24 = appendUnique(j.upgradedToNode24, actionName(s.n))
108114 j.mu.Unlock()
109- if j.cfg.Actions.Node24ByDefault {
115+ if j.cfg.Actions.Node24ByDefault && !j.cfg.Actions.RequireNode24 {
110116 j.out(s.num, "Node 20 is being deprecated. This workflow is running with Node 24 by default. "+
111117 "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. "+
112118 "For more information see: "+node20DeprecationURL)
@@ -51,11 +51,17 @@ func (j *job) nodeEnvFlag(name string, wf *ordered) envFlag {
51 51
52 // nodeVersionDefault is NodeUtil.DetermineActionsNodeVersion52 // nodeVersionDefault is NodeUtil.DetermineActionsNodeVersion
53 // (NodeUtil.cs:50-110) for an action that says node20: the version the action53 // (NodeUtil.cs:50-110) for an action that says node20: the version the action
54-// runs on and the warning of a conflicting configuration. useNode24ByDefault is54+// runs on and the warning of a conflicting configuration. useNode24ByDefault and
55-// the server's flag, ActionsConfig.Node24ByDefault here.55+// requireNode24 are the server's flags, ActionsConfig.Node24ByDefault and
56+// RequireNode24 here.
56 func (j *job) nodeVersionDefault(wf *ordered) (version, warning string) {57 func (j *job) nodeVersionDefault(wf *ordered) (version, warning string) {
57 force := j.nodeEnvFlag(forceNode24Var, wf)58 force := j.nodeEnvFlag(forceNode24Var, wf)
58 allow := j.nodeEnvFlag(allowUnsecureVar, wf)59 allow := j.nodeEnvFlag(allowUnsecureVar, wf)
60+ if j.cfg.Actions.RequireNode24 {
61+ // Phase 3 (NodeUtil.cs:60-63): node24 whatever the variables say, and
62+ // no warning about them.
63+ return node24Name, ""
64+ }
59 byDefault := j.cfg.Actions.Node24ByDefault65 byDefault := j.cfg.Actions.Node24ByDefault
60 bothWorkflow := force.on && allow.on && force.fromWorkflow && allow.fromWorkflow66 bothWorkflow := force.on && allow.on && force.fromWorkflow && allow.fromWorkflow
61 bothSystem := force.on && allow.on && force.fromSystem && allow.fromSystem67 bothSystem := force.on && allow.on && force.fromSystem && allow.fromSystem
@@ -106,7 +112,7 @@ func (j *job) nodeVersionFor(s *stepRun, def *actionDef) error {
106 j.mu.Lock()112 j.mu.Lock()
107 j.upgradedToNode24 = appendUnique(j.upgradedToNode24, actionName(s.n))113 j.upgradedToNode24 = appendUnique(j.upgradedToNode24, actionName(s.n))
108 j.mu.Unlock()114 j.mu.Unlock()
109- if j.cfg.Actions.Node24ByDefault {115+ if j.cfg.Actions.Node24ByDefault && !j.cfg.Actions.RequireNode24 {
110 j.out(s.num, "Node 20 is being deprecated. This workflow is running with Node 24 by default. "+116 j.out(s.num, "Node 20 is being deprecated. This workflow is running with Node 24 by default. "+
111 "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. "+117 "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. "+
112 "For more information see: "+node20DeprecationURL)118 "For more information see: "+node20DeprecationURL)
M internal/executor/actions_test.go +51 -0
@@ -202,6 +202,57 @@ func TestManifestErrors(t *testing.T) {
202202 }
203203 }
204204
205+// The phase of github.com today (recordings act-node-version, act-node-legacy,
206+// act-node-force24): node12, node16 and node20 actions run on node24 whatever
207+// the variables say, and the job ends with one warning that lists them.
208+func TestNodeVersionGitHubPhase(t *testing.T) {
209+ const tail = ". For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/"
210+ act := func(using string) string {
211+ return "name: a\nruns:\n using: " + using + "\n main: m.sh\n"
212+ }
213+ tree := files{"a/action.yml": act("node20"), "a/m.sh": "echo ran\n", "n12/action.yml": act("node12"), "n12/m.sh": "echo ran\n",
214+ "n16/action.yml": act("node16"), "n16/m.sh": "echo ran\n", "n24/action.yml": act("node24"), "n24/m.sh": "echo ran\n"}
215+ run := func(yaml string) *recorder {
216+ t.Helper()
217+ rec, res := actionRun(t, tree, yaml, func(c *Config) { c.Actions = DefaultActionsConfig(nil); c.Actions.ExternalsDir = fakeNode(t) })
218+ if res.Conclusion != ConclusionSuccess {
219+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
220+ }
221+ return rec
222+ }
223+ // Order: case-insensitive, one entry per action; node24 actions are not listed.
224+ rec := run(" - uses: ./n16\n - uses: ./n24\n - uses: ./a\n - uses: ./n12\n - uses: ./a\n")
225+ want := "##[warning]Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: ./a, ./n12, ./n16" + tail
226+ if got := logOf(rec.steps[len(rec.steps)-1]); !strings.Contains(got, "Cleaning up orphan processes\n"+want) {
227+ t.Errorf("complete job:\n%s", got)
228+ }
229+ for _, name := range []string{"Run ./n12", "Run ./n16", "Run ./a", "Run ./n24"} {
230+ if got := logOf(rec.step(t, name)); !strings.Contains(got, "[node24]") {
231+ t.Errorf("%s: %s", name, got)
232+ }
233+ }
234+ // The variables change nothing: not even ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION, nor both.
235+ for _, env := range []string{
236+ " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n",
237+ " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n",
238+ " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n",
239+ } {
240+ rec := run(" - uses: ./a\n env:\n" + env)
241+ got := logOf(rec.step(t, "Run ./a"))
242+ if !strings.Contains(got, "[node24]") || strings.Contains(got, "Both ") || strings.Contains(got, "being deprecated") {
243+ t.Errorf("%s: %s", env, got)
244+ }
245+ if last := logOf(rec.steps[len(rec.steps)-1]); !strings.Contains(last, "forced to run on Node.js 24: ./a"+tail) {
246+ t.Errorf("%s: complete job:\n%s", env, last)
247+ }
248+ }
249+ // Only node24 actions: no warning.
250+ rec = run(" - uses: ./n24\n")
251+ if got := logOf(rec.steps[len(rec.steps)-1]); strings.Contains(got, "Node.js 20 is deprecated") {
252+ t.Errorf("node24 only:\n%s", got)
253+ }
254+}
255+
205256 func TestNodeVersionRules(t *testing.T) {
206257 act := func(using string) files {
207258 return files{"a/action.yml": "name: a\nruns:\n using: " + using + "\n main: m.sh\n", "a/m.sh": "echo ran\n"}
@@ -202,6 +202,57 @@ func TestManifestErrors(t *testing.T) {
202 }202 }
203 }203 }
204 204
205+// The phase of github.com today (recordings act-node-version, act-node-legacy,
206+// act-node-force24): node12, node16 and node20 actions run on node24 whatever
207+// the variables say, and the job ends with one warning that lists them.
208+func TestNodeVersionGitHubPhase(t *testing.T) {
209+ const tail = ". For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/"
210+ act := func(using string) string {
211+ return "name: a\nruns:\n using: " + using + "\n main: m.sh\n"
212+ }
213+ tree := files{"a/action.yml": act("node20"), "a/m.sh": "echo ran\n", "n12/action.yml": act("node12"), "n12/m.sh": "echo ran\n",
214+ "n16/action.yml": act("node16"), "n16/m.sh": "echo ran\n", "n24/action.yml": act("node24"), "n24/m.sh": "echo ran\n"}
215+ run := func(yaml string) *recorder {
216+ t.Helper()
217+ rec, res := actionRun(t, tree, yaml, func(c *Config) { c.Actions = DefaultActionsConfig(nil); c.Actions.ExternalsDir = fakeNode(t) })
218+ if res.Conclusion != ConclusionSuccess {
219+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
220+ }
221+ return rec
222+ }
223+ // Order: case-insensitive, one entry per action; node24 actions are not listed.
224+ rec := run(" - uses: ./n16\n - uses: ./n24\n - uses: ./a\n - uses: ./n12\n - uses: ./a\n")
225+ want := "##[warning]Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: ./a, ./n12, ./n16" + tail
226+ if got := logOf(rec.steps[len(rec.steps)-1]); !strings.Contains(got, "Cleaning up orphan processes\n"+want) {
227+ t.Errorf("complete job:\n%s", got)
228+ }
229+ for _, name := range []string{"Run ./n12", "Run ./n16", "Run ./a", "Run ./n24"} {
230+ if got := logOf(rec.step(t, name)); !strings.Contains(got, "[node24]") {
231+ t.Errorf("%s: %s", name, got)
232+ }
233+ }
234+ // The variables change nothing: not even ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION, nor both.
235+ for _, env := range []string{
236+ " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n",
237+ " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n",
238+ " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n",
239+ } {
240+ rec := run(" - uses: ./a\n env:\n" + env)
241+ got := logOf(rec.step(t, "Run ./a"))
242+ if !strings.Contains(got, "[node24]") || strings.Contains(got, "Both ") || strings.Contains(got, "being deprecated") {
243+ t.Errorf("%s: %s", env, got)
244+ }
245+ if last := logOf(rec.steps[len(rec.steps)-1]); !strings.Contains(last, "forced to run on Node.js 24: ./a"+tail) {
246+ t.Errorf("%s: complete job:\n%s", env, last)
247+ }
248+ }
249+ // Only node24 actions: no warning.
250+ rec = run(" - uses: ./n24\n")
251+ if got := logOf(rec.steps[len(rec.steps)-1]); strings.Contains(got, "Node.js 20 is deprecated") {
252+ t.Errorf("node24 only:\n%s", got)
253+ }
254+}
255+
205 func TestNodeVersionRules(t *testing.T) {256 func TestNodeVersionRules(t *testing.T) {
206 act := func(using string) files {257 act := func(using string) files {
207 return files{"a/action.yml": "name: a\nruns:\n using: " + using + "\n main: m.sh\n", "a/m.sh": "echo ran\n"}258 return files{"a/action.yml": "name: a\nruns:\n using: " + using + "\n main: m.sh\n", "a/m.sh": "echo ran\n"}
M internal/executor/actions_types.go +14 -0
@@ -46,6 +46,13 @@ type ActionsConfig struct {
4646 // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no
4747 // feature flags, so the default is pinned here.
4848 Node24ByDefault bool
49+ // RequireNode24 is the server flag actions.runner.requirenode24 (phase 3
50+ // of the migration): every node20 action runs on node24 whatever
51+ // FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION
52+ // say, without the "being deprecated" notice or the conflict warning.
53+ // github.com does so today (recordings act-node-*). The zero value is the
54+ // runner's phase 1; the guest sets both flags (see DefaultActionsConfig).
55+ RequireNode24 bool
4956 // Containers are the time limits of the docker commands of job containers
5057 // and services; the zero value means the defaults.
5158 Containers ContainerTimeouts
@@ -54,6 +61,13 @@ type ActionsConfig struct {
5461 Backoff func() time.Duration
5562 }
5663
64+// DefaultActionsConfig is the configuration of a job in the sandbox: GitHub's
65+// current node migration phase, in which node12, node16 and node20 actions run
66+// on node24 and the job ends with the "forced to run on Node.js 24" warning.
67+func DefaultActionsConfig(r Resolver) ActionsConfig {
68+ return ActionsConfig{Resolver: r, Node24ByDefault: true, RequireNode24: true}
69+}
70+
5771 const defaultExternalsDir = "/home/runner/externals"
5872
5973 // stage is ActionRunStage.
@@ -46,6 +46,13 @@ type ActionsConfig struct {
46 // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no46 // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no
47 // feature flags, so the default is pinned here.47 // feature flags, so the default is pinned here.
48 Node24ByDefault bool48 Node24ByDefault bool
49+ // RequireNode24 is the server flag actions.runner.requirenode24 (phase 3
50+ // of the migration): every node20 action runs on node24 whatever
51+ // FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION
52+ // say, without the "being deprecated" notice or the conflict warning.
53+ // github.com does so today (recordings act-node-*). The zero value is the
54+ // runner's phase 1; the guest sets both flags (see DefaultActionsConfig).
55+ RequireNode24 bool
49 // Containers are the time limits of the docker commands of job containers56 // Containers are the time limits of the docker commands of job containers
50 // and services; the zero value means the defaults.57 // and services; the zero value means the defaults.
51 Containers ContainerTimeouts58 Containers ContainerTimeouts
@@ -54,6 +61,13 @@ type ActionsConfig struct {
54 Backoff func() time.Duration61 Backoff func() time.Duration
55 }62 }
56 63
64+// DefaultActionsConfig is the configuration of a job in the sandbox: GitHub's
65+// current node migration phase, in which node12, node16 and node20 actions run
66+// on node24 and the job ends with the "forced to run on Node.js 24" warning.
67+func DefaultActionsConfig(r Resolver) ActionsConfig {
68+ return ActionsConfig{Resolver: r, Node24ByDefault: true, RequireNode24: true}
69+}
70+
57 const defaultExternalsDir = "/home/runner/externals"71 const defaultExternalsDir = "/home/runner/externals"
58 72
59 // stage is ActionRunStage.73 // stage is ActionRunStage.
M internal/executor/results_test.go +64 -0
@@ -4,6 +4,7 @@ package executor
44
55 import (
66 "fmt"
7+ "slices"
78 "strings"
89 "testing"
910 "time"
@@ -116,6 +117,69 @@ func TestCatastrophicMatcherIsRemovedAfterThreeTimeouts(t *testing.T) {
116117 }
117118 }
118119
120+// Mirror of the recording match-catastrophic: the removal is per step
121+// (OutputManager.Remove edits the list of the step), so the next step, which
122+// does not add anything, starts from the matchers of the job and times out and
123+// removes the matcher again after 3 more timeouts; "good" works in both.
124+func TestCatastrophicMatcherRemovalIsPerStep(t *testing.T) {
125+ needTools(t, "bash")
126+ rr, res := runResults(t, wfHead+` steps:
127+ - name: prepare
128+ run: |
129+ cat > bad.json <<'JSON'
130+ {"problemMatcher":[{"owner":"catastrophic","pattern":[{"regexp":"^((a+)+)$","message":1}]}]}
131+ JSON
132+ cat > good.json <<'JSON'
133+ {"problemMatcher":[{"owner":"good","severity":"warning","pattern":[{"regexp":"^good: (.+)$","message":1}]}]}
134+ JSON
135+ - name: first
136+ run: |
137+ echo "::add-matcher::bad.json"
138+ echo "::add-matcher::good.json"
139+ echo "good: before the long line"
140+ echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
141+ echo "good: after the long line"
142+ - name: second
143+ run: |
144+ echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
145+ echo "aaa"
146+ echo "good: still alive"
147+`, nil)
148+ if res.Conclusion != ConclusionSuccess {
149+ t.Fatalf("%+v", res)
150+ }
151+ 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."
152+ // The lines of the log, in order, with the long text of the removal cut.
153+ lines := func(log string) []string {
154+ var out []string
155+ for _, l := range strings.Split(log, "\n") {
156+ if strings.HasPrefix(l, removing) {
157+ l = removing
158+ }
159+ out = append(out, l)
160+ }
161+ return out
162+ }
163+ has := func(log string, want ...string) bool {
164+ got := lines(log)
165+ for i := range got {
166+ if slices.Equal(got[i:min(i+len(want), len(got))], want) {
167+ return true
168+ }
169+ }
170+ return false
171+ }
172+ const long = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
173+ first := logOf(rr.step(t, "first"))
174+ if !has(first, "##[warning]good: before the long line", removing, long, "##[warning]good: after the long line") {
175+ t.Errorf("first:\n%s", first)
176+ }
177+ second := logOf(rr.step(t, "second"))
178+ if !has(second, removing, long, "aaa", "##[warning]good: still alive") {
179+ t.Errorf("second:\n%s", second)
180+ }
181+}
182+
119183 func TestMatcherTimeoutFromEnvironment(t *testing.T) {
120184 needTools(t, "bash")
121185 start := time.Now()
@@ -4,6 +4,7 @@ package executor
4 4
5 import (5 import (
6 "fmt"6 "fmt"
7+ "slices"
7 "strings"8 "strings"
8 "testing"9 "testing"
9 "time"10 "time"
@@ -116,6 +117,69 @@ func TestCatastrophicMatcherIsRemovedAfterThreeTimeouts(t *testing.T) {
116 }117 }
117 }118 }
118 119
120+// Mirror of the recording match-catastrophic: the removal is per step
121+// (OutputManager.Remove edits the list of the step), so the next step, which
122+// does not add anything, starts from the matchers of the job and times out and
123+// removes the matcher again after 3 more timeouts; "good" works in both.
124+func TestCatastrophicMatcherRemovalIsPerStep(t *testing.T) {
125+ needTools(t, "bash")
126+ rr, res := runResults(t, wfHead+` steps:
127+ - name: prepare
128+ run: |
129+ cat > bad.json <<'JSON'
130+ {"problemMatcher":[{"owner":"catastrophic","pattern":[{"regexp":"^((a+)+)$","message":1}]}]}
131+ JSON
132+ cat > good.json <<'JSON'
133+ {"problemMatcher":[{"owner":"good","severity":"warning","pattern":[{"regexp":"^good: (.+)$","message":1}]}]}
134+ JSON
135+ - name: first
136+ run: |
137+ echo "::add-matcher::bad.json"
138+ echo "::add-matcher::good.json"
139+ echo "good: before the long line"
140+ echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
141+ echo "good: after the long line"
142+ - name: second
143+ run: |
144+ echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
145+ echo "aaa"
146+ echo "good: still alive"
147+`, nil)
148+ if res.Conclusion != ConclusionSuccess {
149+ t.Fatalf("%+v", res)
150+ }
151+ 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."
152+ // The lines of the log, in order, with the long text of the removal cut.
153+ lines := func(log string) []string {
154+ var out []string
155+ for _, l := range strings.Split(log, "\n") {
156+ if strings.HasPrefix(l, removing) {
157+ l = removing
158+ }
159+ out = append(out, l)
160+ }
161+ return out
162+ }
163+ has := func(log string, want ...string) bool {
164+ got := lines(log)
165+ for i := range got {
166+ if slices.Equal(got[i:min(i+len(want), len(got))], want) {
167+ return true
168+ }
169+ }
170+ return false
171+ }
172+ const long = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
173+ first := logOf(rr.step(t, "first"))
174+ if !has(first, "##[warning]good: before the long line", removing, long, "##[warning]good: after the long line") {
175+ t.Errorf("first:\n%s", first)
176+ }
177+ second := logOf(rr.step(t, "second"))
178+ if !has(second, removing, long, "aaa", "##[warning]good: still alive") {
179+ t.Errorf("second:\n%s", second)
180+ }
181+}
182+
119 func TestMatcherTimeoutFromEnvironment(t *testing.T) {183 func TestMatcherTimeoutFromEnvironment(t *testing.T) {
120 needTools(t, "bash")184 needTools(t, "bash")
121 start := time.Now()185 start := time.Now()
M internal/guest/jobexec.go +1 -1
@@ -256,7 +256,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
256256 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
257257 RunnerVersion: spec.GetRunnerVersion(),
258258 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
259- Debug: spec.GetDebug(), Reporter: rep, Actions: executor.ActionsConfig{Resolver: res.resolve}, Log: zerowrap.FromCtx(ctx),
259+ Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),
260260 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
261261 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
262262 })
@@ -256,7 +256,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
256 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},256 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
257 RunnerVersion: spec.GetRunnerVersion(),257 RunnerVersion: spec.GetRunnerVersion(),
258 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,258 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
259- Debug: spec.GetDebug(), Reporter: rep, Actions: executor.ActionsConfig{Resolver: res.resolve}, Log: zerowrap.FromCtx(ctx),259+ Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),
260 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),260 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
261 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,261 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
262 })262 })

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