@@ -18,6 +18,16 @@ import { getDefaultLogger } from '../logger'
1818import { pRetry } from '../promises'
1919import { 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