iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Software Development

Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影系列 第 28 篇

Day 28:GitHub Actions 上的 Windows GUI 完整工作流程

  • 分享至 

  • xImage
  •  

最後一章。前面 27 天講的是「為什麼會壞」,這三天講「所以檔案要怎麼寫」。

今天把整個 ci.yml 逐段拆開——每一段都對應到前面某一天的問題。

完整工作流程設定檔:.github/workflows/ci.yml


觸發條件與權限

name: CI UI Automation Tests

on:
  push:
    branches: [ main ]
  # Every pull request, not just those targeting main: a stacked PR based on
  # another feature branch would otherwise get no CI at all, and could only be
  # verified after its base was merged.
  pull_request:

# The workflow only reads the repository; nothing here needs a writable token.
permissions:
  contents: read

兩個決定:

pull_request: 不加分支篩選。 如果寫成 pull_request: branches: [main],那麼一個基於其他功能分支的 PR(stacked PR)完全不會跑 CI——你只能等 base 合併之後才驗證得到它。這種疊起來的 PR 在拆分大工作時很常見。

permissions: contents: read。 預設的 GITHUB_TOKEN 權限比你需要的多。明確宣告成唯讀,就算 workflow 裡執行到了不該執行的東西,它也寫不了任何東西。


先跑 lint,再跑測試

jobs:
  lint:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
        with:
          persist-credentials: false
      ...
      - name: Run Ruff Lint & Format Check
        run: |
          ruff check src/ tests/
          ruff format --check src/ tests/

  test-x64:
    needs: lint          # ← 關鍵

needs: lint 讓所有測試 job 等 lint 過了才開始。

為什麼值得:GUI 測試很貴(八個 job × 2–3 分鐘)。如果只是少一個空行,沒必要花那些時間才告訴你。lint 是 20 秒。

兩個都要跑:ruff check 和 ruff format --check 檢查的是不同的東西。我自己吃過虧——本機只跑了 check,推上去被 format --check 擋下來。

persist-credentials: false:checkout 預設會把 token 寫進 .git/config,之後任何一個步驟都讀得到。除非你真的要在 workflow 裡 push,否則關掉。


Matrix:兩種架構 × 四個 Python 版本

  test-x64:
    runs-on: windows-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.11", "3.12", "3.13", "3.14"]

  test-arm64:
    runs-on: windows-11-arm
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.11", "3.12", "3.13", "3.14"]

windows-11-arm 是 GitHub 提供的 ARM64 runner,公開 repo 免費。這是這整個系列能成立的前提——沒有它,你要驗證 ARM64 就得自己準備機器。

注意兩種 runner 的作業系統不一樣:

Runner 作業系統 env.is_desktop
windows-latest Windows Server False
windows-11-arm Windows 11 Client True

這個差別會讓同一份程式碼走不同分支(Day 2 那個虛擬桌面的例子)。在兩種 runner 上跑,不只是在測兩種 CPU,是在測兩種 Windows。

fail-fast: false 很重要。預設值是 true,一個 job 失敗會取消其他所有 job——而你正需要那些資訊:這個問題是只在 ARM64 發生?只在 3.11 發生?還是全部都壞?把它們全部跑完,才看得出模式。


ARM64 runner 的清場:WSL 每 30 秒彈一次視窗

這一段是整份檔案裡最醜、也最有故事的:

      - name: Install WSL on ARM Runner (Stop 30s probe popups)
        shell: pwsh
        run: |
          # 1. Update WSL stub so the provision daemon stops spawning terminal popups every 30s
          $p = Start-Process -FilePath wsl.exe -ArgumentList '--update','--confirm' -NoNewWindow -PassThru
          if (-not $p.WaitForExit(60000)) { $p.Kill(); Write-Host 'wsl --update timed out' }

windows-11-arm 的映像上有一個 WSL 的 stub,而某個背景服務會每 30 秒嘗試一次、每次都彈出一個終端機視窗。

想想這對 GUI 測試的意義:你的測試每 30 秒就會被一個視窗搶走焦點(Day 19)。而且它不是一次性的干擾——它是週期性的,所以任何超過 30 秒的測試都會中獎。這就是那種會產生「隨機失敗」的東西。

解法是把 WSL 真的裝起來,讓那個 daemon 不再重試。後面還有幾段配套:

          # 3. Disable Microsoft Edge First Run Experience via Registry
          Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Edge" `
            -Name "HideFirstRunExperience" -Value 1 -Type DWord -Force

          # 4. Sweep existing background popups
          Get-Process -Name "wsl","wslhost","WindowsTerminal","msedge","msedgewebview2" `
            -ErrorAction SilentlyContinue | Stop-Process -Force

Edge 的首次執行畫面用登錄檔政策關掉(比殺處理程序可靠,因為它根本不會出現),已經開著的則掃掉。

最後那行 $global:LASTEXITCODE = 0 也是必要的——這些清理指令的退出碼不代表任何有意義的失敗,不重設的話整個步驟會紅。

而同一個映像上還有一個更嚴重的:Shell_OOBEProxy(一個 Windows.UI.Core.CoreWindow,標題 Microsoft account,rect 蓋滿整個螢幕)在新開的 runner 上握著前景。WSL 那個是週期性搶焦點,這個是從頭到尾蓋著——底下的視窗收不到任何輸入,連滑鼠點擊都不行,WindowFromPoint 回來的控制項 id 是 0。所以清場還要加一步,把握著前景的 CoreWindow 關掉:

hwnd = get_foreground_window()
if get_window_class(hwnd) == 'Windows.UI.Core.CoreWindow':
    user32.SendMessageW(hwnd, WM_CLOSE, 0, 0)

這一段是所有「環境特定的雜訊」的集中地。 我的建議是把它獨立成一個明確命名的步驟,而不是散在各處——因為這種東西會隨 runner 映像更新而變,你需要一個明確的地方去維護它。

而「一個步驟」還不夠。我有四份 inline 複本,後來發現它們已經漂開:一份在 x64 上跑 WSL 安裝、兩份漏了 OOBE 的 policy、Edge 的 policy 寫到不同的 key。現在是一個檔案(quiet-runner.ps1),composite action 用 ${{ github.action_path }} 叫它,repo 自己的 workflow 用相對路徑叫它。一個檔案沒辦法用手抄,所以不會漂。

它做的每件事都是「壓制」或「量測」,而且全部是對一台沒有人擁有的機器做的:改 HKLM policy、改系統層級的 ErrorMode、殺瀏覽器。所以第一行是一個 gate:

$hosted = ($env:GITHUB_ACTIONS -eq 'true') -and ($env:RUNNER_ENVIRONMENT -eq 'github-hosted')
Write-Host "GITHUB_ACTIONS=$($env:GITHUB_ACTIONS) RUNNER_ENVIRONMENT=$($env:RUNNER_ENVIRONMENT) -> hosted=$hosted"
if (-not $hosted) { Write-Host 'Not a GitHub-hosted runner; leaving the machine alone.'; exit 0 }

self-hosted runner 是某個人的電腦,整個檔案在那上面是 no-op,而且會說。

一個被 if: 跳過的步驟,在 log 裡什麼都不留

這個 gate 原本還有第二層:composite action 裡那一步掛著 if: runner.arch == 'ARM64',從 action 建立那天就在。x64 上整段不執行——0 ms、零輸出。一個人看著那台 runner 的桌面,會直接看到 Windows Terminal 還在前景;流程這邊,一次全綠的整合驗證裡沒有任何東西指出這件事,要讀 log 才發現:沒有 hosted= 那行、沒有 ErrorMode、沒有對話框普查。綠燈和「什麼都沒做」在 log 上長得一樣。

拿掉那個 if:。gate 放在腳本裡,至少永遠會留一行說它為什麼沒做。

該關的東西,要在第一個測試之前關

Shell_OOBEProxy 那一段還有一個順序問題,任何人坐在那台 runner 前面第一秒就會看到,而流程跑了一個星期都沒有看:關掉那個 CoreWindow 的程式碼一直都在——但它住在 Session.__enter__ 裡,所以第一個開 Session 的測試才會去關它。全程錄影切格之後才看見:arm64 runner 上那頁「Choose privacy settings for your device」從第 0 秒蓋到約第 70 秒,蓋住了前面四十個不開 Session 的測試。

這不是退化,是單元測試的 job 從來沒有「開始前看一眼桌面」這一步。現在 conftest 有一個 session-scoped 的 autouse fixture,排在全程錄影的 fixture 之後(所以關 OOBE 的動作在鏡頭裡),第一個測試前執行,並把它看到與做到的寫進 recording-artifacts/desktop_prep.json:

"foreground_before": {"class": "Windows.UI.Core.CoreWindow", "title": "Microsoft account"},
"oobe_dismissed": true, "seconds": 5.05

Store 會在 app 關閉的那一刻換掉它

還有一種雜訊不是視窗,是套件更新。windows-11-arm 的 Notepad 是 Store 版,而且映像上的版本落後(實測 11.2512.29.0)。Store 把更新下載好之後,等 app 關閉的那一刻套用。所以第一個關掉 Notepad 的測試,就是套件被換掉的時刻,下一個測試啟動 notepad.exe 撞上換包中——30 秒沒有視窗。x64 的 windows-latest 是傳統 notepad.exe,Store 不碰它。

quiet-runner 現在寫 HKLM\SOFTWARE\Policies\Microsoft\WindowsStore\AutoDownload = 2,並印出 Notepad 與 Calculator 的套件版本——多於一個版本就 ::warning::,因為那代表有一個更新已經 staged。policy 對已經下載好的更新是否還擋得住,要看接下來幾週 log 裡的版本行。


快取那個會拖慢每一次 run 的東西

      # Python 3.12+ ships pre-installed in the windows-11-arm image's tool cache,
      # but 3.11 does not: setup-python downloads and installs it on every run
      # (~90s). Cache the installed toolchain so later runs restore it instead.
      - name: Cache Python ${{ matrix.python-version }} toolchain
        if: matrix.python-version == '3.11'
        uses: actions/cache@... # v6.1.0
        with:
          path: C:\hostedtoolcache\windows\Python\3.11*
          key: python-toolchain-winarm64-${{ matrix.python-version }}

windows-11-arm 的映像預裝了 3.12 以上,但沒有 3.11。所以每一次 run,setup-python 都要下載安裝,多花 90 秒。

快取的重點不是「快取相依套件」——是去量哪一步慢,然後只快取那一步。這裡用 if: 只對 3.11 啟用,因為其他版本根本沒有這個成本,加了只是多一次快取的存取。

有一個關於 GitHub Actions 快取的重要限制:一個 PR 只能讀到自己的快取和 base 分支的快取。 所以第一次在新分支上跑一定是 miss。這不是設定錯了,是設計如此(安全考量)。


跑測試,然後無論如何都上傳產物

      - name: Run unit & live GUI recording tests
        env:
          WINTEGRATE_RECORD_SUITE: "1"
        run: |
          pytest tests/ -v -s

      - name: Upload Recording Artifacts & Diagnostics
        if: always()          # ← 最重要的三個字
        uses: actions/upload-artifact@...
        with:
          name: ci-artifacts-arm64-py${{ matrix.python-version }}
          path: |
            recording-artifacts/
            artifacts/
          if-no-files-found: ignore

if: always() 是整份檔案裡最重要的一個設定。

預設情況下,一個步驟失敗之後,後面的步驟不會執行。也就是說——測試失敗時,你的影片、視窗普查、事件時間軸全部不會被上傳。

你只會在成功的時候拿到診斷資料。而那正是你最不需要它的時候。

第一章那五天的工作,全部靠這三個字才有意義。

-s 也不能省:它讓 pytest 不要吃掉 stdout。Day 10 那份 provider report 是直接印出來的,沒有 -s 就看不到。

if-no-files-found: ignore:如果某次真的沒產生任何產物(例如環境問題導致錄影沒起來),不要因此讓 job 紅掉——診斷工具不該弄倒測試(Day 3)。

artifact 名稱要帶上架構和版本:八個 job 會上傳八份,名稱撞在一起就只剩一份。


完整骨架

把上面串起來,一份最小可用的 Windows GUI 測試 workflow 是這樣:

name: CI

on:
  push:
    branches: [ main ]
  pull_request:

permissions:
  contents: read

jobs:
  lint:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@<sha> # v6
        with: { persist-credentials: false }
      - uses: actions/setup-python@<sha> # v7
        with: { python-version: "3.13" }
      - run: pip install --require-hashes -r .github/requirements/lint.txt
      - run: |
          ruff check src/ tests/
          ruff format --check src/ tests/

  test:
    needs: lint
    strategy:
      fail-fast: false
      matrix:
        runner: [windows-latest, windows-11-arm]
        python-version: ["3.11", "3.12", "3.13", "3.14"]
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@<sha>
        with: { persist-credentials: false }
      # 把前面那段清場邏輯放這裡(WSL、Edge 首次執行、掃掉背景視窗)。
      # 內容取決於你的 runner 映像,見上一節。
      - name: Quiet the runner
        shell: pwsh
        run: |
          # ...your runner-specific cleanup...
      - uses: actions/setup-python@<sha>
        with: { python-version: ${{ matrix.python-version }} }
      - run: pip install -e .[dev]
      - name: Test
        env:
          WINTEGRATE_RECORD_SUITE: "1"
        run: pytest tests/ -v -s
      - name: Upload diagnostics
        if: always()
        uses: actions/upload-artifact@<sha>
        with:
          name: artifacts-${{ matrix.runner }}-py${{ matrix.python-version }}
          path: |
            recording-artifacts/
            artifacts/
          if-no-files-found: ignore

(<sha> 要換成實際的 commit SHA——明天講為什麼。)


一個 app 一個 runner,而不是 pytest-xdist

上面那份 workflow 的形狀是「一個 job 跑整套測試」。當測試開始驅動多個 第三方應用程式之後,那個形狀就不對了。

首先,pytest-xdist 在這裡是錯的工具。 這些測試驅動真實桌面:兩個應用程式在同一台機器上會搶前景焦點,產生的失敗不屬於任何一個測試。我實測過這個干擾——混跑時有一個測試偶發失敗,單獨跑那個模組全過,再跑混合又全過。這種「只在混跑時偶發」的東西不該花時間追,該從結構上分開。

所以是 CI matrix,一個應用程式一個 runner:

strategy:
  fail-fast: false
  matrix:
    include:
      - app: notepad++
        os: windows-latest
        arch: x64
        tests: tests/test_scintilla.py
      - app: notepad++
        os: windows-11-arm
        arch: arm64
        tests: tests/test_scintilla.py
      # ...
name: test-target-app (${{ matrix.app }} / ${{ matrix.arch }})

三個附帶好處:每個 job 只裝自己要的東西(安裝要好幾分鐘)、紅掉的 job 名稱直接說出是哪個應用程式、八個 job 平行所以總時間等於最慢的那一個。

兩種 SKU 都要跑

一件容易忽略的事:

runner label 實際上是什麼
windows-latest Windows Server 2025 Datacenter
windows-11-arm Windows 11 Enterprise

一個是 server SKU,一個是 client SKU,而它們對 UI 自動化不是可互換的——這個測試套件本來就有 desktop_only 與 server_only 兩個 marker 在處理這件事。

而 target-app 那組測試在很長一段時間裡只跑過 server 那一邊。補上 arm64 之後立刻抓到一個只在那台機器上出現的問題:一個「Microsoft account」的系統提示視窗從開機就握著前景,所以每一個按鍵都送到它那裡去了。

那個問題在我自己的 ARM64 虛擬機上不存在。「同一個架構」不等於「同一個環境」。

先量再改:那個裝了等於沒裝的 runtime

Files 的 job 裡有一行 choco install dotnet-desktopruntime,因為那個 app 是 framework-dependent,少了 runtime 它會開一個 #32770 訊息框而不是視窗。

有人問我這步能不能繞過。我先量:那一步佔 163 秒裡的 66 秒。然後看它裝完之後 dotnet --list-runtimes 印了什麼——

Microsoft.WindowsDesktop.App 8.0.6 / 8.0.22 / 8.0.30 / 9.0.6 / 9.0.19 / 10.0.8 / 10.0.11

image 上本來就有 10.0.8。那 66 秒是在裝一個已經在那裡的東西。

改成先問再裝之後,那一步變成 50 秒。

但沒有直接刪掉,而是留成 fallback。理由是:真的缺 runtime 的時候,症狀是 Files 開出一個訊息框、而視窗探索會把那個對話框當成應用程式——那是一種光看 log 很難查出來的失敗,值得留一行程式碼去避免。

先量再改。 而且要注意「省下多少」跟「跑得多快」不是同一個數字——那次 job 總時間掉得比 66 秒多,因為它剛好也有比較熱的 pip cache。要看的是步驟,不是總計。

一份只在你要求時才跑的 workflow

不是所有東西都該綁在每次 push 上。重現上游 bug 的那組測試要下載八個舊版建置,問的也是不同的問題(「這個工具抓不抓得到 bug」而不是「這個工具還會不會動」),所以它是 workflow_dispatch 專用的。

三個踩過的坑:

matrix 在 job 層級的 if 裡看不到。 那個 context 只有 github、needs、vars、inputs。寫下去不是那一個 job 被跳過,是整個 workflow 在解析階段就壞掉——症狀是 workflow 沒有名字、而且完全無法 dispatch。正確做法是讓一個 plan job 產生 matrix,用 fromJSON 餵給下一個 job。

YAML 裡沒有引號的 # 會開始一段註解。 這個名字:

    name: notepad++ #16326 (${{ matrix.arch }})

實際變成 notepad++,後面連同 matrix 運算式一起被丟掉。而我在同一份檔案裡犯了兩次——第二次是步驟名稱,儘管旁邊就寫著這條註解。

選不到任何 job 的組合要失敗。 arch=arm64 加上一個只支援 x64 的案例,會得到一個空的 matrix。如果就這樣跑完,那是一次綠色的、什麼都沒測的 run——對一個唯一任務就是「證明某件事」的 workflow 來說,那是最糟的結果。

一條寫下來也還是會漏掉的步驟

前面「ARM64 runner 的清場」那節講了 WSL 每 30 秒彈一次視窗,以及怎麼處理。

然後我寫第二份 workflow 的時候,漏了那一步。

結果是 WinMerge 的 arm64 job 大約三次壞一次,而且壞的那次跟成功那次之間只差一個完全無關檔案的 commit。除錯流程的第一個假設是方向鍵送太快、UI 跟不上——那也是一個真的風險,也修了——但真正的成因是那個從一開始就記錄在案、卻沒有帶過去的清場步驟。一個看著錄影的人會先看到每 30 秒彈出來的 WSL 視窗,而不是先去猜按鍵速度。

一份 workflow 的知識不會自己跟著你搬到下一份 workflow。 會的只有你抄過去的那些行。

後來同樣的學費又付了一次,而且更貴。為了量一個新功能,從零寫了一份實驗用的 workflow,兩步清場都沒抄。結果那個功能在 arm64 上「量到」完全不能用,三輪量測、一份結論——全部作廢,因為底下的視窗一直被 OOBE 蓋著。一個人看一眼螢幕就會知道自己在對著一張隱私設定頁按;流程沒有看,也沒有放一個已知可用的對照組(同一個座標先用滑鼠點一次)去分辨「功能不行」和「瞄到空處」。

小結

設定 為什麼
pull_request: 不加分支篩選 否則 stacked PR 完全沒有 CI
permissions: contents: read 預設權限比你需要的多
needs: lint 20 秒的檢查擋在 20 分鐘的測試前面
fail-fast: false 你需要知道「只有 ARM64 壞」還是「全部都壞」
清場步驟 ARM64 runner 上的 WSL 每 30 秒彈一次視窗
清場是一個檔案,不是四份 inline 複本 複本會漂;一個檔案沒辦法用手抄
清場只在 RUNNER_ENVIRONMENT=github-hosted 動手 self-hosted 是某個人的電腦
gate 放腳本裡,不放 if: 被 if: 跳過的步驟在 log 裡什麼都不留
OOBE 在第一個測試前關 放在 Session 裡的話,前四十個測試在它底下跑
Store AutoDownload=2 更新在 app 關閉那一刻套用,下一次啟動沒有視窗
每一份新 workflow 都要抄清場步驟 漏掉的症狀是三次壞一次的 flaky
只在請求時跑的用 workflow_dispatch matrix 交給 plan job,job 層級的 if 看不到 matrix
空的 matrix 要失敗 否則是一次什麼都沒測的綠燈
針對性快取 量出哪一步慢,只快取那一步
if: always() 否則失敗時拿不到任何診斷資料
pytest -s 否則自訂的診斷輸出會被吃掉

明天講怎麼讓這份 workflow 的失敗變得可讀,包括一個自動化流程犯過、而且回報了錯誤結論的錯誤。


上一篇
Day 27:抓四個從來沒聽過這個函式庫的 bug
下一篇
Day 29:Windows GUI CI 的防禦性驗證與診斷設計
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影 共 29 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言