Skip to content

Commit e0abfd0

Browse files
authored
Merge pull request #239 from sproctor/fix/gh-pre-release-channels
feaimplement GH api release processor for pre-releases
2 parents dd61b51 + 43b067b commit e0abfd0

5 files changed

Lines changed: 303 additions & 3 deletions

File tree

updater-runtime/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
22

33
plugins {
44
kotlin("jvm")
5+
alias(libs.plugins.kotlinxSerialization)
56
alias(libs.plugins.vanniktechMavenPublish)
67
}
78

@@ -16,6 +17,7 @@ dependencies {
1617
api(project(":core-runtime"))
1718
implementation(kotlin("stdlib"))
1819
implementation(libs.coroutines.core)
20+
implementation(libs.kotlinx.serialization.json)
1921
testImplementation(libs.junit)
2022
}
2123

updater-runtime/src/main/kotlin/io/github/kdroidfilter/nucleus/updater/NucleusUpdater.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ class NucleusUpdater(
179179
private fun doCheckForUpdates(): UpdateResult {
180180
val platform = PlatformInfo.currentPlatform()
181181
val arch = PlatformInfo.currentArch()
182-
val metadataUrl = config.provider.getUpdateMetadataUrl(config.channel, platform)
182+
val metadataUrl = config.provider.resolveMetadataUrl(config.channel, platform, httpClient)
183183

184184
val requestBuilder =
185185
HttpRequest

updater-runtime/src/main/kotlin/io/github/kdroidfilter/nucleus/updater/provider/GitHubProvider.kt

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,47 @@
11
package io.github.kdroidfilter.nucleus.updater.provider
22

33
import io.github.kdroidfilter.nucleus.core.runtime.Platform
4+
import io.github.kdroidfilter.nucleus.updater.exception.NetworkException
5+
import kotlinx.serialization.SerialName
6+
import kotlinx.serialization.Serializable
7+
import kotlinx.serialization.json.Json
8+
import java.net.URI
9+
import java.net.http.HttpClient
10+
import java.net.http.HttpRequest
11+
import java.net.http.HttpResponse
412

513
class GitHubProvider(
614
val owner: String,
715
val repo: String,
816
val token: String? = null,
917
) : UpdateProvider {
18+
/**
19+
* Base URL for the GitHub REST API. Exposed as `internal` so tests in this module
20+
* can redirect API traffic to a local server; not part of the public API.
21+
*/
22+
internal var apiBaseUrl: String = "https://api.github.com"
23+
1024
override fun getUpdateMetadataUrl(
1125
channel: String,
1226
platform: Platform,
1327
): String {
14-
val suffix = platformSuffix(platform)
15-
val fileName = if (suffix.isEmpty()) "$channel.yml" else "$channel-$suffix.yml"
28+
val fileName = metadataFileName(channel, platform)
1629
return "https://github.com/$owner/$repo/releases/latest/download/$fileName"
1730
}
1831

32+
override fun resolveMetadataUrl(
33+
channel: String,
34+
platform: Platform,
35+
httpClient: HttpClient,
36+
): String {
37+
val fileName = metadataFileName(channel, platform)
38+
if (channel.equals(LATEST_CHANNEL, ignoreCase = true)) {
39+
return "https://github.com/$owner/$repo/releases/latest/download/$fileName"
40+
}
41+
val tag = findLatestPrereleaseTag(channel, httpClient)
42+
return "https://github.com/$owner/$repo/releases/download/$tag/$fileName"
43+
}
44+
1945
override fun getDownloadUrl(
2046
fileName: String,
2147
version: String,
@@ -28,11 +54,80 @@ class GitHubProvider(
2854
emptyMap()
2955
}
3056

57+
private fun metadataFileName(
58+
channel: String,
59+
platform: Platform,
60+
): String {
61+
val suffix = platformSuffix(platform)
62+
return if (suffix.isEmpty()) "$channel.yml" else "$channel-$suffix.yml"
63+
}
64+
65+
private fun findLatestPrereleaseTag(
66+
channel: String,
67+
httpClient: HttpClient,
68+
): String {
69+
val builder =
70+
HttpRequest
71+
.newBuilder()
72+
.uri(URI.create("$apiBaseUrl/repos/$owner/$repo/releases?per_page=$PER_PAGE"))
73+
.header("Accept", "application/vnd.github+json")
74+
.header("X-GitHub-Api-Version", "2026-03-10")
75+
if (token != null) builder.header("Authorization", "Bearer $token")
76+
val request = builder.GET().build()
77+
78+
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
79+
val status = response.statusCode()
80+
if (status != HTTP_OK) {
81+
val rateLimited =
82+
status == HTTP_FORBIDDEN &&
83+
response.headers().firstValue("X-RateLimit-Remaining").orElse(null) == "0"
84+
val detail =
85+
if (rateLimited) {
86+
"rate limit exceeded — configure a token to raise the limit"
87+
} else {
88+
"HTTP $status"
89+
}
90+
throw NetworkException("GitHub API failed while listing releases for $owner/$repo: $detail")
91+
}
92+
93+
val releases = json.decodeFromString<List<GitHubRelease>>(response.body())
94+
val match =
95+
releases.firstOrNull { release ->
96+
release.prerelease && tagMatchesChannel(release.tagName, channel)
97+
} ?: throw NoSuchElementException(
98+
"No release found for channel '$channel' within the most recent $PER_PAGE releases. " +
99+
"Publish a fresh release on this channel.",
100+
)
101+
return match.tagName
102+
}
103+
104+
private fun tagMatchesChannel(
105+
tag: String,
106+
channel: String,
107+
): Boolean {
108+
val suffix = tag.substringAfter('-', missingDelimiterValue = "")
109+
return suffix.startsWith(channel, ignoreCase = true)
110+
}
111+
31112
private fun platformSuffix(platform: Platform): String =
32113
when (platform) {
33114
Platform.Windows -> ""
34115
Platform.MacOS -> "mac"
35116
Platform.Linux -> "linux"
36117
Platform.Unknown -> ""
37118
}
119+
120+
@Serializable
121+
internal data class GitHubRelease(
122+
@SerialName("tag_name") val tagName: String,
123+
val prerelease: Boolean,
124+
)
125+
126+
private companion object {
127+
const val LATEST_CHANNEL = "latest"
128+
const val PER_PAGE = 100
129+
const val HTTP_OK = 200
130+
const val HTTP_FORBIDDEN = 403
131+
val json = Json { ignoreUnknownKeys = true }
132+
}
38133
}

updater-runtime/src/main/kotlin/io/github/kdroidfilter/nucleus/updater/provider/UpdateProvider.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.github.kdroidfilter.nucleus.updater.provider
22

33
import io.github.kdroidfilter.nucleus.core.runtime.Platform
4+
import java.net.http.HttpClient
45

56
interface UpdateProvider {
67
fun getUpdateMetadataUrl(
@@ -14,4 +15,36 @@ interface UpdateProvider {
1415
): String
1516

1617
fun authHeaders(): Map<String, String> = emptyMap()
18+
19+
/**
20+
* Returns the URL of the metadata (YAML) file for the given [channel] and [platform],
21+
* resolving it dynamically when the provider needs to consult a remote service first.
22+
*
23+
* Called by [io.github.kdroidfilter.nucleus.updater.NucleusUpdater] before every update
24+
* check. The default implementation delegates to [getUpdateMetadataUrl], which is
25+
* sufficient for providers whose URLs can be computed without a network round-trip.
26+
*
27+
* Override this method when locating the metadata requires an HTTP request. For example,
28+
* [GitHubProvider] overrides it to query the GitHub Releases API and select the most
29+
* recent pre-release whose tag matches the requested channel — the stable channel keeps
30+
* the static `releases/latest/download/...` redirect, while `beta` and `alpha` channels
31+
* resolve to `releases/download/<tag>/...` URLs that the GitHub `latest` shortcut would
32+
* otherwise skip.
33+
*
34+
* The [httpClient] is the same client that
35+
* [io.github.kdroidfilter.nucleus.updater.UpdaterConfig.httpClient] configures (or the
36+
* default one if none was supplied), so overrides should reuse it instead of constructing
37+
* their own — this keeps redirect, proxy, and trust-store settings consistent across all
38+
* traffic the updater generates. Implementations may call [httpClient] synchronously;
39+
* the updater invokes this method from an IO dispatcher.
40+
*
41+
* Implementations may throw any exception to signal failure; [NoSuchElementException] is
42+
* the conventional choice for "no release matches this channel". The updater surfaces
43+
* such failures as [io.github.kdroidfilter.nucleus.updater.UpdateResult.Error].
44+
*/
45+
fun resolveMetadataUrl(
46+
channel: String,
47+
platform: Platform,
48+
httpClient: HttpClient,
49+
): String = getUpdateMetadataUrl(channel, platform)
1750
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package io.github.kdroidfilter.nucleus.updater.provider
2+
3+
import com.sun.net.httpserver.HttpExchange
4+
import com.sun.net.httpserver.HttpHandler
5+
import com.sun.net.httpserver.HttpServer
6+
import io.github.kdroidfilter.nucleus.core.runtime.Platform
7+
import org.junit.After
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Assert.assertNotNull
10+
import org.junit.Assert.assertNull
11+
import org.junit.Assert.assertTrue
12+
import org.junit.Assert.fail
13+
import org.junit.Before
14+
import org.junit.Test
15+
import java.net.InetSocketAddress
16+
import java.net.http.HttpClient
17+
import java.util.concurrent.atomic.AtomicInteger
18+
import java.util.concurrent.atomic.AtomicReference
19+
20+
class GitHubProviderTest {
21+
private lateinit var server: HttpServer
22+
private lateinit var httpClient: HttpClient
23+
private val apiCallCount = AtomicInteger(0)
24+
private val lastAuthHeader = AtomicReference<String?>(null)
25+
private var responseBody: String = "[]"
26+
private var responseStatus: Int = HTTP_OK
27+
28+
@Before
29+
fun startServer() {
30+
apiCallCount.set(0)
31+
lastAuthHeader.set(null)
32+
responseBody = "[]"
33+
responseStatus = HTTP_OK
34+
35+
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
36+
server.createContext(
37+
"/repos/",
38+
HttpHandler { exchange: HttpExchange ->
39+
apiCallCount.incrementAndGet()
40+
lastAuthHeader.set(exchange.requestHeaders.getFirst("Authorization"))
41+
val bytes = responseBody.toByteArray()
42+
exchange.sendResponseHeaders(responseStatus, bytes.size.toLong())
43+
exchange.responseBody.use { it.write(bytes) }
44+
},
45+
)
46+
server.start()
47+
httpClient = HttpClient.newHttpClient()
48+
}
49+
50+
@After
51+
fun stopServer() {
52+
server.stop(0)
53+
}
54+
55+
private fun newProvider(token: String? = null): GitHubProvider =
56+
GitHubProvider("acme", "tool", token).apply {
57+
apiBaseUrl = "http://127.0.0.1:${server.address.port}"
58+
}
59+
60+
@Test
61+
fun `stable channel makes no API call`() {
62+
val provider = newProvider()
63+
64+
val url = provider.resolveMetadataUrl("latest", Platform.Linux, httpClient)
65+
66+
assertEquals("https://github.com/acme/tool/releases/latest/download/latest-linux.yml", url)
67+
assertEquals(0, apiCallCount.get())
68+
}
69+
70+
@Test
71+
fun `beta channel finds matching pre-release`() {
72+
responseBody =
73+
jsonArray(
74+
Release("v1.2.3-alpha.4", prerelease = true),
75+
Release("v1.2.3-beta.5", prerelease = true),
76+
Release("v1.2.2", prerelease = false),
77+
)
78+
79+
val url = newProvider().resolveMetadataUrl("beta", Platform.Linux, httpClient)
80+
81+
assertEquals(
82+
"https://github.com/acme/tool/releases/download/v1.2.3-beta.5/beta-linux.yml",
83+
url,
84+
)
85+
assertEquals(1, apiCallCount.get())
86+
}
87+
88+
@Test
89+
fun `beta channel picks first match in API order`() {
90+
responseBody =
91+
jsonArray(
92+
Release("v1.3.0-beta.2", prerelease = true),
93+
Release("v1.3.0-beta.1", prerelease = true),
94+
Release("v1.2.9-beta.7", prerelease = true),
95+
)
96+
97+
val url = newProvider().resolveMetadataUrl("beta", Platform.Windows, httpClient)
98+
99+
assertEquals(
100+
"https://github.com/acme/tool/releases/download/v1.3.0-beta.2/beta.yml",
101+
url,
102+
)
103+
}
104+
105+
@Test
106+
fun `skips non-prerelease entries with matching tag name`() {
107+
responseBody =
108+
jsonArray(
109+
Release("v1.0.0-beta-leftover", prerelease = false),
110+
Release("v1.0.0-beta.3", prerelease = true),
111+
)
112+
113+
val url = newProvider().resolveMetadataUrl("beta", Platform.MacOS, httpClient)
114+
115+
assertEquals(
116+
"https://github.com/acme/tool/releases/download/v1.0.0-beta.3/beta-mac.yml",
117+
url,
118+
)
119+
}
120+
121+
@Test
122+
fun `throws when no match in window`() {
123+
val alphas = (1..NO_MATCH_RELEASE_COUNT).map { Release("v1.0.0-alpha.$it", prerelease = true) }
124+
responseBody = jsonArray(*alphas.toTypedArray())
125+
126+
try {
127+
newProvider().resolveMetadataUrl("beta", Platform.Linux, httpClient)
128+
fail("expected NoSuchElementException")
129+
} catch (e: NoSuchElementException) {
130+
assertNotNull(e.message)
131+
assertTrue(
132+
"message should mention the window size",
133+
e.message!!.contains("within the most recent 100 releases"),
134+
)
135+
}
136+
}
137+
138+
@Test
139+
fun `auth header sent when token configured`() {
140+
responseBody = jsonArray(Release("v1.0.0-beta.1", prerelease = true))
141+
142+
newProvider(token = "ghp_test").resolveMetadataUrl("beta", Platform.Linux, httpClient)
143+
144+
assertEquals("Bearer ghp_test", lastAuthHeader.get())
145+
}
146+
147+
@Test
148+
fun `no auth header when token is null`() {
149+
responseBody = jsonArray(Release("v1.0.0-beta.1", prerelease = true))
150+
151+
newProvider().resolveMetadataUrl("beta", Platform.Linux, httpClient)
152+
153+
assertNull(lastAuthHeader.get())
154+
}
155+
156+
private data class Release(
157+
val tag: String,
158+
val prerelease: Boolean,
159+
)
160+
161+
private fun jsonArray(vararg releases: Release): String =
162+
releases.joinToString(prefix = "[", postfix = "]") { r ->
163+
"""{"tag_name":"${r.tag}","prerelease":${r.prerelease}}"""
164+
}
165+
166+
private companion object {
167+
const val HTTP_OK = 200
168+
const val NO_MATCH_RELEASE_COUNT = 100
169+
}
170+
}

0 commit comments

Comments
 (0)