Long CI
Start a gate, get on with something else, and finish it later, even from another machine.
ghostbranch gate waits for CI and then prints the result. That’s fine when CI takes a minute. When it takes half an hour, a job lets the wait happen somewhere else: in another terminal, a script, or on your other machine.
Detach
Section titled “Detach”gate --detach does everything up to starting CI: the rebase, the leak scan, and the push of the candidate. Then it returns a job ID:
A job ID is gate/<feature>/<candidate>: the feature, and the first 12 characters of the commit CI is testing. The leak scan has already passed by now, so a detached gate that returns a job is only waiting on CI.
ghostbranch wait <job> finishes the job: it waits for CI to settle, records the result, and prints what gate would have printed:
That feature had a feature stacked on it, so its real Next pointed at that one’s gate. The Next above is what a feature with no stack on it gets.
A job that has already finished answers at once, so wait is safe to run twice.
Waiting for a while
Section titled “Waiting for a while”--timeout stops waiting after a set time. If CI is still running, wait says so and exits 0. The job keeps running, and you run wait again later:
How long CI may run is set in .ghostbranch.yml: gate.ci_timeout (60 minutes by default) counts from when the gate started, not from when you ran wait. If no check appears within gate.ci_appear_timeout (10 minutes), the gate fails with NO_CI.
wait –json
Section titled “wait –json”With --json, wait prints one JSON object per line as CI goes: waiting when it starts, a check each time a check changes, and settled with the outcome (passed, failed, no_ci or timeout). The last line is the result, the same as gate --json prints:
{"schema_version":"0.15","type":"waiting","job":"gate/add-search/39a2e1652d1e","at":"2026-10-14T14:03:05Z","candidate":"39a2e1652d1eca0158b5a78537a3d57d5fa48fbe","shadow_pr":22,"shadow_pr_url":"https://github.com/you/project-shadow/pull/22","ci_timeout":"1h0m0s"}{"schema_version":"0.15","type":"check","job":"gate/add-search/39a2e1652d1e","at":"2026-10-14T14:03:07Z","name":"ci","status":"in_progress"}{"schema_version":"0.15","type":"check","job":"gate/add-search/39a2e1652d1e","at":"2026-10-14T14:03:07Z","name":"ci","status":"completed","conclusion":"success"}{"schema_version":"0.15","type":"settled","job":"gate/add-search/39a2e1652d1e","at":"2026-10-14T14:04:15Z","outcome":"passed"}{"schema_version":"0.15","command":"wait","ok":true,"exit_code":0,"data":{"feature":"add-search","checks":"passed","leak":"clean","job":"gate/add-search/39a2e1652d1e"}}The last line is trimmed above, to a few fields. The real one is the whole result, with every field gate --json has, its warnings, its next and its error.
A script needs only the last line. Starting a gate and reading its outcome:
job=$(ghostbranch gate add-search --detach --json | jq -r '.data.job')ghostbranch wait "$job" --json | tail -n 1 | jq -r '.data.checks'Progress always goes to stderr, never into the JSON on stdout. Don’t merge the two (2>&1) when you save the JSON.
Finish on the other machine
Section titled “Finish on the other machine”A job isn’t a process on your machine. It’s a record in the shadow repo: the feature’s gate, marked pending, with its shadow pull request and the time it started. So you can start a gate on one machine and finish it on another. On the other machine, run the same wait <job>.
Lost the job ID? ghostbranch status --json names the pending job of each feature, under gate.job.
While CI runs, nothing on your machine is locked, so other Ghostbranch commands for the pair keep working. wait takes the lock only to write the result.
If you press Ctrl-C
Section titled “If you press Ctrl-C”Pressing Ctrl+C during a gate that’s waiting for CI doesn’t lose anything. CI keeps running in the shadow repo, and the gate stops with INTERRUPTED and the job ID in its details. Run wait <job> to finish it, not gate, which would start CI again.
If a newer gate has replaced the job since, because the feature, public main or the overlay changed, wait says JOB_STALE and names the current job, if there is one.
promote –detach
Section titled “promote –detach”promote runs a gate itself when the feature’s gate is missing or stale. promote --detach hands that gate back as a job instead of waiting:
These lines are the format Ghostbranch prints, with sample values, not a captured run.
Once wait says the gate passed, run the same promote without --detach. It reuses the green gate and makes the plan.
Errors you may see
Section titled “Errors you may see”JOB_NOT_FOUNDexit 2The feature has no gate job to wait on: it was never gated, or its last gate stopped before CI (a leak or a conflict).
Run ghostbranch gate <feature>, with --detach to get a job id.
JOB_STALEexit 4A newer gate replaced this job: the feature, public main or the overlay changed, and the feature was gated again.
Wait on the current job (error.details.current_job), or, if there is none, gate the feature again.
INTERRUPTEDexit 1The command was interrupted (Ctrl-C) before it finished.
With error.details.plan_id the plan is saved and the publish may have started: resume it with ghostbranch apply <plan_id> (never promote again; a person runs it unless an agent grant exists). With error.details.job the gate's CI is still running in the shadow repo: run ghostbranch wait <job>. With neither, nothing is pending: run the command again.
CI_TIMEOUTexit 3CI did not finish in time.
Gate again, or raise gate.ci_timeout in .ghostbranch.yml.
NO_CIexit 3No CI checks appeared on the gate candidate (error.details.reason).
- no_workflow: the candidate has no workflow under .github/workflows, and no CI app posts checks
- actions_disabled_or_untriggered: Actions is disabled in the shadow repo, or no workflow triggers on pull_request
- no reason: the gate candidate couldn't be read, so which of the two holds is unknown
For no_workflow, add CI to the public repo, or, if CI is an external app, install it on the shadow repo. Otherwise enable Actions in the shadow repo or add a pull_request trigger. Then gate again.
LOCKEDexit 1Another ghostbranch operation on this pair is running on this machine (error.details.held_by names it).
Wait a few seconds and run the command again (error.retryable is true; next repeats it, or waits on error.details.job). The lock frees itself when that process ends, even if it crashed; there is nothing to delete.