Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- The picker marks a package with `[PATCH]` when upgrading it would leave a pinned pnpm patch unused, and `i` shows which patch. `m` and `l` skip these packages; you can still select one by hand.

### Fixed

- `--apply` and the GitHub Action no longer fail with `ERR_PNPM_UNUSED_PATCH` when a package has a pnpm patch pinned to its installed version (`patchedDependencies: { next@16.3.5: … }`). Such a package is now left alone and the rest of the upgrades go through. It is named in a warning, gets `heldByPatch` in the `--json` report and its own table in the Action's PR body, so you know to upgrade it by hand and re-create the patch.

## [1.8.4] - 2026-09-23

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ Review the diff and run your tests after upgrading.
| `←` | Cycle selection left (latest → range → none) |
| `→` | Cycle selection right (none → range → latest) |
| `Space` | Toggle the current package on/off |
| `m` | Select all minor/patch updates, except peers |
| `l` | Select all latest updates (including major), except peers |
| `m` | Select all minor/patch updates, except peers and patched packages |
| `l` | Select all latest updates (including major), except peers and patched packages |
| `u` | Unselect all packages |
| `Enter` | Confirm selection and upgrade |
| `/` | Search packages by name |
Expand Down
34 changes: 32 additions & 2 deletions action/render-pr-body.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ export function applyVersionPrefix(current, target) {
* no explicit "applied" flag, so we derive it: under minor/patch, `range` is the version satisfying
* the current spec, and it's only a real change when it differs from the current spec's version
* (prefix stripped). Entries where only a major exists have `range === current` and aren't applied.
* Peer ranges never are: `--apply` does not rewrite peerDependencies.
* Peer ranges never are: `--apply` does not rewrite peerDependencies. Neither is a package held by
* a pinned pnpm patch.
*/
export function wasApplied(e) {
if (e.type === 'peerDependencies') return false
if (e.heldByPatch) return false
const cleanCurrent = stripVersionPrefix(e.current)
return e.range !== cleanCurrent && e.range !== e.current
}
Expand Down Expand Up @@ -152,6 +154,33 @@ export function heldSection(report) {
return lines
}

/**
* Packages `--apply` left alone because a pnpm patch is pinned to the installed version.
*
* pnpm refuses to install when a patch matches no package, so these are never part of the PR.
* Listed so the reviewer knows the update exists and is theirs to do by hand.
*/
export function patchedSection(entries) {
const held = entries.filter((e) => e.heldByPatch)
if (held.length === 0) return []

const lines = ['### 🩹 Held by pnpm patch (not applied)', '']
lines.push(
'These packages have a pnpm patch pinned to the installed version. Upgrading would leave the patch unused, ' +
'which pnpm refuses to install, so they are **never upgraded automatically**. Upgrade them by hand and re-create the patch with `pnpm patch`.'
)
lines.push('')
lines.push('| Package | Current | Available | Patch |')
lines.push('|---|---|---|---|')
for (const e of held) {
lines.push(
`| \`${escapeCell(e.name)}\` | ${escapeCell(e.current)} | ${escapeCell(e.latest)} | \`${escapeCell(e.heldByPatch)}\` |`
)
}
lines.push('')
return lines
}

export function render(report) {
const { summary } = report
// Collapse monorepo duplicates up front so every section below counts and lists unique upgrades.
Expand Down Expand Up @@ -203,7 +232,7 @@ export function render(report) {
}
lines.push('')
} else {
lines.push('_No in-range upgrades were applied — see skipped majors below._')
lines.push('_No upgrades were applied — see the sections below._')
lines.push('')
}

Expand Down Expand Up @@ -261,6 +290,7 @@ export function render(report) {
lines.push('')
}

lines.push(...patchedSection(outdated))
lines.push(...heldSection(report))

lines.push('---')
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Anything newer than that target is still reported as the latest update (`hasMajo

`--apply` never rewrites `peerDependencies`, at any target. A peer range says which versions of the host your package supports, and raising its floor would quietly drop support for everything below it. Outdated peer ranges still show up in the report so you can widen them yourself.

With pnpm, `--apply` also skips a package whose patch in `patchedDependencies` is pinned to a version the upgrade would leave (`next@16.3.5`). pnpm refuses to install with an unused patch, so the upgrade is left to you: bump the package and re-create the patch with `pnpm patch`. The skipped packages are named in a warning on stderr and carry `heldByPatch` in the `--json` report. In the picker such a package has a `[PATCH]` badge (press `i` for the patch it is tied to), and `m` and `l` skip it; you can still select it by hand.

It honors [`.inuprc`](configuration.md) exactly as the report does — a package the config excludes is never written. With `--apply --json`, the install output goes to stderr so stdout stays pure JSON.

The install runs once in each project that changed: in its workspace root, or in the project's own directory when it isn't part of a workspace. If an install fails or the package manager isn't installed, `--apply` exits `2` and names each directory with the command to run there. The version bumps stay written.
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/github-action.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ When inup finds applicable upgrades, the workflow commits the changed manifest/l

Peer ranges (`peerDependencies`) are never rewritten, so publishing from the upgraded branch can't quietly narrow the host versions your package supports. Outdated peer ranges still appear in the table, never marked as applied.

Packages with a pnpm patch pinned to a version (`patchedDependencies: { next@16.3.5: … }`) are never upgraded automatically. pnpm refuses to install when a patch no longer matches any package, so the bump would fail the whole run. They are listed in a **Held by pnpm patch** table in the PR body; upgrade them by hand and re-create the patch with `pnpm patch`. Patches keyed by name only (`next`), or by a range that still covers the new version, don't hold anything back.

Catalog-sourced upgrades (pnpm `catalog:` deps) are applied to `pnpm-workspace.yaml` and marked `catalog:<name>` in the PR body so reviewers know which file the diff touches.

With `minimum-release-age` set, the PR body also gets a **Held by release-age cooldown** table listing versions that exist but were deliberately not applied, and how old each one is. A cooldown that skipped silently would read to a reviewer as "nothing newer available" — the opposite of what the control means.
Expand Down
1 change: 1 addition & 0 deletions docs/guide/json-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ One entry per outdated package.
| `enginesNode` | `string?` | Declared `engines.node` range for `latest`, if any. |
| `vulnerability` | `Vulnerability?` | Present only when the installed version has ≥1 known advisory. |
| `heldByCooldown` | `CooldownHold?` | Present when `minimumReleaseAge` withheld a newer version of this package. |
| `heldByPatch` | `string?` | Present only with `--apply`: the pnpm `patchedDependencies` key (e.g. `next@16.3.5`) pinned to the installed version. The package was **not** upgraded. |

## `CooldownHold`

Expand Down
37 changes: 36 additions & 1 deletion src/features/headless/headless-runner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import chalk from 'chalk'
import { PackageManagerDetector } from '../../shared/package-manager'
import { findBlockingPatch } from '../../shared/pnpm-patches'
import { ConsoleUtils, truncatePlainText } from '../../shared/terminal'
import type { PackageInfo, PackageUpgradeChoice, UpgradeOptions } from '../../shared/types'
import { applyVersionPrefix, findHighestPatchVersion } from '../../shared/versions'
Expand Down Expand Up @@ -86,6 +87,11 @@ export class HeadlessRunner {
// advisory against the upgrade targets, so the report says whether upgrading *fixes* it.
const vulnerabilities = await auditVulnerabilities(outdated, advisories)

// Before the report is built, so it can say which packages --apply left alone and why.
if (options.apply) {
this.holdPatchedPackages(outdated, options.target ?? 'minor')
}

// Build the report from the *pre-apply* outdated set: it describes what this run addressed.
const cooldown = this.detector.getCooldownDiagnostics()
const report = buildHeadlessReport(packages, outdated, vulnerabilities, cooldown)
Expand Down Expand Up @@ -159,6 +165,33 @@ export class HeadlessRunner {
await upgrader.upgradePackages(choices)
}

/**
* Mark the packages --apply must not bump because a pnpm patch is pinned to the version they
* would leave. pnpm refuses to install with a patch that matches nothing, so writing the bump
* would fail the whole run — every other upgrade with it. Said out loud on stderr: a package
* skipped in silence looks exactly like one that is up to date.
*/
private holdPatchedPackages(outdated: PackageInfo[], target: ApplyTarget): void {
const held = new Set<string>()
for (const pkg of outdated) {
if (pkg.type === 'peerDependencies') continue
const targetVersion = this.resolveTargetVersion(pkg, target)
if (!targetVersion) continue
const patch = findBlockingPatch(pkg.name, pkg.patchPins, targetVersion)
if (!patch) continue
pkg.heldByPatch = patch
held.add(`${pkg.name} (patch ${patch})`)
}

if (held.size > 0) {
console.error(
chalk.yellow(
`Warning: ${held.size} package(s) were not upgraded — a pnpm patch is pinned to the installed version, and upgrading would leave it unused: ${[...held].join(', ')}. Upgrade these by hand and re-create the patch.`
)
)
}
}

/**
* Build `PackageUpgradeChoice[]` from the outdated set per the version policy. Mirrors
* `createUpgradeChoices` in the TUI: preserves the original range prefix (^/~) unless --save-exact.
Expand All @@ -169,7 +202,8 @@ export class HeadlessRunner {
* update crosses a minor (or major) boundary. Uses upgradeType 'range'.
* - latest: take `latestVersion`; uses upgradeType 'latest' (majors included).
*
* peerDependencies are never written, at any target: they stay in the report only.
* peerDependencies are never written, at any target: they stay in the report only. Neither are
* packages held by a pinned pnpm patch.
*/
private buildChoices(outdated: PackageInfo[], target: ApplyTarget): PackageUpgradeChoice[] {
const saveExact = this.options?.saveExact ?? false
Expand All @@ -179,6 +213,7 @@ export class HeadlessRunner {
// A peer range says which host versions a library supports; it is not a version to install.
// Raising its floor silently drops support for every older host, so that stays a person's call.
if (pkg.type === 'peerDependencies') continue
if (pkg.heldByPatch) continue

const targetVersion = this.resolveTargetVersion(pkg, target)
if (!targetVersion) continue
Expand Down
1 change: 1 addition & 0 deletions src/features/headless/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function buildHeadlessReport(
if (pkg.deprecated) entry.deprecated = pkg.deprecated
if (pkg.enginesNode) entry.enginesNode = pkg.enginesNode
if (pkg.heldByCooldown) entry.heldByCooldown = pkg.heldByCooldown
if (pkg.heldByPatch) entry.heldByPatch = pkg.heldByPatch
const vulnerability = vulnerabilities.get(pkg)
if (vulnerability) entry.vulnerability = vulnerability
return entry
Expand Down
1 change: 1 addition & 0 deletions src/features/headless/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface HeadlessReportEntry {
enginesNode?: string // declared engines.node range for the latest version, if any
vulnerability?: HeadlessVulnerability // Advisories on the current version + whether upgrading clears them
heldByCooldown?: CooldownHold // A newer version exists but minimumReleaseAge withheld it
heldByPatch?: string // --apply skipped it: this pnpm patch key is pinned to a version the bump would leave
}

export interface HeadlessReport {
Expand Down
4 changes: 2 additions & 2 deletions src/features/interactive/keymap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,15 @@ export const KEY_BINDINGS: KeyBinding[] = [
tokens: ['m'],
action: { type: 'bulk_select_minor' },
displayKeys: 'm',
help: 'Select all minor/patch updates, except peers',
help: 'Select all minor/patch updates, except peers and patched packages',
group: 'Selection',
footer: { keyLabel: 'm', label: 'Minor' },
},
{
tokens: ['l'],
action: { type: 'bulk_select_latest' },
displayKeys: 'l',
help: 'Select all latest updates (including major), except peers',
help: 'Select all latest updates (including major), except peers and patched packages',
group: 'Selection',
footer: { keyLabel: 'l', label: 'Latest' },
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,15 @@ export function buildPackageInfoSections(
if (engineWarning) {
warningRows.push(getThemeColor('warning')(`Hold: ${engineWarning}`))
}
if (state.patchHold) {
const patch = state.patchHold.latest ?? state.patchHold.range
for (const line of wrapPlainText(
`Patched: pnpm patch ${patch} is pinned to the installed version. Upgrading leaves it unused and pnpm refuses to install — upgrade this package on its own, then re-create the patch with pnpm patch.`,
warningContentWidth
)) {
warningRows.push(getThemeColor('warning')(line))
}
}
if (state.heldByCooldown) {
// The row badge can only say [HELD]; this is where a user goes to find out
// WHICH version is being withheld and how close it is to becoming eligible.
Expand Down
11 changes: 8 additions & 3 deletions src/features/interactive/presenters/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@ import { getThemeColor } from '../themes-colors'

export type HealthState = Pick<
PackageSelectionState,
'deprecated' | 'enginesNode' | 'heldByCooldown'
'deprecated' | 'enginesNode' | 'heldByCooldown' | 'patchHold'
>

/**
* A compact badge flagging a package's health in the list:
* - `[DEPR]` when the latest version is deprecated (highest priority),
* - `[ENG]` when its `engines.node` is incompatible with the running Node, or
* - `[ENG]` when its `engines.node` is incompatible with the running Node,
* - `[PATCH]` when an offered upgrade would leave a pinned pnpm patch unused, or
* - `[HELD]` when the release-age cooldown withheld a newer version.
*
* Returns an empty string when none applies. Deprecation wins because an
* engines mismatch on an abandoned package is moot; the cooldown ranks last
* engines mismatch on an abandoned package is moot; the patch outranks the
* cooldown because taking the upgrade fails the install; the cooldown ranks last
* because it reports a deliberate, benign choice rather than a problem.
* All render in the theme's (amber) warning color — caution, not alarm.
*/
Expand All @@ -25,6 +27,9 @@ export function getHealthBadge(state: HealthState): string {
if (checkNodeEngineCompatibility(state.enginesNode)) {
return getThemeColor('warning')('[ENG]')
}
if (state.patchHold) {
return getThemeColor('warning')('[PATCH]')
}
if (state.heldByCooldown) {
return getThemeColor('warning')('[HELD]')
}
Expand Down
14 changes: 14 additions & 0 deletions src/features/interactive/session/selection-state-builder.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { findBlockingPatch } from '../../../shared/pnpm-patches'
import type {
PackageInfo,
PackageSelectionState,
Expand Down Expand Up @@ -87,6 +88,12 @@ export function createSelectionStates(
const latestClean = toComparableVersion(pkg.latestVersion) || pkg.latestVersion
const key = selectionKey(pkg.name, pkg.currentVersion, pkg.type, pkg.catalog)
const previousSelection = previousSelections?.get(key) || 'none'
const rangePatch = pkg.hasRangeUpdate
? findBlockingPatch(pkg.name, pkg.patchPins, rangeClean)
: null
const latestPatch = pkg.hasMajorUpdate
? findBlockingPatch(pkg.name, pkg.patchPins, latestClean)
: null

return {
name: pkg.name,
Expand All @@ -106,6 +113,13 @@ export function createSelectionStates(
deprecated: pkg.deprecated,
enginesNode: pkg.enginesNode,
heldByCooldown: pkg.heldByCooldown,
patchHold:
rangePatch || latestPatch
? {
...(rangePatch && { range: rangePatch }),
...(latestPatch && { latest: latestPatch }),
}
: undefined,
heldOnly: !pkg.isOutdated && pkg.heldByCooldown !== undefined,
vulnerability: getCachedSummary(pkg.name, pkg.currentVersion, pkg.type),
allVersions: pkg.allVersions,
Expand Down
11 changes: 6 additions & 5 deletions src/features/interactive/state/state-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,13 @@ export class StateManager {

// Bulk selection skips peer rows (and leaves a manual peer choice as it is). Raising a peer
// range's floor drops support for older hosts, so it should only happen when picked by hand.
// The same goes for an upgrade that would leave a pinned pnpm patch unused: the install fails
// until someone re-creates the patch, so that is a choice to make one package at a time.
bulkSelectMinor(states: PackageSelectionState[]): void {
if (states.length === 0) return
states.forEach((state) => {
if (state.type === 'peerDependencies') return
if (state.hasRangeUpdate) {
if (state.hasRangeUpdate && !state.patchHold?.range) {
state.selectedOption = 'range'
}
})
Expand All @@ -225,10 +227,9 @@ export class StateManager {
if (states.length === 0) return
states.forEach((state) => {
if (state.type === 'peerDependencies') return
if (state.hasMajorUpdate) {
state.selectedOption = 'latest'
} else if (state.hasRangeUpdate) {
state.selectedOption = 'range'
const option = state.hasMajorUpdate ? 'latest' : state.hasRangeUpdate ? 'range' : null
if (option && !state.patchHold?.[option]) {
state.selectedOption = option
}
})
}
Expand Down
Loading
Loading