diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 0148dd9836b4..b84ba214049c 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 e8c3ce0903ba..2740ac27b481 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 000000000000..13e37aa99101
--- /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 4c50330846cf..5cfd4acb4398 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 65f823bec7e0..461a6983a86d 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 38bd9826a0ab..c9a9678014d7 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 6dab20a3546e..27bc44f2ba1f 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)
+}