Commit 8394af3
Eric Bower
·
2026-08-08 11:54:27 -0400 EDT
parent 957187e
feat: add local runner base command with isolated execution and html logging - Default `pici [destination]` command copies CWD to /tmp for isolated execution - Streamline CLI flags with -e/--env for key-value environment overrides - Isolate local sessions with `local.` prefix so `pici monitor` daemons ignore local dev runs - Auto-detect git commit SHA and branch for attestation.json and event metadata - Rsync build artifacts directly into a `<job_id>` subfolder at target destination - Add onboarding guide when pico.sh is missing and root `pici help` overview
2 files changed,
+500,
-36
M
main.go
M
main.go
+396,
-32
1@@ -45,6 +45,17 @@ func defaultWorkspaceFactory(cfg *Cfg, logger *slog.Logger, source string) Works
2 }
3 }
4
5+type envList []string
6+
7+func (e *envList) String() string {
8+ return strings.Join(*e, ", ")
9+}
10+
11+func (e *envList) Set(value string) error {
12+ *e = append(*e, value)
13+ return nil
14+}
15+
16 type Cfg struct {
17 Logger *slog.Logger
18 Ctx context.Context
19@@ -61,6 +72,7 @@ type Cfg struct {
20 IncludeRunning bool // emit running status updates in addition to terminal
21 HumanOutput bool // human-readable output instead of JSONL / slog
22 Wait bool // block until job completes, print history and summary
23+ EnvVars envList // custom environment variables passed via -e / -env
24 }
25
26 type Event struct {
27@@ -80,6 +92,7 @@ func NewCfg() (*Cfg, string, bool) {
28 var monitorInterval time.Duration
29 var gcInterval time.Duration
30 var logLevel string
31+ var envVars envList
32 flag.StringVar(&keyLoc, "pk", "", "ssh private key used to authenticate with pico services")
33 flag.StringVar(&certLoc, "ck", "", "ssh certificate public key used to authenticate with pico services (only required if using ssh certificates)")
34 flag.StringVar(&artifactDir, "artifact-dir", "/tmp/pici-artifacts", "local directory to stage artifacts")
35@@ -87,6 +100,8 @@ func NewCfg() (*Cfg, string, bool) {
36 flag.DurationVar(&monitorInterval, "monitor-interval", 5*time.Second, "interval for monitoring zmx sessions")
37 flag.DurationVar(&gcInterval, "gc-interval", 10*time.Minute, "interval for garbage collection in monitor (0 to disable)")
38 flag.StringVar(&logLevel, "log-level", "info", "log level: debug, info, warn, error")
39+ flag.Var(&envVars, "e", "environment variable in KEY=VAL format (can be specified multiple times)")
40+ flag.Var(&envVars, "env", "environment variable in KEY=VAL format (can be specified multiple times)")
41 var includeRunning bool
42 var human bool
43 var wait bool
44@@ -118,20 +133,30 @@ func NewCfg() (*Cfg, string, bool) {
45 IncludeRunning: includeRunning,
46 HumanOutput: human,
47 Wait: wait,
48+ EnvVars: envVars,
49 }, cmd, wantHelp
50 }
51
52+func isKnownSubcommand(s string) bool {
53+ switch s {
54+ case "runner", "monitor", "cancel", "gc", "status", "orca", "run", "help":
55+ return true
56+ default:
57+ return false
58+ }
59+}
60+
61 // splitCommand separates the first non-flag argument (the subcommand) from
62 // the rest of the flags, so "runner --wait" becomes flags=["--wait"], cmd="runner".
63 // It strips --help/help so we can print custom help per subcommand.
64 func splitCommand(args []string) (flags []string, cmd string, wantHelp bool) {
65 flags = make([]string, 0, len(args))
66 for _, arg := range args {
67- if arg == "--help" || arg == "help" {
68+ if arg == "--help" || arg == "help" || arg == "-h" {
69 wantHelp = true
70 continue
71 }
72- if cmd == "" && arg != "" && !strings.HasPrefix(arg, "-") {
73+ if cmd == "" && isKnownSubcommand(arg) {
74 cmd = arg
75 } else {
76 flags = append(flags, arg)
77@@ -160,17 +185,67 @@ func newLogger(space string, levelStr string) *slog.Logger {
78 })).With("service", space)
79 }
80
81+func printMainHelp() {
82+ fmt.Println(`pici ā minimal parallel CI runner & monitor powered by zmx
83+
84+LOCAL DEVELOPER USAGE
85+ pici [destination] [flags] Run ./pico.sh locally in /tmp & render HTML logs
86+ pici pgs.sh:/my-site Run locally and rsync HTML logs to destination
87+ pici run [destination] Explicit alias for local run
88+
89+DAEMON & SERVICE COMMANDS
90+ pici runner Execute CI job from event JSON payload (stdin/flag)
91+ pici monitor Poll ci.* zmx sessions & stage/sync HTML artifacts
92+ pici cancel Cancel active running jobs for a repository
93+ pici gc Clean up stale/finished zmx sessions & artifacts
94+
95+SUBCOMMAND HELP
96+ pici <command> --help Show detailed help for a specific command (e.g. pici runner --help)
97+
98+FLAGS
99+ -e, -env <KEY=VAL> Set or override environment variable for pico.sh
100+ -pk <path> SSH private key
101+ -ck <path> SSH public certificate key
102+ -artifact-dir <path> Artifact staging directory (default: /tmp/pici-artifacts)
103+ -log-level <level> Log level: debug, info, warn, error`)
104+}
105+
106+func printMissingPicoHelp(cwd string) {
107+ fmt.Printf(`ā Error: no pico.sh found in %s
108+
109+To run local CI tasks, create a pico.sh script in your project root:
110+
111+ #!/usr/bin/env bash
112+ set -euo pipefail
113+
114+ # Run parallel steps using zmx
115+ zmx run lint -d <your lint command>
116+ zmx run test -d <your test command>
117+
118+ # Wait for all steps to finish
119+ zmx wait "*"
120+ printf "\x1b[32msuccess!\x1b[0m\n"
121+
122+For full command documentation and daemon options, run:
123+ pici help
124+
125+`, cwd)
126+}
127+
128 func main() {
129 cfg, cmd, wantHelp := NewCfg()
130
131 cfg.Logger.Debug("setting up ci", "cfg", cfg)
132 cfg.Logger.Debug("running cmd", "cmd", cmd)
133
134- if wantHelp && (cmd == "runner" || cmd == "monitor") {
135- if cmd == "runner" {
136+ if wantHelp {
137+ switch cmd {
138+ case "runner":
139 printRunnerHelp()
140- } else {
141+ case "monitor":
142 printMonitorHelp()
143+ default:
144+ printMainHelp()
145 }
146 return
147 }
148@@ -204,9 +279,23 @@ func main() {
149 cfg.Logger.Debug("starting status updater")
150 case "orca":
151 cfg.Logger.Debug("starting orchestrator")
152+ case "help":
153+ printMainHelp()
154+ case "run", "":
155+ dest := ""
156+ if flag.NArg() > 0 {
157+ dest = flag.Arg(0)
158+ }
159+ if err := runLocal(cfg, dest); err != nil {
160+ cfg.Logger.Error("local run failed", "err", err)
161+ os.Exit(1)
162+ }
163 default:
164- cfg.Logger.Error("must provide command: runner, cancel, gc, monitor, status, or orca")
165- os.Exit(1)
166+ dest := cmd
167+ if err := runLocal(cfg, dest); err != nil {
168+ cfg.Logger.Error("local run failed", "err", err)
169+ os.Exit(1)
170+ }
171 }
172 }
173
174@@ -362,7 +451,9 @@ func (w *WorkspaceRsync) Setup() error {
175 }
176
177 func (w *WorkspaceRsync) Cleanup() error {
178- // return os.RemoveAll(w.Dest)
179+ if w.Dest != "" {
180+ return os.RemoveAll(w.Dest)
181+ }
182 return nil
183 }
184
185@@ -522,9 +613,13 @@ func (eng *JobEngine) Setup() error {
186 }
187
188 func (eng *JobEngine) Run(manifest string) error {
189- prefix := fmt.Sprintf("ci.%s.%s.", eng.Ev.Name, eng.JobID)
190+ domain := "ci"
191+ if eng.Ev != nil && eng.Ev.Type == "local" {
192+ domain = "local"
193+ }
194+ prefix := fmt.Sprintf("%s.%s.%s.", domain, eng.Ev.Name, eng.JobID)
195 // Child sessions use ".step." sub-prefix so zmx wait "*" inside pico.sh
196- // matches ci.<name>.<jobID>.step.* but NOT ci.<name>.<jobID>.runner.
197+ // matches <domain>.<name>.<jobID>.step.* but NOT <domain>.<name>.<jobID>.runner.
198 // This avoids a deadlock where the runner waits for itself.
199 childPrefix := prefix + "step."
200
201@@ -551,9 +646,8 @@ func (eng *JobEngine) Run(manifest string) error {
202 if eng.Ev.Tag != "" {
203 cmdEnv = append(cmdEnv, fmt.Sprintf("PICI_TAG=%s", eng.Ev.Tag))
204 }
205-
206- zmxPrefixStr := fmt.Sprintf("ZMX_SESSION_PREFIX=%s", childPrefix)
207- cmd := exec.Command("zmx", "run", runnerName, "-d", zmxPrefixStr, "bash", manifest)
208+ bashCmd := fmt.Sprintf("export ZMX_SESSION_PREFIX=%q; exec bash %q", childPrefix, manifest)
209+ cmd := exec.Command("zmx", "run", runnerName, "-d", "bash", "-c", bashCmd)
210 cmd.Env = cmdEnv
211 cmd.Dir = eng.Wk.GetDir()
212
213@@ -610,14 +704,18 @@ func eventHandler(cfg *Cfg, eventData *Event) error {
214 Ev: eventData,
215 JobID: jobID,
216 }
217+ var runErr error
218 defer func() {
219- if err := eng.Cleanup(); err != nil {
220- cfg.Logger.Error("engine cleanup", "err", err)
221+ if runErr != nil || cfg.Wait {
222+ if err := eng.Cleanup(); err != nil {
223+ cfg.Logger.Error("engine cleanup", "err", err)
224+ }
225 }
226 }()
227
228 fmt.Fprintf(os.Stdout, "š¦ syncing workspace %s\n", eventData.Workspace) //nolint:errcheck
229 if err := eng.Setup(); err != nil {
230+ runErr = err
231 return fmt.Errorf("setup: %w", err)
232 }
233 fmt.Fprintf(os.Stdout, "ā
workspace ready %s\n", eng.Wk.GetDir()) //nolint:errcheck
234@@ -654,6 +752,7 @@ func eventHandler(cfg *Cfg, eventData *Event) error {
235
236 manifest, err := eng.FindManifest()
237 if err != nil {
238+ runErr = err
239 fmt.Fprintf(os.Stdout, "ā %s\n\n", err) //nolint:errcheck
240 //nolint:errcheck
241 fmt.Fprint(os.Stdout, `Create a pico.sh script in your workspace root:
242@@ -686,13 +785,15 @@ See: https://github.com/picosh/pici
243
244 fmt.Fprint(os.Stdout, "š launching sessions...\n") //nolint:errcheck
245 if err := eng.Run(manifest); err != nil {
246+ runErr = err
247 return fmt.Errorf("run: %w", err)
248 }
249
250 fmt.Fprintln(os.Stdout, "ā
job launched") //nolint:errcheck
251
252 if cfg.Wait {
253- if err := waitAndReport(cfg, log, eventData.Name, jobID); err != nil {
254+ if err := waitAndReport(cfg, log, eventData.Name, jobID, eventData.Type); err != nil {
255+ runErr = err
256 return fmt.Errorf("wait: %w", err)
257 }
258 return nil
259@@ -707,9 +808,17 @@ See: https://github.com/picosh/pici
260
261 // waitAndReport polls the job's sessions until all complete, prints live
262 // progress to stdout, then dumps session history and a final summary.
263-func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID string) error {
264- prefix := "ci." + name + "." + jobID + "."
265- ticker := time.NewTicker(cfg.MonitorInterval)
266+func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) error {
267+ domain := "ci"
268+ if eventType == "local" {
269+ domain = "local"
270+ }
271+ prefix := domain + "." + name + "." + jobID + "."
272+ interval := cfg.MonitorInterval
273+ if interval <= 0 {
274+ interval = 5 * time.Second
275+ }
276+ ticker := time.NewTicker(interval)
277 defer ticker.Stop()
278
279 // Handle ^C gracefully
280@@ -726,9 +835,16 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID string) error {
281 var sessionOrder []string // track insertion order for deterministic output
282 var liveLines []string // last set of status lines printed (for overwrite)
283
284+ var done <-chan struct{}
285+ if cfg.Ctx != nil {
286+ done = cfg.Ctx.Done()
287+ }
288+
289 for {
290 select {
291- case <-cfg.Ctx.Done():
292+ case <-done:
293+ cancelJobSessions(prefix)
294+ fmt.Fprintln(os.Stdout, "\nā¹ job cancelled") //nolint:errcheck
295 return cfg.Ctx.Err()
296 case <-sigCh:
297 fmt.Fprintln(os.Stdout, "\nā¹ cancelled") //nolint:errcheck
298@@ -876,9 +992,11 @@ func cleanSessionShort(name, prefix, repoName, jobID string) string {
299 short := strings.TrimPrefix(name, prefix)
300 // Strip "step." prefix added by child sessions
301 short = strings.TrimPrefix(short, "step.")
302- // Strip nested full prefix (e.g. runner named ci.name.jobID.runner)
303- nested := "ci." + repoName + "." + jobID + "."
304- short = strings.TrimPrefix(short, nested)
305+ // Strip nested full prefix (e.g. runner named ci.name.jobID.runner or local.name.jobID.runner)
306+ nestedCI := "ci." + repoName + "." + jobID + "."
307+ short = strings.TrimPrefix(short, nestedCI)
308+ nestedLocal := "local." + repoName + "." + jobID + "."
309+ short = strings.TrimPrefix(short, nestedLocal)
310 return short
311 }
312
313@@ -917,10 +1035,11 @@ func runMonitor(cfg *Cfg) error {
314 defer ticker.Stop()
315
316 // Optional GC ticker ā runs garbage collection on a separate interval.
317- var gcTicker *time.Ticker
318+ var gcChan <-chan time.Time
319 if cfg.GCInterval > 0 {
320- gcTicker = time.NewTicker(cfg.GCInterval)
321+ gcTicker := time.NewTicker(cfg.GCInterval)
322 defer gcTicker.Stop()
323+ gcChan = gcTicker.C
324 }
325
326 // Track per-job display state across ticks (for human output)
327@@ -938,7 +1057,7 @@ func runMonitor(cfg *Cfg) error {
328 if err := monitorTick(cfg, log, output, jobStates); err != nil {
329 log.Error("monitor tick", "err", err)
330 }
331- case <-gcTicker.C:
332+ case <-gcChan:
333 log.Debug("running periodic garbage collection")
334 if err := runGC(cfg); err != nil {
335 log.Error("periodic gc", "err", err)
336@@ -1560,21 +1679,21 @@ func syncJobArtifacts(cfg *Cfg, repoName, jobID string, log *slog.Logger) error
337 }
338 sshArgs = fmt.Sprintf("-F ~/.ssh/config -i %s%s", cfg.KeyLocation, certFile)
339 }
340- // Append "/" so rsync copies into the destination directory,
341- // not as a subdirectory named after the source.
342+ // Source is jobDir (without trailing slash) so rsync creates
343+ // the jobID subdirectory inside the destination folder.
344 dest := event.ArtifactDest
345 if !strings.HasSuffix(dest, "/") {
346 dest += "/"
347 }
348 var cmd *exec.Cmd
349 if sshArgs != "" {
350- cmd = exec.Command("rsync", "-e", sshArgs, "-rv", jobDir+"/", dest)
351+ cmd = exec.Command("rsync", "-e", sshArgs, "-rv", jobDir, dest)
352 } else {
353- cmd = exec.Command("rsync", "-rv", jobDir+"/", dest)
354+ cmd = exec.Command("rsync", "-rv", jobDir, dest)
355 }
356 rsyncCmd := fmt.Sprintf("rsync %s %s %s",
357 strings.TrimLeft(cmd.Args[1], "-"),
358- jobDir+"/", dest)
359+ jobDir, dest)
360 log.Info("rsync", "cmd", rsyncCmd)
361 return runCmd(cmd, log)
362 }
363@@ -1730,7 +1849,7 @@ func runGC(cfg *Cfg) error {
364
365 var toKill []string
366 for _, s := range sessions {
367- if !strings.HasPrefix(s.Name, "ci.") {
368+ if !strings.HasPrefix(s.Name, "ci.") && !strings.HasPrefix(s.Name, "local.") {
369 continue
370 }
371
372@@ -1951,3 +2070,248 @@ func fmtDuration(created, ended string) string {
373 }
374 return fmt.Sprintf("%.1fs", secs)
375 }
376+
377+func runLocal(cfg *Cfg, dest string) error {
378+ cwd, err := os.Getwd()
379+ if err != nil {
380+ return fmt.Errorf("get working directory: %w", err)
381+ }
382+
383+ picoPath := filepath.Join(cwd, "pico.sh")
384+ if _, err := os.Stat(picoPath); os.IsNotExist(err) {
385+ printMissingPicoHelp(cwd)
386+ return fmt.Errorf("no pico.sh found in %s", cwd)
387+ }
388+
389+ repoName := filepath.Base(cwd)
390+ jobID := fmt.Sprintf("local-%d", time.Now().Unix())
391+
392+ eventData := &Event{
393+ Type: "local",
394+ Name: repoName,
395+ JobID: jobID,
396+ Workspace: cwd,
397+ ArtifactDest: dest,
398+ }
399+
400+ // Process -e / --env flags
401+ for _, envPair := range cfg.EnvVars {
402+ parts := strings.SplitN(envPair, "=", 2)
403+ val := ""
404+ if len(parts) == 2 {
405+ val = parts[1]
406+ }
407+ key := parts[0]
408+ switch key {
409+ case "PICI_REPO":
410+ eventData.Name = val
411+ case "PICI_JOB":
412+ eventData.JobID = val
413+ case "PICI_EVENT":
414+ eventData.Type = val
415+ case "PICI_BRANCH":
416+ eventData.Branch = val
417+ case "PICI_COMMIT":
418+ eventData.Commit = val
419+ case "PICI_TAG":
420+ eventData.Tag = val
421+ }
422+ _ = os.Setenv(key, val)
423+ }
424+
425+ // Auto-detect git commit SHA and branch if not explicitly provided via -e
426+ if eventData.Commit == "" {
427+ eventData.Commit = detectGitCommit(cwd)
428+ }
429+ if eventData.Branch == "" {
430+ eventData.Branch = detectGitBranch(cwd)
431+ }
432+
433+ // Always block and output human format for local runs
434+ cfg.Wait = true
435+ cfg.HumanOutput = true
436+
437+ logger := cfg.Logger
438+ if logger == nil {
439+ logger = newLogger("ci", "info")
440+ }
441+ log := logger.With("repo", eventData.Name, "job_id", jobID, "mode", "local")
442+
443+ // Set up workspace in temp directory
444+ wk := &WorkspaceRsync{
445+ Cfg: cfg,
446+ Logger: log,
447+ Source: cwd,
448+ }
449+
450+ eng := &JobEngine{
451+ Logger: log,
452+ Cfg: cfg,
453+ Wk: wk,
454+ Ev: eventData,
455+ JobID: jobID,
456+ }
457+
458+ defer func() {
459+ if err := eng.Cleanup(); err != nil {
460+ log.Error("cleanup workspace", "err", err)
461+ }
462+ }()
463+
464+ fmt.Fprintf(os.Stdout, "š starting local job local.%s.%s\n", eventData.Name, jobID) //nolint:errcheck
465+ fmt.Fprintln(os.Stdout, "š¦ syncing workspace to temp directory...") //nolint:errcheck
466+ if err := eng.Setup(); err != nil {
467+ return fmt.Errorf("workspace setup: %w", err)
468+ }
469+ log.Debug("workspace directory", "dir", eng.Wk.GetDir())
470+
471+ // Store event.json in artifact dir
472+ eventDir := filepath.Join(cfg.ArtifactDir, eventData.Name, jobID)
473+ artifactsDir := filepath.Join(eventDir, "artifacts")
474+ if err := os.MkdirAll(artifactsDir, 0755); err != nil {
475+ log.Error("create artifacts dir", "err", err)
476+ } else {
477+ eventBytes, _ := json.Marshal(eventData)
478+ _ = os.WriteFile(filepath.Join(artifactsDir, "event.json"), eventBytes, 0644)
479+ }
480+
481+ // Write attestation.json
482+ hostname, _ := os.Hostname()
483+ attestation := map[string]interface{}{
484+ "runner": map[string]string{
485+ "hostname": hostname,
486+ "os": runtimeOS(),
487+ "arch": runtimeArch(),
488+ },
489+ "provenance": map[string]string{
490+ "repo": eventData.Name,
491+ "branch": eventData.Branch,
492+ "commit": eventData.Commit,
493+ },
494+ "workspace_checksum": eng.Wk.Checksum(),
495+ }
496+ attestationBytes, _ := json.Marshal(attestation)
497+ _ = os.WriteFile(filepath.Join(artifactsDir, "attestation.json"), attestationBytes, 0644)
498+
499+ manifest, err := eng.FindManifest()
500+ if err != nil {
501+ return err
502+ }
503+
504+ fmt.Fprintln(os.Stdout, "š launching sessions...") //nolint:errcheck
505+ if err := eng.Run(manifest); err != nil {
506+ return fmt.Errorf("run: %w", err)
507+ }
508+
509+ // Wait for completion & print live progress
510+ if err := waitAndReport(cfg, log, eventData.Name, jobID, eventData.Type); err != nil {
511+ return fmt.Errorf("wait: %w", err)
512+ }
513+
514+ // Generate and stage full HTML/txt artifacts and index
515+ if err := stageLocalArtifacts(cfg, log, eventData.Name, jobID); err != nil {
516+ log.Error("stage local artifacts", "err", err)
517+ }
518+
519+ indexFile := filepath.Join(cfg.ArtifactDir, eventData.Name, jobID, "index.html")
520+ fmt.Fprintf(os.Stdout, "š local html report: file://%s\n", indexFile) //nolint:errcheck
521+
522+ // Sync to destination if specified
523+ if dest != "" {
524+ targetDest := strings.TrimSuffix(dest, "/") + "/" + jobID
525+ fmt.Fprintf(os.Stdout, "š rsyncing artifacts to %s...\n", dest) //nolint:errcheck
526+ if err := syncJobArtifacts(cfg, eventData.Name, jobID, log); err != nil {
527+ return fmt.Errorf("sync artifacts: %w", err)
528+ }
529+ fmt.Fprintf(os.Stdout, "ā
artifacts rsynced to %s\n", targetDest) //nolint:errcheck
530+ }
531+
532+ return nil
533+}
534+
535+func stageLocalArtifacts(cfg *Cfg, log *slog.Logger, repoName, jobID string) error {
536+ listOutput, err := exec.Command("zmx", "list").CombinedOutput()
537+ if err != nil {
538+ return fmt.Errorf("zmx list: %w", err)
539+ }
540+ sessions := parseZMXList(string(listOutput))
541+ var localSessions []SessionInfo
542+ for _, s := range sessions {
543+ if strings.HasPrefix(s.Name, "local.") {
544+ localSessions = append(localSessions, s)
545+ }
546+ }
547+ prefix := fmt.Sprintf("local.%s.%s.", repoName, jobID)
548+
549+ var jobSessions []SessionInfo
550+ for _, s := range localSessions {
551+ if strings.HasPrefix(s.Name, prefix) {
552+ s.Short = cleanSessionShort(s.Name, prefix, repoName, jobID)
553+ jobSessions = append(jobSessions, s)
554+ }
555+ }
556+
557+ for _, s := range jobSessions {
558+ sessionStatus := "running"
559+ sessionDuration := fmtDuration(s.Created, fmt.Sprintf("%d", time.Now().Unix()))
560+ sessionExitCode := ""
561+ if s.Ended != "" {
562+ sessionDuration = fmtDuration(s.Created, s.Ended)
563+ if s.ExitCode == "0" {
564+ sessionStatus = "success"
565+ sessionExitCode = "0"
566+ } else {
567+ sessionStatus = "failed"
568+ sessionExitCode = s.ExitCode
569+ }
570+ }
571+
572+ html, err := fetchHistoryHTML(s.Name, repoName, jobID, sessionStatus, sessionDuration, sessionExitCode)
573+ if err == nil {
574+ _ = stageArtifact(cfg.ArtifactDir, repoName, jobID, s.Short, html, ".html")
575+ }
576+ plain, err := fetchHistoryPlain(s.Name)
577+ if err == nil {
578+ _ = stageArtifact(cfg.ArtifactDir, repoName, jobID, s.Short, plain, ".txt")
579+ }
580+ }
581+
582+ // Write published sentinel
583+ exitCode, status := resolveJobExitCode(jobSessions)
584+ sentinel := filepath.Join(cfg.ArtifactDir, repoName, jobID, "artifacts", "published.json")
585+ published := map[string]interface{}{
586+ "status": status,
587+ "exit_code": exitCode,
588+ "job_id": jobID,
589+ "finished_at": time.Now().UTC().Format(time.RFC3339),
590+ }
591+ publishedJSON, _ := json.Marshal(published)
592+ _ = os.WriteFile(sentinel, publishedJSON, 0644)
593+
594+ indexHTML, indexTXT := generateJobIndex(cfg.ArtifactDir, repoName, jobID, jobSessions)
595+ _ = stageArtifact(cfg.ArtifactDir, repoName, jobID, "index", indexHTML, ".html")
596+ _ = stageArtifact(cfg.ArtifactDir, repoName, jobID, "index", indexTXT, ".txt")
597+ if styles, err := loadStyles(); err == nil {
598+ _ = stageArtifact(cfg.ArtifactDir, repoName, jobID, "styles", styles, ".css")
599+ }
600+
601+ return nil
602+}
603+
604+func detectGitCommit(dir string) string {
605+ cmd := exec.Command("git", "-C", dir, "rev-parse", "HEAD")
606+ out, err := cmd.Output()
607+ if err != nil {
608+ return ""
609+ }
610+ return strings.TrimSpace(string(out))
611+}
612+
613+func detectGitBranch(dir string) string {
614+ cmd := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD")
615+ out, err := cmd.Output()
616+ if err != nil || strings.TrimSpace(string(out)) == "HEAD" {
617+ return ""
618+ }
619+ return strings.TrimSpace(string(out))
620+}
+104,
-4
1@@ -25,17 +25,20 @@ func TestE2E_RunnerWithZMXSessions(t *testing.T) {
2 if testing.Short() {
3 t.Skip("skip integration test")
4 }
5- if _, err := exec.LookPath("zmx"); err != nil {
6+ zmxPath, err := exec.LookPath("zmx")
7+ if err != nil {
8 t.Skip("zmx not found, skipping integration test")
9 }
10+ zmxDir := filepath.Dir(zmxPath)
11
12 // 1. Create workspace with pico.sh that spawns zmx sessions
13 workspaceDir := t.TempDir()
14- picoSh := `#!/usr/bin/env bash
15+ picoSh := fmt.Sprintf(`#!/usr/bin/env bash
16 set -e
17+export PATH="%s:$PATH"
18 zmx run step1 echo "hello from step1"
19 zmx run step2 echo "hello from step2"
20-`
21+`, zmxDir)
22 if err := os.WriteFile(filepath.Join(workspaceDir, "pico.sh"), []byte(picoSh), 0755); err != nil {
23 t.Fatalf("write pico.sh: %v", err)
24 }
25@@ -43,9 +46,14 @@ zmx run step2 echo "hello from step2"
26 // 2. Create config
27 artifactDir := t.TempDir()
28 ctx, cancel := context.WithCancel(context.Background())
29+ testJobID := fmt.Sprintf("j-%d", time.Now().UnixNano()%100000000)
30+ t.Cleanup(func() {
31+ _ = exec.Command("zmx", "kill", "-f", fmt.Sprintf("ci.test-repo.%s", testJobID)).Run()
32+ })
33 event := Event{
34 Type: "build",
35 Name: "test-repo",
36+ JobID: testJobID,
37 Workspace: workspaceDir,
38 }
39 eventJSON, _ := json.Marshal(event)
40@@ -109,7 +117,7 @@ zmx run step2 echo "hello from step2"
41 }
42
43 // Ignore statuses from unrelated jobs (e.g., leftover sessions from previous tests)
44- if p.Name != "test-repo" {
45+ if p.Name != "test-repo" || p.JobID != testJobID {
46 continue
47 }
48
49@@ -690,3 +698,95 @@ func TestResolveJobExitCode(t *testing.T) {
50 })
51 }
52 }
53+
54+func TestRunLocal_MissingPico(t *testing.T) {
55+ tempDir := t.TempDir()
56+ origWd, _ := os.Getwd()
57+ defer func() { _ = os.Chdir(origWd) }()
58+ _ = os.Chdir(tempDir)
59+
60+ cfg := &Cfg{
61+ ArtifactDir: t.TempDir(),
62+ }
63+ err := runLocal(cfg, "")
64+ if err == nil {
65+ t.Fatal("expected error when pico.sh is missing")
66+ }
67+}
68+
69+func TestRunLocal_EnvOverrides(t *testing.T) {
70+ tempDir := t.TempDir()
71+ origWd, _ := os.Getwd()
72+ defer func() { _ = os.Chdir(origWd) }()
73+ _ = os.Chdir(tempDir)
74+
75+ picoContent := `#!/usr/bin/env bash
76+echo "hello from pico"
77+`
78+ if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
79+ t.Fatal(err)
80+ }
81+
82+ cfg := &Cfg{
83+ ArtifactDir: t.TempDir(),
84+ EnvVars: envList{"PICI_REPO=custom-repo", "CUSTOM_VAR=hello"},
85+ }
86+
87+ _ = runLocal(cfg, "")
88+
89+ if os.Getenv("CUSTOM_VAR") != "hello" {
90+ t.Errorf("expected CUSTOM_VAR to be hello, got %q", os.Getenv("CUSTOM_VAR"))
91+ }
92+}
93+
94+func TestDetectGitCommit(t *testing.T) {
95+ cwd, err := os.Getwd()
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ commit := detectGitCommit(cwd)
100+ if commit == "" {
101+ t.Log("git commit sha not detected (not a git repository or git unavailable)")
102+ } else {
103+ t.Logf("detected git commit sha: %s", commit)
104+ }
105+}
106+
107+func TestWaitAndReport_Cancellation(t *testing.T) {
108+ ctx, cancel := context.WithCancel(context.Background())
109+ cfg := &Cfg{
110+ Ctx: ctx,
111+ Cancel: cancel,
112+ MonitorInterval: 100 * time.Millisecond,
113+ }
114+
115+ jobID := fmt.Sprintf("canceltest-%d", time.Now().UnixNano())
116+ prefix := "local.testrepo." + jobID + "."
117+ runnerSession := prefix + "runner"
118+
119+ cmd := exec.Command("zmx", "run", runnerSession, "-d", "sleep", "30")
120+ if err := cmd.Run(); err != nil {
121+ t.Fatalf("failed to start zmx session: %v", err)
122+ }
123+
124+ go func() {
125+ time.Sleep(200 * time.Millisecond)
126+ cancel()
127+ }()
128+
129+ err := waitAndReport(cfg, nil, "testrepo", jobID, "local")
130+ if err == nil {
131+ t.Error("expected error when waitAndReport is cancelled, got nil")
132+ }
133+
134+ time.Sleep(200 * time.Millisecond)
135+ listOutput, _ := exec.Command("zmx", "list").CombinedOutput()
136+ sessions := parseZMXList(string(listOutput))
137+ for _, s := range sessions {
138+ if s.Name == runnerSession {
139+ if s.Ended == "" {
140+ t.Errorf("session %s should have been killed on cancellation", runnerSession)
141+ }
142+ }
143+ }
144+}