Problem
When a project breaks somewhere in its deployment history, there is no supported way to find which deployment introduced the regression.
openship deployment list shows the history and openship deployment rollback <id> reverts to a chosen deployment, but nothing narrows "one of these 40 deployments broke it" to a single culprit. In practice that means walking the history one deployment at a time — O(n) rollbacks, each one mutating the active deployment.
The data needed to do this properly is already stored: deployment.version is a monotonic per-project counter, deployment.createdAt orders the history, and deployment.url is the visitable address.
Proposal
Add openship deployment bisect — a binary search over deployment history.
openship deployment bisect [--project <id>] [--env <environment>]
[--limit <n>] [--good <id>] [--bad <id>]
- Fetch the project's deployments, keep only those that actually deployed something visitable, sort ascending by
createdAt.
- Treat the oldest as known-good and the newest as known-bad, unless
--good / --bad override.
- Present the midpoint deployment — id, version, branch, timestamp, URL — open its URL when one exists, and ask good / bad / skip / abort.
- Narrow the range until only the good/bad boundary pair remains.
- Report the first bad deployment and the last known-good one, then offer to roll back to the good one.
skip covers a candidate that cannot be judged (expired artifact, feature-flagged off). A skipped candidate is never labelled, so the final bracket can be wider than the minimal transition pair — the reported boundaries stay truthful: the lower bound is genuinely good, the upper bound genuinely bad.
Roughly log2(n) checks instead of n: 40 deployments becomes 6 questions.
Why this fits the existing codebase
Additive only — no new API route, no schema change, no new dependency:
- Reads
GET /api/deployments?projectId=&environment=&perPage=, the route deployment list already uses.
- Writes
POST /api/deployments/:id/rollback, the route deployment rollback already uses.
version, url, commitSha and createdAt are already on that list response — presentDeployments masks env only, it does not project columns away.
- Reuses existing CLI conventions: the
run() ApiError wrapper, the ok()/err()/info() output helpers, readProjectLink() for project scope, @clack/prompts for the prompt, and the open dependency already used by openship open.
Status filter: only ready and partial_failure represent a deployment that serves something. queued/building/deploying have not finished; failed/cancelled/rejected have nothing to visit. Related: #410 — ready is the correct string, and a filter on success matches zero rows.
Acceptance criteria
- The search logic is separable from the I/O and unit-tested on its own: midpoint selection, the good/bad/skip transitions, and convergence — including that a skipped candidate still yields a correct bracket.
- Refuses to run without a TTY and under
--json, with a clear message.
- Errors clearly when fewer than two testable deployments are in range, when
--good/--bad name a deployment outside the fetched window, and when --good is not chronologically before --bad. The fewer-than-two case must not blame --good/--bad when neither was passed.
- Aborting, via the menu or Ctrl-C, exits non-zero and issues no rollback.
- Declining the final rollback offer issues no rollback.
- The rollback goes through the existing endpoint, not a new one.
Scope
CLI only. A dashboard equivalent would be a separate issue; the command stands on its own for anyone already using deployment list and rollback.
Problem
When a project breaks somewhere in its deployment history, there is no supported way to find which deployment introduced the regression.
openship deployment listshows the history andopenship deployment rollback <id>reverts to a chosen deployment, but nothing narrows "one of these 40 deployments broke it" to a single culprit. In practice that means walking the history one deployment at a time — O(n) rollbacks, each one mutating the active deployment.The data needed to do this properly is already stored:
deployment.versionis a monotonic per-project counter,deployment.createdAtorders the history, anddeployment.urlis the visitable address.Proposal
Add
openship deployment bisect— a binary search over deployment history.createdAt.--good/--badoverride.skipcovers a candidate that cannot be judged (expired artifact, feature-flagged off). A skipped candidate is never labelled, so the final bracket can be wider than the minimal transition pair — the reported boundaries stay truthful: the lower bound is genuinely good, the upper bound genuinely bad.Roughly
log2(n)checks instead ofn: 40 deployments becomes 6 questions.Why this fits the existing codebase
Additive only — no new API route, no schema change, no new dependency:
GET /api/deployments?projectId=&environment=&perPage=, the routedeployment listalready uses.POST /api/deployments/:id/rollback, the routedeployment rollbackalready uses.version,url,commitShaandcreatedAtare already on that list response —presentDeploymentsmasks env only, it does not project columns away.run()ApiError wrapper, theok()/err()/info()output helpers,readProjectLink()for project scope,@clack/promptsfor the prompt, and theopendependency already used byopenship open.Status filter: only
readyandpartial_failurerepresent a deployment that serves something.queued/building/deployinghave not finished;failed/cancelled/rejectedhave nothing to visit. Related: #410 —readyis the correct string, and a filter onsuccessmatches zero rows.Acceptance criteria
--json, with a clear message.--good/--badname a deployment outside the fetched window, and when--goodis not chronologically before--bad. The fewer-than-two case must not blame--good/--badwhen neither was passed.Scope
CLI only. A dashboard equivalent would be a separate issue; the command stands on its own for anyone already using
deployment listandrollback.