Skip to content

Commit 62d60dd

Browse files
committed
Releases: add older release pruning suggestions
1 parent c678d01 commit 62d60dd

File tree

2 files changed

+538
-1
lines changed

2 files changed

+538
-1
lines changed

scripts/__tests__/releases.test.js

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,139 @@ describe('Releases Script Utility Functions', () => {
5050
return data
5151
}
5252

53+
/**
54+
* Calculate relative time string (e.g., "3+ years ago", "2 months ago")
55+
*/
56+
function getRelativeTime(publishedAt) {
57+
const now = new Date()
58+
const published = new Date(publishedAt)
59+
const diffInMs = now - published
60+
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24))
61+
const diffInMonths = Math.floor(diffInDays / 30)
62+
const diffInYears = Math.floor(diffInDays / 365)
63+
64+
if (diffInYears >= 1) {
65+
return `${diffInYears}+ year${diffInYears > 1 ? 's' : ''} ago`
66+
} else if (diffInMonths >= 1) {
67+
return `${diffInMonths}+ month${diffInMonths > 1 ? 's' : ''} ago`
68+
} else if (diffInDays >= 7) {
69+
const weeks = Math.floor(diffInDays / 7)
70+
return `${weeks}+ week${weeks > 1 ? 's' : ''} ago`
71+
} else if (diffInDays >= 1) {
72+
return `${diffInDays}+ day${diffInDays > 1 ? 's' : ''} ago`
73+
} else {
74+
return 'today'
75+
}
76+
}
77+
78+
/**
79+
* Check if a version is a pre-release version (alpha, beta, rc, etc.)
80+
*/
81+
function isPreRelease(version) {
82+
return /-(alpha|beta|rc|pre|dev|snapshot)/i.test(version)
83+
}
84+
85+
/**
86+
* Compare two version strings for sorting (semantic versioning)
87+
*/
88+
function compareVersions(a, b) {
89+
// Remove pre-release identifiers for comparison
90+
const cleanA = a.replace(/-.*$/, '')
91+
const cleanB = b.replace(/-.*$/, '')
92+
93+
const partsA = cleanA.split('.').map(Number)
94+
const partsB = cleanB.split('.').map(Number)
95+
96+
// Normalize to same length by padding with zeros
97+
const maxLength = Math.max(partsA.length, partsB.length)
98+
while (partsA.length < maxLength) partsA.push(0)
99+
while (partsB.length < maxLength) partsB.push(0)
100+
101+
for (let i = 0; i < maxLength; i++) {
102+
if (partsA[i] !== partsB[i]) {
103+
return partsB[i] - partsA[i] // Descending order (newest first)
104+
}
105+
}
106+
107+
// If versions are equal, put pre-release after stable
108+
const aIsPre = isPreRelease(a)
109+
const bIsPre = isPreRelease(b)
110+
111+
if (aIsPre && !bIsPre) return 1
112+
if (!aIsPre && bIsPre) return -1
113+
114+
return 0
115+
}
116+
117+
/**
118+
* Identify releases that should be pruned based on heuristics
119+
*/
120+
function identifyReleasesToPrune(releases) {
121+
if (releases.length <= 3) {
122+
return [] // Keep at least 3 releases minimum
123+
}
124+
125+
const now = new Date()
126+
const sixMonthsAgo = new Date(now.getTime() - 6 * 30 * 24 * 60 * 60 * 1000)
127+
const twoYearsAgo = new Date(now.getTime() - 2 * 365 * 24 * 60 * 60 * 1000)
128+
129+
// Sort releases by version (newest first)
130+
const sortedReleases = [...releases].sort((a, b) => compareVersions(a.version, b.version))
131+
132+
const toPrune = []
133+
const stableReleases = sortedReleases.filter((r) => !isPreRelease(r.version))
134+
const preReleaseReleases = sortedReleases.filter((r) => isPreRelease(r.version))
135+
136+
// Keep the latest stable release
137+
const latestStable = stableReleases[0]
138+
139+
// Keep releases from the last 6 months
140+
const recentReleases = releases.filter((r) => new Date(r.publishedAt) >= sixMonthsAgo)
141+
142+
// Keep the latest 2-3 pre-release versions if they're recent
143+
const recentPreReleases = preReleaseReleases.filter((r) => new Date(r.publishedAt) >= sixMonthsAgo).slice(0, 3)
144+
145+
// Identify releases to prune
146+
for (const release of releases) {
147+
const isRecent = new Date(release.publishedAt) >= sixMonthsAgo
148+
const isOld = new Date(release.publishedAt) < twoYearsAgo
149+
const isLatestStable = release === latestStable
150+
const isRecentPreRelease = recentPreReleases.includes(release)
151+
152+
// Prune if:
153+
// 1. It's old (2+ years) AND not the latest stable
154+
// 2. It's a pre-release that's not recent and we have more than 5 pre-releases
155+
// 3. It's not recent and not the latest stable and we have more than 5 total releases
156+
157+
if (isOld && !isLatestStable) {
158+
toPrune.push(release)
159+
} else if (isPreRelease(release.version) && !isRecentPreRelease && preReleaseReleases.length > 5) {
160+
toPrune.push(release)
161+
} else if (!isRecent && !isLatestStable && releases.length > 5) {
162+
toPrune.push(release)
163+
}
164+
}
165+
166+
return toPrune
167+
}
168+
169+
/**
170+
* Generate prune commands for identified releases
171+
*/
172+
function generatePruneCommands(releasesToPrune) {
173+
if (releasesToPrune.length === 0) {
174+
return 'No releases recommended for pruning.'
175+
}
176+
177+
const commands = releasesToPrune.map((release) => `gh release delete "${release.tag}" -y`)
178+
179+
if (releasesToPrune.length === 1) {
180+
return `Recommended prune command:\n${commands[0]}`
181+
}
182+
183+
return `Recommended prune commands:\n${commands.join('\n')}\n\nTo prune all at once:\n${commands.join(' && ')}`
184+
}
185+
53186
/**
54187
* Ensure the version being released is new and not a duplicate
55188
*/
@@ -217,6 +350,197 @@ describe('Releases Script Utility Functions', () => {
217350
})
218351
})
219352

353+
describe('getRelativeTime', () => {
354+
test('should return "today" for recent dates', () => {
355+
const today = new Date().toISOString()
356+
expect(getRelativeTime(today)).toBe('today')
357+
})
358+
359+
test('should return days ago for recent dates', () => {
360+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
361+
expect(getRelativeTime(yesterday)).toBe('1+ day ago')
362+
363+
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString()
364+
expect(getRelativeTime(threeDaysAgo)).toBe('3+ days ago')
365+
})
366+
367+
test('should return weeks ago for recent dates', () => {
368+
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
369+
expect(getRelativeTime(oneWeekAgo)).toBe('1+ week ago')
370+
371+
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString()
372+
expect(getRelativeTime(twoWeeksAgo)).toBe('2+ weeks ago')
373+
})
374+
375+
test('should return months ago for older dates', () => {
376+
const oneMonthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
377+
expect(getRelativeTime(oneMonthAgo)).toBe('1+ month ago')
378+
379+
const threeMonthsAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString()
380+
expect(getRelativeTime(threeMonthsAgo)).toBe('3+ months ago')
381+
})
382+
383+
test('should return years ago for old dates', () => {
384+
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString()
385+
expect(getRelativeTime(oneYearAgo)).toBe('1+ year ago')
386+
387+
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000).toISOString()
388+
expect(getRelativeTime(threeYearsAgo)).toBe('3+ years ago')
389+
})
390+
391+
test('should handle edge cases', () => {
392+
// Test with a very old date
393+
const veryOldDate = new Date('2020-01-01T00:00:00Z').toISOString()
394+
const result = getRelativeTime(veryOldDate)
395+
expect(result).toMatch(/\d+\+ years? ago/)
396+
})
397+
})
398+
399+
describe('isPreRelease', () => {
400+
test('should identify pre-release versions', () => {
401+
expect(isPreRelease('1.0.0-alpha.1')).toBe(true)
402+
expect(isPreRelease('1.0.0-beta.2')).toBe(true)
403+
expect(isPreRelease('1.0.0-rc.1')).toBe(true)
404+
expect(isPreRelease('1.0.0-pre.1')).toBe(true)
405+
expect(isPreRelease('1.0.0-dev')).toBe(true)
406+
expect(isPreRelease('1.0.0-snapshot')).toBe(true)
407+
})
408+
409+
test('should identify stable versions', () => {
410+
expect(isPreRelease('1.0.0')).toBe(false)
411+
expect(isPreRelease('2.1.3')).toBe(false)
412+
expect(isPreRelease('0.1.0')).toBe(false)
413+
})
414+
415+
test('should be case insensitive', () => {
416+
expect(isPreRelease('1.0.0-ALPHA')).toBe(true)
417+
expect(isPreRelease('1.0.0-Beta')).toBe(true)
418+
expect(isPreRelease('1.0.0-RC')).toBe(true)
419+
})
420+
})
421+
422+
describe('compareVersions', () => {
423+
test('should sort versions correctly', () => {
424+
const versions = ['1.0.0', '2.0.0', '1.1.0', '1.0.1']
425+
const sorted = versions.sort(compareVersions)
426+
expect(sorted).toEqual(['2.0.0', '1.1.0', '1.0.1', '1.0.0'])
427+
})
428+
429+
test('should handle pre-release versions', () => {
430+
const versions = ['1.0.0', '1.0.0-beta.1', '1.0.0-alpha.1']
431+
const sorted = versions.sort(compareVersions)
432+
expect(sorted).toEqual(['1.0.0', '1.0.0-beta.1', '1.0.0-alpha.1'])
433+
})
434+
435+
test('should handle different version lengths', () => {
436+
const versions = ['1.0', '1.0.0', '1.0.0.0']
437+
const sorted = versions.sort(compareVersions)
438+
expect(sorted).toEqual(['1.0', '1.0.0', '1.0.0.0'])
439+
})
440+
})
441+
442+
describe('identifyReleasesToPrune', () => {
443+
const createRelease = (version, publishedAt) => ({
444+
name: 'test.plugin',
445+
tag: `test.plugin-v${version}`,
446+
version,
447+
publishedAt,
448+
})
449+
450+
test('should not prune if 3 or fewer releases', () => {
451+
const releases = [createRelease('1.0.0', '2023-01-01T00:00:00Z'), createRelease('1.1.0', '2023-02-01T00:00:00Z'), createRelease('1.2.0', '2023-03-01T00:00:00Z')]
452+
expect(identifyReleasesToPrune(releases)).toEqual([])
453+
})
454+
455+
test('should prune old releases (2+ years) but keep latest stable', () => {
456+
const now = new Date()
457+
const threeYearsAgo = new Date(now.getTime() - 3 * 365 * 24 * 60 * 60 * 1000).toISOString()
458+
const oneYearAgo = new Date(now.getTime() - 1 * 365 * 24 * 60 * 60 * 1000).toISOString()
459+
460+
const releases = [
461+
createRelease('1.0.0', threeYearsAgo), // Should be pruned
462+
createRelease('2.0.0', oneYearAgo), // Should be kept (latest stable)
463+
createRelease('1.1.0', threeYearsAgo), // Should be pruned
464+
createRelease('1.2.0', threeYearsAgo), // Should be pruned
465+
createRelease('1.3.0', threeYearsAgo), // Should be pruned
466+
]
467+
468+
const toPrune = identifyReleasesToPrune(releases)
469+
expect(toPrune.length).toBeGreaterThan(0)
470+
expect(toPrune.map((r) => r.version)).toEqual(expect.arrayContaining(['1.0.0', '1.1.0']))
471+
expect(toPrune.map((r) => r.version)).not.toContain('2.0.0')
472+
})
473+
474+
test('should keep recent releases (6 months)', () => {
475+
const now = new Date()
476+
const oneMonthAgo = new Date(now.getTime() - 1 * 30 * 24 * 60 * 60 * 1000).toISOString()
477+
const threeYearsAgo = new Date(now.getTime() - 3 * 365 * 24 * 60 * 60 * 1000).toISOString()
478+
479+
const releases = [
480+
createRelease('1.0.0', oneMonthAgo), // Should be kept (recent)
481+
createRelease('2.0.0', threeYearsAgo), // Should be pruned (old)
482+
createRelease('1.1.0', oneMonthAgo), // Should be kept (recent)
483+
createRelease('1.2.0', threeYearsAgo), // Should be pruned (old)
484+
createRelease('1.3.0', threeYearsAgo), // Should be pruned (old)
485+
]
486+
487+
const toPrune = identifyReleasesToPrune(releases)
488+
expect(toPrune.length).toBeGreaterThan(0)
489+
expect(toPrune.map((r) => r.version)).toContain('1.2.0')
490+
expect(toPrune.map((r) => r.version)).toContain('1.3.0')
491+
expect(toPrune.map((r) => r.version)).not.toContain('2.0.0') // Latest stable should be kept
492+
})
493+
494+
test('should prune excess pre-release versions', () => {
495+
const now = new Date()
496+
const oneMonthAgo = new Date(now.getTime() - 1 * 30 * 24 * 60 * 60 * 1000).toISOString()
497+
const eightMonthsAgo = new Date(now.getTime() - 8 * 30 * 24 * 60 * 60 * 1000).toISOString()
498+
499+
const releases = [
500+
createRelease('1.0.0', oneMonthAgo), // Stable - keep
501+
createRelease('1.1.0-alpha.1', oneMonthAgo), // Recent pre - keep
502+
createRelease('1.1.0-beta.1', oneMonthAgo), // Recent pre - keep
503+
createRelease('1.1.0-alpha.2', eightMonthsAgo), // Old pre - prune
504+
createRelease('1.1.0-beta.2', eightMonthsAgo), // Old pre - prune
505+
createRelease('1.1.0-rc.1', eightMonthsAgo), // Old pre - prune
506+
createRelease('1.1.0-dev', eightMonthsAgo), // Old pre - prune
507+
]
508+
509+
const toPrune = identifyReleasesToPrune(releases)
510+
expect(toPrune.length).toBeGreaterThan(0)
511+
// Should keep recent pre-releases and stable
512+
expect(toPrune.map((r) => r.version)).not.toContain('1.0.0')
513+
expect(toPrune.map((r) => r.version)).not.toContain('1.1.0-alpha.1')
514+
expect(toPrune.map((r) => r.version)).not.toContain('1.1.0-beta.1')
515+
})
516+
})
517+
518+
describe('generatePruneCommands', () => {
519+
test('should return no pruning message for empty array', () => {
520+
expect(generatePruneCommands([])).toBe('No releases recommended for pruning.')
521+
})
522+
523+
test('should generate single prune command without "all at once" section', () => {
524+
const releasesToPrune = [{ tag: 'plugin-v1.0.0', version: '1.0.0' }]
525+
526+
const result = generatePruneCommands(releasesToPrune)
527+
expect(result).toBe('Recommended prune command:\ngh release delete "plugin-v1.0.0" -y')
528+
expect(result).not.toContain('To prune all at once:')
529+
})
530+
531+
test('should generate multiple prune commands with "all at once" section', () => {
532+
const releasesToPrune = [
533+
{ tag: 'plugin-v1.0.0', version: '1.0.0' },
534+
{ tag: 'plugin-v1.1.0', version: '1.1.0' },
535+
]
536+
537+
const result = generatePruneCommands(releasesToPrune)
538+
expect(result).toContain('gh release delete "plugin-v1.0.0" -y')
539+
expect(result).toContain('gh release delete "plugin-v1.1.0" -y')
540+
expect(result).toContain('To prune all at once:')
541+
})
542+
})
543+
220544
describe('ensureVersionIsNew', () => {
221545
const mockReleases = [
222546
{ name: 'test.plugin', tag: 'test.plugin-v1.0.0', version: '1.0.0', publishedAt: '2023-01-01T00:00:00Z' },

0 commit comments

Comments
 (0)