Release and deploy: production follows the release PR, one Worker at a time
Staging uses a separate Cloudflare account and the same deployment runner. See staging. Its entry points are staging-deploy.ts, staging.ts, and deploy-staging.yml.
Every merge to main accumulates into one release PR (release-please, conventional commits). Merging that PR tags the version, publishes the GitHub release — and deploys production from CI: only the Workers whose deployed artifact the release actually changes, in the one supported order, through the same preflights and live gate an operator's deploy all always ran. An exact tag hold skips that automatic deploy while keeping release and image publication; the same CI workflow can then deploy selected Workers from main. Nobody deploys routine releases from a laptop.
Which Workers a release touches is derived from the tree, never declared. A PR body saying "bot deploy only" is a claim; the diff between what a Worker is serving and the release commit, mapped onto that Worker's real inputs (its bundle's import closure, its image's COPY sources, its production dependencies), is a fact. The release PR shows the derived plan before anyone merges it, and every PR's CI shows what its own diff would deploy. When the derivation cannot be sure — a path no rule classifies, a Worker whose live commit cannot be read and no release tag to fall back on — the answer is the whole fleet, said out loud, never a silent skip.
Code:
src/channels/mcp.ts,src/deploy/residentMaintenance.ts(trusted staging policy and matching),.github/workflows/verify-sandbox.yml(manual inspection/verification without deploy),src/deploy/sandboxVerification.ts(verification without upload),src/deploy/publicInventoryObservation.ts(observation-only public metadata),src/deploy/pagingDiagnostic.ts(bounded sanitized paging snapshots),src/deploy/stagingCi.ts,scripts/staging-validate.ts,src/deploy/staging.ts,src/deploy/stagingBootstrap.ts,src/deploy/stagingRecreate.ts,scripts/staging-recreate.ts,.github/workflows/recreate-staging.yml,src/deploy/stagingMemoryReceipt.ts,scripts/staging-initialize.ts,scripts/staging-deploy.ts,.github/workflows/deploy-staging.yml(isolated staging deployment),.gitignore(private per-operation generated configs),.depot/workflows/pr-title.yml(the required title check),vitest.config.ts+src/core/testing/tempRoot.ts(item 29: the suite's one global setup, the run's temp root),deploy/cloudflare-memory/vitest.config.ts+deploy/cloudflare-memory/wrangler.test.jsonc+deploy/cloudflare-memory/testSetup.ts+deploy/cloudflare-memory/backgroundTasks.ts+deploy/cloudflare-memory/testDiagnostics.ts+deploy/cloudflare-memory/testDiagnosticsReporter.ts(the state Worker's workerd pool: production Durable Objects with cross-script service and Workflow bindings omitted, routine logs kept out of the runner RPC, deliberately detached work held by the runtime and refused at a test boundary while pending, with every case's timing and unfinished work written for CI),.github/workflows/scorecard.yml(Scorecard through GitHub Actions on Depot runners),.github/workflows/codeql.yml(CodeQL through GitHub Actions on Depot runners),src/deploy/affected.ts(the selection: inputs, inert rules, import closure, lockfile production-dependency diff, per-Worker base, markdown summary — pure),src/deploy/plan.ts(WORKER_SPECScarry their inputs;workersFor(profile)binds script names and/healthz; a plan built from anAffectedReport; the account decision),src/deploy/profile.ts(the deployment profile: parse, validate, derive URLs) withdeploy/profile.example.json(an installation's own profile file is gitignored),src/deploy/configSource.ts(path /github:/// vault-reference loaders behind one seam),src/deploy/secrets.ts(the secrets manifest's shape, thesecretsSourceshape, the put plan — pure) withsrc/deploy/secretsHost.ts(files orop, andwrangler secret puton stdin) anddeploy/secrets.manifest.json,src/deploy/wranglerTemplate.ts(each Worker'swrangler.jsoncrendered from thewrangler.template.jsoncbeside it —deploy/cloudflare/wrangler.template.jsoncand its siblings) behinddeploy init,src/deploy/images.ts(the three images: the published names fromproject.json, each Worker'simageunder the profile's mode, the copy plan over the account registry's listing — pure) withsrc/deploy/accountRegistry.ts(the registry's name and listing shape — the leaf both halves share),src/deploy/registryTransfer.ts(the registry-to-registry copy's shapes: references, the credential request, manifests and indexes, the linux/amd64 selection, upload part boundaries — pure),src/deploy/registryTransferHost.ts(the copy over HTTPS: the credential mint, the account registry's catalog, the source's token and manifests, chunked blob uploads verified by sha256, the manifest push and its digest check) andsrc/deploy/imagesHost.ts(the credential fromCLOUDFLARE_API_TOKEN, minted once per account and spent on every read and copy; no process),src/configDocument.ts(the base config document, its client and thestate://location — pure client),src/configBoot.ts(the production config refusal probe),src/deploy/liveGate.ts(shared health and deadline decisions — pure),src/deploy/botLiveGate.ts(the bot's application-target/version plus exact-health decision — pure),src/deploy/sandboxLiveGate.ts(the sandbox's live decision: Worker + rollout + probe, and the parsers for wrangler's--jsonand the streamed/execbody — pure),src/deploy/run.ts(the host probe: git,/healthz, the token-verify fallback; the profile loader; the config read, validation and push to the state Worker; the per-step env and wake; the live-gate loops, the sandbox's with injectable deps),src/deploy/operatorRoot.ts(where a deploy's files live: the checkout, or the operator's directory and the package's assets — pure),src/deploy/workArea.ts(the Worker directories materialised from the package under.switchboard/: the copy, the root-inclusive per-Workernpm ci --include-workspace-root --workspace, the stamp — the plan pure,npm ciinjected),src/deploy/host.ts(this process's root, the package's source stamp, the realnpm ci),deploy/bin/build-stamp.mjsanddeploy/cloudflare/write-build.mjs(the commit from the environment when there is no tree),src/core/commands/deploy.ts(--affected,--base; the root in every output path),.github/workflows/deploy-production.yml(the reusable deploy),.github/workflows/sync-mcp-access.yml(the one-click MCP access sync),.github/workflows/release-please.yml(release → deploy, and the image publish),docker-compose.yml(the local loop runs the published image) withproject.json(image, the one statement of its name) andscripts/check-project-facts.mjs,scripts/deploy-targets.mjs(npm run deploy:targets: the per-PR summary, and in release-PR mode the sticky comment),.depot/workflows/ci.yml(deploy targetson every PR),scripts/check-pr-title.mjs(the title gate'smain: reads the tree, reports) oversrc/core/prTitle.mjs(the predicate: grammar, the type list from the release config, the scope list from the code map's Areas, the cap, the migration section behind!, the vocabulary render — pure functions thesubmit_pr_descriptionschema shares), the generatedsrc/core/prTitleVocabulary.jsonand its writerscripts/pr-title-vocabulary.mjs(pr-title:gen/pr-title:check),scripts/check-lockfile.mjs(the lockfile gate: platform variants, manifest mirroring, edge resolution and pinning — pure over the lock JSON, onemain). +src/deploy/residentDrain.ts+src/deploy/residentReadiness.ts(item 31) +src/deploy/supersede.ts(item 32: the supersede guard — pure, reads injected) +src/deploy/agentSmoke.ts+src/deploy/ingressSmoke.ts+src/deploy/testing/agentSmoke.ts+scripts/smoke-ingress.ts+src/core/runsService.ts(item 33),src/deploy/stagingContinuation.ts(original initialization checkpoint validation),scripts/staging-continue.ts(manual continuation controller).Tests:
src/deploy/sandboxVerification.test.ts,src/deploy/pagingDiagnostic.test.ts,src/deploy/staging.test.ts,deploy/cloudflare/selected-config.test.mjs,src/ciWorkflow.test.ts(items 20–21, 23, 27 and the state-Worker diagnostic artifact),src/memoryWorkerDiagnostics.test.ts(the diagnostic reporter and asynchronous-work trap fixtures),src/core/testing/tempRoot.test.ts(item 29),src/projectFacts.test.ts(item 21: the compose file runs the published image),src/deploy/affected.test.ts,src/deploy/plan.test.ts,src/deploy/liveGate.test.ts,src/deploy/botLiveGate.test.ts,src/deploy/botGateRun.test.ts,src/deploy/sandboxLiveGate.test.ts,src/deploy/sandboxGateRun.test.ts,src/deploy/profile.test.ts,src/deploy/configSource.test.ts,src/configDocument.test.ts,src/configBoot.test.tsandsrc/index.boot.test.ts(the refusal-only boot),src/deploy/wranglerTemplate.test.ts,src/deploy/images.test.ts,src/deploy/host.test.ts,src/deploy/imagesHost.test.ts,src/deploy/registryTransfer.test.ts,src/deploy/registryTransferHost.test.ts(items 25–26),src/deploy/secrets.test.ts,src/core/secretsManifest.test.ts,src/deploy/restart.test.ts,src/core/commands/deploy.test.ts,src/cli.test.ts,src/prTitleCheck.test.ts(item 22),src/deploy/operatorRoot.test.ts,src/deploy/workArea.test.ts,src/deploy/buildStamp.test.ts,packages/switchboard/smoke.test.mts(item 24),src/lockfileCheck.test.ts(item 28). +src/deploy/residentDrainRun.test.ts+src/deploy/residentReadiness.test.ts+src/deploy/liveReceipt.test.ts(item 31) +src/deploy/supersede.test.ts(item 32) +src/deploy/agentSmoke.test.ts+src/deploy/ingressSmoke.test.ts+src/core/dispatch/runLoop.test.ts+src/core/runsService.test.ts(item 33),src/deploy/stagingContinuation.test.ts.Docs: docs/how-to/ship-a-release.md, docs/how-to/operate-production.md, docs/how-to/rotate-a-secret.md, docs/explanation/worker-topology.md, Deploy, AGENTS.md, CONTRIBUTING.md (the title rule), docs/reference/migrations.md (the migration notes), docs/reference/code-map.md (the scope vocabulary).
Code:
src/deploy/configConsumerTarget.ts(pure operation-target data shared with the Worker restart protocol; no Node host dependency).Code:
src/configConsumer.ts(protected image identity and the owned configuration slot).Tests:
src/configConsumer.test.ts,src/configConsumerLoader.test.ts,src/deploy/configPublicationRun.test.ts,src/deploy/consumerConfigPublication.test.ts(identity, loading, source/target publication and actual-consumer eligibility).Code:
scripts/ci-plan.mjsandscripts/ci-plan.d.mts(change selection and typed contract),scripts/ci-gate.mjsandscripts/ci-gate.d.mts(required check gate with planned skips).Tests:
src/ciPlan.test.ts,src/ciGate.test.ts(change range, selection and gate behavior).Code:
src/deploy/imageInputs.ts(stable account-registry tags for execution image inputs).Tests:
src/deploy/imageInputs.test.ts(changed and missing image inputs).
Related: command-registry.md (the deploy.* commands), slack-channel.md item 8 (the bot's preflight and live gate), resident-repos.md item 44 (the resident preflight), execution.md item 13 (every Worker reports its commit), docs-site.md (the docs Worker keeps its own CI deploy on docs changes — it is not part of the release deploy).
Workflow runners use Depot Ubuntu 24.04 x64 runners: depot-ubuntu-24.04-4 (4 CPUs, 16 GB RAM) for installs, builds, tests, scans, deploy planning, publishing, and deployment. Lightweight jobs use depot-ubuntu-24.04 (2 CPUs, 8 GB RAM): the bot, workers, and image aggregation gates, PR title validation, review approval, repository visibility checks, and release-please. Gates and title validation keep their existing checkout to run the repository scripts; they do not install dependencies.
Runner validation [agent]: after a PR push, run gh run list --branch <branch> --workflow ci.yml, then gh api repos/{owner}/{repo}/actions/runs/<run-id>/jobs --jq '.jobs[] | {name, labels, runner_name, conclusion}'. Expect compute jobs on depot-ubuntu-24.04-4, the three aggregation gates on depot-ubuntu-24.04, and every required check successful. For forks and callers of the reusable deploy workflow, connect the repository to the Depot GitHub App and allow public repositories in the runner group when applicable, or replace the runs-on labels with available runners. See Depot setup and runner sizes. Existing actions/setup-node npm caches use Depot’s integrated Actions cache; Docker builds use the runner’s local Docker engine. The first run can populate a cold cache. Validate npm install/cache restore, Docker image builds, and the aggregation gates on the PR; validate publishing, production deployment, and review approval when their normal triggers run.
Code:
scripts/staging-held-bootstrap.ts,deploy/bin/deploy-clock.mjs,src/deploy/stagingHeldBootstrap.ts,.github/workflows/deploy-staging-bootstrap.yml(manual held Resident bootstrap).Tests:
src/deploy/stagingHeldBootstrap.test.ts,src/deploy/stagingHeldBootstrapWorkflow.test.ts(held bootstrap identity, no replay and separate status).Code:
deploy/cloudflare-resident/stagingEpisodes.ts(durable successor selection and receipts).
Behavior
Releases are release-please.
release-please.ymlruns on every push tomain: it opens or updates the one release PR (chore(main): release <version>, the changelog from conventional commits) and, when a release PR merges, tagsv<version>and publishes the GitHub release.include-component-in-tag: false— the tag is the bare version. It acts as the installation's release GitHub App (its client id and private key are the repository secretsCONFIG_REPO_APP_CLIENT_IDandCONFIG_REPO_APP_PRIVATE_KEY— the same App the deploy reads the configuration repository with, item 11; the token minted per run is scoped to this repository with contents + pull-requests + workflows write —workflowsbecause the release creates its tag, and GitHub refuses an App-minted ref whose commits carry workflow files without it; after the history collapse every tag's commits do), never asGITHUB_TOKEN: a PR opened withGITHUB_TOKENgetspull_requestruns that sit ataction_requireduntil a person clicks "Approve and run" on every head, so its required checks never report unattended. The release step runs twice when it must: GitHub refuses the release-branch ref update now and then — the identical call succeeds minutes later with nothing changed on our side, and the action reports onlyError updating ref, the HTTP reason swallowed — so the first attempt iscontinue-on-errorand a second attempt runs only when the first failed; the job's outputs come from whichever attempt succeeded, a retried run carries a warning annotation naming the retry, and a second refusal is the run's failure.The release deploys production. When release-please reports
release_created, the same workflow run calls the reusabledeploy-production.yml, defaulting totargets: affectedandcli: checkout. Repository variables can select scoped targets and the exact released package after successful npm publication. In checkout mode, that job checks out the release commit,npm ci, printsdeploy plan --affected, and runsdeploy all --affected— the registry command, not a re-implementation — underenvironment: production, thedeploy-productionconcurrency group (queued, never cancelled), and a 2-hour budget that covers a full preflight wait plus the bot's drain. Deployment records are GitHub's own (gh api repos/…/deployments?environment=production).Manual deploys go through the same workflow.
workflow_dispatchondeploy-production.ymltakestargets(affected— the default — orall, or a comma list of Workers),force,copy-images,cliandversion. Checkout mode runs only frommain; dispatched from any other ref it fails at its first step, naming the ref, before anything is checked out or deployed. Package mode deploys the pinned published version rather than the selected ref's tree, so it does not apply the checkout-only ref guard; it retains the same deployment runner and live gates (items 27 and 34). This is the escape hatch for "the derivation skipped something I know it should not have" and for recovering a Worker whose release deploy failed — not a parallel path with its own rules.The base of a Worker is what it serves. For each Worker,
--affectedreadsbuild.commitfrom its/healthz(the bot, memory and resident Workers answer unauthenticated; the sandbox needsSANDBOX_TOKENas a bearer — absent, it falls through). A commit counts as a base only when it is a plain hex commit (neverunknown, never-dirty) and an ancestor ofHEAD. Otherwise the base is the last release beforeHEAD(git describe --tags --match 'v*' --abbrev=0 HEAD^), and with no tag either the Worker is unsure and deployed, the reason in the report. A Worker servingHEADitself has nothing to deploy. Git failing is never read as "nothing changed": a diff that fails (the base is not in this checkout) makes that Worker unsure, and an unreadableHEADmakes every Worker unsure.A Worker's inputs are derived, with one declared list per Worker. (a) The relative-import closure of
deploy/<worker>/worker.ts, crawled from the tree atHEAD— a file undersrc/is a memory-Worker input because the memory Worker imports it, not because someone listed it; an import that resolves to no file makes the Worker unsure. (b) Its directory (deploy/<worker>/:wrangler.jsonc,Dockerfile,tsconfig.json, helpers). (c) Its slice of the one rootpackage-lock.json(npm workspaces): the dependency closure of its workspace, resolved the way npm resolves it (a nesteddeploy/<worker>/node_modules/…entry before the hoisted one), production only for a wrangler bundle — so avitestorwranglerbump deploys nothing while an@cloudflare/sandboxbump deploys the resident, and the resident's SDK moving does not touch the sandbox Worker's own pinned copy. (d) For the bot, the image's sources:Dockerfile,.dockerignore,package.json,tsconfig.json,tsconfig.build.json,src/,web/,config/,skills/, and the lockfile closures itsnpm ciinstalls — the root package andwebincluding devDependencies (tsc and vite build the artifact) plus the shim workspace's production closure.Inert paths are an explicit list, and everything else is loud. Tests (
*.test.*,testing/,vitest.config.*,test-env.d.ts),docs/,docs/reference/specs/, every*.md,.github/,scripts/, the deploy tooling (deploy/bin/, each Worker'spreflight*.mjs, the bot'swrite-build.mjs), the docs Worker (deploy/cloudflare-docs/— CI deploys it on its own), the secrets and agent-env manifests (a new secret is awrangler secret put, not a deploy), repo metadata (.gitignore,.nvmrc,.env.example,LICENSE,NOTICE,docker-compose.yml,vitest.config.ts,tsconfig.scripts.json, the release-please files,switchboard.png), and lint/format config (.prettierrc.json,.prettierignore,eslint.config.mjs) change no deployed artifact. Apackage.jsonwhose only difference isversionis inert too — release-please bumps it on every release. Any other path that no Worker claims is unclassified, and an unclassified path makes every Worker unsure: the whole fleet deploys and the report names the path. Classifying a new path is a one-line edit; a missed deploy is an incident.The plan is the report.
deploy plan --affected(every surface,--jsonon the CLI) returns the ordinary plan plusaffected: per Worker its decision (deploy/skip), the base it was judged against (live <sha>/release <tag>/ref <x>/ none), and the reasons (changed inputs, the production dependencies that moved, or why it is unsure), plusselectedin deploy order,unclassified,deployAll, and amarkdownrendering of the same table.--base <ref>judges every Worker against one ref and probes nothing — what a PR's CI uses (HEAD^of the merge commit = the PR's base).--onlyand--skipcan only narrow an affected selection, never widen it;--baseneeds--affected. An empty selection is a successful plan with no steps — "nothing to deploy" — never an error, anddeploy all --affectedthen calls no runner and exits 0.Every PR shows its targets; the release PR carries a selection snapshot.
scripts/deploy-targets.mjs(npm run deploy:targets) wraps the existing plan. Ordinary PR CI judgesdeploy plan --affected --base HEAD^. The release preview capturesSWITCHBOARD_RELEASE_DEPLOY_TARGETSwith the sameaffecteddefault and closedall/comma-separated Worker-name grammar as reusable execution: affected uses the per-Worker live-input report; all and explicit names render the actual configured plan steps, with omitted Workers marked skip. Invalid selectors refuse before planning or comment effects. The sticky comment records the selector and replaces the earlier preview; it is a snapshot of that run's tree/profile/inputs, not deployment authority or proof. Execution captures its own selector at start, so a later variable edit affects a future run. Candidate, preservation, configuration, upload and readiness gates can prevent deployment; no preview promises that merging uploads every planned Worker.The runner uses CI's credential and asserts the account.
CLOUDFLARE_API_TOKENis no longer stripped from the deploy steps — it is how CI authenticates;CLOUDFLARE_ACCOUNT_IDstill is (it would override the pinned account). The pre-check passes whenwrangler whoamilists the production account, or — an account-owned token answerswhoamiwith no memberships — when Cloudflare's per-account token verify (GET /accounts/<id>/tokens/verify) reports the tokenactive. A token for another account fails both and the deploy refuses with wrangler's own words and the way out (log in with wrangler, or unset the foreign token).A drain bearer is required to preflight the resident. The resident step requires
RESIDENT_DRAIN_TOKEN: it drains new admissions and fences registered reattach before the ownership read.RESIDENT_READ_TOKENremains the post-deploy readiness credential. A checkout without the drain bearer is refused before any Worker upload.What CI needs, once. Repository secrets:
CLOUDFLARE_DEPLOY_TOKEN— the runtime Workers' own token on the profile's Cloudflare account (Account → Workers Scripts: Edit, Containers: Edit, Workers R2 Storage: Edit, Account Settings: Read; the profile's zone → Workers Routes: Edit, DNS: Edit), distinct from the docs job's narrowerCLOUDFLARE_API_TOKEN, which the deploy falls back to only while the deploy token is unset (and says so as a warning);RESIDENT_DRAIN_TOKEN(resident drain, deploy fence, reconcile, and undrain),RESIDENT_READ_TOKEN(post-deploy readiness);SANDBOX_TOKEN(the sandbox live gate, item 16 — required whenever the sandbox is selected; it also lets--affectedread the sandbox's live commit instead of falling back to the release tag). Docker is on the runner; wrangler builds and pushes the container images from there. None of these are created by code. The profile itself comes from the installation's configuration repository, and where that is lives in repository variables, never in a workflow:SWITCHBOARD_DEPLOY_PROFILE(agithub://<owner>/<repo>/<path>@<ref>reference),CONFIG_REPO_OWNERandCONFIG_REPO_NAME(the repository the App token is minted to read, asCONFIG_REPO_TOKEN, from the repository secretsCONFIG_REPO_APP_CLIENT_IDandCONFIG_REPO_APP_PRIVATE_KEY— the App release-please acts as). The App's two values remain repository secrets; an organization secret scoped to private repositories disappears when a repository goes public. The optional MCP sync (item 27) uses a repositoryOP_SERVICE_ACCOUNT_TOKENto read the complete bearer map from the CI vault; ordinary deploys do not read it. A deploy run refuses without the profile variable; the release PR's deploy-plan job and the docs deploy skip without it. The live table the deploy job appends reads every Worker's/healthzfrom the plan's own URLs, never from a typed hostname;MEMORY_TOKEN(the state Worker's bearer) for the config push (item 15).The credential is checked before anything deploys. Each Worker names the read-only wrangler commands that need the same scopes its deploy will (
WORKERS[].capabilities:containers listfor Containers: Edit on every Worker with an image — bot, resident, sandbox — andr2 bucket listfor Workers R2 Storage: Edit on the resident; each Worker carries its own, since an--affectedrelease can select the resident without the bot — and on the bot too, but only when the profile names an artifacts bucket (execution.md item 20): the bot'snpm run deploythen creates that bucket before the upload, and an installation without artifacts asks nothing extra of its token).deploy allruns each distinct command once in the pre-checks and refuses — naming the Worker, the scope, and wrangler's own error lines — when one fails, so a token that can deploy the memory Worker but not push the bot's image is refused at the top rather than after step one. The bot's own preflight keeps wrangler's stdout in its refusal for the same reason: wrangler prints its errors there, and a message built from stderr alone said nothing.A busy bot is not busy: a deploy never waits on runs, and never needs a person for them. Until the run ledger's handoff (run-history item 39) the bot step refused while runs were in flight and
deploy allwaited that out (--wait-max 45in CI), a preflight still refusing at the end of the budget wasbusy(CLI exit 75), anddeploy-production.ymlre-dispatched itself at the head ofmainup to 8 times. Since the handoff SIGTERM hands every resumable run to the next generation, which continues it within seconds, so: the bot preflight WARNS for runs in flight and a drain under way and proceeds (slack-channel.md item 8);deploy allwaits (its default budget, 10 min) only for a container rollout still settling; a preflight still refusing past that budget is a real anomaly — a stuck rollout — and the deploy failsunavailablelike any stopped run, red, for a person, with the line naming the budget, the refusal,NOT deployed, the re-run (gh run rerun RUN_ID --failed) and what--forcewould do (preflightGaveUpLine); the workflow takes nowait_maxorattempt, passes no--wait-max, and never re-dispatches itself. Thebusycode (exit 75) stays in the command vocabulary for commands that refuse for a reason that clears on its own;deploy alldoes not produce it. What still holds a deploy: the container-application state (a rollout in progress), the fail-closed cases (a bot that cannot be consulted), and the resident preflight — a resident Worker deploy still refuses while a resident has a run in flight (it swaps the isolate under a running command) or is provisioning (no checkpoint to resume from) — a wait the resident step makes finite by draining the fleet first when it holds the drain bearer (item 31) — and whose containers it reconciles onto the new image inside that drain window, after the deploy lands and before the drain is lifted (item 31), so no run is admitted into a container the reconcile is about to restart — and warns and proceeds over a refresh or restore mid-cycle, which resume after the swap (resident-repos item 44). Because what the resident refuses for lasts minutes — a coding run up to 25, a provisioning up to its 5-min deadline — its step carries its own budget,RESIDENT_WAIT_MAX_MS(30 min;DeployStep.waitMaxMs, the plan line saysup to 30 minfor it and the plan's default for the bot), instead of the 10 min sized for a rollout: with the rollout budget a release deploy waits its 10 min behind a resident inrestoring— a state the swap clears, and which now only warns — and goes red with the resident and sandbox not deployed and nothing retrying (resident-repos item 44). Past its budget a refusal is the same real anomaly as the bot's and fails the same way.Where the fleet is comes from a deployment profile, not the code.
deploy/profile.json— an installation's own, gitignored;deploy/profile.example.jsonis the shape;SWITCHBOARD_DEPLOY_PROFILEpoints at another as a path or as thegithub://owner/repo/path@ref/ vault-reference formsconfigSourcetakes (item 15), read through the same loaders (a production profile can live in a separate infrastructure repository, which is how the release workflow names it — item 11) — names the Cloudflare account, the zone, and each Worker's script name and hostname (a Worker may name its ownzonewhen its hostname is on a second domain the account owns);src/deploy/profile.tsparses it, naming each problem by field and refusing a hostname outside the zone or two Workers sharing a script name. The static half of every Worker (dir, entry, inputs, preflight, capabilities) isWORKER_SPECS;workersFor(profile)binds it to the profile, and every URL the tooling uses — each/healthz, the bot's public base and/admin/restart, the state Worker's — is derived from the hostnames, never typed a second time. Without a profile,deploy planreads the example and the plan says so (aProfile:line plus a warning);deploy allanddeploy restartrefuse a plan from the example, since a placeholder account is not a place to deploy to. A profile that is missing where the env var points, not JSON, or invalid is anunavailableerror naming the file and the problems. The bot is the one Worker every profile has:memory,residentandsandboxare optional, the project's docs site is not a Worker of the profile at all (docs-site.md item 12; a profile that still namesworkers.docsparses with the key dropped),workersForbinds only the Workers the profile has (a bot-only profile is a one-step plan, bot + memory a two-step plan, in canonical order),--onlynaming an absent Worker is a warning and no step, and without a state Worker the plan carries nostateWorkerUrl—deploy allpushes no config (and says so),deploy configisunavailablenaming the profile, and the bot and resident templates render withoutSTATE_WORKER_URL(an{{#if urls.stateWorkerUrl}}block). The production-shaped profile plans byte-identically. The profile's optionalartifacts.bucket(execution.md item 20; R2's bucket-name rules, refused by field) is what the bot template's{{#if artifacts}}block renders theARTIFACTSbinding andARTIFACTS_BUCKET_NAMEvar from — the committed example names none, so a fresh installation binds nothing until it opts in. The profile's optionalmetrics.dataset(run-metrics.md item 6; Analytics Engine's dataset-name rule, refused by field) rides the same way: the state Worker template's{{#if metrics}}block renders theRUN_METRICSbinding andRUN_METRICS_DATASETvar from it,deploy plannames the binding in the memory step, and nothing is created ahead of the deploy — the platform creates the dataset on first write; the committed example names none.Deployments stage configuration owned by the publishing image. A state-backed Bot-selected plan freezes the current or original input source/native pair, candidate bytes and private source/prior-target witnesses before uploads. Its selected Memory step supplies the source/target conditional-store protocol. Before Bot activation, the CLI stages only
base-<full-CID>with both frozen versions and requires the transaction-produced source predicate and exact next-version ACK. Legacybaseremains unchanged for old-image restarts. After activation, prove the full serving identity, native application/unique singleton and installed key/version/digest, then re-read the input source; late changes prevent cutover acceptance. Unknown effects stop without retry or automatic restoration. Empty/non-Bot selections publish no config; file mode retains its configured path. Unproved cold state-backed bootstrap is explicitly unsupported; an endpoint or zero-version target is not input/native proof.The sandbox is live when the Worker, the rollout and a probe agree. A sandbox deploy is two artifacts — the Worker version
wrangler deployuploads at once and the container image Cloudflare rolls out afterwards, instance by instance — and in between the new Worker can be handed a container still on the previous image; a Durable Object created then runs on it until the rollout's wave replaces that instance. A run that starts in that window lands on a container from the previous image under the new Worker's SDK, and every exec fails — with the skew shape execution.md item 6 names: an EMPTY error under the 0.12 SDK, the container server's'utils.getRuntimeMetadata' is not a function.under the 0.13 one — while the deploy has already saiddeployedand exited 0. So the sandbox step carries a live gate like the bot's (WORKER_SPECS[].liveGateis the statickind: "sandbox"spec — bearer env + container class — next to the bot'skind: "bot"application + exact-health gate;workersFor(profile)binds it to the profile's/healthzand to the Containers application wrangler names from the profile's script,<script>-switchboardsandbox; the plan renders it), anddeployedbecomesliveonly when three signals agree, checked in order with the first unmet one as the poll's reason: (a) the Worker —GET /healthzwith theSANDBOX_TOKENbearer serves the deployedbuild.commit(decideLive's rules: not JSON, no identity,unknown, another commit,-dirty→ waiting); (b) the rollout — first its target: the container application must have LEFT the state read BEFORE the upload. The runner readswrangler containers info <app> --json(version,configuration.image) before the step'snpm run deployruns, and takes the target from wrangler's ownContainer application changessection in the deploy output — wrangler rebuilds the image on every deploy and prints, underEDIT <app>, a line diff of the configuration (+ "image": "…@sha256:<digest>"when the image changed; other+lines for other fields, a new version still coming with its image unknown), underNEW <app>the whole configuration, orno changeswhen the rebuilt digest and everything else are unchanged. With a target the rollout is waiting (rollout: application still at pre-deploy version N / image sha256:… — the deploy's new version is not registered yet) untilversionis above the pre-deploy one orconfiguration.imageequals the target's image — the version is the advance signal whenever the pre-deploy read succeeded (the running instances and the probe are then judged against that new version), and the image is consulted only when the pre-deploy read failed, since without a pre-deploy version a matching image is the one evidence left; when the pre-deploy read failed and the diff printed no image there is no evidence to wait for and the reason says so until the deadline — and is never "complete" against the pre-deploy version. Without a target (no change printed) no advance is expected: a Worker-only deploy rolls no container, and the rollout requires only that every running instance is on the current version. Without the target, a gate read seconds after the upload passes withrollout complete (1 running instance(s) on version N)and a probe from a version-N instance while the deploy's version N+1 (the new digest) registers afterwards — the application still reports the pre-deploy version, every running instance trivially matches it, and the gate has no notion of the version the deploy created. Then the instances:wrangler containers instances <app> --json(every page,--per-pageabovemax_instances) shows every instance in staterunningon the application'sversionfromwrangler containers info <app> --json; other states (stopping,stopped,failed,provisioning,unhealthy,inactive) are ignored — they serve nobody or are not yet placed; the app id comes fromwrangler containers list --jsonby that name, run indeploy/cloudflare-sandboxwithCLOUDFLARE_ACCOUNT_IDstripped like every wrangler call the runner makes; (c) a probe —POST /execon threaddeploy-gate:<commit>with{command: "echo ok", timeoutMs: 60000}answersexitCode 0/ stdoutok, AND the instance listed under that thread key is on the application's version (anokfrom the old image proves nothing). The application is read only once the Worker is live; the probe is sent and the instance list read only once the application has left its pre-deploy version — the gate's own thread, placed while the deploy's version is not registered, would land on the previous image like any other thread and answer with that skew until the wave reached it — and the probe is sent before the instance list is read so the list includes the probe's own instance. Everything a rollout can cause is waiting, never a failure: a full fleet (reason: "fleet-busy", execution.md item 14), a booting container (Container is starting), ANY in-bodyerror— named as the previous image's shape with the SDK's words kept as the evidence,probe: /exec failed — <words> — the probe's container may still run the previous image(the EMPTY string is named as such) — a nonzero exit, an unreadable wrangler answer (its words kept). Polls every 15 s; at the shared 20-min live-gate deadline the last waiting reason is the failure (deployed, not live: …, exit non-zero, later Workers not attempted — the same result-table semantics as the bot's gate). A rejected bearer (HTTP 401/403 on/healthz) fails at once: waiting cannot fix a credential. One thread key per deployed commit, so the probe holds ONE fleet slot for the sandbox's 5-min idle window per deploy, not one per poll.SANDBOX_TOKENis thereforerequiredEnvfor the sandbox step — a checkout without it is refused before anything deploys (the workflow header and its credential check say so).Every Worker's
wrangler.jsoncis rendered, never written. Beside each one sitswrangler.template.jsonc— itself valid JSONC, so editors, prettier and wrangler's$schemaread it as the config it describes — with the installation's values as placeholders inside strings ({{account}},{{script}},{{hostname}},{{urls.publicBaseUrl}},{{urls.stateWorkerUrl}}) and the one optional block, the bot's Access vars, fenced by comment-line directives (// {{#if access}}…// {{/if}}) so a profile withoutaccessrenders neither var.deploy init(npm run deploy:gen) renders every Worker the profile has from the profile in force (the installation's own, the oneSWITCHBOARD_DEPLOY_PROFILEnames, else the example), and the project's docs site's config beside them fromproject.json—<name>-docson the host of thedocsfact, the profile contributing only the account (docs-site.md item 12) — and writes the files with a header saying what they are; a placeholder the profile has no value for, a missing template, or an unbalanced directive refuses the whole render naming the file and line.deploy init --check(npm run deploy:check, incheck:consistency) writes nothing and failsconflictnaming each rendered file that differs from its render — a hand edit is drift, and so is a template change without a re-render. The rendered files are the ones wrangler reads and are GENERATED, never committed (gitignored, excluded from prettier — the templates are what is committed and formatted):npm test, every Worker'sverifyanddeploy allrender them first, so a pull request's CI and a fresh clone see the example's values and a deploy sees the real ones, and no account or hostname is ever in the tree. The same profile also reaches the deploy steps:deploy allhands each preflighted step its Worker's origin (SWITCHBOARD_BASE_URLfor the bot,RESIDENT_BASE_URLfor the resident — the preflights refuse without one, exit 2, naming the variable anddeploy all), and after an unguarded deploy GETs the Worker's public/healthzonce so it wakes (the memory and resident steps; the bot's live gate polls instead, the sandbox's route needs a bearer). Nothing underdeploy/names a hostname or an account:wrangler tailreads the script name from the rendered config,cf-logsreads the account from the profile.Secrets are provisioned from the manifest and the profile's source, never by hand.
deploy/secrets.manifest.jsonsays WHICH secrets each Worker holds (name, Workers, optional) — the contractsrc/core/secretsManifest.test.tskeeps equal to eachworker.tsEnvinterface and to the bot container's forwarding list, with the explicitly Worker-only Depot organization token excluded (depot-ci.md); the deployment profile'ssecretsSourcesays WHERE the values are: a directory of<NAME>files (~/.secrets/switchboardwhen absent) or a vault itemop://Vault/Item(read with theopCLI) whose fields are the names.deploy secrets <worker> [--only A,B] [--source SOURCE](npm run secretsin the Worker's dir);--sourceoverrides the profile for this invocation only, so a one-secret CI sync can read its own item without redirecting other secrets. The command first renders that Worker'swrangler.jsoncfrom its template and the profile when it is absent or stale (the rendered file is generated and gitignored, so a clean checkout has none, and wrangler reads the script name and account from it), then asks the source once which of the Worker's names it holds (a directory: which files exist; an item: oneop item get, never a read per name), refuses BEFORE any upload when a required name has no value — naming the secret and<source>/<NAME>— skips an absent optional one and says so, and then pipes each value intowrangler secret put <NAME>on stdin in the Worker's directory, in manifest order; a failed put stops the run naming what was not attempted. A value never appears in argv, in the environment, or in any output — the host half (src/deploy/secretsHost.ts) reads it and hands it to wrangler in one function; the command sees only names. An unknown or lowercase--onlyname and an unknown Worker areinvalid_input; a missing or invalid manifest, an unreadable source (no such directory;opabsent, unauthenticated, or failing) and an unparseable profile source or--sourceareunavailable. The manifest carries no vault paths, organization names, or ids — the notes describe each secret's role and rotation rule, nothing about one installation.Every step is measured (tracing.md item 20).
deployStepruns each Worker's step under adeploy.step.<worker>root written to the runner's own output as one JSON span line (slow: the step always, its child when it took a second or more),outcomeone oflive/deployed(no gate) /not_live/busy(the preflight refused for the whole budget) /failed/threw; the live gate — the bot's application + exact-/healthzpoll or the sandbox's three-signal gate — is the step's one child,deploy.wait_live, whosewaitedMsis exactly the number thelive (…; Ns after the upload)line prints, so a question like "how long did the sandbox rollout take on 0.5.0" is agrepof the job log fordeploy.wait_live, not a read of the polls. The runner reads the clock through itsdeps.nowand nowhere else.Every image the deploy builds is built in CI first. A Worker with an image is one whose
wrangler.template.jsoncrenders{{image}}(item 25) — the bot's (the rootDockerfile, built from the repository root), the resident's and the sandbox's, each Dockerfile named once inDOCKERFILES(src/deploy/images.ts) — and every such Worker's workspace has acheck:imagescript (docker build <the Dockerfile's directory>: the same Dockerfile from the same context wrangler builds on a release) and one leg of theimage-eachfan-out under the requiredimagegate inci.yml(npm run check:image -w deploy/<worker>). The rootnpm run check:imageruns every workspace's own (--workspaces --if-present), so the local command and the CI check are one set; a Worker without an image has no such script. The set is derived from the templates, never declared: a new Worker with an image fails the test until it has its script and its leg. A Dockerfile that cannot build is found on the PR that changes it, not by the release deploy stopping at that Worker and leaving the ones after it undeployed. The bot image runscheck:distin its build stage before copyingdist/into the runtime stage: the TypeScript build must pass, test files must be absent, and the PR title predicate and vocabulary must be present. CI selects this image build for bot source, build configuration and distribution-check changes; it has no separatecheck:distjob. Localverify:rootkeeps the same check without requiring Docker.Every release publishes the three images, with provenance and an SBOM. When release-please reports
release_created, thepublish-imagejob inrelease-please.yml— a matrix with one leg per image,fail-fast: falseso one image's failure leaves the others published — builds each Dockerfile from the context itscheck:imageproves on every PR (the bot's, the rootDockerfilefrom the repository root; the resident's and the sandbox's from their Worker directories) — the bot's leg first writesbuild.jsonfrom the release commit (deploy/cloudflare/write-build.mjs, the filenpm run deploywrites from the tree), so the published image says which commit it is on/healthzand aregistry-mode deploy's live gate (item 25, execution.md item 13) can hold; the first registry-mode container rolled without it and saidcommit: "unknown"— and pushes it to GitHub Container Registry at:<version>and:latest, where<version>is the release tag without itsvand the name isghcr.io/<owner>/<repo>for the bot and that name plus-resident/-sandboxfor the other two —github.repositorylowercased (a registry path must be, a GitHub owner need not be) plus the leg's suffix, never a literal in the workflow, so a fork publishes under its own owner. Each build carries BuildKit's SLSA provenance (mode=max) and an SPDX SBOM as attestation manifests on the pushed index, andactions/attest-build-provenancethen signs a provenance attestation for the pushed digest — a tag moves, a digest does not — and stores it in the registry, sogh attestation verify oci://<name>:<version> --owner <owner>resolves the tag and checks it against this workflow. The attestation is attempted twice on that same digest, never by rebuilding: the images are not reproducible, so a re-run of the leg would push a second, unattested digest; the first attempt iscontinue-on-error, a second step runs only when it failed, a success on the first never runs the retry, a second failure is the leg's, and a run that retried carries a warning naming the digest. Neither attempt asks the action for a storage record (create-storage-record: false): the record store needs a permission the job does not grant, and the attestation in the registry is whatgh attestation verifyreads. The job logs in withGITHUB_TOKEN;packages: write,id-token: writeandattestations: writeare granted to that job alone — the workflow stayscontents: read, and the release job keeps acting as the App (item 1).deploy(item 2) waits for it (needs: [release-please, publish-image, publish-npm]): this project's own production runs inregistrymode (item 25), sodeploy allcopies only missing image tags for selected Workers into the account registry; resident and sandbox can reuse an older, digest-verified image when their build inputs are unchanged; a failed publish leg leavesdeployskipped, and rerunning the failed jobs finishes the release. What consumes them: the local loop —docker-compose.ymlnamesimage: <the bot's fact>:latestbeside itsbuild:, sodocker compose pullfetches the release's image,docker compose buildbuilds the same Dockerfile under the same name, andupruns whichever is present — and an installation inregistrymode, which copies only the tags it lacks into its own account registry (item 26). The names are facts,project.json'simage(the bot's) andimages(all three), andcheck:project-factsholdsimageequal to the name the workflow derives (ghcr.io+ the repository path, lowercased), holds each ofimages.bot,images.residentandimages.sandboxtoimageplus the leg's suffix — a missing map, a missing kind or another name is named — and holds the compose file'simage:line toimage.The PR title is the changelog line, and the check holds it to that. The repository squashes with
squash_merge_commit_title=PR_TITLEandsquash_merge_commit_message=BLANK, so the title is the whole commit message and release-please builds the changelog and the release notes from titles alone (item 1) — aBREAKING CHANGE:footer in a PR body never reaches it;!after the type is the only breaking marker — and while the release config pins the next version (release-as, the pre-launch state: the 1.x line moves by minors), the check refuses a!outright, so a breaking cleanup ships as a minor with its note under the pinned version.scripts/check-pr-title.mjs(npm run check:pr-title, the requiredtitlestatus inpr-title.yml) judges each title as that line withcheckPrTitleinsrc/core/prTitle.mjs— the same predicate thesubmit_pr_descriptionschema runs for a description bound for this repository, so a coding run here is refused the title CI would refuse while it can still resubmit, and no other repository is held to this repository's vocabulary (pr-description.md item 1) — as: the Conventional Commits grammartype(scope)!: description; a type fromrelease-please-config.json'schangelog-sections(feat,fix,perf,revert,docs,refactorvisible;style,test,build,ci,chorehidden); a scope, when present, from the Scope column of the Areas table indocs/reference/code-map.md— every code span in that column that is a scope token, prose and quoted titles ignored — where an unknown scope fails naming the whole list and the file, and a scope-less title passes (a tree-wide change); no trailing period; and the whole line at most 72 characters (TITLE_MAX_LENGTH) — git's subject convention and the point past which GitHub's commit list cuts a subject — refused naming the count and the cap beside any other problem, never truncated, with the bots' scopes (BOT_SCOPES:deps,main, the two the code map calls the bots') and arevert:(the original title, already judged) exempt; the submit tool's cap table (PR_DESCRIPTION_CAPS.title) readsTITLE_MAX_LENGTHitself. A!title additionally needsdocs/reference/migrations.mdto carry a## <major + 1>.0.0section forpackage.json's version — the release the title will cut, sincebump-minor-pre-majoris off — an exact##heading, not a mention — presence is the whole test: the first breaking PR of a cycle creates the section, later ones add their lines to it, and the check cannot tell whose lines are there (CONTRIBUTING asks, review holds); the failure names the file and the heading to add. Each list has one source and none can be widened by the title. The bot's image shipssrc/and neither source, sonpm run pr-title:genrenders both lists intosrc/core/prTitleVocabulary.json(renderPrTitleVocabulary: the generator note first, thentypes, thenscopes) — the one file the gate and the tool both read — andpr-title:check(part ofcheck:consistency) fails the build when that file is stale or missing, naming the fix; a unit test holds the committed file equal to the tree's render, andcheck:distproves the predicate and the file reachdist/. The note lives in the tree because the PR body is not part of the commit and release-please regenerates the release PR's body on every push;pull-request-header/footerandextra-filescannot carry a per-change note either, andchangelog-type: githubwould drop the section grouping, so the release config stays as it is. In the merge queue there is no title and the check passes with a note (checked on the PR).A release publishes the CLI to npm once publishing is turned on. Beside
deployandpublish-image, thepublish-npmjob — run only for a release cut from the repository's default branch AND when the repository variableSWITCHBOARD_PUBLISH_NPMistrue(packaging.md item 5) — publishespackages/switchboard— the CLI bundled with the files it reads,project.json'snpmPackageby name — at the release's version, with--provenance=false— trusted publishing generates a bundle by default and npm accepts one from GitHub-hosted runners alone and this project's CI runs on Namespace runners (the registry answered 422Unsupported GitHub Actions runner environment: "self-hosted"on the first public release) (packaging.md item 5). The version is the release commit's: release-please's one component moves the package manifest and the lockfile's workspace entry as extra files, so the package, the tag and the image carry one number.packages/is inert to--affected(item 6): the release publishes it, nothing deploys it. It needs no secret: the package's npm settings name this repository and workflow as its trusted publisher, and the run's OIDC identity is the credential — nothing to rotate, nothing that expires; a misconfigured publisher fails that job alone. Two switches, two grains: the variable is per release — turned off before a release PR merges, that release is tagged, its images published and production deployed, and the package is not published (the run carries a notice saying which switch was off); the branch is per line — a release line kept on another branch that this workflow also runs on (the branch must be named inon.push.branches, asv1.2's copy names itself) can cut releases and publish its images at:<version>without ever reaching npm, and starts publishing the day it becomes the default branch, with nothing to edit in this job.A deploy runs from a checkout or from the published package, through one root. Every path a deploy command touches resolves through the operator root (
src/deploy/operatorRoot.ts, resolved once per process insrc/deploy/host.ts), which names three places: the installation's own files (deploy/profile.json,config/config.yaml,.env, a relativeconfigSourceorsecretsSource), the shipped files (the templates, the secrets manifest, the examples,project.json, the Worker sources), and the Worker directories wrangler runs in with their renderedwrangler.jsonc. In a checkout all three are the repository root, and every path resolves exactly as before the root existed; the plan's first line isRoot: <path> (a checkout)and the git checks stand. From the published package (packaging.md item 7) the installation is one directory every command agrees on without acdfirst —SWITCHBOARD_HOMEwhen set (a leading~/is the home), else the directory the CLI was run in when it already holds an installation (.env,config/config.yamlordeploy/profile.json,INSTALLATION_MARKERS), else~/.switchboard, created byinit(resolveOperatorRoot'schosenBysays which;initprintswrote to <root>:when that is not where the operator stands) —init --cloudflarewrites the profile there,.envis loaded from there, and the profile's relativeconfigSourceandsecretsSourceare relative to it — the shipped files stay in the package'sdist/assets/, and the Worker directories are a work area,<root>/.switchboard/, materialised before anything runs in them (src/deploy/workArea.ts): the shipped tree copied whole, a stamp (.materialised.json) recording the CLI version it came from and the Workers installed, and each Worker about to run installed with onenpm ci --include-workspace-root --workspace deploy/<worker>…against the shipped lockfile — the release's pinned versions — so the Worker's tooling and the root runtime closure imported by its shipped sources are both present, for the union of what was installed and what is requested, sincenpm cistarts from an emptynode_modules. A CLI at another version starts the work area over; a Worker already installed is not installed again; a.switchboard/without the stamp is refused by name, never deleted; nothing is ever written inside the installed package.deploy initanddeploy secretsmaterialise for themselves (deploy initthe copy alone — it spawns no npm;deploy secretsthe Worker it puts to, running the wrangler its install placed: the Worker directory's own nested one, else the work area's hoisted one, PATH only when neither exists), so the renderedwrangler.jsonclands beside its template under.switchboard/deploy/<worker>/, and every output path says so; the work area is brought to the CLI's version before a rendered file is read as well as written, so a render another version left behind is never judged current and then swept away by the install (written .switchboard/deploy/<worker>/wrangler.jsonc;put NAME → .switchboard/deploy/<worker>). The plan is the same plan — the same steps, dirs, commands and gates — withroot: { mode: "package", path, version }and both tree checks off (checks.cleanTreeandchecks.atOriginMainare false whatever--allow-branchsays): the first line isRoot: <path> (the published package <version>), theChecks:line says the Worker sources are the package's at its version, materialised under.switchboard/, no git, and names the Workers not yet installed there. The runner materialises and installs every planned Worker first, renders, then checks; from the package the tree checks give way to the work area holding the package's version, and — because the package carries nosrc/,web/or toolchain — a step whose image is a Dockerfile OUTSIDE its own directory (the bot's../../Dockerfile) is refused up front naming the Worker, the Dockerfile and the checkout, before the memory Worker rolls; the resident's and sandbox's Dockerfiles are their own and build from the materialised directory (Docker still needed). The commit a package deploy stamps into each Worker and compares/healthzagainst is the one the package was built from (source.json, packaging.md item 7): the runner hands it to each step asSWITCHBOARD_BUILD_COMMIT(spelled insrc/deploy/buildStamp.ts, held equal to the scripts' by a test — the bot image carriessrc/and notdeploy/), whichdeploy/bin/build-stamp.mjsanddeploy/cloudflare/write-build.mjsread before asking git,builtAtstill being now; a package built from a dirty tree carries<sha>-dirtyand is refused like an unreadableHEAD.--affectedfrom the package judges every Worker against that commit: one serving it has nothing to deploy, every other is unsure — no ancestry, release tag or diff to consult — and deploys with the reason named. Prebuilt Worker bundles would remove the install and the copy; today wrangler runs in the materialised directory exactly as it does in a checkout.The profile chooses where a Worker's container image comes from.
buildmode uses each Dockerfile;registrymode uses a copy in the installation's Cloudflare account. GHCR still publishes bot, resident and sandbox at the release version. The bot's account image uses that version because it embeds the release commit. Resident and sandbox useinputs-<sha256>tags derived from each Dockerfile, its localCOPYfiles and.dockerignorewhen present. The first deploy of an input set copies the release image to that tag; later Worker-only releases use the same account image reference. If an input is missing or unfamiliar, the renderer uses the release tag and the ordinary copy and rollout.deploy initrenders these references from the profile, anddeploy planshows their presence. The existing Worker and sandbox live gates still prove the new code and running container. See record 0084 and the original registry decision.deploy allcopies only missing images for selected Workers. Inregistrymode,deploy planreads the exact account tags and reports which are present.deploy allcopies a missing bot image from its GHCR release tag to the same version in the account, and a missing resident or sandbox image from its GHCR release tag to its image-input tag. The HTTPS transfer selects the linux/amd64 manifest, checks each blob digest, verifies the account's manifest digest, and replans from exact readbacks before any Worker deploys. An existing input tag needs no transfer on the next release.deploy imagesremains the explicit pre-warm command;deploy all --dry-runsays what it would copy without writing. A missing credential or failed transfer stops before rollout. No Docker daemon is needed inregistrymode. The transfer decision and reuse decision give the rationale.The deploy workflow is reusable from any repository, in two modes, and this repository's release is its first caller.
deploy-production.ymldeclaresworkflow_callinputs —targetsandforceas before;profile(a path in the calling repository or agithub:///op://reference; empty, the caller'sSWITCHBOARD_DEPLOY_PROFILErepository variable);cli(checkout, the default: the calling repository is checked out — it is this one — thennpm ci, and every command isnpm run --silent cli --;package: nothing of this repository is checked out, Node is set up at the version the published CLI'senginesnames, and every command isnpx --yes <npmPackage>@<version>);version(packagemode: the input, else the caller's tag without itsv; judged by the CLI's own release-version rule — three numbers, an optional pre-release — so anything else — a branch,latest, a bare major — is refused before anything runs);copy-images(auto, the default, ornever) — and every secret by name (CLOUDFLARE_DEPLOY_TOKEN,CLOUDFLARE_API_TOKEN,MEMORY_TOKEN,RESIDENT_READ_TOKEN,RESIDENT_DRAIN_TOKEN,SANDBOX_TOKEN;CONFIG_REPO_APP_CLIENT_IDandCONFIG_REPO_APP_PRIVATE_KEYfor agithub://profile), none required, so a caller in another repository passes them withsecrets:and this repository's own call passessecrets: inherit. One command word,$CLI, carries everydeploycommand, and the steps run in one order in both modes: the copy (deploy images, BEFORE the plan as a pre-warm —deploy allwould copy the same images itself, item 26; the profile decides, abuildprofile copying nothing; skipped only bycopy-images: never, which leaves the copy todeploy all),deploy plan(into the log and the job summary), incheckoutmode the proof that the tree is the commit,deploy all, and the live table. Inpackagemode the calling repository is checked out only when the profile is a path in it (agithub://orop://profile checks out nothing) and shallow — there is no tree for--affectedto diff, so no history or tags are fetched;checkoutmode fetches both — the App token is minted only whenCONFIG_REPO_NAMEis set, and aCONFIG_REPO_NAMEwith no App to read it (the two App secrets not passed) is refused by name before the mint; the main-only guard — which guards a tree — does not apply.checkoutmode runs exactly the commands the workflow ran before it was reusable, plus the copy the profile answers.release-please.ymlcalls it as before —uses: ./.github/workflows/deploy-production.yml,with: targets: affected, copy-images: never,secrets: inherit— so this project's production deploys through the same file an operator's CI calls withcli: package(Deploy from your CI).The lockfile is held installable on a cold runner by
check:lockfile, the first step ofcheck:consistency.scripts/check-lockfile.mjsreadspackage-lock.jsonand fails, naming what and how to fix it, on any of three defects, each pure over the lock JSON: (a) a native package family present in the lock without the platform variants CI and the image run (linux-x64/linux-x64-gnu) and the laptops (darwin-arm64) — npm records only the current machine's optional platform package whennpm installruns withnode_modulespresent; (b) a root or workspace record that no longer mirrors its manifest'sname,version,bin,workspacesor dependency maps — npm then re-resolves the ranges instead of trusting the lock; (c) an edge npm cannot honour or a record it cannot pin: everydependencies/optionalDependencies/peerDependenciesentry of every record (and the root's and each workspace'sdevDependencies) is resolved the way npm resolves it —<dependant>/node_modules/<name>, then the parent'snode_modules, up to the root's, a workspace link (link: true) followed to the workspace's record — and the resolved record'sversionmust satisfy the declared range (npm:<name>@<range>aliases judged by their range; a git, file or tag spec not judged; a bundled dependency travels inside its dependant and is skipped — the listed names, or every dependency when the manifest shorthandbundleDependencies: truewas mirrored; an optional or peer edge may resolve to nothing, a required one may not), and every fetched record (not the root, a workspace, a link or a bundled copy) must carryresolved, the exact tarball URL or git commit npm will fetch (integrityis npm's to write beside it and is not demanded: npm omits it for a git checkout and, reproducibly, for a nested duplicate of a package it already fetched; a record with neither is one npm fetches by version alone). npm treats an unsatisfied edge as invalid and re-resolves its range against the registry on every install — which holds until the registry has a newer version inside the range, when a coldnpm cirefuses the lock on every branch at once (Invalid: lock file's @types/node@24.13.4 does not satisfy @types/node@24.13.5—web/node_modules/@types/nodeat 26.5.0 againstweb's^24.0.0, carried since the scaffold, invisible tonpm ls --omit=devbecause it was a workspace's devDependency); an unpinned record is fetched by version alone. The fix for (c) is to delete the named nested record(s) and runnpm install --package-lock-only, which re-adds what is needed, pinned.The test suite leaves nothing in the shared temp dir. The root
vitest.config.tshas oneglobalSetup,src/core/testing/tempRoot.ts: before any project's files run it makes one directoryswb-vitest-*under the machine's temp dir and pointsTMPDIR(andTMP,TEMP) at it;os.tmpdir()reads the variable on every call and the worker processes — and every child a test spawns — inherit the environment, so everymkdtempSync(join(tmpdir(), …))in every project (bot, web, the plain-Node Worker tests) lands under that root without the suites changing; at teardown the root is removed whole. The alternative — a cleanup in each of the sixty-odd files that make a temp dir — is one the next test forgets: onenpm testleft about 7,500 directories behind, one developer machine accumulated 950,000 (19 GB), and every glob of the temp dir and every tool that indexes it paid for them. A run killed before teardown leaves exactly one directory, named for what it is. A test never globs the shared temp dir for its own leftovers (it reads a directory of its own), and a config edit that drops the setup fails the guard test in the same run.The Slack app's interactivity is a setting a person flips, never the deploy. The Run and Cancel buttons a routed state change is offered with (slack-channel.md item 14) reach the bot only while the Slack app has Interactivity & Shortcuts on: over Socket Mode a
block_actionspayload rides the websocket the events already use, so no request URL is asked for. The manifest (docs/public/slack-app-manifest.yaml) setssettings.interactivity.is_enabled: true, so an app created from it has the setting; an app created from an earlier manifest needs it switched on by hand — api.slack.com → the app → Interactivity & Shortcuts → on → save. Nothing in the deploy can flip it and nothing reads it back (auth.testdoes not report it), so it is a human-gated step of the checklist beside item 11's secrets. With it off, text confirmation remains available: the offer message showsconfirm <saved-id>andcancel <saved-id>beside its buttons. A click never reaches the bot, but the requester can type either form in the original thread. The shared click executor checks the saved action, current permission, requester, expiry and single-use consumption. Retyping the command creates another offer when confirmation is required.The resident step drains the fleet, so its wait ends when the runs in flight end (record 0059;
residentDrain.ts, the loop inrun.tsdeployStep; the Worker side is resident-repos.md item 69). Item 13's wait for the resident is a wait for a quiet minute, and steady traffic has none: with ship children holding a repo for their whole lease and new runs landing as old ones end, three consecutive releases refused for their full 30 minutes and left the resident and sandbox releases behind the bot. So with the required resident drain bearer (RESIDENT_DRAIN_TOKEN— a bearer that passes/drain,/deploy-fence,/reconcileand/undrain, so CI never holds the admin one), the runner posts/drainbefore the step's first attempt — for the drained wait plus a thirty-minute margin (20 minutes reserved for upload and readiness, with ten minutes of scheduling room),by: "deploy all",reason: "deploy <commit>"— and the fleet refuses new attaches while the runs in flight finish (resident-repos.md item 69: a new run waits at its attach under its own lease, and starts after the deploy). The step then waitsRESIDENT_DRAINED_WAIT_MAX_MS(60 min: past a coding child's 45-minute ask and its write-up, the longest a run in flight can outlive the drain's start) instead ofRESIDENT_WAIT_MAX_MS, retrying the preflight every minute as before. The drain request must return a successful acknowledgment before the resident command starts. A rejected response, missing acknowledgment or lost response stops the step before upload, retry, reconcile or undrain. A conflict can belong to an earlier upload; a lost response can follow a committed drain. Neither permits cleanup. HTTP 409 triggers one bounded read-only registry lookup with the existing read bearer to report the original fence commit and timestamps when valid. The diagnostic does not grant recovery authority and omits arbitrary response bodies. In afinally, an acknowledged drain is lifted after successful reconciliation or after a structured preflight refusal proves no upload began. An uncertain upload, old Worker build or failed reconciliation leaves any landed drain closed until verified recovery or its backstop. A deployed step reconciles the fleet INSIDE the drain window, before that lift (resident-repos.md item 69's order: the runs in flight end, the swap lands, the containers reconcile, the fleet reopens): it postsPOST /reconcilewith the same drain bearer — the Worker walks every resident and each Durable Object restarts a container that predates the new image (reconcileForDeploy, the image reconcile of resident-repos.md item 16b, under the deploy's own name) — because a lift at preflight-ok left a window between the reopened fleet and the reconcile's restart in which an admitted run was killed when the shared container restarted under it, and the reconcile that followed restarted the container under a live run. The reconcile line names every resident's word and whether its fresh container was VERIFIED on the new image (fleet reconciled and every container verified on the new image (…),fleet reconciled, but not every container is verified …naming the unverified — a container confirmed inactive reports during the reconcile, while a failed activity probe preserves its pending marker and hold, an active replacement reports after hydration and a deferral waits for its next quiet refresh without restarting under a registered run — orthe fleet could NOT be reconciled …), but that line is not readiness evidence: a deployed resident step first requires a successful reconcile against the exact new Worker to establish this deploy's image markers, then succeeds only after an authenticatedGET /residentsreportsdraining: nullandimageReport: currentfor every resident, alongside the Worker serving the exact expected commit.cleared: falsestays pending. The runner polls for at mostDRAIN.cycleBoundMinutes(10 minutes), bounded reads included; a timeout, stale or missing report, unreadable registry or failed reconcile ends as a partial deployment (deployed, not live), preserving the uploaded version and never rolling it back. A backstop-cleared drain with pending reports still fails. This readiness gate applies without the drain bearer and under--forcetoo; neither bypasses proof of readiness. Without the drain bearer, endpoint or POST capability, reconciliation cannot be performed and the uploaded step ends as a partial deployment even if the registry already reports every image current: an absent pending marker is not proof of the new image. The workflow'swhat is livereceipt fails on a selected Worker's missing/wrong commit or a resident registry mismatch; its selection comes from the pre-deploy plan, never a recomputed affected set after upload. Skipped Workers may keep their previous commit. The reopen is a fact, not the lift's return (issue 1931 reopened): "reconciled" marks the Durable Objects while the platform replaces the container processes asynchronously, so the registry holds the drain for every resident the reconcile could not verify on the deploy's image and/undrainthen answerscleared: falsewithheldnaming them — the lift line reports that image-report holds remain without claiming that another guarded request can reopen the fleet immediately — and the fleet normally reopens on the LAST held container's own report (resident-repos.md item 69); the bounded safety backstop below can release admissions but cannot prove readiness. The hold owns the cycle it asked for (issue 2044: the gate's first live deploy held the whole fleet 34 minutes on a container the platform never cycled, until a person reopened it by hand): the first hold stamps the record'sholdsUntil— the cycle's measured bound,DRAIN.cycleBoundMinutes, clamped tountil— and past itliveDrainreads the record as no drain, the registry's alarm naming the stale containers in a warning; a rebuild or fresh provision reports its own container current and counts as the report; the drain'suntilremains the last resort, for a record from before the bound. A proven preflight refusal posts no/reconcile: nothing deployed. A failed command also posts no/reconcile, but its upload may have landed, so it reports partial deployment and does not lift the drain. The lines report an acknowledged drain, a closed refusal class and stopped deployment, or a missing bearer. A fence conflict can also report its original commit, creation time and expiry. A successful guarded lift reports an acknowledgement that still requires registry readback; a failed lift reports a closed refusal class or an unknown result, with reopen status unverified. When an upload may have landed without reconciliation, the line says the fleet stays closed until verified recovery or the drain backstop. A refusal past the drained budget fails by item 13's line with the suffix that what refused was already in flight when the drain began and outlived it, and the drain is lifted.--forcekeeps its preflight bypass, not a readiness bypass. Why the drain and not a longer wait: a longer wait for silence still loses to back-to-back children; the drain makes the wait end by construction, in at most the longest lease already running. Why not drain the bot the same way: the bot already drains itself on SIGTERM (item 13, slack-channel.md item 8), and its runs hand off; only the resident's isolate swap has nothing to hand a run to.A deploy never rolls an older build over a newer one: the supersede guard refuses it by name (
src/deploy/supersede.ts, wired inrun.tsrunDeployPlan). Thedeploy-productionconcurrency group (item 2) queues a second deploy instead of racing it — but a queued re-run of an OLDER release's failed deploy still runs after the newer release went live, and would roll every selected Worker one release back, silently, with/healthzreporting the older commit as "live". So after the pre-checks and before anything uploads,deploy allreads each selected Worker's live/healthzbuild.commit— the live gate's URL, the preflight heartbeat's, or the wake URL; the sandbox's with its bearer — and refuses a Worker whose live commit already CONTAINS the commit being deployed (git merge-base --is-ancestor <deploying> <live>against the checkout, which has the history), with the sentence naming both:refused: <worker> is live on <live> which already contains <deploying>; this release is superseded — re-run nothing, the newer release carries it. A live commit equal to the deploying one proceeds (an idempotent redeploy), an older one proceeds (the normal upgrade), and the guard refuses only what it can prove: an unreadable/healthz, a non-2xx answer, or an ancestry git cannot answer (anunknownor-dirtybuild, a sha not in the checkout) proceeds as before — the live gates own those cases — and from the published package nothing is judged (no git; item 24's probe says the same).--force, orSWITCHBOARD_DEPLOY_FORCE=1in the environment (DEPLOY_FORCE_ENV— the bot preflight's own force switch), overrides for a deliberate rollback: the deploy proceeds and a warning names both commits and says a rollback is happening. Operationally: a newer release cut while an older run's re-run is pending supersedes it — cancel the older run rather than letting it queue; if it runs anyway, this guard is what refuses it.CLI restart requires the selected installed owned consumer. Freeze the account/application/admin/health/state target once. Before POST, require the publishing parser to match the installed owned-slot receipt and the exact unique native running singleton, then compare the source and owned document version/digest. Foreign, legacy, missing or unknown consumer/native evidence refuses even under force. Report
base-<full-CID> vNand accept only a later healthy process with the same native/config tuple on that target. The independently authorized Worker/admin/restartrecovery route still authenticates its bearer throughSWITCHBOARD_INGRESS_TOKENSandSWITCHBOARD_RESTART_DEPLOYER; it is distinct from CLI eligibility. The CLI cannot repair a refusal-only missing slot throughdeploy configfollowed by restart, and neither that route nor a retained snapshot proves bootstrap, migration, restoration or protected-workspace custody.Bot version changes require the selected image and the serving runtime to agree. The registry, Worker upload, container application and process are independent state. In registry mode the plan binds the bot gate to the release's exact account image. Before upload it reads
/containers/applicationswith the selected account and privately captured Wrangler API-token, OAuth or API-key credentials (the dashboard-backedcontainers list --jsonis a different endpoint), then compares the selected registry image against both native views. Missing or incomplete evidence refuses even with--force. A selected image equal to the raw list image while the direct application targets another is a known no-op and refuses before Worker upload. Different native versions alone do not refuse a fresh image or Worker-only deployment; build mode relies on the direct application, runtime version and exact built commit after upload. After upload the runner reads the named application's directcontainers inforesult, its running instances, and public/healthz; Wrangler's printed diff never authorizes a Worker-only classification. Live requires the selected image, a unique singleton identity with no conflicting running instance, and ready, non-draining singleton health with the full selected commit. Placement state may lag process health: stopped or inactive metadata, including a missing instance version, does not negate that exact response. Application versions are identities, not release ordering: a prior version may be reused. In build mode the direct application image and exact built commit must agree; running-instance evidence cannot contradict the selected application. A raw list image matching the selection can make Wrangler skip a needed rollout; uploading a Worker or restarting alone does not prove uptake. The pinned prior-package command uses that package's deployment implementation; this gate fix applies only to packages containing it. No automatic restart, rollout retry or application deletion is part of this gate.CI stalls have a finite deadline. Every job in the mirrored CI workflows has a positive explicit deadline of at most 60 minutes, including planning, gates and docs deployment; compute legs use 20 minutes, test shards 30 and image builds 60. A job stall therefore reaches its deadline rather than the six-hour platform cap. Main pushes retain their running verification; stale PR runs cancel. The limits bound stall impact and do not establish why a job hung.
An empty staging installation has an explicit creation path.
staging:deploy --initializerequires staging isolation, a clean exact checkout, all creation credentials and readable complete native inventories. Existing selected Workers, applications, namespaces or Workflows refuse, except a recorded provisional Memory checkpoint verified against the original accepted upload, current full deployment, unchanged code/bindings/namespaces and the same account-token writer. An approved continuation checkpoint can also identify a completed Memory, Resident and Sandbox prefix. The remaining selected resources must be absent. It installs provisional Memory without unavailable Bot bindings, creates the executors, opens deploy admission, snapshots and conditionally creates an empty owned config slot with an empty legacy slot, rechecks Bot absence, creates the Bot, activates its provisioned secrets through an authenticated non-forced restart and proves its exact config/process/application identity, then restores full Memory bindings. Uncertain reads, config acknowledgments or uploads stop without automatic retry, deletion or restoration. Fresh creation records the provisioned Memory checkpoint before probing its domain. An explicit receipt continuation reuses only that checkpoint and requires empty legacy/candidate config slots; changed or unknown state refuses. An executor checkpoint binds the original failed CI controller and artifact hashes, unchanged installation source, configuration and profile, original accepted uploads, current native application/image/namespace ownership, custom domains, writer and provisioned secret names. The completed prefix is verified again without repeating uploads or secret writes. Sandbox must pass its full native and execution gate before the remaining config, Bot and full Memory steps run. First-install secret activation uses the existing bounded authenticated build wait before the strict Sandbox gate. Ordinary updates retain their normal runner and immediate credential refusal. Other partial phases remain held for inspection.
Validation criteria
The ingress smoke command accepts optional --model provider/model, and a manual staging workflow accepts optional model. Omitted or empty workflow inputs use the configured model, including automatic release-PR runs. An explicit workflow input supplies the same model override to setup checks and actual acceptance. Overrides apply the existing model: directive to each new acceptance request. Receipts record the requested and actual model; a missing or different actual model fails acceptance and skips dependent scenarios. Omitting the CLI override retains configured behavior. All existing actor, build, head, workspace, publication, cost, budget and Slack gates still apply. Model overrides do not change persistent configuration or replay prior runs.
| Criterion | Evidence |
|---|---|
| The staging workflow uses the configured model when automatic/manual acceptance has no explicit model; an explicit model wins, and both setup check and actual acceptance receive the same override. | [unit] src/deploy/staging.test.ts::staging workflow isolation::uses the same staging acceptance model for check and execution…; the existing actual-model and guard criteria below still apply. |
34: a manual CI bot rollback selects an exact published CLI using cli=package, version=<prior-version>, targets=bot, force=true and copy-images=never; it uses the existing serialized deployment runner and its application/image/exact-health gates. State and private workspace custody are not reset by rollback. | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::a manual rollback selects an exact published CLI through the existing package deploy path; [agent] dispatch the pinned prior version and require only the bot in the frozen plan, the selected application image and unique singleton identity without a conflicting running instance, and the prior full commit in ready, non-draining /healthz. |
| 34: registry copy alone changes only inventory, and Worker rollback plus restart changes only Worker/process state; both leave the newer named bot application target and are refused by that retained target | [unit] src/deploy/botLiveGate.test.ts::decideBotLive rollback fence::registry image copy changes inventory only…, ::Worker rollback and restart change only Worker/process state… |
| 34: a bot deploy compares list and direct application reads before upload and succeeds only after selected image, unique singleton identity and exact health agree; retained target and health mismatch fail by name | [unit] src/deploy/botLiveGate.test.ts::decideBotLive rollback fence::*, src/deploy/botGateRun.test.ts::deployStep (bot rollback fence)::*, src/deploy/plan.test.ts::WORKER_SPECS / workersFor / DEPLOY_ORDER::the bot and the sandbox carry live gates… |
15: the production state document requires runHistory.worker, providers and grants; a missing image identity, missing consumer slot or validator failure starts only a refusal health server whose 503 body names config, and a later refusal-only generation is never accepted as live | [unit] src/config.test.ts::loadAppConfigFrom::a state:// production config requires runHistory.worker, providers and grants…, src/configBoot.test.ts::the production config boot refusal::*, src/index.boot.test.ts::the production process boot fixture::*, src/deploy/liveGate.test.ts::decideRestarted::a later refusal-only generation is never live… |
33: restart freezes one deployment target and reads its current source and eligible owned slot before POST; missing or changed content refuses naming deploy config, equal content logs and returns base-<full-CID> vN, and final acceptance requires the same exact application/config tuple on that target | [unit] src/deploy/restartRun.test.ts::runBotRestart::*, src/core/commands/deploy.test.ts::deploy.restart::* |
| 33: the restart deployer is a strict optional profile field rendered into the Worker; the external route compares the token-map subject with that Worker var and calls the stop directly, never the container's runtime grants | [unit] src/deploy/profile.test.ts::parseProfile::the restart deployer is an optional deployment grant…, src/deploy/wranglerTemplate.test.ts::templateView::*, src/deploy/restart.test.ts::authorizeRestartDeployer::*, deploy/cloudflare/coordinator.test.ts::the restart route's recovery grant stays outside runtime config::* |
32: a live commit that already contains the deploying one is refused by name, the sentence naming both commits and that nothing is to be re-run; a live commit equal to the deploying one proceeds without asking git; an older live commit proceeds; --force proceeds with the deliberate-rollback note; SWITCHBOARD_DEPLOY_FORCE=1 proceeds with the note naming the env var; an unreadable live commit or an unanswerable ancestry proceeds; from the package nothing is judged; every planned step is judged at its own health endpoint, the sandbox's with its bearer | [unit] src/deploy/supersede.test.ts::supersededSteps — a deploy never rolls an older build over a newer one::*, src/deploy/supersede.test.ts::stepHealth — where a step's live commit is read from::* |
| 32: live — an older release's re-run after a newer release went live is refused before anything uploads | [agent] Human-gated: after a release deploys, gh run rerun <the previous release's run id> --failed → the deploy all step exits non-zero with refusing — and one refused: <worker> is live on <newer sha7> which already contains <older sha7>; this release is superseded — re-run nothing, the newer release carries it line per selected Worker; every Worker's /healthz build.commit is unchanged. |
| 31: cooperative operator cancellation scopes SIGINT/SIGTERM to the existing host runner, stops next pre-checks/attempts/Workers/health/probes and stops inventory after a cancelled probe, interrupts existing retry waits and awaits the owned command's close. One step-boundary fact preserves known no-upload settlement across every pre-upload return and guarded finally, including failed/malformed native reads and cancellation during cleanup. A fully completed no-upload refusal permits only the original guarded drain cleanup; partial output, active cancelled command, unknown upload or image/fence holds grant no lift. Cleanup403/409/lost reply remains unconfirmed. The existing observer window lifts for a known settled no-upload cancellation without claiming readiness; an active/unconfirmed command cannot claim that settlement. Cancellation during an informational wake preserves an already completed non-Resident upload, closes its settled observer window, stops later Worker work and grants no overall acceptance; a Resident upload still owes reconciliation/readiness. After awaited preparation, supersede and final native reads, cancellation starts no observer window or config verification and grants no readiness; Bot health is not started after cancellation; local cancelled/not-run receipts and artifact/token/Git cleanup remain. Each host fixture owns its Worker files and every temporary directory, including the child's native configuration under SIGKILL; foreign files and temporary resources retain their bytes. SIGKILL/runner loss cannot run finally; direct child close proves neither noncooperating descendants nor remote ending. Exact negative drain/fence cancellation is a separate [gap], not an inferred no-upload or forced clear. | [unit] src/deploy/host.test.ts::deployment command cancellation::*; src/deploy/residentDrainRun.test.ts::deployStep (resident) drains the fleet::cancel*; src/deploy/residentDrainRun.test.ts::deployStep (resident) drains the fleet::a cancelled active command*; src/deploy/botGateRun.test.ts::deployment health cancellation::*; src/deploy/sandboxGateRun.test.ts::waitUntilSandboxLive::cancellation during the owned probe prevents the next inventory read without readiness credit; src/deploy/configPublicationRun.test.ts::runDeployPlan config publication::a known no-upload cancellation*, ::an active unconfirmed cancellation*, ::… completion starts no observer window after cancellation, ::… acceptance reads A after upload changes the shared Wrangler account to B, ::cancelled Bot … pre-upload evidence closes the settled window without an upload, ::cancelled Resident … pre-upload evidence closes the settled window without an upload, ::cancellation during … cleanup preserves completed no-upload settlement only, ::cancellation during … before a Worker command closes only the waiting window, ::an unconfirmed cancelled install retains its waiting window before any Worker upload, ::cancellation during an informational wake preserves a completed Memory upload without later Bot work; src/deploy/liveReceipt.test.ts::what is live deployment receipt::reports cancellation locally without starting health reads, ::retains a truthful local cancelled acceptance receipt without smoke or readiness credit; [gap] exact negative drain/fence cancellation and arbitrary descendant quiescence; source-only tests do not certify a hosted cancelled workflow. |
31: with the drain bearer /drain is posted before the first attempt with the drained wait plus the margin and the commit, the refusals are waited out against the 60-minute budget, the deploy lands, a changed container application takes /reconcile inside the drain window while an authenticated unchanged application skips it, and /undrain follows only after the applicable proof; without the bearer no POST and the line names the env var, the budget is the step's own and a changed-image upload ends partially because reconciliation is unavailable; for a changed application, missing drain endpoint or POST capability likewise cannot certify readiness from preexisting current reports, even under force; a drain the Worker refused is said, the step waits as before, and a post-upload reconcile failure leaves any landed drain closed; a /drain whose answer was lost waits the undrained budget and still reconciles and lifts; a refusal past the drained budget fails by name with the drained suffix and the fleet is still reopened without a reconcile (nothing deployed); a failed command has an uncertain upload, reports a partial deployment and leaves the fleet closed; the reconcile line names every resident's word with its verification, the unverified, or the request's own failure; a missing reconcile answer or a new Worker that never reports the expected commit leaves the fleet closed; readiness separately requires exact Worker identity and a complete authenticated undrained registry with current reports, delayed reports pass, timeout and backstop-stale reports fail partially, and the workflow receipt fails on missing/wrong commits or registry mismatch; a lift answered cleared: false with held reports image-report holds and pending registry readback without echoing response bodies or promising immediate reopen; the former lift-line criterion for container names, cycle bounds and backstop details is retired | [unit] src/deploy/residentDrainRun.test.ts::deployStep (resident) drains the fleet::*, ::the pure pieces::*, ::resident deployment readiness::*, src/deploy/residentReadiness.test.ts::resident readiness evidence::*, src/deploy/liveReceipt.test.ts::what is live deployment receipt::* |
| 31: while image reports are pending after the first reconcile, the runner retries that reconcile inside the existing drain without another upload; repeated identical waiting lines are bounded to one per minute, and readiness still requires every report current | [unit] src/deploy/residentDrainRun.test.ts::resident deployment readiness::an inactive held container reports on the runner's reconcile retry without another upload, ::resident deployment readiness::identical pending readbacks do not flood the deploy log |
31: after an exact resident Worker upload, the runner reads the named container application through its configured Cloudflare authority; only a readable identical version and nonempty image target before and after plus no printed container change permits a Worker-only completion without /reconcile, and an authenticated complete registry read must show no pending image reports or replica activations before the drain lifts; pending replicas require reconciliation even when the image is unchanged; a changed target or printed container change keeps the guarded reconcile path, while an unreadable or malformed pre-upload target refuses the upload and an unreadable or malformed post-upload target leaves it partial and the drain held; an older pending report cannot be adopted or cleared; no path releases a retained workspace | [unit] src/deploy/residentDrainRun.test.ts::resident application target::unchanged named application skips image reconcile while retaining terminal workspaces, ::resident application target::changed application uses guarded reconcile, ::resident application target::a changed image with protected registrations stays pending behind the reconcile guard, ::resident application target::a printed container change refuses an unchanged native target without reconciling or lifting, ::resident application target::unreadable application cannot lift the drain, ::resident application target::an unreadable application before upload refuses without sending the Worker, ::resident application target::an incomplete or regressed application target cannot lift the drain, ::resident application target::an older pending image report blocks the Worker-only shortcut without touching the protected workspace |
| 31: required image reconciliation sends the application/version/image target from the authenticated post-upload read. Registry mode first requires the exact plan-selected image; same-image Worker redeploys need no native version change. Build-mode announcements are hints bound to the named application and must agree with structured native reads. Missing, unrecognized or foreign build intent refuses; an explicit no-change hint also requires identical native reads. A printed container change must first register a later native version and its image, when printed, within the existing readiness deadline; timeout sends no reconcile or undrain. A missing or changed acknowledgement refuses without an undrain. Readiness requires every physical member to report that exact target and a final native read to confirm it. Retries keep the originally selected target. Worker-only completion retains its separate unchanged-application proof. | [unit] src/deploy/residentDrainRun.test.ts::resident application target::*; src/deploy/residentDrainRun.test.ts::deployStep (resident) drains the fleet::*; deploy/cloudflare-resident/imageReconcileState.test.ts::resident image target decisions::* |
31: a confirmed Worker-only upload may repeat normal /undrain at most once after the exact known pre-upload-proof 409. It must retain the original drain window and whole fence (including acquisition markers), refresh the same application/image, exact healthy source and current authenticated fleet, and keep the existing deadline with request reserve. A changed target/drain, maintenance, policy refusal, cancellation or missing proof stops the retry; a second refusal ends partial instead of waiting for the drain backstop. Unknown, lost, malformed and server-failure replies use read-only settlement, never another control. A changed-image deployment retains its original reconciled target and requires that exact target on every physical member during read-only settlement. Native application reads stop within the original readiness deadline, preserve operator cancellation, and reserve time for the one allowed request during retry qualification. Readiness still needs registry draining: null and a final unchanged application read; no reconcile, reupload, force or Worker guard changes are introduced. Closed action output reports phase, status, outcome and version availability without echoing response bodies or attributing the original guard predicate. | [unit] src/deploy/residentDrainRun.test.ts::Worker-only reopen after a definite refusal::*; src/deploy/residentDrainRun.test.ts::resident application target::*; src/deploy/residentDrainRun.test.ts::the pure pieces::* |
| 31: a containing release succeeds only after registry undrain/current reports, then resident admission works | [agent] Human-gated: after person merge and a containing release, retain the deploy workflow/readiness receipt showing exact selected Worker commits, draining: null and current image reports, followed by a successful ordinary post-release resident admission. Fixture tests do not prove this receipt; no manual deploy, drain or probe admission is part of this change. |
31 (issue 2044): the hold has liveness — the first hold stamps holdsUntil (the cycle's measured bound, clamped to until, kept by later holds), a held record past the bound reads as no drain whoever died between the reconcile and the lift, staleHolds names the containers the reopen left stale and the registry alarm warns with them, a fresh provision reports its own container current, and a replay of the incident's deploy — drain, one held container that never reports, the lift asked, the bound elapsing — ends in a reopened fleet with the container named | [unit] deploy/cloudflare-resident/drain.test.ts::the hold's liveness — a cycle that never happens reopens the fleet with the container named, never a silence to the record's own until (issue 2044)::*, ::the Worker's wiring (by scan)::the hold's liveness is wired (issue 2044)… |
31 (issue 1931 reopened): the reopen gate is the containers' own reports — the lift with a hold outstanding keeps the drain standing (liftAsked) and the fleet closed, a report while another container is still old drops only its hold, and the LAST container's new-image report lifts the asked drain; a report before any lift leaves the deploy's own lift to decide; holds ride liveDrain through storage | [unit] deploy/cloudflare-resident/drain.test.ts::the reopen gate — the fleet reopens on the last container's new-image report, never on the reconcile's return (issue 1931)::* |
31: the plan — the resident alone carries drain with the drain bearer's env var, and its step names the Worker's origin to post at; the workflow declares RESIDENT_DRAIN_TOKEN as an optional secret, hands it to the job's env, and never hands the admin bearer | [unit] src/deploy/plan.test.ts::…::names which steps are preflighted …, src/ciWorkflow.test.ts::…::declares every secret by name so another repository can pass them, none required … |
31: live — a release whose deploy job starts while runs are in flight on the resident: the job's resident lines read fleet drained … until …, heartbeats against 60 min, preflight ok once the runs end, the deploy, fleet reopen acknowledged, and authenticated registry readback; /healthz on the resident reports the release commit at the end; no run was killed (each in-flight run's card ends normally) | [agent] The first release after the secret is set — the deploy job log and the resident's /healthz are the receipt; a run asked during the drain is the receipt for resident-repos item 69's card note. |
| The secrets manifest parses (the committed file and a fixture); a lowercase name, an unknown Worker, an empty Worker list and a duplicate name are named by field (item 18) | [unit] src/deploy/secrets.test.ts::parseManifest::* |
secretsSource: the default directory, a path, and a vault-item reference parse; a field in the vault reference, a missing item, an empty value and an unknown scheme are refused; secretRef names where one value is read from (item 18) | [unit] src/deploy/secrets.test.ts::parseSecretsSource / secretRef::* |
The put plan: the Worker's secrets in manifest order bound to its directory; a required name without a value is missing, an optional one skippedOptional; --only narrows and an unknown name is a problem naming the Worker's names (item 18) | [unit] src/deploy/secrets.test.ts::planSecretPuts::* |
deploy secrets end to end over a fake host: renders only the target Worker's wrangler.jsonc first (rewriting nothing when it is current; a template that cannot render is unavailable naming it, nothing put), puts in order from the profile's source (dir or op) or a one-invocation --source override, refuses before any put on a missing required value, --only, a failed put stops naming the rest and quoting wrangler's [ERROR] line(s) (else its last line), manifest/source/secretsSource failures are unavailable; CLI-only, deploy:write (item 18) | [unit] src/core/commands/deploy.test.ts::deploy.secrets::*, src/deploy/secrets.test.ts::wranglerFailureLine…::* |
The manifest names every Worker and nothing else, unique SCREAMING_SNAKE names, each secret a field of every listed Worker's Env, every bot secret forwarded into the container except the explicitly Worker-only Depot organization token (whose bridge bearer alone reaches the bot), STATE_WORKER_URL never a secret (item 18) | [unit] src/core/secretsManifest.test.ts::deploy/secrets.manifest.json::* |
Live: a Worker dir's npm run secrets with one required file missing refuses before wrangler is reached; with every file present it puts each and wrangler secret list shows them (item 18) | [agent] Move ~/.secrets/switchboard/MEMORY_TOKEN aside → npm run secrets in deploy/cloudflare-memory exits 1 with refusing: no value for required memory secret(s) MEMORY_TOKEN — expected ~/.secrets/switchboard/<NAME>. Nothing uploaded.; restore it → put MEMORY_TOKEN → deploy/cloudflare-memory and done: 1 secret(s) on memory from ~/.secrets/switchboard/<NAME>; npx wrangler secret list in that dir lists MEMORY_TOKEN. |
Tests, docs, specs, CI under .github/ or .depot/workflows/, scripts, the deploy tooling, the isolated acceptance source and repo metadata are inert to production deployment; a production Worker's directory and the bot's image sources are inputs; a path nobody claims is unclassified, never silently inert | [unit] src/deploy/affected.test.ts::classifyPath::* |
The import closure follows relative imports transitively from the Worker entry (.js → .ts, index files, cycles once); an unresolvable import is reported, not dropped; the four real entries resolve completely and stay inside src/ and their own dir | [unit] src/deploy/affected.test.ts::importClosure::* |
| A workspace's closure in the root lockfile resolves like npm (nested before hoisted, transitively, production edges only inside, installed peers followed and optional peers not, a package's bundled dependencies tracked by the package itself); a devDependency bump changes nothing for a Worker bundle and does count for the bot image; a production dependency moving, appearing or disappearing is a named change; an unreadable lockfile or an unknown workspace is a change (fail open); the real lockfile's resident and sandbox closures carry each Worker's own pinned SDK | [unit] src/deploy/affected.test.ts::lockfile workspace dependencies::* |
A package.json diff that changes only version is inert; the exact isolated acceptance source workspace registration is also inert to production deployment, while any other field change selects affected production artifacts | [unit] src/deploy/affected.test.ts::package.json version bumps::* |
Nothing changed → every Worker skipped, nothing selected; a shared src/ module selects exactly the Workers that import it plus the bot; docs/specs/tests alone select nothing; an unclassified path makes every Worker unsure and selects the fleet, naming the path | [unit] src/deploy/affected.test.ts::computeAffected::nothing changed…, ::a shared src module…, ::docs, specs and tests alone…, ::an unclassified path… |
| Changes confined to the isolated acceptance source, including its exact root workspace registration and lockfile link, select no production Worker, even for its code, manifest and deployment config | [unit] src/deploy/affected.test.ts::computeAffected::the isolated acceptance source does not select production Workers |
Base selection: live commit first; unreadable live → last release tag; neither → unsure and deployed; a dirty / unknown / non-ancestor live commit is never a base; a Worker serving HEAD has nothing to deploy; --base judges every Worker against one ref and probes nothing | [unit] src/deploy/affected.test.ts::computeAffected::a Worker whose live commit…, ::a live commit that is dirty…, ::--base overrides… |
A git failure is never a confident answer: a diff that fails (a base not in the checkout) makes that Worker unsure, never "skip"; no readable HEAD makes every Worker unsure before anything is probed; a --base with a leading - is refused before it reaches git | [unit] src/deploy/affected.test.ts::computeAffected::a failed diff is unsure…, ::no readable HEAD…, src/core/commands/deploy.test.ts::deploy.plan::--only narrows… |
The resident's Dockerfile, wrangler config and its SDK moving in the root lockfile reach only the resident (not the bot, not the sandbox's nested copy); the bot's version-only package.json bump is inert while a dependency change or a root toolchain bump is a bot input; the markdown names each Worker's decision, base and reasons and the selection in deploy order | [unit] src/deploy/affected.test.ts::computeAffected::the resident Dockerfile…, ::the bot's own version-only…, ::the markdown summary… |
workersFor(profile): every Worker names its /healthz, only the sandbox needs a bearer; the resident's requirement is any of the three bearers; deploy steps strip only CLOUDFLARE_ACCOUNT_ID; a plan built from an AffectedReport has exactly the selected steps (minus --skip) and renders the report; an empty selection renders "nothing to deploy" | [unit] src/deploy/plan.test.ts::WORKER_SPECS / workersFor / DEPLOY_ORDER::every Worker…, ::names which steps are preflighted…, src/deploy/plan.test.ts::planDeploy::each step spawns…, ::an affected report… |
The profile binds each Worker to its script and hostname; the account, health URLs, admin route and config source are the profile's; another profile yields another fleet from the same specs; a plan from the example says so and carries the origin deploy all refuses (item 14) | [unit] src/deploy/plan.test.ts::WORKER_SPECS / workersFor / DEPLOY_ORDER::the profile binds each Worker…, ::a plan from the example profile…, src/deploy/restart.test.ts::planRestart / formatRestartPlan::the plan names the bot's admin route and /healthz — derived from the profile… |
Parsing a profile: the fixture and both committed profiles pass; problems are named by field and never carry the value; a hostname outside the zone — the profile's, or the Worker's own when it names one — or a shared script name is refused; secrets source and access are optional; a profile that still names workers.docs parses with the key dropped; every URL derives from the hostnames (item 14) | [unit] src/deploy/profile.test.ts::parseProfile::*, src/deploy/profile.test.ts::profileUrls::* |
A partial profile (item 14): a bot-only profile is a one-step plan — the bot with its preflight and live gate, config without a stateWorkerUrl and the text saying nothing is pushed; bot + memory is a two-step plan in canonical order with the config pushed to that Worker; --only naming an absent Worker deploys nothing for it and warns, --skip of one is silent; the production-shaped profile plans exactly as before (every Worker, the config to the state Worker, no warning) | [unit] src/deploy/plan.test.ts::planDeploy over a partial profile::* |
A profile may leave memory, resident and sandbox out (the bot is the one required Worker — a missing bot is workers.bot:); the URLs of the Workers it lacks are undefined while the bot's stand | [unit] src/deploy/profile.test.ts::parseProfile::memory, resident and sandbox are optional…, ::names each problem by its field… |
The bot's template renders against a bot-only profile with nothing left unfilled — its STATE_WORKER_URL line drops (an {{#if urls.stateWorkerUrl}} block, the resident template alike); the full profile keeps it; the Workers the profile lacks have no view and no target | [unit] src/deploy/wranglerTemplate.test.ts::templateView / renderTemplate for a bot-only profile::* |
deploy config over a profile with no memory (state) Worker is unavailable naming the profile, and nothing is pushed | [unit] src/core/commands/deploy.test.ts::deploy.config::a profile with no memory (state) Worker → \unavailable` naming the profile, and nothing is pushed` |
| Base config publication uses the version observed before Worker uploads; a successor write is preserved. Only a positive exact-next-version acknowledgement credits a write; lost response bodies and malformed acknowledgements remain unknown, without retry. Canonical base bytes must match their digest. | [unit] src/configDocument.test.ts::ConfigDocumentClient::a frozen version refuses a newer admin setting without refreshing or retrying, ::ConfigDocumentClient::does not credit an uncertain acknowledgement…, ::ConfigDocumentClient::a committed write with a lost body is unknown and never retried, ::ConfigDocumentClient::refuses canonical bytes whose digest disagrees before a direct write |
deploy config returns and renders the private input snapshot key, without returning either YAML document. | [unit] src/core/commands/deploy.test.ts::deploy.config::returns the private input snapshot key without returning either config document |
| A new state-backed consumer selects only the document derived from its exact, clean full image-owned commit; unknown/short/dirty identities, writable or symlinked build artifacts and foreign slots refuse. A legacy state location is a mode hint, while file mode keeps its configured path. | [unit] src/configConsumer.test.ts::image-owned config identity::*; [gap] Boot, publisher and actual-consumer integration. |
| A consumer-aware config loader reads only its staged consumer slot, refuses a missing own slot instead of using a valid legacy base, and refuses an explicitly foreign slot before any state read. | [unit] src/configConsumerLoader.test.ts::consumer-bound config loader::* |
| A conditional slot write compares the frozen input-source version and target-slot version in the same existing ConfigDO transaction. Changed source or target refuses without changing either document; malformed source predicates refuse before target creation. | [worker] deploy/cloudflare-memory/config.test.ts::ConfigDO routes::a new target slot cannot bless an input source changed after preparation, ::ConfigDO routes::matching source and target predicates publish once while preserving the input source, ::ConfigDO routes::an invalid source predicate refuses before creating the target |
| A refusal from a consumer whose immutable image identity is known may report that full identity and its expected slot, but still returns 503 and no installed-config receipt. This is bootstrap diagnostic data and never eligibility or restoration authority. | [unit] src/configBoot.test.ts::the production config boot refusal::a known image's refusal names its own missing slot without claiming loaded eligibility |
| Config-publication eligibility requires the parser's exact consumer identity, an installed owned-slot receipt and matching actual application/instance version and image. A mutable build display, foreign publisher, mixed/unversioned target or bootstrap refusal cannot establish eligibility. | [unit] src/deploy/configPublicationRun.test.ts::actual consumer config publication::* |
| The deployment driver stages the exact publishing consumer slot from a current source/native pair or an original input witness still bound to the unchanged native target; canonical drift refuses before uploads, source changes during Memory fail the store predicate, and native target movement refuses activation. Unknown prior-consumer bootstrap declines before effects. | [unit] src/deploy/configPublicationRun.test.ts::runDeployPlan config publication::* |
| A same-image deployment reuses an already-installed owned config only when the fresh parser/consumer/native target and exact stored revision, bytes and provenance agree. It writes no snapshot or config revision. Pending unloaded revisions refuse, including identical hashes; source successors and changed provenance during upload fail. Changed config retains the conditional publication and exact new loaded-receipt checks. | [unit] src/deploy/configPublicationRun.test.ts::runDeployPlan config publication::keeps the installed owned revision on an identical same-image deployment; ::refuses an unloaded successor even when its config bytes are identical; ::refuses a same-hash successor during %s upload without changing its config; ::refuses changed provenance even when revision and config bytes stay equal; ::names a changed receipt separately from an unchanged durable source; ::refuses stored bytes that do not match the installed digest; ::changed config requires its new loaded revision (installed: %s) |
| Standalone config and restart freeze account/application/admin/health/state selection once, carry it through observations and effects, and never reload a changed profile to borrow another fleet’s eligibility. The source override changes bytes only, and force cannot waive the frozen target. | [unit] src/deploy/standaloneConsumerTarget.test.ts::standalone publication frozen target::*, src/deploy/restartRun.test.ts::runBotRestart::a changed profile…, ::runBotRestart::profile changes during final read… |
| Current/original source and publication guards require one uniquely named running singleton, matching native version/image and complete known inventory; foreign running rows, missing names, duplicate singleton rows, malformed rows and unknown states refuse. Final restart/deployment confirmation rechecks the fresh inventory. | [unit] src/deploy/consumerNativeBinding.test.ts::consumer native singleton binding::*, src/deploy/restartRun.test.ts::runBotRestart::a foreign running row…, ::runBotRestart::fresh contradictory inventory…, src/deploy/sandboxLiveGate.test.ts::wrangler and /exec parsers::parseInstancesPage refuses malformed rows instead of hiding contradictory inventory |
| Direct config publication targets only the eligible owned consumer slot and never writes the legacy base. Foreign parsers and successor updates refuse without erasing current settings. | [unit] src/deploy/consumerConfigPublication.test.ts::direct config targets the actual consumer::* |
| Restart selects the actual owned slot and exact publishing parser, refuses an unknown native target even under force, and requires the same application/config tuple after restart. | [unit] src/deploy/restartRun.test.ts::runBotRestart::* |
| The image's consumer stamp matches the actual clean checkout or exact published package source; short, dirty or environment-mismatched identities cannot be baked as eligible consumers. | [unit] src/deploy/buildStamp.test.ts::consumer image stamp::* |
| A final serving/config/source readback is required after image activation; an old-CLI write after slot ACK remains a cutover failure for the sole configuration writer, never an atomicity or restore claim. | [gap] Source and target publication acceptance in the actual serving fleet under original operation ownership. |
| Both full input documents survive exit of the actual Node writer and restart of an isolated local SQLite store using the unchanged production handler and all real Durable Object classes; a separate Node reader recovers them using only the original snapshot key and endpoint. The test entry omits non-runtime numeric exports. This is store/process durability proof, not full Worker startup, production custody or serving proof. | [unit] src/deploy/configPublicationRun.test.ts::config publication process survival::recovers exact inputs after both the writer process and SQLite Worker restart |
When it refuses a replacement, the snapshot immutability guard emits one countable /config/put log containing the snapshot key and stored version, without input document bytes. Ordinary config writes and conflicts do not emit this snapshot refusal event. A logging failure preserves the known refusal and original stored inputs. | [worker] deploy/cloudflare-memory/config.test.ts::ConfigDO routes::counts snapshot replacement refusals without private bytes or changes to stored inputs |
| Config transport failures report known timeout, abort, DNS and connection error names/codes without raw error messages, arbitrary thrown values, config bytes or bearer values. A failure after send remains unknown and is never retried. | [unit] src/configDocument.test.ts::ConfigDocumentClient::keeps a safe transport cause without crediting or retrying the write…, ::ConfigDocumentClient::does not disclose arbitrary transport fields or thrown values, ::ConfigDocumentClient::keeps private bytes out of failure output from a server or transport |
| Before selected uploads or direct owned-slot publication, full private input-source, prior-target and candidate request witnesses are saved once in an immutable ConfigDO document with CAS zero, an exact version-one acknowledgement and full readback. A new client recovers the documents; uncertain snapshot ACK/readback or oversize data refuses before uploads. Competing writers retain separate snapshots and cannot erase successor settings; snapshot data grants no restoration authority. | [unit] src/deploy/configPublicationRun.test.ts::config publication inputs::*; [worker] deploy/cloudflare-memory/config.test.ts::ConfigDO routes::an input snapshot survives a new client and cannot be replaced even with its current version, ::ConfigDO routes::an unknown committed snapshot ACK is not retried and the exact inputs remain readable, ::ConfigDO routes::two snapshot owners race one base without losing either request or attributing the loser |
| The runner freezes candidate, prior target and proven input-source bytes before selected uploads; Memory failure leaves config unchanged, an unknown slot write stops before Bot without retry or restore, Bot failure does not claim rollback, empty/non-Bot selections publish nothing, and file mode needs no state Worker. An unreadable source or unknown prior consumer refuses before upload. Target version zero permits conditional slot creation only with a proven source/native pair. | [unit] src/deploy/configPublicationRun.test.ts::config publication inputs::*, ::runDeployPlan config publication::*; [worker] deploy/cloudflare-memory/config.test.ts::ConfigDO routes::matching source and target predicates publish once while preserving the input source |
| CLI validation and a missing-slot boot identity do not prove installed configuration or readiness. Cold state-backed bootstrap requires proven native application absence and input-source provenance; unknown prior consumers and file/environment sources decline. | [unit] src/configBoot.test.ts::the production config boot refusal::a known image's refusal names its own missing slot without claiming loaded eligibility, src/deploy/configPublicationRun.test.ts::runDeployPlan config publication::unknown prior consumer cannot bootstrap merely from a readable legacy document; [gap] Cold state-backed bootstrap with actual native absence and original-operation source proof. |
The config source: a path, a github:// reference (default ref main), a vault reference; empty, malformed and unknown-scheme values are refused; each loader names the variable it needs and the source that failed (item 15) | [unit] src/deploy/configSource.test.ts::parseConfigSource::*, src/deploy/configSource.test.ts::readConfigSource::* |
| The template view binds a Worker's script and hostname, the account and zone, the bot's and state Worker's origins, and Access when the profile has it; a Worker the profile lacks has no view (item 16) | [unit] src/deploy/wranglerTemplate.test.ts::templateView::* |
Rendering: every placeholder substituted and everything else kept byte for byte under the generated header; dotted placeholders resolve; an {{#if access}} block is kept (directives removed) with Access and dropped whole without; an unfillable or unknown placeholder, an unclosed block, a stray close, or nesting refuses the render naming the line (item 16) | [unit] src/deploy/wranglerTemplate.test.ts::renderTemplate::* |
One template → one wrangler.jsonc per Worker the profile has, in deploy order, the docs site never among them; a missing template or an unresolved placeholder is a problem prefixed with the template's path; one Worker alone renders from its own template with the others absent (what deploy secrets needs) (item 16) | [unit] src/deploy/wranglerTemplate.test.ts::workerConfigTargets / renderWorkerConfigs::* |
The docs site's config renders from project.json and the profile's account alone — <name>-docs on the host of docs; a missing or non-JSON facts file, a missing or malformed fact, or a missing template is a problem naming its source — and deploy init writes it beside the Workers', refusing unavailable without the facts file (items 16, 17) | [unit] src/deploy/wranglerTemplate.test.ts::siteView / renderSiteConfig::*, src/core/commands/deploy.test.ts::deploy.init::renders every Worker's wrangler.jsonc from its template and the profile, and the site's from project.json…, ::deploy.init::the site's config needs project.json… |
The rendered wrangler.jsonc files are generated (gitignored; deploy:gen runs before test, every Worker's verify and deploy all) and ARE the render of their templates with the profile in force, byte for byte; the templates render against the committed example with nothing unfilled (item 17) | [unit] src/deploy/wranglerTemplate.test.ts::the rendered wrangler.jsonc files::*; [agent] append a comment line to deploy/cloudflare-memory/wrangler.jsonc → npm run deploy:check exits 1 naming that file (stale) and npm run deploy:gen; npm run deploy:gen restores it and deploy:check exits 0. |
deploy init writes each render and is a no-op the second time; --check writes nothing and fails conflict on a stale or absent file; a missing template or unfillable placeholder is unavailable naming the template; CLI-only, deploy:write (item 16) | [unit] src/core/commands/deploy.test.ts::deploy.init::* |
The state Worker's workerd pool keeps the real SQLite Durable Objects but leaves the production Workflow engine unbound; coordinator-delivery cases install their own synchronous double on the live object; every queued or re-entered plane request settles its Durable Object alarm write before its RPC response returns, and a failed alarm write preserves the already-committed queued or re-entry answer rather than inviting the caller to proceed or retry; the re-ask proof explicitly awaits each alarm handler and deletes the re-armed slot; best-effort plane pushes are held by the Durable Object's waitUntil, and the per-test guard names and fails the originating case if registered work is still pending at its end, so no Workflow promise, alarm handler or effect push crosses a test boundary to hold an unrelated case; the test-only setup and wrangler config are inert deploy inputs and absent from the published package | [worker] deploy/cloudflare-memory/backgroundTasks.test.ts::memory Worker pending task guard::fails on a fixture promise that is still pending when the test ends, deploy/cloudflare-memory/runLedger.test.ts::run ledger — the coordinator's event and the key (items 47–48)::the test pool leaves the real Workflow engine unbound…, deploy/cloudflare-memory/runLedger.test.ts::the plane's admission stage — /plane/admit, reservations, the seal's walk (orchestration-plane; record 0064)::a push that fails leaves the effect on the next heartbeat answer…, deploy/cloudflare-memory/runLedger.test.ts::the plane's resident stage — /plane/level, /plane/observe, the re-ask alarm (orchestration-plane item 9; record 0064)::a queued admission awaits its alarm scheduling before the RPC response returns, ::a queued admission returns its committed answer when alarm scheduling fails, ::an observation after the admit's ack re-enters the row…, ::an observation returns its committed re-entry when alarm scheduling fails, ::the re-ask alarm probes a silent resident within the cadence…; [unit] src/deploy/affected.test.ts::classifyPath::tests, docs, specs, CI, scripts, the deploy tooling and repo metadata are inert…; packages/switchboard/build.test.mts::shippedDeployAssets::keeps every tracked deploy path but tests, vitest configs, test typings and the agent-env tooling, sorted |
| Every state-Worker test result records wall time, pool worker, registered background work, promises still pending and timers still open; the reporter always writes the JSON table, prints the slowest five cases on one line, and CI uploads it after both green and red runs. An unhandled rejection or timer that escapes its owner names the test running when it fires; a pending timer fails and is cleared at its owner's boundary. | [unit] src/memoryWorkerDiagnostics.test.ts::memory Worker diagnostics::writes the diagnostic artifact and slowest-five summary for a green fixture run, ::fails and clears a timer left open by its owner, ::names the running test when a leaked promise rejects, src/ciWorkflow.test.ts::the gate workflows run only the repository's own scripts::uploads memory Worker diagnostics after green and red verify runs; [worker] deploy/cloudflare-memory/backgroundTasks.test.ts::memory Worker pending task guard::fails on a fixture promise that is still pending when the test ends; [agent] Human-gated: for each of ten consecutive successful main CI run ids, gh run download <run-id> -p 'memory-test-diagnostics-*' -D receipt/<run-id> yields memory-test-diagnostics.json; otherwise the first red run's table names the contended pool worker or unfinished work, and its originating guard fails. |
A preflighted step is handed its Worker's origin from the profile (SWITCHBOARD_BASE_URL / RESIDENT_BASE_URL) alongside its force env; unguarded public-health steps carry a wakeUrl, the bot and sandbox do not; each preflight exits 2 naming its variable and deploy all when the origin is missing (item 16) | [unit] src/deploy/plan.test.ts::planDeploy::each step spawns…, deploy/cloudflare/preflight.test.mjs::bot deploy preflight — main()::*, deploy/cloudflare-resident/preflight.test.mjs::resident deploy preflight — main()::* |
deploy config publishes the profile's configSource (or --source) to the eligible actual consumer's owned slot and points at deploy restart; a host problem is unavailable verbatim. The command passes base as a state-mode hint and renders the actual returned document key. A state-backed plan names base-<full-source-commit> or an unresolved consumer slot; a file-mode plan retains its legacy hint. Neither plan proves a loaded target. config/ is inert to --affected (item 15). | [unit] src/core/commands/deploy.test.ts::deploy.config::*, src/deploy/consumerConfigPublication.test.ts::direct config targets the actual consumer::*, src/deploy/plan.test.ts::WORKER_SPECS / workersFor / DEPLOY_ORDER::the profile binds each Worker…, src/deploy/plan.test.ts::planDeploy over a partial profile::*, src/deploy/affected.test.ts::classifyPath::*; [agent] In an authorized disposable scope with the CLI parser matching the running owned consumer and native target, npm run cli -- deploy config --source config/config.example.yaml prints document "base-<full-consumer-commit>" v<n>; deploy restart then proves that exact key/version/digest and unchanged native target in /healthz's loadedBase. A missing owned slot returns startup refusal, without legacy fallback or direct publication eligibility. |
The profile and its example are inert to --affected — a deploy-tooling input, never an image's (item 14) | [unit] src/deploy/affected.test.ts::classifyPath::* |
The profile's optional metrics.dataset (item 14; run-metrics.md item 6): refused by field when it breaks the dataset-name rule; the state Worker's template renders the RUN_METRICS binding and RUN_METRICS_DATASET var exactly when the profile names one; deploy plan names the binding in the memory step | [unit] src/deploy/profile.test.ts::the profile's image mode::\metrics.dataset` is optional…, src/deploy/wranglerTemplate.test.ts::templateView / renderTemplate for a bot-only profile::the committed state Worker template binds the run-metrics dataset…, src/deploy/plan.test.ts::planDeploy::the memory step names the RUN_METRICS binding…` |
The account decision: whoami listing the account passes; not listed but the token verifies active against the account passes and says so; neither refuses with wrangler's words and both ways out | [unit] src/deploy/plan.test.ts::decideAccount::* |
Capability pre-checks: the bot and sandbox need containers list, the resident both containers list and r2 bucket list, the memory Worker nothing; the bot adds r2 bucket list exactly when the profile names an artifacts bucket; a failing command is a problem naming the Worker, the scope and wrangler's [ERROR] lines (ANSI stripped, npm noise dropped), a passing one is silent; the bot preflight's own refusal keeps wrangler's stdout | [unit] src/deploy/plan.test.ts::capabilityProblem::*, deploy/cloudflare/preflight.test.mjs::bot deploy preflight — wranglerFailureText()::* |
deploy plan --affected reaches deps.deploy.affected once and plans exactly the selection (--only narrows it, never widens; --base reaches the probe; a hostile --base never does); --base without --affected → invalid_input; an empty selection is a successful plan with no steps and deploy all --affected then calls no runner and exits 0; deploy all --affected hands the runner the same plan with the report attached | [unit] src/core/commands/deploy.test.ts::deploy.plan::--affected…, ::deploy.plan::--only narrows…, ::deploy.plan::an empty --affected selection…, ::deploy.all::--affected… |
| A checkout-mode dispatch from a non-main ref deploys nothing; package mode selects the pinned published version instead of a ref's tree | [agent] gh workflow run deploy-production.yml --ref <branch> -f cli=checkout → the run fails at only from main with the ref in the error, no checkout, no deploy all step ran. |
deploy plan --affected --json runs from the CLI with the host probe injected | [unit] src/cli.test.ts::buildCoreCommands…::phase 4b… |
The docs reference tables carry --affected / --base | [unit] npm run docs:check (CI docs job) |
| Every automatic workflow has one orchestrator across GitHub Actions and Depot CI; release-please and its reusable production deploy stay in GitHub Actions on Depot runners | [unit] src/ciWorkflow.test.ts::workflow ownership::automatic workflows have one orchestrator, src/ciWorkflow.test.ts::workflow ownership::release and deployment use only the GitHub Actions workflows on Depot runners |
| The required title check runs in Depot CI on title edits and merge groups; its GitHub Actions copy is manual-only for rollback | [unit] src/ciWorkflow.test.ts::workflow ownership::GitHub PR title is manual-only with the same jobs as Depot, src/ciWorkflow.test.ts::the gate workflows run only the repository's own scripts::pr-title.yml re-runs when a title is edited and is present in the merge queue |
| Scorecard runs only through GitHub Actions on Depot runners, preserving main, weekly and manual triggers; result publishing needs GitHub OIDC and SARIF upload needs GitHub workflow-run metadata | [unit] src/ciWorkflow.test.ts::Scorecard orchestration::runs only in GitHub Actions on Depot runners |
| CodeQL runs through GitHub Actions on Depot runners, with no duplicate CodeQL analysis job in Depot CI: its SARIF processing needs GitHub workflow-run metadata | [unit] src/ciWorkflow.test.ts::CodeQL orchestration::runs only in GitHub Actions on Depot runners |
During the Depot CI trial, .depot/workflows/ci.yml handles pull requests, pushes to main and the merge queue; .github/workflows/ci.yml is manual-only, retaining the same jobs and action pins for rollback | [unit] src/ciWorkflow.test.ts::the gate workflows run only the repository's own scripts::ci.yml runs on pull requests, pushes to main, and the merge queue, src/ciWorkflow.test.ts::the gate workflows run only the repository's own scripts::GitHub CI is manual-only during the Depot trial |
The plan and its seven literal costly jobs keep the gate workflow nonempty; CI computes changed paths against the PR base, merge queue base or complete push range before starting costly legs; known inputs select bot checks, tests, web, docs, package, Workers and images. The isolated acceptance source and its exact root workspace/lockfile registration select its verification workspace without the none sentinel or a production image; other root manifest changes fail open to full CI. Code changes in any workspace run repository lint, and inputs read by root tests run the root test shards. Shared source imports are traced through dashboard and Worker code and tests. Unknown paths or an unavailable diff run every leg. Required names still report, and a skipped fan-out is accepted only when the successful plan explicitly omitted it. The main docs deploy uses the same complete push range. | [unit] src/ciWorkflow.test.ts::the gate workflows run only the repository's own scripts::the plan feeds each costly job and uses the repository's own script, src/ciPlan.test.ts::selective CI::*, src/ciGate.test.ts::plannedSkips::allows only omissions declared by a successful plan, src/ciWorkflow.test.ts::the required status checks and their gates::selects costly jobs from the change plan |
| Dynamic check and Worker names enter shell commands through quoted environment variables, so an output value cannot alter the command text | [unit] src/ciWorkflow.test.ts::the required status checks and their gates::passes dynamic matrix values through quoted environment variables |
| Every PR's CI shows the Workers its diff would deploy | [agent] Open any PR → Checks → deploy targets → the job summary is the deploy plan --affected --base HEAD^ table; a PR that touches only docs/ shows every Worker skip; a PR that adds an unlisted top-level file shows every Worker deploy with unsure: unclassified. |
A refused release-branch update is retried once: the run stays green with a release-please retried warning annotation and the release PR is updated; two refusals fail the run (item 1) | [agent] On a release-please run whose first release step shows Error updating ref (continue-on-error marks it with a warning icon): the release-retry step ran and succeeded, the run's annotations carry release-please retried, and the release PR's head is the retry's commit; a run whose retry also failed is red. |
| The release PR preview captures the same selector/default as runtime, reports actual per-Worker plan decisions for affected/all/explicit names, rejects invalid selectors before effects, and preserves snapshot semantics when inputs change later. Ordinary PR reporting remains based on its diff. | [unit] src/deployTargets.test.ts::release target preview uses the execution selector::*, src/deployTargets.test.ts::preview selector parity with captured runtime input::*, src/deployTargets.test.ts::invalid captured release selector stops before plan or comment effects, src/deployTargets.test.ts::deploy-targets renderings::* |
| The release PR's own CI runs unattended | [agent] The open chore(main): release … PR is authored by the release App's bot identity (item 1), and on each new head its pull_request runs (ci, pr-title, codeql) start on their own — gh run list --branch release-please--branches--main--components--switchboard shows no action_required conclusion — so the main-ci-required checks report and the PR is mergeable without an "Approve and run" click. The release-please run's own log shows the mint the App token step succeeded. |
| Release execution freezes its selected Workers from the captured selector and follows the existing gates. A preview is not an execution receipt: a candidate failure before upload can leave every planned Worker undeployed. Only an actual successful run proves selected build uptake; variable edits after an execution starts do not change its captured targets. | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::scoped targets cannot expand into extra deploy flags, src/deployTargets.test.ts::preview selector parity with captured runtime input::a later selector edit does not mutate the captured preview; [agent] Compare the release preview's recorded selector with the execution's captured inputs/plan and actual per-Worker upload/readiness receipts. |
An operator's laptop with a foreign CLOUDFLARE_API_TOKEN is refused before anything deploys | [agent] CLOUDFLARE_API_TOKEN=<another account's token> npx tsx src/cli.ts deploy all --only sandbox → refusing — wrangler whoami does not list account 3c7b… and the token does not verify against it naming both ways out; nothing deployed. |
A preflight still refusing at the end of the wait budget (a rollout stuck in progress, a resident still busy — in-flight bot runs do not refuse) is unavailable with the table like any stopped run — never busy, never a deploy over it; the line names the budget, the refusal, NOT deployed and the re-run recipe | [unit] src/core/commands/deploy.test.ts::deploy.all::a step whose preflight was still refusing at the end of the wait budget is \unavailable` like any stopped run: what a refusal names now is a rollout still settling, a real anomaly past the budget (runs in flight no longer refuse — run-history item 39), src/deploy/liveGate.test.ts::preflightGaveUpLine::*` |
The resident step carries waitMaxMs = RESIDENT_WAIT_MAX_MS (30 min) while the bot and memory steps carry none (the plan's default applies); the plan text says up to 30 min for the resident and the plan's minutes for the bot | [unit] src/deploy/plan.test.ts::planDeploy::the resident step carries its own wait budget (RESIDENT_WAIT_MAX_MS, 30 min): its refusals are runs in flight and a provisioning, which last minutes, not a rollout; the bot keeps the plan's (red-verified: constant absent) |
busy stays a command-vocabulary code: exit 75 on the CLI with error (busy): … and 503 on the wire | [unit] src/cli.test.ts::runCommand::a \busy` failure is exit 75…, src/core/commandRegistry.test.ts::CommandRegistry.invoke — parse and error mapping::maps CommandError codes to not_found/conflict/unavailable/busy…` |
| A release deploy over a busy bot completes without waiting, and the runs it rolled over finish | [agent] With a slow run in flight, cut a release (or dispatch deploy-production): the deploy step's bot preflight logs preflight ok: container application settled with a WARNING … N run(s) in flight — handed to the next generation, the step never waits on the run, the job is green within the build + live-gate time, and the run finishes on the new container under its original card (run-history item 38's checks). |
The sandbox carries a kind: "sandbox" live gate whose bearer is its health bearer and whose app is the wrangler-derived application name; the bot's is kind: "bot" with its wrangler-derived application name; memory and resident have none; the plan renders both gates; the sandbox needs SANDBOX_TOKEN and the plan says so | [unit] src/deploy/plan.test.ts::WORKER_SPECS / workersFor / DEPLOY_ORDER::the bot and the sandbox carry live gates…, ::names which steps are preflighted… |
The sandbox live decision: live only when the Worker serves the commit, the application left its pre-deploy version, every running instance is on the new version and the probe answered ok from an instance on it — the summary names all three (the version it is up from included); the Worker is judged first (old commit / non-JSON / transport failure → waiting with decideLive's reason, nothing else consulted); a 401/403 fails at once | [unit] src/deploy/sandboxLiveGate.test.ts::decideSandboxLive::live only when…, ::the Worker first…, ::a rejected bearer fails at once… |
Rollout target: with a target from wrangler's diff the rollout is waiting while the application still reports the pre-deploy version and image — running instances all on that version and a probe ok from one prove nothing, and at the deadline it is a failure, never "complete" against the pre-deploy version; live only after the version advanced AND the running instances and the probe's instance are on the new version, each remaining gap named in order; a configuration-only change advances by version alone; with the pre-deploy read failed the diff's image is the only evidence (live once reported, waiting with wrangler's words while not, and with no image printed the advance can never be told); a Worker-only deploy (no target) expects no advance — live against the current version, a straggler still a rollout in progress | [unit] src/deploy/sandboxLiveGate.test.ts::decideSandboxLive::a target from wrangler's diff…, ::live only after the version advanced…, ::when the pre-deploy read failed…, ::a Worker-only deploy… |
| Rollout: a running instance on another (or unknown) version is waiting, naming counts and versions; stopped/stopping/failed/provisioning/unhealthy old instances are ignored; no running instance is a complete rollout; an unreadable app state or instance list is waiting with wrangler's words | [unit] src/deploy/sandboxLiveGate.test.ts::decideSandboxLive::rollout in progress…, ::an unreadable app state… |
| Probe: fleet-busy, a starting container, any in-body error (the empty string included), a nonzero exit, the wrong stdout, a transport failure — each waiting, each named; a probe instance not listed or on a previous version is waiting | [unit] src/deploy/sandboxLiveGate.test.ts::decideSandboxLive::the probe…, ::a probe that answered from an instance not yet listed… |
| The last waiting reason becomes a failure at the shared 20-min live-gate deadline, never before | [unit] src/deploy/sandboxLiveGate.test.ts::decideSandboxLive::the last waiting reason… |
| The existing Sandbox readiness budget bounds health, application lookup/info, probe/body and complete instance pagination. Original cancellation reaches actual reads; time spent inside I/O counts before later work or readiness. The gate waits for owned local cleanup, preserves uploaded facts and reports late/unknown native outcomes without readiness credit. Repeated page tokens refuse incomplete inventory without logging the token. Read-only Wrangler commands with a signal own a new POSIX group; cancellation/deadline can stop only that group and awaits child close. Other commands retain their original SIGTERM behavior. Unsupported platforms, signal failure, leader-exit ownership loss or descendants that escape the group remain unconfirmed; local cleanup never proves native/container/run ending. | [unit] src/deploy/sandboxGateRun.test.ts::waitUntilSandboxLive::operator cancellation settles an owned pending application read without starting later readiness work, ::the existing readiness deadline waits for owned read cleanup and grants no late readiness, ::counts time spent inside a read before accepting readiness or starting the next operation, ::the default probe carries original cancellation through …; src/deploy/host.test.ts::deployment command cancellation::readiness inventory follows cursor arguments and refuses incomplete pages, ::an owned readiness group stops noncooperating descendants before later work…, ::an already cancelled owned readiness command never spawns or writes, ::owned readiness command exits and uncertain group signalling retain truthful results |
The instance inventory read asks for a page that carries the application's whole retained row set — wrangler lists one row per durable object, the producer keeps about a week of them (499 measured) and that set is far past the fleet's max_instances — so a complete inventory is one producer answer: its continuation re-serves rows and repeats a cursor while rows are still unserved, which stays a refusal rather than a partial fleet | [unit] src/deploy/host.test.ts::deployment command cancellation::reads a retained instance set larger than the fleet in one page |
| Resident preflight reports only registered category/reason pairs and positive safe-integer counts from its current registration readback. Missing or malformed facts stay unavailable, while unknown ownership still refuses upload. Runner summaries retain all already-printed refusal bullets. Eligibility, authorization, custody, fences and budgets do not change. | [unit] deploy/cloudflare-resident/preflight.test.mjs::resident deploy preflight — decide()::reports the closed unknown registration reason while main still exits refused, ::renders only registered readback pairs…, ::keeps malformed or missing readback unavailable…; src/deploy/plan.test.ts::classifyDeployOutput::keeps every printed refusal bullet including the closed owner reason |
| Actual consumer read failures retain their application-list, application-info or instances stage, command exit, observed HTTP status, closed producer error class/response format and bounded numeric provider codes. Missing facts remain unknown; private command logs remain separate per read and are cleaned. Raw messages, bodies and headers never enter the bounded collector. Publication and restart guards do not change. | [unit] src/deploy/configPublicationRun.test.ts::consumer read diagnostics::retains %s failure evidence…, src/deploy/pagingDiagnostic.test.ts::bounded paging evidence::consumer failure details… |
| Actual config-consumer reads retain closed structural health read-error/non-200, application read-error and inventory read-error predicates in the existing bounded diagnostic before the generic refusal. Unknown data stays unknown, raw errors/bodies/headers are omitted, and the first paging failure remains immutable. Eligibility and not-written outcomes stay unchanged. An inspection reporting failure makes its GHA step fail after saving its JSON, with artifacts retained. | [unit] src/deploy/configPublicationRun.test.ts::consumer read diagnostics::*, src/deploy/pagingDiagnostic.test.ts::bounded paging evidence::consumer predicates…, src/ciWorkflow.test.ts::Sandbox verification workflow::failed inner inspection… |
| CLI Sandbox verification requires an explicit Worker commit, application version and image digest independent of verifier source. It reads complete inventory before any echo, refuses incomplete/mismatched/cancelled or late reads, and rechecks the exact target after the probe. Inventory-only mode performs no probe and grants no full-readiness credit. It performs no upload, image copy, restart or model call. | [unit] src/deploy/sandboxVerification.test.ts::verification without upload::*, src/core/commands/deploy.test.ts::deploy.verify::* |
| The manual Sandbox verification workflow defaults to read-only inspection, requires an exact qualified main verifier commit independent of the Worker commit, uses the existing profile/account credentials, and calls only inspection or explicit inventory/probe verification. It uploads bounded seven-day receipts and performs no paid routing, upload, copy or restart. | [unit] src/ciWorkflow.test.ts::Sandbox verification workflow::* |
| CLI inspection reports installed application version/image digest and inventory completeness, including available facts when pagination fails. It performs no echo and never grants desired-target acceptance or full readiness. Verification pins must come from an independent release/image-provenance comparison, not just the observed image. | [unit] src/deploy/sandboxVerification.test.ts::verification without upload::observes installed pins without granting target acceptance…, ::keeps available installed facts… |
| Inventory reads retain at most eight sanitized page frames in a 16 KiB operator artifact and bounded logs. Start-before-read markers, opaque-cursor and row-ID-set hashes, counts, metadata types and available actual producer HTTP facts preserve the first failure. A repeated cursor retains its compact first-return and current-repeat receipts even when the first page is outside the retained window; later polls cannot overwrite them. Artifact/logging failure never changes readiness. Failure artifacts persist for seven days independently of smoke acceptance; no raw headers, cursors, URLs, private instance fields or paths are retained. Producer log reads use one non-following descriptor, refuse non-regular files and retain at most 64 KiB privately before safe extraction. | [unit] src/deploy/pagingDiagnostic.test.ts::bounded paging evidence::*, src/deploy/host.test.ts::deployment command cancellation::paging logging observes the original request and response before preserving the repeated-cursor refusal, ::producer debug capture refuses symlinks and oversized files while preserving readiness, ::retains interruption as the first paging failure after an owned command is cancelled, ::retains the first cursor receipt when a long inventory cycle repeats outside the page window |
wrangler's --json payloads parse past the banner; the app id is found by name; the application state reads the numeric version (string form accepted) with configuration.image (null when absent) and is null without a version; the rollout target reads the image a Container application changes diff adds (the 0.5.0 log's shape, gutter and colours included), a diff without an image line is a target without one, no changes or no section is null, a NEW application's snippet is its image; the explicitly paginated instance listing requires the {instances, result_info} page shape and a string, absent or null next token; bare arrays, missing/malformed metadata and non-string tokens refuse rather than implying a complete fleet; the streamed /exec body yields its one document or a named error | [unit] src/deploy/sandboxLiveGate.test.ts::wrangler and /exec parsers::* |
The gate loop: refuses without the bearer (nothing read); reads /healthz with the bearer, then probes /exec on deploy-gate:<commit> BEFORE listing instances; while the Worker serves the old commit only /healthz is read (no probe, no wrangler) and every poll logs its reason; the application still at the pre-deploy version for two polls is waiting both times and live on the third once it advanced and the instances followed; a Worker-only deploy is live against the current version at once; waits through fleet-busy and a rollout in progress re-sending the probe on the SAME thread; gives up at the deadline with the last reason after 15-s polls; a rejected bearer fails at once | [unit] src/deploy/sandboxGateRun.test.ts::waitUntilSandboxLive::* |
The sandbox step: deployStep reads the container application BEFORE the deploy command runs (logging its version and image), takes the rollout target from what the command printed (logging the image, or that no change was printed), and gates against both — polls name the pre-deploy version until it advanced; a failed pre-read is logged and carried into the gate, never a refusal to deploy; a gate that never holds is the step's failure with the last reason (deployed but NOT live) | [unit] src/deploy/sandboxGateRun.test.ts::deployStep (sandbox)::* |
A production sandbox deploy reads the application before the upload, prints the gate's polls and ends sandbox … live only after the application left its pre-deploy version and the Worker, the rollout and the probe agreed; a deploy-gate:<commit> instance appears in wrangler containers instances for the 5-min idle window | [agent] Human-gated: the next release that selects the sandbox with a changed image → deploy-production / deploy job log: sandbox: container application at version N (image sha256:…) before the upload, then wrangler printed a container change — image sha256:…; the application must leave version N, sandbox: deployed, not live yet — rollout: application still at pre-deploy version N … polls (no probe: line among them, and no deploy-gate:<commit> instance in wrangler containers instances until the version registered), then sandbox: live (Worker serves <sha7>; rollout complete (… on version N+1, up from N); probe `echo ok` exit 0 from deploy-gate:<sha> (version N+1) …), the results table row sandbox … live deployed; wrangler containers info afterwards reports version N+1 with the diff's digest. |
19: the step root and its wait child on the runner's log, waitedMs equal to the printed seconds; a gate that never holds is an error root with not_live | [unit] src/deploy/sandboxGateRun.test.ts::deployStep (sandbox)::the step is a \deploy.step.sandbox` root…, ::a gate that never holds is the step's failure with the last reason — deployed but NOT live` |
20: the Workers with an image are derived from the wrangler templates ({{image}}) and are exactly IMAGE_KINDS — bot (the root Dockerfile), resident, sandbox — each Dockerfile exists, and the memory and docs Workers have none | [unit] src/ciWorkflow.test.ts::the image check builds every image the deploy builds::finds the Workers with an image: the bot (the root Dockerfile), the resident and the sandbox — every IMAGE_KIND, and no other |
| 20: the distribution fixture uses the unchanged production checker, the actual transpiled asset loader and an isolated green manifest with material JS/CSS assets. A complete bundle passes; missing bundles, a legacy-only entry and a legacy co-entry fail without depending on a local prior build. | [unit] src/distCheck.test.ts::bot distribution check::accepts or rejects compiled output* |
| 20: the bot image runs the distribution check before copying its compiled output; CI selects the bot image for source, build configuration and distribution-check changes without a duplicate build job; valid output passes, missing runtime files and test files fail | [unit] src/distCheck.test.ts::bot distribution check::*, src/ciPlan.test.ts::selective CI::checks bot output in the image build without a duplicate CI build |
20: each Worker with an image has a check:image that builds its Dockerfile from wrangler's context, a Worker without one has no such script, and the root check:image runs every workspace's own | [unit] src/ciWorkflow.test.ts::the image check builds every image the deploy builds::$dir: \check:image` builds the Dockerfile wrangler deploys, from the same context, ::$0 has no image and no `check:image`, ::the root `check:image` runs every workspace's own — the local command is the whole CI check` |
20: the required image check is a gate over a fan-out with one leg per image, no other job builds an image, and the gate has the same shape as bot and workers | [unit] src/ciWorkflow.test.ts::the image check builds every image the deploy builds::the \image` gate fans out to one leg per image, and nothing else builds an image, src/ciWorkflow.test.ts::the required status checks and their gates::the fan-outs end in gates: bot, image and workers` |
20: the sandbox image leg uses the pinned Depot Docker builder and its persistent cache when DEPOT_PROJECT_ID is configured on Depot CI; the manual GitHub workflow and runs without that variable use local Docker, and every leg still runs the same check:image script | [unit] src/ciWorkflow.test.ts::the image check builds every image the deploy builds::the sandbox leg uses the persistent Depot builder without changing the manual GitHub build |
21: one job in release-please.yml builds and pushes, gated on release_created, as a fail-fast: false matrix with one leg per image whose context is the Dockerfile's directory and whose name is ghcr.io/<repository> plus the suffix project.json's images records; packages: write and the attestation scopes are that job's alone and the workflow stays contents: read; each build is its matrix context with provenance: mode=max and sbom: true, pushed, and imports and exports a separate GHCR registry build cache per image so an uncached runner does not reinstall unchanged toolchain layers; cache export failure does not fail the image publish; the bot's leg alone runs write-build.mjs with SWITCHBOARD_BUILD_COMMIT = the run's sha, after the checkout and before the build; the tags derive from github.repository lowercased plus the leg's suffix and the release tag without its v, never a literal owner; the login is GITHUB_TOKEN against ghcr.io; the attestation is on the pushed digest and pushed to the registry, attempted twice on that digest with one build per leg — the first continue-on-error, the retry only when the first failed, both without a storage record, the steps the same for every leg, and a warning naming the outcome when the first failed; every action in the workflow is pinned to a commit | [unit] src/ciWorkflow.test.ts::the release publishes the bot image::* |
21: the image names are facts — project.json's image equals ghcr.io + the repository path lowercased, images.bot / images.resident / images.sandbox equal image plus the leg's suffix (a missing map, a missing kind or another name is named), and the compose file's image: is <image>:latest; another owner, another tag or no image: line is named | [unit] src/projectFacts.test.ts::factsProblems::the published image::*, src/projectFacts.test.ts::the repository's own facts::every checked file exists and agrees with project.json |
23: one job publishes the npm package, gated on release_created, the default branch (github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) and the variable, with id-token: write as its only credential (trusted publishing) and no token named in any workflow; packages/ is inert to --affected | [unit] src/ciWorkflow.test.ts::the release publishes the npm package::*, src/deploy/affected.test.ts::classifyPath::tests, docs, specs, CI, scripts, the deploy tooling and repo metadata are inert… |
21: every release publishes ghcr.io/<owner>/<repo>:<version>, ghcr.io/<owner>/<repo>-resident:<version> and ghcr.io/<owner>/<repo>-sandbox:<version> (each also :latest) with a verifiable provenance attestation and an SBOM, and the local loop runs the bot's | [agent] After a release PR merges: the release-please run has three green publish the <bot|resident|sandbox> image legs beside deploy; for each name gh attestation verify oci://<name>:<version> --owner <owner> exits 0 naming release-please.yml as the build workflow and docker buildx imagetools inspect <name>:<version> --format '{{ json .Provenance }}' and --format '{{ json .SBOM }}' each print a document; docker compose pull && docker compose up -d on a checkout runs the bot's image (docker compose images shows the ghcr.io/…:latest tag) and curl -sS localhost:8080/healthz answers. A push to main that is not a release has the job skipped. |
22: the allowed scopes are the code map's Scope column — every scope-token code span under ## Areas, in table order, prose and quoted titles ignored; the repository's map names the product areas and the bots' deps and main, once each; a map without the section or the column is refused, never permissive | [unit] src/prTitleCheck.test.ts::allowedScopes::* |
22: a title with a real scope passes (fix(resident): …, feat(core): …, fix(review): …, feat(config): …, chore(deps): …, ci(deps): …, chore(main): release …), a scope-less one passes, and a scope the map does not name (oss, readme, site, visuals, core/dispatcher) fails naming the scope, the vocabulary and docs/reference/code-map.md; a type problem and a scope problem are both reported; neither list can be widened by the title | [unit] src/prTitleCheck.test.ts::checkPrTitle accepts::*, src/prTitleCheck.test.ts::checkPrTitle rejects, naming the fix::a scope the code map does not name…, src/prTitleCheck.test.ts::checkPrTitle rejects, naming the fix::a scope problem and a type problem are both named, src/prTitleCheck.test.ts::checkPrTitle rejects, naming the fix::neither list can be widened by the title itself |
22: the whole title is at most 72 characters — a title at 72 passes, 73 is refused naming the count and the cap, the type and the scope spend the same budget as the description, a length problem is reported beside a scope or period problem, the repository's 175-character median title is refused; Dependabot's (deps) and release-please's (main) titles and a revert: are not measured, and the exemption is the scope's, not the type's; the gate's number and PR_DESCRIPTION_CAPS.title are equal | [unit] src/prTitleCheck.test.ts::the title is capped at 72 characters::*, src/prTitleCheck.test.ts::allowedScopes::the repository's code map names the scopes the changelog and the bots use |
22: a ! title needs a ## <major + 1>.0.0 section in docs/reference/migrations.md — an exact heading, from 0.x too — and its absence (or a missing file) is one problem naming the file and the heading; a second breaking title in the cycle passes on the section the first created (presence, not authorship); a title without ! needs nothing; the repository's notes are one ## <version> per release, newest first | [unit] src/prTitleCheck.test.ts::a breaking title needs its migration note::* |
22: while release-please-config.json pins the next version (release-as), a ! title is refused naming the pin, the file, and the heading to write under instead; a title without ! is untouched by the pin; the repository's pin, when set, is never behind the released version in .release-please-manifest.json and never a major — the release PR itself carries pin == manifest, so equality passes; a pin behind the manifest, or one that pins a major, fails the suite | [unit] src/prTitleCheck.test.ts::a breaking title needs its migration note::while the release config pins the next version (\release-as`), a `!` title is refused: it cannot cut the major it declares, src/prTitleCheck.test.ts::a breaking title needs its migration note::the pin does not touch a title without `!`, src/prTitleCheck.test.ts::a breaking title needs its migration note::the repository's pin, when set, is never behind the released version and never a major — the release PR itself carries pin == manifest` |
22: the vocabulary file is the render of the release config and the code map byte for byte, names its generator first, and is what the submit tool judges against; pr-title:check refuses a stale or missing file naming npm run pr-title:gen | [unit] src/prTitleCheck.test.ts::the generated vocabulary (src/core/prTitleVocabulary.json)::*; [agent] append a scope to the Areas table → npm run pr-title:check exits 1 naming the file and npm run pr-title:gen; run it, and pr-title:check exits 0 |
22: the verdict from the command line — npm run check:pr-title -- "feat(slack): …" exits 0 printing check:pr-title ok — …; -- "feat(oss): …" exits 1 printing unknown scope "oss" and the list; -- "feat(authz)!: …" exits 1 — while release-please-config.json carries release-as, naming the pinned version, the config file and the ## <pinned> heading to write under; with no pin and no ## <major + 1>.0.0 section, naming docs/reference/migrations.md and that heading | [agent] run the three commands from a checkout at main and read the exits and the lines; the third answer depends on whether the config carries a pin. |
24: the root — in a checkout every kind of path is under the repository root whatever directory the command started in; from the package the installation is SWITCHBOARD_HOME when set (~/ expanded), else the working directory when it holds a marker file, else ~/.switchboard (the cwd when there is no home or no probe — the old three-field call), and chosenBy names which; the shipped files stay in the package and the Worker directories go under .switchboard/, never inside the installed package; a work-area path reads as deploy/… in a checkout and .switchboard/deploy/… from the package; this process's root in the checkout is the repository in checkout mode | [unit] src/deploy/operatorRoot.test.ts::resolveOperatorRoot::*, src/deploy/operatorRoot.test.ts::this process's root::* |
| 24: the work-area plan — nothing on disk copies and installs every requested Worker; stamped at this version with the Workers installed does nothing, a new Worker installs the union; another version copies again and installs afresh; a directory without the stamp is refused naming it and the stamp; the stamp parses and rejects anything else | [unit] src/deploy/workArea.test.ts::planWorkArea::*, src/deploy/workArea.test.ts::parseWorkAreaStamp::* |
24: the work area over a fixture asset tree — the shipped tree copied whole at its tree paths and stamped, one npm ci --include-workspace-root --workspace at the work area's root for the requested Workers so both the root runtime closure and Worker dependencies are installed, the operator's directory holding nothing else and the assets untouched; a second call at the same version copies and installs nothing, a new Worker installs the union and a rendered file survives; the copy alone spawns no npm; another version replaces the work area (stale render and old install gone) and installs again; a failed npm ci is a problem quoting npm and leaves the copy stamped with nothing installed; an unstamped directory is refused and left as it was, an empty one is where the work area goes | [unit] src/deploy/workArea.test.ts::ensureWorkArea over a fixture asset tree::* |
| 24: a Dockerfile above the Worker's directory is named — the bot's template builds from the repository, the resident's and sandbox's from their own, the memory Worker has none; a registry reference and a comment are not | [unit] src/deploy/workArea.test.ts::imageBuiltOutsideDir::* |
24: from the package the plan has the same steps with both tree checks off whatever --allow-branch says, root at the package's version, a first line naming the package and a Checks: line naming the package's sources under .switchboard/ and the Workers not installed there, never origin/main or clean tree; a checkout's plan carries its root with the tree checks on | [unit] src/deploy/plan.test.ts::planDeploy::from the published package there is no tree…, src/deploy/plan.test.ts::planDeploy::the plan carries and prints where it runs from… |
24: deploy plan carries deps.deploy.host.root and prints it first for both modes; deploy init from the package writes by tree path and names each file under .switchboard/; deploy secrets from the package puts in the tree-path directory and names it under .switchboard/ | [unit] src/core/commands/deploy.test.ts::deploy.plan::carries where it runs from…, src/core/commands/deploy.test.ts::deploy.init::from the published package…, src/core/commands/deploy.test.ts::deploy.secrets::from the published package… |
24: the stamp scripts take the commit from SWITCHBOARD_BUILD_COMMIT when set (trimmed, builtAt now, git never asked) and read the tree otherwise; the runner's spelling of the variable equals both scripts' | [unit] src/deploy/buildStamp.test.ts::the commit from the environment…::* |
24: the file access behind deploy init reads a template from the shipped files without touching the work area; reading a rendered file brings the work area to the CLI's version first (another version's render gone, the reader sees nothing current, no install) and a write lands in the new copy; a work area that cannot be materialised is an error naming the problem with nothing read or written | [unit] src/deploy/loadProfile.test.ts::deployFiles (the file access behind deploy init, over a package-mode root)::* |
24: deploy secrets runs the Worker directory's own nested wrangler, else the workspace root's hoisted one, and PATH only when neither exists | [unit] src/deploy/secretsHost.test.ts::wranglerBin::* |
24: a checkout's install probe — a Worker's install holds when its wrangler resolves from its directory, nested under it or hoisted to the repository root; a bare node_modules directory without it is not an install; the runner's npm ci for a missing install runs at the checkout's root, never inside a workspace directory (which prunes the tree to that workspace and takes the root's own dependencies out from under the bundle) | [unit] src/deploy/workArea.test.ts::checkoutInstallHolds::* |
24: from the installed package, init --cloudflare in an empty directory writes the profile there and renders the Worker configs under .switchboard/ with the shipped tree stamped and no node_modules, nothing written into the package, the next commands the package's own deploy steps; deploy plan from that directory names it as the root, plans that installation (not the example), says the sources are the package's under .switchboard/ with no git, and prints no path of the package or the repository; without a profile deploy plan reads the example and names the directory | [unit] packages/switchboard/smoke.test.mts::the installed CLI::\init --cloudflare` in an empty directory…, packages/switchboard/smoke.test.mts::the installed CLI::`deploy plan` in a directory with no profile…` |
24: from a temp directory with the published CLI and a real profile, deploy secrets memory installs the memory Worker under .switchboard/ and puts its secret; deploy all --only memory refuses nothing for the tree, deploys the state Worker stamped with the package's commit, and deploy all selecting the bot is refused up front naming its Dockerfile and the checkout | [agent] Human-gated: needs a Cloudflare account and the package on npm (or a local tarball). With <pkg> the package's name: mkdir op && cd op && npx <pkg> init --organization acme --anthropic-key <k> --cloudflare <account> --zone <zone>; npx <pkg> deploy secrets memory → [deploy] npm ci --include-workspace-root --workspace deploy/cloudflare-memory under …/.switchboard then put MEMORY_TOKEN → .switchboard/deploy/cloudflare-memory; MEMORY_TOKEN=… npx <pkg> deploy all --only memory → no working tree or origin/main line, memory … deployed, and curl https://<memory hostname>/healthz reports build.commit equal to commit in the package's dist/assets/source.json; deploy all --only memory,bot → refusing — bot: its image builds from ../../Dockerfile — outside deploy/cloudflare … with nothing deployed. |
25: the profile's images defaults to build, accepts registry, refuses anything else by field; the committed example says registry | [unit] src/deploy/profile.test.ts::the profile's image mode::* |
25: the three published names are read from project.json's images and a missing map or kind is named; a missing or non-JSON facts file names the file; a release version is three numbers with an optional pre-release; build renders each Worker's Dockerfile and registry renders the bot at the release version and the resident and sandbox at their image-input tags, with the name the published image's last segment | [unit] src/deploy/images.test.ts::the published images::*, src/deploy/images.test.ts::the reference a Worker deploys::* |
25: the bot, resident and sandbox templates name {{image}} and the memory template none; the committed templates render each Dockerfile in build mode and the account registry's copy in registry mode, nothing left unfilled; the view carries no image for a Worker without a container | [unit] src/deploy/wranglerTemplate.test.ts::the image mode in the committed templates::*, src/deploy/wranglerTemplate.test.ts::templateView::binds a Worker's script and hostname… |
25: the CLI's own version in a checkout or the image is the nearest package.json at or above the package root, and nothing above throws naming the start (from the package it is source.json's — item 24) | [unit] src/packageRoot.test.ts::locatePackageVersion::* |
25: one version source — cliVersionOnHost is the root package.json's in a checkout (equal to packageVersion()) and source.json's from the package (a package without one throws naming the file), and the command catalogue binds exactly it as deps.deploy.cliVersion | [unit] src/deploy/host.test.ts::cliVersionOnHost::* |
25: rendered in build mode the bot's config builds from the repository's Dockerfile and the resident's and sandbox's from their own; rendered in registry mode none builds anything, so the package-mode refusal of a Dockerfile outside its directory has nothing to fire on | [unit] src/deploy/workArea.test.ts::imageBuiltOutsideDir::finds the bot's template, rendered in \build` mode…` |
25: the plan's Images: section names each image step's Dockerfile in build mode, each step's reference and whether the registry holds it in registry mode (the line counts the present ones and says deploy all copies the rest; not probed (<why>) without a listing — the example profile, or the registry's words when it could not be read, not read when no reason was given); a step without a container has no entry; deploy init renders the bot's release tag and the execution images' input tags in registry mode and Dockerfiles in build mode | [unit] src/deploy/plan.test.ts::the plan's images::*, src/core/commands/deploy.test.ts::deploy.plan / deploy.all in registry mode::\deploy init` in registry mode…, src/core/commands/deploy.test.ts::deploy.plan / deploy.all in registry mode::reuses present execution image tags while the bot still needs its release image, src/core/commands/deploy.test.ts::deploy.plan / deploy.all in registry mode::build mode (the default for a checkout) never probes the registry and plans each Dockerfile` |
25: the resident and sandbox account-registry tags hash the Dockerfile and every local COPY file; Worker code does not change the tag; missing files or unsupported build-context references fall back to the release tag, so they still deploy by a new image; deploy:gen from a checkout without a profile renders those references at the example's account | [unit] src/deploy/imageInputs.test.ts::execution image input tags::*, src/deploy/loadProfile.test.ts::renderWorkerConfigsOnHost::*, src/deploy/wranglerTemplate.test.ts::the rendered wrangler.jsonc files::* |
| 26: the copy plan sources each image from its GHCR release tag, targets the bot's release tag and the execution images' input tags in the account registry, and copies only absent targets; presence is by name and tag | [unit] src/deploy/images.test.ts::the account registry listing::*, src/deploy/images.test.ts::the reference a Worker deploys::keeps the bot on the release tag and reuses execution images by their input tags |
26: the host half mints one push+pull credential per account from CLOUDFLARE_API_TOKEN and spends it on exact manifest reads, the pre-check and every copy; one nearing its expiry (within 5 minutes of 45) is replaced before it is spent; a 401 from the registry replaces it and retries the operation once, a second 401 being the error; a 403 is not retried; a missing variable is refused by name before the API is asked on all three; a token the credentials endpoint refuses is the mint's problem — the endpoint and the permission by name — on all three and a refused mint is not kept; a listing the registry refuses is its own error with the credential kept; nothing spawns a process | [unit] src/deploy/imagesHost.test.ts::* |
26: the copy's shapes — a published reference parses into registry, repository and tag (a tagless one is refused); a registry's Bearer challenge parses into realm, service and scope (another scheme, no header or no realm is nothing to answer) and its token request is the realm with the service and scope it named; the credential is minted on the account registry's credentials endpoint for 45 minutes with push and pull and used as HTTP Basic, a 403 names the endpoint that answered and Containers Edit (and that a token which has it lacks one Cloudflare does not document) and any other status keeps the API's words; the catalog with tags is the listing, its repositories parse under the account with the prefix stripped, another account's skipped and non-string tags dropped, any other shape refused, presence is by name and tag, and a 403 on the catalog names that endpoint and the permission; the source's anonymous pull token; an index or a manifest, OCI or Docker, is read by content type (the body's mediaType standing in) and the linux/amd64 manifest selected with attestations and other architectures skipped and absence naming what was there; the blobs are the config then the layers; parts are 64 MiB with every part but the last at least 5 MiB, a smaller part size refused, named as an inclusive Content-Range, committed with digest= appended to the upload's location, a relative location resolved against the registry | [unit] src/deploy/registryTransfer.test.ts::* |
26: against an in-process registry the transfer moves the linux/amd64 manifest's config and the layers the target lacks in ≥ 5 MiB parts (the present one HEADed and never read), commits each by digest, pushes the manifest byte-identical under the source's media type and compares the digest the registry reports, following the upload's relative location and its query state; a source that is one manifest is copied as it is; the source is read anonymously and its Bearer challenge answered with one token request naming the challenge's service and scope, a source that needs no token is read with no token request, and a 401 without a challenge is named; a credential the account registry refuses is unauthorized on the copy and on the listing (a 403 is not); a failed part is retried once and a second refusal — the registry's 416 for a short part included — stops the copy naming the part, the status and the code with no manifest pushed; a source stream cut mid-blob is resumed with one Range request from a byte inside the blob, no part is PATCHed twice, the blob hashes and lands and the log names where it broke; a stream that breaks a fourth time stops the copy naming the blob, the byte, the three resumes and the cause with nothing committed and no manifest pushed; a resume answered 200 is named as the Range ignored; bytes that do not hash to their digest are never committed; a manifest reported under another digest is a failure naming both; a tagless reference, an unknown tag, an index without linux/amd64 and a credential the target refuses are named before anything is uploaded; the mint POSTs push+pull with the API token as a Bearer, a 403 names the endpoint and Containers Edit, other statuses and an unreachable API are named; the catalog listing remains available for enumeration, but an exact manifest HEAD proves a known repository and tag independently of catalog visibility; its missing tag is absent, an invalid or missing digest is an error, and credential refusal is named; a cross-repository mount is accepted and ignored by the registry, which is why the copy never asks for one | [unit] src/deploy/registryTransferHost.test.ts::* |
26: deploy images is CLI-only and deploy:write; copies the GHCR release images the account lacks at the bot's release tag or execution images' input tags, falling back to release tags when those inputs cannot be read (no --version option exists; one passed is invalid_input), skips what is present, probes exact manifest tags before and after and every call carries the profile's account, renders present / copied and a summary, is idempotent; --dry-run says would copy and touches nothing; the example profile, an unreadable registry and a credential that cannot be minted (the endpoint and the token's Containers Edit, by name) are unavailable before anything moves; a failed copy stops the run naming the image, the transfer's problem and what was not attempted; a copy whose exact manifest digest does not read back is unavailable; a facts file without images is unavailable naming it; a build-mode profile is answered with nothing to copy, the registry unread and no credential minted | [unit] src/core/commands/deploy.test.ts::deploy.images::* |
26: in registry mode deploy plan probes the known image tags once, reports which of the plan's images are missing (the line counts them and says deploy all copies the rest) and refuses nothing, copying and minting nothing; deploy all copies the planned steps' missing images before anything deploys — the credential first, each copy in order, each copied tag and digest read back, every initially present tag re-probed (a vanished one stops before deployment) — and hands the runner a plan with every image present, saying what it copied; only the planned steps' images are copied (--only memory,bot with the bot present copies nothing and mints nothing); with --affected the probe is asked once and the rebuilt plan carries the same report and selection; deploy all --dry-run computes the plan and the copies it would make and stops, nothing copied, nothing deployed, the render saying so; a copy it cannot make stops it before anything deploys — the token's Containers Edit by name, or the failed image, the transfer's problem and what was not attempted; every image present copies nothing; a step without a container needs none; an unreadable registry leaves deploy plan reading not probed with the registry's words and refusing nothing, and makes deploy all unavailable with them; the example profile is never probed and reads not probed (the example profile) | [unit] src/core/commands/deploy.test.ts::deploy.plan / deploy.all in registry mode::* |
26: the operator's deploy is one command with no container daemon: from a temp directory with the published CLI, a registry-mode profile and a Cloudflare API token with Containers Edit, deploy all copies the images the account registry lacks and then deploys Workers whose containers start from that registry | [agent] Human-gated: needs a throwaway Cloudflare account, a zone in it, and the published package. On a machine without Docker: mkdir op && cd op && npx <pkg> init --organization acme --anthropic-key <k> --cloudflare <account> --zone <zone> (the profile says "images": "registry"), npx <pkg> deploy secrets memory, npx <pkg> deploy secrets bot; CLOUDFLARE_API_TOKEN=<Containers Edit token> npx <pkg> deploy plan prints Images: registry (version <v>) — 0 of 1 present; deploy all copies the rest — bot: registry.cloudflare.com/<account>/switchboard:<v> (missing); … deploy all --dry-run prints would copy into the account registry: bot ← ghcr.io/<owner>/<repo>:<v> and (dry run — nothing copied, nothing deployed) with wrangler containers images list unchanged; MEMORY_TOKEN=… CLOUDFLARE_API_TOKEN=… npx <pkg> deploy all prints [images] switchboard:<v> ← ghcr.io/… blobs to upload, copied into the account registry: bot ← …, then the memory and bot steps and deployed and live; wrangler containers images list lists switchboard at <v>, crane digest ghcr.io/<owner>/<repo>:<v> --platform linux/amd64 equals the digest the account registry reports for switchboard:<v>, and curl https://<bot hostname>/healthz reports the package's commit; run deploy all again: no [images] line, nothing to deploy or the steps alone. Owed by the first registry-mode deploy from a temp directory. |
26: from the package root a registry-mode plan probes the same exact tags and plans the same references — the root printed first, nothing built from the package's directories | [unit] src/core/commands/deploy.test.ts::deploy.plan / deploy.all in registry mode::from the package root the same registry-mode plan… |
26: the catalogue and the CLI carry deploy images; every surface × variant conforms | [unit] src/cli.test.ts::*::phase 4b…, src/core/commandConformance.test.ts::* |
26: from any machine with Node and CLOUDFLARE_API_TOKEN (Containers Edit) and no container daemon, deploy images copies the bot under its release version and resident and sandbox under their input tags, all from the release's GHCR images and with source manifest digests; a second run finds those tags present, and a registry-mode deploy all deploys Workers using those account-registry images | [agent] Human-gated: in a throwaway Cloudflare account with published images, run deploy images from the package CLI and inspect the registry: the bot has the release tag; resident and sandbox have inputs-<sha256> tags; each digest equals the linux/amd64 manifest of its GHCR release source. Run again and confirm all three are present with no upload. Deploy a later release with unchanged execution-image inputs: resident and sandbox need no copy; the sandbox's Worker reports the new commit, wrangler containers info keeps the same application image and version, all running instances remain on that version, and the live probe succeeds. A token without Containers Edit is refused before a copy. |
27: the reusable workflow's workflow_call inputs are targets, force, profile, cli, version and copy-images with the defaults this repository's own call needs (affected, false, empty, checkout, empty, auto); a dispatch keeps targets and force; every secret is declared by name and none is required; the App is the two repository secrets and nothing else, and its token is minted only when CONFIG_REPO_NAME is set; a CONFIG_REPO_NAME without the App secrets is refused by name before the mint; the profile is the input, else the caller's variable | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::is callable with the inputs a caller needs…, src/ciWorkflow.test.ts::the production deploy is one reusable workflow::declares every secret by name…, src/ciWorkflow.test.ts::the production deploy is one reusable workflow::a \github://` profile with CONFIG_REPO_NAME set and no App credentials…, src/ciWorkflow.test.ts::the production deploy is one reusable workflow::the profile is the `profile` input…` |
27: a one-click sync-mcp-access.yml dispatch runs only from main under the production deploy lock; it uses the existing CI vault credential, rejects any bearer map entry that the runtime parser would drop (including invalid bearer keys, subjects, channels and emails), requires exactly one deployer, runs deploy secrets bot --only SWITCHBOARD_INGRESS_TOKENS --source <item>, pushes the merged config grants with deploy config, and waits for deploy restart to prove the bot live; missing inputs refuse before any write and ordinary deploys never read the vault | [unit] src/ciWorkflow.test.ts::one-click MCP access sync::* |
27: one command word — npm run --silent cli --, or npx --yes <npmPackage>@<version> with the version from the input or the caller's tag, judged by the CLI's release-version rule in shell and never latest — carries every deploy command; the two checkouts are the calling repository (no repository:, no ref:): checkout mode's with full history and tags, package mode's shallow and only for a path profile; package mode runs no npm script, reads no tree, sets Node up from the CLI's engines, and has no main-only guard | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::one command word…, src/ciWorkflow.test.ts::the production deploy is one reusable workflow::\package` mode fetches nothing…` |
27: checkout mode runs npm ci, deploy images, deploy plan (JSON and text), git status --porcelain, deploy all and what is live, in that order; what is live uses the frozen pre-deploy selection rather than a new deploy plan; the copy runs before the plan unless copy-images is never; one job, contents: read at both levels, environment: production, the one concurrency group, every action pinned to a commit with its version noted | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::\checkout` mode runs the commands…, src/ciWorkflow.test.ts::the production deploy is one reusable workflow::`deploy images` runs before the plan…, src/ciWorkflow.test.ts::the production deploy is one reusable workflow::keeps its shape…` |
27: release smoke is enabled only by SMOKE_INGRESS_ENABLED; disabled smoke reports acceptance skipped, separately from upload/readiness. Once configured, the bounded capability smoke in item 33 is required: missing token/origin/config or unsupported package mode fails before upload; an executed failure or skipped required step cannot report acceptance passed. | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::this repository's release copies only selected images and enables ingress smoke only after its credential is configured, src/ciWorkflow.test.ts::the production deploy is one reusable workflow::the release fails when an ordinary deployed request cannot start and finish an agent |
27: automatic releases map SWITCHBOARD_RELEASE_DEPLOY_TARGETS (default affected) and SWITCHBOARD_RELEASE_DEPLOY_CLI (default checkout) to the existing reusable inputs. Package mode requires successful npm publication and an exact version derived from a valid released tag; the published manifest must match that version. Failed or skipped npm cannot deploy a package, while checkout remains available when npm is off. Image failure or cancellation prevents either mode; scoped targets accept only comma-separated Worker names, never extra flags. | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::this repository's release copies only selected images and enables ingress smoke only after its credential is configured, ::package releases require successful npm publication…, ::automatic package deployments derive an exact version from the released tag, ::scoped targets cannot expand into extra deploy flags, ::the reusable CLI refuses unknown modes and nonexact package versions |
27: an exact nonempty release tag in SWITCHBOARD_RELEASE_DEPLOY_SKIP_TAG skips only this repository's automatic deploy job; release creation and image publication still run, and an unset or different tag keeps the configured deploy selection. The scoped manual workflow remains the way to deploy selected Workers from that released commit. | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::holds automatic deployment only for the named release tag; [agent] Set the variable to one pending release tag, merge that release, confirm the release and images exist and the automatic deploy job is skipped, then remove the variable. Dispatch deploy-production.yml from main with targets=memory,bot and copy-images=never; verify its plan and exact live build. |
27: a manual scoped dispatch can choose copy-images=never, so the blanket image warm-up does not copy unselected Workers' images; deploy all still copies selected missing images before deploying them. copy-images=auto remains the default. | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::is callable with the inputs a caller needs, each defaulting to this repository's own call; src/ciWorkflow.test.ts::the production deploy is one reusable workflow::\deploy images` runs before the plan — a pre-warm; `deploy all` would copy the same images itself — unless `copy-images` is `never`` |
27: before image copy or deploy, a checkout release runs the candidate Door against the default general model in the deployment profile on an ordinary read, a read containing review and severity words, an existing-PR Ship review, a standalone Review request with adjacent head context, a review with requested renewals and explicit settings. A wrong bind, missing model key, or provider origin differing from the separately configured trusted HTTPS origin fails the release without starting an agent; model requests refuse redirects. The model key reaches only this step of the trusted production job. package mode has no candidate source and skips this check. This proves routing before production changes, not the live ingress or a complete agent answer. | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::tests the candidate Door with the configured model before touching production; [agent] npm run load -- route --smoke --profile-model against a candidate checkout with the deployment profile, configuration repository token, trusted model origin and model key reports all six probes PASS before a release deploy. |
27: the release defaults to the reusable workflow's checkout mode and affected targets; repository variables can select explicit targets and package mode after successful publication at the released version; an operator repository deploys through package mode | [agent] After a release PR merges: the release-please run's deploy job is the called workflow (deploy-production / deploy affected); its the CLI step prints the checkout's CLI at <sha7>, the blanket copy step is skipped, then plan, the tree is the commit, deploy and what is live run in that order. The deploy step copies only selected missing images, and each selected Worker's /healthz build.commit equals the release commit. Human-gated: from a repository holding a committed registry-mode profile and the secrets the how-to names, gh workflow run deploy-switchboard.yml -f targets=all → the called job's the CLI step prints the published CLI at <version> — nothing of the repository is checked out, npm ci is skipped, the copy step copies (or finds present) the three images, deploy all deploys, and the live table shows every Worker on the package's commit. |
28: check:lockfile names every native family present without its Linux x64 and macOS arm64 variants, and ignores families absent from the tree | [unit] src/lockfileCheck.test.ts::names each missing variant of a family that is present, src/lockfileCheck.test.ts::ignores families that are not in the tree at all, src/lockfileCheck.test.ts::requires Linux x64 (CI and the image) and macOS arm64 (the laptops) for every family |
28: a root or workspace record that no longer mirrors its manifest — a bin, a moved range, an added workspace, a missing record — is named per field; fields npm does not mirror and a manifest's shorthand are read the way npm records them | [unit] src/lockfileCheck.test.ts::names a bin the manifest declares and the record lacks, src/lockfileCheck.test.ts::names a dependency range that moved in the manifest but not in the record, src/lockfileCheck.test.ts::compares the fields npm mirrors and ignores the ones it does not, src/lockfileCheck.test.ts::pairs the root manifest and each workspace manifest with its lockfile record |
| 28: an edge resolves nested before hoisted, walking up from the dependant to the root, and a workspace link resolves to the workspace's record | [unit] src/lockfileCheck.test.ts::resolves nested before hoisted, walking up from the dependant, and follows a workspace link |
28: a lock whose every edge is satisfied by what npm resolves passes — a bundled dependency, an optional or peer edge resolving to nothing, an npm: alias judged by its range and a git spec not judged included | [unit] src/lockfileCheck.test.ts::passes a lock whose every edge is satisfied by what npm resolves — bundled, optional-missing, peer-missing, alias and non-semver specs included |
28: the defect that motivated the gate is named — the workspace, field, name and range of the edge, the nested record it resolves to and its version — together with each unpinned record beside it; a hoisted record outside the root's range, a required dependency nothing resolves and an alias outside its range are named too; a workspace record, a link or a bundled copy is never reported as unpinned; a record bundling everything has no edges to judge; a record with a resolved URL and no integrity is npm's own shape and passes, a record with neither is unpinned | [unit] src/lockfileCheck.test.ts::names the defect that motivated the gate: a nested copy that violates its dependant's range, and the unpinned records beside it, src/lockfileCheck.test.ts::names a hoisted record that drifted out of the root's range, a required dependency nothing resolves, and an alias whose target misses its range, src/lockfileCheck.test.ts::does not report a workspace record, a link or a bundled copy as unpinned — only a fetched package without resolved and integrity, src/lockfileCheck.test.ts::a record bundling everything (\bundleDependencies: true`) has no edges to judge; a record with a resolved URL and no integrity passes, a record with neither is unpinned` |
| 28: the repository's own lockfile passes all three gates | [unit] src/lockfileCheck.test.ts::records every required platform variant, src/lockfileCheck.test.ts::mirrors package.json and every workspace's package.json in its records, src/lockfileCheck.test.ts::has every declared edge satisfied by the record npm resolves for it, and every fetched record pinned |
29: inside a worker, tmpdir() is a swb-vitest-* directory that exists; a temp dir made the usual way lands under it; a child process a test spawns answers the same root | [unit] src/core/testing/tempRoot.test.ts::the run's temp root::tmpdir() is a directory of this run's own, named for it, and it exists, src/core/testing/tempRoot.test.ts::the run's temp root::a temp dir a test makes the usual way lands under the root, src/core/testing/tempRoot.test.ts::the run's temp root::a child process a test spawns inherits the same root |
| 29: the root vitest config names the setup, so dropping it fails the same run | [unit] src/core/testing/tempRoot.test.ts::the run's temp root::the root vitest config installs the setup for every project |
29: a whole npm test against an empty TMPDIR leaves no swb-* directory behind — only the toolchain's own caches (node-compile-cache, tsx-<uid>) | [agent] d=$(mktemp -d) && TMPDIR=$d npm test && ls -A $d → every test passes and the listing has no swb- entry. |
| 30: with Interactivity & Shortcuts on, a routed state change in a channel replies the line, the risk line, the footer and two buttons, and Run updates that message with the result; with it off the same message posts, Slack refuses the click, and the typed line runs | [agent] Human-gated: after the first release carrying slack-channel.md item 14, at api.slack.com → the app → Interactivity & Shortcuts → on → save (no request URL under Socket Mode); then slack-channel.md item 14's live rows, with the setting on and again with it off, their records on the receipts tracker. |
| An attempted resident drain must be acknowledged before upload. HTTP conflicts, auth errors, missing acknowledgments and lost responses stop without retry, reconciliation or undrain. Conflict diagnostics use bounded metadata only; unavailable or malformed readback cannot change the refusal or disclose private response bodies. Existing upload uncertainty and cancellation guards remain. | [unit] src/deploy/residentDrainRun.test.ts::deployStep (resident) drains the fleet::a rejected drain stops before upload without lifting another deployment's fence, ::a lost drain response stops before upload and preserves the unknown drain state, ::an unacknowledged HTTP %s drain cannot upload or print its response body, ::an unavailable or malformed conflict readback cannot change the refusal or disclose its body |
| Bot uptake requires native application/singleton agreement and the exact healthy commit. When the upload reports a container change, its existing typed rollout target constrains both build and registry paths: a known pre-upload version must advance, and a reported image must match the native application. The same commit on the old image/config cannot pass early. Worker-only uploads without a container change retain current-target behavior. Missing singleton identity, unreadable inventory, unknown states or conflicting running instances still refuse; final owned-config validation remains unchanged. | [unit] src/deploy/botLiveGate.test.ts::decideBotLive rollback fence::*, src/deploy/botGateRun.test.ts::deployStep (bot rollback fence)::*, src/deploy/botGateRun.test.ts::build deployment waits for its uploaded target::* | | A deployment publishing config waits within the existing Bot readiness deadline for a running singleton on the current application version before final acceptance. Lagging placement cannot pass the early check and then fail config acceptance. A later refusal reports the observed application and instance states/versions without config contents. | [unit] src/deploy/configPublicationRun.test.ts::runDeployPlan config publication::waits for lagging native singleton inventory before confirming the published config; src/deploy/botLiveGate.test.ts::config publication readiness::* |
| Bot pre-upload evidence comes from the raw application list used by deploy, with the selected account and supported Wrangler credential modes; dashboard shapes, missing or failed native results and credential-bearing error bodies cannot authorize upload | [unit] src/deploy/botGateRun.test.ts::raw bot application read::* |
| Different raw-list and effective application views admit a fresh forward image and Worker-only/build deployments, while a selected image matching only the raw list refuses before upload; completion still requires the exact selected runtime | [unit] src/deploy/botGateRun.test.ts::deployStep (bot rollback fence)::admits a fresh forward image with differing list and effective native versions, ::keeps Worker-only and build deployments available across differing native views, ::refuses the stale list that makes Wrangler skip rollback before any upload even by force |
Bounded deployment acceptance
- Three capability representatives.
smoke:ingressruns one ordinary General answer, one Explore workspace read, and one Review with publication to an exact disposable fixture PR/head. These cover the shared model loop, executor/repository access and review producer/post-step consumer. Preset/model/config matrices, interruption, recovery and failure permutations remain deterministic CI work. Candidate Door probes prove routing only. Private question→Fix it→draft-PR crosses an attested Slack DM boundary unavailable to HTTP/MCP: it is always reported as a separate live gap, never inferred from these three checks.
The strict configuration names a disposable repository, dedicated actor/channel, fixture file/answer and fixture PR/head. Each fresh thread gets one asynchronous admission: the existing four-minute minimum lease for General, the existing fifteen-minute Explore floor for the workspace read, and a fifteen-minute lease for the capability Review. The workspace lease leaves twelve minutes for tool work after the existing three-minute write-up allowance. The Review lease includes its final answer and publication allowances; it does not change the normal Review preset. The HTTP deadline is one minute beyond that lease, with bounded MCP polling of that original run until its final facts are saved. A spend threshold of at most $10 stops further admissions after priced observation; this is not a hard cap on the current call, does not include infrastructure or Door cost, and must not be sold as one. Unknown cost stops acceptance. Deployment authorization does not authorize live smoke: an operator explicitly provisions this scope before enabling it.
Each acceptance receipt binds the original ingress run ID, exact serving response build, actor/thread, scenario and typed artifact. Completed status alone cannot establish success: require final persisted facts, a model turn, delivery and the exact task answer or typed posted artifact. Normal harness completion may retain answerOutcome.output: unknown; that flag alone grants no acceptance. Missing/absent output or a forced/budget ending cannot pass. Workspace requires a paired successful read or shell tool call. Review also accepts run_check only with its canonical completed exit-zero, untruncated command receipt bound to the original run, requester, thread, repository, call and expected workspace head. Metadata diagnostics and unknown outcomes grant no acceptance. Review requires the exact posted target/head and the configured fixture verdict, when specified. Persist the run identity before monitoring. Timeout/lost acknowledgment, unreadable final facts or a changed build remain incomplete; do not retry, reset, clean up, roll back, or invent a waiver. Later scenarios record why they skipped; capabilityOutcome: passed requires all configured scenarios passed. The separate structured productAcceptance stays incomplete while the private route is unproven. Upload, readiness, capability acceptance and the private product-path gap remain distinct.
| Criterion | Proof |
|---|---|
| MCP Review acceptance uses the existing dispatch client and actual MCP actor/channel/thread, explicit Sol/high, the serving build, original receipt/owner and recorded effort. Its workflow option reaches both preflight and final acceptance in the same existing serialized job. A read-only MCP bearer, wrong source/build/owner/effort or unknown acknowledgment cannot fall back to HTTP or replay; a known pending ownerless receipt remains incomplete. | [unit] src/deploy/agentSmoke.test.ts::bounded agent smoke::runs MCP Review through the maintained client and real public controller with no HTTP review admission: *, ::uses genuine MCP Review with explicit Sol/high and proves its own source and recorded effort: *, ::retains a known pending receipt with no owner and stops without replay or further admissions…, ::requires the explicit approved model and effort for MCP Review while retaining default HTTP, src/channels/mcpAdmission.test.ts::MCP organization admission::reports the actual serving build and native thread with the original owner, ignoring caller metadata, src/deploy/staging.test.ts::staging workflow isolation::forwards the same MCP Review model and effort through preflight and final acceptance in the existing job |
| 33: the request destination must be the exact planned bare HTTPS origin | [unit] src/deploy/ingressSmoke.test.ts::production ingress smoke::fails on a run failure, wrong answer, missing credential, or unsafe URL |
| 33: the three bounded families request explicit General routing with a four-minute minimum, Explore’s existing fifteen-minute floor with twelve minutes of tool work, and fifteen minutes for capability Review. Recorded profiles must match those requests; original execution/output/publication facts remain required, and unknown effects, spend/build failures and skipped dependents are preserved | [unit] src/deploy/agentSmoke.test.ts::bounded agent smoke::*; src/deploy/ingressSmoke.test.ts::production ingress smoke::*; src/core/runsService.test.ts::RunsService.getRun::finished registry projection exposes the persisted admitted profile; src/channels/browserHomeInstallation.test.ts::browser installation::routes the exported smoke answer through native HTTP to its original General owner |
Native asynchronous acceptance proves caller-authorized readback of the exact accepted original run's sealed, persisted and complete output. It retains physical replyOk unchanged and records receipt/run/seal/count/byte evidence only after every scenario gate passes. Legacy channel output still requires replyOk: true; refused, missing, foreign, unsealed, partial or wrong native output cannot pass | [unit] src/channels/browserHomeWiring.test.ts::browser production wiring::verifies native async output through authenticated original run readback without claiming null-channel delivery: %s; src/deploy/agentSmoke.test.ts::bounded agent smoke::requires exact saved native output instead of channel delivery: %s |
| Native HTTP acceptance waits for its exact accepted owner in the original thread's authorized metadata before its first point read, within the original deadline. Matching affirmative read scope and available stores are required. An absent owner on a partial page stops incomplete; a sibling never substitutes. Read errors are terminal, including a later masked point-read refusal. Receipts can retain the numeric RPC code, never remote error prose or data. Legacy and native MCP reads keep their existing contract | [unit] src/deploy/agentSmoke.test.ts::native HTTP run readiness::*; src/channels/browserHomeWiring.test.ts::browser production wiring::verifies native async output through authenticated original run readback without claiming null-channel delivery: %s |
| 33: the saved acceptance artifact retains the original sanitized HTTP request and status/JSON response before admission parsing, including runless replies and errors. JSON member names and decoded JSON strings, including fragments within prose, are sanitized recursively before encoding or truncation with the shared credential policy; malformed fragments and nested foreign or invalid identities are excluded. Each capture is capped at 16,000 characters after redaction and marks truncation; non-JSON bodies and headers are excluded. A runless reply still fails acceptance. Each original service record and its full retained event history are saved before validation, including lease clocks, ending, delivery and publication facts. Output refusals name the failed predicates. Monitoring failure retains the last observation and a fixed error classification; another requester's content, credentials and callback codes are excluded. Published/stored event counts and truncation remain original facts, never invented completeness. | [unit] src/deploy/agentSmoke.test.ts::bounded agent smoke::retains the sanitized original HTTP request and runless reply before refusing acceptance, ::retains failed and malformed HTTP evidence without accepting or repeating an admission, ::redacts transport secrets and URL callback codes before bounded intake capture, ::redacts credentials in nested JSON-encoded replies before retaining or truncating intake evidence, ::excludes foreign and invalid identities inside JSON-encoded intake replies, ::sanitizes prefixed and encoded JSON fragments before retaining runless replies, ::excludes transport and canonical credentials used as raw or encoded JSON member names, ::excludes a foreign response's content while preserving its refused admission identity, ::retains the original budget failure and complete run evidence before refusing acceptance, ::saves the last original observation when monitoring fails without another admission, ::retains full service history through the HTTP transport and excludes another requester's content, ::refuses a workspace budget mismatch or present-output budget ending while retaining all 115 events |
| 33: the real run-loop recorder commits an original native push and its checkpoint push plus same-final-head salvage; the sealed record round-trips through FileRunStore before the existing original-head adoption consumer reads it | [unit] src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::deployment publication smoke carries native updates through durable recording into original-head adoption |
| 33: required smoke cannot be silently skipped; reports and artifacts separate acceptance from deployment | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::the release fails when an ordinary deployed request cannot start and finish an agent, src/ciWorkflow.test.ts::the production deploy is one reusable workflow::cannot report a stale passed receipt when required smoke was skipped or failed |
33: each production transport checks its MCP reader with a bounded read and an affirmative policy preview of the exact configured HTTP channel, requester and repository before an agent admission. Empty history alone proves no authority; a missing, denied, malformed or mismatched preview and either degraded reader flag refuse before paid work. An optional Access Service Auth pair stays on the trusted canonical MCP endpoint with redirects refused; the original scoped Switchboard bearer still supplies command authority. Partial pairs fail configuration. Normal staging CI passes only its staging Access pair and probes the configured reader before upload with --check --check-reader; plain --check stays configuration-only. The probe starts no agent. Refused redirects retain status and challenge presence without following the redirect. Reader auth or protocol failures prevent admission; a later denial preserves the original run. Receipts retain fixed error codes, HTTP status and challenge presence, excluding bodies, header values and credentials. | [unit] src/deploy/agentSmoke.test.ts::machine MCP reader authentication::*; src/deploy/staging.test.ts::staging workflow isolation::passes only staging Access credentials to configuration and live acceptance; ::checks only the configured smoke reader before deployment:* |
| 33: provision dedicated disposable grants, configure fixture/current head, explicitly execute smoke against a deployed build and independently read its receipt | [gap] No live execution is inferred from scripted transport or CI. Private question→Fix it→draft-PR needs a separately authorized disposable Slack DM procedure and original unit/artifact receipts. |
| 15: the plan freezes its selected profile and account; current/original source, preactivation, live-gate and final native observations use that account. Worker uploads, Bot preflight and bucket creation use a private sibling copy of the selected rendered config, retaining relative source/image paths. Mutable profile or shared generated config changes cannot substitute another account. | [unit] src/deploy/configPublicationRun.test.ts::runDeployPlan config publication::*, deploy/cloudflare/selected-config.test.mjs::selected deployment config::* |
| 35: every CI job, including planning, gates and docs deployment, has an explicit positive deadline no greater than 60 minutes; API sends without a ref use their supplied commit to separate concurrency groups; main pushes keep their running verification and stale same-ref PR runs cancel; Depot and manual GitHub jobs and concurrency controls remain equal | [unit] src/ciWorkflow.test.ts::the gate workflows run only the repository's own scripts::every CI job has a finite deadline below the platform cap, ::CI concurrency uses API commit identity and preserves ref cancellation: %s, ::GitHub CI is manual-only during the Depot trial |
| 36: staging refuses a production account, incomplete topology, release images, production runtime endpoints and mismatched artifact/metrics storage before uploads | [unit] src/deploy/staging.test.ts::isolated staging deployment::* |
| 36: staging deployment uses separate secrets, its own Environment and serialized rollout | [unit] src/deploy/staging.test.ts::staging workflow isolation::* |
| 36: a maintainer label deploys the captured same-repository head; forks, withdrawn labels and moved heads refuse; CLI and dispatch deploy exact source commits with live readiness | [agent] Configure the isolated staging installation per docs/how-to/staging.md. Apply deploy:staging to a fixture same-repository PR, verify the run's selected Worker commits equal that head, then move the head while queued and verify refusal before upload. Reapply the label for the new head. Try a fork and an actor without write access: no upload. Run staging:deploy --check, then staging:deploy --only bot from a clean candidate and read the live-gate receipt. |
| 36: Slack, Review, Ship, private Fix it, both executors, restart and busy deploy retain production behavior within isolated test resources | [gap] Provisioning and bound live scenario receipts are required; local checks do not establish full-stack parity. |
| 36: staging disables shell and checkout-file force overrides before every deploy selection; the child loader can load other settings but cannot bypass preflight or supersede guards | [unit] src/deploy/staging.test.ts::staging inherited force protection::* | | 37: first installation requires native absence, empty config slots and one conditional publication; a partial stack and uncertain inventory/publication stop before Bot creation | [unit] src/deploy/stagingBootstrap.test.ts::staging first installation::*, ::initialization provider evidence::* | | 37: creation reaches exact Bot singleton/config identity, both executors, sandbox execution and full Memory bindings on one commit | [agent] From a clean scoped staging checkout run staging:deploy --initialize. Keep the native absence, private snapshot, upload and per-Worker commit receipts. Verify full Memory service/Workflow bindings and exact owned config readback. A partial result must not be retried or cleaned automatically. |
| 37: a Bot started before its secrets arrive is restarted with the provisioned credentials before any readiness claim | [unit] src/deploy/stagingBootstrap.test.ts::first Bot secret activation::* |
| 37: recorded provisional Memory continuation does not replay creation; a changed deployment ID, hostname ownership, writer, code, namespace or full-stack binding refuses | [unit] src/deploy/stagingBootstrap.test.ts::recorded provisional Memory continuation::*, src/deploy/stagingMemoryReceipt.test.ts::provisional Memory receipt::* |
| A same-repository pull request on a writer/App-owned origin branch waits for exact-head validation and package smoke before staging admission; a fork, closed/moved pull, unauthorized manual actor, failed or wrong-app check gives no deployment eligibility. Latest checks supersede older results. | [unit] src/deploy/stagingCi.test.ts::staging validation before credentials::* | | Automatic staging runs only on release PRs and serializes the full four-Worker deployment and bounded live Review acceptance under the existing shared staging lock; queued heads revalidate through the trusted controller before candidate credentials. Unrelated labels do not deploy or replace the real acceptance check. Deploy/smoke receipts remain separate and retained. | [unit] src/deploy/staging.test.ts::staging workflow isolation::*; [agent] Run the reviewed CI workflow against a trusted open PR at an exact head: validation finishes first, all four deployed builds match it, and the receipt records a persisted exact-head Review publication. A moved/closed or fork PR receives no staging credentials. |
| Asynchronous smoke saves the serving-build admission before polling its original run; waits for persisted final facts, and retains that identity with skipped dependents on deadline without another admission | [unit] src/deploy/agentSmoke.test.ts::bounded agent smoke::waits for the original asynchronous runs to be saved without another admission, ::keeps the admitted identity when asynchronous completion exceeds the deadline, ::retains an asynchronous admission when its build or thread evidence is refused |
| Automatic staging eligibility requires the configured release branch, including the queued-head recheck. Ordinary PRs with no explicit staging request receive a not-required check without staging credentials. A retained deploy label or same-head branch dispatch keeps staging required on ready/reopened events, using manual writer authorization; missing request history fails closed. Labels/CLI retain manual eligibility. Failed authorization never becomes a planned skip. | [unit] src/deploy/stagingCi.test.ts::staging validation before credentials::automatically stages release PRs and keeps ordinary PR staging opt-in; src/deploy/staging.test.ts::staging workflow isolation::* | | When Slack connection is required, live health must report the socket connected before and after each scenario. Disconnected or missing evidence stops admission or retains the original run ID and skips dependents; connection proof does not claim a Slack message round trip. | [unit] src/deploy/agentSmoke.test.ts::bounded agent smoke::requires Slack connection before and after runs when configured | | Opt-in model-control smoke requires a matching recorded card, named effort, vouched cap/window and known cache rule without degraded-control notes; missing evidence fails the original scenario and skips dependents. Receipts retain the observed controls. | [unit] src/deploy/agentSmoke.test.ts::bounded agent smoke::requires recorded model controls when requested, rejecting degraded or missing evidence |
| A configured Slack command check verifies the test workspace/user before one mention in the configured public test channel, saves its native timestamp before observation, and requires the staging bot’s status reply to match the deployed build, process start and loaded config. Bot identities, pending external sharing, wrong identity/build, lost acknowledgment and deadlines retain unknown effects without replay. | [unit] src/deploy/slackSmoke.test.ts::Slack command acceptance::*; [agent] Run with the approved testing user token and retain the actual message/reply receipt; no extra model run is inferred. |
| Explicit deploy inspect --public-metadata and verify-sandbox inspect-public collect unfiltered public V2 pages through the original account/app/credential read boundary. Empty continuation pages require more reads; malformed or missing envelopes, unknown states, repeated tokens, deadline or denial retain incompleteness without a fallback. Closed hashes/counts/states/status omit raw identities, image paths, names, bodies, headers and credentials. Public metadata never supplies an application version, legacy inventory, runtime/probe proof or readiness credit; live and targetVerified remain false. | [unit] src/deploy/publicInventoryObservation.test.ts::public inventory observation::*; src/deploy/sandboxVerification.test.ts::verification without upload::public metadata inspection cannot credit legacy inventory or invoke its runtime probe; src/deploy/pagingDiagnostic.test.ts::bounded paging evidence::public metadata pages retain only native wire facts and hashed identities; src/core/commands/deploy.test.ts::deploy.inspect public metadata::*; src/ciWorkflow.test.ts::Sandbox verification workflow::public inspection uses only the explicit read mode and cannot credit a legacy readiness receipt |
| Paging diagnostics record the caller's actual explicit positive safe-integer page size, or null when absent/malformed. Legacy requested size and observed HTTP query stay independent; explicit public metadata retains its separate page size. Redaction, capture limits, first failure and readiness decisions stay unchanged. | [unit] src/deploy/host.test.ts::deployment command cancellation::records the actual legacy page argument alongside its independent HTTP query; src/deploy/pagingDiagnostic.test.ts::bounded paging evidence::records explicit public size and unknown malformed sizes without inventing arguments |
| Public inspection disables auth-token disk logging, preserves supported credential headers privately and cancels auth through the existing owned process group. An interrupted or unconfirmed read cannot start the public request or grant completeness. | [unit] src/deploy/host.test.ts::deployment command cancellation::public inspection keeps auth credentials off disk while preserving supported header formats, ::a public auth deadline ends its noncooperating owned processes without a native request |
| Public cursor diagnostics describe direct GET query construction with URLSearchParams and continuation from the public response, including cycle witnesses and failures. Refusal before request start claims no wire observation or CLI transformation; legacy reads retain their own argv and producer provenance. | [unit] src/deploy/publicInventoryObservation.test.ts::public inventory observation::*; src/deploy/pagingDiagnostic.test.ts::bounded paging evidence::keeps the matching first and repeated cursor receipts after the page ring rolls over |
| Fixed-pool activation is not skipped on an unchanged container application. The runner requires valid registry evidence before reconciling pending members and checks each physical address after the drain lift. | [unit] src/deploy/residentDrainRun.test.ts::resident application target::reconciles pending pool members even when the uploaded application is unchanged; ::pending activation cannot bypass failed registry evidence: %j; src/deploy/residentReadiness.test.ts::resident readiness evidence::checks each physical replica when several members share one repository |
Selected staging maintenance
A selected staging maintenance policy names one deployment attempt and the evidence for its accepted fixture scope. The policy does not prove that an unfinished request ended. An absent policy leaves ordinary deployment unchanged.
| Criterion | Proof |
|---|---|
| The profile retains the exact selected staging policy and refuses a different account, Resident origin or declared production scope. Staging checks bind the policy to the independently configured account pair. | [unit] src/deploy/profile.test.ts::parseProfile::keeps the exact selected staging maintenance policy and refuses production activation; ::refuses a maintenance policy whose selected Resident or scope differs from the profile; src/deploy/staging.test.ts::isolated staging deployment::binds a selected maintenance policy to the independently configured staging accounts |
| Retained deployment requires an explicit option, the selected policy and a Resident step. Its normal plan carries the exact attempt with force zero. Only that Resident receives the rendered policy. | [unit] src/deploy/plan.test.ts::selected staging maintenance deployment::*; src/core/commands/deploy.test.ts::deploy.plan::accepts the retained-maintenance CLI option only with a selected staging plan; src/deploy/wranglerTemplate.test.ts::templateView::renders the selected maintenance policy only for its staging Resident |
| Manual staging workflow selection passes retained mode through both acceptance plans and the deployment wrapper. Missing or false selection preserves ordinary calls. The existing wrapper keeps force zero and waits for an explicit operator release before acceptance, within the shared staging job. | [unit] src/deploy/staging.test.ts::staging workflow isolation::passes retained mode through planning, deployment and acceptance only when selected…; ::staging inherited force protection::*; ::isolated staging deployment::waits only for the selected explicit release and stops at its deadline. [gap] Live workflow completion requires the selected producer, accepted risk window and explicit release. |
Explicit staging recreation
An operator can discard a staging installation and recreate it with an ordinary profile. This is a destructive maintenance operation, separate from successful deployment and required PR acceptance. The selected profile must omit maintenance and retain a separate staging account. The operation verifies complete runtime provisioning, Docker and rendered inputs before deletion. It requires a reviewed source/configuration/native plan hash, refuses foreign dependencies, deletes only the selected application/Workflow/Worker set, and proves native absence before the existing first-install protocol runs. Unknown mutation outcomes stop without a retry. It does not report the original work as ended or publish an acceptance status. The required automatic PR workflow must still run force zero, all four Workers and actual Review validation afterward.
| Criterion | Proof |
|---|---|
| Domain references permit a plan for only the exact selected staging resources, with Memory deleted before Bot. Production, retained maintenance, foreign dependencies, unknown reference kinds and incomplete inventory refuse. | [unit] src/deploy/stagingRecreate.test.ts::staging installation recreation::plans only the selected staging resources and deletes Memory before its Bot dependency; ::refuses production, retained maintenance, foreign dependencies and incomplete provider inventory |
| Every Bot, Resident and Sandbox Container application must name a uniquely resolved namespace owned by its expected Worker. Missing, unknown, foreign or other selected Worker ownership refuses before any deletion. | [unit] src/deploy/stagingRecreate.test.ts::staging installation recreation::requires positive ownership of application… |
| A configured provider key outside the exact runtime restoration roster refuses the reset before deletion. | [unit] src/deploy/stagingRecreate.test.ts::staging installation recreation::refuses a configured provider key outside the exact restoration roster |
| A reviewed plan and runtime restoration are required before deletion; initialization requires native absence; unknown deletion stops without retry. | [unit] src/deploy/stagingRecreate.test.ts::staging installation recreation::requires the approved exact plan and positive absence before first installation; ::stops an uncertain delete or missing absence proof without retry or initialization; ::refuses to delete when runtime secret restoration is unavailable |
| Recreation is manual-only, uses the shared staging lock and cannot replace required automatic deployment or Review checks. | [unit] src/deploy/stagingRecreate.test.ts::staging recreation workflow::* |
| Source qualification uses the trusted main controller before candidate code runs. | [unit] src/deploy/stagingRecreate.test.ts::staging recreation workflow::qualifies the candidate with the trusted main controller before running candidate code |
| Unknown dependency fields still refuse before deletion. Their bounded diagnostic contains the selected Worker, field names and shapes, without provider values. The published legacy dependency shape remains accepted. | [unit] src/deploy/stagingRecreate.test.ts::staging installation recreation::retains bounded dependency field shapes without provider values when an unknown field refuses; ::accepts the published Wrangler dependency shape and preserves selected incoming Worker dependencies |
Native execution rollout
| Criterion | Proof |
|---|---|
| Memory deploys first. The same frozen configuration is published before the first selected Resident/Bot consumer, against the original Bot application. Resident readiness precedes Bot activation. A Resident refusal leaves Bot unattempted. | [unit] src/deploy/plan.test.ts::WORKER_SPECS / workersFor / DEPLOY_ORDER::is state Worker → resident → bot → sandbox, each once, each with its dir and command; src/deploy/configPublicationRun.test.ts::runDeployPlan config publication::publishes the frozen configuration before Resident and keeps Bot unattempted after a Resident refusal |
| A Bot-only or forced deployment keeps the native Resident dependency. An unavailable/old receiver, closed production registry or non-current image report refuses Bot upload. A retained staging plan accepts only its exact source, policy and qualified completed hold window. This gate does not qualify the kernel or authorize secret rotation. | [unit] src/deploy/plan.test.ts::native Resident producer dependency::*; src/deploy/botGateRun.test.ts::native Resident dependency before Bot upload::* |
| Retained Resident upload keeps fenced-window eligibility and seals that window before returning. Bot requires the same completed window, current authority and ready images. Unsealed, foreign, incomplete, stale or expired evidence refuses Bot without renewing upload eligibility. | [unit] src/deploy/configPublicationRun.test.ts::runDeployPlan config publication::seals the retained Resident window before the same plan uploads Bot; src/deploy/botGateRun.test.ts::native Resident dependency before Bot upload::refuses the %s retained window without Bot upload |
Manual held CI bootstrap
| Criterion | Proof |
|---|---|
| Manual CI binds the current same-repository PR, selected source/controller, original actor/run/attempt and immutable profile/configuration. A rerun, prior matching logical attempt, wrong source, changed input or expired deadline refuses before upload. The runner and final preflight recheck the selected 20-minute readiness reserve after preparation. Positive complete workflow history is required; preserved GitHub history is part of this contract. | [unit] src/deploy/stagingHeldBootstrap.test.ts::held CI bootstrap::*; src/deploy/stagingHeldBootstrapWorkflow.test.ts::held bootstrap workflow::* |
| A selected bootstrap reconciles every original member against the current native application/version/image even when the image is unchanged. Missing members, foreign targets or pending activation refuse. One Resident-only force/retain invocation leaves the selected maintenance held and the risk window null. A lost upload or failed postcheck retains unverified original intent with no second upload or cleanup reopen. Normal staging stays force zero and keeps its separate release wait and E2E acceptance. | [unit] src/deploy/stagingHeldBootstrap.test.ts::held CI bootstrap::*; src/deploy/residentDrainRun.test.ts::retained staging deployment::*; src/deploy/staging.test.ts::isolated staging deployment::* |
| The manual-only bootstrap has a distinct job and artifact, uses the staging Environment and existing deploy-staging lock, refuses run_attempt other than one, and exposes no Admin, production or normal acceptance action. | [unit] src/deploy/stagingHeldBootstrapWorkflow.test.ts::held bootstrap workflow::* |
| An operator-qualified original CI run on the exact containing source verifies original actor/input/producer scope, one held upload, native target and member evidence, held maintenance and a null window; its status supplies no full-stack or Review E2E credit. | [gap] requires separately selected immutable inputs and one operator-authorized physical dispatch; source tests do not supply live qualification. |
Separately selected staging successors
| Criterion | Proof |
|---|---|
| A successor bootstrap requires the exact released predecessor and fresh source-bound operation. It leaves a new held installation receipt and no risk window. Changed predecessor evidence refuses before upload; missing post-install receipt remains unverified without a second upload. | [unit] src/deploy/stagingHeldBootstrap.test.ts::held CI bootstrap::bootstraps the exact released predecessor and verifies its new installation receipt; ::refuses changed released predecessor or missing new installation proof |
| A distinct successor stores its own maintenance/window/audit without deleting or renewing the consumed predecessor. A separate explicit decision precedes the later normal force-zero CI window and release. | [unit] deploy/cloudflare-resident/drain.test.ts::global deploy admission evidence::uses a fresh successor risk window through normal claim seal and explicit release; src/deploy/staging.test.ts::isolated staging deployment::* |
| A cancelled staging workflow cannot start or retain its deploy job. Existing authorization, requested-deployment and validation gates remain; eligible skipped-validation requests still deploy when not cancelled. Receipt/upload cleanup keeps always-run conditions and the existing serialized queue | [unit] src/deploy/staging.test.ts::staging workflow isolation::refuses cancelled deploys while preserving eligible skipped-validation paths and always-run receipts |
Partial initialization continuation
The explicit recreation workflow can continue an approved initialization checkpoint. Its current candidate is the release PR head to qualify. Its commit is the original installation source to finish. The reviewed main controller verifies the original job, artifact and declared source before any installation-source code or install script runs. It then runs the continuation while the original installation checkout supplies all build and configuration inputs. The continuation input contains the nonsecret checkpoint JSON; approved_plan must equal the SHA256 of those exact JSON bytes. The original run and both artifact hashes, executor application IDs, namespaces, images and accepted upload versions remain fixed. This path has no deletion operation and cannot repeat the completed prefix. A requested cold fixture registration runs only after all four original Workers pass, under the same staging lock, using the transient restoration bundle. Manual completion gives no automatic deployment or Review acceptance credit.
| Criterion | Proof |
|---|---|
| A verified creation prefix can finish the stack without repeating completed uploads or secret provisioning. A changed checkpoint or invalid prefix refuses before publication. | [unit] src/deploy/stagingBootstrap.test.ts::recorded executor continuation::*; ::initialization checkpoint order::* |
| Executor continuation requires exact native ownership, image, code, writer, domain and secret-name evidence. The original installation source and failed CI artifacts remain immutable. | [unit] src/deploy/stagingContinuation.test.ts::completed executor checkpoint::*; ::original continuation source::*; ::exact executor namespace identity::* |
| The reviewed main continuation finishes the original installation, registers the exact configured cold fixture under the staging lock, and then permits a separate current-release automatic acceptance run. | [agent] Dispatch the explicit continuation with the qualified current release head and approved original checkpoint. Retain the original-source all-four initialization and cold-fixture receipts. Then require the automatic current-release all-four deployment, actual Review publication, complete persisted run events and Slack checks. [gap] Live continuation and automatic acceptance are not yet verified. |
| Original-source provenance is checked by the trusted controller before any installation-source install hook can execute. A different declared checkout refuses even when the current release candidate is qualified. | [unit] src/deploy/stagingRecreate.test.ts::staging recreation workflow::refuses failed provenance before any original-source installation hook can run; src/deploy/stagingContinuation.test.ts::original continuation source::qualifies original artifacts before source execution and refuses a different declared checkout |