From 26799701d1cb44826bb497720fd47dbc9923512e Mon Sep 17 00:00:00 2001 From: Imod7 Date: Sat, 8 Aug 2026 09:47:15 +0200 Subject: [PATCH 1/3] ci: reject changesets authored directly into .changeset/ --- .github/workflows/changeset-location.yml | 78 ++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/changeset-location.yml diff --git a/.github/workflows/changeset-location.yml b/.github/workflows/changeset-location.yml new file mode 100644 index 00000000..a2d1dfbc --- /dev/null +++ b/.github/workflows/changeset-location.yml @@ -0,0 +1,78 @@ +name: "product-sdk: Changeset location" + +# Guards the pending-changesets/ staging convention. Anything under +# `.changeset/` is picked up by the release workflow on the next push to +# `main`, so a changeset parked there by an in-flight PR gets published the +# moment any *unrelated* PR merges. See product-sdk/pending-changesets/README.md. +# +# 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 changesets authored directly into .changeset/ + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + # Diffing the whole PR (three-dot) rather than the pushed commit, so + # adding a changeset and moving it in a later commit ends up green. + # + # --no-renames makes a legitimate promotion show up as an add under + # .changeset/ plus a delete of the same basename under + # pending-changesets/. The awk pass cancels those against each other; + # whatever add is left over was authored straight into .changeset/. + # + # `sort` because array iteration order in awk 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$/ { + n = split($2, p, "/") + if (tolower(p[n]) != "readme.md") added[p[n]] = $2 + } + $1=="D" && $2 ~ /^product-sdk\/pending-changesets\/[^\/]+\.md$/ { + n = split($2, p, "/"); promoted[p[n]] = 1 + } + END { for (f in added) if (!(f in promoted)) print added[f] } + ' | sort + ) + + [ ${#offenders[@]} -eq 0 ] && exit 0 + + # One annotation per file so it renders inline in the Files changed tab. + for f in "${offenders[@]}"; do + echo "::error file=$f,line=1,title=Changeset must be in pending-changesets/::Anything under .changeset/ ships on the next merge to main. Run: git mv $f product-sdk/pending-changesets/$(basename "$f")" + done + + echo + echo "Changesets belong in product-sdk/pending-changesets/, not .changeset/." + echo "Anything under .changeset/ ships on the next merge to main." + echo + for f in "${offenders[@]}"; do + echo " git mv $f product-sdk/pending-changesets/$(basename "$f")" + done + 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 From 90785786c19f3cc095ebfe3cb2ee1485d7863b31 Mon Sep 17 00:00:00 2001 From: Imod7 Date: Sat, 8 Aug 2026 10:08:18 +0200 Subject: [PATCH 2/3] docs: state that a separate release PR promotes changesets --- CLAUDE.md | 5 +++-- product-sdk/RELEASES.md | 24 +++++++++++++++++------- product-sdk/pending-changesets/README.md | 19 ++++++++++++------- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0c6c44dd..88cc34c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/product-sdk/RELEASES.md b/product-sdk/RELEASES.md index 833e94e0..fe4cde45 100644 --- a/product-sdk/RELEASES.md +++ b/product-sdk/RELEASES.md @@ -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? @@ -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 @@ -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): `, +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/.md .changeset/.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 diff --git a/product-sdk/pending-changesets/README.md b/product-sdk/pending-changesets/README.md index ccbc5369..d2356c95 100644 --- a/product-sdk/pending-changesets/README.md +++ b/product-sdk/pending-changesets/README.md @@ -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/` @@ -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/.md .changeset/.md +git mv pending-changesets/.md .changeset/.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): ` 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:** From e3c9d33df100b3104c6243eae2f041569b1e703b Mon Sep 17 00:00:00 2001 From: Imod7 Date: Mon, 10 Aug 2026 18:44:21 +0200 Subject: [PATCH 3/3] ci: also reject changesets in .changeset/ subdirectories --- .github/workflows/changeset-location.yml | 79 +++++++++++++++++------- 1 file changed, 58 insertions(+), 21 deletions(-) diff --git a/.github/workflows/changeset-location.yml b/.github/workflows/changeset-location.yml index a2d1dfbc..58fbb3d3 100644 --- a/.github/workflows/changeset-location.yml +++ b/.github/workflows/changeset-location.yml @@ -1,9 +1,16 @@ name: "product-sdk: Changeset location" -# Guards the pending-changesets/ staging convention. Anything under -# `.changeset/` is picked up by the release workflow on the next push to -# `main`, so a changeset parked there by an in-flight PR gets published the -# moment any *unrelated* PR merges. See product-sdk/pending-changesets/README.md. +# 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 @@ -31,47 +38,77 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Reject changesets authored directly into .changeset/ + - name: Reject unpromoted changesets and .changeset/ subdirectories env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | - # Diffing the whole PR (three-dot) rather than the pushed commit, so - # adding a changeset and moving it in a later commit ends up green. + # 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. # - # --no-renames makes a legitimate promotion show up as an add under - # .changeset/ plus a delete of the same basename under - # pending-changesets/. The awk pass cancels those against each other; - # whatever add is left over was authored straight into .changeset/. + # 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 array iteration order in awk is unspecified, and the - # runners use mawk, which has no asorti. + # `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$/ { n = split($2, p, "/") - if (tolower(p[n]) != "readme.md") added[p[n]] = $2 + if (tolower(p[n]) != "readme.md") added[$2] = p[n] } - $1=="D" && $2 ~ /^product-sdk\/pending-changesets\/[^\/]+\.md$/ { + $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 (!(f in promoted)) print added[f] } + 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 the Files changed tab. + # 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 - echo "::error file=$f,line=1,title=Changeset must be in pending-changesets/::Anything under .changeset/ ships on the next merge to main. Run: git mv $f product-sdk/pending-changesets/$(basename "$f")" + 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 "Anything under .changeset/ ships on the next merge to main." + 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 - echo " git mv $f product-sdk/pending-changesets/$(basename "$f")" - done + 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"