pici
created pr with
132.1
changed status to
open
cmds
checkout latest patchset:
ssh pr.pico.sh print 132 | git am -3checkout any patchset in a patch request:
ssh pr.pico.sh print 132.[rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 132set PR to open (enables RSS notifications):
ssh pr.pico.sh pr open 132set PR to draft (stops RSS notifications):
ssh pr.pico.sh pr draft 132
Patchset
132.1
feat: add local runner base command with isolated execution and html logging
Eric Bower
2026-08-08T15:54:27Z- 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
main.go
-
type_declarationenvListadded -
method_declarationStringadded -
method_declarationSetadded -
chunklines 72-78modified -
function_declarationNewCfgmodified -
function_declarationmainmodified -
function_declarationprintMainHelpadded -
function_declarationprintMissingPicoHelpadded -
method_declarationCleanupmodified -
method_declarationRunmodified -
function_declarationRunmodified -
function_declarationeventHandlermodified -
chunklines 785-799modified -
function_declarationwaitAndReportsignature changed -
function_declarationcleanSessionShortmodified -
function_declarationrunMonitormodified -
function_declarationsyncJobArtifactsmodified -
function_declarationrunGCmodified -
function_declarationdetectGitCommitadded -
function_declarationdetectGitBranchadded -
function_declarationrunLocaladded -
function_declarationstageLocalArtifactsadded
+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)
+ }
+}