Skip to content

contract: history resolutions come from the power history #3186

contract: history resolutions come from the power history

contract: history resolutions come from the power history #3186

name: changeset-check
# Gate on PRs targeting master: require a `.changeset/*.md` file unless
# the PR only touches paths in the allowlist below (docs, repo config,
# CI tooling, markdown). Pattern lifted from umara/u-front.
#
# Escape layers, in order:
# 1. PR identity exactly matches the auto-generated Version PR → skip
# (trusted repository, head branch, base branch and title).
# 2. PR carries the `no-changeset` label → skip (manual override for
# cases the allowlist doesn't anticipate).
# 3. All changed files are in the path allowlist → skip.
# 4. Otherwise: require at least one added .changeset/<name>.md file.
#
# Layer 3 is decided before Changesets runs, because `changeset status`
# reports "packages have been changed but no changesets were found" for
# any diff inside a package — a docs-only PR included. Validating the
# queue first therefore failed exactly the PRs the allowlist exempts.
on:
pull_request:
branches: [master]
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
pull-requests: read
# A label change or a new push re-asks the same question of a newer diff, so
# the older answer is worthless. Cancelling is also what makes the `labeled`
# trigger cheap: adding `no-changeset` mid-run drops the run it would fail.
concurrency:
group: changeset-check-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
changeset-required:
name: changeset present (or auto-exempt)
runs-on: ubuntu-latest
steps:
- name: Short-circuit on auto-generated Version PR
id: version_pr
env:
TITLE: ${{ github.event.pull_request.title }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
if [[ "${TITLE}" == "chore(release): version packages" \
&& "${HEAD_REPO}" == "${GITHUB_REPOSITORY}" \
&& "${HEAD_REF}" == "changeset-release/master" \
&& "${BASE_REF}" == "master" ]]; then
echo "skip=true" >> "${GITHUB_OUTPUT}"
echo "Auto-generated Version PR — gate skipped."
else
echo "skip=false" >> "${GITHUB_OUTPUT}"
fi
- name: Short-circuit on no-changeset label
if: steps.version_pr.outputs.skip != 'true'
id: label
env:
LABELS: ${{ toJson(github.event.pull_request.labels) }}
run: |
set -euo pipefail
if echo "${LABELS}" | jq -e '.[] | select(.name == "no-changeset")' >/dev/null; then
echo "skip=true" >> "${GITHUB_OUTPUT}"
echo "PR carries 'no-changeset' label — release-notes gate satisfied."
{
echo "### Changeset exemption"
echo "The \`no-changeset\` label skips Changesets setup, install, validation, and entry checks."
} >> "${GITHUB_STEP_SUMMARY}"
else
echo "skip=false" >> "${GITHUB_OUTPUT}"
fi
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Node for version policy
uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: package-lock.json
- name: Reject Core major bumps
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
git fetch --no-tags origin "${BASE_SHA}" "${HEAD_SHA}"
mapfile -t changed_changesets < <(git diff -M --name-only --diff-filter=ACMR "${BASE_SHA}"..."${HEAD_SHA}" -- '.changeset/*.md')
npm ci --ignore-scripts --no-audit --no-fund
node --test .github/scripts/check-changeset-bumps.test.mjs
node .github/scripts/check-changeset-bumps.mjs "${changed_changesets[@]}"
- name: Validate package and lockfile metadata
run: node --test web/release-metadata.test.mjs
- name: Verify changeset present (or PR is allowlisted)
id: gate
if: steps.version_pr.outputs.skip != 'true' && steps.label.outputs.skip != 'true'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
git fetch --no-tags origin "${BASE_SHA}" "${HEAD_SHA}"
CHANGED="$(git diff --name-only "${BASE_SHA}"..."${HEAD_SHA}")"
# Accept any added or modified changeset file. Exclude
# deletions (--diff-filter=d) so a PR that *deletes* a
# changeset can't satisfy the rule. Exclude the README +
# config files which are infrastructure, not real entries.
# Computed up front: the verdict below and the Changesets
# validation that follows need the same answer.
CHANGESETS="$(git diff --name-only --diff-filter=d "${BASE_SHA}"..."${HEAD_SHA}" \
| grep -E '^\.changeset/[^/]+\.md$' \
| grep -vE '^\.changeset/(README)\.md$' \
|| true)"
if [ -n "${CHANGESETS}" ]; then
echo "has_changesets=true" >> "${GITHUB_OUTPUT}"
else
echo "has_changesets=false" >> "${GITHUB_OUTPUT}"
fi
if [ -z "${CHANGED}" ]; then
echo "exempt=true" >> "${GITHUB_OUTPUT}"
echo "No file changes — passing."
exit 0
fi
# Paths in this allowlist do NOT require a changeset. Tweak
# if a new "obviously doesn't ship to users" path is added.
# Patterns are extended-grep regexes:
# - any *.md / *.mdx / *.txt anywhere in the tree
# - the docs/ tree
# - .changeset/ (the changesets themselves + README/config)
# - .github/ (workflows, CODEOWNERS, issue templates…)
# - .vscode/ (editor settings)
# - .cursor/ (agent skills and policies; nothing ships to users)
# - dotfiles at repo root
REQUIRES_CHANGESET="$(echo "${CHANGED}" | grep -vE \
-e '(^|/)[^/]+\.(md|mdx|txt)$' \
-e '^docs/' \
-e '^\.changeset/' \
-e '^\.github/' \
-e '^\.vscode/' \
-e '^\.cursor/' \
-e '^\.editorconfig$' \
-e '^\.gitignore$' \
-e '^\.gitattributes$' \
-e '^LICENSE$' \
|| true)"
if [ -z "${REQUIRES_CHANGESET}" ]; then
echo "exempt=true" >> "${GITHUB_OUTPUT}"
echo "✓ PR changes only docs / repo config / CI tooling (allowlist) — changeset not required."
echo ""
echo "Files in this PR:"
echo "${CHANGED}" | sed 's/^/ /'
exit 0
fi
echo "exempt=false" >> "${GITHUB_OUTPUT}"
if [ -n "${CHANGESETS}" ]; then
echo "✓ Found changeset(s):"
echo "${CHANGESETS}" | sed 's/^/ /'
exit 0
fi
echo "::error::No changeset found and this PR touches files outside the docs/config/CI allowlist."
echo ""
echo "Files that triggered the changeset requirement:"
echo "${REQUIRES_CHANGESET}" | sed 's/^/ /'
echo ""
echo "Add one with:"
echo " npx changeset"
echo ""
echo "Or hand-create '.changeset/<descriptive-name>.md' following an existing example."
echo ""
echo "If this PR genuinely needs no release entry, either:"
echo " - apply the 'no-changeset' label to skip the gate, or"
echo " - expand the allowlist in .github/workflows/changeset-check.yml in the same PR."
exit 1
# Presence alone is not enough: a Changeset can reference a package that
# no longer exists after a rename. Validate the complete pending queue
# with Changesets itself so an invalid entry fails on the PR instead of
# breaking every release run after it reaches master.
#
# Runs when the PR needs a changeset, and also when an allowlisted PR
# carries one anyway — a `.changeset/*.md` file matches the markdown
# allowlist, so a changeset-only PR is exempt from the gate and would
# otherwise ship its entry unvalidated.
- name: Setup Node for Changesets validation
if: >-
steps.version_pr.outputs.skip != 'true'
&& steps.label.outputs.skip != 'true'
&& (steps.gate.outputs.exempt != 'true' || steps.gate.outputs.has_changesets == 'true')
uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: package-lock.json
- name: Install Changesets dependencies
if: >-
steps.version_pr.outputs.skip != 'true'
&& steps.label.outputs.skip != 'true'
&& (steps.gate.outputs.exempt != 'true' || steps.gate.outputs.has_changesets == 'true')
run: npm ci --prefer-offline --no-audit --no-fund
- name: Validate pending Changesets
if: >-
steps.version_pr.outputs.skip != 'true'
&& steps.label.outputs.skip != 'true'
&& (steps.gate.outputs.exempt != 'true' || steps.gate.outputs.has_changesets == 'true')
# pull_request workflows run on a detached merge ref. Compare against
# the fetched remote base because no local `master` branch exists.
run: npx changeset status --since="origin/${{ github.base_ref }}"