fix(ota): support private GitHub release assets

This commit is contained in:
DIYgod 2026-04-11 10:02:23 +08:00
parent 05a789129a
commit a12feec4e1
3 changed files with 174 additions and 9 deletions

View File

@ -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<string, unknown>()
const bucketEntries = new Map<string, { body: Uint8Array; headers?: Record<string, string> }>()
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<string, unknown>()
const bucketEntries = new Map<string, { body: Uint8Array; headers?: Record<string, string> }>()
@ -1122,7 +1255,15 @@ async function createReleaseMetadata(overrides: Partial<OtaRelease> = {}): 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,
},
],

View File

@ -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<GitHubReleaseListResult> {
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),

View File

@ -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<OtaRelease> {
const response = await fetch(url)
async function fetchReleaseMetadata(
url: string,
env: Pick<Env, "GITHUB_OWNER" | "GITHUB_REPO" | "GITHUB_TOKEN">,
): Promise<OtaRelease> {
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<OtaRelease> {
return otaReleaseSchema.parse(await response.json())
}
async function fetchArchiveBuffer(url: string) {
const response = await fetch(url)
async function fetchArchiveBuffer(
url: string,
env: Pick<Env, "GITHUB_OWNER" | "GITHUB_REPO" | "GITHUB_TOKEN">,
) {
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<Env, "GITHUB_OWNER" | "GITHUB_REPO" | "GITHUB_TOKEN">) {
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())
}