From c73482326a5fe4e04f96a86407933a6d7959e982 Mon Sep 17 00:00:00 2001 From: Rolf Smit Date: Thu, 7 May 2026 02:56:06 +0200 Subject: [PATCH] CI: Add Gradle tasks for iOS archive and App Store Connect upload --- build.gradle.kts | 20 ++++ .../org/neotech/plugin/IosArchiveTask.kt | 106 ++++++++++++++++++ .../org/neotech/plugin/IosExportTask.kt | 97 ++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 buildSrc/src/main/kotlin/org/neotech/plugin/IosArchiveTask.kt create mode 100644 buildSrc/src/main/kotlin/org/neotech/plugin/IosExportTask.kt diff --git a/build.gradle.kts b/build.gradle.kts index 54dea8e..b64942a 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -58,6 +58,26 @@ kover { } } +val archiveIosApp = tasks.register("archiveIosApp") { + xcodeProjectDirectory = layout.projectDirectory.dir("iosApp") + scheme = "iosApp" + configuration = "Release" + outputDirectory = layout.projectDirectory.dir("iosApp/build") +} + +tasks.register("exportIosApp") { + dependsOn(archiveIosApp) + archivePath = layout.projectDirectory.dir("iosApp/build/iosApp.xcarchive") + val localProperties = java.util.Properties() + try { + localProperties.load(rootProject.file("local.properties").inputStream()) + } catch (_: Exception) { + logger.warn("w: Unable to load local.properties file!") + } + teamId = localProperties.getProperty("apple.teamId") ?: "" + outputDirectory = layout.projectDirectory.dir("iosApp/build/export") +} + tasks.register("frameAndroidScreenshots") { group = "store" description = "Frames Nothing Phone screenshots into device bezels." diff --git a/buildSrc/src/main/kotlin/org/neotech/plugin/IosArchiveTask.kt b/buildSrc/src/main/kotlin/org/neotech/plugin/IosArchiveTask.kt new file mode 100644 index 0000000..3323511 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/neotech/plugin/IosArchiveTask.kt @@ -0,0 +1,106 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2026 Neotech + * + * Abysner is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License version 3, + * as published by the Free Software Foundation. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package org.neotech.plugin + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction + +/** + * Builds an iOS .xcarchive using xcodebuild. This task must run on macOS with Xcode installed. + * Use [IosExportTask] to export the archive as an IPA for App Store upload. + */ +abstract class IosArchiveTask : DefaultTask() { + + @get:InputDirectory + abstract val xcodeProjectDirectory: DirectoryProperty + + @get:Input + abstract val scheme: Property + + @get:Input + @get:Optional + abstract val configuration: Property + + @get:InputDirectory + @get:Optional + abstract val xcodeAppPath: DirectoryProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + init { + group = "release" + description = "Archives the iOS app into an .xcarchive bundle." + } + + @TaskAction + fun execute() { + val projectDirectory = xcodeProjectDirectory.get().asFile + val schemeName = scheme.get() + val config = configuration.getOrElse("Release") + val outputDir = outputDirectory.get().asFile + val archivePath = outputDir.resolve("$schemeName.xcarchive") + + outputDir.mkdirs() + + logger.lifecycle("Archiving iOS app (scheme=$schemeName, configuration=$config)...") + + val archiveResult = runCommand( + listOf( + "xcodebuild", + "-project", projectDirectory.resolve("iosApp.xcodeproj").absolutePath, + "-scheme", schemeName, + "-configuration", config, + "-archivePath", archivePath.absolutePath, + "-destination", "generic/platform=iOS", + "archive", + ), + workingDirectory = projectDirectory, + ) + + if (archiveResult != 0) { + throw GradleException("xcodebuild archive failed with exit code $archiveResult") + } + + logger.lifecycle("Archive created at: ${archivePath.absolutePath}") + } + + private fun runCommand(command: List, workingDirectory: java.io.File): Int { + logger.info("Running: ${command.joinToString(" ")}") + val process = ProcessBuilder(command) + .directory(workingDirectory) + .redirectErrorStream(true) + .also { builder -> + if (xcodeAppPath.isPresent) { + val developerDir = xcodeAppPath.get().asFile.resolve("Contents/Developer") + logger.info("Using Xcode at: ${xcodeAppPath.get().asFile.absolutePath}") + builder.environment()["DEVELOPER_DIR"] = developerDir.absolutePath + } + } + .start() + + process.inputStream.bufferedReader().forEachLine { line -> + logger.info(" $line") + } + + return process.waitFor() + } +} + diff --git a/buildSrc/src/main/kotlin/org/neotech/plugin/IosExportTask.kt b/buildSrc/src/main/kotlin/org/neotech/plugin/IosExportTask.kt new file mode 100644 index 0000000..05de6ba --- /dev/null +++ b/buildSrc/src/main/kotlin/org/neotech/plugin/IosExportTask.kt @@ -0,0 +1,97 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2026 Neotech + * + * Abysner is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License version 3, + * as published by the Free Software Foundation. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package org.neotech.plugin + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction + +/** + * Exports an existing .xcarchive as an IPA and uploads it to App Store Connect using + * xcodebuild. Generates the ExportOptions.plist at build time from the provided [teamId]. + * Typically depends on [IosArchiveTask] to produce the archive first. + */ +abstract class IosExportTask : DefaultTask() { + + @get:InputDirectory + abstract val archivePath: DirectoryProperty + + @get:Input + abstract val teamId: Property + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + init { + group = "release" + description = "Exports an .xcarchive as an IPA and uploads it to App Store Connect." + } + + @TaskAction + fun execute() { + val archive = archivePath.get().asFile + val outputDir = outputDirectory.get().asFile + val exportOptionsFile = outputDir.resolve("ExportOptions.plist") + + outputDir.mkdirs() + + exportOptionsFile.writeText( + """ + | + | + | + | + | method + | app-store-connect + | teamID + | ${teamId.get()} + | uploadSymbols + | + | destination + | upload + | + | + """.trimMargin() + ) + + logger.lifecycle("Exporting and uploading IPA from ${archive.name} to App Store Connect...") + + val process = ProcessBuilder( + listOf( + "xcodebuild", + "-exportArchive", + "-archivePath", archive.absolutePath, + "-exportOptionsPlist", exportOptionsFile.absolutePath, + "-exportPath", outputDir.absolutePath, + ), + ) + .redirectErrorStream(true) + .start() + + process.inputStream.bufferedReader().forEachLine { line -> + logger.info(" $line") + } + + val exitCode = process.waitFor() + if (exitCode != 0) { + throw GradleException("xcodebuild -exportArchive failed with exit code $exitCode") + } + + logger.lifecycle("IPA exported and uploaded to App Store Connect.") + } +}