From 3be7e5368134e38251fb68af3f5f30e87590a630 Mon Sep 17 00:00:00 2001 From: andrewmcgov Date: Fri, 31 Jul 2026 15:44:12 -0400 Subject: [PATCH] Pin calendar version when changesets bumps a major `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`. --- .github/workflows/deploy.yml | 2 + package.json | 2 + scripts/changeset-version.mjs | 131 ++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 scripts/changeset-version.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e80befdd62..60aa1d1201 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -76,6 +76,8 @@ jobs: uses: changesets/action@e0145edc7d9d8679003495b11f87bd8ef63c0cba # v1.5.3 with: title: Version Packages (${{ github.ref_name }}) + # Pins YYYY.MM after `changeset version` — see scripts/changeset-version.mjs + version: yarn version:calendar publish: yarn run deploy ${{ !endsWith(github.ref_name, '-rc') && format('--tag {0}', github.ref_name) || '' }} # no tag for RC branches. createGithubReleases: false env: diff --git a/package.json b/package.json index 48940999b8..5f77850862 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "clean": "git clean -xdf ./packages; rm -rf ./build", "predeploy": "yarn build", "deploy": "changeset publish", + "version:calendar": "node scripts/changeset-version.mjs", "predeploy:unstable": "yarn build", "docs:admin": "yarn workspace @shopify/ui-extensions docs:admin", "docs:checkout": "yarn workspace @shopify/ui-extensions docs:checkout", @@ -52,6 +53,7 @@ "eslint": "^8.28.0", "nodemon": "^2.0.4", "prettier": "^2.8.8", + "semver": "^7.5.3", "typescript": "^4.9.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" diff --git a/scripts/changeset-version.mjs b/scripts/changeset-version.mjs new file mode 100644 index 0000000000..e9d8a3c44b --- /dev/null +++ b/scripts/changeset-version.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/** + * Wrapper around `changeset version` that enforces this repo's calendar + * versioning scheme (`YYYY.MM.PATCH`). + * + * Wired in as the `version` input of changesets/action in deploy.yml, so it runs + * on the runner when the "Version Packages" PR is generated — not on publish. + * + * Why this exists + * --------------- + * We deliberately mark API-version cuts as `major` changesets so the generated + * CHANGELOG reads "Major Changes". But changesets feeds that bump type straight + * into `semver.inc()`, and a semver major bump on a calendar version always + * rolls the *year*: + * + * semver.inc('2026.10.0-rc.1', 'major') -> '2027.0.0' + * + * (The "pre-major" shortcut that keeps `1.0.0-rc.0` at `1.0.0` only fires when + * minor and patch are both 0 — our minor is the month, so it never fires.) + * + * That produced `2027.0.0-rc.1` on the 2026-10-rc release PR, which broke the + * tester's api_version check. Had it reached npm it would have outranked every + * subsequent 2026.x release permanently. + * + * 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. + * + * 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: run + * `changeset version` as normal, then re-pin `YYYY.MM` and keep whatever + * patch/prerelease counter changesets calculated. + */ + +import {execFileSync} from 'node:child_process'; +import {readFileSync, writeFileSync, existsSync, readdirSync} from 'node:fs'; +import path from 'node:path'; + +import semver from 'semver'; + +const ROOT = process.cwd(); +const PACKAGES_DIR = path.join(ROOT, 'packages'); +const PKG_DIRS = readdirSync(PACKAGES_DIR, {withFileTypes: true}) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(PACKAGES_DIR, entry.name)) + .filter((dir) => existsSync(path.join(dir, 'package.json'))); + +const readJson = (file) => JSON.parse(readFileSync(file, 'utf8')); +const manifestOf = (dir) => readJson(path.join(dir, 'package.json')); + +/** + * Neutralise any year/month movement introduced by a major (or, on a stable + * branch, minor) changeset, keeping the patch/prerelease counter that + * changesets calculated. + */ +function pinToCalendar(name, before, computed) { + const prev = semver.parse(before); + const next = semver.parse(computed); + + // 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}`; + } + + // Invariant: incrementing patch always outranks `before`, so this should be + // unreachable. Kept as a guard so a future edit to the logic above can never + // silently publish a version that moves backwards. + if (!semver.gt(pinned, before)) { + throw new Error( + `Cannot pin ${name} to the calendar version: ${before} -> ${computed} ` + + `would become ${pinned}, which is not a forward move. If you are cutting ` + + `a new API version, set the package versions and .changeset/pre.json ` + + `manually as part of the release commit.`, + ); + } + + return pinned; +} + +const before = new Map(PKG_DIRS.map((dir) => [dir, manifestOf(dir).version])); + +execFileSync('yarn', ['changeset', 'version'], {stdio: 'inherit', cwd: ROOT}); + +// Work out which packages changesets moved off the calendar scheme. +const rewrites = new Map(); +for (const dir of PKG_DIRS) { + const {name, version: computed} = manifestOf(dir); + if (computed === before.get(dir)) continue; + const pinned = pinToCalendar(name, before.get(dir), computed); + if (pinned !== computed) rewrites.set(computed, pinned); +} + +if (rewrites.size > 0) { + // Changesets writes the computed version into package.json versions, internal + // dependency ranges, and CHANGELOG headings. A literal swap covers all three — + // the computed strings are freshly generated and unique. + const targets = PKG_DIRS.flatMap((dir) => [ + path.join(dir, 'package.json'), + path.join(dir, 'CHANGELOG.md'), + ]).filter(existsSync); + + for (const file of targets) { + const original = readFileSync(file, 'utf8'); + let updated = original; + for (const [computed, pinned] of rewrites) { + updated = updated.split(computed).join(pinned); + } + if (updated !== original) writeFileSync(file, updated); + } + + for (const [computed, pinned] of rewrites) { + console.log(`calendar-version: re-pinned ${computed} -> ${pinned}`); + } +} else { + console.log('calendar-version: versions already follow YYYY.MM.PATCH'); +} + +// changesets writes pre.json with its own formatting, which trips the Prettier +// lint step whenever the `changesets` array is short enough to fit on one line. +if (existsSync(path.join(ROOT, '.changeset/pre.json'))) { + execFileSync('yarn', ['prettier', '--write', '.changeset/pre.json'], { + stdio: 'inherit', + cwd: ROOT, + }); +}