Skip to content

Commit 12efcea

Browse files
authored
Merge pull request #228 from kdroidFilter/feat/notarize-api-key
feat(plugin-mac): add notarytool App Store Connect API key auth
2 parents 41e74b0 + e379d46 commit 12efcea

7 files changed

Lines changed: 254 additions & 30 deletions

File tree

docs/code-signing.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,15 @@ macOS {
123123

124124
### Notarization
125125

126-
Apple notarization is required for distributing outside the Mac App Store on macOS 10.15+. Two authentication modes are supported (mutually exclusive — App Store Connect API key support is planned):
126+
Apple notarization is required for distributing outside the Mac App Store on macOS 10.15+. Three authentication modes are supported (mutually exclusive):
127127

128128
#### Mode 1 — Apple ID + app-specific password
129129

130130
```kotlin
131131
macOS {
132132
notarization {
133133
appleID.set("dev@example.com")
134-
password.set("@keychain:AC_PASSWORD")
134+
password.set(System.getenv("MAC_NOTARIZATION_PASSWORD"))
135135
teamID.set("TEAMID")
136136
}
137137
}
@@ -142,7 +142,7 @@ Equivalent Gradle properties:
142142
| Gradle property | Description |
143143
|-----------------|-------------|
144144
| `compose.desktop.mac.notarization.appleID` | Apple ID email |
145-
| `compose.desktop.mac.notarization.password` | App-specific password (or `@keychain:` reference) |
145+
| `compose.desktop.mac.notarization.password` | App-specific password |
146146
| `compose.desktop.mac.notarization.teamID` | Apple Team ID |
147147

148148
#### Mode 2 — `notarytool` keychain profile
@@ -173,7 +173,31 @@ Equivalent Gradle properties:
173173
| `compose.desktop.mac.notarization.keychainProfile` | Profile name created via `store-credentials` |
174174
| `compose.desktop.mac.notarization.keychainPath` | Optional path to the keychain holding the profile |
175175

176-
> Configuring both modes in the same build is rejected at validation time. Pick one.
176+
#### Mode 3 — App Store Connect API key
177+
178+
Generate a key in [App Store Connect → Users and Access → Integrations → Team Keys](https://appstoreconnect.apple.com/access/integrations/api), download the `.p8` file once, then reference it:
179+
180+
```kotlin
181+
macOS {
182+
notarization {
183+
apiKey.set("/path/to/AuthKey_ABC123.p8")
184+
apiKeyId.set("ABC123") // 10-char Key ID
185+
apiIssuer.set("12345678-90ab-cdef-1234-567890abcdef") // Issuer UUID
186+
}
187+
}
188+
```
189+
190+
Equivalent Gradle properties:
191+
192+
| Gradle property | Description |
193+
|-----------------|-------------|
194+
| `compose.desktop.mac.notarization.apiKey` | Path to the `.p8` API key file |
195+
| `compose.desktop.mac.notarization.apiKeyId` | Key ID (the 10-character identifier) |
196+
| `compose.desktop.mac.notarization.apiIssuer` | Issuer UUID for the team |
197+
198+
This mode is recommended for CI/CD: API keys can be revoked independently of the Apple ID, support role-based scoping, and are not affected by 2FA.
199+
200+
> Configuring more than one mode in the same build is rejected at validation time. Pick one.
177201

178202
### CI/CD: macOS Signing
179203

docs/targets/macos.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ macOS {
264264
265265
notarization {
266266
appleID.set("dev@example.com")
267-
password.set("@keychain:AC_PASSWORD")
267+
password.set(System.getenv("MAC_NOTARIZATION_PASSWORD"))
268268
teamID.set("TEAMID")
269269
}
270270
}

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/dsl/MacOSNotarizationSettings.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,41 @@ abstract class MacOSNotarizationSettings {
6666
set(NucleusProperties.macNotarizationKeychainPath(providers))
6767
}
6868

69+
/**
70+
* Path to the App Store Connect API key file (`.p8`).
71+
* Forwarded to `notarytool` as `--key <path>`.
72+
* Mutually exclusive with the apple-id and keychain-profile modes; the trio
73+
* [apiKey], [apiKeyId] and [apiIssuer] must all be set together.
74+
*/
75+
@get:Input
76+
@get:Optional
77+
val apiKey: Property<String> =
78+
objects.nullableProperty<String>().apply {
79+
set(NucleusProperties.macNotarizationApiKey(providers))
80+
}
81+
82+
/**
83+
* App Store Connect API key ID (the 10-character identifier shown in App Store Connect).
84+
* Forwarded to `notarytool` as `--key-id <id>`.
85+
*/
86+
@get:Input
87+
@get:Optional
88+
val apiKeyId: Property<String> =
89+
objects.nullableProperty<String>().apply {
90+
set(NucleusProperties.macNotarizationApiKeyId(providers))
91+
}
92+
93+
/**
94+
* App Store Connect API issuer ID (the team's issuer UUID).
95+
* Forwarded to `notarytool` as `--issuer <uuid>`.
96+
*/
97+
@get:Input
98+
@get:Optional
99+
val apiIssuer: Property<String> =
100+
objects.nullableProperty<String>().apply {
101+
set(NucleusProperties.macNotarizationApiIssuer(providers))
102+
}
103+
69104
@Deprecated("This option is no longer supported and got replaced by teamID", level = DeprecationLevel.ERROR)
70105
@get:Internal
71106
val ascProvider: Property<String> =

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/NucleusProjectProperties.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ internal object NucleusProperties {
2424
internal const val MAC_NOTARIZATION_TEAM_ID_PROVIDER = "compose.desktop.mac.notarization.teamID"
2525
internal const val MAC_NOTARIZATION_KEYCHAIN_PROFILE = "compose.desktop.mac.notarization.keychainProfile"
2626
internal const val MAC_NOTARIZATION_KEYCHAIN_PATH = "compose.desktop.mac.notarization.keychainPath"
27+
internal const val MAC_NOTARIZATION_API_KEY = "compose.desktop.mac.notarization.apiKey"
28+
internal const val MAC_NOTARIZATION_API_KEY_ID = "compose.desktop.mac.notarization.apiKeyId"
29+
internal const val MAC_NOTARIZATION_API_ISSUER = "compose.desktop.mac.notarization.apiIssuer"
2730
internal const val CHECK_JDK_VENDOR = "compose.desktop.packaging.checkJdkVendor"
2831
internal const val DISABLE_MULTIMODULE_RESOURCES = "org.jetbrains.compose.resources.multimodule.disable"
2932
internal const val SYNC_RESOURCES_PROPERTY = "compose.ios.resources.sync"
@@ -58,6 +61,15 @@ internal object NucleusProperties {
5861
@Suppress("MaxLineLength")
5962
fun macNotarizationKeychainPath(providers: ProviderFactory): Provider<String> = providers.valueOrNull(MAC_NOTARIZATION_KEYCHAIN_PATH)
6063

64+
@Suppress("MaxLineLength")
65+
fun macNotarizationApiKey(providers: ProviderFactory): Provider<String> = providers.valueOrNull(MAC_NOTARIZATION_API_KEY)
66+
67+
@Suppress("MaxLineLength")
68+
fun macNotarizationApiKeyId(providers: ProviderFactory): Provider<String> = providers.valueOrNull(MAC_NOTARIZATION_API_KEY_ID)
69+
70+
@Suppress("MaxLineLength")
71+
fun macNotarizationApiIssuer(providers: ProviderFactory): Provider<String> = providers.valueOrNull(MAC_NOTARIZATION_API_ISSUER)
72+
6173
fun checkJdkVendor(providers: ProviderFactory): Provider<Boolean> = providers.valueOrNull(CHECK_JDK_VENDOR).toBooleanProvider(true)
6274

6375
fun disableMultimoduleResources(providers: ProviderFactory): Provider<Boolean> =

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureJvmApplication.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -877,7 +877,11 @@ internal fun JvmApplicationContext.configureCommonNotarizationSettings(notarizat
877877
!notarization.password.orNull.isNullOrEmpty() &&
878878
!notarization.teamID.orNull.isNullOrEmpty()
879879
val hasKeychainProfile = !notarization.keychainProfile.orNull.isNullOrEmpty()
880-
val configured = hasAppleId || hasKeychainProfile
880+
val hasApiKey =
881+
!notarization.apiKey.orNull.isNullOrEmpty() &&
882+
!notarization.apiKeyId.orNull.isNullOrEmpty() &&
883+
!notarization.apiIssuer.orNull.isNullOrEmpty()
884+
val configured = hasAppleId || hasKeychainProfile || hasApiKey
881885
if (!configured) {
882886
it.logger.info("Notarization skipped: macOS notarization settings are not configured")
883887
}

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/validation/ValidatedMacOSNotarizationSettings.kt

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,16 @@ internal sealed class NotarizationAuth {
1919
val profileName: String,
2020
val keychainPath: String?,
2121
) : NotarizationAuth()
22+
23+
data class ApiKey(
24+
val keyPath: String,
25+
val keyId: String,
26+
val issuerId: String,
27+
) : NotarizationAuth()
2228
}
2329

30+
internal data class ValidatedMacOSNotarizationSettings(val auth: NotarizationAuth)
31+
2432
/**
2533
* Builds the `notarytool` authentication arguments and an optional stdin payload
2634
* (the Apple ID password is fed via stdin to keep it off the command line).
@@ -38,10 +46,14 @@ internal fun NotarizationAuth.toNotaryToolArgs(): Pair<List<String>, String?> =
3846
add(it)
3947
}
4048
} to null
49+
is NotarizationAuth.ApiKey ->
50+
listOf(
51+
"--key", keyPath,
52+
"--key-id", keyId,
53+
"--issuer", issuerId,
54+
) to null
4155
}
4256

43-
internal data class ValidatedMacOSNotarizationSettings(val auth: NotarizationAuth)
44-
4557
internal fun MacOSNotarizationSettings?.validate(): ValidatedMacOSNotarizationSettings {
4658
checkNotNull(this) {
4759
ERR_NOTARIZATION_SETTINGS_ARE_NOT_PROVIDED
@@ -52,35 +64,55 @@ internal fun MacOSNotarizationSettings?.validate(): ValidatedMacOSNotarizationSe
5264
val team = teamID.orNull?.takeUnless { it.isEmpty() }
5365
val profile = keychainProfile.orNull?.takeUnless { it.isEmpty() }
5466
val keychainPathValue = keychainPath.orNull?.takeUnless { it.isEmpty() }
67+
val key = apiKey.orNull?.takeUnless { it.isEmpty() }
68+
val keyId = apiKeyId.orNull?.takeUnless { it.isEmpty() }
69+
val issuer = apiIssuer.orNull?.takeUnless { it.isEmpty() }
5570

5671
val appleIdMode = appleId != null || pwd != null || team != null
5772
val keychainMode = profile != null
73+
val apiKeyMode = key != null || keyId != null || issuer != null
5874

59-
check(!(appleIdMode && keychainMode)) {
75+
val activeModes = listOf(appleIdMode, keychainMode, apiKeyMode).count { it }
76+
check(activeModes <= 1) {
6077
ERR_MUTUALLY_EXCLUSIVE
6178
}
62-
check(appleIdMode || keychainMode) {
79+
check(activeModes == 1) {
6380
ERR_NO_MODE_CONFIGURED
6481
}
6582

66-
return if (profile != null) {
67-
ValidatedMacOSNotarizationSettings(
68-
NotarizationAuth.KeychainProfile(
69-
profileName = profile,
70-
keychainPath = keychainPathValue,
71-
),
72-
)
73-
} else {
74-
checkNotNull(appleId) { ERR_APPLE_ID_IS_EMPTY }
75-
checkNotNull(pwd) { ERR_PASSWORD_IS_EMPTY }
76-
checkNotNull(team) { ERR_TEAM_ID_IS_EMPTY }
77-
ValidatedMacOSNotarizationSettings(
78-
NotarizationAuth.AppleId(
79-
appleID = appleId,
80-
password = pwd,
81-
teamID = team,
82-
),
83-
)
83+
return when {
84+
apiKeyMode -> {
85+
checkNotNull(key) { ERR_API_KEY_IS_EMPTY }
86+
checkNotNull(keyId) { ERR_API_KEY_ID_IS_EMPTY }
87+
checkNotNull(issuer) { ERR_API_ISSUER_IS_EMPTY }
88+
ValidatedMacOSNotarizationSettings(
89+
NotarizationAuth.ApiKey(
90+
keyPath = key,
91+
keyId = keyId,
92+
issuerId = issuer,
93+
),
94+
)
95+
}
96+
profile != null -> {
97+
ValidatedMacOSNotarizationSettings(
98+
NotarizationAuth.KeychainProfile(
99+
profileName = profile,
100+
keychainPath = keychainPathValue,
101+
),
102+
)
103+
}
104+
else -> {
105+
checkNotNull(appleId) { ERR_APPLE_ID_IS_EMPTY }
106+
checkNotNull(pwd) { ERR_PASSWORD_IS_EMPTY }
107+
checkNotNull(team) { ERR_TEAM_ID_IS_EMPTY }
108+
ValidatedMacOSNotarizationSettings(
109+
NotarizationAuth.AppleId(
110+
appleID = appleId,
111+
password = pwd,
112+
teamID = team,
113+
),
114+
)
115+
}
84116
}
85117
}
86118

@@ -95,10 +127,14 @@ private val ERR_NO_MODE_CONFIGURED =
95127
| ${NucleusProperties.MAC_NOTARIZATION_TEAM_ID_PROVIDER});
96128
| * Keychain profile mode: keychainProfile (created via 'xcrun notarytool store-credentials')
97129
| (Gradle property: ${NucleusProperties.MAC_NOTARIZATION_KEYCHAIN_PROFILE});
130+
| * App Store Connect API key mode: apiKey + apiKeyId + apiIssuer
131+
| (Gradle properties: ${NucleusProperties.MAC_NOTARIZATION_API_KEY},
132+
| ${NucleusProperties.MAC_NOTARIZATION_API_KEY_ID},
133+
| ${NucleusProperties.MAC_NOTARIZATION_API_ISSUER});
98134
""".trimMargin()
99135
private val ERR_MUTUALLY_EXCLUSIVE =
100-
"""|$ERR_PREFIX appleID/password/teamID and keychainProfile are mutually exclusive.
101-
|Configure only one authentication mode.
136+
"""|$ERR_PREFIX appleID/keychainProfile/apiKey are mutually exclusive authentication modes.
137+
|Configure only one mode at a time.
102138
""".trimMargin()
103139
private val ERR_APPLE_ID_IS_EMPTY =
104140
"""|$ERR_PREFIX appleID is null or empty. To specify:
@@ -115,3 +151,18 @@ private val ERR_TEAM_ID_IS_EMPTY =
115151
| * Use '${NucleusProperties.MAC_NOTARIZATION_TEAM_ID_PROVIDER}' Gradle property;
116152
| * Or use 'nativeDistributions.macOS.notarization.teamID' DSL property;
117153
""".trimMargin()
154+
private val ERR_API_KEY_IS_EMPTY =
155+
"""|$ERR_PREFIX apiKey is null or empty. To specify:
156+
| * Use '${NucleusProperties.MAC_NOTARIZATION_API_KEY}' Gradle property;
157+
| * Or use 'nativeDistributions.macOS.notarization.apiKey' DSL property;
158+
""".trimMargin()
159+
private val ERR_API_KEY_ID_IS_EMPTY =
160+
"""|$ERR_PREFIX apiKeyId is null or empty. To specify:
161+
| * Use '${NucleusProperties.MAC_NOTARIZATION_API_KEY_ID}' Gradle property;
162+
| * Or use 'nativeDistributions.macOS.notarization.apiKeyId' DSL property;
163+
""".trimMargin()
164+
private val ERR_API_ISSUER_IS_EMPTY =
165+
"""|$ERR_PREFIX apiIssuer is null or empty. To specify:
166+
| * Use '${NucleusProperties.MAC_NOTARIZATION_API_ISSUER}' Gradle property;
167+
| * Or use 'nativeDistributions.macOS.notarization.apiIssuer' DSL property;
168+
""".trimMargin()

0 commit comments

Comments
 (0)