From cabe120ba36116d0dfc2299990707f6163585d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 22 Jul 2026 11:24:16 +0200 Subject: [PATCH 1/4] Validate that projects use Changesets CLI v3 and direct Changesets CLI v2 users to `changesets/action@v1` --- .changeset/bright-lions-check.md | 5 +++ src/index.ts | 2 ++ src/pack/index.ts | 3 ++ src/publish/index.ts | 2 ++ src/select-mode/index.ts | 6 ++-- src/utils.test.ts | 54 +++++++++++++++++++++++++++++++- src/utils.ts | 51 ++++++++++++++++++++++++++++++ src/version/index.ts | 7 ++++- 8 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 .changeset/bright-lions-check.md diff --git a/.changeset/bright-lions-check.md b/.changeset/bright-lions-check.md new file mode 100644 index 00000000..75787f18 --- /dev/null +++ b/.changeset/bright-lions-check.md @@ -0,0 +1,5 @@ +--- +"@changesets/action": patch +--- + +Validate that projects use Changesets CLI v3 and direct Changesets CLI v2 users to `changesets/action@v1`. diff --git a/src/index.ts b/src/index.ts index cde1ebfa..6770a36a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { getOptionalInput, getRequiredInput, throwOnRenamedInputs, + validateChangesetsCliVersion, } from "./utils.ts"; (async () => { @@ -34,6 +35,7 @@ import { // If the user needs to change the cwd, set `working-directory` in the step instead const cwd = process.cwd(); + await validateChangesetsCliVersion(cwd); const commitMode = getOptionalInput("commit-mode") ?? "git-cli"; const prDraft = getOptionalInput("pr-draft"); diff --git a/src/pack/index.ts b/src/pack/index.ts index f52f5e8b..3c67d145 100644 --- a/src/pack/index.ts +++ b/src/pack/index.ts @@ -7,6 +7,7 @@ import { downloadArtifact, execChangesetsCli, getOptionalInput, + validateChangesetsCliVersion, } from "../utils.ts"; try { @@ -20,6 +21,8 @@ async function main() { // If the user needs to change the cwd, set `working-directory` in the step instead const cwd = process.cwd(); + await validateChangesetsCliVersion(cwd); + const tmpDir = process.env.RUNNER_TEMP ?? (await fs.realpath(os.tmpdir())); const outDir = path.join(tmpDir, `changeset-pack-${Date.now()}`); diff --git a/src/publish/index.ts b/src/publish/index.ts index 15bf8ab3..61fce756 100644 --- a/src/publish/index.ts +++ b/src/publish/index.ts @@ -7,6 +7,7 @@ import { downloadArtifact, getOptionalInput, getRequiredInput, + validateChangesetsCliVersion, } from "../utils.ts"; try { @@ -32,6 +33,7 @@ async function main() { // If the user needs to change the cwd, set `working-directory` in the step instead const cwd = process.cwd(); + await validateChangesetsCliVersion(cwd); // NOTE: Always use API mode here as publish does not need a commit-mode. const github = new GitHub({ cwd, githubToken, commitMode: "github-api" }); diff --git a/src/select-mode/index.ts b/src/select-mode/index.ts index 00e191d4..fc309a9d 100644 --- a/src/select-mode/index.ts +++ b/src/select-mode/index.ts @@ -4,7 +4,7 @@ import path from "node:path"; import artifact from "@actions/artifact"; import * as core from "@actions/core"; import readChangesetState from "../readChangesetState.ts"; -import { execChangesetsCli } from "../utils.ts"; +import { execChangesetsCli, validateChangesetsCliVersion } from "../utils.ts"; type ModeResult = | { @@ -49,6 +49,9 @@ async function main() { } async function getMode(): Promise { + const cwd = process.cwd(); + await validateChangesetsCliVersion(cwd); + const { changesets } = await readChangesetState(); if (changesets.length > 0) { @@ -61,7 +64,6 @@ async function getMode(): Promise { return { mode: "none" }; } - const cwd = process.cwd(); const publishPlanPath = path.join( process.env.RUNNER_TEMP ?? (await fs.realpath(os.tmpdir())), `changeset-publish-plan-${Date.now()}`, diff --git a/src/utils.test.ts b/src/utils.test.ts index dfa2880c..a2326f31 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,5 +1,11 @@ +import { createFixture } from "fs-fixture"; import { expect, test } from "vitest"; -import { BumpLevels, getChangelogEntry, sortTheThings } from "./utils.ts"; +import { + BumpLevels, + getChangelogEntry, + sortTheThings, + validateChangesetsCliVersion, +} from "./utils.ts"; let changelog = `# @keystone-alpha/email @@ -100,3 +106,49 @@ test("it sorts the things right", () => { ]; expect(things.sort(sortTheThings)).toMatchSnapshot(); }); + +test("throws when the project declares Changesets CLI v2", async () => { + await using fixture = await createFixture({ + "package.json": JSON.stringify({ + name: "project", + devDependencies: { "@changesets/cli": "^2.29.7" }, + }), + "package-lock.json": "", + }); + + await expect(validateChangesetsCliVersion(fixture.path)).rejects.toThrow( + "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.", + ); +}); + +test("accepts a Changesets CLI v3 contract without an installed CLI", async () => { + await using fixture = await createFixture({ + "package.json": JSON.stringify({ + name: "project", + devDependencies: { "@changesets/cli": "^3.0.0" }, + }), + "package-lock.json": "", + }); + + await expect( + validateChangesetsCliVersion(fixture.path), + ).resolves.toBeUndefined(); +}); + +test("throws when the project has Changesets CLI v2 installed", async () => { + await using fixture = await createFixture({ + "package.json": JSON.stringify({ + name: "project", + devDependencies: { "@changesets/cli": "^3.0.0" }, + }), + "package-lock.json": "", + "node_modules/@changesets/cli/package.json": JSON.stringify({ + name: "@changesets/cli", + version: "2.29.7", + }), + }); + + await expect(validateChangesetsCliVersion(fixture.path)).rejects.toThrow( + "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.", + ); +}); diff --git a/src/utils.ts b/src/utils.ts index 4ad5268c..9ff842c7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,6 +10,8 @@ import { type ExecOutput, } from "@actions/exec"; import { getPackages, type Package } from "@manypkg/get-packages"; +import major from "semver/functions/major.js"; +import subset from "semver/ranges/subset.js"; const require = createRequire(import.meta.url); @@ -154,6 +156,55 @@ export function throwOnRenamedInputs(renames: Record) { } } +const changesetsCliCompatibilityError = + "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."; + +export async function validateChangesetsCliVersion(cwd: string) { + const { rootPackage } = await getPackages(cwd); + const packageJson = rootPackage?.packageJson; + const declaredVersion = + packageJson?.devDependencies?.["@changesets/cli"] ?? + packageJson?.dependencies?.["@changesets/cli"]; + + if (typeof declaredVersion === "string") { + const range = declaredVersion.startsWith("workspace:") + ? declaredVersion.slice("workspace:".length) + : declaredVersion; + + let isV2 = false; + + try { + isV2 = subset(range, ">=2.0.0-0 <3.0.0-0", { + includePrerelease: true, + }); + } catch { + // it could be a non-semver protocol + } + + if (isV2) { + throw new Error(changesetsCliCompatibilityError); + } + } + + let cliPackageJson; + + try { + cliPackageJson = require( + require.resolve("@changesets/cli/package.json", { paths: [cwd] }), + ); + } catch { + return; + } + + if ( + typeof cliPackageJson.version === "string" && + major(cliPackageJson.version) === 2 + ) { + throw new Error(changesetsCliCompatibilityError); + } +} + function resolveChangesetsCli(cwd: string) { return require.resolve("@changesets/cli/bin.js", { paths: [cwd], diff --git a/src/version/index.ts b/src/version/index.ts index d9498e1f..08ceeb7b 100644 --- a/src/version/index.ts +++ b/src/version/index.ts @@ -1,7 +1,11 @@ import * as core from "@actions/core"; import { GitHub } from "../github.ts"; import { runVersion } from "../run.ts"; -import { getOptionalInput, getRequiredInput } from "../utils.ts"; +import { + getOptionalInput, + getRequiredInput, + validateChangesetsCliVersion, +} from "../utils.ts"; try { await main(); @@ -29,6 +33,7 @@ async function main() { // If the user needs to change the cwd, set `working-directory` in the step instead const cwd = process.cwd(); + await validateChangesetsCliVersion(cwd); const github = new GitHub({ cwd, From 5a747ed99088ef9f5d3cb08b8ab35c3ab7c5501c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 22 Jul 2026 11:27:14 +0200 Subject: [PATCH 2/4] better spy/mocking --- src/run.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/run.test.ts b/src/run.test.ts index 180df1e4..c50d8312 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -21,10 +21,8 @@ vi.mock("@actions/github", () => ({ graphql: mockedGraphql, }), })); -vi.mock("@actions/core", async (importOriginal) => ({ - ...(await importOriginal()), - warning: vi.fn(), -})); +vi.mock("@actions/core", { spy: true }) +const mockedWarning = vi.mocked(core.warning); vi.mock("@changesets/ghcommit"); let mockedGithubMethods = { @@ -126,7 +124,7 @@ describe("publish", () => { }); expect(result).toEqual({ published: false, exitCode: 0 }); - expect(core.warning).toHaveBeenCalledWith( + expect(mockedWarning).toHaveBeenCalledWith( expect.stringContaining( "GitHub releases and git tags cannot be created without this output", ), From aae0ea997b386098a6b6a1d7c266dfa00d1fde49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 22 Jul 2026 11:32:42 +0200 Subject: [PATCH 3/4] fmt --- src/run.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/run.test.ts b/src/run.test.ts index c50d8312..1dce7a0b 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -21,7 +21,7 @@ vi.mock("@actions/github", () => ({ graphql: mockedGraphql, }), })); -vi.mock("@actions/core", { spy: true }) +vi.mock("@actions/core", { spy: true }); const mockedWarning = vi.mocked(core.warning); vi.mock("@changesets/ghcommit"); From 09fa2584143b13b758ebc5fc7bea240e62731a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 23 Jul 2026 11:23:23 +0200 Subject: [PATCH 4/4] shuffle --- src/index.ts | 8 ++++---- src/pack/index.ts | 4 ++-- src/publish/index.ts | 8 ++++---- src/select-mode/index.ts | 12 ++++++------ src/version/index.ts | 8 ++++---- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6770a36a..83b3dab1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,10 @@ import { } from "./utils.ts"; (async () => { + // If the user needs to change the cwd, set `working-directory` in the step instead + const cwd = process.cwd(); + await validateChangesetsCliVersion(cwd); + throwOnRenamedInputs({ publish: "publish-script", version: "version-script", @@ -33,10 +37,6 @@ import { ); } - // If the user needs to change the cwd, set `working-directory` in the step instead - const cwd = process.cwd(); - await validateChangesetsCliVersion(cwd); - const commitMode = getOptionalInput("commit-mode") ?? "git-cli"; const prDraft = getOptionalInput("pr-draft"); if (commitMode !== "git-cli" && commitMode !== "github-api") { diff --git a/src/pack/index.ts b/src/pack/index.ts index 3c67d145..ae4ed1be 100644 --- a/src/pack/index.ts +++ b/src/pack/index.ts @@ -17,12 +17,12 @@ try { } async function main() { - const publishPlanArtifactId = getOptionalInput("publish-plan-artifact-id"); - // If the user needs to change the cwd, set `working-directory` in the step instead const cwd = process.cwd(); await validateChangesetsCliVersion(cwd); + const publishPlanArtifactId = getOptionalInput("publish-plan-artifact-id"); + const tmpDir = process.env.RUNNER_TEMP ?? (await fs.realpath(os.tmpdir())); const outDir = path.join(tmpDir, `changeset-pack-${Date.now()}`); diff --git a/src/publish/index.ts b/src/publish/index.ts index 61fce756..925489a2 100644 --- a/src/publish/index.ts +++ b/src/publish/index.ts @@ -17,6 +17,10 @@ try { } async function main() { + // If the user needs to change the cwd, set `working-directory` in the step instead + const cwd = process.cwd(); + await validateChangesetsCliVersion(cwd); + const githubToken = getRequiredInput("github-token"); const script = getOptionalInput("script"); const packDirArtifactId = getOptionalInput("pack-dir-artifact-id"); @@ -31,10 +35,6 @@ async function main() { ); } - // If the user needs to change the cwd, set `working-directory` in the step instead - const cwd = process.cwd(); - await validateChangesetsCliVersion(cwd); - // NOTE: Always use API mode here as publish does not need a commit-mode. const github = new GitHub({ cwd, githubToken, commitMode: "github-api" }); diff --git a/src/select-mode/index.ts b/src/select-mode/index.ts index fc309a9d..396aa2ea 100644 --- a/src/select-mode/index.ts +++ b/src/select-mode/index.ts @@ -27,7 +27,10 @@ try { } async function main() { - const result = await getMode(); + const cwd = process.cwd(); + await validateChangesetsCliVersion(cwd); + + const result = await getMode(cwd); core.setOutput("mode", result.mode); if (result.mode === "publish") { const publishPlanArtifact = await artifact.uploadArtifact( @@ -48,11 +51,8 @@ async function main() { } } -async function getMode(): Promise { - const cwd = process.cwd(); - await validateChangesetsCliVersion(cwd); - - const { changesets } = await readChangesetState(); +async function getMode(cwd: string): Promise { + const { changesets } = await readChangesetState(cwd); if (changesets.length > 0) { const hasNonEmptyChangesets = changesets.some( diff --git a/src/version/index.ts b/src/version/index.ts index 08ceeb7b..78bf653a 100644 --- a/src/version/index.ts +++ b/src/version/index.ts @@ -14,6 +14,10 @@ try { } async function main() { + // If the user needs to change the cwd, set `working-directory` in the step instead + const cwd = process.cwd(); + await validateChangesetsCliVersion(cwd); + const githubToken = getRequiredInput("github-token"); const script = getOptionalInput("script"); const commitMessage = getRequiredInput("commit-message"); @@ -31,10 +35,6 @@ async function main() { throw new Error(`Invalid commit-mode input: ${commitMode}`); } - // If the user needs to change the cwd, set `working-directory` in the step instead - const cwd = process.cwd(); - await validateChangesetsCliVersion(cwd); - const github = new GitHub({ cwd, githubToken,