chore: version packages (rc) #10113
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 — 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 "$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 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. | |
| - name: Reject an empty-frontmatter changeset added by this PR | |
| if: steps.labels.outputs.skip != 'true' | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| run: | | |
| node scripts/check-empty-changeset.mjs --self-test | |
| node scripts/check-empty-changeset.mjs --base "$BASE_SHA" | |
| - 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 |