also looking at this
fix(ci): enforce dependency update boundaries
#311
4 files
+163
-28
| @@ -26,7 +26,7 @@ on: | ||
| 26 | 26 | - dashboard |
| 27 | 27 | # Update job inputs |
| 28 | 28 | strategy: |
| 29 | description: Update strategy | |
| 29 | description: Patch only, minor plus patches, major only, or all stable versions; pinned refs require manual review | |
| 30 | 30 | required: false |
| 31 | 31 | default: patch |
| 32 | 32 | type: choice |
| @@ -292,33 +292,13 @@ jobs: | ||
| 292 | 292 | echo "Branch: $" |
| 293 | 293 | |
| 294 | 294 | - 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 | |
| 321 | 296 | 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 }} | |
| 322 | 302 | GITHUB_TOKEN: ${{ secrets.BUDDY_BOT_TOKEN || secrets.GITHUB_TOKEN }} |
| 323 | 303 | BUDDY_BOT_TOKEN: ${{ secrets.BUDDY_BOT_TOKEN }} |
| 324 | 304 | |
| @@ -287,7 +287,7 @@ jobs: | ||
| 287 | 287 | run: bun run verify:docs |
| 288 | 288 | |
| 289 | 289 | - 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 | |
| 291 | 291 | |
| 292 | 292 | - name: Test merged SBOM inventory |
| 293 | 293 | run: bun test scripts/merge-sbom.test.ts |
| @@ -0,0 +1,75 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { readFileSync } from 'node:fs' | |
| 3 | import { join } from 'node:path' | |
| 4 | import { groupUpdates } from 'buddy-bot' | |
| 5 | import { runUpdates, selectUpdates, updateOptions } from './buddy-update' | |
| 6 | ||
| 7 | const update = (name: string, currentVersion: string, newVersion: string) => ({ name, currentVersion, newVersion, updateType: 'patch', file: 'package.json', dependencyType: 'dependencies' }) | |
| 8 | const 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 | ||
| 10 | test('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 | ||
| 18 | test('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 | ||
| 24 | test('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 | ||
| 38 | test('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 | ||
| 57 | test('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 | ||
| 67 | test('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 | }) | |
| @@ -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 | } | |