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
+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+}