Skip to content

Commit fda0b51

Browse files
sorccuclaude
andauthored
feat: deploy projects asynchronously with live progress (#1358)
* feat(cli): use the async project deploy API [RED-644] Switch `checkly deploy` from the synchronous POST /next-v2/projects/deploy to the async POST /v1/projects/deploy, then poll GET /v1/projects/deployments/{id}/completion to completion. This removes the API-gateway request-timeout ceiling that caused large projects to fail with a 504 even though the deploy was still running. - rest/projects.ts: deploy() submits the deployment and awaits completion (looping the long-poll completion endpoint, which 408s while in progress); dry runs still return the preview diff synchronously. Adds ProjectDeployment / ProjectDeployFailedError types, getDeployment(), and awaitDeploymentCompletion() with best-effort progress reporting. - commands/deploy.ts: show a spinner with live progress during the deploy. The deploy() return shape ({ data: { project, diff } }) is unchanged for callers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi * feat(cli): follow deploys via the SSE progress stream [RED-644] Replace the completion long-poll (which only refreshed progress ~every 30s) with the backend's Server-Sent Events stream: deploy() now follows GET /v1/projects/deployments/{id}/events, surfacing each `progress` frame to onProgress for a smooth bar and resolving on the terminal `complete` frame. A single authenticated GET — no client polling. - streamDeploymentEvents() reconnects (bounded) when the stream drops before a terminal frame, covering both a clean EOF and a socket error (ECONNRESET) — the common mid-deploy interruption; the server is stateless so resuming needs no cursor. - openEventStream() buffers an HTTP error body (the response is a stream, so the interceptor can't classify it) and re-runs the classifier to surface the typed error (NotFoundError, etc.). - Drops the snapshot-on-408 progress hack. dryRun stays the synchronous preview. Requires the backend SSE route to be deployed first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi * feat(cli): address deployments under their project [RED-644] Follow a deploy via /v1/projects/{logicalId}/deployments/{id}/events, threading the project logicalId (URL-encoded) through the deployment-following calls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi * feat(deploy): add --cancel-in-progress-deployment to preempt an in-flight deploy [RED-644] When the async deploy endpoint returns 409 (a deployment is already in progress for the project), the CLI previously errored out. With the new opt-in flag, the CLI instead cancels the in-flight deployment, waits for it to finish unwinding, and retries — so a new deploy can preempt an older one instead of waiting. - New boolean flag --cancel-in-progress-deployment (default false). It is a dedicated flag, NOT --force (which only skips the confirmation prompt). - On a 409 with the flag set, deploy() cancels the specific in-flight deployment (from the conflict's deploymentId), long-polls the completion endpoint to a final state, then retries — bounded to avoid an unbounded cancel war, and treating a vanished predecessor (404) as "slot free". - awaitDeploymentCompletion floors its poll cadence so a server returning 408 immediately can't become a tight request loop. - A "Waiting for an in-progress deployment to finish…" status message is shown during the wait; a 409 without the flag now prints an actionable hint. - rest/errors.ts: type the deploymentId carried on a 409 ErrorData. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi * feat(deploy): expose cancelRequestedAt on the deployment type [RED-644] Mirror the API response shape: the deployment now carries cancelRequestedAt (null unless a cancellation has been requested). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi * feat(deploy): wait for an in-progress deployment by default, then deploy [RED-644] Previously a 409 (a deployment is already in progress for the project) failed the deploy. Now `checkly deploy` waits for the in-progress deployment to finish and then deploys — the same flow as --cancel-in-progress-deployment minus the cancel step. The flag now means "cancel it instead of waiting". - On a 409, deploy() resolves the conflict and retries: it long-polls the completion endpoint until the predecessor reaches a final state (optionally cancelling it first with the flag), and only then re-POSTs the deploy — once. The payload is never re-uploaded while the predecessor is still running. - awaitDeploymentCompletion is a single long-poll; the wait/retry cadence lives in deploy()/resolveInProgressDeployment, bounded by an overall ~30-min deadline after which the 409 surfaces with a clear message. - A predecessor whose worker died is finalized by the backend reaper, after which the completion poll returns and we deploy. - Update the flag description and the conflict message to match. Depends on the backend returning 409 immediately on a concurrent deploy (previously the request hung), without which neither the wait nor the cancel path could start. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi * fix(deploy): clearly report a cancelled deployment [RED-644] A deployment that was cancelled surfaced as the generic "Your project could not be deployed. / The deployment did not complete successfully." — giving no hint that it was cancelled. - Add ProjectDeployCancelledError; submitDeployment throws it when the deploy stream completes as CANCELLED (a reliable signal, independent of any backend error text), before the generic non-SUCCEEDED failure path. - The deploy command reports it distinctly: title "Your deployment was cancelled." with the body "A newer deployment may have cancelled yours. Try deploying again if you still need to apply your changes." Still an error (❌, exit 1) — only the messaging changes. - Also reword the in-progress (409 past wait deadline) message so the body stands on its own instead of leaning on the title. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi * style(deploy): plain "..." instead of unicode ellipsis in status messages [RED-644] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi * style(deploy): "an in-progress deployment" in the cancel status message [RED-644] Matches the wait message; "the" implied a deployment the user hadn't been told about. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi * feat(deploy): type the deploy-result project with id + createdAt/updatedAt The async/sync deploy response's `result.project` was typed as the bare `Project` (name/logicalId/repoUrl). Add a `DeployedProject` type matching the API's deploy-result shape: the `id` it always returned plus the newly added camelCase `createdAt`/`updatedAt` timestamps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjBjJjMihvJKv8PEzuCvKi --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fad89ba commit fda0b51

4 files changed

Lines changed: 748 additions & 9 deletions

File tree

packages/cli/src/commands/deploy.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import chalk from 'chalk'
1515
import { splitConfigFilePath, getGitInformation } from '../services/util.js'
1616
import commonMessages from '../messages/common-messages.js'
1717
import { forceFlag } from '../helpers/flags.js'
18-
import { ProjectDeployResponse } from '../rest/projects.js'
18+
import { ProjectDeployResponse, ProjectDeployCancelledError } from '../rest/projects.js'
19+
import { ConflictError } from '../rest/errors.js'
1920
import { uploadSnapshots } from '../services/snapshot-service.js'
2021
import { BrowserCheckBundle } from '../constructs/browser-check-bundle.js'
2122
import { Runtime } from '../runtimes/index.js'
@@ -56,6 +57,10 @@ export default class Deploy extends AuthCommand {
5657
allowNo: true,
5758
}),
5859
'force': forceFlag(),
60+
'cancel-in-progress-deployment': Flags.boolean({
61+
description: 'If a deployment for this project is already in progress, cancel it instead of waiting for it to finish.',
62+
default: false,
63+
}),
5964
'config': Flags.string({
6065
char: 'c',
6166
description: commonMessages.configFile,
@@ -83,6 +88,7 @@ export default class Deploy extends AuthCommand {
8388
const {
8489
force,
8590
preview,
91+
'cancel-in-progress-deployment': cancelInProgress,
8692
'schedule-on-deploy': scheduleOnDeploy,
8793
output: outputFlag,
8894
verbose,
@@ -239,7 +245,22 @@ export default class Deploy extends AuthCommand {
239245
}
240246

241247
try {
242-
const { data } = await api.projects.deploy({ ...projectPayload, repoInfo }, { dryRun: preview, scheduleOnDeploy })
248+
if (!preview) {
249+
this.style.actionStart('Deploying project')
250+
}
251+
const { data } = await api.projects.deploy(
252+
{ ...projectPayload, repoInfo },
253+
{
254+
dryRun: preview,
255+
scheduleOnDeploy,
256+
cancelInProgress,
257+
onProgress: preview ? undefined : progress => this.style.actionStatus(`${progress}% complete`),
258+
onStatus: preview ? undefined : message => this.style.actionStatus(message),
259+
},
260+
)
261+
if (!preview) {
262+
this.style.actionSuccess()
263+
}
243264
if (preview || output) {
244265
this.log(this.formatPreview(data, project, verbose))
245266
}
@@ -258,7 +279,22 @@ export default class Deploy extends AuthCommand {
258279
})
259280
}
260281
} catch (err: any) {
261-
this.style.longError(`Your project could not be deployed.`, err)
282+
if (!preview) {
283+
this.style.actionFailure()
284+
}
285+
if (err instanceof ProjectDeployCancelledError) {
286+
this.style.longError('Your deployment was cancelled.', err.message)
287+
} else if (err instanceof ConflictError) {
288+
// deploy() waits-and-retries behind an in-progress deployment, so a 409
289+
// only reaches here once that wait exceeded its deadline.
290+
this.style.longError(
291+
'A deployment for this project is still in progress.',
292+
'Try again later, or re-run with `--cancel-in-progress-deployment` to '
293+
+ 'cancel the running deployment and deploy now.',
294+
)
295+
} else {
296+
this.style.longError(`Your project could not be deployed.`, err)
297+
}
262298
this.exit(1)
263299
}
264300
}

0 commit comments

Comments
 (0)