自动构建与自动更新

时间:2022-10-16 07:11:40

本文记录的方法不是 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 文件移动到一个待删除文件夹,这个时候就可以把新文件移动到原来的位置。但是还有个问题,非可执行文件被占用后不能移动,所以还是有可能会更新失败,这个方法能用但是不靠谱。