Skip to content

Commit b0f3c6a

Browse files
committed
fix(releases): switch getLatestRelease to GraphQL for immutable releases
GitHub's REST /repos/:owner/:repo/releases endpoint excludes immutable releases (the default for releases created since GitHub introduced release immutability), returning an empty array even when the repo has dozens of published releases. socket-btm's recent releases (binject, iocraft, models, node-smol, …) are all immutable, so any caller using getLatestRelease for tool-prefix discovery saw 'No <prefix>- release found in latest 100 releases' and the downstream socket-cli build failed at download-assets. Switch the listing query to the GraphQL endpoint (repository.releases connection), which includes immutable releases. The per-tag fetch in getReleaseAssetUrl stays on REST since /releases/tags/:tag works correctly for immutable releases. Tests updated with a wrapReleasesAsGraphQL helper that wraps the existing REST-shape fixtures, keeping the test data readable while exercising the new GraphQL parse path. All 40 release-github tests pass.
1 parent 320c217 commit b0f3c6a

3 files changed

Lines changed: 93 additions & 22 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@socketsecurity/lib",
3-
"version": "5.25.1",
3+
"version": "5.25.2",
44
"packageManager": "pnpm@11.0.0-rc.5",
55
"license": "MIT",
66
"description": "Core utilities and infrastructure for Socket.dev security tools",

src/releases/github.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -572,11 +572,34 @@ export async function getLatestRelease(
572572
return (
573573
(await pRetry(
574574
async () => {
575-
// Fetch recent releases (100 should cover all tool releases).
575+
// List releases via GraphQL. GitHub's REST endpoint
576+
// `/repos/:owner/:repo/releases` excludes immutable releases
577+
// (the default for releases created since GitHub introduced
578+
// release immutability), returning an empty array even when
579+
// the repo has dozens of published releases. GraphQL's
580+
// `repository.releases` connection includes immutable releases
581+
// and is the canonical replacement. Per-tag fetches via
582+
// `/repos/:owner/:repo/releases/tags/:tag` still work for
583+
// immutable releases, so `getReleaseAssetUrl` stays on REST.
576584
const response = await httpRequest(
577-
`https://api.github.com/repos/${owner}/${repo}/releases?per_page=100`,
585+
'https://api.github.com/graphql',
578586
{
579-
headers: getAuthHeaders(),
587+
body: JSON.stringify({
588+
query: `query($owner: String!, $repo: String!) {
589+
repository(owner: $owner, name: $repo) {
590+
releases(first: 100, orderBy: {field: CREATED_AT, direction: DESC}) {
591+
nodes {
592+
tagName
593+
publishedAt
594+
releaseAssets(first: 100) { nodes { name } }
595+
}
596+
}
597+
}
598+
}`,
599+
variables: { owner, repo },
600+
}),
601+
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
602+
method: 'POST',
580603
},
581604
)
582605

@@ -590,10 +613,35 @@ export async function getLatestRelease(
590613
assets: Array<{ name: string }>
591614
}>
592615
try {
593-
releases = JSON.parse(response.body.toString('utf8'))
616+
const parsed = JSON.parse(response.body.toString('utf8')) as {
617+
data?: {
618+
repository?: {
619+
releases?: {
620+
nodes?: Array<{
621+
tagName: string
622+
publishedAt: string
623+
releaseAssets?: { nodes?: Array<{ name: string }> }
624+
}>
625+
}
626+
}
627+
}
628+
errors?: Array<{ message: string }>
629+
}
630+
if (parsed.errors?.length) {
631+
throw new Error(
632+
`GraphQL error: ${parsed.errors.map(e => e.message).join('; ')}`,
633+
)
634+
}
635+
releases = (parsed.data?.repository?.releases?.nodes ?? []).map(
636+
n => ({
637+
tag_name: n.tagName,
638+
published_at: n.publishedAt,
639+
assets: n.releaseAssets?.nodes ?? [],
640+
}),
641+
)
594642
} catch (cause) {
595643
throw new Error(
596-
`Failed to parse GitHub releases response from https://api.github.com/repos/${owner}/${repo}/releases`,
644+
`Failed to parse GitHub GraphQL response for ${owner}/${repo} releases`,
597645
{ cause },
598646
)
599647
}

test/unit/releases-github.test.mts

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@ vi.mock('../../src/http-request')
3636
* @param status - HTTP status code
3737
* @returns Complete mock HttpResponse object
3838
*/
39+
/**
40+
* Wrap a REST-shape releases array as a GraphQL response. Mirrors the
41+
* exact shape `getLatestRelease` parses (data.repository.releases.nodes
42+
* with tagName/publishedAt/releaseAssets.nodes), so existing test
43+
* fixtures that read like REST `[{tag_name, published_at, assets}]`
44+
* stay readable while the implementation queries GraphQL.
45+
*/
46+
function wrapReleasesAsGraphQL(
47+
releases: Array<{
48+
tag_name: string
49+
published_at?: string
50+
assets?: Array<{ name: string }>
51+
}>,
52+
): Buffer {
53+
return Buffer.from(
54+
JSON.stringify({
55+
data: {
56+
repository: {
57+
releases: {
58+
nodes: releases.map(r => ({
59+
tagName: r.tag_name,
60+
publishedAt: r.published_at ?? '2026-01-01T00:00:00Z',
61+
releaseAssets: { nodes: r.assets ?? [] },
62+
})),
63+
},
64+
},
65+
},
66+
}),
67+
)
68+
}
69+
3970
function createMockHttpResponse(
4071
body: Buffer,
4172
ok: boolean,
@@ -253,11 +284,7 @@ describe('releases/github', () => {
253284

254285
beforeEach(() => {
255286
vi.mocked(httpRequest).mockResolvedValue(
256-
createMockHttpResponse(
257-
Buffer.from(JSON.stringify(mockReleases)),
258-
true,
259-
200,
260-
),
287+
createMockHttpResponse(wrapReleasesAsGraphQL(mockReleases), true, 200),
261288
)
262289
})
263290

@@ -333,7 +360,7 @@ describe('releases/github', () => {
333360

334361
vi.mocked(httpRequest).mockResolvedValue(
335362
createMockHttpResponse(
336-
Buffer.from(JSON.stringify(releasesOutOfOrder)),
363+
wrapReleasesAsGraphQL(releasesOutOfOrder),
337364
true,
338365
200,
339366
),
@@ -368,7 +395,7 @@ describe('releases/github', () => {
368395
]
369396

370397
vi.mocked(httpRequest).mockResolvedValue(
371-
createMockHttpResponse(Buffer.from(JSON.stringify(sameDay)), true, 200),
398+
createMockHttpResponse(wrapReleasesAsGraphQL(sameDay), true, 200),
372399
)
373400

374401
const tag = await getLatestRelease('yoga-layout-', SOCKET_BTM_REPO, {
@@ -400,7 +427,7 @@ describe('releases/github', () => {
400427

401428
vi.mocked(httpRequest).mockResolvedValue(
402429
createMockHttpResponse(
403-
Buffer.from(JSON.stringify(releasesNewestFirst)),
430+
wrapReleasesAsGraphQL(releasesNewestFirst),
404431
true,
405432
200,
406433
),
@@ -435,7 +462,7 @@ describe('releases/github', () => {
435462

436463
vi.mocked(httpRequest).mockResolvedValue(
437464
createMockHttpResponse(
438-
Buffer.from(JSON.stringify(releasesWithAssets)),
465+
wrapReleasesAsGraphQL(releasesWithAssets),
439466
true,
440467
200,
441468
),
@@ -467,7 +494,7 @@ describe('releases/github', () => {
467494

468495
vi.mocked(httpRequest).mockResolvedValue(
469496
createMockHttpResponse(
470-
Buffer.from(JSON.stringify(releasesWithEmpty)),
497+
wrapReleasesAsGraphQL(releasesWithEmpty),
471498
true,
472499
200,
473500
),
@@ -497,11 +524,7 @@ describe('releases/github', () => {
497524
]
498525

499526
vi.mocked(httpRequest).mockResolvedValue(
500-
createMockHttpResponse(
501-
Buffer.from(JSON.stringify(allEmpty)),
502-
true,
503-
200,
504-
),
527+
createMockHttpResponse(wrapReleasesAsGraphQL(allEmpty), true, 200),
505528
)
506529

507530
const tag = await getLatestRelease('binject-', SOCKET_BTM_REPO, {
@@ -534,7 +557,7 @@ describe('releases/github', () => {
534557

535558
vi.mocked(httpRequest).mockResolvedValue(
536559
createMockHttpResponse(
537-
Buffer.from(JSON.stringify(mixedReleases)),
560+
wrapReleasesAsGraphQL(mixedReleases),
538561
true,
539562
200,
540563
),

0 commit comments

Comments
 (0)