chore: skip workspace/catalog/link payload deps in upgrade rewrite

This commit is contained in:
Elliot DeNolf
2026-09-09 16:09:10 -04:00
parent 301a482be6
commit 7714d7cf7e
5 changed files with 90 additions and 9 deletions
+11 -1
View File
@@ -14,7 +14,11 @@ import { loadProject } from '../utils/project.js'
import { detectPackageManager } from './detectPackageManager.js'
import { renderReport } from './report.js'
import { resolveVersions } from './resolveVersions.js'
import { isPayloadEslintPackage, rewritePackageJson } from './rewritePackageJson.js'
import {
isPayloadEslintPackage,
isPlaceholderSpec,
rewritePackageJson,
} from './rewritePackageJson.js'
import { runInstall } from './runInstall.js'
import { RUNBOOK_RELATIVE_PATH } from './types.js'
@@ -140,6 +144,7 @@ export async function runUpgrade(
floorsWritten: summary.floorsWritten,
nextTarget: resolved.nextTarget,
overridesRemoved: summary.overridesRemoved,
placeholdersSkipped: summary.placeholdersSkipped,
runbookPath: resolve(dirname(fileURLToPath(import.meta.url)), '..', RUNBOOK_RELATIVE_PATH),
transforms: results,
versions,
@@ -175,6 +180,11 @@ function verifyResolution(
if (isPayloadEslintPackage(name)) {
continue
}
// Workspace/catalog/link specs were left unpinned, so there is no exact
// version to verify against.
if (isPlaceholderSpec(deps[name])) {
continue
}
const installed = readInstalledVersion(projectPath, name)
rows.push({
name,
@@ -8,6 +8,7 @@ describe('renderReport', () => {
floorsWritten: ['typescript', '@types/node', 'engines.node'],
nextTarget: '16.9.3',
overridesRemoved: ['pnpm.overrides.payload'],
placeholdersSkipped: ['@payloadcms/ui'],
runbookPath: '/x/dist/runbook/payload-v4-upgrade.md',
transforms: [
{ filesChanged: ['a.ts'], name: 'migrate-versions-default', notes: ['review X'] },
@@ -26,6 +27,8 @@ describe('renderReport', () => {
expect(out).toContain('payload-v4-upgrade.md')
expect(out).toContain('Floors written')
expect(out).toContain('typescript')
expect(out).toContain('Left as-is')
expect(out).toContain('@payloadcms/ui')
})
it('flags a resolution mismatch as not confirmed v4', () => {
@@ -33,6 +36,7 @@ describe('renderReport', () => {
floorsWritten: [],
nextTarget: null,
overridesRemoved: [],
placeholdersSkipped: [],
runbookPath: '/x/runbook.md',
transforms: [],
versions: [{ name: 'payload', ok: false, resolved: '3.40.0', wrote: '4.0.0-canary.20' }],
@@ -48,6 +52,7 @@ describe('renderReport', () => {
floorsWritten: [],
nextTarget: null,
overridesRemoved: [],
placeholdersSkipped: [],
runbookPath: '/x/runbook.md',
transforms: [{ error: new Error('boom'), filesChanged: [], name: 'broken-transform' }],
versions: [],
+8
View File
@@ -11,6 +11,7 @@ export type ReportModel = {
floorsWritten: string[]
nextTarget: null | string
overridesRemoved: string[]
placeholdersSkipped: string[]
runbookPath: string
transforms: TransformRunResult[]
versions: VersionReportRow[]
@@ -36,6 +37,13 @@ export function renderReport(model: ReportModel): string {
lines.push(`Overrides removed: ${model.overridesRemoved.join(', ')}`, '')
}
if (model.placeholdersSkipped.length > 0) {
lines.push(
`Left as-is (workspace/catalog/link specs): ${model.placeholdersSkipped.join(', ')}`,
'',
)
}
if (model.floorsWritten.length > 0) {
lines.push(`Floors written: ${model.floorsWritten.join(', ')}`, '')
}
@@ -63,6 +63,42 @@ describe('rewritePackageJson', () => {
expect(summary.pinnedPayload).not.toContain('@payloadcms/eslint-config')
})
it('leaves workspace/catalog/link specs untouched and reports them as skipped', () => {
const data: Record<string, unknown> = {
dependencies: {
'@payloadcms/next': 'catalog:',
'@payloadcms/richtext-lexical': 'link:../lexical',
payload: 'workspace:*',
},
devDependencies: {
'@payloadcms/eslint-config': 'workspace:^',
'@payloadcms/ui': '^3.0.0',
},
}
const summary = rewritePackageJson({ data, resolved })
expect(data.dependencies).toEqual({
'@payloadcms/next': 'catalog:',
'@payloadcms/richtext-lexical': 'link:../lexical',
payload: 'workspace:*',
})
// The non-placeholder eslint dep still moves to latest; the workspace one does not.
expect(data.devDependencies).toMatchObject({
'@payloadcms/eslint-config': 'workspace:^',
'@payloadcms/ui': '4.0.0-canary.20',
})
expect(summary.placeholdersSkipped.sort()).toEqual(
[
'@payloadcms/eslint-config',
'@payloadcms/next',
'@payloadcms/richtext-lexical',
'payload',
].sort(),
)
expect(summary.pinnedPayload).toEqual(['@payloadcms/ui'])
})
it('adds floors to devDependencies when absent', () => {
const data: Record<string, unknown> = { dependencies: { payload: '^3.0.0' } }
@@ -9,10 +9,14 @@ export type RewriteSummary = {
floorsWritten: string[]
overridesRemoved: string[]
pinnedPayload: string[]
placeholdersSkipped: string[]
}
const DEP_FIELDS = ['dependencies', 'devDependencies'] as const
/** Any protocol-prefixed specifier (`workspace:*`, `catalog:`, `link:`, `file:`, `npm:`, a URL). */
const PLACEHOLDER_SPEC = /^[a-z][a-z0-9+.-]*:/i
export const isPayloadPackage = (name: string): boolean =>
name === 'payload' || name.startsWith('@payloadcms/')
@@ -23,6 +27,14 @@ export const isPayloadPackage = (name: string): boolean =>
export const isPayloadEslintPackage = (name: string): boolean =>
name.startsWith('@payloadcms/eslint')
/**
* A dependency whose version is resolved by the package manager, not a semver
* range: `workspace:*`, `catalog:`, `link:`, `file:`, `npm:` aliases, URLs.
* Overwriting one with an exact version would break the link, so it is left as-is.
*/
export const isPlaceholderSpec = (spec: unknown): boolean =>
typeof spec === 'string' && PLACEHOLDER_SPEC.test(spec)
/**
* Mutate `data` in place for a v4 upgrade: exact-pin payload packages, drop
* payload dependency overrides, and write the toolchain floors. Never touches
@@ -30,31 +42,41 @@ export const isPayloadEslintPackage = (name: string): boolean =>
* Returns a summary for the report. Idempotent.
*/
export function rewritePackageJson({ data, resolved }: RewriteArgs): RewriteSummary {
const pinnedPayload = pinPayloadPackages(data, resolved.payloadVersion)
const { pinnedPayload, placeholdersSkipped } = pinPayloadPackages(data, resolved.payloadVersion)
const overridesRemoved = removePayloadOverrides(data)
const floorsWritten = writeFloors(data, resolved)
return { floorsWritten, overridesRemoved, pinnedPayload }
return { floorsWritten, overridesRemoved, pinnedPayload, placeholdersSkipped }
}
function pinPayloadPackages(data: Record<string, unknown>, version: string): string[] {
function pinPayloadPackages(
data: Record<string, unknown>,
version: string,
): { pinnedPayload: string[]; placeholdersSkipped: string[] } {
const pinned: string[] = []
const skipped: string[] = []
for (const field of DEP_FIELDS) {
const deps = data[field]
if (!isRecord(deps)) {
continue
}
for (const name of Object.keys(deps)) {
if (!isPayloadPackage(name)) {
continue
}
// A workspace/catalog/link spec resolves itself; pinning it breaks the link.
if (isPlaceholderSpec(deps[name])) {
skipped.push(name)
continue
}
if (isPayloadEslintPackage(name)) {
deps[name] = 'latest'
continue
}
if (isPayloadPackage(name)) {
deps[name] = version
pinned.push(name)
}
deps[name] = version
pinned.push(name)
}
}
return pinned
return { pinnedPayload: pinned, placeholdersSkipped: skipped }
}
function removePayloadOverrides(data: Record<string, unknown>): string[] {