diff --git a/apps/ota/src/__tests__/sync.test.ts b/apps/ota/src/__tests__/sync.test.ts index e52145ed0..4e44f9d71 100644 --- a/apps/ota/src/__tests__/sync.test.ts +++ b/apps/ota/src/__tests__/sync.test.ts @@ -498,6 +498,102 @@ describe("syncGitHubReleases", () => { expect(kvEntries.get(KV_KEYS.syncLastSuccessAt)).toEqual(expect.any(String)) }) + it("backfills latest release summaries when GitHub returns 304 and the summary keys are missing", async () => { + const kvEntries = new Map([[KV_KEYS.githubEtag, '"etag-current"']]) + const mobileRelease = await createReleaseMetadata({ + releaseVersion: "0.4.3", + releaseKind: "store", + runtimeVersion: "0.4.3", + publishedAt: "2026-04-10T16:00:00Z", + git: { + tag: "mobile/v0.4.3", + commit: "abcdef1234567895", + }, + platforms: {}, + }) + const desktopRelease = createDesktopReleaseMetadata({ + releaseVersion: "1.5.2", + releaseKind: "binary", + runtimeVersion: null, + publishedAt: "2026-04-11T12:00:00Z", + git: { + tag: "desktop/v1.5.2", + commit: "abcdef1234567891", + }, + desktop: { + renderer: null, + app: null, + }, + }) + + let githubRequestCount = 0 + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = String(input) + + if (url === "https://api.github.com/repos/RSSNext/Folo/releases") { + githubRequestCount += 1 + + if (githubRequestCount === 1) { + return new Response(null, { status: 304 }) + } + + return new Response( + JSON.stringify([ + createGitHubReleaseAssetSet( + "desktop/v1.5.2", + "https://example.com/desktop.json", + null, + ), + createGitHubReleaseAssetSet("mobile/v0.4.3", "https://example.com/mobile.json", null), + ]), + { status: 200 }, + ) + } + + if (url === "https://example.com/mobile.json") { + return new Response(JSON.stringify(mobileRelease), { + headers: { "Content-Type": "application/json" }, + }) + } + + if (url === "https://example.com/desktop.json") { + return new Response(JSON.stringify(desktopRelease), { + headers: { "Content-Type": "application/json" }, + }) + } + + throw new Error(`Unhandled fetch URL: ${url}`) + }), + ) + + await syncGitHubReleases( + createEnv({ + kvEntries, + envOverrides: { + GITHUB_OWNER: "RSSNext", + GITHUB_REPO: "Folo", + GITHUB_TOKEN: "token", + }, + }), + ) + + expect(githubRequestCount).toBe(2) + expect(JSON.parse(String(kvEntries.get(KV_KEYS.latestReleaseVersion("mobile"))))).toEqual({ + product: "mobile", + version: "0.4.3", + publishedAt: "2026-04-10T16:00:00Z", + tag: "mobile/v0.4.3", + }) + expect(JSON.parse(String(kvEntries.get(KV_KEYS.latestReleaseVersion("desktop"))))).toEqual({ + product: "desktop", + version: "1.5.2", + publishedAt: "2026-04-11T12:00:00Z", + tag: "desktop/v1.5.2", + }) + }) + it("does not advance sync markers when a later release fails validation", async () => { const kvEntries = new Map([ [KV_KEYS.githubEtag, '"etag-old"'], diff --git a/apps/ota/src/lib/sync.ts b/apps/ota/src/lib/sync.ts index b14a703f4..5f9b2ea3b 100644 --- a/apps/ota/src/lib/sync.ts +++ b/apps/ota/src/lib/sync.ts @@ -18,6 +18,11 @@ import { compareSemver } from "./version" const OTA_PLATFORMS: OtaPlatform[] = ["ios", "android", "macos", "windows", "linux"] const semverPattern = /^\d+\.\d+\.\d+$/ +type ReleaseSummary = { + tag: string + metadataUrl: string + archiveUrl: string | null +} let inFlightSync: Promise | null = null let inFlightStoreSync: Promise | null = null @@ -54,10 +59,6 @@ export async function syncStoreVersions(env: Env) { } async function runSyncGitHubReleases(env: Env) { - const latestReleaseByProduct = new Map< - OtaRelease["product"], - Pick & { tag: string } - >() const storedEtag = await env.OTA_KV.get(KV_KEYS.githubEtag) const releasesResult = await listPublishedOtaReleases({ owner: env.GITHUB_OWNER, @@ -67,53 +68,12 @@ async function runSyncGitHubReleases(env: Env) { }) if (releasesResult.kind === "not-modified") { + await ensureLatestReleaseVersionRecords(env) await updateSyncLastSuccessAt(env.OTA_KV) return } - for (const releaseSummary of releasesResult.releases) { - const release = await fetchReleaseMetadata(releaseSummary.metadataUrl, env) - updateLatestReleaseByProduct(latestReleaseByProduct, release) - - if (release.releaseKind === "ota") { - if (!releaseSummary.archiveUrl) { - throw new Error( - `Missing OTA archive asset for ${release.product} release ${release.releaseVersion}`, - ) - } - - const archiveBuffer = await fetchArchiveBuffer(releaseSummary.archiveUrl, env) - const files = await extractMirroredFiles({ - release, - archiveBuffer, - }) - - await mirrorReleaseToStorage( - { - release, - files, - }, - { - kv: env.OTA_KV, - bucket: env.OTA_BUCKET, - }, - ) - - continue - } - - await putReleaseRecord(env.OTA_KV, release.product, release.releaseVersion, release) - await putLatestPolicyRecord(env.OTA_KV, release) - } - - for (const [product, latestRelease] of latestReleaseByProduct) { - await putLatestReleaseVersionRecord(env.OTA_KV, { - product, - version: latestRelease.releaseVersion, - publishedAt: latestRelease.publishedAt, - tag: latestRelease.tag, - }) - } + await persistReleaseSummaries(env, releasesResult.releases) if (releasesResult.etag) { await env.OTA_KV.put(KV_KEYS.githubEtag, releasesResult.etag) @@ -181,6 +141,105 @@ async function runSyncStoreVersions(env: Env) { await env.OTA_KV.put(KV_KEYS.storeVersionSyncLastSuccessAt, fetchedAt) } +async function ensureLatestReleaseVersionRecords(env: Env) { + const [mobileLatest, desktopLatest] = await Promise.all([ + env.OTA_KV.get(KV_KEYS.latestReleaseVersion("mobile")), + env.OTA_KV.get(KV_KEYS.latestReleaseVersion("desktop")), + ]) + + if (mobileLatest && desktopLatest) { + return + } + + const releasesResult = await listPublishedOtaReleases({ + owner: env.GITHUB_OWNER, + repo: env.GITHUB_REPO, + token: env.GITHUB_TOKEN, + etag: null, + }) + + if (releasesResult.kind === "not-modified") { + return + } + + await persistLatestReleaseVersionRecords(env, releasesResult.releases) +} + +async function persistReleaseSummaries(env: Env, releases: ReleaseSummary[]) { + const latestReleaseByProduct = new Map< + OtaRelease["product"], + Pick & { tag: string } + >() + + for (const releaseSummary of releases) { + const release = await fetchReleaseMetadata(releaseSummary.metadataUrl, env) + updateLatestReleaseByProduct(latestReleaseByProduct, release) + + if (release.releaseKind === "ota") { + if (!releaseSummary.archiveUrl) { + throw new Error( + `Missing OTA archive asset for ${release.product} release ${release.releaseVersion}`, + ) + } + + const archiveBuffer = await fetchArchiveBuffer(releaseSummary.archiveUrl, env) + const files = await extractMirroredFiles({ + release, + archiveBuffer, + }) + + await mirrorReleaseToStorage( + { + release, + files, + }, + { + kv: env.OTA_KV, + bucket: env.OTA_BUCKET, + }, + ) + + continue + } + + await putReleaseRecord(env.OTA_KV, release.product, release.releaseVersion, release) + await putLatestPolicyRecord(env.OTA_KV, release) + } + + await writeLatestReleaseVersionRecords(env.OTA_KV, latestReleaseByProduct) +} + +async function persistLatestReleaseVersionRecords(env: Env, releases: ReleaseSummary[]) { + const latestReleaseByProduct = new Map< + OtaRelease["product"], + Pick & { tag: string } + >() + + for (const releaseSummary of releases) { + const release = await fetchReleaseMetadata(releaseSummary.metadataUrl, env) + updateLatestReleaseByProduct(latestReleaseByProduct, release) + } + + await writeLatestReleaseVersionRecords(env.OTA_KV, latestReleaseByProduct) +} + +async function writeLatestReleaseVersionRecords( + kv: KVNamespace, + latestReleaseByProduct: Map< + OtaRelease["product"], + Pick & { tag: string } + >, +) { + for (const [product, latestRelease] of latestReleaseByProduct) { + await putLatestReleaseVersionRecord(kv, { + product, + version: latestRelease.releaseVersion, + publishedAt: latestRelease.publishedAt, + tag: latestRelease.tag, + }) + } +} + function updateLatestReleaseByProduct( latestReleaseByProduct: Map< OtaRelease["product"],