Skip to content

Commit 75be1b3

Browse files
committed
fix(releases): use REST primarily, fall back to GraphQL on empty listing
The earlier commit (b0f3c6a) switched getLatestRelease wholesale to GraphQL based on the wrong premise — that GitHub's REST releases listing was excluding immutable releases. Reproduction shows REST does include immutable releases under normal conditions. The actual symptom is a transient GitHub Elasticsearch outage (https://www.githubstatus.com): /repos/:owner/:repo/releases returns HTTP 200 OK with a zero-byte body (or literal []) for arbitrary repos while ES is degraded. There's no error code, no Retry-After, no rate-limit header — just an empty payload that the existing pRetry can't distinguish from a healthy 'no releases' response. Restructure to keep REST as the primary path (the canonical listing URL, no scope creep) and fall back to GraphQL only when REST returns an empty list. Two helpers: fetchReleasesViaRest treats 200 + empty body as the documented incident shape and returns []; fetchReleasesViaGraphQL hits the same endpoint via the GraphQL connection (different backend, stays consistent through ES outages). The caller cross-checks: if REST returns 0 and GraphQL returns >0, log a warning pointing at GitHub status and use the GraphQL result. If both return 0, the repo really has no releases and we report null. Also pin JSON.parse / JSON.stringify / Array.isArray as local primordial aliases (matches the convention in src/packages/provenance.ts), and add three tests covering the new fallback paths: 200+empty-body -> fallback succeeds; 200+[] -> fallback succeeds; both empty -> null. 43/43 release-github tests pass plus 33/33 socket-btm tests.
1 parent 612e35c commit 75be1b3

2 files changed

Lines changed: 298 additions & 118 deletions

File tree

src/releases/github.ts

Lines changed: 156 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ import { getDefaultLogger } from '../logger'
1818
import { pRetry } from '../promises'
1919
import { spawn } from '../spawn'
2020

21+
// Pin global primordials at module load. The fetch + parse path here
22+
// runs in long-lived processes (CI build scripts, downstream tools)
23+
// where consumers may install Object.defineProperty hooks or similar
24+
// over JSON / Array; capturing the original references protects the
25+
// listing logic from those mutations and matches the convention used
26+
// elsewhere in this package (see src/packages/provenance.ts).
27+
const ArrayIsArray = Array.isArray
28+
const JSONParse = JSON.parse
29+
const JSONStringify = JSON.stringify
30+
2131
/**
2232
* Pattern for matching release assets.
2333
* Can be either:
@@ -540,6 +550,125 @@ export function getAuthHeaders(): Record<string, string> {
540550
return headers
541551
}
542552

553+
/**
554+
* Internal release row shape used by the listing helpers and the
555+
* filter pipeline in `getLatestRelease`. Both REST and GraphQL paths
556+
* normalize their output to this shape so downstream code is unaware
557+
* of which transport produced the data.
558+
*/
559+
interface ReleaseRow {
560+
tag_name: string
561+
published_at: string
562+
assets: Array<{ name: string }>
563+
}
564+
565+
/**
566+
* Fetch the latest 100 releases for a repo via REST. Returns an empty
567+
* array on:
568+
* - HTTP 200 + zero-byte body (the GitHub Elasticsearch outage
569+
* symptom — `/releases` returns 200 with no body when its
570+
* listing index is degraded; there is no error code, no
571+
* Retry-After, no rate-limit header, just an empty payload)
572+
* - HTTP 200 + literal `[]` (a brand-new repo with no releases)
573+
* Throws on non-OK status so `pRetry` retries transient failures.
574+
*/
575+
async function fetchReleasesViaRest(
576+
owner: string,
577+
repo: string,
578+
): Promise<ReleaseRow[]> {
579+
const response = await httpRequest(
580+
`https://api.github.com/repos/${owner}/${repo}/releases?per_page=100`,
581+
{ headers: getAuthHeaders() },
582+
)
583+
if (!response.ok) {
584+
throw new Error(`Failed to fetch releases: ${response.status}`)
585+
}
586+
const text = response.body.toString('utf8')
587+
if (text.length === 0) {
588+
// 200 OK + empty body — the documented GitHub-search-degraded
589+
// signature. Return [] so the caller can decide whether to fall
590+
// back rather than throwing (we don't want pRetry to burn
591+
// attempts on a known incident shape).
592+
return []
593+
}
594+
let parsed: unknown
595+
try {
596+
parsed = JSONParse(text)
597+
} catch (cause) {
598+
throw new Error(
599+
`Failed to parse GitHub releases response from https://api.github.com/repos/${owner}/${repo}/releases`,
600+
{ cause },
601+
)
602+
}
603+
return ArrayIsArray(parsed) ? (parsed as ReleaseRow[]) : []
604+
}
605+
606+
/**
607+
* Fetch the latest 100 releases for a repo via GraphQL. Used as a
608+
* fallback when REST returns an empty listing — GraphQL hits a
609+
* different backend and stays consistent through Elasticsearch
610+
* outages. Maps GraphQL fields back to the REST-shaped row so the
611+
* caller doesn't need to know which transport ran.
612+
*/
613+
async function fetchReleasesViaGraphQL(
614+
owner: string,
615+
repo: string,
616+
): Promise<ReleaseRow[]> {
617+
const response = await httpRequest('https://api.github.com/graphql', {
618+
body: JSONStringify({
619+
query: `query($owner: String!, $repo: String!) {
620+
repository(owner: $owner, name: $repo) {
621+
releases(first: 100, orderBy: {field: CREATED_AT, direction: DESC}) {
622+
nodes {
623+
tagName
624+
publishedAt
625+
releaseAssets(first: 100) { nodes { name } }
626+
}
627+
}
628+
}
629+
}`,
630+
variables: { owner, repo },
631+
}),
632+
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
633+
method: 'POST',
634+
})
635+
if (!response.ok) {
636+
throw new Error(`Failed to fetch releases via GraphQL: ${response.status}`)
637+
}
638+
let parsed: {
639+
data?: {
640+
repository?: {
641+
releases?: {
642+
nodes?: Array<{
643+
tagName: string
644+
publishedAt: string
645+
releaseAssets?: { nodes?: Array<{ name: string }> }
646+
}>
647+
}
648+
}
649+
}
650+
errors?: Array<{ message: string }>
651+
}
652+
try {
653+
parsed = JSONParse(response.body.toString('utf8'))
654+
} catch (cause) {
655+
throw new Error(
656+
`Failed to parse GitHub GraphQL response for ${owner}/${repo} releases`,
657+
{ cause },
658+
)
659+
}
660+
if (parsed.errors?.length) {
661+
throw new Error(
662+
`GraphQL error: ${parsed.errors.map(e => e.message).join('; ')}`,
663+
)
664+
}
665+
return (parsed.data?.repository?.releases?.nodes ?? []).map(n => ({
666+
tag_name: n.tagName,
667+
published_at: n.publishedAt,
668+
assets: n.releaseAssets?.nodes ?? [],
669+
}))
670+
}
671+
543672
/**
544673
* Get latest release tag matching a tool prefix.
545674
* Optionally filter by releases containing a matching asset.
@@ -572,75 +701,35 @@ export async function getLatestRelease(
572701
return (
573702
(await pRetry(
574703
async () => {
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.
584-
const response = await httpRequest('https://api.github.com/graphql', {
585-
body: JSON.stringify({
586-
query: `query($owner: String!, $repo: String!) {
587-
repository(owner: $owner, name: $repo) {
588-
releases(first: 100, orderBy: {field: CREATED_AT, direction: DESC}) {
589-
nodes {
590-
tagName
591-
publishedAt
592-
releaseAssets(first: 100) { nodes { name } }
593-
}
594-
}
595-
}
596-
}`,
597-
variables: { owner, repo },
598-
}),
599-
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
600-
method: 'POST',
601-
})
602-
603-
if (!response.ok) {
604-
throw new Error(`Failed to fetch releases: ${response.status}`)
605-
}
606-
607-
let releases: Array<{
608-
tag_name: string
609-
published_at: string
610-
assets: Array<{ name: string }>
611-
}>
612-
try {
613-
const parsed = JSON.parse(response.body.toString('utf8')) as {
614-
data?: {
615-
repository?: {
616-
releases?: {
617-
nodes?: Array<{
618-
tagName: string
619-
publishedAt: string
620-
releaseAssets?: { nodes?: Array<{ name: string }> }
621-
}>
622-
}
623-
}
704+
// Fetch via REST first. The REST endpoint is the canonical
705+
// listing path and is what we want to use when GitHub is
706+
// healthy. During GitHub Elasticsearch outages (which back the
707+
// releases listing index) REST can return HTTP 200 with an
708+
// empty array even when the repo has dozens of releases — see
709+
// https://www.githubstatus.com incidents tagged "search is
710+
// degraded". When that happens we fall back to GraphQL, which
711+
// hits a different backend and stays consistent through ES
712+
// outages. Per-tag fetches in `getReleaseAssetUrl` go through
713+
// `/repos/:owner/:repo/releases/tags/:tag` which is unaffected
714+
// by the listing-index outage, so that helper stays on REST.
715+
let releases = await fetchReleasesViaRest(owner, repo)
716+
if (releases.length === 0) {
717+
// Empty REST response is ambiguous: it could mean the repo
718+
// genuinely has no releases, or GitHub's listing index is
719+
// degraded. Cross-check against GraphQL once. If GraphQL
720+
// also returns 0, the repo really is empty and we report
721+
// "no match"; if GraphQL returns >0, REST was lying and we
722+
// surface the GraphQL result with a warning so the operator
723+
// can correlate with GitHub status.
724+
const graphqlReleases = await fetchReleasesViaGraphQL(owner, repo)
725+
if (graphqlReleases.length > 0) {
726+
if (!quiet) {
727+
logger.warn(
728+
`REST releases endpoint returned 0 results for ${owner}/${repo}; falling back to GraphQL (got ${graphqlReleases.length}). This usually indicates a GitHub search/listing-index incident — see https://www.githubstatus.com.`,
729+
)
624730
}
625-
errors?: Array<{ message: string }>
626-
}
627-
if (parsed.errors?.length) {
628-
throw new Error(
629-
`GraphQL error: ${parsed.errors.map(e => e.message).join('; ')}`,
630-
)
731+
releases = graphqlReleases
631732
}
632-
releases = (parsed.data?.repository?.releases?.nodes ?? []).map(
633-
n => ({
634-
tag_name: n.tagName,
635-
published_at: n.publishedAt,
636-
assets: n.releaseAssets?.nodes ?? [],
637-
}),
638-
)
639-
} catch (cause) {
640-
throw new Error(
641-
`Failed to parse GitHub GraphQL response for ${owner}/${repo} releases`,
642-
{ cause },
643-
)
644733
}
645734

646735
// Filter releases matching the tool prefix.

0 commit comments

Comments
 (0)