feat: expose ota version badges

This commit is contained in:
DIYgod 2026-04-13 09:45:55 +08:00
parent ca3fa9ffc3
commit 7d592abda7
2 changed files with 202 additions and 47 deletions

View File

@ -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<string, unknown>([[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<string, unknown>([
[KV_KEYS.githubEtag, '"etag-old"'],

View File

@ -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<void> | null = null
let inFlightStoreSync: Promise<void> | null = null
@ -54,10 +59,6 @@ export async function syncStoreVersions(env: Env) {
}
async function runSyncGitHubReleases(env: Env) {
const latestReleaseByProduct = new Map<
OtaRelease["product"],
Pick<OtaRelease, "releaseVersion" | "publishedAt"> & { tag: string }
>()
const storedEtag = await env.OTA_KV.get<string>(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<OtaRelease, "releaseVersion" | "publishedAt"> & { 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<OtaRelease, "releaseVersion" | "publishedAt"> & { 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<OtaRelease, "releaseVersion" | "publishedAt"> & { 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"],