Commit 61d5525
Eric Bower
·
2026-08-30 11:10:04 -0400 EDT
parent 25ab3ff
refactor: pici.sh instead of pico.sh
5 files changed,
+114,
-31
+2,
-2
| ... | ... | @@ -1,5 +1,5 @@ | |
| 1 | 1 | name: "Pici CI" | |
| 2 | - | description: "Run parallel CI workflows with pico.sh, zmx, and interactive pipe.pico.sh remote debugging" | |
| 2 | + | description: "Run parallel CI workflows with pici.sh, zmx, and interactive pipe.pico.sh remote debugging" | |
| 3 | 3 | author: "pico.sh" | |
| 4 | 4 | branding: | |
| 5 | 5 | icon: "terminal" |
| ... | ... | @@ -23,7 +23,7 @@ inputs: | |
| 23 | 23 | required: false | |
| 24 | 24 | default: "true" | |
| 25 | 25 | working_directory: | |
| 26 | - | description: "Directory containing pico.sh" | |
| 26 | + | description: "Directory containing pici.sh" | |
| 27 | 27 | required: false | |
| 28 | 28 | default: "." | |
| 29 | 29 | zmx_version: |
M
main.go
+28,
-25
| ... | ... | @@ -230,9 +230,9 @@ func newLogger(space string, levelStr string) *slog.Logger { | |
| 230 | 230 | func printMainHelp() { | |
| 231 | 231 | fmt.Println(`pici - minimal parallel CI runner & monitor powered by zmx | |
| 232 | 232 | ||
| 233 | - | HOW IT WORKS: pici runs your project's CI pipeline defined in pico.sh: | |
| 233 | + | HOW IT WORKS: pici runs your project's CI pipeline defined in pici.sh: | |
| 234 | 234 | 1. Syncs workspace to a clean temporary directory (/tmp). | |
| 235 | - | 2. Executes pico.sh inside zmx, running each task in its own isolated PTY. | |
| 235 | + | 2. Executes pici.sh inside zmx, running each task in its own isolated PTY. | |
| 236 | 236 | 3. Bring your own isolation: tasks run on host unless wrapped (e.g. Docker, Podman, Nix). | |
| 237 | 237 | 4. Tasks execute serially ('zmx run <name> <cmd>') or concurrently in detached mode ('zmx run <name> -d <cmd>'). | |
| 238 | 238 | 5. Concurrent background tasks are joined using 'zmx wait "*"' to block until all complete. |
| ... | ... | @@ -241,7 +241,7 @@ HOW IT WORKS: pici runs your project's CI pipeline defined in pico.sh: | |
| 241 | 241 | 8. Optionally rsyncs rendered logs & artifacts to a destination when specified. | |
| 242 | 242 | ||
| 243 | 243 | PROJECT SETUP | |
| 244 | - | 1. Create a pico.sh script in your project root: | |
| 244 | + | 1. Create a pici.sh script in your project root: | |
| 245 | 245 | ||
| 246 | 246 | #!/usr/bin/env bash | |
| 247 | 247 | set -euo pipefail |
| ... | ... | @@ -266,10 +266,10 @@ PROJECT SETUP | |
| 266 | 266 | # Wait for all background steps to finish | |
| 267 | 267 | zmx wait "*" | |
| 268 | 268 | ||
| 269 | - | 2. Make it executable: chmod +x pico.sh | |
| 269 | + | 2. Make it executable: chmod +x pici.sh | |
| 270 | 270 | 3. Run locally: pici | |
| 271 | 271 | ||
| 272 | - | ENVIRONMENT VARIABLES: pici injects these vars into pico.sh: | |
| 272 | + | ENVIRONMENT VARIABLES: pici injects these vars into pici.sh: | |
| 273 | 273 | PICI_JOB Unique job identifier (timestamp or commit SHA) | |
| 274 | 274 | PICI_REPO Repository name (default: directory basename) | |
| 275 | 275 | PICI_EVENT Event type ('local', 'git.push', 'git.tag') |
| ... | ... | @@ -282,7 +282,7 @@ ENVIRONMENT VARIABLES: pici injects these vars into pico.sh: | |
| 282 | 282 | Pass custom env vars or overrides with -e / -env (e.g. pici -e CGO_ENABLED=0). | |
| 283 | 283 | ||
| 284 | 284 | LOCAL DEVELOPER USAGE | |
| 285 | - | pici [destination] [flags] Run ./pico.sh locally in /tmp & render HTML logs | |
| 285 | + | pici [destination] [flags] Run ./pici.sh locally in /tmp & render HTML logs | |
| 286 | 286 | pici pgs.sh:/my-site Run locally and rsync HTML logs to destination | |
| 287 | 287 | pici run [destination] Explicit alias for local run | |
| 288 | 288 | pici debug <repo/job_id | session> Attach to active zmx session for debugging |
| ... | ... | @@ -297,7 +297,7 @@ SUBCOMMAND HELP | |
| 297 | 297 | pici <command> --help Show detailed help for a specific command (e.g. pici runner --help) | |
| 298 | 298 | ||
| 299 | 299 | FLAGS | |
| 300 | - | -e, -env <KEY=VAL> Set or override environment variable for pico.sh | |
| 300 | + | -e, -env <KEY=VAL> Set or override environment variable for pici.sh | |
| 301 | 301 | -in-place Execute workspace in-place without copying to /tmp | |
| 302 | 302 | -summary-file <path> Write markdown job summary table to file | |
| 303 | 303 | -debug-on-fail Open interactive pipe.pico.sh debug bridge on failure |
| ... | ... | @@ -309,9 +309,9 @@ FLAGS | |
| 309 | 309 | } | |
| 310 | 310 | ||
| 311 | 311 | func printMissingPicoHelp(cwd string) { | |
| 312 | - | fmt.Printf(`❌ Error: no pico.sh found in %s | |
| 312 | + | fmt.Printf(`❌ Error: no pici.sh found in %s | |
| 313 | 313 | ||
| 314 | - | To run local CI tasks, create a pico.sh script in your project root: | |
| 314 | + | To run local CI tasks, create a pici.sh script in your project root: | |
| 315 | 315 | ||
| 316 | 316 | #!/usr/bin/env bash | |
| 317 | 317 | set -euo pipefail |
| ... | ... | @@ -479,8 +479,8 @@ EVENT JSON FIELDS | |
| 479 | 479 | EXAMPLE | |
| 480 | 480 | echo '{"type":"push","name":"myrepo","workspace":"git@github.com:user/myrepo.git"}' | pici runner | |
| 481 | 481 | ||
| 482 | - | ENV VARS IN pico.sh | |
| 483 | - | The runner exports these environment variables for your pico.sh script: | |
| 482 | + | ENV VARS IN pici.sh | |
| 483 | + | The runner exports these environment variables for your pici.sh script: | |
| 484 | 484 | PICI_JOB Unique job identifier (e.g. "a3f2b8c1") | |
| 485 | 485 | PICI_REPO Repository name (from event "name" field) | |
| 486 | 486 | PICI_EVENT Event type (e.g. "git.push", "git.tag") |
| ... | ... | @@ -488,10 +488,10 @@ ENV VARS IN pico.sh | |
| 488 | 488 | PICI_BRANCH Git branch name (set only for git.push events) | |
| 489 | 489 | PICI_ARTIFACTS_DIR Path to job artifacts directory for staging assets | |
| 490 | 490 | ||
| 491 | - | Note: pico.sh runs with the workspace as its working directory, so | |
| 491 | + | Note: pici.sh runs with the workspace as its working directory, so | |
| 492 | 492 | $(pwd) gives you the workspace path directly. | |
| 493 | 493 | ||
| 494 | - | Use them with defaults so pico.sh works standalone: | |
| 494 | + | Use them with defaults so pici.sh works standalone: | |
| 495 | 495 | JOB="${PICI_JOB:-local}" | |
| 496 | 496 | REPO="${PICI_REPO:-unknown}" | |
| 497 | 497 |
| ... | ... | @@ -840,7 +840,7 @@ func (eng *JobEngine) Run(manifest string) error { | |
| 840 | 840 | domain = getDomain(eng.Ev.Type) | |
| 841 | 841 | } | |
| 842 | 842 | prefix := fmt.Sprintf("%s.%s.%s.", domain, eng.Ev.Name, eng.JobID) | |
| 843 | - | // Child sessions use ".step." sub-prefix so zmx wait "*" inside pico.sh | |
| 843 | + | // Child sessions use ".step." sub-prefix so zmx wait "*" inside pici.sh | |
| 844 | 844 | // matches <domain>.<name>.<jobID>.step.* but NOT <domain>.<name>.<jobID>.runner. | |
| 845 | 845 | // This avoids a deadlock where the runner waits for itself. | |
| 846 | 846 | childPrefix := prefix + "step." |
| ... | ... | @@ -851,7 +851,7 @@ func (eng *JobEngine) Run(manifest string) error { | |
| 851 | 851 | runnerName := prefix + "runner" | |
| 852 | 852 | // Name the runner explicitly. Do NOT set ZMX_SESSION_PREFIX for this | |
| 853 | 853 | // outer zmx run call — it would be prepended to the runner's own name. | |
| 854 | - | // The prefix is only for child sessions spawned inside pico.sh (via the | |
| 854 | + | // The prefix is only for child sessions spawned inside pici.sh (via the | |
| 855 | 855 | // exported env var). | |
| 856 | 856 | artifactsDir := filepath.Join(eng.Cfg.ArtifactDir, eng.Ev.Name, eng.JobID, "artifacts") | |
| 857 | 857 | if err := os.MkdirAll(artifactsDir, 0755); err != nil { |
| ... | ... | @@ -890,15 +890,19 @@ func (eng *JobEngine) Cleanup() error { | |
| 890 | 890 | return eng.Wk.Cleanup() | |
| 891 | 891 | } | |
| 892 | 892 | ||
| 893 | - | func (eng *JobEngine) FindManifest() (string, error) { | |
| 894 | - | fnames := []string{"pico.sh"} | |
| 893 | + | func findManifestInDir(dir string) (string, error) { | |
| 894 | + | fnames := []string{"pici.sh", "pico.sh"} | |
| 895 | 895 | for _, manifest := range fnames { | |
| 896 | - | path := filepath.Join(eng.Wk.GetDir(), manifest) | |
| 896 | + | path := filepath.Join(dir, manifest) | |
| 897 | 897 | if _, err := os.Stat(path); err == nil { | |
| 898 | 898 | return path, nil | |
| 899 | 899 | } | |
| 900 | 900 | } | |
| 901 | - | return "", fmt.Errorf("no pico.sh found in %s", eng.Wk.GetDir()) | |
| 901 | + | return "", fmt.Errorf("no pici.sh or pico.sh found in %s", dir) | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | func (eng *JobEngine) FindManifest() (string, error) { | |
| 905 | + | return findManifestInDir(eng.Wk.GetDir()) | |
| 902 | 906 | } | |
| 903 | 907 | ||
| 904 | 908 | func eventHandler(cfg *Cfg, eventData *Event) error { |
| ... | ... | @@ -987,7 +991,7 @@ func eventHandler(cfg *Cfg, eventData *Event) error { | |
| 987 | 991 | runErr = err | |
| 988 | 992 | fmt.Fprintf(os.Stdout, "❌ %s\n\n", err) //nolint:errcheck | |
| 989 | 993 | //nolint:errcheck | |
| 990 | - | fmt.Fprint(os.Stdout, `Create a pico.sh script in your workspace root: | |
| 994 | + | fmt.Fprint(os.Stdout, `Create a pici.sh script in your workspace root: | |
| 991 | 995 | ||
| 992 | 996 | #!/usr/bin/env bash | |
| 993 | 997 | set -euo pipefail |
| ... | ... | @@ -1570,7 +1574,7 @@ func parseJobPrefix(prefix string) (name, jobID string) { | |
| 1570 | 1574 | ||
| 1571 | 1575 | // resolveJobExitCode determines the job's exit code from its sessions. | |
| 1572 | 1576 | // Defensive: if any child session failed, the job failed regardless of the | |
| 1573 | - | // runner's exit code. This protects against bad pico.sh scripts that exit 0 | |
| 1577 | + | // runner's exit code. This protects against bad pici.sh scripts that exit 0 | |
| 1574 | 1578 | // without waiting for children. | |
| 1575 | 1579 | func resolveJobExitCode(sessions []SessionInfo) (int, string) { | |
| 1576 | 1580 | var runnerExit *int |
| ... | ... | @@ -1837,7 +1841,7 @@ func allCompleted(sessions []SessionInfo) bool { | |
| 1837 | 1841 | ||
| 1838 | 1842 | // isJobComplete returns true if all sessions for a job have completed | |
| 1839 | 1843 | // AND a runner session exists and has completed. This ensures monitor and wait | |
| 1840 | - | // do not mark a job complete while pico.sh (the runner) is still executing and | |
| 1844 | + | // do not mark a job complete while pici.sh (the runner) is still executing and | |
| 1841 | 1845 | // potentially spawning new tasks. | |
| 1842 | 1846 | func isJobComplete(sessions []SessionInfo) bool { | |
| 1843 | 1847 | if len(sessions) == 0 { |
| ... | ... | @@ -2318,10 +2322,9 @@ func runLocal(cfg *Cfg, dest string) error { | |
| 2318 | 2322 | return fmt.Errorf("get working directory: %w", err) | |
| 2319 | 2323 | } | |
| 2320 | 2324 | ||
| 2321 | - | picoPath := filepath.Join(cwd, "pico.sh") | |
| 2322 | - | if _, err := os.Stat(picoPath); os.IsNotExist(err) { | |
| 2325 | + | if _, err := findManifestInDir(cwd); err != nil { | |
| 2323 | 2326 | printMissingPicoHelp(cwd) | |
| 2324 | - | return fmt.Errorf("no pico.sh found in %s", cwd) | |
| 2327 | + | return err | |
| 2325 | 2328 | } | |
| 2326 | 2329 | ||
| 2327 | 2330 | repoName := filepath.Base(cwd) |
+82,
-2
| ... | ... | @@ -818,7 +818,62 @@ func TestResolveJobExitCode(t *testing.T) { | |
| 818 | 818 | } | |
| 819 | 819 | } | |
| 820 | 820 | ||
| 821 | - | func TestRunLocal_MissingPico(t *testing.T) { | |
| 821 | + | func TestFindManifestInDir(t *testing.T) { | |
| 822 | + | t.Run("finds pici.sh when present", func(t *testing.T) { | |
| 823 | + | tempDir := t.TempDir() | |
| 824 | + | if err := os.WriteFile(filepath.Join(tempDir, "pici.sh"), []byte("#!/bin/bash\n"), 0755); err != nil { | |
| 825 | + | t.Fatal(err) | |
| 826 | + | } | |
| 827 | + | path, err := findManifestInDir(tempDir) | |
| 828 | + | if err != nil { | |
| 829 | + | t.Fatalf("unexpected error: %v", err) | |
| 830 | + | } | |
| 831 | + | if filepath.Base(path) != "pici.sh" { | |
| 832 | + | t.Fatalf("expected pici.sh, got %s", filepath.Base(path)) | |
| 833 | + | } | |
| 834 | + | }) | |
| 835 | + | ||
| 836 | + | t.Run("falls back to pico.sh when pici.sh is missing", func(t *testing.T) { | |
| 837 | + | tempDir := t.TempDir() | |
| 838 | + | if err := os.WriteFile(filepath.Join(tempDir, "pico.sh"), []byte("#!/bin/bash\n"), 0755); err != nil { | |
| 839 | + | t.Fatal(err) | |
| 840 | + | } | |
| 841 | + | path, err := findManifestInDir(tempDir) | |
| 842 | + | if err != nil { | |
| 843 | + | t.Fatalf("unexpected error: %v", err) | |
| 844 | + | } | |
| 845 | + | if filepath.Base(path) != "pico.sh" { | |
| 846 | + | t.Fatalf("expected pico.sh, got %s", filepath.Base(path)) | |
| 847 | + | } | |
| 848 | + | }) | |
| 849 | + | ||
| 850 | + | t.Run("prefers pici.sh over pico.sh when both exist", func(t *testing.T) { | |
| 851 | + | tempDir := t.TempDir() | |
| 852 | + | if err := os.WriteFile(filepath.Join(tempDir, "pici.sh"), []byte("#!/bin/bash\n"), 0755); err != nil { | |
| 853 | + | t.Fatal(err) | |
| 854 | + | } | |
| 855 | + | if err := os.WriteFile(filepath.Join(tempDir, "pico.sh"), []byte("#!/bin/bash\n"), 0755); err != nil { | |
| 856 | + | t.Fatal(err) | |
| 857 | + | } | |
| 858 | + | path, err := findManifestInDir(tempDir) | |
| 859 | + | if err != nil { | |
| 860 | + | t.Fatalf("unexpected error: %v", err) | |
| 861 | + | } | |
| 862 | + | if filepath.Base(path) != "pici.sh" { | |
| 863 | + | t.Fatalf("expected pici.sh to take precedence, got %s", filepath.Base(path)) | |
| 864 | + | } | |
| 865 | + | }) | |
| 866 | + | ||
| 867 | + | t.Run("returns error when neither exists", func(t *testing.T) { | |
| 868 | + | tempDir := t.TempDir() | |
| 869 | + | _, err := findManifestInDir(tempDir) | |
| 870 | + | if err == nil { | |
| 871 | + | t.Fatal("expected error when neither pici.sh nor pico.sh exists") | |
| 872 | + | } | |
| 873 | + | }) | |
| 874 | + | } | |
| 875 | + | ||
| 876 | + | func TestRunLocal_MissingManifest(t *testing.T) { | |
| 822 | 877 | tempDir := t.TempDir() | |
| 823 | 878 | origWd, _ := os.Getwd() | |
| 824 | 879 | defer func() { _ = os.Chdir(origWd) }() |
| ... | ... | @@ -829,7 +884,32 @@ func TestRunLocal_MissingPico(t *testing.T) { | |
| 829 | 884 | } | |
| 830 | 885 | err := runLocal(cfg, "") | |
| 831 | 886 | if err == nil { | |
| 832 | - | t.Fatal("expected error when pico.sh is missing") | |
| 887 | + | t.Fatal("expected error when runner script is missing") | |
| 888 | + | } | |
| 889 | + | } | |
| 890 | + | ||
| 891 | + | func TestRunLocal_PiciSh_EnvOverrides(t *testing.T) { | |
| 892 | + | tempDir := t.TempDir() | |
| 893 | + | origWd, _ := os.Getwd() | |
| 894 | + | defer func() { _ = os.Chdir(origWd) }() | |
| 895 | + | _ = os.Chdir(tempDir) | |
| 896 | + | ||
| 897 | + | piciContent := `#!/usr/bin/env bash | |
| 898 | + | echo "hello from pici" | |
| 899 | + | ` | |
| 900 | + | if err := os.WriteFile("pici.sh", []byte(piciContent), 0755); err != nil { | |
| 901 | + | t.Fatal(err) | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | cfg := &Cfg{ | |
| 905 | + | ArtifactDir: t.TempDir(), | |
| 906 | + | EnvVars: envList{"PICI_REPO=custom-repo", "CUSTOM_VAR=hello_pici"}, | |
| 907 | + | } | |
| 908 | + | ||
| 909 | + | _ = runLocal(cfg, "") | |
| 910 | + | ||
| 911 | + | if os.Getenv("CUSTOM_VAR") != "hello_pici" { | |
| 912 | + | t.Errorf("expected CUSTOM_VAR to be hello_pici, got %q", os.Getenv("CUSTOM_VAR")) | |
| 833 | 913 | } | |
| 834 | 914 | } | |
| 835 | 915 |
R pico.sh =>
pici.sh
+0,
-0
+2,
-2
| ... | ... | @@ -706,7 +706,7 @@ details .faq-answer { | |
| 706 | 706 | <span class="dot red"></span> | |
| 707 | 707 | <span class="dot yellow"></span> | |
| 708 | 708 | <span class="dot green"></span> | |
| 709 | - | <span style="margin-left: 0.5rem;">pico.sh</span> | |
| 709 | + | <span style="margin-left: 0.5rem;">pici.sh</span> | |
| 710 | 710 | </div> | |
| 711 | 711 | <div class="terminal-body"> | |
| 712 | 712 | <div><span class="comment">#!/usr/bin/env bash</span></div> |
| ... | ... | @@ -731,7 +731,7 @@ details .faq-answer { | |
| 731 | 731 | </div> | |
| 732 | 732 | <div> | |
| 733 | 733 | <p> | |
| 734 | - | Your pipeline is <code>pico.sh</code>: a bash script that runs <strong>identically on your machine | |
| 734 | + | Your pipeline is <code>pici.sh</code>: a bash script that runs <strong>identically on your machine | |
| 735 | 735 | and on the CI runner</strong>; no yaml required. | |
| 736 | 736 | </p> | |
| 737 | 737 | <p> |