fix(ci): 两个标签写入 job 不再并发、也不再被标签事件重复触发 (#5649) #9597
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| - 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: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| 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 BASE_SHA ignores that residue and sees only what the | |
| # PR itself introduced. | |
| # | |
| # An empty-frontmatter changeset still COUNTS here, and that is | |
| # deliberate and unchanged — it remains a legal "this PR releases | |
| # nothing" declaration. What #5292 corrected is the PRESCRIPTION, not | |
| # the count: this comment used to call it "on par with the | |
| # skip-changeset label", and the failure 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. See the message for the | |
| # consequence. | |
| ADDED=$(git diff --name-only --diff-filter=A "$BASE_SHA" 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 three ways forward, and they are NOT | |
| equivalent. 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/, 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. | |
| 3. An empty-frontmatter changeset also satisfies this gate and stays | |
| legal -- but it is a LAST RESORT, not the quick way past a red check. | |
| Unlike the label it is a REAL INPUT to changesets/action: 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. It also buys you | |
| nothing the label does not: an empty changeset names no package, so | |
| its body reaches no CHANGELOG. | |
| 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, apply the 'skip-changeset' label (preferred); otherwise run 'pnpm changeset' and name the packages. An empty-frontmatter changeset also passes this gate, but it is NOT equivalent to the label -- 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)." | |
| - 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 |