From 43452f1ff93e60f86aecafb16fb99e249a321d19 Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Mon, 19 Oct 2026 16:00:06 +0800 Subject: [PATCH 1/7] build: derive a dev version string from version.txt scripts/dev-build-version.py turns version.txt plus the head commit into 0.7.1-dev.20261019.: the -dev display string for installer names and the native settings page, and a YYMMDD stamp for the fourth VERSIONINFO field. apply_version.py accepts an optional fourth quad field to carry that stamp; releases leave it at 0, so their behaviour is unchanged. The settings page's version parser strips the -dev suffix and compares numbers, which is what keeps the update check quiet on dev installs -- no opt-out flag needed. --- scripts/apply_version.py | 21 +++++--- scripts/dev-build-version.py | 100 +++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 scripts/dev-build-version.py diff --git a/scripts/apply_version.py b/scripts/apply_version.py index e76925cb..0dd558d4 100644 --- a/scripts/apply_version.py +++ b/scripts/apply_version.py @@ -5,7 +5,8 @@ release-please owns version.txt. The VERSIONINFO block in windows/src/IME/Metase same number in two shapes, a comma-separated quad and a dotted string, so it cannot be driven by release-please's generic updater. The release workflow runs this before configuring CMake. -The fourth field of the quad stays 0; only MAJOR.MINOR.PATCH are release-managed. +The fourth field of the quad stays 0 for releases; a dev build passes an explicit +--version MAJOR.MINOR.PATCH.YYMMDD to stamp its date there. python scripts/apply_version.py # stamp from version.txt python scripts/apply_version.py --check # exit 1 if the resource is out of date @@ -23,7 +24,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] VERSION_FILE = REPO_ROOT / "version.txt" RESOURCE_FILE = REPO_ROOT / "windows" / "src" / "IME" / "MetasequoiaIME.rc" -SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$") # Each pattern captures the part before the version and the part after it, so only the digits move. SUBSTITUTIONS = ( @@ -34,7 +35,7 @@ SUBSTITUTIONS = ( ) -def read_version(explicit_version: str | None = None) -> tuple[str, str, str]: +def read_version(explicit_version: str | None = None) -> tuple[str, str, str, str]: if explicit_version is None: if not VERSION_FILE.is_file(): raise SystemExit(f"error: {VERSION_FILE} is missing") @@ -45,13 +46,17 @@ def read_version(explicit_version: str | None = None) -> tuple[str, str, str]: source = "--version" match = SEMVER.match(raw) if not match: - raise SystemExit(f"error: {source} must hold MAJOR.MINOR.PATCH, got {raw!r}") - return match.groups() + raise SystemExit( + f"error: {source} must hold MAJOR.MINOR.PATCH[.BUILD], got {raw!r}" + ) + # The optional fourth field carries a dev build's YYMMDD stamp; releases leave it at 0. + major, minor, patch, build_field = match.groups() + return major, minor, patch, build_field or "0" -def render(version: tuple[str, str, str]) -> tuple[str, str]: - major, minor, patch = version - return f"{major},{minor},{patch},0", f"{major}.{minor}.{patch}.0" +def render(version: tuple[str, str, str, str]) -> tuple[str, str]: + major, minor, patch, build_field = version + return f"{major},{minor},{patch},{build_field}", f"{major}.{minor}.{patch}.{build_field}" def apply(text: str, quad: str, dotted: str) -> tuple[str, int]: diff --git a/scripts/dev-build-version.py b/scripts/dev-build-version.py new file mode 100644 index 00000000..df31cf65 --- /dev/null +++ b/scripts/dev-build-version.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Derive the dev-build version from version.txt. + +The release owns version.txt (release-please), and dev builds must never masquerade as one. +The dev number keeps the release base for human recognition and appends a date and the short +commit: 0.7.1-dev.20261019.7fa6fb1a. + +The strings go to the installer name and the settings page. The TSF DLL's VERSIONINFO quad has +four 16-bit fields, so the date compresses to YYMMDD in the fourth slot: 0.7.1.261019. yy > 79 +sorts above every year this product exists, and any real release leaves that slot at 0, so a dev +DLL always reads as newer than its own base and older than the next release. The settings page's +parseVersion strips the -dev suffix and compares 0.7.1, which is why the update check stays quiet. + +Windows filenames and Inno Setup accept the dots in the short hash; a detached HEAD (workflow +dispatch on a raw SHA) falls back to the first twelve hex digits. + + python scripts/dev-build-version.py # all three outputs, one per line + python scripts/dev-build-version.py --display # 0.7.1-dev.20261019.7fa6fb1a + python scripts/dev-build-version.py --base # 0.7.1 + python scripts/dev-build-version.py --date # 261019 +""" + +from __future__ import annotations + +import argparse +import datetime +import re +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +VERSION_FILE = REPO_ROOT / "version.txt" + +SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +SHORT_SHA = re.compile(r"^[0-9a-f]{7,40}$") + + +def read_base() -> tuple[str, str, str]: + if not VERSION_FILE.is_file(): + raise SystemExit(f"error: {VERSION_FILE} is missing") + raw = VERSION_FILE.read_text(encoding="utf-8").strip() + match = SEMVER.match(raw) + if not match: + raise SystemExit(f"error: version.txt must hold MAJOR.MINOR.PATCH, got {raw!r}") + return match.groups() + + +def read_short_sha(explicit: str | None) -> str: + if explicit: + sha = explicit.strip().lower() + if not SHORT_SHA.match(sha): + raise SystemExit(f"error: --commit must be hex digits, got {explicit!r}") + return sha[:12] + result = subprocess.run( + ["git", "rev-parse", "--short=12", "HEAD"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise SystemExit(f"error: cannot read the commit: {result.stderr.strip()}") + return result.stdout.strip() + + +def build(base: tuple[str, str, str], today: datetime.date, sha: str) -> tuple[str, str]: + major, minor, patch = base + display = f"{major}.{minor}.{patch}-dev.{today:%Y%m%d}.{sha}" + # 四段 quad 的末位放 YYMMDD:YY > 79 恒大于本产品存在的一切年份,任何正式版该位是 0, + # 所以 dev DLL 恒读作比基线新、比下一个发布旧。 + date = today.strftime("%y%m%d") + quad = f"{major}.{minor}.{patch}.{date}" + return display, date + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + output = parser.add_mutually_exclusive_group() + output.add_argument("--display", action="store_true", help="print the display version only") + output.add_argument("--base", action="store_true", help="print the base version only") + output.add_argument("--date", action="store_true", help="print the YYMMDD stamp only") + parser.add_argument("--commit", metavar="SHA", help="use this short commit instead of reading git") + args = parser.parse_args() + + base = read_base() + display, date = build(base, datetime.date.today(), read_short_sha(args.commit)) + if args.display: + print(display) + elif args.base: + print(".".join(base)) + elif args.date: + print(date) + else: + print(display) + print(date) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From bb4c48505a19b4f96b56bfdb2a8db4cf6ace18d7 Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Mon, 19 Oct 2026 16:00:20 +0800 Subject: [PATCH 2/7] server: accept a semver prerelease suffix in the product version The validation added in #1084 rejected anything but digits and dots, which is narrower than semver: a prerelease suffix after a hyphen is part of the spec, and the native about page's ParseVersion already strips such a suffix for the update check while displaying it verbatim. Allow exactly one hyphen segment of [0-9A-Za-z.-] after the numeric body, so dev builds can embed 0.7.1-dev.20261019. and the about page shows the full string. version.txt itself stays bare MAJOR.MINOR.PATCH and every release passes unchanged. lcompile-release.ps1 feeds the same override locally, so its ValidatePattern loosens in step. --- server/CMakeLists.txt | 5 ++++- server/scripts/lcompile-release.ps1 | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 56937097..56f90b5f 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -43,7 +43,10 @@ else() file(STRINGS "${MSIME_VERSION_FILE}" MSIME_PRODUCT_VERSION LIMIT_COUNT 1) endif() string(STRIP "${MSIME_PRODUCT_VERSION}" MSIME_PRODUCT_VERSION) -if(NOT MSIME_PRODUCT_VERSION MATCHES "^[0-9]+(\\.[0-9]+)*$") +# 版本主体必须是数字段;允许一个可选的连字符后缀(semver 预发布段),dev-build 用它携带 +# 日期与 commit(0.7.1-dev.20261019.),设置窗口关于页原样显示。关于页的 ParseVersion +# 会剥掉后缀做更新检查的数字比较,所以后缀不影响版本判定。 +if(NOT MSIME_PRODUCT_VERSION MATCHES "^[0-9]+(\\.[0-9]+)*(-[0-9A-Za-z.-]+)?$") message(FATAL_ERROR "Invalid product version in ${MSIME_VERSION_FILE}: '${MSIME_PRODUCT_VERSION}'") endif() configure_file( diff --git a/server/scripts/lcompile-release.ps1 b/server/scripts/lcompile-release.ps1 index a112b0bb..e77a3473 100644 --- a/server/scripts/lcompile-release.ps1 +++ b/server/scripts/lcompile-release.ps1 @@ -2,7 +2,7 @@ [CmdletBinding()] param( [string]$ManifestTool = 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\mt.exe', - [ValidatePattern('^\d+\.\d+\.\d+$')] + [ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')] [string]$ProductVersion = '', # ALL_BUILD also builds MetasequoiaImeServerTests and test_webview_contract, which the packaging # step deletes again. The server target already depends on the watchdog, the three panels, From d376b93e036cee27032f4377fa7dee77b0b2c80c Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Mon, 19 Oct 2026 16:01:05 +0800 Subject: [PATCH 3/7] installer: let the packaging carry a prerelease version string Two independent obstacles surfaced the first time a version with a hyphen reached the installer: - Inno Setup's VersionInfoVersion only accepts digits and dots, so ISCC aborted on 0.7.1-dev.20261019.. AppVersion, the output filename and the versioned DLL directory are free text and keep the full string; only VersionInfoVersion moves to a separate MyVersionInfo define that Prepare-PackageFiles.ps1 fills with the part before the hyphen, failing loudly if what remains is not numeric. Releases pass a bare semver, strip nothing, and behave exactly as before. - TsfRoot64 called ApplyPathRedirRulesForCurrentProcess, an Inno Setup 7 support function the windows-2025 runner's 6.7.1 does not have. #925 added it five hours after the last hosted-compiler run, and every release since then compiled on the self-hosted Inno 7, so the latent break never fired; the dev-build's hosted package job is the first to execute that path. The call was a no-op anyway: with ArchitecturesInstallIn64BitMode=x64compatible the setup process is 64-bit on every supported OS, so {sys} already expands to the real System32 and no WOW64 redirection applies. Expand the constant directly, with the #434 warning in this README updated to name the function explicitly. --- installer/Prepare-PackageFiles.ps1 | 11 +++++++++++ installer/README.md | 14 ++++++++++++-- installer/msime_setup.iss | 16 ++++++++++++---- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/installer/Prepare-PackageFiles.ps1 b/installer/Prepare-PackageFiles.ps1 index 89e864ab..47546f25 100644 --- a/installer/Prepare-PackageFiles.ps1 +++ b/installer/Prepare-PackageFiles.ps1 @@ -347,6 +347,17 @@ $updatedIss = [regex]::Replace( '(?m)^#define\s+MyAppVersion\s+"[^"]+"\s*$', "#define MyAppVersion `"$TargetVersion`"" ) +# VersionInfoVersion 只接受数字和点号;从目标版本剥掉 -后缀(dev 构建的 +# 0.7.1-dev.20261019. → 0.7.1),正式版本剥了也原样。 +$versionInfo = ($TargetVersion -replace '-.*$', '') +if ($versionInfo -notmatch '^[0-9]+(\.[0-9]+)*$') { + throw "目标版本剥掉后缀后不是数字版本号,无法写进 VersionInfoVersion:$TargetVersion" +} +$updatedIss = [regex]::Replace( + $updatedIss, + '(?m)^#define\s+MyVersionInfo\s+"[^"]+"\s*$', + "#define MyVersionInfo `"$versionInfo`"" +) # .gitattributes pins this repo to eol=lf; emit LF so packaging does not leave the file as a # CRLF-only diff that git flags as modified while `git diff` shows no textual change. $updatedIss = $updatedIss.TrimEnd("`r", "`n") + "`n" diff --git a/installer/README.md b/installer/README.md index 34bd6483..979a93ae 100644 --- a/installer/README.md +++ b/installer/README.md @@ -1,8 +1,9 @@ # Metasequoia IME Installer -本目录的脚本从 Windows 合仓目录收集产物、签名、用 Inno Setup 打成安装包。它服务两条流程: +本目录的脚本从 Windows 合仓目录收集产物、签名、用 Inno Setup 打成安装包。它服务三条流程: - **正式发布**:由 `MSIME-Windows` 的 release workflow 驱动,用真实证书签名包内全部 EXE/DLL 和最终安装包;产物是挂在 Release 上的 `MetasequoiaIME_Setup_v<版本>.exe`,并携带与 Release 二进制匹配的 PDB 符号文件。见下面「CI 契约」 +- **开发构建**:由 `dev-build.yml` workflow 驱动,runner 上现场生成自签名证书并签名,产物随 artifact 分发。见下面「Dev build」 - **本地测试**:手工跑,用本机自签名证书,用来在自己机器上验证安装流程。本文其余部分讲的是这条 版本号不是固定的,由 `Prepare-PackageFiles.ps1` 的 `-TargetVersion` 决定,默认 `0.0.1`;正式发布时 CI 传入真实版本。 @@ -11,6 +12,15 @@ 签名脚本会在你自己的机器上生成并复用一张自签名测试证书。 +## Dev build + +`dev-build.yml` workflow 给没有本地工具链的贡献者出可安装的包。触发见 [CONTRIBUTING.md](../CONTRIBUTING.md#没有本地工具链时dev-build-workflow)。签名证书由 `sign-dev-build.ps1` 在 runner 上现场生成,subject 固定为 `CN=Metasequoia IME Dev Build Code Signing`,私钥只在 runner 内存里存在一次构建;公钥 `.cer` 随 artifact 分发,用户用 `import-dev-trust.ps1` 导入本机受信任存储(按 subject 清旧导新,重复构建不会膨胀存储)。 + +- `sign-dev-build.ps1` 复用同一次运行早先创建的证书,所以包内二进制和安装包总在同一签名下;`DEV_CERT_PATH` 指出 `.cer` 的导出位置 +- `import-dev-trust.ps1` 也随 artifact 分发,用户解压后在管理员 PowerShell 里直接跑 +- 版本号带 `-dev.<日期>.`,由 `scripts/dev-build-version.py` 从 `version.txt` 派生;设置页的更新检查会剥掉 `-dev` 后缀做数字比较,dev 包不会提示升级 +- 不要把这两个脚本接进 release 路径:正式发布的签名在 `scripts/ci/sign-binaries.ps1` 或 SignPath,两套互不相干 + ## CI 契约 根 `.github/workflows/release.yml` **不加修改地调用本目录的 `Prepare-PackageFiles.ps1` 和 `Compile-Installer.ps1`**。源目录名是 `Prepare-PackageFiles.ps1` 的参数,不是写死在脚本里的字面量:默认值为 `windows/`、`server/` 和仓根授权文件;旧目录或自定义布局通过参数覆盖。回归检查同时执行默认路径和显式参数的打包。辅助码默认读取 `engine/helpcode/`,词库缓存位于 `MetasequoiaImeDict/out/`。 @@ -71,7 +81,7 @@ Windows CI 运行 `installer/tests/cloud-choice.ps1`,用 Inno Setup 跑八个 安装包不携带 VC 运行库,它是系统级共享组件。缺了它 Server 起不来,而这个故障出现在安装结束之后,用户看不到任何解释。所以 `msime_setup.iss` 的 `InitializeSetup` 在安装开始前先查一遍,缺了就当场说清楚,并给出下载地址。界面全部由 `msimeui` 以 Direct2D 绘制,不再依赖 WebView2 Runtime;旧版本留下的 WebView2 用户数据(`ProgramData\metasequoiaime\webview2`、`webview2-settings`,以及 `%LOCALAPPDATA%\metasequoiaime` 下的同名回退目录)由安装器在升级和卸载时清掉。 - **Visual C++ 2015–2022 可再发行组件包(x64)**:读 `Software\Microsoft\VisualStudio\14.0\VC\Runtimes\x64` 的 `Installed` / `Major` / `Minor`,`HKLM32` 和 `HKLM64` 两个视图都查。要求 **14.40 以上**(常量 `VCRuntimeMinMinor`):`server_exe\` 里的 exe 用 MSVC 14.40+(VS 2022 17.10 起)编译并导入 `msvcp140.dll`,这一版起 `std::mutex` 的构造函数是 constexpr,配上更旧的 `msvcp140.dll` 会在第一次加锁时访问冲突、无声闪退——设置页「点了没反应」就是这个现象。装了 2019 / 早期 2022 运行库的机器同样有 `Installed=1`,所以必须比版本号。构建工具链升级到要求更高 redist 时同步调高这个常量。32 位 TSF DLL 用静态 CRT(`windows/CMakeLists.txt` 的 `CMAKE_MSVC_RUNTIME_LIBRARY`),所以不检查 x86 redist -- **不要改成查 `System32` 里的 DLL。** Setup.exe 是 32 位进程,Pascal Script 的 `FileExists` 会被 WOW64 重定向到 `SysWOW64`,查到的是 x86 运行库;只装了 x64 redist 的机器会被误判成缺少 x64 运行库。绕开重定向的办法不可移植:`EnableFsRedirection` 在 Inno Setup 7 里已被移除,而它的替代品在 CI 用的 6.x 里还不存在 +- **不要改成查 `System32` 里的 DLL,也不要在 Pascal Script 里用 `ApplyPathRedirRulesForCurrentProcess`。** 那是 Inno Setup 7 的 support function,CI 的 6.7.1 没有它,ISCC 会报 `Unknown identifier`(#925 引入后第一次在 hosted runner 上编安装包就炸了,#55e1127a 改回直接展开 `{sys}`)。Setup 在 x64/ARM64 上是 64 位进程,`{sys}` 本来就不经重定向;需要 32 位视图时用 `{syswow64}` - 缺组件或版本过旧时弹一次对话框,区分「没装」和「已装 x.y 但过旧」,列出直接下载链接(`https://aka.ms/vc14/vc_redist.x64.exe`)和说明页:选「是」直接下载安装包并结束安装,选「否」继续装。**不阻断安装**——用户可能马上就要去补组件,也可能只是在准备环境。静默安装(CI、批量部署)用 `SuppressibleMsgBox` 默认「否」直接继续,缺什么已经写进安装日志(`/LOG=`) ## 依赖 diff --git a/installer/msime_setup.iss b/installer/msime_setup.iss index 7030fdbb..4163c6b9 100644 --- a/installer/msime_setup.iss +++ b/installer/msime_setup.iss @@ -20,6 +20,10 @@ #define MyAppName "Metasequoia IME 水杉输入法" #define MyAppVersion "0.0.1" +; VersionInfoVersion 是 PE 的 VERSIONINFO,只接受数字和点号;dev 构建的 MyAppVersion 带 +; -dev.<日期>. 后缀(Prepare-PackageFiles.ps1 会把数字部分同步写进这个 define), +; 文件名与 AppVersion 用完整串,这里只用数字段。 +#define MyVersionInfo "0.0.1" #define MyAppPublisher "Metasequoia" #define MyAppExeName "MetasequoiaImeServer.exe" #define MySettingsExeName "MetasequoiaImeSettings.exe" @@ -36,7 +40,11 @@ #define MySourceRoot "." ; TSF DLL 装在系统目录下:CS2 等开启 Trusted Mode 的游戏只放行「位于系统目录且有代码签名」的 ; 第三方 in-proc DLL,装在 Program Files 的 DLL 连加载都会被拒,输入法在游戏里根本切不出来。 -; 本安装器是 32 位进程,[Code] 里访问 64 位那份要先过 ApplyPathRedirRulesForCurrentProcess。 +; 本安装器在 x64/ARM64 上以 64 位模式运行(ArchitecturesInstallIn64BitMode), +; Pascal Script 里访问 {sys} 就是真正的 System32,没有 WOW64 重定向可绕。 +; 不要在这里用 ApplyPathRedirRulesForCurrentProcess:那是 Inno Setup 7 的 support +; function,windows-2025 runner 的 6.7.1 没有它,ISCC 直接报 Unknown identifier +; (#434 的告诫,见 installer/README.md)。 #define MyTsfRoot64 "{sys}\IME\MetasequoiaIME" #define MyTsfRoot32 "{syswow64}\IME\MetasequoiaIME" #ifdef LightPackage @@ -69,7 +77,7 @@ ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible UninstallDisplayName={#MyAppName} UninstallDisplayIcon={commonpf64}\metasequoiaime\MetasequoiaIME.ico -VersionInfoVersion={#MyAppVersion} +VersionInfoVersion={#MyVersionInfo} [Languages] Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl" @@ -718,10 +726,10 @@ begin '现有数据将从这里迁移:' + NewLine + Space + ResolvePreviousDataDir; end; -{ 本进程能直接访问的 TSF 根目录。32 位安装器里不经改写的 System32 会被 WOW64 落到 SysWOW64。} +// 本进程能直接访问的 TSF 根目录。64 位模式下 sys 常量不经重定向,直接就是 System32。 function TsfRoot64: String; begin - Result := ApplyPathRedirRulesForCurrentProcess(True, ExpandConstant('{#MyTsfRoot64}')); + Result := ExpandConstant('{#MyTsfRoot64}'); end; function TsfRoot32: String; From d1e4282eb6c672f9b7e7f97edebbf075ef430da4 Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Mon, 19 Oct 2026 16:01:26 +0800 Subject: [PATCH 4/7] 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." From c790740dd3408a4a51ec102a32edb67b032767c3 Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Tue, 20 Oct 2026 22:26:09 +0800 Subject: [PATCH 5/7] fix(installer): reuse the dev certificate only within the run that created it --- installer/msime_setup.iss | 2 +- installer/sign-dev-build.ps1 | 42 +++++++++++++++++++++++------------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/installer/msime_setup.iss b/installer/msime_setup.iss index 4163c6b9..9fbbac84 100644 --- a/installer/msime_setup.iss +++ b/installer/msime_setup.iss @@ -44,7 +44,7 @@ ; Pascal Script 里访问 {sys} 就是真正的 System32,没有 WOW64 重定向可绕。 ; 不要在这里用 ApplyPathRedirRulesForCurrentProcess:那是 Inno Setup 7 的 support ; function,windows-2025 runner 的 6.7.1 没有它,ISCC 直接报 Unknown identifier -; (#434 的告诫,见 installer/README.md)。 +; (#925 引入后第一次在 hosted runner 上编安装包就炸了,见 installer/README.md)。 #define MyTsfRoot64 "{sys}\IME\MetasequoiaIME" #define MyTsfRoot32 "{syswow64}\IME\MetasequoiaIME" #ifdef LightPackage diff --git a/installer/sign-dev-build.ps1 b/installer/sign-dev-build.ps1 index cfd93a69..82a7367b 100644 --- a/installer/sign-dev-build.ps1 +++ b/installer/sign-dev-build.ps1 @@ -20,7 +20,6 @@ $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' @@ -36,23 +35,34 @@ function Find-SignTool { } # 同一次运行里重复调用本脚本时复用签名步骤生成的证书,保持包内二进制与安装包同一签名。 +# +# 复用的判断必须来自「本次运行写入的标记」,不能只靠「证书看起来像 dev 证书」:改了 subject +# 之后,任何一台没被重置的机器上都会留下上次构建的证书,有效期 30 天,subject 和 EKU 全都 +# 对得上。只按属性筛就会把上一个任务生成的私钥拿来签名,产出的包属于别的流水线。标记文件 +# 放在 RUNNER_TEMP 下(托管 runner 每次都是新的临时目录),内容就是本次要复用的指纹。 +function Get-RunMarkerPath { + $root = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() } + return Join-Path $root 'msime-dev-build-certificate.thumbprint' +} + 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) { + $marker = Get-RunMarkerPath + if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { return $null } - return $candidates[0] + $thumbprint = (Get-Content -LiteralPath $marker -Raw -ErrorAction SilentlyContinue) + if ($thumbprint) { $thumbprint = $thumbprint.Trim() } + if (-not $thumbprint) { + return $null + } + $certificate = Get-ChildItem -Path 'Cert:\CurrentUser\My' -ErrorAction SilentlyContinue | + Where-Object { $_.Thumbprint -eq $thumbprint -and $_.HasPrivateKey } | + Select-Object -First 1 + if (-not $certificate) { + # 标记在但证书没了(比如被清理):当作本次没有证书,重新生成并覆盖标记。 + Write-Host "The certificate recorded for this run is gone from the store: $thumbprint" + } + return $certificate } function New-RunCertificate { @@ -78,6 +88,8 @@ if ($certificate) { } else { $certificate = New-RunCertificate + # 先落标记再往下走:后面任何一步失败重跑,都应该复用这一张而不是再生成一张。 + Set-Content -LiteralPath (Get-RunMarkerPath) -Value $certificate.Thumbprint -NoNewline -Encoding utf8NoBOM Write-Host "Created the dev-build signing certificate: $($certificate.Thumbprint)" } From d97220f51f45ea8328b0806c2610a087a6e757b3 Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Tue, 20 Oct 2026 22:26:14 +0800 Subject: [PATCH 6/7] fix(ci): sign the standalone Server artifact and stop rebuilding win7 branches --- .github/workflows/dev-build.yml | 39 ++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index fd53c561..1ac2b1cc 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -33,6 +33,9 @@ on: - develop - main - 'release-please--*' + # The Windows 7 port lives in its own repository with dev-build-win7.yml; branches named for + # it are pushed here from that work and would only rebuild an artifact nobody asked for. + - 'win7*' tags-ignore: - '**' paths-ignore: @@ -127,7 +130,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dev-tsf-${{ matrix.name }} - retention-days: 7 + retention-days: ${{ env.DEV_ARTIFACT_RETENTION }} # Same layout convention as the release workflow: download-artifact restores the # directory contents where Prepare-PackageFiles.ps1 expects them. path: ${{ matrix.build_dir }}/Release @@ -218,13 +221,43 @@ jobs: - name: Embed the Server uiAccess manifest shell: pwsh run: ./scripts/ci/embed-server-manifest.ps1 -BuildDir server/build-release/bin/Release + # component=server uploads this directory as an installable artifact with no package job to + # sign it. Windows ignores uiAccess=true without a trusted signature, so the binary would + # install and then silently lose the privilege. component=all leaves it unsigned on purpose: + # Prepare-PackageFiles.ps1 copies these files into the payload and sign-dev-build.ps1 signs + # the copies, so signing here would only add a second signing pass over the same bytes. + - name: Sign the Server build for the standalone artifact + if: ${{ (inputs.component || 'all') == 'server' }} + shell: pwsh + env: + DEV_CERT_PATH: ${{ runner.temp }}/dev-server-certificate.cer + run: | + # -Include 只有在 -Path 带通配符或同时给 -Recurse 时才生效,这里显式计算再交给参数, + # 免得把一条空列表传进脚本后它才在内部报错。 + $targets = @( + Get-ChildItem -LiteralPath 'server/build-release/bin/Release' -Recurse -File | + Where-Object { $_.Extension -in @('.exe', '.dll') } | + ForEach-Object FullName + ) + if ($targets.Count -eq 0) { throw 'No EXE or DLL found to sign under server/build-release/bin/Release.' } + ./installer/sign-dev-build.ps1 -FilePath $targets - name: Upload the Server build uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dev-server - retention-days: 7 + retention-days: ${{ env.DEV_ARTIFACT_RETENTION }} path: server/build-release/bin/Release if-no-files-found: error + # The certificate has to travel with the standalone Server artifact too, or nobody can import + # the trust that the signature above depends on. + - name: Upload the signing certificate + if: ${{ (inputs.component || 'all') == 'server' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dev-server-certificate + retention-days: ${{ env.DEV_ARTIFACT_RETENTION }} + path: ${{ runner.temp }}/dev-server-certificate.cer + if-no-files-found: error package: name: Package installer @@ -321,7 +354,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dev-installer - retention-days: 7 + retention-days: ${{ env.DEV_ARTIFACT_RETENTION }} path: | ${{ steps.asset.outputs.installer_path }} ${{ runner.temp }}/dev-build.cer From 7b42d9b057bdcf38ffda2ab8b3d57d5c3c13d7ba Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Tue, 20 Oct 2026 22:59:50 +0800 Subject: [PATCH 7/7] fix(installer): widen the dev certificate to 90 days and surface its expiry --- installer/README.md | 3 ++- installer/import-dev-trust.ps1 | 19 +++++++++++++++---- installer/sign-dev-build.ps1 | 8 +++++--- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/installer/README.md b/installer/README.md index 979a93ae..db7be9b5 100644 --- a/installer/README.md +++ b/installer/README.md @@ -16,7 +16,8 @@ `dev-build.yml` workflow 给没有本地工具链的贡献者出可安装的包。触发见 [CONTRIBUTING.md](../CONTRIBUTING.md#没有本地工具链时dev-build-workflow)。签名证书由 `sign-dev-build.ps1` 在 runner 上现场生成,subject 固定为 `CN=Metasequoia IME Dev Build Code Signing`,私钥只在 runner 内存里存在一次构建;公钥 `.cer` 随 artifact 分发,用户用 `import-dev-trust.ps1` 导入本机受信任存储(按 subject 清旧导新,重复构建不会膨胀存储)。 -- `sign-dev-build.ps1` 复用同一次运行早先创建的证书,所以包内二进制和安装包总在同一签名下;`DEV_CERT_PATH` 指出 `.cer` 的导出位置 +- `sign-dev-build.ps1` 复用同一次运行早先创建的证书,所以包内二进制和安装包总在同一签名下;复用的依据是本次运行写在 `RUNNER_TEMP` 里的指纹标记,不按 subject 猜,机器上残留的旧证书不会被误用。`DEV_CERT_PATH` 指出 `.cer` 的导出位置 +- 证书有效期 90 天:artifact 只留 7 天,但用户可能下载到本地后放很久才装,而安装器校验的是签名时刻证书是否有效;`import-dev-trust.ps1` 会打印这张 `.cer` 自带的到期时间,发现已过期就直接退出,不往受信任存储里写 - `import-dev-trust.ps1` 也随 artifact 分发,用户解压后在管理员 PowerShell 里直接跑 - 版本号带 `-dev.<日期>.`,由 `scripts/dev-build-version.py` 从 `version.txt` 派生;设置页的更新检查会剥掉 `-dev` 后缀做数字比较,dev 包不会提示升级 - 不要把这两个脚本接进 release 路径:正式发布的签名在 `scripts/ci/sign-binaries.ps1` 或 SignPath,两套互不相干 diff --git a/installer/import-dev-trust.ps1 b/installer/import-dev-trust.ps1 index 0aae8658..5bd70144 100644 --- a/installer/import-dev-trust.ps1 +++ b/installer/import-dev-trust.ps1 @@ -28,10 +28,6 @@ function Test-Administrator { 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 @@ -53,6 +49,20 @@ if ($certificate.Subject -ne $Subject) { throw "这不是 dev-build 签名证书(subject:$($certificate.Subject))。预期:$Subject" } +# 到期时间读的是这张 .cer 自带的字段,不靠「构建过了几天」去推。签名在证书有效期内完成才能 +# 通过校验,所以证书过期后这份包就装不上了,导入得再干净也没用,必须换新构建。 +# 这一步必须排在写受信任存储之前:过期证书导入进去只会让本机多一条无用的信任记录。 +if ($certificate.NotAfter -le (Get-Date)) { + Write-Warning "这张证书已经过期($($certificate.NotAfter.ToString('yyyy-MM-dd HH:mm'))),包内签名无法通过校验。" + Write-Warning '请重新下载最新的 dev build,用它的证书和安装包;不要继续安装当前这份。' + exit 1 +} + +# 权限检查放在体检之后:先让没提升权限的用户知道包本身已经过期,再告诉他去开管理员。 +if (-not (Test-Administrator)) { + throw '需要管理员 PowerShell:写入 LocalMachine 受信任存储必须提升权限。' +} + # 按固定 subject 清理所有旧证书,再导入新的一张。跳过与本次导入指纹相同的那份。 if (-not $Keep) { foreach ($store in $TrustStores) { @@ -80,4 +90,5 @@ finally { Write-Host '' Write-Host "Dev 证书已就绪:$($certificate.Subject)" Write-Host "指纹:$($certificate.Thumbprint)(本次构建专有,换新 build 需重跑本脚本)" +Write-Host "有效期至:$($certificate.NotAfter.ToString('yyyy-MM-dd HH:mm'))(本机时间)" Write-Host '现在可以安装同目录下的 dev 安装包了。' diff --git a/installer/sign-dev-build.ps1 b/installer/sign-dev-build.ps1 index 82a7367b..ef84507c 100644 --- a/installer/sign-dev-build.ps1 +++ b/installer/sign-dev-build.ps1 @@ -66,8 +66,10 @@ function Get-RunCertificate { } function New-RunCertificate { - # -Type CodeSigningCert 负责代码签名 EKU。有效期对齐 artifact 的 7 天保留期再放宽, - # 避免 runner 时钟略慢导致证书尚未生效。 + # -Type CodeSigningCert 负责代码签名 EKU。有效期权衡的是两件事:artifact 只留 7 天, + # 但用户可能下载到本地后放很久才装,而安装器检查的是签名时刻证书是否有效;拉得太长又会 + # 让一张随构建轮换的证书变成长期可信身份。90 天覆盖到拖延的安装,又不至于成为长期凭据。 + # import-dev-trust.ps1 会打印实际的到期时间,安装器发现包内证书过期时也会明确报出来。 $certificate = New-SelfSignedCertificate ` -Type CodeSigningCert ` -Subject $CertificateSubject ` @@ -78,7 +80,7 @@ function New-RunCertificate { -KeyUsage DigitalSignature ` -CertStoreLocation 'Cert:\CurrentUser\My' ` -NotBefore (Get-Date).AddHours(-1) ` - -NotAfter (Get-Date).AddDays(30) + -NotAfter (Get-Date).AddDays(90) return $certificate }