Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions apps/server/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions apps/server/src/features/mods/backfill-branch-pins.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string | null> {
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,
Expand Down
Loading