Skip to content
GhostbranchdocsGitHub

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.

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:

Terminal
ghostbranch gate add-search --detach
Gate for add-search: CI running on candidate 74adada8d4b1 (shadow PR #9); job gate/add-search/74adada8d4b1.
2 commit(s), scanned as preserve
Next:
ghostbranch wait gate/add-search/74adada8d4b1 --repo you/project
CI is running on the gate candidate; wait finishes the job and records the result (with --json it streams progress)

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:

Terminal
ghostbranch wait gate/add-search/74adada8d4b1
Gate for add-search: checks passed, leak scan clean.
2 commit(s), scanned as preserve
candidate 74adada8d4b1 on shadow PR #9
Next:
ghostbranch promote add-search --mode preserve --plan --repo you/project --title <title>
the gate passed; plan the publish

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.

--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:

Terminal
ghostbranch wait gate/search-filters/03890ab7a053 --timeout 10s
Gate for search-filters: CI running on candidate 03890ab7a053 (shadow PR #8); job gate/search-filters/03890ab7a053.
1 commit(s), scanned as preserve
Next:
ghostbranch wait gate/search-filters/03890ab7a053 --repo you/project
CI is running on the gate candidate; wait finishes the job and records the result (with --json it streams progress)

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.

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:

wait.ndjson
{"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:

Terminal window
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.

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.

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 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:

Terminal
ghostbranch promote add-search --plan --detach
Gate for add-search: CI running on candidate 74adada8d4b1 (shadow PR #9); job gate/add-search/74adada8d4b1.
No plan yet: wait for the job, then promote again.

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.

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).
What to do

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.
What to do

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.
What to do

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.
What to do

Gate again, or raise gate.ci_timeout in .ghostbranch.yml.

NO_CIexit 3No CI checks appeared on the gate candidate (error.details.reason).
Causes
  • 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
What to do

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).
What to do

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.