Commit 818edb5
Eric Bower
·
2026-08-15 09:39:08 -0400 EDT
parent 51628ad
fix: report proper error exit codes when job fails
2 files changed,
+186,
-4
M
main.go
M
main.go
+41,
-4
1@@ -7,6 +7,7 @@ import (
2 "crypto/sha256"
3 "embed"
4 "encoding/json"
5+ "errors"
6 "flag"
7 "fmt"
8 "html/template"
9@@ -76,6 +77,16 @@ type Cfg struct {
10 SessionPrefix string // session prefix filter for monitor (default: "ci.")
11 }
12
13+// JobFailedError indicates that one or more sessions within a job failed,
14+// carrying the resolved non-zero exit code.
15+type JobFailedError struct {
16+ ExitCode int
17+}
18+
19+func (e *JobFailedError) Error() string {
20+ return fmt.Sprintf("job failed with exit code %d", e.ExitCode)
21+}
22+
23 type Event struct {
24 Type string `json:"type"`
25 Name string `json:"name"`
26@@ -321,6 +332,11 @@ func main() {
27 case "runner":
28 cfg.Logger.Debug("starting runner")
29 if err := RunRunner(cfg); err != nil {
30+ var failedErr *JobFailedError
31+ if errors.As(err, &failedErr) {
32+ cfg.Logger.Error("runner failed", "err", err, "exit_code", failedErr.ExitCode)
33+ os.Exit(failedErr.ExitCode)
34+ }
35 cfg.Logger.Error("runner failed", "err", err)
36 os.Exit(1)
37 }
38@@ -354,12 +370,22 @@ func main() {
39 dest = flag.Arg(0)
40 }
41 if err := runLocal(cfg, dest); err != nil {
42+ var failedErr *JobFailedError
43+ if errors.As(err, &failedErr) {
44+ cfg.Logger.Error("local run failed", "err", err, "exit_code", failedErr.ExitCode)
45+ os.Exit(failedErr.ExitCode)
46+ }
47 cfg.Logger.Error("local run failed", "err", err)
48 os.Exit(1)
49 }
50 default:
51 dest := cmd
52 if err := runLocal(cfg, dest); err != nil {
53+ var failedErr *JobFailedError
54+ if errors.As(err, &failedErr) {
55+ cfg.Logger.Error("local run failed", "err", err, "exit_code", failedErr.ExitCode)
56+ os.Exit(failedErr.ExitCode)
57+ }
58 cfg.Logger.Error("local run failed", "err", err)
59 os.Exit(1)
60 }
61@@ -822,9 +848,11 @@ func eventHandler(cfg *Cfg, eventData *Event) error {
62 domain := getDomain(eventData.Type)
63 fmt.Fprintf(os.Stdout, "🚀 starting job %s.%s.%s\n", domain, eventData.Name, jobID) //nolint:errcheck
64 fmt.Fprintf(os.Stdout, " event: type=%s name=%s workspace=%s\n", eventData.Type, eventData.Name, eventData.Workspace) //nolint:errcheck
65- fmt.Fprintf(os.Stdout, " %s\n", string(eventBytes)) //nolint:errcheck
66-
67- wk := cfg.NewWorkspace(cfg, log, eventData.Workspace)
68+ newWk := cfg.NewWorkspace
69+ if newWk == nil {
70+ newWk = defaultWorkspaceFactory
71+ }
72+ wk := newWk(cfg, log, eventData.Workspace)
73 eng := &JobEngine{
74 Logger: log,
75 Cfg: cfg,
76@@ -1112,6 +1140,10 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) er
77 fmt.Fprintf(os.Stdout, "%s job failed: exit %d (%s)\n", icon, exitCode, duration) //nolint:errcheck
78 }
79
80+ if exitCode != 0 {
81+ return &JobFailedError{ExitCode: exitCode}
82+ }
83+
84 return nil
85 }
86
87@@ -2454,8 +2486,9 @@ func runLocal(cfg *Cfg, dest string) error {
88 }
89
90 // Wait for completion & print live progress
91+ var waitErr error
92 if err := waitAndReport(cfg, log, eventData.Name, jobID, eventData.Type); err != nil {
93- return fmt.Errorf("wait: %w", err)
94+ waitErr = err
95 }
96
97 // Generate and stage full HTML/txt artifacts and index
98@@ -2491,6 +2524,10 @@ func runLocal(cfg *Cfg, dest string) error {
99 _ = writePublishedSentinel(cfg.ArtifactDir, eventData.Name, jobID, status, exitCode)
100 }
101
102+ if waitErr != nil {
103+ return waitErr
104+ }
105+
106 return nil
107 }
108
+145,
-0
1@@ -5,6 +5,7 @@ import (
2 "bytes"
3 "context"
4 "encoding/json"
5+ "errors"
6 "fmt"
7 "io"
8 "log/slog"
9@@ -1081,3 +1082,147 @@ func TestGenerateJobIndex_WalksSubdirectories(t *testing.T) {
10 t.Errorf("txt index missing nested artifact build/output.json: %s", txtContent)
11 }
12 }
13+
14+func TestWaitAndReport_ExitCodeFailure(t *testing.T) {
15+ if _, err := exec.LookPath("zmx"); err != nil {
16+ t.Skip("zmx not found, skipping test")
17+ }
18+
19+ cfg := &Cfg{
20+ MonitorInterval: 100 * time.Millisecond,
21+ ArtifactDir: t.TempDir(),
22+ }
23+
24+ jobID := fmt.Sprintf("failtest-%d", time.Now().UnixNano())
25+ prefix := "local.testrepo." + jobID + "."
26+ runnerSession := prefix + "runner"
27+
28+ t.Cleanup(func() {
29+ _ = exec.Command("zmx", "kill", "-f", runnerSession).Run()
30+ })
31+
32+ // Start a zmx session that exits with code 42
33+ cmd := exec.Command("zmx", "run", runnerSession, "-d", "bash", "-c", "exit 42")
34+ if err := cmd.Run(); err != nil {
35+ t.Fatalf("failed to start zmx session: %v", err)
36+ }
37+
38+ err := waitAndReport(cfg, nil, "testrepo", jobID, "local")
39+ if err == nil {
40+ t.Fatal("expected error from waitAndReport for failed job, got nil")
41+ }
42+
43+ var jobFailedErr *JobFailedError
44+ if !errors.As(err, &jobFailedErr) {
45+ t.Fatalf("expected error of type *JobFailedError, got %T: %v", err, err)
46+ }
47+ if jobFailedErr.ExitCode != 42 {
48+ t.Errorf("expected exit code 42, got %d", jobFailedErr.ExitCode)
49+ }
50+}
51+
52+func TestWaitAndReport_Success(t *testing.T) {
53+ if _, err := exec.LookPath("zmx"); err != nil {
54+ t.Skip("zmx not found, skipping test")
55+ }
56+
57+ cfg := &Cfg{
58+ MonitorInterval: 100 * time.Millisecond,
59+ ArtifactDir: t.TempDir(),
60+ }
61+
62+ jobID := fmt.Sprintf("successtest-%d", time.Now().UnixNano())
63+ prefix := "local.testrepo." + jobID + "."
64+ runnerSession := prefix + "runner"
65+
66+ t.Cleanup(func() {
67+ _ = exec.Command("zmx", "kill", "-f", runnerSession).Run()
68+ })
69+
70+ // Start a zmx session that exits with code 0
71+ cmd := exec.Command("zmx", "run", runnerSession, "-d", "bash", "-c", "exit 0")
72+ if err := cmd.Run(); err != nil {
73+ t.Fatalf("failed to start zmx session: %v", err)
74+ }
75+
76+ err := waitAndReport(cfg, nil, "testrepo", jobID, "local")
77+ if err != nil {
78+ t.Fatalf("expected nil error for successful job, got %v", err)
79+ }
80+}
81+
82+func TestRunLocal_ExitCodeFailure(t *testing.T) {
83+ if _, err := exec.LookPath("zmx"); err != nil {
84+ t.Skip("zmx not found, skipping test")
85+ }
86+
87+ tempDir := t.TempDir()
88+ origWd, _ := os.Getwd()
89+ defer func() { _ = os.Chdir(origWd) }()
90+ _ = os.Chdir(tempDir)
91+
92+ picoContent := `#!/usr/bin/env bash
93+exit 7
94+`
95+ if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
96+ t.Fatal(err)
97+ }
98+
99+ artifactDir := t.TempDir()
100+ cfg := &Cfg{
101+ ArtifactDir: artifactDir,
102+ MonitorInterval: 100 * time.Millisecond,
103+ }
104+
105+ err := runLocal(cfg, "")
106+ if err == nil {
107+ t.Fatal("expected error from runLocal when pico.sh exits non-zero, got nil")
108+ }
109+
110+ var jobFailedErr *JobFailedError
111+ if !errors.As(err, &jobFailedErr) {
112+ t.Fatalf("expected error of type *JobFailedError, got %T: %v", err, err)
113+ }
114+ if jobFailedErr.ExitCode != 7 {
115+ t.Errorf("expected exit code 7, got %d", jobFailedErr.ExitCode)
116+ }
117+}
118+
119+func TestRunRunner_Wait_ExitCodeFailure(t *testing.T) {
120+ if _, err := exec.LookPath("zmx"); err != nil {
121+ t.Skip("zmx not found, skipping test")
122+ }
123+
124+ workspaceDir := t.TempDir()
125+ picoContent := `#!/usr/bin/env bash
126+exit 13
127+`
128+ if err := os.WriteFile(filepath.Join(workspaceDir, "pico.sh"), []byte(picoContent), 0755); err != nil {
129+ t.Fatal(err)
130+ }
131+
132+ eventJSON := fmt.Sprintf(`{"type":"local","name":"runner-fail-repo","workspace":%q}`, workspaceDir)
133+
134+ artifactDir := t.TempDir()
135+ cfg := &Cfg{
136+ ArtifactDir: artifactDir,
137+ MonitorInterval: 100 * time.Millisecond,
138+ Wait: true,
139+ Event: eventJSON,
140+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
141+ NewWorkspace: defaultWorkspaceFactory,
142+ }
143+
144+ err := RunRunner(cfg)
145+ if err == nil {
146+ t.Fatal("expected error from RunRunner --wait on failed job, got nil")
147+ }
148+
149+ var jobFailedErr *JobFailedError
150+ if !errors.As(err, &jobFailedErr) {
151+ t.Fatalf("expected wrapped *JobFailedError, got %T: %v", err, err)
152+ }
153+ if jobFailedErr.ExitCode != 13 {
154+ t.Errorf("expected exit code 13, got %d", jobFailedErr.ExitCode)
155+ }
156+}