ReviewOS

also looking at this

craft-native/craft

fix(ci): enforce dependency update boundaries

#311
Merged glennmichael123 wants to merge codex/buddy-update-boundaries into main
4 files +163 -28
scripts/buddy-update.tsadded+80-0
Changes to scripts/buddy-update.ts
@@ -0,0 +1,80 @@
1type Strategy = 'patch' | 'minor' | 'major' | 'all'
2interface Update {
3 name: string
4 currentVersion: string
5 newVersion: string
6 updateType: string
7 [key: string]: unknown
8}
9
10export 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
21function 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
30export 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
48export 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
63if (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}