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.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})