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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +586 -4
M go.mod +2 -2
| @@ -5,6 +5,7 @@ go 1.27.1 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/zerowrap v1.4.1 | |
| 7 | 7 | github.com/containerd/errdefs v1.0.0 | |
| 8 | + | github.com/google/uuid v1.6.0 | |
| 8 | 9 | github.com/moby/moby/api v1.56.1 | |
| 9 | 10 | github.com/moby/moby/client v0.6.2 | |
| 10 | 11 | golang.org/x/sys v0.49.0 | |
| @@ -28,7 +29,7 @@ require ( | |||
| 28 | 29 | buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect | |
| 29 | 30 | cel.dev/cel-go v0.32.0 // indirect | |
| 30 | 31 | cel.dev/expr v0.25.3 // indirect | |
| 31 | - | connectrpc.com/connect v1.20.0 // indirect | |
| 32 | + | connectrpc.com/connect v1.21.0 // indirect | |
| 32 | 33 | connectrpc.com/otelconnect v0.9.0 // indirect | |
| 33 | 34 | github.com/Microsoft/go-winio v0.6.2 // indirect | |
| 34 | 35 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | |
| @@ -49,7 +50,6 @@ require ( | |||
| 49 | 50 | github.com/go-logr/stdr v1.2.2 // indirect | |
| 50 | 51 | github.com/gofrs/flock v0.13.1 // indirect | |
| 51 | 52 | github.com/google/go-containerregistry v0.22.1 // indirect | |
| 52 | - | github.com/google/uuid v1.6.0 // indirect | |
| 53 | 53 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 54 | 54 | github.com/jdx/go-netrc v1.0.0 // indirect | |
| 55 | 55 | 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.1 | 6 | github.com/bnema/zerowrap v1.4.1 | ||
| 7 | github.com/containerd/errdefs v1.0.0 | 7 | github.com/containerd/errdefs v1.0.0 | ||
| 8 | + | github.com/google/uuid v1.6.0 | |||
| 8 | github.com/moby/moby/api v1.56.1 | 9 | github.com/moby/moby/api v1.56.1 | ||
| 9 | github.com/moby/moby/client v0.6.2 | 10 | github.com/moby/moby/client v0.6.2 | ||
| 10 | golang.org/x/sys v0.49.0 | 11 | golang.org/x/sys v0.49.0 | ||
| @@ -28,7 +29,7 @@ require ( | |||||
| 28 | buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect | 29 | buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect | ||
| 29 | cel.dev/cel-go v0.32.0 // indirect | 30 | cel.dev/cel-go v0.32.0 // indirect | ||
| 30 | cel.dev/expr v0.25.3 // indirect | 31 | cel.dev/expr v0.25.3 // indirect | ||
| 31 | - | connectrpc.com/connect v1.20.0 // indirect | 32 | + | connectrpc.com/connect v1.21.0 // indirect |
| 32 | connectrpc.com/otelconnect v0.9.0 // indirect | 33 | connectrpc.com/otelconnect v0.9.0 // indirect | ||
| 33 | github.com/Microsoft/go-winio v0.6.2 // indirect | 34 | github.com/Microsoft/go-winio v0.6.2 // indirect | ||
| 34 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | 35 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | ||
| @@ -49,7 +50,6 @@ require ( | |||||
| 49 | github.com/go-logr/stdr v1.2.2 // indirect | 50 | github.com/go-logr/stdr v1.2.2 // indirect | ||
| 50 | github.com/gofrs/flock v0.13.1 // indirect | 51 | github.com/gofrs/flock v0.13.1 // indirect | ||
| 51 | github.com/google/go-containerregistry v0.22.1 // indirect | 52 | 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 // indirect | 53 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 54 | github.com/jdx/go-netrc v1.0.0 // indirect | 54 | github.com/jdx/go-netrc v1.0.0 // indirect | ||
| 55 | github.com/klauspost/compress v1.20.0 // indirect | 55 | 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 | + | } | |||