ReviewOS

also looking at this

pantry-pm/pantry

fix(installer): resolve versions against live sources when one is unreachable

#255
Merged glennmichael123 wants to merge fix/242-version-source-fallback into main
6 files +400 -44
packages/action/action.ymlmodified+4-0
Changes to packages/action/action.yml
@@ -49,6 +49,10 @@ inputs:
4949 description: Pantry registry token for publishing (e.g. from secrets.PANTRY_TOKEN). Exported as PANTRY_TOKEN and PANTRY_REGISTRY_TOKEN for subsequent steps.
5050 required: false
5151 default: ''
52 github-token:
53 description: GitHub token used to authenticate upstream version lookups (api.github.com). Unauthenticated reads share a 60 req/hr quota across the runner pool's IP range, which is routinely exhausted and makes a valid version pin look unsatisfiable. Set to '' to opt out.
54 required: false
55 default: ${{ github.token }}
5256 discord-webhook:
5357 description: Discord webhook URL for release notifications. Sends a rich embed after successful publish.
5458 required: false
packages/action/src/index.tsmodified+16-0
Changes to packages/action/src/index.ts
@@ -332,6 +332,10 @@ async function installSystemPackages(
332332 const installed = await installResolvedSystemPackages(requests, {
333333 installDir: pantryDir,
334334 onRetry: message => core.warning(message),
335 // `quiet` silences progress, not this: a pin that resolved off a stale
336 // bundled catalog because a version source was unreachable installs an
337 // older version than upstream has, and CI is where that happens.
338 onWarn: message => core.warning(message),
335339 nativeInstall: packageSpecs => exec.exec(pantryExecutable, ['install', ...packageSpecs, '--no-save'], {
336340 cwd: path.dirname(pantryDir),
337341 env: { ...process.env, CI: 'true', NO_COLOR: '1' } as { [key: string]: string },
@@ -662,6 +666,18 @@ export async function run(): Promise<void> {
662666 }
663667 else core.warning('GitHub build dependency archive cache unavailable; local caching remains enabled')
664668 }
669
670 // Authenticate the SDK's upstream version lookups before anything resolves
671 // a version. Unauthenticated api.github.com is 60 req/hr per IP and runner
672 // pools share egress addresses, so a 403 here is routine — and a failed
673 // lookup is what made a published `bun.sh: ^1.4.1` report as having no
674 // satisfying version. `github.token` is the input's default, so this is on
675 // by default; passing `github-token: ''` opts out. Kept in-process rather
676 // than exported, so it reaches the lookups without widening the job env.
677 const githubToken = core.getInput('github-token')
678 if (githubToken && !process.env.PANTRY_GITHUB_TOKEN)
679 process.env.PANTRY_GITHUB_TOKEN = githubToken
680
665681 const inputs: ActionInputs = {
666682 nativeApp: (core.getInput('native-app') || undefined) as ActionInputs['nativeApp'],
667683 version: core.getInput('version') || 'latest',
packages/action/src/system-packages.tsmodified+12-2
Changes to packages/action/src/system-packages.ts
@@ -13,6 +13,8 @@ export async function installResolvedSystemPackages(
1313 platform?: installer.Platform
1414 nativeInstall: (specs: string[]) => Promise<number>
1515 onRetry?: (message: string) => void
16 /** Diagnostics that change which version is installed; `quiet` does not suppress these. */
17 onWarn?: (message: string) => void
1618 },
1719): Promise<Map<string, string>> {
1820 const versions = new Map<string, string>()
@@ -20,12 +22,19 @@ export async function installResolvedSystemPackages(
2022 for (const { domain, version } of requests) {
2123 if (installer.isSupported(domain, options.platform)) {
2224 const installed = await installer.installPackage(domain, version, {
23 installDir: options.installDir, platform: options.platform, quiet: true, onRetry: options.onRetry,
25 installDir: options.installDir,
26 platform: options.platform,
27 quiet: true,
28 onRetry: options.onRetry,
29 onWarn: options.onWarn,
2430 })
2531 versions.set(domain, installed.version)
2632 continue
2733 }
28 const resolved = await installer.resolvePackageVersion(domain, version, options.platform)
34 const resolved = await installer.resolvePackageVersion(domain, version, options.platform, {
35 onRetry: options.onRetry,
36 onWarn: options.onWarn,
37 })
2938 const previous = native.get(domain)
3039 if (previous && previous !== resolved)
3140 throw new Error(`Conflicting required versions for ${domain}: ${previous} and ${resolved}`)
@@ -51,6 +60,7 @@ export async function installResolvedSystemPackage(
5160 platform?: installer.Platform
5261 nativeInstall: (spec: string) => Promise<number>
5362 onRetry?: (message: string) => void
63 onWarn?: (message: string) => void
5464 },
5565): Promise<string> {
5666 const versions = await installResolvedSystemPackages([{ domain, version }], {
packages/ts-pantry/src/installer.tsmodified+189-42
Changes to packages/ts-pantry/src/installer.ts
@@ -53,6 +53,17 @@ export interface InstallOptions {
5353 quiet?: boolean
5454 /** Receive bounded transport-retry diagnostics. */
5555 onRetry?: (message: string) => void
56 /**
57 * Receive diagnostics that change which version is installed — currently a
58 * version source that could not be consulted.
59 *
60 * Separate from `quiet`, which suppresses progress output: a caller that
61 * silences progress (the GitHub Action passes `quiet: true` for exactly
62 * that) still needs to hear that a pin resolved off a stale snapshot,
63 * because CI is where that happens. Unset falls back to `console.warn`,
64 * which `quiet` does suppress.
65 */
66 onWarn?: (message: string) => void
5667 /**
5768 * Mirror this package's binaries to a stable user-level directory so they
5869 * appear on PATH after a one-time `pantry shell-init`. When `true` the
@@ -395,7 +406,7 @@ export async function resolvePackageVersion(
395406 domain: string,
396407 version: string,
397408 platform: Platform = detectPlatform(),
398 options: Pick<InstallOptions, 'onRetry'> = {},
409 options: Pick<InstallOptions, 'onRetry' | 'quiet' | 'onWarn'> = {},
399410): Promise<string> {
400411 if (domain === 'git-scm.org' && platform.os === 'windows') {
401412 if (['latest', '*', ''].includes(version))
@@ -411,7 +422,10 @@ export async function resolvePackageVersion(
411422
412423 // Resolve semver constraints (^, ~, >=, etc.) to concrete versions
413424 if (/^[\^~>=<]/.test(version)) {
414 version = await resolveVersionConstraint(domain, version, platform)
425 version = await resolveInstallerVersionConstraint(domain, version, platform, {
426 quiet: options.quiet,
427 onWarn: options.onWarn,
428 })
415429 }
416430 else if (version === 'latest' || version === '*' || !version) {
417431 version = await resolveLatestVersion(domain, platform)
@@ -687,9 +701,16 @@ export async function resolveLatestVersion(domain: string, platform: Platform =
687701 const resp = await fetchJSON('https://api.github.com/repos/oven-sh/bun/releases/latest').catch(() => null)
688702 const tag = (resp as { tag_name?: string } | null)?.tag_name?.replace(/^bun-v/, '') || ''
689703 if (tag) return tag
704 // The registry mirrors bun's releases per platform, so it answers "latest"
705 // correctly during a rate-limited hour. It sits ahead of the bundled
706 // catalog because the catalog is frozen at whichever release this pantry
707 // build shipped with — `latest` resolving to a months-old version is a
708 // wrong answer that looks exactly like a right one.
709 const mirrored = (await registryVersions(domain, platform).catch(() => [])).sort(compareVersions)[0]
710 if (mirrored) return mirrored
690711 const fallback = await latestFromPackageMetadata(domain)
691712 if (fallback) return fallback
692 throw new Error('Failed to resolve latest bun.sh version (GitHub API unreachable, no bundled metadata)')
713 throw new Error('Failed to resolve latest bun.sh version (GitHub API unreachable, registry publishes none, no bundled metadata)')
693714 }
694715 if (domain === 'ziglang.org') {
695716 const versions = await registryVersions(domain, platform)
@@ -702,6 +723,8 @@ export async function resolveLatestVersion(domain: string, platform: Platform =
702723 const lts = versions.find(v => v.lts)
703724 const v = (lts?.version || versions[0]?.version || '').replace(/^v/, '')
704725 if (v) return v
726 const mirrored = (await registryVersions(domain, platform).catch(() => [])).sort(compareVersions)[0]
727 if (mirrored) return mirrored
705728 const fallback = await latestFromPackageMetadata(domain)
706729 if (fallback) return fallback
707730 throw new Error('Failed to resolve latest nodejs.org version')
@@ -1056,27 +1079,118 @@ export function satisfiesInstallerConstraint(version: string, constraint: Instal
10561079 return false
10571080}
10581081
1082// ── Version sources ──
1083
1084/** What one version source was asked, and what it answered. */
1085export interface VersionSourceOutcome {
1086 /** Short identifier for diagnostics, e.g. `api.github.com/…/releases`. */
1087 source: string
1088 /** The versions it returned; `[]` when it errored. */
1089 versions: readonly string[]
1090 /** Why it could not be consulted, when it could not be. */
1091 error?: string
1092}
1093
1094/**
1095 * Whether a secondary source is worth consulting after `primary` answered.
1096 *
1097 * The errored case is the one that matters: `[]` from a 403 is
1098 * indistinguishable from `[]` from "upstream published nothing", and reading
1099 * the first as the second is what turned a satisfiable pin into a hard
1100 * "no version satisfies" failure.
1101 */
1102export function shouldConsultFallbackSource(primary: VersionSourceOutcome): boolean {
1103 return Boolean(primary.error) || primary.versions.length === 0
1104}
1105
1106/** Run one version source, recording its failure instead of throwing it. */
1107async function versionSource(source: string, load: () => Promise<string[]>): Promise<VersionSourceOutcome> {
1108 try {
1109 return { source, versions: await load() }
1110 }
1111 catch (error) {
1112 return { source, versions: [], error: errorMessage(error) }
1113 }
1114}
1115
1116function registrySourceName(domain: string, platform: Platform): string {
1117 return `${registryBase()}/binaries/${domain} (${registryPlatformKey(platform)})`
1118}
1119
1120/** Every version any source returned, de-duplicated and newest-first. */
1121export function mergeVersionOutcomes(outcomes: readonly VersionSourceOutcome[]): string[] {
1122 return [...new Set(outcomes.flatMap(outcome => [...outcome.versions]))].sort(compareInstallerVersionsDesc)
1123}
1124
1125/** One line per source: what it was, and what it answered. */
1126export function describeVersionSources(outcomes: readonly VersionSourceOutcome[]): string {
1127 return outcomes.map((outcome) => {
1128 if (outcome.error) return ` - ${outcome.source}: unavailable (${outcome.error})`
1129 const newest = [...outcome.versions].sort(compareInstallerVersionsDesc)[0]
1130 const count = `${outcome.versions.length} version${outcome.versions.length === 1 ? '' : 's'}`
1131 return ` - ${outcome.source}: ${count}${newest ? `, newest ${newest}` : ''}`
1132 }).join('\n')
1133}
1134
10591135/**
1060 * Live version list for the domains that publish one, newest-first.
1136 * Why `constraint` could not be met, naming every source that was consulted
1137 * and every one that could not be.
1138 *
1139 * The message this replaces read "checked 143 versions, newest 1.4.0" — a
1140 * count that matched no registry endpoint and named no source, so the single
1141 * fact that explained the failure (the live lookup had 403'd, leaving the
1142 * bundled snapshot to answer alone) was the fact it omitted.
1143 */
1144export function unsatisfiableConstraintMessage(
1145 domain: string,
1146 constraint: string,
1147 outcomes: readonly VersionSourceOutcome[],
1148): string {
1149 const degraded = outcomes.filter(outcome => outcome.error)
1150 return [
1151 `No version of ${domain} satisfies "${constraint}".`,
1152 ...(degraded.length > 0
1153 ? [`${degraded.length} of ${outcomes.length} version sources could not be consulted, so this list is incomplete — the pin may well be satisfiable.`]
1154 : []),
1155 'Version sources consulted:',
1156 describeVersionSources(outcomes),
1157 ].join('\n')
1158}
1159
1160/**
1161 * Live version lists for the domains that publish one, as per-source outcomes.
10611162 *
10621163 * The bundled catalog in `packages/index.js` is a point-in-time snapshot: it
10631164 * goes stale the moment upstream cuts a release (bun.sh tops out at 1.3.0
10641165 * there while 1.3.14 is current), so a constraint like `^1.3.14` matched
10651166 * nothing and we silently installed "latest" instead. Consulting upstream
10661167 * keeps a correct `deps.yaml` pin from depending on when this package was
1067 * last published. Returns [] for domains with no live source — the bundled
1068 * list is then the only answer.
1168 * last published.
1169 *
1170 * Each domain gets a fallback source behind its origin, because returning the
1171 * outcomes rather than a bare list is only half the fix: a source that cannot
1172 * be reached has to be *replaced*, not just reported.
10691173 */
1070async function fetchLiveVersions(domain: string, platform: Platform): Promise<string[]> {
1174async function fetchLiveVersions(domain: string, platform: Platform): Promise<VersionSourceOutcome[]> {
10711175 if (isBunDomain(domain)) {
1072 const resp = await fetchJSON('https://api.github.com/repos/oven-sh/bun/releases?per_page=100').catch(() => null)
1073 const releases = (resp as Array<{ tag_name?: string }> | null) || []
1074 return releases.map(r => (r.tag_name || '').replace(/^bun-v/, '')).filter(Boolean)
1176 const upstream = await versionSource('api.github.com/repos/oven-sh/bun/releases', async () => {
1177 const resp = await fetchJSON('https://api.github.com/repos/oven-sh/bun/releases?per_page=100') as Array<{ tag_name?: string }> | null
1178 return (resp || []).map(r => (r.tag_name || '').replace(/^bun-v/, '')).filter(Boolean)
1179 })
1180 // Unauthenticated api.github.com is 60 req/hr per IP and CI runners share
1181 // egress addresses, so a 403 here is routine rather than exceptional. The
1182 // registry mirrors the same releases per platform, which is what keeps a
1183 // rate-limited hour from resolving off the bundled snapshot instead.
1184 if (!shouldConsultFallbackSource(upstream)) return [upstream]
1185 return [upstream, await versionSource(registrySourceName(domain, platform), () => registryVersions(domain, platform))]
10751186 }
10761187 if (domain === 'nodejs.org') {
1077 const resp = await fetchJSON('https://nodejs.org/dist/index.json').catch(() => null)
1078 const versions = (resp as Array<{ version?: string }> | null) || []
1079 return versions.map(v => (v.version || '').replace(/^v/, '')).filter(Boolean)
1188 const upstream = await versionSource('nodejs.org/dist/index.json', async () => {
1189 const resp = await fetchJSON('https://nodejs.org/dist/index.json') as Array<{ version?: string }> | null
1190 return (resp || []).map(v => (v.version || '').replace(/^v/, '')).filter(Boolean)
1191 })
1192 if (!shouldConsultFallbackSource(upstream)) return [upstream]
1193 return [upstream, await versionSource(registrySourceName(domain, platform), () => registryVersions(domain, platform))]
10801194 }
10811195 if (domain === 'ziglang.org') {
10821196 // The installer downloads zig from ziglang.org itself, and ziglang.org
@@ -1086,15 +1200,17 @@ async function fetchLiveVersions(domain: string, platform: Platform): Promise<st
10861200 // job that refreshes the mirror could not install zig to run, so the
10871201 // stale pin could never heal itself. Origin first; the registry is the
10881202 // fallback for when the origin index is unreachable.
1089 const index = await fetchJSON('https://ziglang.org/download/index.json').catch(() => null) as ZigDownloadIndex | null
1090 const origin = zigOriginVersions(index, platform)
1091 if (origin.length > 0) return origin
1092 return await registryVersions(domain, platform)
1203 const origin = await versionSource('ziglang.org/download/index.json', async () => {
1204 const index = await fetchJSON('https://ziglang.org/download/index.json') as ZigDownloadIndex | null
1205 return zigOriginVersions(index, platform)
1206 })
1207 if (!shouldConsultFallbackSource(origin)) return [origin]
1208 return [origin, await versionSource(registrySourceName(domain, platform), () => registryVersions(domain, platform))]
10931209 }
10941210 // For a registry-published package the registry *is* the live list, so a
10951211 // `^0.0.37`-style constraint resolves against what can actually be installed
10961212 // rather than against nothing.
1097 return await registryVersions(domain, platform)
1213 return [await versionSource(registrySourceName(domain, platform), () => registryVersions(domain, platform))]
10981214}
10991215
11001216export type ZigDownloadIndex = Record<string, Record<string, unknown> & { version?: string }>
@@ -1119,44 +1235,67 @@ export function resolveInstallerConstraintFromCandidates(constraint: string, can
11191235 return [...new Set(candidates)].sort(compareInstallerVersionsDesc).find(version => satisfiesInstallerConstraint(version, parsed)) || null
11201236}
11211237
1122/**
1123 * Resolve a semver constraint (^1.0.0, ~1.2.0, >=1.0.0, etc.) to the best matching concrete version.
1124 */
1125async function resolveVersionConstraint(domain: string, constraint: string, platform: Platform): Promise<string> {
1126 if (!parseInstallerConstraint(constraint)) return resolveLatestVersion(domain, platform)
1127
1128 // Get available versions from package metadata
1129 let bundledVersions: readonly string[]
1238/** Versions `packages/index.js` was built with, for `domain`. Point-in-time. */
1239async function bundledCatalogVersions(domain: string): Promise<string[]> {
11301240 try {
11311241 const pkgKey = domain.replace(/[^a-z0-9]/gi, '').toLowerCase()
11321242 const { packages } = await import('./packages/index.js').catch(() => import('./index.js'))
1133 const pkg = (packages as any)[pkgKey]
1134 bundledVersions = pkg?.versions || []
1243 const pkg = (packages as unknown as Record<string, { versions?: readonly string[] }>)[pkgKey]
1244 return [...(pkg?.versions || [])]
11351245 }
11361246 catch {
1137 bundledVersions = []
1247 return []
11381248 }
1249}
1250
1251/**
1252 * Resolve a semver constraint (^1.0.0, ~1.2.0, >=1.0.0, etc.) to the best matching concrete version.
1253 *
1254 * Named apart from `dependency-resolver`'s same-purpose export because the
1255 * package index re-exports both modules with `export *`.
1256 */
1257export async function resolveInstallerVersionConstraint(
1258 domain: string,
1259 constraint: string,
1260 platform: Platform,
1261 options: Pick<InstallOptions, 'quiet' | 'onWarn'> = {},
1262): Promise<string> {
1263 if (!parseInstallerConstraint(constraint)) return resolveLatestVersion(domain, platform)
11391264
11401265 // Union of upstream + bundled, sorted so the first match is the highest one.
11411266 // Merging rather than preferring either list keeps resolution working when
11421267 // upstream is unreachable (bundled-only) and when it's ahead (live-only).
1143 const live = await fetchLiveVersions(domain, platform)
1144 // The Pantry registry is authoritative for Zig binaries. Bundled recipe
1268 //
1269 // The Pantry registry is authoritative for Zig binaries: bundled recipe
11451270 // versions do not prove that a particular target artifact was published.
1146 const candidates = domain === 'ziglang.org'
1147 ? live.sort(compareInstallerVersionsDesc)
1148 : [...new Set([...live, ...bundledVersions])].sort(compareInstallerVersionsDesc)
1149 const resolved = resolveInstallerConstraintFromCandidates(constraint, candidates)
1150 if (resolved) return resolved
1271 const live = await fetchLiveVersions(domain, platform)
1272 const outcomes: VersionSourceOutcome[] = domain === 'ziglang.org'
1273 ? live
1274 : [...live, { source: 'bundled catalog (this pantry build)', versions: await bundledCatalogVersions(domain) }]
1275
1276 const resolved = resolveInstallerConstraintFromCandidates(constraint, mergeVersionOutcomes(outcomes))
1277 const degraded = outcomes.filter(outcome => outcome.error)
1278
1279 if (resolved) {
1280 // Resolving while a source is down means the answer came from whatever
1281 // was left — possibly the bundled snapshot, which can be releases behind.
1282 // Say so: silently installing an older version than upstream has is the
1283 // half of this bug that never announces itself as a failure.
1284 if (degraded.length > 0) {
1285 const warn = options.onWarn || (options.quiet ? undefined : console.warn)
1286 warn?.(
1287 `${domain}: resolved "${constraint}" to ${resolved} with ${degraded.length} of `
1288 + `${outcomes.length} version sources unavailable; a newer match may exist upstream\n`
1289 + describeVersionSources(outcomes),
1290 )
1291 }
1292 return resolved
1293 }
11511294
11521295 // Nothing upstream or bundled satisfies the pin. Installing "latest" here
11531296 // would hand back a version the caller explicitly did not ask for, which is
11541297 // worse than stopping: a `^1.2` pin quietly became 1.3.x.
1155 throw new Error(
1156 `No version of ${domain} satisfies "${constraint}" `
1157 + `(checked ${candidates.length} version${candidates.length === 1 ? '' : 's'}`
1158 + `${candidates.length > 0 ? `, newest ${candidates[0]}` : ''}).`,
1159 )
1298 throw new Error(unsatisfiableConstraintMessage(domain, constraint, outcomes))
11601299}
11611300
11621301// ── Helpers ──
@@ -1411,8 +1550,16 @@ function fetchJSONOnce(url: string, maxRedirects: number, deadline = Date.now()
14111550 }
14121551 // Rebuild credentials on every redirect. A path, query, subdomain, HTTP
14131552 // endpoint or non-default port cannot acquire the GitHub API credential.
1553 //
1554 // PANTRY_GITHUB_TOKEN comes first so a caller can authenticate these reads
1555 // without setting GITHUB_TOKEN — which is NOT present on an Actions runner
1556 // unless the workflow passes it through, and which other code paths read
1557 // for their own purposes (the action's release step among them). The
1558 // Setup Pantry action sets it from its `github-token` input, whose default
1559 // is the job's own token; unauthenticated reads share a 60 req/hr quota
1560 // across the whole runner pool's IP range.
14141561 if (target.origin === 'https://api.github.com' && !target.username && !target.password) {
1415 const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN
1562 const token = process.env.PANTRY_GITHUB_TOKEN || process.env.GITHUB_TOKEN || process.env.GH_TOKEN
14161563 if (token) headers.Authorization = `Bearer ${token}`
14171564 }
14181565 let response: http.IncomingMessage | undefined
packages/ts-pantry/test/installer-degraded-source.test.tsadded+99-0
Changes to packages/ts-pantry/test/installer-degraded-source.test.ts
@@ -0,0 +1,99 @@
1import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'
2import { EventEmitter } from 'node:events'
3import * as http from 'node:http'
4import * as nodeHttps from 'node:https'
5
6// Captured before the mock is installed, so it can be put back afterwards:
7// a module mock outlives the file that registered it, and every later test
8// file in this process shares the registry.
9const realHttps = { ...nodeHttps }
10
11// Every https request fails with the status a shared-IP CI runner actually
12// gets from unauthenticated api.github.com once the 60 req/hr quota is gone.
13// A 403 is deliberately non-retryable, so this resolves at once.
14function rateLimitedRequest(): EventEmitter & { destroy: () => void } {
15 const req = Object.assign(new EventEmitter(), { destroy: () => {} })
16 setImmediate(() => req.emit('error', Object.assign(
17 new Error('HTTP 403 fetching https://api.github.com/repos/oven-sh/bun/releases?per_page=100'),
18 { statusCode: 403 },
19 )))
20 return req
21}
22
23mock.module('node:https', () => ({ get: rateLimitedRequest, default: { get: rateLimitedRequest } }))
24
25const { resolveInstallerVersionConstraint } = await import('../src/installer')
26
27// A version no bundled catalog can contain, so resolving it proves the
28// registry was consulted rather than the point-in-time snapshot.
29const METADATA = {
30 versions: {
31 '99.1.0': { platforms: { 'darwin-arm64': { tarball: 'binaries/bun.com/99.1.0/darwin-arm64/bun.com-99.1.0.tar.gz' } } },
32 '99.1.1': { platforms: { 'darwin-arm64': { tarball: 'binaries/bun.com/99.1.1/darwin-arm64/bun.com-99.1.1.tar.gz' } } },
33 '99.2.0': { platforms: { 'linux-x86-64': { tarball: 'binaries/bun.com/99.2.0/linux-x86-64/bun.com-99.2.0.tar.gz' } } },
34 },
35}
36
37let server: http.Server
38let serveMetadata = true
39const previousRegistry = process.env.PANTRY_REGISTRY_URL
40
41beforeAll(async () => {
42 server = http.createServer((_req, res) => {
43 if (!serveMetadata) {
44 res.writeHead(403).end('rate limited')
45 return
46 }
47 res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(METADATA))
48 })
49 await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
50 const { port } = server.address() as { port: number }
51 // http, so these calls go through the un-mocked `node:http` while the bun
52 // releases lookup stays dead.
53 process.env.PANTRY_REGISTRY_URL = `http://127.0.0.1:${port}`
54})
55
56afterAll(async () => {
57 mock.module('node:https', () => realHttps)
58 if (previousRegistry === undefined) delete process.env.PANTRY_REGISTRY_URL
59 else process.env.PANTRY_REGISTRY_URL = previousRegistry
60 await new Promise<void>(resolve => server.close(() => resolve()))
61})
62
63const darwinArm64 = { os: 'darwin', arch: 'aarch64' } as const
64
65describe('version resolution with a degraded live source', () => {
66 it('falls back to the registry when the bun releases lookup is rate-limited', async () => {
67 // The reported failure: `^1.4.1` rejected as unsatisfiable while 1.4.2 was
68 // published. The pin here can only come from the registry.
69 expect(await resolveInstallerVersionConstraint('bun.com', '^99.1.1', darwinArm64, { quiet: true })).toBe('99.1.1')
70 expect(await resolveInstallerVersionConstraint('bun.sh', '^99.1.0', darwinArm64, { quiet: true })).toBe('99.1.1')
71 })
72
73 it('honours the platform the registry publishes for', async () => {
74 // 99.2.0 is linux-only, so a darwin runner must not be handed it.
75 expect(await resolveInstallerVersionConstraint('bun.com', '^99.1.0', darwinArm64, { quiet: true })).toBe('99.1.1')
76 expect(await resolveInstallerVersionConstraint('bun.com', '^99.2.0', { os: 'linux', arch: 'x86_64' }, { quiet: true })).toBe('99.2.0')
77 })
78
79 it('names both failed sources when no source could be consulted', async () => {
80 serveMetadata = false
81 try {
82 await resolveInstallerVersionConstraint('bun.com', '^99.1.1', darwinArm64, { quiet: true })
83 throw new Error('expected an unsatisfiable-constraint failure')
84 }
85 catch (error) {
86 const message = error instanceof Error ? error.message : String(error)
87 expect(message).toContain('No version of bun.com satisfies "^99.1.1".')
88 expect(message).toContain('2 of 3 version sources could not be consulted')
89 expect(message).toContain('api.github.com/repos/oven-sh/bun/releases: unavailable')
90 expect(message).toContain('/binaries/bun.com (darwin-arm64): unavailable')
91 // The bundled snapshot answered, and the message says so rather than
92 // presenting its ceiling as the whole truth.
93 expect(message).toContain('bundled catalog (this pantry build):')
94 }
95 finally {
96 serveMetadata = true
97 }
98 })
99})
packages/ts-pantry/test/installer-version-sources.test.tsadded+80-0
Changes to packages/ts-pantry/test/installer-version-sources.test.ts
@@ -0,0 +1,80 @@
1import type { VersionSourceOutcome } from '../src/installer'
2import { describe, expect, it } from 'bun:test'
3import {
4 describeVersionSources,
5 mergeVersionOutcomes,
6 resolveInstallerConstraintFromCandidates,
7 shouldConsultFallbackSource,
8 unsatisfiableConstraintMessage,
9} from '../src/installer'
10
11// The bundled catalog as it shipped in the pantry build the failing workflow
12// pinned: 1.4.0 is its ceiling, so `^1.4.1` has nothing to match in it.
13const staleBundled: VersionSourceOutcome = {
14 source: 'bundled catalog (this pantry build)',
15 versions: ['1.4.0', '1.3.14', '1.3.13'],
16}
17
18const rateLimited: VersionSourceOutcome = {
19 source: 'api.github.com/repos/oven-sh/bun/releases',
20 versions: [],
21 error: 'HTTP 403 fetching https://api.github.com/repos/oven-sh/bun/releases?per_page=100',
22}
23
24const mirrored: VersionSourceOutcome = {
25 source: 'https://registry.pantry.dev/binaries/bun.com (darwin-arm64)',
26 versions: ['1.4.2', '1.4.1', '1.4.0', '1.3.14'],
27}
28
29describe('live version source outcomes', () => {
30 it('treats an errored source as a reason to consult the fallback', () => {
31 // `[]` from a 403 and `[]` from "upstream published nothing" are the same
32 // value; only the recorded error separates them.
33 expect(shouldConsultFallbackSource(rateLimited)).toBe(true)
34 expect(shouldConsultFallbackSource({ source: 'upstream', versions: [] })).toBe(true)
35 expect(shouldConsultFallbackSource({ source: 'upstream', versions: ['1.4.2'] })).toBe(false)
36 })
37
38 it('resolves a pin above the stale ceiling from the fallback source', () => {
39 const degraded = mergeVersionOutcomes([rateLimited, staleBundled])
40 expect(resolveInstallerConstraintFromCandidates('^1.4.1', degraded)).toBeNull()
41
42 // With the registry consulted in the rate-limited source's place, the pin
43 // the workflow actually wrote resolves — and to the newest match, not the
44 // floor it names.
45 const recovered = mergeVersionOutcomes([rateLimited, mirrored, staleBundled])
46 expect(resolveInstallerConstraintFromCandidates('^1.4.1', recovered)).toBe('1.4.2')
47 expect(resolveInstallerConstraintFromCandidates('^1.4.0', recovered)).toBe('1.4.2')
48 })
49
50 it('merges sources newest-first without duplicating shared versions', () => {
51 expect(mergeVersionOutcomes([mirrored, staleBundled])).toEqual([
52 '1.4.2',
53 '1.4.1',
54 '1.4.0',
55 '1.3.14',
56 '1.3.13',
57 ])
58 })
59
60 it('names every source consulted, and says which ones errored', () => {
61 const described = describeVersionSources([rateLimited, mirrored, staleBundled])
62 expect(described).toContain('api.github.com/repos/oven-sh/bun/releases: unavailable (HTTP 403')
63 expect(described).toContain('(darwin-arm64): 4 versions, newest 1.4.2')
64 expect(described).toContain('bundled catalog (this pantry build): 3 versions, newest 1.4.0')
65 })
66
67 it('reports an unsatisfiable pin as incomplete when a source was unreachable', () => {
68 const message = unsatisfiableConstraintMessage('bun.com', '^1.4.1', [rateLimited, staleBundled])
69 expect(message).toContain('No version of bun.com satisfies "^1.4.1".')
70 // The fact that explained the failure, and that the old one-line message
71 // ("checked 143 versions, newest 1.4.0") left out entirely.
72 expect(message).toContain('1 of 2 version sources could not be consulted')
73 expect(message).toContain('HTTP 403')
74
75 // With every source reachable, the pin really is unsatisfiable and the
76 // message must not hedge about it.
77 const genuine = unsatisfiableConstraintMessage('bun.com', '^9.0.0', [mirrored, staleBundled])
78 expect(genuine).not.toContain('could not be consulted')
79 })
80})