pici

created pr with 132.1 on 2026-08-08T17:48:30Z · by c8ef7d19
changed status to open on 2026-08-08T17:48:54Z · by c8ef7d19
cmds
checkout latest patchset:
ssh pr.pico.sh print 132 | git am -3
checkout any patchset in a patch request:
ssh pr.pico.sh print 132.[rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 132
set PR to open (enables RSS notifications):
ssh pr.pico.sh pr open 132
set PR to draft (stops RSS notifications):
ssh pr.pico.sh pr draft 132

Patchset 132.1 on 2026-08-08T17:48:30Z · commit 0224564

- 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
Semantic diff summary
12 added, 15 modified, 1 signature changed, 0 removed across 2 analyzed files
+394 -32 main.go #
@@ -45,6 +45,17 @@ func defaultWorkspaceFactory(cfg *Cfg, logger *slog.Logger, source string) Works
 	}
 }
 
+type envList []string
+
+func (e *envList) String() string {
+	return strings.Join(*e, ", ")
+}
+
+func (e *envList) Set(value string) error {
+	*e = append(*e, value)
+	return nil
+}
+
 type Cfg struct {
 	Logger              *slog.Logger
 	Ctx                 context.Context
@@ -61,6 +72,7 @@ type Cfg struct {
 	IncludeRunning      bool      // emit running status updates in addition to terminal
 	HumanOutput         bool      // human-readable output instead of JSONL / slog
 	Wait                bool      // block until job completes, print history and summary
+	EnvVars             envList   // custom environment variables passed via -e / -env
 }
 
 type Event struct {
@@ -80,6 +92,7 @@ func NewCfg() (*Cfg, string, bool) {
 	var monitorInterval time.Duration
 	var gcInterval time.Duration
 	var logLevel string
+	var envVars envList
 	flag.StringVar(&keyLoc, "pk", "", "ssh private key used to authenticate with pico services")
 	flag.StringVar(&certLoc, "ck", "", "ssh certificate public key used to authenticate with pico services (only required if using ssh certificates)")
 	flag.StringVar(&artifactDir, "artifact-dir", "/tmp/pici-artifacts", "local directory to stage artifacts")
@@ -87,6 +100,8 @@ func NewCfg() (*Cfg, string, bool) {
 	flag.DurationVar(&monitorInterval, "monitor-interval", 5*time.Second, "interval for monitoring zmx sessions")
 	flag.DurationVar(&gcInterval, "gc-interval", 10*time.Minute, "interval for garbage collection in monitor (0 to disable)")
 	flag.StringVar(&logLevel, "log-level", "info", "log level: debug, info, warn, error")
+	flag.Var(&envVars, "e", "environment variable in KEY=VAL format (can be specified multiple times)")
+	flag.Var(&envVars, "env", "environment variable in KEY=VAL format (can be specified multiple times)")
 	var includeRunning bool
 	var human bool
 	var wait bool
@@ -118,20 +133,30 @@ func NewCfg() (*Cfg, string, bool) {
 		IncludeRunning:      includeRunning,
 		HumanOutput:         human,
 		Wait:                wait,
+		EnvVars:             envVars,
 	}, cmd, wantHelp
 }
 
+func isKnownSubcommand(s string) bool {
+	switch s {
+	case "runner", "monitor", "cancel", "gc", "status", "orca", "run", "help":
+		return true
+	default:
+		return false
+	}
+}
+
 // splitCommand separates the first non-flag argument (the subcommand) from
 // the rest of the flags, so "runner --wait" becomes flags=["--wait"], cmd="runner".
 // It strips --help/help so we can print custom help per subcommand.
 func splitCommand(args []string) (flags []string, cmd string, wantHelp bool) {
 	flags = make([]string, 0, len(args))
 	for _, arg := range args {
-		if arg == "--help" || arg == "help" {
+		if arg == "--help" || arg == "help" || arg == "-h" {
 			wantHelp = true
 			continue
 		}
-		if cmd == "" && arg != "" && !strings.HasPrefix(arg, "-") {
+		if cmd == "" && isKnownSubcommand(arg) {
 			cmd = arg
 		} else {
 			flags = append(flags, arg)
@@ -160,17 +185,67 @@ func newLogger(space string, levelStr string) *slog.Logger {
 	})).With("service", space)
 }
 
+func printMainHelp() {
+	fmt.Println(`pici — minimal parallel CI runner & monitor powered by zmx
+
+LOCAL DEVELOPER USAGE
+  pici [destination] [flags]                  Run ./pico.sh locally in /tmp & render HTML logs
+  pici pgs.sh:/my-site                        Run locally and rsync HTML logs to destination
+  pici run [destination]                      Explicit alias for local run
+
+DAEMON & SERVICE COMMANDS
+  pici runner                                 Execute CI job from event JSON payload (stdin/flag)
+  pici monitor                                Poll ci.* zmx sessions & stage/sync HTML artifacts
+  pici cancel                                 Cancel active running jobs for a repository
+  pici gc                                     Clean up stale/finished zmx sessions & artifacts
+
+SUBCOMMAND HELP
+  pici <command> --help                       Show detailed help for a specific command (e.g. pici runner --help)
+
+FLAGS
+  -e, -env <KEY=VAL>                          Set or override environment variable for pico.sh
+  -pk <path>                                  SSH private key
+  -ck <path>                                  SSH public certificate key
+  -artifact-dir <path>                        Artifact staging directory (default: /tmp/pici-artifacts)
+  -log-level <level>                          Log level: debug, info, warn, error`)
+}
+
+func printMissingPicoHelp(cwd string) {
+	fmt.Printf(`āŒ Error: no pico.sh found in %s
+
+To run local CI tasks, create a pico.sh script in your project root:
+
+  #!/usr/bin/env bash
+  set -euo pipefail
+
+  # Run parallel steps using zmx
+  zmx run lint -d <your lint command>
+  zmx run test -d <your test command>
+
+  # Wait for all steps to finish
+  zmx wait "*"
+  printf "\x1b[32msuccess!\x1b[0m\n"
+
+For full command documentation and daemon options, run:
+  pici help
+
+`, cwd)
+}
+
 func main() {
 	cfg, cmd, wantHelp := NewCfg()
 
 	cfg.Logger.Debug("setting up ci", "cfg", cfg)
 	cfg.Logger.Debug("running cmd", "cmd", cmd)
 
-	if wantHelp && (cmd == "runner" || cmd == "monitor") {
-		if cmd == "runner" {
+	if wantHelp {
+		switch cmd {
+		case "runner":
 			printRunnerHelp()
-		} else {
+		case "monitor":
 			printMonitorHelp()
+		default:
+			printMainHelp()
 		}
 		return
 	}
@@ -204,9 +279,23 @@ func main() {
 		cfg.Logger.Debug("starting status updater")
 	case "orca":
 		cfg.Logger.Debug("starting orchestrator")
+	case "help":
+		printMainHelp()
+	case "run", "":
+		dest := ""
+		if flag.NArg() > 0 {
+			dest = flag.Arg(0)
+		}
+		if err := runLocal(cfg, dest); err != nil {
+			cfg.Logger.Error("local run failed", "err", err)
+			os.Exit(1)
+		}
 	default:
-		cfg.Logger.Error("must provide command: runner, cancel, gc, monitor, status, or orca")
-		os.Exit(1)
+		dest := cmd
+		if err := runLocal(cfg, dest); err != nil {
+			cfg.Logger.Error("local run failed", "err", err)
+			os.Exit(1)
+		}
 	}
 }
 
@@ -362,7 +451,9 @@ func (w *WorkspaceRsync) Setup() error {
 }
 
 func (w *WorkspaceRsync) Cleanup() error {
-	// return os.RemoveAll(w.Dest)
+	if w.Dest != "" {
+		return os.RemoveAll(w.Dest)
+	}
 	return nil
 }
 
@@ -522,9 +613,13 @@ func (eng *JobEngine) Setup() error {
 }
 
 func (eng *JobEngine) Run(manifest string) error {
-	prefix := fmt.Sprintf("ci.%s.%s.", eng.Ev.Name, eng.JobID)
+	domain := "ci"
+	if eng.Ev != nil && eng.Ev.Type == "local" {
+		domain = "local"
+	}
+	prefix := fmt.Sprintf("%s.%s.%s.", domain, eng.Ev.Name, eng.JobID)
 	// Child sessions use ".step." sub-prefix so zmx wait "*" inside pico.sh
-	// matches ci.<name>.<jobID>.step.* but NOT ci.<name>.<jobID>.runner.
+	// matches <domain>.<name>.<jobID>.step.* but NOT <domain>.<name>.<jobID>.runner.
 	// This avoids a deadlock where the runner waits for itself.
 	childPrefix := prefix + "step."
 
@@ -551,9 +646,8 @@ func (eng *JobEngine) Run(manifest string) error {
 	if eng.Ev.Tag != "" {
 		cmdEnv = append(cmdEnv, fmt.Sprintf("PICI_TAG=%s", eng.Ev.Tag))
 	}
-
-	zmxPrefixStr := fmt.Sprintf("ZMX_SESSION_PREFIX=%s", childPrefix)
-	cmd := exec.Command("zmx", "run", runnerName, "-d", zmxPrefixStr, "bash", manifest)
+	bashCmd := fmt.Sprintf("export ZMX_SESSION_PREFIX=%q; exec bash %q", childPrefix, manifest)
+	cmd := exec.Command("zmx", "run", runnerName, "-d", "bash", "-c", bashCmd)
 	cmd.Env = cmdEnv
 	cmd.Dir = eng.Wk.GetDir()
 
@@ -610,14 +704,18 @@ func eventHandler(cfg *Cfg, eventData *Event) error {
 		Ev:     eventData,
 		JobID:  jobID,
 	}
+	var runErr error
 	defer func() {
-		if err := eng.Cleanup(); err != nil {
-			cfg.Logger.Error("engine cleanup", "err", err)
+		if runErr != nil || cfg.Wait {
+			if err := eng.Cleanup(); err != nil {
+				cfg.Logger.Error("engine cleanup", "err", err)
+			}
 		}
 	}()
 
 	fmt.Fprintf(os.Stdout, "šŸ“¦ syncing workspace %s\n", eventData.Workspace) //nolint:errcheck
 	if err := eng.Setup(); err != nil {
+		runErr = err
 		return fmt.Errorf("setup: %w", err)
 	}
 	fmt.Fprintf(os.Stdout, "āœ… workspace ready %s\n", eng.Wk.GetDir()) //nolint:errcheck
@@ -654,6 +752,7 @@ func eventHandler(cfg *Cfg, eventData *Event) error {
 
 	manifest, err := eng.FindManifest()
 	if err != nil {
+		runErr = err
 		fmt.Fprintf(os.Stdout, "āŒ %s\n\n", err) //nolint:errcheck
 		//nolint:errcheck
 		fmt.Fprint(os.Stdout, `Create a pico.sh script in your workspace root:
@@ -686,13 +785,15 @@ See: https://github.com/picosh/pici
 
 	fmt.Fprint(os.Stdout, "šŸƒ launching sessions...\n") //nolint:errcheck
 	if err := eng.Run(manifest); err != nil {
+		runErr = err
 		return fmt.Errorf("run: %w", err)
 	}
 
 	fmt.Fprintln(os.Stdout, "āœ… job launched") //nolint:errcheck
 
 	if cfg.Wait {
-		if err := waitAndReport(cfg, log, eventData.Name, jobID); err != nil {
+		if err := waitAndReport(cfg, log, eventData.Name, jobID, eventData.Type); err != nil {
+			runErr = err
 			return fmt.Errorf("wait: %w", err)
 		}
 		return nil
@@ -707,9 +808,17 @@ See: https://github.com/picosh/pici
 
 // waitAndReport polls the job's sessions until all complete, prints live
 // progress to stdout, then dumps session history and a final summary.
-func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID string) error {
-	prefix := "ci." + name + "." + jobID + "."
-	ticker := time.NewTicker(cfg.MonitorInterval)
+func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) error {
+	domain := "ci"
+	if eventType == "local" {
+		domain = "local"
+	}
+	prefix := domain + "." + name + "." + jobID + "."
+	interval := cfg.MonitorInterval
+	if interval <= 0 {
+		interval = 5 * time.Second
+	}
+	ticker := time.NewTicker(interval)
 	defer ticker.Stop()
 
 	// Handle ^C gracefully
@@ -726,9 +835,14 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID string) error {
 	var sessionOrder []string // track insertion order for deterministic output
 	var liveLines []string    // last set of status lines printed (for overwrite)
 
+	var done <-chan struct{}
+	if cfg.Ctx != nil {
+		done = cfg.Ctx.Done()
+	}
+
 	for {
 		select {
-		case <-cfg.Ctx.Done():
+		case <-done:
 			return cfg.Ctx.Err()
 		case <-sigCh:
 			fmt.Fprintln(os.Stdout, "\nā¹ cancelled") //nolint:errcheck
@@ -876,9 +990,11 @@ func cleanSessionShort(name, prefix, repoName, jobID string) string {
 	short := strings.TrimPrefix(name, prefix)
 	// Strip "step." prefix added by child sessions
 	short = strings.TrimPrefix(short, "step.")
-	// Strip nested full prefix (e.g. runner named ci.name.jobID.runner)
-	nested := "ci." + repoName + "." + jobID + "."
-	short = strings.TrimPrefix(short, nested)
+	// Strip nested full prefix (e.g. runner named ci.name.jobID.runner or local.name.jobID.runner)
+	nestedCI := "ci." + repoName + "." + jobID + "."
+	short = strings.TrimPrefix(short, nestedCI)
+	nestedLocal := "local." + repoName + "." + jobID + "."
+	short = strings.TrimPrefix(short, nestedLocal)
 	return short
 }
 
@@ -917,10 +1033,11 @@ func runMonitor(cfg *Cfg) error {
 	defer ticker.Stop()
 
 	// Optional GC ticker — runs garbage collection on a separate interval.
-	var gcTicker *time.Ticker
+	var gcChan <-chan time.Time
 	if cfg.GCInterval > 0 {
-		gcTicker = time.NewTicker(cfg.GCInterval)
+		gcTicker := time.NewTicker(cfg.GCInterval)
 		defer gcTicker.Stop()
+		gcChan = gcTicker.C
 	}
 
 	// Track per-job display state across ticks (for human output)
@@ -938,7 +1055,7 @@ func runMonitor(cfg *Cfg) error {
 			if err := monitorTick(cfg, log, output, jobStates); err != nil {
 				log.Error("monitor tick", "err", err)
 			}
-		case <-gcTicker.C:
+		case <-gcChan:
 			log.Debug("running periodic garbage collection")
 			if err := runGC(cfg); err != nil {
 				log.Error("periodic gc", "err", err)
@@ -1560,21 +1677,21 @@ func syncJobArtifacts(cfg *Cfg, repoName, jobID string, log *slog.Logger) error
 		}
 		sshArgs = fmt.Sprintf("-F ~/.ssh/config -i %s%s", cfg.KeyLocation, certFile)
 	}
-	// Append "/" so rsync copies into the destination directory,
-	// not as a subdirectory named after the source.
+	// Source is jobDir (without trailing slash) so rsync creates
+	// the jobID subdirectory inside the destination folder.
 	dest := event.ArtifactDest
 	if !strings.HasSuffix(dest, "/") {
 		dest += "/"
 	}
 	var cmd *exec.Cmd
 	if sshArgs != "" {
-		cmd = exec.Command("rsync", "-e", sshArgs, "-rv", jobDir+"/", dest)
+		cmd = exec.Command("rsync", "-e", sshArgs, "-rv", jobDir, dest)
 	} else {
-		cmd = exec.Command("rsync", "-rv", jobDir+"/", dest)
+		cmd = exec.Command("rsync", "-rv", jobDir, dest)
 	}
 	rsyncCmd := fmt.Sprintf("rsync %s %s %s",
 		strings.TrimLeft(cmd.Args[1], "-"),
-		jobDir+"/", dest)
+		jobDir, dest)
 	log.Info("rsync", "cmd", rsyncCmd)
 	return runCmd(cmd, log)
 }
@@ -1730,7 +1847,7 @@ func runGC(cfg *Cfg) error {
 
 	var toKill []string
 	for _, s := range sessions {
-		if !strings.HasPrefix(s.Name, "ci.") {
+		if !strings.HasPrefix(s.Name, "ci.") && !strings.HasPrefix(s.Name, "local.") {
 			continue
 		}
 
@@ -1951,3 +2068,248 @@ func fmtDuration(created, ended string) string {
 	}
 	return fmt.Sprintf("%.1fs", secs)
 }
+
+func runLocal(cfg *Cfg, dest string) error {
+	cwd, err := os.Getwd()
+	if err != nil {
+		return fmt.Errorf("get working directory: %w", err)
+	}
+
+	picoPath := filepath.Join(cwd, "pico.sh")
+	if _, err := os.Stat(picoPath); os.IsNotExist(err) {
+		printMissingPicoHelp(cwd)
+		return fmt.Errorf("no pico.sh found in %s", cwd)
+	}
+
+	repoName := filepath.Base(cwd)
+	jobID := fmt.Sprintf("local-%d", time.Now().Unix())
+
+	eventData := &Event{
+		Type:         "local",
+		Name:         repoName,
+		JobID:        jobID,
+		Workspace:    cwd,
+		ArtifactDest: dest,
+	}
+
+	// Process -e / --env flags
+	for _, envPair := range cfg.EnvVars {
+		parts := strings.SplitN(envPair, "=", 2)
+		val := ""
+		if len(parts) == 2 {
+			val = parts[1]
+		}
+		key := parts[0]
+		switch key {
+		case "PICI_REPO":
+			eventData.Name = val
+		case "PICI_JOB":
+			eventData.JobID = val
+		case "PICI_EVENT":
+			eventData.Type = val
+		case "PICI_BRANCH":
+			eventData.Branch = val
+		case "PICI_COMMIT":
+			eventData.Commit = val
+		case "PICI_TAG":
+			eventData.Tag = val
+		}
+		_ = os.Setenv(key, val)
+	}
+
+	// Auto-detect git commit SHA and branch if not explicitly provided via -e
+	if eventData.Commit == "" {
+		eventData.Commit = detectGitCommit(cwd)
+	}
+	if eventData.Branch == "" {
+		eventData.Branch = detectGitBranch(cwd)
+	}
+
+	// Always block and output human format for local runs
+	cfg.Wait = true
+	cfg.HumanOutput = true
+
+	logger := cfg.Logger
+	if logger == nil {
+		logger = newLogger("ci", "info")
+	}
+	log := logger.With("repo", eventData.Name, "job_id", jobID, "mode", "local")
+
+	// Set up workspace in temp directory
+	wk := &WorkspaceRsync{
+		Cfg:    cfg,
+		Logger: log,
+		Source: cwd,
+	}
+
+	eng := &JobEngine{
+		Logger: log,
+		Cfg:    cfg,
+		Wk:     wk,
+		Ev:     eventData,
+		JobID:  jobID,
+	}
+
+	defer func() {
+		if err := eng.Cleanup(); err != nil {
+			log.Error("cleanup workspace", "err", err)
+		}
+	}()
+
+	fmt.Fprintf(os.Stdout, "šŸš€ starting local job local.%s.%s\n", eventData.Name, jobID) //nolint:errcheck
+	fmt.Fprintln(os.Stdout, "šŸ“¦ syncing workspace to temp directory...")                 //nolint:errcheck
+	if err := eng.Setup(); err != nil {
+		return fmt.Errorf("workspace setup: %w", err)
+	}
+	log.Debug("workspace directory", "dir", eng.Wk.GetDir())
+
+	// Store event.json in artifact dir
+	eventDir := filepath.Join(cfg.ArtifactDir, eventData.Name, jobID)
+	artifactsDir := filepath.Join(eventDir, "artifacts")
+	if err := os.MkdirAll(artifactsDir, 0755); err != nil {
+		log.Error("create artifacts dir", "err", err)
+	} else {
+		eventBytes, _ := json.Marshal(eventData)
+		_ = os.WriteFile(filepath.Join(artifactsDir, "event.json"), eventBytes, 0644)
+	}
+
+	// Write attestation.json
+	hostname, _ := os.Hostname()
+	attestation := map[string]interface{}{
+		"runner": map[string]string{
+			"hostname": hostname,
+			"os":       runtimeOS(),
+			"arch":     runtimeArch(),
+		},
+		"provenance": map[string]string{
+			"repo":   eventData.Name,
+			"branch": eventData.Branch,
+			"commit": eventData.Commit,
+		},
+		"workspace_checksum": eng.Wk.Checksum(),
+	}
+	attestationBytes, _ := json.Marshal(attestation)
+	_ = os.WriteFile(filepath.Join(artifactsDir, "attestation.json"), attestationBytes, 0644)
+
+	manifest, err := eng.FindManifest()
+	if err != nil {
+		return err
+	}
+
+	fmt.Fprintln(os.Stdout, "šŸƒ launching sessions...") //nolint:errcheck
+	if err := eng.Run(manifest); err != nil {
+		return fmt.Errorf("run: %w", err)
+	}
+
+	// Wait for completion & print live progress
+	if err := waitAndReport(cfg, log, eventData.Name, jobID, eventData.Type); err != nil {
+		return fmt.Errorf("wait: %w", err)
+	}
+
+	// Generate and stage full HTML/txt artifacts and index
+	if err := stageLocalArtifacts(cfg, log, eventData.Name, jobID); err != nil {
+		log.Error("stage local artifacts", "err", err)
+	}
+
+	indexFile := filepath.Join(cfg.ArtifactDir, eventData.Name, jobID, "index.html")
+	fmt.Fprintf(os.Stdout, "šŸ“Š local html report: file://%s\n", indexFile) //nolint:errcheck
+
+	// Sync to destination if specified
+	if dest != "" {
+		targetDest := strings.TrimSuffix(dest, "/") + "/" + jobID
+		fmt.Fprintf(os.Stdout, "šŸ”„ rsyncing artifacts to %s...\n", dest) //nolint:errcheck
+		if err := syncJobArtifacts(cfg, eventData.Name, jobID, log); err != nil {
+			return fmt.Errorf("sync artifacts: %w", err)
+		}
+		fmt.Fprintf(os.Stdout, "āœ… artifacts rsynced to %s\n", targetDest) //nolint:errcheck
+	}
+
+	return nil
+}
+
+func stageLocalArtifacts(cfg *Cfg, log *slog.Logger, repoName, jobID string) error {
+	listOutput, err := exec.Command("zmx", "list").CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("zmx list: %w", err)
+	}
+	sessions := parseZMXList(string(listOutput))
+	var localSessions []SessionInfo
+	for _, s := range sessions {
+		if strings.HasPrefix(s.Name, "local.") {
+			localSessions = append(localSessions, s)
+		}
+	}
+	prefix := fmt.Sprintf("local.%s.%s.", repoName, jobID)
+
+	var jobSessions []SessionInfo
+	for _, s := range localSessions {
+		if strings.HasPrefix(s.Name, prefix) {
+			s.Short = cleanSessionShort(s.Name, prefix, repoName, jobID)
+			jobSessions = append(jobSessions, s)
+		}
+	}
+
+	for _, s := range jobSessions {
+		sessionStatus := "running"
+		sessionDuration := fmtDuration(s.Created, fmt.Sprintf("%d", time.Now().Unix()))
+		sessionExitCode := ""
+		if s.Ended != "" {
+			sessionDuration = fmtDuration(s.Created, s.Ended)
+			if s.ExitCode == "0" {
+				sessionStatus = "success"
+				sessionExitCode = "0"
+			} else {
+				sessionStatus = "failed"
+				sessionExitCode = s.ExitCode
+			}
+		}
+
+		html, err := fetchHistoryHTML(s.Name, repoName, jobID, sessionStatus, sessionDuration, sessionExitCode)
+		if err == nil {
+			_ = stageArtifact(cfg.ArtifactDir, repoName, jobID, s.Short, html, ".html")
+		}
+		plain, err := fetchHistoryPlain(s.Name)
+		if err == nil {
+			_ = stageArtifact(cfg.ArtifactDir, repoName, jobID, s.Short, plain, ".txt")
+		}
+	}
+
+	// Write published sentinel
+	exitCode, status := resolveJobExitCode(jobSessions)
+	sentinel := filepath.Join(cfg.ArtifactDir, repoName, jobID, "artifacts", "published.json")
+	published := map[string]interface{}{
+		"status":      status,
+		"exit_code":   exitCode,
+		"job_id":      jobID,
+		"finished_at": time.Now().UTC().Format(time.RFC3339),
+	}
+	publishedJSON, _ := json.Marshal(published)
+	_ = os.WriteFile(sentinel, publishedJSON, 0644)
+
+	indexHTML, indexTXT := generateJobIndex(cfg.ArtifactDir, repoName, jobID, jobSessions)
+	_ = stageArtifact(cfg.ArtifactDir, repoName, jobID, "index", indexHTML, ".html")
+	_ = stageArtifact(cfg.ArtifactDir, repoName, jobID, "index", indexTXT, ".txt")
+	if styles, err := loadStyles(); err == nil {
+		_ = stageArtifact(cfg.ArtifactDir, repoName, jobID, "styles", styles, ".css")
+	}
+
+	return nil
+}
+
+func detectGitCommit(dir string) string {
+	cmd := exec.Command("git", "-C", dir, "rev-parse", "HEAD")
+	out, err := cmd.Output()
+	if err != nil {
+		return ""
+	}
+	return strings.TrimSpace(string(out))
+}
+
+func detectGitBranch(dir string) string {
+	cmd := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD")
+	out, err := cmd.Output()
+	if err != nil || strings.TrimSpace(string(out)) == "HEAD" {
+		return ""
+	}
+	return strings.TrimSpace(string(out))
+}
+62 -4 main_test.go #
@@ -25,17 +25,20 @@ func TestE2E_RunnerWithZMXSessions(t *testing.T) {
 	if testing.Short() {
 		t.Skip("skip integration test")
 	}
-	if _, err := exec.LookPath("zmx"); err != nil {
+	zmxPath, err := exec.LookPath("zmx")
+	if err != nil {
 		t.Skip("zmx not found, skipping integration test")
 	}
+	zmxDir := filepath.Dir(zmxPath)
 
 	// 1. Create workspace with pico.sh that spawns zmx sessions
 	workspaceDir := t.TempDir()
-	picoSh := `#!/usr/bin/env bash
+	picoSh := fmt.Sprintf(`#!/usr/bin/env bash
 set -e
+export PATH="%s:$PATH"
 zmx run step1 echo "hello from step1"
 zmx run step2 echo "hello from step2"
-`
+`, zmxDir)
 	if err := os.WriteFile(filepath.Join(workspaceDir, "pico.sh"), []byte(picoSh), 0755); err != nil {
 		t.Fatalf("write pico.sh: %v", err)
 	}
@@ -43,9 +46,11 @@ zmx run step2 echo "hello from step2"
 	// 2. Create config
 	artifactDir := t.TempDir()
 	ctx, cancel := context.WithCancel(context.Background())
+	testJobID := fmt.Sprintf("j-%d", time.Now().UnixNano()%100000000)
 	event := Event{
 		Type:      "build",
 		Name:      "test-repo",
+		JobID:     testJobID,
 		Workspace: workspaceDir,
 	}
 	eventJSON, _ := json.Marshal(event)
@@ -109,7 +114,7 @@ zmx run step2 echo "hello from step2"
 				}
 
 				// Ignore statuses from unrelated jobs (e.g., leftover sessions from previous tests)
-				if p.Name != "test-repo" {
+				if p.Name != "test-repo" || p.JobID != testJobID {
 					continue
 				}
 
@@ -690,3 +695,56 @@ func TestResolveJobExitCode(t *testing.T) {
 		})
 	}
 }
+
+func TestRunLocal_MissingPico(t *testing.T) {
+	tempDir := t.TempDir()
+	origWd, _ := os.Getwd()
+	defer func() { _ = os.Chdir(origWd) }()
+	_ = os.Chdir(tempDir)
+
+	cfg := &Cfg{
+		ArtifactDir: t.TempDir(),
+	}
+	err := runLocal(cfg, "")
+	if err == nil {
+		t.Fatal("expected error when pico.sh is missing")
+	}
+}
+
+func TestRunLocal_EnvOverrides(t *testing.T) {
+	tempDir := t.TempDir()
+	origWd, _ := os.Getwd()
+	defer func() { _ = os.Chdir(origWd) }()
+	_ = os.Chdir(tempDir)
+
+	picoContent := `#!/usr/bin/env bash
+echo "hello from pico"
+`
+	if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
+		t.Fatal(err)
+	}
+
+	cfg := &Cfg{
+		ArtifactDir: t.TempDir(),
+		EnvVars:     envList{"PICI_REPO=custom-repo", "CUSTOM_VAR=hello"},
+	}
+
+	_ = runLocal(cfg, "")
+
+	if os.Getenv("CUSTOM_VAR") != "hello" {
+		t.Errorf("expected CUSTOM_VAR to be hello, got %q", os.Getenv("CUSTOM_VAR"))
+	}
+}
+
+func TestDetectGitCommit(t *testing.T) {
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+	commit := detectGitCommit(cwd)
+	if commit == "" {
+		t.Log("git commit sha not detected (not a git repository or git unavailable)")
+	} else {
+		t.Logf("detected git commit sha: %s", commit)
+	}
+}
Back to top