From babe0dab2f211fec6338e06c417afed5b66fb34c Mon Sep 17 00:00:00 2001 From: Manfred Endres <2523575+Larusso@users.noreply.github.com> Date: Thu, 7 May 2020 19:58:52 +0200 Subject: [PATCH] Add testflight publish (#55) Description ======== This patch adds a new task type `PublishTestFlight` to the plugin. This task uses `fastlane pilot upload`. This solution would bring the most options for the future but I'm still unsure if we should not simple use `altool`. The plugin creates a new task `publishTestFlight` and hooks are the base information like fastlane credentials and path to the `ipa` being published. The publish task nows a few more parameters that are passed down to `fastlane pilot upload` | parameter | fastlane flag | description | | ----------------------------- | ----------------------------------- | ----------- | | appIdentifier | --app_identifier | STRING The bundle identifier of the app to upload or manage testers | teamId | --team_id | The ID of your App Store Connect team if you're in multiple teams | teamName | --team_name | STRING The name of your App Store Connect team if you're in multiple teams | itcProvider | --itc_provider | STRING The provider short name to be used with the iTMSTransporter to identify your team. This value will override the automatically detected provider short name. | | devPortalTeamId | --dev_portal_team_id | The short ID of your team in the developer portal, if you're in multiple teams. Different from your iTC team ID! | | username | --username | STRING Your Apple ID Username | skipSubmission | --skip_submission | [VALUE] Skip the distributing action of pilot and only upload the ipa file | | skipWaitingForBuildProcessing | --skip_waiting_for_build_processing | [VALUE] Don't wait for the build to process. If set to true, the changelog won't be set, `distribute_external` option won't work and no build will be distributed to testers. (You might want to use this option if you are using this action on CI and have to pay for 'minutes used' on your CI plan) | | ipa | --ipa | STRING Path to the ipa file to upload | | password | | Your Apple ID Password | Other options might follow in the future but these should be enough for a simple upload and testflight app setup. The `publishTestFlight` task is wired to the global `publish` task when the extention parameter `publishToTestFlight` is set to true. (default `false`) Changes ======= * ![ADD] ![IOS] task `PublishTestflight` * ![IMPROVE] test setup * ![IMPROVE] test coverage --- .../build/unity/ios/tasks/FastlaneSpec.groovy | 31 ++ .../ImportProvisioningProfileSpec.groovy | 26 +- .../ios/tasks/PublishTestFlightSpec.groovy | 168 ++++++++++ .../build/unity/ios/IOSBuildPlugin.groovy | 35 ++- .../unity/ios/IOSBuildPluginExtension.groovy | 6 +- .../DefaultIOSBuildPluginExtension.groovy | 17 + .../unity/ios/tasks/PublishTestFlight.groovy | 293 ++++++++++++++++++ .../build/unity/ios/IOSBuildPluginSpec.groovy | 156 +++++++++- 8 files changed, 703 insertions(+), 29 deletions(-) create mode 100644 src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/FastlaneSpec.groovy create mode 100644 src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/PublishTestFlightSpec.groovy create mode 100644 src/main/groovy/wooga/gradle/build/unity/ios/tasks/PublishTestFlight.groovy diff --git a/src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/FastlaneSpec.groovy b/src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/FastlaneSpec.groovy new file mode 100644 index 00000000..53ffe8d0 --- /dev/null +++ b/src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/FastlaneSpec.groovy @@ -0,0 +1,31 @@ +package wooga.gradle.build.unity.ios.tasks + +import wooga.gradle.build.IntegrationSpec + +class FastlaneSpec extends IntegrationSpec { + File fastlaneMock + File fastlaneMockPath + + def setupFastlaneMock() { + fastlaneMockPath = File.createTempDir("fastlane", "mock") + + def path = System.getenv("PATH") + environmentVariables.clear("PATH") + String newPath = "${fastlaneMockPath}${File.pathSeparator}${path}" + environmentVariables.set("PATH", newPath) + assert System.getenv("PATH") == newPath + + + fastlaneMock = createFile("fastlane", fastlaneMockPath) + fastlaneMock.executable = true + fastlaneMock << """ + #!/usr/bin/env bash + echo \$@ + env + """.stripIndent() + } + + def setup() { + setupFastlaneMock() + } +} diff --git a/src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/ImportProvisioningProfileSpec.groovy b/src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/ImportProvisioningProfileSpec.groovy index b94b2660..95c6a972 100644 --- a/src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/ImportProvisioningProfileSpec.groovy +++ b/src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/ImportProvisioningProfileSpec.groovy @@ -30,29 +30,7 @@ import wooga.gradle.build.IntegrationSpec * account with necessary credentials. We only test the invocation of fastlane and its parameters. */ @Requires({ os.macOs }) -class ImportProvisioningProfileSpec extends IntegrationSpec { - - File fastlaneMock - File fastlaneMockPath - - def setupFastlaneMock() { - fastlaneMockPath = File.createTempDir("fastlane", "mock") - - def path = System.getenv("PATH") - environmentVariables.clear("PATH") - String newPath = "${fastlaneMockPath}${File.pathSeparator}${path}" - environmentVariables.set("PATH", newPath) - assert System.getenv("PATH") == newPath - - - fastlaneMock = createFile("fastlane", fastlaneMockPath) - fastlaneMock.executable = true - fastlaneMock << """ - #!/usr/bin/env bash - echo \$@ - env - """.stripIndent() - } +class ImportProvisioningProfileSpec extends FastlaneSpec { def setup() { buildFile << """ @@ -63,8 +41,6 @@ class ImportProvisioningProfileSpec extends IntegrationSpec { destinationDir = file("build") } """.stripIndent() - - setupFastlaneMock() } @Issue("https://github.com/wooga/atlas-build-unity/issues/38") diff --git a/src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/PublishTestFlightSpec.groovy b/src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/PublishTestFlightSpec.groovy new file mode 100644 index 00000000..a9d4b033 --- /dev/null +++ b/src/integrationTest/groovy/wooga/gradle/build/unity/ios/tasks/PublishTestFlightSpec.groovy @@ -0,0 +1,168 @@ +package wooga.gradle.build.unity.ios.tasks + +import spock.lang.Requires +import spock.lang.Unroll + +@Requires({ os.macOs }) +class PublishTestFlightSpec extends FastlaneSpec { + + def setup() { + def ipaFile = File.createTempFile("mockIpa", ".ipa") + + buildFile << """ + task publishTestFlight(type: wooga.gradle.build.unity.ios.tasks.PublishTestFlight) { + appIdentifier = "com.test.testapp" + teamId = "fakeTeamId" + ipa = file("${ipaFile.path}") + } + """.stripIndent() + } + + @Unroll + def "task :#taskToRun executes fastlane #fastlaneCommand #fastlaneSubCommand"() { + given: "ipa path" + def ipaFile = File.createTempFile("mockIpa", ".ipa") + + and: "a configured task" + buildFile << """ + ${taskToRun} { + ipa = file("${ipaFile.path}") + } + """.stripIndent() + + when: + def result = runTasksSuccessfully(taskToRun) + + then: + result.standardOutput.readLines().any { it.matches("${fastlaneCommand} ${fastlaneSubCommand}.*? --ipa ${ipaFile.path}") } + + where: + taskToRun = "publishTestFlight" + fastlaneCommand = "pilot" + fastlaneSubCommand = "upload" + } + + @Unroll + def "task :#taskToRun accepts input #parameter with #method and type #type"() { + given: "task with configured properties" + buildFile << """ + ${taskToRun} { + ${method}(${value}) + } + """.stripIndent() + + and: + if (parameter == "ipa") { + createFile(rawValue.toString(), projectDir) + } + + when: + def result = runTasksSuccessfully(taskToRun) + + then: + result.standardOutput.contains(expectedCommandlineSwitch.replace("#{value_path}", new File(projectDir, rawValue.toString()).path)) + + where: + parameter | rawValue | type | useSetter | expectedCommandlineSwitchRaw + "appIdentifier" | "com.test.app2" | 'String' | true | "--app_identifier #{value}" + "appIdentifier" | "com.test.app3" | 'String' | false | "--app_identifier #{value}" + "appIdentifier" | "com.test.app4" | 'Closure' | true | "--app_identifier #{value}" + "appIdentifier" | "com.test.app5" | 'Closure' | false | "--app_identifier #{value}" + "appIdentifier" | "com.test.app6" | 'Callable' | true | "--app_identifier #{value}" + "appIdentifier" | "com.test.app7" | 'Callable' | false | "--app_identifier #{value}" + "appIdentifier" | "com.test.app8" | 'Object' | true | "--app_identifier #{value}" + "appIdentifier" | "com.test.app9" | 'Object' | false | "--app_identifier #{value}" + + "teamId" | "1234561" | 'String' | true | "--team_id #{value}" + "teamId" | "1234562" | 'String' | false | "--team_id #{value}" + "teamId" | "1234563" | 'Closure' | true | "--team_id #{value}" + "teamId" | "1234564" | 'Closure' | false | "--team_id #{value}" + "teamId" | "1234565" | 'Callable' | true | "--team_id #{value}" + "teamId" | "1234566" | 'Callable' | false | "--team_id #{value}" + "teamId" | "1234567" | 'Object' | true | "--team_id #{value}" + "teamId" | "1234568" | 'Object' | false | "--team_id #{value}" + + "teamName" | "testName" | 'String' | true | "--team_name #{value}" + "teamName" | "testName" | 'String' | false | "--team_name #{value}" + "teamName" | "testName" | 'Closure' | true | "--team_name #{value}" + "teamName" | "testName" | 'Closure' | false | "--team_name #{value}" + "teamName" | "testName" | 'Callable' | true | "--team_name #{value}" + "teamName" | "testName" | 'Callable' | false | "--team_name #{value}" + "teamName" | "testName" | 'Object' | true | "--team_name #{value}" + "teamName" | "testName" | 'Object' | false | "--team_name #{value}" + + "itcProvider" | "testItcProvider" | 'String' | true | "--itc_provider #{value}" + "itcProvider" | "testItcProvider" | 'String' | false | "--itc_provider #{value}" + "itcProvider" | "testItcProvider" | 'Closure' | true | "--itc_provider #{value}" + "itcProvider" | "testItcProvider" | 'Closure' | false | "--itc_provider #{value}" + "itcProvider" | "testItcProvider" | 'Callable' | true | "--itc_provider #{value}" + "itcProvider" | "testItcProvider" | 'Callable' | false | "--itc_provider #{value}" + "itcProvider" | "testItcProvider" | 'Object' | true | "--itc_provider #{value}" + "itcProvider" | "testItcProvider" | 'Object' | false | "--itc_provider #{value}" + + "devPortalTeamId" | "1234561" | 'String' | true | "--dev_portal_team_id #{value}" + "devPortalTeamId" | "1234562" | 'String' | false | "--dev_portal_team_id #{value}" + "devPortalTeamId" | "1234563" | 'Closure' | true | "--dev_portal_team_id #{value}" + "devPortalTeamId" | "1234564" | 'Closure' | false | "--dev_portal_team_id #{value}" + "devPortalTeamId" | "1234565" | 'Callable' | true | "--dev_portal_team_id #{value}" + "devPortalTeamId" | "1234566" | 'Callable' | false | "--dev_portal_team_id #{value}" + "devPortalTeamId" | "1234567" | 'Object' | true | "--dev_portal_team_id #{value}" + "devPortalTeamId" | "1234568" | 'Object' | false | "--dev_portal_team_id #{value}" + + "username" | "tester1" | 'String' | true | "--username #{value}" + "username" | "tester2" | 'String' | false | "--username #{value}" + "username" | "tester3" | 'Closure' | true | "--username #{value}" + "username" | "tester4" | 'Closure' | false | "--username #{value}" + "username" | "tester5" | 'Callable' | true | "--username #{value}" + "username" | "tester6" | 'Callable' | false | "--username #{value}" + "username" | "tester7" | 'Object' | true | "--username #{value}" + "username" | "tester8" | 'Object' | false | "--username #{value}" + + "password" | "pass1" | 'String' | true | "FASTLANE_PASSWORD=#{value}" + "password" | "pass2" | 'String' | false | "FASTLANE_PASSWORD=#{value}" + "password" | "pass3" | 'Closure' | true | "FASTLANE_PASSWORD=#{value}" + "password" | "pass4" | 'Closure' | false | "FASTLANE_PASSWORD=#{value}" + "password" | "pass5" | 'Callable' | true | "FASTLANE_PASSWORD=#{value}" + "password" | "pass6" | 'Callable' | false | "FASTLANE_PASSWORD=#{value}" + "password" | "pass7" | 'Object' | true | "FASTLANE_PASSWORD=#{value}" + "password" | "pass8" | 'Object' | false | "FASTLANE_PASSWORD=#{value}" + + "skipSubmission" | true | 'Boolean' | true | "--skip_submission true" + "skipSubmission" | true | 'Boolean' | false | "--skip_submission true" + "skipSubmission" | true | 'Closure' | true | "--skip_submission true" + "skipSubmission" | true | 'Closure' | false | "--skip_submission true" + "skipSubmission" | true | 'Callable' | true | "--skip_submission true" + "skipSubmission" | true | 'Callable' | false | "--skip_submission true" + "skipSubmission" | false | 'Boolean' | true | "--skip_submission false" + "skipSubmission" | false | 'Boolean' | false | "--skip_submission false" + "skipSubmission" | false | 'Closure' | true | "--skip_submission false" + "skipSubmission" | false | 'Closure' | false | "--skip_submission false" + "skipSubmission" | false | 'Callable' | true | "--skip_submission false" + "skipSubmission" | false | 'Callable' | false | "--skip_submission false" + + "skipWaitingForBuildProcessing" | true | 'Boolean' | true | "--skip_waiting_for_build_processing true" + "skipWaitingForBuildProcessing" | true | 'Boolean' | false | "--skip_waiting_for_build_processing true" + "skipWaitingForBuildProcessing" | true | 'Closure' | true | "--skip_waiting_for_build_processing true" + "skipWaitingForBuildProcessing" | true | 'Closure' | false | "--skip_waiting_for_build_processing true" + "skipWaitingForBuildProcessing" | true | 'Callable' | true | "--skip_waiting_for_build_processing true" + "skipWaitingForBuildProcessing" | true | 'Callable' | false | "--skip_waiting_for_build_processing true" + "skipWaitingForBuildProcessing" | false | 'Boolean' | true | "--skip_waiting_for_build_processing false" + "skipWaitingForBuildProcessing" | false | 'Boolean' | false | "--skip_waiting_for_build_processing false" + "skipWaitingForBuildProcessing" | false | 'Closure' | true | "--skip_waiting_for_build_processing false" + "skipWaitingForBuildProcessing" | false | 'Closure' | false | "--skip_waiting_for_build_processing false" + "skipWaitingForBuildProcessing" | false | 'Callable' | true | "--skip_waiting_for_build_processing false" + "skipWaitingForBuildProcessing" | false | 'Callable' | false | "--skip_waiting_for_build_processing false" + + "ipa" | "build/out1/test.ipa" | 'String' | true | "--ipa #{value_path}" + "ipa" | "build/out2/test.ipa" | 'String' | false | "--ipa #{value_path}" + "ipa" | "build/out3/test.ipa" | 'File' | true | "--ipa #{value_path}" + "ipa" | "build/out4/test.ipa" | 'File' | false | "--ipa #{value_path}" + "ipa" | "build/out5/test.ipa" | 'Closure' | true | "--ipa #{value_path}" + "ipa" | "build/out6/test.ipa" | 'Closure' | false | "--ipa #{value_path}" + + taskToRun = "publishTestFlight" + value = wrapValueBasedOnType(rawValue, type) + method = (useSetter) ? "set${parameter.capitalize()}" : parameter + expectedCommandlineSwitch = expectedCommandlineSwitchRaw.replace("#{value}", rawValue.toString()) + } +} diff --git a/src/main/groovy/wooga/gradle/build/unity/ios/IOSBuildPlugin.groovy b/src/main/groovy/wooga/gradle/build/unity/ios/IOSBuildPlugin.groovy index eaa1ac1f..9b79ecc5 100644 --- a/src/main/groovy/wooga/gradle/build/unity/ios/IOSBuildPlugin.groovy +++ b/src/main/groovy/wooga/gradle/build/unity/ios/IOSBuildPlugin.groovy @@ -23,6 +23,7 @@ import org.gradle.api.Project import org.gradle.api.logging.Logger import org.gradle.api.logging.Logging import org.gradle.api.plugins.BasePlugin +import org.gradle.api.publish.plugins.PublishingPlugin import org.gradle.api.tasks.Sync import org.gradle.util.GUtil import wooga.gradle.build.unity.ios.internal.DefaultIOSBuildPluginExtension @@ -32,6 +33,7 @@ import wooga.gradle.build.unity.ios.tasks.ImportProvisioningProfile import wooga.gradle.build.unity.ios.tasks.KeychainTask import wooga.gradle.build.unity.ios.tasks.ListKeychainTask import wooga.gradle.build.unity.ios.tasks.LockKeychainTask +import wooga.gradle.build.unity.ios.tasks.PublishTestFlight import wooga.gradle.build.unity.ios.tasks.XCodeArchiveTask import wooga.gradle.build.unity.ios.tasks.XCodeExportTask @@ -50,6 +52,7 @@ class IOSBuildPlugin implements Plugin { } project.pluginManager.apply(BasePlugin.class) + project.pluginManager.apply(PublishingPlugin.class) def extension = project.getExtensions().create(IOSBuildPluginExtension, EXTENSION_NAME, DefaultIOSBuildPluginExtension.class) //register some defaults @@ -125,6 +128,18 @@ class IOSBuildPlugin implements Plugin { } }) + project.tasks.withType(PublishTestFlight.class, new Action() { + @Override + void execute(PublishTestFlight task) { + def conventionMapping = task.getConventionMapping() + conventionMapping.map("username", { extension.fastlaneCredentials.username }) + conventionMapping.map("password", { extension.fastlaneCredentials.password }) + conventionMapping.map("devPortalTeamId", { extension.getTeamId() }) + conventionMapping.map("appIdentifier", { extension.getAppIdentifier() }) + conventionMapping.map("ipa", { extension.getAppIdentifier() }) + } + }) + def projects = project.fileTree(project.projectDir) { it.include("*.xcodeproj/project.pbxproj") }.files projects.each { File xcodeProject -> def base = xcodeProject.parentFile @@ -132,7 +147,7 @@ class IOSBuildPlugin implements Plugin { if (projects.size() == 1) { taskNameBase = "" } - generateBuildTasks(taskNameBase, project, base) + generateBuildTasks(taskNameBase, project, base, extension) } } @@ -147,7 +162,7 @@ class IOSBuildPlugin implements Plugin { return "" } - void generateBuildTasks(final String baseName, final Project project, File xcodeProject) { + void generateBuildTasks(final String baseName, final Project project, File xcodeProject, IOSBuildPluginExtension extension) { def tasks = project.tasks def buildKeychain = tasks.create(maybeBaseName(baseName, "buildKeychain"), KeychainTask) { it.baseName = maybeBaseName(baseName, "build") @@ -196,6 +211,22 @@ class IOSBuildPlugin implements Plugin { it.xcarchivePath xcodeArchive } + def publishTestFlight = tasks.create(maybeBaseName(baseName, "publishTestFlight"), PublishTestFlight) { + it.ipa xcodeExport + it.group = PublishingPlugin.PUBLISH_TASK_GROUP + it.description = "Upload binary to TestFlightApp" + } + + project.afterEvaluate(new Action() { + @Override + void execute(Project _) { + if(extension.publishToTestFlight) { + def lifecyclePublishTask = tasks.getByName(PublishingPlugin.PUBLISH_LIFECYCLE_TASK_NAME) + lifecyclePublishTask.dependsOn(publishTestFlight) + } + } + }) + removeKeychain.mustRunAfter([xcodeArchive, xcodeExport]) lockKeychain.mustRunAfter([xcodeArchive, xcodeExport]) diff --git a/src/main/groovy/wooga/gradle/build/unity/ios/IOSBuildPluginExtension.groovy b/src/main/groovy/wooga/gradle/build/unity/ios/IOSBuildPluginExtension.groovy index b857a767..bac57e83 100644 --- a/src/main/groovy/wooga/gradle/build/unity/ios/IOSBuildPluginExtension.groovy +++ b/src/main/groovy/wooga/gradle/build/unity/ios/IOSBuildPluginExtension.groovy @@ -62,4 +62,8 @@ interface IOSBuildPluginExtension { void setAdhoc(Boolean value) IOSBuildPluginExtension adhoc(Boolean value) -} \ No newline at end of file + Boolean getPublishToTestFlight() + void setPublishToTestFlight(Boolean value) + IOSBuildPluginExtension publishToTestFlight(Boolean value) + +} diff --git a/src/main/groovy/wooga/gradle/build/unity/ios/internal/DefaultIOSBuildPluginExtension.groovy b/src/main/groovy/wooga/gradle/build/unity/ios/internal/DefaultIOSBuildPluginExtension.groovy index 2b45cba2..5f3c8ee6 100644 --- a/src/main/groovy/wooga/gradle/build/unity/ios/internal/DefaultIOSBuildPluginExtension.groovy +++ b/src/main/groovy/wooga/gradle/build/unity/ios/internal/DefaultIOSBuildPluginExtension.groovy @@ -33,6 +33,7 @@ class DefaultIOSBuildPluginExtension implements IOSBuildPluginExtension { private String configuration private String provisioningName private Boolean adhoc = false + private Boolean publishToTestFlight = false @Override org.gradle.api.credentials.PasswordCredentials getFastlaneCredentials() { @@ -192,6 +193,22 @@ class DefaultIOSBuildPluginExtension implements IOSBuildPluginExtension { return this } + @Override + Boolean getPublishToTestFlight() { + return publishToTestFlight + } + + @Override + void setPublishToTestFlight(Boolean value) { + publishToTestFlight = value + } + + @Override + IOSBuildPluginExtension publishToTestFlight(Boolean value) { + setPublishToTestFlight(value) + return this + } + DefaultIOSBuildPluginExtension() { fastlaneCredentials = new DefaultPasswordCredentials() } diff --git a/src/main/groovy/wooga/gradle/build/unity/ios/tasks/PublishTestFlight.groovy b/src/main/groovy/wooga/gradle/build/unity/ios/tasks/PublishTestFlight.groovy new file mode 100644 index 00000000..73702f41 --- /dev/null +++ b/src/main/groovy/wooga/gradle/build/unity/ios/tasks/PublishTestFlight.groovy @@ -0,0 +1,293 @@ +package wooga.gradle.build.unity.ios.tasks + +import org.gradle.api.file.FileCollection +import org.gradle.api.internal.ConventionTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.SkipWhenEmpty +import org.gradle.api.tasks.TaskAction + +import java.util.concurrent.Callable + +class PublishTestFlight extends ConventionTask { + private Object ipa + + @SkipWhenEmpty + @InputFiles + protected FileCollection getInputFiles() { + project.files(ipa) + } + + @InputFile + /** + * -i, --ipa STRING Path to the ipa file to upload (PILOT_IPA) + */ + File getIpa() { + project.files(ipa).singleFile + } + + void setIpa(Object value) { + ipa = value + } + + PublishTestFlight ipa(Object ipa) { + setIpa(ipa) + this + } + + private Object appIdentifier + + @Optional + @Input + String getAppIdentifier() { + convertToString(appIdentifier) + } + + void setAppIdentifier(Object value) { + appIdentifier = value + } + + PublishTestFlight appIdentifier(Object appIdentifier) { + setAppIdentifier(appIdentifier) + this + } + + private Object username + + @Optional + @Input + String getUsername() { + convertToString(username) + } + + void setUsername(Object value) { + username = value + } + + PublishTestFlight username(Object username) { + setUsername(username) + this + } + + private Object password + + @Optional + @Input + String getPassword() { + convertToString(password) + } + + void setPassword(Object value) { + password = value + } + + PublishTestFlight password(Object password) { + setPassword(password) + this + } + + private Object devPortalTeamId + + @Optional + @Input + String getDevPortalTeamId() { + convertToString(devPortalTeamId) + } + + void setDevPortalTeamId(Object value) { + devPortalTeamId = value + } + + PublishTestFlight devPortalTeamId(Object value) { + setDevPortalTeamId(value) + this + } + + private Object itcProvider + + @Optional + @Input + String getItcProvider() { + convertToString(itcProvider) + } + + void setItcProvider(Object value) { + itcProvider = value + } + + PublishTestFlight itcProvider(Object value) { + setItcProvider(value) + this + } + + private Object teamId + + @Optional + @Input + String getTeamId() { + convertToString(teamId) + } + + void setTeamId(Object value) { + teamId = value + } + + PublishTestFlight teamId(Object value) { + setTeamId(value) + this + } + + private Object teamName + + @Optional + @Input + String getTeamName() { + convertToString(teamName) + } + + void setTeamName(Object value) { + teamName = value + } + + PublishTestFlight teamName(Object value) { + setTeamName(value) + this + } + + private Object skipSubmission + + @Optional + @Input + /** + * Skip the distributing action of pilot and only upload the ipa file (PILOT_SKIP_SUBMISSION) + */ + Boolean getSkipSubmission() { + convertToBoolean(skipSubmission) + } + + void setSkipSubmission(Object value) { + skipSubmission = value + } + + PublishTestFlight skipSubmission(Object value) { + setSkipSubmission(value) + this + } + + private Object skipWaitingForBuildProcessing + + @Optional + @Input + /** + * Don't wait for the build to process. + * + * -z, --skip_waiting_for_build_processing [VALUE] If set to true, the changelog won't be set, `distribute_external` + * option won't work and no build will be distributed to testers. + * (You might want to use this option if you are using this action on CI and have to pay for + * 'minutes used' on your CI plan) (PILOT_SKIP_WAITING_FOR_BUILD_PROCESSING) + */ + Boolean getSkipWaitingForBuildProcessing() { + convertToBoolean(skipWaitingForBuildProcessing) + } + + void setSkipWaitingForBuildProcessing(Object value) { + skipWaitingForBuildProcessing = value + } + + PublishTestFlight skipWaitingForBuildProcessing(Object value) { + setSkipWaitingForBuildProcessing(value) + this + } + + PublishTestFlight() { + super() + outputs.upToDateWhen {false} + } + + /** + * Finds path to executable in PATH. + * + * This function is aimed to make the whole task testable. + * The tests can override the PATH environment variable and + * point to a mock executable. + * + * @param executableName the name of the executable to find in PATH + * @return path to executable or executableName + */ + private static String getExecutable(String executableName) { + def path = System.getenv("PATH").split(File.pathSeparator) + .collect {path -> new File(path, "fastlane")} + .find {path -> path.exists() && path.isFile() && path.canExecute()} + path? path.path : executableName + } + + @TaskAction + protected void publishTestFlight() { + def executablePath = getExecutable("fastlane") + project.exec { + executable executablePath + args "pilot", "upload" + def pw = getPassword() + + if (pw) { + environment('FASTLANE_PASSWORD', pw) + } + + if (getUsername()) { + args "--username", getUsername() + } + + if(getDevPortalTeamId()) { + args "--dev_portal_team_id", getDevPortalTeamId() + } + + if(getTeamId()) { + args "--team_id", getTeamId() + } + + if(getTeamName()) { + args "--team_name", getTeamName() + } + + if(getAppIdentifier()) { + args "--app_identifier", getAppIdentifier() + } + + if(getItcProvider()) { + args "--itc_provider", getItcProvider() + } + + args "--skip_submission", getSkipSubmission() + args "--skip_waiting_for_build_processing", getSkipWaitingForBuildProcessing() + + args "--ipa", getIpa().path + } + } + + + private static Boolean convertToBoolean(Object value) { + if (!value) { + return false + } + + if (value instanceof Callable) { + value = ((Callable) value).call() + } + + value + } + + private static String convertToString(Object value) { + if (!value) { + return null + } + + if (value instanceof Callable) { + value = ((Callable) value).call() + } + + value.toString() + } +} diff --git a/src/test/groovy/wooga/gradle/build/unity/ios/IOSBuildPluginSpec.groovy b/src/test/groovy/wooga/gradle/build/unity/ios/IOSBuildPluginSpec.groovy index 882c9af2..384cd79d 100644 --- a/src/test/groovy/wooga/gradle/build/unity/ios/IOSBuildPluginSpec.groovy +++ b/src/test/groovy/wooga/gradle/build/unity/ios/IOSBuildPluginSpec.groovy @@ -1,11 +1,21 @@ package wooga.gradle.build.unity.ios import nebula.test.ProjectSpec +import org.gradle.api.DefaultTask +import org.gradle.api.Task import spock.lang.Requires +import spock.lang.Shared import spock.lang.Unroll import wooga.gradle.build.unity.ios.internal.DefaultIOSBuildPluginExtension +import wooga.gradle.build.unity.ios.tasks.ImportProvisioningProfile +import wooga.gradle.build.unity.ios.tasks.KeychainTask +import wooga.gradle.build.unity.ios.tasks.ListKeychainTask +import wooga.gradle.build.unity.ios.tasks.LockKeychainTask +import wooga.gradle.build.unity.ios.tasks.PublishTestFlight +import wooga.gradle.build.unity.ios.tasks.XCodeArchiveTask +import wooga.gradle.build.unity.ios.tasks.XCodeExportTask -@Requires({os.macOs}) +@Requires({ os.macOs }) class IOSBuildPluginSpec extends ProjectSpec { public static final String PLUGIN_NAME = 'net.wooga.build-unity-ios' @@ -22,6 +32,149 @@ class IOSBuildPluginSpec extends ProjectSpec { extension instanceof DefaultIOSBuildPluginExtension } + @Unroll("creates the task #taskName") + def 'Creates needed tasks'(String taskName, Class taskType) { + given: + assert !project.plugins.hasPlugin(PLUGIN_NAME) + assert !project.tasks.findByName(taskName) + + when: + project.plugins.apply(PLUGIN_NAME) + def task + project.afterEvaluate { + task = project.tasks.findByName(taskName) + } + + then: + project.evaluate() + taskType.isInstance(task) + + where: + taskName | taskType + "publish" | DefaultTask + "assemble" | DefaultTask + "build" | DefaultTask + "check" | DefaultTask + } + + /* + xcProject = new File(projectDir, "test.xcodeproj") + xcProject.mkdirs() + xcProjectConfig = new File(xcProject, "project.pbxproj") + xcProjectConfig << "" + */ + + @Shared + File xcProject + + @Shared + File xcProjectConfig + + @Unroll() + def 'Creates xcode task :#taskName when project contains single xcode project'(String taskName, Class taskType) { + given: + assert !project.plugins.hasPlugin(PLUGIN_NAME) + assert !project.tasks.findByName(taskName) + + and: "a dummpy xcode project" + xcProject = new File(projectDir, "test.xcodeproj") + xcProject.mkdirs() + xcProjectConfig = new File(xcProject, "project.pbxproj") + xcProjectConfig << "" + + when: + project.plugins.apply(PLUGIN_NAME) + def task + project.afterEvaluate { + task = project.tasks.findByName(taskName) + } + + then: + project.evaluate() + taskType.isInstance(task) + + where: + taskName | taskType + "buildKeychain" | KeychainTask + "unlockKeychain" | LockKeychainTask + "lockKeychain" | LockKeychainTask + "addKeychain" | ListKeychainTask + "removeKeychain" | ListKeychainTask + "importProvisioningProfiles" | ImportProvisioningProfile + "xcodeArchive" | XCodeArchiveTask + "xcodeExport" | XCodeExportTask + "publishTestFlight" | PublishTestFlight + } + + @Unroll() + def 'Creates xcode tasks :#taskNames when project contains multiple xcode projects'() { + given: "a dummpy xcode project" + xcodeProjectNames.each { + xcProject = new File(projectDir, "${it}.xcodeproj") + xcProject.mkdirs() + xcProjectConfig = new File(xcProject, "project.pbxproj") + xcProjectConfig << "" + } + + when: + project.plugins.apply(PLUGIN_NAME) + List tasks + project.afterEvaluate { + tasks = taskNames.collect { project.tasks.findByName(it) } + } + + then: + project.evaluate() + tasks.every { taskType.isInstance(it) } + + where: + taskName | taskType + "buildKeychain" | KeychainTask + "unlockKeychain" | LockKeychainTask + "lockKeychain" | LockKeychainTask + "addKeychain" | ListKeychainTask + "removeKeychain" | ListKeychainTask + "importProvisioningProfiles" | ImportProvisioningProfile + "xcodeArchive" | XCodeArchiveTask + "xcodeExport" | XCodeExportTask + "publishTestFlight" | PublishTestFlight + + xcodeProjectNames = ["first", "second", "third"] + taskNames = ["first", "second", "third"].collect { it + taskName.capitalize() } + } + + @Unroll() + def "task :#taskName #message on task :#dependedTask when publishToTestflight is #publishToTestflight"() { + given: "a dummpy xcode project" + xcProject = new File(projectDir, "test.xcodeproj") + xcProject.mkdirs() + xcProjectConfig = new File(xcProject, "project.pbxproj") + xcProjectConfig << "" + + and: "a project with property set" + project.plugins.apply(PLUGIN_NAME) + IOSBuildPluginExtension extension = project.extensions.findByName(IOSBuildPlugin.EXTENSION_NAME) as IOSBuildPluginExtension + extension.publishToTestFlight(publishToTestflight) + + and: "a dummpy xcode project" + xcProject = new File(projectDir, "test.xcodeproj") + xcProject.mkdirs() + xcProjectConfig = new File(xcProject, "project.pbxproj") + xcProjectConfig << "" + + expect: + project.evaluate() + def task1 = project.tasks.getByName(taskName) + def task2 = project.tasks.getByName(dependedTask) + task1.dependsOn.contains(task2) == dependsOnTask + + where: + taskName | dependedTask | publishToTestflight | dependsOnTask + "publish" | "publishTestFlight" | true | true + "publish" | "publishTestFlight" | false | false + message = (dependsOnTask) ? "depends" : "depends not" + } + @Unroll def 'extension returns #defaultValue value for property #property'() { given: @@ -43,5 +196,6 @@ class IOSBuildPluginSpec extends ProjectSpec { "configuration" | null "provisioningName" | null "adhoc" | false + "publishToTestFlight" | false } }