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: <img width="1059" height="683"
alt="image"
src="https://github.com/user-attachments/assets/1d2ea2d1-b602-4631-958f-cc21eb8a633f"
/>


2. Added some integration tests to make sure this also works in pnpm,
bun and normal npm projects.

[ci-all]
This commit is contained in:
Robin Malfait 2025-09-29 16:21:44 +02:00 • committed by GitHub
parent b497e1eaf3
commit 9d00662e10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 290 additions and 1 deletions

View file

@ -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:

View file

@ -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

View file

@ -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`
<div class="!flex">Hello World</div>
`,
'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`
<div class="!flex">Hello World</div>
`,
'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`
<div class="!flex">Hello World</div>
`,
'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.
"
`)
},
)

View file

@ -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

View file

@ -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()

View file

@ -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(' ')

View file

@ -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)
}