Release and deploy: production follows the release PR, one Worker at a time
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. 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/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/deploy/liveGate.ts(the bot's live 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 per-Workernpm ci --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/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),.github/workflows/ci.yml(deploy targetson every PR),scripts/check-pr-title.mjs(the title gate: grammar, the type list from the release config, the scope list from the code map's Areas, the migration section behind!— pure functions, onemain). - Tests:
src/ciWorkflow.test.ts(items 20–21, 23, 27),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/sandboxLiveGate.test.ts,src/deploy/sandboxGateRun.test.ts,src/deploy/profile.test.ts,src/deploy/configSource.test.ts,src/configDocument.test.ts,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). - 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).
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 namespace-profile-mid for installs, builds, tests, scans, deploy planning, publishing, and deployment. Lightweight jobs use namespace-profile-smidge: 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 namespace-profile-mid, the three aggregation gates on namespace-profile-smidge, and every required check successful. For forks and callers of the reusable deploy workflow, configure these Namespace profiles or replace the runs-on labels with available runners.
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.ymlwithtargets: affected. 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), andforce. It runs only frommain; dispatched from any other ref it fails at its first step, naming the ref, before anything is checked out or deployed. 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 the plan. Both are one script,
scripts/deploy-targets.mjs(npm run deploy:targets— like every CI step, something a contributor can run), in two modes. Thedeploy targetsjob inci.ymlruns it on every PR:deploy plan --affected --base HEAD^into the job summary, so the check on any PR says which Workers its diff would deploy and flags an unclassified path before it reaches a release. The release PR's plan is posted byrelease-please.yml, not by that job: the per-PR job judges a diff against its base, while the release must be judged against production and re-rendered on every merge tomain— which is exactly the pushrelease-please.ymlruns on. So, on every push tomainwhile a release PR is open, therelease-pr-deploy-targetsjob checks out the release branch (mainplus the release commit) and runs the script withRELEASE_PR=true:deploy plan --affectedwithout--base— against production's live commits — and one sticky comment (<!-- switchboard:deploy-targets -->) on the release PR with the markdown table, found again by its first line and edited in place. Mergingmainre-renders it; the comment on the PR is always the plan for its current head. - 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). - Read is enough to preflight the resident. The resident step needs one of
RESIDENT_ADMIN_TOKEN/RESIDENT_OPERATOR_TOKEN/RESIDENT_READ_TOKEN— the same three itspreflight.mjsaccepts — so CI holds the read token and nothing more. The plan names the alternatives; a checkout with none of them is refused before anything deploys. - 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_READ_TOKEN(the resident preflight);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). Nothing in CI reaches into a vault: no workflow names a 1Password action, anop://reference orOP_SERVICE_ACCOUNT_TOKEN; the App's two values are repository secrets like every other credential CI holds. The shape that forced it: the App used to be loaded from the vault through an organization secret scoped to private repositories, which GitHub stops exposing the moment a repository goes public — the first public release failed at that step. 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).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; 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 allno longer produces 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), 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. deploy allpushes the bot's config to the state Worker; the image never holds it. The profile'sconfigSourceis a path (the default),github://owner/repo/path@refread from the contents API withCONFIG_REPO_TOKEN, orop://Vault/Item/fieldread with theopCLI underOP_SERVICE_ACCOUNT_TOKEN. When the bot is a step, the config is read and validated (parseAppConfigText) after the pre-checks and BEFORE any Worker deploys — a source that cannot be read (a missing file, a missing token, a 404, a missing CLI), a config that does not validate, or a missingMEMORY_TOKENrefuses the deploy naming the source, the problem, or the variable — and then pushed as thebasedocument on the profile's state Worker (src/configDocument.ts; routing-and-config.md item 14) right before the bot step, after the memory step has rolled; a push that fails stops the run at the bot step naming the Worker's answer. The bot Worker's container reads that document at start (SWITCHBOARD_CONFIG=state://base, withSTATE_WORKER_URLandMEMORY_TOKENforwarded), so the Dockerfile copies noconfig/andconfig/is inert to--affected.deploy config [--source]pushes alone — how a config change goes live without a release: push, thendeploy restart. The plan carriesconfig: { source, document, stateWorkerUrl }and prints it.- 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 stays on it. 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 an EMPTY error — 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: "health";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 rollout and the probe are read only once the Worker is live, 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— the EMPTY string included, the shape a stale container produces — 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; 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](npm run secretsin the Worker's dir) 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 unparseablesecretsSourceareunavailable. 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/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 --quiet <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. - 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]): this project's own production runs inregistrymode (item 25), sodeploy allcopies the release's published images into the account registry, and a release deploys the very bytes it published, only once they exist; 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 all three 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: 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. 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 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 --workspace deploy/<worker>…against the shipped lockfile — the release's pinned versions — 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. - Where a Worker's container image comes from is the profile's image mode. The profile (item 14) carries
images:build— the default when absent, and any checkout — orregistry— this project's own production, from the infrastructure profile. Each Worker with a container names{{image}}in itswrangler.template.jsonc(the bot's, the resident's, the sandbox's; the state Worker has none), anddeploy initrenders it from the mode (src/deploy/images.tscontainerImage): inbuildmode the Worker's Dockerfile relative to its directory (DOCKERFILES—../../Dockerfilefor the bot,./Dockerfilefor the other two), which wrangler builds at deploy time and needs Docker for; inregistrymoderegistry.cloudflare.com/<account>/<name>:<version>— the account the profile's, the name the published image's last segment (switchboard,switchboard-resident,switchboard-sandbox), the version the CLI's own (cliVersionOnHostinsrc/deploy/host.ts: from the package itssource.json's, item 24; in a checkout or the image the rootpackage.json's,packageVersioninsrc/packageRoot.ts— a release publishes its images and its CLI under one number) — which wrangler deploys without building anything. The three published names come fromproject.json'simages(item 21), read through the same file access as the site's facts; a facts file without them refuses the render naming the file. Why a copy in the account registry and not a direct pull: the decision record — Cloudflare pulls from its own registry, Docker Hub, ECR and Artifact Registry, not GHCR, and an external pull is uncached and repeated on every container start.deploy planprints the mode on anImages:line — each planned step's Dockerfile, or its reference — and the committed example profile saysregistry, the shape an installation deploying published images copies, so a checkout without a profile of its own renders the registry references at the placeholder account (which nothing deploys). The templates render in both modes with nothing left unfilled. - The release's images are copied into the account registry once per version, over HTTPS, by
deploy allitself when its Workers lack them and bydeploy imagesas the explicit sub-step. The registry command (deploy:write, CLI only likedeploy all) reads the account registry —GET registry.cloudflare.com/v2/_catalog?tags=trueunder the credential below, the callwrangler containers images listmakes, with no process spawned and no Worker directory installed, so a root that has rendered nothing can ask and a package root asks before its work area exists (the catalog's"/<account>/<name>": [tags]rows are read under the profile's account; another account's are not its) — plans purely which of the three images at this CLI's own version — the only version the rendered configs reference, so there is no--version; another release's images are copied by that release's CLI — are present and which to copy (src/deploy/images.tsplanImageCopies), and moves each absent one registry to registry over HTTPS, with no container daemon anywhere (src/deploy/registryTransfer.ts,src/deploy/registryTransferHost.ts): one push+pull credential per account — minted fromCLOUDFLARE_API_TOKENatPOST /client/v4/accounts/<account>/containers/registries/registry.cloudflare.com/credentials(expiration_minutes: 45,permissions: ["push","pull"]— whatwrangler containers registries credentialscalls) and used as HTTP Basic<username>:<password>for the catalog and the copies alike, kept while it is fresh (replaced before it comes within 5 minutes of its 45-minute expiry, and once more when the registry answers 401 to it, the operation then tried once more) — a missing variable is refused by name before the API is asked (on the read, the pre-check and a copy), and a 403 from the mint or the catalog is refused naming the endpoint that answered and the permission Cloudflare documents forcontainers, Containers Edit (wrangler gates everycontainerscommand on it; Cloudflare documents no permission for the registry endpoint itself, so the refusal also says that a token which has Containers Edit and is still refused lacks one Cloudflare does not name), before anything moves; the source read anonymously, its 401'sWWW-AuthenticateBearer challenge (realm, service, scope) answered once with an anonymous token request at the realm — no token endpoint is spelled; a source that needs no token is read as it is, a 401 without a challenge is named — the manifest by tag, thelinux/amd64manifest by digest when the tag is an index (attestation manifests and other architectures skipped; none there named), then its config and layers by digest; per blob aHEAD /v2/<account>/<name>/blobs/<digest>on the account registry and, for one absent,POST /v2/<account>/<name>/blobs/uploads/and the bytes STREAMED from the source into chunkedPATCHparts of 64 MiB — every part but the last at least 5 MiB, the registry's rule (a smaller one is416 RANGE_ERROR) — with the sha256 computed as the bytes pass and compared to the digest before thePUT <location>&digest=<sha256>commits it (a mismatch is never committed); a failed part is sent once more and a second failure stops the copy naming the part; no whole layer is ever held in memory, one part's buffer is; finallyPUT /v2/<account>/<name>/manifests/<version>byte-identical under the SOURCE's media type, then aHEADwhoseDocker-Content-Digestmust equal the source's. A transfer that reported success is not the proof: the catalog is read again and every copied image must appear, elseunavailablenaming it. Present images are skipped and said (present), copied ones said (copied),--dry-runsayswould copyand touches nothing; the example profile is refused (a placeholder account has no registry); a registry that cannot be read isunavailablewith the registry's words (deploy imagescopies, so it needs the listing); a failed copy stops the run naming the image, the transfer's problem and what was not attempted. A profile that builds (images: build) references no published copy: the command answers with nothing to copy, reading no registry and minting no credential — said, not refused, so the reusable workflow (item 27) runs it before every plan and the profile decides. Inregistrymodedeploy plananddeploy allprobe the same listing once (never for the example profile, whose plan readsnot probed (the example profile)) and refuse nothing over a missing image; a registry that cannot be read — noCLOUDFLARE_API_TOKEN, a token the endpoint refuses — leavesdeploy plan(anddeploy all --dry-run) a plan that readsnot probed (<the registry's words>), since a plan is read on every surface and needs no credential to be read, whiledeploy allanddeploy imagesareunavailablewith those words: the plan'sImages:line counts the present ones and saysdeploy allcopies the rest (1 of 3 present; deploy all copies the rest), naming eachpresentormissing;deploy planstays read-only.deploy allcopies the planned steps' missing images — the same plan narrowed to the planned Workers, so--only memory,botwith the bot present copies nothing and mints no credential — BEFORE anything deploys: the credential first, each copy in order, the listing read back as the proof and the plan rebuilt on that listing from the first plan's own answers (the registry not probed again, the--affectedreport not asked again — the selection the copies were planned for is the selection that deploys), so the runner receives a plan whose every image is present; what was copied is said first in the output (copied into the account registry: resident ← …).deploy all --dry-runcomputes the plan and the copies it would make and stops — nothing copied, nothing deployed — sayingwould copy into the account registry: …and(dry run — nothing copied, nothing deployed);deploy plantakes no--dry-run, being one. A copydeploy allcannot make stops it before anything deploys with the same refusalsdeploy imageshas (the token's Containers Edit by name; the failed image, the transfer's problem and what was not attempted). A step without a container (the state Worker) needs no image, andbuildmode never probes. This is the operator's path — one command,deploy all, from any directory with Node and the token (the decision record);deploy imagespre-warms a registry on its own and is what the reusable deploy workflow runs before its plan (item 27); this project's own production runs inregistrymode too, which is why the release'sdeploywaits forpublish-image(item 21). - 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,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,secrets: inherit— so this project's production deploys through the same file an operator's CI calls withcli: package(Deploy from your CI).
Validation criteria
| Criterion | Evidence |
|---|---|
| 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), 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, 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, scripts, the deploy tooling and repo metadata are inert; a 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; any other field is a change | [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… |
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` |
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::* |
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 pushes the profile's configSource (or --source) to the base document on the profile's state Worker and points at deploy restart; a host problem is unavailable verbatim; the plan carries config: { source, document: "base", stateWorkerUrl } and prints it; config/ is inert to --affected (item 15) | [unit] src/core/commands/deploy.test.ts::deploy.config::*, src/deploy/plan.test.ts::WORKER_SPECS / workersFor / DEPLOY_ORDER::the profile binds each Worker…, src/deploy/affected.test.ts::classifyPath::*; [agent] npm run cli -- deploy config --source config/config.example.yaml against the fixture-profile state Worker → pushed config from config/config.example.yaml → document "base" v<n> … then deploy restart → the bot's log opens with [config] base document "base" v<n> from config/config.example.yaml; with no document pushed the container exits at startup with no "base" document … push one with \deploy config``. |
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 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; 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 dispatch from a non-main ref deploys nothing | [agent] gh workflow run deploy-production.yml --ref <branch> → 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 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 carries a sticky deploy-plan comment, posted from the push to main | [agent] After any merge to main while a release PR is open: the release-please run has a release-pr-deploy-targets job (green), and the open chore(main): release … PR has exactly one comment starting <!-- switchboard:deploy-targets --> listing each Worker with deploy/skip, a base (live <sha> for the bot, memory and resident), and reasons; the next merge to main edits it in place, not duplicating it. The PR's own deploy targets check shows the release diff against its base, a different table by design. |
| 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. |
| Merging the release PR deploys exactly the selected Workers, in order, and nothing else | [agent] Merge the release PR → Actions → release-please run → deploy-production / deploy job: deploy all --affected prints the report, then steps for the selected Workers only; afterwards each selected Worker's /healthz build.commit equals the release commit and each skipped Worker's is unchanged; gh api repos/<owner>/<repo>/deployments?environment=production lists the run. Human-gated once: the repository secrets in item 11 must exist before the first run. |
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) is unavailable with the table like any stopped run — never busy: runs in flight do not refuse | [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)` |
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: "health"; 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… |
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 instance listing parses both the bare array and the {instances, result_info} page shape with its next token; 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, 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: 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` |
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; 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: 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 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 --workspace at the work area's root for the requested Workers, 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: 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 --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 registry.cloudflare.com/<account>/<name>:<version> 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 account registry reference at the CLI's version in registry mode and the Dockerfile 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::build mode (the default for a checkout) never probes the registry and plans each Dockerfile` |
25: deploy:gen from a checkout without a profile renders the registry references at the example's account | [unit] src/deploy/loadProfile.test.ts::renderWorkerConfigsOnHost::*, src/deploy/wranglerTemplate.test.ts::the rendered wrangler.jsonc files::* |
| 26: the copy plan lists every image at the version with source, target and presence, and only the absent ones to copy; presence is by name and tag | [unit] src/deploy/images.test.ts::the account registry listing::* |
26: the host half mints one push+pull credential per account from CLOUDFLARE_API_TOKEN and spends it on the registry read, 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; 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 listing reads the catalog with tags under the credential and answers the account's repositories by bare name (another account's skipped), a 403 on it names the endpoint and the permission, a refused credential and a body that is not a catalog are 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 what the registry lacks at the CLI's version (no --version option exists; one passed is invalid_input), skips what is present, reads the listing 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 the listing does not show afterwards 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 listing 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, the listing read back — 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 listing 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 against a real account copies the three images at the release version into registry.cloudflare.com/<account>/… with the source's manifest digests, a second run finds them present, and a registry-mode deploy all then deploys Workers whose containers start from that registry | [agent] Human-gated: needs a Cloudflare account and the published images. On a machine without Docker, CLOUDFLARE_API_TOKEN=<Containers Edit token> npx <pkg> deploy images prints [images] … blobs to upload lines and three copied rows, wrangler containers images list lists switchboard, switchboard-resident and switchboard-sandbox at the version, and for each crane digest ghcr.io/<owner>/<name>:<version> --platform linux/amd64 equals the digest the account registry reports; run again, three present rows and no upload; a token without Containers Edit is refused with the Cloudflare API token needs Containers Edit … before anything moves; deploy all with "images": "registry" deploys and a cold sandbox container starts (wrangler containers instances <app> --json shows a running instance on the new version). Owed by the first registry-mode deploy from a temp directory. |
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 — no workflow names a 1Password action, an op:// reference or OP_SERVICE_ACCOUNT_TOKEN — 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: 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 the live table's deploy plan, in that order and spelled as before; 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: this repository's release calls the workflow with targets alone and secrets: inherit — its rendered inputs are the defaults | [unit] src/ciWorkflow.test.ts::the production deploy is one reusable workflow::this repository's own call passes… |
27: the release deploys production through the reusable workflow's checkout mode; 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>, its copy step prints images: build — … nothing to copy, then plan, the tree is the commit, deploy and what is live run in that order, 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. |