view .md
Reference · Platform

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.

Time
≈ 8 min read
Outcome
A working model of the deploy state machine: which transitions exist, what gates promotion, what rollback actually changes, and how to read the active deploy from the CLI.

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.

If you only read one thing

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.

StatusMeaningHow a deploy gets here
plannedA deploy id, resource names, and short-lived credential leases exist; nothing is recorded yetEvery linked deploy starts with a plan request
recordedThe revision is in the registry: manifest hash, archived bundle key, provenance (CLI version, git commit/branch, who deployed)Recording, the default on linked deploys
promotedThe active revision for its environmenths-x promote, --promote-when-healthy, or being a rollback target
retiredA later deploy was promoted over itAutomatic when the next promotion lands
rolled-backWas active; explicitly reverted away fromhs-x rollback

The transitions, exhaustively:

  1. recorded to promoted is the attestation-gated transition. It requires the project's latest drift snapshot to name this deploy id and read healthy (§03).
  2. promoted to retired happens automatically: promoting deploy B retires deploy A in the same write.
  3. promoted to rolled-back happens only through rollback, in the same write that re-promotes the target.
  4. retired or rolled-back back to promoted is 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: yes

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

ExitMeaning
0Clean run (including --plan)
20Partial: the Cloudflare Worker is live but the HubSpot upload failed (HSX_W_DEPLOY_PARTIAL)
1Failure
10Input error (missing account/project id, invalid plan request, missing schema scopes)
130Cancelled 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:

FieldWhat it carries
deployIdThe deploy id baked in at deploy time
manifestHashHash of the manifest the Worker is actually running
bindingFingerprintFingerprint of its live bindings
sdkVersionThe runtime version
taggedResourceCountCount of HS-X-tagged resources visible to it
hsXAccountId, projectId, environmentIdentity
timestampWhen 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 stateProduced when
healthyThe attested deploy id has a record, identity and manifest hash match, and tagged resources exist
unknown_codeNo record matches the attested deploy id, or identity/manifest hash mismatch the record
resource_missingThe attestation reports zero HS-X-tagged resources
driftedThe 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:

  1. A recorded deploy exists for the id (404 deploy_record_not_found).
  2. The request's account and project match the record (409 deploy_promotion_mismatch).
  3. 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 prints Promoted deploy: <id>.
  • The snapshot names this deploy but reads unknown_code, resource_missing, or billing_untrusted: fail immediately, because these states do not self-heal by waiting.
  • Nothing healthy within the timeout (default 60 seconds; --promotion-timeout-ms raises 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 returns 409 rollback_requires_force with a compatibility warning; with --force the rollback proceeds and the warning is carried in the result's warnings array.

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.000Z

Until 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 seeWhat it meansWhat 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 unhealthySend 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 expiredRaise --promotion-timeout-ms; confirm the Worker deployed and is reachable
Drift unknown_codeThe 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_missingThe attestation reported zero HS-X-tagged resourcesSomething deleted or renamed the provisioned resources; redeploy
Drift drifted right after a rollbackThe live Worker still runs the rolled-back-from code, which no longer matches the active routeExpected (§05). Deploy the target revision's source to converge
HSX_E_LINK_REQUIRED from hs-x promotePromote is linked-mode onlyRun 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 sessionLog in, or drop the flag for an unlinked deploy
HSX_W_DEPLOY_HUBSPOT_AUTODEPLOY_FAILEDThe HubSpot build succeeded but auto-deploy failed; the app did not shipRead the per-component errors printed below the warning, fix (e.g. add a redirect URL), redeploy
Exit code 20 with HSX_W_DEPLOY_PARTIALThe Cloudflare Worker is live; only the HubSpot upload failedFix the upload error and re-run; don't tear down the serving Worker
409 rollback_requires_forceThe rollback target isn't the immediately previous revisionRe-run with --force after reviewing cross-deploy compatibility
409 rollback_target_has_no_bundleThe target was backfilled from local history; no archived bundle existsPick a CLI-recorded revision, or redeploy the old source directly