Skip to content

Pin calendar version when changesets bumps a major - #4607

Draft
andrewmcgov wants to merge 1 commit into
2026-10-rcfrom
am/pin-calendar-version-on-release
Draft

Pin calendar version when changesets bumps a major#4607
andrewmcgov wants to merge 1 commit into
2026-10-rcfrom
am/pin-calendar-version-on-release

Conversation

@andrewmcgov

@andrewmcgov andrewmcgov commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Background

The Version Packages (2026-10-rc) PR (#4603) landed with the version 2027.0.0-rc.1 instead of 2026.10.0-rc.1.

Root cause

We deliberately mark API-version cuts as major changesets so the generated CHANGELOG reads "Major Changes". But changeset version feeds that bump type straight into semver.inc(), and on a calendar version a semver major bump always rolls the year:

semver.inc('2026.10.0-rc.1', 'major')  ->  '2027.0.0'

node-semver has a "pre-major" shortcut that would keep 1.0.0-rc.0 at 1.0.0, but it only fires when minor and patch are both 0. Our minor is the month, so it never fires:

inc('1.0.0-rc.0',     'major') = 1.0.0      <- shortcut fires
inc('2026.10.0-rc.0', 'major') = 2027.0.0   <- minor=10, real major bump

This is not new. The same thing happened on 2026-07-rc (#4290) — the bot's commit produced 2027.0.0-rc.1 from 2026.7.0-rc.0, and it only reached npm as 2026.7.0-rc.1 because it was hand-corrected in three follow-up commits ("lint fix", "update to correct versioning", "update extension tester example test to new api version"). It will recur on every RC branch that receives a major changeset.

Note that only major is hazardous on an RC branch. minor and patch are no-ops there, because node-semver leaves minor alone when patch is 0 and a prerelease is present — which is why rc.2 released cleanly off a minor changeset.

Left unfixed, the real risk isn't the red CI (that's loud and obvious) — it's that 2027.0.0-rc.1 reaching npm would outrank every future 2026.x release on the rc dist-tag permanently, and npm versions can't be reused.

Solution

In this repo YYYY.MM is chosen by a human at RC-cut time. Changeset bump types are changelog semantics only and must never move it.

So scripts/changeset-version.mjs wraps changeset version: run it as normal, then re-pin YYYY.MM and keep whatever patch/prerelease counter changesets calculated.

// Calendar segment untouched (normal patch bump) — nothing to do.
if (next.major === prev.major && next.minor === prev.minor) return computed;

const pre = next.prerelease.length ? `-${next.prerelease.join('.')}` : '';
let pinned = `${prev.major}.${prev.minor}.${prev.patch}${pre}`;

// Stable branch: no prerelease counter to advance, so move the patch instead.
if (!semver.gt(pinned, before)) {
  pinned = `${prev.major}.${prev.minor}.${prev.patch + 1}${pre}`;
}

It then swaps the stale version across every packages/*/package.json and CHANGELOG.md — covering package versions, internal dependency ranges, and changelog headings in one pass — and finishes with prettier --write .changeset/pre.json, which fixes the second CI failure permanently.

It's wired in as the version input of changesets/action, which defaults to changeset version:

uses: changesets/action@e0145edc7d9d8679003495b11f87bd8ef63c0cba # v1.5.3
with:
  title: Version Packages (${{ github.ref_name }})
  version: yarn version:calendar
  publish: yarn run deploy ...

semver is added as a root devDependency, pinned to ^7.5.3 so it reuses the resolution already in yarn.lock (no lockfile change, no duplicate copy in node_modules).

How the release flow works now

Nothing changes about how you release. Each release is still two pushes to the version branch:

1. Merge a PR containing .changeset/foo.md
        │
        ▼  push to 2026-10-rc
2. deploy.yml → changesets job
        │
   ┌────┴──────────────────────────────────────────────┐
   │ unconsumed changesets exist?                      │
   │   YES → runs `version` input                      │
   │         = yarn version:calendar   ◄── NEW         │
   │         opens/updates "Version Packages" PR       │
   │   NO  → runs `publish` input                      │
   │         = changeset publish → npm                 │
   └───────────────────────────────────────────────────┘
        │
3. Merge the "Version Packages" PR
        │
        ▼  push to 2026-10-rc
4. deploy.yml runs again → nothing unconsumed → publish → npm

The script runs only at step 2, on the runner. It never runs on the publish leg, and never locally unless you invoke yarn version:calendar yourself. In pre mode "unconsumed" means not yet listed in pre.json's changesets array, which is why merging the Version Packages PR doesn't re-trigger versioning even though the .md files remain on the branch.

Deliberately unaffected: /snapit, deploy:unstable and deploy:internal all produce 0.0.0-* snapshot versions where calendar pinning would be meaningless, so they bypass the wrapper.

Scenario changesets computes after fix
New major changeset mid-RC 2027.0.0-rc.3 2026.10.0-rc.3
Normal patch changeset mid-RC 2026.10.0-rc.3 untouched, no-op
major on a stable branch 2027.0.0 2026.10.1 (no collision with 2026.10.0)

Full CI gate green locally: yarn build, yarn type-check, eslint . --max-warnings 0 (on a pristine tree), prettier --check, tester typecheck + 75 tests, all 4 example suites, yarn docs:admin.

`changeset version` feeds our changeset bump types straight into
`semver.inc()`. Because we use calendar versioning (`YYYY.MM.PATCH`), a
`major` bump always rolls the year:

    semver.inc('2026.10.0-rc.1', 'major')  ->  '2027.0.0'

This is what produced `2027.0.0-rc.1` on the 2026-10-rc release PR (#4603)
and `2027.0.0-rc.1` on 2026-07-rc before it (#4290). Both times a human had
to hand-correct the versions, changelogs and pre.json before CI would pass.

Wrap `changeset version` so that after it runs we re-pin `YYYY.MM` and keep
whatever patch/prerelease counter changesets calculated. Bump types stay
purely changelog semantics, so release cuts can keep using `major`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant