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.
9. The first production release — BY HAND, once, in this order
9.0 Everything done by hand on staging, and whether production has it
Kept current as each is done, so production is set up from one list rather than from memory of what staging needed. Each row links to its steps.
| Step | Staging | Production |
|---|---|---|
Command signing key and its public half (DIVVENT_COMMAND_SIGNING_KEY secret, DIVVENT_COMMAND_PUBLIC_KEY variable), §6e | done 2026-09-26 | to do: the same, with --env production |
| GitHub workflow permissions read-only, and no Actions approving pull requests | done: enforced by the organisation for every repository | same setting |
| Delete every Actions cache once (anything a tenant build could have written) | done 2026-09-26, 14 caches | same repository |
| Access independent MFA on the console application, "Requires MFA on every access", §6f steps 1–4. On the same page, Login methods must name exactly one identity provider (Google) with Instant Auth on, and Authenticate with Cloudflare One Client stays off, or Cloudflare refuses to save | done 2026-09-26, console and preview consoles | to do, in the production account |
Access App Launcher enabled, with an Allow policy for the operators' addresses (Operators - App Launcher), so each operator can register a factor at the team domain: without it registering says "Please contact your administrator to enable the Access App Launcher" | done 2026-09-26 | to do |
| Access silent-reissue check, §6f step 5 | done 2026-09-26: deleting the console's CF_Authorization asks for the factor again, so the global session needs no shortening | to do |
| The scheduler still gets in after MFA, §6f step 6 | to do | to do |
Re-capture the AI recordings (recordings.yml), §6g | to do | not per environment |
Staging's registry credential for the production image copy (STAGING_REGISTRY_*), §9.3 | — | to do |
| Resume the fleet rollout after the canary's first landing: a canary seeded fresh on a new engine has no upgrade to have passed, so the rest wait for an operator's resume (DESIGN §17, the canary row) | to do after the next staging run | no canary designated yet, so production raises no fleet update |
| A second operator identity admitted by the console's Access policy, with an Access factor of its own registered (§6f), so a break-glass grant — the support readings of §4, or the portal factor reset of §4a — has somebody other than the requester to approve it | to check before the heatco reset: the approver must be a different identity from the requester | to do |
The internal domain (DIVVENT_INTERNAL_DOMAIN, §1.4a): which addresses are ours, for marking a company one of the platform's own and for a second owner on an existing company | nothing by hand: vars.staging names divvent.dev in the manifest, and a deploy puts it on the console | nothing by hand: vars.prod names divvent.co.uk. A production company marked ours — a canary, once one is designated — needs its owner and everybody in it on divvent.co.uk, not divvent.dev |
Stripe, in the sandbox "Divvent Ltd sandbox" (Stage 5 brief 2, packages/control/src/billing.ts): a meter divvent_ai_pence (aggregation Sum); products and prices under the lookup keys the code asks for — divvent_starter_monthly £49/mo, divvent_growth_monthly £149/mo, divvent_scale_monthly £399/mo, and divvent_ai_metered £0.01 a unit on the meter, all four placeholders (plans.ts); a webhook destination divvent-staging → https://app.divvent.dev/billing/stripe with the ten events the code handles: checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.created, invoice.finalized, invoice.updated, invoice.paid, invoice.payment_succeeded, invoice.payment_failed; a restricted key divvent-staging (Billing Meter Events write, Charges and Refunds write, Checkout Sessions write, Customer Portal write, Customers read, Invoices read, Prices read, Products read, Subscriptions write) in the staging environment's STRIPE_SECRET_KEY, and the destination's signing secret in its STRIPE_WEBHOOK_SECRET; the customer portal saved with invoices, customer information and payment methods on, cancellation at period end, and switching plans and changing quantity off (a plan changes through the portal's own page, on the one subscription) | done 2026-09-27 | to do: the live-mode equivalent of each — meter, products and prices under the same lookup keys, a live restricted key with the same permissions, a live webhook destination to production's portal host (https://app.divvent.co.uk/billing/stripe) with the same ten events, the customer portal saved the same way — and both secrets in the production environment |
Stripe Tax — deliberately not activated. Stripe activates Tax only on a fully activated account, and Divvent is not yet a registered company nor VAT-registered (Ant, 2026-09-27), so no origin address or registration is set. Checkout asks for no automatic tax while DIVVENT_STRIPE_AUTOMATIC_TAX is anything but on (vars.<env> in infra/control-plane.json), because Stripe refuses a Checkout that asks while Tax is inactive, and charging VAT unregistered would be wrong | not activated; vars.staging and vars.preview say off | not yet: when the company is VAT-registered, activate Tax on the live account with the UK registration and the origin address, then set vars.prod's DIVVENT_STRIPE_AUTOMATIC_TAX to on |
Resend: the domain the control plane's own mail is from (DIVVENT_MAIL_FROM), verified, and RESEND_API_KEY in the environment | done: divvent.dev verified, its records in Cloudflare's divvent.dev zone with the proxy off; RESEND_API_KEY was already in staging | to do: divvent.co.uk verified, its records beside Google Workspace's in the production zone (PROGRESS.md's bootstrap step 6), and RESEND_API_KEY in production (§9.3) |
The dispatch App divvent-dispatch, Actions read and write only, installed on divvent-hq/divvent only, its three secrets set and the .pem deleted, §6d | done 2026-09-27: DIVVENT_DISPATCH_APP_ID, DIVVENT_DISPATCH_INSTALLATION_ID and DIVVENT_DISPATCH_PRIVATE_KEY in staging | done 2026-09-27: the same three in production, unused until production declares a repository (§6d) |
| Turnstile: the sign-up widget, its site key and its secret, §6b "The edge, reconciled" | done by hand 2026-09-27: widget divvent-staging-signup for app.divvent.dev, managed mode, secret on the portal. From here the deploy owns it by that name (its site key injected, its secret kept on the portal), once the token has Account → Turnstile: Edit — until then staging's sign-up is closed. Then delete the TURNSTILE_SECRET_KEY GitHub secret: nothing reads it | nothing by hand: the deploy creates divvent-prod-signup for app.divvent.co.uk and writes its secret onto the portal, once production's token has Account → Turnstile: Edit |
| Rate limits at the edge, §6b "The edge, reconciled": a Worker binding on every door (no permission needed), and WAF rules where the zone's plan holds them | bindings: nothing by hand. Rules: add Zone → Zone WAF: Edit for divvent.dev to the deploy token; then delete any rule set by hand before 2026-09-27, which takes a slot | add Zone → Zone WAF: Edit for divvent.co.uk to production's token |
The deployer Container (§6i, decision 38): an image.yml run from a commit carrying the deploy server; its five pin lines on containers.BUILD, containers.DEPLOYER and containers.SUITES alike; then queues[DEPLOYS].consumer set to deployer, and one deploy watched with wrangler tail. The fallback is setting the consumer back to dispatcher | not yet: the consumer is dispatcher | not yet. Production copies the one pinned digest (§9.6), so the three pins need nothing more. The switch is the same manifest line, so production switches when staging does unless the owner decides to hold it back |
Custom domains' registrant (proposal 33, §1.7), once: the variable **DIVVENT_REGISTRANT_CONTACT**, Divvent's registrant as JSON ({"email":…,"phone":"+44.…","postal_info":{"name":…,"organization":"Divvent Ltd","address":{…}}}), published redacted. No registrar token here — each company's is its own, sealed per company (§1.3a); a DIVVENT_REGISTRAR_TOKEN secret left in the staging environment is read by nothing and should be deleted, and the token behind it revoked. Funds: none to top up — each registration is charged to the company account's payment method, which §1.2 already requires | to do: the variable in the staging environment; delete any DIVVENT_REGISTRAR_TOKEN | nothing until production runs a domains pass |
| Custom domains, per company: nothing by hand but its registrar credential (§1.3a). The company's deploy token carries no zone rows: the pass mints a token for the one edge zone and revokes it (§1.7); Cloudflare for SaaS is enabled on a new edge zone in the dashboard until Cloudflare offers another way (§1.7, raised with Ant) | none yet: no company has asked | the same |
RESEND_ADMIN_KEY: a Resend full-access API key in the environment's secrets, for sending domains and restricted sending keys (proposal 33 §5); the control-plane deploy step puts it on the portal, and deploys.yml's domains pass reads the same secret on its own step to re-check sending domains and delete them at Resend (§1.7) — nothing more to set | to do | to do |
Registering the shared trial account: its Cloudflare account id and deploy token, on the console's Billing page (registerTrialAccount; Stage 5 brief 1, docs/proposals/29-stage5-briefs.md) | not yet | not yet |
production.yml runs on a v* tag: the disaster-recovery drill against staging, then — behind the production environment's approval — the promote of staging's artifact by hash into "Divvent — production", the perimeter, and a smoke from outside. It has never run. acceptance/test/first-release.test.ts runs its stage lists and its environment against an empty fake account, and workflow-stages.test.ts walks it step by step; what follows is everything a test cannot do, because each is a person in somebody else's console.
Nothing below is optional except where it says so. A step skipped is a release that stops at it — most of them loudly, and the ones that do not are marked.
9.1 The Cloudflare account — "Divvent — production"
- The account exists (bootstrap step 1) and is on the plan staging is on. Containers need Workers Paid.
- R2 enabled in the dashboard — terms and a billing method. No token permission substitutes, and the deploy stops at the bucket without it.
- **
divvent.co.ukis a zone in this account** (bootstrap step 4: move it once the registrar lock lifts). Thednsstage attachesdivvent.co.uk,www.divvent.co.uk,app.divvent.co.ukandconsole.divvent.co.ukto their Workers and creates no zone, so the release stops atdnswithout it. - **Zero Trust enabled with the team name
divvent**, so its auth domain isdivvent.cloudflareaccess.com— the manifest'steam.prod, which the Google client below is registered against. Pick the Free plan if asked. - An Access service token, in Zero Trust → Access → Service Auth, named
divvent-scheduler. Its id and secret go in the GitHub environment below asDIVVENT_ACCESS_CLIENT_IDandDIVVENT_ACCESS_CLIENT_SECRET— the values only, not theCF-Access-Client-Id:prefix the dashboard shows beside them. Needed before the first tag, because the manifest declares the pair forprodand theworkersstage refuses without it. (It is created here rather than by the deploy because its secret is shown once, and the deploy has nowhere to put it but a log.) Do not add a policy for it: theaccessstage puts the Service Auth policy on the console's application itself, namedscheduler service tokenand naming this token only (STAGE2.md §2.4), and it refuses — naming the two variables — if the token is not in the account. It then renders the console through the perimeter with the token, and waits up to a minute, asking every five seconds, for Access to take a policy it has just written; "turned away" after that is a real failure. Only that check waits: the one asking with no credential is answered once. - One account-scoped deploy token, with exactly the rows
.dev.vars.examplelists for the control plane — the tenant four (Workers Scripts,D1,Workers R2 Storage,Account API Tokens), thenQueues,Workers Containers,Cloudchamber,Access: Apps and Policies,Access: Organizations, Identity Providers, and GroupsandZero Trust, all Edit, andAccess: Service Tokensat Read, andTurnstile: Edit(the sign-up widget) — **plus three zone rows ondivvent.co.uk**,Workers Routes: Edit,Zone: ReadandZone WAF: Edit(the rate-limiting rules), which the editor puts in a separate section. Copy staging's token's rows rather than reading this list, if the two ever disagree: the file is what was read off staging's token, and staging deploys.Check it from a terminal holding it, before the tag:
divvent control-plane token --env prod
9.1a The scheduler's door, on staging — BY HAND, once
The scheduler had never completed a pass: Access refused its token and the console refused its assertion (STAGE2.md §2.4). The code for both halves is in; three things on the account and the repository are Ant's:
- **Add
Access: Service Tokens→ Read to staging's deploy token** (DIVVENT_DEPLOY_API_TOKENin thestagingenvironment). Without it theaccessstage refuses in its preflight, names that row and changes nothing.divvent control-plane token --env stagingshows it before a push. - **Check that
DIVVENT_ACCESS_CLIENT_ID/_SECRETin thestagingenvironment are a live service token in the staging account** (Zero Trust → Access → Service credentials). If they are unset, or the token was deleted or made elsewhere, create one calleddivvent-schedulerand set both — values only. Theaccessstage refuses, naming both variables, when the id matches no token in the account. - Delete any Service Auth policy added to "Divvent console" by hand, which
.dev.vars.exampleused to ask for. The deploy owns the one calledscheduler service tokenat precedence 2; a second one would be a second door nobody reviews, and one at the same precedence may collide with it. - Optional: delete the
DIVVENT_ACCESS_SERVICE_TOKENsecret in theproductionenvironment.health.ymlno longer reads it — the fallback presents the scheduler's pair, as the Worker does. - Optional: delete the
DIVVENT_CONSOLE_URLvariable in theproductionenvironment.scripts/fleet-health.mjscomposes the console frominfra/control-plane.jsonnow, because that is where the pair is sent, and refuses to run if the variable names any other console.
Nothing else. DIVVENT_SYSTEM_CLIENT_ID on the console is set by the deploy from the runner's DIVVENT_ACCESS_CLIENT_ID — it is a var the deploy writes, not one to set. The first push after (1)–(3) carries the client id to the console in its deploy step and reconciles the policy in its last step; from the next tick the console's fault list stops saying Error on cache-warm and divvent doctor's cache-warm section stops saying never pinged.
Production is the same three at its first release: 9.1 step 5 and step 6 above.
9.2 Google — the OAuth client Access signs operators in with
In the Google Cloud console of the divvent.co.uk Workspace, by a Workspace admin:
- An OAuth client, type Web application, with one authorised redirect URI:
https://divvent.cloudflareaccess.com/cdn-cgi/access/callback. - The consent screen Internal, and the Admin SDK API enabled on the same project — without it Cloudflare cannot read Workspace groups, the
operatorsrule matches nobody, and the perimeter looks configured and admits no one. - The client id and secret go in the GitHub environment as
PRODUCTION_GOOGLE_CLIENT_IDandPRODUCTION_GOOGLE_CLIENT_SECRET. Optional for the first tag: without them theaccessstage skips and says so, and the console has no door until a later release runs it.
9.3 GitHub — the production environment
Settings → Environments → **production**:
- A required reviewer — Ant. That is the approval; without it the
environment: productionline gates nothing. - **Deployment branches and tags: tags matching
v*.** - Secrets, spelled exactly as
.dev.varsspells them, with production's own values — never staging's:Secret What DIVVENT_DEPLOY_API_TOKENthe token from 9.1 step 6 DIVVENT_DEPLOY_ACCOUNT_ID"Divvent — production"'s account id BETTER_AUTH_SECRETthe portal's session key DIVVENT_OIDC_SIGNING_KEYthe portal's issuer key CREDENTIAL_SEALING_KEY32 bytes, base64 DEPLOYER_OPENING_KEYthe same value as the sealing key RESEND_API_KEYthe platform's Resend key, with divvent.co.ukverified (bootstrap step 6)DIVVENT_ACCESS_CLIENT_ID,DIVVENT_ACCESS_CLIENT_SECRET9.1 step 5 PRODUCTION_GOOGLE_CLIENT_ID,PRODUCTION_GOOGLE_CLIENT_SECRET9.2 STAGING_REGISTRY_ACCOUNT_ID,STAGING_REGISTRY_API_TOKENstaging's account id, and a token in staging's account with Workers Containers(orCloudchamber) and nothing else — not staging's deploy token. Be clear what it can do: that permission mints registry credentials, push as well as pull, and manages staging's containers. So it is on one step of the release alone, which mints a fifteen-minute pull-only credential and hands that to the copy (§9.6); the step that copies never holds the tokenVariables:
DIVVENT_DEPLOY_TARGET=sub-account,DIVVENT_OPERATOR= whoever is releasing. LeaveDIVVENT_OIDC_KEY_IDunset.Replacing one of these replaces it on the Workers at the next deploy.
control-plane deploywrites every secret it is given to every Worker that holds it, on every run, so the environment is the record of what the control plane runs with — a value changed here, or leaked and changed here, is gone from Cloudflare when the next run finishes. (Before 2026-09-26 it put a secret only where a Worker had none, and a replaced value never arrived.) Two of them are not rotations to make casually:CREDENTIAL_SEALING_KEYandDEPLOYER_OPENING_KEYare one key under two names, replaced together or not at all, and every credential sealed under the old one is unreadable after. A pair that is two keys is refused wherever both are present, before anything is sealed or opened:control-plane deployrefuses before it uploads, and every command that reaches a control plane (deploys,deploy --via,tenant register) before it asks it anything — by sealing a random probe with one and opening it with the other, in memory, and saying neither value.divvent secrets checkasks the same question on its own, from this shell's environment or.dev.vars, after setting or rotating them;BETTER_AUTH_SECRETsigns every portal session out and makes each portal member's authenticator enrolment undecryptable. Run control-plane deploys from the environment's workflow: one run from a terminal whose.dev.varsdiffers puts those values on the Workers, until the workflow's next run puts the environment's back.Make them in a checkout of their own, so staging's
.dev.varsis not overwritten.divvent secrets initasks for the account id, the token, the operator and the Resend key, and generates the auth secret, the signing key and the sealing pair in the shapes the code parses.divvent secrets export --github-env production --script production-secrets.ps1then writes a PowerShell script that reads that.dev.varswhen it runs and hands each value togh secret seton stdin — no value in a command line, a log or the script. Delete that.dev.varsafterwards; the environment is where production's values live. The script also sets names this release does not read (the account registry, the namespace), which is harmless, and names the Google pairDIVVENT_GOOGLE_*— setPRODUCTION_GOOGLE_*by hand, the only two production spells differently. - A tag ruleset, so only Ant can make a release tag. Settings → Rules → Rulesets → New ruleset → New tag ruleset: name
releases, enforcement Active, target Include by patternv*, and tick Restrict creations, Restrict updates and Restrict deletions. Bypass list: Repository admin (Ant) and nobody else — not a team, not an App. Theproductionenvironment admitsv*tags, so without this anybody who can push can make the thing it admits; the approval is still Ant's, but it is an approval of whatever a tag was pushed on.production.ymlrefuses a tag whose commit is not onmainbefore any secret is in reach, and promotes only apushrun ofstaging.ymlonmain— this ruleset is the half a workflow cannot do, which is who may push the tag at all. By hand, because a ruleset is repository settings and no workflow here holds a token that can write them.
9.3a The sealing key: an offline copy in two shares, and rotating it — BY HAND
CREDENTIAL_SEALING_KEY and DEPLOYER_OPENING_KEY are one AES key (§9.3's table). It opens every tenant's Cloudflare token and every target's BETTER_AUTH_SECRET, which encrypts its users' authenticator secrets. GitHub environment secrets cannot be read back, so until this section is done the key has no copy anywhere: lose it and every tenant is registered again by hand, and every end user of every application enrols their authenticator again (the full analysis, S4). Do this per environment, staging and production each with its own key.
The custodians are Ant's to name. Two people, not the same person, and not both reachable by one compromise — for example Ant and a named second operator under NDA (the analysis's A5 asks for that person anyway), or Ant and a solicitor's safe. Neither can use their share alone: each is 32 random bytes. Until two are named, this section is not done, and PROGRESS.md says so.
Making the offline copy — on a trusted machine, never a runner:
- Put the environment's key in this shell as
DEPLOYER_OPENING_KEY(from where it was generated, or joined from an earlier pair of shares). Never as an argument. divvent secrets split --out-a share-a.txt --out-b share-b.txt. It prints the key's fingerprint — sixteen hex characters that name the key and are no part of it — and writes each share with the same fingerprint on it.- Give
share-a.txtto the first custodian andshare-b.txtto the second, each offline: printed and sealed, or on a hardware-encrypted drive. Never both in one place, one email, one password manager or one cloud drive. - Delete both files from the machine, and write the fingerprint, the date and the two custodians' names in the operations log.
- Prove it before you need it: with both shares,
divvent secrets join --a share-a.txt --b share-b.txt --out joined.keymust print the same fingerprint. Deletejoined.key. Once a year, and after any change of custodian.
The recovery key the off-site backups are sealed to (§3d) is held the same way, by the same custodians: put its contents in this shell as DIVVENT_RECOVERY_PRIVATE_KEY and run divvent secrets split --key-env DIVVENT_RECOVERY_PRIVATE_KEY --out-a … --out-b …. Its public half is a variable and needs no custody.
Rotating the key — after a leak (docs/runbooks/compromised-sealing-key.md), after a custodian leaves, or yearly:
- Generate the new key:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"in a shell whose history is off, ordivvent secrets initinto a scratch directory. - In this shell (or
.dev.vars):DIVVENT_RESEAL_FROM_KEY= the old key,DIVVENT_RESEAL_TO_KEY= the new one, plus the platform token and account id, and bothCREDENTIAL_SEALING_KEYandDEPLOYER_OPENING_KEY= the old key (the CLI holds the pair to each other). - Pause what opens: no
deploys.ymldispatch, and the schedule will fail its opens harmlessly until step 5 (a deploy that cannot open retries on the next pass). divvent secrets reseal --env <env>— every sealed value moved to the new key, audited per column (secrets.reseal). It prints fingerprints and counts, never a value. A value somebody re-sealed while it ran is left and counted.- At once, set
CREDENTIAL_SEALING_KEYandDEPLOYER_OPENING_KEYto the new key everywhere they are held: the environment's GitHub secrets and the control plane's Workers (astaging.ymlrun, orproduction.ymlfor production, puts them), and your own shell. divvent secrets reseal --env <env>again — it catches anything the control plane sealed under the old key between steps 4 and 5 — thendivvent secrets reseal --env <env> --check, which fails while anything is left under the old key and says "nothing is under the old key" when done.- Split the new key for the custodians (above), have them destroy the old shares, and record the new fingerprint. Destroy every copy of the old key.
Nothing downstream changes: a tenant's token is the same token and a target's auth secret the same secret, so no customer re-registers and no end user re-enrols.
9.4 GitHub — the staging environment
The release's first job, the recovery drill, runs in **staging** with staging's own DIVVENT_DEPLOY_API_TOKEN, DIVVENT_DEPLOY_ACCOUNT_ID and DEPLOYER_OPENING_KEY. **If staging restricts which refs may deploy, add tags matching v*** beside main — otherwise the drill is refused and the release never reaches the approval, which is right, and confusing the first time.
The domains pass (deploys.yml, "Reconcile customer domains") reads one more from staging, set once (§9.0): the variable DIVVENT_REGISTRANT_CONTACT. Each company's registrar credential is its own, sealed in the control plane (§1.3a); without one, a company whose customers verify a domain, and whose own domain is not already a zone in its account, waits — said by name, and failing that run.
The drill is also runnable by hand from a terminal holding staging's values:
divvent control-plane recover --env staging
9.4a The audit digest — BY HAND, once per environment
The console seals the audit log into its hash chain every hour and writes the chain's head once a day to divvent-<environment>-audit-digests, which the deploy creates (review 2026-09-28, S7; SECURITY §12, "The audit log"). Two things only a person does:
- Lock the bucket. In the dashboard, R2 → the bucket → Settings → Bucket lock rules: one rule, the whole bucket (no prefix), retention indefinite (or the longest the company is willing to hold). An object under a lock rule cannot be overwritten or deleted, which is the whole of what the digest is for — without it, somebody holding the account can rewrite a day's digest to agree with a rewritten log. The deploy does not set it: a lock cannot be taken back, and that is a decision, not a step.
- Give the console a signing key. An Ed25519 key, PKCS#8, standard base64, as
AUDIT_DIGEST_SIGNING_KEYin the environment's GitHub secrets — the console's alone (optionalSecrets):node -e "const k=require('crypto').generateKeyPairSync('ed25519');console.log(k.privateKey.export({type:'pkcs8',format:'der'}).toString('base64'));console.error(k.publicKey.export({type:'spki',format:'pem'}))"The first line is the secret; keep the public half (the second) with the release records, since a digest is verified with it and names it (
keyId,ed25519:and sixteen hex of a SHA-256 of itsx). Unset, the digest is written unsigned and every hourly pass says so in the scheduler's log.
Check it: the console's auditIntegrity answers ok: true with nothing unsealed after the next hour, and the bucket holds audit-digest/<environment>/<day>.json after the next day.
**The build image, after fix-auth-audit.** The base image is now pinned by digest (packages/build/container/Dockerfile), and that branch changes the compiler, the runtime and the model the image carries — so pnpm preflight fails until an image.yml run from the merged commit and the three pins (containers.BUILD, containers.DEPLOYER, containers.SUITES) are moved to it, as in §6i step 2. Confirm the digest against Docker Hub first: docker buildx imagetools inspect node:24.11.1-slim must print sha256:48abc13a19400ca3985071e287bd405a1d99306770eb81d61202fb6b65cf0b57 (it was read from two mirrors; Docker Hub rate-limited the check).
9.5 Anthropic — optional for the first tag
A service account divvent-production (Developer, Default workspace), a federation issuer https://app.divvent.co.uk, and a rule for subject divvent:prod:portal. Then the four ids — ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID — go under vars.prod in infra/control-plane.json, as staging's are, in a commit. Until then production's portal serves and its chat says no Anthropic credential is configured, naming these four.
9.6 The one thing that is a decision, not a step
The build image. containers[].image pins divvent-build@sha256:f95b…, a digest pushed to staging's registry by image.yml (staging only). Cloudflare's registry is per account, the builder's upload names the image in production's namespace, and a rebuild does not reproduce a digest. So on the first release the builder Worker names an image production's account does not hold — what Cloudflare does with that upload has not been observed, and the likely answer is a refusal at workers. Either production copies staging's image by digest (pull and push unchanged, which keeps the digest; one job holding both accounts' tokens), or the manifest pins per environment. Decided by Ant, 2026-09-24: production copies staging's image by digest, in one release job behind the production environment's approval (DESIGN.md §17, decision 1). Built (2026-09-25): production.yml's image job, between the drill and the deploy, runs
divvent control-plane copy-image --to prod --tag "$TAG"
which copies containers[].image's own digest from staging's registry into production's with skopeo copy --all --preserve-digests, reads production's manifest back and hashes it (stdout alone, so a warning on stderr is not hashed with it), and fails before anything is deployed if the digest moved. The step before it, divvent control-plane registry-pull, is the only one holding staging's token: it mints a pull-only credential, masks it, and hands it on through $GITHUB_OUTPUT. Every password is masked before skopeo runs. The manifest's one pin is then right for both accounts. The job needs the two STAGING_REGISTRY_* secrets in §9.3, and it is a second approval of the same release — approve image, then production.
9.7 The release
- Staging is green on the commit:
staging.ymlsucceeded for exactly that SHA, including its last step, because that step uploads the hash a release promotes. A docs-only push has no staging run and cannot be tagged. git tag v0.1.0 <sha>andgit push origin v0.1.0.- The recovery job runs against staging, unattended. Red here is a finding about the dump, not a flake: it names the table and never a value.
- Approve the
imagejob in Actions — it copies staging's build image into production's registry by digest (§9.6) — and then theproductionjob, which waits on it. - It checks first that the tagged commit is on
main— both jobs, before any step holds a secret — then builds, finds staging'spushrun onmainfor the SHA, refuses before deploying unless this build hashes to what staging deployed, then deploys every stage staging does, then the perimeter, then smokesapp.divvent.co.ukanddivvent.co.uk. Each secret is on the step that reads it, never the job, so the install and the build never hold one.
9.8 After the first release
- Complete the Google Workspace provider's test in Zero Trust → Settings → Authentication. The
accessstage registers the provider and says it cannot finish it. - Add a Service Auth policy naming
divvent-schedulerto the "Divvent console" Access application the release created. The deploy reconciles its own policies by name and leaves this one alone. Until it exists the scheduler's fleet and cache passes are refused at the perimeter. - Read what is there, from a terminal holding production's values:
divvent doctor --env prod
- Tag again (
v0.1.1on the same commit is enough). The criterion is that a second release is a no-op, and this is the only place it can be seen.
Not built, and stated so nobody expects it: a runner for production's deploy requests. deploys.yml claims staging's queue only, so production has no dispatcher configured (vars.prod deliberately leaves its three names out) and a customer's change approved in production's portal waits in the queue. That is the next piece after this list, and it needs its own GitHub environment and its own decision about which credential claims production's requests.