跳到內容

CI/CD 與發佈策略

查證日期
驗證環境wrangler@4.118.0·cloudflare/wrangler-action@v4.0.0·compatibility_date: 2026-07-24
對應範例examples/ch40-cicd

第 38 章的結論是「綠燈的測試不保證能部署」,第 39 章的結論是「未捕捉的例外在 trace 裡是 ok」。這一章把這兩件事變成一條真的能擋住問題的 pipeline。


這是整章的地基:

是什麼指令
version一份「已上傳但不一定在跑」的程式碼 + 設定wrangler versions upload
deployment「現在哪個(或哪些)version 在接流量」wrangler versions deploy
兩件事一起做wrangler deploy

這個拆分就是漸進式發佈與快速回滾的全部基礎。 一旦你習慣「上傳」與「切流量」是兩件事,pipeline 的形狀就自然出來了。

三個實測到的硬限制:

  • 第一次建立 Worker 不能用 versions upload,會失敗。要先 wrangler deploy(或 C3)。
  • versions upload 不支援 Service Worker 語法,必須是 ES modules。
  • 只有最近 100 個 version 能拿來做 deployment 或 rollback。

Cloudflare 自己的 CI 叫 Workers Builds(接 GitHub / GitLab,push 就跑)。

實測到的額度(官方 limits 頁):

FreePaid
build 分鐘數3,000 / 月6,000 / 月(超出 $0.005/min)
併發 build16
build timeout20 分鐘20 分鐘
CPU / RAM / 磁碟2 vCPU / 8 GB / 20 GB4 vCPU / 8 GB / 20 GB
環境變數64 個,每個 5 KB同左

⚠️ 不要說 Workers Builds 已經 GA。 文件上沒有 beta 標籤,但我也找不到 GA 公告。準確的說法是:文件不再標它 beta。

它的幾個關鍵行為:

  • dashboard 上的 Worker 名稱必須和指定根目錄的 wrangler 設定裡的 name 一致,否則 build 失敗。
  • build 成功會存成一個 version;deploy command 設成 wrangler deploy 就會自動升成 Active Deployment,設成 npx wrangler versions upload 就只存不發。這是關掉自動部署的官方做法。
  • 不支援自架的 GitHub / GitLab,也不支援其他 Git 服務。
  • 非 production 分支的 build 預設是關的,要手動開「non-production branch builds」才會有 preview URL 與 PR 留言。
  • 注入的 build 變數:CIWORKERS_CIWORKERS_CI_BUILD_UUIDWORKERS_CI_COMMIT_SHAWORKERS_CI_BRANCH —— runtime 拿不到

monorepo:build watch paths 的兩個 bypass

Section titled “monorepo:build watch paths 的兩個 bypass”

第 26 章那個 pnpm workspace 在這裡要設兩層:每個 Worker 各自的 root directory,加上 build watch paths

預設是 includes ["*"]、excludes [],判斷順序是「先排除、再包含、有中就 build」。但有兩個會直接跳過整個路徑比對的情況:

“A push event contains 0 file changes… A push event contains 3000+ file changes or 20+ commits”

一次 merge 20 個 commit 進來,你所有的 Worker 都會重新 build。 大型 monorepo 要把這個算進 build 分鐘數。

需要下面任何一項時:多階段的 gate(staging → 人工核可 → production)漸進式流量切換跟既有的 CI 共用 cache / matrixfork PR 的無憑證檢查。本章的範例走這條路。


實測最新版 v4.0.0(tag v4 / v4.0 / v4.0.0 都指向同一個 commit,commit 日期 2026-05-12)。

⚠️ GitHub releases 頁面在我抓取時把日期渲染成 2024 年。以 git object 的日期為準。

v4 唯一的破壞性變更(CHANGELOG 4.0.0):預設安裝的 wrangler 從 v3 換成 v4。要留在 v3 就明寫 wranglerVersion: "3.90.0"

v3 那批仍然有效的破壞性變更:不再支援 wrangler v1、不再支援 Global API key + Email 認證、action 版本必須帶 v 前綴@v4 可以,@4.x.x 不行)。

action.yml 實測的完整 input:

apiToken, accountId, quiet, environment, workingDirectory, wranglerVersion,
secrets, preCommands, postCommands, command, vars, packageManager, gitHubToken

output:command-outputcommand-stderrdeployment-urlpages-deployment-alias-urlpages-deployment-idpages-environment。runtime 是 node24

🔴 vars 這個 input 存在於 action.yml,但 README 完全沒寫。 它和 secrets 一樣是換行分隔的環境變數名稱清單,差別是綁成 var 而不是 secret。又一次 schema 比文件寬。

secrets 的行為值得抄一遍原文:

“A string of environment variable names, separated by newlines. These will be bound to your Worker as Secrets and must match the names of environment variables declared in env of this workflow.”

也就是說 secret 名稱要在兩個地方各寫一次(with.secrets 列名字,env 給值)。3.14.1 起底層改用 secret bulk


方式上限刪除適合
wrangler secret bulk [file]一次 100 個JSON 裡把值設成 null 可刪;.env 格式不能刪一般情況
versions upload --secrets-file同上不能刪,「Applies additively… omitted secrets will not be deleted」和 version 綁在一起上傳
Secrets Store(帳號層)每帳號 100 個 secret、只能有一個 store、單一 secret ≤ 1024 bytesdashboard / API跨 Worker 共用

🔴 --secrets-file 是「加法」,不是「同步」。 你從檔案裡刪掉一個 key,線上那個 secret 不會被刪。想清空得另外 wrangler secret delete

同理,--keep-vars 的說明也很值得看清楚:預設情況下 wrangler 會先刪掉所有 var 再套用設定檔裡的,但**「secrets are never deleted by deployments」**。var 和 secret 的生命週期完全不同。

Secrets Store(open beta)的 binding 長這樣:

{
"secrets_store_secrets": [
{ "binding": "MY_SECRET", "store_id": "<STORE_ID>", "secret_name": "<NAME>" }
]
}

存取是非同步的:const key = await env.MY_SECRET.get();(和 env.FOO 直接讀不一樣,第 4 章的直覺在這裡不適用)。

⚠️ 本機開發拿不到 production 的 Secrets Store secret(dashboard / API / --remote 建的那些)。又一條進第 38 章那張表。

另外 Secrets Store 沒有 limits 頁,上面那些數字是從 manage-secrets 頁面掃出來的,引用前自己再確認一次


40.5 漸進式發佈:真正要小心的是 Durable Object

Section titled “40.5 漸進式發佈:真正要小心的是 Durable Object”
Terminal window
wrangler versions upload --tag "$GITHUB_SHA" --message "..."
wrangler versions deploy --version-tag "$GITHUB_SHA@10" --yes # 10% 流量
# 觀察…
wrangler versions deploy --version-tag "$GITHUB_SHA@100" --yes # 全量

💡 --version-tag 而不是去 stdout 裡剪 version id。 --version-tag 支援和 version id 一樣的 <tag>@<percentage> 簡寫,把 $GITHUB_SHA 當 tag 上傳,pipeline 就不需要解析輸出。

官方文件沒有明說這個數字。wrangler 的原始碼有:

"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)
}

錯誤訊息是 Too many versions selected. You can deploy at most 2 version(s) at a time. —— 這是伺服器端的限制,那個 flag 是隱藏的、標記為 experimental。不要設計三段式金絲雀。

百分比的精細度沒有文件。wrangler 客戶端用 parseFloat 收,只驗 0–100 與加總,小數是收得下的;伺服器端怎樣我無法驗證。不要宣稱可以用小數。

Durable Object:每個實例被釘在一個 version 上

Section titled “Durable Object:每個實例被釘在一個 version 上”

這是漸進式發佈最反直覺的部分,官方原文:

“To provide global uniqueness, only one version of each Durable Object can run at a time.”

“When you create a new gradual deployment for a Worker with Durable Objects, each Durable Object is assigned a Worker version based on the percentages you configured in your deployment. This version will not change until you create a new deployment.

三個保證:

  1. 同一個 deployment 期間,打到某個 DO 的請求永遠走同一個 version。
  2. 只要你用相同順序列出 version 並且只調高百分比,原本被分到那個 version 的 DO 不會被換回去
  3. DO 只在被分到不同 version 時才會 reset,所以照上面做的話每個 DO 最多只被 reset 一次。

還有兩條硬規則:

  • 一個 bundle 通常同時定義 DO class 和使用它的 Worker,所以你沒辦法把兩者分開部署。
  • 🔴 改動 DO class 生命週期的 version 根本不能用 versions upload 上傳exports 宣告式或舊的 migrations 陣列都算)。官方說法是「Durable Object lifecycle changes are atomic operations」,而且一旦部署,就再也不能回滾到那個變更之前的任何 version

實務規則:任何 create / delete / rename / transfer DO class 的變更,必須用 wrangler deploy 單獨發一次,不要和其他程式碼變更混在一起。 第 14–17 章那些 DO 設計決定,在這裡變成部署流程的約束。

官方明文警告:

“If a user’s initial request for / goes to Version A, they’ll receive HTML that references index-a1b2c3d4.js. However, when their browser then requests /assets/index-a1b2c3d4.js, that request might be routed to Version B, which only contains index-m3n4o5p6.js, resulting in a 404 error.”

解法是 version affinity:用 Transform Rule 設 Cloudflare-Workers-Version-Key header。

⚠️ Transform Rules 需要你自己的 zone 上的 route,*.workers.dev 上沒有。 而且「The platform hashes the key… you do not choose which version a key maps to」—— 你只能保證同一個 key 一致,不能指定它去哪個 version。

第 24、25 章那些 React Router / Astro 的部署,如果要漸進式發佈就必須先有自訂網域

Queue consumer 在漸進式發佈期間走哪個 version,官方文件完全沒寫。 Cron Triggers 與 Tail Workers 也沒寫。第 19、20 章的東西在這裡是空白 —— 不要猜,設計上就避免在漸進式發佈期間依賴 consumer 的版本一致性。

Pages 的漸進式發佈也沒有文件(Pages 有自己的 preview deployment 模型)。這是「沒寫」,不是「不支援」。


Terminal window
wrangler rollback [version-id] --message "reason" --yes

不給 version id 就回到「目前這個的前一個」。

官方原文最重要的一句:

“Resources connected to your Worker will not be changed during a rollback.” “Errors could occur if using code for a prior version if the structure of data has changed…”

回滾只換程式碼,不換資料。 你的 D1 schema、KV 裡的資料格式、DO storage 的形狀都會留在新的樣子。這代表 migration 必須是向後相容的 —— 第 12 章那個 D1 migration 的紀律在這裡收成。

三個會讓 rollback 直接被擋下來的情況:

  1. 只能回到最近 100 個 version。
  2. 兩個 version 之間發生過 Durable Object migration
  3. 目標 version 綁著一個已經不存在的 R2 bucket / KV namespace / queue

如果目前是兩個 version 的分流,rollback 會把它換成單一 version 的 deployment 並把 100% 流量導過去。


兩種:versioned(每個 version 自動有,需要 wrangler ≥ 3.74.0)與 aliased--preview-alias,需要 wrangler ≥ 4.21.0)。

格式:

<VERSION_PREFIX 或 ALIAS>-<WORKER_NAME>.<SUBDOMAIN>.workers.dev
Terminal window
wrangler versions upload --preview-alias pr-123
# → https://pr-123-my-worker.<subdomain>.workers.dev

規則(實測官方原文):

  • alias 只能在 upload 當下建立,事後不能加。
  • 只能小寫字母、數字、dash;必須以小寫字母開頭
  • alias + Worker 名稱(含中間的 dash)不能超過 63 字元(DNS label 限制)。
  • 只保留最近部署的 1000 個 alias,超過會刪掉最久沒用的。

開關:

{ "workers_dev": true, "preview_urls": true }
  • preview_urls 預設跟著 workers_dev
  • ⚠️ dashboard 上的切換會被下一次 wrangler deploy 覆蓋掉(如果設定檔不一致)。寫進 repo。

四個限制,其中兩個很痛:

  • 🔴 實作了 Durable Object 的 Worker 不會產生 preview URL。
  • 🔴 preview URL 看不到任何 log —— Workers Logs、wrangler tail、Logpush 全部沒有。第 39 章那一整套在這裡是關的。
  • Workers for Platforms 的 user Worker 目前也沒有(官方說是暫時的)。
  • 不能換成 workers.dev 以外的網域。

所以第 14–17 章那些用 DO 的東西,PR preview 這條路走不通,只能靠 staging 環境。


40.8 🔴 versions upload 不會套用 observability 設定

Section titled “40.8 🔴 versions upload 不會套用 observability 設定”

這一條在官方文件裡完全沒有,是我從 wrangler 的 bundle 裡讀出來的:

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
};
// …
log("No non-versioned settings to sync. Skipping...");
// … "Syncing non-versioned settings" / "Synced non-versioned settings:"
}

這四個是「非版本化設定」(non-versioned settings),在 versions deploy 的時候同步,不是跟著 version 走。

實務後果很直接:

你改了 observability 然後只跑 versions upload,設定不會生效。 要等到真的有一次 deployment。第 39 章那個「traces 抽樣率」的調整,在只上傳不部署的流程裡是靜默無效的。

(順帶一提,streaming_tail_consumers 又出現了 —— 第 39 章說過它在 schema 裡但文件查不到,這裡是第二個證據。)


40.9 --strict:把「安靜覆蓋」變成「失敗」

Section titled “40.9 --strict:把「安靜覆蓋」變成「失敗」”

wrangler deploywrangler versions upload 都有 --strict,但同一頁上的說明不一樣

deploy“the command will be more defensive and prevent deployments which could introduce potential issues. In particular, this mode prevents deployments if the deployment would potentially override remote settings in non-interactive environments.”

versions upload“Enables strict mode, which prevents uploads when there are conflicting remote changes.”

兩邊都沒有明確定義什麼算「conflicting remote change」。機制在 2025-11-21 的 changelog:wrangler deploy 會先顯示本地設定與 dashboard 設定的差異、詢問要不要更新本地設定檔,純新增的變更則不詢問直接放行。需要 wrangler ≥ 4.50.0。

CI 裡沒有互動介面,所以沒有提示,只會安靜覆蓋。--strict 就是把那個安靜覆蓋變成 build 失敗。(這句因果是我的推論,文件沒有這樣寫,但這是它唯一合理的用途。)


範例的 .github/workflows/deploy.yml 分成四個 job。

1. check —— 不需要憑證,fork PR 也能跑

Section titled “1. check —— 不需要憑證,fork PR 也能跑”
- name: types are in sync with wrangler.jsonc
run: npx wrangler types --check
- run: npx tsc --noEmit
- run: npm test --if-present
- name: bundle budget
run: node scripts/bundle-budget.mjs --limit-kb 900

wrangler types --check 實測行為:設定檔改了但沒重新產生型別 → exit 1;重新產生後 → exit 0。

⚠️ 不要 pipe 它。 npx wrangler types --check | tail -5 會讓 $? 變成 tail 的結果,永遠是 0。我第一次量的時候就踩到了。

bundle budget 用的是 wrangler deploy --dry-run --outdir dist不需要 API token,但會讀設定檔,所以壞掉的 wrangler.jsonc 在這一步就會失敗。範例的腳本量的是 gzip 後的大小 —— 平台限制是壓縮後的。

command: >-
versions upload
--preview-alias pr-${{ github.event.number }}
--tag ${{ github.sha }}

然後對 pr-<n>-<worker>.<subdomain>.workers.dev 跑 smoke,再把網址貼回 PR。

command: deploy --strict
secrets: |
SESSION_SECRET
UPSTREAM_TOKEN
env:
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
UPSTREAM_TOKEN: ${{ secrets.UPSTREAM_TOKEN }}

4. production —— upload → 10% → soak → smoke → 100%

Section titled “4. production —— upload → 10% → soak → smoke → 100%”

用 GitHub 的 environment: production 加 required reviewer,就有了人工核可 gate,不需要額外工具。

concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true

沒有這個,兩次 push 會賽跑,舊的 build 可能後到而覆蓋新的。

.github/workflows/rollback.ymlworkflow_dispatch,輸入 version id(可留空)與原因。

凌晨三點你不會記得指令。 把 rollback 做成一個按鈕,並且回滾之後也跑一次 smoke


範例的 scripts/smoke.mjs 有三個設計取自前兩章的實測:

1. 指數退避重試。 新部署要幾秒才會回應,但要有上限(範例是 6 次)。

2. 斷言 status code,不要斷言 outcome 第 39 章實測未捕捉例外在 trace 裡是 outcome: "ok"

check("GET / is not a 5xx", root.status < 500, `got ${root.status}`);

3. 用 version_metadata 分辨自己打到哪一半。

{ "version_metadata": { "binding": "CF_VERSION_METADATA" } }
if (url.pathname === "/__health") {
return Response.json({ ok: true, env: env.BUILD_ENV, version: env.CF_VERSION_METADATA });
}

本機實測回傳:

{"ok":true,"env":"local","version":{"id":"0653ce41-…","tag":"","timestamp":"2026-08-01T10:04:35.711Z"}}

沒有這個 binding,漸進式發佈期間你的 smoke test 無法知道自己打到 10% 還是 90% 那一邊 —— 它可能連續六次都打到舊版本然後回報「一切正常」。


40.12 🔴 wrangler types 會告訴你哪個 environment 少了 binding

Section titled “40.12 🔴 wrangler types 會告訴你哪個 environment 少了 binding”

這是意外量到的,但對 CI 很有用。

範例一開始只在頂層宣告 version_metadata,named environment 沒宣告。產生的型別:

interface __BaseEnv_Env {
BUILD_ENV: "staging" | "production" | "local";
CF_VERSION_METADATA?: WorkerVersionMetadata; // ← 注意這個問號
}
declare namespace Cloudflare {
interface StagingEnv { BUILD_ENV: "staging"; } // ← 完全沒有這個 binding
interface ProductionEnv { BUILD_ENV: "production"; }
}

只加到 staging

interface __BaseEnv_Env {
CF_VERSION_METADATA?: WorkerVersionMetadata; // ← 還是問號
}
interface StagingEnv { CF_VERSION_METADATA: WorkerVersionMetadata; ... } // ← 這裡是必要的
interface ProductionEnv { BUILD_ENV: "production"; } // ← 還是沒有

兩個都加之後,問號才消失。

規則:wrangler types 對所有 environment 取聯集,只要有任何一個 environment 缺這個 binding,base Env 裡它就是 optional。

這正是第 26 章那個「named environment 不會繼承頂層設定」的型別化版本(wrangler schema 對 tail_consumers 也明寫了同一件事:「This field is not automatically inherited from the top level environment, and so must be specified in every named environment」)。

CI 的用法:那個 ? 就是你的警報。 搭配 strict 模式,忘了在 production 宣告 binding 會在 tsc --noEmit 就爆掉,而不是部署之後才發現 env.FOO 是 undefined。


#結論影響
1version(上傳)與 deployment(切流量)是兩件事漸進式發佈與回滾的基礎
2第一次建立 Worker 不能用 versions upload;也不支援 Service Worker 語法要先 wrangler deploy
3只有最近 100 個 version 可用於 deployment / rollback
4Workers Builds:Free 3,000 / Paid 6,000 build 分鐘;併發 1 / 6;timeout 20 分
5不要說 Workers Builds 已 GA —— 沒有 beta 標籤,也沒有 GA 公告
6deploy command 設 wrangler versions upload 就是官方的「只 build 不部署」
7🔴 build watch paths 有兩個 bypass:0 個檔案變更,或 3000+ 檔案 / 20+ commit大 merge 會 build 全部 Worker
8build 變數(WORKERS_CI_*runtime 拿不到
9wrangler-action 最新 v4.0.0,commit 日期 2026-05-12(GitHub 頁面渲染的 2024 是錯的)
10v4 唯一破壞性變更:預設 wrangler 從 v3 換成 v4要留 v3 就 wranglerVersion: "3.90.0"
11action 版本必須帶 v 前綴(@v4 ✅,@4.x.x ❌)v3 起的規則
12🔴 vars input 存在於 action.yml,README 沒寫又一次實作比文件寬
13🔴 --secrets-file 是加法:「omitted secrets will not be deleted」刪 secret 要另外下指令
14secret bulk 一次 100 個;JSON 設 null 可刪,.env 格式不能刪
15--keep-vars 說明:預設會先刪光 var 再套用;但 secret 永遠不會被 deployment 刪掉var 與 secret 生命週期不同
16Secrets Store open beta;每帳號 100 個 secret、只能一個 store、單一 secret ≤ 1024 bytes沒有 limits 頁,數字要自己再確認
17Secrets Store 的存取是非同步await env.X.get()和一般 binding 不同
18本機拿不到 production 的 Secrets Store secret進第 38 章那張表
19🔴 一次最多 2 個 version(wrangler 原始碼 max-versions default 2,hidden、experimental)不要設計三段式金絲雀
20百分比精細度無文件;wrangler 客戶端用 parseFloat不要宣稱可用小數
21--version-tag <tag>@<pct>,不要去 stdout 剪 version id支援和 id 一樣的簡寫
22DO 在整個 deployment 期間被釘在同一個 version,只在被換 version 時 reset照相同順序、只調高百分比 → 每個 DO 最多 reset 一次
23🔴 改 DO class 生命週期的 version 根本不能 versions upload,而且一旦部署就再也回不去單獨發一次,不要混其他變更
24Workers with Assets 漸進式發佈會因為 asset 名稱不匹配而 404要用 version affinity
25🔴 version affinity 靠 Transform Rule,*.workers.dev 上沒有要漸進式發佈就得先有自訂網域
26Queue consumer / Cron / Tail Worker 在漸進式發佈期間的行為,官方完全沒寫不要猜,設計上避開
27Pages 的漸進式發佈沒有文件是「沒寫」不是「不支援」
28回滾只換程式碼,不換資料migration 必須向後相容(第 12 章)
29rollback 被擋的三種情況:超過 100 個 version、期間有 DO migration、綁的 R2/KV/queue 已不存在
30preview alias 只能在 upload 當下建立;限小寫字母開頭;alias+name ≤ 63 字元;只留最近 1000 個
31preview_urls 預設跟著 workers_devdashboard 的切換會被下次 deploy 覆蓋寫進 repo
32🔴 用了 Durable Object 的 Worker 不會有 preview URL第 14-17 章的東西沒有 PR preview
33🔴 preview URL 完全沒有 log(Workers Logs / wrangler tail / Logpush 都沒有)第 39 章那套在這裡是關的
34🔴 logpush / tail_consumers / streaming_tail_consumers / observability 是「非版本化設定」,在 versions deploy 時才同步只 upload 不 deploy,observability 改動不會生效(官方文件沒有這條)
35--strictdeployversions upload 的說明同頁不同字;需要 wrangler ≥ 4.50.0用途是把 CI 裡的安靜覆蓋變成失敗
36wrangler types --check:設定改了未重產 → exit 1;重產後 → exit 0⚠️ 不要 pipe,會吃掉 exit code
37wrangler deploy --dry-run --outdir 不需要 API token,但會驗設定檔fork PR 也能跑 bundle budget
38🔴 wrangler types 對所有 environment 取聯集:任一 environment 缺 binding,base Env 裡它就是 optional(?那個問號就是「你忘了在某個 env 宣告」的警報
39version_metadata binding 本機可用,回 { id, tag, timestamp }(本機 tag 是空字串)沒有它,smoke test 分不出漸進式發佈的兩邊

  1. 改一下 wrangler.jsonc 但不重新產生型別,跑 npx wrangler types --check 看 exit code。然後在後面接 | tail -3 再跑一次,確認 exit code 變成 0 —— 這就是為什麼 CI 裡不能 pipe。
  2. 把一個 binding 只加到 env.staging,看 worker-configuration.d.ts 裡 base Env 的那個 ?,再加到 production 看它消失。
  3. wrangler versions deploy a@33 b@33 c@34 觸發 Too many versions selected,記下完整訊息。
  4. 在一個有 Durable Object 的 Worker 上跑 versions upload,確認沒有 preview URL。
  5. 只改 observability.head_sampling_rate 然後跑 versions upload,用 wrangler versions view 確認設定沒有跟著走;再 versions deploy 一次,看 Syncing non-versioned settings
  6. scripts/smoke.mjs 的重試上限拿掉,想想它在一次壞掉的部署上會卡多久。

下一章:第 41 章 安全性與多租戶隔離 —— 把第 27 章的 auth、第 33 章的沙箱、以及本章的 secret 生命週期收攏成一份可以照著檢查的清單。