detached 77f2262

feat(executor): annotations, problem matchers, summaries, step outcome and timeout-minutes

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

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