Skip to content

Commit 613c515

Browse files
hotlongclaude
andauthored
fix(ci): Check Changeset 以 merge-base 为差异起点,main 漂移不再算作本 PR 新增 (#6192)
`pr-automation.yml` 的 Check Changeset 用 `github.event.pull_request.base.sha` 作差异起点。这个 sha 在 PR **创建时**被冻结,而 `actions/checkout@v7` 在 `pull_request` 事件上签出的是 merge ref(`refs/pull/N/merge`)—— 一个 parent^1 为**当前** main tip 的合并提交。两者之间的 main 漂移,整段被 `--diff-filter=A` 记成「本 PR 新增的文件」:别人 PR 合进 main 的 changeset,在本 PR 眼里就是本 PR 加的。PR #6117 同一份 diff、零 changeset,02:22Z 判红、02:39Z 判绿,只因为这 17 分钟里 main 多了两个带 changeset 的 PR。这是发版安全门禁上的**假绿**,后续没有 任何一步会把它纠回来。 改法:差异起点改为 `git merge-base origin/$BASE_REF HEAD`。在 merge ref 上它恰好 落在 parent^1,于是 diff 只剩本 PR 自己那侧 —— 早跑晚跑同判。三个 BASE_SHA 消费 者一起改(计数步骤、`check-empty-changeset.mjs --base`、以及脚本自己的 base 语义), checkout 保持不变。 `scripts/check-empty-changeset.mjs`:`scan()` 内部把 base 解析为 `merge-base(base, head)`。CI 路径上这一步是幂等的,它修的是另一半 —— 默认 `--base origin/main` 的本地路径:`changeset pre exit` 从 main 删掉已消费的 changeset 之后,两点 diff 会把未 rebase 分支上仍带着的存量空 changeset 全部读成 「本分支新增」(实测 2 个 fixture → 2 条假红,merge-base → 0 条)。 self-test 21 → 36 条断言,新增两个方向 + 消费者断言:main 漂移**不得**改变判定 (merge ref fixture,真实两父提交)、本 PR 自己新增的空 changeset **仍须**判红、 以及直接读 `pr-automation.yml` 断言计数与 `--base` 都吃 `$MERGE_BASE` —— 少了最 后这组,workflow 明天被改回冻结 sha 也不会有任何一条断言变红,那正是 #6129 要堵 的「换个形状回来」。 `check-changeset-no-major.mjs` 实测未受影响:它不读 `--base`、不做 git diff, `readdirSync('.changeset')` 扫整个目录,不存在 base 选取问题。#5620 的 allow-major 步骤逐字未动。 Refs #6129 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent a38ba08 commit 613c515

2 files changed

Lines changed: 411 additions & 15 deletions

File tree

.github/workflows/pr-automation.yml

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,76 @@ jobs:
217217
if: steps.labels.outputs.skip != 'true'
218218
uses: actions/checkout@v7
219219
with:
220+
# `fetch-depth: 0` is load-bearing for the step below, not just nice to
221+
# have: it is what makes actions/checkout fetch
222+
# `+refs/heads/*:refs/remotes/origin/*` (getRefSpecForAllHistory) on top
223+
# of the PR merge ref, so `origin/<base branch>` exists locally and a
224+
# merge base can be computed at all. A shallow checkout here would take
225+
# the base resolution below straight to its #4690 failure branch.
220226
fetch-depth: 0
221227

228+
# #6129: every diff below starts HERE, and the one thing it must never be
229+
# is `github.event.pull_request.base.sha`.
230+
#
231+
# The payload's `base.sha` is frozen when the PR is OPENED and does not
232+
# move on `synchronize`. HEAD, meanwhile, is the merge ref
233+
# (`refs/pull/N/merge`) that the checkout above resolves by default on a
234+
# `pull_request` event -- a merge commit whose parent^1 is whatever main
235+
# tipped at when the ref was built. So `diff base.sha HEAD` reports
236+
# EVERYTHING main gained in between as "added by this PR", and with ~18
237+
# merges a day that is a lot. Measured on PR #6117: identical diff, zero
238+
# changesets of its own, `failure` at 02:22Z and `success` at 02:39Z --
239+
# main had merged two other PRs' changesets into the merge ref and the
240+
# counting step below took them for this PR's. A release-safety gate that
241+
# goes GREEN because someone ELSE released something is the one direction
242+
# nothing downstream corrects.
243+
#
244+
# The merge base fixes it because on a merge-ref HEAD it lands exactly on
245+
# parent^1 -- verified on a real merge commit, not assumed -- so the diff
246+
# is this PR's own side and nothing else. Same diff, same verdict, however
247+
# long the PR sits and however far main runs ahead.
248+
#
249+
# Two spellings that look like fixes and are not:
250+
# - `git diff base.sha...HEAD` (three dots). Three-dot means
251+
# `merge-base(base.sha, HEAD)..HEAD`, and `base.sha` is ALREADY an
252+
# ancestor of HEAD, so the merge base is `base.sha` itself and the
253+
# count does not move. Measured: still 2 impostors in the #6117 repro.
254+
# - `HEAD^1`. Correct on a merge ref and silently catastrophic the day
255+
# someone gives the checkout a `ref:`, where parent^1 becomes the PR's
256+
# previous commit. `merge-base` is right under BOTH checkouts, which is
257+
# why it is the one written here.
258+
- name: Resolve the diff base (merge base with the base branch)
259+
id: diffbase
260+
if: steps.labels.outputs.skip != 'true'
261+
env:
262+
BASE_REF: ${{ github.event.pull_request.base.ref }}
263+
PINNED_BASE_SHA: ${{ github.event.pull_request.base.sha }}
264+
run: |
265+
if [ -z "$BASE_REF" ]; then
266+
echo "::error::This event carries no base branch, so the changeset diff base cannot be computed. A gate that cannot read its input has verified nothing, so this is a failure rather than a pass (#4690)."
267+
exit 1
268+
fi
269+
if ! git rev-parse --verify --quiet "refs/remotes/origin/$BASE_REF^{commit}" >/dev/null; then
270+
git fetch --no-tags --quiet origin "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" \
271+
|| echo "::warning::Could not fetch origin/$BASE_REF; the merge-base resolution below will decide."
272+
fi
273+
# `if !` rather than a bare assignment on purpose: these steps run under
274+
# `bash -e` (no `shell:` key anywhere in this file), where a failing
275+
# command substitution kills the step with no message at all. The gate
276+
# is allowed to fail here -- it is NOT allowed to fail unexplained.
277+
if ! MERGE_BASE=$(git merge-base "refs/remotes/origin/$BASE_REF" HEAD); then
278+
echo "::error::Could not compute merge-base(origin/$BASE_REF, HEAD), so the changeset diff has no trustworthy starting point. Failing rather than falling back to the frozen base.sha, which is the #6129 defect itself."
279+
exit 1
280+
fi
281+
echo "merge_base=$MERGE_BASE" >> "$GITHUB_OUTPUT"
282+
# The drift is printed, not just corrected. #6129 was invisible for as
283+
# long as it was because nothing in the log ever said which commit the
284+
# diff started from; this line is what makes the next occurrence of the
285+
# family readable straight off the step output.
286+
DRIFT=$(git rev-list --count "$PINNED_BASE_SHA..$MERGE_BASE" 2>/dev/null || echo '?')
287+
echo "Diff base: $MERGE_BASE (merge-base of origin/$BASE_REF and HEAD)"
288+
echo "Frozen payload base.sha: $PINNED_BASE_SHA -- $BASE_REF has moved $DRIFT commit(s) since it was frozen, and that drift is exactly what this gate used to count as this PR's own."
289+
222290
- name: Setup Node.js
223291
if: steps.labels.outputs.skip != 'true'
224292
uses: actions/setup-node@v7
@@ -236,7 +304,7 @@ jobs:
236304
- name: Check for a changeset added by this PR
237305
if: steps.labels.outputs.skip != 'true'
238306
env:
239-
BASE_SHA: ${{ github.event.pull_request.base.sha }}
307+
MERGE_BASE: ${{ steps.diffbase.outputs.merge_base }}
240308
run: |
241309
if [ ! -d ".changeset" ]; then
242310
echo "::warning::.changeset directory not found. Skipping changeset check."
@@ -248,8 +316,11 @@ jobs:
248316
# .md file, so the directory is permanently non-empty and the gate can
249317
# never go red. #3373 merged a real spec/api-surface fix with no
250318
# changeset while this step happily reported "Found 104 changeset(s)".
251-
# Diffing against BASE_SHA ignores that residue and sees only what the
252-
# PR itself introduced.
319+
# Diffing against the merge base ignores that residue and sees only
320+
# what the PR itself introduced. It has to be the MERGE BASE and not
321+
# the payload's frozen `base.sha` -- see the base-resolution step above
322+
# (#6129); with the frozen sha this count silently included every
323+
# changeset main gained while the PR was open.
253324
#
254325
# An empty-frontmatter changeset still COUNTS here — this step counts
255326
# files, and that is deliberately unchanged. What has changed is that
@@ -268,7 +339,7 @@ jobs:
268339
# for new files. Splitting it across two steps is what keeps THIS
269340
# step's failure mode ("no changeset at all") distinct from that one's
270341
# ("the changeset you added declares nothing").
271-
ADDED=$(git diff --name-only --diff-filter=A "$BASE_SHA" HEAD -- '.changeset/*.md' \
342+
ADDED=$(git diff --name-only --diff-filter=A "$MERGE_BASE" HEAD -- '.changeset/*.md' \
272343
| grep -v '/README\.md$' | wc -l | tr -d '[:space:]')
273344
if [ "$ADDED" -eq 0 ]; then
274345
# The full comparison goes to the job log — that is what an author
@@ -362,13 +433,19 @@ jobs:
362433
# one. A consistent exemption beats a nondeterministic gate, and the case
363434
# is empty of motive anyway: an author who already has the label gains
364435
# nothing by adding the file.
436+
#
437+
# `--base` takes the same merge base the counting step uses, for the same
438+
# #6129 reason: fed the payload's frozen `base.sha`, this script reads every
439+
# empty changeset main gained while the PR was open as one this PR added,
440+
# and reports it against an author who never touched the file. Same defect,
441+
# opposite direction (a false RED here, a false GREEN up there), one base.
365442
- name: Reject an empty-frontmatter changeset added by this PR
366443
if: steps.labels.outputs.skip != 'true'
367444
env:
368-
BASE_SHA: ${{ github.event.pull_request.base.sha }}
445+
MERGE_BASE: ${{ steps.diffbase.outputs.merge_base }}
369446
run: |
370447
node scripts/check-empty-changeset.mjs --self-test
371-
node scripts/check-empty-changeset.mjs --base "$BASE_SHA"
448+
node scripts/check-empty-changeset.mjs --base "$MERGE_BASE"
372449
373450
- name: Guard against accidental major bumps (launch window)
374451
# Every publishable package is in one Changesets "fixed" (lockstep) group,

0 commit comments

Comments
 (0)