Deploy lifecycle.
An HS-X deploy is a recorded revision in the control plane, and exactly one revision is active per environment. Activation is gated: a candidate cannot be promoted until the deployed Worker has attested itself — deploy id, manifest hash, bindings — and the control plane has judged that attestation healthy. This page is the full state machine: every state, every transition, what gates each one, and what you see when a transition is refused.
The 30-second answer
hs-x deploy plans, ships, and records a revision. The deployed Worker attests on its first request, and once the control plane marks that attestation healthy, the revision can be promoted to active. hs-x deploy --promote-when-healthy runs the whole chain in one command: record, wait for the healthy heartbeat, promote. Promotion retires the previously active revision; hs-x rollback re-promotes a previous one; hs-x routes shows which revision is active per environment.
The model is promotion-gated single-active deploys: the active deploy is control-plane state, HubSpot routes point directly at the deployed Worker, and there is no router layer in the request path. Blue/green revisioned routing, where multiple revisions stay live and a pointer flips traffic between them, is a later design that has not shipped.
A deploy cannot become active until the control plane has seen a healthy attestation for that exact deploy id — and a Worker that receives no traffic never attests, because heartbeats ride live requests. --promote-when-healthy handles this by pinging the fresh Worker itself while it polls. A manual hs-x promote against a never-invoked Worker is refused with Deploy cannot be promoted until its latest attestation is healthy.
The state machine
A deploy record carries one of five statuses. Four are reachable in practice; planned is the pre-record stage that lives as a separate plan row rather than a revision in your history.
| Status | Meaning | How a deploy gets here |
|---|---|---|
planned | A deploy id, resource names, and short-lived credential leases exist; nothing is recorded yet | Every linked deploy starts with a plan request |
recorded | The revision is in the registry: manifest hash, archived bundle key, provenance (CLI version, git commit/branch, who deployed) | Recording, the default on linked deploys |
promoted | The active revision for its environment | hs-x promote, --promote-when-healthy, or being a rollback target |
retired | A later deploy was promoted over it | Automatic when the next promotion lands |
rolled-back | Was active; explicitly reverted away from | hs-x rollback |
The transitions, exhaustively:
recordedtopromotedis the attestation-gated transition. It requires the project's latest drift snapshot to name this deploy id and readhealthy(§03).promotedtoretiredhappens automatically: promoting deploy B retires deploy A in the same write.promotedtorolled-backhappens only through rollback, in the same write that re-promotes the target.retiredorrolled-backback topromotedis what rollback is: re-promoting a previous record.
Two bookkeeping rules shape what you see in history. Retention is bounded: after every promotion or rollback, terminal inactive revisions (retired or rolled-back, and not referenced by any route) beyond the three most recent are deleted, with an audit row naming the pruned ids. And deploy ids encode their origin: a linked deploy gets a control-plane counter id like deploy_acme-crm_007; an unlinked deploy mints deploy_acme-crm_<epoch>_<hex> locally.
Every transition writes an audit row (DEPLOY_PLANNED, DEPLOY_RECORDED, DEPLOY_PROMOTED, DEPLOY_ROLLED_BACK), which is also what feeds the dashboard's notification feed.
What one hs-x deploy run does
A deploy run is a fixed sequence of phases; flags select which ones execute. In order: validate the project and discover workers, compute (and optionally apply) the portal-schema plan, obtain a deploy plan, push Workers to Cloudflare, record the revision (and optionally wait-and-promote), then upload the HubSpot project and verify its build and its deploy.
For a linked deploy, the plan phase is POST /v1/deploys/plan: it mints the deploy id, the Cloudflare worker names, and two ten-minute credential leases (HubSpot project upload; Cloudflare provision and attest). It refuses with 409 hubspot_not_connected or 409 cloudflare_not_connected if either side of the account isn't connected. An unlinked deploy mints an equivalent plan locally and proceeds straight to Cloudflare.
--plan (alias --dry-run) stops before anything changes: no Cloudflare push, no HubSpot upload, no confirmation prompt, and no control-plane traffic unless you explicitly pass --control-plane-url. It still validates, computes the portal-schema diff when --portal-schema-live is along for the ride, and writes the local artifacts: .hs-x/manifest.json always, plus the generated Alchemy program (.hs-x/alchemy.run.ts) once a project id is in play.
A mutating run in a TTY pauses on a plan summary before touching anything:
Planned changes:
account: acct_demo
project: acme-crm
workers: 2 (5 capabilities)
control plane: https://api.hs-x.dev
record: yes + promote-when-healthy
cloudflare: deploy
hubspot upload: yesThe block renders above an Apply this plan? prompt; --yes (or -y) skips the prompt. With --json the full result object is emitted instead, including the manifest, the plan, the record, any promotion, and the per-phase results.
Recording is the default on linked deploys, and skipping it has a concrete cost: the Worker will attest a deploy id the control plane has no record of, and drift reads unknown_code from then on. --no-record opts out anyway; --record-local and --promote-when-healthy force recording.
Exit codes are deliberate, so automation can tell the failure shapes apart:
| Exit | Meaning |
|---|---|
0 | Clean run (including --plan) |
20 | Partial: the Cloudflare Worker is live but the HubSpot upload failed (HSX_W_DEPLOY_PARTIAL) |
1 | Failure |
10 | Input error (missing account/project id, invalid plan request, missing schema scopes) |
130 | Cancelled at a prompt |
The HubSpot phase verifies both the build and the deploy. The CLI polls the build until terminal, then, when the HubSpot project has auto-deploy enabled, polls the auto-deploy task too. A green HubSpot build does not mean the app shipped — auto-deploy can still fail (for example, with Apps are not allowed to have 0 redirect URLs). Success prints HubSpot deploy: auto-deploy SUCCESS (build #N); failure warns HSX_W_DEPLOY_HUBSPOT_AUTODEPLOY_FAILED with each failing component's error verbatim. The warning is loud but the exit code follows the build, so read the output, not just $?.
The heartbeat that gates promotion
A linked deploy bakes attestation wiring into the generated Worker entrypoint (on by default; --no-heartbeat or heartbeat: false in hsx.config.ts disables it). The Worker posts this payload to POST /v1/projects/:projectId/attestation:
| Field | What it carries |
|---|---|
deployId | The deploy id baked in at deploy time |
manifestHash | Hash of the manifest the Worker is actually running |
bindingFingerprint | Fingerprint of its live bindings |
sdkVersion | The runtime version |
taggedResourceCount | Count of HS-X-tagged resources visible to it |
hsXAccountId, projectId, environment | Identity |
timestamp | When the payload was built |
The cadence matters more than the payload. Heartbeats ride live requests: Cloudflare's runtime does not keep background loops alive after a request ends, so the runtime sends attestations through ctx.waitUntil on incoming traffic, throttled to one per 15 minutes per isolate. A fresh isolate attests on its very first request, which is what keeps promotion fast after a deploy. The corollary: a deployed Worker that nobody calls never attests, and its promotion stays gated forever. A failed heartbeat logs a warning and never affects the request it rode in on; the next attempt rides a later request.
The control plane stores each attestation as the project's drift snapshot (latest wins, one per project) and classifies it:
| Drift state | Produced when |
|---|---|
healthy | The attested deploy id has a record, identity and manifest hash match, and tagged resources exist |
unknown_code | No record matches the attested deploy id, or identity/manifest hash mismatch the record |
resource_missing | The attestation reports zero HS-X-tagged resources |
drifted | The attesting deploy is past recorded (promoted, retired, or rolled back) but is no longer the active route for its environment |
The DriftState schema declares three more values (credential_revoked, billing_untrusted, unknown) for credential-health and billing-trust checks; nothing produces them today, though the promotion wait already treats billing_untrusted as fatal if it ever appears.
One classification detail makes the whole single-active model work: the active-route comparison only applies to records that have left recorded. A recorded-but-unpromoted candidate attests healthy even while a different deploy is active, so a new deploy can pass the gate without touching the current one.
Two heartbeats that are not this one: an unlinked deploy with --heartbeat (or heartbeat: true in config) emits a single anonymous heartbeat.observed machine event for the dashboard's machine history, not an attestation, and there is no gate to feed because unlinked deploys activate themselves (§05). The checkpoint stream that powers hs-x logs is also separate; see the rate-limits reference for how those records read.
Promotion, manual and automatic
Promotion is POST /v1/deploys/:deployId/promote. The control plane checks, in order:
- A recorded deploy exists for the id (
404 deploy_record_not_found). - The request's account and project match the record (
409 deploy_promotion_mismatch). - The project's drift snapshot names this deploy id and reads
healthy(409 deploy_not_healthy:Deploy cannot be promoted until its latest attestation is healthy.).
When all three pass, one batched write does everything: the record becomes promoted, the previously active record (if any) becomes retired, the project's route for the attested environment is rewritten to name the new deploy with reason: promote, retention pruning runs, and the audit row lands. The response is the promotion result:
{
"projectId": "acme-crm",
"promoted": {
"deployId": "deploy_acme-crm_007",
"accountId": "acct_demo",
"projectId": "acme-crm",
"manifestHash": "sha256:91c4f2aa30b817d2",
"bundleKey": "r2://bundles/acme-crm/deploy_acme-crm_007.tar.gz",
"status": "promoted",
"recordedAt": "2026-06-11T14:01:38.000Z",
"cliVersion": "0.2.5"
},
"previousActive": {
"deployId": "deploy_acme-crm_006",
"accountId": "acct_demo",
"projectId": "acme-crm",
"manifestHash": "sha256:5b7d09c3e2f6a1d8",
"bundleKey": "r2://bundles/acme-crm/deploy_acme-crm_006.tar.gz",
"status": "retired",
"recordedAt": "2026-06-10T22:14:09.000Z"
}
}The usual way to drive this is hs-x deploy --promote-when-healthy, which closes the gap between "the Worker is live" and "the Worker has attested". After recording, the CLI polls GET /v1/projects/:id/drift once per second, and on each poll it pings the deployed Worker's /_hsx/health endpoint, precisely so the per-request heartbeat fires on a Worker that has no other traffic yet. The wait resolves three ways:
- The snapshot names this deploy and reads
healthy: the CLI promotes and printsPromoted deploy: <id>. - The snapshot names this deploy but reads
unknown_code,resource_missing, orbilling_untrusted: fail immediately, because these states do not self-heal by waiting. - Nothing healthy within the timeout (default 60 seconds;
--promotion-timeout-msraises it):Timed out waiting for deploy <id> to become healthy; latest drift state was <state>.
Manual promotion is hs-x promote --deploy-id <id> --project-id <id> --account-id <id> (alias: hs-x deploy promote). It is a linked-mode operation: without a logged-in HS-X session it exits 10 with HSX_E_LINK_REQUIRED, because unlinked deploys have no staged candidate to promote: they become active automatically when they succeed. With --json the success shape is { ok, command: "promote", linkState: "linked", mode: "control-plane-promote", promotion }.
What rollback actually does
hs-x rollback on a linked project is POST /v1/projects/:id/rollback, and mechanically it is a re-promotion of a previous record. Target selection: --deploy-id names a specific revision; without it, the most recent retired or rolled-back revision is chosen. The control plane then enforces three rules:
- There must be an active deploy to roll back from (
409 rollback_no_active_deploy), and a target distinct from it (409 rollback_candidate_not_found). - The target must have an archived bundle. Revisions backfilled from unlinked history at link time never had a bundle archived, so they are history-only and refused as targets (
409 rollback_target_has_no_bundle). - Non-adjacent targets need
--force. Rolling back past the immediately previous revision returns409 rollback_requires_forcewith a compatibility warning; with--forcethe rollback proceeds and the warning is carried in the result'swarningsarray.
The write flips both records at once: the active revision becomes rolled-back, the target becomes promoted, and the route is rewritten with reason: rollback. The CLI renders it directly:
Rolled back acme-crm
Previous active: deploy_acme-crm_007
New active: deploy_acme-crm_005
Warning: Rollback target deploy_acme-crm_005 is not adjacent to active deploy deploy_acme-crm_007; cross-deploy compatibility requires review.The scope of the operation deserves a precise statement. Rollback re-points control-plane state; it does not move live Cloudflare traffic. In the current topology there is no router Worker and no revision pointer in the request path: each deploy overwrites the project's Workers in place, so whatever code shipped last is what serves. After a rollback, the running Worker still attests the deploy id you rolled back from, which no longer matches the active route, so the next heartbeat lands as drifted: drift telling you, correctly, that live code and the active revision disagree. Converge by deploying the rolled-back-to source again (and promoting it), at which point attestation matches and the verdict returns to healthy. Sub-second traffic flips between concurrently live revisions are the deferred revisioned-routing design; today's rollback is the registry operation.
Unlinked rollback is the same idea against local state: it rewrites the active-deploy pointer in the project's tenant Cloudflare KV. It requires --project-id and --deploy-id, checks the target's environment matches (--environment, default production), no-ops if the target is already active, and prints its own scope warning verbatim: Active pointer updated in tenant state. Without linked revisioned routing this does not redirect live Cloudflare traffic; redeploy or manually re-route to the rolled-back worker if needed.
Reading the active deploy
The control plane keeps one routing entry per environment per project: the active deploy id, the previous one, when it changed, and whether the change was a promote or a rollback. hs-x routes --project <id> (aliases: route, routing) reads it:
$ hs-x routes --project acme-crm
production active=deploy_acme-crm_007 previous=deploy_acme-crm_006 reason=promote updated=2026-06-11T14:02:11.000ZUntil a project's first promotion there is no routing state at all, and the read returns No routing state exists for this project. That is normal for a project that has only ever recorded deploys.
Three sibling reads complete the picture. hs-x status --project <id> folds routing, drift, and invocation telemetry into one panel (Routes: none active before the first promotion). hs-x drift --project <id> prints the latest attestation verdict with its reason. And the revisions read, which backs the dashboard's Deploys tab, returns the full retained history with the active deploy id called out. The route entry is the authoritative answer to "which revision is active"; the deploys table answers "what exists"; drift answers "does what's running match".
The failure modes you'll actually hit
| What you see | What it means | What to do |
|---|---|---|
Deploy cannot be promoted until its latest attestation is healthy. | The Worker hasn't attested this deploy id yet, or the snapshot is unhealthy | Send the Worker any request (/_hsx/health works) and re-promote, or use --promote-when-healthy, which pings for you. Check hs-x drift for the verdict |
Timed out waiting for deploy <id> to become healthy; latest drift state was <state>. | The 60-second promote wait expired | Raise --promotion-timeout-ms; confirm the Worker deployed and is reachable |
Drift unknown_code | The Worker attests a deploy id or manifest hash the control plane has no record of (usually --no-record, or a deploy outside HS-X) | Re-run hs-x deploy without --no-record; the next heartbeat clears it |
Drift resource_missing | The attestation reported zero HS-X-tagged resources | Something deleted or renamed the provisioned resources; redeploy |
Drift drifted right after a rollback | The live Worker still runs the rolled-back-from code, which no longer matches the active route | Expected (§05). Deploy the target revision's source to converge |
HSX_E_LINK_REQUIRED from hs-x promote | Promote is linked-mode only | Run hs-x link; unlinked deploys activate automatically, and unlinked rollback moves the local pointer |
No valid session. Run hs-x login. during the record phase | --promote-when-healthy (or recording) reached the control plane without a session | Log in, or drop the flag for an unlinked deploy |
HSX_W_DEPLOY_HUBSPOT_AUTODEPLOY_FAILED | The HubSpot build succeeded but auto-deploy failed; the app did not ship | Read the per-component errors printed below the warning, fix (e.g. add a redirect URL), redeploy |
Exit code 20 with HSX_W_DEPLOY_PARTIAL | The Cloudflare Worker is live; only the HubSpot upload failed | Fix the upload error and re-run; don't tear down the serving Worker |
409 rollback_requires_force | The rollback target isn't the immediately previous revision | Re-run with --force after reviewing cross-deploy compatibility |
409 rollback_target_has_no_bundle | The target was backfilled from local history; no archived bundle exists | Pick a CLI-recorded revision, or redeploy the old source directly |