Skip to content

Commit e62c495

Browse files
authored
CI: Add Gradle tasks for iOS archive and App Store Connect upload (#190)
1 parent 8896d5f commit e62c495

3 files changed

Lines changed: 223 additions & 0 deletions

File tree

build.gradle.kts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,26 @@ kover {
5858
}
5959
}
6060

61+
val archiveIosApp = tasks.register<org.neotech.plugin.IosArchiveTask>("archiveIosApp") {
62+
xcodeProjectDirectory = layout.projectDirectory.dir("iosApp")
63+
scheme = "iosApp"
64+
configuration = "Release"
65+
outputDirectory = layout.projectDirectory.dir("iosApp/build")
66+
}
67+
68+
tasks.register<org.neotech.plugin.IosExportTask>("exportIosApp") {
69+
dependsOn(archiveIosApp)
70+
archivePath = layout.projectDirectory.dir("iosApp/build/iosApp.xcarchive")
71+
val localProperties = java.util.Properties()
72+
try {
73+
localProperties.load(rootProject.file("local.properties").inputStream())
74+
} catch (_: Exception) {
75+
logger.warn("w: Unable to load local.properties file!")
76+
}
77+
teamId = localProperties.getProperty("apple.teamId") ?: ""
78+
outputDirectory = layout.projectDirectory.dir("iosApp/build/export")
79+
}
80+
6181
tasks.register<org.neotech.plugin.FrameScreenshotsTask>("frameAndroidScreenshots") {
6282
group = "store"
6383
description = "Frames Nothing Phone screenshots into device bezels."
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Abysner - Dive planner
3+
* Copyright (C) 2026 Neotech
4+
*
5+
* Abysner is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License version 3,
7+
* as published by the Free Software Foundation.
8+
*
9+
* You should have received a copy of the GNU Affero General Public License
10+
* along with this program. If not, see https://www.gnu.org/licenses/.
11+
*/
12+
13+
package org.neotech.plugin
14+
15+
import org.gradle.api.DefaultTask
16+
import org.gradle.api.GradleException
17+
import org.gradle.api.file.DirectoryProperty
18+
import org.gradle.api.provider.Property
19+
import org.gradle.api.tasks.Input
20+
import org.gradle.api.tasks.InputDirectory
21+
import org.gradle.api.tasks.Optional
22+
import org.gradle.api.tasks.OutputDirectory
23+
import org.gradle.api.tasks.TaskAction
24+
25+
/**
26+
* Builds an iOS .xcarchive using xcodebuild. This task must run on macOS with Xcode installed.
27+
* Use [IosExportTask] to export the archive as an IPA for App Store upload.
28+
*/
29+
abstract class IosArchiveTask : DefaultTask() {
30+
31+
@get:InputDirectory
32+
abstract val xcodeProjectDirectory: DirectoryProperty
33+
34+
@get:Input
35+
abstract val scheme: Property<String>
36+
37+
@get:Input
38+
@get:Optional
39+
abstract val configuration: Property<String>
40+
41+
@get:InputDirectory
42+
@get:Optional
43+
abstract val xcodeAppPath: DirectoryProperty
44+
45+
@get:OutputDirectory
46+
abstract val outputDirectory: DirectoryProperty
47+
48+
init {
49+
group = "release"
50+
description = "Archives the iOS app into an .xcarchive bundle."
51+
}
52+
53+
@TaskAction
54+
fun execute() {
55+
val projectDirectory = xcodeProjectDirectory.get().asFile
56+
val schemeName = scheme.get()
57+
val config = configuration.getOrElse("Release")
58+
val outputDir = outputDirectory.get().asFile
59+
val archivePath = outputDir.resolve("$schemeName.xcarchive")
60+
61+
outputDir.mkdirs()
62+
63+
logger.lifecycle("Archiving iOS app (scheme=$schemeName, configuration=$config)...")
64+
65+
val archiveResult = runCommand(
66+
listOf(
67+
"xcodebuild",
68+
"-project", projectDirectory.resolve("iosApp.xcodeproj").absolutePath,
69+
"-scheme", schemeName,
70+
"-configuration", config,
71+
"-archivePath", archivePath.absolutePath,
72+
"-destination", "generic/platform=iOS",
73+
"archive",
74+
),
75+
workingDirectory = projectDirectory,
76+
)
77+
78+
if (archiveResult != 0) {
79+
throw GradleException("xcodebuild archive failed with exit code $archiveResult")
80+
}
81+
82+
logger.lifecycle("Archive created at: ${archivePath.absolutePath}")
83+
}
84+
85+
private fun runCommand(command: List<String>, workingDirectory: java.io.File): Int {
86+
logger.info("Running: ${command.joinToString(" ")}")
87+
val process = ProcessBuilder(command)
88+
.directory(workingDirectory)
89+
.redirectErrorStream(true)
90+
.also { builder ->
91+
if (xcodeAppPath.isPresent) {
92+
val developerDir = xcodeAppPath.get().asFile.resolve("Contents/Developer")
93+
logger.info("Using Xcode at: ${xcodeAppPath.get().asFile.absolutePath}")
94+
builder.environment()["DEVELOPER_DIR"] = developerDir.absolutePath
95+
}
96+
}
97+
.start()
98+
99+
process.inputStream.bufferedReader().forEachLine { line ->
100+
logger.info(" $line")
101+
}
102+
103+
return process.waitFor()
104+
}
105+
}
106+
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Abysner - Dive planner
3+
* Copyright (C) 2026 Neotech
4+
*
5+
* Abysner is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License version 3,
7+
* as published by the Free Software Foundation.
8+
*
9+
* You should have received a copy of the GNU Affero General Public License
10+
* along with this program. If not, see https://www.gnu.org/licenses/.
11+
*/
12+
13+
package org.neotech.plugin
14+
15+
import org.gradle.api.DefaultTask
16+
import org.gradle.api.GradleException
17+
import org.gradle.api.file.DirectoryProperty
18+
import org.gradle.api.provider.Property
19+
import org.gradle.api.tasks.Input
20+
import org.gradle.api.tasks.InputDirectory
21+
import org.gradle.api.tasks.OutputDirectory
22+
import org.gradle.api.tasks.TaskAction
23+
24+
/**
25+
* Exports an existing .xcarchive as an IPA and uploads it to App Store Connect using
26+
* xcodebuild. Generates the ExportOptions.plist at build time from the provided [teamId].
27+
* Typically depends on [IosArchiveTask] to produce the archive first.
28+
*/
29+
abstract class IosExportTask : DefaultTask() {
30+
31+
@get:InputDirectory
32+
abstract val archivePath: DirectoryProperty
33+
34+
@get:Input
35+
abstract val teamId: Property<String>
36+
37+
@get:OutputDirectory
38+
abstract val outputDirectory: DirectoryProperty
39+
40+
init {
41+
group = "release"
42+
description = "Exports an .xcarchive as an IPA and uploads it to App Store Connect."
43+
}
44+
45+
@TaskAction
46+
fun execute() {
47+
val archive = archivePath.get().asFile
48+
val outputDir = outputDirectory.get().asFile
49+
val exportOptionsFile = outputDir.resolve("ExportOptions.plist")
50+
51+
outputDir.mkdirs()
52+
53+
exportOptionsFile.writeText(
54+
"""
55+
|<?xml version="1.0" encoding="UTF-8"?>
56+
|<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
57+
|<plist version="1.0">
58+
|<dict>
59+
| <key>method</key>
60+
| <string>app-store-connect</string>
61+
| <key>teamID</key>
62+
| <string>${teamId.get()}</string>
63+
| <key>uploadSymbols</key>
64+
| <true/>
65+
| <key>destination</key>
66+
| <string>upload</string>
67+
|</dict>
68+
|</plist>
69+
""".trimMargin()
70+
)
71+
72+
logger.lifecycle("Exporting and uploading IPA from ${archive.name} to App Store Connect...")
73+
74+
val process = ProcessBuilder(
75+
listOf(
76+
"xcodebuild",
77+
"-exportArchive",
78+
"-archivePath", archive.absolutePath,
79+
"-exportOptionsPlist", exportOptionsFile.absolutePath,
80+
"-exportPath", outputDir.absolutePath,
81+
),
82+
)
83+
.redirectErrorStream(true)
84+
.start()
85+
86+
process.inputStream.bufferedReader().forEachLine { line ->
87+
logger.info(" $line")
88+
}
89+
90+
val exitCode = process.waitFor()
91+
if (exitCode != 0) {
92+
throw GradleException("xcodebuild -exportArchive failed with exit code $exitCode")
93+
}
94+
95+
logger.lifecycle("IPA exported and uploaded to App Store Connect.")
96+
}
97+
}

0 commit comments

Comments
 (0)