also looking at this
fix(ci): enforce dependency update boundaries
#311
4 files
+163
-28
| @@ -0,0 +1,80 @@ | ||
| 1 | type Strategy = 'patch' | 'minor' | 'major' | 'all' | |
| 2 | interface Update { | |
| 3 | name: string | |
| 4 | currentVersion: string | |
| 5 | newVersion: string | |
| 6 | updateType: string | |
| 7 | [key: string]: unknown | |
| 8 | } | |
| 9 | ||
| 10 | export function updateOptions(env: Record<string, string | undefined>) { | |
| 11 | const strategy = env.BUDDY_STRATEGY || 'patch' | |
| 12 | if (!['patch', 'minor', 'major', 'all'].includes(strategy)) | |
| 13 | throw new Error(`Unknown update strategy: ${strategy}`) | |
| 14 | const dryRun = env.BUDDY_DRY_RUN ?? 'true' | |
| 15 | if (!['true', 'false'].includes(dryRun)) | |
| 16 | throw new Error('BUDDY_DRY_RUN must be true or false') | |
| 17 | const packages = (env.BUDDY_PACKAGES || '').split(',').map(name => name.trim()).filter(Boolean) | |
| 18 | return { strategy: strategy as Strategy, dryRun: dryRun === 'true', packages } | |
| 19 | } | |
| 20 | ||
| 21 | function versionParts(value: string): number[] | null { | |
| 22 | // Ambiguous ranges, prereleases, git refs and SHA/digest pins need manual | |
| 23 | // review. Never replace a pinned action with an unpinned version tag. | |
| 24 | const match = /^[~^]?v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(value) | |
| 25 | if (!match) return null | |
| 26 | const parts = match.slice(1).map(Number) | |
| 27 | return parts.every(Number.isSafeInteger) ? parts : null | |
| 28 | } | |
| 29 | ||
| 30 | export function selectUpdates(updates: Update[], options: ReturnType<typeof updateOptions>): Update[] { | |
| 31 | return updates.flatMap((update) => { | |
| 32 | if (!update || ['name', 'currentVersion', 'newVersion'].some(key => typeof update[key] !== 'string' || !update[key])) | |
| 33 | throw new Error('Buddy Bot returned a malformed update') | |
| 34 | if (options.packages.length && !options.packages.includes(update.name)) return [] | |
| 35 | const before = versionParts(update.currentVersion) | |
| 36 | const after = versionParts(update.newVersion) | |
| 37 | if (!before || !after) return [] | |
| 38 | const index = before.findIndex((part, index) => part !== after[index]) | |
| 39 | if (index < 0 || after[index] < before[index]) return [] | |
| 40 | const kind = ['major', 'minor', 'patch'][index] | |
| 41 | const allowed = options.strategy === 'all' | |
| 42 | || (options.strategy === 'minor' ? index >= 1 : kind === options.strategy) | |
| 43 | // Do not trust a scanner's classification to enforce the version limit. | |
| 44 | return allowed ? [{ ...update, updateType: kind }] : [] | |
| 45 | }) | |
| 46 | } | |
| 47 | ||
| 48 | export async function runUpdates( | |
| 49 | buddy: { scanForUpdates: () => Promise<any>, createPullRequests: (scan: any) => Promise<void> }, | |
| 50 | regroup: (updates: any[]) => any[], | |
| 51 | options: ReturnType<typeof updateOptions>, | |
| 52 | ) { | |
| 53 | const scan = await buddy.scanForUpdates() | |
| 54 | if (!Array.isArray(scan?.updates)) throw new Error('Buddy Bot returned no updates array') | |
| 55 | const updates = selectUpdates(scan.updates, options) | |
| 56 | // Discard the broad scanner groups: passing them through would bypass the | |
| 57 | // guard even if the top-level updates list was filtered correctly. | |
| 58 | const selected = { ...scan, updates, groups: regroup(updates) } | |
| 59 | if (!options.dryRun && updates.length) await buddy.createPullRequests(selected) | |
| 60 | return selected | |
| 61 | } | |
| 62 | ||
| 63 | if (import.meta.main) { | |
| 64 | const options = updateOptions(process.env) | |
| 65 | const repository = (process.env.GITHUB_REPOSITORY || '').split('/') | |
| 66 | if (repository.length !== 2 || repository.some(part => !/^[\w.-]+$/.test(part))) | |
| 67 | throw new Error('GITHUB_REPOSITORY must identify the target owner/repository') | |
| 68 | if (!options.dryRun && !process.env.GITHUB_TOKEN && !process.env.BUDDY_BOT_TOKEN) | |
| 69 | throw new Error('Applying updates requires the configured Buddy Bot token') | |
| 70 | const { Buddy, groupUpdates } = await import('buddy-bot') | |
| 71 | const buddy = new Buddy({ | |
| 72 | repository: { provider: 'github', owner: repository[0], name: repository[1], baseBranch: process.env.BUDDY_BASE_BRANCH || 'main' }, | |
| 73 | packages: { strategy: 'all', respectLatest: true }, | |
| 74 | verbose: process.env.BUDDY_VERBOSE === 'true', | |
| 75 | }) | |
| 76 | const selected = await runUpdates(buddy, groupUpdates, options) | |
| 77 | console.log(`${options.dryRun ? 'Dry run: selected' : 'Processed'} ${selected.updates.length} updates in ${selected.groups.length} groups (${options.strategy}).`) | |
| 78 | for (const update of selected.updates) | |
| 79 | console.log(JSON.stringify({ name: update.name, from: update.currentVersion, to: update.newVersion })) | |
| 80 | } | |