fix(ci): attach automatic release checks to the pull request (#175)
* ci(codeql): 避免版本元数据 PR 产生待人工批准的扫描 * fix(ci): attach release validation to the pull request
This commit is contained in:
6 files changed
+88
-27
No files matched your search
@@ -4,16 +4,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# A release-please pull request only rewrites these three files, and its pull_request run would
|
||||
# be created in action_required and never execute: GITHUB_TOKEN may not start workflows, so
|
||||
# anything it triggers waits for a human. Skipping it here keeps an unapprovable run out of the
|
||||
# checks list. The release workflow dispatches this workflow on the release branch instead,
|
||||
# which is the one trigger GITHUB_TOKEN is allowed to fire, so the version bump still gets a
|
||||
# full build and the checks land on the same commit.
|
||||
paths-ignore:
|
||||
- version.txt
|
||||
- CHANGELOG.md
|
||||
- .release-please-manifest.json
|
||||
workflow_dispatch:
|
||||
|
||||
merge_group:
|
||||
@@ -45,6 +35,8 @@ jobs:
|
||||
run: python3 -m unittest discover -s tests -p 'test_product_lock.py'
|
||||
- name: Test local environment setup in a moved checkout
|
||||
run: python3 -m unittest discover -s tests -p 'test_prepare_env.py'
|
||||
- name: Test the automatic release CI gate
|
||||
run: python3 -m unittest discover -s tests -p 'test_release_pr_ci.py'
|
||||
- name: Read the reviewed lock
|
||||
id: lock
|
||||
run: |
|
||||
|
||||
@@ -31,9 +31,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
# actions: write lets land-release-pr.sh dispatch ci.yml on the release branch, which is the
|
||||
# only trigger GITHUB_TOKEN is allowed to fire.
|
||||
actions: write
|
||||
# Read the PR-triggered run; merging stays scoped to GITHUB_TOKEN.
|
||||
actions: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
outputs:
|
||||
@@ -47,7 +46,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Split release creation from PR writes so the metadata credential never updates branches.
|
||||
# Separate release metadata from PR updates to preserve release-please ordering.
|
||||
# Recover an already-merged release before calculating the next PR, matching release-please's
|
||||
# normal releases-then-pull-requests ordering.
|
||||
- name: Require the release metadata credential
|
||||
@@ -73,8 +72,8 @@ jobs:
|
||||
id: release_update
|
||||
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Branch writes remain token-scoped and cannot enqueue another Release run.
|
||||
# A trusted credential starts normal PR checks without manual workflow approval.
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
skip-github-release: true
|
||||
|
||||
- name: Test and merge the release pull request
|
||||
@@ -90,7 +89,7 @@ jobs:
|
||||
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
|
||||
with:
|
||||
# Creating a tag at a historical workflow-changing commit needs workflows:write.
|
||||
# This credential may create metadata, but must never write the release branch.
|
||||
# Only GITHUB_TOKEN merges to main, preventing a second Release run.
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
skip-github-pull-request: true
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ cmake -S ui -B ui/build -A x64 # GUI 框架
|
||||
|
||||
这一整条链跑在那次 push 触发的同一个 run 里:
|
||||
|
||||
1. release-please 先用 `RELEASE_PLEASE_TOKEN` 补建已有合并版本的元数据,再用 `GITHUB_TOKEN` 把这次 push 的提交刷进 release PR。
|
||||
2. `land-release-pr.sh` 给该 PR 派发一次 CI(`GITHUB_TOKEN` 开不出 workflow run,必须用 `workflow_dispatch` 顶上,否则开着 required status check 的 release PR 永远合不了),绿了就合并它。
|
||||
1. release-please 先用 `RELEASE_PLEASE_TOKEN` 补建已有合并版本的元数据,再用该凭据把这次 push 的提交刷进 release PR,触发正常的 PR 检查。
|
||||
2. `land-release-pr.sh` 等待该 PR 精确 head 的 `pull_request` CI,绿了就用 `GITHUB_TOKEN` 合并它。不能以手动派发检查替代 PR 检查:后者才会进入 PR 的检查汇总并满足门禁。
|
||||
3. release-please 再跑一次,用 `RELEASE_PLEASE_TOKEN` 只创建 draft release 和 tag(`skip-github-pull-request: true`)。维护 PR 的调用只写版本分支(`skip-github-release: true`)。
|
||||
4. 构建、签名、把 exe 挂上去、draft 转正式。
|
||||
|
||||
|
||||
@@ -47,6 +47,6 @@ python scripts/product_lock.py fetch-dictionaries --staging-root /tmp/msime-prod
|
||||
|
||||
## 发布元数据令牌
|
||||
|
||||
版本 PR 的创建、更新和合并仍用 `GITHUB_TOKEN`。维护 PR 的 release-please 调用设置 `skip-github-release: true`。它前后各有一次 `skip-github-pull-request: true` 的元数据调用,使用组织已有的 `RELEASE_PLEASE_TOKEN`:先补建已经手动合并的版本,再处理本次自动合并的版本,保持 release-please 原有的先发布、后计算下一版本的顺序。
|
||||
版本 PR 的创建和更新使用 `RELEASE_PLEASE_TOKEN`,使正常 `pull_request` CI 自动运行并进入 PR 检查汇总。`land-release-pr.sh` 等待精确 head 的 PR CI 通过,再用 `GITHUB_TOKEN` 合并。维护 PR 的 release-please 调用设置 `skip-github-release: true`。它前后各有一次 `skip-github-pull-request: true` 的元数据调用,使用组织已有的 `RELEASE_PLEASE_TOKEN`:先补建已经手动合并的版本,再处理本次自动合并的版本,保持 release-please 原有的先发布、后计算下一版本的顺序。
|
||||
|
||||
后者需要仓库内容和 workflow 写权限(经典 PAT 的 `repo` + `workflow`,或等价的细粒度权限)。主分支先前包含 workflow 变更时,`GITHUB_TOKEN` 创建历史提交的 tag 可能被 Git refs API 拒绝。该令牌不用于合并或更新版本分支,因此不会因 PAT 写入主分支额外触发一次 Release。缺少令牌时提前报出配置错误。
|
||||
后者需要仓库内容和 workflow 写权限(经典 PAT 的 `repo` + `workflow`,或等价的细粒度权限)。主分支先前包含 workflow 变更时,`GITHUB_TOKEN` 创建历史提交的 tag 可能被 Git refs API 拒绝。该令牌只更新版本分支,不用于合并;主分支仍由 `GITHUB_TOKEN` 写入,因此不会额外触发一次 Release。缺少令牌时提前报出配置错误。
|
||||
@@ -5,7 +5,9 @@
|
||||
#
|
||||
# The merge deliberately uses GITHUB_TOKEN. A push made with that token does not start a workflow run, so merging here does not produce a second Release run racing this one through the concurrency group: the release is created and built inside the same run that merged it. A personal access token would break that property rather than improve it.
|
||||
#
|
||||
# Getting a check onto the pull request is 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.
|
||||
# release-please updates the PR with RELEASE_PLEASE_TOKEN so ordinary pull_request CI starts.
|
||||
# Wait for that run at the exact head: workflow_dispatch checks can be absent from the PR rollup
|
||||
# even when their SHA matches, leaving required checks unsatisfied. Do not bypass those checks.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
@@ -43,15 +45,13 @@ if [[ "$head_branch" != release-please--* ]]; then
|
||||
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"
|
||||
echo "Waiting for PR CI for release pull request #$number at $head_sha"
|
||||
|
||||
# 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.
|
||||
# The pull_request run does not appear immediately; poll for 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 \
|
||||
--event pull_request --limit 20 --json databaseId,headSha \
|
||||
--jq "map(select(.headSha == \"$head_sha\")) | first | .databaseId // empty")
|
||||
[[ -n "$run_id" ]] && break
|
||||
sleep 2
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Exercise the release merge gate with a fake GitHub CLI and no network or writes."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts/ci/land-release-pr.sh'
|
||||
SHA = 'a' * 40
|
||||
FAKE_GH = r'''#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ['CALL_LOG'], 'a') as log:
|
||||
log.write(json.dumps(args) + '\n')
|
||||
if args[:2] == ['api', 'repos/test/repo/git/matching-refs/heads/release-please--']:
|
||||
print('release-please--branches--main')
|
||||
elif args[:2] == ['pr', 'list']:
|
||||
print('12')
|
||||
elif args[:2] == ['pr', 'view']:
|
||||
if 'mergeCommit' in args:
|
||||
print('b' * 40)
|
||||
else:
|
||||
print(json.dumps(dict(state='OPEN', isDraft=False,
|
||||
headRefName='release-please--branches--main', headRefOid='a'*40, baseRefName='main')))
|
||||
elif args[:2] == ['run', 'list']:
|
||||
assert args[args.index('--event')+1] == 'pull_request'
|
||||
assert 'a'*40 in args[args.index('--jq')+1]
|
||||
print('123')
|
||||
elif args[:2] == ['run', 'watch']:
|
||||
assert args[2] == '123' and '--exit-status' in args
|
||||
sys.exit(int(os.environ['CI_RESULT']))
|
||||
elif args[:2] == ['pr', 'merge']:
|
||||
assert '--admin' not in args and '--auto' not in args
|
||||
assert args[args.index('--match-head-commit')+1] == 'a'*40
|
||||
elif args[0] == 'api' and '/compare/' in args[1]:
|
||||
print('ahead')
|
||||
else:
|
||||
raise AssertionError(args)
|
||||
'''
|
||||
|
||||
|
||||
class ReleasePRCI(unittest.TestCase):
|
||||
def run_gate(self, result):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
gh = directory / 'gh'
|
||||
gh.write_text(FAKE_GH)
|
||||
gh.chmod(0o755)
|
||||
log = directory / 'calls.jsonl'
|
||||
env = dict(os.environ, GH_REPO='test/repo', CALL_LOG=str(log), CI_RESULT=str(result))
|
||||
env['PATH'] = str(directory) + os.pathsep + env['PATH']
|
||||
run = subprocess.run(['bash', str(SCRIPT)], env=env, text=True, capture_output=True)
|
||||
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
||||
return run, calls
|
||||
|
||||
def test_passing_pr_ci_merges_only_tested_head(self):
|
||||
run, calls = self.run_gate(0)
|
||||
self.assertEqual(run.returncode, 0, run.stderr)
|
||||
self.assertTrue(any(call[:2] == ['pr', 'merge'] for call in calls))
|
||||
self.assertFalse(any(call[:2] == ['workflow', 'run'] for call in calls))
|
||||
|
||||
def test_failed_pr_ci_never_merges(self):
|
||||
run, calls = self.run_gate(1)
|
||||
self.assertNotEqual(run.returncode, 0)
|
||||
self.assertFalse(any(call[:2] == ['pr', 'merge'] for call in calls))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user