Skip to content

ci(release): gate every tag consumer on one certified-commit check - #14

Merged
wangrh0909 merged 6 commits into
masterfrom
ci/release-requires-green-pipeline
Sep 20, 2026
Merged

wangrh0909 merged 6 commits into
masterfrom
ci/release-requires-green-pipeline

Conversation

@voidvec

@voidvec voidvec commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Why

v1.0.0 是在 windows-build-and-test 为 failure 的 commit 上发布出去的。分支 ruleset 的必需检查只管合并,而 tag 不是被合并进来的——git tag v1.1.0 <任意 commit> && git push --tags 可以绕过整套门禁。更糟的是 deploy.yml 吃同一个 v* push,它的 build-and-push / deploy-production 只 needs: preflight(检查凭据在不在,不检查 commit 健不健康),而 jobs.*.needs 不能跨工作流,所以一条红色的 release 根本挡不住另一条工作流滚动 ECS。

本 PR 把"这个 tag 被认证过"做成一份定义、两个调用方,并且把它的判定表钉进 CI。

机制(全部在本仓实测过)

门禁本体 .github/workflows/_tag-gate.yml(on: workflow_call),被 release.yml 的 ci-gate 与 deploy.yml 的 tag-gate 调用;build-and-push 现在 needs: [preflight, tag-gate]。

步骤 断言 证据
tag 名形状 ^v[0-9]+\.[0-9]+\.[0-9]+$,与 check_version_sync.py --tag 同形 形状检查在任何 API 调用之前,路径里进不去任何东西
tag → commit commits/<tag> 会解引用附注 tag v1.0.0 解出 00ab153e…;tag 对象自己的 SHA 查 check-runs 返回 422,即"不带 check run"
包含性 compare/master...<sha> 的 .status 必须是 behind/identical v1.0.0 的 commit = behind;未合并分支上的 commit 即便 PR 检查全绿也会被拒
五个 context linux/windows/macos * / build-test + sdk-smoke-linux/windows / sdk-smoke 名字抄自 master tip 的真实 check-run 清单,不是按矩阵推的;FAST 不列是因为 MAIN needs 它
结论取哪一次 同名多次上报取 id 最大那次 否则旧的一次 cancelled 会把发布永久锁死,重跑绿色也清不掉
早退兜底 ENTRY_CHECKS: static-analysis,clang-tidy 十五分钟没出现即失败 清单永不为空(release/deploy 自己也在同一 commit 上报),所以"空清单"不能当判据;这两个名字在 pull_request 里也出现,故早退只是"别白等两小时"的兜底,包含性检查才是主判

窗口实测:一轮绿色合并链 32 分钟(clang-tidy 单独占 FAST 15 分钟),DEADLINE_MINUTES: 120 容得下冷缓存,timeout-minutes: 135 又必须大于它——否则作业被 runner 掐掉就只剩"超时"而没有"哪个 context 没绿"。

复现套件(新增 scripts/ci/tag_gate_scenarios.py)

门禁是 bash 写的发布策略,C++ 套件覆盖不到它,所以脚本从工作流文件里现场抽取 run: 正文(不是复制一份),生成打桩 gh,跑 23 个判定;--live 再用真实只读 API 跑 3 例。已挂进 ci.yml 的 static-analysis,因此改坏一条判定会在 PR 上变红,而不是在下一次发布时才发现。

all passed: 23/23        # 离线
all passed: 3/3          # --live

覆盖包含:入口早退的正反两向、第一轮 pending / 第二轮绿(否则循环的第二次迭代从未被执行)、三个 API 读取各自失败的分支(传输错误不得被读成结论)、名单被截断/清空、旋钮为空或非数字、context 名含反斜杠、空行不误计数、同名多次上报的 id 数值比较、非 tag 触发原样放行。

--live 直接复现本 PR 存在的原因:用 v1.0.0 那个 commit 自己上报过的检查名去判,门禁给出 windows-build-and-test: failure;用今天这五个名字判,则是"这个 commit 上没有这五条"。

评审抓出来的东西(都已落地,写进了注释)

  1. 早退原本是 [ -z "$runs" ]——永远不会成立的死代码,改成按入口作业判定。
  2. 第一版入口计数器写的是 hit[$2] == 0,awk 里未初始化元素等于 0,于是任何无关 check(包括 release 自己的 version-check)都被记成入口命中,早退依旧永不触发;改成 ($2 in hit) && hit[$2] == 0。这条正是复现套件第一次跑出来的——它当时不是失败而是挂住十分钟,所以每例加了 20s 硬预算。
  3. 旋钮校验原先排在 $(( start + DEADLINE_MINUTES * 60 )) 之后,要抓的值先在 bash 算术里死了、且不报旋钮名;已前移。
  4. context 名会进 awk -v want=(会反转义反斜杠),含反斜杠的名字永远匹配不上→两小时 unreported 死等;已加字符白名单。
  5. 注释里断言"命令替换失败不触发 set -e"是错的(普通 VAR=$(cmd) 赋值会),改成说明守卫是为了给出消息;SHA= 读取补上守卫。
  6. actionlint 抓到我在 run: 注释里写了字面 ${{ matrix.check_name }}——Actions 真会去替换,已改为文字描述。
  7. 文档里四处夸大已收窄:入口作业证明的是"有 ci.yml run 摸到过这条 commit"而非"合并流水线跑完了";畸形 v* tag 是"能走到推镜像/滚 ECS 的作业前"而非"滚过生产"(那两个作业因缺凭据自己跳过,没出事是密钥缺口不是门禁);复现套件跑在进 master 的 PR上而非所有 PR;"只需 bash 和 awk"漏了 date/sleep。

一条评审意见经实测否掉:担心 release.yml 的 sdk-smoke 与五个必需 context 撞名。master tip 的名单显示带 sdk-smoke-linux 那半边只来自 ci.yml 的 name: 钉名,release.yml 没有这个钉名,因此不会覆盖结论;但"仅按名字取 max-id"依赖这个前提,已写进门禁注释。

明确不做

  • 不对历史 commit 生效:Actions 按被 tag 的 commit 里存的工作流文件执行,所以早于本改动的 commit 补 tag 仍跑旧定义。文档已把这条写进去,而不是留一个"以后任何 tag 都安全"的错觉。
  • 不比对 github.sha 与 tag 解析出的 SHA:附注 tag 下 github.sha 可能是 tag 对象 SHA,那样会把每次发布误判失败;包含性 + 入口作业两条判据已经覆盖目的。
  • 门禁红时下游 skipped、notify 也 skipped——保持现状,红色作业本身带 ::error:: 且点名要重跑哪条腿。

Test plan

  • python scripts/ci/tag_gate_scenarios.py → 23/23
  • python scripts/ci/tag_gate_scenarios.py --live → 3/3(只读)
  • 6 道守卫:check_version_sync / check_docs_drift / check_migrations / check_openapi_routes / check_test_layout / check_architecture 全绿
  • actionlint 对 ci.yml / _tag-gate.yml / release.yml / deploy.yml 全绿
  • .claude 与 .codex 两份 release SKILL.md 逐字节一致(cmp)
  • 本轮 CI:static-analysis 的 "Tag gate decision table" 步骤在 ubuntu runner 上绿(本机是 Git Bash,Linux 分支靠 CI 验)
  • 合并后按需 gh run view 看 deploy.yml 的 tag-gate 在分支 dispatch 下为 success 且打印 "nothing to certify"

Branch protection cannot reach a tag: the ruleset's required contexts gate
merges, and `git tag v1.1.0 <commit> && git push --tags` is not a merge. The
hole is not hypothetical — v1.0.0 shipped with windows-build-and-test reported
as failure on that exact commit.

Add a ci-gate job between version-check and sdk-smoke. It dereferences the tag
to its commit, refuses anything compare/master...<sha> does not place inside
master's history (which also rejects an unmerged branch commit whose PR checks
look green), then polls the five contexts the merge pipeline reports until all
are success: a non-success fails immediately, an absent or still-running check
polls for 25 minutes and fails naming what never arrived, and every run sharing
a name must succeed so a re-run cannot certify a release on its stale
conclusion. sdk-smoke still runs afterwards because "green on master" and
"installs the way a consumer builds it" describe different artifacts.

The tag name is matched against semver before it reaches an API path, since it
arrives from the event payload.
The gate as first written could not pass the release it guards: one measured
green pass of the merge chain takes 32 minutes, and the poll window was 25, so
every tag pushed straight after a merge would have timed out waiting for checks
that were simply still running. Two hours now, with the job ceiling above it so
the loop reports which context is missing rather than being cancelled bare.

Also fixed in the same review pass:
- a commit with no check runs at all (an interior master commit; a multi-commit
  push runs CI only on its tip) exits in five minutes instead of two hours;
- one verdict per check name, taken from the highest check-run id, so a stale
  `cancelled` sibling left behind by a re-run cannot veto a release forever;
- the compare call is guarded, because a failing command substitution does not
  trip `set -e` and a 403 must not be read as a policy decision;
- pending/failed lists are joined on one line, since a workflow-command data
  value may not contain a raw newline and the check UI truncates it otherwise;
- the tag shape check matches `check_version_sync.py --tag` exactly (three
  numeric segments), so a prerelease ref cannot reach an API path.

Verified by extracting the run block from the YAML and replaying it: nine
stubbed scenarios plus three against the live API, where the v1.0.0 tag is
refused on `windows-build-and-test: did not succeed` — the incident reproduced
from the record. Docs and the release skill carry the new numbers.
Three ways the first version of ci-gate could report a verdict it had not
earned, all found by reviewing the extracted script rather than the YAML:

- REQUIRED_CHECKS is the whole policy, so a YAML refactor that emptied or
  truncated its block scalar left `pending` empty on the first pass and printed
  "Merge pipeline is green" with nothing inspected. The list now has to hold
  exactly the five contexts, or the job fails.
- DEADLINE_MINUTES et al. feed arithmetic, where an empty value is a silent 0:
  a blank knob would give up instantly and look like a real timeout. Non-numeric
  now exits with the name of the offending variable.
- `did not succeed` hid which conclusion it was, so a `cancelled` leg read the
  same as a `failure`. The line carries the conclusion and says that re-running
  the leg, then this workflow, replaces it — the newest attempt is what counts.

The failure verdict also lost its multi-line form in favour of the per-name
`name: <conclusion>` pairs the scenarios could assert on.

Replay suite grew from 9 to 14 stubbed scenarios (list-count guards, blank
entry, conclusion in the message) plus 3 against the live API; CONTRIBUTING now
lists ci-gate in the release chain, which it never had.
release.yml was not the only thing that ships on a tag. `deploy.yml` triggers on
the same `push: tags: v*`, and `build-and-push` / `deploy-production` needed only
`preflight` — which reports whether secrets exist, not whether the commit is
sound — so `git tag v9.9.9 <commit> && git push --tags` pushed a semver image and
rolled the ECS service for any commit at all, red pipeline or none. A green
release could not have stopped it either: `jobs.*.needs` cannot cross workflows,
so no amount of gating inside release.yml reaches that leg.

The policy therefore moves to `.github/workflows/_tag-gate.yml` (`workflow_call`,
read-only permissions, no trigger of its own), which release.yml calls as
`ci-gate` and deploy.yml now calls as `tag-gate` with `build-and-push` depending
on it. Two copies is how one drifts, and this repo already deleted four
per-platform workflow copies for that reason.

deploy.yml is also dispatchable from a branch, where there is no tag to certify
and its production leg already requires a tag ref, so a non-tag ref passes
through: failing that path would break manual deploys to guard nothing. The
script itself moved byte-for-byte otherwise, plus that one entry condition.

Suite is now 15 stubbed scenarios (adding the pass-through) and 3 live-API ones,
all passing; the replay harness lives in the PR body because `build/` is
gitignored.
The 5-minute bail tested `[ -z "$runs" ]`, and that condition can never
hold: release.yml and deploy.yml report their own check runs against the
tagged commit, so the listing is never empty and a tag on a commit the
merge pipeline never ran on burned the full 120 minutes in both lanes.
It now counts hits for `ENTRY_CHECKS: static-analysis,clang-tidy` —
ci.yml's own entry jobs, which appear within seconds of a start while the
five required contexts only arrive after FAST finishes.

The counter needs `($2 in hit) && hit[$2] == 0`, not `hit[$2] == 0` alone:
an unset awk element compares equal to 0, so the bare form registered
every unrelated check run as an entry hit and the bail still would not
fire. The first draft of the replay suite caught this; it had hung for
ten minutes instead, which is why each case now carries a 60s budget.

`scripts/ci/tag_gate_scenarios.py` extracts the live `run:` bytes from
_tag-gate.yml — never a copy — and drives them through 17 stubbed
verdicts, including the positive control that an entry job present does
*not* bail, and `--live` replays v1.0.0 against the real read-only API
under three name lists. The `static-analysis` job runs the stubbed mode,
so a gate verdict that drifts fails a PR rather than a release.

Docs followed the code: the "a commit with no check runs at all" claim
in AGENTS.md, TECH_SPECS.md and both release SKILL.md copies is now the
entry-job rule, and CONTRIBUTING.md and the CHANGELOG say plainly that
the gate binds forward only (Actions runs the definition stored in the
tagged commit) and that a `v*` tag outside v+N.N.N now reddens the deploy
lane where it previously built and rolled.
A review pass over the gate turned up four ways it could reach a right answer
for the wrong reason, plus wording that was stronger than the evidence.

- The timing knobs were validated *after*
  `$(( start + DEADLINE_MINUTES * 60 ))` had already read them, so the exact
  value that check exists to catch aborted inside bash's own arithmetic error
  and named no knob. Validation now runs first, with a non-numeric case to pin
  the order.
- `EARLY_BAIL_SECONDS` 300 -> 900. ci.yml's entry check runs exist as soon as
  its run is created, so the signal does not need a started job — but a
  congested runner queue on release day should not buy a "nothing is coming"
  verdict after five minutes.
- Each `REQUIRED_CHECKS` line is character-checked before it reaches
  `awk -v want=`, which unescapes backslashes: such a name can never match, and
  the release would have stalled for two hours on an `unreported` line.
- The `SHA=$(gh api ...)` read is guarded, and a comment claiming a plain
  assignment does not trip `set -e` — it does — now says the guard is for the
  message.

The name-only max-id pick is documented as depending on ci.yml's pinned job
names, and checked against a real run: master's tip lists
`sdk-smoke-linux / sdk-smoke` etc. from the `check_name` pins that exist only in
ci.yml, so release.yml's legs cannot overwrite a verdict.

`scripts/ci/tag_gate_scenarios.py` grew a stateful stub so the loop's *second*
iteration executes (pending then green) instead of every case deciding on pass
one, a failing case for each of the three API reads, and a 20s per-case budget
so a broken gate cannot push the suite past the job's 10 minutes. 23 cases
offline, 3 live.

Docs: the entry jobs prove a ci.yml run reached the commit rather than that the
merge pipeline finished it; a malformed `v*` tag previously *reached* the
push-and-roll legs instead of having rolled production (those legs stop on
missing credentials — no incident so far is a secret gap, not a gate); the
replay runs on pull requests into `master`, not every PR; "needs only bash and
awk" forgot `date` and `sleep`. actionlint caught a literal
`${{ matrix.check_name }}` written into the gate's comments, which Actions would
have substituted — gone.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@wangrh0909
wangrh0909 merged commit 92a929c into master Sep 20, 2026
8 checks passed
@voidvec
voidvec deleted the ci/release-requires-green-pipeline branch September 27, 2026 06:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants