Skip to content

Commit 29a6fa9

Browse files
LeoColmankrzema12
andauthored
feat(server): support fetching versions for all actions (via GitHub App) (#1867)
Adds a way to authorize as a GitHub app. The apps can list branches and tags for all repos, contrary to personal access tokens or GitHub tokens that can be blocked for some orgs that own GitHub actions. --------- Co-authored-by: Piotr Krzeminski <[email protected]>
1 parent 4049892 commit 29a6fa9

File tree

6 files changed

+118
-25
lines changed

6 files changed

+118
-25
lines changed

.github/workflows/bindings-server.main.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import kotlin.time.TimeSource
3131
val DOCKERHUB_USERNAME by Contexts.secrets
3232
val DOCKERHUB_PASSWORD by Contexts.secrets
3333
val TRIGGER_IMAGE_PULL by Contexts.secrets
34-
val GITHUB_TOKEN by Contexts.secrets
34+
val APP_PRIVATE_KEY by Contexts.secrets
3535

3636
@OptIn(ExperimentalKotlinLogicStep::class)
3737
workflow(
@@ -52,7 +52,7 @@ workflow(
5252
name = "End-to-end test",
5353
runsOn = UbuntuLatest,
5454
env = mapOf(
55-
"GITHUB_TOKEN" to expr { GITHUB_TOKEN },
55+
"APP_PRIVATE_KEY" to expr { APP_PRIVATE_KEY },
5656
),
5757
) {
5858
uses(action = Checkout())

.github/workflows/bindings-server.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
needs:
4949
- 'check_yaml_consistency'
5050
env:
51-
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
51+
APP_PRIVATE_KEY: '${{ secrets.APP_PRIVATE_KEY }}'
5252
steps:
5353
- id: 'step-0'
5454
uses: 'actions/checkout@v4'

action-updates-checker/src/main/kotlin/io/github/typesafegithub/workflows/updates/Utils.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ internal suspend fun Workflow.availableVersionsForEachAction(
2020
githubAuthToken: String? = getGithubAuthTokenOrNull(),
2121
): Flow<RegularActionVersions> {
2222
if (githubAuthToken == null && !reportWhenTokenUnset) {
23-
githubWarning("github token is required, but not set, skipping api calls")
23+
githubWarning("github auth token is required, but not set, skipping api calls")
2424
return emptyFlow()
2525
}
2626
val groupedSteps = groupStepsByAction()

shared-internal/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies {
1616
implementation("io.ktor:ktor-client-cio:3.1.1")
1717
implementation("io.ktor:ktor-client-content-negotiation:3.1.1")
1818
implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1")
19+
implementation("com.auth0:java-jwt:4.5.0")
1920

2021
// It's a workaround for a problem with Kotlin Scripting, and how it resolves
2122
// conflicting versions: https://youtrack.jetbrains.com/issue/KT-69145
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package io.github.typesafegithub.workflows.shared.internal
2+
3+
import com.auth0.jwt.JWT
4+
import com.auth0.jwt.algorithms.Algorithm
5+
import io.ktor.client.HttpClient
6+
import io.ktor.client.call.body
7+
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
8+
import io.ktor.client.request.header
9+
import io.ktor.client.request.post
10+
import io.ktor.serialization.kotlinx.json.json
11+
import kotlinx.serialization.Serializable
12+
import kotlinx.serialization.Transient
13+
import kotlinx.serialization.json.Json
14+
import java.security.KeyFactory
15+
import java.security.interfaces.RSAPrivateKey
16+
import java.security.spec.PKCS8EncodedKeySpec
17+
import java.time.Instant
18+
import java.time.ZonedDateTime
19+
import java.util.Base64
20+
21+
/**
22+
* Returns an installation access token for the GitHub app, usable with API call to GitHub.
23+
* If `null` is returned, it means that the environment wasn't configured to generate the token.
24+
*/
25+
suspend fun getInstallationAccessToken(): String? {
26+
if (cachedAccessToken?.isExpired() == false) return cachedAccessToken!!.token
27+
val jwtToken = generateJWTToken() ?: return null
28+
cachedAccessToken =
29+
httpClient
30+
.post("https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens") {
31+
header("Accept", "application/vnd.github+json")
32+
header("Authorization", "Bearer $jwtToken")
33+
header("X-GitHub-Api-Version", "2022-11-28")
34+
}.body()
35+
return cachedAccessToken!!.token
36+
}
37+
38+
private const val INSTALLATION_ID = "62885502"
39+
40+
private var cachedAccessToken: Token? = null
41+
42+
@Serializable
43+
private data class Token(
44+
val token: String,
45+
val expires_at: String,
46+
val permissions: Map<String, String>,
47+
val repository_selection: String,
48+
) {
49+
@Transient
50+
private val expiresAtDateTime = ZonedDateTime.parse(expires_at)
51+
52+
fun isExpired() = ZonedDateTime.now().isAfter(expiresAtDateTime)
53+
}
54+
55+
private val httpClient =
56+
HttpClient {
57+
install(ContentNegotiation) {
58+
json(
59+
Json { ignoreUnknownKeys = false },
60+
)
61+
}
62+
}
63+
64+
private const val GITHUB_CLIENT_ID = "Iv23liIZ17VJKUpjacBs"
65+
66+
private fun generateJWTToken(): String? {
67+
val key = loadRsaKey() ?: return null
68+
val algorithm = Algorithm.RSA256(null, key)
69+
val now = Instant.now()
70+
return JWT
71+
.create()
72+
.withIssuer(GITHUB_CLIENT_ID)
73+
.withIssuedAt(now.minusMinutes(1))
74+
.withExpiresAt(now.plusMinutes(9))
75+
.sign(algorithm)
76+
}
77+
78+
private fun loadRsaKey(): RSAPrivateKey? {
79+
val privateKey = System.getenv("APP_PRIVATE_KEY") ?: return null
80+
val filtered =
81+
privateKey
82+
.replace("-----BEGIN PRIVATE KEY-----", "")
83+
.replace("-----END PRIVATE KEY-----", "")
84+
.replace("\\s".toRegex(), "")
85+
val keyBytes = Base64.getDecoder().decode(filtered)
86+
val keySpec = PKCS8EncodedKeySpec(keyBytes)
87+
val keyFactory = KeyFactory.getInstance("RSA")
88+
return keyFactory.generatePrivate(keySpec) as RSAPrivateKey
89+
}
90+
91+
private fun Instant.minusMinutes(minutes: Long): Instant = minusSeconds(minutes * 60)
92+
93+
private fun Instant.plusMinutes(minutes: Long): Instant = plusSeconds(minutes * 60)
Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,37 @@
11
package io.github.typesafegithub.workflows.shared.internal
22

3+
import kotlinx.coroutines.runBlocking
4+
35
/**
46
* Returns a token that should be used to make authorized calls to GitHub,
57
* or null if no token was configured.
68
* The token may be of various kind, e.g. a Personal Access Token, or an
79
* Application Installation Token.
810
*/
911
fun getGithubAuthTokenOrNull(): String? =
10-
System
11-
.getenv("GITHUB_TOKEN")
12-
.also {
13-
if (it == null) {
14-
println(
15-
"""
16-
Missing environment variable export GITHUB_TOKEN=token
17-
Create a personal token at https://github.com/settings/tokens
18-
The token needs to have public_repo scope.
19-
""".trimIndent(),
20-
)
12+
runBlocking {
13+
(System.getenv("GITHUB_TOKEN") ?: getInstallationAccessToken())
14+
.also {
15+
if (it == null) {
16+
println(ERROR_NO_CONFIGURATION)
17+
}
2118
}
22-
}
19+
}
2320

2421
/**
2522
* Returns a token that should be used to make authorized calls to GitHub,
2623
* or throws an exception if no token was configured.
2724
* The token may be of various kind, e.g. a Personal Access Token, or an
2825
* Application Installation Token.
2926
*/
30-
fun getGithubAuthToken(): String =
31-
System.getenv("GITHUB_TOKEN")
32-
?: error(
33-
"""
34-
Missing environment variable export GITHUB_TOKEN=token
35-
Create a personal token at https://github.com/settings/tokens
36-
The token needs to have public_repo scope.
37-
""".trimIndent(),
38-
)
27+
fun getGithubAuthToken(): String = getGithubAuthTokenOrNull() ?: error(ERROR_NO_CONFIGURATION)
28+
29+
private val ERROR_NO_CONFIGURATION =
30+
"""
31+
Missing environment variables for generating an auth token. There are two options:
32+
1. Create a personal access token at https://github.com/settings/tokens.
33+
The token needs to have public_repo scope. Then, set it in `GITHUB_TOKEN` env var.
34+
With this approach, listing versions for some actions may not work.
35+
2. Create a GitHub app, and generate a private key. Then, set it in `APP_PRIVATE_KEY` env var.
36+
With this approach, listing versions for all actions works.
37+
""".trimIndent()

0 commit comments

Comments
 (0)