diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 00000000..1ac2b1cc --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,380 @@ +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--*' + # 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: + - '**.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: ${{ 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 + 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 + # 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: ${{ 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 + 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: ${{ env.DEV_ARTIFACT_RETENTION }} + 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/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..db7be9b5 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,16 @@ 签名脚本会在你自己的机器上生成并复用一张自签名测试证书。 +## 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` 复用同一次运行早先创建的证书,所以包内二进制和安装包总在同一签名下;复用的依据是本次运行写在 `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,两套互不相干 + ## CI 契约 根 `.github/workflows/release.yml` **不加修改地调用本目录的 `Prepare-PackageFiles.ps1` 和 `Compile-Installer.ps1`**。源目录名是 `Prepare-PackageFiles.ps1` 的参数,不是写死在脚本里的字面量:默认值为 `windows/`、`server/` 和仓根授权文件;旧目录或自定义布局通过参数覆盖。回归检查同时执行默认路径和显式参数的打包。辅助码默认读取 `engine/helpcode/`,词库缓存位于 `MetasequoiaImeDict/out/`。 @@ -71,7 +82,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/import-dev-trust.ps1 b/installer/import-dev-trust.ps1 new file mode 100644 index 00000000..5bd70144 --- /dev/null +++ b/installer/import-dev-trust.ps1 @@ -0,0 +1,94 @@ +# 信任一张 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 $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" +} + +# 到期时间读的是这张 .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) { + $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 "有效期至:$($certificate.NotAfter.ToString('yyyy-MM-dd HH:mm'))(本机时间)" +Write-Host '现在可以安装同目录下的 dev 安装包了。' diff --git a/installer/msime_setup.iss b/installer/msime_setup.iss index 7030fdbb..9fbbac84 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 +; (#925 引入后第一次在 hosted runner 上编安装包就炸了,见 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; diff --git a/installer/sign-dev-build.ps1 b/installer/sign-dev-build.ps1 new file mode 100644 index 00000000..ef84507c --- /dev/null +++ b/installer/sign-dev-build.ps1 @@ -0,0 +1,170 @@ +# 在 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' + +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 +} + +# 同一次运行里重复调用本脚本时复用签名步骤生成的证书,保持包内二进制与安装包同一签名。 +# +# 复用的判断必须来自「本次运行写入的标记」,不能只靠「证书看起来像 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 { + $marker = Get-RunMarkerPath + if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { + return $null + } + $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 { + # -Type CodeSigningCert 负责代码签名 EKU。有效期权衡的是两件事:artifact 只留 7 天, + # 但用户可能下载到本地后放很久才装,而安装器检查的是签名时刻证书是否有效;拉得太长又会 + # 让一张随构建轮换的证书变成长期可信身份。90 天覆盖到拖延的安装,又不至于成为长期凭据。 + # import-dev-trust.ps1 会打印实际的到期时间,安装器发现包内证书过期时也会明确报出来。 + $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(90) + return $certificate +} + +$certificate = Get-RunCertificate +if ($certificate) { + Write-Host "Reusing the certificate created earlier in this run: $($certificate.Thumbprint)" +} +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)" +} + +$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." 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()) 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,