also looking at this
refactor: one update-type classifier, with the edge cases decided instead of accidental
#1439
4 files
+108
-116
| @@ -417,12 +417,21 @@ export function sortUpdatesByPriority( | ||
| 417 | 417 | } |
| 418 | 418 | |
| 419 | 419 | /** |
| 420 | * Parse version string and determine update type using Bun's semver | |
| 420 | * Parse a version pair and classify the update, using Bun's semver. | |
| 421 | * | |
| 422 | * The one implementation of update-type classification. `Buddy` and | |
| 423 | * `VersionResolver` used to carry private variants, and the three disagreed | |
| 424 | * on exactly the inputs that matter: short actions tags ('v4'), v-prefixes, | |
| 425 | * suffixed Docker tags and prereleases. | |
| 426 | * | |
| 427 | * Non-semver input ('latest', 'workspace:*', branch names) and downgrades | |
| 428 | * deliberately degrade to 'patch' — the least-alarming label for a change | |
| 429 | * that cannot be reasoned about. | |
| 421 | 430 | */ |
| 422 | 431 | export function getUpdateType(currentVersion: string, newVersion: string): 'major' | 'minor' | 'patch' { |
| 423 | 432 | // Remove any prefixes like ^, ~, >=, v, @, etc. |
| 424 | const cleanCurrent = currentVersion.replace(/^[v^~>=<@]+/, '') | |
| 425 | const cleanNew = newVersion.replace(/^[v^~>=<@]+/, '') | |
| 433 | const cleanCurrent = padNumericVersion(currentVersion.replace(/^[v^~>=<@]+/, '')) | |
| 434 | const cleanNew = padNumericVersion(newVersion.replace(/^[v^~>=<@]+/, '')) | |
| 426 | 435 | |
| 427 | 436 | // If not an upgrade or equal, treat as patch (no-op or bugfix) |
| 428 | 437 | try { |
| @@ -434,52 +443,46 @@ export function getUpdateType(currentVersion: string, newVersion: string): 'majo | ||
| 434 | 443 | return 'patch' |
| 435 | 444 | } |
| 436 | 445 | |
| 437 | try { | |
| 438 | // Parse version parts manually for more reliable comparison | |
| 439 | const currentParts = cleanCurrent.split('.').map(n => Number.parseInt(n, 10)) | |
| 440 | const newParts = cleanNew.split('.').map(n => Number.parseInt(n, 10)) | |
| 446 | // Parse parts manually: parseInt's truncation at the first non-digit is | |
| 447 | // what lets a suffixed Docker tag ('3.19-alpine') classify by its numeric | |
| 448 | // core rather than collapsing to 'patch'. | |
| 449 | const currentParts = cleanCurrent.split('.').map(n => Number.parseInt(n, 10)) | |
| 450 | const newParts = cleanNew.split('.').map(n => Number.parseInt(n, 10)) | |
| 441 | 451 | |
| 442 | // Ensure we have at least 3 parts (major.minor.patch) | |
| 443 | while (currentParts.length < 3) currentParts.push(0) | |
| 444 | while (newParts.length < 3) newParts.push(0) | |
| 452 | // Ensure we have at least 3 parts (major.minor.patch) | |
| 453 | while (currentParts.length < 3) currentParts.push(0) | |
| 454 | while (newParts.length < 3) newParts.push(0) | |
| 445 | 455 | |
| 446 | const [currentMajor, currentMinor, currentPatch] = currentParts | |
| 447 | const [newMajor, newMinor, newPatch] = newParts | |
| 448 | ||
| 449 | // Major version change | |
| 450 | if (newMajor > currentMajor) { | |
| 451 | return 'major' | |
| 452 | } | |
| 456 | const [currentMajor, currentMinor] = currentParts | |
| 457 | const [newMajor, newMinor] = newParts | |
| 453 | 458 | |
| 454 | // Minor version change (same major, higher minor) | |
| 455 | if (newMajor === currentMajor && newMinor > currentMinor) { | |
| 456 | return 'minor' | |
| 457 | } | |
| 459 | if (newMajor > currentMajor) | |
| 460 | return 'major' | |
| 458 | 461 | |
| 459 | // Patch version change (same major and minor, higher patch) | |
| 460 | if (newMajor === currentMajor && newMinor === currentMinor && newPatch > currentPatch) { | |
| 461 | return 'patch' | |
| 462 | } | |
| 463 | } | |
| 464 | catch { | |
| 465 | // Fallback to semver satisfaction checks if parsing fails | |
| 466 | try { | |
| 467 | // Patch: within same minor series | |
| 468 | if (Bun.semver.satisfies(cleanNew, `~${cleanCurrent}`)) | |
| 469 | return 'patch' | |
| 470 | } | |
| 471 | catch {} | |
| 462 | if (newMajor === currentMajor && newMinor > currentMinor) | |
| 463 | return 'minor' | |
| 472 | 464 | |
| 473 | try { | |
| 474 | // Minor: within same major series | |
| 475 | if (Bun.semver.satisfies(cleanNew, `^${cleanCurrent}`)) | |
| 476 | return 'minor' | |
| 477 | } | |
| 478 | catch {} | |
| 479 | } | |
| 465 | // The guard above confirmed an upgrade with major and minor unchanged: a | |
| 466 | // patch bump, or a prerelease moving under an unchanged triple | |
| 467 | // (1.0.0-beta.1 → 1.0.0). This used to fall through to 'major', which let | |
| 468 | // an excludeMajor filter block a beta graduation as a breaking change. | |
| 469 | return 'patch' | |
| 470 | } | |
| 480 | 471 | |
| 481 | // Otherwise major | |
| 482 | return 'major' | |
| 472 | /** | |
| 473 | * Pad a purely numeric short version to major.minor.patch. | |
| 474 | * | |
| 475 | * GitHub Actions tags are routinely 'v4', and Bun.semver.order('4.2.2', '4') | |
| 476 | * is -1 — without padding, the upgrade guard misreads a real bump as a | |
| 477 | * downgrade. Restricted to all-numeric parts so suffixed tags like | |
| 478 | * '3.19-alpine' pass through untouched. | |
| 479 | */ | |
| 480 | function padNumericVersion(version: string): string { | |
| 481 | const parts = version.split('.') | |
| 482 | if (parts.length >= 3 || !parts.every(part => /^\d+$/.test(part))) | |
| 483 | return version | |
| 484 | while (parts.length < 3) parts.push('0') | |
| 485 | return parts.join('.') | |
| 483 | 486 | } |
| 484 | 487 | |
| 485 | 488 | /** |