also looking at this
fix(installer): resolve versions against live sources when one is unreachable
#255
6 files
+400
-44
| @@ -49,6 +49,10 @@ inputs: | ||
| 49 | 49 | description: Pantry registry token for publishing (e.g. from secrets.PANTRY_TOKEN). Exported as PANTRY_TOKEN and PANTRY_REGISTRY_TOKEN for subsequent steps. |
| 50 | 50 | required: false |
| 51 | 51 | 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 }} | |
| 52 | 56 | discord-webhook: |
| 53 | 57 | description: Discord webhook URL for release notifications. Sends a rich embed after successful publish. |
| 54 | 58 | required: false |
| @@ -332,6 +332,10 @@ async function installSystemPackages( | ||
| 332 | 332 | const installed = await installResolvedSystemPackages(requests, { |
| 333 | 333 | installDir: pantryDir, |
| 334 | 334 | 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), | |
| 335 | 339 | nativeInstall: packageSpecs => exec.exec(pantryExecutable, ['install', ...packageSpecs, '--no-save'], { |
| 336 | 340 | cwd: path.dirname(pantryDir), |
| 337 | 341 | env: { ...process.env, CI: 'true', NO_COLOR: '1' } as { [key: string]: string }, |
| @@ -662,6 +666,18 @@ export async function run(): Promise<void> { | ||
| 662 | 666 | } |
| 663 | 667 | else core.warning('GitHub build dependency archive cache unavailable; local caching remains enabled') |
| 664 | 668 | } |
| 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 | ||
| 665 | 681 | const inputs: ActionInputs = { |
| 666 | 682 | nativeApp: (core.getInput('native-app') || undefined) as ActionInputs['nativeApp'], |
| 667 | 683 | version: core.getInput('version') || 'latest', |
| @@ -13,6 +13,8 @@ export async function installResolvedSystemPackages( | ||
| 13 | 13 | platform?: installer.Platform |
| 14 | 14 | nativeInstall: (specs: string[]) => Promise<number> |
| 15 | 15 | onRetry?: (message: string) => void |
| 16 | /** Diagnostics that change which version is installed; `quiet` does not suppress these. */ | |
| 17 | onWarn?: (message: string) => void | |
| 16 | 18 | }, |
| 17 | 19 | ): Promise<Map<string, string>> { |
| 18 | 20 | const versions = new Map<string, string>() |
| @@ -20,12 +22,19 @@ export async function installResolvedSystemPackages( | ||
| 20 | 22 | for (const { domain, version } of requests) { |
| 21 | 23 | if (installer.isSupported(domain, options.platform)) { |
| 22 | 24 | 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, | |
| 24 | 30 | }) |
| 25 | 31 | versions.set(domain, installed.version) |
| 26 | 32 | continue |
| 27 | 33 | } |
| 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 | }) | |
| 29 | 38 | const previous = native.get(domain) |
| 30 | 39 | if (previous && previous !== resolved) |
| 31 | 40 | throw new Error(`Conflicting required versions for ${domain}: ${previous} and ${resolved}`) |
| @@ -51,6 +60,7 @@ export async function installResolvedSystemPackage( | ||
| 51 | 60 | platform?: installer.Platform |
| 52 | 61 | nativeInstall: (spec: string) => Promise<number> |
| 53 | 62 | onRetry?: (message: string) => void |
| 63 | onWarn?: (message: string) => void | |
| 54 | 64 | }, |
| 55 | 65 | ): Promise<string> { |
| 56 | 66 | const versions = await installResolvedSystemPackages([{ domain, version }], { |
| @@ -53,6 +53,17 @@ export interface InstallOptions { | ||
| 53 | 53 | quiet?: boolean |
| 54 | 54 | /** Receive bounded transport-retry diagnostics. */ |
| 55 | 55 | 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 | |
| 56 | 67 | /** |
| 57 | 68 | * Mirror this package's binaries to a stable user-level directory so they |
| 58 | 69 | * appear on PATH after a one-time `pantry shell-init`. When `true` the |
| @@ -395,7 +406,7 @@ export async function resolvePackageVersion( | ||
| 395 | 406 | domain: string, |
| 396 | 407 | version: string, |
| 397 | 408 | platform: Platform = detectPlatform(), |
| 398 | options: Pick<InstallOptions, 'onRetry'> = {}, | |
| 409 | options: Pick<InstallOptions, 'onRetry' | 'quiet' | 'onWarn'> = {}, | |
| 399 | 410 | ): Promise<string> { |
| 400 | 411 | if (domain === 'git-scm.org' && platform.os === 'windows') { |
| 401 | 412 | if (['latest', '*', ''].includes(version)) |
| @@ -411,7 +422,10 @@ export async function resolvePackageVersion( | ||
| 411 | 422 | |
| 412 | 423 | // Resolve semver constraints (^, ~, >=, etc.) to concrete versions |
| 413 | 424 | 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 | }) | |
| 415 | 429 | } |
| 416 | 430 | else if (version === 'latest' || version === '*' || !version) { |
| 417 | 431 | version = await resolveLatestVersion(domain, platform) |
| @@ -687,9 +701,16 @@ export async function resolveLatestVersion(domain: string, platform: Platform = | ||
| 687 | 701 | const resp = await fetchJSON('https://api.github.com/repos/oven-sh/bun/releases/latest').catch(() => null) |
| 688 | 702 | const tag = (resp as { tag_name?: string } | null)?.tag_name?.replace(/^bun-v/, '') || '' |
| 689 | 703 | 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 | |
| 690 | 711 | const fallback = await latestFromPackageMetadata(domain) |
| 691 | 712 | 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)') | |
| 693 | 714 | } |
| 694 | 715 | if (domain === 'ziglang.org') { |
| 695 | 716 | const versions = await registryVersions(domain, platform) |
| @@ -702,6 +723,8 @@ export async function resolveLatestVersion(domain: string, platform: Platform = | ||
| 702 | 723 | const lts = versions.find(v => v.lts) |
| 703 | 724 | const v = (lts?.version || versions[0]?.version || '').replace(/^v/, '') |
| 704 | 725 | if (v) return v |
| 726 | const mirrored = (await registryVersions(domain, platform).catch(() => [])).sort(compareVersions)[0] | |
| 727 | if (mirrored) return mirrored | |
| 705 | 728 | const fallback = await latestFromPackageMetadata(domain) |
| 706 | 729 | if (fallback) return fallback |
| 707 | 730 | throw new Error('Failed to resolve latest nodejs.org version') |
| @@ -1056,27 +1079,118 @@ export function satisfiesInstallerConstraint(version: string, constraint: Instal | ||
| 1056 | 1079 | return false |
| 1057 | 1080 | } |
| 1058 | 1081 | |
| 1082 | // ── Version sources ── | |
| 1083 | ||
| 1084 | /** What one version source was asked, and what it answered. */ | |
| 1085 | export 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 | */ | |
| 1102 | export 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. */ | |
| 1107 | async 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 | ||
| 1116 | function 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. */ | |
| 1121 | export 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. */ | |
| 1126 | export 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 | ||
| 1059 | 1135 | /** |
| 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 | */ | |
| 1144 | export 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. | |
| 1061 | 1162 | * |
| 1062 | 1163 | * The bundled catalog in `packages/index.js` is a point-in-time snapshot: it |
| 1063 | 1164 | * goes stale the moment upstream cuts a release (bun.sh tops out at 1.3.0 |
| 1064 | 1165 | * there while 1.3.14 is current), so a constraint like `^1.3.14` matched |
| 1065 | 1166 | * nothing and we silently installed "latest" instead. Consulting upstream |
| 1066 | 1167 | * 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. | |
| 1069 | 1173 | */ |
| 1070 | async function fetchLiveVersions(domain: string, platform: Platform): Promise<string[]> { | |
| 1174 | async function fetchLiveVersions(domain: string, platform: Platform): Promise<VersionSourceOutcome[]> { | |
| 1071 | 1175 | 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))] | |
| 1075 | 1186 | } |
| 1076 | 1187 | 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))] | |
| 1080 | 1194 | } |
| 1081 | 1195 | if (domain === 'ziglang.org') { |
| 1082 | 1196 | // The installer downloads zig from ziglang.org itself, and ziglang.org |
| @@ -1086,15 +1200,17 @@ async function fetchLiveVersions(domain: string, platform: Platform): Promise<st | ||
| 1086 | 1200 | // job that refreshes the mirror could not install zig to run, so the |
| 1087 | 1201 | // stale pin could never heal itself. Origin first; the registry is the |
| 1088 | 1202 | // 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))] | |
| 1093 | 1209 | } |
| 1094 | 1210 | // For a registry-published package the registry *is* the live list, so a |
| 1095 | 1211 | // `^0.0.37`-style constraint resolves against what can actually be installed |
| 1096 | 1212 | // rather than against nothing. |
| 1097 | return await registryVersions(domain, platform) | |
| 1213 | return [await versionSource(registrySourceName(domain, platform), () => registryVersions(domain, platform))] | |
| 1098 | 1214 | } |
| 1099 | 1215 | |
| 1100 | 1216 | export type ZigDownloadIndex = Record<string, Record<string, unknown> & { version?: string }> |
| @@ -1119,44 +1235,67 @@ export function resolveInstallerConstraintFromCandidates(constraint: string, can | ||
| 1119 | 1235 | return [...new Set(candidates)].sort(compareInstallerVersionsDesc).find(version => satisfiesInstallerConstraint(version, parsed)) || null |
| 1120 | 1236 | } |
| 1121 | 1237 | |
| 1122 | /** | |
| 1123 | * Resolve a semver constraint (^1.0.0, ~1.2.0, >=1.0.0, etc.) to the best matching concrete version. | |
| 1124 | */ | |
| 1125 | async 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. */ | |
| 1239 | async function bundledCatalogVersions(domain: string): Promise<string[]> { | |
| 1130 | 1240 | try { |
| 1131 | 1241 | const pkgKey = domain.replace(/[^a-z0-9]/gi, '').toLowerCase() |
| 1132 | 1242 | 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 || [])] | |
| 1135 | 1245 | } |
| 1136 | 1246 | catch { |
| 1137 | bundledVersions = [] | |
| 1247 | return [] | |
| 1138 | 1248 | } |
| 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 | */ | |
| 1257 | export 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) | |
| 1139 | 1264 | |
| 1140 | 1265 | // Union of upstream + bundled, sorted so the first match is the highest one. |
| 1141 | 1266 | // Merging rather than preferring either list keeps resolution working when |
| 1142 | 1267 | // 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 | |
| 1145 | 1270 | // 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 | } | |
| 1151 | 1294 | |
| 1152 | 1295 | // Nothing upstream or bundled satisfies the pin. Installing "latest" here |
| 1153 | 1296 | // would hand back a version the caller explicitly did not ask for, which is |
| 1154 | 1297 | // 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)) | |
| 1160 | 1299 | } |
| 1161 | 1300 | |
| 1162 | 1301 | // ── Helpers ── |
| @@ -1411,8 +1550,16 @@ function fetchJSONOnce(url: string, maxRedirects: number, deadline = Date.now() | ||
| 1411 | 1550 | } |
| 1412 | 1551 | // Rebuild credentials on every redirect. A path, query, subdomain, HTTP |
| 1413 | 1552 | // 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. | |
| 1414 | 1561 | 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 | |
| 1416 | 1563 | if (token) headers.Authorization = `Bearer ${token}` |
| 1417 | 1564 | } |
| 1418 | 1565 | let response: http.IncomingMessage | undefined |
| @@ -0,0 +1,99 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test' | |
| 2 | import { EventEmitter } from 'node:events' | |
| 3 | import * as http from 'node:http' | |
| 4 | import * 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. | |
| 9 | const 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. | |
| 14 | function 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 | ||
| 23 | mock.module('node:https', () => ({ get: rateLimitedRequest, default: { get: rateLimitedRequest } })) | |
| 24 | ||
| 25 | const { 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. | |
| 29 | const 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 | ||
| 37 | let server: http.Server | |
| 38 | let serveMetadata = true | |
| 39 | const previousRegistry = process.env.PANTRY_REGISTRY_URL | |
| 40 | ||
| 41 | beforeAll(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 | ||
| 56 | afterAll(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 | ||
| 63 | const darwinArm64 = { os: 'darwin', arch: 'aarch64' } as const | |
| 64 | ||
| 65 | describe('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 | }) | |
| @@ -0,0 +1,80 @@ | ||
| 1 | import type { VersionSourceOutcome } from '../src/installer' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { | |
| 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. | |
| 13 | const staleBundled: VersionSourceOutcome = { | |
| 14 | source: 'bundled catalog (this pantry build)', | |
| 15 | versions: ['1.4.0', '1.3.14', '1.3.13'], | |
| 16 | } | |
| 17 | ||
| 18 | const 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 | ||
| 24 | const 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 | ||
| 29 | describe('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 | }) | |