also looking at this
feat(engines): propose runtime bumps, which were resolved and never proposed
#1428
8 files
+649
-14
| @@ -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) { |