Commit a869da2
Eric Bower
·
2026-08-15 12:44:23 -0400 EDT
parent bfb4fa8
feat: --in-place and --summary-file flags
2 files changed,
+299,
-6
M
main.go
M
main.go
+143,
-6
| ... | ... | @@ -32,6 +32,13 @@ var tmplFS embed.FS | |
| 32 | 32 | type WorkspaceFactory func(cfg *Cfg, logger *slog.Logger, source string) Workspace | |
| 33 | 33 | ||
| 34 | 34 | func defaultWorkspaceFactory(cfg *Cfg, logger *slog.Logger, source string) Workspace { | |
| 35 | + | if cfg != nil && cfg.InPlace { | |
| 36 | + | return &WorkspaceInPlace{ | |
| 37 | + | Cfg: cfg, | |
| 38 | + | Logger: logger, | |
| 39 | + | Source: source, | |
| 40 | + | } | |
| 41 | + | } | |
| 35 | 42 | if strings.HasSuffix(source, ".tar") { | |
| 36 | 43 | return &WorkspaceTar{ | |
| 37 | 44 | Cfg: cfg, |
| ... | ... | @@ -73,6 +80,8 @@ type Cfg struct { | |
| 73 | 80 | IncludeRunning bool // emit running status updates in addition to terminal | |
| 74 | 81 | HumanOutput bool // human-readable output instead of JSONL / slog | |
| 75 | 82 | Wait bool // block until job completes, print history and summary | |
| 83 | + | InPlace bool // execute workspace in-place without rsyncing to /tmp | |
| 84 | + | SummaryFile string // write markdown summary to this file path | |
| 76 | 85 | EnvVars envList // custom environment variables passed via -e / -env | |
| 77 | 86 | SessionPrefix string // session prefix filter for monitor (default: "ci.") | |
| 78 | 87 | } |
| ... | ... | @@ -119,9 +128,13 @@ func NewCfg() (*Cfg, string, bool) { | |
| 119 | 128 | var includeRunning bool | |
| 120 | 129 | var human bool | |
| 121 | 130 | var wait bool | |
| 131 | + | var inPlace bool | |
| 132 | + | var summaryFile string | |
| 122 | 133 | flag.BoolVar(&includeRunning, "include-running", false, "emit running status updates in addition to terminal (default: terminal only)") | |
| 123 | 134 | flag.BoolVar(&human, "human", false, "human-readable output (default: JSONL / slog)") | |
| 124 | 135 | flag.BoolVar(&wait, "wait", false, "block until job completes, printing session history and summary") | |
| 136 | + | flag.BoolVar(&inPlace, "in-place", false, "execute workspace in-place without rsyncing to /tmp") | |
| 137 | + | flag.StringVar(&summaryFile, "summary-file", "", "file path to write markdown job summary") | |
| 125 | 138 | ||
| 126 | 139 | // Split args so the subcommand (first non-flag arg) doesn't block | |
| 127 | 140 | // flags that appear after it: "pici runner --wait" works. |
| ... | ... | @@ -147,6 +160,8 @@ func NewCfg() (*Cfg, string, bool) { | |
| 147 | 160 | IncludeRunning: includeRunning, | |
| 148 | 161 | HumanOutput: human, | |
| 149 | 162 | Wait: wait, | |
| 163 | + | InPlace: inPlace, | |
| 164 | + | SummaryFile: summaryFile, | |
| 150 | 165 | EnvVars: envVars, | |
| 151 | 166 | SessionPrefix: sessionPrefix, | |
| 152 | 167 | }, cmd, wantHelp |
| ... | ... | @@ -270,6 +285,8 @@ SUBCOMMAND HELP | |
| 270 | 285 | ||
| 271 | 286 | FLAGS | |
| 272 | 287 | -e, -env <KEY=VAL> Set or override environment variable for pico.sh | |
| 288 | + | -in-place Execute workspace in-place without copying to /tmp | |
| 289 | + | -summary-file <path> Write markdown job summary table to file | |
| 273 | 290 | -pk <path> SSH private key | |
| 274 | 291 | -ck <path> SSH public certificate key | |
| 275 | 292 | -artifact-dir <path> Artifact staging directory (default: /tmp/pici-artifacts) |
| ... | ... | @@ -571,6 +588,39 @@ func (w *WorkspaceRsync) Checksum() string { | |
| 571 | 588 | return w.checksum | |
| 572 | 589 | } | |
| 573 | 590 | ||
| 591 | + | type WorkspaceInPlace struct { | |
| 592 | + | Cfg *Cfg | |
| 593 | + | Logger *slog.Logger | |
| 594 | + | Source string | |
| 595 | + | checksum string | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | func (w *WorkspaceInPlace) Setup() error { | |
| 599 | + | log := w.Logger.With("source", w.Source) | |
| 600 | + | log.Debug("using in-place workspace") | |
| 601 | + | cs, err := computeDirChecksum(w.Source) | |
| 602 | + | if err != nil { | |
| 603 | + | log.Error("compute workspace checksum", "err", err) | |
| 604 | + | } else { | |
| 605 | + | w.checksum = cs | |
| 606 | + | log.Debug("workspace checksum", "checksum", w.checksum) | |
| 607 | + | } | |
| 608 | + | return nil | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | func (w *WorkspaceInPlace) Cleanup() error { | |
| 612 | + | // In-place workspace is not cleaned up | |
| 613 | + | return nil | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | func (w *WorkspaceInPlace) GetDir() string { | |
| 617 | + | return w.Source | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | func (w *WorkspaceInPlace) Checksum() string { | |
| 621 | + | return w.checksum | |
| 622 | + | } | |
| 623 | + | ||
| 574 | 624 | func computeDirChecksum(dir string) (string, error) { | |
| 575 | 625 | hasher := sha256.New() | |
| 576 | 626 | err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { |
| ... | ... | @@ -1140,6 +1190,12 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) er | |
| 1140 | 1190 | fmt.Fprintf(os.Stdout, "%s job failed: exit %d (%s)\n", icon, exitCode, duration) //nolint:errcheck | |
| 1141 | 1191 | } | |
| 1142 | 1192 | ||
| 1193 | + | if cfg.SummaryFile != "" { | |
| 1194 | + | if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, jobSessions, known); err != nil { | |
| 1195 | + | log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile) | |
| 1196 | + | } | |
| 1197 | + | } | |
| 1198 | + | ||
| 1143 | 1199 | if exitCode != 0 { | |
| 1144 | 1200 | return &JobFailedError{ExitCode: exitCode} | |
| 1145 | 1201 | } |
| ... | ... | @@ -1147,6 +1203,74 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) er | |
| 1147 | 1203 | return nil | |
| 1148 | 1204 | } | |
| 1149 | 1205 | ||
| 1206 | + | func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions []SessionInfo, known map[string]*sessionState) error { | |
| 1207 | + | if filePath == "" { | |
| 1208 | + | return nil | |
| 1209 | + | } | |
| 1210 | + | ||
| 1211 | + | exitCode, status := resolveJobExitCode(jobSessions) | |
| 1212 | + | _, _, duration := computeJobTiming(jobSessions) | |
| 1213 | + | statusIcon := map[string]string{"success": "✅", "failed": "❌"}[status] | |
| 1214 | + | ||
| 1215 | + | var sb strings.Builder | |
| 1216 | + | sb.WriteString(fmt.Sprintf("### Pici CI Summary: `%s` (Job `%s`)\n\n", repoName, jobID)) | |
| 1217 | + | sb.WriteString(fmt.Sprintf("**Status:** %s %s | **Exit Code:** `%d` | **Duration:** `%s`\n\n", statusIcon, strings.ToUpper(status), exitCode, duration)) | |
| 1218 | + | ||
| 1219 | + | sb.WriteString("| Session | Status | Exit Code | Duration |\n") | |
| 1220 | + | sb.WriteString("| :--- | :--- | :--- | :--- |\n") | |
| 1221 | + | ||
| 1222 | + | for _, s := range jobSessions { | |
| 1223 | + | state := known[s.Short] | |
| 1224 | + | sStatus := "unknown" | |
| 1225 | + | sExit := "—" | |
| 1226 | + | sDur := "—" | |
| 1227 | + | sIcon := "❓" | |
| 1228 | + | if state != nil { | |
| 1229 | + | sStatus = state.status | |
| 1230 | + | sExit = state.exitCode | |
| 1231 | + | if sExit == "" { | |
| 1232 | + | sExit = "—" | |
| 1233 | + | } | |
| 1234 | + | sDur = state.duration | |
| 1235 | + | if sDur == "" { | |
| 1236 | + | sDur = "—" | |
| 1237 | + | } | |
| 1238 | + | sIcon = map[string]string{"running": "🚀", "success": "✅", "failed": "❌"}[sStatus] | |
| 1239 | + | } | |
| 1240 | + | sb.WriteString(fmt.Sprintf("| `%s` | %s %s | `%s` | %s |\n", s.Short, sIcon, sStatus, sExit, sDur)) | |
| 1241 | + | } | |
| 1242 | + | sb.WriteString("\n") | |
| 1243 | + | ||
| 1244 | + | // Include failed session details | |
| 1245 | + | hasFailed := false | |
| 1246 | + | for _, s := range jobSessions { | |
| 1247 | + | state := known[s.Short] | |
| 1248 | + | if state == nil || state.status != "failed" { | |
| 1249 | + | continue | |
| 1250 | + | } | |
| 1251 | + | if !hasFailed { | |
| 1252 | + | sb.WriteString("### Failed Session Details\n\n") | |
| 1253 | + | hasFailed = true | |
| 1254 | + | } | |
| 1255 | + | history, err := fetchHistoryPlain(s.Name) | |
| 1256 | + | if err != nil { | |
| 1257 | + | history = fmt.Sprintf("(history unavailable: %v)", err) | |
| 1258 | + | } | |
| 1259 | + | sb.WriteString(fmt.Sprintf("<details><summary><b>%s</b> (exit %s)</summary>\n\n```\n%s\n```\n\n</details>\n\n", s.Short, state.exitCode, strings.TrimSpace(history))) | |
| 1260 | + | } | |
| 1261 | + | ||
| 1262 | + | f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) | |
| 1263 | + | if err != nil { | |
| 1264 | + | return fmt.Errorf("open summary file: %w", err) | |
| 1265 | + | } | |
| 1266 | + | defer f.Close() | |
| 1267 | + | ||
| 1268 | + | if _, err := f.WriteString(sb.String()); err != nil { | |
| 1269 | + | return fmt.Errorf("write summary file: %w", err) | |
| 1270 | + | } | |
| 1271 | + | return nil | |
| 1272 | + | } | |
| 1273 | + | ||
| 1150 | 1274 | // cleanSessionShort produces a readable short name from a full session name. | |
| 1151 | 1275 | // ci.name.jobID.step.lint → lint | |
| 1152 | 1276 | // ci.name.jobID.step.ci.name.jobID.runner → runner |
| ... | ... | @@ -2419,11 +2543,20 @@ func runLocal(cfg *Cfg, dest string) error { | |
| 2419 | 2543 | } | |
| 2420 | 2544 | log := logger.With("repo", eventData.Name, "job_id", jobID, "mode", "local") | |
| 2421 | 2545 | ||
| 2422 | - | // Set up workspace in temp directory | |
| 2423 | - | wk := &WorkspaceRsync{ | |
| 2424 | - | Cfg: cfg, | |
| 2425 | - | Logger: log, | |
| 2426 | - | Source: cwd, | |
| 2546 | + | // Set up workspace | |
| 2547 | + | var wk Workspace | |
| 2548 | + | if cfg.InPlace { | |
| 2549 | + | wk = &WorkspaceInPlace{ | |
| 2550 | + | Cfg: cfg, | |
| 2551 | + | Logger: log, | |
| 2552 | + | Source: cwd, | |
| 2553 | + | } | |
| 2554 | + | } else { | |
| 2555 | + | wk = &WorkspaceRsync{ | |
| 2556 | + | Cfg: cfg, | |
| 2557 | + | Logger: log, | |
| 2558 | + | Source: cwd, | |
| 2559 | + | } | |
| 2427 | 2560 | } | |
| 2428 | 2561 | ||
| 2429 | 2562 | eng := &JobEngine{ |
| ... | ... | @@ -2444,7 +2577,11 @@ func runLocal(cfg *Cfg, dest string) error { | |
| 2444 | 2577 | if err := eng.Setup(); err != nil { | |
| 2445 | 2578 | return fmt.Errorf("workspace setup: %w", err) | |
| 2446 | 2579 | } | |
| 2447 | - | fmt.Fprintf(os.Stdout, "📦 syncing workspace to temp directory %s\n", eng.Wk.GetDir()) //nolint:errcheck | |
| 2580 | + | if cfg.InPlace { | |
| 2581 | + | fmt.Fprintf(os.Stdout, "📁 using in-place workspace directory %s\n", eng.Wk.GetDir()) //nolint:errcheck | |
| 2582 | + | } else { | |
| 2583 | + | fmt.Fprintf(os.Stdout, "📦 syncing workspace to temp directory %s\n", eng.Wk.GetDir()) //nolint:errcheck | |
| 2584 | + | } | |
| 2448 | 2585 | log.Debug("workspace directory", "dir", eng.Wk.GetDir()) | |
| 2449 | 2586 | ||
| 2450 | 2587 | // Store event.json in artifact dir |
+156,
-0
| ... | ... | @@ -1226,3 +1226,159 @@ exit 13 | |
| 1226 | 1226 | t.Errorf("expected exit code 13, got %d", jobFailedErr.ExitCode) | |
| 1227 | 1227 | } | |
| 1228 | 1228 | } | |
| 1229 | + | ||
| 1230 | + | func TestWorkspaceInPlace(t *testing.T) { | |
| 1231 | + | tempDir := t.TempDir() | |
| 1232 | + | testFile := filepath.Join(tempDir, "test.txt") | |
| 1233 | + | if err := os.WriteFile(testFile, []byte("hello world"), 0644); err != nil { | |
| 1234 | + | t.Fatal(err) | |
| 1235 | + | } | |
| 1236 | + | ||
| 1237 | + | cfg := &Cfg{ | |
| 1238 | + | InPlace: true, | |
| 1239 | + | } | |
| 1240 | + | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) | |
| 1241 | + | wk := defaultWorkspaceFactory(cfg, logger, tempDir) | |
| 1242 | + | ||
| 1243 | + | if _, ok := wk.(*WorkspaceInPlace); !ok { | |
| 1244 | + | t.Fatalf("expected WorkspaceInPlace, got %T", wk) | |
| 1245 | + | } | |
| 1246 | + | ||
| 1247 | + | if err := wk.Setup(); err != nil { | |
| 1248 | + | t.Fatalf("Setup failed: %v", err) | |
| 1249 | + | } | |
| 1250 | + | ||
| 1251 | + | if wk.GetDir() != tempDir { | |
| 1252 | + | t.Errorf("expected GetDir() == %q, got %q", tempDir, wk.GetDir()) | |
| 1253 | + | } | |
| 1254 | + | ||
| 1255 | + | if wk.Checksum() == "" { | |
| 1256 | + | t.Errorf("expected non-empty checksum") | |
| 1257 | + | } | |
| 1258 | + | ||
| 1259 | + | if err := wk.Cleanup(); err != nil { | |
| 1260 | + | t.Fatalf("Cleanup failed: %v", err) | |
| 1261 | + | } | |
| 1262 | + | ||
| 1263 | + | if _, err := os.Stat(testFile); os.IsNotExist(err) { | |
| 1264 | + | t.Fatalf("expected test file to still exist after Cleanup() on in-place workspace") | |
| 1265 | + | } | |
| 1266 | + | } | |
| 1267 | + | ||
| 1268 | + | func TestRunLocal_InPlace(t *testing.T) { | |
| 1269 | + | if _, err := exec.LookPath("zmx"); err != nil { | |
| 1270 | + | t.Skip("zmx not found, skipping test") | |
| 1271 | + | } | |
| 1272 | + | ||
| 1273 | + | tempDir := t.TempDir() | |
| 1274 | + | origWd, _ := os.Getwd() | |
| 1275 | + | defer func() { _ = os.Chdir(origWd) }() | |
| 1276 | + | _ = os.Chdir(tempDir) | |
| 1277 | + | ||
| 1278 | + | picoContent := `#!/usr/bin/env bash | |
| 1279 | + | set -e | |
| 1280 | + | echo "inplace marker" > marker.txt | |
| 1281 | + | ` | |
| 1282 | + | if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil { | |
| 1283 | + | t.Fatal(err) | |
| 1284 | + | } | |
| 1285 | + | ||
| 1286 | + | artifactDir := t.TempDir() | |
| 1287 | + | cfg := &Cfg{ | |
| 1288 | + | ArtifactDir: artifactDir, | |
| 1289 | + | MonitorInterval: 100 * time.Millisecond, | |
| 1290 | + | InPlace: true, | |
| 1291 | + | } | |
| 1292 | + | ||
| 1293 | + | err := runLocal(cfg, "") | |
| 1294 | + | if err != nil { | |
| 1295 | + | t.Fatalf("expected runLocal in-place to succeed, got %v", err) | |
| 1296 | + | } | |
| 1297 | + | ||
| 1298 | + | markerPath := filepath.Join(tempDir, "marker.txt") | |
| 1299 | + | data, err := os.ReadFile(markerPath) | |
| 1300 | + | if err != nil { | |
| 1301 | + | t.Fatalf("expected marker.txt to be created in working directory: %v", err) | |
| 1302 | + | } | |
| 1303 | + | if !strings.Contains(string(data), "inplace marker") { | |
| 1304 | + | t.Errorf("unexpected marker.txt content: %q", string(data)) | |
| 1305 | + | } | |
| 1306 | + | } | |
| 1307 | + | ||
| 1308 | + | func TestRunLocal_SummaryFile(t *testing.T) { | |
| 1309 | + | if _, err := exec.LookPath("zmx"); err != nil { | |
| 1310 | + | t.Skip("zmx not found, skipping test") | |
| 1311 | + | } | |
| 1312 | + | ||
| 1313 | + | tempDir := t.TempDir() | |
| 1314 | + | origWd, _ := os.Getwd() | |
| 1315 | + | defer func() { _ = os.Chdir(origWd) }() | |
| 1316 | + | _ = os.Chdir(tempDir) | |
| 1317 | + | ||
| 1318 | + | picoContent := `#!/usr/bin/env bash | |
| 1319 | + | echo "hello from step" | |
| 1320 | + | ` | |
| 1321 | + | if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil { | |
| 1322 | + | t.Fatal(err) | |
| 1323 | + | } | |
| 1324 | + | ||
| 1325 | + | artifactDir := t.TempDir() | |
| 1326 | + | summaryFilePath := filepath.Join(tempDir, "summary.md") | |
| 1327 | + | cfg := &Cfg{ | |
| 1328 | + | ArtifactDir: artifactDir, | |
| 1329 | + | MonitorInterval: 100 * time.Millisecond, | |
| 1330 | + | InPlace: true, | |
| 1331 | + | SummaryFile: summaryFilePath, | |
| 1332 | + | } | |
| 1333 | + | ||
| 1334 | + | err := runLocal(cfg, "") | |
| 1335 | + | if err != nil { | |
| 1336 | + | t.Fatalf("expected runLocal to succeed, got %v", err) | |
| 1337 | + | } | |
| 1338 | + | ||
| 1339 | + | summaryBytes, err := os.ReadFile(summaryFilePath) | |
| 1340 | + | if err != nil { | |
| 1341 | + | t.Fatalf("expected summary file %s to exist: %v", summaryFilePath, err) | |
| 1342 | + | } | |
| 1343 | + | ||
| 1344 | + | summary := string(summaryBytes) | |
| 1345 | + | if !strings.Contains(summary, "### Pici CI Summary:") { | |
| 1346 | + | t.Errorf("expected summary to contain header, got:\n%s", summary) | |
| 1347 | + | } | |
| 1348 | + | if !strings.Contains(summary, "| Session | Status | Exit Code | Duration |") { | |
| 1349 | + | t.Errorf("expected summary to contain table header, got:\n%s", summary) | |
| 1350 | + | } | |
| 1351 | + | } | |
| 1352 | + | ||
| 1353 | + | func TestWriteSummaryMarkdown_WithFailure(t *testing.T) { | |
| 1354 | + | tempFile := filepath.Join(t.TempDir(), "summary.md") | |
| 1355 | + | sessions := []SessionInfo{ | |
| 1356 | + | {Short: "setup", Name: "local.repo.job.setup", ExitCode: "0"}, | |
| 1357 | + | {Short: "failing-step", Name: "local.repo.job.failing-step", ExitCode: "42"}, | |
| 1358 | + | } | |
| 1359 | + | known := map[string]*sessionState{ | |
| 1360 | + | "setup": {status: "success", exitCode: "0", duration: "1.2s"}, | |
| 1361 | + | "failing-step": {status: "failed", exitCode: "42", duration: "3.4s"}, | |
| 1362 | + | } | |
| 1363 | + | ||
| 1364 | + | err := writeSummaryMarkdown(tempFile, "test-repo", "job-123", sessions, known) | |
| 1365 | + | if err != nil { | |
| 1366 | + | t.Fatalf("writeSummaryMarkdown failed: %v", err) | |
| 1367 | + | } | |
| 1368 | + | ||
| 1369 | + | data, err := os.ReadFile(tempFile) | |
| 1370 | + | if err != nil { | |
| 1371 | + | t.Fatalf("read summary file: %v", err) | |
| 1372 | + | } | |
| 1373 | + | content := string(data) | |
| 1374 | + | ||
| 1375 | + | if !strings.Contains(content, "### Pici CI Summary: `test-repo` (Job `job-123`)") { | |
| 1376 | + | t.Errorf("missing header in content:\n%s", content) | |
| 1377 | + | } | |
| 1378 | + | if !strings.Contains(content, "| `failing-step` | ❌ failed | `42` | 3.4s |") { | |
| 1379 | + | t.Errorf("missing table row in content:\n%s", content) | |
| 1380 | + | } | |
| 1381 | + | if !strings.Contains(content, "### Failed Session Details") { | |
| 1382 | + | t.Errorf("missing failed details in content:\n%s", content) | |
| 1383 | + | } | |
| 1384 | + | } |