detached 3470aad

feat(executor): port the file commands (GITHUB_ENV, OUTPUT, PATH, STATE, STEP_SUMMARY)

commit
3470aadb64007805314273d41aa436e47d1ed9af
author
brice · · 2026-10-12 17:30 UTC
parent
ca0f9b5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +586 -4
M go.mod +2 -2
@@ -5,6 +5,7 @@ go 1.27.1
55 require (
66 github.com/bnema/zerowrap v1.4.1
77 github.com/containerd/errdefs v1.0.0
8+ github.com/google/uuid v1.6.0
89 github.com/moby/moby/api v1.56.1
910 github.com/moby/moby/client v0.6.2
1011 golang.org/x/sys v0.49.0
@@ -28,7 +29,7 @@ require (
2829 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect
2930 cel.dev/cel-go v0.32.0 // indirect
3031 cel.dev/expr v0.25.3 // indirect
31- connectrpc.com/connect v1.20.0 // indirect
32+ connectrpc.com/connect v1.21.0 // indirect
3233 connectrpc.com/otelconnect v0.9.0 // indirect
3334 github.com/Microsoft/go-winio v0.6.2 // indirect
3435 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
@@ -49,7 +50,6 @@ require (
4950 github.com/go-logr/stdr v1.2.2 // indirect
5051 github.com/gofrs/flock v0.13.1 // indirect
5152 github.com/google/go-containerregistry v0.22.1 // indirect
52- github.com/google/uuid v1.6.0 // indirect
5353 github.com/inconshreveable/mousetrap v1.1.0 // indirect
5454 github.com/jdx/go-netrc v1.0.0 // indirect
5555 github.com/klauspost/compress v1.20.0 // indirect
@@ -5,6 +5,7 @@ go 1.27.1
5 require (5 require (
6 github.com/bnema/zerowrap v1.4.16 github.com/bnema/zerowrap v1.4.1
7 github.com/containerd/errdefs v1.0.07 github.com/containerd/errdefs v1.0.0
8+ github.com/google/uuid v1.6.0
8 github.com/moby/moby/api v1.56.19 github.com/moby/moby/api v1.56.1
9 github.com/moby/moby/client v0.6.210 github.com/moby/moby/client v0.6.2
10 golang.org/x/sys v0.49.011 golang.org/x/sys v0.49.0
@@ -28,7 +29,7 @@ require (
28 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect29 buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect
29 cel.dev/cel-go v0.32.0 // indirect30 cel.dev/cel-go v0.32.0 // indirect
30 cel.dev/expr v0.25.3 // indirect31 cel.dev/expr v0.25.3 // indirect
31- connectrpc.com/connect v1.20.0 // indirect32+ connectrpc.com/connect v1.21.0 // indirect
32 connectrpc.com/otelconnect v0.9.0 // indirect33 connectrpc.com/otelconnect v0.9.0 // indirect
33 github.com/Microsoft/go-winio v0.6.2 // indirect34 github.com/Microsoft/go-winio v0.6.2 // indirect
34 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect35 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
@@ -49,7 +50,6 @@ require (
49 github.com/go-logr/stdr v1.2.2 // indirect50 github.com/go-logr/stdr v1.2.2 // indirect
50 github.com/gofrs/flock v0.13.1 // indirect51 github.com/gofrs/flock v0.13.1 // indirect
51 github.com/google/go-containerregistry v0.22.1 // indirect52 github.com/google/go-containerregistry v0.22.1 // indirect
52- github.com/google/uuid v1.6.0 // indirect
53 github.com/inconshreveable/mousetrap v1.1.0 // indirect53 github.com/inconshreveable/mousetrap v1.1.0 // indirect
54 github.com/jdx/go-netrc v1.0.0 // indirect54 github.com/jdx/go-netrc v1.0.0 // indirect
55 github.com/klauspost/compress v1.20.0 // indirect55 github.com/klauspost/compress v1.20.0 // indirect
M go.sum +2 -2 generated file: collapsed
A internal/executor/filecmd.go +351 -0
@@ -0,0 +1,351 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package executor runs the steps of a GitHub Actions job inside a sandbox.
4+// It is a port of the parts of the official runner (actions/runner, C#, MIT)
5+// that CI-1 needs: file commands, shells, contexts and the steps runner. The
6+// package does no sandboxing and no networking: it runs local processes and
7+// reports to a Reporter.
8+package executor
9+
10+import (
11+ "bytes"
12+ "errors"
13+ "fmt"
14+ "os"
15+ "path/filepath"
16+ "slices"
17+ "strings"
18+ "unicode/utf8"
19+
20+ "github.com/google/uuid"
21+)
22+
23+// Names of the file commands (the GitHub context names) and their
24+// environment variables.
25+const (
26+ cmdPath = "path"
27+ cmdEnv = "env"
28+ cmdStepSummary = "step_summary"
29+ cmdState = "state"
30+ cmdOutput = "output"
31+)
32+
33+// fileCommand describes one file command like the IFileCommandExtension of
34+// FileCommandManager.cs.
35+type fileCommand struct {
36+ ContextName string // github context name; the variable is GITHUB_<UPPER>
37+ FilePrefix string
38+}
39+
40+// fileCommands is in the order of the official runner's extension list, which
41+// is the order ProcessFiles runs them.
42+var fileCommands = []fileCommand{
43+ {cmdPath, "add_path_"}, // AddPathFileCommand (FileCommandManager.cs:127)
44+ {cmdEnv, "set_env_"}, // SetEnvFileCommand (:160)
45+ {cmdStepSummary, "step_summary_"}, // CreateStepSummaryCommand (:221)
46+ {cmdState, "save_state_"}, // SaveStateFileCommand (:295)
47+ {cmdOutput, "set_output_"}, // SetOutputFileCommand (:328)
48+}
49+
50+// EnvVarName returns the environment variable that holds the file of a command:
51+// GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, GITHUB_STATE, GITHUB_STEP_SUMMARY
52+// (GitHubContext.GetRuntimeEnvironmentVariables, GitHubContext.cs:52).
53+func envVarName(contextName string) string { return "GITHUB_" + strings.ToUpper(contextName) }
54+
55+// stepSummaryLimit is CreateStepSummaryCommand.AttachmentSizeLimit
56+// (FileCommandManager.cs:223).
57+const stepSummaryLimit = 1024 * 1024
58+
59+// setEnvBlockList is SetEnvFileCommand._setEnvBlockList (FileCommandManager.cs:215).
60+var setEnvBlockList = []string{"NODE_OPTIONS"}
61+
62+// Pair is one name and value read from an environment-style file.
63+type Pair struct{ Key, Value string }
64+
65+// Sink receives the effects of the file commands of a step.
66+type Sink interface {
67+ // SetEnv sets an environment variable of the job (GITHUB_ENV).
68+ SetEnv(name, value string)
69+ // SetOutput sets an output of the step (GITHUB_OUTPUT).
70+ SetOutput(name, value string)
71+ // AddPath prepends a directory to PATH for the next steps (GITHUB_PATH). The
72+ // caller keeps the list like Global.PrependPath: the last added is first
73+ // in PATH, and a repeated entry moves to the end of the list.
74+ AddPath(path string)
75+ // SaveState keeps a value for the step's own post phase (GITHUB_STATE).
76+ SaveState(name, value string)
77+ // Error writes an error issue to the log (##[error]).
78+ Error(msg string)
79+ // Debug writes a ##[debug] line when step debugging is on.
80+ Debug(msg string)
81+ // CommandFailed marks the step as failed (context.CommandResult = Failed).
82+ CommandFailed()
83+}
84+
85+// FileCommands manages the per-step files, like FileCommandManager.
86+type FileCommands struct {
87+ dir string
88+ suffix string
89+}
90+
91+// NewFileCommands uses dir (RUNNER_TEMP/_runner_file_commands), creating it.
92+func NewFileCommands(dir string) (*FileCommands, error) {
93+ if err := os.MkdirAll(dir, 0o755); err != nil {
94+ return nil, err
95+ }
96+ return &FileCommands{dir: dir}, nil
97+}
98+
99+func (f *FileCommands) path(c fileCommand, suffix string) string {
100+ return filepath.Join(f.dir, c.FilePrefix+suffix)
101+}
102+
103+// Init creates fresh, empty files for the next step (after deleting the ones
104+// of the previous step) and returns the environment variables that name them
105+// (InitializeFiles, FileCommandManager.cs:42).
106+func (f *FileCommands) Init() (map[string]string, error) {
107+ old := f.suffix
108+ f.suffix = uuid.NewString()
109+ env := make(map[string]string, len(fileCommands))
110+ for _, c := range fileCommands {
111+ if old != "" {
112+ _ = os.Remove(f.path(c, old))
113+ }
114+ p := f.path(c, f.suffix)
115+ _ = os.Remove(p)
116+ fh, err := os.Create(p)
117+ if err != nil {
118+ return nil, err
119+ }
120+ _ = fh.Close()
121+ env[envVarName(c.ContextName)] = p
122+ }
123+ return env, nil
124+}
125+
126+// Process reads the files of the step that just ran and applies them to sink.
127+// A command that fails does not stop the others (ProcessFiles, :75).
128+func (f *FileCommands) Process(sink Sink) {
129+ for _, c := range fileCommands {
130+ p := f.path(c, f.suffix)
131+ var err error
132+ switch c.ContextName {
133+ case cmdPath:
134+ err = processAddPath(sink, p)
135+ case cmdEnv:
136+ err = processSetEnv(sink, p)
137+ case cmdStepSummary:
138+ err = processStepSummary(sink, p)
139+ case cmdState:
140+ err = processPairs(sink, p, sink.SaveState, func(k, v string) { sink.Debug(fmt.Sprintf("Save intra-action state %s = %s", k, v)) })
141+ case cmdOutput:
142+ err = processPairs(sink, p, sink.SetOutput, func(k, v string) { sink.Debug(fmt.Sprintf("Set output %s = %s", k, v)) })
143+ }
144+ if err != nil {
145+ sink.Error(fmt.Sprintf("Unable to process file command '%s' successfully.", c.ContextName))
146+ sink.Error(err.Error())
147+ sink.CommandFailed()
148+ }
149+ }
150+}
151+
152+// readText is File.ReadAllText: a UTF-8 BOM is dropped and invalid bytes
153+// become U+FFFD. A missing file is reported as ok=false (the callers debug-log
154+// it; AddPath and StepSummary skip silently).
155+func readText(path string) (text string, ok bool) {
156+ b, err := os.ReadFile(path)
157+ if err != nil {
158+ return "", false
159+ }
160+ b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf"))
161+ if !utf8.Valid(b) {
162+ b = bytes.ToValidUTF8(b, []byte("\ufffd"))
163+ }
164+ return string(b), true
165+}
166+
167+// processAddPath is AddPathFileCommand.ProcessCommand (FileCommandManager.cs:134).
168+func processAddPath(sink Sink, path string) error {
169+ text, ok := readText(path)
170+ if !ok {
171+ return nil
172+ }
173+ // File.ReadAllLines splits on \r\n, \n and \r.
174+ for _, line := range splitLines(text) {
175+ if line == "" {
176+ continue
177+ }
178+ sink.AddPath(line)
179+ }
180+ return nil
181+}
182+
183+// splitLines is File.ReadAllLines / StreamReader.ReadLine: \r\n, \r and \n
184+// end a line; no empty last line is produced.
185+func splitLines(s string) []string {
186+ var out []string
187+ for len(s) > 0 {
188+ i := strings.IndexAny(s, "\r\n")
189+ if i < 0 {
190+ out = append(out, s)
191+ break
192+ }
193+ out = append(out, s[:i])
194+ if s[i] == '\r' && i+1 < len(s) && s[i+1] == '\n' {
195+ i++
196+ }
197+ s = s[i+1:]
198+ }
199+ return out
200+}
201+
202+// processSetEnv is SetEnvFileCommand.ProcessCommand (:167). Pairs read before
203+// a format error are applied, like the lazy enumeration of the original.
204+func processSetEnv(sink Sink, path string) error {
205+ pairs, err := readEnvFile(sink, path)
206+ for _, p := range pairs {
207+ if blocked := blockedEnvName(p.Key); blocked != "" {
208+ sink.Error(fmt.Sprintf("Can't store %s output parameter using '$GITHUB_ENV' command.", blocked))
209+ continue
210+ }
211+ sink.SetEnv(p.Key, p.Value)
212+ sink.Debug(fmt.Sprintf("%s='%s'", p.Key, p.Value))
213+ }
214+ return err
215+}
216+
217+func blockedEnvName(name string) string {
218+ for _, b := range setEnvBlockList {
219+ if strings.EqualFold(b, name) {
220+ return b
221+ }
222+ }
223+ return ""
224+}
225+
226+// processPairs serves GITHUB_STATE and GITHUB_OUTPUT.
227+func processPairs(sink Sink, path string, apply func(k, v string), debug func(k, v string)) error {
228+ pairs, err := readEnvFile(sink, path)
229+ for _, p := range pairs {
230+ apply(p.Key, p.Value)
231+ debug(p.Key, p.Value)
232+ }
233+ return err
234+}
235+
236+func readEnvFile(sink Sink, path string) ([]Pair, error) {
237+ text, ok := readText(path)
238+ if !ok {
239+ sink.Debug(fmt.Sprintf("File does not exist '%s'", path))
240+ return nil, nil
241+ }
242+ return ParseEnvFile(text)
243+}
244+
245+// processStepSummary is CreateStepSummaryCommand.ProcessCommand (:230) for
246+// CI-1: the file is only checked (an empty file is ignored, a file above 1 MiB
247+// is an error); ermite does not store summaries yet.
248+func processStepSummary(sink Sink, path string) error {
249+ st, err := os.Stat(path)
250+ if err != nil || st.Size() == 0 {
251+ return nil
252+ }
253+ if st.Size() > stepSummaryLimit {
254+ 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))
255+ }
256+ return nil
257+}
258+
259+// ParseEnvFile parses the contents of GITHUB_ENV, GITHUB_OUTPUT or
260+// GITHUB_STATE: EnvFileKeyValuePairs.GetEnumerator (FileCommandManager.cs:364).
261+//
262+// Lines are NAME=VALUE (split at the first "=") or heredocs NAME<<DELIMITER up
263+// to a line equal to DELIMITER; the form whose marker comes first wins. Empty
264+// lines between entries are skipped. Only "\n" ends a line (on Linux). The
265+// pairs read before an error are returned with it, because the original
266+// applies them as it enumerates.
267+func ParseEnvFile(text string) ([]Pair, error) {
268+ var out []Pair
269+ index := 0
270+ line, _, ok := readLine(text, &index)
271+ for ok {
272+ if line != "" {
273+ var key, output string
274+ equals := strings.Index(line, "=")
275+ heredoc := strings.Index(line, "<<")
276+ switch {
277+ case equals >= 0 && (heredoc < 0 || equals < heredoc):
278+ // The original tests string.IsNullOrEmpty(line) here, which cannot
279+ // be true; the intent (the message says so) is an empty name, and
280+ // an empty variable name is useless: we reject it.
281+ k, v, _ := strings.Cut(line, "=")
282+ if k == "" {
283+ return out, fmt.Errorf("Invalid format '%s'. Name must not be empty", line)
284+ }
285+ key, output = k, v
286+ case heredoc >= 0 && (equals < 0 || heredoc < equals):
287+ k, delim, _ := strings.Cut(line, "<<")
288+ if k == "" || delim == "" {
289+ return out, fmt.Errorf("Invalid format '%s'. Name must not be empty and delimiter must not be empty", line)
290+ }
291+ key = k
292+ start, end := index, index
293+ temp, newline, tok := readLine(text, &index)
294+ for temp != delim || !tok {
295+ if !tok {
296+ return out, fmt.Errorf("Invalid value. Matching delimiter not found '%s'", delim)
297+ }
298+ if newline == "" {
299+ return out, errors.New("Invalid value. EOF marker missing new line.")
300+ }
301+ end = index - len(newline)
302+ temp, newline, tok = readLine(text, &index)
303+ }
304+ if end > start {
305+ output = text[start:end]
306+ }
307+ default:
308+ return out, fmt.Errorf("Invalid format '%s'", line)
309+ }
310+ out = append(out, Pair{key, output})
311+ }
312+ line, _, ok = readLine(text, &index)
313+ }
314+ return out, nil
315+}
316+
317+// readLine is the private ReadLine of EnvFileKeyValuePairs (:462): it returns
318+// the line without its "\n", the newline ("\n" or "" at the end of the text)
319+// and false once the text is exhausted.
320+func readLine(text string, index *int) (line, newline string, ok bool) {
321+ if *index >= len(text) {
322+ return "", "", false
323+ }
324+ orig := *index
325+ lf := strings.IndexByte(text[orig:], '\n')
326+ if lf < 0 {
327+ *index = len(text)
328+ return text[orig:], "", true
329+ }
330+ *index = orig + lf + 1
331+ return text[orig : orig+lf], "\n", true
332+}
333+
334+// PrependedPath returns the PATH value that results from the AddPath calls
335+// recorded in list (in call order) in front of orig, like
336+// Handler.AddPrependPathToEnvironment (Handler.cs:205) with PathUtil.PrependPath.
337+func PrependedPath(list []string, orig string) string {
338+ if len(list) == 0 {
339+ return orig
340+ }
341+ rev := slices.Clone(list)
342+ slices.Reverse(rev)
343+ prepend := strings.Join(rev, ":")
344+ if orig == "" {
345+ return prepend
346+ }
347+ if strings.HasPrefix(orig, prepend+":") {
348+ return orig
349+ }
350+ return prepend + ":" + orig
351+}
@@ -0,0 +1,351 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package executor runs the steps of a GitHub Actions job inside a sandbox.
4+// It is a port of the parts of the official runner (actions/runner, C#, MIT)
5+// that CI-1 needs: file commands, shells, contexts and the steps runner. The
6+// package does no sandboxing and no networking: it runs local processes and
7+// reports to a Reporter.
8+package executor
9+
10+import (
11+ "bytes"
12+ "errors"
13+ "fmt"
14+ "os"
15+ "path/filepath"
16+ "slices"
17+ "strings"
18+ "unicode/utf8"
19+
20+ "github.com/google/uuid"
21+)
22+
23+// Names of the file commands (the GitHub context names) and their
24+// environment variables.
25+const (
26+ cmdPath = "path"
27+ cmdEnv = "env"
28+ cmdStepSummary = "step_summary"
29+ cmdState = "state"
30+ cmdOutput = "output"
31+)
32+
33+// fileCommand describes one file command like the IFileCommandExtension of
34+// FileCommandManager.cs.
35+type fileCommand struct {
36+ ContextName string // github context name; the variable is GITHUB_<UPPER>
37+ FilePrefix string
38+}
39+
40+// fileCommands is in the order of the official runner's extension list, which
41+// is the order ProcessFiles runs them.
42+var fileCommands = []fileCommand{
43+ {cmdPath, "add_path_"}, // AddPathFileCommand (FileCommandManager.cs:127)
44+ {cmdEnv, "set_env_"}, // SetEnvFileCommand (:160)
45+ {cmdStepSummary, "step_summary_"}, // CreateStepSummaryCommand (:221)
46+ {cmdState, "save_state_"}, // SaveStateFileCommand (:295)
47+ {cmdOutput, "set_output_"}, // SetOutputFileCommand (:328)
48+}
49+
50+// EnvVarName returns the environment variable that holds the file of a command:
51+// GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, GITHUB_STATE, GITHUB_STEP_SUMMARY
52+// (GitHubContext.GetRuntimeEnvironmentVariables, GitHubContext.cs:52).
53+func envVarName(contextName string) string { return "GITHUB_" + strings.ToUpper(contextName) }
54+
55+// stepSummaryLimit is CreateStepSummaryCommand.AttachmentSizeLimit
56+// (FileCommandManager.cs:223).
57+const stepSummaryLimit = 1024 * 1024
58+
59+// setEnvBlockList is SetEnvFileCommand._setEnvBlockList (FileCommandManager.cs:215).
60+var setEnvBlockList = []string{"NODE_OPTIONS"}
61+
62+// Pair is one name and value read from an environment-style file.
63+type Pair struct{ Key, Value string }
64+
65+// Sink receives the effects of the file commands of a step.
66+type Sink interface {
67+ // SetEnv sets an environment variable of the job (GITHUB_ENV).
68+ SetEnv(name, value string)
69+ // SetOutput sets an output of the step (GITHUB_OUTPUT).
70+ SetOutput(name, value string)
71+ // AddPath prepends a directory to PATH for the next steps (GITHUB_PATH). The
72+ // caller keeps the list like Global.PrependPath: the last added is first
73+ // in PATH, and a repeated entry moves to the end of the list.
74+ AddPath(path string)
75+ // SaveState keeps a value for the step's own post phase (GITHUB_STATE).
76+ SaveState(name, value string)
77+ // Error writes an error issue to the log (##[error]).
78+ Error(msg string)
79+ // Debug writes a ##[debug] line when step debugging is on.
80+ Debug(msg string)
81+ // CommandFailed marks the step as failed (context.CommandResult = Failed).
82+ CommandFailed()
83+}
84+
85+// FileCommands manages the per-step files, like FileCommandManager.
86+type FileCommands struct {
87+ dir string
88+ suffix string
89+}
90+
91+// NewFileCommands uses dir (RUNNER_TEMP/_runner_file_commands), creating it.
92+func NewFileCommands(dir string) (*FileCommands, error) {
93+ if err := os.MkdirAll(dir, 0o755); err != nil {
94+ return nil, err
95+ }
96+ return &FileCommands{dir: dir}, nil
97+}
98+
99+func (f *FileCommands) path(c fileCommand, suffix string) string {
100+ return filepath.Join(f.dir, c.FilePrefix+suffix)
101+}
102+
103+// Init creates fresh, empty files for the next step (after deleting the ones
104+// of the previous step) and returns the environment variables that name them
105+// (InitializeFiles, FileCommandManager.cs:42).
106+func (f *FileCommands) Init() (map[string]string, error) {
107+ old := f.suffix
108+ f.suffix = uuid.NewString()
109+ env := make(map[string]string, len(fileCommands))
110+ for _, c := range fileCommands {
111+ if old != "" {
112+ _ = os.Remove(f.path(c, old))
113+ }
114+ p := f.path(c, f.suffix)
115+ _ = os.Remove(p)
116+ fh, err := os.Create(p)
117+ if err != nil {
118+ return nil, err
119+ }
120+ _ = fh.Close()
121+ env[envVarName(c.ContextName)] = p
122+ }
123+ return env, nil
124+}
125+
126+// Process reads the files of the step that just ran and applies them to sink.
127+// A command that fails does not stop the others (ProcessFiles, :75).
128+func (f *FileCommands) Process(sink Sink) {
129+ for _, c := range fileCommands {
130+ p := f.path(c, f.suffix)
131+ var err error
132+ switch c.ContextName {
133+ case cmdPath:
134+ err = processAddPath(sink, p)
135+ case cmdEnv:
136+ err = processSetEnv(sink, p)
137+ case cmdStepSummary:
138+ err = processStepSummary(sink, p)
139+ case cmdState:
140+ err = processPairs(sink, p, sink.SaveState, func(k, v string) { sink.Debug(fmt.Sprintf("Save intra-action state %s = %s", k, v)) })
141+ case cmdOutput:
142+ err = processPairs(sink, p, sink.SetOutput, func(k, v string) { sink.Debug(fmt.Sprintf("Set output %s = %s", k, v)) })
143+ }
144+ if err != nil {
145+ sink.Error(fmt.Sprintf("Unable to process file command '%s' successfully.", c.ContextName))
146+ sink.Error(err.Error())
147+ sink.CommandFailed()
148+ }
149+ }
150+}
151+
152+// readText is File.ReadAllText: a UTF-8 BOM is dropped and invalid bytes
153+// become U+FFFD. A missing file is reported as ok=false (the callers debug-log
154+// it; AddPath and StepSummary skip silently).
155+func readText(path string) (text string, ok bool) {
156+ b, err := os.ReadFile(path)
157+ if err != nil {
158+ return "", false
159+ }
160+ b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf"))
161+ if !utf8.Valid(b) {
162+ b = bytes.ToValidUTF8(b, []byte("\ufffd"))
163+ }
164+ return string(b), true
165+}
166+
167+// processAddPath is AddPathFileCommand.ProcessCommand (FileCommandManager.cs:134).
168+func processAddPath(sink Sink, path string) error {
169+ text, ok := readText(path)
170+ if !ok {
171+ return nil
172+ }
173+ // File.ReadAllLines splits on \r\n, \n and \r.
174+ for _, line := range splitLines(text) {
175+ if line == "" {
176+ continue
177+ }
178+ sink.AddPath(line)
179+ }
180+ return nil
181+}
182+
183+// splitLines is File.ReadAllLines / StreamReader.ReadLine: \r\n, \r and \n
184+// end a line; no empty last line is produced.
185+func splitLines(s string) []string {
186+ var out []string
187+ for len(s) > 0 {
188+ i := strings.IndexAny(s, "\r\n")
189+ if i < 0 {
190+ out = append(out, s)
191+ break
192+ }
193+ out = append(out, s[:i])
194+ if s[i] == '\r' && i+1 < len(s) && s[i+1] == '\n' {
195+ i++
196+ }
197+ s = s[i+1:]
198+ }
199+ return out
200+}
201+
202+// processSetEnv is SetEnvFileCommand.ProcessCommand (:167). Pairs read before
203+// a format error are applied, like the lazy enumeration of the original.
204+func processSetEnv(sink Sink, path string) error {
205+ pairs, err := readEnvFile(sink, path)
206+ for _, p := range pairs {
207+ if blocked := blockedEnvName(p.Key); blocked != "" {
208+ sink.Error(fmt.Sprintf("Can't store %s output parameter using '$GITHUB_ENV' command.", blocked))
209+ continue
210+ }
211+ sink.SetEnv(p.Key, p.Value)
212+ sink.Debug(fmt.Sprintf("%s='%s'", p.Key, p.Value))
213+ }
214+ return err
215+}
216+
217+func blockedEnvName(name string) string {
218+ for _, b := range setEnvBlockList {
219+ if strings.EqualFold(b, name) {
220+ return b
221+ }
222+ }
223+ return ""
224+}
225+
226+// processPairs serves GITHUB_STATE and GITHUB_OUTPUT.
227+func processPairs(sink Sink, path string, apply func(k, v string), debug func(k, v string)) error {
228+ pairs, err := readEnvFile(sink, path)
229+ for _, p := range pairs {
230+ apply(p.Key, p.Value)
231+ debug(p.Key, p.Value)
232+ }
233+ return err
234+}
235+
236+func readEnvFile(sink Sink, path string) ([]Pair, error) {
237+ text, ok := readText(path)
238+ if !ok {
239+ sink.Debug(fmt.Sprintf("File does not exist '%s'", path))
240+ return nil, nil
241+ }
242+ return ParseEnvFile(text)
243+}
244+
245+// processStepSummary is CreateStepSummaryCommand.ProcessCommand (:230) for
246+// CI-1: the file is only checked (an empty file is ignored, a file above 1 MiB
247+// is an error); ermite does not store summaries yet.
248+func processStepSummary(sink Sink, path string) error {
249+ st, err := os.Stat(path)
250+ if err != nil || st.Size() == 0 {
251+ return nil
252+ }
253+ if st.Size() > stepSummaryLimit {
254+ 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))
255+ }
256+ return nil
257+}
258+
259+// ParseEnvFile parses the contents of GITHUB_ENV, GITHUB_OUTPUT or
260+// GITHUB_STATE: EnvFileKeyValuePairs.GetEnumerator (FileCommandManager.cs:364).
261+//
262+// Lines are NAME=VALUE (split at the first "=") or heredocs NAME<<DELIMITER up
263+// to a line equal to DELIMITER; the form whose marker comes first wins. Empty
264+// lines between entries are skipped. Only "\n" ends a line (on Linux). The
265+// pairs read before an error are returned with it, because the original
266+// applies them as it enumerates.
267+func ParseEnvFile(text string) ([]Pair, error) {
268+ var out []Pair
269+ index := 0
270+ line, _, ok := readLine(text, &index)
271+ for ok {
272+ if line != "" {
273+ var key, output string
274+ equals := strings.Index(line, "=")
275+ heredoc := strings.Index(line, "<<")
276+ switch {
277+ case equals >= 0 && (heredoc < 0 || equals < heredoc):
278+ // The original tests string.IsNullOrEmpty(line) here, which cannot
279+ // be true; the intent (the message says so) is an empty name, and
280+ // an empty variable name is useless: we reject it.
281+ k, v, _ := strings.Cut(line, "=")
282+ if k == "" {
283+ return out, fmt.Errorf("Invalid format '%s'. Name must not be empty", line)
284+ }
285+ key, output = k, v
286+ case heredoc >= 0 && (equals < 0 || heredoc < equals):
287+ k, delim, _ := strings.Cut(line, "<<")
288+ if k == "" || delim == "" {
289+ return out, fmt.Errorf("Invalid format '%s'. Name must not be empty and delimiter must not be empty", line)
290+ }
291+ key = k
292+ start, end := index, index
293+ temp, newline, tok := readLine(text, &index)
294+ for temp != delim || !tok {
295+ if !tok {
296+ return out, fmt.Errorf("Invalid value. Matching delimiter not found '%s'", delim)
297+ }
298+ if newline == "" {
299+ return out, errors.New("Invalid value. EOF marker missing new line.")
300+ }
301+ end = index - len(newline)
302+ temp, newline, tok = readLine(text, &index)
303+ }
304+ if end > start {
305+ output = text[start:end]
306+ }
307+ default:
308+ return out, fmt.Errorf("Invalid format '%s'", line)
309+ }
310+ out = append(out, Pair{key, output})
311+ }
312+ line, _, ok = readLine(text, &index)
313+ }
314+ return out, nil
315+}
316+
317+// readLine is the private ReadLine of EnvFileKeyValuePairs (:462): it returns
318+// the line without its "\n", the newline ("\n" or "" at the end of the text)
319+// and false once the text is exhausted.
320+func readLine(text string, index *int) (line, newline string, ok bool) {
321+ if *index >= len(text) {
322+ return "", "", false
323+ }
324+ orig := *index
325+ lf := strings.IndexByte(text[orig:], '\n')
326+ if lf < 0 {
327+ *index = len(text)
328+ return text[orig:], "", true
329+ }
330+ *index = orig + lf + 1
331+ return text[orig : orig+lf], "\n", true
332+}
333+
334+// PrependedPath returns the PATH value that results from the AddPath calls
335+// recorded in list (in call order) in front of orig, like
336+// Handler.AddPrependPathToEnvironment (Handler.cs:205) with PathUtil.PrependPath.
337+func PrependedPath(list []string, orig string) string {
338+ if len(list) == 0 {
339+ return orig
340+ }
341+ rev := slices.Clone(list)
342+ slices.Reverse(rev)
343+ prepend := strings.Join(rev, ":")
344+ if orig == "" {
345+ return prepend
346+ }
347+ if strings.HasPrefix(orig, prepend+":") {
348+ return orig
349+ }
350+ return prepend + ":" + orig
351+}
A internal/executor/filecmd_test.go +231 -0
@@ -0,0 +1,231 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "testing"
11+)
12+
13+// Cases ported from SetEnvFileCommandL0, SetOutputFileCommandL0 and
14+// SaveStateFileCommandL0 (actions/runner, src/Test/L0/Worker). The C# tests
15+// write their files with a UTF-8 BOM and join lines with the platform newline;
16+// "\n" is used here and the BOM is added by writeEnvFile.
17+
18+func TestParseEnvFile(t *testing.T) {
19+ type kv = Pair
20+ tests := []struct {
21+ name string
22+ in string
23+ want []kv
24+ wantErr string
25+ }{
26+ {"empty file", "", nil, ""},
27+ {"simple", "MY_ENV=MY VALUE", []kv{{"MY_ENV", "MY VALUE"}}, ""},
28+ {"skip empty lines", "\nMY_ENV=my value\n\nMY_ENV_2=my second value\n", []kv{{"MY_ENV", "my value"}, {"MY_ENV_2", "my second value"}}, ""},
29+ {"empty value", "MY_ENV=", []kv{{"MY_ENV", ""}}, ""},
30+ {"multiple values", "MY_ENV=my value\nMY_ENV_2=\nMY_ENV_3=my third value", []kv{{"MY_ENV", "my value"}, {"MY_ENV_2", ""}, {"MY_ENV_3", "my third value"}}, ""},
31+ {"special characters", "MY_ENV==abc\nMY_ENV_2=def=ghi\nMY_ENV_3=jkl=", []kv{{"MY_ENV", "=abc"}, {"MY_ENV_2", "def=ghi"}, {"MY_ENV_3", "jkl="}}, ""},
32+ {"heredoc", "MY_ENV<<EOF\nline one\nline two\nline three\nEOF", []kv{{"MY_ENV", "line one\nline two\nline three"}}, ""},
33+ {"heredoc trailing newline", "MY_ENV<<EOF\nhello\nworld\nEOF\n", []kv{{"MY_ENV", "hello\nworld"}}, ""},
34+ {"heredoc empty value", "MY_ENV<<EOF\nEOF", []kv{{"MY_ENV", ""}}, ""},
35+ {"heredoc skip empty lines", "\nMY_ENV<<EOF\nhello\nworld\nEOF\n\nMY_ENV_2<<EOF\nHELLO\nAGAIN\nEOF\n", []kv{{"MY_ENV", "hello\nworld"}, {"MY_ENV_2", "HELLO\nAGAIN"}}, ""},
36+ {"heredoc special characters",
37+ "MY_ENV<<=EOF\nhello\none\n=EOF\nMY_ENV_2<<<EOF\nhello\ntwo\n<EOF\nMY_ENV_3<<EOF\nhello\n\nthree\n\nEOF\nMY_ENV_4<<EOF\nhello=four\nEOF\nMY_ENV_5<<EOF\n EOF\nEOF",
38+ []kv{{"MY_ENV", "hello\none"}, {"MY_ENV_2", "hello\ntwo"}, {"MY_ENV_3", "hello\n\nthree\n"}, {"MY_ENV_4", "hello=four"}, {"MY_ENV_5", " EOF"}}, ""},
39+ {"heredoc delimiter missing", "MY_ENV<<EOF line one line two line three EOF", nil, "Matching delimiter not found"},
40+ {"heredoc missing delimiter at end of text", "MY_ENV<<EOF\nline one\nline two", nil, "EOF marker missing new line"},
41+ {"heredoc multi-line missing newline", "MY_ENV<<EOF line one\n line two\n line three EOF", nil, "EOF marker missing new line"},
42+ {"equals first wins over heredoc", "A=b<<C", []kv{{"A", "b<<C"}}, ""},
43+ {"heredoc first wins over equals", "A<<B=C\nx\nB=C", []kv{{"A", "x"}}, ""},
44+ {"no separator", "just text", nil, "Invalid format 'just text'"},
45+ {"empty name", "=value", nil, "Name must not be empty"},
46+ {"empty heredoc name", "<<EOF\nx\nEOF", nil, "Name must not be empty and delimiter must not be empty"},
47+ {"empty delimiter", "A<<\nx", nil, "delimiter must not be empty"},
48+ {"pairs before the error are kept", "A=1\nbroken", []kv{{"A", "1"}}, "Invalid format 'broken'"},
49+ {"CR is data", "A=1\r\n", []kv{{"A", "1\r"}}, ""},
50+ }
51+ for _, tc := range tests {
52+ t.Run(tc.name, func(t *testing.T) {
53+ got, err := ParseEnvFile(tc.in)
54+ if tc.wantErr != "" {
55+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
56+ t.Fatalf("error %v, want %q", err, tc.wantErr)
57+ }
58+ } else if err != nil {
59+ t.Fatal(err)
60+ }
61+ if !reflect.DeepEqual(got, tc.want) {
62+ t.Fatalf("got %q, want %q", got, tc.want)
63+ }
64+ })
65+ }
66+}
67+
68+// fakeSink records the effects of file commands.
69+type fakeSink struct {
70+ env, outputs, state map[string]string
71+ path []string
72+ errors, debugs []string
73+ failed bool
74+}
75+
76+func newSink() *fakeSink {
77+ return &fakeSink{env: map[string]string{}, outputs: map[string]string{}, state: map[string]string{}}
78+}
79+func (s *fakeSink) SetEnv(k, v string) { s.env[k] = v }
80+func (s *fakeSink) SetOutput(k, v string) { s.outputs[k] = v }
81+func (s *fakeSink) SaveState(k, v string) { s.state[k] = v }
82+func (s *fakeSink) AddPath(p string) { s.path = append(s.path, p) }
83+func (s *fakeSink) Error(m string) { s.errors = append(s.errors, m) }
84+func (s *fakeSink) Debug(m string) { s.debugs = append(s.debugs, m) }
85+func (s *fakeSink) CommandFailed() { s.failed = true }
86+
87+func writeEnvFile(t *testing.T, path, content string) {
88+ t.Helper()
89+ // The C# tests write a BOM; ReadAllText drops it.
90+ if err := os.WriteFile(path, []byte("\xef\xbb\xbf"+content), 0o644); err != nil {
91+ t.Fatal(err)
92+ }
93+}
94+
95+func TestFileCommandsInit(t *testing.T) {
96+ dir := filepath.Join(t.TempDir(), "_runner_file_commands")
97+ fc, err := NewFileCommands(dir)
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ env, err := fc.Init()
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ for _, name := range []string{"GITHUB_PATH", "GITHUB_ENV", "GITHUB_STEP_SUMMARY", "GITHUB_STATE", "GITHUB_OUTPUT"} {
106+ p := env[name]
107+ if p == "" || filepath.Dir(p) != dir {
108+ t.Fatalf("%s = %q", name, p)
109+ }
110+ if st, err := os.Stat(p); err != nil || st.Size() != 0 {
111+ t.Fatalf("%s: %v %v", name, st, err)
112+ }
113+ }
114+ if !strings.HasPrefix(filepath.Base(env["GITHUB_ENV"]), "set_env_") || !strings.HasPrefix(filepath.Base(env["GITHUB_PATH"]), "add_path_") {
115+ t.Fatalf("prefixes: %v", env)
116+ }
117+ // A second Init deletes the previous files and uses new names.
118+ env2, _ := fc.Init()
119+ if env2["GITHUB_ENV"] == env["GITHUB_ENV"] {
120+ t.Fatal("same file for two steps")
121+ }
122+ if _, err := os.Stat(env["GITHUB_ENV"]); !os.IsNotExist(err) {
123+ t.Fatalf("old file kept: %v", err)
124+ }
125+}
126+
127+func TestProcessAllCommands(t *testing.T) {
128+ fc, _ := NewFileCommands(t.TempDir())
129+ env, _ := fc.Init()
130+ writeEnvFile(t, env["GITHUB_ENV"], "A=1\nM<<X\nl1\nl2\nX\n")
131+ writeEnvFile(t, env["GITHUB_OUTPUT"], "o=2\n")
132+ writeEnvFile(t, env["GITHUB_STATE"], "s=3\n")
133+ writeEnvFile(t, env["GITHUB_PATH"], "/a\n\n/b\n/a\n")
134+ writeEnvFile(t, env["GITHUB_STEP_SUMMARY"], "# hi")
135+ s := newSink()
136+ fc.Process(s)
137+ if !reflect.DeepEqual(s.env, map[string]string{"A": "1", "M": "l1\nl2"}) || s.outputs["o"] != "2" || s.state["s"] != "3" {
138+ t.Fatalf("%+v", s)
139+ }
140+ if !reflect.DeepEqual(s.path, []string{"/a", "/b", "/a"}) {
141+ t.Fatalf("path %v", s.path)
142+ }
143+ if s.failed || len(s.errors) != 0 {
144+ t.Fatalf("errors %v", s.errors)
145+ }
146+}
147+
148+func TestProcessBlockedEnvNames(t *testing.T) {
149+ // SetEnvFileCommand_BlockListItemsFiltered and _Heredoc.
150+ for _, content := range []string{"NODE_OPTIONS=asdf", "NODE_OPTIONS<<EOF\nasdf\nEOF", "node_options=asdf"} {
151+ fc, _ := NewFileCommands(t.TempDir())
152+ env, _ := fc.Init()
153+ writeEnvFile(t, env["GITHUB_ENV"], content)
154+ s := newSink()
155+ fc.Process(s)
156+ if len(s.env) != 0 || len(s.errors) != 1 || s.errors[0] != "Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command." || s.failed {
157+ t.Fatalf("%q: env=%v errors=%v failed=%v", content, s.env, s.errors, s.failed)
158+ }
159+ }
160+}
161+
162+func TestProcessFormatErrorFailsTheStep(t *testing.T) {
163+ fc, _ := NewFileCommands(t.TempDir())
164+ env, _ := fc.Init()
165+ writeEnvFile(t, env["GITHUB_OUTPUT"], "good=1\nbad line\n")
166+ writeEnvFile(t, env["GITHUB_ENV"], "E=1\n")
167+ s := newSink()
168+ fc.Process(s)
169+ // Pairs before the error are applied; other commands still run.
170+ if s.outputs["good"] != "1" || s.env["E"] != "1" || !s.failed {
171+ t.Fatalf("%+v", s)
172+ }
173+ want := []string{"Unable to process file command 'output' successfully.", "Invalid format 'bad line'"}
174+ if !reflect.DeepEqual(s.errors, want) {
175+ t.Fatalf("errors %q", s.errors)
176+ }
177+}
178+
179+func TestProcessMissingFilesAreSilent(t *testing.T) {
180+ // *_DirectoryNotFound and *_NotFound: nothing is set, no issue.
181+ fc, _ := NewFileCommands(t.TempDir())
182+ env, _ := fc.Init()
183+ for _, p := range env {
184+ _ = os.Remove(p)
185+ }
186+ s := newSink()
187+ fc.Process(s)
188+ if len(s.env)+len(s.outputs)+len(s.state)+len(s.path) != 0 || len(s.errors) != 0 || s.failed {
189+ t.Fatalf("%+v", s)
190+ }
191+}
192+
193+func TestStepSummarySize(t *testing.T) {
194+ fc, _ := NewFileCommands(t.TempDir())
195+ env, _ := fc.Init()
196+ if err := os.WriteFile(env["GITHUB_STEP_SUMMARY"], make([]byte, 1024*1024+1), 0o644); err != nil {
197+ t.Fatal(err)
198+ }
199+ s := newSink()
200+ fc.Process(s)
201+ if len(s.errors) != 1 || !strings.HasPrefix(s.errors[0], "$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of 1024k, got 1024k.") {
202+ t.Fatalf("%q", s.errors)
203+ }
204+ if s.failed {
205+ t.Fatal("an oversized summary only logs an error")
206+ }
207+}
208+
209+func TestSplitLines(t *testing.T) {
210+ got := splitLines("a\r\nb\rc\n\nd")
211+ if !reflect.DeepEqual(got, []string{"a", "b", "c", "", "d"}) {
212+ t.Fatalf("%q", got)
213+ }
214+}
215+
216+func TestPrependedPath(t *testing.T) {
217+ // PathUtil.PrependPath: the last added entry comes first; no trailing
218+ // separator on an empty PATH; nothing changes when already first.
219+ if got := PrependedPath([]string{"/a", "/b"}, "/usr/bin"); got != "/b:/a:/usr/bin" {
220+ t.Fatal(got)
221+ }
222+ if got := PrependedPath([]string{"/a"}, ""); got != "/a" {
223+ t.Fatal(got)
224+ }
225+ if got := PrependedPath([]string{"/a"}, "/a:/usr/bin"); got != "/a:/usr/bin" {
226+ t.Fatal(got)
227+ }
228+ if got := PrependedPath(nil, "/usr/bin"); got != "/usr/bin" {
229+ t.Fatal(got)
230+ }
231+}
@@ -0,0 +1,231 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "testing"
11+)
12+
13+// Cases ported from SetEnvFileCommandL0, SetOutputFileCommandL0 and
14+// SaveStateFileCommandL0 (actions/runner, src/Test/L0/Worker). The C# tests
15+// write their files with a UTF-8 BOM and join lines with the platform newline;
16+// "\n" is used here and the BOM is added by writeEnvFile.
17+
18+func TestParseEnvFile(t *testing.T) {
19+ type kv = Pair
20+ tests := []struct {
21+ name string
22+ in string
23+ want []kv
24+ wantErr string
25+ }{
26+ {"empty file", "", nil, ""},
27+ {"simple", "MY_ENV=MY VALUE", []kv{{"MY_ENV", "MY VALUE"}}, ""},
28+ {"skip empty lines", "\nMY_ENV=my value\n\nMY_ENV_2=my second value\n", []kv{{"MY_ENV", "my value"}, {"MY_ENV_2", "my second value"}}, ""},
29+ {"empty value", "MY_ENV=", []kv{{"MY_ENV", ""}}, ""},
30+ {"multiple values", "MY_ENV=my value\nMY_ENV_2=\nMY_ENV_3=my third value", []kv{{"MY_ENV", "my value"}, {"MY_ENV_2", ""}, {"MY_ENV_3", "my third value"}}, ""},
31+ {"special characters", "MY_ENV==abc\nMY_ENV_2=def=ghi\nMY_ENV_3=jkl=", []kv{{"MY_ENV", "=abc"}, {"MY_ENV_2", "def=ghi"}, {"MY_ENV_3", "jkl="}}, ""},
32+ {"heredoc", "MY_ENV<<EOF\nline one\nline two\nline three\nEOF", []kv{{"MY_ENV", "line one\nline two\nline three"}}, ""},
33+ {"heredoc trailing newline", "MY_ENV<<EOF\nhello\nworld\nEOF\n", []kv{{"MY_ENV", "hello\nworld"}}, ""},
34+ {"heredoc empty value", "MY_ENV<<EOF\nEOF", []kv{{"MY_ENV", ""}}, ""},
35+ {"heredoc skip empty lines", "\nMY_ENV<<EOF\nhello\nworld\nEOF\n\nMY_ENV_2<<EOF\nHELLO\nAGAIN\nEOF\n", []kv{{"MY_ENV", "hello\nworld"}, {"MY_ENV_2", "HELLO\nAGAIN"}}, ""},
36+ {"heredoc special characters",
37+ "MY_ENV<<=EOF\nhello\none\n=EOF\nMY_ENV_2<<<EOF\nhello\ntwo\n<EOF\nMY_ENV_3<<EOF\nhello\n\nthree\n\nEOF\nMY_ENV_4<<EOF\nhello=four\nEOF\nMY_ENV_5<<EOF\n EOF\nEOF",
38+ []kv{{"MY_ENV", "hello\none"}, {"MY_ENV_2", "hello\ntwo"}, {"MY_ENV_3", "hello\n\nthree\n"}, {"MY_ENV_4", "hello=four"}, {"MY_ENV_5", " EOF"}}, ""},
39+ {"heredoc delimiter missing", "MY_ENV<<EOF line one line two line three EOF", nil, "Matching delimiter not found"},
40+ {"heredoc missing delimiter at end of text", "MY_ENV<<EOF\nline one\nline two", nil, "EOF marker missing new line"},
41+ {"heredoc multi-line missing newline", "MY_ENV<<EOF line one\n line two\n line three EOF", nil, "EOF marker missing new line"},
42+ {"equals first wins over heredoc", "A=b<<C", []kv{{"A", "b<<C"}}, ""},
43+ {"heredoc first wins over equals", "A<<B=C\nx\nB=C", []kv{{"A", "x"}}, ""},
44+ {"no separator", "just text", nil, "Invalid format 'just text'"},
45+ {"empty name", "=value", nil, "Name must not be empty"},
46+ {"empty heredoc name", "<<EOF\nx\nEOF", nil, "Name must not be empty and delimiter must not be empty"},
47+ {"empty delimiter", "A<<\nx", nil, "delimiter must not be empty"},
48+ {"pairs before the error are kept", "A=1\nbroken", []kv{{"A", "1"}}, "Invalid format 'broken'"},
49+ {"CR is data", "A=1\r\n", []kv{{"A", "1\r"}}, ""},
50+ }
51+ for _, tc := range tests {
52+ t.Run(tc.name, func(t *testing.T) {
53+ got, err := ParseEnvFile(tc.in)
54+ if tc.wantErr != "" {
55+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
56+ t.Fatalf("error %v, want %q", err, tc.wantErr)
57+ }
58+ } else if err != nil {
59+ t.Fatal(err)
60+ }
61+ if !reflect.DeepEqual(got, tc.want) {
62+ t.Fatalf("got %q, want %q", got, tc.want)
63+ }
64+ })
65+ }
66+}
67+
68+// fakeSink records the effects of file commands.
69+type fakeSink struct {
70+ env, outputs, state map[string]string
71+ path []string
72+ errors, debugs []string
73+ failed bool
74+}
75+
76+func newSink() *fakeSink {
77+ return &fakeSink{env: map[string]string{}, outputs: map[string]string{}, state: map[string]string{}}
78+}
79+func (s *fakeSink) SetEnv(k, v string) { s.env[k] = v }
80+func (s *fakeSink) SetOutput(k, v string) { s.outputs[k] = v }
81+func (s *fakeSink) SaveState(k, v string) { s.state[k] = v }
82+func (s *fakeSink) AddPath(p string) { s.path = append(s.path, p) }
83+func (s *fakeSink) Error(m string) { s.errors = append(s.errors, m) }
84+func (s *fakeSink) Debug(m string) { s.debugs = append(s.debugs, m) }
85+func (s *fakeSink) CommandFailed() { s.failed = true }
86+
87+func writeEnvFile(t *testing.T, path, content string) {
88+ t.Helper()
89+ // The C# tests write a BOM; ReadAllText drops it.
90+ if err := os.WriteFile(path, []byte("\xef\xbb\xbf"+content), 0o644); err != nil {
91+ t.Fatal(err)
92+ }
93+}
94+
95+func TestFileCommandsInit(t *testing.T) {
96+ dir := filepath.Join(t.TempDir(), "_runner_file_commands")
97+ fc, err := NewFileCommands(dir)
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ env, err := fc.Init()
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ for _, name := range []string{"GITHUB_PATH", "GITHUB_ENV", "GITHUB_STEP_SUMMARY", "GITHUB_STATE", "GITHUB_OUTPUT"} {
106+ p := env[name]
107+ if p == "" || filepath.Dir(p) != dir {
108+ t.Fatalf("%s = %q", name, p)
109+ }
110+ if st, err := os.Stat(p); err != nil || st.Size() != 0 {
111+ t.Fatalf("%s: %v %v", name, st, err)
112+ }
113+ }
114+ if !strings.HasPrefix(filepath.Base(env["GITHUB_ENV"]), "set_env_") || !strings.HasPrefix(filepath.Base(env["GITHUB_PATH"]), "add_path_") {
115+ t.Fatalf("prefixes: %v", env)
116+ }
117+ // A second Init deletes the previous files and uses new names.
118+ env2, _ := fc.Init()
119+ if env2["GITHUB_ENV"] == env["GITHUB_ENV"] {
120+ t.Fatal("same file for two steps")
121+ }
122+ if _, err := os.Stat(env["GITHUB_ENV"]); !os.IsNotExist(err) {
123+ t.Fatalf("old file kept: %v", err)
124+ }
125+}
126+
127+func TestProcessAllCommands(t *testing.T) {
128+ fc, _ := NewFileCommands(t.TempDir())
129+ env, _ := fc.Init()
130+ writeEnvFile(t, env["GITHUB_ENV"], "A=1\nM<<X\nl1\nl2\nX\n")
131+ writeEnvFile(t, env["GITHUB_OUTPUT"], "o=2\n")
132+ writeEnvFile(t, env["GITHUB_STATE"], "s=3\n")
133+ writeEnvFile(t, env["GITHUB_PATH"], "/a\n\n/b\n/a\n")
134+ writeEnvFile(t, env["GITHUB_STEP_SUMMARY"], "# hi")
135+ s := newSink()
136+ fc.Process(s)
137+ if !reflect.DeepEqual(s.env, map[string]string{"A": "1", "M": "l1\nl2"}) || s.outputs["o"] != "2" || s.state["s"] != "3" {
138+ t.Fatalf("%+v", s)
139+ }
140+ if !reflect.DeepEqual(s.path, []string{"/a", "/b", "/a"}) {
141+ t.Fatalf("path %v", s.path)
142+ }
143+ if s.failed || len(s.errors) != 0 {
144+ t.Fatalf("errors %v", s.errors)
145+ }
146+}
147+
148+func TestProcessBlockedEnvNames(t *testing.T) {
149+ // SetEnvFileCommand_BlockListItemsFiltered and _Heredoc.
150+ for _, content := range []string{"NODE_OPTIONS=asdf", "NODE_OPTIONS<<EOF\nasdf\nEOF", "node_options=asdf"} {
151+ fc, _ := NewFileCommands(t.TempDir())
152+ env, _ := fc.Init()
153+ writeEnvFile(t, env["GITHUB_ENV"], content)
154+ s := newSink()
155+ fc.Process(s)
156+ if len(s.env) != 0 || len(s.errors) != 1 || s.errors[0] != "Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command." || s.failed {
157+ t.Fatalf("%q: env=%v errors=%v failed=%v", content, s.env, s.errors, s.failed)
158+ }
159+ }
160+}
161+
162+func TestProcessFormatErrorFailsTheStep(t *testing.T) {
163+ fc, _ := NewFileCommands(t.TempDir())
164+ env, _ := fc.Init()
165+ writeEnvFile(t, env["GITHUB_OUTPUT"], "good=1\nbad line\n")
166+ writeEnvFile(t, env["GITHUB_ENV"], "E=1\n")
167+ s := newSink()
168+ fc.Process(s)
169+ // Pairs before the error are applied; other commands still run.
170+ if s.outputs["good"] != "1" || s.env["E"] != "1" || !s.failed {
171+ t.Fatalf("%+v", s)
172+ }
173+ want := []string{"Unable to process file command 'output' successfully.", "Invalid format 'bad line'"}
174+ if !reflect.DeepEqual(s.errors, want) {
175+ t.Fatalf("errors %q", s.errors)
176+ }
177+}
178+
179+func TestProcessMissingFilesAreSilent(t *testing.T) {
180+ // *_DirectoryNotFound and *_NotFound: nothing is set, no issue.
181+ fc, _ := NewFileCommands(t.TempDir())
182+ env, _ := fc.Init()
183+ for _, p := range env {
184+ _ = os.Remove(p)
185+ }
186+ s := newSink()
187+ fc.Process(s)
188+ if len(s.env)+len(s.outputs)+len(s.state)+len(s.path) != 0 || len(s.errors) != 0 || s.failed {
189+ t.Fatalf("%+v", s)
190+ }
191+}
192+
193+func TestStepSummarySize(t *testing.T) {
194+ fc, _ := NewFileCommands(t.TempDir())
195+ env, _ := fc.Init()
196+ if err := os.WriteFile(env["GITHUB_STEP_SUMMARY"], make([]byte, 1024*1024+1), 0o644); err != nil {
197+ t.Fatal(err)
198+ }
199+ s := newSink()
200+ fc.Process(s)
201+ if len(s.errors) != 1 || !strings.HasPrefix(s.errors[0], "$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of 1024k, got 1024k.") {
202+ t.Fatalf("%q", s.errors)
203+ }
204+ if s.failed {
205+ t.Fatal("an oversized summary only logs an error")
206+ }
207+}
208+
209+func TestSplitLines(t *testing.T) {
210+ got := splitLines("a\r\nb\rc\n\nd")
211+ if !reflect.DeepEqual(got, []string{"a", "b", "c", "", "d"}) {
212+ t.Fatalf("%q", got)
213+ }
214+}
215+
216+func TestPrependedPath(t *testing.T) {
217+ // PathUtil.PrependPath: the last added entry comes first; no trailing
218+ // separator on an empty PATH; nothing changes when already first.
219+ if got := PrependedPath([]string{"/a", "/b"}, "/usr/bin"); got != "/b:/a:/usr/bin" {
220+ t.Fatal(got)
221+ }
222+ if got := PrependedPath([]string{"/a"}, ""); got != "/a" {
223+ t.Fatal(got)
224+ }
225+ if got := PrependedPath([]string{"/a"}, "/a:/usr/bin"); got != "/a:/usr/bin" {
226+ t.Fatal(got)
227+ }
228+ if got := PrependedPath(nil, "/usr/bin"); got != "/usr/bin" {
229+ t.Fatal(got)
230+ }
231+}

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