Skip to content

fix(tooling): check-i18n-coverage 逐 config 收集失败,第一个失败不再吃掉其余十一个 (#6033) #10330

fix(tooling): check-i18n-coverage 逐 config 收集失败,第一个失败不再吃掉其余十一个 (#6033)

fix(tooling): check-i18n-coverage 逐 config 收集失败,第一个失败不再吃掉其余十一个 (#6033) #10330

Workflow file for this run

name: PR Automation
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
jobs:
# ===========================================================================
# Both label-writing jobs below write this PR's label set with a WHOLE-SET PUT
# (`PUT /issues/{n}/labels`), never an additive POST. Read out of the pinned
# sources rather than inferred from the docs (#5649):
#
# * codelytv/pr-size-labeler@v1.10.4 -- src/github.sh:68-91
# (`github::add_label_to_pr`): GETs the PR, greps its OWN size family out
# of the result, appends the new size label, then
# `curl -X PUT .../issues/$pr_number/labels` with the whole set.
# * actions/labeler@v7.0.0 -- src/labeler.ts:56,111-133 plus
# src/api/set-labels.ts: snapshots `preexistingLabels` at run start,
# unions in the config matches, re-reads the live label list once, then
# calls `client.rest.issues.setLabels` -- which IS the PUT.
#
# Neither action exposes an input that makes its write additive, and
# `sync-labels` is NOT that input: it only decides whether a label the CONFIG
# owns is dropped once its globs stop matching (labeler.ts:81-83). It is
# pinned explicitly below for upgrade-drift protection only. It does not, and
# cannot, stop the clobbering described here.
#
# A whole-set PUT only destroys someone else's label when that label lands
# inside the window between the writer's read and its PUT. What this file can
# therefore fix is the OVERLAP, and two changes below do exactly that:
#
# 1. The two writers no longer run concurrently -- `auto-label` needs
# `pr-size`. They used to be started by the same event and overlapped
# exactly. Live specimen, PR #5650 run 31051251795 (the `opened` run):
# `Add size label` ran 22:03:47->22:03:49 and
# `Label based on changed files` ran 22:03:47->22:03:49, and the
# labeler's PUT emitted `unlabeled size/s` at 22:03:49 -- one second
# after the size job added it, for a label the labeler does not manage.
# 2. Neither writer runs on `labeled`/`unlabeled` any more. Their only input
# is the diff, which a label event cannot change, so such a run could
# only ever re-PUT the same set -- one more chance to erase a concurrent
# writer in exchange for no new information. Same PR, run 31051273625
# (started by a label event): `Auto Label` recomputed and wrote nothing,
# `Check PR Size` re-PUT at 22:04:22. The two event types stay in `on:`
# because `changeset-check` genuinely needs them (#5580).
#
# NOT closed by either change, and deliberately recorded rather than implied:
# a writer OUTSIDE this workflow -- an agent or a human labelling the PR
# seconds after `gh pr create`, i.e. exactly while these jobs run -- can still
# land inside a PUT window and be erased. That is how #5533 lost its
# `skip-changeset` exemption for one second (15:46:44 applied, 15:46:45 erased
# by the labeler's PUT of `{size/m, tests}`). Closing that half needs the
# writes themselves to become additive, not merely better ordered; it is the
# open half of #5649 and no configuration here can stand in for it.
# ===========================================================================
pr-size:
name: Check PR Size
# A `labeled`/`unlabeled` event cannot change this job's input (the diff),
# so running it there buys nothing and costs one whole-set PUT. See above.
if: github.event.action != 'labeled' && github.event.action != 'unlabeled'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Add size label
uses: codelytv/pr-size-labeler@v1.10.4
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
xs_label: 'size/xs'
xs_max_size: '10'
s_label: 'size/s'
s_max_size: '100'
m_label: 'size/m'
m_max_size: '500'
l_label: 'size/l'
l_max_size: '1000'
xl_label: 'size/xl'
fail_if_xl: 'false'
message_if_xl: 'This PR is very large. Consider breaking it into smaller PRs for easier review.'
files_to_ignore: 'pnpm-lock.yaml package-lock.json yarn.lock'
auto-label:
name: Auto Label
# ORDERING ONLY, not a dependency: this job wants `pr-size`'s PUT to be
# already done, so that the label set this one reads includes the size
# label and its own PUT carries it forward. `!cancelled()` is written out
# because GitHub would otherwise wrap this `if:` in an implicit `success()`
# -- a failed or skipped size job must not silently stop path labelling.
# (Same reasoning the check-workflow-status-functions gate exists to make
# explicit; that gate scans only `needs.*.outputs.*` reads, so this one is
# out of its scope and has to state its intent by hand.)
needs: pr-size
if: >-
!cancelled()
&& github.event.action != 'labeled'
&& github.event.action != 'unlabeled'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Label based on changed files
uses: actions/labeler@v7.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler.yml
# Pinned at the value it already defaults to (action.yml), because a
# default is not a decision: an upgrade may move it, and `true` would
# make this step REMOVE a label of its own config whenever the globs
# stop matching -- on a `synchronize` that reverts a docs file, for
# instance. Pinning it is upgrade-drift protection and nothing more:
# `sync-labels` never governed foreign labels, so it is NOT the fix
# for the clobbering documented at the top of this file (#5649).
sync-labels: false
changeset-check:
name: Check Changeset
runs-on: ubuntu-latest
# Two exemptions, both meaning "this PR declares no release of its own":
# - the `skip-changeset` label — the author's explicit opt-out;
# - the Changesets release PR (`changeset-release/main`, pushed by
# changesets/action). That PR is the CONSUMING side: it applies pending
# changesets into versions and CHANGELOGs and adds none, so the gate
# below can only ever fail it. It did, on every `chore: version
# packages` PR (#4422 / #4894), leaving the release blocked on a check
# that was structurally unsatisfiable.
# Pin the author as well as the branch name, so a hand-pushed branch of
# that name cannot borrow the exemption as an escape hatch.
#
# The LABEL half of this expression is a fast path, NOT the authority (#5580).
# `github.event.pull_request.labels` is a snapshot frozen when the event
# fired, so a label applied seconds after `gh pr create` is invisible to the
# `opened` run -- and `rerun_failed_jobs` replays that SAME payload
# (pm-dispatch Operational notes 5), so the resulting red run can never be
# re-run green. It is permanently red by construction: three PRs in one day
# (#5467 -- this gate's own fix PR -- plus #5501 and #5577) each left a stale
# red that a human or agent had to stop and explain away.
# The authority is the live re-read in the first step below. This expression
# only short-circuits the case where the payload ALREADY shows the label, so
# the common path still costs no runner at all. The branch/author half needs
# no such treatment: head_ref and the PR author cannot change under a rerun.
#
# Keeping the fast path leaves ONE stale cell, in the opposite direction: a
# label REMOVED after the event fired still short-circuits this run, which is
# then permissive on the strength of a snapshot. That one is self-correcting
# and was left deliberately -- removing a label always fires an `unlabeled`
# event of its own, and the run it starts sees no label in either place and
# enforces. The direction #5580 is about has no such rescue: the `labeled`
# run's green verdict does not clear the `opened` run's red one.
if: >-
!contains(github.event.pull_request.labels.*.name, 'skip-changeset')
&& !(github.head_ref == 'changeset-release/main'
&& github.event.pull_request.user.login == 'github-actions[bot]')
permissions:
contents: read
pull-requests: write
steps:
# The label read the frozen payload could not do. It runs BEFORE checkout
# on purpose: when the label is there, every step below is skipped and the
# whole job costs one API call, so converging on the live state is cheaper
# than the stale red it replaces.
#
# The direction of the tolerance is deliberate: an unreadable label list
# (API error, no PR number) resolves to `skip=false`, i.e. ENFORCE. A gate
# that could not read its input has verified nothing, and handing out an
# exemption on that basis is the #4690 anti-pattern -- a check that skips
# silently, exits 0 and reads as "no violations". The failure is announced
# as a warning and the changeset count below decides.
- name: Re-read this PR's labels live (the event payload can predate them)
id: labels
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
if [ -z "$PR_NUMBER" ]; then
echo "::warning::No PR number on this event, so the labels could not be re-read. Enforcing the changeset check."
echo 'skip=false' >> "$GITHUB_OUTPUT"
exit 0
fi
# The pulls endpoint carries the PR's full label set inline, and a GET
# on it is covered by this job's own pull-requests permission -- no
# pagination, no wider scope than the job already declares.
if ! LABELS=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.labels[].name'); then
echo "::warning::Could not read the labels of PR #$PR_NUMBER, so this run cannot see a 'skip-changeset' applied after the event fired. Enforcing the changeset check."
echo 'skip=false' >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Labels on PR #$PR_NUMBER right now: ${LABELS:-(none)}"
# Whole-line fixed match, fed by a here-string rather than a pipe.
# `-x -F` because the payload expression this replaces, `contains(<array>,
# 'skip-changeset')`, matches an array ELEMENT exactly -- a substring
# match would newly exempt a PR labelled e.g. `skip-changeset-audit`.
# The here-string (as in release.yml) keeps `grep -q` out of a pipeline:
# -q closes the pipe on the first hit, so a piped writer can take
# SIGPIPE and, under `set -o pipefail`, flip this test to false for a
# long enough label list.
if grep -qxF 'skip-changeset' <<<"$LABELS"; then
echo "::notice::'skip-changeset' is on PR #$PR_NUMBER (read live, not from the event payload), so this PR declares no release of its own and the changeset check is exempt."
echo 'skip=true' >> "$GITHUB_OUTPUT"
else
echo 'skip=false' >> "$GITHUB_OUTPUT"
fi
# Every step from here down carries the same guard rather than the job
# carrying one `if:`, because a job-level `if:` cannot read a step of its
# own job. Repeating it beats the alternatives: a separate gate job would
# add a check row and a brand-new way to go red to a repo already fighting
# check-list noise, and testing the label inside the counting step would
# pay for checkout + install before discovering the PR is exempt.
- name: Checkout repository
if: steps.labels.outputs.skip != 'true'
uses: actions/checkout@v7
with:
# `fetch-depth: 0` is load-bearing for the step below, not just nice to
# have: it is what makes actions/checkout fetch
# `+refs/heads/*:refs/remotes/origin/*` (getRefSpecForAllHistory) on top
# of the PR merge ref, so `origin/<base branch>` exists locally and a
# merge base can be computed at all. A shallow checkout here would take
# the base resolution below straight to its #4690 failure branch.
fetch-depth: 0
# #6129: every diff below starts HERE, and the one thing it must never be
# is `github.event.pull_request.base.sha`.
#
# The payload's `base.sha` is frozen when the PR is OPENED and does not
# move on `synchronize`. HEAD, meanwhile, is the merge ref
# (`refs/pull/N/merge`) that the checkout above resolves by default on a
# `pull_request` event -- a merge commit whose parent^1 is whatever main
# tipped at when the ref was built. So `diff base.sha HEAD` reports
# EVERYTHING main gained in between as "added by this PR", and with ~18
# merges a day that is a lot. Measured on PR #6117: identical diff, zero
# changesets of its own, `failure` at 02:22Z and `success` at 02:39Z --
# main had merged two other PRs' changesets into the merge ref and the
# counting step below took them for this PR's. A release-safety gate that
# goes GREEN because someone ELSE released something is the one direction
# nothing downstream corrects.
#
# The merge base fixes it because on a merge-ref HEAD it lands exactly on
# parent^1 -- verified on a real merge commit, not assumed -- so the diff
# is this PR's own side and nothing else. Same diff, same verdict, however
# long the PR sits and however far main runs ahead.
#
# Two spellings that look like fixes and are not:
# - `git diff base.sha...HEAD` (three dots). Three-dot means
# `merge-base(base.sha, HEAD)..HEAD`, and `base.sha` is ALREADY an
# ancestor of HEAD, so the merge base is `base.sha` itself and the
# count does not move. Measured: still 2 impostors in the #6117 repro.
# - `HEAD^1`. Correct on a merge ref and silently catastrophic the day
# someone gives the checkout a `ref:`, where parent^1 becomes the PR's
# previous commit. `merge-base` is right under BOTH checkouts, which is
# why it is the one written here.
- name: Resolve the diff base (merge base with the base branch)
id: diffbase
if: steps.labels.outputs.skip != 'true'
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
PINNED_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ -z "$BASE_REF" ]; then
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)."
exit 1
fi
if ! git rev-parse --verify --quiet "refs/remotes/origin/$BASE_REF^{commit}" >/dev/null; then
git fetch --no-tags --quiet origin "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" \
|| echo "::warning::Could not fetch origin/$BASE_REF; the merge-base resolution below will decide."
fi
# `if !` rather than a bare assignment on purpose: these steps run under
# `bash -e` (no `shell:` key anywhere in this file), where a failing
# command substitution kills the step with no message at all. The gate
# is allowed to fail here -- it is NOT allowed to fail unexplained.
if ! MERGE_BASE=$(git merge-base "refs/remotes/origin/$BASE_REF" HEAD); then
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."
exit 1
fi
echo "merge_base=$MERGE_BASE" >> "$GITHUB_OUTPUT"
# The drift is printed, not just corrected. #6129 was invisible for as
# long as it was because nothing in the log ever said which commit the
# diff started from; this line is what makes the next occurrence of the
# family readable straight off the step output.
DRIFT=$(git rev-list --count "$PINNED_BASE_SHA..$MERGE_BASE" 2>/dev/null || echo '?')
echo "Diff base: $MERGE_BASE (merge-base of origin/$BASE_REF and HEAD)"
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."
- name: Setup Node.js
if: steps.labels.outputs.skip != 'true'
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Enable Corepack
if: steps.labels.outputs.skip != 'true'
run: corepack enable
- name: Install dependencies
if: steps.labels.outputs.skip != 'true'
run: pnpm install --frozen-lockfile
- name: Check for a changeset added by this PR
if: steps.labels.outputs.skip != 'true'
env:
MERGE_BASE: ${{ steps.diffbase.outputs.merge_base }}
run: |
if [ ! -d ".changeset" ]; then
echo "::warning::.changeset directory not found. Skipping changeset check."
exit 0
fi
# Count changesets THIS PR adds (diff against the base commit), NOT
# the whole .changeset directory. A global `find | wc -l` is unsound:
# in pre-release (RC) mode `changeset version` RETAINS every consumed
# .md file, so the directory is permanently non-empty and the gate can
# never go red. #3373 merged a real spec/api-surface fix with no
# changeset while this step happily reported "Found 104 changeset(s)".
# Diffing against the merge base ignores that residue and sees only
# what the PR itself introduced. It has to be the MERGE BASE and not
# the payload's frozen `base.sha` -- see the base-resolution step above
# (#6129); with the frozen sha this count silently included every
# changeset main gained while the PR was open.
#
# An empty-frontmatter changeset still COUNTS here — this step counts
# files, and that is deliberately unchanged. What has changed is that
# counting is no longer the last word: the step BELOW rejects an empty
# changeset this PR newly introduces (#5471), so satisfying this count
# with an empty file now trades one red for another.
#
# The history is worth keeping straight. #5292 corrected the
# PRESCRIPTION, not the count: this comment used to call an empty
# changeset "on par with the skip-changeset label" and the message
# below used to offer the two as equals. They are not equal
# downstream — the label is a gate-level exemption that produces no
# input for changesets/action, an empty changeset is a real input to
# it. #5471 then measured that the prose alone did not hold (empty
# files kept accruing after PR #5467 merged) and ruled the route shut
# for new files. Splitting it across two steps is what keeps THIS
# step's failure mode ("no changeset at all") distinct from that one's
# ("the changeset you added declares nothing").
ADDED=$(git diff --name-only --diff-filter=A "$MERGE_BASE" HEAD -- '.changeset/*.md' \
| grep -v '/README\.md$' | wc -l | tr -d '[:space:]')
if [ "$ADDED" -eq 0 ]; then
# The full comparison goes to the job log — that is what an author
# reading `gh run view --log-failed`, or expanding this step in the
# UI, actually sees. The ::error:: annotation after it is the
# one-line version for the Checks tab. Both lead with the label.
# Terminator sits at this block's base indentation on purpose: YAML
# strips that much from every line, so `MSG` lands in column 0 of
# the generated script. Do not re-indent it.
cat <<'MSG'
This PR adds no changeset. There are TWO ways forward -- route 3 used to be
a third and is now closed, see below. Pick by what the PR actually releases:
1. It releases something
-> run 'pnpm changeset' and name the packages it releases.
2. It releases nothing (.github/, .claude/, skills/, docs/, content/,
examples/, tests-only, and the like)
-> apply the 'skip-changeset' label. <<< PREFERRED
The label is a gate-level exemption. It produces NO input for
changesets/action, so it cannot affect a release.
'skills/**' is on that list, and it is spelled out because the git
log says otherwise (#5947). Changes to PUBLISHED skills have
repeatedly shipped with an empty changeset instead -- #4607, #5130,
#5451 / PR #5799 -- on the reasoning "skills/ ships with no npm
package, so there is no package to name". That premise is true:
skills/ is not a workspace member and no package's 'files' field
includes it. The conclusion does not follow. Naming no package is
precisely what the LABEL is for; an empty changeset names no package
EITHER, and pays #4898 for the privilege. Take the label.
3. (CLOSED) An empty-frontmatter changeset. Still present in the
repository's history and still counted by this step, but the step
below now REJECTS any that a PR newly adds (#5471). It was never worth
taking: it names no package, so its body reaches no CHANGELOG, and it
buys nothing the label does not. What it uniquely buys is risk --
unlike the label it is a REAL INPUT to changesets/action, and when
every pending changeset is empty the action takes its
"hasChangesets && !hasNonEmptyChangesets" branch, prints
"All changesets are empty; not creating PR", and returns in 0 seconds
-- no version PR, no publish, and the Release run still goes GREEN.
That is #4898, which silently stalled 17.0.0-rc.2. The empty
changesets already on main are exempt and stay where they are; only
newly introduced ones are rejected.
If you are unsure, take route 2. A wrong 'skip-changeset' label is caught by
review; a wrong empty changeset is caught by nobody.
MSG
echo "::error::This PR adds no changeset. If it releases nothing (including any 'skills/**' change -- see #5947), apply the 'skip-changeset' label; otherwise run 'pnpm changeset' and name the packages. An empty-frontmatter changeset is NOT a third option any more: the step below rejects newly added ones (#5471), because it is a real input to changesets/action and an all-empty set stalls the release silently and greenly (#4898). Full comparison in this step's log."
exit 1
fi
echo "This PR adds $ADDED changeset(s)."
# #5471: an empty-frontmatter changeset is rejected when this PR is the one
# introducing it. Ruled 2026-08-06 after the #5292 / PR #5467 prose route
# failed to hold -- empty files kept accruing at roughly ten a day while
# the workflow text called them a LAST RESORT, and the `skills/**`
# precedent chain (#4607 / #5130 / #5451 -> PR #5799) kept copying the
# downgraded route out of `git log`, where the prescription is invisible.
#
# A SEPARATE step, not more logic inside the step above, for two reasons.
# The two failures are different facts and deserve different messages
# ("no changeset at all" vs "the changeset you added declares nothing"),
# and a script can be self-tested where an inline shell block cannot.
#
# Scope, and the two things this deliberately does NOT do:
# - The EXISTING empty changesets (182 at efedd289f) are exempt. The gate
# judges the PR's diff, never the directory, so the exemption needs no
# roster -- a 182-name list would be a high-water mark that rots on the
# first merge. Nothing here cleans them up; that is deferred to the
# next `changeset pre exit`, by the same ruling.
# - Nothing about the release machinery, `.changeset/config.json` or
# changesets/action's behaviour changes. This is a PR-layer gate and
# reverts in one commit.
#
# The `--self-test` is chained AHEAD of the real run on purpose (the repo
# convention for `check-*.mjs` gates): a checker whose own fixtures are
# never executed is a phantom check, and this one's fixtures are the only
# place the red and green directions are pinned. It builds real temp git
# repositories and costs well under a second.
#
# Label handling: this step carries the same `steps.labels.outputs.skip`
# guard as every step above it, so it honours the LIVE label re-read
# (#5580 / #5625) and a rerun after labelling converges. That leaves one
# cell open and it is recorded rather than implied: a PR carrying BOTH the
# `skip-changeset` label AND a new empty changeset is not caught, because
# the whole job is exempt. Closing it would mean running this step outside
# the job's exemption, where it would fire only when the label arrived
# after the event fired -- a gate that reds one PR and greens an identical
# one. A consistent exemption beats a nondeterministic gate, and the case
# is empty of motive anyway: an author who already has the label gains
# nothing by adding the file.
#
# `--base` takes the same merge base the counting step uses, for the same
# #6129 reason: fed the payload's frozen `base.sha`, this script reads every
# empty changeset main gained while the PR was open as one this PR added,
# and reports it against an author who never touched the file. Same defect,
# opposite direction (a false RED here, a false GREEN up there), one base.
- name: Reject an empty-frontmatter changeset added by this PR
if: steps.labels.outputs.skip != 'true'
env:
MERGE_BASE: ${{ steps.diffbase.outputs.merge_base }}
run: |
node scripts/check-empty-changeset.mjs --self-test
node scripts/check-empty-changeset.mjs --base "$MERGE_BASE"
- name: Guard against accidental major bumps (launch window)
# Every publishable package is in one Changesets "fixed" (lockstep) group,
# so a single `major` bump promotes the ENTIRE monorepo to a new major
# version. During the launch window we ship breaking changes as `minor`.
# Add the `allow-major` PR label when a whole-stack major is intended.
#
# The first clause keeps this step exempt exactly when the changeset check
# above is: before #5580 the `skip-changeset` label skipped the whole job,
# this step included, and a live-read label must not quietly re-arm it.
#
# The second clause still reads the frozen payload, and so still carries
# the #5580 race in its own right: an `allow-major` applied after the event
# fired is invisible to this run and a rerun replays the same payload.
# It is DORMANT while Changesets is in pre-release mode, because
# check-changeset-no-major.mjs stands aside for the whole RC window (see
# its RC EXEMPTION note), so the label is currently never needed. Tracked
# as #5620 rather than fixed here: #5580 scoped this change to the
# `skip-changeset` read, and widening a green gate's exemption path under
# cover of another issue is how exemptions grow unnoticed.
if: >-
steps.labels.outputs.skip != 'true'
&& !contains(github.event.pull_request.labels.*.name, 'allow-major')
run: node scripts/check-changeset-no-major.mjs