test(executor): run corpus workflows through the executor and compare with the github.com recordings; stdout/stderr batching like ProcessInvoker
- commit
- e0de5de4bd424b88b6ab822da9965885d944ef3c
- author
- brice · · 2026-10-12 18:18 UTC
- parent
- 41c9e28
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +925 -37
M internal/executor/commands.go +9 -9
| @@ -219,7 +219,7 @@ func (m *commandManager) fail(input string, omitEcho bool, err error) { | |||
| 219 | 219 | func (m *commandManager) validateStopToken(token string) error { | |
| 220 | 220 | invalid := m.isRegistered(token) || token == "" || strings.EqualFold(token, "pause-logging") | |
| 221 | 221 | if invalid && !m.host.EnvFlag("ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS") { | |
| 222 | - | return fmt.Errorf("%s", unsupportedStopTokenDisabled) | |
| 222 | + | return msgError(unsupportedStopTokenDisabled) | |
| 223 | 223 | } | |
| 224 | 224 | return nil | |
| 225 | 225 | } | |
| @@ -239,7 +239,7 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||
| 239 | 239 | h.Tagged("##["+name+"]", c.Data) | |
| 240 | 240 | case "echo": | |
| 241 | 241 | if c.Data == "" { | |
| 242 | - | return fmt.Errorf("Value cannot be null or empty. (Parameter 'value')") | |
| 242 | + | return ghErrorf("Value cannot be null or empty. (Parameter 'value')") | |
| 243 | 243 | } | |
| 244 | 244 | switch strings.ToUpper(strings.TrimSpace(c.Data)) { | |
| 245 | 245 | case "ON": | |
| @@ -249,13 +249,13 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||
| 249 | 249 | m.echo = false | |
| 250 | 250 | h.Debug("Setting echo command value to 'off'") | |
| 251 | 251 | default: | |
| 252 | - | return fmt.Errorf("Invalid echo command value. Possible values can be: 'on', 'off'. Current value is: '%s'.", c.Data) | |
| 252 | + | return ghErrorf("Invalid echo command value. Possible values can be: 'on', 'off'. Current value is: '%s'.", c.Data) | |
| 253 | 253 | } | |
| 254 | 254 | case "set-output": | |
| 255 | 255 | h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name)) | |
| 256 | 256 | n := c.prop("name") | |
| 257 | 257 | if n == "" { | |
| 258 | - | return fmt.Errorf("Required field 'name' is missing in ##[set-output] command.") | |
| 258 | + | return ghErrorf("Required field 'name' is missing in ##[set-output] command.") | |
| 259 | 259 | } | |
| 260 | 260 | h.SetOutput(n, c.Data) | |
| 261 | 261 | h.Debug(fmt.Sprintf("steps.<id>.outputs.%s='%s'", n, c.Data)) | |
| @@ -263,17 +263,17 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||
| 263 | 263 | h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name)) | |
| 264 | 264 | n := c.prop("name") | |
| 265 | 265 | if n == "" { | |
| 266 | - | return fmt.Errorf("Required field 'name' is missing in ##[save-state] command.") | |
| 266 | + | return ghErrorf("Required field 'name' is missing in ##[save-state] command.") | |
| 267 | 267 | } | |
| 268 | 268 | h.SaveState(n, c.Data) | |
| 269 | 269 | h.Debug(fmt.Sprintf("Save intra-action state %s = %s", n, c.Data)) | |
| 270 | 270 | case "set-env": | |
| 271 | 271 | if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") { | |
| 272 | - | return fmt.Errorf(unsupportedCommandMessageDisabled, name) | |
| 272 | + | return ghErrorf(unsupportedCommandMessageDisabled, name) | |
| 273 | 273 | } | |
| 274 | 274 | n := c.prop("name") | |
| 275 | 275 | if n == "" { | |
| 276 | - | return fmt.Errorf("Required field 'name' is missing in ##[set-env] command.") | |
| 276 | + | return ghErrorf("Required field 'name' is missing in ##[set-env] command.") | |
| 277 | 277 | } | |
| 278 | 278 | if b := blockedEnvName(n); b != "" { | |
| 279 | 279 | h.Tagged("##[error]", fmt.Sprintf("Can't update %s environment variable using ::set-env:: command.", b)) | |
| @@ -283,10 +283,10 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||
| 283 | 283 | h.Debug(fmt.Sprintf("%s='%s'", n, c.Data)) | |
| 284 | 284 | case "add-path": | |
| 285 | 285 | if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") { | |
| 286 | - | return fmt.Errorf(unsupportedCommandMessageDisabled, name) | |
| 286 | + | return ghErrorf(unsupportedCommandMessageDisabled, name) | |
| 287 | 287 | } | |
| 288 | 288 | if c.Data == "" { | |
| 289 | - | return fmt.Errorf("Value cannot be null or empty. (Parameter 'path')") | |
| 289 | + | return ghErrorf("Value cannot be null or empty. (Parameter 'path')") | |
| 290 | 290 | } | |
| 291 | 291 | h.AddPath(c.Data) | |
| 292 | 292 | case "add-mask", "add-matcher", "remove-matcher": | |
| @@ -219,7 +219,7 @@ func (m *commandManager) fail(input string, omitEcho bool, err error) { | |||||
| 219 | func (m *commandManager) validateStopToken(token string) error { | 219 | func (m *commandManager) validateStopToken(token string) error { | ||
| 220 | invalid := m.isRegistered(token) || token == "" || strings.EqualFold(token, "pause-logging") | 220 | invalid := m.isRegistered(token) || token == "" || strings.EqualFold(token, "pause-logging") | ||
| 221 | if invalid && !m.host.EnvFlag("ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS") { | 221 | if invalid && !m.host.EnvFlag("ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS") { | ||
| 222 | - | return fmt.Errorf("%s", unsupportedStopTokenDisabled) | 222 | + | return msgError(unsupportedStopTokenDisabled) |
| 223 | } | 223 | } | ||
| 224 | return nil | 224 | return nil | ||
| 225 | } | 225 | } | ||
| @@ -239,7 +239,7 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||||
| 239 | h.Tagged("##["+name+"]", c.Data) | 239 | h.Tagged("##["+name+"]", c.Data) | ||
| 240 | case "echo": | 240 | case "echo": | ||
| 241 | if c.Data == "" { | 241 | if c.Data == "" { | ||
| 242 | - | return fmt.Errorf("Value cannot be null or empty. (Parameter 'value')") | 242 | + | return ghErrorf("Value cannot be null or empty. (Parameter 'value')") |
| 243 | } | 243 | } | ||
| 244 | switch strings.ToUpper(strings.TrimSpace(c.Data)) { | 244 | switch strings.ToUpper(strings.TrimSpace(c.Data)) { | ||
| 245 | case "ON": | 245 | case "ON": | ||
| @@ -249,13 +249,13 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||||
| 249 | m.echo = false | 249 | m.echo = false | ||
| 250 | h.Debug("Setting echo command value to 'off'") | 250 | h.Debug("Setting echo command value to 'off'") | ||
| 251 | default: | 251 | default: | ||
| 252 | - | return fmt.Errorf("Invalid echo command value. Possible values can be: 'on', 'off'. Current value is: '%s'.", c.Data) | 252 | + | return ghErrorf("Invalid echo command value. Possible values can be: 'on', 'off'. Current value is: '%s'.", c.Data) |
| 253 | } | 253 | } | ||
| 254 | case "set-output": | 254 | case "set-output": | ||
| 255 | h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name)) | 255 | h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name)) | ||
| 256 | n := c.prop("name") | 256 | n := c.prop("name") | ||
| 257 | if n == "" { | 257 | if n == "" { | ||
| 258 | - | return fmt.Errorf("Required field 'name' is missing in ##[set-output] command.") | 258 | + | return ghErrorf("Required field 'name' is missing in ##[set-output] command.") |
| 259 | } | 259 | } | ||
| 260 | h.SetOutput(n, c.Data) | 260 | h.SetOutput(n, c.Data) | ||
| 261 | h.Debug(fmt.Sprintf("steps.<id>.outputs.%s='%s'", n, c.Data)) | 261 | h.Debug(fmt.Sprintf("steps.<id>.outputs.%s='%s'", n, c.Data)) | ||
| @@ -263,17 +263,17 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||||
| 263 | h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name)) | 263 | h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name)) | ||
| 264 | n := c.prop("name") | 264 | n := c.prop("name") | ||
| 265 | if n == "" { | 265 | if n == "" { | ||
| 266 | - | return fmt.Errorf("Required field 'name' is missing in ##[save-state] command.") | 266 | + | return ghErrorf("Required field 'name' is missing in ##[save-state] command.") |
| 267 | } | 267 | } | ||
| 268 | h.SaveState(n, c.Data) | 268 | h.SaveState(n, c.Data) | ||
| 269 | h.Debug(fmt.Sprintf("Save intra-action state %s = %s", n, c.Data)) | 269 | h.Debug(fmt.Sprintf("Save intra-action state %s = %s", n, c.Data)) | ||
| 270 | case "set-env": | 270 | case "set-env": | ||
| 271 | if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") { | 271 | if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") { | ||
| 272 | - | return fmt.Errorf(unsupportedCommandMessageDisabled, name) | 272 | + | return ghErrorf(unsupportedCommandMessageDisabled, name) |
| 273 | } | 273 | } | ||
| 274 | n := c.prop("name") | 274 | n := c.prop("name") | ||
| 275 | if n == "" { | 275 | if n == "" { | ||
| 276 | - | return fmt.Errorf("Required field 'name' is missing in ##[set-env] command.") | 276 | + | return ghErrorf("Required field 'name' is missing in ##[set-env] command.") |
| 277 | } | 277 | } | ||
| 278 | if b := blockedEnvName(n); b != "" { | 278 | if b := blockedEnvName(n); b != "" { | ||
| 279 | h.Tagged("##[error]", fmt.Sprintf("Can't update %s environment variable using ::set-env:: command.", b)) | 279 | h.Tagged("##[error]", fmt.Sprintf("Can't update %s environment variable using ::set-env:: command.", b)) | ||
| @@ -283,10 +283,10 @@ func (m *commandManager) run(name string, c *actionCommand) error { | |||||
| 283 | h.Debug(fmt.Sprintf("%s='%s'", n, c.Data)) | 283 | h.Debug(fmt.Sprintf("%s='%s'", n, c.Data)) | ||
| 284 | case "add-path": | 284 | case "add-path": | ||
| 285 | if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") { | 285 | if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") { | ||
| 286 | - | return fmt.Errorf(unsupportedCommandMessageDisabled, name) | 286 | + | return ghErrorf(unsupportedCommandMessageDisabled, name) |
| 287 | } | 287 | } | ||
| 288 | if c.Data == "" { | 288 | if c.Data == "" { | ||
| 289 | - | return fmt.Errorf("Value cannot be null or empty. (Parameter 'path')") | 289 | + | return ghErrorf("Value cannot be null or empty. (Parameter 'path')") |
| 290 | } | 290 | } | ||
| 291 | h.AddPath(c.Data) | 291 | h.AddPath(c.Data) | ||
| 292 | case "add-mask", "add-matcher", "remove-matcher": | 292 | case "add-mask", "add-matcher", "remove-matcher": | ||
A internal/executor/corpus_test.go +327 -0
| @@ -0,0 +1,327 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "os/exec" | |
| 10 | + | "path/filepath" | |
| 11 | + | "regexp" | |
| 12 | + | "strings" | |
| 13 | + | "sync" | |
| 14 | + | "testing" | |
| 15 | + | "time" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // These tests run jobs of the ermite-conformance corpus (the CI-1 scenarios) | |
| 19 | + | // through the executor on this machine and compare the steps, their | |
| 20 | + | // conclusions and their logs with the recordings made on github.com. They need | |
| 21 | + | // a checkout of ermite-conformance (the corpus and recordings directories), | |
| 22 | + | // found through ERMITE_CONFORMANCE_DIR or as a sibling of this repository; | |
| 23 | + | // they skip without it. | |
| 24 | + | ||
| 25 | + | func conformanceDir(t *testing.T) string { | |
| 26 | + | t.Helper() | |
| 27 | + | cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")} | |
| 28 | + | wd, _ := os.Getwd() | |
| 29 | + | for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { | |
| 30 | + | cands = append(cands, | |
| 31 | + | filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"), | |
| 32 | + | filepath.Join(d, "ermite-conformance")) | |
| 33 | + | } | |
| 34 | + | for _, c := range cands { | |
| 35 | + | if c == "" { | |
| 36 | + | continue | |
| 37 | + | } | |
| 38 | + | if st, err := os.Stat(filepath.Join(c, "corpus")); err == nil && st.IsDir() { | |
| 39 | + | return c | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)") | |
| 43 | + | return "" | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func needTools(t *testing.T, names ...string) { | |
| 47 | + | t.Helper() | |
| 48 | + | for _, n := range names { | |
| 49 | + | if _, err := exec.LookPath(n); err != nil { | |
| 50 | + | t.Skipf("%s not installed", n) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // recorder is a Reporter that keeps everything. | |
| 56 | + | type recorder struct { | |
| 57 | + | mu sync.Mutex | |
| 58 | + | steps []*recStep | |
| 59 | + | byNum map[int]*recStep | |
| 60 | + | result Result | |
| 61 | + | done bool | |
| 62 | + | lastLogTime time.Time | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | type recStep struct { | |
| 66 | + | Num int | |
| 67 | + | Name string | |
| 68 | + | Conclusion Conclusion | |
| 69 | + | Log []string | |
| 70 | + | Started time.Time | |
| 71 | + | Completed time.Time | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func newRecorder() *recorder { return &recorder{byNum: map[int]*recStep{}} } | |
| 75 | + | ||
| 76 | + | func (r *recorder) StepStarted(n int, name string, at time.Time) { | |
| 77 | + | r.mu.Lock() | |
| 78 | + | defer r.mu.Unlock() | |
| 79 | + | s := &recStep{Num: n, Name: name, Started: at} | |
| 80 | + | r.steps = append(r.steps, s) | |
| 81 | + | r.byNum[n] = s | |
| 82 | + | } | |
| 83 | + | func (r *recorder) Log(n int, at time.Time, line string) { | |
| 84 | + | r.mu.Lock() | |
| 85 | + | defer r.mu.Unlock() | |
| 86 | + | if s := r.byNum[n]; s != nil { | |
| 87 | + | s.Log = append(s.Log, line) | |
| 88 | + | } | |
| 89 | + | r.lastLogTime = at | |
| 90 | + | } | |
| 91 | + | func (r *recorder) StepCompleted(n int, name string, c Conclusion, at time.Time) { | |
| 92 | + | r.mu.Lock() | |
| 93 | + | defer r.mu.Unlock() | |
| 94 | + | s := r.byNum[n] | |
| 95 | + | s.Conclusion, s.Completed = c, at | |
| 96 | + | if name != "" { | |
| 97 | + | s.Name = name | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | func (r *recorder) JobCompleted(res Result) { | |
| 101 | + | r.mu.Lock() | |
| 102 | + | defer r.mu.Unlock() | |
| 103 | + | r.result, r.done = res, true | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (r *recorder) step(t *testing.T, name string) *recStep { | |
| 107 | + | t.Helper() | |
| 108 | + | for _, s := range r.steps { | |
| 109 | + | if s.Name == name { | |
| 110 | + | return s | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | t.Fatalf("no step %q in %v", name, r.names()) | |
| 114 | + | return nil | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (r *recorder) names() []string { | |
| 118 | + | var out []string | |
| 119 | + | for _, s := range r.steps { | |
| 120 | + | out = append(out, s.Name) | |
| 121 | + | } | |
| 122 | + | return out | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // testGitHubContext is a push on a branch, with the keys the corpus reads. | |
| 126 | + | func testGitHubContext(job string) string { | |
| 127 | + | return fmt.Sprintf(`{"github":{"repository":"octo/hello","repository_owner":"octo","run_id":"1","run_number":"1","run_attempt":"1", | |
| 128 | + | "ref":"refs/heads/scn/x/r1","ref_name":"scn/x/r1","ref_type":"branch","ref_protected":false,"sha":"0123456789abcdef0123456789abcdef01234567", | |
| 129 | + | "event_name":"push","event":{"ref":"refs/heads/scn/x/r1"},"job":%q,"workflow":"w","actor":"octo","triggering_actor":"octo", | |
| 130 | + | "server_url":"https://ermite.example","retention_days":"90"}}`, job) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // runLocal runs one job of a workflow file on this machine under a temporary | |
| 134 | + | // home. The environment is minimal and fixed. | |
| 135 | + | func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) { | |
| 136 | + | t.Helper() | |
| 137 | + | home := t.TempDir() | |
| 138 | + | layout := NewLayout(home, "hello", filepath.Join(home, "toolcache")) | |
| 139 | + | rec := newRecorder() | |
| 140 | + | cfg := Config{ | |
| 141 | + | WorkflowPath: ".github/workflows/ci.yml", WorkflowYAML: []byte(yaml), JobKey: jobKey, | |
| 142 | + | ContextsJSON: testGitHubContext(jobKey), Runner: RunnerInfo{Name: "test-runner", Environment: "github-hosted"}, | |
| 143 | + | RunnerVersion: "test", Layout: &layout, Reporter: rec, | |
| 144 | + | BaseEnv: []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home, "LANG=C.UTF-8"}, | |
| 145 | + | TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 500 * time.Millisecond, | |
| 146 | + | } | |
| 147 | + | if tweak != nil { | |
| 148 | + | tweak(&cfg) | |
| 149 | + | } | |
| 150 | + | res := Run(nil, cfg) | |
| 151 | + | return rec, res | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func readFile(t *testing.T, p string) string { | |
| 155 | + | t.Helper() | |
| 156 | + | b, err := os.ReadFile(p) | |
| 157 | + | if err != nil { | |
| 158 | + | t.Fatal(err) | |
| 159 | + | } | |
| 160 | + | return string(b) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | type recording struct { | |
| 164 | + | Pushes []struct { | |
| 165 | + | Runs []struct { | |
| 166 | + | Conclusion string `json:"conclusion"` | |
| 167 | + | Jobs []struct { | |
| 168 | + | Name string `json:"name"` | |
| 169 | + | Conclusion string `json:"conclusion"` | |
| 170 | + | Steps []struct { | |
| 171 | + | Name string `json:"name"` | |
| 172 | + | Conclusion string `json:"conclusion"` | |
| 173 | + | Log []string `json:"log"` | |
| 174 | + | } `json:"steps"` | |
| 175 | + | } `json:"jobs"` | |
| 176 | + | } `json:"runs"` | |
| 177 | + | } `json:"pushes"` | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | var ( | |
| 181 | + | shellPathRe = regexp.MustCompile(`^shell: \S*/(\w+) `) | |
| 182 | + | startPathRe = regexp.MustCompile(`process '\S*/(\w+)'`) | |
| 183 | + | uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) | |
| 184 | + | ) | |
| 185 | + | ||
| 186 | + | // normalizeLine maps the machine-dependent parts of a line to the form of the | |
| 187 | + | // recordings, like the normalisation rules of ermite-conformance: the | |
| 188 | + | // directory of the shell binary, the home directory, the repository name and | |
| 189 | + | // the random names of script files. | |
| 190 | + | func normalizeLine(l, home string) string { | |
| 191 | + | l = shellPathRe.ReplaceAllString(l, "shell: /usr/bin/$1 ") | |
| 192 | + | l = startPathRe.ReplaceAllString(l, "process '/usr/bin/$1'") | |
| 193 | + | l = strings.ReplaceAll(l, home, "/home/runner") | |
| 194 | + | l = strings.ReplaceAll(l, "/work/hello/hello", "/work/<repo>/<repo>") | |
| 195 | + | return uuidRe.ReplaceAllString(l, "<uuid>") | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // shIsBash reports whether sh on this machine is bash (Arch, Fedora): the | |
| 199 | + | // image of ermite has dash, so a step that checks BASH_VERSION under sh would | |
| 200 | + | // differ here for a reason that has nothing to do with the executor. | |
| 201 | + | func shIsBash() bool { | |
| 202 | + | out, err := exec.Command("sh", "-c", `echo "${BASH_VERSION-unset}"`).Output() | |
| 203 | + | return err == nil && strings.TrimSpace(string(out)) != "unset" | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // compareWithRecording runs every job without needs of a scenario and checks | |
| 207 | + | // that the steps, conclusions and logs equal the recording of github.com. Set | |
| 208 | + | // up job (runner and image details) and the dump step the harness appends are | |
| 209 | + | // not compared. | |
| 210 | + | func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) { | |
| 211 | + | t.Helper() | |
| 212 | + | needTools(t, "bash", "sh") | |
| 213 | + | dir := conformanceDir(t) | |
| 214 | + | files, _ := filepath.Glob(filepath.Join(dir, "corpus", scenario, "tree", ".github", "workflows", "*.yml")) | |
| 215 | + | if len(files) != 1 { | |
| 216 | + | t.Fatalf("scenario %s: workflow files %v", scenario, files) | |
| 217 | + | } | |
| 218 | + | yaml := readFile(t, files[0]) | |
| 219 | + | var rec recording | |
| 220 | + | if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "recordings", scenario+".json"))), &rec); err != nil { | |
| 221 | + | t.Fatal(err) | |
| 222 | + | } | |
| 223 | + | if len(rec.Pushes) == 0 || len(rec.Pushes[0].Runs) == 0 { | |
| 224 | + | t.Skip("scenario not recorded") | |
| 225 | + | } | |
| 226 | + | skip := map[string]bool{} | |
| 227 | + | for _, s := range skipJobs { | |
| 228 | + | skip[s] = true | |
| 229 | + | } | |
| 230 | + | n := 0 | |
| 231 | + | for _, jr := range rec.Pushes[0].Runs[0].Jobs { | |
| 232 | + | if skip[jr.Name] { | |
| 233 | + | continue | |
| 234 | + | } | |
| 235 | + | jr := jr | |
| 236 | + | t.Run(jr.Name, func(t *testing.T) { | |
| 237 | + | var home string | |
| 238 | + | got, res := runLocal(t, yaml, jr.Name, func(c *Config) { home = c.Layout.Home }) | |
| 239 | + | // Expected steps: the recording without Set up job and the dump step. | |
| 240 | + | type es struct { | |
| 241 | + | Name, Conclusion string | |
| 242 | + | Log []string | |
| 243 | + | } | |
| 244 | + | var want []es | |
| 245 | + | for _, s := range jr.Steps { | |
| 246 | + | if s.Name == SetUpJobName || s.Name == "ermite-dump" { | |
| 247 | + | continue | |
| 248 | + | } | |
| 249 | + | want = append(want, es{s.Name, s.Conclusion, s.Log}) | |
| 250 | + | } | |
| 251 | + | var have []es | |
| 252 | + | for _, s := range got.steps { | |
| 253 | + | if s.Name == SetUpJobName { | |
| 254 | + | continue | |
| 255 | + | } | |
| 256 | + | var log []string | |
| 257 | + | for _, l := range s.Log { | |
| 258 | + | log = append(log, normalizeLine(l, home)) | |
| 259 | + | } | |
| 260 | + | have = append(have, es{s.Name, string(s.Conclusion), log}) | |
| 261 | + | } | |
| 262 | + | if len(have) != len(want) { | |
| 263 | + | t.Fatalf("steps: got %v, want %d steps", got.names(), len(want)) | |
| 264 | + | } | |
| 265 | + | for i := range want { | |
| 266 | + | w, h := want[i], have[i] | |
| 267 | + | if h.Name != w.Name || h.Conclusion != w.Conclusion { | |
| 268 | + | t.Errorf("step %d: got %q (%s), want %q (%s)", i+1, h.Name, h.Conclusion, w.Name, w.Conclusion) | |
| 269 | + | continue | |
| 270 | + | } | |
| 271 | + | if shIsBash() && strings.Contains(strings.Join(w.Log, "\n"), "shell: /usr/bin/sh ") { | |
| 272 | + | continue // sh is bash here, dash on the image: the output is not comparable | |
| 273 | + | } | |
| 274 | + | if strings.Join(h.Log, "\n") != strings.Join(w.Log, "\n") { | |
| 275 | + | t.Errorf("step %q log differs:\n--- got\n%s\n--- want\n%s", w.Name, strings.Join(h.Log, "\n"), strings.Join(w.Log, "\n")) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | if string(res.Conclusion) != jr.Conclusion && jr.Conclusion != "" { | |
| 279 | + | // The dump step fails on GitHub when an earlier step failed in | |
| 280 | + | // some scenarios; the job conclusion is still the one of the steps. | |
| 281 | + | t.Errorf("job conclusion %s, recording %s", res.Conclusion, jr.Conclusion) | |
| 282 | + | } | |
| 283 | + | }) | |
| 284 | + | n++ | |
| 285 | + | } | |
| 286 | + | if n == 0 { | |
| 287 | + | t.Fatal("no job compared") | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // Scenarios whose jobs have no dependencies and whose logs do not depend on | |
| 292 | + | // the machine, in the order of the plan's list (shells, env, outputs, file | |
| 293 | + | // commands, conditions). | |
| 294 | + | func TestCorpusEnvPrecedence(t *testing.T) { compareWithRecording(t, "env-precedence") } | |
| 295 | + | func TestCorpusEnvExpressions(t *testing.T) { compareWithRecording(t, "env-expressions") } | |
| 296 | + | func TestCorpusEnvSpecialValues(t *testing.T) { compareWithRecording(t, "env-special-values") } | |
| 297 | + | func TestCorpusOutGithubEnv(t *testing.T) { compareWithRecording(t, "out-github-env") } | |
| 298 | + | func TestCorpusOutGithubEnvReserved(t *testing.T) { compareWithRecording(t, "out-github-env-reserved") } | |
| 299 | + | func TestCorpusOutGithubPath(t *testing.T) { compareWithRecording(t, "out-github-path") } | |
| 300 | + | func TestCorpusOutStepOutputs(t *testing.T) { compareWithRecording(t, "out-step-outputs") } | |
| 301 | + | func TestCorpusCtxSteps(t *testing.T) { compareWithRecording(t, "ctx-steps") } | |
| 302 | + | func TestCorpusIfStepConditions(t *testing.T) { compareWithRecording(t, "if-step-conditions") } | |
| 303 | + | func TestCorpusShellWorkingDirectory(t *testing.T) { | |
| 304 | + | compareWithRecording(t, "shell-working-directory") | |
| 305 | + | } | |
| 306 | + | func TestCorpusShellExitCodes(t *testing.T) { compareWithRecording(t, "shell-exit-codes") } | |
| 307 | + | func TestCorpusIfJobLevel(t *testing.T) { | |
| 308 | + | compareWithRecording(t, "if-job-level-fail-effects", "after-job-continue") | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func envPath() string { return os.Getenv("PATH") } | |
| 312 | + | ||
| 313 | + | func TestCorpusShellBuiltin(t *testing.T) { compareWithRecording(t, "shell-builtin") } | |
| 314 | + | func TestCorpusShellCustom(t *testing.T) { compareWithRecording(t, "shell-custom") } | |
| 315 | + | func TestCorpusStatusFunctions(t *testing.T) { compareWithRecording(t, "expr-status-functions") } | |
| 316 | + | ||
| 317 | + | // GitHub rejects the workflow of expr-status-in-env (status functions are not | |
| 318 | + | // allowed in a step's env): the recorded run has no jobs. The executor must | |
| 319 | + | // refuse it at "Set up job" too. | |
| 320 | + | func TestCorpusStatusInEnvIsRejected(t *testing.T) { | |
| 321 | + | dir := conformanceDir(t) | |
| 322 | + | yaml := readFile(t, filepath.Join(dir, "corpus", "expr-status-in-env", "tree", ".github", "workflows", "ci.yml")) | |
| 323 | + | rec, res := runLocal(t, yaml, "main", nil) | |
| 324 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 { | |
| 325 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 326 | + | } | |
| 327 | + | } | |
| @@ -0,0 +1,327 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "regexp" | |||
| 12 | + | "strings" | |||
| 13 | + | "sync" | |||
| 14 | + | "testing" | |||
| 15 | + | "time" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // These tests run jobs of the ermite-conformance corpus (the CI-1 scenarios) | |||
| 19 | + | // through the executor on this machine and compare the steps, their | |||
| 20 | + | // conclusions and their logs with the recordings made on github.com. They need | |||
| 21 | + | // a checkout of ermite-conformance (the corpus and recordings directories), | |||
| 22 | + | // found through ERMITE_CONFORMANCE_DIR or as a sibling of this repository; | |||
| 23 | + | // they skip without it. | |||
| 24 | + | ||||
| 25 | + | func conformanceDir(t *testing.T) string { | |||
| 26 | + | t.Helper() | |||
| 27 | + | cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")} | |||
| 28 | + | wd, _ := os.Getwd() | |||
| 29 | + | for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { | |||
| 30 | + | cands = append(cands, | |||
| 31 | + | filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"), | |||
| 32 | + | filepath.Join(d, "ermite-conformance")) | |||
| 33 | + | } | |||
| 34 | + | for _, c := range cands { | |||
| 35 | + | if c == "" { | |||
| 36 | + | continue | |||
| 37 | + | } | |||
| 38 | + | if st, err := os.Stat(filepath.Join(c, "corpus")); err == nil && st.IsDir() { | |||
| 39 | + | return c | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)") | |||
| 43 | + | return "" | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func needTools(t *testing.T, names ...string) { | |||
| 47 | + | t.Helper() | |||
| 48 | + | for _, n := range names { | |||
| 49 | + | if _, err := exec.LookPath(n); err != nil { | |||
| 50 | + | t.Skipf("%s not installed", n) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // recorder is a Reporter that keeps everything. | |||
| 56 | + | type recorder struct { | |||
| 57 | + | mu sync.Mutex | |||
| 58 | + | steps []*recStep | |||
| 59 | + | byNum map[int]*recStep | |||
| 60 | + | result Result | |||
| 61 | + | done bool | |||
| 62 | + | lastLogTime time.Time | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | type recStep struct { | |||
| 66 | + | Num int | |||
| 67 | + | Name string | |||
| 68 | + | Conclusion Conclusion | |||
| 69 | + | Log []string | |||
| 70 | + | Started time.Time | |||
| 71 | + | Completed time.Time | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func newRecorder() *recorder { return &recorder{byNum: map[int]*recStep{}} } | |||
| 75 | + | ||||
| 76 | + | func (r *recorder) StepStarted(n int, name string, at time.Time) { | |||
| 77 | + | r.mu.Lock() | |||
| 78 | + | defer r.mu.Unlock() | |||
| 79 | + | s := &recStep{Num: n, Name: name, Started: at} | |||
| 80 | + | r.steps = append(r.steps, s) | |||
| 81 | + | r.byNum[n] = s | |||
| 82 | + | } | |||
| 83 | + | func (r *recorder) Log(n int, at time.Time, line string) { | |||
| 84 | + | r.mu.Lock() | |||
| 85 | + | defer r.mu.Unlock() | |||
| 86 | + | if s := r.byNum[n]; s != nil { | |||
| 87 | + | s.Log = append(s.Log, line) | |||
| 88 | + | } | |||
| 89 | + | r.lastLogTime = at | |||
| 90 | + | } | |||
| 91 | + | func (r *recorder) StepCompleted(n int, name string, c Conclusion, at time.Time) { | |||
| 92 | + | r.mu.Lock() | |||
| 93 | + | defer r.mu.Unlock() | |||
| 94 | + | s := r.byNum[n] | |||
| 95 | + | s.Conclusion, s.Completed = c, at | |||
| 96 | + | if name != "" { | |||
| 97 | + | s.Name = name | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | func (r *recorder) JobCompleted(res Result) { | |||
| 101 | + | r.mu.Lock() | |||
| 102 | + | defer r.mu.Unlock() | |||
| 103 | + | r.result, r.done = res, true | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (r *recorder) step(t *testing.T, name string) *recStep { | |||
| 107 | + | t.Helper() | |||
| 108 | + | for _, s := range r.steps { | |||
| 109 | + | if s.Name == name { | |||
| 110 | + | return s | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | t.Fatalf("no step %q in %v", name, r.names()) | |||
| 114 | + | return nil | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func (r *recorder) names() []string { | |||
| 118 | + | var out []string | |||
| 119 | + | for _, s := range r.steps { | |||
| 120 | + | out = append(out, s.Name) | |||
| 121 | + | } | |||
| 122 | + | return out | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // testGitHubContext is a push on a branch, with the keys the corpus reads. | |||
| 126 | + | func testGitHubContext(job string) string { | |||
| 127 | + | return fmt.Sprintf(`{"github":{"repository":"octo/hello","repository_owner":"octo","run_id":"1","run_number":"1","run_attempt":"1", | |||
| 128 | + | "ref":"refs/heads/scn/x/r1","ref_name":"scn/x/r1","ref_type":"branch","ref_protected":false,"sha":"0123456789abcdef0123456789abcdef01234567", | |||
| 129 | + | "event_name":"push","event":{"ref":"refs/heads/scn/x/r1"},"job":%q,"workflow":"w","actor":"octo","triggering_actor":"octo", | |||
| 130 | + | "server_url":"https://ermite.example","retention_days":"90"}}`, job) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // runLocal runs one job of a workflow file on this machine under a temporary | |||
| 134 | + | // home. The environment is minimal and fixed. | |||
| 135 | + | func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) { | |||
| 136 | + | t.Helper() | |||
| 137 | + | home := t.TempDir() | |||
| 138 | + | layout := NewLayout(home, "hello", filepath.Join(home, "toolcache")) | |||
| 139 | + | rec := newRecorder() | |||
| 140 | + | cfg := Config{ | |||
| 141 | + | WorkflowPath: ".github/workflows/ci.yml", WorkflowYAML: []byte(yaml), JobKey: jobKey, | |||
| 142 | + | ContextsJSON: testGitHubContext(jobKey), Runner: RunnerInfo{Name: "test-runner", Environment: "github-hosted"}, | |||
| 143 | + | RunnerVersion: "test", Layout: &layout, Reporter: rec, | |||
| 144 | + | BaseEnv: []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home, "LANG=C.UTF-8"}, | |||
| 145 | + | TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 500 * time.Millisecond, | |||
| 146 | + | } | |||
| 147 | + | if tweak != nil { | |||
| 148 | + | tweak(&cfg) | |||
| 149 | + | } | |||
| 150 | + | res := Run(nil, cfg) | |||
| 151 | + | return rec, res | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func readFile(t *testing.T, p string) string { | |||
| 155 | + | t.Helper() | |||
| 156 | + | b, err := os.ReadFile(p) | |||
| 157 | + | if err != nil { | |||
| 158 | + | t.Fatal(err) | |||
| 159 | + | } | |||
| 160 | + | return string(b) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | type recording struct { | |||
| 164 | + | Pushes []struct { | |||
| 165 | + | Runs []struct { | |||
| 166 | + | Conclusion string `json:"conclusion"` | |||
| 167 | + | Jobs []struct { | |||
| 168 | + | Name string `json:"name"` | |||
| 169 | + | Conclusion string `json:"conclusion"` | |||
| 170 | + | Steps []struct { | |||
| 171 | + | Name string `json:"name"` | |||
| 172 | + | Conclusion string `json:"conclusion"` | |||
| 173 | + | Log []string `json:"log"` | |||
| 174 | + | } `json:"steps"` | |||
| 175 | + | } `json:"jobs"` | |||
| 176 | + | } `json:"runs"` | |||
| 177 | + | } `json:"pushes"` | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | var ( | |||
| 181 | + | shellPathRe = regexp.MustCompile(`^shell: \S*/(\w+) `) | |||
| 182 | + | startPathRe = regexp.MustCompile(`process '\S*/(\w+)'`) | |||
| 183 | + | uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) | |||
| 184 | + | ) | |||
| 185 | + | ||||
| 186 | + | // normalizeLine maps the machine-dependent parts of a line to the form of the | |||
| 187 | + | // recordings, like the normalisation rules of ermite-conformance: the | |||
| 188 | + | // directory of the shell binary, the home directory, the repository name and | |||
| 189 | + | // the random names of script files. | |||
| 190 | + | func normalizeLine(l, home string) string { | |||
| 191 | + | l = shellPathRe.ReplaceAllString(l, "shell: /usr/bin/$1 ") | |||
| 192 | + | l = startPathRe.ReplaceAllString(l, "process '/usr/bin/$1'") | |||
| 193 | + | l = strings.ReplaceAll(l, home, "/home/runner") | |||
| 194 | + | l = strings.ReplaceAll(l, "/work/hello/hello", "/work/<repo>/<repo>") | |||
| 195 | + | return uuidRe.ReplaceAllString(l, "<uuid>") | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // shIsBash reports whether sh on this machine is bash (Arch, Fedora): the | |||
| 199 | + | // image of ermite has dash, so a step that checks BASH_VERSION under sh would | |||
| 200 | + | // differ here for a reason that has nothing to do with the executor. | |||
| 201 | + | func shIsBash() bool { | |||
| 202 | + | out, err := exec.Command("sh", "-c", `echo "${BASH_VERSION-unset}"`).Output() | |||
| 203 | + | return err == nil && strings.TrimSpace(string(out)) != "unset" | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // compareWithRecording runs every job without needs of a scenario and checks | |||
| 207 | + | // that the steps, conclusions and logs equal the recording of github.com. Set | |||
| 208 | + | // up job (runner and image details) and the dump step the harness appends are | |||
| 209 | + | // not compared. | |||
| 210 | + | func compareWithRecording(t *testing.T, scenario string, skipJobs ...string) { | |||
| 211 | + | t.Helper() | |||
| 212 | + | needTools(t, "bash", "sh") | |||
| 213 | + | dir := conformanceDir(t) | |||
| 214 | + | files, _ := filepath.Glob(filepath.Join(dir, "corpus", scenario, "tree", ".github", "workflows", "*.yml")) | |||
| 215 | + | if len(files) != 1 { | |||
| 216 | + | t.Fatalf("scenario %s: workflow files %v", scenario, files) | |||
| 217 | + | } | |||
| 218 | + | yaml := readFile(t, files[0]) | |||
| 219 | + | var rec recording | |||
| 220 | + | if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "recordings", scenario+".json"))), &rec); err != nil { | |||
| 221 | + | t.Fatal(err) | |||
| 222 | + | } | |||
| 223 | + | if len(rec.Pushes) == 0 || len(rec.Pushes[0].Runs) == 0 { | |||
| 224 | + | t.Skip("scenario not recorded") | |||
| 225 | + | } | |||
| 226 | + | skip := map[string]bool{} | |||
| 227 | + | for _, s := range skipJobs { | |||
| 228 | + | skip[s] = true | |||
| 229 | + | } | |||
| 230 | + | n := 0 | |||
| 231 | + | for _, jr := range rec.Pushes[0].Runs[0].Jobs { | |||
| 232 | + | if skip[jr.Name] { | |||
| 233 | + | continue | |||
| 234 | + | } | |||
| 235 | + | jr := jr | |||
| 236 | + | t.Run(jr.Name, func(t *testing.T) { | |||
| 237 | + | var home string | |||
| 238 | + | got, res := runLocal(t, yaml, jr.Name, func(c *Config) { home = c.Layout.Home }) | |||
| 239 | + | // Expected steps: the recording without Set up job and the dump step. | |||
| 240 | + | type es struct { | |||
| 241 | + | Name, Conclusion string | |||
| 242 | + | Log []string | |||
| 243 | + | } | |||
| 244 | + | var want []es | |||
| 245 | + | for _, s := range jr.Steps { | |||
| 246 | + | if s.Name == SetUpJobName || s.Name == "ermite-dump" { | |||
| 247 | + | continue | |||
| 248 | + | } | |||
| 249 | + | want = append(want, es{s.Name, s.Conclusion, s.Log}) | |||
| 250 | + | } | |||
| 251 | + | var have []es | |||
| 252 | + | for _, s := range got.steps { | |||
| 253 | + | if s.Name == SetUpJobName { | |||
| 254 | + | continue | |||
| 255 | + | } | |||
| 256 | + | var log []string | |||
| 257 | + | for _, l := range s.Log { | |||
| 258 | + | log = append(log, normalizeLine(l, home)) | |||
| 259 | + | } | |||
| 260 | + | have = append(have, es{s.Name, string(s.Conclusion), log}) | |||
| 261 | + | } | |||
| 262 | + | if len(have) != len(want) { | |||
| 263 | + | t.Fatalf("steps: got %v, want %d steps", got.names(), len(want)) | |||
| 264 | + | } | |||
| 265 | + | for i := range want { | |||
| 266 | + | w, h := want[i], have[i] | |||
| 267 | + | if h.Name != w.Name || h.Conclusion != w.Conclusion { | |||
| 268 | + | t.Errorf("step %d: got %q (%s), want %q (%s)", i+1, h.Name, h.Conclusion, w.Name, w.Conclusion) | |||
| 269 | + | continue | |||
| 270 | + | } | |||
| 271 | + | if shIsBash() && strings.Contains(strings.Join(w.Log, "\n"), "shell: /usr/bin/sh ") { | |||
| 272 | + | continue // sh is bash here, dash on the image: the output is not comparable | |||
| 273 | + | } | |||
| 274 | + | if strings.Join(h.Log, "\n") != strings.Join(w.Log, "\n") { | |||
| 275 | + | t.Errorf("step %q log differs:\n--- got\n%s\n--- want\n%s", w.Name, strings.Join(h.Log, "\n"), strings.Join(w.Log, "\n")) | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | if string(res.Conclusion) != jr.Conclusion && jr.Conclusion != "" { | |||
| 279 | + | // The dump step fails on GitHub when an earlier step failed in | |||
| 280 | + | // some scenarios; the job conclusion is still the one of the steps. | |||
| 281 | + | t.Errorf("job conclusion %s, recording %s", res.Conclusion, jr.Conclusion) | |||
| 282 | + | } | |||
| 283 | + | }) | |||
| 284 | + | n++ | |||
| 285 | + | } | |||
| 286 | + | if n == 0 { | |||
| 287 | + | t.Fatal("no job compared") | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // Scenarios whose jobs have no dependencies and whose logs do not depend on | |||
| 292 | + | // the machine, in the order of the plan's list (shells, env, outputs, file | |||
| 293 | + | // commands, conditions). | |||
| 294 | + | func TestCorpusEnvPrecedence(t *testing.T) { compareWithRecording(t, "env-precedence") } | |||
| 295 | + | func TestCorpusEnvExpressions(t *testing.T) { compareWithRecording(t, "env-expressions") } | |||
| 296 | + | func TestCorpusEnvSpecialValues(t *testing.T) { compareWithRecording(t, "env-special-values") } | |||
| 297 | + | func TestCorpusOutGithubEnv(t *testing.T) { compareWithRecording(t, "out-github-env") } | |||
| 298 | + | func TestCorpusOutGithubEnvReserved(t *testing.T) { compareWithRecording(t, "out-github-env-reserved") } | |||
| 299 | + | func TestCorpusOutGithubPath(t *testing.T) { compareWithRecording(t, "out-github-path") } | |||
| 300 | + | func TestCorpusOutStepOutputs(t *testing.T) { compareWithRecording(t, "out-step-outputs") } | |||
| 301 | + | func TestCorpusCtxSteps(t *testing.T) { compareWithRecording(t, "ctx-steps") } | |||
| 302 | + | func TestCorpusIfStepConditions(t *testing.T) { compareWithRecording(t, "if-step-conditions") } | |||
| 303 | + | func TestCorpusShellWorkingDirectory(t *testing.T) { | |||
| 304 | + | compareWithRecording(t, "shell-working-directory") | |||
| 305 | + | } | |||
| 306 | + | func TestCorpusShellExitCodes(t *testing.T) { compareWithRecording(t, "shell-exit-codes") } | |||
| 307 | + | func TestCorpusIfJobLevel(t *testing.T) { | |||
| 308 | + | compareWithRecording(t, "if-job-level-fail-effects", "after-job-continue") | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func envPath() string { return os.Getenv("PATH") } | |||
| 312 | + | ||||
| 313 | + | func TestCorpusShellBuiltin(t *testing.T) { compareWithRecording(t, "shell-builtin") } | |||
| 314 | + | func TestCorpusShellCustom(t *testing.T) { compareWithRecording(t, "shell-custom") } | |||
| 315 | + | func TestCorpusStatusFunctions(t *testing.T) { compareWithRecording(t, "expr-status-functions") } | |||
| 316 | + | ||||
| 317 | + | // GitHub rejects the workflow of expr-status-in-env (status functions are not | |||
| 318 | + | // allowed in a step's env): the recorded run has no jobs. The executor must | |||
| 319 | + | // refuse it at "Set up job" too. | |||
| 320 | + | func TestCorpusStatusInEnvIsRejected(t *testing.T) { | |||
| 321 | + | dir := conformanceDir(t) | |||
| 322 | + | yaml := readFile(t, filepath.Join(dir, "corpus", "expr-status-in-env", "tree", ".github", "workflows", "ci.yml")) | |||
| 323 | + | rec, res := runLocal(t, yaml, "main", nil) | |||
| 324 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 { | |||
| 325 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 326 | + | } | |||
| 327 | + | } | |||
M internal/executor/filecmd.go +13 -6
| @@ -9,7 +9,6 @@ package executor | |||
| 9 | 9 | ||
| 10 | 10 | import ( | |
| 11 | 11 | "bytes" | |
| 12 | - | "errors" | |
| 13 | 12 | "fmt" | |
| 14 | 13 | "os" | |
| 15 | 14 | "path/filepath" | |
| @@ -59,6 +58,14 @@ const stepSummaryLimit = 1024 * 1024 | |||
| 59 | 58 | // setEnvBlockList is SetEnvFileCommand._setEnvBlockList (FileCommandManager.cs:215). | |
| 60 | 59 | var setEnvBlockList = []string{"NODE_OPTIONS"} | |
| 61 | 60 | ||
| 61 | + | // msgError is an error whose text is GitHub's own message, capitalised and | |
| 62 | + | // punctuated like the official runner's. | |
| 63 | + | type msgError string | |
| 64 | + | ||
| 65 | + | func (e msgError) Error() string { return string(e) } | |
| 66 | + | ||
| 67 | + | func ghErrorf(format string, a ...any) error { return msgError(fmt.Sprintf(format, a...)) } | |
| 68 | + | ||
| 62 | 69 | // Pair is one name and value read from an environment-style file. | |
| 63 | 70 | type Pair struct{ Key, Value string } | |
| 64 | 71 | ||
| @@ -280,23 +287,23 @@ func ParseEnvFile(text string) ([]Pair, error) { | |||
| 280 | 287 | // an empty variable name is useless: we reject it. | |
| 281 | 288 | k, v, _ := strings.Cut(line, "=") | |
| 282 | 289 | if k == "" { | |
| 283 | - | return out, fmt.Errorf("Invalid format '%s'. Name must not be empty", line) | |
| 290 | + | return out, ghErrorf("Invalid format '%s'. Name must not be empty", line) | |
| 284 | 291 | } | |
| 285 | 292 | key, output = k, v | |
| 286 | 293 | case heredoc >= 0 && (equals < 0 || heredoc < equals): | |
| 287 | 294 | k, delim, _ := strings.Cut(line, "<<") | |
| 288 | 295 | if k == "" || delim == "" { | |
| 289 | - | return out, fmt.Errorf("Invalid format '%s'. Name must not be empty and delimiter must not be empty", line) | |
| 296 | + | return out, ghErrorf("Invalid format '%s'. Name must not be empty and delimiter must not be empty", line) | |
| 290 | 297 | } | |
| 291 | 298 | key = k | |
| 292 | 299 | start, end := index, index | |
| 293 | 300 | temp, newline, tok := readLine(text, &index) | |
| 294 | 301 | for temp != delim || !tok { | |
| 295 | 302 | if !tok { | |
| 296 | - | return out, fmt.Errorf("Invalid value. Matching delimiter not found '%s'", delim) | |
| 303 | + | return out, ghErrorf("Invalid value. Matching delimiter not found '%s'", delim) | |
| 297 | 304 | } | |
| 298 | 305 | if newline == "" { | |
| 299 | - | return out, errors.New("Invalid value. EOF marker missing new line.") | |
| 306 | + | return out, ghErrorf("Invalid value. EOF marker missing new line.") | |
| 300 | 307 | } | |
| 301 | 308 | end = index - len(newline) | |
| 302 | 309 | temp, newline, tok = readLine(text, &index) | |
| @@ -305,7 +312,7 @@ func ParseEnvFile(text string) ([]Pair, error) { | |||
| 305 | 312 | output = text[start:end] | |
| 306 | 313 | } | |
| 307 | 314 | default: | |
| 308 | - | return out, fmt.Errorf("Invalid format '%s'", line) | |
| 315 | + | return out, ghErrorf("Invalid format '%s'", line) | |
| 309 | 316 | } | |
| 310 | 317 | out = append(out, Pair{key, output}) | |
| 311 | 318 | } | |
| @@ -9,7 +9,6 @@ package executor | |||||
| 9 | 9 | ||||
| 10 | import ( | 10 | import ( | ||
| 11 | "bytes" | 11 | "bytes" | ||
| 12 | - | "errors" | |||
| 13 | "fmt" | 12 | "fmt" | ||
| 14 | "os" | 13 | "os" | ||
| 15 | "path/filepath" | 14 | "path/filepath" | ||
| @@ -59,6 +58,14 @@ const stepSummaryLimit = 1024 * 1024 | |||||
| 59 | // setEnvBlockList is SetEnvFileCommand._setEnvBlockList (FileCommandManager.cs:215). | 58 | // setEnvBlockList is SetEnvFileCommand._setEnvBlockList (FileCommandManager.cs:215). | ||
| 60 | var setEnvBlockList = []string{"NODE_OPTIONS"} | 59 | var setEnvBlockList = []string{"NODE_OPTIONS"} | ||
| 61 | 60 | ||||
| 61 | + | // msgError is an error whose text is GitHub's own message, capitalised and | |||
| 62 | + | // punctuated like the official runner's. | |||
| 63 | + | type msgError string | |||
| 64 | + | ||||
| 65 | + | func (e msgError) Error() string { return string(e) } | |||
| 66 | + | ||||
| 67 | + | func ghErrorf(format string, a ...any) error { return msgError(fmt.Sprintf(format, a...)) } | |||
| 68 | + | ||||
| 62 | // Pair is one name and value read from an environment-style file. | 69 | // Pair is one name and value read from an environment-style file. | ||
| 63 | type Pair struct{ Key, Value string } | 70 | type Pair struct{ Key, Value string } | ||
| 64 | 71 | ||||
| @@ -280,23 +287,23 @@ func ParseEnvFile(text string) ([]Pair, error) { | |||||
| 280 | // an empty variable name is useless: we reject it. | 287 | // an empty variable name is useless: we reject it. | ||
| 281 | k, v, _ := strings.Cut(line, "=") | 288 | k, v, _ := strings.Cut(line, "=") | ||
| 282 | if k == "" { | 289 | if k == "" { | ||
| 283 | - | return out, fmt.Errorf("Invalid format '%s'. Name must not be empty", line) | 290 | + | return out, ghErrorf("Invalid format '%s'. Name must not be empty", line) |
| 284 | } | 291 | } | ||
| 285 | key, output = k, v | 292 | key, output = k, v | ||
| 286 | case heredoc >= 0 && (equals < 0 || heredoc < equals): | 293 | case heredoc >= 0 && (equals < 0 || heredoc < equals): | ||
| 287 | k, delim, _ := strings.Cut(line, "<<") | 294 | k, delim, _ := strings.Cut(line, "<<") | ||
| 288 | if k == "" || delim == "" { | 295 | if k == "" || delim == "" { | ||
| 289 | - | return out, fmt.Errorf("Invalid format '%s'. Name must not be empty and delimiter must not be empty", line) | 296 | + | return out, ghErrorf("Invalid format '%s'. Name must not be empty and delimiter must not be empty", line) |
| 290 | } | 297 | } | ||
| 291 | key = k | 298 | key = k | ||
| 292 | start, end := index, index | 299 | start, end := index, index | ||
| 293 | temp, newline, tok := readLine(text, &index) | 300 | temp, newline, tok := readLine(text, &index) | ||
| 294 | for temp != delim || !tok { | 301 | for temp != delim || !tok { | ||
| 295 | if !tok { | 302 | if !tok { | ||
| 296 | - | return out, fmt.Errorf("Invalid value. Matching delimiter not found '%s'", delim) | 303 | + | return out, ghErrorf("Invalid value. Matching delimiter not found '%s'", delim) |
| 297 | } | 304 | } | ||
| 298 | if newline == "" { | 305 | if newline == "" { | ||
| 299 | - | return out, errors.New("Invalid value. EOF marker missing new line.") | 306 | + | return out, ghErrorf("Invalid value. EOF marker missing new line.") |
| 300 | } | 307 | } | ||
| 301 | end = index - len(newline) | 308 | end = index - len(newline) | ||
| 302 | temp, newline, tok = readLine(text, &index) | 309 | temp, newline, tok = readLine(text, &index) | ||
| @@ -305,7 +312,7 @@ func ParseEnvFile(text string) ([]Pair, error) { | |||||
| 305 | output = text[start:end] | 312 | output = text[start:end] | ||
| 306 | } | 313 | } | ||
| 307 | default: | 314 | default: | ||
| 308 | - | return out, fmt.Errorf("Invalid format '%s'", line) | 315 | + | return out, ghErrorf("Invalid format '%s'", line) |
| 309 | } | 316 | } | ||
| 310 | out = append(out, Pair{key, output}) | 317 | out = append(out, Pair{key, output}) | ||
| 311 | } | 318 | } | ||
M internal/executor/proc.go +85 -4
| @@ -79,6 +79,86 @@ func (w *lineWriter) Flush() { | |||
| 79 | 79 | } | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | + | // dispatchBatchDelay is how long the dispatcher lets lines pile up after the | |
| 83 | + | // first one before it hands a batch over. The official runner hands over | |
| 84 | + | // whatever its reader threads queued when its loop wakes up (a thread-pool | |
| 85 | + | // wake-up, about a millisecond); Go wakes in microseconds, so without this | |
| 86 | + | // pause the interleaving of a fast script's stdout and stderr would differ | |
| 87 | + | // from the official runner's more often. | |
| 88 | + | const dispatchBatchDelay = 2 * time.Millisecond | |
| 89 | + | ||
| 90 | + | // dispatcher reproduces how ProcessInvoker delivers output: stdout and stderr | |
| 91 | + | // are read by two readers into two queues, and a loop that wakes up on a | |
| 92 | + | // signal hands over all queued stderr lines first and then all queued stdout | |
| 93 | + | // lines (ProcessInvoker.ProcessOutput, ProcessInvoker.cs:392-436). Within a | |
| 94 | + | // stream the order is kept; between the streams a batch puts stderr first, | |
| 95 | + | // which is visible with `bash -x` (the corpus recording of shell-custom). | |
| 96 | + | type dispatcher struct { | |
| 97 | + | fn func(string) | |
| 98 | + | mu sync.Mutex | |
| 99 | + | outQ []string | |
| 100 | + | errQ []string | |
| 101 | + | signal chan struct{} | |
| 102 | + | closed chan struct{} | |
| 103 | + | finished chan struct{} | |
| 104 | + | out, err *lineWriter | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func newDispatcher(fn func(string)) *dispatcher { | |
| 108 | + | d := &dispatcher{fn: fn, signal: make(chan struct{}, 1), closed: make(chan struct{}), finished: make(chan struct{})} | |
| 109 | + | push := func(q *[]string) func(string) { | |
| 110 | + | return func(line string) { | |
| 111 | + | d.mu.Lock() | |
| 112 | + | *q = append(*q, line) | |
| 113 | + | d.mu.Unlock() | |
| 114 | + | select { | |
| 115 | + | case d.signal <- struct{}{}: | |
| 116 | + | default: | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | d.out = &lineWriter{fn: push(&d.outQ)} | |
| 121 | + | d.err = &lineWriter{fn: push(&d.errQ)} | |
| 122 | + | go d.loop() | |
| 123 | + | return d | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (d *dispatcher) drain() { | |
| 127 | + | d.mu.Lock() | |
| 128 | + | errs, outs := d.errQ, d.outQ | |
| 129 | + | d.errQ, d.outQ = nil, nil | |
| 130 | + | d.mu.Unlock() | |
| 131 | + | for _, l := range errs { | |
| 132 | + | d.fn(l) | |
| 133 | + | } | |
| 134 | + | for _, l := range outs { | |
| 135 | + | d.fn(l) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (d *dispatcher) loop() { | |
| 140 | + | defer close(d.finished) | |
| 141 | + | for { | |
| 142 | + | select { | |
| 143 | + | case <-d.signal: | |
| 144 | + | time.Sleep(dispatchBatchDelay) | |
| 145 | + | d.drain() | |
| 146 | + | case <-d.closed: | |
| 147 | + | d.drain() | |
| 148 | + | return | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // Close flushes the readers and delivers what is left. Call it once the | |
| 154 | + | // process and its pipes are done. | |
| 155 | + | func (d *dispatcher) Close() { | |
| 156 | + | d.err.Flush() | |
| 157 | + | d.out.Flush() | |
| 158 | + | close(d.closed) | |
| 159 | + | <-d.finished | |
| 160 | + | } | |
| 161 | + | ||
| 82 | 162 | // procSpec describes one process to run. | |
| 83 | 163 | type procSpec struct { | |
| 84 | 164 | Argv []string | |
| @@ -86,7 +166,8 @@ type procSpec struct { | |||
| 86 | 166 | Env []string | |
| 87 | 167 | // Credential, when set, is the user the process runs as. | |
| 88 | 168 | Credential *syscall.Credential | |
| 89 | - | // OnLine receives the lines of stdout and stderr (one stream, in order). | |
| 169 | + | // OnLine receives the lines of stdout and stderr, one call at a time, in | |
| 170 | + | // the order of dispatcher. | |
| 90 | 171 | OnLine func(line string) | |
| 91 | 172 | // Cancel starts the SIGINT, SIGTERM, SIGKILL escalation when closed. | |
| 92 | 173 | Cancel <-chan struct{} | |
| @@ -124,8 +205,8 @@ func runProcess(spec procSpec) (procResult, error) { | |||
| 124 | 205 | cmd.Dir = spec.Dir | |
| 125 | 206 | cmd.Env = spec.Env | |
| 126 | 207 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: spec.Credential} | |
| 127 | - | lw := &lineWriter{fn: spec.OnLine} | |
| 128 | - | cmd.Stdout, cmd.Stderr = lw, lw // the same writer: one pipe, order kept | |
| 208 | + | disp := newDispatcher(spec.OnLine) | |
| 209 | + | cmd.Stdout, cmd.Stderr = disp.out, disp.err | |
| 129 | 210 | cmd.WaitDelay = spec.WaitDelay | |
| 130 | 211 | if err := cmd.Start(); err != nil { | |
| 131 | 212 | return procResult{}, err | |
| @@ -177,7 +258,7 @@ func runProcess(spec procSpec) (procResult, error) { | |||
| 177 | 258 | mu.Unlock() | |
| 178 | 259 | close(stopWatch) | |
| 179 | 260 | <-watchDone | |
| 180 | - | lw.Flush() | |
| 261 | + | disp.Close() | |
| 181 | 262 | ||
| 182 | 263 | res := procResult{Cancelled: was} | |
| 183 | 264 | var ee *exec.ExitError | |
| @@ -79,6 +79,86 @@ func (w *lineWriter) Flush() { | |||||
| 79 | } | 79 | } | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | + | // dispatchBatchDelay is how long the dispatcher lets lines pile up after the | |||
| 83 | + | // first one before it hands a batch over. The official runner hands over | |||
| 84 | + | // whatever its reader threads queued when its loop wakes up (a thread-pool | |||
| 85 | + | // wake-up, about a millisecond); Go wakes in microseconds, so without this | |||
| 86 | + | // pause the interleaving of a fast script's stdout and stderr would differ | |||
| 87 | + | // from the official runner's more often. | |||
| 88 | + | const dispatchBatchDelay = 2 * time.Millisecond | |||
| 89 | + | ||||
| 90 | + | // dispatcher reproduces how ProcessInvoker delivers output: stdout and stderr | |||
| 91 | + | // are read by two readers into two queues, and a loop that wakes up on a | |||
| 92 | + | // signal hands over all queued stderr lines first and then all queued stdout | |||
| 93 | + | // lines (ProcessInvoker.ProcessOutput, ProcessInvoker.cs:392-436). Within a | |||
| 94 | + | // stream the order is kept; between the streams a batch puts stderr first, | |||
| 95 | + | // which is visible with `bash -x` (the corpus recording of shell-custom). | |||
| 96 | + | type dispatcher struct { | |||
| 97 | + | fn func(string) | |||
| 98 | + | mu sync.Mutex | |||
| 99 | + | outQ []string | |||
| 100 | + | errQ []string | |||
| 101 | + | signal chan struct{} | |||
| 102 | + | closed chan struct{} | |||
| 103 | + | finished chan struct{} | |||
| 104 | + | out, err *lineWriter | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func newDispatcher(fn func(string)) *dispatcher { | |||
| 108 | + | d := &dispatcher{fn: fn, signal: make(chan struct{}, 1), closed: make(chan struct{}), finished: make(chan struct{})} | |||
| 109 | + | push := func(q *[]string) func(string) { | |||
| 110 | + | return func(line string) { | |||
| 111 | + | d.mu.Lock() | |||
| 112 | + | *q = append(*q, line) | |||
| 113 | + | d.mu.Unlock() | |||
| 114 | + | select { | |||
| 115 | + | case d.signal <- struct{}{}: | |||
| 116 | + | default: | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | d.out = &lineWriter{fn: push(&d.outQ)} | |||
| 121 | + | d.err = &lineWriter{fn: push(&d.errQ)} | |||
| 122 | + | go d.loop() | |||
| 123 | + | return d | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (d *dispatcher) drain() { | |||
| 127 | + | d.mu.Lock() | |||
| 128 | + | errs, outs := d.errQ, d.outQ | |||
| 129 | + | d.errQ, d.outQ = nil, nil | |||
| 130 | + | d.mu.Unlock() | |||
| 131 | + | for _, l := range errs { | |||
| 132 | + | d.fn(l) | |||
| 133 | + | } | |||
| 134 | + | for _, l := range outs { | |||
| 135 | + | d.fn(l) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (d *dispatcher) loop() { | |||
| 140 | + | defer close(d.finished) | |||
| 141 | + | for { | |||
| 142 | + | select { | |||
| 143 | + | case <-d.signal: | |||
| 144 | + | time.Sleep(dispatchBatchDelay) | |||
| 145 | + | d.drain() | |||
| 146 | + | case <-d.closed: | |||
| 147 | + | d.drain() | |||
| 148 | + | return | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // Close flushes the readers and delivers what is left. Call it once the | |||
| 154 | + | // process and its pipes are done. | |||
| 155 | + | func (d *dispatcher) Close() { | |||
| 156 | + | d.err.Flush() | |||
| 157 | + | d.out.Flush() | |||
| 158 | + | close(d.closed) | |||
| 159 | + | <-d.finished | |||
| 160 | + | } | |||
| 161 | + | ||||
| 82 | // procSpec describes one process to run. | 162 | // procSpec describes one process to run. | ||
| 83 | type procSpec struct { | 163 | type procSpec struct { | ||
| 84 | Argv []string | 164 | Argv []string | ||
| @@ -86,7 +166,8 @@ type procSpec struct { | |||||
| 86 | Env []string | 166 | Env []string | ||
| 87 | // Credential, when set, is the user the process runs as. | 167 | // Credential, when set, is the user the process runs as. | ||
| 88 | Credential *syscall.Credential | 168 | Credential *syscall.Credential | ||
| 89 | - | // OnLine receives the lines of stdout and stderr (one stream, in order). | 169 | + | // OnLine receives the lines of stdout and stderr, one call at a time, in |
| 170 | + | // the order of dispatcher. | |||
| 90 | OnLine func(line string) | 171 | OnLine func(line string) | ||
| 91 | // Cancel starts the SIGINT, SIGTERM, SIGKILL escalation when closed. | 172 | // Cancel starts the SIGINT, SIGTERM, SIGKILL escalation when closed. | ||
| 92 | Cancel <-chan struct{} | 173 | Cancel <-chan struct{} | ||
| @@ -124,8 +205,8 @@ func runProcess(spec procSpec) (procResult, error) { | |||||
| 124 | cmd.Dir = spec.Dir | 205 | cmd.Dir = spec.Dir | ||
| 125 | cmd.Env = spec.Env | 206 | cmd.Env = spec.Env | ||
| 126 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: spec.Credential} | 207 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: spec.Credential} | ||
| 127 | - | lw := &lineWriter{fn: spec.OnLine} | 208 | + | disp := newDispatcher(spec.OnLine) |
| 128 | - | cmd.Stdout, cmd.Stderr = lw, lw // the same writer: one pipe, order kept | 209 | + | cmd.Stdout, cmd.Stderr = disp.out, disp.err |
| 129 | cmd.WaitDelay = spec.WaitDelay | 210 | cmd.WaitDelay = spec.WaitDelay | ||
| 130 | if err := cmd.Start(); err != nil { | 211 | if err := cmd.Start(); err != nil { | ||
| 131 | return procResult{}, err | 212 | return procResult{}, err | ||
| @@ -177,7 +258,7 @@ func runProcess(spec procSpec) (procResult, error) { | |||||
| 177 | mu.Unlock() | 258 | mu.Unlock() | ||
| 178 | close(stopWatch) | 259 | close(stopWatch) | ||
| 179 | <-watchDone | 260 | <-watchDone | ||
| 180 | - | lw.Flush() | 261 | + | disp.Close() |
| 181 | 262 | ||||
| 182 | res := procResult{Cancelled: was} | 263 | res := procResult{Cancelled: was} | ||
| 183 | var ee *exec.ExitError | 264 | var ee *exec.ExitError | ||
M internal/executor/shell.go +4 -5
| @@ -3,7 +3,6 @@ | |||
| 3 | 3 | package executor | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "os" | |
| 9 | 8 | "path/filepath" | |
| @@ -71,7 +70,7 @@ func ParseShellOption(shell string) (command, args string) { | |||
| 71 | 70 | ||
| 72 | 71 | // ErrInvalidShell is the error of a shell whose argument format has no {0} | |
| 73 | 72 | // (ScriptHandler.cs:245). | |
| 74 | - | var ErrInvalidShell = errors.New("Invalid shell option. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{0}'") | |
| 73 | + | var ErrInvalidShell error = msgError("Invalid shell option. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{0}'") | |
| 75 | 74 | ||
| 76 | 75 | // Lookup finds executables like WhichUtil.Which (Runner.Sdk/Util/WhichUtil.cs): | |
| 77 | 76 | // in PATH with the directories added by GITHUB_PATH in front. | |
| @@ -203,18 +202,18 @@ func FormatArguments(format, scriptPath string) (string, error) { | |||
| 203 | 202 | case c == '{': | |
| 204 | 203 | end := strings.IndexByte(format[i:], '}') | |
| 205 | 204 | if end < 0 { | |
| 206 | - | return "", errors.New("Input string was not in a correct format.") | |
| 205 | + | return "", ghErrorf("Input string was not in a correct format.") | |
| 207 | 206 | } | |
| 208 | 207 | spec := format[i+1 : i+end] | |
| 209 | 208 | index, _, _ := strings.Cut(spec, ",") | |
| 210 | 209 | index, _, _ = strings.Cut(index, ":") | |
| 211 | 210 | if strings.TrimSpace(index) != "0" { | |
| 212 | - | return "", errors.New("Index (zero based) must be greater than or equal to zero and less than the size of the argument list.") | |
| 211 | + | return "", ghErrorf("Index (zero based) must be greater than or equal to zero and less than the size of the argument list.") | |
| 213 | 212 | } | |
| 214 | 213 | b.WriteString(path) | |
| 215 | 214 | i += end | |
| 216 | 215 | case c == '}': | |
| 217 | - | return "", errors.New("Input string was not in a correct format.") | |
| 216 | + | return "", ghErrorf("Input string was not in a correct format.") | |
| 218 | 217 | default: | |
| 219 | 218 | b.WriteByte(c) | |
| 220 | 219 | } | |
| @@ -3,7 +3,6 @@ | |||||
| 3 | package executor | 3 | package executor | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | - | "errors" | |||
| 7 | "fmt" | 6 | "fmt" | ||
| 8 | "os" | 7 | "os" | ||
| 9 | "path/filepath" | 8 | "path/filepath" | ||
| @@ -71,7 +70,7 @@ func ParseShellOption(shell string) (command, args string) { | |||||
| 71 | 70 | ||||
| 72 | // ErrInvalidShell is the error of a shell whose argument format has no {0} | 71 | // ErrInvalidShell is the error of a shell whose argument format has no {0} | ||
| 73 | // (ScriptHandler.cs:245). | 72 | // (ScriptHandler.cs:245). | ||
| 74 | - | var ErrInvalidShell = errors.New("Invalid shell option. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{0}'") | 73 | + | var ErrInvalidShell error = msgError("Invalid shell option. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{0}'") |
| 75 | 74 | ||||
| 76 | // Lookup finds executables like WhichUtil.Which (Runner.Sdk/Util/WhichUtil.cs): | 75 | // Lookup finds executables like WhichUtil.Which (Runner.Sdk/Util/WhichUtil.cs): | ||
| 77 | // in PATH with the directories added by GITHUB_PATH in front. | 76 | // in PATH with the directories added by GITHUB_PATH in front. | ||
| @@ -203,18 +202,18 @@ func FormatArguments(format, scriptPath string) (string, error) { | |||||
| 203 | case c == '{': | 202 | case c == '{': | ||
| 204 | end := strings.IndexByte(format[i:], '}') | 203 | end := strings.IndexByte(format[i:], '}') | ||
| 205 | if end < 0 { | 204 | if end < 0 { | ||
| 206 | - | return "", errors.New("Input string was not in a correct format.") | 205 | + | return "", ghErrorf("Input string was not in a correct format.") |
| 207 | } | 206 | } | ||
| 208 | spec := format[i+1 : i+end] | 207 | spec := format[i+1 : i+end] | ||
| 209 | index, _, _ := strings.Cut(spec, ",") | 208 | index, _, _ := strings.Cut(spec, ",") | ||
| 210 | index, _, _ = strings.Cut(index, ":") | 209 | index, _, _ = strings.Cut(index, ":") | ||
| 211 | if strings.TrimSpace(index) != "0" { | 210 | if strings.TrimSpace(index) != "0" { | ||
| 212 | - | return "", errors.New("Index (zero based) must be greater than or equal to zero and less than the size of the argument list.") | 211 | + | return "", ghErrorf("Index (zero based) must be greater than or equal to zero and less than the size of the argument list.") |
| 213 | } | 212 | } | ||
| 214 | b.WriteString(path) | 213 | b.WriteString(path) | ||
| 215 | i += end | 214 | i += end | ||
| 216 | case c == '}': | 215 | case c == '}': | ||
| 217 | - | return "", errors.New("Input string was not in a correct format.") | 216 | + | return "", ghErrorf("Input string was not in a correct format.") |
| 218 | default: | 217 | default: | ||
| 219 | b.WriteByte(c) | 218 | b.WriteByte(c) | ||
| 220 | } | 219 | } | ||
M internal/executor/steps.go +10 -13
| @@ -123,7 +123,6 @@ type job struct { | |||
| 123 | 123 | baseEnv []string | |
| 124 | 124 | lookupPath string | |
| 125 | 125 | before map[string]bool | |
| 126 | - | outputsSet bool | |
| 127 | 126 | } | |
| 128 | 127 | ||
| 129 | 128 | func (j *job) now() time.Time { | |
| @@ -287,7 +286,7 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) { | |||
| 287 | 286 | var out []Pair | |
| 288 | 287 | for _, p := range d.Pairs() { | |
| 289 | 288 | if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary { | |
| 290 | - | return nil, fmt.Errorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k) | |
| 289 | + | return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k) | |
| 291 | 290 | } | |
| 292 | 291 | out = append(out, Pair{p.Key, expr.CoerceString(p.Value)}) | |
| 293 | 292 | } | |
| @@ -313,11 +312,11 @@ func (j *job) setUp() ([]*workflow.Step, error) { | |||
| 313 | 312 | for _, e := range errs { | |
| 314 | 313 | msgs = append(msgs, e.Error()) | |
| 315 | 314 | } | |
| 316 | - | return nil, fmt.Errorf("The workflow is not valid. %s", strings.Join(msgs, "\n")) | |
| 315 | + | return nil, ghErrorf("The workflow is not valid. %s", strings.Join(msgs, "\n")) | |
| 317 | 316 | } | |
| 318 | 317 | jb := wf.Job(cfg.JobKey) | |
| 319 | 318 | if jb == nil { | |
| 320 | - | return nil, fmt.Errorf("The workflow has no job '%s'.", cfg.JobKey) | |
| 319 | + | return nil, ghErrorf("The workflow has no job '%s'.", cfg.JobKey) | |
| 321 | 320 | } | |
| 322 | 321 | j.wf, j.jb = wf, jb | |
| 323 | 322 | ||
| @@ -446,15 +445,13 @@ func envValue(env []string, key string) string { | |||
| 446 | 445 | // stepRun is the state of one step being run; it implements Sink and | |
| 447 | 446 | // commandHost. | |
| 448 | 447 | type stepRun struct { | |
| 449 | - | j *job | |
| 450 | - | num int | |
| 451 | - | st *workflow.Step | |
| 452 | - | env *ordered // the step's environment: job env + step env | |
| 453 | - | result *stepResult | |
| 454 | - | cmdResult *stepResult | |
| 455 | - | outcome *stepResult | |
| 456 | - | cmds *commandManager | |
| 457 | - | secretEnvLogged bool | |
| 448 | + | j *job | |
| 449 | + | num int | |
| 450 | + | st *workflow.Step | |
| 451 | + | env *ordered // the step's environment: job env + step env | |
| 452 | + | cmdResult *stepResult | |
| 453 | + | outcome *stepResult | |
| 454 | + | cmds *commandManager | |
| 458 | 455 | } | |
| 459 | 456 | ||
| 460 | 457 | func (s *stepRun) Out(text string) { s.j.out(s.num, text) } | |
| @@ -123,7 +123,6 @@ type job struct { | |||||
| 123 | baseEnv []string | 123 | baseEnv []string | ||
| 124 | lookupPath string | 124 | lookupPath string | ||
| 125 | before map[string]bool | 125 | before map[string]bool | ||
| 126 | - | outputsSet bool | |||
| 127 | } | 126 | } | ||
| 128 | 127 | ||||
| 129 | func (j *job) now() time.Time { | 128 | func (j *job) now() time.Time { | ||
| @@ -287,7 +286,7 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) { | |||||
| 287 | var out []Pair | 286 | var out []Pair | ||
| 288 | for _, p := range d.Pairs() { | 287 | for _, p := range d.Pairs() { | ||
| 289 | if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary { | 288 | if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary { | ||
| 290 | - | return nil, fmt.Errorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k) | 289 | + | return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k) |
| 291 | } | 290 | } | ||
| 292 | out = append(out, Pair{p.Key, expr.CoerceString(p.Value)}) | 291 | out = append(out, Pair{p.Key, expr.CoerceString(p.Value)}) | ||
| 293 | } | 292 | } | ||
| @@ -313,11 +312,11 @@ func (j *job) setUp() ([]*workflow.Step, error) { | |||||
| 313 | for _, e := range errs { | 312 | for _, e := range errs { | ||
| 314 | msgs = append(msgs, e.Error()) | 313 | msgs = append(msgs, e.Error()) | ||
| 315 | } | 314 | } | ||
| 316 | - | return nil, fmt.Errorf("The workflow is not valid. %s", strings.Join(msgs, "\n")) | 315 | + | return nil, ghErrorf("The workflow is not valid. %s", strings.Join(msgs, "\n")) |
| 317 | } | 316 | } | ||
| 318 | jb := wf.Job(cfg.JobKey) | 317 | jb := wf.Job(cfg.JobKey) | ||
| 319 | if jb == nil { | 318 | if jb == nil { | ||
| 320 | - | return nil, fmt.Errorf("The workflow has no job '%s'.", cfg.JobKey) | 319 | + | return nil, ghErrorf("The workflow has no job '%s'.", cfg.JobKey) |
| 321 | } | 320 | } | ||
| 322 | j.wf, j.jb = wf, jb | 321 | j.wf, j.jb = wf, jb | ||
| 323 | 322 | ||||
| @@ -446,15 +445,13 @@ func envValue(env []string, key string) string { | |||||
| 446 | // stepRun is the state of one step being run; it implements Sink and | 445 | // stepRun is the state of one step being run; it implements Sink and | ||
| 447 | // commandHost. | 446 | // commandHost. | ||
| 448 | type stepRun struct { | 447 | type stepRun struct { | ||
| 449 | - | j *job | 448 | + | j *job |
| 450 | - | num int | 449 | + | num int |
| 451 | - | st *workflow.Step | 450 | + | st *workflow.Step |
| 452 | - | env *ordered // the step's environment: job env + step env | 451 | + | env *ordered // the step's environment: job env + step env |
| 453 | - | result *stepResult | 452 | + | cmdResult *stepResult |
| 454 | - | cmdResult *stepResult | 453 | + | outcome *stepResult |
| 455 | - | outcome *stepResult | 454 | + | cmds *commandManager |
| 456 | - | cmds *commandManager | |||
| 457 | - | secretEnvLogged bool | |||
| 458 | } | 455 | } | ||
| 459 | 456 | ||||
| 460 | func (s *stepRun) Out(text string) { s.j.out(s.num, text) } | 457 | func (s *stepRun) Out(text string) { s.j.out(s.num, text) } | ||
A internal/executor/steps_test.go +477 -0
| @@ -0,0 +1,477 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "syscall" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const wfHead = "on: push\njobs:\n j:\n runs-on: x\n" | |
| 13 | + | ||
| 14 | + | func logOf(s *recStep) string { return strings.Join(s.Log, "\n") } | |
| 15 | + | ||
| 16 | + | func TestImplicitStepsAndNumbering(t *testing.T) { | |
| 17 | + | needTools(t, "bash") | |
| 18 | + | rec, res := runLocal(t, wfHead+" steps:\n - run: echo hi\n - name: second\n run: echo two\n", "j", nil) | |
| 19 | + | if res.Conclusion != ConclusionSuccess || !rec.done { | |
| 20 | + | t.Fatalf("%+v", res) | |
| 21 | + | } | |
| 22 | + | want := []string{"Set up job", "Run echo hi", "second", "Complete job"} | |
| 23 | + | if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") { | |
| 24 | + | t.Fatalf("steps %v", got) | |
| 25 | + | } | |
| 26 | + | for i, s := range rec.steps { | |
| 27 | + | if s.Num != i+1 || s.Conclusion != ConclusionSuccess { | |
| 28 | + | t.Errorf("%+v", s) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | setup := logOf(rec.steps[0]) | |
| 32 | + | for _, w := range []string{"Current runner version: 'test'", "Runner name: 'test-runner'", "Prepare workflow directory", "Prepare all required actions", "Complete job name: j"} { | |
| 33 | + | if !strings.Contains(setup, w) { | |
| 34 | + | t.Errorf("Set up job lacks %q:\n%s", w, setup) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | if got := logOf(rec.steps[3]); got != "Cleaning up orphan processes" { | |
| 38 | + | t.Errorf("Complete job: %q", got) | |
| 39 | + | } | |
| 40 | + | if got := logOf(rec.steps[1]); !strings.HasSuffix(got, "##[endgroup]\nhi") { | |
| 41 | + | t.Errorf("log %q", got) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestFailureSkipsAndStatusFunctions(t *testing.T) { | |
| 46 | + | needTools(t, "bash") | |
| 47 | + | rec, res := runLocal(t, wfHead+` steps: | |
| 48 | + | - id: boom | |
| 49 | + | run: exit 3 | |
| 50 | + | - name: skipped by default success() | |
| 51 | + | run: echo NO | |
| 52 | + | - name: always | |
| 53 | + | if: always() | |
| 54 | + | run: echo ran-always | |
| 55 | + | - name: failure | |
| 56 | + | if: failure() | |
| 57 | + | run: echo ran-failure | |
| 58 | + | - name: cancelled | |
| 59 | + | if: cancelled() | |
| 60 | + | run: echo NO | |
| 61 | + | - name: outcome | |
| 62 | + | if: always() && steps.boom.outcome == 'failure' && steps.boom.conclusion == 'failure' | |
| 63 | + | run: echo ran-outcome | |
| 64 | + | `, "j", nil) | |
| 65 | + | if res.Conclusion != ConclusionFailure { | |
| 66 | + | t.Fatalf("%+v", res) | |
| 67 | + | } | |
| 68 | + | for name, c := range map[string]Conclusion{ | |
| 69 | + | "Run exit 3": ConclusionFailure, "skipped by default success()": ConclusionSkipped, "always": ConclusionSuccess, | |
| 70 | + | "failure": ConclusionSuccess, "cancelled": ConclusionSkipped, "outcome": ConclusionSuccess, | |
| 71 | + | } { | |
| 72 | + | if got := rec.step(t, name).Conclusion; got != c { | |
| 73 | + | t.Errorf("%s: %s, want %s", name, got, c) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | if got := logOf(rec.step(t, "Run exit 3")); !strings.HasSuffix(got, "##[error]Process completed with exit code 3.") { | |
| 77 | + | t.Errorf("%q", got) | |
| 78 | + | } | |
| 79 | + | if len(rec.step(t, "skipped by default success()").Log) != 0 { | |
| 80 | + | t.Error("a skipped step logs nothing") | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestEnvPrecedenceAndExpressions(t *testing.T) { | |
| 85 | + | needTools(t, "bash") | |
| 86 | + | rec, res := runLocal(t, `on: push | |
| 87 | + | env: | |
| 88 | + | A: wf | |
| 89 | + | B: ${{ github.event_name }} | |
| 90 | + | jobs: | |
| 91 | + | j: | |
| 92 | + | runs-on: x | |
| 93 | + | env: | |
| 94 | + | A: job | |
| 95 | + | C: ${{ github.ref_type }}-c | |
| 96 | + | steps: | |
| 97 | + | - env: | |
| 98 | + | D: ${{ env.A }}-${{ env.C }} | |
| 99 | + | A: step | |
| 100 | + | run: echo "A=$A B=$B C=$C D=$D" | |
| 101 | + | - run: echo "A=$A D=${D-unset}" | |
| 102 | + | `, "j", nil) | |
| 103 | + | if res.Conclusion != ConclusionSuccess { | |
| 104 | + | t.Fatalf("%+v", res) | |
| 105 | + | } | |
| 106 | + | // The step env sees the job env (env.A is "job", not "step"), and the | |
| 107 | + | // workflow and job env are evaluated before the steps. | |
| 108 | + | if got := logOf(rec.steps[1]); !strings.HasSuffix(got, "A=step B=push C=branch-c D=job-branch-c") { | |
| 109 | + | t.Errorf("first step:\n%s", got) | |
| 110 | + | } | |
| 111 | + | if got := logOf(rec.steps[2]); !strings.HasSuffix(got, "A=job D=unset") { | |
| 112 | + | t.Errorf("second step:\n%s", got) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestWorkflowCommands(t *testing.T) { | |
| 117 | + | needTools(t, "bash") | |
| 118 | + | rec, res := runLocal(t, wfHead+` steps: | |
| 119 | + | - id: s | |
| 120 | + | run: | | |
| 121 | + | echo "::group::My group" | |
| 122 | + | echo inside | |
| 123 | + | echo "::endgroup::" | |
| 124 | + | echo "::warning file=a.txt,line=1::careful" | |
| 125 | + | echo "::error::broken" | |
| 126 | + | echo "::debug::hidden" | |
| 127 | + | echo "::set-output name=old::v" | |
| 128 | + | echo "::add-mask::secret" | |
| 129 | + | echo "::not-a-command::x" | |
| 130 | + | - run: echo "old=${{ steps.s.outputs.old }}" | |
| 131 | + | `, "j", nil) | |
| 132 | + | if res.Conclusion != ConclusionSuccess { | |
| 133 | + | t.Fatalf("%+v", res) | |
| 134 | + | } | |
| 135 | + | got := logOf(rec.steps[1]) | |
| 136 | + | for _, w := range []string{"##[group]My group\ninside\n##[endgroup]", "##[warning]careful", "##[error]broken", "##[warning]The `set-output` command is deprecated", "::not-a-command::x"} { | |
| 137 | + | if !strings.Contains(got, w) { | |
| 138 | + | t.Errorf("log lacks %q:\n%s", w, got) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | _, body, _ := strings.Cut(got, "##[endgroup]\n") // after the header, which echoes the script | |
| 142 | + | if strings.Contains(body, "hidden") || strings.Contains(body, "::add-mask") || strings.Contains(body, "secret") { | |
| 143 | + | t.Errorf("debug and add-mask must not show:\n%s", body) | |
| 144 | + | } | |
| 145 | + | if !strings.HasSuffix(logOf(rec.steps[2]), "old=v") { | |
| 146 | + | t.Errorf("legacy output not visible: %s", logOf(rec.steps[2])) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestCommandFailureFailsTheStep(t *testing.T) { | |
| 151 | + | needTools(t, "bash") | |
| 152 | + | rec, res := runLocal(t, wfHead+" steps:\n - run: |\n echo '::set-env name=X::1'\n", "j", nil) | |
| 153 | + | if res.Conclusion != ConclusionFailure { | |
| 154 | + | t.Fatalf("%+v", res) | |
| 155 | + | } | |
| 156 | + | got := logOf(rec.steps[1]) | |
| 157 | + | if !strings.Contains(got, "##[error]Unable to process command '::set-env name=X::1' successfully.") || !strings.Contains(got, "The `set-env` command is disabled.") { | |
| 158 | + | t.Errorf("%s", got) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestJobOutputs(t *testing.T) { | |
| 163 | + | needTools(t, "bash") | |
| 164 | + | rec, res := runLocal(t, `on: push | |
| 165 | + | jobs: | |
| 166 | + | j: | |
| 167 | + | runs-on: x | |
| 168 | + | outputs: | |
| 169 | + | a: ${{ steps.one.outputs.v }} | |
| 170 | + | empty: ${{ steps.one.outputs.none }} | |
| 171 | + | lit: fixed | |
| 172 | + | env: ${{ env.E }} | |
| 173 | + | env: | |
| 174 | + | E: from-env | |
| 175 | + | steps: | |
| 176 | + | - id: one | |
| 177 | + | run: echo "v=1" >> "$GITHUB_OUTPUT" | |
| 178 | + | - run: exit 1 | |
| 179 | + | `, "j", nil) | |
| 180 | + | if res.Conclusion != ConclusionFailure { | |
| 181 | + | t.Fatalf("%+v", res) | |
| 182 | + | } | |
| 183 | + | // Outputs are collected even after a failure; empty ones are dropped. | |
| 184 | + | if len(res.Outputs) != 3 || res.Outputs["a"] != "1" || res.Outputs["lit"] != "fixed" || res.Outputs["env"] != "from-env" { | |
| 185 | + | t.Fatalf("%v", res.Outputs) | |
| 186 | + | } | |
| 187 | + | _ = rec | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestNotSupportedYet(t *testing.T) { | |
| 191 | + | needTools(t, "bash") | |
| 192 | + | rec, res := runLocal(t, wfHead+` steps: | |
| 193 | + | - uses: actions/checkout@v4 | |
| 194 | + | - name: after | |
| 195 | + | if: always() | |
| 196 | + | run: echo still-runs | |
| 197 | + | - run: echo skipped | |
| 198 | + | `, "j", nil) | |
| 199 | + | if res.Conclusion != ConclusionFailure { | |
| 200 | + | t.Fatalf("%+v", res) | |
| 201 | + | } | |
| 202 | + | u := rec.step(t, "Run actions/checkout@v4") | |
| 203 | + | if u.Conclusion != ConclusionFailure || !strings.Contains(logOf(u), "##[error]not supported yet: uses (actions/checkout@v4)") { | |
| 204 | + | t.Fatalf("%+v", u) | |
| 205 | + | } | |
| 206 | + | if rec.step(t, "after").Conclusion != ConclusionSuccess { | |
| 207 | + | t.Error("always() steps still run after a failure") | |
| 208 | + | } | |
| 209 | + | for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} { | |
| 210 | + | rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil) | |
| 211 | + | if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") { | |
| 212 | + | t.Errorf("%q: %+v %v", f, res2, rec2.names()) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestInvalidWorkflowAndUnknownJob(t *testing.T) { | |
| 218 | + | rec, res := runLocal(t, "on: push\njobs: [", "j", nil) | |
| 219 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 || !rec.done { | |
| 220 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 221 | + | } | |
| 222 | + | _, res = runLocal(t, wfHead+" steps: [{run: a}]\n", "nope", nil) | |
| 223 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "no job 'nope'") { | |
| 224 | + | t.Fatalf("%+v", res) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func TestShellVariants(t *testing.T) { | |
| 229 | + | needTools(t, "bash", "sh", "python3") | |
| 230 | + | rec, res := runLocal(t, wfHead+` steps: | |
| 231 | + | - shell: python3 {0} | |
| 232 | + | run: print("py-ok") | |
| 233 | + | - shell: bash {0} | |
| 234 | + | run: echo "no-e $-" | |
| 235 | + | - shell: sh | |
| 236 | + | run: echo sh-ok | |
| 237 | + | - shell: nonexistent-shell {0} | |
| 238 | + | run: echo NO | |
| 239 | + | `, "j", nil) | |
| 240 | + | if res.Conclusion != ConclusionFailure { | |
| 241 | + | t.Fatalf("%+v", res) | |
| 242 | + | } | |
| 243 | + | if !strings.HasSuffix(logOf(rec.steps[1]), "py-ok") || !strings.Contains(logOf(rec.steps[2]), "shell: ") { | |
| 244 | + | t.Errorf("%s | %s", logOf(rec.steps[1]), logOf(rec.steps[2])) | |
| 245 | + | } | |
| 246 | + | last := rec.steps[4] | |
| 247 | + | if last.Conclusion != ConclusionFailure || !strings.Contains(logOf(last), "##[error]nonexistent-shell: command not found") { | |
| 248 | + | t.Errorf("%+v", last) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestGithubPathAndEnvReachNextSteps(t *testing.T) { | |
| 253 | + | needTools(t, "bash") | |
| 254 | + | rec, _ := runLocal(t, wfHead+` steps: | |
| 255 | + | - run: | | |
| 256 | + | mkdir -p "$RUNNER_TEMP/bin1" "$RUNNER_TEMP/bin2" | |
| 257 | + | printf '#!/bin/sh\necho tool-1\n' > "$RUNNER_TEMP/bin1/tool-ermite"; chmod +x "$RUNNER_TEMP/bin1/tool-ermite" | |
| 258 | + | printf '#!/bin/sh\necho tool-2\n' > "$RUNNER_TEMP/bin2/tool-ermite"; chmod +x "$RUNNER_TEMP/bin2/tool-ermite" | |
| 259 | + | echo "$RUNNER_TEMP/bin1" >> "$GITHUB_PATH" | |
| 260 | + | echo "$RUNNER_TEMP/bin2" >> "$GITHUB_PATH" | |
| 261 | + | echo "MULTI<<X" >> "$GITHUB_ENV"; echo "a" >> "$GITHUB_ENV"; echo "b" >> "$GITHUB_ENV"; echo "X" >> "$GITHUB_ENV" | |
| 262 | + | - run: | | |
| 263 | + | tool-ermite | |
| 264 | + | printf 'MULTI=<%s>\n' "$MULTI" | |
| 265 | + | echo "ci=$CI actions=$GITHUB_ACTIONS ws=${GITHUB_WORKSPACE##*/} cwd=${PWD##*/}" | |
| 266 | + | `, "j", nil) | |
| 267 | + | got := logOf(rec.steps[2]) | |
| 268 | + | if !strings.HasSuffix(got, "tool-2\nMULTI=<a\nb>\nci=true actions=true ws=hello cwd=hello") { | |
| 269 | + | t.Errorf("%s", got) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // ---- cancellation and timeouts | |
| 274 | + | ||
| 275 | + | func TestCancelSendsSIGINTThenStopsTheJob(t *testing.T) { | |
| 276 | + | needTools(t, "bash") | |
| 277 | + | c := NewCanceller() | |
| 278 | + | started := make(chan struct{}) | |
| 279 | + | go func() { | |
| 280 | + | <-started | |
| 281 | + | time.Sleep(300 * time.Millisecond) | |
| 282 | + | c.Cancel(CancelUser) | |
| 283 | + | }() | |
| 284 | + | rec := newRecorder() | |
| 285 | + | rep := &signalReporter{recorder: rec, started: started, match: "ready"} | |
| 286 | + | home := t.TempDir() | |
| 287 | + | layout := NewLayout(home, "hello", home+"/tc") | |
| 288 | + | res := Run(c, Config{ | |
| 289 | + | WorkflowYAML: []byte(wfHead + ` steps: | |
| 290 | + | - run: | | |
| 291 | + | trap 'echo got-int; exit 130' INT | |
| 292 | + | echo ready | |
| 293 | + | while :; do sleep 0.05; done | |
| 294 | + | - name: after | |
| 295 | + | run: echo NO | |
| 296 | + | - name: cleanup | |
| 297 | + | if: always() | |
| 298 | + | run: echo cleanup-ran | |
| 299 | + | `), JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep, | |
| 300 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, | |
| 301 | + | TermAfter: 300 * time.Millisecond, KillAfter: 300 * time.Millisecond, | |
| 302 | + | }) | |
| 303 | + | if res.Conclusion != ConclusionCancelled || res.RunnerShutdown { | |
| 304 | + | t.Fatalf("%+v", res) | |
| 305 | + | } | |
| 306 | + | run := rec.steps[1] | |
| 307 | + | if run.Conclusion != ConclusionCancelled || !strings.Contains(logOf(run), "got-int") || !strings.Contains(logOf(run), "##[error]The operation was canceled.") { | |
| 308 | + | t.Fatalf("%s %s", run.Conclusion, logOf(run)) | |
| 309 | + | } | |
| 310 | + | if rec.step(t, "after").Conclusion != ConclusionSkipped { | |
| 311 | + | t.Error("after must be skipped") | |
| 312 | + | } | |
| 313 | + | if got := rec.step(t, "cleanup"); got.Conclusion != ConclusionSuccess || !strings.HasSuffix(logOf(got), "cleanup-ran") { | |
| 314 | + | t.Errorf("always() step must run after a cancel: %+v", got) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // signalReporter closes started when a log line equals match. | |
| 319 | + | type signalReporter struct { | |
| 320 | + | *recorder | |
| 321 | + | started chan struct{} | |
| 322 | + | match string | |
| 323 | + | once bool | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (r *signalReporter) Log(n int, at time.Time, line string) { | |
| 327 | + | r.recorder.Log(n, at, line) | |
| 328 | + | if !r.once && line == r.match { | |
| 329 | + | r.once = true | |
| 330 | + | close(r.started) | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func TestCancelEscalatesToTermAndKill(t *testing.T) { | |
| 335 | + | needTools(t, "bash") | |
| 336 | + | c := NewCanceller() | |
| 337 | + | var mu = make(chan struct{}, 8) | |
| 338 | + | var got []syscall.Signal | |
| 339 | + | started := make(chan struct{}) | |
| 340 | + | rec := newRecorder() | |
| 341 | + | rep := &signalReporter{recorder: rec, started: started, match: "ready"} | |
| 342 | + | go func() { <-started; c.Cancel(CancelUser) }() | |
| 343 | + | home := t.TempDir() | |
| 344 | + | layout := NewLayout(home, "hello", home+"/tc") | |
| 345 | + | t0 := time.Now() | |
| 346 | + | var times []time.Duration | |
| 347 | + | res := Run(c, Config{ | |
| 348 | + | WorkflowYAML: []byte(wfHead + " steps:\n - run: |\n trap '' INT TERM\n echo ready\n while :; do sleep 0.05; done\n"), | |
| 349 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep, | |
| 350 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, | |
| 351 | + | TermAfter: 250 * time.Millisecond, KillAfter: 250 * time.Millisecond, | |
| 352 | + | Signal: func(pgid int, sig syscall.Signal) { | |
| 353 | + | got = append(got, sig) | |
| 354 | + | times = append(times, time.Since(t0)) | |
| 355 | + | _ = syscall.Kill(-pgid, sig) | |
| 356 | + | mu <- struct{}{} | |
| 357 | + | }, | |
| 358 | + | }) | |
| 359 | + | if res.Conclusion != ConclusionCancelled { | |
| 360 | + | t.Fatalf("%+v", res) | |
| 361 | + | } | |
| 362 | + | if len(got) != 3 || got[0] != syscall.SIGINT || got[1] != syscall.SIGTERM || got[2] != syscall.SIGKILL { | |
| 363 | + | t.Fatalf("signals %v", got) | |
| 364 | + | } | |
| 365 | + | if d := times[2] - times[0]; d < 450*time.Millisecond || d > 2*time.Second { | |
| 366 | + | t.Errorf("kill came %v after the interrupt, want about 500ms", d) | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func TestShutdownCancelSkipsRemainingSteps(t *testing.T) { | |
| 371 | + | needTools(t, "bash") | |
| 372 | + | c := NewCanceller() | |
| 373 | + | started := make(chan struct{}) | |
| 374 | + | rec := newRecorder() | |
| 375 | + | rep := &signalReporter{recorder: rec, started: started, match: "ready"} | |
| 376 | + | go func() { <-started; c.Cancel(CancelShutdown) }() | |
| 377 | + | home := t.TempDir() | |
| 378 | + | layout := NewLayout(home, "hello", home+"/tc") | |
| 379 | + | res := Run(c, Config{ | |
| 380 | + | WorkflowYAML: []byte(wfHead + " steps:\n - run: |\n echo ready\n sleep 30\n - if: always()\n run: echo NO\n"), | |
| 381 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep, | |
| 382 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, | |
| 383 | + | }) | |
| 384 | + | if res.Conclusion != ConclusionCancelled || !res.RunnerShutdown { | |
| 385 | + | t.Fatalf("%+v", res) | |
| 386 | + | } | |
| 387 | + | // On runner shutdown conditions are not evaluated (StepsRunner.cs:205-208): | |
| 388 | + | // even always() steps are skipped. | |
| 389 | + | if got := rec.steps[2]; got.Conclusion != ConclusionSkipped { | |
| 390 | + | t.Errorf("%+v", got) | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func TestJobTimeout(t *testing.T) { | |
| 395 | + | needTools(t, "bash") | |
| 396 | + | home := t.TempDir() | |
| 397 | + | layout := NewLayout(home, "hello", home+"/tc") | |
| 398 | + | rec := newRecorder() | |
| 399 | + | cfg := Config{ | |
| 400 | + | WorkflowYAML: []byte(wfHead + " steps:\n - run: sleep 30\n"), | |
| 401 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rec, | |
| 402 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, TermAfter: 100 * time.Millisecond, KillAfter: 100 * time.Millisecond, | |
| 403 | + | } | |
| 404 | + | c := NewCanceller() | |
| 405 | + | // A one-minute job timeout cannot be waited for: fire it as the timer does. | |
| 406 | + | time.AfterFunc(300*time.Millisecond, func() { c.Cancel(CancelTimeout) }) | |
| 407 | + | res := Run(c, cfg) | |
| 408 | + | if res.Conclusion != ConclusionTimedOut || !strings.Contains(res.Error, "maximum execution time of 360 minutes") { | |
| 409 | + | t.Fatalf("%+v", res) | |
| 410 | + | } | |
| 411 | + | if rec.steps[1].Conclusion != ConclusionCancelled { | |
| 412 | + | t.Errorf("%+v", rec.steps[1]) | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func TestStartErrorsAndOrphans(t *testing.T) { | |
| 417 | + | needTools(t, "bash") | |
| 418 | + | rec, res := runLocal(t, wfHead+` steps: | |
| 419 | + | - run: (sleep 60 &) ; echo started-background | |
| 420 | + | `, "j", nil) | |
| 421 | + | if res.Conclusion != ConclusionSuccess { | |
| 422 | + | t.Fatalf("%+v", res) | |
| 423 | + | } | |
| 424 | + | last := logOf(rec.steps[len(rec.steps)-1]) | |
| 425 | + | if !strings.Contains(last, "Cleaning up orphan processes") || !strings.Contains(last, "Terminate orphan process: pid (") || !strings.Contains(last, ") (sleep)") { | |
| 426 | + | t.Errorf("orphan cleanup: %q", last) | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func TestLineWriter(t *testing.T) { | |
| 431 | + | var got []string | |
| 432 | + | w := &lineWriter{fn: func(l string) { got = append(got, l) }} | |
| 433 | + | for _, chunk := range []string{"a\nb\r", "\nc\rd", "\ne\xff", "f\n", "tail"} { | |
| 434 | + | _, _ = w.Write([]byte(chunk)) | |
| 435 | + | } | |
| 436 | + | w.Flush() | |
| 437 | + | want := []string{"a", "b", "c", "d", "e\ufffdf", "tail"} | |
| 438 | + | if strings.Join(got, "|") != strings.Join(want, "|") { | |
| 439 | + | t.Fatalf("%q", got) | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func TestParseCommands(t *testing.T) { | |
| 444 | + | reg := func(s string) bool { return s == "set-output" || s == "group" || s == "warning" } | |
| 445 | + | c, ok := parseCommandV2("::set-output name=a%2Cb,x=y::v%0Aw%25", reg) | |
| 446 | + | if !ok || c.Name != "set-output" || c.prop("name") != "a,b" || c.Data != "v\nw%" { | |
| 447 | + | t.Fatalf("%+v %v", c, ok) | |
| 448 | + | } | |
| 449 | + | if c, ok = parseCommandV2(" ::group::title", reg); !ok || c.Data != "title" { | |
| 450 | + | t.Fatalf("leading space: %+v", c) | |
| 451 | + | } | |
| 452 | + | if _, ok = parseCommandV2("::unknown::x", reg); ok { | |
| 453 | + | t.Fatal("unregistered command") | |
| 454 | + | } | |
| 455 | + | if _, ok = parseCommandV2("x ::group::t", reg); ok { | |
| 456 | + | t.Fatal("V2 must start the line") | |
| 457 | + | } | |
| 458 | + | c, ok = parseCommandV1("text ##[set-output name=n;k=v%3Bz]data%5D", reg) | |
| 459 | + | if !ok || c.prop("name") != "n" || c.prop("k") != "v;z" || c.Data != "data]" { | |
| 460 | + | t.Fatalf("v1: %+v %v", c, ok) | |
| 461 | + | } | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func TestStopCommands(t *testing.T) { | |
| 465 | + | needTools(t, "bash") | |
| 466 | + | rec, _ := runLocal(t, wfHead+` steps: | |
| 467 | + | - run: | | |
| 468 | + | echo "::stop-commands::tok12345" | |
| 469 | + | echo "::warning::not-processed" | |
| 470 | + | echo "::tok12345::" | |
| 471 | + | echo "::warning::processed" | |
| 472 | + | `, "j", nil) | |
| 473 | + | got := logOf(rec.steps[1]) | |
| 474 | + | if !strings.Contains(got, "::warning::not-processed") || !strings.Contains(got, "##[warning]processed") || !strings.Contains(got, "::stop-commands::tok12345") { | |
| 475 | + | t.Errorf("%s", got) | |
| 476 | + | } | |
| 477 | + | } | |
| @@ -0,0 +1,477 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "syscall" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const wfHead = "on: push\njobs:\n j:\n runs-on: x\n" | |||
| 13 | + | ||||
| 14 | + | func logOf(s *recStep) string { return strings.Join(s.Log, "\n") } | |||
| 15 | + | ||||
| 16 | + | func TestImplicitStepsAndNumbering(t *testing.T) { | |||
| 17 | + | needTools(t, "bash") | |||
| 18 | + | rec, res := runLocal(t, wfHead+" steps:\n - run: echo hi\n - name: second\n run: echo two\n", "j", nil) | |||
| 19 | + | if res.Conclusion != ConclusionSuccess || !rec.done { | |||
| 20 | + | t.Fatalf("%+v", res) | |||
| 21 | + | } | |||
| 22 | + | want := []string{"Set up job", "Run echo hi", "second", "Complete job"} | |||
| 23 | + | if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") { | |||
| 24 | + | t.Fatalf("steps %v", got) | |||
| 25 | + | } | |||
| 26 | + | for i, s := range rec.steps { | |||
| 27 | + | if s.Num != i+1 || s.Conclusion != ConclusionSuccess { | |||
| 28 | + | t.Errorf("%+v", s) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | setup := logOf(rec.steps[0]) | |||
| 32 | + | for _, w := range []string{"Current runner version: 'test'", "Runner name: 'test-runner'", "Prepare workflow directory", "Prepare all required actions", "Complete job name: j"} { | |||
| 33 | + | if !strings.Contains(setup, w) { | |||
| 34 | + | t.Errorf("Set up job lacks %q:\n%s", w, setup) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | if got := logOf(rec.steps[3]); got != "Cleaning up orphan processes" { | |||
| 38 | + | t.Errorf("Complete job: %q", got) | |||
| 39 | + | } | |||
| 40 | + | if got := logOf(rec.steps[1]); !strings.HasSuffix(got, "##[endgroup]\nhi") { | |||
| 41 | + | t.Errorf("log %q", got) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestFailureSkipsAndStatusFunctions(t *testing.T) { | |||
| 46 | + | needTools(t, "bash") | |||
| 47 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 48 | + | - id: boom | |||
| 49 | + | run: exit 3 | |||
| 50 | + | - name: skipped by default success() | |||
| 51 | + | run: echo NO | |||
| 52 | + | - name: always | |||
| 53 | + | if: always() | |||
| 54 | + | run: echo ran-always | |||
| 55 | + | - name: failure | |||
| 56 | + | if: failure() | |||
| 57 | + | run: echo ran-failure | |||
| 58 | + | - name: cancelled | |||
| 59 | + | if: cancelled() | |||
| 60 | + | run: echo NO | |||
| 61 | + | - name: outcome | |||
| 62 | + | if: always() && steps.boom.outcome == 'failure' && steps.boom.conclusion == 'failure' | |||
| 63 | + | run: echo ran-outcome | |||
| 64 | + | `, "j", nil) | |||
| 65 | + | if res.Conclusion != ConclusionFailure { | |||
| 66 | + | t.Fatalf("%+v", res) | |||
| 67 | + | } | |||
| 68 | + | for name, c := range map[string]Conclusion{ | |||
| 69 | + | "Run exit 3": ConclusionFailure, "skipped by default success()": ConclusionSkipped, "always": ConclusionSuccess, | |||
| 70 | + | "failure": ConclusionSuccess, "cancelled": ConclusionSkipped, "outcome": ConclusionSuccess, | |||
| 71 | + | } { | |||
| 72 | + | if got := rec.step(t, name).Conclusion; got != c { | |||
| 73 | + | t.Errorf("%s: %s, want %s", name, got, c) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | if got := logOf(rec.step(t, "Run exit 3")); !strings.HasSuffix(got, "##[error]Process completed with exit code 3.") { | |||
| 77 | + | t.Errorf("%q", got) | |||
| 78 | + | } | |||
| 79 | + | if len(rec.step(t, "skipped by default success()").Log) != 0 { | |||
| 80 | + | t.Error("a skipped step logs nothing") | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestEnvPrecedenceAndExpressions(t *testing.T) { | |||
| 85 | + | needTools(t, "bash") | |||
| 86 | + | rec, res := runLocal(t, `on: push | |||
| 87 | + | env: | |||
| 88 | + | A: wf | |||
| 89 | + | B: ${{ github.event_name }} | |||
| 90 | + | jobs: | |||
| 91 | + | j: | |||
| 92 | + | runs-on: x | |||
| 93 | + | env: | |||
| 94 | + | A: job | |||
| 95 | + | C: ${{ github.ref_type }}-c | |||
| 96 | + | steps: | |||
| 97 | + | - env: | |||
| 98 | + | D: ${{ env.A }}-${{ env.C }} | |||
| 99 | + | A: step | |||
| 100 | + | run: echo "A=$A B=$B C=$C D=$D" | |||
| 101 | + | - run: echo "A=$A D=${D-unset}" | |||
| 102 | + | `, "j", nil) | |||
| 103 | + | if res.Conclusion != ConclusionSuccess { | |||
| 104 | + | t.Fatalf("%+v", res) | |||
| 105 | + | } | |||
| 106 | + | // The step env sees the job env (env.A is "job", not "step"), and the | |||
| 107 | + | // workflow and job env are evaluated before the steps. | |||
| 108 | + | if got := logOf(rec.steps[1]); !strings.HasSuffix(got, "A=step B=push C=branch-c D=job-branch-c") { | |||
| 109 | + | t.Errorf("first step:\n%s", got) | |||
| 110 | + | } | |||
| 111 | + | if got := logOf(rec.steps[2]); !strings.HasSuffix(got, "A=job D=unset") { | |||
| 112 | + | t.Errorf("second step:\n%s", got) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestWorkflowCommands(t *testing.T) { | |||
| 117 | + | needTools(t, "bash") | |||
| 118 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 119 | + | - id: s | |||
| 120 | + | run: | | |||
| 121 | + | echo "::group::My group" | |||
| 122 | + | echo inside | |||
| 123 | + | echo "::endgroup::" | |||
| 124 | + | echo "::warning file=a.txt,line=1::careful" | |||
| 125 | + | echo "::error::broken" | |||
| 126 | + | echo "::debug::hidden" | |||
| 127 | + | echo "::set-output name=old::v" | |||
| 128 | + | echo "::add-mask::secret" | |||
| 129 | + | echo "::not-a-command::x" | |||
| 130 | + | - run: echo "old=${{ steps.s.outputs.old }}" | |||
| 131 | + | `, "j", nil) | |||
| 132 | + | if res.Conclusion != ConclusionSuccess { | |||
| 133 | + | t.Fatalf("%+v", res) | |||
| 134 | + | } | |||
| 135 | + | got := logOf(rec.steps[1]) | |||
| 136 | + | for _, w := range []string{"##[group]My group\ninside\n##[endgroup]", "##[warning]careful", "##[error]broken", "##[warning]The `set-output` command is deprecated", "::not-a-command::x"} { | |||
| 137 | + | if !strings.Contains(got, w) { | |||
| 138 | + | t.Errorf("log lacks %q:\n%s", w, got) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | _, body, _ := strings.Cut(got, "##[endgroup]\n") // after the header, which echoes the script | |||
| 142 | + | if strings.Contains(body, "hidden") || strings.Contains(body, "::add-mask") || strings.Contains(body, "secret") { | |||
| 143 | + | t.Errorf("debug and add-mask must not show:\n%s", body) | |||
| 144 | + | } | |||
| 145 | + | if !strings.HasSuffix(logOf(rec.steps[2]), "old=v") { | |||
| 146 | + | t.Errorf("legacy output not visible: %s", logOf(rec.steps[2])) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestCommandFailureFailsTheStep(t *testing.T) { | |||
| 151 | + | needTools(t, "bash") | |||
| 152 | + | rec, res := runLocal(t, wfHead+" steps:\n - run: |\n echo '::set-env name=X::1'\n", "j", nil) | |||
| 153 | + | if res.Conclusion != ConclusionFailure { | |||
| 154 | + | t.Fatalf("%+v", res) | |||
| 155 | + | } | |||
| 156 | + | got := logOf(rec.steps[1]) | |||
| 157 | + | if !strings.Contains(got, "##[error]Unable to process command '::set-env name=X::1' successfully.") || !strings.Contains(got, "The `set-env` command is disabled.") { | |||
| 158 | + | t.Errorf("%s", got) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestJobOutputs(t *testing.T) { | |||
| 163 | + | needTools(t, "bash") | |||
| 164 | + | rec, res := runLocal(t, `on: push | |||
| 165 | + | jobs: | |||
| 166 | + | j: | |||
| 167 | + | runs-on: x | |||
| 168 | + | outputs: | |||
| 169 | + | a: ${{ steps.one.outputs.v }} | |||
| 170 | + | empty: ${{ steps.one.outputs.none }} | |||
| 171 | + | lit: fixed | |||
| 172 | + | env: ${{ env.E }} | |||
| 173 | + | env: | |||
| 174 | + | E: from-env | |||
| 175 | + | steps: | |||
| 176 | + | - id: one | |||
| 177 | + | run: echo "v=1" >> "$GITHUB_OUTPUT" | |||
| 178 | + | - run: exit 1 | |||
| 179 | + | `, "j", nil) | |||
| 180 | + | if res.Conclusion != ConclusionFailure { | |||
| 181 | + | t.Fatalf("%+v", res) | |||
| 182 | + | } | |||
| 183 | + | // Outputs are collected even after a failure; empty ones are dropped. | |||
| 184 | + | if len(res.Outputs) != 3 || res.Outputs["a"] != "1" || res.Outputs["lit"] != "fixed" || res.Outputs["env"] != "from-env" { | |||
| 185 | + | t.Fatalf("%v", res.Outputs) | |||
| 186 | + | } | |||
| 187 | + | _ = rec | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestNotSupportedYet(t *testing.T) { | |||
| 191 | + | needTools(t, "bash") | |||
| 192 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 193 | + | - uses: actions/checkout@v4 | |||
| 194 | + | - name: after | |||
| 195 | + | if: always() | |||
| 196 | + | run: echo still-runs | |||
| 197 | + | - run: echo skipped | |||
| 198 | + | `, "j", nil) | |||
| 199 | + | if res.Conclusion != ConclusionFailure { | |||
| 200 | + | t.Fatalf("%+v", res) | |||
| 201 | + | } | |||
| 202 | + | u := rec.step(t, "Run actions/checkout@v4") | |||
| 203 | + | if u.Conclusion != ConclusionFailure || !strings.Contains(logOf(u), "##[error]not supported yet: uses (actions/checkout@v4)") { | |||
| 204 | + | t.Fatalf("%+v", u) | |||
| 205 | + | } | |||
| 206 | + | if rec.step(t, "after").Conclusion != ConclusionSuccess { | |||
| 207 | + | t.Error("always() steps still run after a failure") | |||
| 208 | + | } | |||
| 209 | + | for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} { | |||
| 210 | + | rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil) | |||
| 211 | + | if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") { | |||
| 212 | + | t.Errorf("%q: %+v %v", f, res2, rec2.names()) | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestInvalidWorkflowAndUnknownJob(t *testing.T) { | |||
| 218 | + | rec, res := runLocal(t, "on: push\njobs: [", "j", nil) | |||
| 219 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 || !rec.done { | |||
| 220 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 221 | + | } | |||
| 222 | + | _, res = runLocal(t, wfHead+" steps: [{run: a}]\n", "nope", nil) | |||
| 223 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "no job 'nope'") { | |||
| 224 | + | t.Fatalf("%+v", res) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func TestShellVariants(t *testing.T) { | |||
| 229 | + | needTools(t, "bash", "sh", "python3") | |||
| 230 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 231 | + | - shell: python3 {0} | |||
| 232 | + | run: print("py-ok") | |||
| 233 | + | - shell: bash {0} | |||
| 234 | + | run: echo "no-e $-" | |||
| 235 | + | - shell: sh | |||
| 236 | + | run: echo sh-ok | |||
| 237 | + | - shell: nonexistent-shell {0} | |||
| 238 | + | run: echo NO | |||
| 239 | + | `, "j", nil) | |||
| 240 | + | if res.Conclusion != ConclusionFailure { | |||
| 241 | + | t.Fatalf("%+v", res) | |||
| 242 | + | } | |||
| 243 | + | if !strings.HasSuffix(logOf(rec.steps[1]), "py-ok") || !strings.Contains(logOf(rec.steps[2]), "shell: ") { | |||
| 244 | + | t.Errorf("%s | %s", logOf(rec.steps[1]), logOf(rec.steps[2])) | |||
| 245 | + | } | |||
| 246 | + | last := rec.steps[4] | |||
| 247 | + | if last.Conclusion != ConclusionFailure || !strings.Contains(logOf(last), "##[error]nonexistent-shell: command not found") { | |||
| 248 | + | t.Errorf("%+v", last) | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func TestGithubPathAndEnvReachNextSteps(t *testing.T) { | |||
| 253 | + | needTools(t, "bash") | |||
| 254 | + | rec, _ := runLocal(t, wfHead+` steps: | |||
| 255 | + | - run: | | |||
| 256 | + | mkdir -p "$RUNNER_TEMP/bin1" "$RUNNER_TEMP/bin2" | |||
| 257 | + | printf '#!/bin/sh\necho tool-1\n' > "$RUNNER_TEMP/bin1/tool-ermite"; chmod +x "$RUNNER_TEMP/bin1/tool-ermite" | |||
| 258 | + | printf '#!/bin/sh\necho tool-2\n' > "$RUNNER_TEMP/bin2/tool-ermite"; chmod +x "$RUNNER_TEMP/bin2/tool-ermite" | |||
| 259 | + | echo "$RUNNER_TEMP/bin1" >> "$GITHUB_PATH" | |||
| 260 | + | echo "$RUNNER_TEMP/bin2" >> "$GITHUB_PATH" | |||
| 261 | + | echo "MULTI<<X" >> "$GITHUB_ENV"; echo "a" >> "$GITHUB_ENV"; echo "b" >> "$GITHUB_ENV"; echo "X" >> "$GITHUB_ENV" | |||
| 262 | + | - run: | | |||
| 263 | + | tool-ermite | |||
| 264 | + | printf 'MULTI=<%s>\n' "$MULTI" | |||
| 265 | + | echo "ci=$CI actions=$GITHUB_ACTIONS ws=${GITHUB_WORKSPACE##*/} cwd=${PWD##*/}" | |||
| 266 | + | `, "j", nil) | |||
| 267 | + | got := logOf(rec.steps[2]) | |||
| 268 | + | if !strings.HasSuffix(got, "tool-2\nMULTI=<a\nb>\nci=true actions=true ws=hello cwd=hello") { | |||
| 269 | + | t.Errorf("%s", got) | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // ---- cancellation and timeouts | |||
| 274 | + | ||||
| 275 | + | func TestCancelSendsSIGINTThenStopsTheJob(t *testing.T) { | |||
| 276 | + | needTools(t, "bash") | |||
| 277 | + | c := NewCanceller() | |||
| 278 | + | started := make(chan struct{}) | |||
| 279 | + | go func() { | |||
| 280 | + | <-started | |||
| 281 | + | time.Sleep(300 * time.Millisecond) | |||
| 282 | + | c.Cancel(CancelUser) | |||
| 283 | + | }() | |||
| 284 | + | rec := newRecorder() | |||
| 285 | + | rep := &signalReporter{recorder: rec, started: started, match: "ready"} | |||
| 286 | + | home := t.TempDir() | |||
| 287 | + | layout := NewLayout(home, "hello", home+"/tc") | |||
| 288 | + | res := Run(c, Config{ | |||
| 289 | + | WorkflowYAML: []byte(wfHead + ` steps: | |||
| 290 | + | - run: | | |||
| 291 | + | trap 'echo got-int; exit 130' INT | |||
| 292 | + | echo ready | |||
| 293 | + | while :; do sleep 0.05; done | |||
| 294 | + | - name: after | |||
| 295 | + | run: echo NO | |||
| 296 | + | - name: cleanup | |||
| 297 | + | if: always() | |||
| 298 | + | run: echo cleanup-ran | |||
| 299 | + | `), JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep, | |||
| 300 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, | |||
| 301 | + | TermAfter: 300 * time.Millisecond, KillAfter: 300 * time.Millisecond, | |||
| 302 | + | }) | |||
| 303 | + | if res.Conclusion != ConclusionCancelled || res.RunnerShutdown { | |||
| 304 | + | t.Fatalf("%+v", res) | |||
| 305 | + | } | |||
| 306 | + | run := rec.steps[1] | |||
| 307 | + | if run.Conclusion != ConclusionCancelled || !strings.Contains(logOf(run), "got-int") || !strings.Contains(logOf(run), "##[error]The operation was canceled.") { | |||
| 308 | + | t.Fatalf("%s %s", run.Conclusion, logOf(run)) | |||
| 309 | + | } | |||
| 310 | + | if rec.step(t, "after").Conclusion != ConclusionSkipped { | |||
| 311 | + | t.Error("after must be skipped") | |||
| 312 | + | } | |||
| 313 | + | if got := rec.step(t, "cleanup"); got.Conclusion != ConclusionSuccess || !strings.HasSuffix(logOf(got), "cleanup-ran") { | |||
| 314 | + | t.Errorf("always() step must run after a cancel: %+v", got) | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | // signalReporter closes started when a log line equals match. | |||
| 319 | + | type signalReporter struct { | |||
| 320 | + | *recorder | |||
| 321 | + | started chan struct{} | |||
| 322 | + | match string | |||
| 323 | + | once bool | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (r *signalReporter) Log(n int, at time.Time, line string) { | |||
| 327 | + | r.recorder.Log(n, at, line) | |||
| 328 | + | if !r.once && line == r.match { | |||
| 329 | + | r.once = true | |||
| 330 | + | close(r.started) | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | func TestCancelEscalatesToTermAndKill(t *testing.T) { | |||
| 335 | + | needTools(t, "bash") | |||
| 336 | + | c := NewCanceller() | |||
| 337 | + | var mu = make(chan struct{}, 8) | |||
| 338 | + | var got []syscall.Signal | |||
| 339 | + | started := make(chan struct{}) | |||
| 340 | + | rec := newRecorder() | |||
| 341 | + | rep := &signalReporter{recorder: rec, started: started, match: "ready"} | |||
| 342 | + | go func() { <-started; c.Cancel(CancelUser) }() | |||
| 343 | + | home := t.TempDir() | |||
| 344 | + | layout := NewLayout(home, "hello", home+"/tc") | |||
| 345 | + | t0 := time.Now() | |||
| 346 | + | var times []time.Duration | |||
| 347 | + | res := Run(c, Config{ | |||
| 348 | + | WorkflowYAML: []byte(wfHead + " steps:\n - run: |\n trap '' INT TERM\n echo ready\n while :; do sleep 0.05; done\n"), | |||
| 349 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep, | |||
| 350 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, | |||
| 351 | + | TermAfter: 250 * time.Millisecond, KillAfter: 250 * time.Millisecond, | |||
| 352 | + | Signal: func(pgid int, sig syscall.Signal) { | |||
| 353 | + | got = append(got, sig) | |||
| 354 | + | times = append(times, time.Since(t0)) | |||
| 355 | + | _ = syscall.Kill(-pgid, sig) | |||
| 356 | + | mu <- struct{}{} | |||
| 357 | + | }, | |||
| 358 | + | }) | |||
| 359 | + | if res.Conclusion != ConclusionCancelled { | |||
| 360 | + | t.Fatalf("%+v", res) | |||
| 361 | + | } | |||
| 362 | + | if len(got) != 3 || got[0] != syscall.SIGINT || got[1] != syscall.SIGTERM || got[2] != syscall.SIGKILL { | |||
| 363 | + | t.Fatalf("signals %v", got) | |||
| 364 | + | } | |||
| 365 | + | if d := times[2] - times[0]; d < 450*time.Millisecond || d > 2*time.Second { | |||
| 366 | + | t.Errorf("kill came %v after the interrupt, want about 500ms", d) | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | func TestShutdownCancelSkipsRemainingSteps(t *testing.T) { | |||
| 371 | + | needTools(t, "bash") | |||
| 372 | + | c := NewCanceller() | |||
| 373 | + | started := make(chan struct{}) | |||
| 374 | + | rec := newRecorder() | |||
| 375 | + | rep := &signalReporter{recorder: rec, started: started, match: "ready"} | |||
| 376 | + | go func() { <-started; c.Cancel(CancelShutdown) }() | |||
| 377 | + | home := t.TempDir() | |||
| 378 | + | layout := NewLayout(home, "hello", home+"/tc") | |||
| 379 | + | res := Run(c, Config{ | |||
| 380 | + | WorkflowYAML: []byte(wfHead + " steps:\n - run: |\n echo ready\n sleep 30\n - if: always()\n run: echo NO\n"), | |||
| 381 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep, | |||
| 382 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, | |||
| 383 | + | }) | |||
| 384 | + | if res.Conclusion != ConclusionCancelled || !res.RunnerShutdown { | |||
| 385 | + | t.Fatalf("%+v", res) | |||
| 386 | + | } | |||
| 387 | + | // On runner shutdown conditions are not evaluated (StepsRunner.cs:205-208): | |||
| 388 | + | // even always() steps are skipped. | |||
| 389 | + | if got := rec.steps[2]; got.Conclusion != ConclusionSkipped { | |||
| 390 | + | t.Errorf("%+v", got) | |||
| 391 | + | } | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func TestJobTimeout(t *testing.T) { | |||
| 395 | + | needTools(t, "bash") | |||
| 396 | + | home := t.TempDir() | |||
| 397 | + | layout := NewLayout(home, "hello", home+"/tc") | |||
| 398 | + | rec := newRecorder() | |||
| 399 | + | cfg := Config{ | |||
| 400 | + | WorkflowYAML: []byte(wfHead + " steps:\n - run: sleep 30\n"), | |||
| 401 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rec, | |||
| 402 | + | BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, TermAfter: 100 * time.Millisecond, KillAfter: 100 * time.Millisecond, | |||
| 403 | + | } | |||
| 404 | + | c := NewCanceller() | |||
| 405 | + | // A one-minute job timeout cannot be waited for: fire it as the timer does. | |||
| 406 | + | time.AfterFunc(300*time.Millisecond, func() { c.Cancel(CancelTimeout) }) | |||
| 407 | + | res := Run(c, cfg) | |||
| 408 | + | if res.Conclusion != ConclusionTimedOut || !strings.Contains(res.Error, "maximum execution time of 360 minutes") { | |||
| 409 | + | t.Fatalf("%+v", res) | |||
| 410 | + | } | |||
| 411 | + | if rec.steps[1].Conclusion != ConclusionCancelled { | |||
| 412 | + | t.Errorf("%+v", rec.steps[1]) | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func TestStartErrorsAndOrphans(t *testing.T) { | |||
| 417 | + | needTools(t, "bash") | |||
| 418 | + | rec, res := runLocal(t, wfHead+` steps: | |||
| 419 | + | - run: (sleep 60 &) ; echo started-background | |||
| 420 | + | `, "j", nil) | |||
| 421 | + | if res.Conclusion != ConclusionSuccess { | |||
| 422 | + | t.Fatalf("%+v", res) | |||
| 423 | + | } | |||
| 424 | + | last := logOf(rec.steps[len(rec.steps)-1]) | |||
| 425 | + | if !strings.Contains(last, "Cleaning up orphan processes") || !strings.Contains(last, "Terminate orphan process: pid (") || !strings.Contains(last, ") (sleep)") { | |||
| 426 | + | t.Errorf("orphan cleanup: %q", last) | |||
| 427 | + | } | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | func TestLineWriter(t *testing.T) { | |||
| 431 | + | var got []string | |||
| 432 | + | w := &lineWriter{fn: func(l string) { got = append(got, l) }} | |||
| 433 | + | for _, chunk := range []string{"a\nb\r", "\nc\rd", "\ne\xff", "f\n", "tail"} { | |||
| 434 | + | _, _ = w.Write([]byte(chunk)) | |||
| 435 | + | } | |||
| 436 | + | w.Flush() | |||
| 437 | + | want := []string{"a", "b", "c", "d", "e\ufffdf", "tail"} | |||
| 438 | + | if strings.Join(got, "|") != strings.Join(want, "|") { | |||
| 439 | + | t.Fatalf("%q", got) | |||
| 440 | + | } | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | func TestParseCommands(t *testing.T) { | |||
| 444 | + | reg := func(s string) bool { return s == "set-output" || s == "group" || s == "warning" } | |||
| 445 | + | c, ok := parseCommandV2("::set-output name=a%2Cb,x=y::v%0Aw%25", reg) | |||
| 446 | + | if !ok || c.Name != "set-output" || c.prop("name") != "a,b" || c.Data != "v\nw%" { | |||
| 447 | + | t.Fatalf("%+v %v", c, ok) | |||
| 448 | + | } | |||
| 449 | + | if c, ok = parseCommandV2(" ::group::title", reg); !ok || c.Data != "title" { | |||
| 450 | + | t.Fatalf("leading space: %+v", c) | |||
| 451 | + | } | |||
| 452 | + | if _, ok = parseCommandV2("::unknown::x", reg); ok { | |||
| 453 | + | t.Fatal("unregistered command") | |||
| 454 | + | } | |||
| 455 | + | if _, ok = parseCommandV2("x ::group::t", reg); ok { | |||
| 456 | + | t.Fatal("V2 must start the line") | |||
| 457 | + | } | |||
| 458 | + | c, ok = parseCommandV1("text ##[set-output name=n;k=v%3Bz]data%5D", reg) | |||
| 459 | + | if !ok || c.prop("name") != "n" || c.prop("k") != "v;z" || c.Data != "data]" { | |||
| 460 | + | t.Fatalf("v1: %+v %v", c, ok) | |||
| 461 | + | } | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | func TestStopCommands(t *testing.T) { | |||
| 465 | + | needTools(t, "bash") | |||
| 466 | + | rec, _ := runLocal(t, wfHead+` steps: | |||
| 467 | + | - run: | | |||
| 468 | + | echo "::stop-commands::tok12345" | |||
| 469 | + | echo "::warning::not-processed" | |||
| 470 | + | echo "::tok12345::" | |||
| 471 | + | echo "::warning::processed" | |||
| 472 | + | `, "j", nil) | |||
| 473 | + | got := logOf(rec.steps[1]) | |||
| 474 | + | if !strings.Contains(got, "::warning::not-processed") || !strings.Contains(got, "##[warning]processed") || !strings.Contains(got, "::stop-commands::tok12345") { | |||
| 475 | + | t.Errorf("%s", got) | |||
| 476 | + | } | |||
| 477 | + | } | |||