From a12feec4e14a66f9edb3b3bfb3d63c68c28d1c94 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Sat, 11 Apr 2026 10:02:23 +0800 Subject: [PATCH] fix(ota): support private GitHub release assets --- apps/ota/src/__tests__/sync.test.ts | 145 +++++++++++++++++++++++++++- apps/ota/src/lib/github.ts | 8 +- apps/ota/src/lib/sync.ts | 30 ++++-- 3 files changed, 174 insertions(+), 9 deletions(-) diff --git a/apps/ota/src/__tests__/sync.test.ts b/apps/ota/src/__tests__/sync.test.ts index f3b57ef3a..13105dc44 100644 --- a/apps/ota/src/__tests__/sync.test.ts +++ b/apps/ota/src/__tests__/sync.test.ts @@ -180,6 +180,32 @@ describe("listPublishedOtaReleases", () => { }) }) + it("sends a user agent header required by the GitHub releases API", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify([]), { + status: 200, + }), + ) + + vi.stubGlobal("fetch", fetchMock) + + await listPublishedOtaReleases({ + owner: "RSSNext", + repo: "Folo", + token: "token", + etag: null, + }) + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/repos/RSSNext/Folo/releases", + expect.objectContaining({ + headers: expect.objectContaining({ + "User-Agent": expect.any(String), + }), + }), + ) + }) + it("throws a structured error for failed requests", async () => { vi.stubGlobal( "fetch", @@ -571,6 +597,113 @@ describe("syncGitHubReleases", () => { expect(kvEntries.get(KV_KEYS.syncLastSuccessAt)).toEqual(expect.any(String)) }) + it("downloads private release assets through authenticated GitHub asset API requests", async () => { + const kvEntries = new Map() + const bucketEntries = new Map }>() + const otaBundle = textEncoder.encode("console.log('private-ota')") + const otaRelease = await createReleaseMetadata({ + platforms: { + ios: { + launchAsset: { + path: "bundles/ios-main.js", + sha256: await sha256Hex(otaBundle), + contentType: "application/javascript", + }, + assets: [], + }, + }, + }) + const otaArchive = await createTarArchive([ + { + name: "bundles/ios-main.js", + body: otaBundle, + }, + ]) + + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input) + + if (url === "https://api.github.com/repos/RSSNext/Folo/releases") { + return new Response( + JSON.stringify([ + createGitHubReleaseAssetSet( + "mobile/v0.4.2", + "https://github.com/RSSNext/Folo/releases/download/mobile/v0.4.2/ota-release.json", + "https://github.com/RSSNext/Folo/releases/download/mobile/v0.4.2/dist.tar.zst", + { + metadataApiUrl: "https://api.github.com/repos/RSSNext/Folo/releases/assets/1", + archiveApiUrl: "https://api.github.com/repos/RSSNext/Folo/releases/assets/2", + }, + ), + ]), + { status: 200 }, + ) + } + + if (url === "https://api.github.com/repos/RSSNext/Folo/releases/assets/1") { + expect(init).toMatchObject({ + headers: expect.objectContaining({ + Authorization: "Bearer token", + Accept: "application/octet-stream", + "User-Agent": expect.any(String), + }), + }) + + return new Response(JSON.stringify(otaRelease), { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + }, + }) + } + + if (url === "https://api.github.com/repos/RSSNext/Folo/releases/assets/2") { + expect(init).toMatchObject({ + headers: expect.objectContaining({ + Authorization: "Bearer token", + Accept: "application/octet-stream", + "User-Agent": expect.any(String), + }), + }) + + const archivePayload = new Uint8Array(otaArchive.byteLength) + archivePayload.set(otaArchive) + + return new Response(archivePayload.buffer, { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + }, + }) + } + + if (url.startsWith("https://github.com/RSSNext/Folo/releases/download/")) { + return new Response("Not Found", { + status: 404, + statusText: "Not Found", + }) + } + + throw new Error(`Unhandled fetch URL: ${url}`) + }) + + vi.stubGlobal("fetch", fetchMock) + + await syncGitHubReleases( + createEnv({ + kvEntries, + bucketEntries, + envOverrides: { + GITHUB_OWNER: "RSSNext", + GITHUB_REPO: "Folo", + GITHUB_TOKEN: "token", + }, + }), + ) + + expect(bucketEntries.has("mobile/production/0.4.1/0.4.2/ios/bundles/ios-main.js")).toBe(true) + }) + it("keeps newer OTA pointers and store policy records when releases arrive out of order", async () => { const kvEntries = new Map() const bucketEntries = new Map }>() @@ -1122,7 +1255,15 @@ async function createReleaseMetadata(overrides: Partial = {}): Promi } } -function createGitHubReleaseAssetSet(tag: string, metadataUrl: string, archiveUrl: string) { +function createGitHubReleaseAssetSet( + tag: string, + metadataUrl: string, + archiveUrl: string, + options?: { + metadataApiUrl?: string + archiveApiUrl?: string + }, +) { return { tag_name: tag, draft: false, @@ -1130,10 +1271,12 @@ function createGitHubReleaseAssetSet(tag: string, metadataUrl: string, archiveUr assets: [ { name: "ota-release.json", + url: options?.metadataApiUrl ?? metadataUrl, browser_download_url: metadataUrl, }, { name: "dist.tar.zst", + url: options?.archiveApiUrl ?? archiveUrl, browser_download_url: archiveUrl, }, ], diff --git a/apps/ota/src/lib/github.ts b/apps/ota/src/lib/github.ts index 54f244ced..587e80377 100644 --- a/apps/ota/src/lib/github.ts +++ b/apps/ota/src/lib/github.ts @@ -1,5 +1,6 @@ export interface GitHubReleaseAsset { name: string + url?: string browser_download_url: string } @@ -44,12 +45,15 @@ export async function listPublishedOtaReleases(input: { token: string etag: string | null }): Promise { + const userAgent = `folo-ota-worker/${input.owner}.${input.repo}` + const response = await fetch( `https://api.github.com/repos/${input.owner}/${input.repo}/releases`, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${input.token}`, + "User-Agent": userAgent, "X-GitHub-Api-Version": "2022-11-28", ...(input.etag ? { "If-None-Match": input.etag } : {}), }, @@ -85,8 +89,8 @@ export async function listPublishedOtaReleases(input: { return { tag: release.tag_name, - metadataUrl: metadata.browser_download_url, - archiveUrl: archive.browser_download_url, + metadataUrl: metadata.url ?? metadata.browser_download_url, + archiveUrl: archive.url ?? archive.browser_download_url, } }) .filter((value): value is GitHubReleaseSummary => value !== null), diff --git a/apps/ota/src/lib/sync.ts b/apps/ota/src/lib/sync.ts index 2e3ec333e..694c431a4 100644 --- a/apps/ota/src/lib/sync.ts +++ b/apps/ota/src/lib/sync.ts @@ -45,10 +45,10 @@ async function runSyncGitHubReleases(env: Env) { } for (const releaseSummary of releasesResult.releases) { - const release = await fetchReleaseMetadata(releaseSummary.metadataUrl) + const release = await fetchReleaseMetadata(releaseSummary.metadataUrl, env) if (release.releaseKind === "ota") { - const archiveBuffer = await fetchArchiveBuffer(releaseSummary.archiveUrl) + const archiveBuffer = await fetchArchiveBuffer(releaseSummary.archiveUrl, env) const files = await extractMirroredFiles({ release, archiveBuffer, @@ -163,8 +163,13 @@ async function putLatestPolicyRecord(kv: KVNamespace, release: OtaRelease) { await kv.put(KV_KEYS.policy(release.product, release.channel), JSON.stringify(release)) } -async function fetchReleaseMetadata(url: string): Promise { - const response = await fetch(url) +async function fetchReleaseMetadata( + url: string, + env: Pick, +): Promise { + const response = await fetch(url, { + headers: createGitHubAssetHeaders(env), + }) if (!response.ok) { throw new Error(`Failed to fetch OTA release metadata from ${url}: ${response.status}`) @@ -173,8 +178,13 @@ async function fetchReleaseMetadata(url: string): Promise { return otaReleaseSchema.parse(await response.json()) } -async function fetchArchiveBuffer(url: string) { - const response = await fetch(url) +async function fetchArchiveBuffer( + url: string, + env: Pick, +) { + const response = await fetch(url, { + headers: createGitHubAssetHeaders(env), + }) if (!response.ok) { throw new Error(`Failed to fetch OTA archive from ${url}: ${response.status}`) @@ -183,6 +193,14 @@ async function fetchArchiveBuffer(url: string) { return new Uint8Array(await response.arrayBuffer()) } +function createGitHubAssetHeaders(env: Pick) { + return { + Accept: "application/octet-stream", + Authorization: `Bearer ${env.GITHUB_TOKEN}`, + "User-Agent": `folo-ota-worker/${env.GITHUB_OWNER}.${env.GITHUB_REPO}`, + } +} + async function updateSyncLastSuccessAt(kv: KVNamespace) { await kv.put(KV_KEYS.syncLastSuccessAt, new Date().toISOString()) }