iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0

workflow 寫好了、測試也綠了。今天講剩下的一半:當它紅的時候,你要怎麼知道發生什麼事,以及幾個會讓你得到錯誤結論的陷阱。


陷阱一:判斷「CI 跑完了沒」的條件是失敗開放的

等 CI 完成,最直覺的寫法:

# 不要這樣寫
[ "$(gh pr checks $N --repo $R | grep -c pending)" = "0" ] && echo "all done"

想法是「沒有 pending 就是跑完了」。

問題是:gh 自己失敗時,輸出是空的,grep -c 回傳 0,於是條件成立。

網路抖一下、token 過期、API 限流、gh 的作用中帳號被別的東西切走——任何一種都會讓這個檢查回報「全部跑完了」。

我真的因為這個回報過一次錯誤的「全綠」。那次的直接原因是 gh 的作用中帳號在等待期間被切換,查詢開始 404,而我的迴圈把「查不到資料」讀成了「沒有東西在跑」。

用「某個東西不存在」當成功條件,就是把查詢失敗變成成功。

正確的寫法要求正面證據:

until gh pr checks $N --repo $R --json bucket \
      | jq -e 'length > 0 and all(.bucket != "pending")' >/dev/null; do
  sleep 30
done
  • length > 0 拒絕空結果
  • all(...) 要求每一項都到達終局狀態
  • jq -e 在條件為假或輸入無效時都回傳非零

三個條件缺一不可。而這個原則的名字是 fail-closed:不確定的時候,往「還沒完成」倒,而不是往「完成了」倒。

length > 0 還不夠,因為清單會長大

上面那段我自己用過之後才發現它仍然是失敗開放的,而且差一點讓一個沒跑完的版本上 PyPI。

情境:merge 之後等 main 的檢查全綠才發版。條件寫成 length > 0 and all(.status == "completed")。它回報了「全部完成、全部成功」,我去查的時候卻看到 test-arm64 (3.11) 還是 in_progress。

原因很簡單,而且很難在寫的時候想到:一個還沒被建立的 job 不可能是未完成的。 GitHub 的 check-runs 清單是隨著 job 被排入佇列而長大的,我的迴圈在清單只有 15 筆的時候就判定「這 15 筆都完成了」。

# 仍然是失敗開放的:清單不完整時也會成立
jq -e 'length > 0 and all(.status == "completed")'

# 要求完整:這個 commit 應該有 21 個檢查
jq -e 'length >= 21 and all(.status == "completed")'

length > 0 排除的是「完全沒有資料」,它不排除「資料還不完整」。而後者才是分散式系統的常態。

正面證據不只是「有東西」,是「該有的東西都到了」。

那次沒有誤發版純粹是運氣:迴圈退出的瞬間那個 job 剛好已經被建立成 in_progress,被後面另一道檢查攔下。晚幾秒就會發出去。

同一個錯誤的第三種樣子:問了會回答「有」的那個端點

同一天我還踩了兩次形狀完全相同的坑,值得並列:

我查的 消費者實際問的 結果
pypi.org/pypi/<pkg>/<ver>/json pypi.org/simple/<pkg>/(pip 讀這個) 宣稱「已發佈」,CI 的 pip install 找不到
check-runs length > 0 這個 commit 該有的 21 個檢查 差點發出沒跑完的版本
用眼睛看錄影判斷視窗放大了沒 IsZoomed(hwnd) 連下兩個錯結論

PyPI 那個特別值得記:JSON API 和 simple index 是兩條分開的傳播路徑。 /pypi/<pkg>/0.5.1/json 回 200 的時候,/simple/<pkg>/ 可能還只列到 0.5.0,而 pip 解析的是後者。我據此宣布發佈完成、推了三個依賴它的 repo,三個 CI 全部掛在 Could not find a version that satisfies the requirement。

三次的共同結構是:我問了一個會回答「有」的來源,但那不是決定結果的來源。 而這正好就是這個系列從第一章講到現在的東西——PrintWindow 回報成功但圖是全黑的、FindFirst 找不到但元素就在那裡、GetCurrentColumnHeaders 成功但集合是空的。我在自己的工作流程上把同一個錯誤犯了三遍。

驗證要問那個「說了算」的來源。 而它通常不是最方便查的那一個。


原生崩潰不會出現在結束碼裡

前面幾種假全綠都是條件寫錯。這一種不同:結束碼裡根本沒有那個資訊。

一次 run 印了七次

Windows fatal exception: access violation

然後以 211 passed, 5 skipped、結束碼 0 收尾。崩潰被 faulthandler 印出來之後執行就繼續了,pytest 既不算它失敗也不算它錯誤——而被它弄壞的兩支測試,把自己報成了 skipped。摘要行乾淨得看不出任何異常。

所以那份 log 是崩潰唯一存在的地方,而沒人讀的 log 不是檢查。要讓它變紅只能自己搜:

pytest tests/ -v -s 2>&1 | Tee-Object -FilePath pytest-output.txt
$failed = $LASTEXITCODE
if (Select-String -Path pytest-output.txt -Pattern 'Windows fatal exception|access violation') {
  Write-Host "::error::a native crash happened during the tests"
  exit 1
}
if ($failed -ne 0) { exit $failed }

這個守衛在上線第一次就抓到一個我以為已經修好的崩潰,而且抓了兩次——第二次是因為我只修了一半。它值得的地方不是它修了什麼,是它讓「綠燈」重新代表「沒事」。

陷阱二:pipeline 的結束碼是最後一段的

同一類的錯誤,換一個外觀:

pytest tests/ | tail -20 && echo "tests passed"

永遠會印出 tests passed,因為 && 看的是 tail 的結束碼,而 tail 幾乎不會失敗。

set -o pipefail          # 讓 pipeline 回傳第一個失敗的結束碼
pytest tests/ | tail -20

或直接檢查 ${PIPESTATUS[0]}。

這個專案的發佈流程也踩過一次:驗證發佈產物的簽章時寫了 gh attestation verify ... | head -12; echo "exit=$?",然後拿到 exit=0——那是 head 的結束碼,不是 gh 的。當時的輸出是空的。一個人看著一片空白的終端機不會說「驗證通過」;一個只讀結束碼的腳本會,而且差一點就回報了。

重做時改成分開檢查,才拿到真正的證據。

這兩個陷阱是同一件事:你檢查了一個代理指標,而不是你真正關心的事實。 這個主題從 Day 2 就開始了(nc -z 說埠通了,Day 20),到最後一章還在出現。


陷阱三:把逾時當成 flaky,然後加 retry

Day 16 講過這個,但它在 workflow 層級有一個具體的體現:

      - name: Test
        run: pytest tests/ --reruns 3      # 危險

自動重試會讓你的 CI 變綠,而且永久掩蓋掉一個真實的問題。

它適合的情境是:失敗真的是隨機的、無法消除的外部因素(例如某個第三方服務偶爾抖動)。它不適合的情境是:你還沒查清楚為什麼會失敗。

在你知道原因之前加 retry,等於是決定永遠不知道原因。

如果一定要用,至少讓重試是可見的——把重試次數印出來、讓它出現在報告裡。一個安靜的重試機制會讓「這個測試最近失敗率上升了」這件事完全不可觀測。


一個空的 artifact 目錄

if: always() 只保證「有東西就上傳」。有一種失敗連東西都沒有。

一次清場殺掉了 harness 自己的 console host(Day 19),行程 0.27 秒內死掉。事件時間軸 session_events.json 是從 __exit__ 寫的,__exit__ 沒跑到,目錄就是空的。三輪 CI,零證據,而自動化的除錯流程拿那零證據推出了兩個錯的結論。一個人在旁邊看,會看到終端機視窗閃一下就沒了;流程手上只有一個空目錄和一個寫著 in_progress 的欄位。

所以現在 Session.__enter__ 的第一件事是開 session_events.jsonl:一個事件一行,寫完就 flush,每行帶 wall clock、monotonic、pid、當下的 step。安靜的每一秒寫一拍 heartbeat。它存在的理由是分辨死了和卡住——CI 頁面上一個 in_progress 分不出這兩件事,時間軸最後一拍的時間分得出來。

相關原始碼:_open_journal / _heartbeat_loop

配套幾件事,每一件都在補一個坐在機器前的人本來就有、而無人看管的流程沒有的東西:

  • READ_THIS_FIRST.md,開檔時和每個 step 邊界重寫,內容從實際存在的檔案產生:該信哪個檔、session_events.json 缺席代表什麼(行程沒跑到 teardown——那是關於這次執行的事實,不是漏掉的產物)、截圖拍不到什麼(Day 4 那個把自己從擷取裡拿掉的視窗)。
  • 錄影先於清場。 殺行程、藏視窗、切桌面正是錄影該拍到的事,以前都在鏡頭外。
  • mp4 是 fragmented 的(frag_keyframe+empty_moov,GOP 一秒),行程沒關檔也能播,最多丟一秒。recording_anchor.json 說 wall clock 怎麼對到影片時間,以及一件以前沒寫在任何地方的事:影片只拍主螢幕,截圖拍整個虛擬桌面。
  • __exit__ 先 flush 時間軸,再 join 錄影執行緒、再做最後的 census。有 kill deadline 的時候,被砍掉的是最後一句。

這些不是設計出來就算了。在 VM 上把事故重演一次:child 進 Session(錄影開),4.5 秒後 TerminateProcess,不 unwind——

files = READ_THIS_FIRST.md, artifact_index.json, recording_anchor.json,
        session_events.jsonl, session_recording.mp4
jsonl: 9 行,最後一行是 heartbeat;video_recording_started 排在 session_start 之前
session_events.json: 缺席     READ_THIS_FIRST: State: **running** ... never reached teardown
mp4: 32974 bytes,可解 60 frames

對照組(同一支 child 跑完):closed、53 frames、沒有 heartbeat。正控負控同一次執行都要成立——只有負控等於沒驗。

看錄影,不要推論

有了錄影之後,還要真的去看。這一天自動化的除錯流程三次用推論代替看:「12 分鐘的 hang」(實測 0.27 秒)、「PyAV 的 wheel 壞了」(是 CTRL_C 落在 import av)、「Window.find 撿到上一個測試正在死的 dialog」(VM 上 20 次 0 次中)。全部被量測推翻。

第一個值得單獨記:一般人看一眼畫面絕對不會把它當成 12 分鐘的 hang,因為畫面上什麼都沒在跑。會這樣錯的是一個只讀狀態欄位、不看螢幕的流程。所以檢討要寫在自動化上——它缺的是「先看畫面」這一步——而不是寫成人也會犯的錯。這一章後面的每一個產物,都是把那一步做進工具裡。

有一支 arm64 測試紅了,失敗訊息是:

<Window hwnd=0x30254 pid=3396 class='' title=''>.is_visible  ->  False

class='' 讀起來像「一個沒有名字的視窗」。它其實是handle 已經失效——dialog 在 SW_SHOW 之前就被銷毀了。現在 __repr__ 對這種 handle 直接印 (destroyed: handle no longer valid);同一個問題的兩個答案,不該長得一樣。

而為什麼它被銷毀,log 說不出來:fixture 沒記 child 的 pid 和 hwnd,teardown 沒記 exit code。從全程錄影切 0–90 秒、每 3 秒一格,看到的是 OOBE 隱私頁蓋滿螢幕約 70 秒(Day 28)——但同一輪另一個 Python 版本也是這個畫面卻全綠,所以那不是差異點。

老實的結論是:這支 log 解釋不了這次紅燈,而這是 log 的缺口,不是運氣。 修的是能量到的東西——fixture 用 pid= 綁自己的 child、印 pid/hwnd、teardown 印 exit code 與 IsWindow。下次再紅,log 自己會說。

讀出來的第二個原因:Store 在 app 關閉那一刻換掉了它

另一輪 arm64 有兩支紅:notepad.exe 啟動後 30 秒沒有新視窗,以及 Calculator 找不到元素。discovery 的錯誤訊息已經列出放棄當下的 11 個可見視窗——Performance Options(pagefile 錯誤的下一頁)、runner 的 conhost、shell 視窗——沒有任何 Notepad。而那兩個雜物在綠的那一輪錄影裡也都在,不是差異點。

差異在錄影裡:紅的那輪,前一個測試的 Notepad 帶著「A new version of Notepad is available」的橫幅;綠的沒有。橫幅代表更新已下載、等 app 關閉時套用。前一個測試關掉 Notepad → Store 換包 → 下一個測試的啟動撞上換包中 → 沒有視窗。

所以 discovery 的 timeout 現在會說出目標是 packaged app。第一版按路徑判斷——命令解析到 WindowsApps 底下的 alias 才算——在 VM 上量到會漏:

which notepad.exe  = C:\WINDOWS\system32\notepad.exe      (327168 bytes)
Get-AppxPackage    = Microsoft.WindowsNotepad 11.2607.14.0 [Ok]

Windows 11 的 System32\notepad.exe 是個交棒給套件的 launcher。改成看「有沒有同名套件」:

notepad.exe resolved to C:\WINDOWS\system32\notepad.exe, and a packaged (Store) app of that
name is installed; on Windows 11 the executable hands off to the package. The Store applies a
pending update the moment the app closes, and a launch during that swap produces no window.
Installed: Microsoft.WindowsNotepad 11.2607.14.0 [Ok].

相關原始碼:_alias_note / _launch_target_note

把「看」做成機械動作:frames/

一個人拿到紅燈會做的事很固定:打開錄影,拉到失敗那一刻,看那一格。今天這一步全是手工——job log 的時間戳、step 結束的時間、片長回推影片起點、再用 ffmpeg 切格。在 recording_anchor.json 出現之前連起點都要用減法猜;出現之後也還是一個人(或一個代理)坐在那裡做算術。流程自己從來不看。

現在失敗的 session 在 teardown 自己做這件事。錄影停下來之後,用 anchor 把時間軸每個事件的 monotonic 換成影片毫秒,切出最後一個 step_failed 前後(−1000、−500、0、+500 ms)、片尾、以及其餘最近的事件,最多 8 格,放進 frames/。檔名就是答案:

frames/t001230ms_step-failed_submit.png
frames/t002900ms_tail_last-frame.png
frames/index.json

預算是明講的優先序,不是「切到滿為止」:失敗窗口先、片尾第二、其餘最後。裝不下的 整個丟掉並記在 index.json 的 dropped——一組被默默截斷的截圖,讀起來像完整的故事,比沒有更糟。READ_THIS_FIRST.md 會多一行說 frames/ 在。

測試斷言的是「挑到哪一格」,不是「有檔案」:合成一支影片,第 n 格是灰階 8n,然後檢查 t001230ms 那張的灰階值是第 12 格的。第一版就是這樣過的,然後 CI 八個 job 全紅——失敗的 session 有錄影、frames/ 是空的。兩個原因,都是量到的,不是猜的:

  • ContinuousRecorder.anchor() 在 stop() 之後回 None,而切格發生在停止之後。流程在最需要 anchor 的時候才去拿它。現在 session 在錄影開始時就把 anchor 留下來。
  • recorder 寫的是 fragmented mp4,解碼出來有 0.2 秒的 edit-list 偏移:pts 0 那一格在 t=0.2 s 出現。離線的合成影片沒用 recorder 的 movflags,所以沒抓到。現在幀時間相對 stream.start_time 算,並多一支用一模一樣 movflags 的測試——第 12 格就是第 12 格,不是被推了兩格的第 10 格。

第二輪剩一個 job 紅:arm64 3.11 的錄影只有兩秒、失敗在片尾附近,片尾那格和失敗窗口的 +0 那格是同一格,設計上同一格只寫一次——錯的是測試堅持要一張叫 tail 的檔案,不是程式。第三輪 20/20,八個 Windows job 的實測(真的失敗一個 session、真的產出 frames/)全過。三輪都是先讀 job log、再改;沒有一次是從症狀猜的。

同一個基準也套在等檔案上。人等一個檔案出現是看著目錄等;time.sleep(2) 是猜它兩秒後會在。expect_artifact(path, timeout) 等它存在、非空、大小一輪不再變,等不到就把目錄裡 實際有什麼列出來:留下的 .tmp、或什麼都沒有。負結果要說出它檢查了什麼。

相關原始碼:plan_marks / extract_frames、_extract_failure_frames、expect_artifact、合成影片的測試

讓失敗自己說話

比上面所有技巧更有用的,是讓失敗的當下就留下足夠的資訊。這是第一章那五天的全部意義,在 workflow 層級的體現只有三個字:

        if: always()

但還有幾件配套的小事:

把關鍵數字印在 log 裡,不要只留在 artifact 裡。 有些東西你希望不用下載檔案就看得到:

launch -> discovered: 17.18 s

一行輸出,省掉一次下載。artifact 適合放「可能需要的細節」,log 適合放「一定會想知道的摘要」。

Artifact 的名稱要能一眼分辨。 artifacts-windows-11-arm-py3.11 比 artifacts-3 有用得多。當八個 job 裡有兩個紅了,你要能直接點對的那一個。

保留期限要調。 GitHub 預設保留 90 天,對 CI 診斷來說太長了(而且佔配額)。GUI 測試的影片檔案不小,設成 7–14 天通常夠用:

        with:
          retention-days: 14

同樣的數量不是同樣的問題。 兩次 CI 都是 1 failed,都是 TextMismatchError。一個人會把兩張失敗截圖並排:一張前景是 explorer.exe 的 Start 選單,一張是一個兩秒後就消失的通知——兩個問題。流程比的是數量,數量一樣就當同一件事。

所以例外現在帶著簽名:類別名稱加上一組事實,FocusStealDetectedError[foreground_image=explorer.exe]、WindowDiscoveryTimeoutError[process_names=notepad.exe]。哪些事實可以進簽名是每個類別自己宣告的 allow-list(SIGNATURE_KEYS 與 signature),而 hwnd、pid、時間戳一律不准——一個每次都不同的整數會讓每個簽名獨一無二,跨 run 比對就沒了;有一個測試逐類別檢查這件事。前景在重試迴圈裡取樣(_foreground_facts),不是丟出時才看:set_focus 先點一下再等 2 秒、重試三次,到丟出時已經過了七秒,一個瞬間的通知早就不在了。TextMismatchError 也帶同一組事實,因為半途被搶焦點最常到的其實是它——那一下實體點擊通常把焦點搶回來,FocusSteal 那個分支根本不會進。step_failed、session_error、job summary、READ_THIS_FIRST.md 都印簽名。

順手補的一行:step_failed 現在也帶視窗普查的差分。那份「這一步期間出現又消失的視窗」一直有算,只掛在 step_ok 上——在唯一要緊的那條路徑上算完就丟。你自己動過的東西,要記下你以為的和你量到的。 清場藏視窗的那幾行以前只有動作,沒有紀錄。現在 kill_plan.json 的 interventions 每一筆都有 intended、observed、verified;verified: false 的意思是「視窗沒理你」——Start 選單的 CoreWindow 就是這樣,量過。結束時還回去的也同樣記一筆。一個人藏東西會回頭看一眼,流程以前沒有那一眼。

被啟動的子行程說了什麼,也要留下。 launched_NN.out / .err 在 artifact 目錄裡,逾時訊息會把非空的引出來(Day 17 的第六種)。

擋住你的那個東西,要把它說的話也記下來。 有一次 arm64 的 job 這樣失敗:

Window failed to appear within 30.0s (cmd=['notepad.exe'], pattern=None).
  Visible windows at the moment discovery gave up (11 total):
        class='#32770' pid=7392 title='System Properties'

視窗普查做了它該做的事——它指出了擋路的那一個。但 'System Properties' 沒有縮小任何範圍,而沒有人能去看一台已經不存在的 runner 的螢幕。

所以現在遇到 #32770 這類對話框,會把它子控制項的文字一起印出來:

        class='#32770' pid=3756 title='系統內容'
          Static: 完整電腦名稱:
          Edit: DESKTOP-BKG8K4E
          Button: 變更(C)...
          Button: 確定

讀法是 WM_GETTEXT 而不是 UIA:它是系統訊息,USER32 會幫你跨處理程序 marshal(Day 22 那條界線),而且不需要 COM——這段程式碼跑的時候,已經有別的東西壞掉了,這時候少一個相依就少一個失敗的理由。


順帶一提:把 action 釘在 SHA 上

昨天那份 workflow 裡所有 action 都是這樣寫的:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6

不是 @v6,是完整的 commit SHA,後面用註解標示這是哪個版本。

原因是 tag 是可以移動的。v6 這個標籤指向哪個 commit,是由那個 repo 的維護者決定的,而且隨時可以改。釘在 SHA 上,你執行的東西就是你當初審過的東西。

代價是升級變得麻煩——但這正是 Dependabot 該做的事,讓它自動開 PR 換 SHA,你在 PR 上審。

有一個相關的細節值得知道:有些 action 有多個進入點,必須一起升級。 例如 CodeQL 有 init、analyze、autobuild 三個,如果只升其中一個,版本會不一致而失敗。在 Dependabot 設定裡把它們分到同一個 group 就能一起處理。


一個關於相依固定的教訓

同樣的道理適用於建置工具。我曾經把 twine 用 hash 釘死,卻讓 hatchling 保持浮動——結果 hatchling 升級後產生了 metadata 2.5 的套件,而釘死的那版 twine 不認得,發佈直接失敗。

釘住一組相依裡的一半,比兩邊都不釘更糟。 兩邊都浮動時,它們至少會一起前進;釘住一邊,你就創造了一個會隨時間漂移的版本組合,而且是在你完全沒有改動的情況下壞掉。


skipif 是 fail-open 的

這一篇講的是「一個查詢失敗看起來像條件成立」。同樣的結構有一個更常見的版本,而它就在測試檔案的最上面:

requires_notepadpp = pytest.mark.skipif(
    not NPP.exists(), reason="Notepad++ is not installed on this machine"
)

我有八個測試這樣標著。而它們從來沒有在 CI 上執行過——CI 沒有裝 Notepad++,所以八個全部 skip,而摘要行寫的是「118 passed, 2 skipped」。那兩個 skip 沒有人看。

一組會 skip 的測試,在儀表板上跟一組會通過的測試長得完全一樣。

這跟 grep -c pending 回 0 是同一個病:綠燈可能是「測過了」,也可能是「根本沒測」,而兩者的外觀完全相同。

修法是讓「必須存在」變成可宣告的:

WINTEGRATE_REQUIRE_TARGET_APPS=1                     四個都必須存在
WINTEGRATE_REQUIRE_TARGET_APPS='WinMerge,Notepad++'  只有這些必須

沒設就照舊 skip(筆記型電腦上是對的預設),CI 設 1,缺任何一個就是失敗,而且訊息會列出所有找過的路徑。

而我在同一份 workflow 裡又寫了一次同樣的病:

choco install $pkg -y --no-progress
# choco 的結束碼跨套件不可靠,測試自己會大聲失敗
$global:LASTEXITCODE = 0

那句註解是我寫的,理由當時聽起來也對。然後 Chocolatey 的社群 feed 從 runner 連不上,安裝真的失敗了,而這一行把它藏起來——六秒之後以一個測試說「Notepad++ not available: not found at any of:」的樣子浮現,指向完全錯誤的地方。

現在那個步驟安裝完會檢查執行檔在不在,並印出它的版本。一個「安裝」步驟的後置條件就是「東西裝好了」,而那要真的去問。

版本要釘在兩個地方,而且要互相檢查

一個 release gate 必須是確定的。而這些測試斷言的東西包括:一個從畫面上取樣的顏色值、一個藏在視窗類別裡的框架版本、一組 automation id、一個分頁列上的分頁數量——全部都是對著某一個版本量出來的。

版本一浮動,任何一次上游發版都會讓 gate 變紅,而這邊什麼都沒錯。那不是 gate 該回報的東西。

所以釘在兩個地方:

  1. CI 的安裝步驟指定版本(一個含版本號的官方安裝檔 URL,或一個被 SHA-256 釘住的套件)
  2. 測試模組裡宣告 VERIFIED_VERSION,再加一個測試比對實際安裝的版本

兩個都要,因為只釘一邊的話它們會默默漂開——而漂開的那一天,你會以為自己在測 8.9.8,其實在測別的東西。

(一個小坑:版本要從 file version 的數值欄位讀,不要讀 FileVersion 字串。有一個應用程式自己寫的是 '3.13.1.'——尾巴一個點、沒有第四段。)

別人的下載站台不該讓你的 gate 變紅

上面說「釘住版本」,但釘住版本還不夠:那個 URL 還得能被下載到。

一個要在每次 push 都去四個不同廠商站台抓安裝檔的 release gate,只要其中任何一個今天狀況不好就會變紅——而那個紅燈跟你的函式庫一點關係都沒有。

四個標的裡有一個根本沒得選:Files 的每一個 GitHub release 都掛零個檔案,套件只存在於它自己 workflow 裡寫的那個 CDN,而那個 CDN 的 bot protection 會對 runner 的機房 IP 回一頁 "Just a moment..." 而不是那個 100 MB 的套件。

所以全部改成從自己的鏡像抓。但把別人的位元組搬進自己的帳號是一個供應鏈問題,不是解法——除非你檢查它們:

  • SHA-256,而且每一個雜湊都是在兩台不同機器上各下載一次算出來、兩邊一致的
  • 發行者的完整 Authenticode subject,整串比對而不是前綴比對——前綴比對 CN=Some Publisher 也會接受 CN=Some Publisher Ltd
  • 上游沒簽的就不要假裝有簽。 DB Browser 的 arm64 msi 上游根本沒有簽章,zip 也不可能有;那裡雜湊就是唯一的檢查,而步驟裡就這樣寫,不要暗示更多

那個 subject 裡的重音符號

第一版把 Notepad++ 的 subject 釘成 S=Ile-de-France,兩個 Notepad++ job 全部掛掉,錯誤是 unexpected signer 而字串看起來一模一樣。

真正的值是 S=Île-de-France。我是從一台不是 UTF-8 code page 的主控台把那串字複製下來的,重音在路上被吃掉了。

Authenticode subject 不是 ASCII。要從 runner 自己的 log 複製,不要從一個你不確定編碼的終端機複製。

skip 在 CI 裡也是 fail-open

前面說過 skipif 是 fail-open 的,這裡有一個更細的版本。

Files 是 MSIX,兩個版本不能同時安裝,所以 workflow 一次裝一版、再用 -k 選出屬於那一版的斷言。而「這個斷言屬於哪一版」的判斷如果寫成 pytest.skip,那麼選錯版本的那一刻,job 會綠著結束,什麼都沒有斷言。

所以那個 gate 在 WINTEGRATE_REQUIRE_UPSTREAM_BUILDS=1 的時候呼叫的是 pytest.fail 而不是 pytest.skip:在本機跳過是對的(本來就只能裝一版),在 CI 跳過不是。

同一個原則在 matrix 上也成立:一個 case 和 arch 組合起來選不到任何 job 的 dispatch,如果就這樣跑完,那是一次「什麼都沒測的綠燈」——那是這種 workflow 最糟的結果,所以 plan 那一步直接失敗。

守衛放錯位置,跟沒有守衛一樣——而修好之後還要驗它沒有到處觸發

(上面那個崩潰守衛也踩了同一顆:我拿它的比對字串去數 job log,結果配到 GitHub 把 run: 腳本回顯進 log 的我自己那段註解,於是數字比實際崩潰次數多。守衛讀的檔案跟你事後讀的 log 不是同一份。)

前面兩節都是「守衛不該在的地方 skip」。還有第三種形狀:守衛擋的不是真正會爆的那一行。

有個測試要真的產生一個被 DWM 隱藏的視窗,做法是開第二個虛擬桌面、把視窗留在原本那個桌面上。我這樣寫:

pyvda = pytest.importorskip("pyvda", reason="virtual desktop control needs pyvda")
try:
    scratch = pyvda.VirtualDesktop.create()
    scratch.go()
except Exception as exc:
    pytest.skip(f"virtual desktops are not usable here ({exc})")

看起來兩層都顧到了:套件不在就 skip,呼叫失敗也 skip。

x64 的三個 Python 版本全掛,arm64 四個全過。錯誤是:

NotImplementedError: The virtual desktop feature is only available on Windows 10 and later.
pyvda/__init__.py:50

windows-latest 是 Windows Server,那上面 pyvda 在自己的 module body 就拋。所以炸的是 import,不是後面任何一次呼叫——而 pytest.importorskip 只把 ImportError 轉成 skip,其他一律放行。traceback 直接指在 importorskip 那一行。

修法是把 import 搬進 try 裡面:

try:
    import pyvda
    original = pyvda.VirtualDesktop.current()
    scratch = pyvda.VirtualDesktop.create()
    scratch.go()
except Exception as exc:
    pytest.skip(f"virtual desktops are not usable on this host ({type(exc).__name__}: {exc})")

而這裡有第二半,比第一半重要

修好一個守衛之後,你會想「推上去看 CI 綠不綠」。綠了什麼都沒證明——因為

一個 skip 太積極的守衛,跟一個正常工作的守衛,在儀表板上長得一模一樣。

我在自己機器上驗了兩個方向才推:

  1. 用一個 import 就拋 NotImplementedError 的假 pyvda 蓋掉真的(PYTHONPATH 放前面)→ 測試 SKIPPED,而且訊息帶出例外型別;
  2. 用真的 pyvda 在 Windows 11 上 → 六個測試全部 PASSED。

第二項才是關鍵。只驗第一項的話,我可能做出一個在每個平台都靜靜跳過、從此再也沒量到任何東西的測試,而 CI 會一路全綠。

CI 上最後看到的分裂正是想要的樣子:

平台 那個測試 總計
x64(Windows Server) SKIPPED 150 passed, 5 skipped
arm64(Windows 11) PASSED 152 passed, 3 skipped

兩邊的 passed 差 2、skipped 差 2。差額本身就是「有人真的在跑那半邊」的證據,而一個到處 skip 的守衛會讓兩邊數字一樣。

另外一個順帶的教訓:這個 bug 只有 x64 會踩,而我的開發 VM 是 arm64。牽涉平台能力的東西,本機綠不代表過關——那半邊只有 CI 跑得到。

小結

陷阱 修法
用「不存在」當成功條件 要求正面證據:length > 0 and all(...)
cmd | tail && echo ok set -o pipefail 或看 PIPESTATUS
對還沒查清楚的失敗加 retry 先查清楚;真要重試就讓它可見
失敗時沒有產物 if: always()
Action 釘在可移動的 tag 上 釘 SHA,用 Dependabot 升級
只釘一半的建置相依 要嘛都釘,要嘛都不釘
每次 push 都去別人站台抓安裝檔 鏡像起來,然後用雜湊與簽章驗證它
相信自己鏡像的位元組 兩台機器各算一次雜湊;簽章 subject 整串比對
CI 裡用 skip 當版本判斷 在 CI 改成 fail;空的 matrix 也要失敗
length > 0 當完整性檢查 要求 length >= 預期數;清單會長大
查 JSON API 當「已發佈」 查 pip 真正讀的 simple index
用眼睛判斷 UI 狀態 問系統(IsZoomed),讓測試自己回報
事件時間軸從 __exit__ 寫 __enter__ 第一件事開 jsonl;每秒一拍分辨死了和卡住
錄影在清場之後才開 錄影先開;mp4 fragmented,被殺也能播
用 log 推論一個只在 CI 發生的事 切錄影的格,對著 job log 的時間;解釋不了就補 log
把 in_progress 的時長當成現象 人會先看畫面;流程要先看錄影,heartbeat 說最後活著是幾秒前
class='' title='' 說出來:(destroyed: handle no longer valid)
兩次 1 failed 當同一個問題 簽名:類別加上辨識這次失敗的事實;前景在重試迴圈裡取樣;hwnd/pid/時間戳不准進
死在 __enter__ 的 KeyboardInterrupt 探針差分:preflight 有答、現在沒答 → ConsoleHostEndedError,說階段、幾秒前、殺了誰
錄影要人手動拉到失敗那一刻 frames/:流程自己切 step_failed 前後與片尾,檔名寫時間與事件,裝不下的記為 dropped
等檔案用固定 sleep expect_artifact:等到存在且穩定;等不到就列出目錄裡實際有什麼
「沒有新視窗」只列視窗 也說出目標是不是 packaged app、裝了哪些版本
守衛擋在爆炸點之後 連 import 一起包進 try;importorskip 只擋 ImportError
改完守衛只看 CI 綠不綠 兩個方向都驗:該 skip 時 skip,該跑時真的跑

明天是最後一天:從黑箱到確定性——三十天工程實踐的體系總結、不可逾越的四大系統鐵律、一套落地策略,以及這套架構的可為與不可為。


上一篇
Day 28:GitHub Actions 上的 Windows GUI 完整工作流程
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影 共 29 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言