detached e0de5de

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
219219 func (m *commandManager) validateStopToken(token string) error {
220220 invalid := m.isRegistered(token) || token == "" || strings.EqualFold(token, "pause-logging")
221221 if invalid && !m.host.EnvFlag("ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS") {
222- return fmt.Errorf("%s", unsupportedStopTokenDisabled)
222+ return msgError(unsupportedStopTokenDisabled)
223223 }
224224 return nil
225225 }
@@ -239,7 +239,7 @@ func (m *commandManager) run(name string, c *actionCommand) error {
239239 h.Tagged("##["+name+"]", c.Data)
240240 case "echo":
241241 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')")
243243 }
244244 switch strings.ToUpper(strings.TrimSpace(c.Data)) {
245245 case "ON":
@@ -249,13 +249,13 @@ func (m *commandManager) run(name string, c *actionCommand) error {
249249 m.echo = false
250250 h.Debug("Setting echo command value to 'off'")
251251 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)
253253 }
254254 case "set-output":
255255 h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
256256 n := c.prop("name")
257257 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.")
259259 }
260260 h.SetOutput(n, c.Data)
261261 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 {
263263 h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
264264 n := c.prop("name")
265265 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.")
267267 }
268268 h.SaveState(n, c.Data)
269269 h.Debug(fmt.Sprintf("Save intra-action state %s = %s", n, c.Data))
270270 case "set-env":
271271 if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
272- return fmt.Errorf(unsupportedCommandMessageDisabled, name)
272+ return ghErrorf(unsupportedCommandMessageDisabled, name)
273273 }
274274 n := c.prop("name")
275275 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.")
277277 }
278278 if b := blockedEnvName(n); b != "" {
279279 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 {
283283 h.Debug(fmt.Sprintf("%s='%s'", n, c.Data))
284284 case "add-path":
285285 if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
286- return fmt.Errorf(unsupportedCommandMessageDisabled, name)
286+ return ghErrorf(unsupportedCommandMessageDisabled, name)
287287 }
288288 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')")
290290 }
291291 h.AddPath(c.Data)
292292 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 nil224 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 = false249 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
99
1010 import (
1111 "bytes"
12- "errors"
1312 "fmt"
1413 "os"
1514 "path/filepath"
@@ -59,6 +58,14 @@ const stepSummaryLimit = 1024 * 1024
5958 // setEnvBlockList is SetEnvFileCommand._setEnvBlockList (FileCommandManager.cs:215).
6059 var setEnvBlockList = []string{"NODE_OPTIONS"}
6160
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+
6269 // Pair is one name and value read from an environment-style file.
6370 type Pair struct{ Key, Value string }
6471
@@ -280,23 +287,23 @@ func ParseEnvFile(text string) ([]Pair, error) {
280287 // an empty variable name is useless: we reject it.
281288 k, v, _ := strings.Cut(line, "=")
282289 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)
284291 }
285292 key, output = k, v
286293 case heredoc >= 0 && (equals < 0 || heredoc < equals):
287294 k, delim, _ := strings.Cut(line, "<<")
288295 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)
290297 }
291298 key = k
292299 start, end := index, index
293300 temp, newline, tok := readLine(text, &index)
294301 for temp != delim || !tok {
295302 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)
297304 }
298305 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.")
300307 }
301308 end = index - len(newline)
302309 temp, newline, tok = readLine(text, &index)
@@ -305,7 +312,7 @@ func ParseEnvFile(text string) ([]Pair, error) {
305312 output = text[start:end]
306313 }
307314 default:
308- return out, fmt.Errorf("Invalid format '%s'", line)
315+ return out, ghErrorf("Invalid format '%s'", line)
309316 }
310317 out = append(out, Pair{key, output})
311318 }
@@ -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, v292 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 = k298 key = k
292 start, end := index, index299 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() {
7979 }
8080 }
8181
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+
82162 // procSpec describes one process to run.
83163 type procSpec struct {
84164 Argv []string
@@ -86,7 +166,8 @@ type procSpec struct {
86166 Env []string
87167 // Credential, when set, is the user the process runs as.
88168 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.
90171 OnLine func(line string)
91172 // Cancel starts the SIGINT, SIGTERM, SIGKILL escalation when closed.
92173 Cancel <-chan struct{}
@@ -124,8 +205,8 @@ func runProcess(spec procSpec) (procResult, error) {
124205 cmd.Dir = spec.Dir
125206 cmd.Env = spec.Env
126207 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
129210 cmd.WaitDelay = spec.WaitDelay
130211 if err := cmd.Start(); err != nil {
131212 return procResult{}, err
@@ -177,7 +258,7 @@ func runProcess(spec procSpec) (procResult, error) {
177258 mu.Unlock()
178259 close(stopWatch)
179260 <-watchDone
180- lw.Flush()
261+ disp.Close()
181262
182263 res := procResult{Cancelled: was}
183264 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 []string164 Argv []string
@@ -86,7 +166,8 @@ type procSpec struct {
86 Env []string166 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.Credential168 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.Dir205 cmd.Dir = spec.Dir
125 cmd.Env = spec.Env206 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 kept209+ cmd.Stdout, cmd.Stderr = disp.out, disp.err
129 cmd.WaitDelay = spec.WaitDelay210 cmd.WaitDelay = spec.WaitDelay
130 if err := cmd.Start(); err != nil {211 if err := cmd.Start(); err != nil {
131 return procResult{}, err212 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 <-watchDone260 <-watchDone
180- lw.Flush()261+ disp.Close()
181 262
182 res := procResult{Cancelled: was}263 res := procResult{Cancelled: was}
183 var ee *exec.ExitError264 var ee *exec.ExitError
M internal/executor/shell.go +4 -5
@@ -3,7 +3,6 @@
33 package executor
44
55 import (
6- "errors"
76 "fmt"
87 "os"
98 "path/filepath"
@@ -71,7 +70,7 @@ func ParseShellOption(shell string) (command, args string) {
7170
7271 // ErrInvalidShell is the error of a shell whose argument format has no {0}
7372 // (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}'")
7574
7675 // Lookup finds executables like WhichUtil.Which (Runner.Sdk/Util/WhichUtil.cs):
7776 // in PATH with the directories added by GITHUB_PATH in front.
@@ -203,18 +202,18 @@ func FormatArguments(format, scriptPath string) (string, error) {
203202 case c == '{':
204203 end := strings.IndexByte(format[i:], '}')
205204 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.")
207206 }
208207 spec := format[i+1 : i+end]
209208 index, _, _ := strings.Cut(spec, ",")
210209 index, _, _ = strings.Cut(index, ":")
211210 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.")
213212 }
214213 b.WriteString(path)
215214 i += end
216215 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.")
218217 default:
219218 b.WriteByte(c)
220219 }
@@ -3,7 +3,6 @@
3 package executor3 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 += end214 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 {
123123 baseEnv []string
124124 lookupPath string
125125 before map[string]bool
126- outputsSet bool
127126 }
128127
129128 func (j *job) now() time.Time {
@@ -287,7 +286,7 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
287286 var out []Pair
288287 for _, p := range d.Pairs() {
289288 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)
291290 }
292291 out = append(out, Pair{p.Key, expr.CoerceString(p.Value)})
293292 }
@@ -313,11 +312,11 @@ func (j *job) setUp() ([]*workflow.Step, error) {
313312 for _, e := range errs {
314313 msgs = append(msgs, e.Error())
315314 }
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"))
317316 }
318317 jb := wf.Job(cfg.JobKey)
319318 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)
321320 }
322321 j.wf, j.jb = wf, jb
323322
@@ -446,15 +445,13 @@ func envValue(env []string, key string) string {
446445 // stepRun is the state of one step being run; it implements Sink and
447446 // commandHost.
448447 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
458455 }
459456
460457 func (s *stepRun) Out(text string) { s.j.out(s.num, text) }
@@ -123,7 +123,6 @@ type job struct {
123 baseEnv []string123 baseEnv []string
124 lookupPath string124 lookupPath string
125 before map[string]bool125 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 []Pair286 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, jb321 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 and445 // 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 *job448+ j *job
450- num int449+ num int
451- st *workflow.Step450+ st *workflow.Step
452- env *ordered // the step's environment: job env + step env451+ env *ordered // the step's environment: job env + step env
453- result *stepResult452+ cmdResult *stepResult
454- cmdResult *stepResult453+ outcome *stepResult
455- outcome *stepResult454+ 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close