Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .github/workflows/changeset-location.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: "product-sdk: Changeset location"

# Guards the pending-changesets/ staging convention.
# See product-sdk/pending-changesets/README.md.
#
# A changeset directly under `.changeset/` ships on the next push to `main`, so
# one parked there by an in-flight PR goes out when any unrelated PR merges.
#
# A subdirectory of `.changeset/` never ships (@changesets/read does a
# non-recursive readdir) but is read as a legacy v1 changeset: `changeset
# version` throws ENOENT on the missing `sub/changes.md`, breaking every
# release until someone deletes it -- or publishes the directory unreviewed,
# if `changes.md` and `changes.json` are both in it.
#
# Deliberately NOT path-filtered on `product-sdk/**` like the other workflows:
# a filtered workflow reports no status on PRs that don't match, which leaves a
# required check waiting forever. This one always runs so it can be required.
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]

permissions:
contents: read

concurrency:
group: changeset-location-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
check:
name: "product-sdk: Changeset location"
runs-on: parity-default
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# Full history so the three-dot diff against the PR base resolves.
fetch-depth: 0
persist-credentials: false

- name: Reject unpromoted changesets and .changeset/ subdirectories
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
# Three-dot diff, so adding a changeset and moving it in a later
# commit ends up green.
#
# --no-renames turns a promotion into an add under .changeset/ plus a
# delete of the same basename under pending-changesets/, and the awk
# pass cancels the pair. What is left was authored straight into
# .changeset/. Parking and promoting in one PR has no base-side delete
# to cancel against, so it still fails: park in its own PR first.
#
# Nested adds go in a second bucket, never cancelled, because no
# promotion targets a subdirectory. Any extension counts there, since
# the directory is what breaks the release; README.md is exempt only
# at the top level. Deletes match at any depth, so promoting out of a
# pending-changesets/ subdirectory still counts.
#
# Both buckets are keyed on the path and print it. The top-level
# bucket's value is the basename the promotion lookup needs.
#
# `sort` because awk array order is unspecified and the runners use
# mawk, which has no asorti.
mapfile -t offenders < <(
git diff --no-renames --name-status "$BASE_SHA"...HEAD | awk '
$1=="A" && $2 ~ /^product-sdk\/\.changeset\/[^\/]+\.md$/ {
Comment thread
Imod7 marked this conversation as resolved.
n = split($2, p, "/")
if (tolower(p[n]) != "readme.md") added[$2] = p[n]
}
$1=="A" && $2 ~ /^product-sdk\/\.changeset\/.+\/./ {
nested[$2] = 1
}
$1=="D" && $2 ~ /^product-sdk\/pending-changesets\/.+\.md$/ {
n = split($2, p, "/"); promoted[p[n]] = 1
}
END {
for (f in added) if (!(added[f] in promoted)) print f
for (f in nested) print f
}
' | sort
)

[ ${#offenders[@]} -eq 0 ] && exit 0

# One annotation per file so it renders inline in Files changed.
# Depth decides cause and fix, so these globs and the nested regex
# above have to keep agreeing.
for f in "${offenders[@]}"; do
case "$f" in
product-sdk/.changeset/*/*)
echo "::error file=$f,line=1,title=Nothing may live in a .changeset/ subdirectory::A directory here is read as a legacy v1 changeset, which breaks or silently publishes the next release. Remove it; changesets go in product-sdk/pending-changesets/." ;;
*)
echo "::error file=$f,line=1,title=Changeset must be in pending-changesets/::A changeset directly under .changeset/ ships on the next merge to main. Run: git mv $f product-sdk/pending-changesets/$(basename "$f")" ;;
esac
done

echo
echo "Changesets belong in product-sdk/pending-changesets/, not .changeset/."
echo "A changeset directly under .changeset/ ships on the next merge to main;"
echo "a .changeset/ subdirectory breaks or silently publishes the next release."
echo
# sort -u because several files can share one offending directory.
for f in "${offenders[@]}"; do
case "$f" in
product-sdk/.changeset/*/*)
echo " git rm -r $(dirname "$f")" ;;
*)
echo " git mv $f product-sdk/pending-changesets/$(basename "$f")" ;;
esac
done | sort -u
echo
echo "A separate chore(release): PR promotes them back when the wave goes out."
echo "See product-sdk/pending-changesets/README.md"
exit 1
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ For e2e on a single demo: `pnpm --filter "@parity/product-sdk-tx-demo" test:e2e`

## Changesets

Every PR that changes a published artifact needs a changeset. See [`product-sdk/RELEASES.md#when-does-a-pr-need-a-changeset`](./product-sdk/RELEASES.md#when-does-a-pr-need-a-changeset) for the exact criteria. Two important quirks:
Every PR that changes a published artifact needs a changeset. See [`product-sdk/RELEASES.md#when-does-a-pr-need-a-changeset`](./product-sdk/RELEASES.md#when-does-a-pr-need-a-changeset) for the exact criteria. Three important quirks:

- Park work-in-progress changesets in `pending-changesets/`, not `.changeset/` — anything under `.changeset/` ships on the next merge to `main`, even if the PR that created it is unfinished.
- Park your changeset in `pending-changesets/`, not `.changeset/` — anything under `.changeset/` ships on the next merge to `main`, even if the PR that created it is unfinished. Leave it there; your PR never moves it.
- **A separate `chore(release):` PR promotes the ready changesets into `.changeset/` and cuts the release wave.** Feature PRs don't promote. CI enforces this with the `product-sdk: Changeset location` check.
- When any constituent gets a `minor` bump, **also list `@parity/product-sdk` as `minor`** in the same changeset. Otherwise the umbrella cascades only at patch level.

## PR workflow
Expand Down
24 changes: 17 additions & 7 deletions product-sdk/RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ changeset file under `.changeset/`.
## TL;DR

1. Author your changeset on a feature branch — park it in
[`pending-changesets/`](./pending-changesets/) while the work is
in progress.
2. On the PR that closes the work, move the changeset into `.changeset/`.
3. Merging that PR to `main` is what triggers the release.
[`pending-changesets/`](./pending-changesets/) and leave it there.
Your PR never moves it into `.changeset/`; CI rejects that.
2. A separate `chore(release):` PR promotes the changesets that are
ready into `.changeset/` and cuts the release wave.
3. Merging that release PR to `main` is what triggers publishing.

## When does a PR need a changeset?

Expand Down Expand Up @@ -78,7 +79,10 @@ Put differently: anything under `.changeset/` will go out on the next
release. If you authored a changeset on a feature branch but the PR
is still iterating, parking it under `.changeset/` means an unrelated
merge could ship your unfinished work. Stage it in
`pending-changesets/` instead and promote it on the closing PR.
`pending-changesets/` instead and leave it there. The release PR
promotes it. This is enforced by the `product-sdk: Changeset location`
CI check, which fails any PR that adds a file to `.changeset/` unless
the same PR moves it out of `pending-changesets/`.

See [`pending-changesets/README.md`](./pending-changesets/README.md)
for the details — including why we can't nest a subdirectory under
Expand Down Expand Up @@ -145,13 +149,19 @@ Example changeset header:

## Promoting a pending changeset

When the PR that closes the work is ready:
This happens in a dedicated release PR, titled `chore(release): <version>`,
not in the PR that closes the underlying work. Open that PR from a branch
off `main` and move every changeset that is ready to ship:

```bash
git mv pending-changesets/<name>.md .changeset/<name>.md
```

Commit the move. Merging that PR to `main` triggers the release.
Keep the filename identical. The CI check matches the file leaving
`pending-changesets/` against the file arriving in `.changeset/` by name,
so renaming it during the move fails the check.

Commit the moves. Merging the release PR to `main` triggers publishing.

## Previewing what the next release will do

Expand Down
19 changes: 12 additions & 7 deletions product-sdk/pending-changesets/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Pending Changesets

A staging area for changesets that document changes which **aren't ready
to publish yet**. Move them into `.changeset/` only on the PR that
closes out the work they describe.
to publish yet**. Your PR parks its changeset here and leaves it here.
**A separate `chore(release):` PR moves them into `.changeset/` and cuts
the release wave.** The `product-sdk: Changeset location` CI check fails
any PR that adds a file to `.changeset/` without moving it out of here.

## Why this directory exists separately from `.changeset/`

Expand Down Expand Up @@ -63,15 +65,18 @@ review. Examples that worked well in past waves:
- `host-request-resource-allocation.md`
- `terminal-papi-native-signer.md`

**Promoting to release on the closing PR:**
**Promoting to release (release PR only):**

```bash
mv pending-changesets/<name>.md .changeset/<name>.md
git mv pending-changesets/<name>.md .changeset/<name>.md
```

Stage that move in the same PR that closes the underlying work. When
the PR merges to `main`, the release workflow picks up the changeset
and publishes the wave.
Do this on a dedicated `chore(release): <version>` PR branched off
`main`, not on the PR that closes the underlying work. Keep the filename
identical — the CI check pairs the file leaving here with the file
arriving in `.changeset/` by name, so a rename during the move fails.
When the release PR merges to `main`, the release workflow picks up the
changesets and publishes the wave.

**Check what the next release will look like:**

Expand Down
Loading