The Hold-for-review step built its comment with printf and spliced ${{ steps.classify.outputs.reason }} into the command text inside single quotes. That value is the list of file names the engine changed. GitHub substitutes ${{ }} textually before bash parses the line, so a path containing a single quote closes the quoting and the rest runs as a command — on a runner holding a contents:write and pull-requests:write token for this repository. Merge rights in MSIME-Engine became command execution here. Non-adversarially the same splice makes the step die under set -euo pipefail on any engine path containing an apostrophe, so a routine bump gets a red triage job and no review comment.
Every value now reaches the shell through env: and the comment body goes through --body-file. The $GITHUB_OUTPUT heredoc that carries those same file names gets a random delimiter, since a path equal to the literal EOF would otherwise close the block early.
Separately, the classifier could auto-merge a range it had not inspected. With an empty file list, echo emits one blank line, grep -v prints it, and command substitution strips it, so the emptiness test read "nothing suspicious changed" and enabled auto-merge. The compare API returns an empty file list whenever head is already an ancestor of base — an engine force-push, or the pin sitting ahead — which is exactly the case where a bump silently reverts engine source this frontend depends on. It now fails closed on any compare status other than a plain fast-forward, and on an empty file list.
Found while auditing MSIME-Apple, which carried a byte-identical copy of this workflow. Fixed there in metasequoiaime/MSIME-Apple#269.
Dependency review was guarded on the pull_request event, which made it report "skipped" on every other trigger. A required status check that reports "skipped" blocks a merge rather than passing it, and #166 made this one of the nine contexts main requires, so the automatic release could no longer land its own release pull request: land-release-pr.sh watched ci.yml go green and then got "the base branch policy prohibits the merge" (run 34004706430, pull request #173).
The guard cannot simply move to the release path either. ci.yml ignores the release pull request by path, because a pull_request run started by GITHUB_TOKEN is created in action_required and never executes, so the release workflow dispatches ci.yml on the release branch and that dispatch is the only run reporting checks on that commit.
The job now runs on every trigger. A pull_request event supplies both refs on its own; every other trigger compares the default branch against the checked-out commit, which is the same question the pull request asks.
GitHub has three release states and no notion of a channel, so an automatic per-merge build and a release somebody deliberately cut both land in the same Pre-release list that docs/installation.md presents to users as the 内测版 channel. Before every releasable merge produced a release that did not matter; now it does.
Releases published from a push get 「(自动构建)」 in the title and a note saying they were not curated. Releases published from a workflow_dispatch keep the bare tag as their title, which is what release-please already set. docs/installation.md tells readers which is which and why they share one list.
This is deliberately a labelling change and not a second release track. Giving automatic builds their own tag namespace would need a parallel versioning mechanism alongside the one release-please owns, and stacking that on a pipeline that is currently failing and shipping unsigned artifacts is the wrong order.
Refs MSIME-Windows#167
A push to main carrying a fix or a feat now ends with a published installer, with no human merging the release pull request and no manual workflow_dispatch in between.
release-please only cuts a release for a release pull request that was already merged when it ran, so the prepare job now invokes it twice: once to refresh the pull request against the commits this push brought, and once after land-release-pr.sh has merged it, to turn that merge into the draft the rest of the run builds and publishes. Merging before the first invocation would instead release everything except the push that triggered the run.
land-release-pr.sh is check-release-pr.sh plus the merge it deliberately used to skip. It merges with GITHUB_TOKEN, whose pushes do not start workflow runs, so the merge cannot produce a second Release run queued behind this one on the concurrency group; the release is created and built inside the run that merged it. It merges the exact commit CI proved via --match-head-commit, then waits for that merge commit to be reachable from main so the second release-please invocation does not quietly find nothing to release.
resolve-auto-release.sh picks which of the two invocations reported the release to build, preferring the second when both did, and applies the invariants validate-draft-release.sh gives the manual path: the release is tagged at a full commit SHA, and the tag agrees with the version.
workflow_dispatch stays as a repair path for a run that failed after creating its draft.
This spends one of the signing certificate's limited signatures on every releasable merge, which is what the previous manual design existed to avoid. docs/product-release.md records the trade, including why the queueing this can produce is not answered by cancel-in-progress.
MSIME-HelpCode is no longer a product input and the dictionary now ships from MSIME-Engine, so the readme, the agent notes, the release documentation and the issue templates stop naming a repository the product does not consume.
Ports metasequoiaime/MSIME-Windows#153 onto the consolidated repository. That pull request was written against the six-repository layout: it edited product-ci.yml, which no longer exists, and re-pinned Server, UiHtml and Installer, which are directories now.
Three of its pinned commits had not reached this repository at all. The consolidation took each source repository at its main, and Server, UiHtml and Installer each landed two more commits afterwards, so dropping the pins as merely obsolete would have silently dropped the work the pull request exists to consume. The two substantive ones are ported here: Server drops its vendored miniaudio.h and builds against the engine's MetasequoiaIme::VoiceCapture, and the installer collects helpcodes from the engine. UiHtml's was an engine gitlink bump, which one submodule resolves on its own.
The engine is pinned to d0dc0c2b rather than to the b7c799ef that pull request named. b7c799ef predates 403b4a4d, which is what this repository pins today and is the commit that added pagination to the settings dictionary contract, so taking the pull request's pin verbatim would have reverted a contract main already depends on and stripped offset, limit and hasMore back out of the generated web bindings. d0dc0c2b is the engine's main and is the same commit the dictionary release declares as its source, so the code and the data now name one commit.
The helpcodes moved into the engine, so MSIME-HelpCode stops being a product input: the gitlink that pins the contracts pins the tables too. product-lock.json is down to the engine and the dictionary release, and the dictionary itself now ships from MSIME-Engine. Its manifest states which commit built the data and whether that tree was clean, and the lock verifies both, so matching digests can no longer stand in for a source nobody can rebuild from. The retired MSIME-Dict repository stays valid for the one tag that shipped from it and nothing else.
The release job that packages now checks out submodules, because the helpcodes it collects live in one.
check-dictionary-contract.py compares scripts/dictionary_product.py against the engine's own copy, so the job needs the submodule's contents and not just its gitlink. The reusable workflow this job replaced checked out submodules; the rewrite dropped that.
The script reported a missing file as "differs from the pinned Engine contract", which describes a drift that had not happened and sends the reader looking for a content change. It now says the file is missing and how to get it.
The product lock existed because five repositories had to be pinned to reproduce one product. Four of them are directories now, so a commit of this repository already pins them and there is nothing left for the lock to say about them. What it still records is what the product consumes from outside: the engine submodule, the helpcodes and the dictionary release.
CI gains a job per component instead of a reusable workflow that checked out locked commits and rebuilt them. The release workflow no longer stages five sibling checkouts under historical names and no longer carries a second sparse checkout for its own scripts; Prepare-PackageFiles.ps1 already took the source directory names as parameters, so it is told the new ones, plus where the product-wide notice now lives.
Two checks the removed cross-repository pins used to provide are replaced in kind: the pages are verified against the engine submodule's web contract in CI rather than by locking UiHtml and Engine together, and the engine bump triage that Server carried moves here and now also says so when a bump leaves the lock behind.
Every other piece of shell in these workflows lives in scripts/ci and the workflow only wires it up. The language probe went in inline, so it did not: twenty lines of PowerShell building an .iss heredoc inside a YAML block scalar, which is the least reviewable and least runnable place to put it.
Same check, now in check-inno-language.ps1, and runnable by hand on a Windows machine. It falls back to the system temp directory when RUNNER_TEMP is unset so that running it outside CI works.
Every release build since the packaging pipeline landed has failed at "Compile the installer", which is why the last eight versions are still unpublished drafts:
Error on line 63 in msime_setup.iss: Couldn't open include file
"c:\program files (x86)\inno setup 6\Languages\ChineseSimplified.isl"
install-inno-language.ps1 derived the destination from `Get-Command ISCC.exe`. On the windows-2025 image Inno Setup comes from Chocolatey, so that resolves to the shim at C:\ProgramData\Chocolatey\bin\ISCC.exe and the file was written to C:\ProgramData\Chocolatey\bin\Languages\. ISPP resolves compiler: against the real installation. The step printed "Installed ... (21516 bytes)" and exited 0, and the build failed two steps later pointing at a path nothing had written to, which is why this survived eight releases.
The installation is now located by a file only it has -- ISPPBuiltins.iss or Default.isl, neither of which sits beside a shim -- checking the uninstall registry key, the two Program Files locations and any ISCC.exe on PATH, and failing with the list it tried rather than falling back to a guess.
ci.yml now compiles a throwaway script whose only content is a [Languages] entry pointing at compiler:Languages\ChineseSimplified.isl. Asserting the file exists at a path this script computed would only restate the script's own belief; compiling is what proves ISCC agrees. The URL and digest pin moved into the script so that check does not need a second copy of them.
Reverts the switch to calendar versioning, before it produced a release here.
release-please has no CalVer strategy, so the switch reset the version series by hand and pinned always-bump-patch. What that analysis missed is what release-please does to the changelog when the series it is walking no longer connects to the tag history: on MSIME-Apple it rewrote the whole file into a single 158-line 2026.9.1 entry that listed every feature since the first pull request as though it had just shipped. MSIME-Linux got a 64-line one. This repository had not released yet, so there is no changelog damage to undo and reverting now costs nothing.
Restores the 0.0.17 baseline across version.txt, .release-please-manifest.json and the version resource, drops always-bump-patch from the release-please config, and removes the monthly rollover workflow. The next release goes back to the previous series.
The three version lines the switch was meant to unify stay unaligned. That problem is real but wants a mechanism that does not fight release-please.