diff --git a/README.md b/README.md index ed79cb23..91f4f80a 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,10 @@ In addition to the custom service endpoint, this extension also contributes the * [Google Play - Status Update](#google-play---status-update) - Allows you to update the status of an app that was previously released to the selected track. -### Google Play - Release +### Google Play Release -Allows you to release an update to your app on Google Play, and includes the following options: +Allows you to release an update to your app on Google Play: release app bundle or apk, attach obb or mapping file, update metadata. +Includes the following options: 1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), @@ -117,7 +118,129 @@ Allows you to release an update to your app on Google Play, and includes the fol ![Service Endpoint](images/auth-with-endpoint.png) Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. + Please also note that from the point of security it's preferrable to store it as [Secure file](https://docs.microsoft.com/azure/devops/pipelines/library/secure-files) and download using [Download Secure File task](https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/download-secure-file). + +2. **Application ID** *(String, Required)* - The unique package identifier (e.g. com.foo.myapp) of the bundle you want to release. + +3. **Action** *(String, Required)* - Action you want to take in the release. Available options are *Only update store listing*, *Upload single bundle*, *Upload single apk*, *Upload multiple apk/aab files*. + + ![Action Input Options](images/action-input.png) + +4. **Bundle Path** *(File path, Required if visible)* - Path or glob pattern to the bundle file you want to publish to the specified track. Only visible if `action` is *Upload single bundle*. + + ![Bundle Path](images/bundle-path.png) + +5. **APK Path** *(File path, Required if visible)* - Path or glob pattern to the APK file you want to publish to the specified track. Only visible if `action` is *Upload single apk*. + + ![APK Path](images/apk-path.png) + +6. **Bundle paths, APK paths** *(Multiline, Optional)* - Paths or glob patterns to the APK/AAB files you want to publish to the specified track. It's required that at least one APK/AAB is picked up from these inputs, otherwise the task will fail. Only visible if `action` is *Upload multiple apk/aab files*. + + ![APK/AAB Paths](images/apk-aab-paths.png) + +7. **Upload OBB for APK** *(Boolean, Optional)* - Whether or not to pick up OBB files for each of the specified APKs. Only visible if `action` is *Upload single apk* or *Upload multiple apk/aab files*. + + ![Attach OBB For APK](images/obb-for-apk.png) + +8. **Track** *(String, Required)* - Release track to publish the APK to. This input is editable but provides default options: *Internal test*, *Alpha*, *Beta*, *Production*. + + ![Track](images/track.png) + +9. **Update Metadata** *(Boolean, Optional)* - Allows automating metadata updates to the Google Play store by reading the contents of the `Metadata Root Directory`. + + ![Update Metadata](images/update-metadata.png) + +10. **Metadata Root Directory** *(String, Required if visible)* - Root directory for metadata related files. Becomes available after enabling the `Update Metadata` option. Expects a format similar to fastlane’s [supply tool](https://github.com/fastlane/fastlane/tree/master/supply#readme) which is summarized below: + +``` +$(Specified Directory) + └ $(languageCodes) + ├ full_description.txt + ├ short_description.txt + ├ title.txt + ├ video.txt + ├ images + | ├ featureGraphic.png || featureGraphic.jpg || featureGraphic.jpeg + | ├ icon.png || icon.jpg || icon.jpeg + | ├ promoGraphic.png || promoGraphic.jpg || promoGraphic.jpeg + | ├ tvBanner.png || tvBanner.jpg || tvBanner.jpeg + | ├ phoneScreenshots + | | └ *.png || *.jpg || *.jpeg + | ├ sevenInchScreenshots + | | └ *.png || *.jpg || *.jpeg + | ├ tenInchScreenshots + | | └ *.png || *.jpg || *.jpeg + | ├ tvScreenshots + | | └ *.png || *.jpg || *.jpeg + | └ wearScreenshots + | └ *.png || *.jpg || *.jpeg + └ changelogs + └ $(versioncodes).txt +``` + +11. **Release Notes** *(File path)* - Path to the file specifying the release notes for the release you are publishing. Only visible if `Update metadata` option is disabled. + + ![Release Notes](images/release-notes.png) +12. **Language Code** *(String, Optional)* - An IETF language tag identifying the language of the release notes as specified in the BCP-47 document. Default value is _en-US_. Only visible if `Update metadata` option is disabled. + +13. **Update Metadata** *(Boolean, Optional)* - Allows automating metadata updates to the Google Play store by leveraging the contents of the `Metadata Root Directory`. + + ![Update Metadata](images/update-metadata.png) + +#### Advanced Options + +1. **Set in-app update priority** *(Boolean, Optional)* - Enables to set in-app update priority. Not visible if `action` is *Only update store listing*. + + ![Update Priority](images/update-priority.png) + +2. **Update priority** *(Number, Required if visible)* - How strongly to recommend an update to the user. An integer value between 0 and 5, with 0 being the default and 5 being the highest priority. Only visible if `Set in-app update priority` is enabled. + +3. **Roll out release** *(Boolean, Optional)* - Allows to roll out the release to a percentage of users. Not visible if `action` is *Only update store listing*. + + ![Rollout Fraction](images/rollout-release.png) + +4. **User fraction** *(Number, Required if visible)* - The percentage of users to roll the specified APK out to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users). + +5. **Upload deobfuscation file** *(Boolean, Optional)* - Allows to attach your proguard mapping.txt file to your aab/apk. Only visible if `action` is *Upload single apk* or *Upload single bundle*. + + ![Mapping File](images/mapping-file.png) + +6. **Deobfuscation path** *(File path, Required if visible)* - The path to the proguard mapping.txt file to upload. Glob patterns are supported. Only visible if `Upload deobfuscation file` is enabled. + +7. **Send changes to review** *(Boolean, Optional)* - Select this option to send changes for review in GooglePlay Console. If changes are already sent for review automatically, you shouldn't select this option. + + ![Send Changes To Review](images/send-changes-to-review.png) + +8. **Release name** *(String, Optional)* - Allows to set meaningful release name that can be seen in your Google Play Console. It won't be visible to your users. + + ![Send Changes To Review](images/send-changes-to-review.png) + +9. **Replace version codes** *(String, Required)* - You may specify which APK version codes should be replaced in the track with this deployment. Available options are: *All*, *List* - comma separated list of version codes, *Regular expression* - a regular expression pattern to select a list of APK version codes to be removed from the track with this deployment, e.g. _.\\*12?(3|4)?5_ + + +10. **Replace Version Codes** *(String, Optional)* - Specify version codes to replace in the selected track with the new APKs/AABs: all, the comma separated list, or a regular expression pattern. Not visible if `action` is *Only update store listing*. + + ![Advanced Options](images/replace-version-codes.png) + +11. **Version Code List** *(String, Required if visible)* - The comma separated list of version codes to be removed from the track with this deployment. Only available if `Replace Version Codes` value is *List*. + +12. **Version Code Pattern** *(String, Required if visible)* - The regular expression pattern to select a list of version codes to be removed from the track with this deployment, e.g. .\*12?(3|4)?5. Only available if `Replace Version Codes` value is *Regular expression*. + +### Google Play - Release V3 (deprecated in favor of Google Play - Release V4) + +Allows you to release an update to your app on Google Play, and includes the following options: + +1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), + + ![JSON Auth File](images/auth-with-json-file.png) + + or configured within a service endpoint that you reference from the task (via the `Service Endpoint` authentication method). + + ![Service Endpoint](images/auth-with-endpoint.png) + + Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. + Please also note that from the point of security it's preferrable to store it as [Secure file](https://docs.microsoft.com/azure/devops/pipelines/library/secure-files) and download using [Download Secure File task](https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/download-secure-file). 2. **APK Path** *(File path, Required)* - Path to the APK file you want to publish to the specified track. @@ -127,7 +250,7 @@ Allows you to release an update to your app on Google Play, and includes the fol ![Track](images/track.png) -4. **Rollout Fraction** *(String, Required if visible)* - The percentage of users to roll the specified APK out to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users). This option is only available when the **Track** input is set to **Rollout**. +4. **Rollout Fraction** *(String, Required if visible)* - The percentage of users to roll the specified APK out to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users). ![Rollout Fraction](images/rollout-fraction.png) @@ -191,15 +314,17 @@ Allows you to promote a previously released APK from one track to another (e.g. ![Promote task](images/promote-task.png) -1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), or configured within a service endpoint that you reference from the task (via the `Service Endpoint` authentication method). Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. +1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), or configured within a service endpoint that you reference from the task (via the `Service Endpoint` authentication method). Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. Please note that from the point of security it's preferrable to store it as [Secure file](https://docs.microsoft.com/azure/devops/pipelines/library/secure-files) and download using [Download Secure File task](https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/download-secure-file). 2. **Package Name** *(String, Required)* - The unique package identifier (e.g. `com.foo.myapp`) that you wish to promote. +3. **Version Code** *(String, Optional)* - The version code of the apk (e.g. 123) that you whish to promote. If no version code is given, the latest version on the specified track will be promoted. + 3. **Source Track** *(Required, Required)* - The track you wish to promote your app from (e.g. `alpha`). This assumes that you previously released an update to this track, potentially using the [`Google Play - Release`](#google-play---release) task. 4. **Destination Track** *(Required, Required)* - The track you wish to promote your app to (e.g. `production`). -5. **Rollout Fraction** *(String, Required if visible)* - The percentage of users to roll the app out to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users). This option is only available when the **Destination Track** option is set to `Rollout`. If you use rollout, and want to be able to automate the process of increasing the rollout over time, refer to the `Google Play - Increase Rollout` task. +5. **Rollout Fraction** *(String, Required if visible)* - The percentage of users to roll the app out to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users). If you use rollout, and want to be able to automate the process of increasing the rollout over time, refer to the `Google Play - Increase Rollout` task. ### Google Play - Increase Rollout @@ -207,7 +332,7 @@ Allows you to increase the rollout percentage of an app that was previously rele ![Increase task](images/increase-task.png) -1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), or configured within a service endpoint that you reference from the task (via the `Service Endpoint` authentication method). Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. +1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), or configured within a service endpoint that you reference from the task (via the `Service Endpoint` authentication method). Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. Please note that from the point of security it's preferrable to store it as [Secure file](https://docs.microsoft.com/azure/devops/pipelines/library/secure-files) and download using [Download Secure File task](https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/download-secure-file). 2. **Package Name** *(String, Required)* - The unique package identifier (e.g. com.foo.myapp) of the app you wish to increase the rollout percentage for. @@ -231,7 +356,7 @@ Allows you to update the status of an app that was previously released to the se 5. **User Fraction** *(String, Optional)* - The new user fraction to update the rollout to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users, does not contain 0 and 1). If the input User Fraction is not specified, will maintain the current user fraction without updating (**Notice**: if you want to update the status to `inProgress` or `halted`, make sure current user fraction or the input User Fraction is specified). -### Google Play - Release Bundle +### Google Play - Release Bundle (deprecated in favor of Google Play - Release V4) Allows you to release an app bundle to Google Play, and includes the following options: @@ -309,22 +434,10 @@ $(Specified Directory) 3. **Version Code Pattern** *(String, Required if visible)* - The regular expression pattern to select a list of APK version codes to be removed from the track with this deployment, e.g. .*12?(3|4)?5 - -#### Advanced Options - -1. **Replace version codes** *(String, Required)* - You may specify which APK version codes should be replaced in the track with this deployment. Available options are: *All*, *List* - comma separated list of version codes, *Regular expression* - a regular expression pattern to select a list of APK version codes to be removed from the track with this deployment, e.g. _.\\*12?(3|4)?5_ - -2. **Send changes to review** *(Boolean, Optional)* - Select this option to add `changesNotSentForReview=true` query parameter and send changes for review in GooglePlay Console. [More info](https://developers.google.com/android-publisher/api-ref/rest/v3/edits/commit#query-parameters). - - If you are getting the following error `Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true`, select this option. - - Please be cautious when selecting this option, if the app is already sent for review automatically, you can get the error `Changes are sent for review automatically. The query parameter changesNotSentForReview must not be set`. - ## Contact Us * [Report an issue](https://github.com/Microsoft/google-play-vsts-extension/issues) Google Play and the Google Play logo are trademarks of Google Inc. - This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/Tasks/GooglePlayReleaseBundleV3/Strings/resources.resjson/en-US/resources.resjson b/Tasks/GooglePlayReleaseBundleV3/Strings/resources.resjson/en-US/resources.resjson index a088f327..aad24cc5 100644 --- a/Tasks/GooglePlayReleaseBundleV3/Strings/resources.resjson/en-US/resources.resjson +++ b/Tasks/GooglePlayReleaseBundleV3/Strings/resources.resjson/en-US/resources.resjson @@ -1,5 +1,5 @@ { - "loc.friendlyName": "Google Play - Release Bundle", + "loc.friendlyName": "Google Play - Release Bundle (deprecated)", "loc.description": "Release an app bundle to the Google Play Store", "loc.instanceNameFormat": "Release $(bundleFile) to $(track)", "loc.group.displayName.advanced": "Advanced Options", diff --git a/Tasks/GooglePlayReleaseBundleV3/package-lock.json b/Tasks/GooglePlayReleaseBundleV3/package-lock.json index 15c1c772..f573d444 100644 --- a/Tasks/GooglePlayReleaseBundleV3/package-lock.json +++ b/Tasks/GooglePlayReleaseBundleV3/package-lock.json @@ -9,7 +9,7 @@ "resolved": "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-1.6.0.tgz", "integrity": "sha1-OU2+C7X+5Gs42JZzXoto7yOQ0A0=", "requires": { - "@types/node": "10.17.51" + "@types/node": "*" } }, "@types/form-data": { @@ -17,7 +17,7 @@ "resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-0.0.33.tgz", "integrity": "sha1-yayFsqX9GENbjIXZ7LUObWyJP/g=", "requires": { - "@types/node": "10.17.51" + "@types/node": "*" } }, "@types/mocha": { @@ -40,7 +40,7 @@ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "requires": { - "event-target-shim": "5.0.1" + "event-target-shim": "^5.0.0" } }, "agent-base": { @@ -48,7 +48,7 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "requires": { - "debug": "4.3.1" + "debug": "4" } }, "arrify": { @@ -72,12 +72,12 @@ "integrity": "sha512-ZafpInsnjHKGemA5l3jwfeaI8jARrIv5aYCn3KPP4iNJHgMG2RtwyFGynIfpkUsHMfzC/370iyLru0NGAuSVWQ==", "requires": { "minimatch": "3.0.4", - "mockery": "1.7.0", - "q": "1.5.1", - "semver": "5.7.1", - "shelljs": "0.8.4", + "mockery": "^1.7.0", + "q": "^1.5.1", + "semver": "^5.1.0", + "shelljs": "^0.8.4", "sync-request": "6.1.0", - "uuid": "3.4.0" + "uuid": "^3.0.1" } }, "balanced-match": { @@ -100,7 +100,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "requires": { - "balanced-match": "1.0.0", + "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, @@ -119,8 +119,8 @@ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", "requires": { - "function-bind": "1.1.1", - "get-intrinsic": "1.1.1" + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" } }, "caseless": { @@ -133,7 +133,7 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "requires": { - "delayed-stream": "1.0.0" + "delayed-stream": "~1.0.0" } }, "concat-map": { @@ -146,10 +146,10 @@ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "requires": { - "buffer-from": "1.1.1", - "inherits": "2.0.4", - "readable-stream": "2.3.7", - "typedarray": "0.0.6" + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" } }, "core-util-is": { @@ -175,7 +175,7 @@ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "requires": { - "safe-buffer": "5.1.2" + "safe-buffer": "^5.0.1" } }, "event-target-shim": { @@ -198,9 +198,9 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", "requires": { - "asynckit": "0.4.0", - "combined-stream": "1.0.8", - "mime-types": "2.1.30" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" } }, "fs.realpath": { @@ -218,11 +218,11 @@ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-4.2.0.tgz", "integrity": "sha512-Ms7fNifGv0XVU+6eIyL9LB7RVESeML9+cMvkwGS70xyD6w2Z80wl6RiqiJ9k1KFlJCUTQqFFc8tXmPQfSKUe8g==", "requires": { - "abort-controller": "3.0.0", - "extend": "3.0.2", - "https-proxy-agent": "5.0.0", - "is-stream": "2.0.0", - "node-fetch": "2.6.1" + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.3.0" } }, "gcp-metadata": { @@ -230,8 +230,8 @@ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-4.2.1.tgz", "integrity": "sha512-tSk+REe5iq/N+K+SK1XjZJUrFPuDqGZVzCy2vocIHIGmPlTGsa8owXMJwGkrXr73NO0AzhPW4MF2DEHz7P2AVw==", "requires": { - "gaxios": "4.2.0", - "json-bigint": "1.0.0" + "gaxios": "^4.0.0", + "json-bigint": "^1.0.0" } }, "get-intrinsic": { @@ -239,9 +239,9 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", "requires": { - "function-bind": "1.1.1", - "has": "1.0.3", - "has-symbols": "1.0.2" + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" } }, "get-port": { @@ -254,12 +254,12 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", "requires": { - "fs.realpath": "1.0.0", - "inflight": "1.0.6", - "inherits": "2.0.4", - "minimatch": "3.0.4", - "once": "1.4.0", - "path-is-absolute": "1.0.1" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" } }, "google-auth-library": { @@ -267,15 +267,15 @@ "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.0.3.tgz", "integrity": "sha512-6wJNYqY1QUr5I2lWaUkkzOT2b9OCNhNQrdFOt/bsBbGb7T7NCdEvrBsXraUm+KTUGk2xGlQ7m9RgUd4Llcw8NQ==", "requires": { - "arrify": "2.0.1", - "base64-js": "1.5.1", - "ecdsa-sig-formatter": "1.0.11", - "fast-text-encoding": "1.0.3", - "gaxios": "4.2.0", - "gcp-metadata": "4.2.1", - "gtoken": "5.2.1", - "jws": "4.0.0", - "lru-cache": "6.0.0" + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^4.0.0", + "gcp-metadata": "^4.2.0", + "gtoken": "^5.0.4", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" } }, "google-p12-pem": { @@ -283,7 +283,7 @@ "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-3.0.3.tgz", "integrity": "sha512-wS0ek4ZtFx/ACKYF3JhyGe5kzH7pgiQ7J5otlumqR9psmWMYc+U9cErKlCYVYHoUaidXHdZ2xbo34kB+S+24hA==", "requires": { - "node-forge": "0.10.0" + "node-forge": "^0.10.0" } }, "googleapis": { @@ -291,8 +291,8 @@ "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-67.1.1.tgz", "integrity": "sha512-WLYk8R4dpW/oIxXhj0PQGhu+eOUpQbtWYTCxx/jeENr4arE9UmV5qmz0h1Gs1SPF/O/8PjCQIsPwOuHAlj78GA==", "requires": { - "google-auth-library": "7.0.3", - "googleapis-common": "5.0.2" + "google-auth-library": "^7.0.2", + "googleapis-common": "^5.0.1" } }, "googleapis-common": { @@ -300,12 +300,12 @@ "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-5.0.2.tgz", "integrity": "sha512-TL7qronKNZwE/XBvqshwzCPmZGq2gz/beXzANF7EVoO7FsQjOd7dk40DYrXkoCpvbnJHCQKWESq6NansiIPFqA==", "requires": { - "extend": "3.0.2", - "gaxios": "4.2.0", - "google-auth-library": "7.0.3", - "qs": "6.10.1", - "url-template": "2.0.8", - "uuid": "8.3.2" + "extend": "^3.0.2", + "gaxios": "^4.0.0", + "google-auth-library": "^7.0.2", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^8.0.0" }, "dependencies": { "uuid": { @@ -320,9 +320,9 @@ "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-5.2.1.tgz", "integrity": "sha512-OY0BfPKe3QnMsY9MzTHTSKn+Vl2l1CcLe6BwDEQj00mbbkl5nyQ/7EUREstg4fQNZ8iYE7br4JJ7TdKeDOPWmw==", "requires": { - "gaxios": "4.2.0", - "google-p12-pem": "3.0.3", - "jws": "4.0.0" + "gaxios": "^4.0.0", + "google-p12-pem": "^3.0.3", + "jws": "^4.0.0" } }, "has": { @@ -330,7 +330,7 @@ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", "requires": { - "function-bind": "1.1.1" + "function-bind": "^1.1.1" } }, "has-symbols": { @@ -343,10 +343,10 @@ "resolved": "https://registry.npmjs.org/http-basic/-/http-basic-8.1.3.tgz", "integrity": "sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw==", "requires": { - "caseless": "0.12.0", - "concat-stream": "1.6.2", - "http-response-object": "3.0.2", - "parse-cache-control": "1.0.1" + "caseless": "^0.12.0", + "concat-stream": "^1.6.2", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" } }, "http-response-object": { @@ -354,7 +354,7 @@ "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", "requires": { - "@types/node": "10.17.51" + "@types/node": "^10.0.3" } }, "https-proxy-agent": { @@ -362,8 +362,8 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", "requires": { - "agent-base": "6.0.2", - "debug": "4.3.1" + "agent-base": "6", + "debug": "4" } }, "inflight": { @@ -371,8 +371,8 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "requires": { - "once": "1.4.0", - "wrappy": "1.0.2" + "once": "^1.3.0", + "wrappy": "1" } }, "inherits": { @@ -390,7 +390,7 @@ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", "requires": { - "has": "1.0.3" + "has": "^1.0.3" } }, "is-stream": { @@ -408,7 +408,7 @@ "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "requires": { - "bignumber.js": "9.0.1" + "bignumber.js": "^9.0.0" } }, "jwa": { @@ -418,7 +418,7 @@ "requires": { "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "5.1.2" + "safe-buffer": "^5.0.1" } }, "jws": { @@ -426,8 +426,8 @@ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", "requires": { - "jwa": "2.0.0", - "safe-buffer": "5.1.2" + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" } }, "lru-cache": { @@ -435,7 +435,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "requires": { - "yallist": "4.0.0" + "yallist": "^4.0.0" } }, "mime-db": { @@ -456,7 +456,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "requires": { - "brace-expansion": "1.1.11" + "brace-expansion": "^1.1.7" } }, "mockery": { @@ -489,7 +489,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "requires": { - "wrappy": "1.0.2" + "wrappy": "1" } }, "parse-cache-control": { @@ -517,7 +517,7 @@ "resolved": "https://registry.npmjs.org/promise/-/promise-8.1.0.tgz", "integrity": "sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q==", "requires": { - "asap": "2.0.6" + "asap": "~2.0.6" } }, "q": { @@ -530,7 +530,7 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz", "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==", "requires": { - "side-channel": "1.0.4" + "side-channel": "^1.0.4" } }, "readable-stream": { @@ -538,13 +538,13 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", "requires": { - "core-util-is": "1.0.2", - "inherits": "2.0.4", - "isarray": "1.0.0", - "process-nextick-args": "2.0.1", - "safe-buffer": "5.1.2", - "string_decoder": "1.1.1", - "util-deprecate": "1.0.2" + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, "rechoir": { @@ -552,7 +552,7 @@ "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", "requires": { - "resolve": "1.20.0" + "resolve": "^1.1.6" } }, "resolve": { @@ -560,8 +560,8 @@ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", "requires": { - "is-core-module": "2.4.0", - "path-parse": "1.0.7" + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" } }, "safe-buffer": { @@ -579,9 +579,9 @@ "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.4.tgz", "integrity": "sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==", "requires": { - "glob": "7.1.4", - "interpret": "1.4.0", - "rechoir": "0.6.2" + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" } }, "side-channel": { @@ -589,9 +589,9 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", "requires": { - "call-bind": "1.0.2", - "get-intrinsic": "1.1.1", - "object-inspect": "1.9.0" + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" } }, "string_decoder": { @@ -599,7 +599,7 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "requires": { - "safe-buffer": "5.1.2" + "safe-buffer": "~5.1.0" } }, "sync-request": { @@ -607,9 +607,9 @@ "resolved": "https://registry.npmjs.org/sync-request/-/sync-request-6.1.0.tgz", "integrity": "sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw==", "requires": { - "http-response-object": "3.0.2", - "sync-rpc": "1.3.6", - "then-request": "6.0.2" + "http-response-object": "^3.0.1", + "sync-rpc": "^1.2.1", + "then-request": "^6.0.0" } }, "sync-rpc": { @@ -617,7 +617,7 @@ "resolved": "https://registry.npmjs.org/sync-rpc/-/sync-rpc-1.3.6.tgz", "integrity": "sha512-J8jTXuZzRlvU7HemDgHi3pGnh/rkoqR/OZSjhTyyZrEkkYQbk7Z33AXp37mkPfPpfdOuj7Ex3H/TJM1z48uPQw==", "requires": { - "get-port": "3.2.0" + "get-port": "^3.1.0" } }, "then-request": { @@ -625,17 +625,17 @@ "resolved": "https://registry.npmjs.org/then-request/-/then-request-6.0.2.tgz", "integrity": "sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA==", "requires": { - "@types/concat-stream": "1.6.0", + "@types/concat-stream": "^1.6.0", "@types/form-data": "0.0.33", - "@types/node": "8.10.66", - "@types/qs": "6.9.6", - "caseless": "0.12.0", - "concat-stream": "1.6.2", - "form-data": "2.5.1", - "http-basic": "8.1.3", - "http-response-object": "3.0.2", - "promise": "8.1.0", - "qs": "6.10.1" + "@types/node": "^8.0.0", + "@types/qs": "^6.2.31", + "caseless": "~0.12.0", + "concat-stream": "^1.6.0", + "form-data": "^2.2.0", + "http-basic": "^8.1.1", + "http-response-object": "^3.0.1", + "promise": "^8.0.0", + "qs": "^6.4.0" }, "dependencies": { "@types/node": { diff --git a/Tasks/GooglePlayReleaseBundleV3/package.json b/Tasks/GooglePlayReleaseBundleV3/package.json index f3b046a1..6e825c89 100644 --- a/Tasks/GooglePlayReleaseBundleV3/package.json +++ b/Tasks/GooglePlayReleaseBundleV3/package.json @@ -13,8 +13,7 @@ "@types/mocha": "^5.2.7", "azure-pipelines-task-lib": "^3.1.2", "glob": "~7.1.4", - "googleapis": "^67.1.0", - "google-auth-library": "^7.0.2" + "googleapis": "^67.1.0" }, "devDependencies": { "typescript": "4.0.2" diff --git a/Tasks/GooglePlayReleaseBundleV3/task.json b/Tasks/GooglePlayReleaseBundleV3/task.json index d301682c..1774b1a3 100644 --- a/Tasks/GooglePlayReleaseBundleV3/task.json +++ b/Tasks/GooglePlayReleaseBundleV3/task.json @@ -1,7 +1,7 @@ { "id": "64f05ee7-e81b-4fdb-85a0-ad518819e0d4", "name": "GooglePlayReleaseBundle", - "friendlyName": "Google Play - Release Bundle", + "friendlyName": "Google Play - Release Bundle (deprecated)", "description": "Release an app bundle to the Google Play Store", "author": "Microsoft Corporation", "category": "Deploy", diff --git a/Tasks/GooglePlayReleaseV3/Strings/resources.resjson/en-US/resources.resjson b/Tasks/GooglePlayReleaseV3/Strings/resources.resjson/en-US/resources.resjson index 43cd94bf..b73a69e2 100644 --- a/Tasks/GooglePlayReleaseV3/Strings/resources.resjson/en-US/resources.resjson +++ b/Tasks/GooglePlayReleaseV3/Strings/resources.resjson/en-US/resources.resjson @@ -1,5 +1,5 @@ { - "loc.friendlyName": "Google Play - Release", + "loc.friendlyName": "Google Play - Release (deprecated)", "loc.description": "Release an app to the Google Play Store", "loc.instanceNameFormat": "Release $(apkFile) to $(track)", "loc.group.displayName.advanced": "Advanced Options", diff --git a/Tasks/GooglePlayReleaseV3/task.json b/Tasks/GooglePlayReleaseV3/task.json index 6f2bc294..84c931f5 100644 --- a/Tasks/GooglePlayReleaseV3/task.json +++ b/Tasks/GooglePlayReleaseV3/task.json @@ -1,7 +1,7 @@ { "id": "8cf7cac0-620b-11e5-b4cf-8565e60f4d27", "name": "GooglePlayRelease", - "friendlyName": "Google Play - Release", + "friendlyName": "Google Play - Release (deprecated)", "description": "Release an app to the Google Play Store", "author": "Microsoft Corporation", "category": "Deploy", diff --git a/Tasks/GooglePlayReleaseV4/Strings/resources.resjson/en-US/resources.resjson b/Tasks/GooglePlayReleaseV4/Strings/resources.resjson/en-US/resources.resjson new file mode 100644 index 00000000..65c35e7c --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Strings/resources.resjson/en-US/resources.resjson @@ -0,0 +1,84 @@ +{ + "loc.friendlyName": "Google Play - Release", + "loc.description": "Release an app to the Google Play Store", + "loc.instanceNameFormat": "Release $(applicationId) to $(track)", + "loc.group.displayName.advanced": "Advanced Options", + "loc.input.label.authType": "Authentication method", + "loc.input.label.serviceEndpoint": "Service connection", + "loc.input.help.serviceEndpoint": "Google Play service connection that is configured with your account credentials.", + "loc.input.label.serviceAccountKey": "JSON key path", + "loc.input.help.serviceAccountKey": "The JSON file provided by Google Play that includes the service account's identity you wish to publish your APKs or AABs under.", + "loc.input.label.applicationId": "Application id (com.google.MyApp)", + "loc.input.help.applicationId": "The application id of APK or AAB you want to release, e.g. com.company.MyApp.", + "loc.input.label.action": "Action", + "loc.input.label.bundleFile": "Bundle path", + "loc.input.help.bundleFile": "Path to the bundle file you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.aab_ to match the first AAB file, in any directory.", + "loc.input.label.apkFile": "APK path", + "loc.input.help.apkFile": "Path to the APK file you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.apk_ to match the first APK file, in any directory.", + "loc.input.label.bundleFiles": "Bundle paths", + "loc.input.help.bundleFiles": "Paths to the bundle files you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.aab_ to match all AAB files, in any directory.", + "loc.input.label.apkFiles": "APK paths", + "loc.input.help.apkFiles": "Paths to the APK files you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.apk_ to match all APK files, in any directory.", + "loc.input.label.shouldPickObbFile": "Upload OBB for APK", + "loc.input.help.shouldPickObbFile": "Select this option to pick expansion file for the apk(s). If present in the parent directory, it will pick the first file with .obb extension, else it will pick from apk directory with expected format as main...obb", + "loc.input.label.track": "Track", + "loc.input.help.track": "Track you want to publish the apk(s)/aab(s) to.", + "loc.input.label.shouldAttachMetadata": "Update metadata", + "loc.input.help.shouldAttachMetadata": "Select this option to update the metadata in fastlane format on your app release.", + "loc.input.label.changeLogFile": "Release notes (file)", + "loc.input.help.changeLogFile": "Path to the file specifying the release notes (change log) for the application you are publishing.", + "loc.input.label.languageCode": "Language code", + "loc.input.help.languageCode": "An IETF language tag identifying the language of the release notes as specified in the BCP-47 document. Default value is _en-US_", + "loc.input.label.metadataRootPath": "Metadata root directory", + "loc.input.help.metadataRootPath": "The path to the metadata folder with the fastlane metadata structure.", + "loc.input.label.changeUpdatePriority": "Set in-app update priority", + "loc.input.help.changeUpdatePriority": "Change the in-app update priority value.", + "loc.input.label.updatePriority": "In-app Update Priority", + "loc.input.help.updatePriority": "Set a custom in-app update priority value to help keep your app up-to-date on your users' devices. To determine priority, Google Play uses an integer value between 0 and 5, with 0 being the default, and 5 being the highest priority. Priority can only be set when rolling out a new release, and cannot be changed later.", + "loc.input.label.rolloutToUserFraction": "Roll out release", + "loc.input.help.rolloutToUserFraction": "Roll out the release to a percentage of users.", + "loc.input.label.userFraction": "Rollout fraction", + "loc.input.help.userFraction": "The percentage of users the specified application will be released to for the specified 'Track'. It can be increased later with the 'Google Play - Increase Rollout' task.", + "loc.input.label.shouldUploadMappingFile": "Upload deobfuscation file (mapping.txt)", + "loc.input.help.shouldUploadMappingFile": "Select this option to attach your proguard mapping.txt file to your aab/apk.", + "loc.input.label.mappingFilePath": "Deobfuscation path", + "loc.input.help.mappingFilePath": "The path to the proguard mapping.txt file to upload. Glob patterns are supported.", + "loc.input.label.changesNotSentForReview": "Send changes to review", + "loc.input.help.changesNotSentForReview": "Select this option to send changes for review in GooglePlay Console. If changes are already sent for review automatically, you shouldn't select this option. [More info](https://developers.google.com/android-publisher/api-ref/rest/v3/edits/commit#query-parameters).", + "loc.input.label.releaseName": "Release name", + "loc.input.help.releaseName": "The release name is only for use in Play Console and won't be visible to users. To make your release easier to identify, add a release name that's meaningful to you.", + "loc.input.label.versionCodeFilterType": "Replace version codes", + "loc.input.help.versionCodeFilterType": "Specify version codes to replace in the selected track with the new aab(s)/apk(s): all, the comma separated list, or a regular expression pattern.", + "loc.input.label.replaceList": "Version code list", + "loc.input.help.replaceList": "The comma separated list of version codes to be removed from the track with this deployment.", + "loc.input.label.replaceExpression": "Version code pattern", + "loc.input.help.replaceExpression": "The regular expression pattern to select a list of version codes to be removed from the track with this deployment, e.g. _.\\*12?(3|4)?5_ ", + "loc.messages.ApkOrAabNotFound": "Could not find %s using pattern %s", + "loc.messages.AppendChangelog": "Appending changelog %s", + "loc.messages.AttachingMetadataToRelease": "Attempting to attach metadata to release...", + "loc.messages.CannotCreateListing": "Failed to create the localized %s store listing. Failed with message: %s.", + "loc.messages.CannotDownloadTrack": "Failed to download track %s information. Failed with message: %s.", + "loc.messages.CannotReadChangeLog": "Failed to read change log %s. Failed with message: %s.", + "loc.messages.CannotUpdateTrack": "Failed to update track %s information. Failed with message: %s.", + "loc.messages.CannotUploadApk": "Failed to upload the APK %s. Failed with message: %s.", + "loc.messages.CannotUploadBundle": "Failed to upload the bundle %s. Failed with message: %s.", + "loc.messages.CannotUploadDeobfuscationFile": "Failed to upload the deobfuscation file %s. Failed with message: %s.", + "loc.messages.CannotUploadExpansionFile": "Failed to upload the expansion file %s. Failed with message: %s.", + "loc.messages.FoundDeobfuscationFile": "Found deobfuscation (mapping) file: %s", + "loc.messages.FoundImageAtPath": "Found image for type %s at %s", + "loc.messages.GetNewEditAfterAuth": "Authenticated with Google Play and getting new edit", + "loc.messages.ImageDirNotFound": "Image directory for %s was not found. Skipping...", + "loc.messages.ImageTypeNotFound": "Image for %s was not found. Skipping...", + "loc.messages.IncorrectVersionCodeFilter": "Version code list specified contains incorrect codes: %s", + "loc.messages.InvalidActionInputValue": "Action input value is invalid: $s. Please recheck pipeline task configuration.", + "loc.messages.InvalidAuthFile": "%s is not a valid auth file", + "loc.messages.MustProvideApkIfObb": "shouldPickObbFile input is enabled, but no apk files could be found", + "loc.messages.MustProvideApkOrAab": "You must provide either apk or aab file(s). Neither were found.", + "loc.messages.PublishSucceed": "App was successfully published!", + "loc.messages.SetUnusedInput": "Input %s was set, but it will not be used in this action", + "loc.messages.StatNotDirectory": "Stat returned that %s was not a directory. Is there a file that shares this name?", + "loc.messages.TrackInfo": "Track: %s", + "loc.messages.UpdateTrack": "Updating track information...", + "loc.messages.UploadImageFail": "Failed to upload image.", + "loc.messages.UploadingMetadataForLanguage": "Attempting to upload metadata in %s for language code %s" +} \ No newline at end of file diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0.ts b/Tasks/GooglePlayReleaseV4/Tests/L0.ts new file mode 100644 index 00000000..3acd4aed --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0.ts @@ -0,0 +1,193 @@ +import assert = require('assert'); +import path = require('path'); +import process = require('process'); + +import * as ttm from 'azure-pipelines-task-lib/mock-test'; + +describe('L0 Suite GooglePlayReleaseV4', function () { + this.timeout(parseInt(process.env.TASK_TEST_TIMEOUT) || 20000); + + before((done: Mocha.Done) => { + done(); + }); + + describe('Google Util tests', function() { + require('./L0_googleutil'); + }); + + it('test no service endpoint fails', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0NoServiceEndpoint.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.createdErrorIssue('Error: Input required: serviceEndpoint'), 'Did not print the expected message'); + assert(testRunner.failed, 'task should have failed'); + done(); + }); + + it('test no json file fails', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0NoJsonFileAuth.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.createdErrorIssue('Error: Input required: serviceAccountKey'), 'Did not print the expected message'); + assert(testRunner.failed, 'task should have failed'); + done(); + }); + + it('test invalid json file fails', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0InvalidJsonAuth.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.createdErrorIssue('Error: loc_mock_InvalidAuthFile myServiceAccountKey'), 'Did not print the expected message'); + assert(testRunner.failed, 'task should have failed'); + done(); + }); + + it('test fail when no APK supplied', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0NoApkSupplied.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.createdErrorIssue('Error: Input required: apkFile'), 'Did not print the expected message'); + assert(testRunner.failed, 'task should have failed'); + done(); + }); + + it('test fail when no APK found', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0NoApkFound.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.createdErrorIssue('Error: loc_mock_ApkOrAabNotFound apkFile /path/to/apk'), 'Did not print the expected message'); + assert(testRunner.failed, 'task should have failed'); + done(); + }); + + it('test found obb file in parent directory', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0ObbFoundInParentDirectory.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.stdOutContained('Found Obb file for upload in parent directory: /path/to/obbfolder/file.obb'), 'Did not print the expected message'); + assert(testRunner.succeeded, 'task should have succeeded'); + done(); + }); + + it('test found obb file in apk directory', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0ObbFoundInApkDirectory.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.stdOutContained('Found Obb file for upload in current directory: main.1.package.obb'), 'Did not print the expected message'); + assert(testRunner.succeeded, 'task should have succeeded'); + done(); + }); + + it('test obb file not found', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0ObbFileNotFound.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.stdOutContained('No Obb found for /path/to/apk, skipping upload'), 'Did not print the expected message'); + assert(testRunner.succeeded, 'task should have succeeded'); + done(); + }); + + it('test found deobfuscation file', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0FoundDeobfuscationFile.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.stdOutContained('loc_mock_FoundDeobfuscationFile /path/to/mapping'), 'Did not print the expected message: ' + JSON.stringify(testRunner)); + assert(testRunner.succeeded, 'task should have succeeded: ' + JSON.stringify(testRunner)); + done(); + }); + + it('test deobfuscation file not found', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0DeobfuscationFileNotFound.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.createdErrorIssue('Error: Not found /path/to/mapping'), 'Did not print the expected message'); + assert(testRunner.failed, 'task should have failed'); + done(); + }); + + it('test succeeds on happy path', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0HappyPath.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.succeeded, 'task should have succeeded'); + done(); + }); + + it('test fails task when cannot read changelog', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0UseChangeLogFail.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.stdOutContained('loc_mock_AppendChangelog /path/to/changelog'), 'Did not have expected localized message'); + assert(testRunner.createdErrorIssue('Error: loc_mock_CannotReadChangeLog /path/to/changelog'), 'Did not have expected localized message'); + assert(testRunner.failed, 'task should have failed'); + done(); + }); + + it('test succeeds task with updating changelog', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0UseChangeLog.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.stdOutContained('loc_mock_AppendChangelog /path/to/changelog'), 'Did not have expected localized message'); + assert(testRunner.succeeded, 'task should have succeeded'); + done(); + }); + + it('test uploads metadata', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0AttachMetadata.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.stdOutContained('loc_mock_AttachingMetadataToRelease'), 'Did not have expected localized message'); + assert(testRunner.succeeded, 'task should have succeeded'); + done(); + }); + + it('test update track with specified versions', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0UpdateTrackWithVersionList.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.stdOutContained('New Production track version codes: [2,4]'), 'Did not have expected localized message: ' + JSON.stringify(testRunner)); + assert(testRunner.succeeded, 'task should have succeeded: ' + JSON.stringify(testRunner)); + done(); + }); + + it('test fails with bad version list', (done: Mocha.Done) => { + const testFile = path.join(__dirname, 'L0BadVersionList.js'); + const testRunner = new ttm.MockTestRunner(testFile); + + testRunner.run(); + + assert(testRunner.createdErrorIssue('Error: loc_mock_IncorrectVersionCodeFilter ["notreal"]'), 'Did not have expected localized message: ' + JSON.stringify(testRunner)); + assert(testRunner.failed, 'task should have failed: ' + JSON.stringify(testRunner)); + done(); + }); +}); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0AttachMetadata.ts b/Tasks/GooglePlayReleaseV4/Tests/L0AttachMetadata.ts new file mode 100644 index 00000000..b6f7e8f9 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0AttachMetadata.ts @@ -0,0 +1,76 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import * as sinon from 'sinon'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); +tr.setInput('track', 'Production'); +tr.setInput('shouldAttachMetadata', 'true'); +tr.setInput('metadataRootPath', '/path/to/metadata/folder'); +tr.setInput('versionCodeFilterType', 'list'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': true, + '/path/to/metadata/folder': true + } +}; +tr.setAnswers(a); + +tr.registerMock('./modules/googleutil', { + publisher: { + edits: { + commit: sinon.stub(), + listings: { + update: sinon.stub() + } + } + }, + getJWT: () => { + return { + authorize: sinon.stub() + }; + }, + getNewEdit: () => Promise.resolve({}), + getTrack: () => Promise.resolve({ releases: [{ versionCodes: [1, 2, 3 ]}]}), + updateTrack: () => Promise.resolve({}), + updateGlobalParams: () => Promise.resolve({}), + addApk: () => Promise.resolve({}) +}); + +tr.registerMock('glob', { + sync: (path) => [path] +}); + +tr.registerMock('fs', { + readdirSync: () => { + return { + filter: () => ['/path/to/metadata/folder/en-US'] + }; + }, + readFileSync: () => { + return { + toString: () => 'file contents' + }; + }, + writeFileSync: sinon.stub(), + statSync: () => { + return { + isFile: () => true, + isDirectory: () => false + }; + } +}); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0BadVersionList.ts b/Tasks/GooglePlayReleaseV4/Tests/L0BadVersionList.ts new file mode 100644 index 00000000..e709b1a4 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0BadVersionList.ts @@ -0,0 +1,33 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); +tr.setInput('track', 'Production'); +tr.setInput('shouldAttachMetadata', 'false'); +tr.setInput('versionCodeFilterType', 'list'); +tr.setInput('replaceList', '1, 3, notreal'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': true + } +}; +tr.setAnswers(a); + +tr.registerMock('glob', { + sync: (path) => [path] +}); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0DeobfuscationFileNotFound.ts b/Tasks/GooglePlayReleaseV4/Tests/L0DeobfuscationFileNotFound.ts new file mode 100644 index 00000000..566f8af6 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0DeobfuscationFileNotFound.ts @@ -0,0 +1,51 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import * as sinon from 'sinon'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); +tr.setInput('shouldUploadMappingFile', 'true'); +tr.setInput('mappingFilePath', '/path/to/mapping'); +tr.setInput('track', 'Production'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': true + } +}; +tr.setAnswers(a); + +tr.registerMock('./modules/googleutil', { + publisher: { + edits: { + commit: sinon.stub() + } + }, + getJWT: () => { + return { + authorize: sinon.stub() + }; + }, + getNewEdit: () => Promise.resolve({}), + getTrack: () => Promise.resolve({}), + updateTrack: () => Promise.resolve({}), + updateGlobalParams: () => Promise.resolve({}), + addApk: () => Promise.resolve({}) +}); + +tr.registerMock('glob', { + sync: (path) => [path] +}); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0FoundDeobfuscationFile.ts b/Tasks/GooglePlayReleaseV4/Tests/L0FoundDeobfuscationFile.ts new file mode 100644 index 00000000..4d8978c8 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0FoundDeobfuscationFile.ts @@ -0,0 +1,54 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import * as sinon from 'sinon'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); +tr.setInput('shouldUploadMappingFile', 'true'); +tr.setInput('mappingFilePath', '/path/to/mapping'); +tr.setInput('track', 'Production'); +tr.setInput('versionCodeFilterType', 'all'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': true, + '/path/to/mapping': true + } +}; +tr.setAnswers(a); + +tr.registerMock('./modules/googleutil', { + publisher: { + edits: { + commit: sinon.stub() + } + }, + getJWT: () => { + return { + authorize: sinon.stub() + }; + }, + getNewEdit: () => Promise.resolve({}), + getTrack: () => Promise.resolve({}), + updateTrack: () => Promise.resolve({}), + updateGlobalParams: () => Promise.resolve({}), + uploadDeobfuscation: () => Promise.resolve(), + addApk: () => Promise.resolve({}) +}); + +tr.registerMock('glob', { + sync: (path) => [path] +}); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0HappyPath.ts b/Tasks/GooglePlayReleaseV4/Tests/L0HappyPath.ts new file mode 100644 index 00000000..f2d6afa8 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0HappyPath.ts @@ -0,0 +1,49 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import * as sinon from 'sinon'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); +tr.setInput('track', 'Production'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': true + } +}; +tr.setAnswers(a); + +tr.registerMock('./modules/googleutil', { + publisher: { + edits: { + commit: sinon.stub() + } + }, + getJWT: () => { + return { + authorize: sinon.stub() + }; + }, + getNewEdit: () => Promise.resolve({}), + getTrack: () => Promise.resolve({}), + updateTrack: () => Promise.resolve({}), + updateGlobalParams: () => Promise.resolve({}), + addApk: () => Promise.resolve({}) +}); + +tr.registerMock('glob', { + sync: (path) => [path] +}); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0InvalidJsonAuth.ts b/Tasks/GooglePlayReleaseV4/Tests/L0InvalidJsonAuth.ts new file mode 100644 index 00000000..0521d3e3 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0InvalidJsonAuth.ts @@ -0,0 +1,23 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +tr.setInput('authType', 'JsonFile'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('serviceAccountKey', 'myServiceAccountKey'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'getEndpointAuthorization': { + 'myServiceEndpoint': true + }, + 'checkPath': { + 'myServiceAccountKey': true + } +}; +tr.setAnswers(a); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0NoApkFound.ts b/Tasks/GooglePlayReleaseV4/Tests/L0NoApkFound.ts new file mode 100644 index 00000000..20c8e6e5 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0NoApkFound.ts @@ -0,0 +1,24 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': false + } +}; +tr.setAnswers(a); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0NoApkSupplied.ts b/Tasks/GooglePlayReleaseV4/Tests/L0NoApkSupplied.ts new file mode 100644 index 00000000..ab609b76 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0NoApkSupplied.ts @@ -0,0 +1,16 @@ +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', ''); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0NoJsonFileAuth.ts b/Tasks/GooglePlayReleaseV4/Tests/L0NoJsonFileAuth.ts new file mode 100644 index 00000000..8e6504fc --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0NoJsonFileAuth.ts @@ -0,0 +1,11 @@ +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +tr.setInput('authType', 'JsonFile'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('serviceAccountKey', ''); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0NoServiceEndpoint.ts b/Tasks/GooglePlayReleaseV4/Tests/L0NoServiceEndpoint.ts new file mode 100644 index 00000000..530b9be1 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0NoServiceEndpoint.ts @@ -0,0 +1,10 @@ +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', ''); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0ObbFileNotFound.ts b/Tasks/GooglePlayReleaseV4/Tests/L0ObbFileNotFound.ts new file mode 100644 index 00000000..36181c76 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0ObbFileNotFound.ts @@ -0,0 +1,68 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import * as sinon from 'sinon'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); +tr.setInput('track', 'Production'); +tr.setInput('shouldPickObbFile', 'true'); +tr.setInput('versionCodeFilterType', 'list'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': true + } +}; +tr.setAnswers(a); + +tr.registerMock('./modules/googleutil', { + publisher: { + edits: { + commit: sinon.stub() + } + }, + getJWT: () => { + return { + authorize: sinon.stub() + }; + }, + getNewEdit: () => Promise.resolve({}), + getTrack: () => Promise.resolve({ releases: [{ versionCodes: [1, 2, 3 ]}]}), + updateTrack: () => Promise.resolve({}), + updateGlobalParams: () => Promise.resolve({}), + addApk: () => Promise.resolve({versionCode: 1}), + addObb: () => Promise.resolve({ expansionFile: { fileSize: '1000' } }) +}); + +tr.registerMock('glob', { + sync: (path) => [path] +}); + +tr.registerMock('fs', { + readdirSync: () => ['/path/to/obbfolder/filename1.txt', '/path/to/obbfolder/filename2.txt'], + readFileSync: () => { + return { + toString: () => 'file contents' + }; + }, + writeFileSync: sinon.stub(), + statSync: () => { + return { + isFile: () => true, + isDirectory: () => false + }; + } +}); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0ObbFoundInApkDirectory.ts b/Tasks/GooglePlayReleaseV4/Tests/L0ObbFoundInApkDirectory.ts new file mode 100644 index 00000000..41b43b30 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0ObbFoundInApkDirectory.ts @@ -0,0 +1,74 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import * as sinon from 'sinon'; +import * as fs from 'fs'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +const stubForReaddirSync: sinon.SinonStub = sinon.stub(fs, 'readdirSync'); +stubForReaddirSync.onFirstCall().returns(['/path/to/obbfolder/file.exe', '/path/to/obbfolder/filename.txt']); +stubForReaddirSync.onSecondCall().returns(['main.1.package.obb', '/path/to/obbfolder/filename.txt']); +stubForReaddirSync.onThirdCall().returns(['main.1.package.obb', '/path/to/obbfolder/filename.txt']); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); +tr.setInput('track', 'Production'); +tr.setInput('shouldPickObbFile', 'true'); +tr.setInput('versionCodeFilterType', 'list'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': true + } +}; +tr.setAnswers(a); + +tr.registerMock('./modules/googleutil', { + publisher: { + edits: { + commit: sinon.stub() + } + }, + getJWT: () => { + return { + authorize: sinon.stub() + }; + }, + getNewEdit: () => Promise.resolve({}), + getTrack: () => Promise.resolve({ releases: [{ versionCodes: [1, 2, 3 ]}]}), + updateTrack: () => Promise.resolve({}), + updateGlobalParams: () => Promise.resolve({}), + addApk: () => Promise.resolve({versionCode: 1}), + addObb: () => Promise.resolve({ expansionFile: { fileSize: '1000' } }) +}); + +tr.registerMock('glob', { + sync: (path) => [path] +}); + +tr.registerMock('fs', { + readdirSync: () => stubForReaddirSync(), + readFileSync: () => { + return { + toString: () => 'file contents' + }; + }, + writeFileSync: sinon.stub(), + statSync: () => { + return { + isFile: () => true, + isDirectory: () => false + }; + } +}); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0ObbFoundInParentDirectory.ts b/Tasks/GooglePlayReleaseV4/Tests/L0ObbFoundInParentDirectory.ts new file mode 100644 index 00000000..21c72bc9 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0ObbFoundInParentDirectory.ts @@ -0,0 +1,68 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import * as sinon from 'sinon'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); +tr.setInput('track', 'Production'); +tr.setInput('shouldPickObbFile', 'true'); +tr.setInput('versionCodeFilterType', 'list'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': true + } +}; +tr.setAnswers(a); + +tr.registerMock('./modules/googleutil', { + publisher: { + edits: { + commit: sinon.stub() + } + }, + getJWT: () => { + return { + authorize: sinon.stub() + }; + }, + getNewEdit: () => Promise.resolve({}), + getTrack: () => Promise.resolve({ releases: [{ versionCodes: [1, 2, 3 ]}]}), + updateTrack: () => Promise.resolve({}), + updateGlobalParams: () => Promise.resolve({}), + addApk: () => Promise.resolve({versionCode: 1}), + addObb: () => Promise.resolve({ expansionFile: { fileSize: '1000' } }) +}); + +tr.registerMock('glob', { + sync: (path) => [path] +}); + +tr.registerMock('fs', { + readdirSync: () => ['/path/to/obbfolder/file.obb', '/path/to/obbfolder/filename.txt'], + readFileSync: () => { + return { + toString: () => 'file contents' + }; + }, + writeFileSync: sinon.stub(), + statSync: () => { + return { + isFile: () => true, + isDirectory: () => false + }; + } +}); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0UpdateTrackWithVersionList.ts b/Tasks/GooglePlayReleaseV4/Tests/L0UpdateTrackWithVersionList.ts new file mode 100644 index 00000000..3675ec6e --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0UpdateTrackWithVersionList.ts @@ -0,0 +1,77 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import * as sinon from 'sinon'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); +tr.setInput('track', 'Production'); +tr.setInput('shouldAttachMetadata', 'true'); +tr.setInput('metadataRootPath', '/path/to/metadata/folder'); +tr.setInput('versionCodeFilterType', 'list'); +tr.setInput('replaceList', '1, 3'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': true, + '/path/to/metadata/folder': true + } +}; +tr.setAnswers(a); + +tr.registerMock('./modules/googleutil', { + publisher: { + edits: { + commit: sinon.stub(), + listings: { + update: sinon.stub() + } + } + }, + getJWT: () => { + return { + authorize: sinon.stub() + }; + }, + getNewEdit: () => Promise.resolve({}), + getTrack: () => Promise.resolve({ releases: [{ versionCodes: [1, 2, 3]}]}), + updateTrack: () => Promise.resolve({}), + updateGlobalParams: () => Promise.resolve({}), + addApk: () => Promise.resolve({ versionCode: 4 }) +}); + +tr.registerMock('glob', { + sync: (path) => [path] +}); + +tr.registerMock('fs', { + readdirSync: () => { + return { + filter: () => ['/path/to/metadata/folder/en-US'] + }; + }, + readFileSync: () => { + return { + toString: () => 'file contents' + }; + }, + writeFileSync: sinon.stub(), + statSync: () => { + return { + isFile: () => true, + isDirectory: () => false + }; + } +}); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0UseChangeLog.ts b/Tasks/GooglePlayReleaseV4/Tests/L0UseChangeLog.ts new file mode 100644 index 00000000..fa778315 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0UseChangeLog.ts @@ -0,0 +1,75 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import * as sinon from 'sinon'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); +tr.setInput('track', 'Production'); +tr.setInput('shouldAttachMetadata', 'false'); +tr.setInput('changelogFile', '/path/to/changelog'); +tr.setInput('languageCode', 'lang-Code'); +tr.setInput('versionCodeFilterType', 'list'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': true, + '/path/to/changelog': true + }, + 'stats': { + '/path/to/changelog': { + isFile: () => true, + isDirectory: () => false + } + } +}; +tr.setAnswers(a); + +tr.registerMock('./modules/googleutil', { + publisher: { + edits: { + commit: sinon.stub() + } + }, + getJWT: () => { + return { + authorize: sinon.stub() + }; + }, + getNewEdit: () => Promise.resolve({}), + getTrack: () => Promise.resolve({ releases: [{ versionCodes: [1, 2, 3 ]}]}), + updateTrack: () => Promise.resolve({}), + updateGlobalParams: () => Promise.resolve({}), + addApk: () => Promise.resolve({}) +}); + +tr.registerMock('glob', { + sync: (path) => [path] +}); + +tr.registerMock('fs', { + readFileSync: () => { + return { + toString: () => 'file contents' + }; + }, + writeFileSync: sinon.stub(), + statSync: () => { + return { + isFile: () => true, + isDirectory: () => false + }; + } +}); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0UseChangeLogFail.ts b/Tasks/GooglePlayReleaseV4/Tests/L0UseChangeLogFail.ts new file mode 100644 index 00000000..4a02d821 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0UseChangeLogFail.ts @@ -0,0 +1,59 @@ +import * as ma from 'azure-pipelines-task-lib/mock-answer'; +import * as tmrm from 'azure-pipelines-task-lib/mock-run'; +import * as sinon from 'sinon'; + +import path = require('path'); + +const taskPath = path.join(__dirname, '..', 'main.js'); +const tr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath); + +process.env['ENDPOINT_AUTH_myServiceEndpoint'] = '{ "parameters": {"username": "myUser", "password": "myPass"}, "scheme": "UsernamePassword"}'; + +tr.setInput('authType', 'ServiceEndpoint'); +tr.setInput('serviceEndpoint', 'myServiceEndpoint'); +tr.setInput('applicationId', 'package'); +tr.setInput('action', 'SingleApk'); +tr.setInput('apkFile', '/path/to/apk'); +tr.setInput('track', 'Production'); +tr.setInput('shouldAttachMetadata', 'false'); +tr.setInput('changelogFile', '/path/to/changelog'); +tr.setInput('languageCode', 'lang-Code'); + +// provide answers for task mock +const a: ma.TaskLibAnswers = { + 'checkPath': { + '/path/to/apk': true, + '/path/to/changelog': true + }, + 'stats': { + '/path/to/changelog': { + isFile: () => true, + isDirectory: () => false + } + } +}; +tr.setAnswers(a); + +tr.registerMock('./modules/googleutil', { + publisher: { + edits: { + commit: sinon.stub() + } + }, + getJWT: () => { + return { + authorize: sinon.stub() + }; + }, + getNewEdit: () => Promise.resolve({}), + getTrack: () => Promise.resolve({}), + updateTrack: () => Promise.resolve({}), + updateGlobalParams: () => Promise.resolve({}), + addApk: () => Promise.resolve({}) +}); + +tr.registerMock('glob', { + sync: (path) => [path] +}); + +tr.run(); diff --git a/Tasks/GooglePlayReleaseV4/Tests/L0_googleutil.ts b/Tasks/GooglePlayReleaseV4/Tests/L0_googleutil.ts new file mode 100644 index 00000000..6da22927 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/Tests/L0_googleutil.ts @@ -0,0 +1,116 @@ +import * as assert from 'assert'; +import * as mockery from 'mockery'; +import * as sinon from 'sinon'; + +import * as mockTask from 'azure-pipelines-task-lib/mock-task'; +import * as googleutil from '../modules/googleutil'; + +import * as googleapis from 'googleapis'; + +before(function () { + mockery.enable({ + useCleanCache: true, + warnOnUnregistered: false + }); +}); + +after(function () { + mockery.disable(); +}); + +afterEach(function () { + mockery.deregisterAll(); + mockery.resetCache(); +}); + +it('getNewEdit tests', async function () { + mockery.registerMock('azure-pipelines-task-lib/task', mockTask); + mockery.registerMock('googleapis', { + google: { + androidpublisher: () => ({}) + } + }); + + const fakeEditId = 123; + const stub = sinon.stub(); + stub.returns({ data: { id: fakeEditId } }); + const edits: any = { insert: stub }; + + const p: googleapis.Common.GlobalOptions = { params: {} }; + const packname = 'myPackageName'; + + const edit = await googleutil.getNewEdit(edits, packname); + + assert.equal(edit.id, fakeEditId); + assert(stub.called); + assert.equal(packname, stub.args[0][0].packageName); + + googleutil.updateGlobalParams(p, 'editId', edit.id); + assert.equal(fakeEditId, p.params['editId']); +}); + +it('getTrack tests', async function () { + mockery.registerMock('azure-pipelines-task-lib/task', mockTask); + mockery.registerMock('googleapis', { + google: { + androidpublisher: () => ({}) + } + }); + + const stub = sinon.stub(); + stub.returns({ data: {}}); + const edits: any = { tracks: { get: stub } }; + + const packname = 'myPackageName'; + const track = 'myFakeTrack'; + + await googleutil.getTrack(edits, packname, track); + + assert(stub.called); + assert.equal(track, stub.args[0][0].track); + assert.equal(packname, stub.args[0][0].packageName); +}); + +it('updateTrack tests', async function () { + mockery.registerMock('azure-pipelines-task-lib/task', mockTask); + mockery.registerMock('googleapis', { + google: { + androidpublisher: () => ({}) + } + }); + + const stub = sinon.stub(); + const edits: any = { tracks: { update: stub } }; + + const packname = 'myPackageName'; + const track = 'myFakeTrack'; + + stub.returns({ data: {}}); + await googleutil.updateTrack(edits, packname, track, 123, 1.0, 0); + assert(stub.called); + let response = stub.args[0][0]; + assert.equal(packname, response.packageName); + assert.equal(track, response.track); + assert.equal(123, response.requestBody.releases[0].versionCodes); + assert(!response.requestBody.releases[0].userFraction); + assert.equal('completed', response.requestBody.releases[0].status); + stub.reset(); + + stub.returns({ data: {}}); + await googleutil.updateTrack(edits, packname, track, [234], 1.0, 0); + assert(stub.called); + response = stub.args[0][0]; + assert.equal(234, response.requestBody.releases[0].versionCodes); + assert(!response.requestBody.releases[0].userFraction); + assert.equal('completed', response.requestBody.releases[0].status); + stub.reset(); + + stub.returns({ data: {}}); + await googleutil.updateTrack(edits, packname, track, [345], 0.9, 0); + assert(stub.called); + response = stub.args[0][0]; + assert.equal(345, response.requestBody.releases[0].versionCodes); + assert.equal(0.9, response.requestBody.releases[0].userFraction); + assert.equal('inProgress', response.requestBody.releases[0].status); + stub.reset(); +}); diff --git a/Tasks/GooglePlayReleaseV4/icon.png b/Tasks/GooglePlayReleaseV4/icon.png new file mode 100644 index 00000000..ee0b1569 Binary files /dev/null and b/Tasks/GooglePlayReleaseV4/icon.png differ diff --git a/Tasks/GooglePlayReleaseV4/main.ts b/Tasks/GooglePlayReleaseV4/main.ts new file mode 100644 index 00000000..69d3e1ba --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/main.ts @@ -0,0 +1,309 @@ +import * as path from 'path'; +import * as tl from 'azure-pipelines-task-lib/task'; + +import * as googleutil from './modules/googleutil'; +import * as metadataHelper from './modules/metadataHelper'; +import * as inputsHelper from './modules/inputsHelper'; +import * as fileHelper from './modules/fileHelper'; + +import * as googleapis from 'googleapis'; +import { androidpublisher_v3 as pub3 } from 'googleapis'; + +async function run(): Promise { + try { + tl.setResourcePath(path.join(__dirname, 'task.json')); + + tl.debug('Prepare task inputs.'); + + // Authentication inputs + + const key: googleutil.ClientKey = inputsHelper.getClientKey(); + + // General inputs + + const action: inputsHelper.Action = inputsHelper.getAction(); + tl.debug(`Action: ${action}`); + + const packageName: string = tl.getInput('applicationId', true); + tl.debug(`Application identifier: ${packageName}`); + + const bundleFileList: string[] = inputsHelper.getBundles(action); + tl.debug(`Bundles: ${bundleFileList}`); + const apkFileList: string[] = inputsHelper.getApks(action); + tl.debug(`APKs: ${apkFileList}`); + + const shouldPickObb: boolean = tl.getBoolInput('shouldPickObbFile', false); + + if (shouldPickObb && apkFileList.length === 0) { + throw new Error(tl.loc('MustProvideApkIfObb')); + } + + if (action !== 'OnlyStoreListing' && bundleFileList.length === 0 && apkFileList.length === 0) { + throw new Error(tl.loc('MustProvideApkOrAab')); + } + + const track: string = tl.getInput('track', true); + + const shouldAttachMetadata: boolean = tl.getBoolInput('shouldAttachMetadata', false); + + let changelogFile: string = null; + let languageCode: string = null; + let metadataRootPath: string = null; + + if (shouldAttachMetadata) { + metadataRootPath = tl.getPathInput('metadataRootPath', true, true); + } else { + changelogFile = tl.getInput('changelogFile', false); + const defaultLanguageCode = 'en-US'; + languageCode = tl.getInput('languageCode', false) || defaultLanguageCode; + } + + // Advanced inputs + + const updatePrioritySupplied: boolean = tl.getBoolInput('changeUpdatePriority'); + const updatePriority: number = Number(updatePrioritySupplied ? tl.getInput('updatePriority', false) : 0); + + const userFractionSupplied: boolean = tl.getBoolInput('rolloutToUserFraction'); + const userFraction: number = Number(userFractionSupplied ? tl.getInput('userFraction', false) : 1.0); + + const uploadMappingFile: boolean = tl.getBoolInput('shouldUploadMappingFile', false) && (action === 'SingleApk' || action === 'SingleBundle'); + const mappingFilePattern: string = tl.getInput('mappingFilePath'); + + const changesNotSentForReview: boolean = tl.getBoolInput('changesNotSentForReview'); + + const releaseName: string = tl.getInput('releaseName', false); + + const versionCodeFilterType: string = tl.getInput('versionCodeFilterType', false) || 'all'; + let versionCodeFilter: string | number[] = null; + if (versionCodeFilterType === 'list') { + versionCodeFilter = inputsHelper.getVersionCodeListInput(); + } else if (versionCodeFilterType === 'expression') { + versionCodeFilter = tl.getInput('replaceExpression', true); + } + + inputsHelper.warnAboutUnusedInputs(action); + + // The regular submission process is composed + // of a transction with the following steps: + // ----------------------------------------- + // #1) Get an OAuth token by authenticating the service account + // #2) Create a new editing transaction + // #3) Upload the new APK(s) or AAB(s) + // #4) Specify the track that should be used for the new APK/AAB (e.g. alpha, beta) + // #5) Specify the new change log + // #6) Commit the edit transaction + + const globalParams: googleapis.Common.GlobalOptions = { auth: null, params: {} }; + + googleutil.updateGlobalParams(globalParams, 'packageName', packageName); + + tl.debug('Initializing JWT.'); + const jwtClient: googleapis.Common.JWT = googleutil.getJWT(key); + globalParams.auth = jwtClient; + + tl.debug('Initializing Google Play publisher API.'); + const edits: pub3.Resource$Edits = googleutil.publisher.edits; + + tl.debug('Authorize JWT.'); + await jwtClient.authorize(); + + console.log(tl.loc('GetNewEditAfterAuth')); + tl.debug('Creating a new edit transaction in Google Play.'); + const edit: pub3.Schema$AppEdit = await googleutil.getNewEdit(edits, packageName); + googleutil.updateGlobalParams(globalParams, 'editId', edit.id); + + let requireTrackUpdate = false; + const versionCodes: number[] = []; + + if (action === 'OnlyStoreListing') { + tl.debug('Selected store listing update only -> skip APK/AAB reading'); + } else { + requireTrackUpdate = true; + + tl.debug(`Uploading ${bundleFileList.length} AAB(s).`); + + for (const bundleFile of bundleFileList) { + tl.debug(`Uploading bundle ${bundleFile}`); + const bundle: pub3.Schema$Bundle = await googleutil.addBundle(edits, packageName, bundleFile); + tl.debug(`Uploaded ${bundleFile} with the version code ${bundle.versionCode}`); + versionCodes.push(bundle.versionCode); + } + + tl.debug(`Uploading ${apkFileList.length} APK(s).`); + + for (const apkFile of apkFileList) { + tl.debug(`Uploading APK ${apkFile}`); + const apk: pub3.Schema$Apk = await googleutil.addApk(edits, packageName, apkFile); + tl.debug(`Uploaded ${apkFile} with the version code ${apk.versionCode}`); + + if (shouldPickObb) { + const obbFile: string | null = fileHelper.getObbFile(apkFile, packageName, apk.versionCode); + + if (obbFile !== null) { + const obb: pub3.Schema$ExpansionFilesUploadResponse | null = await googleutil.addObb( + edits, + packageName, + obbFile, + apk.versionCode, + 'main' + ); + + if (obb.expansionFile.fileSize !== null && Number(obb.expansionFile.fileSize) !== 0) { + console.log(`Uploaded Obb file with version code ${apk.versionCode} and size ${obb.expansionFile.fileSize}`); + } + } + } + versionCodes.push(apk.versionCode); + } + + if (uploadMappingFile) { + tl.debug(`Mapping file pattern: ${mappingFilePattern}`); + + const mappingFilePath = fileHelper.resolveGlobPath(mappingFilePattern); + tl.checkPath(mappingFilePath, 'Mapping file path'); + console.log(tl.loc('FoundDeobfuscationFile', mappingFilePath)); + tl.debug(`Uploading ${mappingFilePath} for version code ${versionCodes[0]}`); + await googleutil.uploadDeobfuscation(edits, mappingFilePath, packageName, versionCodes[0]); + } + } + + let releaseNotes: googleapis.androidpublisher_v3.Schema$LocalizedText[]; + if (shouldAttachMetadata) { + console.log(tl.loc('AttachingMetadataToRelease')); + tl.debug(`Uploading metadata from ${metadataRootPath}`); + releaseNotes = await metadataHelper.addMetadata(edits, versionCodes.map((versionCode) => Number(versionCode)), metadataRootPath); + if (action === 'OnlyStoreListing') { + tl.debug('Selected store listing update -> skip update track'); + } + requireTrackUpdate = action !== 'OnlyStoreListing'; + } else if (changelogFile) { + tl.debug(`Uploading the common change log ${changelogFile} to all versions`); + const commonNotes = await metadataHelper.getCommonReleaseNotes(languageCode, changelogFile); + releaseNotes = commonNotes && [commonNotes]; + requireTrackUpdate = true; + } + + if (requireTrackUpdate) { + console.log(tl.loc('UpdateTrack')); + tl.debug(`Updating the track ${track}.`); + const parameters: TrackUpdateParameters = { + edits, + packageName, + track, + versionCodes, + versionCodeFilterType, + versionCodeFilter, + userFraction, + updatePriority, + releaseNotes, + releaseName + }; + const updatedTrack: pub3.Schema$Track = await prepareTrackUpdate(parameters); + tl.debug('Updated track info: ' + JSON.stringify(updatedTrack)); + } + + tl.debug('Committing the edit transaction in Google Play.'); + await edits.commit({ changesNotSentForReview }); + + console.log(tl.loc('TrackInfo', track)); + tl.setResult(tl.TaskResult.Succeeded, tl.loc('PublishSucceed')); + } catch (e) { + tl.setResult(tl.TaskResult.Failed, e); + } +} + +interface TrackUpdateParameters { + edits: pub3.Resource$Edits; + packageName: string; + track: string; + versionCodes: number[]; + versionCodeFilterType: string; + versionCodeFilter: string | number[]; + userFraction: number; + updatePriority: number; + releaseNotes?: pub3.Schema$LocalizedText[]; + releaseName?: string; +} + +/** + * Removes old version codes, then updates a given release track with the given information + * Assumes authorized + * @param packageName unique android package name (com.android.etc) + * @param track one of the values {"internal", "alpha", "beta", "production"} + * @param bundleVersionCode version code of uploaded modules. + * @param versionCodeFilterType type of version code replacement filter, i.e. 'all', 'list', or 'expression' + * @param versionCodeFilter version code filter, i.e. either a list of version code or a regular expression string. + * @param userFraction the fraction of users to get update + * @param updatePriority - In-app update priority value of the release. All newly added APKs in the release will be considered at this priority. Can take values in the range [0, 5], with 5 the highest priority. Defaults to 0. + * @returns track A promise that will return result from updating a track + * { track: string, versionCodes: [integer], userFraction: double } + */ +async function prepareTrackUpdate({ + edits, + packageName, + track, + versionCodes, + versionCodeFilterType, + versionCodeFilter, + userFraction, + updatePriority, + releaseNotes, + releaseName +}: TrackUpdateParameters): Promise { + let newTrackVersionCodes: number[] = []; + let res: pub3.Schema$Track; + + if (versionCodeFilterType === 'all') { + newTrackVersionCodes = versionCodes; + } else { + try { + res = await googleutil.getTrack(edits, packageName, track); + } catch (e) { + tl.debug(`Failed to download track ${track} information.`); + tl.debug(e); + throw new Error(tl.loc('CannotDownloadTrack', track, e)); + } + + const oldTrackVersionCodes: number[] = res.releases[0].versionCodes.map((v) => Number(v)); + tl.debug('Current version codes: ' + JSON.stringify(oldTrackVersionCodes)); + + if (typeof(versionCodeFilter) === 'string') { + tl.debug(`Removing version codes matching the regular expression: ^${versionCodeFilter}$`); + const versionCodesToRemove: RegExp = new RegExp(`^${versionCodeFilter}$`); + + oldTrackVersionCodes.forEach((versionCode) => { + if (!versionCode.toString().match(versionCodesToRemove)) { + newTrackVersionCodes.push(versionCode); + } + }); + } else { + const versionCodesToRemove = versionCodeFilter; + tl.debug('Removing version codes: ' + JSON.stringify(versionCodesToRemove)); + + oldTrackVersionCodes.forEach((versionCode) => { + if (versionCodesToRemove.indexOf(versionCode) === -1) { + newTrackVersionCodes.push(versionCode); + } + }); + } + + tl.debug('Version codes to keep: ' + JSON.stringify(newTrackVersionCodes)); + versionCodes.forEach((versionCode) => { + if (newTrackVersionCodes.indexOf(versionCode) === -1) { + newTrackVersionCodes.push(versionCode); + } + }); + } + + tl.debug(`New ${track} track version codes: ` + JSON.stringify(newTrackVersionCodes)); + try { + res = await googleutil.updateTrack(edits, packageName, track, newTrackVersionCodes, userFraction, updatePriority, releaseNotes, releaseName); + } catch (e) { + tl.debug(`Failed to update track ${track}.`); + tl.debug(e); + throw new Error(tl.loc('CannotUpdateTrack', track, e)); + } + return res; +} + +run(); diff --git a/Tasks/GooglePlayReleaseV4/modules/fileHelper.ts b/Tasks/GooglePlayReleaseV4/modules/fileHelper.ts new file mode 100644 index 00000000..50592915 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/modules/fileHelper.ts @@ -0,0 +1,77 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as glob from 'glob'; +import * as tl from 'azure-pipelines-task-lib/task'; + +/** + * Get the appropriate file from the provided pattern + * @param path The minimatch pattern of glob to be resolved to file path + * @returns path path of the file resolved by glob. Returns null if not found or if `path` argument was not provided + */ +export function resolveGlobPath(path: string): string { + if (path) { + // VSTS tries to be smart when passing in paths with spaces in them by quoting the whole path. Unfortunately, this actually breaks everything, so remove them here. + path = path.replace(/\"/g, ''); + + const filesList: string[] = glob.sync(path); + if (filesList.length > 0) { + return filesList[0]; + } + + return null; + } + + return null; +} + +/** + * Get the appropriate files from the provided pattern + * @param path The minimatch pattern of glob to be resolved to file path + * @returns paths of the files resolved by glob + */ +export function resolveGlobPaths(path: string): string[] { + if (path) { + // Convert the path pattern to a rooted one. We do this to mimic for string inputs the behaviour of filePath inputs provided by Build Agent. + path = tl.resolve(tl.getVariable('System.DefaultWorkingDirectory'), path); + + let filesList: string[] = glob.sync(path); + tl.debug(`Additional paths: ${JSON.stringify(filesList)}`); + + return filesList; + } + + return []; +} + +/** + * Get obb file. Returns any file with .obb extension if present in parent directory else returns + * from apk directory with pattern: main...obb + * @param apkPath apk file path + * @param packageName package name of the apk + * @param versionCode version code of the apk + * @returns ObbPathFile of the obb file is present else null + */ +export function getObbFile(apkPath: string, packageName: string, versionCode: number): string | null { + const currentDirectory: string = path.dirname(apkPath); + const parentDirectory: string = path.dirname(currentDirectory); + + const fileNamesInParentDirectory: string[] = fs.readdirSync(parentDirectory); + const obbPathFileInParent: string | undefined = fileNamesInParentDirectory.find(file => path.extname(file) === '.obb'); + + if (obbPathFileInParent) { + tl.debug(`Found Obb file for upload in parent directory: ${obbPathFileInParent}`); + return path.join(parentDirectory, obbPathFileInParent); + } + + const fileNamesInApkDirectory: string[] = fs.readdirSync(currentDirectory); + const expectedMainObbFile: string = `main.${versionCode}.${packageName}.obb`; + const obbPathFileInCurrent: string | undefined = fileNamesInApkDirectory.find(file => file.toString() === expectedMainObbFile); + + if (!obbPathFileInCurrent) { + tl.debug(`No Obb found for ${apkPath}, skipping upload`); + return null; + } + + tl.debug(`Found Obb file for upload in current directory: ${obbPathFileInCurrent}`); + return path.join(currentDirectory, obbPathFileInCurrent); +} diff --git a/Tasks/GooglePlayReleaseV4/modules/googleutil.ts b/Tasks/GooglePlayReleaseV4/modules/googleutil.ts new file mode 100644 index 00000000..f9020d02 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/modules/googleutil.ts @@ -0,0 +1,274 @@ +import * as fs from 'fs'; +import * as tl from 'azure-pipelines-task-lib'; +import * as googleapis from 'googleapis'; +import { androidpublisher_v3 as pub3 } from 'googleapis'; // Short alias for convenience + +export const publisher: pub3.Androidpublisher = googleapis.google.androidpublisher('v3'); + +export interface ClientKey { + client_email?: string; + private_key?: string; +} + +/** + * @param key an object containing client email and private key + * @returns JWT service account credentials. + */ +export function getJWT(key: ClientKey): googleapis.Auth.JWT { + const GOOGLE_PLAY_SCOPES: string[] = ['https://www.googleapis.com/auth/androidpublisher']; + return new googleapis.Auth.JWT(key.client_email, null, key.private_key, GOOGLE_PLAY_SCOPES, null); +} + +/** + * Uses the provided JWT client to request a new edit from the Play store and attach the edit id to all requests made this session + * Assumes authorized + * @param packageName - unique android package name (com.android.etc) + * @return edit - A promise that will return result from inserting a new edit + * { id: string, expiryTimeSeconds: string } + */ +export async function getNewEdit(edits: pub3.Resource$Edits, packageName: string): Promise { + tl.debug('Creating a new edit'); + const requestParameters: pub3.Params$Resource$Edits$Insert = { + packageName: packageName + }; + + tl.debug('Additional Parameters: ' + JSON.stringify(requestParameters)); + const res = await edits.insert(requestParameters); + return res.data; +} + +/** + * Gets information for the specified app and track + * Assumes authorized + * @param packageName - unique android package name (com.android.etc) + * @param track - one of the values {"internal", "alpha", "beta", "production"} + * @returns track - A promise that will return result from updating a track + * { track: string, versionCodes: [integer], userFraction: double } + */ +export async function getTrack(edits: pub3.Resource$Edits, packageName: string, track: string): Promise { + tl.debug('Getting Track information'); + const requestParameters: pub3.Params$Resource$Edits$Tracks$Get = { + packageName: packageName, + track: track + }; + + tl.debug('Additional Parameters: ' + JSON.stringify(requestParameters)); + const getTrack = await edits.tracks.get(requestParameters); + return getTrack.data; +} + +/** + * @param edits Google API Edits + * @param packageName unique android package name (com.android.etc) + * @param track release track. Should be one of {"internal", "alpha", "beta", "production"} + * @param versionCodes version codes that will be exposed to the users of this track when this release is rolled out + * @param userFraction for rollouting out a release to a track, it's the fraction of users to get update; 1.0 is all users + * @param updatePriority in-app update priority value of the release. All newly added APKs in the release will be considered at this priority. Can take values in the range [0, 5], with 5 the highest priority. Defaults to 0. + * @param releaseNotes optional release notes to be attached as part of the update + * @param releaseName optional release name. If not set, the name is generated from the APK's version_name. If the release contains multiple APKs, the name is generated from the date + * @returns track - A promise that will return result from updating a track + * { track: string, versionCodes: [integer], userFraction: double } + */ +export async function updateTrack( + edits: pub3.Resource$Edits, + packageName: string, + track: string, + versionCodes: number | number[], + userFraction: number, + updatePriority: number, + releaseNotes?: pub3.Schema$LocalizedText[], + releaseName?: string +): Promise { + tl.debug('Updating track'); + const versionCodesArray: number[] = (Array.isArray(versionCodes) ? versionCodes : [versionCodes]); + const release: pub3.Schema$TrackRelease = { + versionCodes: versionCodesArray.map((versionCode) => versionCode.toString()), + inAppUpdatePriority: updatePriority + }; + + if (releaseName && releaseName.length > 0) { + tl.debug('Add release name: ' + releaseName); + release.name = releaseName; + } + + if (releaseNotes && releaseNotes.length > 0) { + tl.debug('Attaching release notes to the update'); + release.releaseNotes = releaseNotes; + } + + if (userFraction < 1.0) { + release.userFraction = userFraction; + release.status = 'inProgress'; + } else { + tl.debug('User fraction is more than 100% marking rollout "completed"'); + release.status = 'completed'; + } + + const requestParameters: pub3.Params$Resource$Edits$Tracks$Update = { + packageName: packageName, + track: track, + requestBody: { + track, + releases: [release] + } + }; + + tl.debug('Additional Parameters: ' + JSON.stringify(requestParameters)); + const updatedTrack = await edits.tracks.update(requestParameters); + return updatedTrack.data; +} + +/** + * Update the universal parameters attached to every request + * @param paramName - Name of parameter to add/update + * @param value - value to assign to paramName. Any value is admissible. + */ +export function updateGlobalParams(globalParams: googleapis.Common.GlobalOptions, paramName: string, value: any): void { + tl.debug('Updating Global Parameters'); + tl.debug('SETTING ' + paramName + ' TO ' + JSON.stringify(value)); + globalParams.params[paramName] = value; + googleapis.google.options(globalParams); + tl.debug('Global Params set to ' + JSON.stringify(globalParams)); +} + +/** + * Adds a bundle to an existing edit + * Assumes authorized + * @param packageName unique android package name (com.android.etc) + * @param bundleFile path to bundle file + * @returns A promise that will return result from uploading a bundle + * { versionCode: integer, binary: { sha1: string } } + */ +export async function addBundle(edits: pub3.Resource$Edits, packageName: string, bundleFile: string): Promise { + let requestParameters: pub3.Params$Resource$Edits$Bundles$Upload = { + packageName: packageName, + media: { + body: fs.createReadStream(bundleFile), + mimeType: 'application/octet-stream' + } + }; + + try { + tl.debug('Request Parameters: ' + JSON.stringify(requestParameters)); + const res = await edits.bundles.upload(requestParameters, { onUploadProgress }); + tl.debug('Returned: ' + JSON.stringify(res)); + return res.data; + } catch (e) { + tl.debug(`Failed to upload Bundle ${bundleFile}`); + tl.debug(e); + throw new Error(tl.loc('CannotUploadBundle', bundleFile, e)); + } +} + +/** + * Adds an apk to an existing edit + * Assumes authorized + * @param packageName unique android package name (com.android.etc) + * @param apkFile path to apk file + * @returns A promise that will return result from uploading an apk + * { versionCode: integer, binary: { sha1: string } } + */ +export async function addApk(edits: pub3.Resource$Edits, packageName: string, apkFile: string): Promise { + let requestParameters: pub3.Params$Resource$Edits$Apks$Upload = { + packageName: packageName, + media: { + body: fs.createReadStream(apkFile), + mimeType: 'application/vnd.android.package-archive' + } + }; + + try { + tl.debug('Request Parameters: ' + JSON.stringify(requestParameters)); + const res = await edits.apks.upload(requestParameters, { onUploadProgress }); + tl.debug('Returned: ' + JSON.stringify(res)); + return res.data; + } catch (e) { + tl.debug(`Failed to upload APK ${apkFile}`); + tl.debug(e); + throw new Error(tl.loc('CannotUploadAPK', apkFile, e)); + } +} + +/** + * Adds an obb for an apk to an existing edit + * Assumes authorized + * @param packageName unique android package name (com.android.etc) + * @param obbFile path to obb file + * @param apkVersionCode version code of the corresponding apk + * @param obbFileType type of obb to be uploaded (main/patch) + * @returns ObbResponse A promise that will return result from uploading an obb + * { expansionFile: { referencesVersion: number, fileSize: number } } + */ +export async function addObb( + edits: pub3.Resource$Edits, + packageName: string, + obbFile: string, + apkVersionCode: number, + obbFileType: string +): Promise { + const requestParameters: pub3.Params$Resource$Edits$Expansionfiles$Upload = { + packageName: packageName, + media: { + body: fs.createReadStream(obbFile), + mimeType: 'application/octet-stream' + }, + apkVersionCode: apkVersionCode, + expansionFileType: obbFileType + }; + + try { + tl.debug('Request Parameters: ' + JSON.stringify(requestParameters)); + const res = await edits.expansionfiles.upload(requestParameters, { onUploadProgress }); + tl.debug('returned: ' + JSON.stringify(res)); + return res.data; + } catch (e) { + tl.debug(`Failed to upload the Obb ${obbFile}`); + tl.debug(e); + throw new Error(tl.loc('CannotUploadExpansionFile', obbFile, e)); + } +} + +/** + * Uploads a deobfuscation file (mapping.txt) for a given package + * Assumes authorized + * @param mappingFilePath the path to the file to upload + * @param packageName unique android package name (com.android.etc) + * @param versionCode version code of uploaded APK or AAB + * @returns deobfuscationFiles A promise that will return result from uploading a deobfuscation file + * { deobfuscationFile: { symbolType: string } } + */ +export async function uploadDeobfuscation( + edits: pub3.Resource$Edits, + mappingFilePath: string, + packageName: string, + versionCode: number +): Promise { + const requestParameters: pub3.Params$Resource$Edits$Deobfuscationfiles$Upload = { + deobfuscationFileType: 'proguard', + packageName: packageName, + apkVersionCode: versionCode, + media: { + body: fs.createReadStream(mappingFilePath), + mimeType: '' + } + }; + + try { + tl.debug('Request Parameters: ' + JSON.stringify(requestParameters)); + const res = await edits.deobfuscationfiles.upload(requestParameters, { onUploadProgress }); + tl.debug('returned: ' + JSON.stringify(res)); + return res.data; + } catch (e) { + tl.debug(`Failed to upload deobfuscation file ${mappingFilePath}`); + tl.debug(e); + throw new Error(tl.loc('CannotUploadDeobfuscationFile', mappingFilePath, e)); + } +} + +/** + * Default logger for uploading files + * @param progress progress update from googleapis + */ +function onUploadProgress(progress: any): void { + tl.debug('Upload progress: ' + JSON.stringify(progress)); +} diff --git a/Tasks/GooglePlayReleaseV4/modules/inputsHelper.ts b/Tasks/GooglePlayReleaseV4/modules/inputsHelper.ts new file mode 100644 index 00000000..c6e9276b --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/modules/inputsHelper.ts @@ -0,0 +1,183 @@ +import * as tl from 'azure-pipelines-task-lib/task'; + +import * as googleutil from './googleutil'; +import * as fileHelper from './fileHelper'; + +/** + * Fills client key with valid parameters depending on auth type. Returns the filled key. + * @returns client key + */ +export function getClientKey(): googleutil.ClientKey { + const authType: string = tl.getInput('authType', true); + let key: googleutil.ClientKey = {}; + if (authType === 'JsonFile') { + const serviceAccountKeyFile: string = tl.getPathInput('serviceAccountKey', true, true); + + const stats: tl.FsStats = tl.stats(serviceAccountKeyFile); + if (stats && stats.isFile()) { + key = require(serviceAccountKeyFile); + } else { + tl.debug(`The service account file path ${serviceAccountKeyFile} points to a directory.`); + throw new Error(tl.loc('InvalidAuthFile', serviceAccountKeyFile)); + } + } else if (authType === 'ServiceEndpoint') { + const serviceEndpoint: tl.EndpointAuthorization = tl.getEndpointAuthorization(tl.getInput('serviceEndpoint', true), false); + key.client_email = serviceEndpoint.parameters['username']; + key.private_key = serviceEndpoint.parameters['password'].replace(/\\n/g, '\n'); + } + + return key; +} + +const actions = ['OnlyStoreListing', 'SingleBundle', 'SingleApk', 'MultiApkAab'] as const; +export type Action = (typeof actions)[number]; + +/** + * @param userInput the value that user provided in the 'action' input + * @returns whether the specified input is a valid action + */ +function isOfTypeAction(userInput: string): userInput is Action { + return (actions as readonly string[]).includes(userInput); +} + +/** + * Checks action value input and verifies it. Throws if non-existing action is specified + * @returns chosen action + */ +export function getAction(): Action { + const actionString: string = tl.getInput('action', false); + if (!isOfTypeAction(actionString)) { + throw new Error(tl.loc('InvalidActionInputValue', actionString)); + } + return actionString; +} + +/** + * Gets the right bundles(s) depending on the action. Uses `getApksOrAabs()` + * @param action user's action + * @returns a list of bundles + */ +export function getBundles(action: Action): string[] { + return getApksOrAabs(action, 'SingleBundle', 'bundleFile', 'bundleFiles'); +} + +/** + * Gets the right apk(s) depending on the action. Uses `getApksOrAabs()` + * @param action user's action + * @returns a list of apks + */ +export function getApks(action: Action): string[] { + return getApksOrAabs(action, 'SingleApk', 'apkFile', 'apkFiles'); +} + +/** + * Gets the right apk(s)/aab(s) depending on the action. + * This function exists to avoid code duplication: the process of getting APKs and AABs is very similar. + * @param action user's action + * @param singleAction which action would be considered a single file upload + * @param singleInput input containing single file pattern. Used if `action == singleAction` + * @param multiInput input containing multiple files patterns. Used if `action == 'MultiApkAab'` + * @returns a list of apks/aabs + */ +export function getApksOrAabs( + action: Action, + singleAction: 'SingleApk' | 'SingleBundle', + singleInput: 'apkFile' | 'bundleFile', + multiInput: 'apkFiles' | 'bundleFiles', +): string[] { + if (action === singleAction) { + const pattern: string = tl.getInput(singleInput, true); + const path: string | null = fileHelper.resolveGlobPath(pattern); + if (path === null) { + throw new Error(tl.loc('ApkOrAabNotFound', singleInput, pattern)); + } + return [path]; + } else if (action === 'MultiApkAab') { + const patterns: string[] = tl.getDelimitedInput(multiInput, '\n'); + const allPaths = new Set(); + for (const pattern of patterns) { + const paths: string[] = fileHelper.resolveGlobPaths(pattern); + paths.forEach((path) => allPaths.add(path)); + } + return Array.from(allPaths); + } + + return []; +} + +/** + * Shows warnings if some actions for the specified action have been set by the user but are not used by the task. + * @param action user's action + */ +export function warnAboutUnusedInputs(action: Action): void { + switch (action) { + case 'MultiApkAab': + warnIfUnusedInputsSet('bundleFile', 'apkFile', 'mappingFilePath'); + warnIfUnusedBoolInputsSet('shouldUploadMappingFile'); + break; + case 'SingleBundle': + warnIfUnusedInputsSet('apkFile', 'bundleFiles', 'apkFiles'); + break; + case 'SingleApk': + warnIfUnusedInputsSet('bundleFile', 'bundleFiles', 'apkFiles'); + break; + case 'OnlyStoreListing': + warnIfUnusedInputsSet('bundleFile', 'apkFile', 'bundleFiles', 'apkFiles', 'mappingFilePath'); + warnIfUnusedBoolInputsSet('changeUpdatePriority', 'rolloutToUserFraction', 'shouldUploadMappingFile'); + break; + } +} + +/** + * If any of the provided inputs are set, it will show a warning. + * @param inputs inputs to check + */ +export function warnIfUnusedInputsSet(...inputs: string[]): void { + for (const input of inputs) { + tl.debug(`Checking if unused input ${input} is set...`); + const inputValue: string | undefined = tl.getInput(input); + if (inputValue !== undefined && inputValue.length !== 0) { + tl.warning(tl.loc('SetUnusedInput', input)); + } + } +} + +/** + * If any of the provided boolean inputs are set, it will show a warning. + * @param inputs inputs to check + */ +export function warnIfUnusedBoolInputsSet(...inputs: string[]): void { + for (const input of inputs) { + tl.debug(`Checking if unused boolean input ${input} is set...`); + const inputValue: boolean = tl.getBoolInput(input); + if (inputValue) { + tl.warning(tl.loc('SetUnusedInput', input)); + } + } +} + +/** + * Gets correct version codes from replaceList inputs. If any are invalid, throws and logs them. + * @returns list of valid version codes + */ +export function getVersionCodeListInput(): number[] { + const versionCodeFilterInput: string[] = tl.getDelimitedInput('replaceList', ',', false); + const versionCodeFilter: number[] = []; + const incorrectCodes: string[] = []; + + for (const versionCode of versionCodeFilterInput) { + const versionCodeNumber: number = parseInt(versionCode.trim(), 10); + + if (versionCodeNumber && (versionCodeNumber > 0)) { + versionCodeFilter.push(versionCodeNumber); + } else { + incorrectCodes.push(versionCode.trim()); + } + } + + if (incorrectCodes.length > 0) { + throw new Error(tl.loc('IncorrectVersionCodeFilter', JSON.stringify(incorrectCodes))); + } else { + return versionCodeFilter; + } +} diff --git a/Tasks/GooglePlayReleaseV4/modules/metadataHelper.ts b/Tasks/GooglePlayReleaseV4/modules/metadataHelper.ts new file mode 100644 index 00000000..0083fbc4 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/modules/metadataHelper.ts @@ -0,0 +1,477 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as tl from 'azure-pipelines-task-lib/task'; + +import { androidpublisher_v3 as pub3 } from 'googleapis'; + +/** + * Uploads change log files if specified for all the version codes in the update + * @param changelogFile + * @param versionCodes + * @returns nothing + */ +export async function getCommonReleaseNotes(languageCode: string, changelogFile: string): Promise { + const stats: tl.FsStats = tl.stats(changelogFile); + + let releaseNotes: pub3.Schema$LocalizedText = null; + if (stats && stats.isFile()) { + console.log(tl.loc('AppendChangelog', changelogFile)); + releaseNotes = { + language: languageCode, + text: getChangelog(changelogFile) + }; + + } else { + tl.debug(`The change log path ${changelogFile} either does not exist or points to a directory. Ignoring...`); + } + return releaseNotes; +} + +/** + * Reads a change log from a file + * Assumes authorized + * @param {string} changelogFile Path to changelog file. + * @returns {string} change log file content as a string. + */ +function getChangelog(changelogFile: string): string { + tl.debug(`Reading change log from ${changelogFile}`); + try { + return fs.readFileSync(changelogFile).toString(); + } catch (e) { + tl.debug(`Change log reading from ${changelogFile} failed`); + tl.debug(e); + throw new Error(tl.loc('CannotReadChangeLog', changelogFile)); + } +} + +/** + * Adds all release notes found in directory to an edit. Pulls version code from file name. + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} directory Directory with a changesogs folder where release notes can be found. + * @returns nothing + */ +async function addAllReleaseNotes(versionCodes: number[], languageCode: string, directory: string): Promise { + const changelogDir: string = path.join(directory, 'changelogs'); + + const changelogs: string[] = filterDirectoryContents(changelogDir, stat => stat.isFile()); + + if (changelogs.length === 0) { + return []; + } + + const releaseNotes: pub3.Schema$LocalizedText[] = []; + for (const changelogFile of changelogs) { + const changelogName: string = path.basename(changelogFile, path.extname(changelogFile)); + const changelogVersion: number = parseInt(changelogName, 10); + if (!isNaN(changelogVersion) && (versionCodes.indexOf(changelogVersion) !== -1)) { + const fullChangelogPath: string = path.join(changelogDir, changelogFile); + + console.log(tl.loc('AppendChangelog', fullChangelogPath)); + releaseNotes.push({ + language: languageCode, + text: getChangelog(fullChangelogPath) + }); + tl.debug(`Found release notes version ${changelogVersion} from ${fullChangelogPath} for language code ${languageCode}`); + } else { + tl.debug(`The name of the file ${changelogFile} is not a valid version code. Skipping it.`); + } + } + + tl.debug(`All release notes found for ${changelogDir}: ${JSON.stringify(releaseNotes)}`); + return releaseNotes; +} + +/** + * Filters the directory contents to find files or directories + * @param {string} directory the directory to search + * @param {(stats: tl.FsStats) => boolean} filter callback on every item in the directory, return true to keep the results + * @returns the filtered contents of the directory + */ +function filterDirectoryContents(directory: string, filter: (stats: tl.FsStats) => boolean): string[] { + return fs.readdirSync(directory).filter(subPath => { + try { + const fullPath: string = path.join(directory, subPath); + tl.debug(`Checking path ${fullPath}`); + return filter(tl.stats(fullPath)); + } catch (e) { + tl.debug(`Failed to stat path ${subPath}:`); + tl.debug(e); + tl.debug('Ignoring...'); + return false; + } + }); +} + +/** + * Attaches the metadata in the specified directory to the edit. Assumes the metadata structure specified by Fastlane. + * Assumes authorized + * + * Metadata Structure: + * metadata + * └ $(languageCodes) + * ├ full_description.txt + * ├ short_description.txt + * ├ title.txt + * ├ video.txt + * ├ images + * | ├ featureGraphic.png || featureGraphic.jpg || featureGraphic.jpeg + * | ├ icon.png || icon.jpg || icon.jpeg + * | ├ promoGraphic.png || promoGraphic.jpg || promoGraphic.jpeg + * | ├ tvBanner.png || tvBanner.jpg || tvBanner.jpeg + * | ├ phoneScreenshots + * | | └ *.png || *.jpg || *.jpeg + * | ├ sevenInchScreenshots + * | | └ *.png || *.jpg || *.jpeg + * | ├ tenInchScreenshots + * | | └ *.png || *.jpg || *.jpeg + * | ├ tvScreenshots + * | | └ *.png || *.jpg || *.jpeg + * | └ wearScreenshots + * | └ *.png || *.jpg || *.jpeg + * └ changelogs + * └ $(versioncodes).txt + * + * @param {string} metadataRootDirectory Path to the folder where the Fastlane metadata structure is found. eg the folders under this directory should be the language codes + * @returns nothing + */ +export async function addMetadata(edits: pub3.Resource$Edits, versionCodes: number[], metadataRootDirectory: string): Promise { + const metadataLanguageCodes: string[] = filterDirectoryContents(metadataRootDirectory, stat => stat.isDirectory()); + tl.debug(`Found language codes: ${metadataLanguageCodes}`); + + let allReleaseNotes: pub3.Schema$LocalizedText[] = []; + for (const languageCode of metadataLanguageCodes) { + const metadataDirectory: string = path.join(metadataRootDirectory, languageCode); + + tl.debug(`Uploading metadata from ${metadataDirectory} for language code ${languageCode} and version codes ${versionCodes}`); + const releaseNotesForLanguage = await uploadMetadataWithLanguageCode(edits, versionCodes, languageCode, metadataDirectory); + allReleaseNotes = allReleaseNotes.concat(releaseNotesForLanguage); + } + + tl.debug(`Collected ${allReleaseNotes.length} release notes`); + return allReleaseNotes; +} + +/** + * Updates the details for a language with new information + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} directory Directory where updated listing details can be found. + * @returns nothing + */ +async function uploadMetadataWithLanguageCode(edits: pub3.Resource$Edits, versionCodes: number[], languageCode: string, directory: string): Promise { + console.log(tl.loc('UploadingMetadataForLanguage', directory, languageCode)); + + tl.debug(`Adding localized store listing for language code ${languageCode} from ${directory}`); + await addLanguageListing(edits, languageCode, directory); + + tl.debug(`Uploading change logs for language code ${languageCode} from ${directory}`); + const releaseNotes: pub3.Schema$LocalizedText[] = await addAllReleaseNotes(versionCodes, languageCode, directory); + + tl.debug(`Uploading images for language code ${languageCode} from ${directory}`); + await attachImages(edits, languageCode, directory); + + return releaseNotes; +} + +/** + * Updates the details for a language with new information + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} directory Directory where updated listing details can be found. + * @returns nothing + */ +async function addLanguageListing(edits: pub3.Resource$Edits, languageCode: string, directory: string) { + const listingResource: pub3.Schema$Listing = createListingResource(languageCode, directory); + + const isPatch:boolean = (!listingResource.fullDescription) || + (!listingResource.shortDescription) || + (!listingResource.title); + + const isEmpty:boolean = (!listingResource.fullDescription) && + (!listingResource.shortDescription) && + (!listingResource.video) && + (!listingResource.title); + + const listingRequestParameters: pub3.Params$Resource$Edits$Listings$Patch = { + language: languageCode, + requestBody: listingResource + }; + + try { + + if (isEmpty) { + tl.debug(`Skip localized ${languageCode} store listing.`); + } else if (isPatch) { + tl.debug(`Patching an existing localized ${languageCode} store listing.`); + tl.debug('Request Parameters: ' + JSON.stringify(listingRequestParameters)); + await edits.listings.patch(listingRequestParameters); + tl.debug(`Successfully patched the localized ${languageCode} store listing.`); + } else { + // The patch method fails if the listing for the language does not exist already, + // while update actually updates or creates. + tl.debug(`Updating a localized ${languageCode} store listing.`); + tl.debug('Request Parameters: ' + JSON.stringify(listingRequestParameters)); + await edits.listings.update(listingRequestParameters); + tl.debug(`Successfully updated the localized ${languageCode} store listing.`); + } + } catch (e) { + tl.debug(`Failed to create the localized ${languageCode} store listing.`); + tl.debug(e); + throw new Error(tl.loc('CannotCreateListing', languageCode, e)); + } +} + +/** + * Helper method for creating the resource for the edits.listings.update method. + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} directory Directory where updated listing details can be found. + * @returns {AndroidListingResource} resource A crafted resource for the edits.listings.update method. + * { languageCode: string, fullDescription: string, shortDescription: string, title: string, video: string } + */ +function createListingResource(languageCode: string, directory: string): pub3.Schema$Listing { + tl.debug(`Constructing resource to update listing with language code ${languageCode} from ${directory}`); + + const resourceParts = { + fullDescription: 'full_description.txt', + shortDescription: 'short_description.txt', + title: 'title.txt', + video: 'video.txt' + }; + + const resource: pub3.Schema$Listing = { + language: languageCode + }; + + for (const i in resourceParts) { + if (resourceParts.hasOwnProperty(i)) { + const file: string = path.join(directory, resourceParts[i]); + try { + const fileContents: Buffer = fs.readFileSync(file); + resource[i] = fileContents.toString(); + } catch (e) { + tl.debug(`Failed to read metadata file ${file}:`); + tl.debug(e); + tl.debug('Ignoring...'); + } + } + } + + tl.debug(`Finished constructing listing resource ${JSON.stringify(resource)}`); + return resource; +} + +/** + * Upload images to the app listing. + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} directory Directory where updated listing details can be found. + * @returns nothing + */ +async function attachImages(edits: pub3.Resource$Edits, languageCode: string, directory: string) { + const imageList: { [key: string]: string[] } = getImageList(directory); + tl.debug(`Found ${languageCode} images: ${JSON.stringify(imageList)}`); + + let cnt: number = 0; + for (const imageType of Object.keys(imageList)) { + const images: string[] = imageList[imageType]; + tl.debug(`Uploading images of type ${imageType}: ${JSON.stringify(images)}`); + + if (images.length > 0) { + await removeOldImages(edits, languageCode, imageType); + } + + for (const image of images) { + tl.debug(`Uploading image of type ${imageType} from ${image}`); + await uploadImage(edits, languageCode, imageType, image); + cnt++; + } + } + + tl.debug(`${cnt} image(s) uploaded.`); +} + +/** + * Remove existing images from the app listing. + * See the user Story 955465 and https://github.com/Microsoft/google-play-vsts-extension/issues/34. + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} imageType type of images. + * @returns nothing + */ +async function removeOldImages(edits: pub3.Resource$Edits, languageCode: string, imageType: string) { + try { + let imageRequest: pub3.Params$Resource$Edits$Images$Deleteall = { + language: languageCode, + imageType: imageType + }; + + tl.debug(`Removing old images of type ${imageType} for language ${languageCode}.`); + tl.debug('Request Parameters: ' + JSON.stringify(imageRequest)); + await edits.images.deleteall(imageRequest); + tl.debug(`Successfully removed old images of type ${imageType} for language ${languageCode}.`); + } catch (e) { + tl.debug(`Failed to remove old images of type ${imageType} for language ${languageCode}.`); + tl.debug(e); + } +} + +/** + * Get all the images in the metadata directory that need to be uploaded. + * Assumes all files are in a folder labeled "images" at the root of directory + * directory + * └ images + * ├ featureGraphic.png || featureGraphic.jpg || featureGraphic.jpeg + * ├ icon.png || icon.jpg || icon.jpeg + * ├ promoGraphic.png || promoGraphic.jpg || promoGraphic.jpeg + * ├ tvBanner.png || tvBanner.jpg || tvBanner.jpeg + * ├ phoneScreenshots + * | └ *.png || *.jpg || *.jpeg + * ├ sevenInchScreenshots + * | └ *.png || *.jpg || *.jpeg + * ├ tenInchScreenshots + * | └ *.png || *.jpg || *.jpeg + * ├ tvScreenshots + * | └ *.png || *.jpg || *.jpeg + * └ wearScreenshots + * └ *.png || *.jpg || *.jpeg + * @param {string} directory Directory where the "images" folder is found matching the structure specified above + * @returns {Object} imageList Map of image types to lists of images matching that type. + * { [imageType]: string[] } + */ +function getImageList(directory: string): { [key: string]: string[] } { + const imageTypes: string[] = ['featureGraphic', 'icon', 'promoGraphic', 'tvBanner', 'phoneScreenshots', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvScreenshots', 'wearScreenshots']; + const acceptedExtensions: string[] = ['.png', '.jpg', '.jpeg']; + + const imageDirectory: string = path.join(directory, 'images'); + const imageList: { [key: string]: string[] } = {}; + + for (const imageType of imageTypes) { + let shouldAttemptUpload: boolean = false; + + imageList[imageType] = []; + + tl.debug(`Attempting to get images of type ${imageType}`); + switch (imageType) { + case 'featureGraphic': + case 'icon': + case 'promoGraphic': + case 'tvBanner': + for (let acceptedExtension of acceptedExtensions) { + let fullPathToFileToCheck: string = path.join(imageDirectory, imageType + acceptedExtension); + try { + let imageStat: tl.FsStats = tl.stats(fullPathToFileToCheck); + if (imageStat) { + shouldAttemptUpload = imageStat.isFile(); + if (shouldAttemptUpload) { + console.log(tl.loc('FoundImageAtPath', imageType, fullPathToFileToCheck)); + imageList[imageType].push(fullPathToFileToCheck); + break; + } + } + } catch (e) { + tl.debug(`File ${fullPathToFileToCheck} doesn't exist. Skipping...`); + } + } + + if (!shouldAttemptUpload) { + console.log(tl.loc('ImageTypeNotFound', imageType)); + } + break; + case 'phoneScreenshots': + case 'sevenInchScreenshots': + case 'tenInchScreenshots': + case 'tvScreenshots': + case 'wearScreenshots': + try { + let fullPathToDirToCheck: string = path.join(imageDirectory, imageType); + let imageStat: fs.Stats = fs.statSync(fullPathToDirToCheck); + if (imageStat) { + tl.debug(`Found something for type ${imageType}`); + shouldAttemptUpload = imageStat.isDirectory(); + if (!shouldAttemptUpload) { + console.log(tl.loc('StatNotDirectory', imageType)); + } else { + imageList[imageType] = fs.readdirSync(fullPathToDirToCheck) + .filter(function (image) { + try { + return fs.statSync(path.join(fullPathToDirToCheck, image)).isFile(); + } catch (e) { + tl.debug(e); + tl.debug(`Failed to stat path ${image}. Ignoring...`); + } + + return false; + }) + .map(function (image) { + return path.join(fullPathToDirToCheck, image); + }); + } + } + } catch (e) { + tl.debug(e); + console.log(tl.loc('ImageDirNotFound', imageType)); + } + break; + default: + tl.debug(`Image type ${imageType} is an unknown type and was ignored`); + continue; + } + } + + tl.debug(`Finished enumerating images: ${JSON.stringify(imageList)}`); + return imageList; +} + +/** + * Attempts to upload the specified image to the edit + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} imageType One of the following values: "featureGraphic", "icon", "promoGraphic", "tvBanner", "phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots", "tvScreenshots", "wearScreenshots" + * @param {string} imagePath Path to image to attempt upload with + * @returns nothing + */ +async function uploadImage(edits: pub3.Resource$Edits, languageCode: string, imageType: string, imagePath: string) { + // Docs at https://developers.google.com/android-publisher/api-ref/edits/images/upload + const imageRequest: pub3.Params$Resource$Edits$Images$Upload = { + language: languageCode, + imageType: imageType + }; + // imageRequest.uploadType = 'media'; + imageRequest.media = { + body: fs.createReadStream(imagePath), + mimeType: helperResolveImageMimeType(imagePath) + }; + + try { + tl.debug(`Uploading image ${imagePath} of type ${imageType}.`); + tl.debug('Request Parameters: ' + JSON.stringify(imageRequest)); + await edits.images.upload(imageRequest); + tl.debug(`Successfully uploaded image ${imagePath} of type ${imageType}.`); + } catch (e) { + tl.debug(`Failed to upload image ${imagePath} of type ${imageType}.`); + tl.debug(e); + throw new Error(tl.loc('UploadImageFail')); + } +} + +/** + * Attempts to resolve the image mime type of the given path. + * Not compelete. DO NOT REUSE. + * @param {string} imagePath Path to attempt to resolve image mime for. + * @returns {string} mimeType Google Play accepted image mime type that imagePath most closely maps to. + */ +function helperResolveImageMimeType(imagePath: string): string { + const extension: string = imagePath.split('.').pop(); + + switch (extension) { + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + default: + tl.debug(`Could not resolve image mime type for ${imagePath}. Defaulting to jpeg.`); + return 'image/jpeg'; + } +} diff --git a/Tasks/GooglePlayReleaseV4/package-lock.json b/Tasks/GooglePlayReleaseV4/package-lock.json new file mode 100644 index 00000000..ed596077 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/package-lock.json @@ -0,0 +1,692 @@ +{ + "name": "google-play-release-v4", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/concat-stream": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-1.6.1.tgz", + "integrity": "sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA==", + "requires": { + "@types/node": "*" + } + }, + "@types/form-data": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-0.0.33.tgz", + "integrity": "sha1-yayFsqX9GENbjIXZ7LUObWyJP/g=", + "requires": { + "@types/node": "*" + } + }, + "@types/mocha": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.0.0.tgz", + "integrity": "sha512-scN0hAWyLVAvLR9AyW7HoFF5sJZglyBsbPuHO4fv7JRvfmPBMfp1ozWqOf/e4wwPNxezBZXRfWzMb6iFLgEVRA==" + }, + "@types/node": { + "version": "16.7.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.7.10.tgz", + "integrity": "sha512-S63Dlv4zIPb8x6MMTgDq5WWRJQe56iBEY0O3SOFA9JrRienkOVDXSXBjjJw6HTNQYSE2JI6GMCR6LVbIMHJVvA==" + }, + "@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" + }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + } + }, + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==" + }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "azure-pipelines-task-lib": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/azure-pipelines-task-lib/-/azure-pipelines-task-lib-3.1.9.tgz", + "integrity": "sha512-K7gSptgXzQopg4hZ5ABgPKU5WPeSVX4UfiH9T4lSUdmDw/6+3o4/AXkUFcgPVWGXefNLx6GzHcKYRUE1r+KwZQ==", + "requires": { + "minimatch": "3.0.4", + "mockery": "^1.7.0", + "q": "^1.5.1", + "semver": "^5.1.0", + "shelljs": "^0.8.4", + "sync-request": "6.1.0", + "uuid": "^3.0.1" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bignumber.js": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.1.tgz", + "integrity": "sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA==" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "requires": { + "ms": "2.1.2" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "fast-text-encoding": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz", + "integrity": "sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig==" + }, + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "gaxios": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-4.3.1.tgz", + "integrity": "sha512-9qXV7yrMCGzTrphl9/YGMVH41oSg0rhn1j3wJWed4Oqk45/hXDD2wBT5J1NjQcqTCcv4g3nFnyQ7reSRHNgBgw==", + "requires": { + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.1" + } + }, + "gcp-metadata": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-4.3.1.tgz", + "integrity": "sha512-x850LS5N7V1F3UcV7PoupzGsyD6iVwTVvsh3tbXfkctZnBnjW5yu5z1/3k3SehF7TyoTIe78rJs02GMMy+LF+A==", + "requires": { + "gaxios": "^4.0.0", + "json-bigint": "^1.0.0" + } + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "get-port": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", + "integrity": "sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=" + }, + "glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "google-auth-library": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.9.1.tgz", + "integrity": "sha512-cWGykH2WBR+UuYPGRnGVZ6Cjq2ftQiEIFjQWNIRIauZH7hUWoYTr/lkKUqLTYt5dex77nlWWVQ8aPV80mhfp5w==", + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^4.0.0", + "gcp-metadata": "^4.2.0", + "gtoken": "^5.0.4", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" + } + }, + "google-p12-pem": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-3.1.2.tgz", + "integrity": "sha512-tjf3IQIt7tWCDsa0ofDQ1qqSCNzahXDxdAGJDbruWqu3eCg5CKLYKN+hi0s6lfvzYZ1GDVr+oDF9OOWlDSdf0A==", + "requires": { + "node-forge": "^0.10.0" + } + }, + "googleapis": { + "version": "85.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-85.0.0.tgz", + "integrity": "sha512-9zFsCbxz/642PROcYJsg/CCm89U1qe15c0Wtv7bmZ8cWYLD1Jszc5z+xTNoXZxnomLbvQaHeKBCPh7RdAccYOA==", + "requires": { + "google-auth-library": "^7.0.2", + "googleapis-common": "^5.0.2" + } + }, + "googleapis-common": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-5.0.5.tgz", + "integrity": "sha512-o2dgoW4x4fLIAN+IVAOccz3mEH8Lj1LP9c9BSSvkNJEn+U7UZh0WSr4fdH08x5VH7+sstIpd1lOYFZD0g7j4pw==", + "requires": { + "extend": "^3.0.2", + "gaxios": "^4.0.0", + "google-auth-library": "^7.0.2", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^8.0.0" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, + "gtoken": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-5.3.1.tgz", + "integrity": "sha512-yqOREjzLHcbzz1UrQoxhBtpk8KjrVhuqPE7od1K2uhyxG2BHjKZetlbLw/SPZak/QqTIQW+addS+EcjqQsZbwQ==", + "requires": { + "gaxios": "^4.0.0", + "google-p12-pem": "^3.0.3", + "jws": "^4.0.0" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" + }, + "http-basic": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/http-basic/-/http-basic-8.1.3.tgz", + "integrity": "sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw==", + "requires": { + "caseless": "^0.12.0", + "concat-stream": "^1.6.2", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + } + }, + "http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "requires": { + "@types/node": "^10.0.3" + }, + "dependencies": { + "@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + } + } + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==" + }, + "is-core-module": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.6.0.tgz", + "integrity": "sha512-wShG8vs60jKfPWpF2KZRaAtvt3a20OAn7+IJ6hLPECpSABLcKtFKTTI4ZtH5QcBruBHlq+WsdHWyz0BCZW7svQ==", + "requires": { + "has": "^1.0.3" + } + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "requires": { + "bignumber.js": "^9.0.0" + } + }, + "jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "requires": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "mime-db": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.49.0.tgz", + "integrity": "sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==" + }, + "mime-types": { + "version": "2.1.32", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.32.tgz", + "integrity": "sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==", + "requires": { + "mime-db": "1.49.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "mockery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/mockery/-/mockery-1.7.0.tgz", + "integrity": "sha1-9O3g2HUMHJcnwnLqLGBiniyaHE8=" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, + "node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" + }, + "object-inspect": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz", + "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha1-juqz5U+laSD+Fro493+iGqzC104=" + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "promise": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.1.0.tgz", + "integrity": "sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q==", + "requires": { + "asap": "~2.0.6" + } + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=" + }, + "qs": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz", + "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==", + "requires": { + "side-channel": "^1.0.4" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "requires": { + "resolve": "^1.1.6" + } + }, + "resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "requires": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + }, + "shelljs": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.4.tgz", + "integrity": "sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==", + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "sync-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/sync-request/-/sync-request-6.1.0.tgz", + "integrity": "sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw==", + "requires": { + "http-response-object": "^3.0.1", + "sync-rpc": "^1.2.1", + "then-request": "^6.0.0" + } + }, + "sync-rpc": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/sync-rpc/-/sync-rpc-1.3.6.tgz", + "integrity": "sha512-J8jTXuZzRlvU7HemDgHi3pGnh/rkoqR/OZSjhTyyZrEkkYQbk7Z33AXp37mkPfPpfdOuj7Ex3H/TJM1z48uPQw==", + "requires": { + "get-port": "^3.1.0" + } + }, + "then-request": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/then-request/-/then-request-6.0.2.tgz", + "integrity": "sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA==", + "requires": { + "@types/concat-stream": "^1.6.0", + "@types/form-data": "0.0.33", + "@types/node": "^8.0.0", + "@types/qs": "^6.2.31", + "caseless": "~0.12.0", + "concat-stream": "^1.6.0", + "form-data": "^2.2.0", + "http-basic": "^8.1.1", + "http-response-object": "^3.0.1", + "promise": "^8.0.0", + "qs": "^6.4.0" + }, + "dependencies": { + "@types/node": { + "version": "8.10.66", + "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.66.tgz", + "integrity": "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw==" + } + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, + "typescript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.2.tgz", + "integrity": "sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ==", + "dev": true + }, + "url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha1-/FZaPMy/93MMd19WQflVV5FDnyE=" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } +} diff --git a/Tasks/GooglePlayReleaseV4/package.json b/Tasks/GooglePlayReleaseV4/package.json new file mode 100644 index 00000000..77ddffcc --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/package.json @@ -0,0 +1,14 @@ +{ + "name": "google-play-release-v4", + "version": "1.0.0", + "dependencies": { + "@types/mocha": "^9.0.0", + "@types/node": "^16.7.10", + "azure-pipelines-task-lib": "^3.1.9", + "glob": "^7.1.7", + "googleapis": "^85.0.0" + }, + "devDependencies": { + "typescript": "4.0.2" + } +} diff --git a/Tasks/GooglePlayReleaseV4/task.json b/Tasks/GooglePlayReleaseV4/task.json new file mode 100644 index 00000000..3f764af9 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/task.json @@ -0,0 +1,334 @@ +{ + "id": "b0388a05-ace6-4d51-a99b-39356749e634", + "name": "GooglePlayRelease", + "friendlyName": "Google Play - Release", + "description": "Release an app to the Google Play Store", + "author": "Microsoft Corporation", + "category": "Deploy", + "visibility": [ + "Build", + "Release" + ], + "demands": [], + "version": { + "Major": "4", + "Minor": "194", + "Patch": "0" + }, + "minimumAgentVersion": "2.182.1", + "instanceNameFormat": "Release $(applicationId) to $(track)", + "groups": [ + { + "name": "advanced", + "displayName": "Advanced Options", + "isExpanded": false + } + ], + "inputs": [ + { + "name": "authType", + "label": "Authentication method", + "defaultValue": "ServiceEndpoint", + "type": "pickList", + "helpMarkDown": "", + "options": { + "JsonFile": "JSON Auth File", + "ServiceEndpoint": "Service connection" + } + }, + { + "name": "serviceEndpoint", + "aliases": [ + "serviceConnection" + ], + "label": "Service connection", + "defaultValue": "", + "required": true, + "type": "connectedService:google-play", + "helpMarkDown": "Google Play service connection that is configured with your account credentials.", + "visibleRule": "authType = ServiceEndpoint" + }, + { + "name": "serviceAccountKey", + "label": "JSON key path", + "defaultValue": "", + "required": true, + "type": "filePath", + "helpMarkDown": "The JSON file provided by Google Play that includes the service account's identity you wish to publish your APKs or AABs under.", + "visibleRule": "authType = JsonFile" + }, + { + "name": "applicationId", + "label": "Application id (com.google.MyApp)", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "The application id of APK or AAB you want to release, e.g. com.company.MyApp." + }, + { + "name": "action", + "label": "Action", + "defaultValue": "SingleBundle", + "required": true, + "type": "pickList", + "helpMarkDown": "", + "options": { + "OnlyStoreListing": "Only update store listing", + "SingleBundle": "Upload single bundle", + "SingleApk": "Upload single apk", + "MultiApkAab": "Upload multiple apk/aab files" + } + }, + { + "name": "bundleFile", + "label": "Bundle path", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "Path to the bundle file you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.aab_ to match the first AAB file, in any directory.", + "visibleRule": "action = SingleBundle" + }, + { + "name": "apkFile", + "label": "APK path", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "Path to the APK file you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.apk_ to match the first APK file, in any directory.", + "visibleRule": "action = SingleApk" + }, + { + "name": "bundleFiles", + "label": "Bundle paths", + "defaultValue": "", + "type": "multiLine", + "helpMarkDown": "Paths to the bundle files you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.aab_ to match all AAB files, in any directory.", + "visibleRule": "action = MultiApkAab" + }, + { + "name": "apkFiles", + "label": "APK paths", + "defaultValue": "", + "type": "multiLine", + "helpMarkDown": "Paths to the APK files you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.apk_ to match all APK files, in any directory.", + "visibleRule": "action = MultiApkAab" + }, + { + "name": "shouldPickObbFile", + "label": "Upload OBB for APK", + "defaultValue": false, + "type": "boolean", + "helpMarkDown": "Select this option to pick expansion file for the apk(s). If present in the parent directory, it will pick the first file with .obb extension, else it will pick from apk directory with expected format as main...obb", + "visibleRule": "action = SingleApk || action = MultiApkAab" + }, + { + "name": "track", + "label": "Track", + "defaultValue": "internal", + "required": true, + "type": "pickList", + "helpMarkDown": "Track you want to publish the apk(s)/aab(s) to.", + "options": { + "internal": "Internal test", + "alpha": "Alpha", + "beta": "Beta", + "production": "Production" + }, + "properties": { + "EditableOptions": "True" + } + }, + { + "name": "shouldAttachMetadata", + "label": "Update metadata", + "defaultValue": false, + "type": "boolean", + "helpMarkDown": "Select this option to update the metadata in fastlane format on your app release." + }, + { + "name": "changeLogFile", + "label": "Release notes (file)", + "defaultValue": "", + "type": "filePath", + "helpMarkDown": "Path to the file specifying the release notes (change log) for the application you are publishing.", + "visibleRule": "shouldAttachMetadata = false" + }, + { + "name": "languageCode", + "label": "Language code", + "defaultValue": "en-US", + "type": "string", + "helpMarkDown": "An IETF language tag identifying the language of the release notes as specified in the BCP-47 document. Default value is _en-US_", + "visibleRule": "shouldAttachMetadata = false" + }, + { + "name": "metadataRootPath", + "label": "Metadata root directory", + "defaultValue": "", + "required": true, + "type": "filePath", + "helpMarkDown": "The path to the metadata folder with the fastlane metadata structure.", + "visibleRule": "shouldAttachMetadata = true" + }, + { + "name": "changeUpdatePriority", + "label": "Set in-app update priority", + "defaultValue": false, + "type": "boolean", + "helpMarkDown": "Change the in-app update priority value.", + "visibleRule": "action != OnlyStoreListing", + "groupName": "advanced" + }, + { + "name": "updatePriority", + "label": "In-app Update Priority", + "defaultValue": "0", + "required": true, + "type": "pickList", + "helpMarkDown": "Set a custom in-app update priority value to help keep your app up-to-date on your users' devices. To determine priority, Google Play uses an integer value between 0 and 5, with 0 being the default, and 5 being the highest priority. Priority can only be set when rolling out a new release, and cannot be changed later.", + "visibleRule": "action != OnlyStoreListing && changeUpdatePriority = true", + "groupName": "advanced", + "options": { + "0": "0", + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5" + } + }, + { + "name": "rolloutToUserFraction", + "label": "Roll out release", + "defaultValue": false, + "type": "boolean", + "helpMarkDown": "Roll out the release to a percentage of users.", + "visibleRule": "action != OnlyStoreListing", + "groupName": "advanced" + }, + { + "name": "userFraction", + "label": "Rollout fraction", + "defaultValue": "1.0", + "required": true, + "type": "string", + "helpMarkDown": "The percentage of users the specified application will be released to for the specified 'Track'. It can be increased later with the 'Google Play - Increase Rollout' task.", + "visibleRule": "action != OnlyStoreListing && rolloutToUserFraction = true", + "groupName": "advanced" + }, + { + "name": "shouldUploadMappingFile", + "label": "Upload deobfuscation file (mapping.txt)", + "defaultValue": false, + "type": "boolean", + "helpMarkDown": "Select this option to attach your proguard mapping.txt file to your aab/apk.", + "visibleRule": "action != OnlyStoreListing && action != MultiApkAab", + "groupName": "advanced" + }, + { + "name": "mappingFilePath", + "label": "Deobfuscation path", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "The path to the proguard mapping.txt file to upload. Glob patterns are supported.", + "visibleRule": "action != OnlyStoreListing && action != MultiApkAab && shouldUploadMappingFile = true", + "groupName": "advanced" + }, + { + "name": "changesNotSentForReview", + "type": "boolean", + "label": "Send changes to review", + "defaultValue": false, + "helpMarkDown": "Select this option to send changes for review in GooglePlay Console. If changes are already sent for review automatically, you shouldn't select this option. [More info](https://developers.google.com/android-publisher/api-ref/rest/v3/edits/commit#query-parameters).", + "groupName": "advanced" + }, + { + "name": "releaseName", + "type": "string", + "label": "Release name", + "defaultValue": "", + "helpMarkDown": "The release name is only for use in Play Console and won't be visible to users. To make your release easier to identify, add a release name that's meaningful to you.", + "visibleRule": "action != OnlyStoreListing", + "groupName": "advanced" + }, + { + "name": "versionCodeFilterType", + "label": "Replace version codes", + "defaultValue": "all", + "type": "pickList", + "helpMarkDown": "Specify version codes to replace in the selected track with the new aab(s)/apk(s): all, the comma separated list, or a regular expression pattern.", + "visibleRule": "action != OnlyStoreListing", + "groupName": "advanced", + "options": { + "all": "All", + "list": "List", + "expression": "Regular expression" + } + }, + { + "name": "replaceList", + "label": "Version code list", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "The comma separated list of version codes to be removed from the track with this deployment.", + "visibleRule": "action != OnlyStoreListing && versionCodeFilterType = list", + "groupName": "advanced" + }, + { + "name": "replaceExpression", + "label": "Version code pattern", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "The regular expression pattern to select a list of version codes to be removed from the track with this deployment, e.g. _.\\*12?(3|4)?5_ ", + "visibleRule": "action != OnlyStoreListing && versionCodeFilterType = expression", + "groupName": "advanced" + } + ], + "execution": { + "Node10": { + "target": "main.js", + "argumentFormat": "" + } + }, + "messages": { + "ApkOrAabNotFound": "Could not find %s using pattern %s", + "AppendChangelog": "Appending changelog %s", + "AttachingMetadataToRelease": "Attempting to attach metadata to release...", + "CannotCreateListing": "Failed to create the localized %s store listing. Failed with message: %s.", + "CannotDownloadTrack": "Failed to download track %s information. Failed with message: %s.", + "CannotReadChangeLog": "Failed to read change log %s. Failed with message: %s.", + "CannotUpdateTrack": "Failed to update track %s information. Failed with message: %s.", + "CannotUploadApk": "Failed to upload the APK %s. Failed with message: %s.", + "CannotUploadBundle": "Failed to upload the bundle %s. Failed with message: %s.", + "CannotUploadDeobfuscationFile": "Failed to upload the deobfuscation file %s. Failed with message: %s.", + "CannotUploadExpansionFile": "Failed to upload the expansion file %s. Failed with message: %s.", + "FoundDeobfuscationFile": "Found deobfuscation (mapping) file: %s", + "FoundImageAtPath": "Found image for type %s at %s", + "GetNewEditAfterAuth": "Authenticated with Google Play and getting new edit", + "ImageDirNotFound": "Image directory for %s was not found. Skipping...", + "ImageTypeNotFound": "Image for %s was not found. Skipping...", + "IncorrectVersionCodeFilter": "Version code list specified contains incorrect codes: %s", + "InvalidActionInputValue": "Action input value is invalid: $s. Please recheck pipeline task configuration.", + "InvalidAuthFile": "%s is not a valid auth file", + "MustProvideApkIfObb": "shouldPickObbFile input is enabled, but no apk files could be found", + "MustProvideApkOrAab": "You must provide either apk or aab file(s). Neither were found.", + "PublishSucceed": "App was successfully published!", + "SetUnusedInput": "Input %s was set, but it will not be used in this action", + "StatNotDirectory": "Stat returned that %s was not a directory. Is there a file that shares this name?", + "TrackInfo": "Track: %s", + "UpdateTrack": "Updating track information...", + "UploadImageFail": "Failed to upload image.", + "UploadingMetadataForLanguage": "Attempting to upload metadata in %s for language code %s" + }, + "restrictions": { + "commands": { + "mode": "restricted" + }, + "settableVariables": { + "allowed": [] + } + } +} diff --git a/Tasks/GooglePlayReleaseV4/task.loc.json b/Tasks/GooglePlayReleaseV4/task.loc.json new file mode 100644 index 00000000..b82b6d2f --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/task.loc.json @@ -0,0 +1,335 @@ +{ + "id": "b0388a05-ace6-4d51-a99b-39356749e634", + "name": "GooglePlayRelease", + "friendlyName": "ms-resource:loc.friendlyName", + "description": "ms-resource:loc.description", + "author": "Microsoft Corporation", + "category": "Deploy", + "visibility": [ + "Build", + "Release" + ], + "demands": [], + "version": { + "Major": "4", + "Minor": "194", + "Patch": "0" + }, + "minimumAgentVersion": "2.182.1", + "instanceNameFormat": "ms-resource:loc.instanceNameFormat", + "groups": [ + { + "name": "advanced", + "displayName": "ms-resource:loc.group.displayName.advanced", + "isExpanded": false + } + ], + "inputs": [ + { + "name": "authType", + "label": "ms-resource:loc.input.label.authType", + "defaultValue": "ServiceEndpoint", + "type": "pickList", + "helpMarkDown": "", + "options": { + "JsonFile": "JSON Auth File", + "ServiceEndpoint": "Service connection" + } + }, + { + "name": "serviceEndpoint", + "aliases": [ + "serviceConnection" + ], + "label": "ms-resource:loc.input.label.serviceEndpoint", + "defaultValue": "", + "required": true, + "type": "connectedService:google-play", + "helpMarkDown": "ms-resource:loc.input.help.serviceEndpoint", + "visibleRule": "authType = ServiceEndpoint" + }, + { + "name": "serviceAccountKey", + "label": "ms-resource:loc.input.label.serviceAccountKey", + "defaultValue": "", + "required": true, + "type": "filePath", + "helpMarkDown": "ms-resource:loc.input.help.serviceAccountKey", + "visibleRule": "authType = JsonFile" + }, + { + "name": "applicationId", + "label": "ms-resource:loc.input.label.applicationId", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "ms-resource:loc.input.help.applicationId" + }, + { + "name": "action", + "label": "ms-resource:loc.input.label.action", + "defaultValue": "SingleBundle", + "required": true, + "type": "pickList", + "helpMarkDown": "", + "options": { + "OnlyStoreListing": "Only update store listing", + "SingleBundle": "Upload single bundle", + "SingleApk": "Upload single apk", + "MultiApkAab": "Upload multiple apk/aab files" + } + }, + { + "name": "bundleFile", + "label": "ms-resource:loc.input.label.bundleFile", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "ms-resource:loc.input.help.bundleFile", + "visibleRule": "action = SingleBundle" + }, + { + "name": "apkFile", + "label": "ms-resource:loc.input.label.apkFile", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "ms-resource:loc.input.help.apkFile", + "visibleRule": "action = SingleApk" + }, + { + "name": "bundleFiles", + "label": "ms-resource:loc.input.label.bundleFiles", + "defaultValue": "", + "type": "multiLine", + "helpMarkDown": "ms-resource:loc.input.help.bundleFiles", + "visibleRule": "action = MultiApkAab" + }, + { + "name": "apkFiles", + "label": "ms-resource:loc.input.label.apkFiles", + "defaultValue": "", + "type": "multiLine", + "helpMarkDown": "ms-resource:loc.input.help.apkFiles", + "visibleRule": "action = MultiApkAab" + }, + { + "name": "shouldPickObbFile", + "label": "ms-resource:loc.input.label.shouldPickObbFile", + "defaultValue": false, + "type": "boolean", + "helpMarkDown": "ms-resource:loc.input.help.shouldPickObbFile", + "visibleRule": "action = SingleApk || action = MultiApkAab" + }, + { + "name": "track", + "label": "ms-resource:loc.input.label.track", + "defaultValue": "internal", + "required": true, + "type": "pickList", + "helpMarkDown": "ms-resource:loc.input.help.track", + "options": { + "internal": "Internal test", + "alpha": "Alpha", + "beta": "Beta", + "production": "Production" + }, + "properties": { + "EditableOptions": "True" + } + }, + { + "name": "shouldAttachMetadata", + "label": "ms-resource:loc.input.label.shouldAttachMetadata", + "defaultValue": false, + "type": "boolean", + "helpMarkDown": "ms-resource:loc.input.help.shouldAttachMetadata" + }, + { + "name": "changeLogFile", + "label": "ms-resource:loc.input.label.changeLogFile", + "defaultValue": "", + "type": "filePath", + "helpMarkDown": "ms-resource:loc.input.help.changeLogFile", + "visibleRule": "shouldAttachMetadata = false" + }, + { + "name": "languageCode", + "label": "ms-resource:loc.input.label.languageCode", + "defaultValue": "en-US", + "type": "string", + "helpMarkDown": "ms-resource:loc.input.help.languageCode", + "visibleRule": "shouldAttachMetadata = false" + }, + { + "name": "metadataRootPath", + "label": "ms-resource:loc.input.label.metadataRootPath", + "defaultValue": "", + "required": true, + "type": "filePath", + "helpMarkDown": "ms-resource:loc.input.help.metadataRootPath", + "visibleRule": "shouldAttachMetadata = true" + }, + { + "name": "changeUpdatePriority", + "label": "ms-resource:loc.input.label.changeUpdatePriority", + "defaultValue": false, + "type": "boolean", + "helpMarkDown": "ms-resource:loc.input.help.changeUpdatePriority", + "visibleRule": "action != OnlyStoreListing", + "groupName": "advanced" + }, + { + "name": "updatePriority", + "label": "ms-resource:loc.input.label.updatePriority", + "defaultValue": "0", + "required": true, + "type": "pickList", + "helpMarkDown": "ms-resource:loc.input.help.updatePriority", + "visibleRule": "action != OnlyStoreListing && changeUpdatePriority = true", + "groupName": "advanced", + "options": { + "0": "0", + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5" + } + }, + { + "name": "rolloutToUserFraction", + "label": "ms-resource:loc.input.label.rolloutToUserFraction", + "defaultValue": false, + "type": "boolean", + "helpMarkDown": "ms-resource:loc.input.help.rolloutToUserFraction", + "visibleRule": "action != OnlyStoreListing", + "groupName": "advanced" + }, + { + "name": "userFraction", + "label": "ms-resource:loc.input.label.userFraction", + "defaultValue": "1.0", + "required": true, + "type": "string", + "helpMarkDown": "ms-resource:loc.input.help.userFraction", + "visibleRule": "action != OnlyStoreListing && rolloutToUserFraction = true", + "groupName": "advanced" + }, + { + "name": "shouldUploadMappingFile", + "label": "ms-resource:loc.input.label.shouldUploadMappingFile", + "defaultValue": false, + "type": "boolean", + "helpMarkDown": "ms-resource:loc.input.help.shouldUploadMappingFile", + "visibleRule": "action != OnlyStoreListing && action != MultiApkAab", + "groupName": "advanced" + }, + { + "name": "mappingFilePath", + "label": "ms-resource:loc.input.label.mappingFilePath", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "ms-resource:loc.input.help.mappingFilePath", + "visibleRule": "action != OnlyStoreListing && action != MultiApkAab && shouldUploadMappingFile = true", + "groupName": "advanced" + }, + { + "name": "changesNotSentForReview", + "type": "boolean", + "label": "ms-resource:loc.input.label.changesNotSentForReview", + "defaultValue": false, + "helpMarkDown": "ms-resource:loc.input.help.changesNotSentForReview", + "groupName": "advanced" + }, + { + "name": "releaseName", + "type": "string", + "label": "ms-resource:loc.input.label.releaseName", + "defaultValue": "", + "helpMarkDown": "ms-resource:loc.input.help.releaseName", + "visibleRule": "action != OnlyStoreListing", + "groupName": "advanced" + }, + { + "name": "versionCodeFilterType", + "label": "ms-resource:loc.input.label.versionCodeFilterType", + "defaultValue": "all", + "type": "pickList", + "helpMarkDown": "ms-resource:loc.input.help.versionCodeFilterType", + "visibleRule": "action != OnlyStoreListing", + "groupName": "advanced", + "options": { + "all": "All", + "list": "List", + "expression": "Regular expression" + } + }, + { + "name": "replaceList", + "label": "ms-resource:loc.input.label.replaceList", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "ms-resource:loc.input.help.replaceList", + "visibleRule": "action != OnlyStoreListing && versionCodeFilterType = list", + "groupName": "advanced" + }, + { + "name": "replaceExpression", + "label": "ms-resource:loc.input.label.replaceExpression", + "defaultValue": "", + "required": true, + "type": "string", + "helpMarkDown": "ms-resource:loc.input.help.replaceExpression", + "visibleRule": "action != OnlyStoreListing && versionCodeFilterType = expression", + "groupName": "advanced" + } + ], + "execution": { + "Node10": { + "target": "main.js", + "argumentFormat": "" + } + }, + "messages": { + "ApkOrAabNotFound": "ms-resource:loc.messages.ApkOrAabNotFound", + "AppendChangelog": "ms-resource:loc.messages.AppendChangelog", + "AttachingMetadataToRelease": "ms-resource:loc.messages.AttachingMetadataToRelease", + "CannotCreateListing": "ms-resource:loc.messages.CannotCreateListing", + "CannotDownloadTrack": "ms-resource:loc.messages.CannotDownloadTrack", + "CannotReadChangeLog": "ms-resource:loc.messages.CannotReadChangeLog", + "CannotUpdateTrack": "ms-resource:loc.messages.CannotUpdateTrack", + "CannotUploadApk": "ms-resource:loc.messages.CannotUploadApk", + "CannotUploadBundle": "ms-resource:loc.messages.CannotUploadBundle", + "CannotUploadDeobfuscationFile": "ms-resource:loc.messages.CannotUploadDeobfuscationFile", + "CannotUploadExpansionFile": "ms-resource:loc.messages.CannotUploadExpansionFile", + "FoundDeobfuscationFile": "ms-resource:loc.messages.FoundDeobfuscationFile", + "FoundImageAtPath": "ms-resource:loc.messages.FoundImageAtPath", + "GetNewEditAfterAuth": "ms-resource:loc.messages.GetNewEditAfterAuth", + "ImageDirNotFound": "ms-resource:loc.messages.ImageDirNotFound", + "ImageTypeNotFound": "ms-resource:loc.messages.ImageTypeNotFound", + "IncorrectVersionCodeFilter": "ms-resource:loc.messages.IncorrectVersionCodeFilter", + "InvalidActionInputValue": "ms-resource:loc.messages.InvalidActionInputValue", + "InvalidAuthFile": "ms-resource:loc.messages.InvalidAuthFile", + "MustProvideApkIfObb": "ms-resource:loc.messages.MustProvideApkIfObb", + "MustProvideApkOrAab": "ms-resource:loc.messages.MustProvideApkOrAab", + "PublishSucceed": "ms-resource:loc.messages.PublishSucceed", + "SetUnusedInput": "ms-resource:loc.messages.SetUnusedInput", + "StatNotDirectory": "ms-resource:loc.messages.StatNotDirectory", + "TrackInfo": "ms-resource:loc.messages.TrackInfo", + "UpdateTrack": "ms-resource:loc.messages.UpdateTrack", + "UploadImageFail": "ms-resource:loc.messages.UploadImageFail", + "UploadingMetadataForLanguage": "ms-resource:loc.messages.UploadingMetadataForLanguage" + }, + "restrictions": { + "commands": { + "mode": "restricted" + }, + "settableVariables": { + "allowed": [] + } + }, + "helpMarkDown": "ms-resource:loc.helpMarkDown" +} \ No newline at end of file diff --git a/Tasks/GooglePlayReleaseV4/tsconfig.json b/Tasks/GooglePlayReleaseV4/tsconfig.json new file mode 100644 index 00000000..04d28cc6 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "commonjs", + "sourceMap": true + }, + "exclude": [ + "node_modules" + ] +} diff --git a/Tasks/GooglePlayReleaseV4/tslint.json b/Tasks/GooglePlayReleaseV4/tslint.json new file mode 100644 index 00000000..323b6929 --- /dev/null +++ b/Tasks/GooglePlayReleaseV4/tslint.json @@ -0,0 +1,64 @@ +{ + "rules": { + "align": [true, + "parameters", + "arguments", + "statements" + ], + "class-name": true, + "curly": true, + "eofline": true, + "forin": true, + "indent": [true, "spaces", 4], + "label-position": true, + "label-undefined": true, + "max-line-length": [false, 160], + "no-arg": true, + "no-bitwise": true, + "no-console": [true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-consecutive-blank-lines": true, + "no-construct": true, + "no-debugger": true, + "no-duplicate-key": true, + "no-duplicate-variable": true, + "no-empty": true, + "no-eval": true, + "no-require-imports": false, + "no-null-keyword": false, + "no-string-literal": false, + "no-switch-case-fall-through": true, + "no-trailing-whitespace": true, + "no-unused-variable": true, + "no-unreachable": true, + "no-use-before-declare": true, + "no-var-keyword": true, + "one-line": [true, + "check-open-brace", + "check-catch", + "check-else", + "check-whitespace" + ], + "quotemark": [true, "single", "avoid-escape"], + "radix": false, + "semicolon": true, + "trailing-comma": [true, {"multiline": "never", "singleline": "never"}], + "triple-equals": [true, "allow-null-check"], + "variable-name": [true, + "check-format", + "allow-leading-underscore", + "ban-keywords" + ], + "whitespace": [true, + "check-branch", + "check-decl", + "check-operator", + "check-separator" + ] + } +} diff --git a/baseREADME.md b/baseREADME.md index 7b1a8119..f39ff12e 100644 --- a/baseREADME.md +++ b/baseREADME.md @@ -4,7 +4,10 @@ This extension contains a set of deployment tasks which allow you to automate th ## Prerequisites +This extension supports Visual Studio Team Services (VSTS) and Team Foundation Server (TFS) 2017 and later. + In order to automate the release of app updates to the Google Play store, you need to have manually released at least one version through the [Google Play Developer Console](https://play.google.com/apps/publish/). Additionally, you need to create a service account that is authorized to manage your app(s) releases on your behalf and can be used to authenticate "headlessly" from your VSTS build/release definitions. If you haven't already done so, then perform the following steps to create a service account: +> For a more in depth guide [click this link](https://docs.microsoft.com/en-us/appcenter/distribution/stores/googleplay). 1. Login to the [Google Play Developer Console](https://play.google.com/apps/publish/) and select **Settings** in the left-hand navigation menu (the gear icon) @@ -12,7 +15,7 @@ In order to automate the release of app updates to the Google Play store, you ne 3. Follow the provided **Google Developers Console** hyperlink -4. Click the **Create credentials** button in the displayed modal dialog, and select **Service account key** +4. Click the **Create credentials** button in the displayed modal dialog, and select **Service account key** (with the role "Owner") 5. Select **JSON** as the **Key type** and click the **Create** button @@ -63,11 +66,11 @@ In addition to specifying your publisher credentials file directly within each b 1. Setup a publishing manager (https://play.google.com/apps/publish/) and get the JSON key file from the [Google Developer API console](https://console.developers.google.com/apis) -2. Go into your Visual Studio Team Services or TFS project and click on the gear icon in the upper right corner +2. Go into your Visual Studio Team Services or TFS project and click on the gear icon in the lower left corner -3. Click on the **Services** tab +3. Click on the **Service Connections** tab -4. Click on **New Service Endpoint** and select **Google Play** +4. Click on **New service connection** and select **Google Play** 5. Give the new endpoint a name and enter the credentials for the publishing manager you generated in step#1. The credentials you need can be found in the JSON file and are the Email and the private key. @@ -83,7 +86,134 @@ In addition to the custom service endpoint, this extension also contributes the * [Google Play - Increase Rollout](#google-play---increase-rollout) - Allows automating increasing the rollout percentage of a previous release app update. -### Google Play - Release +* [Google Play - Release Bundle](#google-play---release-bundle) - Allows automating the release of a new Android bundle to the Google Play store. + +* [Google Play - Status Update](#google-play---status-update) - Allows you to update the status of an app that was previously released to the selected track. + +### Google Play Release + +Allows you to release an update to your app on Google Play: release app bundle or apk, attach obb or mapping file, update metadata. +Includes the following options: + +1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), + + ![JSON Auth File](images/auth-with-json-file.png) + + or configured within a service endpoint that you reference from the task (via the `Service Endpoint` authentication method). + + ![Service Endpoint](images/auth-with-endpoint.png) + + Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. + Please also note that from the point of security it's preferrable to store it as [Secure file](https://docs.microsoft.com/azure/devops/pipelines/library/secure-files) and download using [Download Secure File task](https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/download-secure-file). + +2. **Application ID** *(String, Required)* - The unique package identifier (e.g. com.foo.myapp) of the bundle you want to release. + +3. **Action** *(String, Required)* - Action you want to take in the release. Available options are *Only update store listing*, *Upload single bundle*, *Upload single apk*, *Upload multiple apk/aab files*. + + ![Action Input Options](images/action-input.png) + +4. **Bundle Path** *(File path, Required if visible)* - Path or glob pattern to the bundle file you want to publish to the specified track. Only visible if `action` is *Upload single bundle*. + + ![Bundle Path](images/bundle-path.png) + +5. **APK Path** *(File path, Required if visible)* - Path or glob pattern to the APK file you want to publish to the specified track. Only visible if `action` is *Upload single apk*. + + ![APK Path](images/apk-path.png) + +6. **Bundle paths, APK paths** *(Multiline, Optional)* - Paths or glob patterns to the APK/AAB files you want to publish to the specified track. It's required that at least one APK/AAB is picked up from these inputs, otherwise the task will fail. Only visible if `action` is *Upload multiple apk/aab files*. + + ![APK/AAB Paths](images/apk-aab-paths.png) + +7. **Upload OBB for APK** *(Boolean, Optional)* - Whether or not to pick up OBB files for each of the specified APKs. Only visible if `action` is *Upload single apk* or *Upload multiple apk/aab files*. + + ![Attach OBB For APK](images/obb-for-apk.png) + +8. **Track** *(String, Required)* - Release track to publish the APK to. This input is editable but provides default options: *Internal test*, *Alpha*, *Beta*, *Production*. + + ![Track](images/track.png) + +9. **Update Metadata** *(Boolean, Optional)* - Allows automating metadata updates to the Google Play store by reading the contents of the `Metadata Root Directory`. + + ![Update Metadata](images/update-metadata.png) + +10. **Metadata Root Directory** *(String, Required if visible)* - Root directory for metadata related files. Becomes available after enabling the `Update Metadata` option. Expects a format similar to fastlane’s [supply tool](https://github.com/fastlane/fastlane/tree/master/supply#readme) which is summarized below: + +``` +$(Specified Directory) + └ $(languageCodes) + ├ full_description.txt + ├ short_description.txt + ├ title.txt + ├ video.txt + ├ images + | ├ featureGraphic.png || featureGraphic.jpg || featureGraphic.jpeg + | ├ icon.png || icon.jpg || icon.jpeg + | ├ promoGraphic.png || promoGraphic.jpg || promoGraphic.jpeg + | ├ tvBanner.png || tvBanner.jpg || tvBanner.jpeg + | ├ phoneScreenshots + | | └ *.png || *.jpg || *.jpeg + | ├ sevenInchScreenshots + | | └ *.png || *.jpg || *.jpeg + | ├ tenInchScreenshots + | | └ *.png || *.jpg || *.jpeg + | ├ tvScreenshots + | | └ *.png || *.jpg || *.jpeg + | └ wearScreenshots + | └ *.png || *.jpg || *.jpeg + └ changelogs + └ $(versioncodes).txt +``` + +11. **Release Notes** *(File path)* - Path to the file specifying the release notes for the release you are publishing. Only visible if `Update metadata` option is disabled. + + ![Release Notes](images/release-notes.png) + +12. **Language Code** *(String, Optional)* - An IETF language tag identifying the language of the release notes as specified in the BCP-47 document. Default value is _en-US_. Only visible if `Update metadata` option is disabled. + +13. **Update Metadata** *(Boolean, Optional)* - Allows automating metadata updates to the Google Play store by leveraging the contents of the `Metadata Root Directory`. + + ![Update Metadata](images/update-metadata.png) + +#### Advanced Options + +1. **Set in-app update priority** *(Boolean, Optional)* - Enables to set in-app update priority. Not visible if `action` is *Only update store listing*. + + ![Update Priority](images/update-priority.png) + +2. **Update priority** *(Number, Required if visible)* - How strongly to recommend an update to the user. An integer value between 0 and 5, with 0 being the default and 5 being the highest priority. Only visible if `Set in-app update priority` is enabled. + +3. **Roll out release** *(Boolean, Optional)* - Allows to roll out the release to a percentage of users. Not visible if `action` is *Only update store listing*. + + ![Rollout Fraction](images/rollout-release.png) + +4. **User fraction** *(Number, Required if visible)* - The percentage of users to roll the specified APK out to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users). + +5. **Upload deobfuscation file** *(Boolean, Optional)* - Allows to attach your proguard mapping.txt file to your aab/apk. Only visible if `action` is *Upload single apk* or *Upload single bundle*. + + ![Mapping File](images/mapping-file.png) + +6. **Deobfuscation path** *(File path, Required if visible)* - The path to the proguard mapping.txt file to upload. Glob patterns are supported. Only visible if `Upload deobfuscation file` is enabled. + +7. **Send changes to review** *(Boolean, Optional)* - Select this option to send changes for review in GooglePlay Console. If changes are already sent for review automatically, you shouldn't select this option. + + ![Send Changes To Review](images/send-changes-to-review.png) + +8. **Release name** *(String, Optional)* - Allows to set meaningful release name that can be seen in your Google Play Console. It won't be visible to your users. + + ![Send Changes To Review](images/send-changes-to-review.png) + +9. **Replace version codes** *(String, Required)* - You may specify which APK version codes should be replaced in the track with this deployment. Available options are: *All*, *List* - comma separated list of version codes, *Regular expression* - a regular expression pattern to select a list of APK version codes to be removed from the track with this deployment, e.g. _.\\*12?(3|4)?5_ + + +10. **Replace Version Codes** *(String, Optional)* - Specify version codes to replace in the selected track with the new APKs/AABs: all, the comma separated list, or a regular expression pattern. Not visible if `action` is *Only update store listing*. + + ![Advanced Options](images/replace-version-codes.png) + +11. **Version Code List** *(String, Required if visible)* - The comma separated list of version codes to be removed from the track with this deployment. Only available if `Replace Version Codes` value is *List*. + +12. **Version Code Pattern** *(String, Required if visible)* - The regular expression pattern to select a list of version codes to be removed from the track with this deployment, e.g. .\*12?(3|4)?5. Only available if `Replace Version Codes` value is *Regular expression*. + +### Google Play - Release V3 (deprecated in favor of Google Play - Release V4) Allows you to release an update to your app on Google Play, and includes the following options: @@ -148,7 +278,11 @@ $(Specified Directory) └ $(versioncodes).txt ``` -9. **Update APK(s)** *(Boolean, Optional)* - By default, the task will update the specified binary APK file(s) on your app release. By unselecting this option you can update metadata keeping the APKs untouched. Default value is _true_. +9. **Update only store listing** *(Boolean, Optional)* - By default, the task will update the specified track and selected APK file(s) will be assigned to the related track. By selecting this option you can update only store listing. Default value is _false_. + + ![Advanced Options](images//update-store-listing.png) + +10. **Update APK(s)** *(Boolean, Optional)* - By default, the task will update the specified binary APK file(s) on your app release. By unselecting this option you can update metadata keeping the APKs untouched. Default value is _true_. ![Update APKs](images/update-apks.png) @@ -208,7 +342,7 @@ Allows you to update the status of an app that was previously released to the se 5. **User Fraction** *(String, Optional)* - The new user fraction to update the rollout to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users, does not contain 0 and 1). If the input User Fraction is not specified, will maintain the current user fraction without updating (**Notice**: if you want to update the status to `inProgress` or `halted`, make sure current user fraction or the input User Fraction is specified). -### Google Play - Release Bundle +### Google Play - Release Bundle (deprecated in favor of Google Play - Release V4) Allows you to release an app bundle to Google Play, and includes the following options: @@ -289,3 +423,7 @@ $(Specified Directory) ## Contact Us * [Report an issue](https://github.com/Microsoft/google-play-vsts-extension/issues) + +Google Play and the Google Play logo are trademarks of Google Inc. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/docs/vsts-README.md b/docs/vsts-README.md index 8c9cbb7c..c19548c6 100644 --- a/docs/vsts-README.md +++ b/docs/vsts-README.md @@ -7,6 +7,7 @@ This extension contains a set of deployment tasks which allow you to automate th This extension supports Visual Studio Team Services (VSTS) and Team Foundation Server (TFS) 2017 and later. In order to automate the release of app updates to the Google Play store, you need to have manually released at least one version through the [Google Play Developer Console](https://play.google.com/apps/publish/). Additionally, you need to create a service account that is authorized to manage your app(s) releases on your behalf and can be used to authenticate "headlessly" from your VSTS build/release definitions. If you haven't already done so, then perform the following steps to create a service account: +> For a more in depth guide [click this link](https://docs.microsoft.com/en-us/appcenter/distribution/stores/googleplay). 1. Login to the [Google Play Developer Console](https://play.google.com/apps/publish/) and select **Settings** in the left-hand navigation menu (the gear icon) @@ -14,17 +15,21 @@ In order to automate the release of app updates to the Google Play store, you ne 3. Follow the provided **Google Developers Console** hyperlink -4. Click the **Create credentials** button in the displayed modal dialog, and select **Service account key** +4. Click the **Create credentials** button in the displayed modal dialog, and select **Service account key** (with the role "Owner") 5. Select **JSON** as the **Key type** and click the **Create** button 6. Save the provided JSON file somewhere safe and memorable. You'll be using it later. -7. Back in the **Google Play Developer Console**, click the **Done** button to close the modal +7. Go to the **IAM** page and click on the **Add** button. -8. Click the **Grant access** button in the row associated with the service account you just created. - -9. Ensure that the **Role** is set to **Release Manager** and then click the **Add user** button +8. Select the newly created service account in the **New members** box and assign it the **Service Account User Role**, then click **Save**. + +9. Back in the **Google Play Developer Console**, click the **Done** button to close the modal + +10. Click the **Grant access** button in the row associated with the service account you just created. + +11. Ensure that the **Role** is set to **Release Manager** and then click the **Add user** button To take advantage of the metadata updating capabilities, files need to be organized using fastlane’s [supply tool](https://github.com/fastlane/fastlane/tree/master/supply#readme) format: @@ -85,9 +90,14 @@ In addition to the custom service endpoint, this extension also contributes the * [Google Play - Increase Rollout](#google-play---increase-rollout) - Allows automating increasing the rollout percentage of a previous release app update. -### Google Play - Release +* [Google Play - Release Bundle](#google-play---release-bundle) - Allows automating the release of a new Android bundle to the Google Play store. -Allows you to release an update to your app on Google Play, and includes the following options: +* [Google Play - Status Update](#google-play---status-update) - Allows you to update the status of an app that was previously released to the selected track. + +### Google Play Release + +Allows you to release an update to your app on Google Play: release app bundle or apk, attach obb or mapping file, update metadata. +Includes the following options: 1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), @@ -98,7 +108,129 @@ Allows you to release an update to your app on Google Play, and includes the fol ![Service Endpoint](images/auth-with-endpoint.png) Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. + Please also note that from the point of security it's preferrable to store it as [Secure file](https://docs.microsoft.com/azure/devops/pipelines/library/secure-files) and download using [Download Secure File task](https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/download-secure-file). + +2. **Application ID** *(String, Required)* - The unique package identifier (e.g. com.foo.myapp) of the bundle you want to release. + +3. **Action** *(String, Required)* - Action you want to take in the release. Available options are *Only update store listing*, *Upload single bundle*, *Upload single apk*, *Upload multiple apk/aab files*. + + ![Action Input Options](images/action-input.png) + +4. **Bundle Path** *(File path, Required if visible)* - Path or glob pattern to the bundle file you want to publish to the specified track. Only visible if `action` is *Upload single bundle*. + + ![Bundle Path](images/bundle-path.png) + +5. **APK Path** *(File path, Required if visible)* - Path or glob pattern to the APK file you want to publish to the specified track. Only visible if `action` is *Upload single apk*. + + ![APK Path](images/apk-path.png) + +6. **Bundle paths, APK paths** *(Multiline, Optional)* - Paths or glob patterns to the APK/AAB files you want to publish to the specified track. It's required that at least one APK/AAB is picked up from these inputs, otherwise the task will fail. Only visible if `action` is *Upload multiple apk/aab files*. + + ![APK/AAB Paths](images/apk-aab-paths.png) + +7. **Upload OBB for APK** *(Boolean, Optional)* - Whether or not to pick up OBB files for each of the specified APKs. Only visible if `action` is *Upload single apk* or *Upload multiple apk/aab files*. + + ![Attach OBB For APK](images/obb-for-apk.png) + +8. **Track** *(String, Required)* - Release track to publish the APK to. This input is editable but provides default options: *Internal test*, *Alpha*, *Beta*, *Production*. + + ![Track](images/track.png) + +9. **Update Metadata** *(Boolean, Optional)* - Allows automating metadata updates to the Google Play store by reading the contents of the `Metadata Root Directory`. + + ![Update Metadata](images/update-metadata.png) + +10. **Metadata Root Directory** *(String, Required if visible)* - Root directory for metadata related files. Becomes available after enabling the `Update Metadata` option. Expects a format similar to fastlane’s [supply tool](https://github.com/fastlane/fastlane/tree/master/supply#readme) which is summarized below: + +``` +$(Specified Directory) + └ $(languageCodes) + ├ full_description.txt + ├ short_description.txt + ├ title.txt + ├ video.txt + ├ images + | ├ featureGraphic.png || featureGraphic.jpg || featureGraphic.jpeg + | ├ icon.png || icon.jpg || icon.jpeg + | ├ promoGraphic.png || promoGraphic.jpg || promoGraphic.jpeg + | ├ tvBanner.png || tvBanner.jpg || tvBanner.jpeg + | ├ phoneScreenshots + | | └ *.png || *.jpg || *.jpeg + | ├ sevenInchScreenshots + | | └ *.png || *.jpg || *.jpeg + | ├ tenInchScreenshots + | | └ *.png || *.jpg || *.jpeg + | ├ tvScreenshots + | | └ *.png || *.jpg || *.jpeg + | └ wearScreenshots + | └ *.png || *.jpg || *.jpeg + └ changelogs + └ $(versioncodes).txt +``` +11. **Release Notes** *(File path)* - Path to the file specifying the release notes for the release you are publishing. Only visible if `Update metadata` option is disabled. + + ![Release Notes](images/release-notes.png) + +12. **Language Code** *(String, Optional)* - An IETF language tag identifying the language of the release notes as specified in the BCP-47 document. Default value is _en-US_. Only visible if `Update metadata` option is disabled. + +13. **Update Metadata** *(Boolean, Optional)* - Allows automating metadata updates to the Google Play store by leveraging the contents of the `Metadata Root Directory`. + + ![Update Metadata](images/update-metadata.png) + +#### Advanced Options + +1. **Set in-app update priority** *(Boolean, Optional)* - Enables to set in-app update priority. Not visible if `action` is *Only update store listing*. + + ![Update Priority](images/update-priority.png) + +2. **Update priority** *(Number, Required if visible)* - How strongly to recommend an update to the user. An integer value between 0 and 5, with 0 being the default and 5 being the highest priority. Only visible if `Set in-app update priority` is enabled. + +3. **Roll out release** *(Boolean, Optional)* - Allows to roll out the release to a percentage of users. Not visible if `action` is *Only update store listing*. + + ![Rollout Fraction](images/rollout-release.png) + +4. **User fraction** *(Number, Required if visible)* - The percentage of users to roll the specified APK out to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users). + +5. **Upload deobfuscation file** *(Boolean, Optional)* - Allows to attach your proguard mapping.txt file to your aab/apk. Only visible if `action` is *Upload single apk* or *Upload single bundle*. + + ![Mapping File](images/mapping-file.png) + +6. **Deobfuscation path** *(File path, Required if visible)* - The path to the proguard mapping.txt file to upload. Glob patterns are supported. Only visible if `Upload deobfuscation file` is enabled. + +7. **Send changes to review** *(Boolean, Optional)* - Select this option to send changes for review in GooglePlay Console. If changes are already sent for review automatically, you shouldn't select this option. + + ![Send Changes To Review](images/send-changes-to-review.png) + +8. **Release name** *(String, Optional)* - Allows to set meaningful release name that can be seen in your Google Play Console. It won't be visible to your users. + + ![Send Changes To Review](images/send-changes-to-review.png) + +9. **Replace version codes** *(String, Required)* - You may specify which APK version codes should be replaced in the track with this deployment. Available options are: *All*, *List* - comma separated list of version codes, *Regular expression* - a regular expression pattern to select a list of APK version codes to be removed from the track with this deployment, e.g. _.\\*12?(3|4)?5_ + + +10. **Replace Version Codes** *(String, Optional)* - Specify version codes to replace in the selected track with the new APKs/AABs: all, the comma separated list, or a regular expression pattern. Not visible if `action` is *Only update store listing*. + + ![Advanced Options](images/replace-version-codes.png) + +11. **Version Code List** *(String, Required if visible)* - The comma separated list of version codes to be removed from the track with this deployment. Only available if `Replace Version Codes` value is *List*. + +12. **Version Code Pattern** *(String, Required if visible)* - The regular expression pattern to select a list of version codes to be removed from the track with this deployment, e.g. .\*12?(3|4)?5. Only available if `Replace Version Codes` value is *Regular expression*. + +### Google Play - Release V3 (deprecated in favor of Google Play - Release V4) + +Allows you to release an update to your app on Google Play, and includes the following options: + +1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), + + ![JSON Auth File](images/auth-with-json-file.png) + + or configured within a service endpoint that you reference from the task (via the `Service Endpoint` authentication method). + + ![Service Endpoint](images/auth-with-endpoint.png) + + Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. + Please also note that from the point of security it's preferrable to store it as [Secure file](https://docs.microsoft.com/azure/devops/pipelines/library/secure-files) and download using [Download Secure File task](https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/download-secure-file). 2. **APK Path** *(File path, Required)* - Path to the APK file you want to publish to the specified track. @@ -123,7 +255,7 @@ Allows you to release an update to your app on Google Play, and includes the fol ![Update Metadata](images/update-metadata.png) 8. **Metadata Root Directory** *(String, Required if visible)* - Root directory for metadata related files. Becomes available after enabling the `Update Metadata` option. Expects a format similar to fastlane’s [supply tool](https://github.com/fastlane/fastlane/tree/master/supply#readme) which is summarized below: - + ``` $(Specified Directory) └ $(languageCodes) @@ -150,7 +282,11 @@ $(Specified Directory) └ $(versioncodes).txt ``` -9. **Update APK(s)** *(Boolean, Optional)* - By default, the task will update the specified binary APK file(s) on your app release. By unselecting this option you can update metadata keeping the APKs untouched. Default value is _true_. +9. **Update only store listing** *(Boolean, Optional)* - By default, the task will update the specified track and selected APK file(s) will be assigned to the related track. By selecting this option you can update only store listing. Default value is _false_. + + ![Advanced Options](images//update-store-listing.png) + +10. **Update APK(s)** *(Boolean, Optional)* - By default, the task will update the specified binary APK file(s) on your app release. By unselecting this option you can update metadata keeping the APKs untouched. Default value is _true_. ![Update APKs](images/update-apks.png) @@ -168,15 +304,17 @@ Allows you to promote a previously released APK from one track to another (e.g. ![Promote task](images/promote-task.png) -1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), or configured within a service endpoint that you reference from the task (via the `Service Endpoint` authentication method). Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. +1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), or configured within a service endpoint that you reference from the task (via the `Service Endpoint` authentication method). Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. Please note that from the point of security it's preferrable to store it as [Secure file](https://docs.microsoft.com/azure/devops/pipelines/library/secure-files) and download using [Download Secure File task](https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/download-secure-file). 2. **Package Name** *(String, Required)* - The unique package identifier (e.g. `com.foo.myapp`) that you wish to promote. +3. **Version Code** *(String, Optional)* - The version code of the apk (e.g. 123) that you whish to promote. If no version code is given, the latest version on the specified track will be promoted. + 3. **Source Track** *(Required, Required)* - The track you wish to promote your app from (e.g. `alpha`). This assumes that you previously released an update to this track, potentially using the [`Google Play - Release`](#google-play---release) task. 4. **Destination Track** *(Required, Required)* - The track you wish to promote your app to (e.g. `production`). -5. **Rollout Fraction** *(String, Required if visible)* - The percentage of users to roll the app out to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users). If you use this task to promote to a percentage of users, use the task `Googe Play - Increase Rollout` with the value set to `1.0` to complete the rollout for all users of your app. +5. **Rollout Fraction** *(String, Required if visible)* - The percentage of users to roll the app out to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users). If you use rollout, and want to be able to automate the process of increasing the rollout over time, refer to the `Google Play - Increase Rollout` task. ### Google Play - Increase Rollout @@ -184,11 +322,11 @@ Allows you to increase the rollout percentage of an app that was previously rele ![Increase task](images/increase-task.png) -1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), or configured within a service endpoint that you reference from the task (via the `Service Endpoint` authentication method). Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. +1. **JSON Key Path** *(File path)* or **Service Endpoint** - The credentials used to authenticate with Google Play. This can be acquired from the [Google Developer API console](https://console.developers.google.com/apis) and provided either directly to the task (via the `JSON Auth File` authentication method), or configured within a service endpoint that you reference from the task (via the `Service Endpoint` authentication method). Note that in order to use the JSON Auth File method, the JSON file you get from the developer console needs to be checked into your source repo. Please note that from the point of security it's preferrable to store it as [Secure file](https://docs.microsoft.com/azure/devops/pipelines/library/secure-files) and download using [Download Secure File task](https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/download-secure-file). 2. **Package Name** *(String, Required)* - The unique package identifier (e.g. com.foo.myapp) of the app you wish to increase the rollout percentage for. -3. **Rollout Fraction** *(String, Required)* - The new user fraction to increase the rollout to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users). If set to `1.0`, then the rollout will complete and 100% of your users will receive the update. +3. **Rollout Fraction** *(String, Required)* - The new user fraction to increase the rollout to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users) ### Google Play - Status Update @@ -204,11 +342,11 @@ Allows you to update the status of an app that was previously released to the se 4. **Status** *(String, Required)* - The status of the release you want to update to. - ![Status](images/status.png) + ![Status](images/status.png) 5. **User Fraction** *(String, Optional)* - The new user fraction to update the rollout to, specified as a number between 0 and 1 (e.g. `0.5` == `50%` of users, does not contain 0 and 1). If the input User Fraction is not specified, will maintain the current user fraction without updating (**Notice**: if you want to update the status to `inProgress` or `halted`, make sure current user fraction or the input User Fraction is specified). -### Google Play - Release Bundle +### Google Play - Release Bundle (deprecated in favor of Google Play - Release V4) Allows you to release an app bundle to Google Play, and includes the following options: @@ -247,7 +385,7 @@ Allows you to release an app bundle to Google Play, and includes the following o 10. **Rollout Fraction** *(String, Optional)* - The percentage of users the specified APK will be released to for the specified 'Track'. It can be increased later with the 'Google Play - Increase Rollout' task. 11. **Metadata Root Directory** *(String, Required)* - Root directory for metadata related files. Becomes available after enabling the `Update Metadata` option. Expects a format similar to fastlane’s [supply tool](https://github.com/fastlane/fastlane/tree/master/supply#readme) which is summarized below: - + ``` $(Specified Directory) └ $(languageCodes) @@ -286,14 +424,10 @@ $(Specified Directory) 3. **Version Code Pattern** *(String, Required if visible)* - The regular expression pattern to select a list of APK version codes to be removed from the track with this deployment, e.g. .*12?(3|4)?5 -4. **Send changes to review** *(Boolean, Optional)* - Select this option to add `changesNotSentForReview=true` query parameter and send changes for review in GooglePlay Console. [More info](https://developers.google.com/android-publisher/api-ref/rest/v3/edits/commit#query-parameters). - - If you are getting the following error `Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true`, select this option. - - Please be cautious when selecting this option, if the app is already sent for review automatically, you can get the error `Changes are sent for review automatically. The query parameter changesNotSentForReview must not be set`. - ## Contact Us * [Report an issue](https://github.com/Microsoft/google-play-vsts-extension/issues) Google Play and the Google Play logo are trademarks of Google Inc. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/images/action-input.png b/images/action-input.png new file mode 100644 index 00000000..b10ccc8e Binary files /dev/null and b/images/action-input.png differ diff --git a/images/apk-aab-paths.png b/images/apk-aab-paths.png new file mode 100644 index 00000000..3873850f Binary files /dev/null and b/images/apk-aab-paths.png differ diff --git a/images/apk-path.png b/images/apk-path.png index c7a464f9..a9bd58e7 100644 Binary files a/images/apk-path.png and b/images/apk-path.png differ diff --git a/images/auth-with-endpoint.png b/images/auth-with-endpoint.png index 503e9db1..42ab9455 100644 Binary files a/images/auth-with-endpoint.png and b/images/auth-with-endpoint.png differ diff --git a/images/auth-with-json-file.png b/images/auth-with-json-file.png index 38a6bb5c..2ee7a772 100644 Binary files a/images/auth-with-json-file.png and b/images/auth-with-json-file.png differ diff --git a/images/bundle-path.png b/images/bundle-path.png new file mode 100644 index 00000000..6b75cb48 Binary files /dev/null and b/images/bundle-path.png differ diff --git a/images/mapping-file.png b/images/mapping-file.png new file mode 100644 index 00000000..7b37a8e1 Binary files /dev/null and b/images/mapping-file.png differ diff --git a/images/obb-for-apk.png b/images/obb-for-apk.png new file mode 100644 index 00000000..7449fb58 Binary files /dev/null and b/images/obb-for-apk.png differ diff --git a/images/release-name.png b/images/release-name.png new file mode 100644 index 00000000..af391f44 Binary files /dev/null and b/images/release-name.png differ diff --git a/images/release-notes.png b/images/release-notes.png index c11e45f3..7a15d049 100644 Binary files a/images/release-notes.png and b/images/release-notes.png differ diff --git a/images/replace-version-codes.png b/images/replace-version-codes.png new file mode 100644 index 00000000..51c03d3e Binary files /dev/null and b/images/replace-version-codes.png differ diff --git a/images/rollout-release.png b/images/rollout-release.png new file mode 100644 index 00000000..4a74ecea Binary files /dev/null and b/images/rollout-release.png differ diff --git a/images/send-changes-to-review.png b/images/send-changes-to-review.png new file mode 100644 index 00000000..01d281bb Binary files /dev/null and b/images/send-changes-to-review.png differ diff --git a/images/track.png b/images/track.png index 82a0bdc7..88165b18 100644 Binary files a/images/track.png and b/images/track.png differ diff --git a/images/update-metadata.png b/images/update-metadata.png index 4c3db5d6..e328cb8e 100644 Binary files a/images/update-metadata.png and b/images/update-metadata.png differ diff --git a/images/update-priority.png b/images/update-priority.png new file mode 100644 index 00000000..8b2630f5 Binary files /dev/null and b/images/update-priority.png differ diff --git a/make-options.json b/make-options.json index 7a0cbf9a..9468dbd2 100644 --- a/make-options.json +++ b/make-options.json @@ -1,6 +1,7 @@ { "tasks": [ "GooglePlayReleaseV3", + "GooglePlayReleaseV4", "GooglePlayReleaseBundleV3", "GooglePlayPromoteV2", "GooglePlayRolloutUpdateV3", diff --git a/vsts-extension-google-play.json b/vsts-extension-google-play.json index b45a9482..d3df2747 100644 --- a/vsts-extension-google-play.json +++ b/vsts-extension-google-play.json @@ -38,6 +38,9 @@ { "path": "_build/Tasks/GooglePlayReleaseV3" }, + { + "path": "_build/Tasks/GooglePlayReleaseV4" + }, { "path": "_build/Tasks/GooglePlayReleaseBundleV3" }, @@ -84,6 +87,16 @@ "name": "_build/Tasks/GooglePlayReleaseV3" } }, + { + "id": "google-play-release-v4", + "type": "ms.vss-distributed-task.task", + "targets": [ + "ms.vss-distributed-task.tasks" + ], + "properties": { + "name": "_build/Tasks/GooglePlayReleaseV4" + } + }, { "id": "google-play-release-bundle", "type": "ms.vss-distributed-task.task",