A just-recipe runner that turns a recipe graph into a process-compose pipeline, fans out per-platform over SSH, and posts GitHub commit statuses live as the run progresses. Sibling recipes keep running after one fails — the final exit code comes from a per-node outcome map a central observer accumulates, not from process-compose's own exit.
The pipeline root is the recipe annotated [metadata("ci")] — exactly one across the justfile and its submodules (zero or multiple is a startup error). Its reachable dependency subgraph is the pipeline; submodule recipes appear under fully-qualified mod::recipe names. Each (recipe, platform) pair becomes its own process-compose node — see Platform fanout.
just --dump → root → reachable subgraph → fan out per platform → process-compose YAML → run
Strict by default. justci run refuses a dirty tree, snapshots HEAD via git worktree, posts GitHub commit statuses, and routes per-recipe logs under .ci/<sha>/<plat>/<recipe>.log. Three flags relax pieces of that policy for the cases where it's wrong:
| Flag(s) | Tree | HEAD pin | Status posts | Runtime files |
|---|---|---|---|---|
| (none — default) | clean (refuses dirty) | git worktree at HEAD |
<recipe>@<platform> per transition |
.ci/lock, .ci/pc.log, .ci/pc.sock, .ci/worktree/, .ci/<sha>/<platform>/<recipe>.log |
--no-post |
clean | git worktree at HEAD |
none | same as default (logs still SHA-keyed) |
--no-snapshot (implies --no-post) |
live working tree | none | none | .ci/lock, .ci/pc.log, .ci/pc.sock |
--no-strict (meta — same as --no-snapshot --no-post) |
live working tree | none | none | .ci/lock, .ci/pc.log, .ci/pc.sock |
The dirty-tree refuse, gh repo view, and git rev-parse HEAD all run before process-compose starts — a misconfigured environment (dirty tree, missing gh auth, no github remote) halts at the front door, not mid-run. Both modes still take an exclusive flock on .ci/lock, so a second justci run in the same checkout fails fast with "another justci run is in progress" instead of ghost-attaching to the first run's socket and posting stale check results. Runtime artifacts live under $PWD/.ci/ (gitignored); the SHA-keyed log directory keeps prior runs alongside the latest.
The pre-flip CI=true env-var gate is gone. Existing scripts that set CI=true keep working — the var is now a harmless no-op, since strict is the default.
A central observer subscribes to process-compose's /process/states/ws stream over a Unix domain socket and folds each transition into a per-node outcome map. The map drives the verdict (exit zero only if every node finished Success); when posts are enabled (default; suppressed by --no-post / --no-snapshot / --no-strict), it's also translated to GitHub commit-status posts.
| Wire state | GH post | Notes |
|---|---|---|
| Running | pending |
the moment of "now executing" — no pre-run seed |
| Success | success |
terminal |
| Failure | failure |
description links to .ci/<sha>/<platform>/<recipe>.log |
| Launch failure | failure + "Errored (did not start)" |
defect in the recipe's launch path, not a cascade |
| Cascade-skipped | no post | rides on GitHub's "Expected — Waiting for status to be reported" placeholder |
The cascade-skipped row is unposted because the placeholder — driven by justci protect's required-checks list — already encodes "required but unreported." Posting our own Skipped row used to overwrite a prior success on partial re-runs; deferring to the placeholder fixes that. Both unposted required checks and pending posts block merge, so the path to green after a failure is to re-run the failing root recipe (which re-runs the cascade downstream).
The local CLI summary uses the same vocabulary (skipped, failed) as the PR check rows.
Remote-platform setup (the _ci-setup@<platform> SSH bundle-ship + drv-copy step every recipe on that platform depends on) posts its own statuses, so a setup failure surfaces as one red row on the PR instead of a wall of "Expected — Waiting" on downstream recipes. But setup is not a required check: a local-only run schedules no setup nodes, and a required check that never receives a status would permanently block merge. Two predicates in JustCI.CommitStatus carry the split — shouldPostStatus (setup included) and isRequiredCheck (setup excluded).
Pure dependency aggregators — recipes whose just body is empty and that exist only to fan out, like default: checks run-check — are dropped from both the commit-status surface and the justci protect required-checks list. Their state is fully derivative of their leaves: if every dep is green the aggregator's check could only be green; if a dep fails the aggregator is skipped the moment process-compose decides not to run it.
The wedge case is downstream retries: re-running a single failed leaf (e.g. justci run e2e@x86_64-linux) succeeds and overwrites the leaf's check to green, but a required aggregator would still be merge-blocked on a recipe that no per-leaf retry will ever clear. Removing aggregators from both surfaces means required checks are exactly the recipes that do real work, and a successful leaf retry is sufficient to clear the PR.
The aggregator still runs as the DAG entrypoint and still contributes to the local exit code; only its GitHub presence is suppressed. The filter is structural — keyed off the recipe's body field in just --dump --dump-format json — so it covers both the canonical [metadata("ci")] root and any intermediate body-less aggregator (e.g. a checks: build flake-check fmt-check fan-out node).
The pipeline's target platforms come from the root recipe's just OS attributes:
[linux] [macos] [metadata("ci")]
ci: build run-check…declares a pipeline that runs across both Linux and macOS. A root recipe with no OS attribute defaults to the local platform only (single-lane pipeline, identical to the pre-fanout shape). Each (recipe × platform) pair becomes its own process-compose node; depends_on edges are replicated within each lane but never crossed, so a failure on one platform doesn't block the other.
Node names — and GitHub commit-status contexts — are <recipe>@<platform> (e.g. ci::build@linux).
Every emitted process is restart: no and exit_on_skipped: false, so one failing node leaves sibling lanes free to keep running and skipped dependents don't tear the project down. Process-compose's own exit code is therefore not authoritative — a failed node leaves pc exiting 0 — and the verdict step that consults the outcome map is what surfaces the failure.
A node whose platform doesn't match the local host runs via SSH. The runner pipes a git bundle through ssh <host>, the remote shell clones it into a tempdir, checks out the pipeline's HEAD SHA, and runs just --no-deps <recipe> there. Per-node stdout/stderr streams back over SSH and lands in .ci/<sha>/<platform>/<recipe>.log exactly as a local node would.
The remote-side checkout lives under $JUSTCI_CACHE_DIR (defaults to ${XDG_STATE_HOME:-$HOME/.local/state}/justci) keyed by <short-sha>/<platform>/, persisting across runs so same-SHA reruns skip the bundle+clone. Per-SHA dirs are pruned on every setup: anything older than --cache-ttl-hours (default 48) is removed, except the current run's own dir. Set --cache-ttl-hours 0 to disable eviction. The exclusion of the current dir means concurrent runs from separate orchestrators targeting the same remote can't evict each other's in-progress clone.
Hosts go in ~/.config/justci/hosts.json, keyed by Nix system tuple:
{
"x86_64-linux": "builder.example.com",
"aarch64-darwin": "mac-runner.example.com"
}The fanout = (root's OS families × configured systems matching those families) ∪ {local system if its family matches}. [linux] matches any *-linux; [macos] matches any *-darwin. Systems without entries are silently dropped — the user opts in by writing the file.
The remote needs nix, git, and any tools the recipes themselves use on its PATH. just does not need to be pre-installed — the runner ships the target-platform just derivation via nix-store --export | ssh <host> nix-store --import, then the remote nix-store --realises it. The remote's substituter chain (typically cache.nixos.org) fetches the natively-built binary for its arch, so the linux runner never tries to execute a darwin binary and vice versa.
Host strings are whatever ssh knows how to dial — bare hostname, user@host, an alias from ~/.ssh/config. Incus instances are reached via an ssh-config alias; no special-case client at the runner layer.
Local-system entry takes precedence over inline execution. Configure "x86_64-linux": "pu connect srid1" from an x86_64-linux host and the linux lane routes through pu instead of running in the worktree — useful for exercising remote runners (or testing failure modes) without leaving the local box.
--host PLATFORM=ADDR overlays onto hosts.json for one invocation. Repeatable; CLI entries win on collision. justci run --host x86_64-linux=root@lxc-foo redirects the linux lane to a throwaway LXC container without touching the JSON file. Platforms not named on the CLI still consult hosts.json as usual.
--platform PLATFORM restricts the fanout itself. Repeatable; the pipeline universe becomes (root OS families ∩ configured systems) ∩ --platform set. justci run --platform x86_64-linux runs the linux lane only, regardless of how many other platforms the root recipe declares. Distinct from the positional RECIPE@PLATFORM selector: that pins one named recipe to one platform via post-fanout reachability; --platform slices the platform universe pre-fanout, so it composes orthogonally with positional selectors that don't name a platform (e.g. justci run e2e --platform x86_64-linux runs e2e + its deps on linux only). Composes with --no-strict / --no-snapshot / --no-post too — handy for testing strict-mode behavior on one lane without the full remote fanout.
| Command | Purpose |
|---|---|
justci run |
drive the pipeline (default) |
justci dump-yaml |
emit the assembled YAML to stdout |
justci protect |
sync GitHub branch-protection required-checks to the DAG |
justci status / logs / monitor |
passthroughs to process-compose against the live socket |
justci run [--tui] [--no-strict | --no-snapshot | --no-post] [--progress json] [--host PLATFORM=ADDR ...] [--platform PLATFORM ...] [--root RECIPE] [--no-deps] [--cache-ttl-hours N] [RECIPE[@PLATFORM]...] [-- <args>]
--tui— swap process-compose's headless logger for its interactive tcell view; useful for poking at long-running pipelines locally--no-strict— dev-mode shortcut: run against the live working tree and skip GitHub commit-status posts (equivalent to--no-snapshot --no-post). The pre-flight (clean-tree refuse +gh repo view+git rev-parse HEAD) is skipped entirely, so a misconfigured environment doesn't block the run.--no-snapshot— run against the live working tree (skip the clean-tree refuse and the HEADgit worktreepin). Implies--no-post— a SHA-tagged status against unpinned bytes violates the "SHA matches tested bytes" invariant. Distinct from process-compose's own--no-snapshotflag (which is forwarded after--); same name, different layers.--no-post— skip GitHub commit-status posts. Clean-tree refuse and HEAD worktree pin still apply; useful for non-github strict consumers and for debugging strict runs without writing to the PR's checks list.--progress json— stream a per-node transition feed to stdout, one NDJSON line per transition, the moment process-compose reports it. See Progress stream. Defaultnoneleaves stdout unchanged.--platform PLATFORM— restrict the run to this platform; repeatable to opt into a subset (e.g.--platform x86_64-linux --platform aarch64-darwinruns two of three lanes). Intersected with the natural fanout: requested platforms outside it are silently dropped, an empty intersection errors with a message naming--platformas the cause. Composes with the strict-mode opt-outs too — useful for testing strict-mode behavior on one lane without spinning up every remote. See Platform fanout.--root RECIPE— replace the DAG root that[metadata("ci")]would have picked--no-deps— thejust-style escape hatch: keep only the named selectors, skip their dependency closure (setup nodes still auto-included on remote platforms so the YAML doesn't reference dropped dependencies)--cache-ttl-hours N— prune per-SHA cache dirs older thanNhours on every remote setup (default 48).0disables eviction; the current run's dir is never evicted. See Remote builds over SSH.- positional
RECIPE[@PLATFORM]selectors restrict the run to those nodes and their transitive deps (e.g.justci run e2e@x86_64-linuxre-runs just that one node after a flakye2elane). The status context (<recipe>@<platform>) is unchanged, so a partial re-run overwrites the same GitHub check the full run wrote. - anything after
--is forwarded verbatim toprocess-compose up
justci run already drives three live surfaces off one process-compose event stream: GitHub commit-status posts (visible on the PR), the end-of-run ── ci run summary ──, and the final exit code. --progress json adds a fourth aimed at a programmatic consumer — a tool or agent driving justci run in the background that wants to react to a failing recipe the instant it fails, while sibling lanes keep running, instead of waiting for the whole pipeline to finish or polling gh pr checks.
It emits one NDJSON object to stdout per node transition, flushed immediately:
| Field | Always? | Notes |
|---|---|---|
node |
yes | <recipe>@<platform> — same string the GitHub status context carries, so a progress line correlates to a PR check. |
recipe / platform |
yes | the identity pre-split, so a consumer needn't re-parse node to group by lane or filter to one recipe. |
status |
yes | running | success | failed | skipped | errored. errored (pc couldn't launch the process) is kept distinct from failed (the process ran and exited non-zero) — different remedies. |
exit_code |
terminal-with-code only | present on success / failed / errored; omitted for running (no exit yet) and skipped (the process never ran). |
log |
snapshotted ran/running nodes | the on-disk per-recipe log path, so a consumer can read the failing output directly. Present for running / success / failed under a snapshot (the default); omitted under --no-snapshot (no log dir exists) and for errored / skipped (no log was written — a pointer to a missing file is worse than none, per #26). |
The feed is the raw per-node stream — it includes the _ci-setup@<platform> setup nodes and any body-less aggregator (default@<platform>), unlike the aggregator-filtered commit-status and required-check surfaces. A consumer that only cares about real work filters on recipe.
Consumer contract: extract JSON, don't match line starts. process-compose runs headless on the same inherited stdout and emits its own lines there (per-process logs prefixed [<recipe>@<platform>], plus an xterm title escape at startup that can prefix the first progress line). Each progress object is written contiguously, one per line, with no nested braces — so pull them out with a tolerant extractor rather than grep '^{':
justci run --progress json 2>/dev/null | grep -o '{.*"node":.*}' | jq -c 'select(.status=="failed" or .status=="errored")'--progress json composes with every mode: it's most useful on a backgrounded strict run (where log points into .ci/<sha>/), but --no-strict --progress json is equally valid for a local dev loop (no log field). It's a no-op under the default --progress none, so plain justci run stdout is unchanged.
Emits the assembled YAML to stdout for inspection. Side-effect-free — no host prompts, no git rev-parse shell-out — so it works offline, on a remote VM with no TTY, and outside a git checkout. Unresolved hosts render as <unconfigured> and the SSH checkout carries a 0000000-dump-yaml-placeholder token; the YAML's structure (process keys, depends_on edges) still reflects the real fanout.
One-shot: PATCH GitHub branch protection's required_status_checks to the (recipe, platform) contexts the canonical DAG produces. Runs the same DumpRun-mode pipeline build dump-yaml/graph use, filters to user-facing nodes (setup nodes and pure-aggregator recipes excluded — see Aggregator filtering), and sends the list to GitHub. --branch defaults to the repo's default branch (queried via gh repo view); --dry-run prints what would be PATCHed and exits.
Set up the protection ruleset once in the GH UI; justci protect keeps the required-check list in sync with the DAG every time the recipe set changes. The DAG root stays the canonical [metadata("ci")] recipe — partial-run flags like --root/--no-deps belong on run, not on the required-check source of truth.
Thin passthroughs to process-compose process list / logs / monitor against $PWD/.ci/pc.sock, the UDS that a live justci run binds. Useful when justci run is in the background and the caller wants fine-grained per-node state:
justci status -o json # one-shot snapshot
justci logs -f <recipe>@<platform> # tail one node
justci monitor # live event streamEach resolves the socket via the same RunDir the runner uses and shells out to the same compile-time-baked process-compose binary the server runs, so client and server never disagree on wire format. If no run is in progress in the checkout, the subcommand exits non-zero with a clear "no socket at .ci/pc.sock" message. Unknown flags pass through to process-compose directly — no flag re-declaration here.
Consume justci as an APM package
This repo ships a /ci reference skill — a cheat-sheet for which subcommand to invoke (full pipeline, single recipe, platform-pinned re-run, dump-yaml/graph/protect, live-introspection status/logs/monitor against a backgrounded run, hosts.json overrides). Downstream projects pick it up by adding one line to their own apm.yml:
dependencies:
apm:
- juspay/justciapm install lands the skill at .claude/skills/ci/SKILL.md (or the equivalent path for the consumer's harness). When the consumer's agent reaches a "run justci" / "re-run a flaky check" task, the skill triggers and dispatches the right justci ... invocation against the consumer's checkout.
The skill is just documentation — it doesn't ship the runner itself. The consumer's project gets justci from this flake (nix run github:juspay/justci -- run) or a pinned version in its own flake.nix.
- Per-recipe OS-attribute filtering. Today a recipe is replicated to every pipeline platform regardless of its own
[linux]/[macos]attribute (and the remotejustrefuses if the recipe isn't enabled on that host). A future pass at our layer would prune those nodes upfront so the verdict surface doesn't show them asFailed.
{"node":"biome@x86_64-linux","platform":"x86_64-linux","recipe":"biome","status":"running"} {"node":"unit@x86_64-linux","platform":"x86_64-linux","recipe":"unit","status":"success","exit_code":0} {"node":"biome@x86_64-linux","platform":"x86_64-linux","recipe":"biome","status":"failed","exit_code":1,"log":".ci/<sha>/x86_64-linux/biome.log"}