From 9b2568900d144afcb09354e37094d6ac135fe04e Mon Sep 17 00:00:00 2001 From: unional Date: Wed, 12 Aug 2026 23:19:25 -0700 Subject: [PATCH 1/3] fix(pnpm-release-changeset): restore changesets/action v1 Reverts the one-line change from #42, which Renovate opened and Mergify auto-merged on 2026-08-12. changesets/action v2 validates the consumer's @changesets/cli major and aborts on v2: This version of the Changesets action is designed to work with Changesets CLI v3. Changesets CLI v2 is not supported; use Changesets action v1 instead, which is compatible with CLI v2. Four of the five consumers of this workflow are on @changesets/cli v2 (storybook ^2.29.7, visual-testing ^2.29.8, rolldown-inline-type-exports ^2.29.8, jest-watch-toggle-config-2 ^2.25.2). All four pin @main, so all four have a broken release path right now; none has noticed because none has released since #42 merged. This branch is the v1 line and stays paired with CLI v2. The v3-compatible variant lives on v2.x and is published as the v2 tag, which is what repobuddy/repobuddy (now on ^3.0.0) will pin. Refs: https://github.com/repobuddy/.github/issues/43 Co-Authored-By: Claude Opus 5 --- .github/workflows/pnpm-release-changeset.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pnpm-release-changeset.yml b/.github/workflows/pnpm-release-changeset.yml index 05dbb67..6a6352f 100644 --- a/.github/workflows/pnpm-release-changeset.yml +++ b/.github/workflows/pnpm-release-changeset.yml @@ -53,7 +53,7 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create Release Pull Request or Publish to npm id: changesets - uses: changesets/action@v2.0.0 + uses: changesets/action@v1 with: commit: 'chore: version packages' version: pnpm run version From e1f30d71a5fde8399d9494ad5f0436235aea2166 Mon Sep 17 00:00:00 2001 From: unional Date: Wed, 12 Aug 2026 23:19:25 -0700 Subject: [PATCH 2/3] feat(pnpm-release-changeset-oidc): add a secretless release workflow Mirrors pnpm-release-changeset.yml but authenticates without long-lived credentials: the built-in GITHUB_TOKEN with elevated permissions instead of CI_GITHUB_TOKEN, and npm trusted publishing (OIDC) instead of NPM_TOKEN. Trusted publishing also emits provenance attestations. Both tokens it replaces are long-lived, silently expiring, and worth stealing. An expired NPM_TOKEN fails in a way nobody notices until a release has been missing from the registry for a long time. Added alongside the token-based workflow rather than replacing it, so repos keep working until they have registered trusted publishers. Each published package needs one registered against the *caller* workflow's filename before its first OIDC publish. Known trade-off, documented in the file: a PR opened with GITHUB_TOKEN does not trigger on: pull_request, so the version PR gets no status checks. Every consumer in this org already suppresses those checks with branches-ignore: ['changeset-release/*'], so this gives up nothing new. Stays on changesets/action@v1 to match this line's CLI v2 pairing. Co-Authored-By: Claude Opus 5 --- .../workflows/pnpm-release-changeset-oidc.yml | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/pnpm-release-changeset-oidc.yml diff --git a/.github/workflows/pnpm-release-changeset-oidc.yml b/.github/workflows/pnpm-release-changeset-oidc.yml new file mode 100644 index 0000000..178185a --- /dev/null +++ b/.github/workflows/pnpm-release-changeset-oidc.yml @@ -0,0 +1,100 @@ +name: pnpm-release-changeset-oidc +# Secretless variant of pnpm-release-changeset.yml. +# +# Differences from that workflow: +# - No CI_GITHUB_TOKEN. Uses the built-in GITHUB_TOKEN with elevated `permissions`. +# Trade-off: a PR opened with GITHUB_TOKEN does not trigger `on: pull_request` +# workflows, so the "version packages" PR gets no status checks. That is already the +# case across this org — every consumer's pull-request.yml carries +# `branches-ignore: ['changeset-release/*']` — so adopting this costs nothing that +# was not already given up deliberately. +# - No NPM_TOKEN. Publishes via npm trusted publishing (OIDC), which also emits +# provenance attestations. Each package must have a trusted publisher registered at +# npmjs.com/package//access naming the repo and the *caller* workflow's +# filename (release.yml), not this file. Publishing fails with a 404-style auth +# error until that registration exists. +# +# Why bother, given this org does have a secret scope: CI_GITHUB_TOKEN and NPM_TOKEN are +# long-lived credentials that expire silently and are worth stealing. An expired +# NPM_TOKEN is the kind of failure nobody notices until a release has been missing from +# the registry for months. OIDC credentials are minted per run and last minutes. +# +# This is the v1 line, so it pairs changesets/action@v1 with @changesets/cli v2. The +# pairing is strict and enforced by the action itself: action v2 refuses to run against +# CLI v2, and directs you here. Consumers on @changesets/cli v3 want the v2 tag of this +# repo instead. See README "What counts as breaking". +# +# Added alongside pnpm-release-changeset.yml rather than replacing it, so repos that have +# not registered trusted publishers keep working. +on: + workflow_call: + inputs: + pnpm-version: + type: string + required: false + node-version: + type: string + required: false + default: '24' + outputs: + published: + description: 'Whether the release was published' + value: ${{ jobs.release.outputs.published }} + +permissions: + # Mints the OIDC token npm exchanges for short-lived publish credentials. + id-token: write + # Lets changesets/action push the version branch and tags. + contents: write + # Lets changesets/action open the "version packages" PR. + pull-requests: write + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + runs-on: ubuntu-latest + env: + PLAYWRIGHT_PATH: ~/.cache/ms-playwright + outputs: + published: ${{ steps.changesets.outputs.published }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v7 + with: + node-version: ${{ inputs.node-version }} + cache: pnpm + registry-url: https://registry.npmjs.org + + # Trusted publishing requires npm 11.5.1+. Node LTS may ship an older npm. + - name: Ensure npm supports trusted publishing + run: | + npm install -g npm@latest + npm --version + + - name: Install Dependencies + run: pnpm install + + - name: Install playwright browsers + uses: repobuddy/.github/.github/actions/setup-playwright@main + + - name: Install vsce + run: pnpm install -g vsce + + - run: pnpm build + + # No .npmrc token step: the OIDC exchange supplies credentials at publish time. + - name: Create Release Pull Request or Publish to npm + id: changesets + uses: changesets/action@v1 + with: + commit: 'chore: version packages' + version: pnpm run version + publish: pnpm run release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 385761aa44a3a8b77b0b8a47359a69f590c2640d Mon Sep 17 00:00:00 2001 From: unional Date: Wed, 12 Aug 2026 23:19:25 -0700 Subject: [PATCH 3/3] docs: define the two-line tagging scheme for the shared workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo has never been tagged and every consumer pins @main, so every change reaches all of them the instant it merges — including breaking ones nobody reviewed. That is how #42 broke the release path of five repos at once. Adopt semver tags plus a moving major alias (v1.0.0 immutable, v1 re-pointed on each compatible release), and split the changesets release path into two parallel lines because changesets/action and @changesets/cli are strictly paired and the action enforces it: v1 (main) -> changesets/action@v1 -> @changesets/cli v2 v2 (v2.x) -> changesets/action@v2 -> @changesets/cli v3 Consumers pick a tag by their CLI major, not by recency. The lines run in parallel rather than one being a deadline; v2.x merges down into main when the last consumer reaches CLI v3. Documents the per-consumer pin table, why v1.0.0 rather than v0.x, what one tag covers, what counts as breaking, the OIDC adoption steps, the manual release procedure for both lines, and the known gap that internal setup-playwright refs still float on @main. Also records that Mergify's `head~=^(?!major-)` guard does not match the branch names Renovate produces, so majors merge unreviewed. That is a separate defect and should be fixed on its own. Co-Authored-By: Claude Opus 5 --- README.md | 295 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) diff --git a/README.md b/README.md index e2f89c2..da4f5e8 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,298 @@ Use the workflow templates to create workflows for each repository. References: - + +## Versioning + +### Which ref to pin + +**Pin a tag, never `@main`.** Which tag depends on one thing only — the major of +`@changesets/cli` in your repo: + +| Your `@changesets/cli` | Pin | Because | +| --- | --- | --- | +| v2 | `@v1` | `pnpm-release-changeset*.yml` uses `changesets/action@v1` | +| v3 | `@v2` | `pnpm-release-changeset*.yml` uses `changesets/action@v2` | + +```yaml +jobs: + verify: + uses: repobuddy/.github/.github/workflows/pnpm-verify.yml@v1 +``` + +If you do not use the changesets release workflows at all, either tag works and +the newer one is fine — every other workflow is byte-identical across the two. + +`v1` and `v2` are *moving* tags. Each is re-pointed forward on every +backward-compatible release within its line, so you get fixes without doing +anything. Neither is ever moved across a breaking change. + +If you need a ref that never moves under you, pin the exact version instead +(`@v1.0.0`) and accept that you must bump it by hand to get any fix. + +Do **not** pin `@main`. `main` is the development branch: it carries unreleased +and possibly broken work, and every consumer on it takes every change the +instant it merges. That is how a silently-broken release path reached five repos +([#43](https://github.com/repobuddy/.github/issues/43)). + +### Why there are two lines + +`changesets/action` and `@changesets/cli` are strictly paired, and the action +enforces the pairing itself: + +| `changesets/action` | works with | inputs | +| --- | --- | --- | +| `v1` | `@changesets/cli` v2 | `version`, `publish`, `commit` | +| `v2` | `@changesets/cli` v3 | `version-script`, `publish-script`, `commit-message` | + +Run action v2 against CLI v2 and it aborts with *"This version of the Changesets +action is designed to work with Changesets CLI v3. Changesets CLI v2 is not +supported; use Changesets action v1 instead."* There is no configuration that +makes one workflow serve both. + +Consumers are split across both CLI majors and will be for a while, so the two +lines exist in parallel rather than one being a migration deadline: + +| Repo | `@changesets/cli` | Pin | +| --- | --- | --- | +| `repobuddy/repobuddy` | `^3.0.0` | `@v2` | +| `repobuddy/storybook` | `^2.29.7` | `@v1` | +| `repobuddy/visual-testing` | `^2.29.8` | `@v1` | +| `repobuddy/rolldown-inline-type-exports` | `^2.29.8` | `@v1` | +| `repobuddy/jest-watch-toggle-config-2` | `^2.25.2` | `@v1` | + +**Branch layout:** `main` is the v1 line. The v2 line lives on the `v2.x` +branch. A fix that applies to both is made on `main` and cherry-picked to +`v2.x`; the two differ only in the `changesets/action` ref and its input names. + +When the last consumer reaches CLI v3, `v2.x` can be merged down into `main` and +the v1 line retired. + +### The scheme + +Semver tags plus a moving major alias — the GitHub Actions ecosystem +convention, and what `actions/*` itself does: + +| Tag | Mutable? | Meaning | +| --- | --- | --- | +| `v1.0.0` | no | One exact release. Never re-pointed. | +| `v1` | yes | Latest `v1.x.y`. Re-pointed on each compatible release. | + +This was chosen over an immutable-only scheme with full deliberation of the +tradeoff: a moving `v1` does hand back some of the instant-propagation problem +that `@main` had. It is still a large improvement, because the two are not +equivalent: + +- `@main` propagates **everything**, including breaking changes and + work-in-progress, with no one having decided it was safe. +- `@v1` propagates only what a maintainer explicitly judged backward + compatible. A breaking change stops at the `v1` boundary and requires + consumers to opt in by moving to `@v2`. + +The moving alias is also what Dependabot and Renovate understand, and what +anyone reading a `uses:` line in this org will expect. An immutable-only scheme +is strictly safer but only if someone actually bumps the pins; a stale pin +nobody updates is a worse outcome than a moving tag. Consumers who want the +stricter guarantee can opt into `@v1.0.0` individually. + +### Why this matters more here than in most repos + +`main` in this repo is *not* hand-curated. `.mergify.yml` auto-merges Renovate +PRs, so dependency bumps to the actions these workflows call land on `main` +without a human in the loop — and under `@main` they reach every consumer the +moment they merge. + +That is not hypothetical. `changesets/action` v1 → **v2**, a major upgrade with +renamed inputs and a hard CLI-version requirement, arrived as Renovate PR +[#42](https://github.com/repobuddy/.github/pull/42) on branch +`renovate/changesets-action-2.x` and was auto-merged. The Mergify rule intends +to hold majors back with `head~=^(?!major-)`, but Renovate does not prefix these +branches with `major-`, so the guard did not match and the major merged like any +patch. The result was [#43](https://github.com/repobuddy/.github/issues/43): +every consumer's release path broken at once, and none of them found out until +the next release was attempted. + +Tagging fixes the consumer half of this — an auto-merged bump now lands on +`main` and waits there until someone cuts a release. The Mergify rule not +actually excluding majors is a separate defect and should be fixed on its own. + +### Starting version: `v1.0.0` + +Not `v0.x`. Two reasons: + +1. These workflows are already in production use by five repos and have been + for a long time. `v0.x` would advertise "expect this to break", which is a + less honest description of the status quo than `v1` — and `@main` offered no + stability guarantee whatsoever, so anything we tag is an improvement rather + than a regression in stability. +2. The moving-major-alias convention is only coherent at `>= 1`. Under semver, + `0.x` minor bumps are permitted to break, so a moving `v0` alias would carry + breaking changes to every consumer automatically — precisely the failure mode + this change exists to stop. + +### What one version covers + +**Everything in this repo shares one tag** — all reusable workflows plus the +`setup-playwright` composite action. They live in one repo, and a git tag names +a commit of the whole repo; there is no per-workflow versioning. + +The consequence, stated plainly: **a change to any one workflow bumps the tag +for all consumers of all of them.** A repo that only uses `pnpm-verify.yml` +will still see version churn from an edit to `pnpm-release-semantic.yml`. +That churn is noise, not risk — the unchanged workflows are byte-identical +across the two tags. + +If that churn ever becomes a real problem, the escape hatch is per-workflow tag +prefixes (`pnpm-verify/v1`). Do not reach for it pre-emptively; it multiplies +the release procedure by the number of workflows. + +### What counts as breaking + +A major bump is required when a change would break a consumer that changed +nothing on its side: + +- removing or renaming a workflow, an action, or one of their inputs +- making a previously optional input required +- changing a default such that existing behavior changes +- requiring a secret that consumers did not previously have to set +- **requiring a new precondition in the consumer's own repo** + +That last one is easy to miss and is exactly what the two lines encode: `v1`'s +contract includes "consumers of `pnpm-release-changeset*.yml` are on +`@changesets/cli` v2", and `v2`'s includes "…are on v3". Moving a consumer +across CLI majors means moving its pin in the same change. + +## Release workflows + +Two variants of the changesets release path, per line. They differ only in how +they authenticate. + +| Workflow | GitHub auth | npm auth | +| --- | --- | --- | +| `pnpm-release-changeset.yml` | `CI_GITHUB_TOKEN` | `NPM_TOKEN` | +| `pnpm-release-changeset-oidc.yml` | built-in `GITHUB_TOKEN` | trusted publishing (OIDC) | + +**Prefer the OIDC variant for new repos, and migrate existing ones.** Both +tokens are long-lived credentials that expire silently and are worth stealing; +OIDC credentials are minted per run and last minutes. Trusted publishing also +emits provenance attestations. + +### Adopting the OIDC variant + +1. Register a trusted publisher for **each published package** at + `npmjs.com/package//access`, naming this repo and the **caller** + workflow's filename — `release.yml`, not the reusable workflow's filename. + Publishing fails with an auth error until this exists. +2. Point the caller at `pnpm-release-changeset-oidc.yml`. +3. Declare permissions **in the caller**. Declaring `permissions:` replaces the + default set, so every scope the callee needs must be listed — unlisted ones + drop to `none`, not to the default: + + ```yaml + release: + uses: repobuddy/.github/.github/workflows/pnpm-release-changeset-oidc.yml@v1 + needs: code + permissions: + id-token: write + contents: write + pull-requests: write + ``` + +4. Once a release has published successfully, delete `NPM_TOKEN` and + `CI_GITHUB_TOKEN` from the repo's secrets. + +Known trade-off: a PR opened with `GITHUB_TOKEN` does not trigger +`on: pull_request` workflows, so the "version packages" PR gets no status +checks. Every consumer in this org already suppresses those checks explicitly +(`branches-ignore: ['changeset-release/*']`), so this costs nothing that was not +already given up deliberately. Repos that want checks there should adopt a merge +queue with a `merge_group:` trigger rather than reintroduce a PAT. + +### Will Renovate keep consumer pins current? + +Checked, because a pin nobody bumps is worse than `@main`. + +Consumers extend `github>unional/renovate-preset`, which is: + +```json +{ + "description": "Preserving Semver", + "extends": ["config:base", ":preserveSemverRanges"] +} +``` + +No `enabledManagers`, no `packageRules`, nothing disabling `github-actions`. +`config:base` enables all managers, and Renovate's `github-actions` manager +handles both `steps[].uses` and reusable-workflow `jobs..uses`. **So yes — +tagged refs will be picked up.** (`:preserveSemverRanges` applies to npm ranges, +not Actions refs. Renovate ignores `@main`, which is why nothing bumps today.) + +One consequence worth understanding before it surprises someone: pinned to a +moving `@v1`, Renovate has **nothing to bump** for ordinary releases — `v1` is +still `v1` after `v1.0.1`. You will see no update PRs, and you do not need them, +because the alias moves on its own. Renovate only opens a PR when `v2` appears — +which, here, it should **not** be allowed to merge automatically, because moving +a consumer from `@v1` to `@v2` is only correct alongside a `@changesets/cli` +v2 → v3 upgrade in the same repo. + +Dependabot will not help here: `visual-testing` and `storybook` have a +`.github/dependabot.yml` with an `npm` entry only and no `github-actions` +ecosystem. Renovate is doing this job. + +Two config problems found while checking, neither blocking: + +- `repobuddy/storybook` has **two** Renovate configs — a root `renovate.json` + (`config:recommended`) and `.github/renovate.json` (the org preset). Root wins + by Renovate's precedence order and the other is ignored. The github-actions + manager is enabled either way, so tag bumping still works, but the duplicate + should be removed. +- `config:base` is a deprecated alias for `config:recommended`; worth updating + the preset. + +### Cutting a release + +Manual. This repo is tagged rarely, and a release workflow for that cadence is +scaffolding that needs its own maintenance. + +For the **v1 line** (from `main`): + +```bash +# 1. From the merge commit you intend to release: +git checkout main && git pull + +# 2. Immutable version tag + release notes. +git tag -a v1.0.0 -m "v1.0.0" +git push origin v1.0.0 +gh release create v1.0.0 --generate-notes + +# 3. Move the major alias forward. -f on both sides is required: the tag +# already exists and is intentionally being re-pointed. +git tag -f v1 v1.0.0 +git push -f origin v1 +``` + +For the **v2 line**, the same three steps from the `v2.x` branch with `v2.0.0` +and `v2` substituted. + +Step 3 is the one that is easy to get wrong or forget. If the alias is not +moved, the release is invisible to everyone pinning it. + +#### Known gap: internal refs still float on `@main` + +Workflows in this repo consume this repo's own composite action: + +```text + uses: repobuddy/.github/.github/actions/setup-playwright@main +``` + +A reusable workflow cannot reference a sibling action by relative path — `./` +resolves against the *caller's* checkout, not this repo's — so these must be +fully-qualified `owner/repo/path@ref`, and today that ref is `@main`. **A +consumer pinned at `@v1` therefore still picks up `setup-playwright` from +`main`,** which partially defeats the pin. + +This is deliberately not fixed in the same change that introduces the scheme: +re-pointing them to `@v1` before `v1` exists would break every current consumer +immediately. Once `v1.0.0` is cut, change these to `@v1` on `main` and `@v2` on +`v2.x` — the moving aliases, so they do not need touching on every subsequent +release — and include that edit in the following release.