fix/step-umask a16908b

shell.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10
11	"github.com/google/uuid"
12)
13
14// Shells and script files of `run:` steps: a port of ScriptHandlerHelpers.cs
15// and of the parts of ScriptHandler.cs that build the command line
16// (Runner.Worker/Handlers). Only the Linux behaviour is ported.
17
18// defaultArguments is ScriptHandlerHelpers._defaultArguments
19// (ScriptHandlerHelpers.cs:13). Keys are compared case-insensitively.
20var defaultArguments = map[string]string{
21	"cmd":        `/D /E:ON /V:OFF /S /C "CALL "{0}""`,
22	"pwsh":       `-command ". '{0}'"`,
23	"powershell": `-command ". '{0}'"`,
24	"bash":       "--noprofile --norc -e -o pipefail {0}",
25	"sh":         "-e {0}",
26	"python":     "{0}",
27}
28
29// scriptExtensions is ScriptHandlerHelpers._extensions (:23).
30var scriptExtensions = map[string]string{
31	"cmd":        ".cmd",
32	"pwsh":       ".ps1",
33	"powershell": ".ps1",
34	"bash":       ".sh",
35	"sh":         ".sh",
36	"python":     ".py",
37}
38
39// ScriptArgumentsFormat is GetScriptArgumentsFormat (:33): the argument format
40// of a built-in shell, "" for any other command.
41func ScriptArgumentsFormat(shellType string) string {
42	return defaultArguments[strings.ToLower(shellType)]
43}
44
45// ScriptFileExtension is GetScriptFileExtension (:42): ".sh", ".py", ... or "".
46func ScriptFileExtension(shellType string) string {
47	return scriptExtensions[strings.ToLower(shellType)]
48}
49
50// FixUpScriptContents is ScriptHandlerHelpers.FixUpScriptContents (:51). The
51// switch is case-sensitive, unlike the two lookups above.
52func FixUpScriptContents(shellType, contents string) string {
53	switch shellType {
54	case "cmd":
55		// Use @echo off rather than /Q: with /Q, echo can't be turned on.
56		return "@echo off\n" + contents
57	case "powershell", "pwsh":
58		return "$ErrorActionPreference = 'stop'\n" + contents + "\n" +
59			`if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }`
60	}
61	return contents
62}
63
64// ParseShellOption is ParseShellOptionString (:70): "COMMAND [..ARGS]" split at
65// the first space.
66func ParseShellOption(shell string) (command, args string) {
67	command, args, _ = strings.Cut(shell, " ")
68	return command, args
69}
70
71// ErrInvalidShell is the error of a shell whose argument format has no {0}
72// (ScriptHandler.cs:245).
73var ErrInvalidShell error = msgError("Invalid shell option. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{0}'")
74
75// Lookup finds executables like WhichUtil.Which (Runner.Sdk/Util/WhichUtil.cs):
76// in PATH with the directories added by GITHUB_PATH in front.
77type Lookup struct {
78	// Path is the PATH of the executor process (not of the step).
79	Path string
80	// Prepend is Global.PrependPath in the order of the AddPath calls.
81	Prepend []string
82}
83
84// NotFoundError is the FileNotFoundException of Which(require: true).
85type NotFoundError struct{ Command string }
86
87func (e *NotFoundError) Error() string { return e.Command + ": command not found" }
88
89// Which returns the path of command, or "" when it is not found.
90func (l Lookup) Which(command string) string {
91	if filepath.IsAbs(command) {
92		if isFile(command) {
93			return command
94		}
95	}
96	path := PrependedPath(l.Prepend, l.Path)
97	for _, seg := range strings.Split(path, ":") {
98		if seg == "" {
99			continue
100		}
101		seg = os.ExpandEnv(seg)
102		// Directory.EnumerateFiles(seg, command): a plain file name in the directory.
103		if strings.ContainsRune(command, '/') {
104			continue
105		}
106		p := filepath.Join(seg, command)
107		if isFile(p) {
108			return p
109		}
110	}
111	return ""
112}
113
114// isFile reports whether p is a file, or a symbolic link whose target exists
115// (WhichUtil.IsPathValid).
116func isFile(p string) bool {
117	st, err := os.Stat(p)
118	return err == nil && !st.IsDir()
119}
120
121// Require is Which(command, require: true).
122func (l Lookup) Require(command string) (string, error) {
123	if p := l.Which(command); p != "" {
124		return p, nil
125	}
126	return "", &NotFoundError{command}
127}
128
129// ShellPlan is how a step's script is run: the shell chosen by the step, the
130// job defaults, or the runner's default.
131type ShellPlan struct {
132	// Command is the shell command: "sh" for the default shell (whose path is
133	// bash when found), else the first word of the shell option.
134	Command string
135	// CommandPath is the executable that is started, and shown on the
136	// "shell:" line of the log.
137	CommandPath string
138	// ArgFormat is the argument format with {0} for the script path.
139	ArgFormat string
140}
141
142// PlanShell chooses the shell like ScriptHandler.RunAsync (:170-234) and
143// PrintActionDetails (:65-117). shell is the step's `shell` or, when empty,
144// defaults.run.shell (the caller does that merge, :171-180).
145//
146// With no shell: bash if it is in PATH, else sh, with the arguments of "sh"
147// (-e {0}). "sh" is only the name used for the arguments and the extension.
148func PlanShell(shell string, l Lookup) (ShellPlan, error) {
149	if shell == "" {
150		p, err := l.lookupBashOrSh()
151		if err != nil {
152			return ShellPlan{}, err
153		}
154		return checkFormat(ShellPlan{Command: "sh", CommandPath: p, ArgFormat: ScriptArgumentsFormat("sh")})
155	}
156	cmd, args := ParseShellOption(shell)
157	p, err := l.Require(cmd)
158	if err != nil {
159		return ShellPlan{}, err
160	}
161	format := strings.TrimLeft(args, " \t\n\v\f\r")
162	if format == "" {
163		format = ScriptArgumentsFormat(cmd)
164	}
165	return checkFormat(ShellPlan{Command: cmd, CommandPath: p, ArgFormat: format})
166}
167
168func (l Lookup) lookupBashOrSh() (string, error) {
169	if p := l.Which("bash"); p != "" {
170		return p, nil
171	}
172	return l.Require("sh")
173}
174
175func checkFormat(p ShellPlan) (ShellPlan, error) {
176	if p.ArgFormat == "" || !strings.Contains(p.ArgFormat, "{0}") {
177		return p, ErrInvalidShell
178	}
179	return p, nil
180}
181
182// ShellLine is the text after "shell: " in the step header (:121-126).
183func (p ShellPlan) ShellLine() string { return p.CommandPath + " " + p.ArgFormat }
184
185// Extension is the extension of the script file, from the shell command.
186func (p ShellPlan) Extension() string { return ScriptFileExtension(p.Command) }
187
188// FormatArguments is string.Format(argFormat, path) of .NET with one argument:
189// {0} is the path, {{ and }} are braces; anything else is a format error. The
190// script path has its double quotes escaped first (ScriptHandler.cs:252).
191func FormatArguments(format, scriptPath string) (string, error) {
192	path := strings.ReplaceAll(scriptPath, `"`, `\"`)
193	var b strings.Builder
194	for i := 0; i < len(format); i++ {
195		switch c := format[i]; {
196		case c == '{' && i+1 < len(format) && format[i+1] == '{':
197			b.WriteByte('{')
198			i++
199		case c == '}' && i+1 < len(format) && format[i+1] == '}':
200			b.WriteByte('}')
201			i++
202		case c == '{':
203			end := strings.IndexByte(format[i:], '}')
204			if end < 0 {
205				return "", ghErrorf("Input string was not in a correct format.")
206			}
207			spec := format[i+1 : i+end]
208			index, _, _ := strings.Cut(spec, ",")
209			index, _, _ = strings.Cut(index, ":")
210			if strings.TrimSpace(index) != "0" {
211				return "", ghErrorf("Index (zero based) must be greater than or equal to zero and less than the size of the argument list.")
212			}
213			b.WriteString(path)
214			i += end
215		case c == '}':
216			return "", ghErrorf("Input string was not in a correct format.")
217		default:
218			b.WriteByte(c)
219		}
220	}
221	return b.String(), nil
222}
223
224// ParseArguments splits an argument string into arguments the way .NET does
225// for Process.Start on Unix (Process.Unix.cs ParseArgumentsIntoList): spaces
226// and tabs separate; double quotes group; backslashes only count before a
227// quote.
228func ParseArguments(s string) []string {
229	var out []string
230	for i := 0; i < len(s); i++ {
231		for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
232			i++
233		}
234		if i == len(s) {
235			break
236		}
237		var cur strings.Builder
238		inQuotes := false
239		for i < len(s) {
240			n := 0
241			for i < len(s) && s[i] == '\\' {
242				i++
243				n++
244			}
245			if n > 0 {
246				if i >= len(s) || s[i] != '"' {
247					cur.WriteString(strings.Repeat(`\`, n))
248				} else {
249					cur.WriteString(strings.Repeat(`\`, n/2))
250					if n%2 != 0 {
251						cur.WriteByte('"')
252						i++
253					}
254				}
255				continue
256			}
257			c := s[i]
258			if c == '"' {
259				if inQuotes && i < len(s)-1 && s[i+1] == '"' {
260					cur.WriteByte('"')
261					i++
262				} else {
263					inQuotes = !inQuotes
264				}
265				i++
266				continue
267			}
268			if (c == ' ' || c == '\t') && !inQuotes {
269				break
270			}
271			cur.WriteByte(c)
272			i++
273		}
274		out = append(out, cur.String())
275	}
276	return out
277}
278
279// Script is a script written to the temp directory, ready to run.
280type Script struct {
281	// Path is the script file.
282	Path string
283	// Argv is the command: the shell and its arguments with the script path.
284	Argv []string
285}
286
287// WriteScript writes the script of a step to the directory of root (RUNNER_TEMP)
288// under a random name with the extension of the shell, and builds the command
289// line (ScriptHandler.RunAsync :247-285). The file has no BOM and is not
290// executable: the shell reads it. The directory belongs to the user of the steps,
291// so the file is created through root, exclusively and without following links
292// (see createExcl).
293func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) {
294	return WriteScriptFor(root, p, contents, nil)
295}
296
297// WriteScriptFor is WriteScript for a shell that sees the file under another
298// path: tr translates the host path for the arguments (a job container sees
299// RUNNER_TEMP as /__w/_temp, ScriptHandler.cs:252). Script.Path stays the
300// host's.
301func WriteScriptFor(root *os.Root, p ShellPlan, contents string, tr func(string) string) (Script, error) {
302	name := uuid.NewString() + p.Extension()
303	path := filepath.Join(root.Name(), name)
304	shown := path
305	if tr != nil {
306		shown = tr(path)
307	}
308	args, err := FormatArguments(p.ArgFormat, shown)
309	if err != nil {
310		return Script{}, err
311	}
312	contents = FixUpScriptContents(p.Command, contents)
313	if err := createExcl(root, name, []byte(contents), 0o644, nil); err != nil {
314		return Script{}, fmt.Errorf("write script: %w", err)
315	}
316	return Script{Path: path, Argv: append([]string{p.CommandPath}, ParseArguments(args)...)}, nil
317}
318
319// ResolveWorkingDirectory is Path.Combine(workspace, workingDirectory ?? "")
320// (ScriptHandler.cs:168): an absolute directory replaces the workspace.
321func ResolveWorkingDirectory(workspace, dir string) string {
322	if filepath.IsAbs(dir) {
323		return dir
324	}
325	if dir == "" {
326		return workspace
327	}
328	return strings.TrimRight(workspace, "/") + "/" + dir
329}
330
331// PlanShellInContainer is the shell of a step that runs in the job container
332// (ScriptHandler.cs:73-117, :182-232 with isContainerStepHost): nothing is
333// looked up, the shell is found by the container itself, so the default is
334// always "sh -e {0}" even when the image has bash.
335func PlanShellInContainer(shell string) (ShellPlan, error) {
336	if shell == "" {
337		return checkFormat(ShellPlan{Command: "sh", CommandPath: "sh", ArgFormat: ScriptArgumentsFormat("sh")})
338	}
339	cmd, args := ParseShellOption(shell)
340	format := strings.TrimLeft(args, " \t\n\v\f\r")
341	if format == "" {
342		format = ScriptArgumentsFormat(cmd)
343	}
344	return checkFormat(ShellPlan{Command: cmd, CommandPath: cmd, ArgFormat: format})
345}

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