iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
AI Engineering

Backend 工程師的 Azure GenAI 實戰系列 第 25

Day 25:用 GitHub Actions 建立 CI/CD:每道防線都比它的名字窄

  • 分享至 

  • xImage
  •  

Day 24 收尾時,部署是一支 operator 手跑的 script:十一個 stage、每步讀回驗證、順序即契約。那十一個 stage 是首次建置——建 ACR、建 identity、發 role assignment、把 app 生出來。這篇搬進 GitHub Actions 的不是那一段,是目標建好之後每次改程式碼都要重跑的那一段:push 到 main 就跑測試、build image、等一個人核准、把新 image 換上去。對 Azure 的登入走 OIDC,沒有任何一把長效的 Azure credential。

建置那半仍然是 shell script,而且是 pipeline 的前提:ACR、container app、兩個身份、GitHub environment 都得先存在,workflow 才有東西可以更新。

但把綠燈接起來不是這篇的重點。這篇想讓你帶走的判準是:pipeline 上每道防線,實際保證的範圍都比它的名字窄——「所有 gate 綠了」不等於有人讀過 diff,「subject 綁定」管不到分支,「approval」擋不住核准前的那次寫入。知道每道防線窄在哪、把窄的部分寫進文件,才是你敢把部署交給 pipeline 的原因。

每一道防線都問同樣兩個問題:它實際檢查什麼?什麼繞得過它?最後把接上線與拆下線的順序契約走一遍。

部署主鏈與每道防線的邊界:push 到 main 後,python/site/image 三個 gate 在每次 push 到 main 與每個 PR 上跑(證明不了有人讀過 diff,上限是 fake CLI 的保真度);needs 三 job 全綠加上 main+DEPLOY_ENABLED 才進 environment: production 的 branch policy 與 required reviewer(擋不住核准前 image 已寫進 ACR,單人自我核准也不是雙人覆核);人核准後 deploy 先跑 freshness guard 確認 github.sha 仍是 main HEAD(答的是「還新鮮嗎」不是「review 過了嗎」)、再做 OIDC token 交換(subject 只帶 environment:production 不帶 ref,限 main 由 job if 與 branch policy 兩層各自提供)、以 digest 執行 az containerapp update(runningState 只當失敗偵測器),最後由 template image 讀回加上 /health 精確 body 判定部署完成(腳本不查 revision 的 active 或 provisioningState),而 image-pull 失敗那條路當時未測

一支 workflow,needs 就是 gate

原本規劃兩支 workflow:CI 一支、deploy 一支。真做下去第二支取消了——跨 workflow 你得自己發明「所有 gate 都綠了沒」的協調機制,而在單一 workflow 裡,這件事是一行宣告:

deploy:
  needs: [python, site, image]
  if: github.ref == 'refs/heads/main' && vars.DEPLOY_ENABLED == 'true'
  environment: production

四個 job。python 跑 ruff、mypy、pytest、behave、三個 schema drift check(OpenAPI/index-schema/audit-schema)、mermaid 語法 gate,再用 actionlint 檢查這支 workflow 自己;site build Astro 站;image build Docker image 並跑 boot smoke;deploy 等前三個全綠。

Trigger 是 pull_request: 加上 push:branches: [main],所以前三個 job 在每次 push 到 main每個 PR 上都跑,包括那些永遠碰不到 Azure 的分支開的 PR——這正是 gate 的意義:正確性標準不隨分支或部署開關改變。(側分支沒開 PR 就 push,什麼都不會觸發;那是 trigger 的形狀,不是 gate 的漏洞。)

needs: [python, site, image] 本身就是「所有 gate 綠」的約束:GitHub Actions 不會在三個 job 都回報成功之前啟動 deploy,任何一個失敗或被跳過,deploy 就跟著被跳過。約束是 job graph 的性質,不是疊在上面的另一層機制。

這道防線窄在哪?它證明的是「這些自動檢查綠了」,僅此而已。一個測試沒蓋到的 bug 照樣進 main;actionlint 抓得到打錯字的 needs.image.outputs.digest(否則 GitHub Actions 會靜默評估成空字串、一路流進 --image 參數),抓不到要真的跑起來才顯形的錯。連「測試綠」本身都有上限——上限是測試替身的保真度,這篇後面會用一個真 bug 演示。

permissions 也逐 job 宣告。一旦宣告,沒列的權限全部歸零,連 contents: read 都要重述;只有 imagedeploy 拿到 id-token: write,也就是整支 workflow 只有這兩個 job 鑄得出 OIDC token。pythonsite 拿不到 GitHub OIDC token,也沒有接進這條 pipeline 的任何 Azure credential。

這句話的邊界在哪:那是憑證面的結構性保證,不是網路隔離。這兩個 job 的 runner 照樣連得上網,也照樣打得到 Azure 的公開 endpoint——它們只是沒有任何東西可以用來通過驗證。

deploy 另掛一個 concurrency group(group: productioncancel-in-progress: false):同時最多一個部署在跑、最多一個排在後面,正在 az containerapp update 的 run 不會被下一個 push 從腳下取消。

兩個身份,沒有一把長效 Azure credential

deploy job 對 Azure 登入時,repo 裡沒有任何一把可以拿去換 Azure 存取權的東西——沒有 client secret、沒有 service principal 密碼、沒有下載下來的憑證。

azure/login 拿的是 GitHub 替這個 job 當場鑄出的 OIDC token,到 Entra 換成 Azure 的 token——而 Entra 只在 token 的 subject 與事先註冊的 federated credential 完全匹配時才肯換。真正的 credential 是那條信任關係,它住在 Entra,不住在 repo。

repo 裡確實有七個 GitHub repository secrets,但那七個是識別碼、不是 credential——最後一節專門講它們為什麼被放進 secrets。

身份有兩個,各自一個 role、一個 scope、一個 subject:

身份 Role Scope Subject
build AcrPush container registry repo:<owner>/<repo>:ref:refs/heads/main
deploy Container Apps Contributor 那一個 container app repo:<owner>/<repo>:environment:production

Scope 都釘到單一資源,不是 resource group 也不是 subscription——兩個窄身份存在的目的就是縮小爆炸半徑,scope 放寬等於自己把它還回去。

這道防線窄在哪?deploy 的 subject 不帶 ref。environment:production 這個字串說的是「這個 job 宣告了 production environment」,對觸發 run 的分支隻字未提。

把它讀成「subject 就是分支限制」,是這條 pipeline 最容易發生的誤讀。「只有 main 能部署」由兩層獨立的機制提供,兩層都在 subject 的上游,都不在 subject 裡面:

  1. deploy job 自己的 if: github.ref == 'refs/heads/main'——別的 ref 上的 run 連隊都排不進去,environment protection 根本不會被評估。
  2. production environment 的 deployment branch policy,只放行 main——它不依賴這支 workflow 寫對。

第二層才是真正要理解的那層,因為它守的正是第一層守不到的情況:一支宣告了 environment: production 卻沒有任何 ref 條件的 workflow——if 沒寫、被刪掉,或哪天多出一個 workflow_dispatch 入口。負面測試 A 測的就是這個形狀,也正因如此它需要一支獨立的臨時 workflow:在 ci.yml 自己的 if 底下,側分支的 run 早在碰到 branch policy 之前就被 skip 掉,要測的那層永遠到不了。

而 branch policy 又有自己的陷阱:GitHub 在 workflow 第一次引用一個還不存在的 environment 名字時,會自動建立一個沒有任何保護規則的 environment。「叫 production 的 environment 存在」證明不了它有被把關。所以 create-github-oidc.sh 設完 required reviewer 與 branch policy 後,把每個設定讀回來逐欄比對——名字不算數,設定值才算數。

負面測試直接驗了 subject 這一層(2026-08-20,臨時 workflow,測完連分支一起刪)。一個不宣告 environment: 的 job 拿 deploy 身份登入,GitHub 鑄出的是 ref 形式的 subject,Entra 當場拒絕:

##[error]AADSTS700213: No matching federated identity record found for presented
assertion subject 'repo:<owner>/<repo>:ref:refs/heads/day-25-negative-tests'.

注意拒絕發生在哪:token 交換,而且要分清楚是哪個 token。GitHub 該鑄的 OIDC assertion 鑄出來了,也送到 Entra 了——錯誤訊息裡那串 subject 就是它的內容。Entra 拒絕的是拿這張 assertion 換 Azure access token。 所以這個 job 手上有 GitHub 的 token、沒有 Azure 的 token,而 Azure 端的授權從頭到尾沒被評估過,後面的步驟直接跳過。

一個順路的陷阱,看起來像整理程式碼時會順手「修掉」的東西:ci.ymlaz acr login 刻意不帶 -g。不帶時,control-plane 查詢失敗會拋 ResourceNotFound,CLI 容忍它並退回 data-plane token 交換——AcrPush 涵蓋的正是這條路;帶了 -g,CLI 改走 registries/read,那是 AcrPush 沒有的權限,而且這種失敗不在容忍路徑上。加 -g 求工整會直接弄壞 push。

live 實測補了一個細節:AcrPush 單獨登入成功,而且只印 Login Succeeded,沒有任何警告——fallback 走了也毫無跡象,這就是那行「不要加 -g」的註解必須存在的原因。

順帶一筆:create-acr.sh 從這天起顯式釘 --role-assignment-mode rbac

Microsoft 已文件化 ABAC 模式的 registry(rbac-abac,其上 AcrPush 這類 classic role 不生效)並表明未來會改成預設(查核 2026-08,ACR ABAC repository permissions,ms.date 2025-12-11)。

預設是 Microsoft 的,不是你的——跟 Day 24 的「沒寫不等於預設」是同一族的教訓。

Approval 擋在哪裡——和它擋不到的三件事

deploy job 掛在 environment: production 上,這個 environment 設了 required reviewer:run 進到 deploy 之前停下來,等一個人按核准。核准前的 job 字面狀態是 status: waitingconclusion: nullsteps: []——一步都沒跑,連 OIDC token 都還沒得鑄。

這道防線窄在哪?三件事。

第一,核准前,image 已經寫進 ACR 了。image job 的 Azure 步驟只 gate 在「是 main」加「pipeline 已上膛」(DEPLOY_ENABLED,最後一節講它的操作紀律),approval 在三個 job 之後的 deploy 才出現。

所以只要 pipeline 上了膛、run 在 main 上、build 與 boot smoke 跟 ACR login、push 都成功,build 身份的 AcrPush 就真的被行使、image 真的落進 registry——在任何人核准任何事之前。

那幾個條件讓這次寫入是常態而非例外,而其中沒有一個是「核准」。沒上膛、不在 main、或 push 之前任何一步失敗的 run,什麼都不會寫。

這次寫入本身無害(沒有東西會去跑一個沒被 --image 點名的 image),但「approval 擋住所有 Azure 存取」這句話在這個設計下不成立,文件就照這樣寫。

被否決的替代方案值得記:單一身份、subject 只綁 environment:production,核准前連 token 都鑄不出來,殘留徹底消失。

代價是二選一——核准後才 build,那 ACR 裡的 bytes 就不再是 boot smoke 測過的那顆(Docker build 不保證逐位可重現);或者把 build 好的 image 用 artifact 扛過核准邊界,bytes 保住了,換來每次 run 的 save/load 開銷,以及一個同時能寫 image 又能動 live app 的 credential——比兩個窄身份中的任何一個都大的爆炸半徑。

這個專案收下殘留,換兩個窄身份加「測過的 bytes」,並把殘留寫進 docs/ci-cd.md

https://ithelp.ithome.com.tw/upload/images/20260825/20168288p8oKdbuZ6c.png
忍喵:「approval 之前什麼都不會發生」這句話,太多 pipeline 的文件都寫得出來。先去看你家 build job 的 if: 掛在哪一層,再決定敢不敢寫。

第二,單人自我核准不是雙人覆核。 這是單人 repo,核准的人就是開 PR、merge PR 的同一個人。這不是猜的——pending_deployments API 對同一個帳號回 current_user_can_approve: true,欄位為證。prevent_self_review 顯式送 false,記錄在案:留預設值等於把一個該寫下來的決定藏起來。approval 在這裡買到的是「部署前有一個明確的人為停頓點」,不是四眼原則。

第三,approval 花的是人的時間,而 main 不等人。 核准按下去的時候,這個 run 綁的 commit 可能已經不是 main 的 HEAD。所以 deployactions/checkout 之後、任何 Azure 登入之前的第一個檢查,就是 freshness guard:拿 github.sha 問 GitHub API「你還是 main 的 HEAD 嗎」,不是就失敗,查詢自己失敗也失敗(fail closed)。

過期的 run 不准變成綠色 no-op——事後看,綠色 no-op 和綠色部署長得一模一樣。這個 guard 答的是「這個 commit 還新鮮嗎」,不是「有人 review 過嗎」;review 早在它跑之前就結束了。

另一個負面測試驗的是 branch policy 這一層:從非 main 分支宣告 environment: production,那個 job 自行變成 failuresteps: [],annotation 寫明 Branch "day-25-negative-tests" is not allowed to deploy to production due to environment protection rules

多快?事後從 GitHub API 讀回那個 job 的時間戳:started_at 04:27:29Zcompleted_at 04:27:31Z約兩秒(2026-08-20 該次 run)。

關鍵觀測是 pending_deployments 是空的——分支不合規時,沒有任何東西被送到人面前等核准,拒絕是自動的,不經過 reviewer。這一項的證據強度跟上面兩項不同:它是 run 還活著時當下查到的,事後補不回來(run 結束後本來就沒有 pending,environment 也早拆了),所以它是操作者的 session 觀測,不是可獨立稽核的擷取。

兩個負面測試合起來才是完整的證據:一個拒絕發生在 Entra 的 token 交換,一個發生在 GitHub 的 environment protection——兩道防線活在兩個不同的系統裡,這也是為什麼兩個都得測,測一個代表不了另一個。

Digest,不是 tag

image job push 的時候打了一個 tag(sha-<commit-sha>),但那只是 push 的載具——docker push 需要一個 reference。跨過 job 邊界、真正被 deploy 拿去部署的,是 push 完立刻讀回來的 digest

FULL_DIGEST="$(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE")"
DIGEST="${FULL_DIGEST#*@}"

deploy<registry>.azurecr.io/azgenai-lab@<digest> 更新 app。digest 解析不出來就直接讓 push 步驟失敗,不退回 tag。

為什麼?這是整條 pipeline 的立足點:被核准的那一次 run 產出的 bytes,就是部署下去的 bytes。 digest 是內容的 hash,它指得到的物件恰好一個;tag 是可以被重新指向的指標。

如果部署走 tag,同一個 commit 的 re-run 會重新 build(Docker build 不保證逐位可重現)、把同名 tag 推上去靜默覆寫第一輪 boot smoke 驗過的那顆 image——之後任何按 tag 部署的動作,跑的都不再是那次核准所指向的 bytes,而且沒有任何錯誤會告訴你。

要說精確:被覆寫上去的 bytes 並非沒過 gate——re-run 自己的 image job 一樣得先 build、先 boot smoke 才推得上去。tag 做不到的是證明是哪一次 run 的。而人按下的那個核准,是給某一次 run 的產物,不是給那個名字之後裝著的任何東西。

digest 讓這件事在結構上不可能:交給 deploy 的 digest 是在 boot smoke 剛通過的同一個 job run 裡當場算出來的,不從任何後來的 run 可能覆寫的地方讀回。

剝掉 name@ 前綴、只留 sha256: 加 64 個 hex 字元跨界,還有第二個作用,跟下一節的 secrets 決策直接相關。GitHub 的 runner 會對每個 job output 的求值結果做子字串遮蔽比對,命中 secret 就把整個 output 丟棄——不是傳 ***,是丟棄(actions/runnerJobExtension.cs,釘 SHA 258d6c85…,查核 2026-08-20)。

完整 reference 含 ACR 名,而 ACR 名現在是 secret,整條 output 會被清空,deploy 端的 [ -z "$DIGEST" ] guard 接著拒絕部署。剝到純 digest,七個 secret 值在結構上不可能出現在裡面——安全來自剝除,不來自 digest 的來歷。

部署下去之後,「成功」由誰判定?這裡掛著這天最有教育價值的 bug。update-container-app.sh 原本 poll revision 的 runningState,只接受 Running。第一次 live 部署,job 紅了:

Revision '<app>--0000001' runningState is 'RunningAtMaxScale' after 30 attempts.

部署其實成功了——image 是要求的 digest、revision active、/health 回精確 body。

去查 SDK 也救不了你:那台機器上安裝的 containerapp CLI extension,自己的 RevisionRunningState enum 只有 RunningProcessingStoppedDegradedFailedUnknown——RunningAtMaxScale 不在裡面。修完重跑,第二輪部署又回了一個 Activating也不在 enum 裡

同一場 session、兩次部署、兩個 enum 外的值。這兩次觀測證明得了的是:那份 enum 沒辦法拿來當完整的成功允許清單——照它寫,這個 bug 一模一樣會發生。證明不了的是服務端的詞彙表到底有哪些字,更不能預言下一個沒見過的值什麼時候出現。但要證明「允許清單這個做法不可靠」,這樣就夠了:唯一能拿來訂清單的權威,已經被實測證明不完整。

https://ithelp.ithome.com.tw/upload/images/20260825/201682885d24nZBNde.png
忍喵:服務回的狀態值,連自家 SDK 的 enum 都沒收錄。你對照官方定義寫的允許清單,在 production 就是這樣死的。

修法是把檢查整個倒過來:就緒檢查是失敗偵測器,不是成功預言機。 已知的失敗態(FailedDegraded)立刻中止,Processing 續等,其餘一律「不是失敗的證據」。改回只收 Running 的變異讓 7 個測試變紅,防止哪天有人把它「修」回去。

那「成功」由誰判定?不是單一一項,是這個 poll 兩側的兩個檢查:app 的 template 讀回來掛的是不是要求的那個 image,以及 /health 回不回精確 body——再加上 poll 這邊沒看到已知的失敗態。清單就這三樣。 腳本沒有查 revision 的任何其他欄位(沒有 active、沒有 provisioningState),而 step 1 的 pre-mutation 快照是 rollback 用的資料,不是成功的判準。

這裡有一個這組讀回關不掉的缺口,值得寫下來:這個 app 跑 single revision mode。新 revision 若起不來(拉不到 image,或拉到了卻在 startup 崩),回應 /health 的會不會是舊 revision?那樣探測會拿到正確的 body,而結論是錯的。digest 讀回屬控制面,照樣會回報「你要求的就是這個」。就「部署成功了」這個判定而言,這是它踩在假設上、而不是踩在讀回上的地方。

寫這篇的時候我把它列成待查核,賭的是不會這麼巧。Day 29 兩種失敗模式各注入一次,兩次都三道檢查全過、腳本印出「Verified」並 exit 0。 舊 revision 還在服務,/health 回的是逐位元組正確的 body。那篇補上第四道檢查:latestReadyRevisionName,控制面自己對「就緒」的判定。

Secrets 還是 variables:變的是暴露面,不是分類

Workflow 讀的識別碼有八個:兩個 client id、tenant id、subscription id、ACR 名、resource group、container app 名,加一個 DEPLOY_ENABLED。前七個放 repository secrets,最後一個留 variable。這個配置是改過一次主意的結果,而兩層論證得分開看。

分類論證:這些值不是 secret,這個論證沒有變。 Microsoft 的 OIDC 教學說「for security reasons, we recommend using GitHub Secrets」(查核 2026-08,Authenticate to Azure by OIDC,ms.date 2024-07-01)。

但在 OIDC 的威脅模型裡這個理由站不住:真正的 credential 是 Entra 那條 federated trust relationship,拿到 AZURE_CLIENT_ID_DEPLOY 而沒有 subject 匹配的 token,什麼都換不到。client id、tenant id、resource 名,單獨都不授予任何存取。

變的是暴露面。 這個 repo 是公開的,public repo 的 Actions log 在保留窗內不需要 GitHub 帳號就能讀,而窗內被讀走的東西從那一刻起永遠不在你的控制裡。

保留窗預設 90 天、可設 1–90 天(查核 2026-08-20,Managing GitHub Actions settings)——這個數字圈住的是 GitHub 存多久,不是讀者留多久。

同時,這個系列在每一張截圖裡遮蔽的正是同一批值。同值、同威脅模型,log 裡印出來、截圖裡遮掉——不一致。搬進 secrets 不是承認它們是 credential,是讓「這批值不主動發布」這件事在每個表面上一致。variables 在 log 裡永遠不遮,secrets 會。

代價要明講。 gh 沒有任何指令能把 secret 的值讀回來——gh secret list 只回名字和時間戳。這個 repo 到處在用的「寫入後讀回比對」紀律,對 secrets 不成立:create-github-oidc.sh 第 6 步只能確認七個名字存在,證明不了值是剛剛送出去的那個。寫錯值(過期的 $AZ_ACR_NAME、環境裡帶進來的 typo)的第一個症狀,是第一個用到它的 workflow run 失敗,不是腳本當場報錯。這是一次真實的紀律降級,寫在 docs/ci-cd.md 裡,不是默默吞掉。

DEPLOY_ENABLED 留 variable 的理由跟秘密無關:ci.yml 在四個 if: 裡讀它,其中一個是 job 層級——而 jobs.<job_id>.if 的作用域只有 githubneedsvarsinputs,沒有 secrets 也沒有 env。它本來就是一個 boolean 開關、不是 secret 形狀的值,現在剛好落在唯一容得下它的機制旁邊。

出處:job 層 if: 的作用域見 Contexts(查核 2026-08-20);「secrets 不可直接用於 if:」見 Using secrets(查核 2026-08-19)。

接上線、拆下線:DEPLOY_ENABLED 與順序契約

這個系列的 Azure 資源 ephemeral-by-default,pipeline 卻常駐在 repo 裡——沒有開關的話,資源拆掉之後每個 push 到 main 都會紅,紅的原因跟程式碼毫無關係。DEPLOY_ENABLED 就是那個開關,而它的操作紀律是兩支 script 的順序契約。

前提:Day 24 的 app 已部署(deploy-container-app.sh)、ACR 在、gh 以 repo admin 登入、az 登入工作訂閱。接上線一次到位:

infra/scripts/create-github-oidc.sh

順序即契約,從記錄檔到上膛:兩個 app registration+service principal → 兩個 federated credential → 兩個 role assignment → 讀回驗證 → GitHub environment(reviewer+branch policy,設完逐欄讀回)→ 七個 secrets(存在性讀回)→ 最後一步DEPLOY_ENABLED=true。上膛是最後一個動作:前面任何一步沒驗乾淨,pipeline 就不准是活的。

之後 push 到 main,四個 job 跑起來,deploy 停在 approval,核准後全綠——判準是 run 綠 /health 回精確 body,不是 az containerapp update exit 0。

這支 script 的第一次 live 執行,就是「gate 綠的上限是測試替身的保真度」的演示。它在建立六個 Azure 物件之後當場中止:

ERROR: group or scope are not required when --all is used

az role assignment list 拒絕 --all--scope 併用。這條 bug 的來歷比它本身更值得記下來:Day 24 最嚴重的 finding 是反向的失敗——不帶 --all 時,這個指令預設只看 subscription scope,對 resource-scoped assignments 靜默回 0,讀回等於沒讀。

那成了一條專案規則「read-back 一律帶 --all」,寫進文件、進了每個 task 指示、還過了 mutation test。規則是對的,邊界沒跟著寫:--all 的適用情境是「你沒有指定 scope」;給了精確 --scope,那本身就是 scoped 查詢,真 CLI 直接拒收。

而測試用的 fake az 照單全收兩個旗標,於是連專門測這兩支腳本 lifecycle 的 regression 都綠——整套 unit suite 對著一個真 CLI 根本拒收的指令全數通過。

修法分三路:有 --scope 的 call site 拿掉 --all;沒有 scope 的 assignee-wide 讀回保留 --all(那裡它是 load-bearing 的);fake az 改成用真 CLI 的訊息拒絕這個組合。變異驗證:把 --all 放回 scoped 查詢,7 passed 變 3 failed。收斂成一句:一條從真 bug 學來的規則,套用時脫離它的邊界,就是下一個 bug——而且它會通過所有為前一個 bug 建立的防護。

拆下線是同一條契約的反向,delete-github-oidc.sh 六步:DEPLOY_ENABLED=false → 刪兩個 federated credential(只擋未來的 token 交換,所以敢放前面)→ drain check → 刪 role assignments 並以 --all 讀回歸零 → 刪 app registration 並 purge → 刪 environment、七個 secrets 與 variable,逐項讀回。

drain check 掃整個 repo 的非終態 run,找到就中止、不取消——殺別人的 run 是人的決定,不是腳本的。這次它真的攔了一次:擋下它的正是這個 session 自己 push bug 修正觸發的 CI run,照設計中止、回報視窗未截斷,等 run 結束重跑就過。

但要誠實:沒有 admission lock。 drain 讀空之後,新的 push 照樣進得來;單人 repo 靠的是「teardown 期間不要 push」這條流程紀律,不是技術保證,文件也不把它寫成保證。

之後才輪到 Day 24 的 delete-container-app.sh——OIDC 的 role assignments 指著 ACR 與 app 的 scope,趁資源還活著時讀回,比對著一個解析不了的 scope 推理簡單。

這天的誠實邊界

  • 兩個問題明確留著未解:等核准的 waiting run 算不算 concurrency group 的 pending(從頭到尾只有一個 run 在等,沒有機會觀測);single revision mode 的 app 在 image pull 失敗時的行為(operator-only 的破壞性測試沒有跑)。第二個直接影響上面那句「成功由讀回加 /health 判定」能不能算數——新 revision 壞掉時 /health 會不會由舊 revision 回答,沒有觀測就沒有答案。
  • runningState 的兩個值是觀測,不是規則。 證明得了的只有「那份安裝在機器上的 enum 不能當完整的成功清單」;服務端的完整詞彙表、以及下一個沒見過的值會不會出現,都不在這兩次觀測的射程內。session 當時的 extension 版本沒有保存下來1.3.0b4 是 2026-08-20 事後從同一台機器、同一個安裝路徑讀到的值,只證明得了事後的環境,證明不了 session 期間那台機器上就是這個版本(中間沒更新過這件事,我沒有證據)。evidence C5 記的就是這個限度。
  • Role assignment propagation 這輪又沒量到。 第一個 workflow run 沒有撞 authorization 失敗——那是「一次沒有失敗」,不是一次量測。Day 20 的 14 分 44 秒仍是本專案唯一的 propagation 實測值,create-github-oidc.sh 也因此不加沒量測依據的 sleep:第一個真實 run 就是 readiness probe。
  • 七個 secrets 的值寫入後不可驗證,只驗得了存在。這條紀律降級上面講過,這裡列進清單是因為它是常設狀態,不是一次性事件。
  • Fake-CLI 保真度只修了這次咬人的那一套。 github-oidc 的 fake az 現在會拒絕非法旗標組合,但 container-app 與 keyvault 的 role-assignment 假處理器完全不驗旗標、delete-apim.sh 零 fake 覆蓋——同類休眠缺口,列為已知債,動到那些腳本的旗標前先補守衛。
  • containerapp env delete 的完成訊號第二次不可靠,兩次還不同型:Day 24 是指令回來了但環境還在清單上,這次是 CLI 自己長輪詢的 GET 客戶端 ReadTimeout(read timeout=None 照樣逾時)。記錄、不修——目前覆蓋它的是操作者重跑(腳本幂等,重跑補完剩下的步驟)。順序即契約的另一面就在這:在順序中間中止,比整個失敗留下更多東西。
  • 負面測試各只跑了一次(2026-08-20):AADSTS700213 的訊息字面、約兩秒的自動 failure、空的 pending_deployments,都是單次觀測綁日期,不外推成 GitHub 與 Entra 的永久行為。三者的證據強度不一樣:時間戳與 annotation 事後可從 GitHub API 逐位讀回;pending_deployments 為空是當下的操作者觀測,run 結束後再查已經不是同一回事,補不了原始擷取。
  • 本篇標為實測的數字都釘 2026-08-20 與 japaneast。版本要分四層查,「文章有沒有印出來」不是判準。
  • 第一層,workflow 裡以版本+checksum 直接釘死的獨立工具actionlint 1.7.12(tarball+sha256)、mermaid-cli 11.16.0——後者釘的是 CLI 版本,它底下的 renderer、瀏覽器與字型另有既存的可重現性限制。
  • 第二層,lockfile 保存、由 locked install 取用的依賴閉包:workflow 跑 uv sync --lockednpm ci,實際版本由 uv.locksite/package-lock.json 決定。
  • 那份閉包裡有 ruff 0.15.20、mypy 2.2.0、pytest 9.1.1、behave 1.3.3、FastAPI 0.139.0、uvicorn 0.50.2、Astro 5.18.2,Python minor 由 .python-version 釘為 3.13。文章沒印,但 repo 查得回來。
  • 第三層,repo 只宣告 mutable major ref、那次 run 解析到的 SHA 沒保存actions/checkout@v4azure/login@v2 這些。ref 查得到,實際跑的 commit 查不到,所以它們算不上已知版本。
  • 第四層,完全沒保存:runner image,以及 containerapp CLI extension 的當時版本(見上一條)。
  • 所以判準是:按層去 workflow、lockfile、擷取紀錄裡查;四層都沒保存,才算未知。 GitHub 與 Microsoft 的文件主張各自標了查核年月與來源頁。

下一篇

這條 pipeline 自己的每一步現在都有防線,但它腳下的一切——ACR、container app、身份、environment——全是 shell script 建的:create-acr.shdeploy-container-app.shcreate-github-oidc.sh,一支一支跑、一步一步讀回。Day 26 談這個選擇本身:為什麼系列第一版用 CLI scripts 而不是 Bicep、什麼訊號出現時該轉、scripts 與 IaC 各自把什麼交給你、把什麼藏起來。

完整程式碼與文件在 day-25 tag。這個 tag 上的 CI 是三個常駐 gate(pythonsiteimage)success、deploy skipped——teardown 之後 DEPLOY_ENABLED 沒開,它照設計不進場。

四個 job 全 success 的是 2026-08-20 那次真的部署的 run(commit 26f2cbb):

用到的 Azure 服務

  • Azure Container Apps(Consumption;app 與 environment 皆 ephemeral,已拆)
  • Azure Container Registry(Basic,per-run 唯一名,已拆)
  • Log Analytics(顯式建立,已拆)
  • Microsoft Entra ID(OIDC 與 auth 的 app registrations 皆 ephemeral,已 delete+purge)
  • Azure OpenAI in Microsoft Foundry(常駐 chat-mini

Session 結束時資源群組只剩常駐的 Azure OpenAI 帳號;GitHub 側 secrets、variables、environment 也都刪掉了。

這裡的「零殘留」要說清楚範圍。正式的 --verify-teardown 對 secret 清單回的是 UNVERIFIABLE——第一次 run 中止在記下 GH_SECRETS_WRITTEN 之前,驗證器因此不知道該去點名哪幾個,就拒絕宣告「已清乾淨」並保留紀錄檔。它 fail-closed 得完全正確。

補上的是另一種證據:gh secret listgh variable listaz ad app list 等清單查詢回來都是空的。 所以成立的說法是「文中列出的這幾組查詢都查不到東西」,不是「驗證器逐一點名讀回了每一把 secret」。

pipeline 現在處於「安全的不上膛」狀態,重新上膛就是再跑一次 create-github-oidc.sh


本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。


上一篇
Day 24:部署到 Azure Container Apps:「沒寫」不等於「預設」
下一篇
Day 26:CLI scripts first,Bicep later:轉換是兩個軸,不是一個開關
系列文
Backend 工程師的 Azure GenAI 實戰35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言