fix(mobile): remove Android release page cutoff

This commit is contained in:
Brennan Benson
2026-07-22 14:16:45 -07:00
parent dbe8115815
commit dab3bcd463
3 changed files with 157 additions and 70 deletions
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
__resetAndroidReleaseFeedCacheForTests,
__setAndroidReleaseFeedFetcherForTests,
extractLatestAndroidVersion,
extractAndroidVersionCandidates,
getRecommendedAndroidVersion,
isAndroidReleaseFeedRefreshPending
} from './mobile-android-release-feed'
@@ -12,47 +12,40 @@ afterEach(() => {
vi.restoreAllMocks()
})
describe('extractLatestAndroidVersion', () => {
it('picks the newest stable mobile-android tag', () => {
const version = extractLatestAndroidVersion([
{ tag_name: 'mobile-android-v0.0.30' },
{ tag_name: 'mobile-android-v0.0.31' },
{ tag_name: 'mobile-android-v0.0.9' },
{ tag_name: 'v1.4.150' }, // desktop release — ignored
{ tag_name: 'mobile-ios-v0.0.31' } // iOS is never sourced here
describe('extractAndroidVersionCandidates', () => {
it('sorts exact mobile-android tag refs newest first', () => {
const versions = extractAndroidVersionCandidates([
{ ref: 'refs/tags/mobile-android-v0.0.30' },
{ ref: 'refs/tags/mobile-android-v0.0.31' },
{ ref: 'refs/tags/mobile-android-v0.0.9' },
{ ref: 'refs/tags/v1.4.150' }, // desktop release — ignored
{ ref: 'refs/tags/mobile-ios-v0.0.31' } // iOS is never sourced here
])
// Why: numeric compare, so 0.0.31 beats 0.0.9 (string compare would invert).
expect(version).toBe('0.0.31')
expect(versions).toEqual(['0.0.31', '0.0.30', '0.0.9'])
})
it('accepts the published mobile channel while skipping drafts', () => {
const version = extractLatestAndroidVersion([
{ tag_name: 'mobile-android-v0.0.40', prerelease: true },
{ tag_name: 'mobile-android-v0.0.41', draft: true },
{ tag_name: 'mobile-android-v0.0.31' }
])
// Android workflow releases use --prerelease to avoid replacing desktop latest.
expect(version).toBe('0.0.40')
})
it('rejects non-semver and malformed tags, failing open to null', () => {
it('rejects non-semver and malformed refs', () => {
expect(
extractLatestAndroidVersion([
{ tag_name: 'mobile-android-vlatest' },
{ tag_name: 'mobile-android-v0.0' },
{ tag_name: 42 },
extractAndroidVersionCandidates([
{ ref: 'refs/tags/mobile-android-vlatest' },
{ ref: 'refs/tags/mobile-android-v0.0' },
{ ref: 42 },
{}
])
).toBeNull()
expect(extractLatestAndroidVersion([])).toBeNull()
).toEqual([])
})
it('deduplicates a cold refresh and exposes its completion without blocking callers', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([{ tag_name: 'mobile-android-v0.0.40', prerelease: true }]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
it('deduplicates a cold refresh and verifies the published APK', async () => {
const fetchMock = vi.fn(async (url: string) =>
url.includes('/git/matching-refs/')
? jsonResponse([{ ref: 'refs/tags/mobile-android-v0.0.40' }])
: jsonResponse({
tag_name: 'mobile-android-v0.0.40',
draft: false,
prerelease: true,
assets: [{ name: 'app-release.apk' }]
})
)
__setAndroidReleaseFeedFetcherForTests(fetchMock)
@@ -65,6 +58,56 @@ describe('extractLatestAndroidVersion', () => {
expect(isAndroidReleaseFeedRefreshPending()).toBe(false)
})
expect(getRecommendedAndroidVersion(1_000)).toBe('0.0.40')
expect(fetchMock).toHaveBeenCalledOnce()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('falls back when newer tags lack a confirmed published APK', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.includes('/git/matching-refs/')) {
return jsonResponse([
{ ref: 'refs/tags/mobile-android-v0.0.42' },
{ ref: 'refs/tags/mobile-android-v0.0.41' },
{ ref: 'refs/tags/mobile-android-v0.0.40' }
])
}
const version = url.match(/mobile-android-v(\d+\.\d+\.\d+)$/)?.[1] ?? ''
return jsonResponse({
tag_name: `mobile-android-v${version}`,
draft: version === '0.0.41' ? 'false' : false,
assets: version === '0.0.42' ? [] : [{ name: 'app-release.apk' }]
})
})
__setAndroidReleaseFeedFetcherForTests(fetchMock)
expect(getRecommendedAndroidVersion(1_000)).toBeNull()
await vi.waitFor(() => expect(isAndroidReleaseFeedRefreshPending()).toBe(false))
expect(getRecommendedAndroidVersion(1_000)).toBe('0.0.40')
expect(fetchMock).toHaveBeenCalledTimes(4)
})
it('fails open without probing more tags after a provider failure', async () => {
const fetchMock = vi.fn(async (url: string) =>
url.includes('/git/matching-refs/')
? jsonResponse([
{ ref: 'refs/tags/mobile-android-v0.0.41' },
{ ref: 'refs/tags/mobile-android-v0.0.40' }
])
: jsonResponse({ message: 'rate limited' }, 403)
)
__setAndroidReleaseFeedFetcherForTests(fetchMock)
expect(getRecommendedAndroidVersion(1_000)).toBeNull()
await vi.waitFor(() => expect(isAndroidReleaseFeedRefreshPending()).toBe(false))
expect(getRecommendedAndroidVersion(1_000)).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
})
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
})
}
+79 -35
View File
@@ -1,24 +1,30 @@
import { net } from 'electron'
import { compareVersions, isValidVersion } from '../updater-fallback'
// Why: the Android app is distributed as an APK from GitHub Releases, so the
// release list IS the source of truth for "latest Android build" — never a
// hand-maintained constant. iOS is deliberately absent: it ships through the
// Why: published GitHub releases with APK assets are the Android source of truth,
// never a hand-maintained constant. iOS is deliberately absent: it ships through the
// App Store, which auto-updates, and the hard minimum-version cutoff is handled
// separately by ProtocolBlockScreen, so no soft iOS nudge is derived here.
const RELEASES_API_URL = 'https://api.github.com/repos/stablyai/orca/releases?per_page=100'
const GITHUB_API_ROOT = 'https://api.github.com/repos/stablyai/orca'
const ANDROID_TAG_REFS_URL = `${GITHUB_API_ROOT}/git/matching-refs/tags/mobile-android-v`
const FETCH_TIMEOUT_MS = 5000
// Why: a mobile build ships ~monthly, so a long TTL keeps this to a few
// unauthenticated GitHub calls per day per host while staying fresh enough.
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
// Why: only exact Android release tags count, so suffixed RC builds never nudge stable users.
const ANDROID_TAG_RE = /^mobile-android-v(\d+\.\d+\.\d+)$/
const ANDROID_TAG_REF_RE = /^refs\/tags\/mobile-android-v(\d+\.\d+\.\d+)$/
// Why: malformed/orphaned tags must not turn one refresh into unbounded API fanout.
const MAX_RELEASE_CANDIDATES = 3
type ReleaseFeedEntry = {
type AndroidTagRef = {
ref?: unknown
}
type AndroidRelease = {
tag_name?: unknown
draft?: unknown
prerelease?: unknown
assets?: unknown
}
type CacheState = {
@@ -26,54 +32,92 @@ type CacheState = {
fetchedAt: number
}
type ReleaseFeedResponse = Pick<Response, 'ok' | 'json'>
type ReleaseFeedFetcher = () => Promise<ReleaseFeedResponse>
type GitHubResponse = Pick<Response, 'ok' | 'status' | 'json'>
type GitHubFetcher = (url: string, signal: AbortSignal) => Promise<GitHubResponse>
const defaultReleaseFeedFetcher: ReleaseFeedFetcher = () =>
net.fetch(RELEASES_API_URL, {
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'orca-runtime' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
const defaultGitHubFetcher: GitHubFetcher = (url, signal) =>
net.fetch(url, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'orca-runtime',
'X-GitHub-Api-Version': '2022-11-28'
},
signal
})
// Module-level cache shared across every status.get on this host.
let cache: CacheState | null = null
let inFlight: Promise<void> | null = null
let releaseFeedFetcher = defaultReleaseFeedFetcher
let githubFetcher = defaultGitHubFetcher
export function extractLatestAndroidVersion(entries: ReleaseFeedEntry[]): string | null {
let latest: string | null = null
export function extractAndroidVersionCandidates(entries: AndroidTagRef[]): string[] {
const versions = new Set<string>()
for (const entry of entries) {
// Mobile releases intentionally use GitHub's prerelease flag so they do not
// replace the desktop's latest release; the strict tag defines this channel.
if (entry.draft === true) {
if (typeof entry.ref !== 'string') {
continue
}
if (typeof entry.tag_name !== 'string') {
continue
}
const match = entry.tag_name.match(ANDROID_TAG_RE)
const match = entry.ref.match(ANDROID_TAG_REF_RE)
const version = match?.[1]
if (!version || !isValidVersion(version)) {
continue
}
if (latest === null || compareVersions(version, latest) > 0) {
latest = version
}
versions.add(version)
}
return latest
return [...versions].sort((left, right) => compareVersions(right, left))
}
function releaseHasAndroidApk(release: AndroidRelease, version: string): boolean {
if (release.draft !== false || release.tag_name !== `mobile-android-v${version}`) {
return false
}
if (!Array.isArray(release.assets)) {
return false
}
return release.assets.some((asset) => {
if (typeof asset !== 'object' || asset === null) {
return false
}
const name = (asset as { name?: unknown }).name
return typeof name === 'string' && name.toLowerCase().endsWith('.apk')
})
}
async function fetchLatestAndroidVersion(): Promise<string | null> {
const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS)
try {
const response = await releaseFeedFetcher()
if (!response.ok) {
const refsResponse = await githubFetcher(ANDROID_TAG_REFS_URL, signal)
if (!refsResponse.ok) {
return null
}
const body = (await response.json()) as unknown
if (!Array.isArray(body)) {
const refsBody = (await refsResponse.json()) as unknown
if (!Array.isArray(refsBody)) {
return null
}
return extractLatestAndroidVersion(body as ReleaseFeedEntry[])
const candidates = extractAndroidVersionCandidates(refsBody as AndroidTagRef[]).slice(
0,
MAX_RELEASE_CANDIDATES
)
for (const version of candidates) {
const tag = `mobile-android-v${version}`
const releaseUrl = `${GITHUB_API_ROOT}/releases/tags/${encodeURIComponent(tag)}`
const releaseResponse = await githubFetcher(releaseUrl, signal)
if (!releaseResponse.ok) {
// A tag can briefly precede its release; other failures should not fan out requests.
if (releaseResponse.status === 404) {
continue
}
return null
}
const release = (await releaseResponse.json()) as unknown
if (
typeof release === 'object' &&
release !== null &&
releaseHasAndroidApk(release as AndroidRelease, version)
) {
return version
}
}
return null
} catch {
// Why: fail open — an unreachable/rate-limited/offline host simply advertises
// no recommendation, and mobile shows no banner.
@@ -119,7 +163,7 @@ export function isAndroidReleaseFeedRefreshPending(): boolean {
export function __resetAndroidReleaseFeedCacheForTests(): void {
cache = null
inFlight = null
releaseFeedFetcher = defaultReleaseFeedFetcher
githubFetcher = defaultGitHubFetcher
}
// Test-only: seed the cache with a fresh value so status.get reads it without
@@ -129,6 +173,6 @@ export function __setAndroidReleaseFeedCacheForTests(version: string | null): vo
inFlight = null
}
export function __setAndroidReleaseFeedFetcherForTests(fetcher: ReleaseFeedFetcher): void {
releaseFeedFetcher = fetcher
export function __setAndroidReleaseFeedFetcherForTests(fetcher: GitHubFetcher): void {
githubFetcher = fetcher
}
+1 -1
View File
@@ -3397,7 +3397,7 @@ export class OrcaRuntimeService {
// omit rather than fabricate when running outside that bootstrap (tests).
appVersion: process.env.ORCA_APP_VERSION,
// Why: derived live from GitHub Releases (cached, fail-open) instead of a
// hand-bumped constant, so the Android nudge can never go stale; omitted
// hand-bumped constant, so releases need no recommendation bookkeeping; omitted
// entirely when unknown. iOS defers to the App Store (no soft nudge).
recommendedMobileAppVersions: mobileRecommendation.versions,
recommendedMobileAppVersionsPending: mobileRecommendation.pending,