diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 00000000..fd53c561 --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,347 @@ +name: Dev build + +# Development builds for contributors. The point is that a newcomer does not install vcpkg, +# Boost, Inno Setup and the Windows SDK to try a change; the runner builds, packages and signs, +# and the artifact is a full installer with a fresh self-signed certificate plus the public key +# to trust it. +# +# Three ways in. A feature-branch push builds automatically, so the artifact is ready when the +# push finishes. A pull_request run gives reviewers and fork contributors an installer for real +# device testing -- it uses pull_request, not pull_request_target, deliberately: building the +# PR's code runs scripts from that code, and pull_request_target would hand those scripts a +# writable token. workflow_dispatch stays for picking a single component or rebuilding without +# a push. develop and main never trigger this; they have ci.yml and release.yml. +# +# This is a test channel, not a release: the version always carries -dev.., the +# certificate is minted per run, and nothing here creates a release or touches the update +# manifest. ci.yml remains the merge gate; this workflow deliberately skips the suite the CI +# already runs and only builds what an installable artifact needs. +on: + workflow_dispatch: + inputs: + component: + description: Which component to build (all = full signed installer) + required: true + type: choice + default: all + options: + - all + - tip + - server + push: + branches-ignore: + - develop + - main + - 'release-please--*' + tags-ignore: + - '**' + paths-ignore: + - '**.md' + - 'docs/**' + pull_request: + branches: + - develop + paths-ignore: + - '**.md' + - 'docs/**' + +permissions: + contents: read + +# push and pull_request have no inputs; both build everything, since those events exist to +# produce an installable artifact and a component filter nobody can set would only mislead. + +# Artifacts carry a working installer plus its signing certificate. The default 90-day retention +# would pile up hundreds of megabytes per push on an active branch, so every upload below passes +# retention-days: 7 -- enough to notice a build and install it, and the next push replaces it. +env: + DEV_ARTIFACT_RETENTION: 7 + +# Same ref, newer run wins; an older run keeps burning a runner for nothing. +concurrency: + group: dev-build-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tip: + name: TSF ${{ matrix.name }} + if: ${{ (inputs.component || 'all') == 'all' || (inputs.component || 'all') == 'tip' }} + runs-on: windows-2025 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - name: x64 + architecture: x64 + triplet: x64-windows-static + build_dir: windows/build64-release + - name: Win32 + architecture: Win32 + triplet: x86-windows-static + build_dir: windows/build32-release + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + - name: Stamp the dev version into the resource + shell: pwsh + run: | + # YYMMDD in the fourth VERSIONINFO slot: the quad must stay four 16-bit fields, and a + # date-compressed value sorts a dev DLL above its own base and below the next release. + $base = python scripts/dev-build-version.py --base + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $date = python scripts/dev-build-version.py --date + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python scripts/apply_version.py --version "$base.$date" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Restore dependency binaries + id: vcpkg-cache + uses: ./.github/actions/vcpkg-cache + with: + scope: tip-${{ matrix.triplet }} + - name: Install dependencies + shell: pwsh + working-directory: windows + run: vcpkg install --triplet ${{ matrix.triplet }} + - name: Save dependency binaries before compiling + if: steps.vcpkg-cache.outputs.matched-key != format('{0}-complete', steps.vcpkg-cache.outputs.key) + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.vcpkg-cache.outputs.path }} + key: ${{ steps.vcpkg-cache.outputs.key }}-complete + - name: Configure + shell: pwsh + run: > + cmake -S windows -B ${{ matrix.build_dir }} -A ${{ matrix.architecture }} + -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" + -DVCPKG_TARGET_TRIPLET=${{ matrix.triplet }} + - name: Build + shell: pwsh + run: cmake --build ${{ matrix.build_dir }} --config Release --parallel + - name: Check the Release binary and symbols + shell: pwsh + run: ./scripts/ci/check-tsf-dll.ps1 -BuildDir '${{ matrix.build_dir }}' + - name: Upload the TSF binary and symbols + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dev-tsf-${{ matrix.name }} + retention-days: 7 + # Same layout convention as the release workflow: download-artifact restores the + # directory contents where Prepare-PackageFiles.ps1 expects them. + path: ${{ matrix.build_dir }}/Release + if-no-files-found: error + + server: + name: Server + if: ${{ (inputs.component || 'all') == 'all' || (inputs.component || 'all') == 'server' }} + runs-on: windows-2025 + timeout-minutes: 90 + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_IGNORE_SERVER_IO_ERROR: "1" + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + - name: Stamp the dev version into the resource + # The TSF DLL carries the product's VERSIONINFO even in a server-only build, because the + # resource is stamped before configure; keep every build of this run on one version. + shell: pwsh + run: | + $base = python scripts/dev-build-version.py --base + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $date = python scripts/dev-build-version.py --date + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python scripts/apply_version.py --version "$base.$date" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Restore dependency binaries + id: vcpkg-cache + uses: ./.github/actions/vcpkg-cache + with: + scope: server-x64-windows-and-static-md + - name: Set up MSVC environment + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 + - name: Set up sccache + id: sccache + continue-on-error: true + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - name: Install Boost + shell: pwsh + run: ./scripts/ci/install-boost.ps1 + - name: Save Boost binaries before configuring + if: steps.vcpkg-cache.outputs.matched-key != format('{0}-complete', steps.vcpkg-cache.outputs.key) && steps.vcpkg-cache.outputs.matched-key != format('{0}-boost', steps.vcpkg-cache.outputs.key) + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.vcpkg-cache.outputs.path }} + key: ${{ steps.vcpkg-cache.outputs.key }}-boost + # Ninja Multi-Config so sccache plugs in through CMAKE__COMPILER_LAUNCHER, mirroring + # ci.yml's Server job; the bin/Release layout is what every later step reads. The override + # embeds the full dev version string into the native settings page; server/CMakeLists.txt + # accepts a semver prerelease suffix, and the about page's ParseVersion strips it for the + # update check while displaying it verbatim. + - name: Configure + shell: pwsh + run: | + $display = python scripts/dev-build-version.py --display + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $cmakeArgs = @( + '-S', 'server', '-B', 'server/build-release', '-G', 'Ninja Multi-Config', + "-DCMAKE_TOOLCHAIN_FILE=$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake", + '-DVCPKG_TARGET_TRIPLET=x64-windows', + "-DMSIME_PRODUCT_VERSION_OVERRIDE=$display" + ) + $cmakeArgs += @(./scripts/ci/cmake-sccache-args.ps1 -SetupOutcome '${{ steps.sccache.outcome }}') + cmake @cmakeArgs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Save dependency binaries before compiling + if: steps.vcpkg-cache.outputs.matched-key != format('{0}-complete', steps.vcpkg-cache.outputs.key) + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.vcpkg-cache.outputs.path }} + key: ${{ steps.vcpkg-cache.outputs.key }}-complete + - name: Build + shell: pwsh + run: cmake --build server/build-release --config Release --parallel + - name: Check the Release binaries and symbols + shell: pwsh + run: ./scripts/ci/check-server-binaries.ps1 -BuildDir server/build-release/bin/Release + - name: Test with the product's exact data + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./scripts/ci/test-server.ps1 -ServerRoot server -StagingRoot "$env:RUNNER_TEMP/product-data" -BuildDir build-release + # uiAccess=true is ignored without a signature, so the manifest goes in before upload and + # the whole payload gets signed in the package job. + - name: Embed the Server uiAccess manifest + shell: pwsh + run: ./scripts/ci/embed-server-manifest.ps1 -BuildDir server/build-release/bin/Release + - name: Upload the Server build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dev-server + retention-days: 7 + path: server/build-release/bin/Release + if-no-files-found: error + + package: + name: Package installer + needs: [tip, server] + # Only component=all produces an installer: packaging needs both TSF architectures and the + # Server, so there is no meaningful way to package a single-component run. + if: ${{ !cancelled() && needs.tip.result == 'success' && needs.server.result == 'success' && (inputs.component || 'all') == 'all' }} + runs-on: windows-2025 + timeout-minutes: 60 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + - name: Read the dev version + id: version + shell: pwsh + run: | + $display = python scripts/dev-build-version.py --display + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "display=$display" >> $env:GITHUB_OUTPUT + "installer=installer/Output/MetasequoiaIME_Setup_v$display.exe" >> $env:GITHUB_OUTPUT + - name: Download the x64 TSF binary and symbols + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dev-tsf-x64 + path: windows/build64-release/Release + - name: Download the 32-bit TSF binary and symbols + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dev-tsf-Win32 + path: windows/build32-release/Release + - name: Download the Server build + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dev-server + path: server/build-release/bin/Release + - name: Download the dictionaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STAGING_ROOT: . + run: bash scripts/ci/download-dictionaries.sh + - name: Build the word-lattice language model + shell: pwsh + run: ./scripts/build-language-model.ps1 + - name: Download the neural sentence models + shell: pwsh + run: ./scripts/fetch-neural-model.ps1 + - name: Collect the installer payload + shell: pwsh + working-directory: installer + env: + TARGET_VERSION: ${{ steps.version.outputs.display }} + run: > + pwsh -File ./Prepare-PackageFiles.ps1 -TargetVersion $env:TARGET_VERSION + -RepoRoot (Resolve-Path '..').Path + -TsfDirectory windows -ServerDirectory server -NoticesDirectory . + -HelpCodeDirectory engine/helpcode + - name: Sign the payload binaries + shell: pwsh + working-directory: installer + env: + DEV_CERT_PATH: ${{ runner.temp }}/dev-build.cer + run: ./sign-dev-build.ps1 + - name: Install the Chinese language file for Inno Setup + shell: pwsh + run: ./scripts/ci/install-inno-language.ps1 + - name: Compile the installer + shell: pwsh + working-directory: installer + run: pwsh -File ./Compile-Installer.ps1 + - name: Sign the installer + shell: pwsh + working-directory: installer + env: + DEV_CERT_PATH: ${{ runner.temp }}/dev-build.cer + run: ./sign-dev-build.ps1 -Installer + - name: Name the artifact and record its checksum + id: asset + shell: pwsh + working-directory: installer + env: + TARGET_VERSION: ${{ steps.version.outputs.display }} + run: | + $built = "Output/MetasequoiaIME_Setup_v$env:TARGET_VERSION.exe" + if (-not (Test-Path $built)) { throw "Inno Setup did not produce $built" } + $hash = (Get-FileHash $built -Algorithm SHA256).Hash.ToLower() + $size = [math]::Round((Get-Item $built).Length / 1MB, 1) + Write-Host "$built ($size MB, sha256 $hash)" + "installer_path=$((Resolve-Path $built).Path)" >> $env:GITHUB_OUTPUT + "installer_sha256=$hash" >> $env:GITHUB_OUTPUT + - name: Upload the installer and the signing certificate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dev-installer + retention-days: 7 + path: | + ${{ steps.asset.outputs.installer_path }} + ${{ runner.temp }}/dev-build.cer + installer/import-dev-trust.ps1 + if-no-files-found: error + - name: Summarise the build + shell: pwsh + env: + TARGET_VERSION: ${{ steps.version.outputs.display }} + INSTALLER_SHA256: ${{ steps.asset.outputs.installer_sha256 }} + run: | + @( + '### Dev build' + '' + "| Field | Value |" + "| --- | --- |" + "| Version | $env:TARGET_VERSION |" + "| Installer SHA256 | ``$env:INSTALLER_SHA256`` |" + '' + 'To install: download the artifact, unzip, then in an **admin** PowerShell run' + '`import-dev-trust.ps1` and launch the installer. Re-run the trust import for every' + 'new build; the certificate is minted per run and the script replaces the old one.' + ) >> $env:GITHUB_STEP_SUMMARY diff --git a/AGENTS.md b/AGENTS.md index dd1e0c16..82c92156 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ Server 当前仍使用引擎的兼容 `InputSession`,尚未迁移公共 `Sessi ## 构建 +**用户想编译验证但没装工具链时,优先用 dev-build workflow 而不是让用户装 vcpkg**:特性分支 push 自动触发,PR 会自动跑一次,或 `gh workflow run dev-build.yml --ref <分支> -f component=all` 手动触发,然后 `gh run watch` 等它,`gh run download -n dev-installer` 拿安装包与证书,安装步骤见 [CONTRIBUTING.md](CONTRIBUTING.md#没有本地工具链时dev-build-workflow)。develop 和 main 不触发;版本号带 `-dev.<日期>.`,与正式发布隔离。 + 每个组件是独立的 CMake 工程,各自带 `vcpkg.json` 和 preset,从仓库根目录指定 `-S`: ```powershell diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d81b8387..31f838db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,27 @@ Boost 按 `-DBoost_ROOT=...` → 环境变量 `BOOST_ROOT` → `%USERPROFILE%\scoop\apps\boost\current` 的顺序解析。preset 里的 vcpkg 路径取自 `$env{VCPKG_ROOT}`,不需要改文件。 +### 没有本地工具链时:dev-build workflow + +只想验证一个小改动、不想为它装一整套工具链的话,让 CI 编。`Dev build` workflow 平时不跑主分支(develop 和 main 有自己的 CI 和发布流),三种触发方式: + +```powershell +# 特性分支一推就自动编,不需要任何手动操作 +git push -u origin fix/xxx + +# 或者手动触发,可选单个组件快速迭代 +gh workflow run dev-build.yml --ref <你的分支> -f component=all + +# 等 run 结束拿产物;agent 也可以在这里等 +gh run list --workflow=dev-build.yml --branch <你的分支> --limit 1 --json databaseId,status +gh run watch --exit-status +gh run download -n dev-installer +``` + +开向 develop 的 PR 也会自动跑一次,评审者和 fork 贡献者都能从 PR 的检查页拿到安装包。安装只有两步:管理员 PowerShell 里运行解压出来的 `import-dev-trust.ps1`(导入本次构建的签名证书并清掉上一张),然后双击安装包。版本号带 `-dev.<日期>.`,一眼可辨,设置页的更新检查不会提示它升级。每次新构建的证书都不同,重装前重跑一次导入脚本即可;产物保留 7 天。 + +几个边界:`workflow_dispatch` 需要仓库写权限;fork PR 的构建用 `pull_request` 触发(只读 token,无 secrets),首个 run 要维护者点一次批准;`component=tip` 或 `server` 只编译单个组件出裸二进制;这个 workflow 不出 release、不碰更新清单,合并门槛仍然由 PR 上的 CI 把守。 + ## 取代码 ```powershell diff --git a/installer/import-dev-trust.ps1 b/installer/import-dev-trust.ps1 new file mode 100644 index 00000000..0aae8658 --- /dev/null +++ b/installer/import-dev-trust.ps1 @@ -0,0 +1,83 @@ +# 信任一张 dev-build 签名证书并清理旧证书。 +# +# CI 的 dev-build workflow 每次运行现场生成一张自签名代码签名证书,签完整包后把公钥 +# (.cer)放进 artifact。指纹每次都变,但 subject 固定,所以本脚本先把本地受信任存储里 +# 旧 subject 的证书清掉,再导入新证书,测试机的受信任根不会随构建次数膨胀。 +# +# 用管理员 PowerShell 运行(写 LocalMachine 存储需要提升): +# pwsh -File .\import-dev-trust.ps1 [-CertificatePath .\dev-build.cer] [-Keep] +# +# 导入完成就可以安装同目录下的 MetasequoiaIME_Setup_v*-dev.*.exe。 +# -Keep 保留旧证书不清理(排查签名问题时用)。 +[CmdletBinding()] +param( + # 默认取脚本同目录下唯一的 .cer;有多个时报错让用户指定,避免导错文件。 + [string]$CertificatePath, + [switch]$Keep +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$Subject = 'CN=Metasequoia IME Dev Build Code Signing' +$TrustStores = @('Cert:\LocalMachine\Root', 'Cert:\LocalMachine\TrustedPublisher') + +function Test-Administrator { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [System.Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not (Test-Administrator)) { + throw '需要管理员 PowerShell:写入 LocalMachine 受信任存储必须提升权限。' +} + +if (-not $CertificatePath) { + $candidates = @( + Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.cer' -File -ErrorAction SilentlyContinue + ) + if ($candidates.Count -eq 0) { + throw "当前目录没有 .cer 文件。请把 dev-build artifact 解压后在本目录运行:$PSScriptRoot" + } + if ($candidates.Count -gt 1) { + throw "发现多个 .cer 文件,请用 -CertificatePath 指定一个:$($candidates.Name -join ', ')" + } + $CertificatePath = $candidates[0].FullName +} +if (-not (Test-Path -LiteralPath $CertificatePath -PathType Leaf)) { + throw "证书文件不存在:$CertificatePath" +} + +$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertificatePath) +if ($certificate.Subject -ne $Subject) { + throw "这不是 dev-build 签名证书(subject:$($certificate.Subject))。预期:$Subject" +} + +# 按固定 subject 清理所有旧证书,再导入新的一张。跳过与本次导入指纹相同的那份。 +if (-not $Keep) { + foreach ($store in $TrustStores) { + $stale = @(Get-ChildItem -Path $store -ErrorAction SilentlyContinue | + Where-Object { $_.Subject -eq $Subject -and $_.Thumbprint -ne $certificate.Thumbprint }) + foreach ($old in $stale) { + Remove-Item -LiteralPath ("{0}\{1}" -f $store, $old.Thumbprint) -Force + Write-Host "已移除旧 dev 证书:$($old.Thumbprint)($store)" + } + } +} + +$temporaryFile = Join-Path ([IO.Path]::GetTempPath()) ('msime-dev-trust-' + [Guid]::NewGuid() + '.cer') +try { + [IO.File]::Copy($CertificatePath, $temporaryFile, $true) + foreach ($store in $TrustStores) { + Import-Certificate -FilePath $temporaryFile -CertStoreLocation $store | Out-Null + Write-Host "已信任:$store" + } +} +finally { + Remove-Item -LiteralPath $temporaryFile -Force -ErrorAction SilentlyContinue +} + +Write-Host '' +Write-Host "Dev 证书已就绪:$($certificate.Subject)" +Write-Host "指纹:$($certificate.Thumbprint)(本次构建专有,换新 build 需重跑本脚本)" +Write-Host '现在可以安装同目录下的 dev 安装包了。' diff --git a/installer/sign-dev-build.ps1 b/installer/sign-dev-build.ps1 new file mode 100644 index 00000000..cfd93a69 --- /dev/null +++ b/installer/sign-dev-build.ps1 @@ -0,0 +1,156 @@ +# 在 runner 上现场生成自签名代码签名证书,签 dev-build 的包内二进制与安装包。 +# +# dev-build workflow 每次运行生成一张新证书,私钥只存在于 runner 内存中,运行结束即消失; +# 公钥导出成 .cer 进 artifact,由用户用 import-dev-trust.ps1 导入本机受信任存储。 +# uiAccess=true 的 Server 没有可信签名会被 Windows 忽略,所以签名是 dev 包可用的前提。 +# +# 环境变量:DEV_CERT_PATH(.cer 输出路径)。在 installer/ 目录下运行,与 +# Sign-PackageBinaries-Local.ps1 相同的待签集合(server_exe/ 与 tsf_dll/ 下全部 EXE/DLL)。 +# +# pwsh -File .\sign-dev-build.ps1 # 签包内二进制 +# pwsh -File .\sign-dev-build.ps1 -Installer # 签编译完成的安装包 +# pwsh -File .\sign-dev-build.ps1 -FilePath a.exe,b.exe # 签显式列出的文件(server 单独构建用) +[CmdletBinding()] +param( + [switch]$Installer, + [string[]]$FilePath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$CertificateSubject = 'CN=Metasequoia IME Dev Build Code Signing' +$CodeSigningEku = '1.3.6.1.5.5.7.3.3' + +function Find-SignTool { + $kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $candidate = Get-ChildItem -LiteralPath $kitsBin -Directory -ErrorAction SilentlyContinue | + Sort-Object { try { [version]$_.Name } catch { [version]'0.0' } } -Descending | + ForEach-Object { Join-Path $_.FullName 'x64\signtool.exe' } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + Select-Object -First 1 + if (-not $candidate) { + throw 'signtool.exe not found in the Windows SDK.' + } + return $candidate +} + +# 同一次运行里重复调用本脚本时复用签名步骤生成的证书,保持包内二进制与安装包同一签名。 +function Get-RunCertificate { + $now = Get-Date + $candidates = @( + Get-ChildItem -Path 'Cert:\CurrentUser\My' | + Where-Object { + $_.Subject -eq $CertificateSubject -and + $_.HasPrivateKey -and + $_.NotBefore -le $now -and + $_.NotAfter -gt $now -and + ($_.EnhancedKeyUsageList.ObjectId -contains $CodeSigningEku) + } | + Sort-Object NotAfter -Descending + ) + if ($candidates.Count -eq 0) { + return $null + } + return $candidates[0] +} + +function New-RunCertificate { + # -Type CodeSigningCert 负责代码签名 EKU。有效期对齐 artifact 的 7 天保留期再放宽, + # 避免 runner 时钟略慢导致证书尚未生效。 + $certificate = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $CertificateSubject ` + -FriendlyName 'Metasequoia IME Dev Build Code Signing' ` + -KeyAlgorithm RSA ` + -KeyLength 3072 ` + -HashAlgorithm SHA256 ` + -KeyUsage DigitalSignature ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -NotBefore (Get-Date).AddHours(-1) ` + -NotAfter (Get-Date).AddDays(30) + return $certificate +} + +$certificate = Get-RunCertificate +if ($certificate) { + Write-Host "Reusing the certificate created earlier in this run: $($certificate.Thumbprint)" +} +else { + $certificate = New-RunCertificate + Write-Host "Created the dev-build signing certificate: $($certificate.Thumbprint)" +} + +$certPath = $env:DEV_CERT_PATH +if (-not $certPath) { + throw 'DEV_CERT_PATH is not set; the workflow must say where to export the public key.' +} +$certDirectory = Split-Path -Parent $certPath +if (-not (Test-Path -LiteralPath $certDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $certDirectory -Force | Out-Null +} +Export-Certificate -Cert $certificate -FilePath $certPath -Type CERT -Force | Out-Null +Write-Host "Public key exported to $certPath" + +$signTool = Find-SignTool + +if ($Installer) { + $candidates = @(Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot 'Output') -Filter '*.exe' -File -ErrorAction SilentlyContinue) + if ($candidates.Count -eq 0) { + throw 'No installer found under Output\. Compile it first.' + } + if ($candidates.Count -gt 1) { + throw "Multiple installers under Output\, refusing to guess: $($candidates.Name -join ', ')" + } + $targets = @($candidates[0].FullName) +} +elseif ($FilePath) { + $targets = @( + foreach ($item in $FilePath) { + if (-not (Test-Path -LiteralPath $item -PathType Leaf)) { + throw "File to sign does not exist: $item" + } + (Resolve-Path -LiteralPath $item).Path + } + ) +} +else { + $packageDirectories = @( + (Join-Path $PSScriptRoot 'server_exe'), + (Join-Path $PSScriptRoot 'tsf_dll') + ) + foreach ($directory in $packageDirectories) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + throw "Missing staging directory, run Prepare-PackageFiles.ps1 first: $directory" + } + } + # 最外层的 @() 不能省:只有一个待签文件时管道结果是标量,.Count 在严格模式下会报错。 + $targets = @( + @( + foreach ($directory in $packageDirectories) { + Get-ChildItem -LiteralPath $directory -Recurse -File | + Where-Object { $_.Extension -in @('.exe', '.dll') } + } + ) | Sort-Object FullName -Unique + ) | ForEach-Object { $_.FullName } + if ($targets.Count -eq 0) { + throw 'No EXE or DLL found to sign.' + } +} + +$thumbprint = $certificate.Thumbprint +Write-Host "Signing $($targets.Count) file(s) with $thumbprint" +# 自签名证书的时间戳没有意义(测试机可能离线,且证书随 build 轮换),所以不加 /tr。 +& $signTool sign /sha1 $thumbprint /s My /fd sha256 /v @($targets) +if ($LASTEXITCODE -ne 0) { + throw "signtool.exe signing failed with exit code $LASTEXITCODE" +} + +foreach ($target in $targets) { + $signature = Get-AuthenticodeSignature -LiteralPath $target + if (-not $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -ne $thumbprint) { + throw "Signature missing or signed by the wrong certificate: $target" + } +} +Write-Host "Signed $($targets.Count) file(s); every signature verified against the dev certificate."