v0.5.0 22211fd

commands.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"errors"
7	"fmt"
8	"io/fs"
9	"os"
10	"path/filepath"
11	"strings"
12	"unicode"
13)
14
15// Workflow commands in the output of a step: a port of ActionCommand.cs
16// (Runner.Common) and of ActionCommandManager.cs.
17
18type escapeMapping struct{ token, replacement string }
19
20// _escapeMappings, _escapeDataMappings, _escapePropertyMappings of
21// ActionCommand.cs:11-35, in order: the replacements run one after the other.
22var (
23	escapeMappings = []escapeMapping{{";", "%3B"}, {"\r", "%0D"}, {"\n", "%0A"}, {"]", "%5D"}, {"%", "%25"}}
24	escapeData     = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {"%", "%25"}}
25	escapeProperty = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {":", "%3A"}, {",", "%2C"}, {"%", "%25"}}
26)
27
28func unescape(s string, maps []escapeMapping) string {
29	for _, m := range maps {
30		s = strings.ReplaceAll(s, m.replacement, m.token)
31	}
32	return s
33}
34
35// actionCommand is a parsed workflow command.
36type actionCommand struct {
37	Name  string
38	Props map[string]string // keys compared case-insensitively (lower-cased)
39	Data  string
40}
41
42func (c *actionCommand) prop(name string) string { return c.Props[strings.ToLower(name)] }
43
44const commandKey = "::"
45
46// parseCommandV2 is ActionCommand.TryParseV2 (ActionCommand.cs:57): "::name
47// key=value,key=value::data".
48func parseCommandV2(message string, registered func(string) bool) (*actionCommand, bool) {
49	// .NET TrimStart() trims Unicode white space.
50	message = strings.TrimLeftFunc(message, unicode.IsSpace)
51	if !strings.HasPrefix(message, commandKey) {
52		return nil, false
53	}
54	end := strings.Index(message[len(commandKey):], commandKey)
55	if end < 0 {
56		return nil, false
57	}
58	end += len(commandKey)
59	info := message[len(commandKey):end]
60	name, rest, hasSpace := strings.Cut(info, " ")
61	if !registered(name) {
62		return nil, false
63	}
64	cmd := &actionCommand{Name: name, Props: map[string]string{}}
65	if hasSpace && len(info) > len(name) { // spaceIndex > 0
66		for _, p := range strings.Split(strings.TrimSpace(rest), ",") {
67			if p == "" {
68				continue
69			}
70			k, v, ok := strings.Cut(p, "=")
71			if ok && k != "" && v != "" { // Split(RemoveEmptyEntries): both parts non-empty
72				cmd.Props[strings.ToLower(k)] = unescape(v, escapeProperty)
73			}
74		}
75	}
76	cmd.Data = unescape(message[end+len(commandKey):], escapeData)
77	return cmd, true
78}
79
80// parseCommandV1 is ActionCommand.TryParse (ActionCommand.cs:127): the legacy
81// "##[name key=value;key=value]data".
82func parseCommandV1(message string, registered func(string) bool) (*actionCommand, bool) {
83	const prefix = "##["
84	pi := strings.Index(message, prefix)
85	if pi < 0 {
86		return nil, false
87	}
88	rb := strings.IndexByte(message[pi:], ']')
89	if rb < 0 {
90		return nil, false
91	}
92	rb += pi
93	info := message[pi+len(prefix) : rb]
94	name, rest, hasSpace := strings.Cut(info, " ")
95	if !registered(name) {
96		return nil, false
97	}
98	cmd := &actionCommand{Name: name, Props: map[string]string{}}
99	if hasSpace && len(info) > len(name) {
100		for _, p := range strings.Split(rest, ";") {
101			if p == "" {
102				continue
103			}
104			k, v, ok := strings.Cut(p, "=")
105			if ok && k != "" && v != "" {
106				cmd.Props[strings.ToLower(k)] = unescape(v, escapeMappings)
107			}
108		}
109	}
110	cmd.Data = unescape(message[rb+1:], escapeMappings)
111	return cmd, true
112}
113
114// Messages of Constants.cs:234-236.
115const (
116	unsupportedCommandMessage         = "The `%s` command is deprecated and will be disabled soon. Please upgrade to using Environment Files. For more information see: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/"
117	unsupportedCommandMessageDisabled = "The `%s` command is disabled. Please upgrade to using Environment Files or opt into unsecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_COMMANDS` environment variable to `true`. For more information see: https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/"
118	unsupportedStopTokenDisabled      = "You cannot use a endToken that is an empty string, the string 'pause-logging', or another workflow command. For more information see: https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#example-stopping-and-starting-workflow-commands or opt into insecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS` environment variable to `true`."
119)
120
121// commandNames are the commands the executor registers, in ActionCommandManager
122// (the names of the extensions at ActionCommandManager.cs).
123var commandNames = []string{
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",
126}
127
128// commandHost is what commands act on: the step that is running.
129type commandHost interface {
130	// Out writes a log line (several when it contains line feeds).
131	Out(text string)
132	// Tagged writes a ##[tag]message line.
133	Tagged(tag, message string)
134	Debug(msg string)
135	SetOutput(name, value string)
136	SaveState(name, value string)
137	SetEnv(name, value string)
138	AddPath(path string)
139	CommandFailed()
140	// EnvFlag reads a variable of the env context, then of the process.
141	EnvFlag(name string) bool
142	// AddIssue is ExecutionContext.AddIssue: it logs the issue and keeps it for
143	// the annotations of the step.
144	AddIssue(issue)
145	// Workspace is github.workspace.
146	Workspace() string
147	// HostPath translates a path printed by a process in the job container
148	// back to the host's (ContainerInfo.TranslateToHostPath); without a
149	// container it is the identity.
150	HostPath(p string) string
151	// AddMask is SecretMasker.AddValue.
152	AddMask(value string)
153	// Matchers is the root of the problem matchers of the job.
154	Matchers() *matcherRoot
155	// ReadMatcherFile reads a problem matcher file (IOUtil.LoadObject).
156	ReadMatcherFile(path string) ([]byte, error)
157}
158
159// commandManager is ActionCommandManager: one per step.
160type commandManager struct {
161	host       commandHost
162	registered map[string]bool
163	stopped    bool
164	stopToken  string
165	echo       bool
166	// fatal is the exception that ActionCommandManager.ValidateStopToken throws
167	// outside the try block of TryProcessCommand: it leaves OutputDataReceived,
168	// ProcessInvoker.ExecuteAsync and the step with it, so the step fails with
169	// the message alone and the output that follows is never read. The
170	// official runner leaves the process running (ProcessInvoker is only
171	// disposed; "Complete job" kills it as an orphan); ermite stops it through
172	// the cancellation path (SIGINT, SIGTERM, SIGKILL) so that the step does not
173	// wait for it, and discards what it still writes.
174	fatal error
175	// onFatal, when set, is called once when fatal is set: the process that
176	// writes the output is then stopped (runProc).
177	onFatal func()
178}
179
180func newCommandManager(h commandHost, echo bool) *commandManager {
181	m := &commandManager{host: h, registered: map[string]bool{}, echo: echo}
182	for _, n := range commandNames {
183		m.registered[strings.ToLower(n)] = true
184	}
185	return m
186}
187
188func (m *commandManager) isRegistered(name string) bool { return m.registered[strings.ToLower(name)] }
189
190// TryProcess handles one line of output. It returns true when the line was a
191// command and must not be logged as plain text (ActionCommandManager.cs:61).
192func (m *commandManager) TryProcess(input string) bool {
193	if input == "" || m.fatal != nil {
194		return false
195	}
196	cmd, ok := parseCommandV2(input, m.isRegistered)
197	if !ok {
198		cmd, ok = parseCommandV1(input, m.isRegistered)
199	}
200	if !ok {
201		return false
202	}
203	name := strings.ToLower(cmd.Name)
204	if m.stopped {
205		if m.stopToken != "" && strings.EqualFold(cmd.Name, m.stopToken) {
206			m.host.Out(input)
207			m.host.Debug("Resume processing commands")
208			delete(m.registered, strings.ToLower(m.stopToken))
209			m.stopped, m.stopToken = false, ""
210			return true
211		}
212		m.host.Debug(fmt.Sprintf("Process commands has been stopped and waiting for '##[%s]' to resume.", m.stopToken))
213		return false
214	}
215	if name == "stop-commands" {
216		if err := m.validateStopToken(cmd.Data); err != nil {
217			m.fatal = err
218			if m.onFatal != nil {
219				m.onFatal()
220			}
221			return true
222		}
223		m.stopToken, m.stopped = cmd.Data, true
224		m.registered[strings.ToLower(cmd.Data)] = true
225		if len(m.stopToken) > 6 { // ActionCommandManager.cs:117
226			m.host.AddMask(m.stopToken)
227		}
228		m.host.Out(input)
229		m.host.Debug("Paused processing commands until the token you called ::stopCommands:: with is received")
230		return true
231	}
232	omitEcho := name == "debug" || name == "warning" || name == "error" || name == "notice" || name == "add-mask"
233	if m.echo && !omitEcho {
234		m.host.Out(input)
235	}
236	if err := m.run(name, cmd); err != nil {
237		m.fail(input, omitEcho, err)
238	}
239	return true
240}
241
242func (m *commandManager) fail(input string, omitEcho bool, err error) {
243	info := input
244	if omitEcho {
245		info = ""
246	}
247	m.host.Tagged("##[error]", fmt.Sprintf("Unable to process command '%s' successfully.", info))
248	m.host.Tagged("##[error]", err.Error())
249	m.host.CommandFailed()
250}
251
252func (m *commandManager) validateStopToken(token string) error {
253	invalid := m.isRegistered(token) || token == "" || strings.EqualFold(token, "pause-logging")
254	if invalid && !m.host.EnvFlag("ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS") {
255		return msgError(unsupportedStopTokenDisabled)
256	}
257	return nil
258}
259
260func (m *commandManager) run(name string, c *actionCommand) error {
261	h := m.host
262	switch name {
263	case "group":
264		h.Out("##[group]" + c.Data) // GroupingCommandExtension, ActionCommandManager.cs:802
265	case "endgroup":
266		h.Out("##[endgroup]")
267	case "debug":
268		h.Debug(c.Data)
269	case "warning":
270		m.commandIssue(LevelWarning, name, c)
271	case "error":
272		m.commandIssue(LevelFailure, name, c)
273	case "notice":
274		m.commandIssue(LevelNotice, name, c)
275	case "echo":
276		if c.Data == "" {
277			// ArgUtil.NotNullOrEmpty throws ArgumentNullException.
278			return ghErrorf("Value cannot be null. (Parameter 'value')")
279		}
280		switch strings.ToUpper(strings.TrimSpace(c.Data)) {
281		case "ON":
282			m.echo = true
283			h.Debug("Setting echo command value to 'on'")
284		case "OFF":
285			m.echo = false
286			h.Debug("Setting echo command value to 'off'")
287		default:
288			return ghErrorf("Invalid echo command value. Possible values can be: 'on', 'off'. Current value is: '%s'.", c.Data)
289		}
290	case "set-output":
291		h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
292		n := c.prop("name")
293		if n == "" {
294			return ghErrorf("Required field 'name' is missing in ##[set-output] command.")
295		}
296		h.SetOutput(n, c.Data)
297		h.Debug(fmt.Sprintf("steps.<id>.outputs.%s='%s'", n, c.Data))
298	case "save-state":
299		h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
300		n := c.prop("name")
301		if n == "" {
302			return ghErrorf("Required field 'name' is missing in ##[save-state] command.")
303		}
304		h.SaveState(n, c.Data)
305		h.Debug(fmt.Sprintf("Save intra-action state %s = %s", n, c.Data))
306	case "set-env":
307		if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
308			return ghErrorf(unsupportedCommandMessageDisabled, name)
309		}
310		n := c.prop("name")
311		if n == "" {
312			return ghErrorf("Required field 'name' is missing in ##[set-env] command.")
313		}
314		if b := blockedEnvName(n); b != "" {
315			h.Tagged("##[error]", fmt.Sprintf("Can't update %s environment variable using ::set-env:: command.", b))
316			return nil
317		}
318		h.SetEnv(n, c.Data)
319		h.Debug(fmt.Sprintf("%s='%s'", n, c.Data))
320	case "add-path":
321		if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
322			return ghErrorf(unsupportedCommandMessageDisabled, name)
323		}
324		if c.Data == "" {
325			return ghErrorf("Value cannot be null. (Parameter 'path')")
326		}
327		h.AddPath(c.Data)
328	case "add-mask":
329		m.addMask(c)
330	case "add-matcher":
331		return m.addMatcher(c)
332	case "remove-matcher":
333		return m.removeMatcher(c)
334	}
335	return nil
336}
337
338// warn is IExecutionContext.Warning.
339func (m *commandManager) warn(msg string) { m.host.AddIssue(issue{Level: LevelWarning, Message: msg}) }
340
341// addMask is AddMaskCommandExtension (ActionCommandManager.cs:402-424).
342func (m *commandManager) addMask(c *actionCommand) {
343	if strings.TrimFunc(c.Data, unicode.IsSpace) == "" {
344		m.warn("Can't add secret mask for empty string in ##[add-mask] command.")
345		return
346	}
347	if m.echo {
348		m.host.Out("::add-mask::***")
349	}
350	m.host.AddMask(c.Data)
351	// Also add each individual line. Typically individual lines are processed
352	// from STDOUT of child processes.
353	for _, item := range strings.FieldsFunc(c.Data, func(r rune) bool { return r == '\r' || r == '\n' }) {
354		if item = strings.TrimFunc(item, unicode.IsSpace); item != "" {
355			m.host.AddMask(item)
356		}
357	}
358}
359
360// matcherPath roots a path of add-matcher and remove-matcher in the workspace.
361func (m *commandManager) matcherPath(file string) string {
362	file = m.host.HostPath(file) // ActionCommandManager.cs:513-517, :575-580
363	if !filepath.IsAbs(file) {
364		return filepath.Join(m.host.Workspace(), file)
365	}
366	return file
367}
368
369// loadMatchers is IOUtil.LoadObject<IssueMatchersConfig>.
370func (m *commandManager) loadMatchers(file string) (*matchersConfig, error) {
371	b, err := m.host.ReadMatcherFile(file)
372	if err != nil {
373		return nil, dotnetFileError(file, err)
374	}
375	return parseMatchersConfig(b)
376}
377
378// dotnetFileError gives the error of a file that cannot be read the text of
379// the .NET exception (FileNotFoundException, DirectoryNotFoundException).
380func dotnetFileError(file string, err error) error {
381	switch {
382	case errors.Is(err, fs.ErrNotExist):
383		if st, e := os.Stat(filepath.Dir(file)); e != nil || !st.IsDir() {
384			return ghErrorf("Could not find a part of the path '%s'.", file)
385		}
386		return ghErrorf("Could not find file '%s'.", file)
387	case errors.Is(err, fs.ErrPermission):
388		return ghErrorf("Access to the path '%s' is denied.", file)
389	}
390	return err
391}
392
393// addMatcher is AddMatcherCommandExtension (ActionCommandManager.cs:496-536).
394func (m *commandManager) addMatcher(c *actionCommand) error {
395	if c.Data == "" {
396		m.warn("File path must be specified.")
397		return nil
398	}
399	cfg, err := m.loadMatchers(m.matcherPath(c.Data))
400	if err != nil {
401		return err
402	}
403	if cfg != nil && len(cfg.Matchers) > 0 {
404		if err := cfg.validate(); err != nil {
405			return err
406		}
407		m.host.Debug(m.host.Matchers().add(cfg))
408	}
409	return nil
410}
411
412// removeMatcher is RemoveMatcherCommandExtension (ActionCommandManager.cs:538-607).
413func (m *commandManager) removeMatcher(c *actionCommand) error {
414	owner, file := c.prop("owner"), c.Data
415	if owner != "" && file != "" {
416		m.warn("Either specify an owner name or a file path in ##[remove-matcher] command. Both values cannot be set.")
417		return nil
418	}
419	if owner == "" && file == "" {
420		m.warn("Either an owner name or a file path must be specified in ##[remove-matcher] command.")
421		return nil
422	}
423	if owner != "" {
424		m.host.Debug(m.host.Matchers().remove([]string{owner}))
425		return nil
426	}
427	cfg, err := m.loadMatchers(m.matcherPath(file))
428	if err != nil {
429		return err
430	}
431	if cfg != nil && len(cfg.Matchers) > 0 {
432		var owners []string
433		for _, mc := range cfg.Matchers {
434			owners = append(owners, mc.Owner)
435		}
436		m.host.Debug(m.host.Matchers().remove(owners))
437	}
438	return nil
439}

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