From 6d6a51de4faa928993862ff2e705347501621525 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 28 Sep 2026 09:55:28 +0100 Subject: [PATCH 1/2] fix(release): exclude skipped packages from version bumps --- scripts/changeset-prerelease.test.ts | 100 +++++++++++++++++++++++++++ scripts/version.mts | 60 +++++++++++++--- 2 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 scripts/changeset-prerelease.test.ts diff --git a/scripts/changeset-prerelease.test.ts b/scripts/changeset-prerelease.test.ts new file mode 100644 index 0000000000..12a75c71d9 --- /dev/null +++ b/scripts/changeset-prerelease.test.ts @@ -0,0 +1,100 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect, it } from "vite-plus/test"; + +import { versionPackages } from "./version.mts"; + +// Regression for https://github.com/changesets/changesets/issues/2024. +it.each([false, true])( + "exits prereleases without bumping skipped packages (private versioning: %s)", + async (versionPrivatePackages) => { + const root = mkdtempSync(join(tmpdir(), "vinext-changeset-pre-exit-")); + const packages = [ + { name: "public-beta", version: "1.0.0-beta.2" }, + { name: "public-stable", version: "1.2.3" }, + { name: "private-fixture", private: true }, + { name: "private-stable", private: true, version: "0.2.0" }, + { name: "private-beta", private: true, version: "1.0.0-beta.1" }, + { name: "ignored-beta", version: "1.0.0-beta.1" }, + { + name: "private-dependent", + private: true, + dependencies: { "public-beta": "workspace:^1.0.0-beta.2" }, + }, + ]; + + try { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "test-workspace", private: true, workspaces: ["packages/*"] }), + ); + mkdirSync(join(root, ".changeset")); + writeFileSync( + join(root, ".changeset/config.json"), + JSON.stringify({ + changelog: "@changesets/cli/changelog", + ignore: ["ignored-beta"], + privatePackages: { version: versionPrivatePackages, tag: false }, + }), + ); + writeFileSync( + join(root, ".changeset/pre.json"), + JSON.stringify({ mode: "exit", tag: "beta", initialVersions: {}, changesets: [] }), + ); + const changesetPath = join(root, ".changeset/public-beta.md"); + writeFileSync( + changesetPath, + '---\n"public-beta": patch\n---\n\nRelease the public package.\n', + ); + for (const pkg of packages) { + const dir = join(root, "packages", pkg.name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify(pkg)); + } + const existingChangelog = join(root, "packages/private-stable/CHANGELOG.md"); + writeFileSync(existingChangelog, "# Existing private changelog\n"); + + await versionPackages(root); + + for (const pkg of packages) { + const shouldGraduate = + pkg.name === "public-beta" || (pkg.name === "private-beta" && versionPrivatePackages); + const actual = JSON.parse( + readFileSync(join(root, "packages", pkg.name, "package.json"), "utf8"), + ); + const expected = shouldGraduate ? { ...pkg, version: "1.0.0" } : { ...pkg }; + if (pkg.name === "private-dependent") { + expected.dependencies = { "public-beta": "workspace:^1.0.0" }; + } + expect(actual, pkg.name).toEqual(expected); + const changelogPath = join(root, "packages", pkg.name, "CHANGELOG.md"); + if (shouldGraduate) { + expect(readFileSync(changelogPath, "utf8")).toContain("## 1.0.0"); + } else if (pkg.name === "private-stable") { + expect(readFileSync(changelogPath, "utf8")).toBe("# Existing private changelog\n"); + } else { + expect(existsSync(changelogPath), pkg.name).toBe(false); + } + } + expect(existsSync(join(root, ".changeset/pre.json"))).toBe(false); + expect(existsSync(changesetPath)).toBe(false); + + writeFileSync(changesetPath, '---\n"public-beta": patch\n---\n\nNext stable release.\n'); + await versionPackages(root); + expect( + JSON.parse(readFileSync(join(root, "packages/public-beta/package.json"), "utf8")).version, + ).toBe("1.0.1"); + expect( + JSON.parse(readFileSync(join(root, "packages/private-dependent/package.json"), "utf8")), + ).toEqual({ + name: "private-dependent", + private: true, + dependencies: { "public-beta": "workspace:^1.0.1" }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, +); diff --git a/scripts/version.mts b/scripts/version.mts index 14c1ba17c6..d64a3e3d90 100644 --- a/scripts/version.mts +++ b/scripts/version.mts @@ -1,12 +1,12 @@ #!/usr/bin/env node /** - * `changeset version` + a grouped, conventional-commits changelog with a bottom - * `## Contributors` list. Used as the `version:` command for `changesets/action` - * (see .github/workflows/release.yml). + * Apply the Changesets release plan + a grouped, conventional-commits changelog + * with a bottom `## Contributors` list. Used as the `version:` command for + * `changesets/action` (see .github/workflows/release.yml). * * Changesets' default changelog groups by bump level (Minor/Patch Changes), not - * by commit type, and has no end-of-release contributor hook. So after running - * `changeset version` we rewrite each bumped package's newest CHANGELOG section: + * by commit type, and has no end-of-release contributor hook. After applying the + * release plan we rewrite each bumped package's newest CHANGELOG section: * the release commits are regrouped into `### Features` / `### Bug Fixes` / etc. * and a deduped, bot-filtered `## Contributors` list is appended. The pure * builders (groupedChangelogBody, rewriteReleaseSection, dedupeSortLogins) are @@ -18,6 +18,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -232,19 +233,56 @@ function resolveContributors(from: string, repository: string, commits: Commit[] } } -function main(): void { +/** Apply only eligible releases, while retaining dependency-only updates. */ +export async function versionPackages(root: string): Promise { + // Use the APIs from the pinned CLI's dependency tree, without a second toolchain. + const require = createRequire(import.meta.resolve("@changesets/cli/package.json")); + const { getPackages } = require("@manypkg/get-packages"); + const { read: readConfig } = require("@changesets/config"); + const { default: getReleasePlan } = require("@changesets/get-release-plan"); + const { default: applyReleasePlan } = require("@changesets/apply-release-plan"); + const { shouldSkipPackage } = require("@changesets/should-skip-package"); + const packages = await getPackages(root); + const config = await readConfig(root, packages); + const plan = await getReleasePlan(root, undefined, config); + const skippedNames = new Set(); + for (const pkg of packages.packages) { + if ( + shouldSkipPackage(pkg, { + ignore: config.ignore, + allowPrivatePackages: config.privatePackages.version, + }) + ) { + skippedNames.add(pkg.packageJson.name); + } + } + // Prerelease exit can reintroduce skipped packages as patch releases: + // https://github.com/changesets/changesets/issues/2024 + plan.releases = plan.releases.filter( + (release: { name: string; type: string }) => + release.type === "none" || !skippedNames.has(release.name), + ); + await applyReleasePlan( + plan, + packages, + config, + undefined, + dirname(require.resolve("@changesets/cli")), + ); +} + +async function main(): Promise { const repository = process.env.GITHUB_REPOSITORY || ""; const packages = discoverPublishablePackages(); // Load the same SHA-named changeset overrides the generator uses, so a commit // reclassified there (e.g. feat → fix) lands in the matching changelog section. - // Must happen BEFORE `changeset version` below, which consumes and deletes the + // Must happen BEFORE applying the plan below, which consumes and deletes the // `.changeset/.md` files; the grouping later reads this in-memory copy. const overrides = loadOverrides(); const before = readVersions(packages); - console.log("[version] Running `changeset version`..."); - // `vp exec` runs the pinned, installed @changesets/cli — not a floating `dlx` fetch. - execFileSync("vp", ["exec", "changeset", "version"], { cwd: REPO_ROOT, stdio: "inherit" }); + console.log("[version] Applying the Changesets release plan..."); + await versionPackages(REPO_ROOT); const after = readVersions(packages); @@ -274,4 +312,4 @@ function main(): void { } } -if (import.meta.url === `file://${process.argv[1]}`) main(); +if (import.meta.url === `file://${process.argv[1]}`) await main(); From d92442fa342d7f791906981e1c28a080f728b3dd Mon Sep 17 00:00:00 2001 From: James Date: Mon, 28 Sep 2026 10:00:55 +0100 Subject: [PATCH 2/2] fix(release): preserve normalized skip-policy validation --- knip.ts | 7 +++++ scripts/changeset-prerelease.test.ts | 47 ++++++++++++++++++++++++++++ scripts/version.mts | 9 ++++-- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/knip.ts b/knip.ts index 10ecf13d9c..68a3dd0f95 100644 --- a/knip.ts +++ b/knip.ts @@ -53,6 +53,13 @@ export default { "oxc-transform-react", // OG tests resolve these from vinext's @vercel/og install, not the root workspace. "@vercel/og", + // scripts/version.mts resolves release APIs from the pinned Changesets CLI, + // keeping its dependency tree authoritative instead of installing separate copies. + "@manypkg/get-packages", + "@changesets/config", + "@changesets/get-release-plan", + "@changesets/apply-release-plan", + "@changesets/should-skip-package", ], }, "packages/vinext": { diff --git a/scripts/changeset-prerelease.test.ts b/scripts/changeset-prerelease.test.ts index 12a75c71d9..1b8db8781f 100644 --- a/scripts/changeset-prerelease.test.ts +++ b/scripts/changeset-prerelease.test.ts @@ -98,3 +98,50 @@ it.each([false, true])( } }, ); + +it.each(["dependencies", "devDependencies"])( + "validates expanded ignore globs for %s before writing releases", + async (dependencyType) => { + const root = mkdtempSync(join(tmpdir(), "vinext-changeset-ignore-")); + const pkg = { + name: "public-package", + version: "1.0.0", + [dependencyType]: { "ignored-package": "workspace:^1.0.0" }, + }; + try { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "test-workspace", private: true, workspaces: ["packages/*"] }), + ); + mkdirSync(join(root, ".changeset")); + writeFileSync( + join(root, ".changeset/config.json"), + JSON.stringify({ changelog: false, ignore: ["ignored-*"] }), + ); + const changesetPath = join(root, ".changeset/public-package.md"); + writeFileSync(changesetPath, '---\n"public-package": patch\n---\n\nPublic package fix.\n'); + for (const manifest of [pkg, { name: "ignored-package", version: "1.0.0" }]) { + const dir = join(root, "packages", manifest.name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify(manifest)); + } + + const manifestPath = join(root, "packages/public-package/package.json"); + if (dependencyType === "dependencies") { + await expect(versionPackages(root)).rejects.toThrow( + '"public-package" depends on the skipped package "ignored-package"', + ); + expect(JSON.parse(readFileSync(manifestPath, "utf8"))).toEqual(pkg); + expect(existsSync(changesetPath)).toBe(true); + } else { + await versionPackages(root); + expect(JSON.parse(readFileSync(manifestPath, "utf8"))).toEqual({ + ...pkg, + version: "1.0.1", + }); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, +); diff --git a/scripts/version.mts b/scripts/version.mts index d64a3e3d90..b106ff7039 100644 --- a/scripts/version.mts +++ b/scripts/version.mts @@ -238,12 +238,17 @@ export async function versionPackages(root: string): Promise { // Use the APIs from the pinned CLI's dependency tree, without a second toolchain. const require = createRequire(import.meta.resolve("@changesets/cli/package.json")); const { getPackages } = require("@manypkg/get-packages"); - const { read: readConfig } = require("@changesets/config"); + const { parse: parseConfig } = require("@changesets/config"); const { default: getReleasePlan } = require("@changesets/get-release-plan"); const { default: applyReleasePlan } = require("@changesets/apply-release-plan"); const { shouldSkipPackage } = require("@changesets/should-skip-package"); const packages = await getPackages(root); - const config = await readConfig(root, packages); + const writtenConfig = JSON.parse( + readFileSync(join(packages.root.dir, ".changeset/config.json"), "utf8"), + ); + const config = parseConfig(writtenConfig, packages); + // Match the CLI's second dependency-policy validation after expanding ignore globs. + parseConfig({ ...writtenConfig, ignore: config.ignore }, packages); const plan = await getReleasePlan(root, undefined, config); const skippedNames = new Set(); for (const pkg of packages.packages) {