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..83b3dab1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,9 +8,14 @@ import { getOptionalInput, getRequiredInput, throwOnRenamedInputs, + validateChangesetsCliVersion, } 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", @@ -32,9 +37,6 @@ import { ); } - // If the user needs to change the cwd, set `working-directory` in the step instead - const cwd = process.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 f52f5e8b..ae4ed1be 100644 --- a/src/pack/index.ts +++ b/src/pack/index.ts @@ -7,6 +7,7 @@ import { downloadArtifact, execChangesetsCli, getOptionalInput, + validateChangesetsCliVersion, } from "../utils.ts"; try { @@ -16,10 +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 15bf8ab3..925489a2 100644 --- a/src/publish/index.ts +++ b/src/publish/index.ts @@ -7,6 +7,7 @@ import { downloadArtifact, getOptionalInput, getRequiredInput, + validateChangesetsCliVersion, } from "../utils.ts"; try { @@ -16,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"); @@ -30,9 +35,6 @@ async function main() { ); } - // If the user needs to change the cwd, set `working-directory` in the step instead - const cwd = process.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/run.test.ts b/src/run.test.ts index 180df1e4..1dce7a0b 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", ), diff --git a/src/select-mode/index.ts b/src/select-mode/index.ts index 00e191d4..396aa2ea 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 = | { @@ -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,8 +51,8 @@ async function main() { } } -async function getMode(): Promise { - const { changesets } = await readChangesetState(); +async function getMode(cwd: string): Promise { + const { changesets } = await readChangesetState(cwd); if (changesets.length > 0) { const hasNonEmptyChangesets = changesets.some( @@ -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..78bf653a 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(); @@ -10,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"); @@ -27,9 +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(); - const github = new GitHub({ cwd, githubToken,