WinForms / WPF 應用程式的 CI/CD 實務 ── 用 GitHub Actions 把從建置到簽章・發布全部自動化

· · CI/CD, GitHub Actions, WinForms, WPF, C#, .NET, 程式碼簽章, MSIX, 部署, Windows 開發, 判斷表

「發布版只有那位工程師的電腦才能建置」──在 WinForms 或 WPF 業務應用程式的諮詢中,這句話真的非常常見。在自己的 Visual Studio 上做 Release 建置,壓成 zip 放到共用資料夾裡。雖然目前運作正常,但沒有人能回答「那位開發者請假的那天,能不能發出修正版」這個問題。

Web 應用程式的 CI/CD 資訊多到氾濫,但一談到桌面應用程式,資訊量就急遽變薄。畢竟「部署對象是客戶端的電腦,而不是伺服器」是根本性的差異,Web 相關文章的做法確實無法直接照搬。不過,只要範圍限定在建置與測試的自動化,桌面應用程式也能用和 Web 幾乎相同的工夫組出來。真正的難關在於接下來的簽章與發布,而這部分依發布方式不同,現實的解法也不一樣。

本站在「Windows 應用發布方式怎麼選 - MSI / MSIX / ClickOnce / xcopy / 自訂 updater 的判斷表」中整理過發布方式的選法,在「Windows 為什麼會出現「Windows 已保護您的電腦」」中整理過簽章的思路。本文章以上述兩篇為前提,從實務角度整理如何運用 GitHub Actions,把 WinForms / WPF 應用程式的建置、測試、版本編號、簽章、發布物產出自動化到什麼程度

適用對象與前提條件

為了讓讀者能對照自身情況閱讀,先列出本文所假設的前提條件。

  • 對象:以自己手邊的 Visual Studio 建置並發布 WinForms / WPF 撰寫的 Windows 桌面應用程式的開發者或團隊。不要求具備 CI/CD 經驗。
  • 儲存庫:原始碼放在 GitHub 儲存庫中(公開或私有皆可)。公開儲存庫的標準執行器是免費的,私有儲存庫的執行時間則依分鐘計費。1
  • 專案格式:本文的 YAML 以 .NET SDK 格式的 csproj(如 net8.0-windows)為前提。即使是 .NET Framework 4.x 的舊格式 csproj,思路也相同,只是用 MSBuild 與 NuGet CLI 取代 dotnet build(第 3 章)。
  • 簽章:第 5 章從「今後該如何準備憑證」開始討論。結論會因為你目前是已經有 PFX 檔案或內部 CA,還是打算之後才申請公開憑證而改變,請一邊確認自己的現況一邊閱讀。
  • 目標:並非全自動發布,而是先做到「無論在誰的電腦上都能重現建置・測試,並能取出成果物」的狀態(第 3 章)。之後再逐步加入簽章與發布。

整條管線的整體樣貌如下所示。要把自動化的範圍劃到哪裡,第 2 章會討論。

[日常] push 到 main / Pull Request
    └→ checkout → setup-dotnet → build → test → publish → upload-artifact (第 3 章)

[發布] push v1.2.3 標籤
    └→ checkout → setup-dotnet → test
         → 從標籤注入版本後 publish (第 4 章)
         → 簽章(signtool / 雲端簽章服務)(第 5 章)
         → 製作發布物(zip / MSI / MSIX / ClickOnce)(第 6 章)
         → 附加到 GitHub Release(長期保存)(第 4 章)

1. 先講結論

  • 最大的風險是「只有開發者的電腦才能建置」這種狀態。CI/CD 的第一目標不是發布的全自動化,而是不依賴任何人的電腦、都能重現建置。
  • 最小組態光是建置+測試的自動化就已經有充分的價值。windows-latest + actions/checkout + actions/setup-dotnet + dotnet build / test + actions/upload-artifact,只用一個 YAML 檔就能組出來。12
  • WinForms / WPF 以 Windows 執行器為前提。因為以 net8.0-windows 這類 Windows 專用 TFM 為對象3,若要在 CI 中連測試都執行,就需要 Windows 環境。
  • 版本編號以標籤驅動是最合理的落腳點。v1.2.3 標籤的 push 觸發發布建置,並把標籤的值注入 MSBuild 的 Version 屬性。4
  • 簽章是自動化的最大障礙。2023 年 6 月起,公開的 OV 憑證私密金鑰已必須以 HSM 保管,「把 PFX 放進 Secrets、再用 signtool」這套過去的標準做法已無法照原樣使用。容易與 CI 整合的 Azure Artifact Signing(前身為 Trusted Signing)並未將日本納入可用地區,因此對日本的開發者而言,現實的解法是CA 提供的雲端 HSM 選項,或是只把簽章這道工序留在本機進行(5.2 節)。5
  • 不同發布形式,納入 CI 的難易度差異很大。依序是 xcopy(zip)最簡單、MSIX 必須簽章6、MSI 需與 WiX 等 CLI 整合、ClickOnce 需要 msbuild /target:publish 且較為棘手。7
  • 不要把 UI 自動測試當成 CI 的必要關卡。現實的做法是把單元測試列為 CI 必要項目,UI 測試則只留下最基本的煙霧測試,放到另一個工作中執行。

2. 桌面應用程式的 CI/CD 與 Web 有何不同

先整理為什麼無法把 Web 應用程式 CI/CD 的固定模式(push → 建置 → 測試 → 部署到伺服器)原封不動地搬過來使用。

觀點 Web 應用程式 WinForms / WPF 桌面應用程式
部署對象 自己管理的伺服器 客戶端・現場的電腦(管理範圍之外)
發布單位 在伺服器上一次性切換 MSI / MSIX / ClickOnce / zip 等多種形式,部署時機取決於對方
回滾 可在伺服器端還原 已發布出去的電腦很難輕易還原,必須保存舊版安裝程式
簽章 通常不需要(TLS 由基礎設施端負責) 對執行檔・套件進行程式碼簽章實質上是必要的
建置環境 容易在 Linux 執行器上完成 以 Windows 執行器為前提
測試 容易以無頭(headless)方式完成 單元測試相同,UI 測試則需要桌面工作階段
「部署」的意義 直到反映到正式環境為止 CI 的守備範圍只到「發布物完成」為止,安裝是另一道工序

重點在最後一列。在桌面應用程式中,CI/CD 管線的出口不是「反映到正式環境」,而是「已簽章的發布物被放在隨時可以取出的位置」。從那之後(展開到客戶端、自動更新)屬於發布方式設計的範疇,是「發布方式判斷表」所處理的領域。反過來說,只要這樣界定出口,桌面應用程式的 CI/CD 就能用和 Web 相同的工具組建起來。

3. 最小組態 ── 用 GitHub Actions 建置+測試

最先該導入的就只有這些。由於 GitHub 代管執行器會為每個工作配置全新的 VM1,每次 push 時都會在一台全新的 Windows 上執行建置與測試,「只有那位工程師的電腦上才有的 SDK」這種依賴問題會當場曝露出來。

name: build-and-test

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: windows-latest   # WinForms / WPF 必須使用 Windows 執行器
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --configuration Release --no-restore

      - name: Test
        run: dotnet test --configuration Release --no-build

      - name: Publish
        run: dotnet publish src/MyApp/MyApp.csproj -c Release -o publish

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: MyApp
          path: publish

只要把這份 YAML 放成 .github/workflows/build-and-test.yml,push 之後就能直接運作。不過請把 src/MyApp/MyApp.csproj 這部分換成你自己儲存庫的專案路徑。之後的 YAML 範例也都沿用同一個路徑。如果儲存庫根目錄下只有一個方案與一個 csproj,即使像 dotnet publish -c Release -o publish 這樣省略路徑也能運作。

補充三個重點。

第一,runs-on: windows-latest 是基本設定。WinForms / WPF 專案的 TargetFrameworknet8.0-windows 這類 Windows 專用 TFM,是啟用了 UseWindowsFormsUseWPF 的 .NET 桌面 SDK 專案。3 嚴格來說,如果只是編譯,即使在 Linux 執行器上啟用 EnableWindowsTargeting 也能建置,但 dotnet test 這類需要實際執行的步驟需要 Windows 環境,因此在這個把測試也放進同一個工作中執行的組態裡,直接使用 Windows 執行器最單純。若是 .NET Framework 4.x(舊格式 csproj),則不是用 dotnet build,而是使用 MSBuild 與 NuGet CLI,但兩者都已預先安裝在 Windows 執行器上,思路是一樣的。

第二,務必用 actions/upload-artifact 保留成果物。「該次建置的成果物一式都能從 GitHub 取出」正是擺脫依賴特定個人電腦的實質內容。就連想緊急確認動作的版本,也只要從 Actions 畫面下載 zip 即可。

第三,在這個階段還不進行簽章與發布。光是這個最小組態,就能得到「main 隨時可建置・測試」「任何人都能取出相同成果物」這兩項保證。依筆者的經驗,小型團隊的煩惱大多能靠這一步解決。

4. 版本編號的自動採番 ── 標籤驅動發布

下一階段要解決「這個 zip 到底是幾版?」的問題。在手動建置的運作方式下,常見的事故是忘記改寫 csproj 的 Version,導致同一個 1.0.0 出現好幾代。實務上合理的落腳點是標籤驅動發布:在想要發布的 commit 上打上 v1.2.3 這樣的標籤,以此觸發工作流程執行,並把從標籤名稱取得的版本注入建置中。

name: release

on:
  push:
    tags: [ 'v*' ]

jobs:
  release:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      # 由於標籤的 push 不會觸發第 3 章的建置+測試工作流程,
      # 因此在製作發布成果物之前,這裡也先跑一次測試
      - name: Test
        run: dotnet test --configuration Release

      - name: Publish with version from tag
        shell: pwsh
        run: |
          $version = $env:GITHUB_REF_NAME.TrimStart('v')   # v1.2.3 -> 1.2.3
          dotnet publish src/MyApp/MyApp.csproj `
            -c Release -o publish `
            -p:Version=$version

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: MyApp-${{ github.ref_name }}
          path: publish

-p:Version=1.2.3 這樣以 MSBuild 屬性傳入時,在 .NET SDK 專案中,AssemblyVersionFileVersion 預設會從 Version 的前綴(去除後綴的部分)產生,InformationalVersion 則會直接使用 Version 本身產生。4 csproj 中只放開發用的暫定值,發布時的正式版本只由標籤持有,形成一元化管理。

有一點要注意。actions/upload-artifact 的成果物存在儲存庫的保留期限(預設 90 天),過期後就會消失。桌面應用程式為了切回舊版而需要長期保存舊版安裝程式,因此標籤建置的成果物應發布到「附加到 GitHub Release」之類的永久保存位置,並把 Actions 的產物視為單純的暫時性傳遞手段。

附加到 GitHub Release 有兩種方式:使用專用的 action,以及使用執行器上原本就已安裝的 GitHub CLI(gh)。8 兩者都需要工作具備 contents: write 權限。

jobs:
  release:
    runs-on: windows-latest
    permissions:
      contents: write        # 建立 Release・附加資產所需
    steps:
      # ...(建置與 publish 如前所述)

      - name: Zip
        shell: pwsh
        run: Compress-Archive -Path publish\* -DestinationPath MyApp-${{ github.ref_name }}.zip

      # 方法 A: 使用專用 action
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v3
        with:
          files: MyApp-${{ github.ref_name }}.zip

      # 方法 B: 使用執行器內建的 GitHub CLI(上面兩者擇一即可)
      - name: Create GitHub Release (gh)
        shell: pwsh
        run: gh release create ${{ github.ref_name }} MyApp-${{ github.ref_name }}.zip --generate-notes
        env:
          GH_TOKEN: ${{ github.token }}

gh 已預先安裝在 GitHub 代管執行器上,但每個步驟都需要把具備必要範圍的權杖傳給 GH_TOKEN 環境變數。8

這種做法的優點是運作範圍完全封閉在 Git 之內。「客戶環境上的 1.2.3 對應哪個 commit」由標籤直接確定,EXE 內容資訊中顯示的檔案版本與 Git 標籤會機械式地一致。此外,自 .NET 8 SDK 起,InformationalVersion 預設會附加 Git 的 commit hash(SourceRevisionId4,只要在版本顯示畫面呈現這個值,就能直接從成果物鎖定對應的 commit。

5. 把程式碼簽章整合進 CI ── 這裡是最大的難關

即使一路把建置與版本編號都順利自動化的團隊,幾乎肯定會在簽章這一步卡住。關於在 Store 外發布的 Windows 應用程式為什麼實質上必須進行程式碼簽章(SmartScreen、企業資安產品、竄改偵測),已經在「SmartScreen 與程式碼簽章」中整理過,這裡把焦點縮小在CI 的哪個環節、要怎麼執行

5.1 signtool 的基本用法

執行簽章本身只需要一道指令。signtool 內含於 Windows SDK,在 GitHub 的 Windows 執行器上也能使用。在目前的 SDK 中,/fd(檔案摘要)與 /td(時間戳記摘要)的指定是必要的,建議使用 SHA256。9

signtool sign /f MyCert.pfx /p $env:PFX_PASSWORD `
  /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 `
  publish\MyApp.exe

時間戳記(/tr)雖然可以省略,但務必附加。有了時間戳記,即使憑證過期之後,仍可驗證「簽章當下是有效的」,已發布檔案上的簽章也能持續有效。96

5.2 憑證的種類與整合進 CI 的現實

問題不在指令,而在於私密金鑰要放在哪裡。依憑證的取得形式不同,整合進 CI 的方式會有根本性的差異。

憑證的形式 私密金鑰的位置 整合進 CI 備註
雲端簽章服務(Azure Artifact Signing = 前身 Trusted Signing 等) 雲端端 容易整合。設計上以與 GitHub Actions 等工具整合為前提 可用的國家・地區有限制,日本不在對象範圍內(法人限美國・加拿大・EU・英國,個人僅限美國・加拿大)。詳見下方5
OV 憑證(2023 年 6 月後新申請者) 必須為 HSM / USB 金鑰 由於金鑰無法插入執行器,原樣無法使用。若 CA 有雲端 HSM 選項則可行 依 CA/Browser Forum 要求5
EV 憑證 HSM / USB 金鑰 同上 SmartScreen 的即時信任效果已於 2024 年廢止,簽章運作上與 OV 同等看待5
舊式 PFX 檔案(過去核發的憑證・內部 CA・自我簽署) 檔案 以 Base64 存進 Secrets 後還原(見下方) 用於公開發布的新申請憑證,原則上已無法再取得這種形式

也就是說,搜尋時常見的「把 PFX 放進 GitHub 的 Secrets、再用 signtool 簽章」這套架構,在內部 CA 或既有 PFX 的情況下至今仍然有效,但在今後要申請公開憑證的情境下,前提已經不成立。若是要新建組態,現實的做法是以從一開始就支援 CI 整合的雲端簽章服務為主軸來評估。5 若目前正以 USB 金鑰運作,就會形成一種折衷架構:只把簽章這道工序留在本機電腦,或插著金鑰的自架執行器上進行。

對日本開發者而言的核心問題 ── Azure Artifact Signing 到底能不能用

上面表格的第一列寫著「可用的國家・地區有限制」,但對日本的讀者來說,這正是最重要的判斷依據,因此獨立整理如下。

Microsoft 的文件明確指出,Azure Artifact Signing(前身為 Trusted Signing)僅限法人在美國・加拿大・EU・英國,個人開發者在美國・加拿大才能使用5 也就是說,日本法人與居住在日本的個人開發者,目前並不在可用範圍內。如果直接照搬「雲端簽章才是正解」這種一般論,會在建立帳號的階段就卡住。以這項限制為前提,可選的方案如下。

狀況 現實的選擇
今後要申請公開憑證(日本法人) OV 憑證+CA 的雲端 HSM 選項。2023 年 6 月起,OV 憑證的私密金鑰必須以 HSM 或硬體金鑰保管,但許多 CA 除了 USB 金鑰之外,也提供雲端 HSM 選項,選擇後者就能從 CI 呼叫簽章。5 若有納入 CI 的計畫,請在選擇憑證的階段就先向 CA 確認是否支援雲端 HSM,買了金鑰之後就無法再改
已經在用 USB 金鑰運作 只把簽章這道工序留在插著金鑰的本機電腦或自架執行器上。建置・測試・版本編號等前段以 GitHub 代管執行器自動化,只有最後的簽章保留人工操作
可以透過 Microsoft Store(MSIX)發布 因為由 Store 端的 Microsoft 重新簽章,所以不需要自備憑證。5 若能重新選擇發布方式,這是讓簽章的煩惱一次消失的最短路徑(不過若以 MSI/EXE 安裝程式的形式上架 Store,仍需要 publisher 端的簽章)
開源專案 SignPath Foundation 為符合條件的 OSS 專案提供免費的程式碼簽章。5
僅限公司內部發布 用內部 CA 核發的憑證與既有 PFX 方式就已足夠(5.3 節)。若環境可以透過群組原則或 Intune 把憑證配發為信任的根憑證,就不需要公開憑證

Azure Artifact Signing 的可用地區未來有可能擴大,因此在確定 CI/CD 的簽章設計時,請以第一手資訊確認當下的可用地區5

5.3 Secrets 管理的注意事項

以下是把 PFX 方式(內部 CA・既有憑證)納入 CI 時的標準做法。

  • 把 PFX 轉成 Base64 字串存進 GitHub 的 Secrets,再於工作內還原成檔案。這是 GitHub Docs 針對如何以 Secrets 處理二進位資料所提供的作法。10
  • 密碼放進另一個獨立的 Secret。Secrets 的值會在紀錄中自動遮蔽10,但經過加工衍生出的值不在此保護範圍內,因此不要把它傳給簽章步驟以外的環境變數。
  • 來自 fork 的 Pull Request 不會拿到 Secrets(GITHUB_TOKEN 除外)。10 但由於工作本身會以空的 Secrets 執行,上述的還原步驟會因為對空字串做 Base64 解碼而失敗。因此應該把簽章步驟分離到像第 4 章那種由標籤觸發的發布工作流程(不會被 fork 的 PR 觸發),或是加上 if: github.event_name != 'pull_request' 這樣的條件明確跳過。
      - name: Restore signing certificate
        shell: pwsh
        run: |
          $bytes = [Convert]::FromBase64String($env:PFX_BASE64)
          [IO.File]::WriteAllBytes("$env:RUNNER_TEMP\sign.pfx", $bytes)
        env:
          PFX_BASE64: ${{ secrets.SIGNING_PFX_BASE64 }}

5.4 PFX 方式的完整工作流程

這裡展示把先前的片段(標籤驅動的版本注入・PFX 還原・signtool 執行・成果物發布)串成一份完整流程的樣子。這是以已擁有內部 CA 或既有 PFX 為前提的組態,直接放進儲存庫的 .github/workflows/release.yml 就能運作。專案路徑與時間戳記伺服器的 URL 請換成自己的環境。

name: release

on:
  push:
    tags: [ 'v*' ]

jobs:
  release:
    runs-on: windows-latest
    # 會接觸到簽章金鑰的工作,要綁定到附審核者的 Environment(5.5 節)。
    # 若省略這一行、把值留在儲存庫 Secrets 中,擁有寫入權限的人
    # (或是遭到入侵的帳號)只要改寫工作流程、push 一個標籤,
    # 就能取出簽章金鑰。請指定這個 environment,並把
    # SIGNING_PFX_BASE64 與 SIGNING_PFX_PASSWORD 放在 Environment 那一側
    environment: release-signing
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Test
        run: dotnet test --configuration Release

      - name: Publish with version from tag
        shell: pwsh
        run: |
          $version = $env:GITHUB_REF_NAME.TrimStart('v')
          dotnet publish src/MyApp/MyApp.csproj `
            -c Release -o publish `
            -p:Version=$version

      # --- 以下開始簽章 ---
      - name: Restore signing certificate
        shell: pwsh
        run: |
          $bytes = [Convert]::FromBase64String($env:PFX_BASE64)
          [IO.File]::WriteAllBytes("$env:RUNNER_TEMP\sign.pfx", $bytes)
        env:
          PFX_BASE64: ${{ secrets.SIGNING_PFX_BASE64 }}

      - name: Sign
        shell: pwsh
        run: |
          # signtool 內含於 Windows SDK。不寫死路徑,動態解析
          $signtool = Get-ChildItem `
            "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" |
            Sort-Object FullName | Select-Object -Last 1

          # 不只 exe,也要把發布物中自家建置的 DLL 一併簽章。
          # 若發布對象是以 App Control / AppLocker 的發行者規則來檢查 DLL,
          # 只要有一個 DLL 沒有簽章,就會在那裡卡住。
          #
          # 不過對象只限定於自家建置。publish 中也會混入 NuGet 或
          # framework 附帶的 DLL,若用萬用字元全部撈進來簽章,
          # 就會把廠商已經簽好的 DLL 覆寫成自家憑證(不加 /as 的
          # sign 會取代既有簽章),還會把第三方未簽章的 DLL 也
          # 當成「自家成果物」發布出去。這會破壞以發行者為基礎的
          # 允許規則與來源驗證機制,因此改用名稱明確列舉
          $ownAssemblies = @('MyApp', 'MyApp.Core', 'MyApp.Plugins')
          $targets = Get-ChildItem publish -Recurse -Include *.exe, *.dll |
            Where-Object { $ownAssemblies -contains $_.BaseName } |
            Select-Object -ExpandProperty FullName
          if (-not $targets) { throw '找不到簽章對象,請確認 publish 目錄的內容。' }

          & $signtool.FullName sign `
            /f "$env:RUNNER_TEMP\sign.pfx" /p $env:PFX_PASSWORD `
            /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 `
            $targets
          if ($LASTEXITCODE -ne 0) { throw "簽章失敗 (exit $LASTEXITCODE)" }
        env:
          PFX_PASSWORD: ${{ secrets.SIGNING_PFX_PASSWORD }}

      - name: Remove certificate
        if: always()
        shell: pwsh
        run: Remove-Item "$env:RUNNER_TEMP\sign.pfx" -ErrorAction SilentlyContinue

      # --- 以下開始產生成果物 ---
      - name: Zip
        shell: pwsh
        run: Compress-Archive -Path publish\* -DestinationPath MyApp-${{ github.ref_name }}.zip

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v3
        with:
          files: MyApp-${{ github.ref_name }}.zip

閱讀時的重點有五個。

  • 不要拿掉 environment: release-signing若把簽章金鑰留在儲存庫 Secrets 中,任何擁有儲存庫寫入權限的人,只要改寫工作流程、push 一個標籤,就能取出金鑰。應該把 Secrets 放在附審核者的 Environment 中,讓只有這個工作能參照它(5.5 節)。請不要把少了這一行的工作流程當成「完成版」來運作。
  • 簽章對象只限定於自家建置。publish 中也會混入依賴套件的 DLL。若一併簽章,會把廠商的簽章換成自家憑證,還會把第三方未簽章的 DLL 當成自家成果物發布出去。若真的有必要為第三方的二進位檔加上簽章,應該用 /as 附加,而不是取代。
  • 簽章要在 dotnet publish 之後、壓縮之前進行。壓成 zip 之後才簽章,裡面的 EXE 並不會被簽到。製作 MSI 或 MSIX 時也是同樣的順序:先為裡面的 EXE/DLL 簽章,再製作套件,最後為套件本身簽章。
  • PFX 用完就刪除。加上 if: always(),讓即使簽章失敗,刪除步驟也一定會執行。由於 GitHub 代管執行器每個工作結束後都會銷毀1,這一步並非必要,但轉移到自架執行器時若省略就會出事。
  • 這份工作流程只會在 push 標籤時執行。由於不會被 fork 送出的 Pull Request 觸發,因此結構上就能避開 5.3 節提到的 Secrets 為空的問題。

若使用雲端簽章服務或雲端 HSM,只是把「Restore signing certificate」與「Sign」這兩個步驟,換成服務端提供的 action 或 CLI 呼叫,前後的架構並不會改變。

5.5 限縮能接觸簽章金鑰的工作流程

與 Secrets 的處理方式(5.3 節)並列的另一個要害,是限縮能存取簽章金鑰的工作流程。由於擁有儲存庫寫入權限的人可以改寫工作流程,因此應把簽章用的 Secrets 放在附審核者的 Environment 中,只讓發布工作流程可以參照它。已簽章的二進位檔本身就是「由自家公司製作」的證明,因此金鑰的處理應該設計成與自動更新的發布基礎設施同等級的信任邊界(這個觀點可參考「自動更新功能的安全性基本 - 糟糕的模式與最佳實踐」)。

6. 依發布形式劃分的 CI/CD 整合判斷表

走到簽章這一步之後,最後剩下的就是發布物的形式。方式選擇本身留給「Windows 應用發布方式怎麼選 - MSI / MSIX / ClickOnce / xcopy / 自訂 updater 的判斷表」處理,這裡只從 CI/CD 的角度進行比較。

發布形式 CI 建置的難易度 CI 中的建立方式 簽章要求 自動更新
xcopy(zip 發布) 最簡單 只需 dotnet publish + 壓縮 對 EXE/DLL 簽章(建議) 無(手動展開)
xcopy + 自製 updater 建置本身簡單,但更新發布的設計另需下大量工夫 dotnet publish + 產生 manifest 需要 EXE 簽章 + 更新檔案的驗證設計 自製(需要設計信任邊界)
MSI 中等 以 CLI 執行 WiX 等工具 對 MSI 檔案簽章(建議~實質必須) 無(需要另外的發布機制)
MSIX 中等 MSBuild / MakeAppx + signtool 套件簽章為必要條件(未簽章無法安裝)6 可透過 App Installer 等方式支援
ClickOnce 較為棘手 msbuild /target:publish + 發行設定檔(dotnet CLI 不支援)7 manifest 簽章 + EXE 簽章 內建(此方式的主要目的)

以下做補充。

  • xcopy(zip):第 3 章、第 4 章的工作流程幾乎就是完成版本。無論最終選定哪種發布方式,先跑通這個形式都是一條捷徑。
  • MSI:把安裝程式定義(WiX 等)放進儲存庫,以 CLI 建置。比起產生過程本身,「MSI 要包含什麼(服務註冊、per-machine/per-user)」的設計才是重點所在。
  • MSIX:因為 Windows 不允許安裝未簽章的 MSIX,若沒有搭配簽章自動化,CI 化就無法完成6 反過來說,只要簽章基礎設施到位,這是很容易納入 CI 的形式。若透過 Microsoft Store 發布,還有另一種解法:由 Store 端重新簽章,因此不需要自備憑證。5
  • ClickOnce:無法用 dotnet CLI 發行,必須使用指定發行設定檔(.pubxml)的 msbuild /target:publish。由於 IDE 中每次發行都會自動遞增的修訂版號(ApplicationRevision)在命令列建置中不會遞增7,因此必須採用第 4 章那種標籤驅動的做法明確傳入版本。這裡要注意的是,ClickOnce 的更新判定不是根據 -p:Version(組件資訊),而是根據部署端的版本(ApplicationVersion / ApplicationRevision),因此如果沒有另外用 /p:ApplicationVersion=1.2.3.0 這樣的方式,把從標籤產生的四段式版本值傳進去,新的發布就不會被判定為更新。相關機制與適用場合,已在「ClickOnce 是什麼 - 以實務視角整理機制、更新、適合場面・不適合場面」中解說過。

單從 CI/CD 的角度來說,「先從 zip 開始,等發布需求確定後再把 MSIX 或 MSI 當成一個工作加進去」是增量最小的做法。前段(建置・測試・版本編號)在所有形式中都是共通的,即使之後才替換發布形式的步驟,先前投入的成果也不會白費。

7. 測試自動化該做到什麼程度

最後是關於作為 CI 關卡(必要檢查)的測試,該劃到哪一條線的討論。

測試層級 在 CI 中的處理方式 理由
單元測試(邏輯) 必要關卡,每次 Pull Request 都要執行 速度快・穩定・可以直接在 Windows 執行器上運作
無畫面整合測試(DB・檔案 I/O) 原則上必要,若太慢就分離到夜間執行 外部依賴的初始化需要一些工夫,但自動化的價值很高
UI 自動測試(煙霧測試) 只在另一個工作中執行少數項目,大約到「啟動~主要畫面切換」程度即可 必須有桌面工作階段,不穩定因素較多
UI 自動測試(全面涵蓋) 不列為 CI 關卡 維護成本很容易超過帶來的效益

在桌面應用程式中,自動化價值最高的並不是 UI,而是它下面的那一層。如果業務邏輯埋在 code-behind 裡,就寫不出單元測試,因此把邏輯從畫面中分離出來,這件事本身就是 CI/CD 的前提投資。UI 自動測試應該只限縮在「啟動、登入、開啟主要畫面」這種煙霧測試,並用夜間等其他觸發方式來執行,這才是現實的解法。在執行器上跑 UI 測試存在許多畫面工作階段、解析度、時序方面的陷阱,這個領域在「Windows 桌面應用程式的 UI 自動測試 ── UI Automation 的原理與用 FlaUI 打造不易損壞的測試」中,連 CI、無人執行的陷阱都一併整理過。

8. 總結

  • 桌面應用程式的 CI/CD,只要把出口定義為「已簽章發布物的完成」,就能用和 Web 相同的工具組建起來。
  • 最小組態是 windows-latest + actions/checkout + actions/setup-dotnet + dotnet build / test + actions/upload-artifact。光是這樣就能消除「只有開發者的電腦才能建置」的風險。12
  • WinForms / WPF 因為使用 net8.0-windows 等 Windows 專用 TFM,所以以 Windows 執行器為前提。3
  • 版本以「v1.2.3 標籤 → 注入 -p:Version」的標籤驅動方式一元化管理。4
  • 簽章是 CI 自動化的最大障礙。如今連 OV 憑證都必須以 HSM 保管,而 Azure Artifact Signing 又把日本排除在可用地區之外,因此在選擇憑證的階段就確認CA 的雲端 HSM 選項,是實務上的入口。PFX + Secrets 方式適用於內部 CA・既有憑證,5.4 節提供了完整版本的工作流程。510
  • 標籤建置的成果物,可透過 softprops/action-gh-release 或執行器內建的 gh release create 附加到 GitHub Release 長期保存(工作需要 contents: write 權限)。8
  • 發布形式納入 CI 的難易度,依序是 xcopy(zip)→ MSI / MSIX → ClickOnce。MSIX 必須簽章6,ClickOnce 則要注意 msbuild /target:publish 以及修訂版號不會自動遞增這兩點。7
  • 把單元測試列為 CI 的必要關卡,UI 測試則限縮在煙霧測試並放到另一個工作中執行。把邏輯從畫面分離出來是前提投資。

相關文章

相關諮詢領域

合同會社小村軟體承接 WinForms / WPF 應用程式開發,加上從手動建置轉為 CI/CD 的移轉、以 GitHub Actions 設計建置・簽章・發布管線,以及既有桌面應用程式的可測試化(邏輯分離)等相關服務。

參考連結

  1. GitHub Docs, GitHub-hosted runners reference。關於 windows-latest 等執行器標籤、每個工作都會配置全新虛擬機器,以及公開儲存庫使用標準執行器免費的說明。  2 3 4 5

  2. Microsoft Learn, GitHub Actions and .NET。關於用 GitHub Actions 進行 .NET 的 CI/CD、actions/checkout・actions/setup-dotnet 的作用,以及在工作流程中使用 dotnet restore / build / test / publish 的說明。  2

  3. Microsoft Learn, MSBuild reference for .NET Desktop SDK projects。關於 WinForms / WPF 專案會指定 net8.0-windows 這類 Windows 專屬 TFM,並透過 UseWindowsForms / UseWPF 啟用 .NET 桌面 SDK 的說明。  2 3

  4. Microsoft Learn, Set assembly attributes in a project file。關於 AssemblyVersion / FileVersion(去除後綴)與 InformationalVersion 預設會從 Version 屬性產生,以及 .NET 8 SDK 以後 InformationalVersion 會附加 SourceRevisionId(commit hash)的說明。  2 3 4

  5. Microsoft Learn, Code signing options for Windows app developers。關於 2023 年 6 月起依 CA/Browser Forum 要求,OV 憑證的私密金鑰必須以 HSM/硬體金鑰保管、EV 憑證的首次 SmartScreen 避開效果已於 2024 年廢止、Azure Artifact Signing(前身為 Trusted Signing)不需要金鑰即可與 GitHub Actions 等工具整合但可用地區有限,以及 Store 的 MSIX 發布會由 Microsoft 重新簽章的說明。  2 3 4 5 6 7 8 9 10 11 12

  6. Microsoft Learn, Sign an MSIX package。關於 Windows 要求 MSIX 套件必須具備有效的程式碼簽章,以及時間戳記能讓憑證過期後簽章驗證依然有效的說明。  2 3 4 5

  7. Microsoft Learn, Build .NET ClickOnce applications from the command line。關於 .NET 的 ClickOnce 發行需要使用指定發行設定檔的 msbuild /target:publish,以及 ApplicationRevision 在命令列建置中不會自動遞增的說明。  2 3 4

  8. GitHub Docs, Using GitHub CLI in workflows。關於 GitHub CLI(gh)已預先安裝在所有 GitHub 代管執行器上,以及使用 gh 的每個步驟都需要在 GH_TOKEN 環境變數中設定具備必要範圍的權杖的說明。  2 3

  9. Microsoft Learn, SignTool。關於 SignTool 內含於 Windows SDK、目前的建置版本必須指定 /fd/td 且建議使用 SHA256,以及透過 /tr 指定 RFC 3161 時間戳記的說明。  2

  10. GitHub Docs, Using secrets in GitHub Actions。關於 Secrets 的值會在紀錄中自動遮蔽、把憑證等二進位資料以 Base64 存進 Secrets 並在工作內還原的做法,以及由 fork 觸發的工作流程不會拿到 Secrets 的說明。  2 3 4

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

WinForms / WPF 應用程式的建置應該在 GitHub Actions 的哪一種執行器上進行?
應使用 windows-latest 之類的 Windows 執行器。WinForms / WPF 專案以 net8.0-windows 這類 Windows 專用的目標框架為對象,建置成果的運作確認以及透過 dotnet test 執行測試都需要 Windows 環境。GitHub 代管執行器會為每個工作配置全新的虛擬機器,因此可以取得不依賴開發者個人電腦的可重現建置。公開儲存庫使用標準執行器是免費的,私有儲存庫則依執行時間以分鐘計費。
程式碼簽章可以在 CI 中完全自動化嗎?
取決於憑證的持有方式。過去那種把 PFX 檔案放進 Secrets、再用 signtool 簽章的做法,由於 2023 年 6 月起 CA/Browser Forum 的要求規定公開的 OV 憑證私密金鑰必須以 HSM(硬體)保管,新申請的憑證原則上已無法使用該方式。USB 金鑰型憑證無法插入雲端執行器,因此若要在 CI 中完全自動化,現實的做法是使用 Azure Artifact Signing(前身為 Trusted Signing)這類雲端簽章服務,或透過 CA 提供的雲端 HSM。若仍採用實體金鑰運作,就只能把簽章這道工序留在本機或自架執行器上進行。
應該先從哪裡開始自動化?
應該先只導入建置+測試的自動化。只要做到每次 push 時都會在 windows-latest 執行器上跑 dotnet build / dotnet test,就能消除「只有開發者的電腦才能建置」、「合併後建置壞掉卻要到臨發布前才發現」這兩個最大的風險。簽章、安裝程式製作、發布的自動化可以之後再逐步加入,若一開始就想全部組好,往往會卡在簽章這一關。
發布形式(MSI / MSIX / ClickOnce / xcopy)不同,CI/CD 的建置難易度會有差異嗎?
差異很大。xcopy 發布(zip)只需要把 dotnet publish 的輸出壓縮起來,是最簡單的。MSIX 可以透過 MSBuild 與 signtool 納入 CI,但套件簽章是必要條件。MSI 可以透過在 CI 中呼叫 WiX 等工具來自動化。ClickOnce 無法用 dotnet CLI 發行,需要搭配 msbuild /target:publish 與發行設定檔,而且命令列方式不會自動遞增修訂版號,這點也要留意。在決定發布方式的階段,就應該把納入 CI 的難易度一併列入判斷依據。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽