v0.1.0 cbb8ad6

commands.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"fmt"
7	"strings"
8	"unicode"
9)
10
11// 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.
15
16type escapeMapping struct{ token, replacement string }
17
18// _escapeMappings, _escapeDataMappings, _escapePropertyMappings of
19// ActionCommand.cs:11-35, in order: the replacements run one after the other.
20var (
21	escapeMappings = []escapeMapping{{";", "%3B"}, {"\r", "%0D"}, {"\n", "%0A"}, {"]", "%5D"}, {"%", "%25"}}
22	escapeData     = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {"%", "%25"}}
23	escapeProperty = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {":", "%3A"}, {",", "%2C"}, {"%", "%25"}}
24)
25
26func unescape(s string, maps []escapeMapping) string {
27	for _, m := range maps {
28		s = strings.ReplaceAll(s, m.replacement, m.token)
29	}
30	return s
31}
32
33// actionCommand is a parsed workflow command.
34type actionCommand struct {
35	Name  string
36	Props map[string]string // keys compared case-insensitively (lower-cased)
37	Data  string
38}
39
40func (c *actionCommand) prop(name string) string { return c.Props[strings.ToLower(name)] }
41
42const commandKey = "::"
43
44// parseCommandV2 is ActionCommand.TryParseV2 (ActionCommand.cs:57): "::name
45// key=value,key=value::data".
46func parseCommandV2(message string, registered func(string) bool) (*actionCommand, bool) {
47	// .NET TrimStart() trims Unicode white space.
48	message = strings.TrimLeftFunc(message, unicode.IsSpace)
49	if !strings.HasPrefix(message, commandKey) {
50		return nil, false
51	}
52	end := strings.Index(message[len(commandKey):], commandKey)
53	if end < 0 {
54		return nil, false
55	}
56	end += len(commandKey)
57	info := message[len(commandKey):end]
58	name, rest, hasSpace := strings.Cut(info, " ")
59	if !registered(name) {
60		return nil, false
61	}
62	cmd := &actionCommand{Name: name, Props: map[string]string{}}
63	if hasSpace && len(info) > len(name) { // spaceIndex > 0
64		for _, p := range strings.Split(strings.TrimSpace(rest), ",") {
65			if p == "" {
66				continue
67			}
68			k, v, ok := strings.Cut(p, "=")
69			if ok && k != "" && v != "" { // Split(RemoveEmptyEntries): both parts non-empty
70				cmd.Props[strings.ToLower(k)] = unescape(v, escapeProperty)
71			}
72		}
73	}
74	cmd.Data = unescape(message[end+len(commandKey):], escapeData)
75	return cmd, true
76}
77
78// parseCommandV1 is ActionCommand.TryParse (ActionCommand.cs:127): the legacy
79// "##[name key=value;key=value]data".
80func parseCommandV1(message string, registered func(string) bool) (*actionCommand, bool) {
81	const prefix = "##["
82	pi := strings.Index(message, prefix)
83	if pi < 0 {
84		return nil, false
85	}
86	rb := strings.IndexByte(message[pi:], ']')
87	if rb < 0 {
88		return nil, false
89	}
90	rb += pi
91	info := message[pi+len(prefix) : rb]
92	name, rest, hasSpace := strings.Cut(info, " ")
93	if !registered(name) {
94		return nil, false
95	}
96	cmd := &actionCommand{Name: name, Props: map[string]string{}}
97	if hasSpace && len(info) > len(name) {
98		for _, p := range strings.Split(rest, ";") {
99			if p == "" {
100				continue
101			}
102			k, v, ok := strings.Cut(p, "=")
103			if ok && k != "" && v != "" {
104				cmd.Props[strings.ToLower(k)] = unescape(v, escapeMappings)
105			}
106		}
107	}
108	cmd.Data = unescape(message[rb+1:], escapeMappings)
109	return cmd, true
110}
111
112// Messages of Constants.cs:234-236.
113const (
114	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/"
115	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/"
116	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`."
117)
118
119// 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.
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}
143
144// commandManager is ActionCommandManager: one per step.
145type commandManager struct {
146	host       commandHost
147	registered map[string]bool
148	stopped    bool
149	stopToken  string
150	echo       bool
151}
152
153func newCommandManager(h commandHost, echo bool) *commandManager {
154	m := &commandManager{host: h, registered: map[string]bool{}, echo: echo}
155	for _, n := range commandNames {
156		m.registered[strings.ToLower(n)] = true
157	}
158	return m
159}
160
161func (m *commandManager) isRegistered(name string) bool { return m.registered[strings.ToLower(name)] }
162
163// TryProcess handles one line of output. It returns true when the line was a
164// command and must not be logged as plain text (ActionCommandManager.cs:61).
165func (m *commandManager) TryProcess(input string) bool {
166	if input == "" {
167		return false
168	}
169	cmd, ok := parseCommandV2(input, m.isRegistered)
170	if !ok {
171		cmd, ok = parseCommandV1(input, m.isRegistered)
172	}
173	if !ok {
174		return false
175	}
176	name := strings.ToLower(cmd.Name)
177	if m.stopped {
178		if m.stopToken != "" && strings.EqualFold(cmd.Name, m.stopToken) {
179			m.host.Out(input)
180			m.host.Debug("Resume processing commands")
181			delete(m.registered, strings.ToLower(m.stopToken))
182			m.stopped, m.stopToken = false, ""
183			return true
184		}
185		m.host.Debug(fmt.Sprintf("Process commands has been stopped and waiting for '##[%s]' to resume.", m.stopToken))
186		return false
187	}
188	if name == "stop-commands" {
189		if err := m.validateStopToken(cmd.Data); err != nil {
190			m.fail(input, false, err)
191			return true
192		}
193		m.stopToken, m.stopped = cmd.Data, true
194		m.registered[strings.ToLower(cmd.Data)] = true
195		m.host.Out(input)
196		m.host.Debug("Paused processing commands until the token you called ::stopCommands:: with is received")
197		return true
198	}
199	omitEcho := name == "debug" || name == "warning" || name == "error" || name == "notice" || name == "add-mask"
200	if m.echo && !omitEcho {
201		m.host.Out(input)
202	}
203	if err := m.run(name, cmd); err != nil {
204		m.fail(input, omitEcho, err)
205	}
206	return true
207}
208
209func (m *commandManager) fail(input string, omitEcho bool, err error) {
210	info := input
211	if omitEcho {
212		info = ""
213	}
214	m.host.Tagged("##[error]", fmt.Sprintf("Unable to process command '%s' successfully.", info))
215	m.host.Tagged("##[error]", err.Error())
216	m.host.CommandFailed()
217}
218
219func (m *commandManager) validateStopToken(token string) error {
220	invalid := m.isRegistered(token) || token == "" || strings.EqualFold(token, "pause-logging")
221	if invalid && !m.host.EnvFlag("ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS") {
222		return msgError(unsupportedStopTokenDisabled)
223	}
224	return nil
225}
226
227func (m *commandManager) run(name string, c *actionCommand) error {
228	h := m.host
229	switch name {
230	case "group":
231		h.Out("##[group]" + c.Data) // GroupingCommandExtension, ActionCommandManager.cs:802
232	case "endgroup":
233		h.Out("##[endgroup]")
234	case "debug":
235		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)
240	case "echo":
241		if c.Data == "" {
242			return ghErrorf("Value cannot be null or empty. (Parameter 'value')")
243		}
244		switch strings.ToUpper(strings.TrimSpace(c.Data)) {
245		case "ON":
246			m.echo = true
247			h.Debug("Setting echo command value to 'on'")
248		case "OFF":
249			m.echo = false
250			h.Debug("Setting echo command value to 'off'")
251		default:
252			return ghErrorf("Invalid echo command value. Possible values can be: 'on', 'off'. Current value is: '%s'.", c.Data)
253		}
254	case "set-output":
255		h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
256		n := c.prop("name")
257		if n == "" {
258			return ghErrorf("Required field 'name' is missing in ##[set-output] command.")
259		}
260		h.SetOutput(n, c.Data)
261		h.Debug(fmt.Sprintf("steps.<id>.outputs.%s='%s'", n, c.Data))
262	case "save-state":
263		h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
264		n := c.prop("name")
265		if n == "" {
266			return ghErrorf("Required field 'name' is missing in ##[save-state] command.")
267		}
268		h.SaveState(n, c.Data)
269		h.Debug(fmt.Sprintf("Save intra-action state %s = %s", n, c.Data))
270	case "set-env":
271		if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
272			return ghErrorf(unsupportedCommandMessageDisabled, name)
273		}
274		n := c.prop("name")
275		if n == "" {
276			return ghErrorf("Required field 'name' is missing in ##[set-env] command.")
277		}
278		if b := blockedEnvName(n); b != "" {
279			h.Tagged("##[error]", fmt.Sprintf("Can't update %s environment variable using ::set-env:: command.", b))
280			return nil
281		}
282		h.SetEnv(n, c.Data)
283		h.Debug(fmt.Sprintf("%s='%s'", n, c.Data))
284	case "add-path":
285		if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
286			return ghErrorf(unsupportedCommandMessageDisabled, name)
287		}
288		if c.Data == "" {
289			return ghErrorf("Value cannot be null or empty. (Parameter 'path')")
290		}
291		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))
295	}
296	return nil
297}

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