Skip to content

Commit b070a78

Browse files
committed
fix(releases): sort by published_at to find latest release
GitHub API doesn't guarantee release order, causing getLatestRelease() to potentially return outdated releases. This caused socket-btm tools to download old versions when multiple releases existed for the same day. Solution: Filter matching releases, then sort by published_at descending to ensure we always get the most recently published release. Fixes issue where node-smol-20260112-d8601d1 was returned instead of the newer node-smol-20260112-9ec3865 despite having a later published_at.
1 parent 05f0b02 commit b070a78

File tree

2 files changed

+175
-19
lines changed

2 files changed

+175
-19
lines changed

src/releases/github.ts

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -261,34 +261,51 @@ export async function getLatestRelease(
261261

262262
const releases = JSON.parse(response.body.toString('utf8'))
263263

264-
// Find the first release matching the tool prefix.
265-
for (const release of releases) {
266-
const { assets, tag_name: tag } = release
267-
if (!tag.startsWith(toolPrefix)) {
268-
continue
269-
}
264+
// Filter releases matching the tool prefix.
265+
const matchingReleases = releases.filter(
266+
(release: { tag_name: string; assets: Array<{ name: string }> }) => {
267+
const { assets, tag_name: tag } = release
268+
if (!tag.startsWith(toolPrefix)) {
269+
return false
270+
}
270271

271-
// If asset pattern provided, check if release has matching asset.
272-
if (isMatch) {
273-
const hasMatchingAsset = assets.some((a: { name: string }) =>
274-
isMatch(a.name),
275-
)
276-
if (!hasMatchingAsset) {
277-
continue
272+
// If asset pattern provided, check if release has matching asset.
273+
if (isMatch) {
274+
const hasMatchingAsset = assets.some((a: { name: string }) =>
275+
isMatch(a.name),
276+
)
277+
if (!hasMatchingAsset) {
278+
return false
279+
}
278280
}
279-
}
280281

282+
return true
283+
},
284+
)
285+
286+
if (matchingReleases.length === 0) {
287+
// No matching release found.
281288
if (!quiet) {
282-
logger.info(`Found release: ${tag}`)
289+
logger.info(`No ${toolPrefix} release found in latest 100 releases`)
283290
}
284-
return tag
291+
return null
285292
}
286293

287-
// No matching release found.
294+
// Sort by published_at descending (newest first).
295+
// GitHub API doesn't guarantee order, so we must sort explicitly.
296+
matchingReleases.sort(
297+
(a: { published_at: string }, b: { published_at: string }) =>
298+
new Date(b.published_at).getTime() -
299+
new Date(a.published_at).getTime(),
300+
)
301+
302+
const latestRelease = matchingReleases[0]
303+
const tag = latestRelease.tag_name
304+
288305
if (!quiet) {
289-
logger.info(`No ${toolPrefix} release found in latest 100 releases`)
306+
logger.info(`Found release: ${tag}`)
290307
}
291-
return null
308+
return tag
292309
},
293310
{
294311
...RETRY_CONFIG,

test/unit/releases-github.test.mts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,145 @@ describe('releases/github', () => {
302302
})
303303
expect(tag).toBeNull()
304304
})
305+
306+
it('should sort by published_at and return most recent release', async () => {
307+
// Mock releases with same prefix but different published_at times.
308+
const releasesOutOfOrder = [
309+
{
310+
assets: [{ name: 'node-darwin-arm64' }],
311+
published_at: '2026-01-12T10:00:00Z',
312+
tag_name: 'node-smol-20260112-d8601d1',
313+
},
314+
{
315+
assets: [{ name: 'node-darwin-arm64' }],
316+
published_at: '2026-01-12T15:30:00Z', // Most recent
317+
tag_name: 'node-smol-20260112-9ec3865',
318+
},
319+
{
320+
assets: [{ name: 'node-darwin-arm64' }],
321+
published_at: '2026-01-12T08:00:00Z',
322+
tag_name: 'node-smol-20260112-abc1234',
323+
},
324+
]
325+
326+
vi.mocked(httpRequest).mockResolvedValue(
327+
createMockHttpResponse(
328+
Buffer.from(JSON.stringify(releasesOutOfOrder)),
329+
true,
330+
200,
331+
),
332+
)
333+
334+
const tag = await getLatestRelease('node-smol-', SOCKET_BTM_REPO, {
335+
quiet: true,
336+
})
337+
338+
// Should return the release with the latest published_at time.
339+
expect(tag).toBe('node-smol-20260112-9ec3865')
340+
})
341+
342+
it('should handle multiple releases on same day with different times', async () => {
343+
// Simulate scenario where GitHub API returns releases in arbitrary order.
344+
const sameDay = [
345+
{
346+
assets: [{ name: 'yoga-sync-abc.mjs' }],
347+
published_at: '2026-01-12T09:15:22Z',
348+
tag_name: 'yoga-layout-20260112-first',
349+
},
350+
{
351+
assets: [{ name: 'yoga-sync-xyz.mjs' }],
352+
published_at: '2026-01-12T14:45:10Z', // Latest
353+
tag_name: 'yoga-layout-20260112-latest',
354+
},
355+
{
356+
assets: [{ name: 'yoga-sync-def.mjs' }],
357+
published_at: '2026-01-12T11:30:00Z',
358+
tag_name: 'yoga-layout-20260112-middle',
359+
},
360+
]
361+
362+
vi.mocked(httpRequest).mockResolvedValue(
363+
createMockHttpResponse(Buffer.from(JSON.stringify(sameDay)), true, 200),
364+
)
365+
366+
const tag = await getLatestRelease('yoga-layout-', SOCKET_BTM_REPO, {
367+
quiet: true,
368+
})
369+
370+
expect(tag).toBe('yoga-layout-20260112-latest')
371+
})
372+
373+
it('should sort by published_at even when API returns newest first', async () => {
374+
// Test that we don't rely on API ordering.
375+
const releasesNewestFirst = [
376+
{
377+
assets: [{ name: 'models-data.tar.gz' }],
378+
published_at: '2026-01-15T12:00:00Z', // Newest
379+
tag_name: 'models-20260115-newest',
380+
},
381+
{
382+
assets: [{ name: 'models-data.tar.gz' }],
383+
published_at: '2026-01-14T12:00:00Z',
384+
tag_name: 'models-20260114-older',
385+
},
386+
{
387+
assets: [{ name: 'models-data.tar.gz' }],
388+
published_at: '2026-01-13T12:00:00Z',
389+
tag_name: 'models-20260113-oldest',
390+
},
391+
]
392+
393+
vi.mocked(httpRequest).mockResolvedValue(
394+
createMockHttpResponse(
395+
Buffer.from(JSON.stringify(releasesNewestFirst)),
396+
true,
397+
200,
398+
),
399+
)
400+
401+
const tag = await getLatestRelease('models-', SOCKET_BTM_REPO, {
402+
quiet: true,
403+
})
404+
405+
expect(tag).toBe('models-20260115-newest')
406+
})
407+
408+
it('should sort by published_at with asset pattern filtering', async () => {
409+
// Multiple releases matching prefix, but only some have matching assets.
410+
const releasesWithAssets = [
411+
{
412+
assets: [{ name: 'node-linux-x64' }], // No matching asset
413+
published_at: '2026-01-12T16:00:00Z',
414+
tag_name: 'node-smol-20260112-no-match',
415+
},
416+
{
417+
assets: [{ name: 'node-darwin-arm64' }], // Matching asset, oldest
418+
published_at: '2026-01-12T10:00:00Z',
419+
tag_name: 'node-smol-20260112-older',
420+
},
421+
{
422+
assets: [{ name: 'node-darwin-arm64' }], // Matching asset, newest
423+
published_at: '2026-01-12T14:00:00Z',
424+
tag_name: 'node-smol-20260112-newer',
425+
},
426+
]
427+
428+
vi.mocked(httpRequest).mockResolvedValue(
429+
createMockHttpResponse(
430+
Buffer.from(JSON.stringify(releasesWithAssets)),
431+
true,
432+
200,
433+
),
434+
)
435+
436+
const tag = await getLatestRelease('node-smol-', SOCKET_BTM_REPO, {
437+
assetPattern: 'node-darwin-*',
438+
quiet: true,
439+
})
440+
441+
// Should return the newest release that has the matching asset.
442+
expect(tag).toBe('node-smol-20260112-newer')
443+
})
305444
})
306445

307446
describe('getReleaseAssetUrl', () => {

0 commit comments

Comments
 (0)