diff --git a/apps/server/drizzle/0036_mod_registry_versions_pin_failed_at.sql b/apps/server/drizzle/0036_mod_registry_versions_pin_failed_at.sql new file mode 100644 index 0000000..0573bf9 --- /dev/null +++ b/apps/server/drizzle/0036_mod_registry_versions_pin_failed_at.sql @@ -0,0 +1,8 @@ +-- Tracks a mod_registry_versions row that backfill-branch-pins.ts gave up on +-- permanently resolving to a commit-pinned downloadUrl (see that script and +-- mods-sync.service.ts's pinBranchVersionIfNew) after exhausting its +-- retries within a run. Null means "never permanently failed" -- either +-- already pinned (downloadUrl no longer classifies as 'branch') or not +-- attempted yet. Lets a re-run of the backfill skip known-dead rows by +-- default instead of re-spending GitHub API calls on them every time. +ALTER TABLE "mod_registry_versions" ADD COLUMN "pin_failed_at" timestamp with time zone; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 5026c12..f537149 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -253,6 +253,13 @@ "when": 1791100000000, "tag": "0035_blog_posts", "breakpoints": true + }, + { + "idx": 36, + "version": "7", + "when": 1791200000000, + "tag": "0036_mod_registry_versions_pin_failed_at", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 8e6c787..485bcf6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -9,6 +9,7 @@ "migrate": "tsx src/infrastructure/db/migrate.ts", "generate": "drizzle-kit generate", "backfill-mod-hashes": "tsx src/features/mods/backfill-mod-hashes.ts", + "backfill-branch-pins": "tsx src/features/mods/backfill-branch-pins.ts", "test": "vitest run", "test:watch": "vitest", "test:e2e": "vitest run --config vitest.e2e.config.ts", diff --git a/apps/server/src/features/mods/backfill-branch-pins.ts b/apps/server/src/features/mods/backfill-branch-pins.ts new file mode 100644 index 0000000..6a4fc45 --- /dev/null +++ b/apps/server/src/features/mods/backfill-branch-pins.ts @@ -0,0 +1,67 @@ +/** + * One-off maintenance operation: pins every still-branch-tracked + * mod_registry_versions row (its downloadUrl still classifies as 'branch' -- + * see mod-source-classifier.ts) to a real, permanently-fetchable + * commit-specific downloadUrl, and re-downloads + re-hashes each one against + * that pinned URL -- see resolveCommitPinnedDownloadUrl's doc comment + * (custom-mod-version-check.service.ts) for why the URL is unfetchable-once- + * stale in the first place. The hash gets re-verified too, not just the URL: + * a stale row's stored sha256 was originally computed against whatever the + * branch's live tip happened to be at hash-time, not necessarily the exact + * commit its own version label names, so trusting the existing hash could + * silently leave it mismatched against the commit it's about to claim to be + * pinned to. + * + * Complements pinBranchVersionIfNew() in mods-sync.service.ts, which only + * ever pins a version the first time it's synced going forward -- this is + * the one-time catch-up pass for every row that was already synced (and + * hashed) before that fix existed. + * + * A row that fails to resolve/hash after a few retries within this run (a + * deleted repo/branch, a garbage-collected commit, or a rate limit that + * outlasts the retries) is marked via pinFailedAt and skipped on future + * runs, so a known-dead row doesn't keep burning GitHub API calls forever. + * Pass --retry-failed to also re-attempt rows an earlier run gave up on -- + * useful after whatever made them unresolvable might have changed (a + * renamed repo, an expired rate limit). + * + * Safe to re-run: a row that's already pinned (downloadUrl no longer + * classifies as 'branch') is left alone, and re-pinning an already-pinned + * row would just reproduce the same result anyway. + * + * Needs the same runtime as the server itself -- network access to every + * mod's GitHub download URL -- so run it inside the deployed container: + * + * docker compose exec api pnpm --filter balatro-multiplayer-api-server backfill-branch-pins + * docker compose exec api pnpm --filter balatro-multiplayer-api-server backfill-branch-pins --retry-failed + * + * or locally against a real DATABASE_URL: + * + * tsx --env-file=.env src/features/mods/backfill-branch-pins.ts + */ + +import { pool } from '../../infrastructure/db/index.js' +import { runBranchPinBackfill } from './mods-sync.service.js' + +const retryFailed = process.argv.includes('--retry-failed') + +runBranchPinBackfill({ retryFailed }) + .then(async (summary) => { + await pool.end() + console.log( + `[backfill-branch-pins] Done: ${summary.pinned} pinned, ${summary.alreadyPinned} already pinned, ${summary.failed} newly failed, ${summary.skippedFailed} skipped (already marked failed, pass --retry-failed to retry them).`, + ) + if (summary.failedRows.length > 0) { + console.log( + `[backfill-branch-pins] Rows marked failed this run: ${summary.failedRows + .map((r) => `${r.modId}@${r.version}`) + .join(', ')}`, + ) + } + process.exit(0) + }) + .catch(async (err) => { + console.error('[backfill-branch-pins] Failed:', err) + await pool.end().catch(() => {}) + process.exit(1) + }) diff --git a/apps/server/src/features/mods/custom-mod-version-check.service.ts b/apps/server/src/features/mods/custom-mod-version-check.service.ts index 1ceafee..304861a 100644 --- a/apps/server/src/features/mods/custom-mod-version-check.service.ts +++ b/apps/server/src/features/mods/custom-mod-version-check.service.ts @@ -6,7 +6,10 @@ import { classifyDownloadUrl } from './mod-source-classifier.js' // custom mods (mod_registry.isCustom rows) that opt into // automaticVersionCheck -- upstream mods get this for free from that same // script running on the real skyline69/balatro-mod-index repo, but a custom -// mod has no meta.json anywhere for it to have already run against. +// mod has no meta.json anywhere for it to have already run against. Also +// home to resolveCommitPinnedDownloadUrl() below, which both this module's +// own HEAD-tracking callers and mods-sync.service.ts's upstream-index sync +// share -- see that function's doc comment. export type VersionSource = 'latest_tag' | 'specific_tag' | 'head' export interface VersionCheckInput { @@ -107,6 +110,59 @@ async function fetchHeadSha( return data[0].sha.slice(0, 7) } +// A short (7-char, matching what update_mod_versions.py/fetchHeadSha above +// both write) or full (40-char) git commit SHA. +const GIT_SHA_LIKE = /^[0-9a-f]{7,40}$/i + +// Branch-tracked mods (no GitHub releases -- downloadUrl classifies as +// 'branch') get their `version` bumped by update_mod_versions.py to the +// *whole repo's* latest commit SHA on any commit anywhere in the repo, but +// that script only ever rewrites `downloadURL` for its tag/release cases -- +// never for the HEAD case (see that script: the `if`/`elif` guarding +// `meta['downloadURL'] = ...` has no branch for `VersionSource.HEAD` at +// all). So every version ever recorded for such a mod carries the exact +// same URL: the branch's own live-HEAD archive link. Downloading it always +// fetches "whatever's on the branch right now", never the specific commit +// the version label names -- confirmed live via +// skyline69/balatro-mod-index's Aikoyori@Aikoyoris-Shenanigans, whose +// mod_registry_versions history has a dozen distinct commit-hash version +// labels all sharing one identical downloadUrl and (whenever the branch +// hadn't actually moved between two of those label bumps) identical sha256. +// The real cost isn't the duplication itself -- it's that an *older* label +// becomes permanently unfetchable once the branch advances past it: nothing +// in this pipeline can ever again produce that label's original bytes, +// which silently breaks any profile (a Ranked rankedVersion pin, or a user +// manually pinning an older entry from the version dropdown) sitting on it. +// +// This resolves the label to a real, permanently-fetchable commit-pinned +// codeload URL instead -- one extra GitHub API call, made only the first +// time a given (modId, version) is about to be hashed and stored (see +// mods-sync.service.ts's pinBranchVersionIfNew()), never on every sync, +// since a version already hashed/stored is never re-resolved. Returns null +// (falls back to the literal branch URL -- exactly today's behavior) +// whenever resolution isn't possible: the URL isn't a branch-archive shape, +// the version string doesn't look like a git SHA at all (a custom mod's own +// hand-typed version string, say), or the GitHub lookup fails/rate-limits -- +// never a hard failure that should abort the sync over one mod. +export async function resolveCommitPinnedDownloadUrl( + downloadUrl: string, + version: string, +): Promise { + if (classifyDownloadUrl(downloadUrl) !== 'branch') return null + if (!GIT_SHA_LIKE.test(version)) return null + + const repoInfo = extractRepoInfo(downloadUrl) + if (!repoInfo) return null + const { owner, repo } = repoInfo + + const res = await githubGet(`/repos/${owner}/${repo}/commits/${version}`) + if (!res || res.status === 404) return null + const data = (await res.json()) as { sha?: string } + if (!data.sha) return null + + return `https://codeload.github.com/${owner}/${repo}/zip/${data.sha}` +} + async function fetchSpecificTag( owner: string, repo: string, diff --git a/apps/server/src/features/mods/mods-sync.service.ts b/apps/server/src/features/mods/mods-sync.service.ts index df160eb..50533f5 100644 --- a/apps/server/src/features/mods/mods-sync.service.ts +++ b/apps/server/src/features/mods/mods-sync.service.ts @@ -4,19 +4,28 @@ import path from 'node:path' import AdmZip from 'adm-zip' import { env } from '../../env.js' import { + applyBranchPin, applyDetectedVersion, getStoredHash, listAllVersionsWithDownloadUrl, listCustomMods, + listVersionsWithDownloadUrl, + markVersionPinFailed, pruneModsMissingFrom, storeComputedHash, upsertModFromIndex, upsertVersionRow, } from '../../infrastructure/gateways/mods.gateway.js' -import { checkCustomModVersion } from './custom-mod-version-check.service.js' +import { + checkCustomModVersion, + resolveCommitPinnedDownloadUrl, +} from './custom-mod-version-check.service.js' import { relocateModRoot } from './mod-archive-flatten.js' import { computeModFolderHash } from './mod-folder-hash.js' -import { resolveReliableDownloadUrl } from './mod-source-classifier.js' +import { + classifyDownloadUrl, + resolveReliableDownloadUrl, +} from './mod-source-classifier.js' import { fetchUpstreamModIndex } from './upstream-mod-index.service.js' export interface ModRegistrySyncSummary { @@ -102,6 +111,27 @@ async function computeModFolderHashForRelease( } } +// Applies resolveCommitPinnedDownloadUrl() (see that function's doc comment +// in custom-mod-version-check.service.ts for the underlying problem) at the +// one point in this sync where it matters: right before a (modId, version) +// pair is about to be hashed and stored for the very first time. A version +// that's already been hashed is left alone unconditionally -- its +// downloadUrl (pinned or not) is already whatever was hashed for it, and +// re-resolving would just spend a GitHub API call to confirm what's already +// true. Falls back to returning downloadUrl unchanged whenever pinning +// isn't applicable or the GitHub lookup fails -- never blocks the sync. +async function pinBranchVersionIfNew( + modId: string, + version: string, + downloadUrl: string, +): Promise { + const alreadyHashed = await getStoredHash(modId, version) + if (alreadyHashed) return downloadUrl + + const pinned = await resolveCommitPinnedDownloadUrl(downloadUrl, version) + return pinned ?? downloadUrl +} + interface HashCandidate { modId: string version: string @@ -311,6 +341,25 @@ async function runSync(): Promise { const hashCandidates: HashCandidate[] = [] for (const entry of entries) { + if (entry.latestVersion && entry.latestDownloadUrl) { + entry.latestDownloadUrl = await pinBranchVersionIfNew( + entry.id, + entry.latestVersion, + entry.latestDownloadUrl, + ) + // entries[].versions is this same (version, downloadUrl) pair + // wrapped for mod_registry_versions -- see + // upstream-mod-index.service.ts's buildEntry(). Keep it in sync + // with the pin above so the stored version row and + // mod_registry.latestDownloadUrl never disagree. + if (entry.versions[0]?.version === entry.latestVersion) { + entry.versions[0] = { + ...entry.versions[0], + downloadUrl: entry.latestDownloadUrl, + } + } + } + await upsertModFromIndex(entry) if (entry.latestVersion && entry.latestDownloadUrl) { @@ -338,12 +387,33 @@ async function runSync(): Promise { fixedReleaseTagUpdates: mod.fixedReleaseTagUpdates, }) if (detected) { + // detected.newDownloadUrl is null for the HEAD case (see + // checkCustomModVersion's own doc comment) -- that's exactly + // the branch-tracked shape pinBranchVersionIfNew() exists + // for, so resolve against whatever URL is actually in effect + // (the freshly detected one, or the mod's existing one) and + // only pass a non-null downloadUrl through to + // applyDetectedVersion when pinning actually produced one. + const effectiveDownloadUrl = + detected.newDownloadUrl ?? mod.latestDownloadUrl + let downloadUrlToApply = detected.newDownloadUrl + if (effectiveDownloadUrl) { + const pinned = await pinBranchVersionIfNew( + mod.id, + detected.newVersion, + effectiveDownloadUrl, + ) + if (pinned !== effectiveDownloadUrl) { + downloadUrlToApply = pinned + } + } + await applyDetectedVersion(mod.id, { version: detected.newVersion, - downloadUrl: detected.newDownloadUrl, + downloadUrl: downloadUrlToApply, }) latestVersion = detected.newVersion - latestDownloadUrl = detected.newDownloadUrl ?? mod.latestDownloadUrl + latestDownloadUrl = downloadUrlToApply ?? mod.latestDownloadUrl versionsChecked++ } } @@ -394,3 +464,126 @@ export function syncModRegistry(): Promise { } return inFlight } + +// --- One-off backfill: pin every pre-existing branch-tracked version row +// (see backfill-branch-pins.ts) --- +// +// pinBranchVersionIfNew() above only ever pins a version the first time +// it's synced -- every mod_registry_versions row written before that fix +// existed is still sitting on its original moving-branch-tip URL (and a +// sha256 computed against whatever that tip happened to be at hash-time, +// not necessarily the exact commit its own version label names). This is +// the one-time catch-up pass for that backlog, run manually via +// `pnpm backfill-branch-pins`, not part of the regular hourly/startup sync. + +const PIN_RETRY_ATTEMPTS = 3 +const PIN_RETRY_DELAY_MS = 2000 + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// One full resolve-then-hash cycle for a single stale row, retried up to +// PIN_RETRY_ATTEMPTS times with a short linear backoff. A transient GitHub +// rate-limit/5xx is already swallowed to null by +// resolveCommitPinnedDownloadUrl()'s own best-effort design -- without a +// retry here, a single blip would look identical to a genuinely dead +// repo/commit. The regular hourly sync gets this same self-healing for free +// ("try again next hour"); a one-off backfill run has no next hour to fall +// back on, hence its own tighter retry loop. +async function attemptBranchPin( + modId: string, + version: string, + downloadUrl: string, +): Promise<{ downloadUrl: string; sha256: string } | null> { + for (let attempt = 1; attempt <= PIN_RETRY_ATTEMPTS; attempt++) { + const pinnedUrl = await resolveCommitPinnedDownloadUrl(downloadUrl, version) + if (pinnedUrl) { + const hash = await computeModFolderHashForRelease( + modId, + version, + pinnedUrl, + ) + if (hash) return { downloadUrl: pinnedUrl, sha256: hash } + } + if (attempt < PIN_RETRY_ATTEMPTS) { + await sleep(PIN_RETRY_DELAY_MS * attempt) + } + } + return null +} + +export interface BranchPinBackfillOptions { + // Also re-attempts rows already marked pinFailedAt by an earlier run, + // instead of skipping them (the default) -- use after fixing whatever + // made them unresolvable (a renamed repo, an expired rate limit that + // outlasted this script's own retries, etc.). + retryFailed?: boolean +} + +export interface BranchPinBackfillSummary { + pinned: number + alreadyPinned: number + failed: number + skippedFailed: number + failedRows: Array<{ modId: string; version: string }> +} + +export async function runBranchPinBackfill( + options: BranchPinBackfillOptions = {}, +): Promise { + const rows = await listVersionsWithDownloadUrl() + const targets = rows.filter( + (r) => classifyDownloadUrl(r.downloadUrl) === 'branch', + ) + + const summary: BranchPinBackfillSummary = { + pinned: 0, + alreadyPinned: rows.length - targets.length, + failed: 0, + skippedFailed: 0, + failedRows: [], + } + + // Same bounded-worker-pool shape as runHashPool above (HASH_CONCURRENCY + // wide) -- each row here does real work too (a resolve call, then a full + // download+extract+hash), so unbounded concurrency has the same + // GitHub-connection-reset risk flagged on recomputeAllModHashes. + let next = 0 + async function worker(): Promise { + while (true) { + const i = next++ + if (i >= targets.length) return + const row = targets[i] + + if (row.pinFailedAt && !options.retryFailed) { + summary.skippedFailed++ + continue + } + + const result = await attemptBranchPin( + row.modId, + row.version, + row.downloadUrl, + ) + if (!result) { + await markVersionPinFailed(row.modId, row.version) + summary.failed++ + summary.failedRows.push({ modId: row.modId, version: row.version }) + console.warn( + `[backfill-branch-pins] Couldn't pin ${row.modId}@${row.version} after ${PIN_RETRY_ATTEMPTS} attempts - marked pinFailedAt.`, + ) + continue + } + + await applyBranchPin(row.modId, row.version, result.downloadUrl, result.sha256) + summary.pinned++ + console.log( + `[backfill-branch-pins] Pinned ${row.modId}@${row.version} -> ${result.downloadUrl}`, + ) + } + } + + await Promise.all(Array.from({ length: HASH_CONCURRENCY }, () => worker())) + return summary +} diff --git a/apps/server/src/infrastructure/db/schema.ts b/apps/server/src/infrastructure/db/schema.ts index 1f52ecc..19b23cf 100644 --- a/apps/server/src/infrastructure/db/schema.ts +++ b/apps/server/src/infrastructure/db/schema.ts @@ -667,6 +667,18 @@ export const modRegistryVersions = pgTable( sha256: varchar('sha256', { length: 64 }), downloadUrl: text('download_url'), releasedAt: timestamp('released_at', { withTimezone: true }), + // Set once backfill-branch-pins.ts gives up on permanently resolving + // this row's downloadUrl to a commit-pinned one (see that script and + // mods-sync.service.ts's pinBranchVersionIfNew) after exhausting its + // retries within a run -- a genuinely dead repo/branch/commit, not a + // transient rate-limit. Null means "never permanently failed" (either + // already pinned -- downloadUrl no longer classifies as 'branch' -- + // or not attempted yet). A later re-run of the backfill script skips + // rows where this is set unless told to retry them, so a known-dead + // row doesn't keep burning GitHub API calls on every run; an admin + // can still force a retry (see that script's --retry-failed flag) if + // something later becomes resolvable again (e.g. a renamed repo). + pinFailedAt: timestamp('pin_failed_at', { withTimezone: true }), }, (t) => [ uniqueIndex('mod_registry_versions_mod_version_idx').on(t.modId, t.version), diff --git a/apps/server/src/infrastructure/gateways/mods.gateway.ts b/apps/server/src/infrastructure/gateways/mods.gateway.ts index 762cb1b..266f191 100644 --- a/apps/server/src/infrastructure/gateways/mods.gateway.ts +++ b/apps/server/src/infrastructure/gateways/mods.gateway.ts @@ -451,6 +451,85 @@ export async function storeComputedHash( ) } +// --- Branch-tracked version pinning backfill (backfill-branch-pins.ts) --- + +export interface BranchPinCandidate { + modId: string + version: string + downloadUrl: string + pinFailedAt: Date | null +} + +// Every mod_registry_versions row that has a downloadUrl -- narrowing down +// to the ones still pointing at a live branch archive (not yet pinned to a +// specific commit) is backfill-branch-pins.ts's own job, via +// mod-source-classifier.ts's classifyDownloadUrl -- same "fetch broad, +// filter/branch in TS" shape as listAllVersionsWithDownloadUrl above. +export async function listVersionsWithDownloadUrl(): Promise< + BranchPinCandidate[] +> { + const rows = await db + .select({ + modId: modRegistryVersions.modId, + version: modRegistryVersions.version, + downloadUrl: modRegistryVersions.downloadUrl, + pinFailedAt: modRegistryVersions.pinFailedAt, + }) + .from(modRegistryVersions) + .where(isNotNull(modRegistryVersions.downloadUrl)) + + return rows.filter( + (r): r is BranchPinCandidate => r.downloadUrl !== null, + ) +} + +// Writes a successfully-pinned commit-specific downloadUrl and its +// freshly-recomputed hash onto a version row, and clears any earlier +// pinFailedAt -- a retried row that succeeds this time is no longer +// permanently failed. Mirrors sha256 onto mod_registry.latestSha256 the +// same way storeComputedHash does, for the same reason (this version can +// still be the mod's current latest). +export async function applyBranchPin( + modId: string, + version: string, + downloadUrl: string, + sha256: string, +): Promise { + await db + .update(modRegistryVersions) + .set({ downloadUrl, sha256, pinFailedAt: null }) + .where( + and( + eq(modRegistryVersions.modId, modId), + eq(modRegistryVersions.version, version), + ), + ) + await db + .update(modRegistry) + .set({ latestSha256: sha256 }) + .where( + and(eq(modRegistry.id, modId), eq(modRegistry.latestVersion, version)), + ) +} + +// Marks a row as permanently unpinnable after backfill-branch-pins.ts +// exhausts its retries for it within one run -- see +// mod_registry_versions.pinFailedAt's own doc comment in schema.ts. +export async function markVersionPinFailed( + modId: string, + version: string, +): Promise { + await db + .update(modRegistryVersions) + .set({ pinFailedAt: new Date() }) + .where( + and( + eq(modRegistryVersions.modId, modId), + eq(modRegistryVersions.version, version), + ), + ) +} + // --- Admin: ranked version (PUT/DELETE /api/webadmin/mods/:modId(/ranked)) --- // The sole ranked-eligibility write path: null un-ranks the mod, any other diff --git a/apps/server/src/tests/mods/custom-mod-version-check.test.ts b/apps/server/src/tests/mods/custom-mod-version-check.test.ts index 3304a0c..ab9fe28 100644 --- a/apps/server/src/tests/mods/custom-mod-version-check.test.ts +++ b/apps/server/src/tests/mods/custom-mod-version-check.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AppError } from '../../shared/utils/errors.js' import { checkCustomModVersion, + resolveCommitPinnedDownloadUrl, resolveSourceInput, } from '../../features/mods/custom-mod-version-check.service.js' @@ -184,6 +185,86 @@ describe('checkCustomModVersion', () => { }) }) +describe('resolveCommitPinnedDownloadUrl', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('resolves a branch-archive URL + short-SHA version to a commit-pinned codeload URL', async () => { + mockFetch((url) => { + if (url.endsWith('/repos/Aikoyori/Balatro-Aikoyoris-Shenanigans/commits/55be56c')) { + return jsonResponse(200, { + sha: '55be56c1234567890abcdef1234567890abcdef', + }) + } + throw new Error(`unexpected fetch: ${url}`) + }) + + const result = await resolveCommitPinnedDownloadUrl( + 'https://github.com/Aikoyori/Balatro-Aikoyoris-Shenanigans/archive/refs/heads/stable.zip', + '55be56c', + ) + + expect(result).toBe( + 'https://codeload.github.com/Aikoyori/Balatro-Aikoyoris-Shenanigans/zip/55be56c1234567890abcdef1234567890abcdef', + ) + }) + + it('returns null for a non-branch (release/custom) URL - nothing to pin, the literal URL is already stable', async () => { + mockFetch(() => { + throw new Error('should not fetch') + }) + + const result = await resolveCommitPinnedDownloadUrl( + 'https://github.com/Alice/Mod/releases/download/v1.0.0/mod.zip', + 'v1.0.0', + ) + + expect(result).toBeNull() + }) + + it("returns null when the version doesn't look like a git SHA (e.g. a custom mod's own version string)", async () => { + mockFetch(() => { + throw new Error('should not fetch') + }) + + const result = await resolveCommitPinnedDownloadUrl( + 'https://github.com/Alice/Mod/archive/refs/heads/main.zip', + 'v1.0.0-beta', + ) + + expect(result).toBeNull() + }) + + it('returns null (not throw) when GitHub 404s on the commit lookup', async () => { + mockFetch(() => jsonResponse(404, {})) + + const result = await resolveCommitPinnedDownloadUrl( + 'https://github.com/Alice/Mod/archive/refs/heads/main.zip', + '0000000', + ) + + expect(result).toBeNull() + }) + + it('returns null (not throw) when GitHub responds rate-limited', async () => { + mockFetch( + () => + new Response('rate limit exceeded', { + status: 403, + headers: { 'x-ratelimit-remaining': '0' }, + }), + ) + + const result = await resolveCommitPinnedDownloadUrl( + 'https://github.com/Alice/Mod/archive/refs/heads/main.zip', + 'abcdef1', + ) + + expect(result).toBeNull() + }) +}) + describe('resolveSourceInput', () => { afterEach(() => { vi.unstubAllGlobals()