diff --git a/Jenkinsfile b/Jenkinsfile index 15a34d8..9ac1a48 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,11 +3,18 @@ withCredentials([string(credentialsId: 'atlas_hockey_integration_api_token', variable: 'hockeyToken'), string(credentialsId: 'atlas_hockey_integration_application_identifier', variable: 'hockeyAppId'), - string(credentialsId: 'atlas_hockey_coveralls_token', variable: 'coveralls_token')]) { + string(credentialsId: 'atlas_hockey_integration_appcenter_token', variable: 'appcenterToken'), + string(credentialsId: 'atlas_hockey_integration_appcenter_application_identifier', variable: 'appcenterAppId'), + string(credentialsId: 'atlas_hockey_integration_appcenter_application_owner', variable: 'appcenterOwner'), + string(credentialsId: 'atlas_hockey_coveralls_token', variable: 'coveralls_token') + ]) { def testEnvironment = [ "ATLAS_HOCKEY_INTEGRATION_API_TOKEN=${hockeyToken}", - "ATLAS_HOCKEY_INTEGRATION_APPLICATION_IDENTIFIER=${hockeyAppId}" + "ATLAS_HOCKEY_INTEGRATION_APPLICATION_IDENTIFIER=${hockeyAppId}", + "ATLAS_APP_CENTER_INTEGRATION_API_TOKEN=${appcenterToken}", + "ATLAS_APP_CENTER_OWNER=${appcenterOwner}", + "ATLAS_APP_CENTER_INTEGRATION_APPLICATION_IDENTIFIER=${appcenterAppId}" ] buildGradlePlugin plaforms: ['osx', 'windows', 'linux'], coverallsToken: coveralls_token, testEnvironment: testEnvironment diff --git a/src/integrationTest/groovy/wooga/gradle/hockey/tasks/AppCenterUploadTaskIntegrationSpec.groovy b/src/integrationTest/groovy/wooga/gradle/hockey/tasks/AppCenterUploadTaskIntegrationSpec.groovy new file mode 100644 index 0000000..d9a4436 --- /dev/null +++ b/src/integrationTest/groovy/wooga/gradle/hockey/tasks/AppCenterUploadTaskIntegrationSpec.groovy @@ -0,0 +1,234 @@ +/* + * Copyright 2019 Wooga GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package wooga.gradle.hockey.tasks + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import org.apache.http.HttpResponse +import org.apache.http.client.HttpClient +import org.apache.http.client.methods.HttpGet +import org.apache.http.client.methods.HttpPost +import org.apache.http.client.methods.HttpDelete +import org.apache.http.entity.ContentType +import org.apache.http.entity.StringEntity +import org.apache.http.impl.client.HttpClientBuilder +import wooga.gradle.hockey.HockeyPlugin +import wooga.gradle.hockey.IntegrationSpec + +class AppCenterUploadTaskIntegrationSpec extends IntegrationSpec { + static String apiToken = System.env["ATLAS_APP_CENTER_INTEGRATION_API_TOKEN"] + static String owner = System.env["ATLAS_APP_CENTER_OWNER"] + static String applicationIdentifier = System.env["ATLAS_APP_CENTER_INTEGRATION_APPLICATION_IDENTIFIER"] + + def setup() { + buildFile << """ + version = "0.1.0" + ${applyPlugin(HockeyPlugin)} + publishAppCenter { + owner = "$owner" + apiToken = "$apiToken" + applicationIdentifier = "$applicationIdentifier" + } + """.stripIndent() + } + + def "uploads dummy ipa to AppCenter successfully"() { + given: "a dummy ipa binary to upload" + + def testFile = getClass().getClassLoader().getResource("test.ipa").path + buildFile << """ + publishAppCenter.binary = "$testFile" + """.stripIndent() + + expect: + runTasksSuccessfully("publishAppCenter") + } + + def "writes json file with uploaded version meta data"() { + given: "a dummy ipa binary to upload" + def testFile = getClass().getClassLoader().getResource("test.ipa").path + buildFile << """ + publishAppCenter.binary = "$testFile" + """.stripIndent() + + and: "a future version meta file" + def versionMeta = new File(projectDir,"build/tmp/publishAppCenter/${owner}_${applicationIdentifier}.json") + assert !versionMeta.exists() + + when: + runTasksSuccessfully("publishAppCenter") + + then: + versionMeta.exists() + } + + def "publishes to Collaborators group when no groups are configured"() { + given: "a dummy ipa binary to upload" + def testFile = getClass().getClassLoader().getResource("test.ipa").path + buildFile << """ + publishAppCenter.binary = "$testFile" + """.stripIndent() + + and: "a future version meta file" + def versionMeta = new File(projectDir,"build/tmp/publishAppCenter/${owner}_${applicationIdentifier}.json") + assert !versionMeta.exists() + + and: "no configured distribution groups" + + when: + runTasksSuccessfully("publishAppCenter") + + then: + def release = getRelease(versionMeta) + def destinations = release["destinations"] + destinations.any {it["name"] == "Collaborators"} + } + + def "can publish to custom distribution groups"() { + given: "a new distribution group" + ensureDistributionGroup("Test") + ensureDistributionGroup("Test2") + + buildFile << """ + publishAppCenter.destination "Test", "Test2" + """.stripIndent() + + and: "a dummy ipa binary to upload" + def testFile = getClass().getClassLoader().getResource("test.ipa").path + buildFile << """ + publishAppCenter.binary = "$testFile" + """.stripIndent() + + and: "a future version meta file" + def versionMeta = new File(projectDir,"build/tmp/publishAppCenter/${owner}_${applicationIdentifier}.json") + assert !versionMeta.exists() + + when: + runTasksSuccessfully("publishAppCenter") + + then: + def release = getRelease(versionMeta) + def destinations = release["destinations"] + destinations.any {it["name"] == "Test" || it["name"] == "Test2"} + !destinations.any {it["name"] == "Collaborators"} + } + + def "fails when distribution group is invalid"() { + given: "a publish task with invalid distribution groups" + buildFile << """ + publishAppCenter.destination "some value", "some other group" + """.stripIndent() + + and: "a dummy ipa binary to upload" + def testFile = getClass().getClassLoader().getResource("test.ipa").path + buildFile << """ + publishAppCenter.binary = "$testFile" + """.stripIndent() + + expect: + runTasksWithFailure("publishAppCenter") + } + + def "can publish custom build infos"() { + given: "publish task with build infos" + buildFile << """ + publishAppCenter.buildInfo { + branchName = "master" + commitHash = "000000000000" + commitMessage = "Fix tests" + } + """.stripIndent() + + and: "a dummy ipa binary to upload" + def testFile = getClass().getClassLoader().getResource("test.ipa").path + buildFile << """ + publishAppCenter.binary = "$testFile" + """.stripIndent() + + and: "a future version meta file" + def versionMeta = new File(projectDir,"build/tmp/publishAppCenter/${owner}_${applicationIdentifier}.json") + assert !versionMeta.exists() + + when: + runTasksSuccessfully("publishAppCenter") + + then: + versionMeta.exists() + def jsonSlurper = new JsonSlurper() + def releaseMeta = jsonSlurper.parse(versionMeta) + + def releaseId = releaseMeta["release_id"] + def release = getRelease(releaseId) + + def buildInfo = release["build"] + buildInfo["branch_name"] == "master" + buildInfo["commit_hash"] == "000000000000" + buildInfo["commit_message"] == "Fix tests" + } + + void deleteDistributionGroup(String name) { + HttpClient client = HttpClientBuilder.create().build() + HttpDelete request = new HttpDelete("https://api.appcenter.ms/v0.1/apps/${owner}/${applicationIdentifier}/distribution_groups/${URLEncoder.encode(name,"UTF-8")}") + + request.setHeader("Accept", 'application/json') + request.setHeader("X-API-Token", apiToken) + + HttpResponse response = client.execute(request) + + if (response.statusLine.statusCode != 204) { + throw new Exception("Failed to delete distribution group") + } + } + + void ensureDistributionGroup(String name) { + HttpClient client = HttpClientBuilder.create().build() + HttpPost request = new HttpPost("https://api.appcenter.ms/v0.1/apps/${owner}/${applicationIdentifier}/distribution_groups") + + request.setHeader("Accept", 'application/json') + request.setHeader("X-API-Token", apiToken) + + def body = ["name": name] + request.setEntity(new StringEntity(JsonOutput.toJson(body), ContentType.APPLICATION_JSON)) + + HttpResponse response = client.execute(request) + + if (response.statusLine.statusCode != 201 && response.statusLine.statusCode != 409) { + throw new Exception("Failed to create distribution group") + } + } + + Map getRelease(File versionMeta) { + def jsonSlurper = new JsonSlurper() + def releaseMeta = jsonSlurper.parse(versionMeta) + + String releaseId = releaseMeta["release_id"] + getRelease(releaseId) + } + + Map getRelease(String releaseId) { + HttpClient client = HttpClientBuilder.create().build() + HttpGet request = new HttpGet("https://api.appcenter.ms/v0.1/apps/${owner}/${applicationIdentifier}/releases/${releaseId}") + + request.setHeader("Accept", 'application/json') + request.setHeader("X-API-Token", apiToken) + + HttpResponse response = client.execute(request) + def jsonSlurper = new JsonSlurper() + jsonSlurper.parseText(response.entity.content.text) as Map + } +} diff --git a/src/main/groovy/wooga/gradle/hockey/HockeyPlugin.groovy b/src/main/groovy/wooga/gradle/hockey/HockeyPlugin.groovy index 2f4c970..96b2594 100644 --- a/src/main/groovy/wooga/gradle/hockey/HockeyPlugin.groovy +++ b/src/main/groovy/wooga/gradle/hockey/HockeyPlugin.groovy @@ -16,15 +16,20 @@ package wooga.gradle.hockey +import org.gradle.api.Action import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.publish.plugins.PublishingPlugin +import wooga.gradle.hockey.tasks.AppCenterUploadTask import wooga.gradle.hockey.tasks.HockeyUploadTask class HockeyPlugin implements Plugin { - static final String TASK_NAME = "publishHockey" - static final String TASK_DESCRIPTION = "Upload binary to HockeyApp." + static final String PUBLISH_HOCKEY_TASK_NAME = "publishHockey" + static final String PUBLISH_HOCKEY_TASK_DESCRIPTION = "Upload binary to HockeyApp." + + static final String PUBLISH_APP_CENTER_TASK_NAME = "publishAppCenter" + static final String PUBLISH_APP_CENTER_TASK_DESCRIPTION = "Upload binary to AppCenter." @Override void apply(Project project) { @@ -33,10 +38,22 @@ class HockeyPlugin implements Plugin { def tasks = project.tasks - def publishHockey = tasks.create(name: TASK_NAME, type: HockeyUploadTask, group: PublishingPlugin.PUBLISH_TASK_GROUP) - publishHockey.description = TASK_DESCRIPTION + def publishHockey = tasks.create(name: PUBLISH_HOCKEY_TASK_NAME, type: HockeyUploadTask, group: PublishingPlugin.PUBLISH_TASK_GROUP) + publishHockey.description = PUBLISH_HOCKEY_TASK_DESCRIPTION + + def publishAppCenter = tasks.create(name: PUBLISH_APP_CENTER_TASK_NAME, type: AppCenterUploadTask, group: PublishingPlugin.PUBLISH_TASK_GROUP) + publishAppCenter.description = PUBLISH_APP_CENTER_TASK_DESCRIPTION + + tasks.withType(AppCenterUploadTask, new Action() { + @Override + void execute(AppCenterUploadTask t) { + def conventionMapping = t.getConventionMapping() + conventionMapping.map("buildVersion", {project.version}) + conventionMapping.map("destinations", {[["name": "Collaborators"]]}) + } + }) def lifecyclePublishTask = tasks.getByName(PublishingPlugin.PUBLISH_LIFECYCLE_TASK_NAME) - lifecyclePublishTask.dependsOn(publishHockey) + lifecyclePublishTask.dependsOn(publishHockey, publishHockey) } } diff --git a/src/main/groovy/wooga/gradle/hockey/api/AppCenterBuildInfo.groovy b/src/main/groovy/wooga/gradle/hockey/api/AppCenterBuildInfo.groovy new file mode 100644 index 0000000..d325d85 --- /dev/null +++ b/src/main/groovy/wooga/gradle/hockey/api/AppCenterBuildInfo.groovy @@ -0,0 +1,33 @@ +/* + * Copyright 2019 Wooga GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package wooga.gradle.hockey.api + +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional + +class AppCenterBuildInfo { + @Input + @Optional + String branchName + @Input + @Optional + String commitHash + @Input + @Optional + String commitMessage +} diff --git a/src/main/groovy/wooga/gradle/hockey/tasks/AppCenterUploadTask.groovy b/src/main/groovy/wooga/gradle/hockey/tasks/AppCenterUploadTask.groovy new file mode 100644 index 0000000..739b6dd --- /dev/null +++ b/src/main/groovy/wooga/gradle/hockey/tasks/AppCenterUploadTask.groovy @@ -0,0 +1,341 @@ +/* + * Copyright 2019 Wooga GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package wooga.gradle.hockey.tasks + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import org.apache.http.HttpEntity +import org.apache.http.HttpResponse +import org.apache.http.client.HttpClient +import org.apache.http.client.methods.HttpPatch +import org.apache.http.client.methods.HttpPost +import org.apache.http.entity.ContentType +import org.apache.http.entity.StringEntity +import org.apache.http.entity.mime.MultipartEntityBuilder +import org.apache.http.entity.mime.content.FileBody +import org.apache.http.impl.client.HttpClientBuilder +import org.gradle.api.Action +import org.gradle.api.GradleException +import org.gradle.api.file.FileCollection +import org.gradle.api.internal.ConventionTask +import org.gradle.api.tasks.* +import org.gradle.internal.impldep.com.google.gson.JsonObject +import wooga.gradle.hockey.api.AppCenterBuildInfo +import static org.gradle.util.ConfigureUtil.configureUsing + +import java.util.concurrent.Callable + +class AppCenterUploadTask extends ConventionTask { + + private Object apiToken + + @Input + String getApiToken() { + convertToString(apiToken) + } + + void setApiToken(Object value) { + apiToken = value + } + + HockeyUploadTask apiToken(Object apiToken) { + setApiToken(apiToken) + this + } + + private Object owner + + @Input + String getOwner() { + convertToString(owner) + } + + void setOwner(Object value) { + owner = value + } + + AppCenterUploadTask owner(Object owner) { + setOwner(owner) + this + } + + private Object buildVersion + + @Input + String getBuildVersion() { + convertToString(buildVersion) + } + + void setBuildVersion(Object value) { + buildVersion = value + } + + AppCenterUploadTask buildVersion(Object buildVersion) { + setBuildVersion(buildVersion) + this + } + + private Object releaseId + + @Optional + @Input + int getReleaseId() { + if (!releaseId) { + return 0 + } + + Integer.parseInt(convertToString(releaseId)) + } + + void setReleaseId(Object value) { + releaseId = value + } + + AppCenterUploadTask releaseId(Object releaseId) { + setReleaseId(releaseId) + this + } + + private Object applicationIdentifier + + @Input + String getApplicationIdentifier() { + convertToString(applicationIdentifier) + } + + void setApplicationIdentifier(Object value) { + applicationIdentifier = value + } + + HockeyUploadTask applicationIdentifier(Object applicationIdentifier) { + setApplicationIdentifier(applicationIdentifier) + this + } + + private List> destinations = new ArrayList<>() + + @Input + protected List> getDestinations() { + destinations + } + + void destination(String name) { + destinations.add(["name": name]) + } + + void destination(Iterable destinations) { + this.destinations.addAll(destinations.collect {["name": it]}) + } + + void destination(String... destinations) { + this.destinations.addAll(destinations.collect {["name": it]}) + } + + void destinationId(String id) { + destinations.add(["id": id]) + } + + private AppCenterBuildInfo buildInfo = new AppCenterBuildInfo() + + @Nested + protected AppCenterBuildInfo getBuildInfo() { + buildInfo + } + + void buildInfo(Closure closure) { + buildInfo(configureUsing(closure)) + } + + void buildInfo(Action action) { + action.execute(buildInfo) + } + + private Object binary + + @SkipWhenEmpty + @InputFiles + protected FileCollection getInputFiles() { + if (!binary) { + return project.files() + } + return project.files(binary) + } + + File getBinary() { + + def files = getInputFiles() + if (files.size() > 0) { + return files.getSingleFile() + } + return null + } + + void setBinary(Object value) { + binary = value + } + + HockeyUploadTask binary(Object binary) { + setBinary(binary) + this + } + + @OutputFiles + protected FileCollection getOutputFiles() { + return project.files(getUploadVersionMetaData()) + } + + File getUploadVersionMetaData() { + new File(temporaryDir, "${getOwner()}_${getApplicationIdentifier()}.json") + } + + private static Map createUploadResource(HttpClient client, String owner, String applicationIdentifier, String apiToken, String buildVersion, int releaseId = 0) { + // curl -X POST --header 'Content-Type: application/json' --header 'Accept: application/json' + // --header 'X-API-Token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' + // 'https://api.appcenter.ms/v0.1/apps/JoshuaWeber/APIExample/release_uploads' + + def uri = "https://api.appcenter.ms/v0.1/apps/${owner}/${applicationIdentifier}/release_uploads" + HttpPost post = new HttpPost(uri) + post.setHeader("Accept", 'application/json') + post.setHeader("X-API-Token", apiToken) + + + def body = ["build_version": buildVersion, "release_id": releaseId] + + post.setEntity(new StringEntity(JsonOutput.toJson(body), ContentType.APPLICATION_JSON)) + + HttpResponse response = client.execute(post) + if (response.statusLine.statusCode != 201) { + throw new GradleException("unable to create upload resource for ${owner}/${applicationIdentifier}") + } + + def jsonSlurper = new JsonSlurper() + jsonSlurper.parseText(response.entity.content.text) as Map + } + + private static uploadResources(HttpClient client, String apiToken, String uploadUrl, File binary) { + HttpPost post = new HttpPost(uploadUrl) + FileBody ipa = new FileBody(binary) + post.setHeader("X-API-Token", apiToken) + HttpEntity content = MultipartEntityBuilder.create() + .addPart("ipa", ipa) + .build() + + post.setEntity(content) + HttpResponse response = client.execute(post) + + if (response.statusLine.statusCode != 204) { + throw new GradleException("unable to upload to provided upload url" + response.statusLine.toString()) + } + } + + private static Map commitResource(HttpClient client, String owner, String applicationIdentifier, String apiToken, String uploadId) { + // curl -X PATCH --header 'Content-Type: application/json' + // --header 'Accept: application/json' + // --header 'X-API-Token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' -d '{ "status": "committed" }' + // 'https://api.appcenter.ms/v0.1/apps/JoshuaWeber/APITesting/release_uploads/c18df340-069f-0135-3290-22000b559634' + // https://openapi.appcenter.ms/#/distribute/releaseUploads_complete + + def uri = "https://api.appcenter.ms/v0.1/apps/${owner}/${applicationIdentifier}/release_uploads/${uploadId}" + HttpPatch patch = new HttpPatch(uri) + patch.setHeader("Accept", 'application/json') + patch.setHeader("X-API-Token", apiToken) + + def body = ["status": "committed"] + patch.setEntity(new StringEntity(JsonOutput.toJson(body), ContentType.APPLICATION_JSON)) + + HttpResponse response = client.execute(patch) + if (response.statusLine.statusCode != 200) { + throw new GradleException("unable to commit upload resource ${uploadId} for ${owner}/${applicationIdentifier}") + } + + def jsonSlurper = new JsonSlurper() + jsonSlurper.parseText(response.entity.content.text) as Map + } + + private static void distribute(HttpClient client, String owner, String applicationIdentifier, String apiToken, String releaseId, List> destinations, AppCenterBuildInfo buildInfo) { + // curl -X PATCH --header 'Content-Type: application/json' + // --header 'Accept: application/json' + // --header 'X-API-Token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' + // -d '{ "destinations": [{"name":"QA Testers"}], "release_notes": "Example new release via the APIs" }' + // 'https://api.appcenter.ms/v0.1/apps/JoshuaWeber/APITesting/releases/2' + // + // https://openapi.appcenter.ms/#/distribute/releases_update + + def uri = "https://api.appcenter.ms/v0.1/apps/${owner}/${applicationIdentifier}/releases/${releaseId}" + HttpPatch patch = new HttpPatch(uri) + patch.setHeader("Accept", 'application/json') + patch.setHeader("X-API-Token", apiToken) + + def build = [:] + + if(buildInfo.branchName && !buildInfo.branchName.empty) { + build["branch_name"] = buildInfo.branchName + } + + if(buildInfo.commitHash && !buildInfo.commitHash.empty) { + build["commit_hash"] = buildInfo.commitHash + } + + if(buildInfo.commitMessage && !buildInfo.commitMessage.empty) { + build["commit_message"] = buildInfo.commitMessage + } + + def body = ["destinations": destinations, "build": build] + patch.setEntity(new StringEntity(JsonOutput.toJson(body), ContentType.APPLICATION_JSON)) + + HttpResponse response = client.execute(patch) + + if (response.statusLine.statusCode != 200) { + throw new GradleException("unable to distribute release ${releaseId} for ${owner}/${applicationIdentifier}") + } + } + + @TaskAction + protected void upload() { + HttpClient client = HttpClientBuilder.create().build() + def uploadResource = createUploadResource(client, getOwner(), getApplicationIdentifier(), getApiToken(), getBuildVersion(), getReleaseId()) + + String uploadUrl = uploadResource["upload_url"] + String uploadId = uploadResource["upload_id"] + + uploadResources(client, getApiToken(), uploadUrl, getBinary()) + + def resource = commitResource(client, getOwner(), getApplicationIdentifier(), getApiToken(), uploadId) + String releaseId = resource["release_id"].toString() + String releaseUrl = resource["release_url"].toString() + + distribute(client, getOwner(), getApplicationIdentifier(), getApiToken(), releaseId, getDestinations(), getBuildInfo()) + + logger.info("published to AppCenter release: ${releaseId}") + logger.info("release_url: ${releaseUrl}") + + getUploadVersionMetaData() << JsonOutput.prettyPrint(JsonOutput.toJson(resource)) + } + + private static String convertToString(Object value) { + if (!value) { + return null + } + + if (value instanceof Callable) { + value = ((Callable) value).call() + } + + value.toString() + } +}