ch40-cicd
在 GitHub 上檢視·9 個檔案·13.8 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch40-cicd
cd ch40-cicd
npm install可用指令
npm run dev # wrangler dev
npm run typecheck # wrangler types --check && tsc --noEmit
npm run budget # node scripts/bundle-budget.mjs
npm run smoke # node scripts/smoke.mjs說明
ch40-cicd — a deployment pipeline, with the parts that actually catch things
Section titled “ch40-cicd — a deployment pipeline, with the parts that actually catch things”Companion example for chapter 40. Measured with wrangler 4.118.0 and
cloudflare/wrangler-action@v4.0.0, on 2026-08-01.
.github/workflows/deploy.yml check -> preview -> staging -> production (gradual).github/workflows/rollback.yml workflow_dispatch, because 3amscripts/smoke.mjs post-deploy smoke testscripts/bundle-budget.mjs gzipped bundle budget, no API token neededsrc/index.ts /__health reporting env + version idnpm installnpm run devnode scripts/smoke.mjs http://localhost:8787 --expect-env localnode scripts/bundle-budget.mjs --limit-kb 900Why each pipeline step exists
Section titled “Why each pipeline step exists”| Step | Catches | Comes from |
|---|---|---|
wrangler types --check | config changed, types not regenerated | this chapter |
tsc --noEmit | a binding missing from one environment (see below) | this chapter |
bundle-budget.mjs | size regressions, before upload, without credentials | this chapter |
smoke.mjs after every deploy | code that passes tests but cannot run (ch38’s new Function) | ch38 |
| smoke asserting on status code | uncaught exceptions record outcome: "ok" | ch39 |
version_metadata binding | which half of a gradual deployment answered | this chapter |
Finding 1 — wrangler types flags bindings missing from an environment
Section titled “Finding 1 — wrangler types flags bindings missing from an environment”Start with version_metadata declared only at the top level:
interface __BaseEnv_Env { BUILD_ENV: "staging" | "production" | "local"; CF_VERSION_METADATA?: WorkerVersionMetadata; // note the ?}declare namespace Cloudflare { interface StagingEnv { BUILD_ENV: "staging"; } // binding absent interface ProductionEnv { BUILD_ENV: "production"; } // binding absent}Add it to env.staging only:
interface __BaseEnv_Env { CF_VERSION_METADATA?: WorkerVersionMetadata; ... } // still ?interface StagingEnv { CF_VERSION_METADATA: WorkerVersionMetadata; ... } // required hereinterface ProductionEnv { BUILD_ENV: "production"; } // still absentAdd it to both and the ? disappears.
Rule: wrangler types unions across environments, and marks a binding
optional on the base Env if any environment lacks it. That ? is the
type-level form of “named environments do not inherit top-level config” —
the same thing the config schema says about tail_consumers: “This field is
not automatically inherited from the top level environment, and so must be
specified in every named environment.”
In CI with strict: true, forgetting to declare a binding for production
fails at tsc --noEmit instead of surfacing as env.FOO === undefined after
deploy.
Finding 2 — wrangler types --check works, but don’t pipe it
Section titled “Finding 2 — wrangler types --check works, but don’t pipe it”config edited, types stale -> exit 1wrangler types, then --check -> exit 0npx wrangler types --check | tail -5 reports exit 0 forever, because $? is
tail’s. I hit this on the first measurement. In the workflow it is a bare
run: line for that reason.
Finding 3 — at most two versions in a gradual deployment
Section titled “Finding 3 — at most two versions in a gradual deployment”Not stated as a limit in the docs. It is explicit in
node_modules/wrangler/wrangler-dist/cli.js:
"max-versions": { hidden: true, // experimental, not supported long-term describe: "Maximum allowed versions to select", type: "number", default: 2 // (when server-side limitation is lifted, we can update this default or just remove the option entirely)}with the error Too many versions selected. You can deploy at most 2 version(s) at a time. The limit is server-side; the flag is hidden and marked
experimental. Do not design a three-stage canary.
Finding 4 — versions upload does not apply observability
Section titled “Finding 4 — versions upload does not apply observability”Also from wrangler’s bundle, and absent from the docs:
async function maybePatchSettings(config, accountId, workerName) { const maybeUndefinedSettings = { logpush: config.logpush, tail_consumers: config.tail_consumers, streaming_tail_consumers: config.streaming_tail_consumers, observability: config.observability // TODO reconcile with how regular deploy handles empty state }; // ... "No non-versioned settings to sync. Skipping..." // ... "Syncing non-versioned settings" / "Synced non-versioned settings:"}These four are non-versioned settings, synced at versions deploy time.
Change observability.traces.head_sampling_rate and run only versions upload and nothing happens — the change lands on the next deployment.
(Note streaming_tail_consumers again. It is in the config schema and in
wrangler’s own code, and nowhere in the documentation. Second sighting after
ch39.)
Finding 5 — version_metadata locally
Section titled “Finding 5 — version_metadata locally”{ "version_metadata": { "binding": "CF_VERSION_METADATA" } }GET /__health{"ok":true,"env":"local", "version":{"id":"0653ce41-3fe8-4936-9aa4-d54ef93c72c3","tag":"","timestamp":"2026-08-01T10:04:35.711Z"}}tag is empty locally; in CI it is whatever --tag you passed to versions upload. Without this binding a smoke test running against a 10/90 split can
hit the old version six times in a row and report success.
Preview URLs — two limitations that change the design
Section titled “Preview URLs — two limitations that change the design”- A Worker that implements a Durable Object gets no preview URL at all. So everything from ch14–ch17 has no PR-preview path; use a staging environment.
- Preview URLs produce no logs — not Workers Logs, not
wrangler tail, not Logpush. All of ch39 is switched off there.
Alias rules worth knowing before you script them: aliases can only be created during upload, must start with a lowercase letter, alias-plus-worker-name must stay under 63 characters (DNS label limit), and only the 1000 most recently deployed aliases are retained.
Durable Objects and gradual deployments
Section titled “Durable Objects and gradual deployments”Each Durable Object is pinned to one version for the whole deployment and is only reset when it is assigned a different one. Keep the versions in the same order and only raise the percentage, and each DO resets at most once.
The hard rule: a version that changes Durable Object class lifecycle cannot
be uploaded with versions upload at all, and once such a change is
deployed you can never roll back past it. Deploy those changes on their own,
with wrangler deploy, separate from any other work.
Secrets
Section titled “Secrets”--secrets-fileis additive. Removing a key from the file does not delete the secret.secret bulktakes up to 100 per request; a JSONnulldeletes,.envformat cannot delete.--keep-varsoff (the default) means wrangler deletes all vars before applying the config — but “secrets are never deleted by deployments”.- Secrets Store (open beta) bindings are async:
await env.MY_SECRET.get(), and local dev cannot read production secrets.
原始碼
wrangler.jsoncpackage.jsonsrc/index.tsscripts/bundle-budget.mjsscripts/smoke.mjs.github/workflows/deploy.yml.github/workflows/rollback.yml.gitignoretsconfig.json
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch40-cicd",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"observability": { "enabled": true, "head_sampling_rate": 1 },
"upload_source_maps": true,
// Preview URLs default to `workers_dev`. Being explicit means the dashboard
// toggle cannot silently disagree with the repo.
"workers_dev": true,
"preview_urls": true,
"vars": { "BUILD_ENV": "local" },
// The only way a Worker can know which version it is. Required if you want
// a smoke test to tell the two halves of a gradual deployment apart.
"version_metadata": { "binding": "CF_VERSION_METADATA" },
"env": {
"staging": { "name": "ch40-cicd-staging", "vars": { "BUILD_ENV": "staging" }, "version_metadata": { "binding": "CF_VERSION_METADATA" } },
"production": { "name": "ch40-cicd-production", "vars": { "BUILD_ENV": "production" }, "version_metadata": { "binding": "CF_VERSION_METADATA" } }
}
}package.json
{
"name": "ch40-cicd",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"typecheck": "wrangler types --check && tsc --noEmit",
"smoke": "node scripts/smoke.mjs",
"budget": "node scripts/bundle-budget.mjs"
},
"devDependencies": { "wrangler": "^4.118.0", "typescript": "^5.9.2", "@types/node": "^22.0.0" }
}src/index.ts
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
// A health endpoint that reports which version is answering. This is what
// the smoke test asserts on, and what makes a gradual deployment
// observable from the outside.
if (url.pathname === "/__health") {
return Response.json({
ok: true,
env: env.BUILD_ENV,
// The version_metadata binding is the only way a Worker can know its
// own version id. Without it a smoke test cannot tell which half of a
// 90/10 split it just hit.
version: env.CF_VERSION_METADATA ?? null,
});
}
return new Response(`ch40-cicd (${env.BUILD_ENV})`);
},
} satisfies ExportedHandler<Env>;scripts/bundle-budget.mjs
#!/usr/bin/env node
/**
* Bundle size budget, enforced in CI before anything is uploaded.
*
* `wrangler deploy --dry-run --outdir dist` builds exactly what would be
* uploaded without touching the account, so this needs no credentials and can
* run on a pull request from a fork.
*
* Usage: node scripts/bundle-budget.mjs [--limit-kb 900] [--env staging]
*/
import { execFileSync } from "node:child_process";
import { readdirSync, statSync, rmSync } from "node:fs";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import { readFileSync } from "node:fs";
const args = process.argv.slice(2);
const flag = (n, d) => {
const i = args.indexOf(n);
return i === -1 ? d : args[i + 1];
};
const limitKb = Number(flag("--limit-kb", "900"));
const env = flag("--env", undefined);
const outdir = "dist/budget";
rmSync(outdir, { recursive: true, force: true });
const cmd = ["wrangler", "deploy", "--dry-run", "--outdir", outdir];
if (env) cmd.push("--env", env);
// --dry-run needs no API token. It does still read the config, so a broken
// wrangler.jsonc fails here rather than at deploy time.
execFileSync("npx", cmd, { stdio: "inherit" });
let raw = 0;
let gz = 0;
const walk = (dir) => {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const s = statSync(p);
if (s.isDirectory()) walk(p);
else if (name.endsWith(".js") || name.endsWith(".mjs") || name.endsWith(".wasm")) {
raw += s.size;
gz += gzipSync(readFileSync(p)).length;
}
}
};
walk(outdir);
const kb = (n) => (n / 1024).toFixed(1);
console.log(`\nbundle: ${kb(raw)} KB raw, ${kb(gz)} KB gzipped (limit ${limitKb} KB gzipped)`);
// The platform limit is on the compressed size, so budget on gzip, not raw.
if (gz / 1024 > limitKb) {
console.error(`bundle budget EXCEEDED: ${kb(gz)} KB > ${limitKb} KB`);
process.exit(1);
}
console.log("bundle budget OK");scripts/smoke.mjs
#!/usr/bin/env node
/**
* Post-deploy smoke test.
*
* Why this exists: ch38 measured that `vitest run` passes on code that
* `wrangler dev` refuses to run (new Function). A green test suite does not
* prove the Worker starts. This does -- it makes a real request to a real
* deployment.
*
* Usage:
* node scripts/smoke.mjs https://staging-ch40-cicd.example.workers.dev
* node scripts/smoke.mjs <url> --expect-version <version-id>
*/
const [, , base, ...rest] = process.argv;
if (!base) {
console.error("usage: smoke.mjs <base-url> [--expect-version <id>] [--expect-env <name>]");
process.exit(2);
}
const flag = (name) => {
const i = rest.indexOf(name);
return i === -1 ? undefined : rest[i + 1];
};
const expectVersion = flag("--expect-version");
const expectEnv = flag("--expect-env");
const RETRIES = 6;
const failures = [];
const check = (name, cond, detail) => {
if (cond) console.log(` ok ${name}`);
else {
console.log(` FAIL ${name}${detail ? ` -- ${detail}` : ""}`);
failures.push(name);
}
};
/** A fresh deployment can take a few seconds to answer. Retry, but bounded. */
async function getWithRetry(url) {
let last;
for (let i = 0; i < RETRIES; i++) {
try {
const res = await fetch(url, { headers: { "user-agent": "ch40-smoke" } });
if (res.status < 500) return res;
last = `status ${res.status}`;
} catch (e) {
last = String(e.message ?? e);
}
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
}
throw new Error(`gave up after ${RETRIES} attempts: ${last}`);
}
console.log(`smoke: ${base}`);
const health = await getWithRetry(new URL("/__health", base));
check("GET /__health returns 200", health.status === 200, `got ${health.status}`);
const body = await health.json().catch(() => null);
check("/__health returns JSON", body !== null);
check("/__health reports ok", body?.ok === true);
if (expectEnv) {
check(`env is ${expectEnv}`, body?.env === expectEnv, `got ${body?.env}`);
}
// The reason for the version_metadata binding: without it you cannot tell
// which half of a gradual deployment answered.
if (expectVersion) {
check(
`version is ${expectVersion}`,
body?.version?.id === expectVersion,
`got ${body?.version?.id}`,
);
}
const root = await getWithRetry(base);
check("GET / returns 200", root.status === 200, `got ${root.status}`);
// ch39: an uncaught exception records outcome "ok" in the trace, so the only
// reliable failure signal is the status code. Assert on it here.
check("GET / is not a 5xx", root.status < 500, `got ${root.status}`);
if (failures.length > 0) {
console.error(`\nsmoke FAILED: ${failures.length} check(s)`);
process.exit(1);
}
console.log("\nsmoke OK");.github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
pull_request:
concurrency:
# One deploy at a time per branch. Without this, two pushes race and the
# older build can win.
group: deploy-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
# ---------------------------------------------------------------------
# 1. Checks. No credentials -- runs on fork PRs too.
# ---------------------------------------------------------------------
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
# `wrangler types --check` exits 1 when the generated types no longer
# match wrangler.jsonc. Do NOT pipe it -- the exit code is what matters.
- name: types are in sync with wrangler.jsonc
run: npx wrangler types --check
- run: npx tsc --noEmit
- run: npm test --if-present
# Builds exactly what would be uploaded, without an API token.
- name: bundle budget
run: node scripts/bundle-budget.mjs --limit-kb 900
# ---------------------------------------------------------------------
# 2. PR preview. Uploads a version WITHOUT deploying it.
# ---------------------------------------------------------------------
preview:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with: { node-version: 24, cache: npm }
- run: npm ci
# `versions upload` creates a version and a preview URL but does not
# touch the active deployment. This is the whole point of the
# version/deployment split.
- id: upload
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: >-
versions upload
--preview-alias pr-${{ github.event.number }}
--tag ${{ github.sha }}
--message "PR #${{ github.event.number }}"
- name: smoke the preview alias
run: node scripts/smoke.mjs "https://pr-${{ github.event.number }}-ch40-cicd.${{ vars.WORKERS_SUBDOMAIN }}.workers.dev"
- uses: actions/github-script@v7
with:
script: |
const url = `https://pr-${{ github.event.number }}-ch40-cicd.${{ vars.WORKERS_SUBDOMAIN }}.workers.dev`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview: ${url}`,
});
# ---------------------------------------------------------------------
# 3. Staging. Full deploy, then smoke.
# ---------------------------------------------------------------------
staging:
if: github.ref == 'refs/heads/main'
needs: check
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with: { node-version: 24, cache: npm }
- run: npm ci
- uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
environment: staging
# --strict turns a silent overwrite of dashboard-side settings into
# a failure. In CI there is no prompt, so without it Wrangler would
# just clobber them.
command: deploy --strict
# Bulk secrets, applied additively. Omitted keys are NOT deleted.
secrets: |
SESSION_SECRET
UPSTREAM_TOKEN
env:
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
UPSTREAM_TOKEN: ${{ secrets.UPSTREAM_TOKEN }}
- run: node scripts/smoke.mjs "${{ vars.STAGING_URL }}" --expect-env staging
# ---------------------------------------------------------------------
# 4. Production, gradual. Upload -> 10% -> smoke -> 100%.
# ---------------------------------------------------------------------
production:
if: github.ref == 'refs/heads/main'
needs: staging
runs-on: ubuntu-latest
# A GitHub environment with a required reviewer turns this into a manual
# gate without any extra tooling.
environment: production
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with: { node-version: 24, cache: npm }
- run: npm ci
- id: upload
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
environment: production
command: versions upload --tag ${{ github.sha }} --message "${{ github.event.head_commit.message }}"
# Deploy by TAG, not by parsing a version id out of stdout. --version-tag
# accepts the same <tag>@<percentage> shorthand as version ids.
- name: shift 10% of traffic
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
environment: production
command: versions deploy --version-tag ${{ github.sha }}@10 --yes --message "canary 10%"
- name: soak
run: sleep 120
- run: node scripts/smoke.mjs "${{ vars.PRODUCTION_URL }}" --expect-env production
- name: promote to 100%
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
environment: production
command: versions deploy --version-tag ${{ github.sha }}@100 --yes --message "promote ${{ github.sha }}".github/workflows/rollback.yml
name: rollback
# A rollback you have to remember how to do is a rollback you will not do at
# 3am. Make it a button.
on:
workflow_dispatch:
inputs:
version_id:
description: "Version id to roll back to. Leave empty for the version before the current one."
required: false
reason:
description: "Why"
required: true
jobs:
rollback:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with: { node-version: 24, cache: npm }
- run: npm ci
- uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
environment: production
command: rollback ${{ inputs.version_id }} --message "${{ inputs.reason }}" --yes
- run: node scripts/smoke.mjs "${{ vars.PRODUCTION_URL }}" --expect-env production.gitignore
node_modules/
.wrangler/
worker-configuration.d.ts
dist/tsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "types": ["./worker-configuration.d.ts", "node"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
},
"include": ["src/**/*.ts", "worker-configuration.d.ts"]
}