Divvent

For Divvent’s own team. It is generated from the operations document in our repository, which is the one to edit. If you use Divvent, the help centre is for you.

6i. Customer deploys on Cloudflare: the deployer Container

DESIGN.md §17, "Customer deploys run on Cloudflare, not on GitHub Actions", and decision 38. Switched on (2026-09-28, Ant's instruction, on image run 30): the deploys queue's consumer is deployer, so a customer deploy runs in a DeployContainer on Cloudflare. The dispatcher's scheduled floor and the runner path (§6h) still work, and setting the consumer back to dispatcher is the fallback.

What it is. A Worker, deployer, consumes the deploys queue. For each message it starts one DeployContainer instance for that request alone (deploy:<request>@<image>#<attempt>). The instance runs divvent deploys --request <id> --stored, the same code the runner's run job runs. It destroys the instance when the request settles, whatever it settled as. It builds nothing: --stored deploys the build container's own bytes for the hash, read from the artifacts bucket and held to the registry's built_digest. Where no stored build exists, it asks the builder for one and puts the request back on the queue. A rehearsed land stages its rehearsal in that instance. Its generated suites then run in a SuiteContainer instance with no environment, no bindings and no way back to the Worker, and a deployer instance lands it with their verdict.

A rehearsed land is three steps, each on the delivery's clock (decision 99, packages/workers/deployer/src/steps.ts). Stage, suites, land. The staging pass stores its bundle through the door before it answers, and the Worker stores the suites' verdict beside it, in the artifacts bucket under rehearsals/<request>/<rehearsal>/ (deleted when the request settles). Each delivery reads where the request stands and runs the next step, so a delivery Cloudflare ends mid-way is resumed by the next rather than rehearsed again an hour on. A delivery waits at most twelve minutes (INVOCATION_BUDGET_MS); a step it has no room left for, or one that outruns it, is put back on the deploys queue as a continuation — acked, never a retry, so Cloudflare's attempts are for failures. The suites run under the request's claim, and the land under its pass's: a delivery that finds another deployer delivery's claim live waits a minute and looks again, and once the claim is stale (CLAIM_MINUTES, twenty) it is taken over, once.

What it holds is in docs/SECURITY.md §18. In short:

The switch. Do these in order. Skipping a step is refused where it can be:

  1. Build an image that carries the deploy server. Dispatch image.yml from a commit at or after this one. The image now also carries the CLI (packages/cli/container/deploy-server.mjs and suite-server.mjs), and it is the first image whose builds store their built bytes (built_digest).
  2. Pin its digest three times, identically. Put the five lines image.yml prints (image, builtFrom, engine, engineFingerprint, toolchain) on each of containers.BUILD, containers.DEPLOYER and containers.SUITES in infra/control-plane.json. Commit and push. The staging deploy creates both new container applications. From then on every build stores its bytes. Rows built by an older image have none, and are rebuilt the first time the deployer is asked to deploy them (below).
  3. Check that the token can do it. The control-plane deploy token already has Workers Containers:Write for the builder (§1.3). The same scope creates the two new applications. No new permission is needed.
  4. Switch. In infra/control-plane.json, set queues[DEPLOYS].consumer from "dispatcher" to "deployer", then commit and push. A queue has one consumer, so the deploy detaches the dispatcher first and says so (replaced).
  5. Watch one deploy. Approve a change on a staging test tenant. Then check two things:
    • wrangler tail divvent-staging-deployer shows a {"deployer":…} line for each stage (deploy, then suites and land where the land was rehearsed), ending in {"outcome":"done"};
    • divvent doctor --env staging --require heatco is green. No deploys.yml run should have started for it.

The fallback. Set queues[DEPLOYS].consumer back to "dispatcher", then commit and push. The deploy moves the consumer back, and the next message dispatches a runner as before. Nothing else changes, because the runner path was never removed. Leaving the containers pinned is harmless: nothing starts them without a message.

Staging, previews and the renewal go through it too (Ant, 2026-09-29). None of these deploys from a runner any more:

Both paths at once is safe. The dispatcher's scheduled floor (§6d) keeps running whichever Worker consumes the queue. So a pass on a runner can meet a request the container is deploying. The claim is a compare-and-swap on the runner's name, so one of them deploys it and the other records claimed-elsewhere and moves on. The floor is also what picks up a request the container left:

Reading it. Each line in wrangler tail divvent-<env>-deployer is JSON. It names the request and a stage or how the request ended, and nothing the pass or the suites printed: that is customer output, and it stays out of a log the platform keeps. Why a request ended as it did is on the request, in the console's queue and the customer's portal, as it was on a runner:

OutcomeWhat it meansWhat to do
done, parked, blocked, failedSettled. The request says whyAs on a runner
building, unplannedNo stored build of that hash yet. The builder was asked, and the message is retried in two minutes, three deliveries at mostNothing, unless it outlasts the build. Then the runner's floor takes it
rehearsingThe suites have not answered, or nothing stored the bundle and the hour has not passedAs building. The hour rehearses it again
continued with a whyCarried on to the next delivery, on the deploys queue: its next step had no room in this one (a deploy.continued row on the request), a step outran the clock (DeployOverdue or SuitesOverdue in the fault list, and a row), or another delivery holds its claimNothing. One overrun is a slow step; the same request overrunning every delivery is a pass that never finishes — read its rows
abandonedCarried on for ninety minutes, or four overruns, without settling (DeployAbandoned)Left queued for the rollout's hourly ask and the runner's floor. Find out why its steps never finish before retrying it
unconfiguredNo key or no image on this WorkerSteps 1–2, or the key (§9.3)
retry with a reasonThe instance could not run the pass. The reason is the Worker's own sentence, or the class of an error it did not writeRead the request's detail in the console. Reproduce on a runner (the fallback) if it needs the pass's output
A DoorRefusal on the requestThe pass asked the door for something that is not its request's. That is a bug in the pass, or an instance that is not behavingTell Ant with the request id. The refusal names the rule, not the data

Where a release's minutes went (decision 100). Every step writes how long it took on the request's application, so a release is measured off its rows rather than the gaps between them:

RowWritten bySays
deploy.stepThe deployer Worker, after each stepthe deploy step took 184s — the stage (or the whole pass, where nothing is rehearsed), suites, land: the instance started, the pass or the suites run, the answer read
deploy.timing rehearsal … stagedThe staging pass (needs an image from deploy-speed or later)Its compile, the clearing of what an earlier one left, the time before the rehearsal's deploy (materialising, the compile, the stored build, the credentials), each deploy phase, the schema read, the teardown and the bundle
deploy.timing rehearsal … copy of productionThe land passThe copy's whole time; its steps are the deploy.upgrade.rehearsal.copy rows
deploy.timing landThe land passbefore the deploy, then preflight, snapshot, stage, migrate, smoke, flip and watch — the last is the wait after the flip's steps for the five-minute window, which opens at the flip
deploy.done, then deploy.watchedThe land passdeploy.done when production serves the revision — the request is done, its watch watching — and deploy.watched when the watch has passed. A watch that fails writes deploy.failed instead and fails the request from done

**done comes before the watch ends** (decision 100, decided (b) by Ant on 2026-10-01). A request is done once production serves its revision, and the portal and the console say "Live, being watched" until its five-minute watch ends. While it is watched the target is busy: a new request for it is refused ("is live on … and being watched"), a release's next step waits, and a canary's bake has not started — it starts when the watch passes. A failed watch fails the request with what it displaced recorded, so the rollback is offered (divvent deploy --rollback, or Roll back in the portal); nothing is rolled back by itself. A request left done with its watch watching after twenty minutes is a runner that died mid-watch: nothing is held to it any more, and the revision serves. Read the target's health, and deploy or roll back as for a stale claim.

They are ordinary audit rows on the application, read wherever its audit trail is. A step much slower than its row usually is, on one request, is the one to read first.

The suites' install goes through Cloudflare's HTTPS interception. Its container trusts /etc/cloudflare/certs/cloudflare-containers-ca.crt (suite-server.mjs points Node at it), and only registry.npmjs.org is let through. The first rehearsed land on staging is where that is seen working. If the install cannot fetch, the rehearsal fails closed and the request is blocked with the reason. The fallback is the runner, whose rehearse leg is unchanged.

What it does not do yet (PROGRESS.md has the detail):