自動構建與自動更新

2022-10-16 06:02:49

本文記錄的方法不是 WinUI 3 專屬的,也可以用於其他框架。

某不能說名字的軟體在開發時需要頻繁更新版本,為了兼顧開發體驗和使用者體驗,使用自動化流程達到這一目的。

自動構建

程式碼託管在 GitHub 上,使用 GitHub Actions 是最合適的選擇。在寫自動構建的流程之前,考慮了這麼幾個問題:

  1. GitHub 在部分地區速度太慢或不可用,不適合作為安裝包的下載來源,選擇了白嫖阿里雲 OSS 和 Cloudflare 代理。
  2. 為了區分自動構建的版本和發行版,需要在版本號後加上 -dev.*,選擇了在檔案的 ProductVersion 屬性中記錄當前版本號。

準備環境

執行 Actions 的系統為 windows-latest,預設 Shell 為 PowerShell

- name: Checkout
  uses: actions/checkout@v3
  with:
    # 遷出所有程式碼,後面有用
    fetch-depth: 0

- name: Install .NET Core
  uses: actions/setup-dotnet@v3
  with:
    dotnet-version: 7.0.x

- name: Restore the Packages
  run: dotnet restore

# 設定 阿里雲 OssUtil
- name: Setup Aliyun OssUtil
  run: |
    # 見下方 PowerShell 程式碼塊
# 下載適合 Windows 的 OssUtil 安裝包到臨時資料夾
Invoke-WebRequest https://gosspublic.alicdn.com/ossutil/1.7.14/ossutil64.zip -OutFile ${{runner.temp}}/ossutil.zip
# 解壓
Expand-Archive -Path ${{runner.temp}}/ossutil.zip -DestinationPath ${{runner.temp}}
# 把 exe 檔案移動到 system32 資料夾下,為了在後續步驟中可以直接使用 ossutil 命令
Move-Item -Path ${{runner.temp}}/ossutil64/ossutil64.exe -Destination C:/Windows/System32/ossutil.exe -Force
# 設定賬號
ossutil config -e ${{ secrets.OSS_ENDPOINT }} -i ${{ secrets.ACCESS_KEY_ID }} -k ${{ secrets.ACCESS_KEY_SECRET }}

版本號

每次構建的版本必須比上一次構建的版本要大,可以通過當前時間生成版本 v1.2.3-dev.221015.2201,或者通過環境變數 ${{github.run_number}} 獲取當前 Action 是第幾次執行,就是 Actions 頁面每次執行時 # 後面的那個數位。但是這兩個方法生成的版本號是一直遞增的,我比較傾向於使用 上個版本釋出後的提交數 作為 -dev. 後的數位。

下面是生成目標版本號的函數,可以不用看

function Get-TargetVersion {
    # 獲取上一個 tag,遷出程式碼時設定 fetch-depth: 0
    $lastTag = git describe --abbrev=0 --tags
    if ([String]::IsNullOrWhiteSpace($lastTag)) {
        # 沒有 tag
        $lastTag = 'v0.1.0'
        # 提交次數
        $commitCount = git rev-list HEAD --count
    }else {
        # 有 tag
        $commitCount = git rev-list "$lastTag..HEAD" --count
    }
    # 把 tag 開頭的 v 去掉
    if ($lastTag.StartsWith('v') -or $lastTag.StartsWith('V')) {
        $lastTag = $lastTag.SubString(1)
    }
    # 引入一個新庫 NuGet.Versioning
    $null = [System.Reflection.Assembly]::LoadFrom('NuGet.Versioning.dll')
    # TryParse 方法中使用 out 修飾的引數必須先定義
    [ref]$lastVer = [NuGet.Versioning.SemanticVersion]::Parse('0.1.0')
    # 解析上一個版本號,有可能是 v1.2.3、v1.2.3-preview.1 或更復雜的內容
    if ([NuGet.Versioning.SemanticVersion]::TryParse($lastTag, $lastVer)) {
        if ($lastVer.Value.IsPrerelease) {
            # 上個版本是預發行版,直接在後面加上 -dev.*
            $targetVer = "$lastVer-dev.$commitCount"
        }
        else {
            # 上個版本是正式版,版本號+1後再接 -dev.*
            $targetVer = "$($lastVer.Value.Major).$($lastVer.Value.Minor).$($lastVer.Value.Patch+1)-dev.$commitCount"
        }
    }
    else {
        # 理論上來說不會執行到這裡
        $targetVer = "$lastVer-dev.$commitCount"
    }
    Write-Output $targetVer
}

在這裡使用了一個庫 NuGet.Versioning,這是解析 語意化版本 v2.0 一個庫,和 System.Version 僅支援 4 個數位不同,它支援解析和比較如 1.2.3-preview.4.5.6+abcdef 這樣的格式。

釋出

$v = Get-TargetVersion
dotnet publish -p:Configuration=$env:Configuration -p:Platform=$env:Platform -p:Version=$v -p:DefineConstants=DEV

壓縮並上傳至 OSS

使用 zip 壓縮後檔案大小大約是 60 MB,使用 7zip 則是 40 MB,使用者的網路環境不同,能壓縮就儘量壓縮。在上傳到 OSS 時自定義後設資料 x-oss-meta-version 為目標版本號,軟體可以通過檢查這個值判斷是否應該更新。

# 安裝 7zip 壓縮模組
Install-Module -Name 7Zip4Powershell -Scope CurrentUser -Force
New-Item -Path ./publish -ItemType Directory -Force
# 壓縮等級最大,壓縮時包含根目錄
Compress-7Zip -ArchiveFileName software.7z -Path $env:Publish_Path -OutputPath ./publish -CompressionLevel Ultra -PreserveDirectoryRoot
# 上傳至 OSS
ossutil cp -rf ./publish/* oss://${{ secrets.OSS_BUCKET_NAME }}/software.7z --meta x-oss-meta-version:$v

自動更新

非打包的 WinUI 專案和 Win32 專案一樣,更新時會遇到檔案被佔用的情況,一般是通過額外的更新程序解決這個問題。不這麼一個小東西要啥自行車,用 PowerShell 一把梭,不僅能規避佔用問題,還能少一個專案。

我原本是打算在 PowerShell 中下載新版本安裝包的,PowerShell 會自動展示下載進度,但是我發現下載速度始終限制在 1 MB/s 左右,而在瀏覽器或 C# 中下載速度能夠達到 6 MB/s。在網上搜了一圈後發現是 實時顯示下載資料量拖慢了效能,參考 Issue Progress bar can significantly impact cmdlet performance,這個問題已在 PowerShell Core 修復,在 Windows 自帶的 PowerShell 中仍然存在,唯一的解決辦法是禁止顯示下載進度。

40 MB 的安裝包不顯示下載進度還是不太好,只能多寫一點程式碼在軟體中下載了(略),下面是 PowerShell 更新指令碼程式碼。

# 出現錯誤時停止執行後續命令,若不加這一句,即使出現錯誤也無法被 catch
# 程式碼省略了最外部的 try-catch 部分
$ErrorActionPreference = 'Stop'
# 檢查新版本的安裝包是否存在
if(![System.IO.File]::Exists('./temp/software.7z')) {
    # 不存在時重新下載
    $null = New-Item "./temp" -ItemType "Directory" -Force
    Invoke-WebRequest -Uri $url -UseBasicParsing -OutFile "./temp/software.7z"
}
# 檢查 7zip 解壓模組是否存在
if(![System.IO.File]::Exists('./7Zip4Powershell/7Zip4Powershell.psd1')) {
    # 應該使用下面這一句安裝解壓模組,但是大陸連線源站 PowerShell Gallery 非常困難
    # Install-Module -Name 7Zip4Powershell -Scope CurrentUser -Force
    # 所以需要自行分發解壓模組
    Invoke-WebRequest "url/to/7Zip4Powershell.zip" -UseBasicParsing -OutFile "./7Zip4Powershell.zip"
    Expand-Archive -Path "./7Zip4Powershell.zip" -DestinationPath "./" -Force
    Remove-Item -Path "./7Zip4Powershell.zip" -Force -Recurse
}
# 匯入模組
Import-Module -Name "./7Zip4Powershell/7Zip4Powershell.psd1" -Force
# 解壓
Expand-7Zip -ArchiveFileName "./temp/software.7z" -TargetPath "./temp/"
# 檢查軟體是否仍在執行
try {
    # 沒找到程序時會拋錯,需要 catch
    $null = Get-Process -Name "software"
    Write-Host "software.exe 正在執行,等待程序退出" -ForegroundColor Yellow
    Wait-Process -Name "software"
    # 停 1s 等待資源釋放
    Start-Sleep -Seconds 1
} catch { }
# 替換檔案
Copy-Item -Path "./temp/*" -Destination "./" -Force -Recurse
# 重啟軟體
Invoke-Item -Path "./software.exe"
# 清理安裝包
Remove-Item -Path "./temp" -Force -Recurse

就這樣,使用 PowerShell 代替了額外的更新程序,也不用考慮更新程序需要更新的問題。唯一需要注意的是手動匯入的 7zip 解壓模組中的檔案會被佔用,即使使用 Remove-Module 移除模組後,載入的程式集仍不會被解除安裝,所以不能在安裝包中包含該模組,使用前下載最合適,下載後可以一直使用,不用考慮更新問題。

有經驗的讀者可能會問:哎,系統預設禁用沒有簽名的 PowerShell 指令碼,你總不可能還要讓使用者手動解除限制吧。這個問題很好解決,不能執行指令碼可以執行命令嘛,把指令碼內容當作啟動引數扔給 PowerShell 程序就好了。

// 這裡不是指令碼檔案路徑,而是指令碼內容
const string script="...";
Process.Start("PowerShell", script);

最後

單程序軟體真的沒辦法自己更新嗎?其實不然,可執行檔案在執行期間無法被刪除和覆蓋,但是可以移動啊。把 exe 和 dll 檔案移動到一個待刪除資料夾,這個時候就可以把新檔案移動到原來的位置。但是還有個問題,非可執行檔案被佔用後不能移動,所以還是有可能會更新失敗,這個方法能用但是不靠譜。