ci(release): only build and sign on an explicit dispatch

The signing certificate allows a limited number of signatures, so a release costs something finite. It should not be spent by merging a pull request.

Until now a push to main was enough to spend one: release-please opened the release pull request, merge-release-pr.sh merged it, the merge commit came back through this workflow as release_created, and the build jobs signed two binaries and an installer and published the result. Nobody had to ask for any of that.

should_publish is now true only for workflow_dispatch, and the package job -- the only one that signs -- states that condition itself rather than inheriting it from four other jobs whose conditions a later edit could quietly change.

Auto-merging the release pull request goes with it, since that is the mechanism that turned a merge into a release, and leaving it would also mint a draft release and a tag on every merge to main that nobody intends to build. What remains automatic is getting a passing check onto that pull request, which is not optional: GITHUB_TOKEN cannot start a workflow run, so without the dispatch in check-release-pr.sh the release pull request has no checks at all and required_status_checks (#121) leaves it permanently unmergeable.

Releasing is now two deliberate steps. Merge the release pull request, which creates the draft release and its tag and builds nothing. Then run the Release workflow against that tag. Dispatching the same tag twice is already refused by validate-draft-release.sh, because a published release is no longer a draft, so a retry cannot quietly spend a second signature.
This commit is contained in:
Evan Hu committed 2026-09-06 00:48:51 +09:00
1 parent 1ec19ff277
commit 2a15c6a4ad
5 files changed
+100 -97

No files matched your search

+16 -5
View File
@@ -1,5 +1,10 @@
name: Release
# Building and publishing only ever happens on workflow_dispatch. The code signing certificate
# has a limited number of signatures, so a release costs something real and is not something a merge
# to main should spend on its own. The push trigger only keeps release-please's pull request current
# and green; merging that pull request creates the draft, and dispatching this workflow against the
# draft's tag is what builds, signs and publishes it.
on:
push:
branches:
@@ -7,7 +12,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: Existing draft release tag to build and publish, for example v0.0.10
description: Draft release tag to build, sign and publish, for example v2026.9.1
required: true
type: string
@@ -30,13 +35,15 @@ jobs:
name: Prepare release metadata
runs-on: ubuntu-24.04
permissions:
# actions: write lets merge-release-pr.sh dispatch ci.yml on the release branch, which is the
# actions: write lets check-release-pr.sh dispatch ci.yml on the release branch, which is the
# only trigger GITHUB_TOKEN is allowed to fire.
actions: write
contents: write
pull-requests: write
outputs:
should_publish: ${{ github.event_name == 'workflow_dispatch' || steps.release.outputs.release_created == 'true' }}
# Never true on push: see the comment above the triggers. release-please still creates the
# draft release there, it just does not get built until somebody asks for it.
should_publish: ${{ github.event_name == 'workflow_dispatch' }}
tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || steps.release.outputs.tag_name }}
version: ${{ github.event_name == 'workflow_dispatch' && steps.requested_release.outputs.version || steps.release.outputs.version }}
target_sha: ${{ github.event_name == 'workflow_dispatch' && steps.requested_release.outputs.target_sha || steps.release.outputs.sha }}
@@ -53,12 +60,12 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Build and merge the release pull request
- name: Test the release pull request
if: ${{ github.event_name == 'push' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: bash scripts/ci/merge-release-pr.sh
run: bash scripts/ci/check-release-pr.sh
- name: Validate requested draft release
if: ${{ github.event_name == 'workflow_dispatch' }}
@@ -308,6 +315,10 @@ jobs:
package:
name: Package and publish installer
needs: [prepare, dependencies, tsf, server, ui]
# Stated explicitly even though skipped needs would already skip this job. This is the only job
# that spends a signature, so the condition that stops it belongs here rather than being a
# consequence of four other jobs' conditions that a later edit could quietly undo.
if: ${{ needs.prepare.outputs.should_publish == 'true' }}
runs-on: windows-2025
timeout-minutes: 60
permissions:
+9 -2
View File
@@ -294,7 +294,14 @@ C++ 组件编译完成且相关前端/词库产物已准备好之后运行。`Si
上面那套是本地测试打包。对外发布走 `.github/workflows/release.yml`,产出的就是历来挂在本仓 Release 上的 `MetasequoiaIME_Setup_v<版本>.exe`。
版本号由 release-please 管理,真源是根目录的 `version.txt`。往 `main` 推提交后,release-please 会开一个 release PR;合并它就生成一个 draft release,随后 release workflow 构建安装包、附加产物并发布。也可以手动触发 workflow,对一个已存在的 draft tag 重新构建。
版本号由 release-please 管理,真源是根目录的 `version.txt`。**发布是手动的**:签名证书的签名次数有限,一次发布是实打实的开销,不该由「合了个 PR」这种事替你花掉。
往 `main` 推提交后只发生两件事:release-please 更新那个 release PR,`check-release-pr.sh` 给它挂上一次通过的 CI(`GITHUB_TOKEN` 开不出 workflow run,必须用 `workflow_dispatch` 顶上,否则开着 required status check 的 release PR 永远合不了)。想发版时:
1. 合并 release PR —— 这一步只生成 draft release 和 tag,不构建、不签名。
2. 手动触发 `Release` workflow,`tag` 填那个 draft 的 tag —— 这一步才构建、签名、发布。
同一个 tag 重复触发会被 `validate-draft-release.sh` 挡下:发布之后它就不是 draft 了,不会二次消耗签名次数。
`src/IME/MetasequoiaIME.rc` 的 `FILEVERSION` / `PRODUCTVERSION` 不由 release-please 直接改(它是逗号和点号两种写法),而是由 `scripts/apply_version.py` 从 `version.txt` 注入,release 构建在 configure 之前执行:
@@ -308,7 +315,7 @@ release workflow 里的每一段 shell 都抽在 `scripts/ci/` 下,workflow
| 脚本 | 用途 |
|---|---|
| `validate-draft-release.sh` | 手动触发时校验 tag 是未发布 draft、指向不可变 commit、且与 `version.txt` 一致 |
| `merge-release-pr.sh` | 合并 release-please 刚开的 PR |
| `check-release-pr.sh` | 给 release PR 挂上一次通过的 CI,不合并 |
| `verify-commit-on-main.sh` | 拒绝构建不在 main 历史里的 commit |
| `install-boost.ps1` | 装 Server 链接但未声明的 Boost,triplet 必须是 static-md |
| `check-server-binaries.ps1` | 提前拦住 Server 产物缺文件 |
+1 -1
View File
@@ -12,7 +12,7 @@
"prerelease": true,
"force-tag-creation": true,
"pull-request-header": "This PR prepares the next Metasequoia IME for Windows release.",
"pull-request-footer": "Merging this PR creates a draft release. The Release workflow then builds the installer, attaches MetasequoiaIME_Setup_v<version>.exe and publishes it."
"pull-request-footer": "Merging this PR creates a draft release. Nothing is built or signed until somebody runs the Release workflow manually against that draft's tag: the signing certificate allows a limited number of signatures, so a release is a deliberate act rather than a consequence of merging."
}
}
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Get a passing check onto the pull request release-please just opened, and stop there.
#
# This script used to merge that pull request too, which is what made releases automatic. Releases
# are manual now: the code signing certificate has a limited number of signatures, and an automatic
# release spends one on every merge to main whether or not anybody wanted a build. Merging the
# release pull request is a deliberate human act, and building and signing the resulting draft is a
# second one (workflow_dispatch on release.yml).
#
# Getting a check onto the pull request is still automatic, and still awkward. GITHUB_TOKEN may not
# start workflows, so the pull_request run is created in action_required and never executes; a push
# to the branch does not produce a run at all. workflow_dispatch is the exception GitHub allows, so
# dispatch CI on the release branch and wait for it. The checks land on the pull request's head
# commit, which is what lets required_status_checks stay on for this repository (MSIME-Windows#121)
# without leaving the release pull request permanently unmergeable.
#
# The pull request is found by branch rather than from release-please's payload, which only reports
# a pull request as created once but rewrites that branch on every later push to main.
#
# Requires GH_TOKEN and GH_REPO.
set -euo pipefail
branch=$(gh api "repos/$GH_REPO/git/matching-refs/heads/release-please--" \
--jq '.[0].ref // empty' | sed 's|^refs/heads/||')
if [[ -z "$branch" ]]; then
echo "No release branch exists; nothing to test."
exit 0
fi
number=$(gh pr list --repo "$GH_REPO" --head "$branch" --state open --limit 1 \
--json number --jq '.[0].number // empty')
if [[ -z "$number" ]]; then
echo "Release branch $branch has no open pull request; nothing to test."
exit 0
fi
pr_json=$(gh pr view "$number" --repo "$GH_REPO" \
--json state,isDraft,headRefName,headRefOid,baseRefName)
state=$(jq -er .state <<< "$pr_json")
is_draft=$(jq -r .isDraft <<< "$pr_json")
head_branch=$(jq -er .headRefName <<< "$pr_json")
head_sha=$(jq -er .headRefOid <<< "$pr_json")
base_branch=$(jq -er .baseRefName <<< "$pr_json")
if [[ "$state" != OPEN || "$is_draft" != false || "$base_branch" != main ]]; then
echo "Release pull request #$number is not an open, non-draft pull request against main." >&2
exit 1
fi
if [[ "$head_branch" != release-please--* ]]; then
echo "Release pull request #$number has an unexpected head branch: $head_branch" >&2
exit 1
fi
echo "Dispatching CI for release pull request #$number at $head_sha"
gh workflow run ci.yml --repo "$GH_REPO" --ref "$head_branch"
# The run does not appear immediately, and gh has no way to ask for the run a dispatch created, so
# poll for one on this branch at this exact commit.
run_id=""
for _ in $(seq 1 150); do
run_id=$(gh run list --repo "$GH_REPO" --workflow ci.yml --branch "$head_branch" \
--event workflow_dispatch --limit 20 --json databaseId,headSha \
--jq "map(select(.headSha == \"$head_sha\")) | first | .databaseId // empty")
[[ -n "$run_id" ]] && break
sleep 2
done
if [[ -z "$run_id" ]]; then
echo "Timed out waiting for CI to start for release pull request #$number at $head_sha." >&2
exit 1
fi
gh run watch "$run_id" --repo "$GH_REPO" --exit-status --interval 15
echo "Release pull request #$number is green at $head_sha and ready to merge when you want a release."
-89
View File
@@ -1,89 +0,0 @@
#!/usr/bin/env bash
# Build the pull request release-please just opened, then merge it.
#
# Merging is what carries the release forward: the resulting push runs the release workflow again,
# release-please turns the release commit into the draft release, and the build jobs take over.
#
# Getting a check onto that pull request is the awkward part. GITHUB_TOKEN may not start workflows,
# so the pull_request run is created in action_required and never executes; a push to the branch
# does not produce a run at all. workflow_dispatch is the exception GitHub allows, so dispatch CI on
# the release branch and wait for it. The checks land on the pull request's head commit, which is
# what lets required_status_checks be turned on for this repository (see MSIME-Windows#121). This
# mirrors what MSIME-Apple's release automation already does.
#
# The pull request is found by branch rather than from release-please's payload. It only reports a
# pull request as created once, but it rewrites that branch on every later push to main, and a
# release pull request that could not be merged the first time -- main advancing mid-build is the
# normal way that happens -- would then never be retried.
#
# Requires GH_TOKEN and GH_REPO.
set -euo pipefail
branch=$(gh api "repos/$GH_REPO/git/matching-refs/heads/release-please--" \
--jq '.[0].ref // empty' | sed 's|^refs/heads/||')
if [[ -z "$branch" ]]; then
echo "No release branch exists; nothing to build or merge."
exit 0
fi
number=$(gh pr list --repo "$GH_REPO" --head "$branch" --state open --limit 1 \
--json number --jq '.[0].number // empty')
if [[ -z "$number" ]]; then
echo "Release branch $branch has no open pull request; nothing to merge."
exit 0
fi
pr_json=$(gh pr view "$number" --repo "$GH_REPO" \
--json state,isDraft,headRefName,headRefOid,baseRefName,baseRefOid)
state=$(jq -er .state <<< "$pr_json")
is_draft=$(jq -r .isDraft <<< "$pr_json")
head_branch=$(jq -er .headRefName <<< "$pr_json")
head_sha=$(jq -er .headRefOid <<< "$pr_json")
base_branch=$(jq -er .baseRefName <<< "$pr_json")
base_sha=$(jq -er .baseRefOid <<< "$pr_json")
if [[ "$state" != OPEN || "$is_draft" != false || "$base_branch" != main ]]; then
echo "Release pull request #$number is not an open, non-draft pull request against main." >&2
exit 1
fi
if [[ "$head_branch" != release-please--* ]]; then
echo "Release pull request #$number has an unexpected head branch: $head_branch" >&2
exit 1
fi
echo "Dispatching CI for release pull request #$number at $head_sha"
gh workflow run ci.yml --repo "$GH_REPO" --ref "$head_branch"
# The run does not appear immediately, and gh has no way to ask for the run a dispatch created, so
# poll for one on this branch at this exact commit.
run_id=""
for _ in $(seq 1 150); do
run_id=$(gh run list --repo "$GH_REPO" --workflow ci.yml --branch "$head_branch" \
--event workflow_dispatch --limit 20 --json databaseId,headSha \
--jq "map(select(.headSha == \"$head_sha\")) | first | .databaseId // empty")
[[ -n "$run_id" ]] && break
sleep 2
done
if [[ -z "$run_id" ]]; then
echo "Timed out waiting for CI to start for release pull request #$number at $head_sha." >&2
exit 1
fi
gh run watch "$run_id" --repo "$GH_REPO" --exit-status --interval 15
# main can move while the build runs. Merging then would rebase the release commit onto something
# that was never tested, so leave the pull request for the next release run instead.
current_base_sha=$(gh api "repos/$GH_REPO/git/ref/heads/$base_branch" --jq .object.sha)
if [[ "$current_base_sha" != "$base_sha" ]]; then
echo "main advanced while release pull request #$number was being tested; leaving it open."
exit 0
fi
current_head_sha=$(gh pr view "$number" --repo "$GH_REPO" --json headRefOid --jq .headRefOid)
if [[ "$current_head_sha" != "$head_sha" ]]; then
echo "Release pull request #$number changed after CI completed; refusing to merge it." >&2
exit 1
fi
echo "Merging release pull request #$number"
gh pr merge "$number" --repo "$GH_REPO" --merge