ci: add the dev-build workflow for toolchain-free contributor builds

A contributor who only wants to try a change should not have to install
vcpkg, Boost, Inno Setup and the Windows SDK. The Dev build workflow builds,
packages and signs on the runner, and the artifact is a full installer plus
its signing certificate.

Three ways in: a feature-branch push builds automatically; a pull_request
run gives reviewers and fork contributors an installer from the check page
(pull_request deliberately, not pull_request_target -- building PR code
executes 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, main and release-please
maintenance branches never trigger it.

The certificate is minted per run by installer/sign-dev-build.ps1 with the
fixed subject CN=Metasequoia IME Dev Build Code Signing; the private key
lives only in the runner's memory. installer/import-dev-trust.ps1 ships in
the artifact for the user side: remove the previous certificate by subject,
import the new one, so repeated builds do not grow the trust stores.

The workflow reuses ci.yml's vcpkg cache scopes and sccache, downloads the
locked dictionaries, embeds the uiAccess manifest and passes the dev version
through MSIME_PRODUCT_VERSION_OVERRIDE so the about page shows the full dev
string. Artifacts expire in 7 days. ci.yml remains the merge gate; nothing
here creates a release or touches the update manifest.
This commit is contained in:
tokisaki galaxy committed 2026-10-19 16:01:26 +08:00
1 parent d376b93e03
commit d1e4282eb6
5 files changed
+609

No files matched your search

+347
View File
@@ -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.<date>.<hash>, 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_<LANG>_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
+2
View File
@@ -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 <runId> -n dev-installer` 拿安装包与证书,安装步骤见 [CONTRIBUTING.md](CONTRIBUTING.md#没有本地工具链时dev-build-workflow)。develop 和 main 不触发;版本号带 `-dev.<日期>.<commit>`,与正式发布隔离。
每个组件是独立的 CMake 工程,各自带 `vcpkg.json` 和 preset,从仓库根目录指定 `-S`:
```powershell
+21
View File
@@ -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 <runId> --exit-status
gh run download <runId> -n dev-installer
```
开向 develop 的 PR 也会自动跑一次,评审者和 fork 贡献者都能从 PR 的检查页拿到安装包。安装只有两步:管理员 PowerShell 里运行解压出来的 `import-dev-trust.ps1`(导入本次构建的签名证书并清掉上一张),然后双击安装包。版本号带 `-dev.<日期>.<commit>`,一眼可辨,设置页的更新检查不会提示它升级。每次新构建的证书都不同,重装前重跑一次导入脚本即可;产物保留 7 天。
几个边界:`workflow_dispatch` 需要仓库写权限;fork PR 的构建用 `pull_request` 触发(只读 token,无 secrets),首个 run 要维护者点一次批准;`component=tip` 或 `server` 只编译单个组件出裸二进制;这个 workflow 不出 release、不碰更新清单,合并门槛仍然由 PR 上的 CI 把守。
## 取代码
```powershell
+83
View File
@@ -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 安装包了。'
+156
View File
@@ -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."