Skip to content

chore: version packages (rc) #9691

chore: version packages (rc)

chore: version packages (rc) #9691

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