also looking at this
fix(rules): apply the per-rule minimum release age, which nothing read
#1419
3 files
+223
-7
| @@ -0,0 +1,191 @@ | ||
| 1 | import type { BuddyConfig, PackageUpdate } from '../src/types' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { Buddy } from '../src/buddy' | |
| 4 | ||
| 5 | /** An update, with only the fields the age filter reads. */ | |
| 6 | function update(name: string): PackageUpdate { | |
| 7 | return { | |
| 8 | name, | |
| 9 | currentVersion: '1.0.0', | |
| 10 | newVersion: '1.1.0', | |
| 11 | updateType: 'minor', | |
| 12 | dependencyType: 'dependencies', | |
| 13 | file: 'package.json', | |
| 14 | } as PackageUpdate | |
| 15 | } | |
| 16 | ||
| 17 | interface Asked { | |
| 18 | name: string | |
| 19 | required?: number | |
| 20 | } | |
| 21 | ||
| 22 | /** | |
| 23 | * Run the filter with the age check stubbed, recording what each package was | |
| 24 | * asked to satisfy. | |
| 25 | * | |
| 26 | * `holdsFor` decides the verdict: a package is kept when the age it is | |
| 27 | * required to meet is at or below what the fixture says it has actually aged. | |
| 28 | */ | |
| 29 | function filter(config: BuddyConfig, updates: PackageUpdate[], agedMinutes: Record<string, number>) { | |
| 30 | const buddy = new Buddy(config, process.cwd()) | |
| 31 | const asked: Asked[] = [] | |
| 32 | ||
| 33 | ;(buddy as any).registryClient.meetsMinimumReleaseAge = async ( | |
| 34 | name: string, | |
| 35 | _version: string, | |
| 36 | _type?: string, | |
| 37 | required?: number, | |
| 38 | ) => { | |
| 39 | asked.push({ name, required }) | |
| 40 | return (agedMinutes[name] ?? 0) >= (required ?? 0) | |
| 41 | } | |
| 42 | ||
| 43 | return (buddy as any).filterUpdatesByMinimumReleaseAge(updates) | |
| 44 | .then((kept: PackageUpdate[]) => ({ kept: kept.map(u => u.name), asked })) | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * `packages.rules[].minimumReleaseAge` was declared on `PackageRule`, resolved | |
| 49 | * into `ResolvedEffects`, validated by `config-validation`, and converted from | |
| 50 | * Renovate's prose durations — and nothing ever read the resolved value. Both | |
| 51 | * age gates read the global setting only, so a rule holding one risky package | |
| 52 | * for a week did nothing at all. | |
| 53 | */ | |
| 54 | describe('per-rule minimum release age', () => { | |
| 55 | it('success case - a rule holds a package the global would have let through', async () => { | |
| 56 | // The headline: no global age at all, and the rule still holds. | |
| 57 | const { kept } = await filter( | |
| 58 | { | |
| 59 | packages: { | |
| 60 | strategy: 'all', | |
| 61 | rules: [{ matchPackages: ['react'], minimumReleaseAge: 10080 }], | |
| 62 | }, | |
| 63 | }, | |
| 64 | [update('react'), update('lodash')], | |
| 65 | { react: 60, lodash: 60 }, | |
| 66 | ) | |
| 67 | ||
| 68 | expect(kept).toEqual(['lodash']) | |
| 69 | }) | |
| 70 | ||
| 71 | it('success case - a rule can hold longer than the global', async () => { | |
| 72 | const { kept, asked } = await filter( | |
| 73 | { | |
| 74 | packages: { | |
| 75 | strategy: 'all', | |
| 76 | minimumReleaseAge: 60, | |
| 77 | rules: [{ matchPackages: ['react'], minimumReleaseAge: 10080 }], | |
| 78 | }, | |
| 79 | }, | |
| 80 | [update('react'), update('lodash')], | |
| 81 | { react: 1440, lodash: 1440 }, | |
| 82 | ) | |
| 83 | ||
| 84 | expect(kept).toEqual(['lodash']) | |
| 85 | expect(asked).toEqual([ | |
| 86 | { name: 'react', required: 10080 }, | |
| 87 | { name: 'lodash', required: 60 }, | |
| 88 | ]) | |
| 89 | }) | |
| 90 | ||
| 91 | it('success case - a rule can release sooner than the global', async () => { | |
| 92 | // Overriding downward matters as much: a repository holding everything for | |
| 93 | // a week still wants its own packages to move. | |
| 94 | const { kept } = await filter( | |
| 95 | { | |
| 96 | packages: { | |
| 97 | strategy: 'all', | |
| 98 | minimumReleaseAge: 10080, | |
| 99 | rules: [{ matchPackages: ['@acme/*'], minimumReleaseAge: 0 }], | |
| 100 | }, | |
| 101 | }, | |
| 102 | [update('@acme/ui'), update('lodash')], | |
| 103 | { '@acme/ui': 5, 'lodash': 5 }, | |
| 104 | ) | |
| 105 | ||
| 106 | expect(kept).toEqual(['@acme/ui']) | |
| 107 | }) | |
| 108 | ||
| 109 | it('success case - packages no rule matches keep the global hold', async () => { | |
| 110 | const { asked } = await filter( | |
| 111 | { | |
| 112 | packages: { | |
| 113 | strategy: 'all', | |
| 114 | minimumReleaseAge: 60, | |
| 115 | rules: [{ matchPackages: ['react'], minimumReleaseAge: 10080 }], | |
| 116 | }, | |
| 117 | }, | |
| 118 | [update('lodash')], | |
| 119 | { lodash: 99999 }, | |
| 120 | ) | |
| 121 | ||
| 122 | expect(asked).toEqual([{ name: 'lodash', required: 60 }]) | |
| 123 | }) | |
| 124 | ||
| 125 | it('edge case - a zero global with no rule ages skips the check entirely', async () => { | |
| 126 | // The filter is a network round trip per update; it must not run when | |
| 127 | // nothing has asked for a hold. | |
| 128 | const { kept, asked } = await filter( | |
| 129 | { packages: { strategy: 'all', minimumReleaseAge: 0 } }, | |
| 130 | [update('react')], | |
| 131 | {}, | |
| 132 | ) | |
| 133 | ||
| 134 | expect(kept).toEqual(['react']) | |
| 135 | expect(asked).toEqual([]) | |
| 136 | }) | |
| 137 | ||
| 138 | it('edge case - a rule with no age of its own falls back to the global', async () => { | |
| 139 | const { asked } = await filter( | |
| 140 | { | |
| 141 | packages: { | |
| 142 | strategy: 'all', | |
| 143 | minimumReleaseAge: 60, | |
| 144 | rules: [{ matchPackages: ['react'], groupName: 'React' }], | |
| 145 | }, | |
| 146 | }, | |
| 147 | [update('react')], | |
| 148 | { react: 99999 }, | |
| 149 | ) | |
| 150 | ||
| 151 | expect(asked).toEqual([{ name: 'react', required: 60 }]) | |
| 152 | }) | |
| 153 | ||
| 154 | it('failure case - a hold is not defeated by pairing it with auto-merge', async () => { | |
| 155 | // The dangerous shape. A rule saying "hold this for a week, then merge it | |
| 156 | // without review" was proposing and auto-merging a version published | |
| 157 | // minutes earlier, because only the auto-merge half was ever read. | |
| 158 | const { kept } = await filter( | |
| 159 | { | |
| 160 | packages: { | |
| 161 | strategy: 'all', | |
| 162 | rules: [{ matchPackages: ['react'], minimumReleaseAge: 10080, autoMerge: true }], | |
| 163 | }, | |
| 164 | }, | |
| 165 | [update('react')], | |
| 166 | { react: 5 }, | |
| 167 | ) | |
| 168 | ||
| 169 | expect(kept).toEqual([]) | |
| 170 | }) | |
| 171 | ||
| 172 | it('success case - the later of two matching rules wins', async () => { | |
| 173 | // `minimumReleaseAge` is a single value, so a broad rule sets a default | |
| 174 | // and a narrow one refines it — which is what the docs promise. | |
| 175 | const { asked } = await filter( | |
| 176 | { | |
| 177 | packages: { | |
| 178 | strategy: 'all', | |
| 179 | rules: [ | |
| 180 | { matchPackages: ['**'], minimumReleaseAge: 60 }, | |
| 181 | { matchPackages: ['react'], minimumReleaseAge: 10080 }, | |
| 182 | ], | |
| 183 | }, | |
| 184 | }, | |
| 185 | [update('react')], | |
| 186 | { react: 99999 }, | |
| 187 | ) | |
| 188 | ||
| 189 | expect(asked).toEqual([{ name: 'react', required: 10080 }]) | |
| 190 | }) | |
| 191 | }) | |