From 0884f5eb98931d09b6938e4378a3acd4db34e31b Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 24 Aug 2026 16:16:35 -0400 Subject: [PATCH 1/3] ci: let the release pull request merge itself on a cadence switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changesets keeps one release pull request open and rewrites it as changesets land, so how often that pull request is merged is what decides the version sequence. Left to accumulate it collapses a backlog into a single bump — which is how nineteen changesets became one version and the numbers in between never existed. `AUTO_MERGE_RELEASE_PR` picks the cadence. Set to `true`, the release pull request merges itself once its required checks pass, so one merge to main is one release and the version tracks each change. Unset, nothing changes and a maintainer merges it when a release is wanted. This is not a blanket auto-merge: it only ever targets the changesets-authored branch, the repository still requires its status checks, and a major bump is gated on the pull request that introduces the changeset — so an unattended release cannot rename the major version on its own. The step never fails the run, because a version pull request left open costs a manual merge while a failure here would block npm, the Marketplace and the CDN behind it. CONTRIBUTING now states the bump levels and both cadences, so the version sequence is a documented choice rather than a side effect of merge timing. --- .github/workflows/release.yml | 33 +++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 21 +++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52833137..ab479f33 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -180,6 +180,39 @@ jobs: fi echo "Cut ${tag} at ${GITHUB_SHA} (desktop ${prev} -> ${curr})." + # Release cadence switch. With this on, the version PR merges itself once + # every required check passes, so one merge to main becomes one release + # and the version tracks each change instead of collapsing a backlog of + # changesets into a single jump. Turn it off to go back to releasing by + # hand: `gh variable set AUTO_MERGE_RELEASE_PR --body false`. + # + # This is deliberately not a blanket auto-merge. It only ever targets the + # changesets-authored branch, the repository requires its status checks + # before any merge, and a major bump is gated separately on the pull + # request that introduces the changeset — so an unattended release can + # still never rename the major version on its own. + # + # Never fail the release over this: a version PR that stays open costs a + # manual merge, while a failure here would block npm, the Marketplace and + # the CDN behind it. + - name: Enable auto-merge on the version PR + if: steps.changesets.outputs.published != 'true' && vars.AUTO_MERGE_RELEASE_PR == 'true' + continue-on-error: true + env: + GH_TOKEN: ${{ steps.release-bot.outputs.token }} + run: | + set -uo pipefail + pr=$(gh pr list --head changeset-release/main --state open --json number --jq '.[0].number' || true) + if [ -z "$pr" ] || [ "$pr" = "null" ]; then + echo "::notice::No open version PR; nothing to auto-merge." + exit 0 + fi + if gh pr merge "$pr" --squash --auto; then + echo "Auto-merge armed on #${pr}; it lands when its required checks pass." + else + echo "::warning::Could not arm auto-merge on #${pr}. Merge it by hand to cut the release." + fi + - name: Request CodeRabbit review on version PR if: steps.changesets.outputs.published != 'true' env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79105f13..e7d11421 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,6 +80,27 @@ This repo uses [changesets](https://github.com/changesets/changesets) to manage - Generate one with `pnpm changeset` and follow the prompts (which packages are touched, which bump level). - For repo-specific conventions on package selection and bump levels, see `.changeset/README.md`. When working in this repo with coding agents, use the `gen-changesets` skill. +### Bump levels + +| Level | Use for | Example | +| --- | --- | --- | +| `patch` | A fix, or a small addition to something that already exists | `2.1.2` → `2.1.3` | +| `minor` | A capability a user could not reach before | `2.1.3` → `2.2.0` | +| `major` | A break: something that worked stops working, or works differently | `2.2.0` → `3.0.0` | + +Prefer one changeset per pull request. A pull request that needs several is usually several releases wearing one hat, and the changelog cannot attribute the changes afterwards. + +A `major` needs a maintainer's sign-off: the `changeset-policy` workflow fails a pull request that adds one unless it carries the `breaking-change-approved` label. A major renames the release and breaks every pinned install, and an npm publish cannot be taken back — so it is a decision, never a side effect of a large branch. + +### Release cadence + +Changesets keeps a `ci: release packages` pull request open on `main` and rewrites it as changesets land. Merging it cuts exactly one release, so how often it is merged is what decides the version sequence: + +- Merged per change, versions follow each change: `2.1.2`, `2.1.3`, `2.1.4`, `2.2.0`. +- Left to accumulate, a backlog collapses into one bump and the numbers in between never exist. + +The repository variable `AUTO_MERGE_RELEASE_PR` chooses between the two. Set to `true`, the release pull request merges itself once its required checks pass, giving one release per change. Unset or `false`, a maintainer merges it when a release is wanted. + ## Pull Requests Every PR opens with the [PR template](.github/pull_request_template.md). PR titles must follow [Conventional Commits](#commit-convention); CI runs `pnpm lint`, `pnpm typecheck`, and `pnpm test` on every PR. Update user-facing docs in `docs/` when behavior changes — use the `gen-docs` skill when working with coding agents. From bba5db7ae57078de453b24dc14eb434d47ee1757 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 24 Aug 2026 16:46:55 -0400 Subject: [PATCH 2/3] ci: resolve the version pull request before acting on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both release steps found the pull request by head branch alone. That can select a fork's branch of the same name, and `|| true` turned an API or auth failure into "no version PR" — so a lookup that broke looked exactly like a release with nothing to do. Resolve it once instead: take the number the changesets action reports, and fall back to a listing scoped to this repository's own head branch, keeping a failed lookup distinct from an empty result. Auto-merge and the review request both consume that number. Also state in CONTRIBUTING that a changeset landing while the release pull request waits on its checks joins that release, and correct the claim that a major breaks pinned installs — it breaks consumers who upgrade. --- .github/workflows/release.yml | 83 ++++++++++++++++++++++++++++------- CONTRIBUTING.md | 6 ++- 2 files changed, 72 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab479f33..3146d01c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -195,34 +195,87 @@ jobs: # Never fail the release over this: a version PR that stays open costs a # manual merge, while a failure here would block npm, the Marketplace and # the CDN behind it. - - name: Enable auto-merge on the version PR - if: steps.changesets.outputs.published != 'true' && vars.AUTO_MERGE_RELEASE_PR == 'true' - continue-on-error: true + # Resolve the version PR once, for every step that acts on it. + # + # The changesets action reports the pull request it just created or + # updated, which is the only unambiguous identifier. The fallback covers + # the run where changesets had nothing to change, and it is scoped to a + # head branch in THIS repository: a head-name match on its own can select + # a fork's branch of the same name, which would point the steps below at + # somebody else's pull request. + - name: Resolve the version PR + id: version-pr + if: steps.changesets.outputs.published != 'true' env: GH_TOKEN: ${{ steps.release-bot.outputs.token }} + CHANGESETS_PR: ${{ steps.changesets.outputs.pullRequestNumber }} run: | set -uo pipefail - pr=$(gh pr list --head changeset-release/main --state open --json number --jq '.[0].number' || true) - if [ -z "$pr" ] || [ "$pr" = "null" ]; then - echo "::notice::No open version PR; nothing to auto-merge." + if [ -n "${CHANGESETS_PR}" ] && [ "${CHANGESETS_PR}" != "null" ]; then + echo "number=${CHANGESETS_PR}" >> "${GITHUB_OUTPUT}" + echo "Version PR #${CHANGESETS_PR}, as reported by the changesets action." exit 0 fi - if gh pr merge "$pr" --squash --auto; then - echo "Auto-merge armed on #${pr}; it lands when its required checks pass." - else - echo "::warning::Could not arm auto-merge on #${pr}. Merge it by hand to cut the release." + if ! open_prs=$(gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head changeset-release/main \ + --base main \ + --state open \ + --json number,headRepositoryOwner); then + echo "::warning::Could not look up the version PR. Nothing downstream will run; check it by hand." + exit 0 + fi + number=$(printf '%s' "${open_prs}" \ + | jq -r --arg owner "${GITHUB_REPOSITORY_OWNER}" \ + '[.[] | select(.headRepositoryOwner.login == $owner)][0].number // ""') + if [ -z "${number}" ]; then + echo "::notice::No open version PR in this repository." + exit 0 fi + echo "number=${number}" >> "${GITHUB_OUTPUT}" + echo "Version PR #${number}." - - name: Request CodeRabbit review on version PR - if: steps.changesets.outputs.published != 'true' + # Release cadence switch. With this on, the version PR merges itself once + # every required check passes, so one merge to main becomes one release + # and the version tracks each change instead of collapsing a backlog of + # changesets into a single jump. Turn it off to go back to releasing by + # hand: `gh variable set AUTO_MERGE_RELEASE_PR --body false`. + # + # This is deliberately not a blanket auto-merge. It only ever targets the + # changesets-authored branch in this repository, the repository requires + # its status checks before any merge, and a major bump is gated separately + # on the pull request that introduces the changeset — so an unattended + # release can still never rename the major version on its own. + # + # A changeset that lands while this pull request is waiting on its checks + # joins the same release rather than starting the next one. That window is + # how changesets works, not something this step can close; keeping one + # changeset per pull request keeps it small. + # + # Never fail the release over this: a version PR that stays open costs a + # manual merge, while a failure here would block npm, the Marketplace and + # the CDN behind it. + - name: Enable auto-merge on the version PR + if: steps.version-pr.outputs.number != '' && vars.AUTO_MERGE_RELEASE_PR == 'true' + continue-on-error: true env: GH_TOKEN: ${{ steps.release-bot.outputs.token }} + PR: ${{ steps.version-pr.outputs.number }} run: | - pr=$(gh pr list --head changeset-release/main --state open --json number --jq '.[0].number' || true) - if [ -n "$pr" ] && [ "$pr" != "null" ]; then - gh pr comment "$pr" --body '@coderabbitai review' + set -uo pipefail + if gh pr merge "${PR}" --squash --auto; then + echo "Auto-merge armed on #${PR}; it lands when its required checks pass." + else + echo "::warning::Could not arm auto-merge on #${PR}. Merge it by hand to cut the release." fi + - name: Request CodeRabbit review on version PR + if: steps.version-pr.outputs.number != '' + env: + GH_TOKEN: ${{ steps.release-bot.outputs.token }} + PR: ${{ steps.version-pr.outputs.number }} + run: gh pr comment "${PR}" --body '@coderabbitai review' + - name: Resolve Pythinker Code native release if: steps.changesets.outputs.published == 'true' id: pythinker-release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7d11421..72a4d638 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,9 +88,9 @@ This repo uses [changesets](https://github.com/changesets/changesets) to manage | `minor` | A capability a user could not reach before | `2.1.3` → `2.2.0` | | `major` | A break: something that worked stops working, or works differently | `2.2.0` → `3.0.0` | -Prefer one changeset per pull request. A pull request that needs several is usually several releases wearing one hat, and the changelog cannot attribute the changes afterwards. +Prefer one changeset per pull request. A pull request that needs several is usually carrying several separate releases, and once they are versioned together the changelog can no longer say which change each entry came from. -A `major` needs a maintainer's sign-off: the `changeset-policy` workflow fails a pull request that adds one unless it carries the `breaking-change-approved` label. A major renames the release and breaks every pinned install, and an npm publish cannot be taken back — so it is a decision, never a side effect of a large branch. +A `major` needs a maintainer's sign-off: the `changeset-policy` workflow fails a pull request that adds a major changeset, or edits an existing one up to `major`, unless it carries the `breaking-change-approved` label. A pinned install keeps working, but every consumer who upgrades has to deal with the break, and an npm publish cannot be taken back — so it is a decision, never a side effect of a large branch. ### Release cadence @@ -101,6 +101,8 @@ Changesets keeps a `ci: release packages` pull request open on `main` and rewrit The repository variable `AUTO_MERGE_RELEASE_PR` chooses between the two. Set to `true`, the release pull request merges itself once its required checks pass, giving one release per change. Unset or `false`, a maintainer merges it when a release is wanted. +Either way there is a window: a changeset that lands while the release pull request is waiting on its checks joins that release instead of starting the next one. That is how changesets works, so a release can still carry more than one change — one changeset per pull request keeps the window small. + ## Pull Requests Every PR opens with the [PR template](.github/pull_request_template.md). PR titles must follow [Conventional Commits](#commit-convention); CI runs `pnpm lint`, `pnpm typecheck`, and `pnpm test` on every PR. Update user-facing docs in `docs/` when behavior changes — use the `gen-docs` skill when working with coding agents. From 5ece47dd67637b481acae12f51a9f13a1a0b277c Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 24 Aug 2026 17:12:16 -0400 Subject: [PATCH 3/3] ci: match the version pull request by full repository, not owner Filtering the fallback lookup by head-repository owner still accepts a different repository belonging to that owner. Compare the head repository's full `owner/name` against `GITHUB_REPOSITORY` instead. --- .github/workflows/release.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3146d01c..1d54b2cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -200,9 +200,10 @@ jobs: # The changesets action reports the pull request it just created or # updated, which is the only unambiguous identifier. The fallback covers # the run where changesets had nothing to change, and it is scoped to a - # head branch in THIS repository: a head-name match on its own can select - # a fork's branch of the same name, which would point the steps below at - # somebody else's pull request. + # head branch in THIS repository by full `owner/name`: `--head` matches a + # branch name only, so a head-name match on its own can select a fork's + # branch of the same name, and matching the owner alone would still accept + # a different repository belonging to that owner. - name: Resolve the version PR id: version-pr if: steps.changesets.outputs.published != 'true' @@ -221,13 +222,13 @@ jobs: --head changeset-release/main \ --base main \ --state open \ - --json number,headRepositoryOwner); then + --json number,headRepository); then echo "::warning::Could not look up the version PR. Nothing downstream will run; check it by hand." exit 0 fi number=$(printf '%s' "${open_prs}" \ - | jq -r --arg owner "${GITHUB_REPOSITORY_OWNER}" \ - '[.[] | select(.headRepositoryOwner.login == $owner)][0].number // ""') + | jq -r --arg repo "${GITHUB_REPOSITORY}" \ + '[.[] | select(.headRepository.nameWithOwner == $repo)][0].number // ""') if [ -z "${number}" ]; then echo "::notice::No open version PR in this repository." exit 0