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
.github/workflows/buddy-bot.ymlmodified+7-27
Changes to .github/workflows/buddy-bot.yml
@@ -26,7 +26,7 @@ on:
2626 - dashboard
2727 # Update job inputs
2828 strategy:
29 description: Update strategy
29 description: Patch only, minor plus patches, major only, or all stable versions; pinned refs require manual review
3030 required: false
3131 default: patch
3232 type: choice
@@ -292,33 +292,13 @@ jobs:
292292 echo "Branch: $"
293293
294294 - name: Run Buddy dependency updates
295 if: ${{ github.event.inputs.dry_run != 'true' }}
296 run: |
297 STRATEGY="$"
298 PACKAGES="$"
299 VERBOSE="$"
300
301 echo "🚀 Running dependency updates..."
302 echo "This will create/update PRs if outdated dependencies are found"
303 echo ""
304
305 set -e # Exit on any error
306
307 if [ "$PACKAGES" != "" ]; then
308 if [ "$VERBOSE" = "true" ]; then
309 bunx buddy-bot update --packages "$PACKAGES" --verbose
310 else
311 bunx buddy-bot update --packages "$PACKAGES"
312 fi
313 else
314 if [ "$VERBOSE" = "true" ]; then
315 bunx buddy-bot update --strategy "$STRATEGY" --verbose
316 else
317 bunx buddy-bot update --strategy "$STRATEGY"
318 fi
319 fi
320
295 run: bun scripts/buddy-update.ts
321296 env:
297 BUDDY_STRATEGY: ${{ github.event.inputs.strategy || 'patch' }}
298 BUDDY_PACKAGES: ${{ github.event.inputs.packages }}
299 BUDDY_DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
300 BUDDY_VERBOSE: ${{ github.event.inputs.verbose || 'true' }}
301 BUDDY_BASE_BRANCH: ${{ github.event.repository.default_branch }}
322302 GITHUB_TOKEN: ${{ secrets.BUDDY_BOT_TOKEN || secrets.GITHUB_TOKEN }}
323303 BUDDY_BOT_TOKEN: ${{ secrets.BUDDY_BOT_TOKEN }}
324304
.github/workflows/ci.ymlmodified+1-1
Changes to .github/workflows/ci.yml
@@ -287,7 +287,7 @@ jobs:
287287 run: bun run verify:docs
288288
289289 - name: Test dependency-bot job selection
290 run: bun test scripts/buddy-schedule.test.ts
290 run: bun test scripts/buddy-schedule.test.ts scripts/buddy-update.test.ts
291291
292292 - name: Test merged SBOM inventory
293293 run: bun test scripts/merge-sbom.test.ts
scripts/buddy-update.test.tsadded+75-0
Changes to scripts/buddy-update.test.ts
@@ -0,0 +1,75 @@
1import { expect, test } from 'bun:test'
2import { readFileSync } from 'node:fs'
3import { join } from 'node:path'
4import { groupUpdates } from 'buddy-bot'
5import { runUpdates, selectUpdates, updateOptions } from './buddy-update'
6
7const update = (name: string, currentVersion: string, newVersion: string) => ({ name, currentVersion, newVersion, updateType: 'patch', file: 'package.json', dependencyType: 'dependencies' })
8const candidates = [update('patch', '^1.2.3', '1.2.4'), update('minor', '~1.2.3', '1.3.0'), update('major', '1.2.3', '2.0.0')]
9
10test('strategy bounds use versions even when every candidate is labelled patch', () => {
11 for (const [strategy, names] of Object.entries({ patch: ['patch'], minor: ['patch', 'minor'], major: ['major'], all: ['patch', 'minor', 'major'] })) {
12 const selected = selectUpdates(candidates, updateOptions({ BUDDY_STRATEGY: strategy }))
13 expect(selected.map(item => item.name)).toEqual(names)
14 expect(selected.map(item => item.updateType)).toEqual(names)
15 }
16})
17
18test('package selection is exact and intersects the version strategy', () => {
19 const options = updateOptions({ BUDDY_PACKAGES: ' patch, major, patch ' })
20 expect(selectUpdates(candidates, options).map(item => item.name)).toEqual(['patch'])
21 expect(selectUpdates(candidates, updateOptions({ BUDDY_PACKAGES: 'pat' }))).toEqual([])
22})
23
24test('pinned, ambiguous, prerelease, same-version and downgrade candidates are retained unchanged', () => {
25 for (const [before, after] of [
26 ['235036fa0f48bae99b2293df5a3dc35c809b1777', '0.11.62'],
27 ['sha256:abc', '2.0.0'], ['v4', 'v5'], ['^3.0.0 || ^4.0.0', '5.57.1'],
28 ['workspace:*', '1.0.0'], ['latest', '1.0.0'], ['1.0.0-beta.1', '1.0.0'],
29 ['1.0.0', '1.0.0-beta.2'], ['1.0.0', '1.0.0'], ['2.0.0', '1.9.9'],
30 ['1.2.3', '1.2.2'], ['9007199254740992.0.0', '9007199254740993.0.0'],
31 ['01.2.3', '1.2.4'],
32 ]) {
33 expect(selectUpdates([update('candidate', before, after)], updateOptions({ BUDDY_STRATEGY: 'all' }))).toEqual([])
34 }
35 expect(selectUpdates([update('safe', 'v1.2.3', 'v1.2.4')], updateOptions({}))).toHaveLength(1)
36})
37
38test('dry run scans but never creates PRs; apply replaces unfiltered groups', async () => {
39 let calls = 0
40 const created: any[] = []
41 const buddy = {
42 scanForUpdates: async () => { calls++; return { updates: candidates, groups: [{ updates: candidates }] } },
43 createPullRequests: async (scan: any) => { created.push(scan) },
44 }
45 const preview = await runUpdates(buddy, groupUpdates, updateOptions({}))
46 expect(calls).toBe(1)
47 expect(created).toEqual([])
48 expect(preview.groups.flatMap(group => group.updates).map(item => item.name)).toEqual(['patch'])
49 await runUpdates(buddy, groupUpdates, updateOptions({ BUDDY_DRY_RUN: 'false' }))
50 expect(created).toHaveLength(1)
51 expect(created[0].updates.map((item: any) => item.name)).toEqual(['patch'])
52 expect(created[0].groups.flatMap((group: any) => group.updates).map((item: any) => item.name)).toEqual(['patch'])
53 await runUpdates(buddy, groupUpdates, updateOptions({ BUDDY_DRY_RUN: 'false', BUDDY_PACKAGES: 'absent' }))
54 expect(created).toHaveLength(1)
55})
56
57test('invalid options and scanner failures remain visible', async () => {
58 expect(() => updateOptions({ BUDDY_STRATEGY: 'typo' })).toThrow()
59 expect(() => updateOptions({ BUDDY_DRY_RUN: 'maybe' })).toThrow()
60 const neverCreate = async () => { throw new Error('must not create') }
61 await expect(runUpdates({ scanForUpdates: async () => { throw new Error('offline') }, createPullRequests: neverCreate }, groupUpdates, updateOptions({}))).rejects.toThrow('offline')
62 await expect(runUpdates({ scanForUpdates: async () => ({}), createPullRequests: neverCreate }, groupUpdates, updateOptions({}))).rejects.toThrow('updates array')
63 await expect(runUpdates({ scanForUpdates: async () => ({ updates: [{}] }), createPullRequests: neverCreate }, groupUpdates, updateOptions({}))).rejects.toThrow('malformed update')
64 await expect(runUpdates({ scanForUpdates: async () => ({ updates: candidates }), createPullRequests: async () => { throw new Error('API failure') } }, groupUpdates, updateOptions({ BUDDY_DRY_RUN: 'false' }))).rejects.toThrow('API failure')
65})
66
67test('workflow applies the same guard to dry runs and real updates', () => {
68 const workflow = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/buddy-bot.yml'), 'utf8')) as any
69 const step = workflow.jobs['dependency-update'].steps.find((step: any) => step.name === 'Run Buddy dependency updates')
70 expect(step.run.trim()).toBe('bun scripts/buddy-update.ts')
71 expect(step.if).toBeUndefined()
72 expect(step.env.BUDDY_STRATEGY).toBe("$")
73 expect(step.env.BUDDY_PACKAGES).toBe('$')
74 expect(step.env.BUDDY_DRY_RUN).toBe("$")
75})
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}