diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24c537e4..96320f87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index 101e4121..068c6cb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 产物缺文件 | diff --git a/release-please-config.json b/release-please-config.json index b2cadb60..2e2058bf 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -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.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." } } } diff --git a/scripts/ci/check-release-pr.sh b/scripts/ci/check-release-pr.sh new file mode 100755 index 00000000..36652074 --- /dev/null +++ b/scripts/ci/check-release-pr.sh @@ -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." diff --git a/scripts/ci/merge-release-pr.sh b/scripts/ci/merge-release-pr.sh deleted file mode 100755 index 8ec99459..00000000 --- a/scripts/ci/merge-release-pr.sh +++ /dev/null @@ -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