@@ -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