From d1e4282eb6c672f9b7e7f97edebbf075ef430da4 Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Mon, 19 Oct 2026 16:01:26 +0800 Subject: [PATCH] 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. --- .github/workflows/dev-build.yml | 347 ++++++++++++++++++++++++++++++++ AGENTS.md | 2 + CONTRIBUTING.md | 21 ++ installer/import-dev-trust.ps1 | 83 ++++++++ installer/sign-dev-build.ps1 | 156 ++++++++++++++ 5 files changed, 609 insertions(+) create mode 100644 .github/workflows/dev-build.yml create mode 100644 installer/import-dev-trust.ps1 create mode 100644 installer/sign-dev-build.ps1 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."