From 357221d101295264c4e31304e2b56f4f3ed328d7 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 27 Aug 2025 19:04:05 +0200 Subject: [PATCH 1/8] ensure `\n\n` are kept as `\n\n` When splitting `'foo\n\nbar'` by `\n`, you will get `['foo', '', 'bar']`. The `''` value will result in `[]` after the word wrapping. This information gets lost when we `flatMap`, so let's keep the newline using `['']` as the fallback. --- packages/@tailwindcss-upgrade/src/utils/renderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(' ') From ab7dda11c8a30dc4ebc7c65f71a6d08efeb9579a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 29 Sep 2025 12:37:25 +0200 Subject: [PATCH 2/8] add expected Tailwind CSS version --- .../@tailwindcss-upgrade/src/utils/version.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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) +} From 294fa85dc778bde84cfb51a3b79385ac6642f7b0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 29 Sep 2025 12:50:47 +0200 Subject: [PATCH 3/8] error when there is a version mismatch --- packages/@tailwindcss-upgrade/src/index.ts | 22 +++++++++++++++++++ .../src/utils/packages.ts | 3 +++ 2 files changed, 25 insertions(+) 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() From c9dd5354af60f97bf08a551b47723fda2136bb43 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 29 Sep 2025 13:22:46 +0200 Subject: [PATCH 4/8] add integration tests for pnpm, npm and bun --- integrations/upgrade/upgrade-errors.test.ts | 226 ++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 integrations/upgrade/upgrade-errors.test.ts diff --git a/integrations/upgrade/upgrade-errors.test.ts b/integrations/upgrade/upgrade-errors.test.ts new file mode 100644 index 000000000000..b877ae714316 --- /dev/null +++ b/integrations/upgrade/upgrade-errors.test.ts @@ -0,0 +1,226 @@ +// @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(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(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(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. + + " + `) + }, +) From f53cd0acbbfae19ef2465d66908e769fbcf1ccca Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 29 Sep 2025 13:32:24 +0200 Subject: [PATCH 5/8] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From c9537f011247c6f65183d419702444810e9b4083 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 29 Sep 2025 14:13:48 +0200 Subject: [PATCH 6/8] setup git config in CI This is used for some tests that interact with `git` and have to commit things during tests. --- .github/workflows/integration-tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 0148dd9836b4..9eae41cd5e10 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 user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: From ac36a04274142de814fb6c7ea1b739842add076b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 29 Sep 2025 14:19:38 +0200 Subject: [PATCH 7/8] set config globally --- .github/workflows/integration-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 9eae41cd5e10..b84ba214049c 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -54,8 +54,8 @@ jobs: - uses: pnpm/action-setup@v4 - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + 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 From 11e1d71b3f0ff10aae41c69e2f39c4cc83cb52bb Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 29 Sep 2025 14:25:35 +0200 Subject: [PATCH 8/8] strip VTControl characters to make CI happy --- integrations/upgrade/upgrade-errors.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/integrations/upgrade/upgrade-errors.test.ts b/integrations/upgrade/upgrade-errors.test.ts index b877ae714316..13e37aa99101 100644 --- a/integrations/upgrade/upgrade-errors.test.ts +++ b/integrations/upgrade/upgrade-errors.test.ts @@ -1,3 +1,4 @@ +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' @@ -51,7 +52,7 @@ test( 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(e.message.replaceAll(version, '4.0.0')) + return Promise.reject(stripVTControlCharacters(e.message.replaceAll(version, '4.0.0'))) }) }).rejects.toThrowErrorMatchingInlineSnapshot(` "Command failed: npx @tailwindcss/upgrade @@ -127,7 +128,7 @@ test( 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(e.message.replaceAll(version, '4.0.0')) + return Promise.reject(stripVTControlCharacters(e.message.replaceAll(version, '4.0.0'))) }) }).rejects.toThrowErrorMatchingInlineSnapshot(` "Command failed: npx @tailwindcss/upgrade @@ -203,7 +204,7 @@ test( 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(e.message.replaceAll(version, '4.0.0')) + return Promise.reject(stripVTControlCharacters(e.message.replaceAll(version, '4.0.0'))) }) }).rejects.toThrowErrorMatchingInlineSnapshot(` "Command failed: npx @tailwindcss/upgrade