跳到內容

ch40-cicd

取得並執行

這個範例可以獨立 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 3am
scripts/smoke.mjs post-deploy smoke test
scripts/bundle-budget.mjs gzipped bundle budget, no API token needed
src/index.ts /__health reporting env + version id
Terminal window
npm install
npm run dev
node scripts/smoke.mjs http://localhost:8787 --expect-env local
node scripts/bundle-budget.mjs --limit-kb 900
StepCatchesComes from
wrangler types --checkconfig changed, types not regeneratedthis chapter
tsc --noEmita binding missing from one environment (see below)this chapter
bundle-budget.mjssize regressions, before upload, without credentialsthis chapter
smoke.mjs after every deploycode that passes tests but cannot run (ch38’s new Function)ch38
smoke asserting on status codeuncaught exceptions record outcome: "ok"ch39
version_metadata bindingwhich half of a gradual deployment answeredthis 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 here
interface ProductionEnv { BUILD_ENV: "production"; } // still absent

Add 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 1
wrangler types, then --check -> exit 0

npx 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.)

{ "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.

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-file is additive. Removing a key from the file does not delete the secret.
  • secret bulk takes up to 100 per request; a JSON null deletes, .env format cannot delete.
  • --keep-vars off (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.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"]
}