Skip to content

chore: version packages #199

chore: version packages

chore: version packages #199

name: Require Changeset
# Every PR to `main` must add a changeset (a new `.changeset/<name>.md`) so the
# version bump is intentional and the changelog stays complete. A PR that
# legitimately needs no release note (docs, CI, chores) can carry the
# `skip-changeset` label to bypass the requirement.
#
# The job ALWAYS runs and reports a status (the label is checked inside the
# step, not via a job-level `if`), so it stays safe to mark as a required check —
# a conditionally-skipped required check would otherwise block merges.
on:
pull_request:
branches: [main]
types: [opened, reopened, synchronize, labeled, unlabeled]
permissions:
contents: read
jobs:
changeset:
name: Require a changeset
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for a changeset
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
run: |
set -euo pipefail
# Mid-stack PR. On a stack, exactly one changeset describes the whole
# change and it lives on the bottom PR — the one targeting `main`,
# which is where this check has to pass. Every branch above inherits
# that file, so it is never *added* in a child's own diff, and without
# this guard the check below fails every one of them. That must not be
# papered over with a `skip-changeset` label, which would wrongly
# record "this change ships no release note."
#
# This guard is required, not belt-and-braces. `branches: [main]` reads
# like "only PRs whose base is main," and that is how it filtered when
# a PR had exactly one base. Under GitHub's stacked-PR support a PR in
# a stack is understood to target `main` *eventually*, so the filter
# matches on the eventual target and the workflow runs on mid-stack
# PRs too. Observed here: #73, #80, and #81 — bases
# `openspec/partition-engine-*`, never `main` — each produced a failing
# `Require a changeset` check run (e.g. run 30786954198, event
# `pull_request`, head `openspec/partition-engine-2-dispatch`).
#
# So `branches:` no longer scopes a workflow to the bottom of a stack.
# Any job whose correctness depends on "is this the PR that merges to
# main" has to establish that itself, as this one does.
if [ "$BASE_REF" != "main" ]; then
echo "Base is '$BASE_REF', not 'main' — mid-stack PR, so the bottom PR of the stack carries the changeset."
exit 0
fi
# The changesets "Version Packages" PR consumes changesets (removing
# them is its whole job), so it legitimately has none. Bypass it by its
# well-known bot branch name so it needs no manual `skip-changeset`.
if [ "$HEAD_REF" = "changeset-release/main" ]; then
echo "Version Packages PR (changeset-release/main) — bypassing the changeset requirement."
exit 0
fi
# Escape hatch for PRs that intentionally ship no release note.
case ",${LABELS}," in
*,skip-changeset,*)
echo "The 'skip-changeset' label is present — bypassing the changeset requirement."
exit 0
;;
esac
# A changeset is any `.changeset/*.md` this PR adds OR modifies, other
# than the template README.
#
# Modifications count because one change gets ONE changeset: when a
# stack lands forward, the second PR onward finds the release note
# already on `main` from the PR below it, and the right move is to
# extend that file rather than add a second entry for something that
# ships once. Counting additions only made "grow the changeset as the
# stack lands" impossible to satisfy.
ADDED=$(git diff --name-only --diff-filter=AM "$BASE_SHA...$HEAD_SHA" -- '.changeset/*.md' \
| grep -viE '/README\.md$' || true)
if [ -z "$ADDED" ]; then
echo "::error::This PR adds no changeset. Run \`pnpm changeset\` to record the release impact, or apply the \`skip-changeset\` label if no release note is needed (docs / CI / chore)."
exit 1
fi
echo "Changeset(s) added by this PR:"
echo "$ADDED" | sed 's/^/ - /'