Skip to content

Commit e379d46

Browse files
committed
feat(plugin-mac): add notarytool App Store Connect API key auth
Adds the third macOS notarization authentication mode, alongside the existing Apple ID + password and keychain-profile modes. App Store Connect API keys are the recommended option for CI/CD: revocable, role-scoped, and unaffected by 2FA. - DSL: `notarization.apiKey` (path to .p8) + `apiKeyId` + `apiIssuer` - Gradle properties: `compose.desktop.mac.notarization.apiKey` / `...apiKeyId` / `...apiIssuer` - New `NotarizationAuth.ApiKey` branch in the sealed class; `validate()` enforces all three modes mutually exclusive and requires the full trio - `toNotaryToolArgs()` emits `--key <path>` / `--key-id <id>` / `--issuer <uuid>` per Apple's `notarytool` CLI - `onlyIf` gate accepts the API-key-only configuration - Docs updated with the third mode and a CI/CD note
1 parent 37ef4dc commit e379d46

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
@@ -122,15 +122,15 @@ macOS {
122122

123123
### Notarization
124124

125-
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):
125+
Apple notarization is required for distributing outside the Mac App Store on macOS 10.15+. Three authentication modes are supported (mutually exclusive):
126126

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

129129
```kotlin
130130
macOS {
131131
notarization {
132132
appleID.set("dev@example.com")
133-
password.set("@keychain:AC_PASSWORD")
133+
password.set(System.getenv("MAC_NOTARIZATION_PASSWORD"))
134134
teamID.set("TEAMID")
135135
}
136136
}
@@ -141,7 +141,7 @@ Equivalent Gradle properties:
141141
| Gradle property | Description |
142142
|-----------------|-------------|
143143
| `compose.desktop.mac.notarization.appleID` | Apple ID email |
144-
| `compose.desktop.mac.notarization.password` | App-specific password (or `@keychain:` reference) |
144+
| `compose.desktop.mac.notarization.password` | App-specific password |
145145
| `compose.desktop.mac.notarization.teamID` | Apple Team ID |
146146

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

175-
> Configuring both modes in the same build is rejected at validation time. Pick one.
175+
#### Mode 3 — App Store Connect API key
176+
177+
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:
178+
179+
```kotlin
180+
macOS {
181+
notarization {
182+
apiKey.set("/path/to/AuthKey_ABC123.p8")
183+
apiKeyId.set("ABC123") // 10-char Key ID
184+
apiIssuer.set("12345678-90ab-cdef-1234-567890abcdef") // Issuer UUID
185+
}
186+
}
187+
```
188+
189+
Equivalent Gradle properties:
190+
191+
| Gradle property | Description |
192+
|-----------------|-------------|
193+
| `compose.desktop.mac.notarization.apiKey` | Path to the `.p8` API key file |
194+
| `compose.desktop.mac.notarization.apiKeyId` | Key ID (the 10-character identifier) |
195+
| `compose.desktop.mac.notarization.apiIssuer` | Issuer UUID for the team |
196+
197+
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.
198+
199+
> Configuring more than one mode in the same build is rejected at validation time. Pick one.
176200

177201
### CI/CD: macOS Signing
178202

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)