also looking at this
feat(engines): propose runtime bumps, which were resolved and never proposed
#1428
8 files
+649
-14
| @@ -159,6 +159,7 @@ matches `react` but not `@types/node`. Use `['**']` for every package, or | ||
| 159 | 159 | | `autoMerge` | Allows unattended merge — see the caveat below | |
| 160 | 160 | | `autoMigrate` | Attempts the migration for matching majors | |
| 161 | 161 | | `minimumReleaseAge` | Minutes a version must have been published | |
| 162 | | `engines` | Propose bumps to the runtimes under `engines` (default off) | | |
| 162 | 163 | | `prPriority` | Ordering within `maxPRsPerRun`; higher goes first | |
| 163 | 164 | |
| 164 | 165 | `prPriority` is applied **before** the per-run cap, so a high-priority group |
| @@ -168,6 +168,15 @@ export class Buddy { | ||
| 168 | 168 | // Get outdated Zig dependencies |
| 169 | 169 | const zigStartTime = Date.now() |
| 170 | 170 | const zigUpdates = await this.checkZigForUpdates(packageFiles) |
| 171 | ||
| 172 | // Runtimes declared under `engines`. Both halves of this — resolving a | |
| 173 | // newer runtime and rewriting the constraint — existed and were tested, | |
| 174 | // and no scan step called either, so an engine was listed on the | |
| 175 | // dashboard and never proposed. Opt-in, because bumping one is a | |
| 176 | // deployment decision rather than a dependency one. | |
| 177 | const engineUpdates = this.config.packages?.engines === true | |
| 178 | ? await this.checkEnginesForUpdates(packageFiles) | |
| 179 | : [] | |
| 171 | 180 | this.logger.info(`⏱️ Zig dependency checks took ${Date.now() - zigStartTime}ms (found ${zigUpdates.length} updates)`) |
| 172 | 181 | |
| 173 | 182 | // pnpm catalogs are where the version lives in a workspace that uses |
| @@ -212,6 +221,7 @@ export class Buddy { | ||
| 212 | 221 | ...githubActionsUpdates, |
| 213 | 222 | ...dockerUpdates, |
| 214 | 223 | ...zigUpdates, |
| 224 | ...engineUpdates, | |
| 215 | 225 | ...adapterScan.updates, |
| 216 | 226 | ...catalogScan.updates, |
| 217 | 227 | ] |
| @@ -1403,6 +1413,71 @@ export class Buddy { | ||
| 1403 | 1413 | return deduplicatedUpdates |
| 1404 | 1414 | } |
| 1405 | 1415 | |
| 1416 | /** | |
| 1417 | * Propose bumps to the runtimes declared under `engines`. | |
| 1418 | * | |
| 1419 | * Node and Deno publish releases on GitHub; the package managers publish to | |
| 1420 | * npm. `resolveEngineVersion` knows which is which, and the lookups are the | |
| 1421 | * clients this instance already has, so the npm side shares the registry | |
| 1422 | * cache with everything else in the scan. | |
| 1423 | * | |
| 1424 | * @param packageFiles - Every scanned manifest | |
| 1425 | * @returns One update per engine whose constraint no longer admits the newest release | |
| 1426 | */ | |
| 1427 | private async checkEnginesForUpdates(packageFiles: PackageFile[]): Promise<PackageUpdate[]> { | |
| 1428 | const { bumpEngineConstraint, engineUpdateType, resolveEngineVersion } = await import('./scanner/package-json-extras') | |
| 1429 | const { fetchLatestActionVersion } = await import('./utils/github-actions-parser') | |
| 1430 | ||
| 1431 | const engines = packageFiles.flatMap(file => | |
| 1432 | file.dependencies | |
| 1433 | .filter(dep => dep.type === 'engines') | |
| 1434 | .map(dep => ({ file, dep })), | |
| 1435 | ) | |
| 1436 | ||
| 1437 | if (engines.length === 0) | |
| 1438 | return [] | |
| 1439 | ||
| 1440 | this.logger.info(`⚡ Checking ${engines.length} engine constraint(s)...`) | |
| 1441 | ||
| 1442 | const lookups = { | |
| 1443 | npm: (name: string) => this.registryClient.getLatestVersion(name), | |
| 1444 | // The action fetcher takes `owner/repo` and returns the latest release | |
| 1445 | // tag, which is exactly the shape a runtime's GitHub releases have. | |
| 1446 | github: (repo: string) => fetchLatestActionVersion(repo), | |
| 1447 | } | |
| 1448 | ||
| 1449 | const checks = engines.map(async ({ file, dep }): Promise<PackageUpdate | null> => { | |
| 1450 | try { | |
| 1451 | const latest = await resolveEngineVersion(dep.name, lookups) | |
| 1452 | if (!latest) | |
| 1453 | return null | |
| 1454 | ||
| 1455 | const proposed = bumpEngineConstraint(dep.currentVersion, latest) | |
| 1456 | if (!proposed) { | |
| 1457 | this.logger.debug(`${dep.name} ${dep.currentVersion} already admits ${latest}`) | |
| 1458 | return null | |
| 1459 | } | |
| 1460 | ||
| 1461 | this.logger.info(`Update available: ${dep.name} ${dep.currentVersion} → ${proposed}`) | |
| 1462 | ||
| 1463 | return { | |
| 1464 | name: dep.name, | |
| 1465 | currentVersion: dep.currentVersion, | |
| 1466 | newVersion: proposed, | |
| 1467 | updateType: engineUpdateType(dep.currentVersion, proposed), | |
| 1468 | dependencyType: 'engines', | |
| 1469 | file: file.path, | |
| 1470 | } | |
| 1471 | } | |
| 1472 | catch (error) { | |
| 1473 | this.logger.warn(`Could not check engine ${dep.name}:`, error) | |
| 1474 | return null | |
| 1475 | } | |
| 1476 | }) | |
| 1477 | ||
| 1478 | return (await Promise.all(checks)).filter((update): update is PackageUpdate => update !== null) | |
| 1479 | } | |
| 1480 | ||
| 1406 | 1481 | /** |
| 1407 | 1482 | * Check Dockerfiles for updates |
| 1408 | 1483 | */ |
| @@ -1601,11 +1676,38 @@ export class Buddy { | ||
| 1601 | 1676 | // Handle package.json updates - only for actual package.json files, not dependency or GitHub Actions files |
| 1602 | 1677 | const packageJsonUpdates = updates.filter(update => |
| 1603 | 1678 | update.file.endsWith('package.json') |
| 1679 | && update.dependencyType !== 'engines' | |
| 1604 | 1680 | && !update.file.includes('.yaml') |
| 1605 | 1681 | && !update.file.includes('.yml') |
| 1606 | 1682 | && !update.file.includes('.github/workflows/'), |
| 1607 | 1683 | ) |
| 1608 | 1684 | |
| 1685 | // Engine constraints live under `engines`, which the dependency-section | |
| 1686 | // loop below never looks in — an engine update sent through it logged | |
| 1687 | // "not found" and wrote nothing. | |
| 1688 | const engineUpdates = updates.filter(update => update.dependencyType === 'engines') | |
| 1689 | if (engineUpdates.length > 0) { | |
| 1690 | const { applyEngineUpdates } = await import('./scanner/package-json-extras') | |
| 1691 | const byFile = new Map<string, PackageUpdate[]>() | |
| 1692 | for (const update of engineUpdates) | |
| 1693 | byFile.set(update.file, [...(byFile.get(update.file) ?? []), update]) | |
| 1694 | ||
| 1695 | for (const [path, fileEngines] of byFile) { | |
| 1696 | try { | |
| 1697 | const original = fs.readFileSync(path, 'utf-8') | |
| 1698 | const content = applyEngineUpdates(original, fileEngines) | |
| 1699 | if (content === original) { | |
| 1700 | this.logger.warn(`⚠️ No engine changes written to ${path}`) | |
| 1701 | continue | |
| 1702 | } | |
| 1703 | fileUpdates.push({ path, content, type: 'update' }) | |
| 1704 | } | |
| 1705 | catch (error) { | |
| 1706 | this.logger.warn(`Failed to update engines in ${path}:`, error) | |
| 1707 | } | |
| 1708 | } | |
| 1709 | } | |
| 1710 | ||
| 1609 | 1711 | // Group package.json updates by file |
| 1610 | 1712 | const updatesByPackageFile = new Map<string, PackageUpdate[]>() |
| 1611 | 1713 | for (const update of packageJsonUpdates) { |
| @@ -1745,7 +1847,12 @@ export class Buddy { | ||
| 1745 | 1847 | try { |
| 1746 | 1848 | const { generateDockerfileUpdates } = await import('./utils/dockerfile-parser') |
| 1747 | 1849 | // Pass only the Dockerfile updates for this specific group |
| 1748 | const dockerUpdates = await generateDockerfileUpdates(dockerfileUpdates) | |
| 1850 | // Passed through so a digest on a private image can be re-resolved | |
| 1851 | // with the same credentials the scan used to find the tag. | |
| 1852 | const dockerUpdates = await generateDockerfileUpdates( | |
| 1853 | dockerfileUpdates, | |
| 1854 | this.config.registries?.docker, | |
| 1855 | ) | |
| 1749 | 1856 | fileUpdates.push(...dockerUpdates) |
| 1750 | 1857 | } |
| 1751 | 1858 | catch (error) { |
| @@ -290,7 +290,7 @@ function validatePackages(issues: ConfigIssue[], config: BuddyConfig): void { | ||
| 290 | 290 | checkStringArray(issues, 'packages.minimumReleaseAgeExclude', packages.minimumReleaseAgeExclude) |
| 291 | 291 | checkNumber(issues, 'packages.minimumReleaseAge', packages.minimumReleaseAge, { min: 0 }) |
| 292 | 292 | |
| 293 | for (const key of ['includePrerelease', 'excludeMajor', 'respectLatest'] as const) { | |
| 293 | for (const key of ['includePrerelease', 'excludeMajor', 'respectLatest', 'engines'] as const) { | |
| 294 | 294 | const value = packages[key] |
| 295 | 295 | if (value !== undefined && typeof value !== 'boolean') |
| 296 | 296 | issues.push({ path: `packages.${key}`, message: `expected a boolean, got ${quote(value)}` }) |
| @@ -1,4 +1,4 @@ | ||
| 1 | import type { Dependency } from '../types' | |
| 1 | import type { Dependency, PackageUpdate } from '../types' | |
| 2 | 2 | |
| 3 | 3 | /** Runtimes buddy knows how to resolve a version for. */ |
| 4 | 4 | export const KNOWN_ENGINES: Record<string, { registry: 'npm' | 'github', source: string }> = { |
| @@ -217,3 +217,69 @@ export function bumpEngineConstraint(constraint: string, latest: string): string | ||
| 217 | 217 | |
| 218 | 218 | return `${operator}${truncated.join('.')}` |
| 219 | 219 | } |
| 220 | ||
| 221 | /** | |
| 222 | * Classify an engine bump by the first component that changed. | |
| 223 | * | |
| 224 | * Constraints carry operators and are often imprecise (`>=20`), so semver | |
| 225 | * ordering cannot be applied to them directly. Comparing the numeric parts | |
| 226 | * positionally is what the constraint means anyway. | |
| 227 | * | |
| 228 | * @param current - The declared constraint | |
| 229 | * @param next - The proposed constraint | |
| 230 | * @returns The semver impact of the change | |
| 231 | * @example | |
| 232 | * ```ts | |
| 233 | * engineUpdateType('>=20', '>=22') // 'major' | |
| 234 | * engineUpdateType('>=20.5', '>=20.9') // 'minor' | |
| 235 | * ``` | |
| 236 | */ | |
| 237 | export function engineUpdateType(current: string, next: string): PackageUpdate['updateType'] { | |
| 238 | const parts = (value: string): number[] => (/(\d+(?:\.\d+)*)/.exec(value)?.[1] ?? '0').split('.').map(Number) | |
| 239 | const before = parts(current) | |
| 240 | const after = parts(next) | |
| 241 | ||
| 242 | if ((after[0] ?? 0) !== (before[0] ?? 0)) | |
| 243 | return 'major' | |
| 244 | if ((after[1] ?? 0) !== (before[1] ?? 0)) | |
| 245 | return 'minor' | |
| 246 | return 'patch' | |
| 247 | } | |
| 248 | ||
| 249 | /** | |
| 250 | * Rewrite engine constraints in a package.json's text. | |
| 251 | * | |
| 252 | * String replacement rather than parse-and-serialise, for the same reason the | |
| 253 | * dependency writer does it: the file's indentation, key order and trailing | |
| 254 | * newline are somebody's choices, and re-serialising would silently discard | |
| 255 | * them in a pull request that claims to touch one line. | |
| 256 | * | |
| 257 | * Scoped to the `engines` object, so an engine whose name is also a dependency | |
| 258 | * — `npm`, `yarn` — is not rewritten in `devDependencies` by mistake. | |
| 259 | * | |
| 260 | * @param content - Current package.json text | |
| 261 | * @param updates - Engine updates to apply | |
| 262 | * @returns The rewritten text, unchanged when nothing matched | |
| 263 | */ | |
| 264 | export function applyEngineUpdates(content: string, updates: PackageUpdate[]): string { | |
| 265 | const block = /("engines"\s*:\s*\{)([\s\S]*?)(\})/.exec(content) | |
| 266 | if (!block) | |
| 267 | return content | |
| 268 | ||
| 269 | const [whole, open, body, close] = block | |
| 270 | let next = body | |
| 271 | ||
| 272 | for (const update of updates) { | |
| 273 | if (update.dependencyType !== 'engines') | |
| 274 | continue | |
| 275 | ||
| 276 | const name = update.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| 277 | const current = update.currentVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| 278 | next = next.replace( | |
| 279 | new RegExp(`("${name}"\\s*:\\s*")${current}(")`), | |
| 280 | `$1${update.newVersion}$2`, | |
| 281 | ) | |
| 282 | } | |
| 283 | ||
| 284 | return next === body ? content : content.replace(whole, `${open}${next}${close}`) | |
| 285 | } | |
| @@ -135,6 +135,15 @@ export interface BuddyConfig { | ||
| 135 | 135 | excludeMajor?: boolean |
| 136 | 136 | /** Respect "latest" and "*" version indicators (default: true) */ |
| 137 | 137 | respectLatest?: boolean |
| 138 | /** | |
| 139 | * Propose bumps to the runtimes declared under `engines` (default: false). | |
| 140 | * | |
| 141 | * Off by default because an engine constraint is a deployment decision | |
| 142 | * rather than a dependency one: raising `node: '>=20'` to `'>=22'` is a | |
| 143 | * statement about every environment the project runs in, not just the | |
| 144 | * lockfile. The constraint's shape is preserved — a floor stays a floor. | |
| 145 | */ | |
| 146 | engines?: boolean | |
| 138 | 147 | /** Minimum age in minutes that a package version must have before installation (default: 0) */ |
| 139 | 148 | minimumReleaseAge?: number |
| 140 | 149 | /** Package names to exclude from minimum release age requirement */ |
| @@ -112,17 +112,34 @@ export async function parseDockerfile(filePath: string, content: string): Promis | ||
| 112 | 112 | /** |
| 113 | 113 | * Parse a Docker image reference into name and version components |
| 114 | 114 | */ |
| 115 | function parseImageReference(imageRef: string): { name: string, version: string } | null { | |
| 115 | function parseImageReference(imageRef: string): { name: string, version: string, digest?: string } | null { | |
| 116 | 116 | try { |
| 117 | 117 | // Handle different image reference formats: |
| 118 | 118 | // - image:tag |
| 119 | 119 | // - registry/image:tag |
| 120 | 120 | // - registry:port/image:tag |
| 121 | 121 | // - registry/namespace/image:tag |
| 122 | // - image:tag@sha256:digest | |
| 122 | 123 | |
| 123 | 124 | let name: string |
| 124 | 125 | let version: string |
| 125 | 126 | |
| 127 | // Split the digest off first. Everything below reasons about the *last* | |
| 128 | // colon, and a digest contributes two of them — so `node:20@sha256:abc` | |
| 129 | // parsed as the image `node:20@sha256` at version `abc`, which is not an | |
| 130 | // image, is never found in any registry, and appears on the dependency | |
| 131 | // dashboard as though it were real. | |
| 132 | const digestIndex = imageRef.indexOf('@') | |
| 133 | const digest = digestIndex === -1 ? undefined : imageRef.slice(digestIndex + 1) | |
| 134 | if (digestIndex !== -1) | |
| 135 | imageRef = imageRef.slice(0, digestIndex) | |
| 136 | ||
| 137 | // Pinned by digest alone, with no tag. There is no version to move, and | |
| 138 | // reporting it as `latest` would put a tag on the dashboard that the file | |
| 139 | // does not contain. | |
| 140 | if (digest && !imageRef.includes(':')) | |
| 141 | return null | |
| 142 | ||
| 126 | 143 | if (imageRef.includes(':')) { |
| 127 | 144 | const lastColonIndex = imageRef.lastIndexOf(':') |
| 128 | 145 | const beforeColon = imageRef.substring(0, lastColonIndex) |
| @@ -146,12 +163,12 @@ function parseImageReference(imageRef: string): { name: string, version: string | ||
| 146 | 163 | version = 'latest' |
| 147 | 164 | } |
| 148 | 165 | |
| 149 | // Skip if version contains variables or is a digest | |
| 166 | // Skip if version contains variables or is a bare digest | |
| 150 | 167 | if (version.includes('$') || version.startsWith('sha256:')) { |
| 151 | 168 | return null |
| 152 | 169 | } |
| 153 | 170 | |
| 154 | return { name, version } | |
| 171 | return { name, version, ...(digest ? { digest } : {}) } | |
| 155 | 172 | } |
| 156 | 173 | catch (error) { |
| 157 | 174 | getDefaultLogger().warn(`Failed to parse image reference ${imageRef}:`, error) |
| @@ -230,7 +247,12 @@ export async function resolveDockerDigest( | ||
| 230 | 247 | /** |
| 231 | 248 | * Update Dockerfile content with new image versions |
| 232 | 249 | */ |
| 233 | export async function updateDockerfile(filePath: string, content: string, updates: PackageUpdate[]): Promise<string> { | |
| 250 | export async function updateDockerfile( | |
| 251 | filePath: string, | |
| 252 | content: string, | |
| 253 | updates: PackageUpdate[], | |
| 254 | registries?: DockerRegistryConfig, | |
| 255 | ): Promise<string> { | |
| 234 | 256 | try { |
| 235 | 257 | if (!isDockerfile(filePath)) { |
| 236 | 258 | getDefaultLogger().info(`⚠️ updateDockerfile: ${filePath} is not a Dockerfile, returning original content`) |
| @@ -251,8 +273,15 @@ export async function updateDockerfile(filePath: string, content: string, update | ||
| 251 | 273 | // Create regex to find FROM instructions with this image |
| 252 | 274 | // Handle various formats: FROM image:tag, FROM image:tag as alias |
| 253 | 275 | const escapedImageName = cleanImageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') |
| 276 | // The tag is captured without its digest, so a pinned reference does not | |
| 277 | // swallow `@sha256:…` into the version and then lose it on replacement. | |
| 278 | // | |
| 279 | // The trailing group is horizontal whitespace only. `\\s` matches a | |
| 280 | // newline, so with the `m` flag it ran past the end of the line and took | |
| 281 | // every following line with it — a multi-stage Dockerfile that builds | |
| 282 | // from the same image twice had both `FROM` lines collapsed into one. | |
| 254 | 283 | const fromRegex = new RegExp( |
| 255 | `(FROM\\s+${escapedImageName})(:)([^\\s]+)(\\s.*)?$`, | |
| 284 | `(FROM\\s+${escapedImageName})(:)([^\\s@]+)(@sha256:[a-f0-9]+)?([ \\t].*)?$`, | |
| 256 | 285 | 'gim', |
| 257 | 286 | ) |
| 258 | 287 | |
| @@ -269,19 +298,40 @@ export async function updateDockerfile(filePath: string, content: string, update | ||
| 269 | 298 | const fullMatch = match[0] |
| 270 | 299 | const beforeColon = match[1] // "FROM image" |
| 271 | 300 | const colon = match[2] // ":" |
| 272 | const currentVersion = match[3] // "tag" | |
| 273 | const afterVersion = match[4] || '' // " as alias" or empty | |
| 301 | const currentVersion = match[3] // "tag", without any digest | |
| 302 | const currentDigest = match[4] || '' // "@sha256:…" or empty | |
| 303 | const afterVersion = match[5] || '' // " as alias" or empty | |
| 274 | 304 | |
| 275 | 305 | if (shouldRespectVersion(currentVersion)) { |
| 276 | 306 | getDefaultLogger().info(`⚠️ Skipping update for ${cleanImageName} - version "${currentVersion}" should be respected`) |
| 277 | 307 | continue |
| 278 | 308 | } |
| 279 | 309 | |
| 310 | // A pinned reference has to move both halves together. Writing the new | |
| 311 | // tag beside the old digest would leave the old image running while | |
| 312 | // the file claims otherwise, and dropping the digest silently unpins | |
| 313 | // an image somebody pinned on purpose — both worse than not updating. | |
| 314 | let digest = currentDigest | |
| 315 | if (currentDigest) { | |
| 316 | const resolved = await resolveDockerDigest(cleanImageName, update.newVersion, registries) | |
| 317 | ||
| 318 | if (!resolved) { | |
| 319 | getDefaultLogger().warn( | |
| 320 | `⚠️ Leaving ${cleanImageName} pinned at ${currentVersion}: could not resolve the digest for ${update.newVersion}`, | |
| 321 | ) | |
| 322 | continue | |
| 323 | } | |
| 324 | ||
| 325 | digest = `@${resolved}` | |
| 326 | } | |
| 327 | ||
| 280 | 328 | // Replace with new version |
| 281 | const replacement = `${beforeColon}${colon}${update.newVersion}${afterVersion}` | |
| 329 | const replacement = `${beforeColon}${colon}${update.newVersion}${digest}${afterVersion}` | |
| 282 | 330 | updatedContent = updatedContent.replace(fullMatch, replacement) |
| 283 | 331 | |
| 284 | getDefaultLogger().info(`📝 Updated ${cleanImageName}: ${currentVersion} → ${update.newVersion}`) | |
| 332 | getDefaultLogger().info( | |
| 333 | `📝 Updated ${cleanImageName}: ${currentVersion} → ${update.newVersion}${digest ? ' (digest re-resolved)' : ''}`, | |
| 334 | ) | |
| 285 | 335 | } |
| 286 | 336 | } |
| 287 | 337 | |
| @@ -296,7 +346,10 @@ export async function updateDockerfile(filePath: string, content: string, update | ||
| 296 | 346 | /** |
| 297 | 347 | * Generate file changes for Dockerfiles |
| 298 | 348 | */ |
| 299 | export async function generateDockerfileUpdates(updates: PackageUpdate[]): Promise<Array<{ path: string, content: string, type: 'update' }>> { | |
| 349 | export async function generateDockerfileUpdates( | |
| 350 | updates: PackageUpdate[], | |
| 351 | registries?: DockerRegistryConfig, | |
| 352 | ): Promise<Array<{ path: string, content: string, type: 'update' }>> { | |
| 300 | 353 | const fileUpdates: Array<{ path: string, content: string, type: 'update' }> = [] |
| 301 | 354 | |
| 302 | 355 | // Group updates by file |
| @@ -318,7 +371,7 @@ export async function generateDockerfileUpdates(updates: PackageUpdate[]): Promi | ||
| 318 | 371 | const fs = await import('node:fs') |
| 319 | 372 | if (fs.existsSync(filePath)) { |
| 320 | 373 | const currentContent = fs.readFileSync(filePath, 'utf-8') |
| 321 | const updatedContent = await updateDockerfile(filePath, currentContent, dockerUpdates) | |
| 374 | const updatedContent = await updateDockerfile(filePath, currentContent, dockerUpdates, registries) | |
| 322 | 375 | |
| 323 | 376 | // Only add file update if content actually changed |
| 324 | 377 | if (updatedContent !== currentContent) { |
| @@ -0,0 +1,175 @@ | ||
| 1 | import type { PackageUpdate } from '../src/types' | |
| 2 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 3 | import { parseDockerfile, updateDockerfile } from '../src/utils/dockerfile-parser' | |
| 4 | ||
| 5 | const realFetch = globalThis.fetch | |
| 6 | ||
| 7 | afterEach(() => { | |
| 8 | globalThis.fetch = realFetch | |
| 9 | }) | |
| 10 | ||
| 11 | const DIGEST = 'sha256:aaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa7777bbbb8888' | |
| 12 | const NEW_DIGEST = 'sha256:9999888877776666555544443333222211110000ffffeeeeddddccccbbbbaaaa' | |
| 13 | ||
| 14 | function update(overrides: Partial<PackageUpdate> = {}): PackageUpdate { | |
| 15 | return { | |
| 16 | name: 'node', | |
| 17 | currentVersion: '20', | |
| 18 | newVersion: '22', | |
| 19 | updateType: 'major', | |
| 20 | dependencyType: 'docker-image', | |
| 21 | file: 'Dockerfile', | |
| 22 | ...overrides, | |
| 23 | } as PackageUpdate | |
| 24 | } | |
| 25 | ||
| 26 | /** Answer every registry request with a manifest carrying this digest. */ | |
| 27 | function registryReturning(digest: string | null): void { | |
| 28 | globalThis.fetch = (async (input: string) => { | |
| 29 | if (digest === null) | |
| 30 | return new Response('not found', { status: 404 }) | |
| 31 | ||
| 32 | if (String(input).includes('/token') || String(input).includes('auth')) | |
| 33 | return new Response(JSON.stringify({ token: 't' }), { status: 200 }) | |
| 34 | ||
| 35 | return new Response(JSON.stringify({ schemaVersion: 2 }), { | |
| 36 | status: 200, | |
| 37 | headers: { 'docker-content-digest': digest, 'content-type': 'application/vnd.oci.image.manifest.v1+json' }, | |
| 38 | }) | |
| 39 | }) as unknown as typeof fetch | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * `resolveDockerDigest` was written — with a docstring explaining that moving | |
| 44 | * a tag without moving its digest "would leave the old image running while the | |
| 45 | * file claims otherwise" — and never called. Two things followed from that. | |
| 46 | */ | |
| 47 | describe('digest-pinned base images', () => { | |
| 48 | describe('parsing', () => { | |
| 49 | it('success case - a pinned reference names the image, not the digest', async () => { | |
| 50 | // `node:20@sha256:abc` was split on the *last* colon, giving the image | |
| 51 | // `node:20@sha256` at version `abc`. That is not an image, is never | |
| 52 | // found in any registry, and appears on the dashboard as though real. | |
| 53 | const parsed = await parseDockerfile('Dockerfile', `FROM node:20@${DIGEST}\n`) | |
| 54 | ||
| 55 | expect(parsed?.dependencies).toEqual([ | |
| 56 | { name: 'node', currentVersion: '20', type: 'docker-image', file: 'Dockerfile' }, | |
| 57 | ]) | |
| 58 | }) | |
| 59 | ||
| 60 | it('success case - an unpinned reference is unchanged', async () => { | |
| 61 | const parsed = await parseDockerfile('Dockerfile', 'FROM alpine:3.19\n') | |
| 62 | ||
| 63 | expect(parsed?.dependencies[0]).toMatchObject({ name: 'alpine', currentVersion: '3.19' }) | |
| 64 | }) | |
| 65 | ||
| 66 | it('edge case - a registry with a port still parses', async () => { | |
| 67 | const parsed = await parseDockerfile('Dockerfile', `FROM registry.io:5000/team/app:2.1@${DIGEST}\n`) | |
| 68 | ||
| 69 | expect(parsed?.dependencies[0]).toMatchObject({ | |
| 70 | name: 'registry.io:5000/team/app', | |
| 71 | currentVersion: '2.1', | |
| 72 | }) | |
| 73 | }) | |
| 74 | ||
| 75 | it('edge case - a bare digest reference is still skipped', async () => { | |
| 76 | const parsed = await parseDockerfile('Dockerfile', `FROM node@${DIGEST}\n`) | |
| 77 | ||
| 78 | expect(parsed?.dependencies).toEqual([]) | |
| 79 | }) | |
| 80 | }) | |
| 81 | ||
| 82 | describe('updating', () => { | |
| 83 | it('failure case - the pin is never silently dropped', async () => { | |
| 84 | // The regression: `FROM node:20@sha256:…` became `FROM node:22`, which | |
| 85 | // unpins an image somebody pinned on purpose. | |
| 86 | registryReturning(NEW_DIGEST) | |
| 87 | ||
| 88 | const result = await updateDockerfile('Dockerfile', `FROM node:20@${DIGEST}\n`, [update()]) | |
| 89 | ||
| 90 | expect(result).not.toBe(`FROM node:22\n`) | |
| 91 | expect(result).toContain('@sha256:') | |
| 92 | }) | |
| 93 | ||
| 94 | it('success case - the digest moves with the tag', async () => { | |
| 95 | registryReturning(NEW_DIGEST) | |
| 96 | ||
| 97 | const result = await updateDockerfile('Dockerfile', `FROM node:20@${DIGEST}\n`, [update()]) | |
| 98 | ||
| 99 | expect(result).toBe(`FROM node:22@${NEW_DIGEST}\n`) | |
| 100 | }) | |
| 101 | ||
| 102 | it('failure case - an unresolvable digest leaves the line alone', async () => { | |
| 103 | // Writing the new tag beside the old digest would leave the old image | |
| 104 | // running while the file claims otherwise — worse than not updating. | |
| 105 | registryReturning(null) | |
| 106 | ||
| 107 | const result = await updateDockerfile('Dockerfile', `FROM node:20@${DIGEST}\n`, [update()]) | |
| 108 | ||
| 109 | expect(result).toBe(`FROM node:20@${DIGEST}\n`) | |
| 110 | }) | |
| 111 | ||
| 112 | it('success case - an unpinned image needs no registry call', async () => { | |
| 113 | let called = false | |
| 114 | globalThis.fetch = (async () => { | |
| 115 | called = true | |
| 116 | return new Response('{}', { status: 200 }) | |
| 117 | }) as unknown as typeof fetch | |
| 118 | ||
| 119 | const result = await updateDockerfile('Dockerfile', 'FROM node:20\n', [update()]) | |
| 120 | ||
| 121 | expect(result).toBe('FROM node:22\n') | |
| 122 | expect(called).toBe(false) | |
| 123 | }) | |
| 124 | ||
| 125 | it('success case - a stage alias survives the rewrite', async () => { | |
| 126 | registryReturning(NEW_DIGEST) | |
| 127 | ||
| 128 | const result = await updateDockerfile('Dockerfile', `FROM node:20@${DIGEST} as build\n`, [update()]) | |
| 129 | ||
| 130 | expect(result).toBe(`FROM node:22@${NEW_DIGEST} as build\n`) | |
| 131 | }) | |
| 132 | ||
| 133 | it('edge case - a pinned and an unpinned line update independently', async () => { | |
| 134 | registryReturning(NEW_DIGEST) | |
| 135 | ||
| 136 | const result = await updateDockerfile( | |
| 137 | 'Dockerfile', | |
| 138 | `FROM node:20@${DIGEST}\nFROM node:20 as tools\n`, | |
| 139 | [update()], | |
| 140 | ) | |
| 141 | ||
| 142 | expect(result).toContain(`FROM node:22@${NEW_DIGEST}`) | |
| 143 | expect(result).toContain('FROM node:22 as tools') | |
| 144 | }) | |
| 145 | }) | |
| 146 | }) | |
| 147 | ||
| 148 | /** | |
| 149 | * Pre-existing, and found while fixing the digest handling: the trailing group | |
| 150 | * was `(\s.*)?$`, and `\s` matches a newline. With the `m` flag the match ran | |
| 151 | * past the end of its line and took every following line with it. | |
| 152 | */ | |
| 153 | describe('a FROM line stays on its line', () => { | |
| 154 | it('failure case - a repeated base image does not collapse the file', async () => { | |
| 155 | // A multi-stage build from the same image twice had both `FROM` lines | |
| 156 | // replaced by a single one, silently deleting a build stage. | |
| 157 | const content = 'FROM node:20 as build\nRUN bun install\nFROM node:20 as run\n' | |
| 158 | ||
| 159 | const result = await updateDockerfile('Dockerfile', content, [update()]) | |
| 160 | ||
| 161 | expect(result.split('\n').filter(line => line.startsWith('FROM'))).toEqual([ | |
| 162 | 'FROM node:22 as build', | |
| 163 | 'FROM node:22 as run', | |
| 164 | ]) | |
| 165 | expect(result).toContain('RUN bun install') | |
| 166 | }) | |
| 167 | ||
| 168 | it('success case - lines between two FROMs are untouched', async () => { | |
| 169 | const content = 'FROM node:20\nCOPY . .\nFROM alpine:3.19\n' | |
| 170 | ||
| 171 | const result = await updateDockerfile('Dockerfile', content, [update()]) | |
| 172 | ||
| 173 | expect(result).toBe('FROM node:22\nCOPY . .\nFROM alpine:3.19\n') | |
| 174 | }) | |
| 175 | }) | |
| @@ -0,0 +1,224 @@ | ||
| 1 | import type { PackageFile, PackageUpdate } from '../src/types' | |
| 2 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 3 | import { promises as fs } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { Buddy } from '../src/buddy' | |
| 7 | import { validateConfig } from '../src/config-validation' | |
| 8 | import { applyEngineUpdates, engineUpdateType } from '../src/scanner/package-json-extras' | |
| 9 | ||
| 10 | const realFetch = globalThis.fetch | |
| 11 | ||
| 12 | afterEach(() => { | |
| 13 | globalThis.fetch = realFetch | |
| 14 | }) | |
| 15 | ||
| 16 | const MANIFEST = `{ | |
| 17 | "name": "app", | |
| 18 | "engines": { | |
| 19 | "node": ">=20", | |
| 20 | "pnpm": "^9.0.0" | |
| 21 | }, | |
| 22 | "devDependencies": { | |
| 23 | "pnpm": "^9.0.0" | |
| 24 | } | |
| 25 | } | |
| 26 | ` | |
| 27 | ||
| 28 | function engine(name: string, current: string, next: string): PackageUpdate { | |
| 29 | return { | |
| 30 | name, | |
| 31 | currentVersion: current, | |
| 32 | newVersion: next, | |
| 33 | updateType: engineUpdateType(current, next), | |
| 34 | dependencyType: 'engines', | |
| 35 | file: 'package.json', | |
| 36 | } as PackageUpdate | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * `resolveEngineVersion` and `bumpEngineConstraint` were written, tested and | |
| 41 | * exported, and no scan step called either — so an engine was listed on the | |
| 42 | * dashboard and never proposed. Had one been, the package.json writer only | |
| 43 | * looks in the four dependency sections, so it would have logged "not found" | |
| 44 | * and written nothing. | |
| 45 | */ | |
| 46 | describe('engine update type', () => { | |
| 47 | it('success case - classifies by the first component that moved', () => { | |
| 48 | expect(engineUpdateType('>=20', '>=22')).toBe('major') | |
| 49 | expect(engineUpdateType('>=20.5', '>=20.9')).toBe('minor') | |
| 50 | expect(engineUpdateType('^1.2.3', '^1.2.4')).toBe('patch') | |
| 51 | }) | |
| 52 | }) | |
| 53 | ||
| 54 | describe('rewriting engine constraints', () => { | |
| 55 | it('success case - rewrites the constraint and nothing else', () => { | |
| 56 | const result = applyEngineUpdates(MANIFEST, [engine('node', '>=20', '>=22')]) | |
| 57 | ||
| 58 | expect(result).toContain('"node": ">=22"') | |
| 59 | // Indentation, key order and the trailing newline are the author's. | |
| 60 | expect(result.split('\n').length).toBe(MANIFEST.split('\n').length) | |
| 61 | expect(result.endsWith('\n')).toBe(true) | |
| 62 | }) | |
| 63 | ||
| 64 | it('failure case - a name shared with a dependency is not touched there', () => { | |
| 65 | // `pnpm` is both an engine and a devDependency here. Only the engine | |
| 66 | // moves; rewriting the devDependency would be a different update. | |
| 67 | const result = applyEngineUpdates(MANIFEST, [engine('pnpm', '^9.0.0', '^10.0.0')]) | |
| 68 | ||
| 69 | expect(result).toContain('"engines": {\n "node": ">=20",\n "pnpm": "^10.0.0"') | |
| 70 | expect(result).toContain('"devDependencies": {\n "pnpm": "^9.0.0"') | |
| 71 | }) | |
| 72 | ||
| 73 | it('success case - applies several engines in one pass', () => { | |
| 74 | const result = applyEngineUpdates(MANIFEST, [ | |
| 75 | engine('node', '>=20', '>=22'), | |
| 76 | engine('pnpm', '^9.0.0', '^10.0.0'), | |
| 77 | ]) | |
| 78 | ||
| 79 | expect(result).toContain('"node": ">=22"') | |
| 80 | expect(result).toContain('"pnpm": "^10.0.0"') | |
| 81 | }) | |
| 82 | ||
| 83 | it('edge case - an unmatched constraint leaves the file identical', () => { | |
| 84 | expect(applyEngineUpdates(MANIFEST, [engine('node', '>=18', '>=22')])).toBe(MANIFEST) | |
| 85 | }) | |
| 86 | ||
| 87 | it('edge case - a manifest with no engines block is untouched', () => { | |
| 88 | const bare = '{\n "name": "app"\n}\n' | |
| 89 | expect(applyEngineUpdates(bare, [engine('node', '>=20', '>=22')])).toBe(bare) | |
| 90 | }) | |
| 91 | }) | |
| 92 | ||
| 93 | describe('checking engines for updates', () => { | |
| 94 | function manifestWith(engines: Record<string, string>): PackageFile { | |
| 95 | return { | |
| 96 | path: 'package.json', | |
| 97 | type: 'package.json', | |
| 98 | content: '', | |
| 99 | dependencies: Object.entries(engines).map(([name, currentVersion]) => ({ | |
| 100 | name, | |
| 101 | currentVersion, | |
| 102 | type: 'engines' as const, | |
| 103 | file: 'package.json', | |
| 104 | })), | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | function buddyWithRegistry(latest: Record<string, string | null>): Buddy { | |
| 109 | const buddy = new Buddy({ packages: { strategy: 'all', engines: true } }, process.cwd()) | |
| 110 | ;(buddy as never as { registryClient: { getLatestVersion: (n: string) => Promise<string | null> } }) | |
| 111 | .registryClient.getLatestVersion = async name => latest[name] ?? null | |
| 112 | return buddy | |
| 113 | } | |
| 114 | ||
| 115 | async function check(buddy: Buddy, files: PackageFile[]): Promise<PackageUpdate[]> { | |
| 116 | return (buddy as never as { checkEnginesForUpdates: (f: PackageFile[]) => Promise<PackageUpdate[]> }) | |
| 117 | .checkEnginesForUpdates(files) | |
| 118 | } | |
| 119 | ||
| 120 | it('success case - an npm-published runtime is resolved through the registry', async () => { | |
| 121 | const updates = await check(buddyWithRegistry({ pnpm: '10.4.1' }), [manifestWith({ pnpm: '^9.0.0' })]) | |
| 122 | ||
| 123 | expect(updates).toEqual([ | |
| 124 | expect.objectContaining({ | |
| 125 | name: 'pnpm', | |
| 126 | currentVersion: '^9.0.0', | |
| 127 | newVersion: '^10.4.1', | |
| 128 | updateType: 'major', | |
| 129 | dependencyType: 'engines', | |
| 130 | }), | |
| 131 | ]) | |
| 132 | }) | |
| 133 | ||
| 134 | it('success case - node is resolved through its GitHub releases', async () => { | |
| 135 | globalThis.fetch = (async (input: string) => { | |
| 136 | expect(String(input)).toContain('/repos/nodejs/node/releases/latest') | |
| 137 | return new Response(JSON.stringify({ tag_name: 'v22.3.0' }), { status: 200 }) | |
| 138 | }) as unknown as typeof fetch | |
| 139 | ||
| 140 | const updates = await check(buddyWithRegistry({}), [manifestWith({ node: '>=20' })]) | |
| 141 | ||
| 142 | expect(updates[0]).toMatchObject({ name: 'node', newVersion: '>=22' }) | |
| 143 | }) | |
| 144 | ||
| 145 | it('success case - the constraint keeps its shape', async () => { | |
| 146 | // A floor stays a floor at the author's precision: `>=20` becomes `>=22`, | |
| 147 | // never `22.3.0`, which would break every contributor on another patch. | |
| 148 | globalThis.fetch = (async () => | |
| 149 | new Response(JSON.stringify({ tag_name: 'v22.3.0' }), { status: 200 })) as unknown as typeof fetch | |
| 150 | ||
| 151 | const updates = await check(buddyWithRegistry({}), [manifestWith({ node: '>=20.5' })]) | |
| 152 | ||
| 153 | expect(updates[0]?.newVersion).toBe('>=22.3') | |
| 154 | }) | |
| 155 | ||
| 156 | it('edge case - a constraint already at the latest yields nothing', async () => { | |
| 157 | // Compared at the author's precision: a floor of `>=9` is not moved by a | |
| 158 | // 9.x release, only by 10. | |
| 159 | const updates = await check(buddyWithRegistry({ pnpm: '9.1.0' }), [manifestWith({ pnpm: '>=9' })]) | |
| 160 | ||
| 161 | expect(updates).toEqual([]) | |
| 162 | }) | |
| 163 | ||
| 164 | it('success case - a precise caret constraint follows the latest within its major', async () => { | |
| 165 | // `^9.0.0` admits 9.1.0 already, but the floor is what the author wrote, | |
| 166 | // and keeping it current is the point — the same way `^9.0.0` on a | |
| 167 | // dependency is bumped to `^9.1.0`. | |
| 168 | const updates = await check(buddyWithRegistry({ pnpm: '9.1.0' }), [manifestWith({ pnpm: '^9.0.0' })]) | |
| 169 | ||
| 170 | expect(updates[0]).toMatchObject({ newVersion: '^9.1.0', updateType: 'minor' }) | |
| 171 | }) | |
| 172 | ||
| 173 | it('edge case - an unresolvable runtime yields nothing rather than an error', async () => { | |
| 174 | const updates = await check(buddyWithRegistry({ pnpm: null }), [manifestWith({ pnpm: '^9.0.0' })]) | |
| 175 | ||
| 176 | expect(updates).toEqual([]) | |
| 177 | }) | |
| 178 | ||
| 179 | it('edge case - a manifest with no engines makes no lookups', async () => { | |
| 180 | let looked = false | |
| 181 | const buddy = buddyWithRegistry({}) | |
| 182 | ;(buddy as never as { registryClient: { getLatestVersion: () => Promise<null> } }) | |
| 183 | .registryClient.getLatestVersion = async () => { | |
| 184 | looked = true | |
| 185 | return null | |
| 186 | } | |
| 187 | ||
| 188 | expect(await check(buddy, [manifestWith({})])).toEqual([]) | |
| 189 | expect(looked).toBe(false) | |
| 190 | }) | |
| 191 | }) | |
| 192 | ||
| 193 | describe('the opt-in', () => { | |
| 194 | it('success case - engines is a boolean in config', () => { | |
| 195 | expect(validateConfig({ packages: { strategy: 'all', engines: true } })).toEqual([]) | |
| 196 | }) | |
| 197 | ||
| 198 | it('failure case - a non-boolean is rejected', () => { | |
| 199 | const issues = validateConfig({ packages: { strategy: 'all', engines: 'yes' as never } }) | |
| 200 | ||
| 201 | expect(issues.some(issue => issue.path === 'packages.engines')).toBe(true) | |
| 202 | }) | |
| 203 | }) | |
| 204 | ||
| 205 | describe('writing engines through the file generator', () => { | |
| 206 | it('success case - an engines update reaches the manifest on disk', async () => { | |
| 207 | const dir = await fs.mkdtemp(join(tmpdir(), 'buddy-engines-')) | |
| 208 | const path = join(dir, 'package.json') | |
| 209 | await fs.writeFile(path, MANIFEST) | |
| 210 | ||
| 211 | try { | |
| 212 | const buddy = new Buddy({ packages: { strategy: 'all' } }, dir) | |
| 213 | const changes = await buddy.generateAllFileUpdates([{ ...engine('node', '>=20', '>=22'), file: path }]) | |
| 214 | ||
| 215 | // Before this, the update went through the dependency-section loop, | |
| 216 | // logged "Package node not found", and produced no change at all. | |
| 217 | expect(changes).toHaveLength(1) | |
| 218 | expect(changes[0].content).toContain('"node": ">=22"') | |
| 219 | } | |
| 220 | finally { | |
| 221 | await fs.rm(dir, { recursive: true, force: true }) | |
| 222 | } | |
| 223 | }) | |
| 224 | }) | |