Instruction file imported from louisbrulenaudet/monorepo-template (
.cursor/rules/ops/cd.mdc). Copyright stays with the author.
Continuous Deployment
The pipeline is .github/workflows/cd.yml. Read it for the step list; a prose copy here would only drift. How a release reaches it: ops/release.md. CI invariants: ops/ci.md. Human-facing secret/variable tables: the README deploy section. Wrangler secrets vs vars: backend/workers-config.md.
Weakening a deploy gate or shipping with missing credentials is covered by guardrails.md and is never the answer here either.
Status
CD is paused until the production GitHub Environment secrets are configured. The pause is the repository variable CD_ENABLED, checked by release.yml's deploy job - set it to true in the same act as adding the secrets. It deliberately does not live in this workflow: a job skipped inside a workflow_call target reports success to the caller, so a paused CD used to leave a green Release run with the tag already cut and nothing shipped. Gating at the caller makes the skip visible, and keeps workflow_dispatch usable for a manual redeploy.
Trigger
workflow_callandworkflow_dispatchonly.release.ymlcalls this workflow after it has cut the tag and confirmed it did not already exist;workflow_dispatchtakes ataginput and is the redeploy/rollforward path.- There is no
push: tags:trigger, and adding one is a regression. The release tag is created withGITHUB_TOKEN, and events created by that token do not start workflow runs, so a tag trigger never fires (GitHubGITHUB_TOKENdocs; changesets/action#669, changesets/changesets#1545). Zero tags existed while that dead trigger was the only path. - Do not deploy from
pull_requestorpull_request_target(the latter especially - base context plus untrusted checkout is a pwn pattern). - Concurrency is
cd-production,cancel-in-progress: false,queue: max. Cancelling would drop an in-flight ship; and with the defaultqueue: singlea third trigger replaces the pending one, silently losing that release. - Checkout uses
ref: ${{ inputs.tag }}- the ship must match the tagged commit, not whatever the branch tip is at job start. The tag must be exactlyvX.Y.Z: a changesets pre-mode tag (v1.0.0-next.0) fails closed atResolve release versioninstead of shipping @100%, and that red run is by design.RELEASE_SHA(the tagged commit) is what upload messages and Release notes record -GITHUB_SHApoints at the dispatching ref onworkflow_dispatchredeploys.
Auth and environment
- The job declares
environment: {name: production, url: …}. Protection rules, required reviewers, and environment secrets belong there. Do not move Cloudflare credentials into unprotected repository secrets "for convenience". - Cloudflare credentials are never passed in by a caller.
on.workflow_call.secretsdeclares onlyTURBO_TOKENandTURBO_REMOTE_CACHE_SIGNATURE_KEY;CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_IDare resolved from theproductionenvironment by the individual wrangler steps via step-levelenv:- install, build, and smoke never see them. Never switch the caller tosecrets: inherit, and never lift the credentials back to job-levelenv:. A new wrangler step must copy the two-line credentialsenv:block - theRequire deploy inputsgate only vouches for its own copy. - Both Cloudflare values are required - the workflow fails closed if either is empty. Use a scoped Workers API token (Edit Cloudflare Workers, account-scoped), never a Global API Key, never a committed value. Secrets Store Edit only if the Worker binds Secrets Store.
VITE_API_BASE_URLis a repository/environment variable, not a secret - public build-time config forfront-app, and theenvironment.url. Still required; empty fails.- Wrangler auth in CI is non-interactive via those env vars (Cloudflare GitHub Actions guide). Do not add
wrangler loginto CD. - No OIDC, and that is correct. Cloudflare has no OIDC path here, and npm trusted publishing is npm-CLI-specific and irrelevant - nothing in this repo is published to npm.
id-token: writewould be privilege with no consumer.
Versions upload, then promote
- This repo does not use
wrangler deployorcloudflare/wrangler-actionin CD. Package scriptsupload/promotecallwrangler versions uploadthenwrangler versions deploy …@100% --yesagainst the workspace-pinned Wrangler, keeping the CLI identical to local and avoiding a second floating action pin. - Upload is machine-readable:
WRANGLER_OUTPUT_FILE_PATHcaptures JSON lines; CD parsestype === "version-upload"forversion_id. Do not replace that with scraping stdout - a per-app path is also what lets the uploads run concurrently without clobbering each other. - Keep
--strict, and tag/message with the release version +RELEASE_SHA(the tagged commit). Cloudflare:--strictmakes upload/deploy more defensive in non-interactive CI - do not drop it to force green. - Traffic policy today is 100%. Gradual percentages exist; do not add them without observability and a rollback runbook.
versions uploaddoes not apply routes, custom domains, or cron triggers (deployment management). When those land inwrangler.jsonc, also runwrangler triggers deploy --env production(comment beside the upload inupload-versions.sh).- The app set is discovered, never listed.
.github/actions/lib/apps.mjsreads everyapps/*/package.json, so a new Worker ships by existing;upload-versions.shandpromote-versions.shloop over it. It fails closed on an app missing an integermonorepo.deployOrder, and on two apps sharing one, instead of skipping or silently tiebreaking - a tag cut for a release that never shipped one of its Workers, or shipped them in an order nobody declared, is the failure this prevents. - Every app declares
monorepo.healthPath- the public probe path, ornullwhen it has no public HTTP surface (RPC-onlyworker-*,queue-*).apps.mjsfails closed on an app that declares neither, because an app nobody declared a probe for ships to production unverified.smoke-versions.shprobes the app whosemonorepo.roleishttp-gatewayat${VITE_API_BASE_URL}${healthPath}; CD knows no other origin, so every other app with a non-nullhealthPathis named in a::notice::as promoted-but-unsmoked rather than passed over silently. - Promotes are sequential and ordered by
monorepo.deployOrder. Lower first: gateways before the SPAs that call them (worker-api1,front-app2). If a later promote fails while an earlier one is already live at 100%, production is split across two releases -promote-versions.shnames every already-promoted app and itswrangler rollback --env productioncommand. Do not parallelize the promotes without handling that partial state, and declare a new app's position in its ownpackage.jsonrather than reordering the loop. - Uploads run concurrently; only the promotes are ordered. An upload changes no traffic, so nothing depends on its order and one failing cannot split production - that is the whole reason the two loops differ.
upload-versions.shbackgrounds onepnpm --filterper app, each with its ownWRANGLER_OUTPUT_FILE_PATHtemp file, thenwaits on every pid: an upload that fails does not abort its siblings, so the step reports every app that failed in one pass rather than only the first. The version-id table is written afterwards by walking the discovery order, never completion order, becausepromote-versions.shreads it as thedeployOrdercontract. Each app's output is captured and replayed inside a::group::so concurrent streams do not interleave. Concurrency is unbounded - bound it here if an app count ever makes N simultaneous wrangler processes the constraint.
Release and deployment records
- The deployment record is created by GitHub, because the job references an environment ("when a workflow job that references an environment runs, it creates a deployment object"). Do not re-create deployments through the REST API: an earlier version of this pipeline did, producing a duplicate record and marking GitHub's own one inactive.
- Only the GitHub Release is ours to write - created/updated in-workflow with
gh release create --verify-tag, listing each deployed app's version id with its matchingapps/<app>/CHANGELOG.mdsection when present.
Same hardening as CI
SHA-pinned uses: with # vX.Y.Z comments; persist-credentials: false on checkout; workflow permissions: {} with per-job re-grants (contents: write for the Release, deployments: write for the environment record); runner ubuntu-24.04; frozen pnpm install; Node 24 via pnpm/setup; telemetry off. Step bodies live as scripts per the convention in ops/ci.md; the smoke probe now lives in smoke-versions.sh too, and the deploy-order coupling lives in each app's monorepo.deployOrder (above) rather than in step names. $RUNNER_TEMP only ever from a step - see the expression-context section in ops/ci.md, which is what broke this file once. Do not diverge "just for CD".
What is deliberately not used
- Workers Builds - external GitHub Actions is the chosen path so CI and CD share one pipeline. Do not dual-enable Cloudflare Builds on the same production Workers.
- Baking secrets into
wrangler.jsoncvarsor workflow YAML - runtime secrets stay in Cloudflare; build secrets stay in GitHub Environments (workers-config.md). - Deploying every green
main- only a newly created release tag deploys, gated oncreate-release-tag'screatedoutput.