workflow 寫好了、測試也綠了。今天講剩下的一半:當它紅的時候,你要怎麼知道發生什麼事,以及幾個會讓你得到錯誤結論的陷阱。
等 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 }
這個守衛在上線第一次就抓到一個我以為已經修好的崩潰,而且抓了兩次——第二次是因為我只修了一半。它值得的地方不是它修了什麼,是它讓「綠燈」重新代表「沒事」。
同一類的錯誤,換一個外觀:
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),到最後一章還在出現。
Day 16 講過這個,但它在 workflow 層級有一個具體的體現:
- name: Test
run: pytest tests/ --reruns 3 # 危險
自動重試會讓你的 CI 變綠,而且永久掩蓋掉一個真實的問題。
它適合的情境是:失敗真的是隨機的、無法消除的外部因素(例如某個第三方服務偶爾抖動)。它不適合的情境是:你還沒查清楚為什麼會失敗。
在你知道原因之前加 retry,等於是決定永遠不知道原因。
如果一定要用,至少讓重試是可見的——把重試次數印出來、讓它出現在報告裡。一個安靜的重試機制會讓「這個測試最近失敗率上升了」這件事完全不可觀測。
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 分不出這兩件事,時間軸最後一拍的時間分得出來。
配套幾件事,每一件都在補一個坐在機器前的人本來就有、而無人看管的流程沒有的東西:
READ_THIS_FIRST.md,開檔時和每個 step 邊界重寫,內容從實際存在的檔案產生:該信哪個檔、session_events.json 缺席代表什麼(行程沒跑到 teardown——那是關於這次執行的事實,不是漏掉的產物)、截圖拍不到什麼(Day 4 那個把自己從擷取裡拿掉的視窗)。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 自己會說。
另一輪 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].
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 留下來。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——這段程式碼跑的時候,已經有別的東西壞掉了,這時候少一個相依就少一個失敗的理由。
昨天那份 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 該回報的東西。
所以釘在兩個地方:
VERIFIED_VERSION,再加一個測試比對實際安裝的版本兩個都要,因為只釘一邊的話它們會默默漂開——而漂開的那一天,你會以為自己在測 8.9.8,其實在測別的東西。
(一個小坑:版本要從 file version 的數值欄位讀,不要讀 FileVersion 字串。有一個應用程式自己寫的是 '3.13.1.'——尾巴一個點、沒有第四段。)
上面說「釘住版本」,但釘住版本還不夠:那個 URL 還得能被下載到。
一個要在每次 push 都去四個不同廠商站台抓安裝檔的 release gate,只要其中任何一個今天狀況不好就會變紅——而那個紅燈跟你的函式庫一點關係都沒有。
四個標的裡有一個根本沒得選:Files 的每一個 GitHub release 都掛零個檔案,套件只存在於它自己 workflow 裡寫的那個 CDN,而那個 CDN 的 bot protection 會對 runner 的機房 IP 回一頁 "Just a moment..." 而不是那個 100 MB 的套件。
所以全部改成從自己的鏡像抓。但把別人的位元組搬進自己的帳號是一個供應鏈問題,不是解法——除非你檢查它們:
CN=Some Publisher 也會接受 CN=Some Publisher Ltd
第一版把 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 太積極的守衛,跟一個正常工作的守衛,在儀表板上長得一模一樣。
我在自己機器上驗了兩個方向才推:
import 就拋 NotImplementedError 的假 pyvda 蓋掉真的(PYTHONPATH 放前面)→ 測試 SKIPPED,而且訊息帶出例外型別;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,該跑時真的跑 |
明天是最後一天:從黑箱到確定性——三十天工程實踐的體系總結、不可逾越的四大系統鐵律、一套落地策略,以及這套架構的可為與不可為。