From 9d00662e10ffc46a8dce93241bd621b76f485c45 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 29 Sep 2025 16:21:44 +0200 Subject: [PATCH] Show version mismatch when running upgrade tool (#19028) This PR fixes an issue where sometimes people try to run the upgrade tool, reset the changes and then try again. If this happens, then the `package.json` and/or your lock file will point to the old Tailwind CSS v3 version, but the actual installed version will be v4. This will also cause the upgrade tool to now upgrade from v4 to v4, which is not what most people want if they were trying to upgrade from v3 to v4. This in turn will cause some issues because now we won't try to migrate the config file, or v3-specific classes that also exist in v4 but are only safe to upgrade from v3 to v4. This PR uses `npm ls tailwindcss` to determine the actual installed version. This command already errors if there is a mismatch between the installed version and the version in `package.json` or the lock file. This also happens to work in pnpm and bun projects (added integration tests for these). If for whatever reason we can't determine the expected version, we fall back to the old behavior of just upgrading. In this scenario, the changes introduced in https://github.com/tailwindlabs/tailwindcss/pull/19026 will at least give you a hint of what version was actually installed. ### Test plan 1. Tested it in a v3 project where I performed the following steps: 1. Run the upgrade tool in full (`npx tailwindcss-upgrade`) 2. Reset the changes (`git reset --hard && git clean -df`) 1. Run the upgrade tool again This resulted in the following output: image 2. Added some integration tests to make sure this also works in pnpm, bun and normal npm projects. [ci-all] --- .github/workflows/integration-tests.yml | 4 + CHANGELOG.md | 1 + integrations/upgrade/upgrade-errors.test.ts | 227 ++++++++++++++++++ packages/@tailwindcss-upgrade/src/index.ts | 22 ++ .../src/utils/packages.ts | 3 + .../src/utils/renderer.ts | 2 +- .../@tailwindcss-upgrade/src/utils/version.ts | 32 +++ 7 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 integrations/upgrade/upgrade-errors.test.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 0148dd983..b84ba2140 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -53,6 +53,10 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 + - run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c3ce090..2740ac27b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Detect classes in markdown inline directives ([#18967](https://github.com/tailwindlabs/tailwindcss/pull/18967)) - Ensure files with only `@theme` produce no output when built ([#18979](https://github.com/tailwindlabs/tailwindcss/pull/18979)) - Support Maud templates when extracting classes ([#18988](https://github.com/tailwindlabs/tailwindcss/pull/18988)) +- Show version mismatch (if any) when running upgrade tool ([#19028](https://github.com/tailwindlabs/tailwindcss/pull/19028)) ## [4.1.13] - 2025-09-03 diff --git a/integrations/upgrade/upgrade-errors.test.ts b/integrations/upgrade/upgrade-errors.test.ts new file mode 100644 index 000000000..13e37aa99 --- /dev/null +++ b/integrations/upgrade/upgrade-errors.test.ts @@ -0,0 +1,227 @@ +import { stripVTControlCharacters } from 'node:util' +// @ts-expect-error This path does exist +import { version } from '../../packages/tailwindcss/package.json' +import { css, html, js, json, test } from '../utils' + +test( + 'upgrades half-upgraded v3 project to v4 (pnpm)', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "^3", + "@tailwindcss/upgrade": "workspace:^" + }, + "devDependencies": { + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'tailwind.config.js': js` + /** @type {import('tailwindcss').Config} */ + module.exports = { + content: ['./src/**/*.{html,js}'], + } + `, + 'src/index.html': html` +
Hello World
+ `, + 'src/input.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + }, + }, + async ({ exec, expect }) => { + // Ensure we are in a git repo + await exec('git init') + await exec('git add --all') + await exec('git commit -m "before migration"') + + // Fully upgrade to v4 + await exec('npx @tailwindcss/upgrade') + + // Undo all changes to the current repo. This will bring the repo back to a + // v3 state, but the `node_modules` will now have v4 installed. + await exec('git reset --hard HEAD') + + // Re-running the upgrade should result in an error + return expect(() => { + return exec('npx @tailwindcss/upgrade', {}, { ignoreStdErr: true }).catch((e) => { + // Replacing the current version with a hardcoded `v4` to make it stable + // when we release new minor/patch versions. + return Promise.reject(stripVTControlCharacters(e.message.replaceAll(version, '4.0.0'))) + }) + }).rejects.toThrowErrorMatchingInlineSnapshot(` + "Command failed: npx @tailwindcss/upgrade + ≈ tailwindcss v4.0.0 + + │ ↳ Upgrading from Tailwind CSS \`v4.0.0\` + + │ ↳ Version mismatch + │ + │ \`\`\`diff + │ - "tailwindcss": "^3" (expected version in package.json / lockfile) + │ + "tailwindcss": "4.0.0" (installed version in \`node_modules\`) + │ \`\`\` + │ + │ Make sure to run \`pnpm install\`, and try again. + + " + `) + }, +) + +test( + 'upgrades half-upgraded v3 project to v4 (bun)', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "^3", + "@tailwindcss/upgrade": "workspace:^" + }, + "devDependencies": { + "@tailwindcss/cli": "workspace:^", + "bun": "^1.0.0" + } + } + `, + 'tailwind.config.js': js` + /** @type {import('tailwindcss').Config} */ + module.exports = { + content: ['./src/**/*.{html,js}'], + } + `, + 'src/index.html': html` +
Hello World
+ `, + 'src/input.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + }, + }, + async ({ exec, expect }) => { + // Use `bun` to install dependencies + await exec('rm ./pnpm-lock.yaml') + await exec('npx bun install') + + // Ensure we are in a git repo + await exec('git init') + await exec('git add --all') + await exec('git commit -m "before migration"') + + // Fully upgrade to v4 + await exec('npx @tailwindcss/upgrade') + + // Undo all changes to the current repo. This will bring the repo back to a + // v3 state, but the `node_modules` will now have v4 installed. + await exec('git reset --hard HEAD') + + // Re-running the upgrade should result in an error + return expect(() => { + return exec('npx @tailwindcss/upgrade', {}, { ignoreStdErr: true }).catch((e) => { + // Replacing the current version with a hardcoded `v4` to make it stable + // when we release new minor/patch versions. + return Promise.reject(stripVTControlCharacters(e.message.replaceAll(version, '4.0.0'))) + }) + }).rejects.toThrowErrorMatchingInlineSnapshot(` + "Command failed: npx @tailwindcss/upgrade + ≈ tailwindcss v4.0.0 + + │ ↳ Upgrading from Tailwind CSS \`v4.0.0\` + + │ ↳ Version mismatch + │ + │ \`\`\`diff + │ - "tailwindcss": "^3" (expected version in package.json / lockfile) + │ + "tailwindcss": "4.0.0" (installed version in \`node_modules\`) + │ \`\`\` + │ + │ Make sure to run \`bun install\`, and try again. + + " + `) + }, +) + +test( + 'upgrades half-upgraded v3 project to v4 (npm)', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "^3", + "@tailwindcss/upgrade": "workspace:^" + }, + "devDependencies": { + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'tailwind.config.js': js` + /** @type {import('tailwindcss').Config} */ + module.exports = { + content: ['./src/**/*.{html,js}'], + } + `, + 'src/index.html': html` +
Hello World
+ `, + 'src/input.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + }, + }, + async ({ exec, expect }) => { + // Use `bun` to install dependencies + await exec('rm ./pnpm-lock.yaml') + await exec('rm -rf ./node_modules') + await exec('npm install') + + // Ensure we are in a git repo + await exec('git init') + await exec('git add --all') + await exec('git commit -m "before migration"') + + // Fully upgrade to v4 + await exec('npx @tailwindcss/upgrade') + + // Undo all changes to the current repo. This will bring the repo back to a + // v3 state, but the `node_modules` will now have v4 installed. + await exec('git reset --hard HEAD') + + // Re-running the upgrade should result in an error + return expect(() => { + return exec('npx @tailwindcss/upgrade', {}, { ignoreStdErr: true }).catch((e) => { + // Replacing the current version with a hardcoded `v4` to make it stable + // when we release new minor/patch versions. + return Promise.reject(stripVTControlCharacters(e.message.replaceAll(version, '4.0.0'))) + }) + }).rejects.toThrowErrorMatchingInlineSnapshot(` + "Command failed: npx @tailwindcss/upgrade + ≈ tailwindcss v4.0.0 + + │ ↳ Upgrading from Tailwind CSS \`v4.0.0\` + + │ ↳ Version mismatch + │ + │ \`\`\`diff + │ - "tailwindcss": "^3" (expected version in package.json / lockfile) + │ + "tailwindcss": "4.0.0" (installed version in \`node_modules\`) + │ \`\`\` + │ + │ Make sure to run \`npm install\`, and try again. + + " + `) + }, +) diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 4c5033084..5cfd4acb4 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -4,6 +4,7 @@ import { Scanner } from '@tailwindcss/oxide' import { globby } from 'globby' import fs from 'node:fs/promises' import path from 'node:path' +import pc from 'picocolors' import postcss from 'postcss' import { migrateJsConfig } from './codemods/config/migrate-js-config' import { migratePostCSSConfig } from './codemods/config/migrate-postcss' @@ -62,6 +63,27 @@ async function run() { prefix: '↳ ', }) + if (version.installedTailwindVersion(base) !== version.expectedTailwindVersion(base)) { + let pkgManager = await pkg(base).manager() + + error( + [ + 'Version mismatch', + '', + pc.dim('```diff'), + `${pc.red('-')} ${`${pc.dim('"tailwindcss":')} ${`${pc.dim('"')}${pc.blue(version.expectedTailwindVersion(base))}${pc.dim('"')}`}`} (expected version in package.json / lockfile)`, + `${pc.green('+')} ${`${pc.dim('"tailwindcss":')} ${`${pc.dim('"')}${pc.blue(version.installedTailwindVersion(base))}${pc.dim('"')}`}`} (installed version in \`node_modules\`)`, + pc.dim('```'), + '', + `Make sure to run ${highlight(`${pkgManager} install`)}, and try again.`, + ].join('\n'), + { + prefix: '↳ ', + }, + ) + process.exit(1) + } + { // Stylesheet migrations diff --git a/packages/@tailwindcss-upgrade/src/utils/packages.ts b/packages/@tailwindcss-upgrade/src/utils/packages.ts index 65f823bec..461a6983a 100644 --- a/packages/@tailwindcss-upgrade/src/utils/packages.ts +++ b/packages/@tailwindcss-upgrade/src/utils/packages.ts @@ -24,6 +24,9 @@ const manifests = new DefaultMap((base) => { export function pkg(base: string) { return { + async manager() { + return await packageManagerForBase.get(base) + }, async add(packages: string[], location: 'dependencies' | 'devDependencies' = 'dependencies') { let packageManager = await packageManagerForBase.get(base) let args = packages.slice() diff --git a/packages/@tailwindcss-upgrade/src/utils/renderer.ts b/packages/@tailwindcss-upgrade/src/utils/renderer.ts index 38bd9826a..c9a967801 100644 --- a/packages/@tailwindcss-upgrade/src/utils/renderer.ts +++ b/packages/@tailwindcss-upgrade/src/utils/renderer.ts @@ -44,7 +44,7 @@ export function wordWrap(text: string, width: number): string[] { // Handle text with newlines by maintaining the newlines, then splitting // each line separately. if (text.includes('\n')) { - return text.split('\n').flatMap((line) => wordWrap(line, width)) + return text.split('\n').flatMap((line) => (line ? wordWrap(line, width) : [''])) } let words = text.split(' ') diff --git a/packages/@tailwindcss-upgrade/src/utils/version.ts b/packages/@tailwindcss-upgrade/src/utils/version.ts index 6dab20a35..27bc44f2b 100644 --- a/packages/@tailwindcss-upgrade/src/utils/version.ts +++ b/packages/@tailwindcss-upgrade/src/utils/version.ts @@ -1,3 +1,4 @@ +import { execSync } from 'node:child_process' import semver from 'semver' import { DefaultMap } from '../../../tailwindcss/src/utils/default-map' import { getPackageVersionSync } from './package-version' @@ -29,3 +30,34 @@ let cache = new DefaultMap((base) => { export function installedTailwindVersion(base = process.cwd()): string { return cache.get(base) } + +let expectedCache = new DefaultMap((base) => { + try { + // This will report a problem if the package.json/package-lock.json + // mismatches with the installed version in node_modules. + // + // Also tested this with Bun and PNPM, both seem to work fine. + execSync('npm ls tailwindcss --json', { cwd: base, stdio: 'pipe' }) + return installedTailwindVersion(base) + } catch (_e) { + try { + let e = _e as { stdout: Buffer } + let data = JSON.parse(e.stdout.toString()) + + return ( + // Could be a sub-dependency issue, but we are only interested in + // the top-level version mismatch. + /"(.*?)" from the root project/.exec(data.dependencies.tailwindcss.invalid)?.[1] ?? + // Fallback to the installed version + installedTailwindVersion(base) + ) + } catch { + // We don't know how to verify, so let's just return the installed + // version to not block the user. + return installedTailwindVersion(base) + } + } +}) +export function expectedTailwindVersion(base = process.cwd()): string { + return expectedCache.get(base) +}