Skip to content

Commit 60f8c7a

Browse files
committed
chore(release): v5.26.0
Tightens the GitHub fallback work from 5.25.x and rolls in supporting tooling. Highlights: * `getLatestRelease`, `getReleaseAssetUrl`: silent transparent GraphQL fallback when REST returns 200 + empty body. The `logger.warn` / `logger.info` chatter is gone — the helpers are silent by design now (errors throw, success returns). * New `nothrow` option on both helpers, mirroring the `whichSync({ nothrow })` convention from `@socketsecurity/lib/bin`. When true, returns `undefined` instead of throwing on the both-backends-degraded shape. * Return-type cleanup: `null` → `undefined` everywhere we own the surface. Wire-shape types (REST/GraphQL JSON literals) keep `null` because that's what GitHub puts on the wire. * Error messages tightened to the CLAUDE.md library-API style: terse, stable, callable-by-`instanceof`/`.message`. The verbose explanations live in the JSDoc. * `tools/prim` audit gained a `redeclaration` finding kind. It now flags top-level `const ErrorCtor = Error` / `const JSONParse = JSON.parse` / `const ArrayIsArray = Array.isArray` and recommends importing from `./primordials` instead of redeclaring locally. `--coverage` includes redeclarations alongside covered call sites; `--gaps` excludes them. The audit found 14 pre-existing redeclarations in the package (out of scope for this release). * socket-lib's own github.ts and releases/github.ts now import from `./primordials` instead of redeclaring locally — matches what the new audit recommends. Tests: 154/154 pass across `github.test.mts` (97) and `releases-github.test.mts` (57). Coverage on `src/github.ts` is 19/19 functions, 80/96 branches, 126/135 statements. `src/releases/github.ts` is 27/29 functions, 105/172 branches (the missing branches are all in pre-existing `downloadAndExtractArchive` / `downloadAndExtractZip` not touched in this release).
1 parent ee56c1f commit 60f8c7a

10 files changed

Lines changed: 603 additions & 271 deletions

File tree

.git-hooks/pre-commit.mts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,12 @@ const main = (): number => {
159159
if (
160160
file.includes('node_modules/') ||
161161
file.endsWith('pnpm-lock.yaml') ||
162-
file.includes('.git-hooks/')
162+
file.includes('.git-hooks/') ||
163+
// CHANGELOG entries discuss npx ecosystem *behavior* (cache
164+
// semantics, naming conventions) as historical documentation —
165+
// they're not commands. Skip the npx/dlx scan for changelogs.
166+
file === 'CHANGELOG.md' ||
167+
file.endsWith('/CHANGELOG.md')
163168
) {
164169
continue
165170
}

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [5.26.0](https://github.com/SocketDev/socket-lib/releases/tag/v5.26.0) - 2026-04-27
9+
10+
### Added
11+
12+
- `@socketsecurity/lib/github` `GitHubEmptyBodyError` — new exported error class. Thrown by `fetchGitHub` when GitHub returns HTTP 200 OK with a zero-byte body (the documented signature of GitHub Elasticsearch / search-degraded incidents — see https://www.githubstatus.com). Lets callers `instanceof` this error to detect the upstream-degraded shape and route around it instead of catching a confusing downstream `JSON.parse('')` SyntaxError
13+
- `@socketsecurity/lib/github` `getLatestRelease({ nothrow })` and `@socketsecurity/lib/releases/github` `getReleaseAssetUrl({ nothrow })` — when `true`, return `undefined` instead of throwing if both REST and GraphQL backends are degraded. Matches the existing `nothrow` convention from `@socketsecurity/lib/bin` (`whichSync({ nothrow })`)
14+
15+
### Changed
16+
17+
- `getLatestRelease`, `getReleaseAssetUrl`, `fetchRefShaViaGraphQL` (internal), `fetchReleaseAssetsViaGraphQL` (internal) — return `undefined` (was: `null`) when no result is found. Matches the `__proto__: null` only / `undefined` convention used elsewhere in the package. Callers using `=== null` will need to switch to `=== undefined` or a falsy check; callers using `if (!result)` are unaffected
18+
- `fetchGhsaDetails` GraphQL fallback path normalizes severity to lowercase (`"MODERATE"``"moderate"`) to match the REST endpoint's wire shape. Callers comparing against a single canonical case no longer have to handle both
19+
- `getLatestRelease` and `getReleaseAssetUrl` no longer log to `logger.info` / `logger.warn` on success, retry, or fallback. The helpers are silent by design now: errors throw, success returns. The `quiet` option is still accepted for backward compat but ignored
20+
21+
### Fixed
22+
23+
- `@socketsecurity/lib/releases/github` `getLatestRelease` now transparently falls back to GraphQL `repository.releases` when the REST `/repos/:owner/:repo/releases` listing endpoint returns HTTP 200 with an empty body. The REST listing shares an Elasticsearch-backed index with search; during incidents the endpoint returns success-with-no-body for arbitrary repos. GraphQL hits a different backend and stays consistent through these outages
24+
- `@socketsecurity/lib/releases/github` `getReleaseAssetUrl` now transparently falls back to GraphQL `repository.release(tagName).releaseAssets` on the same incident shape. GraphQL's `downloadUrl` field is normalized back to REST's `browser_download_url` so the asset matcher runs unchanged
25+
- `@socketsecurity/lib/github` `resolveRefToSha` now transparently falls back to GraphQL `repository.ref(qualifiedName)` + `repository.object(oid)` after the REST tag → branch → commit cascade hits the empty-body shape. GraphQL resolves all three forms in one round trip, including dereferencing annotated tags via `Tag.target.oid`
26+
- `@socketsecurity/lib/github` `fetchGhsaDetails` now falls back to GraphQL `securityAdvisory(ghsaId)` on the empty-body shape. Two field-shape diffs are normalized: severity case (uppercase → lowercase) and `aliases` (derived from GraphQL `identifiers` by filtering out the advisory's own GHSA id)
27+
- All four fallback paths only fire on `GitHubEmptyBodyError` (the documented incident signature). Real 404s, rate-limit errors, and 5xx responses still propagate as-is — the fallback is reserved for the case REST gives no usable signal
28+
29+
### Tooling
30+
31+
- `tools/prim` audit now detects local-alias redeclarations of primordials (`const ErrorCtor = Error`, `const JSONParse = JSON.parse`, `const ArrayIsArray = Array.isArray`, etc.) at top-level scope and reports them as a new `redeclaration` finding kind. The recommendation is to import the alias from `./primordials` instead of redeclaring it locally. `--coverage` includes redeclarations alongside covered call sites since both are migration candidates against the existing surface; `--gaps` excludes them
32+
833
## [5.25.1](https://github.com/SocketDev/socket-lib/releases/tag/v5.25.1) - 2026-04-27
934

1035
### Fixed

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.2",
3+
"version": "5.26.0",
44
"packageManager": "pnpm@11.0.0-rc.5",
55
"license": "MIT",
66
"description": "Core utilities and infrastructure for Socket.dev security tools",

src/github.ts

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,12 @@ import { getGhToken, getGithubToken } from './env/github'
2828
import { getSocketCliGithubToken } from './env/socket-cli'
2929
import { errorMessage } from './errors'
3030
import { httpRequest } from './http-request'
31+
import { ErrorCtor, JSONParse, JSONStringify } from './primordials'
3132
import { spawn } from './spawn'
3233

3334
import type { TtlCache } from './cache-with-ttl'
3435
import type { SpawnOptions } from './spawn'
3536

36-
// Pin global primordials at module load. Matches src/packages/provenance.ts.
37-
const ErrorCtor = Error
38-
const JSONParse = JSON.parse
39-
const JSONStringify = JSON.stringify
40-
4137
// GitHub API base URL constant (inlined for coverage mode compatibility).
4238
const GITHUB_API_BASE_URL = 'https://api.github.com'
4339

@@ -83,12 +79,11 @@ export class GitHubEmptyBodyError extends Error {
8379
/** HTTP status (always 200 — that's what makes this case insidious). */
8480
status: number
8581
constructor(url: string) {
86-
super(
87-
`GitHub API returned HTTP 200 with an empty body for ${url}. ` +
88-
'This is the documented signature of an upstream incident — ' +
89-
'see https://www.githubstatus.com. Retrying or falling back ' +
90-
'to a different transport is recommended.',
91-
)
82+
// Library-API error: terse and stable so callers can switch on
83+
// .name / instanceof without parsing the message. The verbose
84+
// background ("documented incident shape", status URL) lives in
85+
// the JSDoc above the class declaration.
86+
super(`GitHub API returned HTTP 200 with empty body: ${url}`)
9287
this.name = 'GitHubEmptyBodyError'
9388
this.status = 200
9489
}
@@ -390,28 +385,36 @@ async function fetchRefSha(
390385
// "ref not found" outcome.
391386
//
392387
// If GraphQL ALSO fails (network error, GraphQL errors[],
393-
// etc.) we silently swallow it and re-throw the *original*
394-
// REST error. The reasoning is: the REST cascade error
395-
// gives the user an actionable message ("ref not found"),
396-
// while a GraphQL transport error is incident plumbing
397-
// they can't act on. Better to show the lower-level "we
398-
// couldn't find it" error than a confusing GraphQL
399-
// exception caused by the same incident.
388+
// etc.) we throw an informative "both transports failed"
389+
// error so the operator sees the cross-backend signal
390+
// rather than a bare last-tier REST error.
400391
// -----------------------------------------------------------
401392
if (sawEmptyBody) {
393+
let graphqlSha: string | undefined
394+
let graphqlErr: unknown
402395
try {
403-
const sha = await fetchRefShaViaGraphQL(
396+
graphqlSha = await fetchRefShaViaGraphQL(
404397
owner,
405398
repo,
406399
ref,
407400
fetchOptions,
408401
)
409-
if (sha) {
410-
return sha
411-
}
412-
} catch {
413-
// fall through to the original error
402+
} catch (cause) {
403+
graphqlErr = cause
404+
}
405+
if (graphqlSha) {
406+
return graphqlSha
407+
}
408+
if (graphqlErr !== undefined) {
409+
throw new ErrorCtor(
410+
`Failed to resolve ref "${ref}" for ${owner}/${repo}: both REST and GraphQL backends degraded`,
411+
{ cause: graphqlErr },
412+
)
414413
}
414+
// GraphQL completed successfully but found no match — the ref
415+
// genuinely doesn't exist (or the empty-body signal happened
416+
// but GitHub has since recovered enough for GraphQL to confirm
417+
// the absence). Surface the cleaner "ref not found" message.
415418
}
416419
throw new ErrorCtor(
417420
`failed to resolve ref "${ref}" for ${owner}/${repo}: ${errorMessage(e3)}`,
@@ -451,10 +454,10 @@ async function fetchRefSha(
451454
*
452455
* Return contract:
453456
* - Returns the SHA string when any form matches.
454-
* - Returns `null` when the ref genuinely doesn't exist as a
455-
* tag, branch, OR commit. The caller treats `null` the same
457+
* - Returns `undefined` when the ref genuinely doesn't exist as a
458+
* tag, branch, OR commit. The caller treats `undefined` the same
456459
* as "REST cascade also failed" — a real "ref not found".
457-
* - Returns `null` (not throws) on transport-level failures too:
460+
* - Returns `undefined` (not throws) on transport-level failures too:
458461
* non-OK HTTP, empty GraphQL body, or JSON parse error. The
459462
* REST cascade's "ref not found" message is more useful to the
460463
* end user than a GraphQL transport error.
@@ -464,7 +467,7 @@ async function fetchRefShaViaGraphQL(
464467
repo: string,
465468
ref: string,
466469
options: GitHubFetchOptions,
467-
): Promise<string | null> {
470+
): Promise<string | undefined> {
468471
const token = options.token || getGitHubToken()
469472
const headers: Record<string, string> = {
470473
Accept: 'application/vnd.github.v3+json',
@@ -526,10 +529,10 @@ async function fetchRefShaViaGraphQL(
526529
if (!response.ok || response.body.byteLength === 0) {
527530
// Either GraphQL itself failed (non-OK status) or it ALSO
528531
// returned an empty body — both backends are degraded. Return
529-
// null so the caller surfaces the original REST error rather
532+
// undefined so the caller surfaces the original REST error rather
530533
// than re-throwing here. We deliberately don't recurse to
531534
// another transport because there isn't a third option.
532-
return null
535+
return undefined
533536
}
534537
let parsed: {
535538
data?: {
@@ -549,7 +552,7 @@ async function fetchRefShaViaGraphQL(
549552
try {
550553
parsed = JSONParse(response.body.toString('utf8'))
551554
} catch {
552-
return null
555+
return undefined
553556
}
554557
// GraphQL has two ways of saying "no":
555558
//
@@ -570,15 +573,15 @@ async function fetchRefShaViaGraphQL(
570573
// identical to REST when both backends return data.
571574
const repoData = parsed.data?.repository
572575
if (!repoData) {
573-
return null
576+
return undefined
574577
}
575578
const tagTarget = repoData.tagRef?.target
576579
if (tagTarget) {
577580
if (tagTarget.__typename === 'Tag') {
578-
return tagTarget.target?.oid ?? null
581+
return tagTarget.target?.oid ?? undefined
579582
}
580583
if (tagTarget.__typename === 'Commit') {
581-
return tagTarget.oid ?? null
584+
return tagTarget.oid ?? undefined
582585
}
583586
}
584587
const branchOid = repoData.branchRef?.target?.oid
@@ -588,7 +591,7 @@ async function fetchRefShaViaGraphQL(
588591
if (repoData.commit?.__typename === 'Commit' && repoData.commit.oid) {
589592
return repoData.commit.oid
590593
}
591-
return null
594+
return undefined
592595
}
593596

594597
/**
@@ -781,7 +784,14 @@ export async function fetchGhsaDetails(
781784
// shape so callers don't see the difference.
782785
// -------------------------------------------------------------
783786
if (e instanceof GitHubEmptyBodyError) {
784-
return await fetchGhsaDetailsViaGraphQL(ghsaId, options)
787+
try {
788+
return await fetchGhsaDetailsViaGraphQL(ghsaId, options)
789+
} catch (cause) {
790+
throw new ErrorCtor(
791+
`Failed to fetch advisory ${ghsaId}: both REST and GraphQL backends degraded`,
792+
{ cause },
793+
)
794+
}
785795
}
786796
throw e
787797
}

0 commit comments

Comments
 (0)