├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASING.md ├── api └── app-versioning.api ├── build.gradle.kts ├── detekt.yml ├── docs └── images │ └── sample.png ├── gradle.properties ├── gradle ├── gradle-daemon-jvm.properties ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src ├── functionalTest └── kotlin │ └── io │ └── github │ └── reactivecircus │ └── appversioning │ ├── AppVersioningPluginIntegrationTest.kt │ ├── fixtures │ ├── FixtureRunner.kt │ └── ProjectTemplates.kt │ └── tasks │ ├── GenerateAppVersionInfoTest.kt │ └── PrintAppVersionInfoTest.kt ├── main └── kotlin │ └── io │ └── github │ └── reactivecircus │ └── appversioning │ ├── AppVersioningExtension.kt │ ├── AppVersioningPlugin.kt │ ├── GitTag.kt │ ├── SemVer.kt │ ├── VariantInfo.kt │ ├── internal │ └── GitClient.kt │ └── tasks │ ├── GenerateAppVersionInfo.kt │ └── PrintAppVersionInfo.kt └── test └── kotlin └── io └── github └── reactivecircus └── appversioning ├── GitTagTest.kt ├── SemVerTest.kt └── VariantInfoTest.kt /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | max_line_length = 200 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**/*.md' 9 | push: 10 | branches: 11 | - main 12 | paths-ignore: 13 | - '**/*.md' 14 | 15 | env: 16 | GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx4g -XX:+HeapDumpOnOutOfMemoryError" 17 | TERM: dumb 18 | 19 | jobs: 20 | assemble: 21 | name: Assemble 22 | runs-on: ubuntu-latest 23 | env: 24 | JAVA_TOOL_OPTIONS: -Xmx4g -XX:+UseParallelGC 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: actions/setup-java@v4 29 | with: 30 | distribution: 'zulu' 31 | java-version: '24' 32 | - uses: gradle/actions/setup-gradle@v4 33 | with: 34 | cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} 35 | - name: Assemble 36 | run: ./gradlew assemble 37 | 38 | checks: 39 | name: Checks (unit tests, static analysis, plugin validations and binary compatibility API check) 40 | runs-on: ubuntu-latest 41 | env: 42 | JAVA_TOOL_OPTIONS: -Xmx4g -XX:+UseParallelGC 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | - uses: actions/setup-java@v4 47 | with: 48 | distribution: 'zulu' 49 | java-version: '24' 50 | - uses: gradle/actions/setup-gradle@v4 51 | with: 52 | cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} 53 | - name: Checks 54 | run: ./gradlew test detekt validatePlugins apiCheck 55 | 56 | functional-tests: 57 | name: Functional tests 58 | runs-on: ubuntu-latest 59 | env: 60 | JAVA_TOOL_OPTIONS: -Xmx4g -XX:+UseParallelGC 61 | AGP_VERSION: ${{ matrix.agp-version }} 62 | strategy: 63 | matrix: 64 | agp-version: [ 8.0.2, 8.1.4, 8.2.2, 8.3.2, 8.4.2, 8.5.2, 8.6.1, 8.7.3, 8.8.2, 8.9.2, 8.10.1, 8.11.0-rc01, 8.12.0-alpha03 ] 65 | 66 | steps: 67 | - uses: actions/checkout@v4 68 | - uses: actions/setup-java@v4 69 | with: 70 | distribution: 'zulu' 71 | java-version: '24' 72 | - name: Configure git user 73 | run: | 74 | git config --global user.name "GitHub Action" 75 | git config --global user.email "action@github.com" 76 | - uses: gradle/actions/setup-gradle@v4 77 | with: 78 | cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} 79 | - name: Functional tests 80 | run: ./gradlew functionalTest 81 | - name: Upload test reports 82 | if: failure() 83 | uses: actions/upload-artifact@v4 84 | with: 85 | name: test-reports-${{ matrix.agp-version }} 86 | path: build/reports/tests/functionalTest 87 | retention-days: 3 88 | 89 | deploy-snapshot: 90 | name: Deploy snapshot 91 | needs: [assemble, checks, functional-tests] 92 | if: github.ref == 'refs/heads/main' 93 | runs-on: ubuntu-latest 94 | env: 95 | JAVA_TOOL_OPTIONS: -Xmx4g -XX:+UseParallelGC 96 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} 97 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} 98 | 99 | steps: 100 | - uses: actions/checkout@v4 101 | - uses: actions/setup-java@v4 102 | with: 103 | distribution: 'zulu' 104 | java-version: '24' 105 | - uses: gradle/actions/setup-gradle@v4 106 | with: 107 | cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} 108 | - name: Deploy snapshot 109 | run: ./gradlew publish 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | .kotlin/ 3 | build/ 4 | out/ 5 | 6 | # Ignore Gradle GUI config 7 | gradle-app.setting 8 | 9 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 10 | !gradle-wrapper.jar 11 | 12 | local.properties 13 | 14 | .idea/ 15 | *.iml 16 | 17 | *.DS_Store 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## Unreleased 4 | 5 | ### Changed 6 | - Minimum Android Gradle Plugin version is now **8.0.0**. 7 | - Remove `compileOnly` dependency on `com.android.tools:common` to support future version of AGP. 8 | 9 | ## 1.4.0 10 | 11 | ### Added 12 | - Support Splits/Multi-APKs and universal APK modes. 13 | 14 | ### Changed 15 | - Compile with AGP 8.9.0. 16 | - Compile with Kotlin 2.1.0. 17 | 18 | ## 1.3.2 19 | 20 | ### Changed 21 | - Compile with AGP 8.3.0. 22 | - Compile with Kotlin 1.9.23. 23 | 24 | ## 1.3.1 25 | 26 | ### Fixed 27 | - Fix an issue where the version info files cannot be found when configuration cache is on - [#30](https://github.com/ReactiveCircus/app-versioning/pull/30) 28 | 29 | ## 1.3.0 30 | 31 | ### Changed 32 | - Compile with AGP 8.0.0. 33 | - Compile with Kotlin 1.8.20. 34 | 35 | ## 1.2.0 36 | 37 | ### Fixed 38 | - Stop depending on `com.android.tools:sdk-common` to support AGP 8. 39 | 40 | ### Changed 41 | - Compile with AGP 7.3.1. 42 | - Compile with Kotlin 1.7.20. 43 | 44 | ## 1.1.2 45 | 46 | ### Fixed 47 | - Fix an issue where changing git HEAD does not invalidate task cache - [#28](https://github.com/ReactiveCircus/app-versioning/pull/28) 48 | 49 | ### Changed 50 | - Compile with AGP 7.1.3. 51 | - Compile with Kotlin 1.6.21. 52 | 53 | ## 1.1.1 54 | 55 | Same as 1.1.0. 56 | 57 | ## 1.1.0 58 | 59 | ### Added 60 | - Support specifying **bare** git repository e.g. `app.git`. 61 | 62 | ### Changed 63 | - Compile with AGP 7.0.3. 64 | 65 | ## 1.0.0 66 | 67 | This is our first stable release. Thanks everyone for trying out the plugin and sending bug reports and feature requests! 68 | 69 | ### Changed 70 | - Compile with AGP 7.0.0. 71 | 72 | ## 1.0.0-rc01 73 | 74 | ### Changed 75 | - Minimum Android Gradle Plugin version is now **7.0.0-beta04**. 76 | 77 | ## 0.10.0 78 | 79 | This is the final release of the plugin that's compatible with **Android Gradle Plugin 4.2**. 80 | 81 | When AGP **7.0.0-beta01** was released, we thought all the APIs we use are stable and can therefore support **4.2.1** which have the same APIs we were using. 82 | Unfortunately **AGP 7.0.0-beta04** moved `ApplicationAndroidComponentsExtension` to a new package and deprecated the old one. In order to move to the new `ApplicationAndroidComponentsExtension` 83 | before our 1.0 release and avoid the overhead of publishing multiple artifacts, we decided to start requiring **AGP 7.0.0-beta04** in the next release. 84 | 85 | ### Changed 86 | - Compile with AGP 7.0.0-rc01. 87 | - Compile with Kotlin 1.5.21. 88 | 89 | ## 0.9.1 90 | 91 | ### Changed 92 | - Minimum Gradle version is **6.8**. 93 | 94 | ## 0.9.0 95 | 96 | ### Changed 97 | - Minimum Android Gradle Plugin version is now **4.2.1**. All versions of **AGP 7.x** are supported. 98 | - Compile with AGP 7.0.0-beta03. 99 | - Compile with Kotlin 1.5.10. 100 | 101 | ## 0.8.1 102 | 103 | ### Added 104 | - Support setting git root directory explicitly when the root Gradle project is not the git root. 105 | 106 | ## 0.8.0 107 | 108 | ### Added 109 | - Support tag filtering with custom glob pattern. 110 | 111 | ### Changed 112 | - Compile with AGP 4.2.0-beta02. 113 | - Target Java 11 bytecode. 114 | - Kotlin 1.4.21. 115 | - Gradle 6.8-rc-4. 116 | 117 | ## 0.7.0 118 | 119 | ### Changed 120 | - Change minimum Android Gradle Plugin version to **4.2.0-beta01**. 121 | - Support AGP 7.0.0-alpha02. 122 | - Kotlin 1.4.20. 123 | - Gradle 6.8-rc-1. 124 | 125 | ## 0.6.0 126 | 127 | ### Added 128 | - Add `VariantInfo` lambda parameters to `overrideVersionVode` and `overrideVersionName` to support customizing `versionCode` and `versionName` based on build variants. 129 | 130 | ### Changed 131 | - Change `AppVersioningPlugin` from `internal` to `public` to support type-safe plugin application in `buildSrc`. 132 | - AGP 4.2.0-alpha16. 133 | - Kotlin 1.4.20-RC. 134 | 135 | ### Fixed 136 | - Disable IR to support applying the plugin from `buildSrc`. 137 | 138 | ## 0.5.0 139 | 140 | The plugin now requires the latest version of Android Gradle Plugin (currently `4.2.0-alpha13`) until the next variant APIs become stable. 141 | 142 | Please use version `0.4.0` if you want to use the plugin with AGP 4.0 or 4.1. 143 | 144 | ## 0.4.0 145 | 146 | This is the final release of the plugin that's compatible with **Android Gradle Plugin 4.0 and 4.1**. 147 | 148 | Starting from the next release, the latest version of AGP (currently `4.2.0-alpha13`) will be required, until the new variant APIs become stable which is expected to happen with the next major version of AGP. 149 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2020] [Yang Chen] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # App Versioning 2 | 3 | ![CI](https://github.com/ReactiveCircus/app-versioning/workflows/CI/badge.svg) 4 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.github.reactivecircus.appversioning/app-versioning-gradle-plugin/badge.svg)](https://search.maven.org/search?q=g:io.github.reactivecircus.appversioning) 5 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 6 | 7 | A Gradle Plugin for lazily generating Android app's `versionCode` & `versionName` from Git tags. 8 | 9 | ![App Versioning](docs/images/sample.png) 10 | 11 | Android Gradle Plugin 4.0 and 4.1 introduced some [new experimental APIs](https://medium.com/androiddevelopers/new-apis-in-the-android-gradle-plugin-f5325742e614) to support **lazily** computing and setting the `versionCode` and `versionName` for an APK or App Bundle. This plugin builds on top of these APIs to support the common app versioning use cases based on Git. 12 | 13 | This [blogpost](https://dev.to/ychescale9/git-based-android-app-versioning-with-agp-4-0-24ip) should provide more context around Git-based app versioning in general and why this plugin needs to build on top of the new variant APIs introduced in AGP 4.0 / 4.1 which are currently **incubating**. 14 | 15 | ## Android Gradle Plugin version compatibility 16 | 17 | The minimum version of Android Gradle Plugin required is **8.0.0**. 18 | 19 | Version `1.4.0` of the plugin is the final version that's compatible with AGP **7.x** and below. 20 | 21 | Version `0.4.0` of the plugin is the final version that's compatible with AGP **4.0** and **4.1**. 22 | 23 | Version `0.10.0` of the plugin is the final version that's compatible with AGP **4.2**. 24 | 25 | ## Installation 26 | 27 | The **Android App Versioning Gradle Plugin** is available from both [Maven Central](https://search.maven.org/artifact/io.github.reactivecircus.appversioning/app-versioning-gradle-plugin). Make sure you have added `mavenCentral()` to the plugin `repositories`: 28 | 29 | ```kt 30 | // in settings.gradle.kts 31 | pluginManagement { 32 | repositories { 33 | mavenCentral() 34 | } 35 | } 36 | ``` 37 | 38 | or 39 | 40 | ```kt 41 | // in root build.gradle.kts 42 | buildscript { 43 | repositories { 44 | mavenCentral() 45 | } 46 | } 47 | ``` 48 | 49 | The plugin can now be applied to your **Android Application** module (Gradle subproject). 50 | 51 |
Kotlin 52 | 53 | ```kt 54 | plugins { 55 | id("com.android.application") 56 | id("io.github.reactivecircus.app-versioning") version "x.y.z" 57 | } 58 | ``` 59 | 60 |
61 | 62 |
Groovy 63 | 64 | ```groovy 65 | plugins { 66 | id 'com.android.application' 67 | id 'io.github.reactivecircus.app-versioning' version "x.y.z" 68 | } 69 | ``` 70 | 71 |
72 | 73 | ## Usage 74 | 75 | The plugin offers 2 Gradle tasks for each build variant: 76 | 77 | - `generateAppVersionInfoFor` - generates the `versionCode` and `versionName` for the `BuildVariant`. This task is automatically triggered when assembling the APK or AAB e.g. by running `assemble` or `bundle`, and the generated `versionCode` and `versionName` will be injected into the final merged `AndroidManifest`. 78 | - `printAppVersionInfoFor` - prints the latest `versionCode` and `versionName` generated by the plugin to the console if available. 79 | 80 | ### Default behavior 81 | 82 | Without any configurations, by default the plugin will fetch the latest Git tag in the repository, attempt to parse it into a **SemVer** string, and compute the `versionCode` following [positional notation](https://en.wikipedia.org/wiki/Positional_notation): 83 | 84 | ``` 85 | versionCode = MAJOR * 10000 + MINOR * 100 + PATCH 86 | ``` 87 | 88 | As an example, for a tag `1.3.1` the generated `versionCode` is `1 * 10000 + 3 * 100 + 1 = 10301`. 89 | 90 | The default `versionName` generated will just be the name of the latest Git tag. 91 | 92 | ``` 93 | > Task :app:generateAppVersionInfoForRelease 94 | Generated app version code: 10301. 95 | Generated app version name: "1.3.1". 96 | ``` 97 | 98 | If the default behavior described above works for you, you are all set to go. 99 | 100 | ### Custom rules 101 | 102 | The plugin lets you define how you want to compute the `versionCode` and `versionName` by implementing lambdas which are evaluated lazily during execution: 103 | 104 | ```kt 105 | appVersioning { 106 | overrideVersionCode { gitTag, providers, variantInfo -> 107 | // TODO generate an Int from the given gitTag, providers, build variant 108 | } 109 | 110 | overrideVersionName { gitTag, providers, variantInfo -> 111 | // TODO generate a String from the given gitTag, providers, build variant 112 | } 113 | } 114 | ``` 115 | 116 | `GitTag` is a type-safe representation of a tag encapsulating the `rawTagName`, `commitsSinceLatestTag` and `commitHash`, provided by the plugin. 117 | 118 | `providers` is a `ProviderFactory` instance which is a Gradle API that can be useful for [reading environment variables and system properties lazily](https://docs.gradle.org/current/javadoc/org/gradle/api/provider/ProviderFactory.html). 119 | 120 | `VariantInfo` is an object that encapsulates the build variant information including `buildType`, `flavorName`, and `variantName`. 121 | 122 | #### SemVer-based version code 123 | 124 | The plugin by default reserves 2 digits for each of the **MAJOR**, **MINOR** and **PATCH** components in a SemVer tag. 125 | 126 | To allocate 3 digits per component instead (i.e. each version component can go up to 999): 127 | 128 |
Kotlin 129 | 130 | ```kt 131 | import io.github.reactivecircus.appversioning.toSemVer 132 | appVersioning { 133 | overrideVersionCode { gitTag, _, _ -> 134 | val semVer = gitTag.toSemVer() 135 | semVer.major * 1000000 + semVer.minor * 1000 + semVer.patch 136 | } 137 | } 138 | ``` 139 | 140 |
141 | 142 |
Groovy 143 | 144 | ```groovy 145 | import io.github.reactivecircus.appversioning.SemVer 146 | appVersioning { 147 | overrideVersionCode { gitTag, providers, variantInfo -> 148 | def semVer = SemVer.fromGitTag(gitTag) 149 | semVer.major * 1000000 + semVer.minor * 1000 + semVer.patch 150 | } 151 | } 152 | ``` 153 | 154 |
155 | 156 | 157 | `toSemVer()` is an extension function (or `SemVer.fromGitTag(gitTag)` if you use Groovy) provided by the plugin to help create a type-safe `SemVer` object from the `GitTag` by parsing its `rawTagName` field. 158 | 159 | If a Git tag is not fully [SemVer compliant](https://semver.org/#semantic-versioning-specification-semver) (e.g. `1.2`), calling `gitTag.toSemVer()` will throw an exception. In that case we'll need to find another way to compute the `versionCode`. 160 | 161 | #### Using timestamp for version code 162 | 163 | Since the key characteristic for `versionCode` is that it must **monotonically increase** with each app release, a common approach is to use the Epoch / Unix timestamp for `versionCode`: 164 | 165 |
Kotlin 166 | 167 | ```kt 168 | import java.time.Instant 169 | appVersioning { 170 | overrideVersionCode { _, _, _ -> 171 | Instant.now().epochSecond.toInt() 172 | } 173 | } 174 | ``` 175 | 176 |
177 | 178 |
Groovy 179 | 180 | ```groovy 181 | appVersioning { 182 | overrideVersionCode { gitTag, providers, variantInfo -> 183 | Instant.now().epochSecond.intValue() 184 | } 185 | } 186 | ``` 187 | 188 |
189 | 190 | 191 | This will generate a monotonically increasing version code every time the `generateAppVersionInfoForRelease` task is run: 192 | 193 | ``` 194 | Generated app version code: 1599750437. 195 | ``` 196 | 197 | #### Using environment variable 198 | 199 | We can also add a `BUILD_NUMBER` environment variable provided by CI to the `versionCode` or `versionName`. To do this, use the `providers` lambda parameter to create a provider that's only queried during execution: 200 | 201 |
Kotlin 202 | 203 | ```kt 204 | import io.github.reactivecircus.appversioning.toSemVer 205 | appVersioning { 206 | overrideVersionCode { gitTag, providers, _ -> 207 | val buildNumber = providers 208 | .environmentVariable("BUILD_NUMBER") 209 | .getOrElse("0").toInt() 210 | val semVer = gitTag.toSemVer() 211 | semVer.major * 10000 + semVer.minor * 100 + semVer.patch + buildNumber 212 | } 213 | } 214 | ``` 215 | 216 |
217 | 218 |
Groovy 219 | 220 | ```groovy 221 | import io.github.reactivecircus.appversioning.SemVer 222 | appVersioning { 223 | overrideVersionCode { gitTag, providers, variantInfo -> 224 | def buildNumber = providers 225 | .environmentVariable("BUILD_NUMBER") 226 | .getOrElse("0") as Integer 227 | def semVer = SemVer.fromGitTag(gitTag) 228 | semVer.major * 10000 + semVer.minor * 100 + semVer.patch + buildNumber 229 | } 230 | } 231 | ``` 232 | 233 |
234 | 235 | `versionName` can be customized with the same approach: 236 | 237 |
Kotlin 238 | 239 | ```kt 240 | import io.github.reactivecircus.appversioning.toSemVer 241 | appVersioning { 242 | overrideVersionName { gitTag, providers, _ -> 243 | // a custom versionName combining the tag name, commitHash and an environment variable 244 | val buildNumber = providers 245 | .environmentVariable("BUILD_NUMBER") 246 | .getOrElse("0").toInt() 247 | "${gitTag.rawTagName} - #$buildNumber (${gitTag.commitHash})" 248 | } 249 | } 250 | ``` 251 | 252 |
253 | 254 |
Groovy 255 | 256 | ```groovy 257 | appVersioning { 258 | overrideVersionName { gitTag, providers, variantInfo -> 259 | // a custom versionName combining the tag name, commitHash and an environment variable 260 | def buildNumber = providers 261 | .environmentVariable("BUILD_NUMBER") 262 | .getOrElse("0") as Integer 263 | "${gitTag.rawTagName} - #$buildNumber (${gitTag.commitHash})".toString() 264 | } 265 | } 266 | ``` 267 | 268 |
269 | 270 | #### Custom rules based on build variants 271 | 272 | Sometimes you might want to customize `versionCode` or `versionName` based on the build variants (product flavor, build type). To do this, use the `variantInfo` lambda parameter to query the build variant information when generating custom `versionCode` or `verrsionName`: 273 | 274 |
Kotlin 275 | 276 | ```kt 277 | import io.github.reactivecircus.appversioning.toSemVer 278 | appVersioning { 279 | overrideVersionCode { gitTag, _, _ -> 280 | // add 1 to the versionCode for builds with the "paid" product flavor 281 | val offset = if (variantInfo.flavorName == "paid") 1 else 0 282 | val semVer = gitTag.toSemVer() 283 | semVer.major * 10000 + semVer.minor * 100 + semVer.patch + offset 284 | } 285 | overrideVersionName { gitTag, _, variantInfo -> 286 | // append build variant to the versionName for debug builds 287 | val suffix = if (variantInfo.isDebugBuild) " (${variantInfo.variantName})" else "" 288 | gitTag.toString() + suffix 289 | } 290 | } 291 | ``` 292 | 293 |
294 | 295 |
Groovy 296 | 297 | ```groovy 298 | import io.github.reactivecircus.appversioning.SemVer 299 | appVersioning { 300 | overrideVersionCode { gitTag, providers, variantInfo -> 301 | // add 1 to the versionCode for builds with the "paid" product flavor 302 | def offset 303 | if (variantInfo.flavorName == "paid") { 304 | offset = 1 305 | } else { 306 | offset = 0 307 | } 308 | def semVer = SemVer.fromGitTag(gitTag) 309 | semVer.major * 10000 + semVer.minor * 100 + semVer.patch + offset 310 | } 311 | overrideVersionName { gitTag, providers, variantInfo -> 312 | // append build variant to the versionName for debug builds 313 | def suffix 314 | if (variantInfo.debugBuild == true) { 315 | suffix = " (" + variantInfo.variantName + ")" 316 | } else { 317 | suffix = "" 318 | } 319 | gitTag.toString() + suffix 320 | } 321 | } 322 | ``` 323 | 324 |
325 | 326 | ### Tag filtering 327 | 328 | By default the plugin uses the latest tag in the current branch for `versionCode` and `versionName` generation. 329 | 330 | Sometimes it's useful to be able to use the latest tag that follows a specific glob pattern. 331 | 332 | For example a codebase might build and publish 3 different apps separately using the following tag pattern: 333 | 334 | `..[-]+` 335 | 336 | where `app-identifier` is the build metadata component in **SemVer**. 337 | 338 | Some of the possible tags are: 339 | 340 | ``` 341 | 1.5.8+app-a 342 | 2.29.0-rc01+app-b 343 | 10.87.9-alpha04+app-c 344 | ``` 345 | 346 | To configure the plugin to generate version info specific to `app-b`: 347 | 348 | ```kt 349 | appVersioning { 350 | tagFilter.set("[0-9]*.[0-9]*.[0-9]*+app-b") 351 | } 352 | ``` 353 | 354 | ## More configurations 355 | 356 | ### Disabling the plugin 357 | 358 | To disable the plugin such that the `versionCode` and `versionName` defined in the `defaultConfig` block are used instead (if specified): 359 | 360 | ```kt 361 | appVersioning { 362 | /** 363 | * Whether to enable the plugin. 364 | * 365 | * Default is `true`. 366 | */ 367 | enabled.set(false) 368 | } 369 | ``` 370 | 371 | ### Release build only 372 | 373 | To generate `versionCode` and `versionName` **only** for the `Release` build type: 374 | 375 | ```kt 376 | appVersioning { 377 | /** 378 | * Whether to only generate version name and version code for `release` builds. 379 | * 380 | * Default is `false`. 381 | */ 382 | releaseBuildOnly.set(true) 383 | } 384 | ``` 385 | 386 | With `releaseBuildOnly` set to `true`, for a project with the default `debug` and `release` build types and no product flavors, the following tasks are available (note the absense of tasks with `Debug` suffix): 387 | 388 | ``` 389 | /gradlew tasks --group=versioning 390 | 391 | Versioning tasks 392 | ---------------- 393 | generateAppVersionInfoForRelease - Generates app's versionCode and versionName based on git tags for the release variant. 394 | printAppVersionInfoForRelease - Prints the versionCode and versionName generated by Android App Versioning plugin (if available) to the console for the release variant. 395 | ``` 396 | 397 | ### Fetching tags if none exists locally 398 | 399 | Sometimes a local checkout may not contain the Git tags (e.g. when cloning was done with `--no-tags`). To fetch git tags from remote when no tags can be found locally: 400 | 401 | ```kt 402 | appVersioning { 403 | /** 404 | * Whether to fetch git tags from remote when no git tag can be found locally. 405 | * 406 | * Default is `false`. 407 | */ 408 | fetchTagsWhenNoneExistsLocally.set(true) 409 | } 410 | ``` 411 | 412 | ### Custom git root directory 413 | 414 | The plugin assumes the root Gradle project is the git root directory that contains `.git`. If your root Gradle project is not your git root, you can specify it explicitly: 415 | 416 | ```kt 417 | appVersioning { 418 | /** 419 | * Git root directory used for fetching git tags. 420 | * Use this to explicitly set the git root directory when the root Gradle project is not the git root directory. 421 | */ 422 | gitRootDirectory.set(rootProject.file("../")) // if the .git directory is in the root Gradle project's parent directory. 423 | } 424 | ``` 425 | 426 | ### Bare git repository 427 | 428 | If your `.git` is a symbolic link to a **bare** git repository, you need to explicitly specify the directory of the bare git repository: 429 | 430 | ```kt 431 | appVersioning { 432 | /** 433 | * Bare Git repository directory. 434 | * Use this to explicitly set the directory of a bare git repository (e.g. `app.git`) instead of the standard `.git`. 435 | * Setting this will override the value of [gitRootDirectory] property. 436 | */ 437 | bareGitRepoDirectory.set(rootProject.file("../.repo/projects/app.git")) // if the .git directory in the Gradle project root is a symlink to app.git. 438 | } 439 | ``` 440 | 441 | ## App versioning on CI 442 | 443 | For performance reason many CI providers only fetch a single commit by default when checking out the repository. For **app-versioning** to work we need to make sure Git tags are also fetched. Here's an example for doing this with [GitHub Actions](https://github.com/actions/checkout): 444 | 445 | ``` 446 | - uses: actions/checkout@v4 447 | with: 448 | fetch-depth: 0 449 | ``` 450 | 451 | ### Retrieving the generated version code and version name 452 | 453 | Both the `versionCode` and `versionName` generated by **app-versioning** are in the build output directory: 454 | 455 | ``` 456 | app/build/outputs/app_versioning//version_code.txt 457 | app/build/outputs/app_versioning//version_name.txt 458 | ``` 459 | 460 | We can `cat` the output of these files into variables: 461 | 462 | ``` 463 | VERSION_CODE=$(cat app/build/outputs/app_versioning//version_code.txt) 464 | VERSION_NAME=$(cat app/build/outputs/app_versioning//version_name.txt) 465 | ``` 466 | 467 | Note that if you need to query these files in a different VM than where the APK (and its version info) was originally generated, you need to make sure these files are "carried over" from the original VM. Otherwise you'll need to run the `generateAppVersionInfoFor` task again to generate these files, but the generated version info might not be the same as what's actually used for the APK (e.g. if you use the Epoch timestamp for `versionCode`). 468 | 469 | Here's an example with GitHub Actions that does the following: 470 | 471 | - in the [Assemble job](https://github.com/ReactiveCircus/streamlined/blob/f0b605627ffaa2a51b37cdec7c2dd846ad3a7dbf/.github/workflows/ci.yml#L33-L72), build the App Bundle and archive / upload the build outputs directory which include the AAB and its R8 mapping file, along with the `version_code.txt` and `version_name.txt` files generated by **app-versioning**. 472 | - later in the [Publish to Play Store job](https://github.com/ReactiveCircus/streamlined/blob/f0b605627ffaa2a51b37cdec7c2dd846ad3a7dbf/.github/workflows/ci.yml#L202-L247), download the previously archived build outputs directory, `cat` the content of `version_code.txt` and `version_name.txt` into variables, upload the R8 mapping file to Bugsnag API with curl and passing the retrieved `$VERSION_CODE` and `$VERSION_NAME` as parameters, and finally upload the AAB to Play Store (without building the AAB or generating the app version info again). 473 | 474 | ## License 475 | 476 | ``` 477 | Copyright 2020 Yang Chen 478 | 479 | Licensed under the Apache License, Version 2.0 (the "License"); 480 | you may not use this file except in compliance with the License. 481 | You may obtain a copy of the License at 482 | 483 | http://www.apache.org/licenses/LICENSE-2.0 484 | 485 | Unless required by applicable law or agreed to in writing, software 486 | distributed under the License is distributed on an "AS IS" BASIS, 487 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 488 | See the License for the specific language governing permissions and 489 | limitations under the License. 490 | ``` 491 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | The following steps publish the plugin to both **Maven Central** and **Gradle Plugin Portal**: 4 | 5 | 1. Change the version in top-level `gradle.properties` to a non-SNAPSHOT version. 6 | 2. Update the `CHANGELOG.md` for the impending release. 7 | 3. Update the `README.md` with the new version. 8 | 4. `git commit -am "Prepare for release X.Y.Z."` (where X.Y.Z is the new version). 9 | 5. `./gradlew publishAndReleaseToMavenCentral` 10 | 6. Visit [Sonatype Nexus](https://s01.oss.sonatype.org/) to verify the release has been completed. 11 | 7. `git tag -a X.Y.X -m "X.Y.Z"` (where X.Y.Z is the new version) 12 | 8. Update the top-level `gradle.properties` to the next SNAPSHOT version. 13 | 9. `git commit -am "Prepare next development version."` 14 | 10. `git push && git push --tags` 15 | 16 | If step 5 or 6 fails, drop the Sonatype repo, fix the problem, commit, and start again at step 5. 17 | -------------------------------------------------------------------------------- /api/app-versioning.api: -------------------------------------------------------------------------------- 1 | public class io/github/reactivecircus/appversioning/AppVersioningExtension { 2 | public static final field Companion Lio/github/reactivecircus/appversioning/AppVersioningExtension$Companion; 3 | public final fun getBareGitRepoDirectory ()Lorg/gradle/api/file/DirectoryProperty; 4 | public final fun getEnabled ()Lorg/gradle/api/provider/Property; 5 | public final fun getFetchTagsWhenNoneExistsLocally ()Lorg/gradle/api/provider/Property; 6 | public final fun getGitRootDirectory ()Lorg/gradle/api/file/DirectoryProperty; 7 | public final fun getReleaseBuildOnly ()Lorg/gradle/api/provider/Property; 8 | public final fun getTagFilter ()Lorg/gradle/api/provider/Property; 9 | public final fun overrideVersionCode (Lgroovy/lang/Closure;)V 10 | public final fun overrideVersionCode (Lkotlin/jvm/functions/Function3;)V 11 | public final fun overrideVersionName (Lgroovy/lang/Closure;)V 12 | public final fun overrideVersionName (Lkotlin/jvm/functions/Function3;)V 13 | } 14 | 15 | public final class io/github/reactivecircus/appversioning/AppVersioningExtension$Companion { 16 | } 17 | 18 | public final class io/github/reactivecircus/appversioning/AppVersioningPlugin : org/gradle/api/Plugin { 19 | public static final field Companion Lio/github/reactivecircus/appversioning/AppVersioningPlugin$Companion; 20 | public fun ()V 21 | public synthetic fun apply (Ljava/lang/Object;)V 22 | public fun apply (Lorg/gradle/api/Project;)V 23 | } 24 | 25 | public final class io/github/reactivecircus/appversioning/AppVersioningPlugin$Companion { 26 | } 27 | 28 | public final class io/github/reactivecircus/appversioning/AppVersioningPlugin$inlined$sam$i$org_gradle_api_Action$0 : org/gradle/api/Action { 29 | public fun (Lkotlin/jvm/functions/Function1;)V 30 | public final synthetic fun execute (Ljava/lang/Object;)V 31 | } 32 | 33 | public final class io/github/reactivecircus/appversioning/GitTag { 34 | public fun (Ljava/lang/String;ILjava/lang/String;)V 35 | public final fun component1 ()Ljava/lang/String; 36 | public final fun component2 ()I 37 | public final fun component3 ()Ljava/lang/String; 38 | public final fun copy (Ljava/lang/String;ILjava/lang/String;)Lio/github/reactivecircus/appversioning/GitTag; 39 | public static synthetic fun copy$default (Lio/github/reactivecircus/appversioning/GitTag;Ljava/lang/String;ILjava/lang/String;ILjava/lang/Object;)Lio/github/reactivecircus/appversioning/GitTag; 40 | public fun equals (Ljava/lang/Object;)Z 41 | public final fun getCommitHash ()Ljava/lang/String; 42 | public final fun getCommitsSinceLatestTag ()I 43 | public final fun getRawTagName ()Ljava/lang/String; 44 | public fun hashCode ()I 45 | public fun toString ()Ljava/lang/String; 46 | } 47 | 48 | public final class io/github/reactivecircus/appversioning/SemVer { 49 | public static final field Companion Lio/github/reactivecircus/appversioning/SemVer$Companion; 50 | public fun (IIILjava/lang/String;Ljava/lang/String;)V 51 | public synthetic fun (IIILjava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V 52 | public final fun component1 ()I 53 | public final fun component2 ()I 54 | public final fun component3 ()I 55 | public final fun component4 ()Ljava/lang/String; 56 | public final fun component5 ()Ljava/lang/String; 57 | public final fun copy (IIILjava/lang/String;Ljava/lang/String;)Lio/github/reactivecircus/appversioning/SemVer; 58 | public static synthetic fun copy$default (Lio/github/reactivecircus/appversioning/SemVer;IIILjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/github/reactivecircus/appversioning/SemVer; 59 | public fun equals (Ljava/lang/Object;)Z 60 | public static final fun fromGitTag (Lio/github/reactivecircus/appversioning/GitTag;)Lio/github/reactivecircus/appversioning/SemVer; 61 | public static final fun fromGitTag (Lio/github/reactivecircus/appversioning/GitTag;Z)Lio/github/reactivecircus/appversioning/SemVer; 62 | public final fun getBuildMetadata ()Ljava/lang/String; 63 | public final fun getMajor ()I 64 | public final fun getMinor ()I 65 | public final fun getPatch ()I 66 | public final fun getPreRelease ()Ljava/lang/String; 67 | public fun hashCode ()I 68 | public fun toString ()Ljava/lang/String; 69 | } 70 | 71 | public final class io/github/reactivecircus/appversioning/SemVer$Companion { 72 | public final fun fromGitTag (Lio/github/reactivecircus/appversioning/GitTag;)Lio/github/reactivecircus/appversioning/SemVer; 73 | public final fun fromGitTag (Lio/github/reactivecircus/appversioning/GitTag;Z)Lio/github/reactivecircus/appversioning/SemVer; 74 | public static synthetic fun fromGitTag$default (Lio/github/reactivecircus/appversioning/SemVer$Companion;Lio/github/reactivecircus/appversioning/GitTag;ZILjava/lang/Object;)Lio/github/reactivecircus/appversioning/SemVer; 75 | } 76 | 77 | public final class io/github/reactivecircus/appversioning/SemVerKt { 78 | public static final fun toSemVer (Lio/github/reactivecircus/appversioning/GitTag;Z)Lio/github/reactivecircus/appversioning/SemVer; 79 | public static synthetic fun toSemVer$default (Lio/github/reactivecircus/appversioning/GitTag;ZILjava/lang/Object;)Lio/github/reactivecircus/appversioning/SemVer; 80 | } 81 | 82 | public final class io/github/reactivecircus/appversioning/VariantInfo : java/io/Serializable { 83 | public static final field Companion Lio/github/reactivecircus/appversioning/VariantInfo$Companion; 84 | public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V 85 | public final fun getBuildType ()Ljava/lang/String; 86 | public final fun getFlavorName ()Ljava/lang/String; 87 | public final fun getVariantName ()Ljava/lang/String; 88 | public final fun isDebugBuild ()Z 89 | public final fun isReleaseBuild ()Z 90 | } 91 | 92 | public final class io/github/reactivecircus/appversioning/VariantInfo$Companion { 93 | } 94 | 95 | public final class io/github/reactivecircus/appversioning/internal/CommitId { 96 | public static final synthetic fun box-impl (Ljava/lang/String;)Lio/github/reactivecircus/appversioning/internal/CommitId; 97 | public static fun constructor-impl (Ljava/lang/String;)Ljava/lang/String; 98 | public fun equals (Ljava/lang/Object;)Z 99 | public static fun equals-impl (Ljava/lang/String;Ljava/lang/Object;)Z 100 | public static final fun equals-impl0 (Ljava/lang/String;Ljava/lang/String;)Z 101 | public final fun getValue ()Ljava/lang/String; 102 | public fun hashCode ()I 103 | public static fun hashCode-impl (Ljava/lang/String;)I 104 | public fun toString ()Ljava/lang/String; 105 | public static fun toString-impl (Ljava/lang/String;)Ljava/lang/String; 106 | public final synthetic fun unbox-impl ()Ljava/lang/String; 107 | } 108 | 109 | public final class io/github/reactivecircus/appversioning/internal/GitClient { 110 | public static final field Companion Lio/github/reactivecircus/appversioning/internal/GitClient$Companion; 111 | public synthetic fun (Ljava/io/File;Lkotlin/jvm/internal/DefaultConstructorMarker;)V 112 | public final fun checkoutTag (Ljava/lang/String;)V 113 | public final fun commit-HhBVPuw (Ljava/lang/String;Z)Ljava/lang/String; 114 | public static synthetic fun commit-HhBVPuw$default (Lio/github/reactivecircus/appversioning/internal/GitClient;Ljava/lang/String;ZILjava/lang/Object;)Ljava/lang/String; 115 | public final fun describeLatestTag (Ljava/lang/String;)Ljava/lang/String; 116 | public static synthetic fun describeLatestTag$default (Lio/github/reactivecircus/appversioning/internal/GitClient;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/String; 117 | public final fun fetchRemoteTags ()V 118 | public final fun listLocalTags ()Ljava/util/List; 119 | public final fun tag-9kPyyPo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V 120 | public static synthetic fun tag-9kPyyPo$default (Lio/github/reactivecircus/appversioning/internal/GitClient;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V 121 | } 122 | 123 | public final class io/github/reactivecircus/appversioning/internal/GitClient$Companion { 124 | public final fun initialize (Ljava/io/File;)Lio/github/reactivecircus/appversioning/internal/GitClient; 125 | public final fun open (Ljava/io/File;)Lio/github/reactivecircus/appversioning/internal/GitClient; 126 | } 127 | 128 | public abstract class io/github/reactivecircus/appversioning/tasks/GenerateAppVersionInfo : org/gradle/api/DefaultTask { 129 | public static final field Companion Lio/github/reactivecircus/appversioning/tasks/GenerateAppVersionInfo$Companion; 130 | public static final field TASK_DESCRIPTION_PREFIX Ljava/lang/String; 131 | public static final field TASK_NAME_PREFIX Ljava/lang/String; 132 | public static final field VERSION_CODE_FALLBACK I 133 | public static final field VERSION_NAME_FALLBACK Ljava/lang/String; 134 | public fun (Lorg/gradle/workers/WorkerExecutor;)V 135 | public final fun generate ()V 136 | public abstract fun getFetchTagsWhenNoneExistsLocally ()Lorg/gradle/api/provider/Property; 137 | public abstract fun getGitHead ()Lorg/gradle/api/file/RegularFileProperty; 138 | public abstract fun getGitRefsDirectory ()Lorg/gradle/api/file/DirectoryProperty; 139 | public abstract fun getGroovyVersionCodeCustomizer ()Lorg/gradle/api/provider/Property; 140 | public abstract fun getGroovyVersionNameCustomizer ()Lorg/gradle/api/provider/Property; 141 | public abstract fun getKotlinVersionCodeCustomizer ()Lorg/gradle/api/provider/Property; 142 | public abstract fun getKotlinVersionNameCustomizer ()Lorg/gradle/api/provider/Property; 143 | public abstract fun getRootProjectDirectory ()Lorg/gradle/api/file/DirectoryProperty; 144 | public abstract fun getRootProjectDisplayName ()Lorg/gradle/api/provider/Property; 145 | public abstract fun getTagFilter ()Lorg/gradle/api/provider/Property; 146 | public abstract fun getVariantInfo ()Lorg/gradle/api/provider/Property; 147 | public abstract fun getVersionCodeFile ()Lorg/gradle/api/file/RegularFileProperty; 148 | public abstract fun getVersionNameFile ()Lorg/gradle/api/file/RegularFileProperty; 149 | } 150 | 151 | public final class io/github/reactivecircus/appversioning/tasks/GenerateAppVersionInfo$Companion { 152 | } 153 | 154 | public abstract class io/github/reactivecircus/appversioning/tasks/PrintAppVersionInfo : org/gradle/api/DefaultTask { 155 | public static final field Companion Lio/github/reactivecircus/appversioning/tasks/PrintAppVersionInfo$Companion; 156 | public static final field TASK_DESCRIPTION_PREFIX Ljava/lang/String; 157 | public static final field TASK_NAME_PREFIX Ljava/lang/String; 158 | public fun ()V 159 | public abstract fun getBuildVariantName ()Lorg/gradle/api/provider/Property; 160 | public abstract fun getProjectName ()Lorg/gradle/api/provider/Property; 161 | public abstract fun getVersionCodeFile ()Lorg/gradle/api/file/RegularFileProperty; 162 | public abstract fun getVersionNameFile ()Lorg/gradle/api/file/RegularFileProperty; 163 | public final fun print ()V 164 | } 165 | 166 | public final class io/github/reactivecircus/appversioning/tasks/PrintAppVersionInfo$Companion { 167 | } 168 | 169 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | import com.vanniktech.maven.publish.SonatypeHost 4 | import io.gitlab.arturbosch.detekt.Detekt 5 | import org.gradle.api.tasks.testing.logging.TestLogEvent 6 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 7 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 8 | 9 | plugins { 10 | `kotlin-dsl` 11 | alias(libs.plugins.kotlin.jvm) 12 | alias(libs.plugins.binaryCompatibilityValidator) 13 | alias(libs.plugins.detekt) 14 | alias(libs.plugins.mavenPublish) 15 | } 16 | 17 | group = property("GROUP") as String 18 | version = property("VERSION_NAME") as String 19 | 20 | mavenPublishing { 21 | publishToMavenCentral(SonatypeHost.S01, automaticRelease = true) 22 | signAllPublications() 23 | } 24 | 25 | gradlePlugin { 26 | website.set(property("POM_URL") as String) 27 | vcsUrl.set(property("POM_SCM_URL") as String) 28 | plugins.create("appVersioning") { 29 | id = "io.github.reactivecircus.app-versioning" 30 | displayName = "Android App Versioning Gradle Plugin." 31 | description = "Gradle plugin for lazily generating Android app's versionCode & versionName from Git tags." 32 | tags.set(listOf("android", "versioning")) 33 | implementationClass = "io.github.reactivecircus.appversioning.AppVersioningPlugin" 34 | } 35 | } 36 | 37 | tasks.withType().configureEach { 38 | compilerOptions { 39 | jvmTarget.set(JvmTarget.JVM_17) 40 | freeCompilerArgs.addAll( 41 | "-Xjvm-default=all", 42 | ) 43 | } 44 | } 45 | 46 | tasks.withType().configureEach { 47 | sourceCompatibility = JavaVersion.VERSION_17.toString() 48 | targetCompatibility = JavaVersion.VERSION_17.toString() 49 | } 50 | 51 | val fixtureClasspath: Configuration by configurations.creating 52 | tasks.pluginUnderTestMetadata { 53 | pluginClasspath.from(fixtureClasspath) 54 | } 55 | 56 | val functionalTestSourceSet: SourceSet = sourceSets.create("functionalTest") { 57 | compileClasspath += sourceSets["main"].output + configurations["testRuntimeClasspath"] 58 | runtimeClasspath += output + compileClasspath 59 | } 60 | 61 | val functionalTestImplementation: Configuration = configurations.getByName("functionalTestImplementation") 62 | .extendsFrom(configurations.getByName("testImplementation")) 63 | 64 | gradlePlugin.testSourceSets(functionalTestSourceSet) 65 | 66 | val functionalTest by tasks.registering(Test::class) { 67 | failFast = true 68 | testLogging { 69 | events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) 70 | } 71 | testClassesDirs = functionalTestSourceSet.output.classesDirs 72 | classpath = functionalTestSourceSet.runtimeClasspath 73 | } 74 | 75 | val check by tasks.getting(Task::class) { 76 | dependsOn(functionalTest) 77 | } 78 | 79 | val test by tasks.getting(Test::class) { 80 | maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) 81 | testLogging { 82 | events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) 83 | } 84 | } 85 | 86 | val fixtureAgpVersion: Provider = providers 87 | .environmentVariable("AGP_VERSION") 88 | .orElse(providers.gradleProperty("AGP_VERSION")) 89 | .orElse(libs.versions.agp) 90 | 91 | dependencies { 92 | compileOnly(libs.agp.build) 93 | testImplementation(libs.junit) 94 | testImplementation(libs.truth) 95 | testImplementation(libs.testParameterInjector) 96 | fixtureClasspath(libs.agp.build.flatMap { dependency -> 97 | fixtureAgpVersion.map { version -> 98 | "${dependency.group}:${dependency.name}:$version" 99 | } 100 | }) 101 | } 102 | 103 | detekt { 104 | source.from(files("src/")) 105 | config.from(files("${project.rootDir}/detekt.yml")) 106 | buildUponDefaultConfig = true 107 | allRules = true 108 | } 109 | 110 | tasks.withType().configureEach { 111 | jvmTarget = JvmTarget.JVM_22.target 112 | reports { 113 | html.outputLocation.set(file("build/reports/detekt/${project.name}.html")) 114 | } 115 | } 116 | 117 | val detektFormatting = libs.plugin.detektFormatting.get() 118 | 119 | dependencies.add("detektPlugins", detektFormatting) 120 | -------------------------------------------------------------------------------- /detekt.yml: -------------------------------------------------------------------------------- 1 | complexity: 2 | LongMethod: 3 | excludes: ["**/test/**","**/functionalTest/**"] 4 | TooManyFunctions: 5 | excludes: ["**/test/**","**/functionalTest/**"] 6 | LargeClass: 7 | excludes: ["**/test/**","**/functionalTest/**"] 8 | 9 | formatting: 10 | MaximumLineLength: 11 | active: false 12 | TrailingCommaOnCallSite: 13 | active: false 14 | TrailingCommaOnDeclarationSite: 15 | active: false 16 | FunctionSignature: 17 | active: false 18 | 19 | naming: 20 | FunctionMaxLength: 21 | active: false 22 | 23 | style: 24 | MaxLineLength: 25 | active: false 26 | -------------------------------------------------------------------------------- /docs/images/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/app-versioning/d313a4b57b67857463681367e27c83581a398a04/docs/images/sample.png -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | GROUP=io.github.reactivecircus.appversioning 2 | VERSION_NAME=1.5.0-SNAPSHOT 3 | 4 | POM_ARTIFACT_ID=app-versioning-gradle-plugin 5 | POM_NAME=Android App Versioning Gradle Plugin 6 | POM_DESCRIPTION=Gradle plugin for lazily generating Android app's versionCode & versionName from Git tags 7 | POM_PACKAGING=jar 8 | 9 | POM_URL=https://github.com/ReactiveCircus/app-versioning 10 | POM_SCM_URL=https://github.com/ReactiveCircus/app-versioning 11 | POM_SCM_CONNECTION=scm:git:https://github.com/ReactiveCircus/app-versioning.git 12 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/ReactiveCircus/app-versioning.git 13 | 14 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 15 | POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt 16 | POM_LICENCE_DIST=repo 17 | 18 | POM_DEVELOPER_ID=reactivecircus 19 | POM_DEVELOPER_NAME=Reactive Circus 20 | 21 | org.gradle.parallel=true 22 | org.gradle.configureondemand=true 23 | org.gradle.caching=true 24 | org.gradle.configuration-cache=true 25 | org.gradle.configuration-cache.problems=warn 26 | org.gradle.unsafe.isolated-projects=true 27 | 28 | # Kotlin code style 29 | kotlin.code.style=official 30 | -------------------------------------------------------------------------------- /gradle/gradle-daemon-jvm.properties: -------------------------------------------------------------------------------- 1 | #This file is generated by updateDaemonJvm 2 | toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/796901cbf89fc850a3f3bb8ddfe4506c/redirect 3 | toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/26af28a3208b58b524a51fc519bca85a/redirect 4 | toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/796901cbf89fc850a3f3bb8ddfe4506c/redirect 5 | toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/26af28a3208b58b524a51fc519bca85a/redirect 6 | toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/761743ab934ff2cc7d8e409918f67acf/redirect 7 | toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d5f8fa0927b058deea7bc5543fc3e371/redirect 8 | toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/796901cbf89fc850a3f3bb8ddfe4506c/redirect 9 | toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/26af28a3208b58b524a51fc519bca85a/redirect 10 | toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/f765ffc0b109346d16cfa1fed6eb856a/redirect 11 | toolchainVendor=AZUL 12 | toolchainVersion=24 13 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kotlin = "2.1.20" 3 | binaryCompabilityValidator = "0.15.1" 4 | agp = "8.10.1" 5 | detekt = "1.23.7" 6 | mavenPublish = "0.31.0" 7 | junit = "4.13.2" 8 | truth = "1.1.3" 9 | testParameterInjector = "1.18" 10 | toolchainsResolver = "0.10.0" 11 | 12 | [libraries] 13 | plugin-detektFormatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } 14 | 15 | agp-build = { module = "com.android.tools.build:gradle", version.ref = "agp" } 16 | junit = { module = "junit:junit", version.ref = "junit" } 17 | truth = { module = "com.google.truth:truth", version.ref = "truth" } 18 | testParameterInjector = { group = "com.google.testparameterinjector", name = "test-parameter-injector", version.ref = "testParameterInjector" } 19 | 20 | [plugins] 21 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 22 | binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompabilityValidator" } 23 | detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } 24 | mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } 25 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveCircus/app-versioning/d313a4b57b67857463681367e27c83581a398a04/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "app-versioning" 2 | 3 | pluginManagement { 4 | repositories { 5 | gradlePluginPortal { 6 | content { 7 | includeGroupByRegex("org.gradle.*") 8 | } 9 | } 10 | mavenCentral() 11 | } 12 | 13 | val toolchainsResolverVersion = file("$rootDir/gradle/libs.versions.toml") 14 | .readLines() 15 | .first { it.contains("toolchainsResolver") } 16 | .substringAfter("=") 17 | .trim() 18 | .removeSurrounding("\"") 19 | 20 | plugins { 21 | id("org.gradle.toolchains.foojay-resolver-convention") version toolchainsResolverVersion 22 | } 23 | } 24 | 25 | @Suppress("UnstableApiUsage") 26 | dependencyResolutionManagement { 27 | repositories { 28 | mavenCentral() 29 | google() 30 | } 31 | } 32 | 33 | plugins { 34 | id("org.gradle.toolchains.foojay-resolver-convention") 35 | } 36 | -------------------------------------------------------------------------------- /src/functionalTest/kotlin/io/github/reactivecircus/appversioning/AppVersioningPluginIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("FunctionName") 2 | 3 | package io.github.reactivecircus.appversioning 4 | 5 | import com.google.common.truth.Truth.assertThat 6 | import com.google.testing.junit.testparameterinjector.TestParameter 7 | import com.google.testing.junit.testparameterinjector.TestParameterInjector 8 | import io.github.reactivecircus.appversioning.fixtures.AppProjectTemplate 9 | import io.github.reactivecircus.appversioning.fixtures.LibraryProjectTemplate 10 | import io.github.reactivecircus.appversioning.fixtures.withFixtureRunner 11 | import io.github.reactivecircus.appversioning.internal.GitClient 12 | import io.github.reactivecircus.appversioning.tasks.GenerateAppVersionInfo 13 | import io.github.reactivecircus.appversioning.tasks.PrintAppVersionInfo 14 | import org.gradle.testkit.runner.TaskOutcome 15 | import org.junit.Rule 16 | import org.junit.Test 17 | import org.junit.rules.TemporaryFolder 18 | import org.junit.runner.RunWith 19 | import java.io.File 20 | 21 | @RunWith(TestParameterInjector::class) 22 | class AppVersioningPluginIntegrationTest { 23 | 24 | @get:Rule 25 | val fixtureDir = TemporaryFolder() 26 | 27 | @Test 28 | fun `plugin cannot be applied to project without Android App plugin`() { 29 | GitClient.initialize(fixtureDir.root) 30 | 31 | withFixtureRunner( 32 | fixtureDir = fixtureDir, 33 | subprojects = listOf(LibraryProjectTemplate()) 34 | ).runAndExpectFailure( 35 | "help" 36 | ) { 37 | assertThat(task("help")?.outcome).isNull() 38 | assertThat(output).contains( 39 | "Android App Versioning plugin should only be applied to an Android Application project but project ':library' doesn't have the 'com.android.application' plugin applied." 40 | ) 41 | } 42 | } 43 | 44 | @Test 45 | fun `plugin tasks are registered for Android App project without product flavors`() { 46 | GitClient.initialize(fixtureDir.root) 47 | 48 | withFixtureRunner( 49 | fixtureDir = fixtureDir, 50 | subprojects = listOf(AppProjectTemplate()) 51 | ).runAndCheckResult( 52 | "tasks", 53 | "--group=versioning" 54 | ) { 55 | assertThat(output).contains("Versioning tasks") 56 | assertThat(output).contains( 57 | "generateAppVersionInfoForDebug - ${GenerateAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the debug variant." 58 | ) 59 | assertThat(output).contains( 60 | "generateAppVersionInfoForRelease - ${GenerateAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the release variant." 61 | ) 62 | assertThat(output).contains( 63 | "printAppVersionInfoForDebug - ${PrintAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the debug variant." 64 | ) 65 | assertThat(output).contains( 66 | "printAppVersionInfoForRelease - ${PrintAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the release variant." 67 | ) 68 | } 69 | } 70 | 71 | @Test 72 | fun `plugin tasks are registered for Android App project with product flavors`( 73 | @TestParameter useKts: Boolean 74 | ) { 75 | GitClient.initialize(fixtureDir.root) 76 | 77 | val flavors = listOf("mock", "prod") 78 | withFixtureRunner( 79 | fixtureDir = fixtureDir, 80 | subprojects = listOf(AppProjectTemplate(useKts = useKts, flavors = flavors)) 81 | ).runAndCheckResult( 82 | "tasks", 83 | "--group=versioning" 84 | ) { 85 | assertThat(output).contains("Versioning tasks") 86 | assertThat(output).contains( 87 | "generateAppVersionInfoForMockDebug - ${GenerateAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the mockDebug variant." 88 | ) 89 | assertThat(output).contains( 90 | "generateAppVersionInfoForProdDebug - ${GenerateAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the prodDebug variant." 91 | ) 92 | assertThat(output).contains( 93 | "generateAppVersionInfoForMockRelease - ${GenerateAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the mockRelease variant." 94 | ) 95 | assertThat(output).contains( 96 | "generateAppVersionInfoForProdRelease - ${GenerateAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the prodRelease variant." 97 | ) 98 | assertThat(output).contains( 99 | "printAppVersionInfoForMockDebug - ${PrintAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the mockDebug variant." 100 | ) 101 | assertThat(output).contains( 102 | "printAppVersionInfoForProdDebug - ${PrintAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the prodDebug variant." 103 | ) 104 | assertThat(output).contains( 105 | "printAppVersionInfoForMockRelease - ${PrintAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the mockRelease variant." 106 | ) 107 | assertThat(output).contains( 108 | "printAppVersionInfoForProdRelease - ${PrintAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the prodRelease variant." 109 | ) 110 | } 111 | } 112 | 113 | @Test 114 | fun `plugin tasks are not registered for debug builds when releaseBuildOnly is enabled`() { 115 | GitClient.initialize(fixtureDir.root) 116 | 117 | val extension = """ 118 | appVersioning { 119 | releaseBuildOnly.set(true) 120 | } 121 | """.trimIndent() 122 | withFixtureRunner( 123 | fixtureDir = fixtureDir, 124 | subprojects = listOf(AppProjectTemplate(pluginExtension = extension)) 125 | ).runAndCheckResult( 126 | "tasks", 127 | "--group=versioning" 128 | ) { 129 | assertThat(output).doesNotContain("generateAppVersionInfoForDebug") 130 | assertThat(output).doesNotContain("printAppVersionInfoForDebug") 131 | } 132 | } 133 | 134 | @Test 135 | fun `plugin tasks are registered for debug builds when releaseBuildOnly is disabled`() { 136 | GitClient.initialize(fixtureDir.root) 137 | 138 | val extension = """ 139 | appVersioning { 140 | releaseBuildOnly.set(false) 141 | } 142 | """.trimIndent() 143 | withFixtureRunner( 144 | fixtureDir = fixtureDir, 145 | subprojects = listOf(AppProjectTemplate(pluginExtension = extension)) 146 | ).runAndCheckResult( 147 | "tasks", 148 | "--group=versioning" 149 | ) { 150 | assertThat(output).contains( 151 | "generateAppVersionInfoForDebug - ${GenerateAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the debug variant." 152 | ) 153 | assertThat(output).contains( 154 | "printAppVersionInfoForDebug - ${PrintAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the debug variant." 155 | ) 156 | } 157 | } 158 | 159 | @Test 160 | fun `plugin tasks are registered when plugin is enabled`() { 161 | GitClient.initialize(fixtureDir.root) 162 | 163 | val extension = """ 164 | appVersioning { 165 | enabled.set(true) 166 | } 167 | """.trimIndent() 168 | withFixtureRunner( 169 | fixtureDir = fixtureDir, 170 | subprojects = listOf(AppProjectTemplate(pluginExtension = extension)) 171 | ).runAndCheckResult( 172 | "tasks", 173 | "--group=versioning" 174 | ) { 175 | assertThat(output).contains("Versioning tasks") 176 | assertThat(output).contains( 177 | "generateAppVersionInfoForRelease - ${GenerateAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the release variant." 178 | ) 179 | assertThat(output).contains( 180 | "printAppVersionInfoForRelease - ${PrintAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the release variant." 181 | ) 182 | } 183 | } 184 | 185 | @Test 186 | fun `plugin tasks are not registered when plugin is disabled`() { 187 | GitClient.initialize(fixtureDir.root) 188 | 189 | val extension = """ 190 | appVersioning { 191 | enabled.set(false) 192 | } 193 | """.trimIndent() 194 | withFixtureRunner( 195 | fixtureDir = fixtureDir, 196 | subprojects = listOf(AppProjectTemplate(pluginExtension = extension)) 197 | ).runAndCheckResult( 198 | "tasks", 199 | "--group=versioning" 200 | ) { 201 | assertThat(output).doesNotContain("Versioning tasks") 202 | assertThat(output).contains("No tasks") 203 | assertThat(output).contains("Android App Versioning plugin is disabled.") 204 | } 205 | } 206 | 207 | @Test 208 | fun `plugin generates versionCode and versionName for the assembled APK when assemble task is run`( 209 | @TestParameter useKts: Boolean 210 | ) { 211 | GitClient.initialize(fixtureDir.root).apply { 212 | val commitId = commit(message = "1st commit.") 213 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 214 | } 215 | 216 | withFixtureRunner( 217 | fixtureDir = fixtureDir, 218 | subprojects = listOf(AppProjectTemplate(useKts = useKts)) 219 | ).runAndCheckResult( 220 | "assembleRelease" 221 | ) { 222 | val versionCodeFileContent = File( 223 | fixtureDir.root, 224 | "app/build/outputs/app_versioning/release/version_code.txt" 225 | ).readText() 226 | val versionNameFileContent = File( 227 | fixtureDir.root, 228 | "app/build/outputs/app_versioning/release/version_name.txt" 229 | ).readText() 230 | 231 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 232 | assertThat(task(":app:assembleRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 233 | 234 | assertThat(output).contains("Generated app version code: 10203.") 235 | assertThat(output).contains("Generated app version name: \"1.2.3\".") 236 | 237 | assertThat(versionCodeFileContent).isEqualTo("10203") 238 | assertThat(versionNameFileContent).isEqualTo("1.2.3") 239 | } 240 | } 241 | 242 | @Test 243 | fun `plugin generates versionCode and versionName for the assembled APKs when splits-APKs is enabled`( 244 | @TestParameter useKts: Boolean 245 | ) { 246 | GitClient.initialize(fixtureDir.root).apply { 247 | val commitId = commit(message = "1st commit.") 248 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 249 | } 250 | 251 | withFixtureRunner( 252 | fixtureDir = fixtureDir, 253 | subprojects = listOf(AppProjectTemplate(useKts = useKts, splitsApks = true)) 254 | ).runAndCheckResult( 255 | "assembleRelease" 256 | ) { 257 | val versionCodeFileContent = File( 258 | fixtureDir.root, 259 | "app/build/outputs/app_versioning/release/version_code.txt" 260 | ).readText() 261 | val versionNameFileContent = File( 262 | fixtureDir.root, 263 | "app/build/outputs/app_versioning/release/version_name.txt" 264 | ).readText() 265 | 266 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 267 | assertThat(task(":app:assembleRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 268 | 269 | assertThat(output).contains("Generated app version code: 10203.") 270 | assertThat(output).contains("Generated app version name: \"1.2.3\".") 271 | 272 | assertThat(versionCodeFileContent).isEqualTo("10203") 273 | assertThat(versionNameFileContent).isEqualTo("1.2.3") 274 | } 275 | } 276 | 277 | @Test 278 | fun `plugin generates versionCode and versionName for the assembled APKs when splits-APKs is enabled and universal mode is on`( 279 | @TestParameter useKts: Boolean 280 | ) { 281 | GitClient.initialize(fixtureDir.root).apply { 282 | val commitId = commit(message = "1st commit.") 283 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 284 | } 285 | 286 | withFixtureRunner( 287 | fixtureDir = fixtureDir, 288 | subprojects = listOf(AppProjectTemplate(useKts = useKts, splitsApks = true, universalApk = true)) 289 | ).runAndCheckResult( 290 | "assembleRelease" 291 | ) { 292 | val versionCodeFileContent = File( 293 | fixtureDir.root, 294 | "app/build/outputs/app_versioning/release/version_code.txt" 295 | ).readText() 296 | val versionNameFileContent = File( 297 | fixtureDir.root, 298 | "app/build/outputs/app_versioning/release/version_name.txt" 299 | ).readText() 300 | 301 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 302 | assertThat(task(":app:assembleRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 303 | 304 | assertThat(output).contains("Generated app version code: 10203.") 305 | assertThat(output).contains("Generated app version name: \"1.2.3\".") 306 | 307 | assertThat(versionCodeFileContent).isEqualTo("10203") 308 | assertThat(versionNameFileContent).isEqualTo("1.2.3") 309 | } 310 | } 311 | 312 | @Test 313 | fun `plugin (when disabled) does not generate versionCode and versionName from git tag for the assembled APK when assemble task is run`() { 314 | GitClient.initialize(fixtureDir.root).apply { 315 | val commitId = commit(message = "1st commit.") 316 | tag(name = "1.0.0", message = "1st tag", commitId = commitId) 317 | } 318 | 319 | val extension = """ 320 | appVersioning { 321 | enabled.set(false) 322 | } 323 | """.trimIndent() 324 | withFixtureRunner( 325 | fixtureDir = fixtureDir, 326 | subprojects = listOf(AppProjectTemplate(pluginExtension = extension)) 327 | ).runAndCheckResult( 328 | "assembleRelease" 329 | ) { 330 | val versionCodeFile = File( 331 | fixtureDir.root, 332 | "app/build/outputs/app_versioning/release/version_code.txt" 333 | ) 334 | val versionNameFile = File( 335 | fixtureDir.root, 336 | "app/build/outputs/app_versioning/release/version_name.txt" 337 | ) 338 | 339 | assertThat(task(":app:assembleRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 340 | 341 | assertThat(output).doesNotContain("Generated app version code") 342 | assertThat(output).doesNotContain("Generated app version name") 343 | 344 | assertThat(versionCodeFile.exists()).isFalse() 345 | assertThat(versionNameFile.exists()).isFalse() 346 | } 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/functionalTest/kotlin/io/github/reactivecircus/appversioning/fixtures/FixtureRunner.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning.fixtures 2 | 3 | import org.gradle.testkit.runner.BuildResult 4 | import org.gradle.testkit.runner.GradleRunner 5 | import org.gradle.util.GradleVersion 6 | import org.junit.rules.TemporaryFolder 7 | import java.io.File 8 | 9 | fun withFixtureRunner( 10 | fixtureDir: TemporaryFolder, 11 | subprojects: List, 12 | parentDirectoryName: String? = null, 13 | dryRun: Boolean = false 14 | ) = FixtureRunner( 15 | fixtureDir = fixtureDir, 16 | subprojects = subprojects, 17 | parentDirectoryName = parentDirectoryName, 18 | dryRun = dryRun 19 | ) 20 | 21 | class FixtureRunner( 22 | fixtureDir: TemporaryFolder, 23 | subprojects: List, 24 | parentDirectoryName: String?, 25 | private val dryRun: Boolean 26 | ) { 27 | private val gradleRoot: File = if (parentDirectoryName.isNullOrBlank()) { 28 | fixtureDir.root 29 | } else { 30 | fixtureDir.root.resolve(parentDirectoryName) 31 | } 32 | 33 | init { 34 | fixtureDir.buildFixture(gradleRoot, subprojects) 35 | } 36 | 37 | fun runAndCheckResult(vararg commands: String, action: BuildResult.() -> Unit) { 38 | val buildResult = runner.withProjectDir(gradleRoot) 39 | .withArguments(buildArguments(commands.toList())) 40 | .withPluginClasspath() 41 | .withGradleVersion(GradleVersion.current().version) 42 | .build() 43 | action(buildResult) 44 | } 45 | 46 | fun runAndExpectFailure(vararg commands: String, action: BuildResult.() -> Unit) { 47 | val buildResult = runner.withProjectDir(gradleRoot) 48 | .withArguments(buildArguments(commands.toList())) 49 | .withPluginClasspath() 50 | .withGradleVersion(GradleVersion.current().version) 51 | .buildAndFail() 52 | action(buildResult) 53 | } 54 | 55 | private fun buildArguments(commands: List): List { 56 | val args = mutableListOf("--stacktrace", "--info") 57 | if (dryRun) { 58 | args.add("--dry-run") 59 | } 60 | args.addAll(commands) 61 | return args 62 | } 63 | } 64 | 65 | private val runner = GradleRunner.create() 66 | .withPluginClasspath() 67 | .withDebug(true) 68 | 69 | private fun TemporaryFolder.buildFixture(gradleRoot: File, subprojects: List) { 70 | // settings.gradle 71 | gradleRoot.resolve("settings.gradle.kts").also { it.parentFile.mkdirs() } 72 | .writeText( 73 | settingsFileContent( 74 | localBuildCacheUri = newFolder("local-cache").toURI().toString(), 75 | subprojects = subprojects 76 | ) 77 | ) 78 | 79 | // gradle.properties 80 | gradleRoot.resolve("gradle.properties").also { it.parentFile.mkdir() } 81 | .writeText(gradlePropertiesFileContent(enableConfigurationCache = false)) 82 | 83 | // subprojects 84 | subprojects.forEach { subproject -> 85 | // build.gradle or build.gradle.kts 86 | val buildFileName = if (subproject.useKts) "build.gradle.kts" else "build.gradle" 87 | gradleRoot.resolve("${subproject.projectName}/$buildFileName").also { it.parentFile.mkdirs() } 88 | .writeText(subproject.buildFileContent) 89 | 90 | // AndroidManifest.xml 91 | gradleRoot.resolve("${subproject.projectName}/src/main/AndroidManifest.xml").also { it.parentFile.mkdirs() } 92 | .writeText(subproject.manifestFileContent) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/functionalTest/kotlin/io/github/reactivecircus/appversioning/fixtures/ProjectTemplates.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning.fixtures 2 | 3 | fun settingsFileContent( 4 | localBuildCacheUri: String, 5 | subprojects: List 6 | ) = """ 7 | dependencyResolutionManagement { 8 | repositories { 9 | mavenCentral() 10 | google() 11 | } 12 | } 13 | 14 | pluginManagement { 15 | repositories { 16 | mavenCentral() 17 | google() 18 | } 19 | } 20 | 21 | buildCache { 22 | local { 23 | directory = "$localBuildCacheUri" 24 | } 25 | } 26 | 27 | rootProject.name = "app-versioning-fixture" 28 | 29 | ${subprojects.map { it.projectName }.joinToString("\n") { "include(\":$it\")" }} 30 | """.trimIndent() 31 | 32 | fun gradlePropertiesFileContent(enableConfigurationCache: Boolean): String { 33 | val configurationCacheProperties = if (enableConfigurationCache) { 34 | """ 35 | org.gradle.configuration-cache=true 36 | org.gradle.unsafe.isolated-projects=true 37 | """.trimIndent() 38 | } else { 39 | "" 40 | } 41 | return """ 42 | $configurationCacheProperties 43 | """.trimIndent() 44 | } 45 | 46 | sealed class AndroidProjectTemplate { 47 | abstract val projectName: String 48 | abstract val pluginExtension: String? 49 | abstract val useKts: Boolean 50 | abstract val flavors: List 51 | 52 | val buildFileContent: String get() = if (useKts) ktsBuildFileContent else groovyBuildFileContent 53 | 54 | private val isAppProject = this is AppProjectTemplate 55 | 56 | private val ktsBuildFileContent: String 57 | get() { 58 | val flavorConfigs = if (flavors.isNotEmpty()) { 59 | """ 60 | flavorDimensions("environment") 61 | productFlavors { 62 | ${flavors.joinToString("\n") { "register(\"$it\") {}" }} 63 | } 64 | """.trimIndent() 65 | } else { 66 | "" 67 | } 68 | val abiConfigs = if (this is AppProjectTemplate) { 69 | if (splitsApks) { 70 | """ 71 | splits { 72 | abi { 73 | isEnable = true 74 | reset() 75 | include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") 76 | isUniversalApk = $universalApk 77 | } 78 | } 79 | """.trimIndent() 80 | } else { 81 | "" 82 | } 83 | } else { 84 | "" 85 | } 86 | return """ 87 | plugins { 88 | id("com.android.${if (isAppProject) "application" else "library"}") 89 | id("io.github.reactivecircus.app-versioning") 90 | } 91 | 92 | ${pluginExtension ?: ""} 93 | 94 | android { 95 | namespace = "$DEFAULT_PACKAGE_NAME.${projectName.replace("-", ".")}" 96 | compileSdkVersion(34) 97 | buildToolsVersion = "34.0.0" 98 | defaultConfig { 99 | minSdkVersion(21) 100 | targetSdkVersion(34) 101 | } 102 | 103 | lintOptions.isCheckReleaseBuilds = false 104 | 105 | $flavorConfigs 106 | 107 | $abiConfigs 108 | } 109 | """.trimIndent() 110 | } 111 | 112 | private val groovyBuildFileContent: String 113 | get() { 114 | val flavorConfigs = if (flavors.isNotEmpty()) { 115 | """ 116 | flavorDimensions "environment" 117 | productFlavors { 118 | ${flavors.joinToString("\n") { "$it {}" }} 119 | } 120 | """.trimIndent() 121 | } else { 122 | "" 123 | } 124 | val abiConfigs = if (this is AppProjectTemplate) { 125 | if (splitsApks) { 126 | """ 127 | splits { 128 | abi { 129 | enable true 130 | reset() 131 | include "armeabi-v7a", "arm64-v8a", "x86", "x86_64" 132 | universalApk $universalApk 133 | } 134 | } 135 | """.trimIndent() 136 | } else { 137 | "" 138 | } 139 | } else { 140 | "" 141 | } 142 | return """ 143 | plugins { 144 | id 'com.android.${if (isAppProject) "application" else "library"}' 145 | id 'io.github.reactivecircus.app-versioning' 146 | } 147 | 148 | ${pluginExtension ?: ""} 149 | 150 | android { 151 | namespace '$DEFAULT_PACKAGE_NAME.${projectName.replace("-", ".")}' 152 | compileSdkVersion 34 153 | buildToolsVersion "34.0.0" 154 | defaultConfig { 155 | minSdkVersion 21 156 | targetSdkVersion 34 157 | } 158 | 159 | lintOptions { 160 | checkReleaseBuilds false 161 | } 162 | 163 | $flavorConfigs 164 | 165 | $abiConfigs 166 | } 167 | """.trimIndent() 168 | } 169 | 170 | val manifestFileContent: String 171 | get() = """ 172 | 173 | 174 | """.trimIndent() 175 | 176 | companion object { 177 | private const val DEFAULT_PACKAGE_NAME = "io.github.reactivecircus.appversioning" 178 | } 179 | } 180 | 181 | class AppProjectTemplate( 182 | override val projectName: String = "app", 183 | override val pluginExtension: String? = null, 184 | override val useKts: Boolean = true, 185 | override val flavors: List = emptyList(), 186 | val splitsApks: Boolean = false, 187 | val universalApk: Boolean = false, 188 | ) : AndroidProjectTemplate() 189 | 190 | class LibraryProjectTemplate( 191 | override val projectName: String = "library", 192 | override val pluginExtension: String? = null, 193 | override val useKts: Boolean = true, 194 | override val flavors: List = emptyList() 195 | ) : AndroidProjectTemplate() 196 | -------------------------------------------------------------------------------- /src/functionalTest/kotlin/io/github/reactivecircus/appversioning/tasks/GenerateAppVersionInfoTest.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("FunctionName", "DuplicatedCode") 2 | 3 | package io.github.reactivecircus.appversioning.tasks 4 | 5 | import com.google.common.truth.Truth.assertThat 6 | import io.github.reactivecircus.appversioning.fixtures.AppProjectTemplate 7 | import io.github.reactivecircus.appversioning.fixtures.withFixtureRunner 8 | import io.github.reactivecircus.appversioning.internal.GitClient 9 | import org.gradle.testkit.runner.TaskOutcome 10 | import org.junit.Rule 11 | import org.junit.Test 12 | import org.junit.rules.TemporaryFolder 13 | import java.io.File 14 | 15 | class GenerateAppVersionInfoTest { 16 | 17 | @get:Rule 18 | val fixtureDir = TemporaryFolder() 19 | 20 | @Test 21 | fun `GenerateAppVersionInfo fails when root Gradle project is not a git root directory and gitRootDirectory is not provided`() { 22 | withFixtureRunner( 23 | fixtureDir = fixtureDir, 24 | subprojects = listOf(AppProjectTemplate()) 25 | ).runAndExpectFailure( 26 | "generateAppVersionInfoForRelease" 27 | ) { 28 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.FAILED) 29 | assertThat(output).contains( 30 | "Android App Versioning Gradle Plugin works with git tags but root project 'app-versioning-fixture' is not a git root directory, and a valid gitRootDirectory is not provided." 31 | ) 32 | } 33 | } 34 | 35 | @Test 36 | fun `GenerateAppVersionInfo fails when root Gradle project is not a git root directory and gitRootDirectory provided is not a git root directory`() { 37 | val extensions = """ 38 | appVersioning { 39 | gitRootDirectory.set(rootProject.file("../")) 40 | } 41 | """.trimIndent() 42 | 43 | withFixtureRunner( 44 | fixtureDir = fixtureDir, 45 | subprojects = listOf(AppProjectTemplate(pluginExtension = extensions)), 46 | parentDirectoryName = "android" 47 | ).runAndExpectFailure( 48 | "generateAppVersionInfoForRelease" 49 | ) { 50 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.FAILED) 51 | assertThat(output).contains( 52 | "Android App Versioning Gradle Plugin works with git tags but root project 'app-versioning-fixture' is not a git root directory, and a valid gitRootDirectory is not provided." 53 | ) 54 | } 55 | } 56 | 57 | @Test 58 | fun `GenerateAppVersionInfo generates versionCode and versionName when root Gradle project is not a git root directory but gitRootDirectory provided is a git root directory`() { 59 | GitClient.initialize(fixtureDir.root).apply { 60 | val commitId = commit(message = "1st commit.") 61 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 62 | } 63 | 64 | val versionCodeFile = File(fixtureDir.root, "android/app/build/outputs/app_versioning/release/version_code.txt") 65 | val versionNameFile = File(fixtureDir.root, "android/app/build/outputs/app_versioning/release/version_name.txt") 66 | 67 | val extensions = """ 68 | appVersioning { 69 | gitRootDirectory.set(rootProject.file("../")) 70 | } 71 | """.trimIndent() 72 | 73 | withFixtureRunner( 74 | fixtureDir = fixtureDir, 75 | subprojects = listOf(AppProjectTemplate(pluginExtension = extensions)), 76 | parentDirectoryName = "android" 77 | ).runAndCheckResult( 78 | "generateAppVersionInfoForRelease" 79 | ) { 80 | assertThat(versionCodeFile.readText()).isEqualTo("10203") 81 | assertThat(versionNameFile.readText()).isEqualTo("1.2.3") 82 | } 83 | } 84 | 85 | @Test 86 | fun `GenerateAppVersionInfo generates versionCode by converting latest SemVer-compliant git tag to a single integer using positional notation when no custom versionCode generation rule is provided`() { 87 | val gitClient = GitClient.initialize(fixtureDir.root).apply { 88 | val commitId = commit(message = "1st commit.") 89 | tag(name = "0.0.1", message = "1st tag", commitId = commitId) 90 | } 91 | 92 | val versionCodeFile = File(fixtureDir.root, "app/build/outputs/app_versioning/release/version_code.txt") 93 | 94 | val runner = withFixtureRunner( 95 | fixtureDir = fixtureDir, 96 | subprojects = listOf(AppProjectTemplate()) 97 | ) 98 | 99 | runner.runAndCheckResult( 100 | "generateAppVersionInfoForRelease" 101 | ) { 102 | assertThat(versionCodeFile.readText()).isEqualTo("1") 103 | } 104 | 105 | val commitId2 = gitClient.commit(message = "2nd commit.") 106 | gitClient.tag(name = "0.1.0", message = "2nd tag", commitId = commitId2) 107 | 108 | runner.runAndCheckResult( 109 | "generateAppVersionInfoForRelease" 110 | ) { 111 | assertThat(versionCodeFile.readText()).isEqualTo("100") 112 | } 113 | 114 | val commitId3 = gitClient.commit(message = "3rd commit.") 115 | gitClient.tag(name = "1.0.0", message = "3rd tag", commitId = commitId3) 116 | 117 | runner.runAndCheckResult( 118 | "generateAppVersionInfoForRelease" 119 | ) { 120 | assertThat(versionCodeFile.readText()).isEqualTo("10000") 121 | } 122 | 123 | val commitId4 = gitClient.commit(message = "4th commit.") 124 | gitClient.tag(name = "1.0.99", message = "4th tag", commitId = commitId4) 125 | 126 | runner.runAndCheckResult( 127 | "generateAppVersionInfoForRelease" 128 | ) { 129 | assertThat(versionCodeFile.readText()).isEqualTo("10099") 130 | } 131 | 132 | val commitId5 = gitClient.commit(message = "5th commit.") 133 | gitClient.tag(name = "1.99.99", message = "5th tag", commitId = commitId5) 134 | 135 | runner.runAndCheckResult( 136 | "generateAppVersionInfoForRelease" 137 | ) { 138 | assertThat(versionCodeFile.readText()).isEqualTo("19999") 139 | } 140 | 141 | val commitId6 = gitClient.commit(message = "6th commit.") 142 | gitClient.tag(name = "1.2.3-alpha01+build.567", message = "6th tag", commitId = commitId6) 143 | 144 | runner.runAndCheckResult( 145 | "generateAppVersionInfoForRelease" 146 | ) { 147 | assertThat(versionCodeFile.readText()).isEqualTo("10203") 148 | } 149 | 150 | val commitId7 = gitClient.commit(message = "7th commit.") 151 | gitClient.tag(name = "v3.0.0-SNAPSHOT", message = "7th tag", commitId = commitId7) 152 | 153 | runner.runAndCheckResult( 154 | "generateAppVersionInfoForRelease" 155 | ) { 156 | assertThat(versionCodeFile.readText()).isEqualTo("30000") 157 | } 158 | } 159 | 160 | @Test 161 | fun `GenerateAppVersionInfo fails when latest git tag is not SemVer-compliant and no custom versionCode generation rule is provided`() { 162 | GitClient.initialize(fixtureDir.root).apply { 163 | val commitId = commit(message = "1st commit.") 164 | tag(name = "1.2", message = "1st tag", commitId = commitId) 165 | } 166 | 167 | withFixtureRunner( 168 | fixtureDir = fixtureDir, 169 | subprojects = listOf(AppProjectTemplate()) 170 | ).runAndExpectFailure( 171 | "generateAppVersionInfoForRelease" 172 | ) { 173 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.FAILED) 174 | assertThat(output).contains( 175 | """ 176 | Could not generate versionCode as "1.2" does not follow semantic versioning. 177 | Please either ensure latest git tag follows semantic versioning, or provide a custom rule for generating versionCode using the `overrideVersionCode` lambda. 178 | """.trimIndent() 179 | ) 180 | } 181 | } 182 | 183 | @Test 184 | fun `GenerateAppVersionInfo fails when SemVer-compliant git tag has a component that exceeds the maximum digits allocated (2) and no custom versionCode generation rule is provided`() { 185 | GitClient.initialize(fixtureDir.root).apply { 186 | val commitId = commit(message = "1st commit.") 187 | tag(name = "1.2.100", message = "1st tag", commitId = commitId) 188 | } 189 | 190 | withFixtureRunner( 191 | fixtureDir = fixtureDir, 192 | subprojects = listOf(AppProjectTemplate()) 193 | ).runAndExpectFailure( 194 | "generateAppVersionInfoForRelease" 195 | ) { 196 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.FAILED) 197 | assertThat(output).contains( 198 | """ 199 | Could not generate versionCode from "1.2.100" as the SemVer cannot be represented as an Integer. 200 | This is usually because MAJOR or MINOR version is greater than 99, as by default maximum of 2 digits is allowed for MINOR and PATCH components of a SemVer tag. 201 | Another reason might be that the overall positional notation of the SemVer (MAJOR * 10000 + MINOR * 100 + PATCH) is greater than the maximum value of an integer (2147483647). 202 | As a workaround you can provide a custom rule for generating versionCode using the `overrideVersionCode` lambda. 203 | """.trimIndent() 204 | ) 205 | } 206 | } 207 | 208 | @Test 209 | fun `GenerateAppVersionInfo generates fallback versionCode and versionName when no git tags exist`() { 210 | GitClient.initialize(fixtureDir.root).apply { 211 | commit(message = "1st commit.") 212 | } 213 | 214 | val versionCodeFile = File(fixtureDir.root, "app/build/outputs/app_versioning/release/version_code.txt") 215 | val versionNameFile = File(fixtureDir.root, "app/build/outputs/app_versioning/release/version_name.txt") 216 | 217 | withFixtureRunner( 218 | fixtureDir = fixtureDir, 219 | subprojects = listOf(AppProjectTemplate()) 220 | ).runAndCheckResult( 221 | "generateAppVersionInfoForRelease" 222 | ) { 223 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 224 | assertThat(output).contains( 225 | """ 226 | No git tags found. Falling back to version code ${GenerateAppVersionInfo.VERSION_CODE_FALLBACK} and version name "${GenerateAppVersionInfo.VERSION_NAME_FALLBACK}". 227 | If you want to fallback to the versionCode and versionName set via the DSL or manifest, or stop generating versionCode and versionName from Git tags: 228 | appVersioning { 229 | enabled.set(false) 230 | } 231 | """.trimIndent() 232 | ) 233 | assertThat(versionCodeFile.readText()).isEqualTo(GenerateAppVersionInfo.VERSION_CODE_FALLBACK.toString()) 234 | assertThat(versionNameFile.readText()).isEqualTo(GenerateAppVersionInfo.VERSION_NAME_FALLBACK) 235 | } 236 | } 237 | 238 | @Test 239 | fun `GenerateAppVersionInfo generates custom versionCode when custom versionCode generation rule is provided`() { 240 | GitClient.initialize(fixtureDir.root).apply { 241 | val commitId = commit(message = "1st commit.") 242 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 243 | } 244 | 245 | val versionCodeFile = File(fixtureDir.root, "app/build/outputs/app_versioning/release/version_code.txt") 246 | 247 | val extensions = """ 248 | import io.github.reactivecircus.appversioning.toSemVer 249 | appVersioning { 250 | overrideVersionCode { gitTag, providers, _ -> 251 | val buildNumber = providers 252 | .gradleProperty("buildNumber") 253 | .orNull?.toInt()?: 0 254 | val semVer = gitTag.toSemVer() 255 | semVer.major * 1000000 + semVer.minor * 10000 + semVer.patch * 100 + buildNumber 256 | } 257 | } 258 | """.trimIndent() 259 | 260 | withFixtureRunner( 261 | fixtureDir = fixtureDir, 262 | subprojects = listOf(AppProjectTemplate(pluginExtension = extensions)) 263 | ).runAndCheckResult( 264 | "generateAppVersionInfoForRelease", 265 | "-PbuildNumber=78" 266 | ) { 267 | assertThat(versionCodeFile.readText()).isEqualTo("1020378") 268 | } 269 | } 270 | 271 | @Test 272 | fun `GenerateAppVersionInfo fails when converting a non-SemVer compliant git tag to SemVer in a custom versionCode generation rule`() { 273 | GitClient.initialize(fixtureDir.root).apply { 274 | val commitId = commit(message = "1st commit.") 275 | tag(name = "1.2", message = "1st tag", commitId = commitId) 276 | } 277 | 278 | val extensions = """ 279 | import io.github.reactivecircus.appversioning.toSemVer 280 | appVersioning { 281 | overrideVersionCode { gitTag, providers, _ -> 282 | val semVer = gitTag.toSemVer() 283 | semVer.major * 1000000 + semVer.minor * 10000 + semVer.patch * 100 284 | } 285 | } 286 | """.trimIndent() 287 | 288 | withFixtureRunner( 289 | fixtureDir = fixtureDir, 290 | subprojects = listOf(AppProjectTemplate(pluginExtension = extensions)) 291 | ).runAndExpectFailure( 292 | "generateAppVersionInfoForRelease" 293 | ) { 294 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.FAILED) 295 | assertThat(output).contains("\"1.2\" is not a valid SemVer.") 296 | } 297 | } 298 | 299 | @Test 300 | fun `GenerateAppVersionInfo generates versionName directly from the latest git tag when no custom versionName generation rule is provided`() { 301 | val gitClient = GitClient.initialize(fixtureDir.root).apply { 302 | val commitId = commit(message = "1st commit.") 303 | tag(name = "0.0.1", message = "1st tag", commitId = commitId) 304 | } 305 | 306 | val versionNameFile = File(fixtureDir.root, "app/build/outputs/app_versioning/release/version_name.txt") 307 | 308 | val extensions = """ 309 | appVersioning { 310 | overrideVersionCode { _, _, _ -> 0 } 311 | } 312 | """.trimIndent() 313 | 314 | val runner = withFixtureRunner( 315 | fixtureDir = fixtureDir, 316 | subprojects = listOf(AppProjectTemplate(pluginExtension = extensions)) 317 | ) 318 | 319 | runner.runAndCheckResult( 320 | "generateAppVersionInfoForRelease" 321 | ) { 322 | assertThat(versionNameFile.readText()).isEqualTo("0.0.1") 323 | } 324 | 325 | val commitId2 = gitClient.commit(message = "2nd commit.") 326 | gitClient.tag(name = "v1.1.0-alpha01", message = "2nd tag", commitId = commitId2) 327 | 328 | runner.runAndCheckResult( 329 | "generateAppVersionInfoForRelease" 330 | ) { 331 | assertThat(versionNameFile.readText()).isEqualTo("v1.1.0-alpha01") 332 | } 333 | 334 | val commitId3 = gitClient.commit(message = "3rd commit.") 335 | gitClient.tag(name = "alpha", message = "3rd tag", commitId = commitId3) 336 | 337 | runner.runAndCheckResult( 338 | "generateAppVersionInfoForRelease" 339 | ) { 340 | assertThat(versionNameFile.readText()).isEqualTo("alpha") 341 | } 342 | } 343 | 344 | @Test 345 | fun `GenerateAppVersionInfo generates custom versionName when custom versionName generation rule is provided`() { 346 | GitClient.initialize(fixtureDir.root).apply { 347 | val commitId = commit(message = "1st commit.") 348 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 349 | } 350 | 351 | val versionNameFile = File(fixtureDir.root, "app/build/outputs/app_versioning/release/version_name.txt") 352 | 353 | val extensions = """ 354 | appVersioning { 355 | overrideVersionName { gitTag, _, _ -> 356 | "Version " + gitTag.toString() 357 | } 358 | } 359 | """.trimIndent() 360 | 361 | withFixtureRunner( 362 | fixtureDir = fixtureDir, 363 | subprojects = listOf(AppProjectTemplate(pluginExtension = extensions)) 364 | ).runAndCheckResult( 365 | "generateAppVersionInfoForRelease" 366 | ) { 367 | assertThat(versionNameFile.readText()).isEqualTo("Version 1.2.3") 368 | } 369 | } 370 | 371 | @Test 372 | fun `GenerateAppVersionInfo can generate custom versionCode based on build variant`() { 373 | GitClient.initialize(fixtureDir.root).apply { 374 | val commitId = commit(message = "1st commit.") 375 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 376 | } 377 | 378 | val versionCodeFileForFreeDebug = File( 379 | fixtureDir.root, 380 | "app/build/outputs/app_versioning/freeDebug/version_code.txt" 381 | ) 382 | val versionCodeFileForFreeRelease = File( 383 | fixtureDir.root, 384 | "app/build/outputs/app_versioning/freeRelease/version_code.txt" 385 | ) 386 | val versionCodeFileForPaidDebug = File( 387 | fixtureDir.root, 388 | "app/build/outputs/app_versioning/paidDebug/version_code.txt" 389 | ) 390 | val versionCodeFileForPaidRelease = File( 391 | fixtureDir.root, 392 | "app/build/outputs/app_versioning/paidRelease/version_code.txt" 393 | ) 394 | 395 | val extensions = """ 396 | import io.github.reactivecircus.appversioning.toSemVer 397 | appVersioning { 398 | overrideVersionCode { gitTag, _, variantInfo -> 399 | val offset = if (variantInfo.flavorName == "paid") 1 else 0 400 | val semVer = gitTag.toSemVer() 401 | semVer.major * 1000000 + semVer.minor * 10000 + semVer.patch * 100 + offset 402 | } 403 | } 404 | """.trimIndent() 405 | 406 | val flavors = listOf("free", "paid") 407 | val runner = withFixtureRunner( 408 | fixtureDir = fixtureDir, 409 | subprojects = listOf(AppProjectTemplate(pluginExtension = extensions, flavors = flavors)) 410 | ) 411 | 412 | runner.runAndCheckResult( 413 | "generateAppVersionInfoForFreeDebug" 414 | ) { 415 | assertThat(versionCodeFileForFreeDebug.readText()).isEqualTo("1020300") 416 | } 417 | 418 | runner.runAndCheckResult( 419 | "generateAppVersionInfoForFreeRelease" 420 | ) { 421 | assertThat(versionCodeFileForFreeRelease.readText()).isEqualTo("1020300") 422 | } 423 | 424 | runner.runAndCheckResult( 425 | "generateAppVersionInfoForPaidDebug" 426 | ) { 427 | assertThat(versionCodeFileForPaidDebug.readText()).isEqualTo("1020301") 428 | } 429 | 430 | runner.runAndCheckResult( 431 | "generateAppVersionInfoForPaidRelease" 432 | ) { 433 | assertThat(versionCodeFileForPaidRelease.readText()).isEqualTo("1020301") 434 | } 435 | } 436 | 437 | @Test 438 | fun `GenerateAppVersionInfo can generate custom versionName based on build variant`() { 439 | GitClient.initialize(fixtureDir.root).apply { 440 | val commitId = commit(message = "1st commit.") 441 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 442 | } 443 | 444 | val versionNameFileForFreeDebug = File( 445 | fixtureDir.root, 446 | "app/build/outputs/app_versioning/freeDebug/version_name.txt" 447 | ) 448 | val versionNameFileForFreeRelease = File( 449 | fixtureDir.root, 450 | "app/build/outputs/app_versioning/freeRelease/version_name.txt" 451 | ) 452 | val versionNameFileForPaidDebug = File( 453 | fixtureDir.root, 454 | "app/build/outputs/app_versioning/paidDebug/version_name.txt" 455 | ) 456 | val versionNameFileForPaidRelease = File( 457 | fixtureDir.root, 458 | "app/build/outputs/app_versioning/paidRelease/version_name.txt" 459 | ) 460 | 461 | val extensions = """ 462 | appVersioning { 463 | overrideVersionName { gitTag, _, variantInfo -> 464 | val suffix = if (!variantInfo.isReleaseBuild) { 465 | " (" + variantInfo.variantName + ")" 466 | } else { 467 | "" 468 | } 469 | "Version " + gitTag.toString() + suffix 470 | } 471 | } 472 | """.trimIndent() 473 | 474 | val flavors = listOf("free", "paid") 475 | val runner = withFixtureRunner( 476 | fixtureDir = fixtureDir, 477 | subprojects = listOf(AppProjectTemplate(pluginExtension = extensions, flavors = flavors)) 478 | ) 479 | 480 | runner.runAndCheckResult( 481 | "generateAppVersionInfoForFreeDebug" 482 | ) { 483 | assertThat(versionNameFileForFreeDebug.readText()).isEqualTo("Version 1.2.3 (freeDebug)") 484 | } 485 | 486 | runner.runAndCheckResult( 487 | "generateAppVersionInfoForFreeRelease" 488 | ) { 489 | assertThat(versionNameFileForFreeRelease.readText()).isEqualTo("Version 1.2.3") 490 | } 491 | 492 | runner.runAndCheckResult( 493 | "generateAppVersionInfoForPaidDebug" 494 | ) { 495 | assertThat(versionNameFileForPaidDebug.readText()).isEqualTo("Version 1.2.3 (paidDebug)") 496 | } 497 | 498 | runner.runAndCheckResult( 499 | "generateAppVersionInfoForPaidRelease" 500 | ) { 501 | assertThat(versionNameFileForPaidRelease.readText()).isEqualTo("Version 1.2.3") 502 | } 503 | } 504 | 505 | @Test 506 | fun `GenerateAppVersionInfo generates versionCode and versionName from the latest git tag matching the provided tagFilter pattern when a tagFilter pattern is provided`() { 507 | val gitClient = GitClient.initialize(fixtureDir.root).apply { 508 | commit(message = "Initial commit.") 509 | } 510 | 511 | val versionCodeFileAppA = File(fixtureDir.root, "app-a/build/outputs/app_versioning/release/version_code.txt") 512 | val versionCodeFileAppB = File(fixtureDir.root, "app-b/build/outputs/app_versioning/release/version_code.txt") 513 | val versionCodeFileAppC = File(fixtureDir.root, "app-c/build/outputs/app_versioning/release/version_code.txt") 514 | val versionNameFileAppA = File(fixtureDir.root, "app-a/build/outputs/app_versioning/release/version_name.txt") 515 | val versionNameFileAppB = File(fixtureDir.root, "app-b/build/outputs/app_versioning/release/version_name.txt") 516 | val versionNameFileAppC = File(fixtureDir.root, "app-c/build/outputs/app_versioning/release/version_name.txt") 517 | 518 | val tagFilterAppA = "[0-9]*.[0-9]*.[0-9]*+appA" 519 | val tagFilterAppB = "[0-9]*.[0-9]*.[0-9]*+appB" 520 | val tagFilterAppC = "[0-9]*.[0-9]*.[0-9]*+appC" 521 | 522 | val extensionsAppA = """ 523 | appVersioning { 524 | tagFilter.set("$tagFilterAppA") 525 | } 526 | """.trimIndent() 527 | 528 | val extensionsAppB = """ 529 | appVersioning { 530 | tagFilter.set("$tagFilterAppB") 531 | } 532 | """.trimIndent() 533 | 534 | val extensionsAppC = """ 535 | appVersioning { 536 | tagFilter.set("$tagFilterAppC") 537 | } 538 | """.trimIndent() 539 | 540 | val runner = withFixtureRunner( 541 | fixtureDir = fixtureDir, 542 | subprojects = listOf( 543 | AppProjectTemplate( 544 | projectName = "app-a", 545 | pluginExtension = extensionsAppA 546 | ), 547 | AppProjectTemplate( 548 | projectName = "app-b", 549 | pluginExtension = extensionsAppB 550 | ), 551 | AppProjectTemplate( 552 | projectName = "app-c", 553 | pluginExtension = extensionsAppC 554 | ) 555 | ) 556 | ) 557 | 558 | // 1st appA release 559 | val commitId = gitClient.commit(message = "appA release.") 560 | gitClient.tag(name = "0.1.0+appA", message = "1st tag", commitId = commitId) 561 | 562 | runner.runAndCheckResult( 563 | "generateAppVersionInfoForRelease" 564 | ) { 565 | assertThat(task(":app-a:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 566 | assertThat(versionCodeFileAppA.readText()).isEqualTo("100") 567 | assertThat(versionNameFileAppA.readText()).isEqualTo("0.1.0+appA") 568 | 569 | assertThat(task(":app-b:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 570 | assertThat(versionCodeFileAppB.readText()).isEqualTo( 571 | GenerateAppVersionInfo.VERSION_CODE_FALLBACK.toString() 572 | ) 573 | assertThat(versionNameFileAppB.readText()).isEqualTo(GenerateAppVersionInfo.VERSION_NAME_FALLBACK) 574 | 575 | assertThat(task(":app-c:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 576 | assertThat(versionCodeFileAppC.readText()).isEqualTo( 577 | GenerateAppVersionInfo.VERSION_CODE_FALLBACK.toString() 578 | ) 579 | assertThat(versionNameFileAppC.readText()).isEqualTo(GenerateAppVersionInfo.VERSION_NAME_FALLBACK) 580 | } 581 | 582 | gitClient.commit(message = "Commit a.") 583 | gitClient.commit(message = "Commit b.") 584 | 585 | // 1st appB release 586 | val commitId2 = gitClient.commit(message = "appB release.") 587 | gitClient.tag(name = "1.2.3-rc01+appB", message = "2nd tag", commitId = commitId2) 588 | 589 | runner.runAndCheckResult( 590 | "generateAppVersionInfoForRelease" 591 | ) { 592 | assertThat(task(":app-a:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 593 | assertThat(versionCodeFileAppA.readText()).isEqualTo("100") 594 | assertThat(versionNameFileAppA.readText()).isEqualTo("0.1.0+appA") 595 | 596 | assertThat(task(":app-b:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 597 | assertThat(versionCodeFileAppB.readText()).isEqualTo("10203") 598 | assertThat(versionNameFileAppB.readText()).isEqualTo("1.2.3-rc01+appB") 599 | 600 | assertThat(task(":app-c:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 601 | assertThat(versionCodeFileAppC.readText()).isEqualTo( 602 | GenerateAppVersionInfo.VERSION_CODE_FALLBACK.toString() 603 | ) 604 | assertThat(versionNameFileAppC.readText()).isEqualTo(GenerateAppVersionInfo.VERSION_NAME_FALLBACK) 605 | } 606 | 607 | gitClient.commit(message = "Commit c.") 608 | 609 | // 2nd appB release 610 | val commitId3 = gitClient.commit(message = "appB release.") 611 | gitClient.tag(name = "1.2.3-rc02+appB", message = "3rd tag", commitId = commitId3) 612 | 613 | runner.runAndCheckResult( 614 | "generateAppVersionInfoForRelease" 615 | ) { 616 | assertThat(task(":app-a:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 617 | assertThat(versionCodeFileAppA.readText()).isEqualTo("100") 618 | assertThat(versionNameFileAppA.readText()).isEqualTo("0.1.0+appA") 619 | 620 | assertThat(task(":app-b:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 621 | assertThat(versionCodeFileAppB.readText()).isEqualTo("10203") 622 | assertThat(versionNameFileAppB.readText()).isEqualTo("1.2.3-rc02+appB") 623 | 624 | assertThat(task(":app-c:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 625 | assertThat(versionCodeFileAppC.readText()).isEqualTo( 626 | GenerateAppVersionInfo.VERSION_CODE_FALLBACK.toString() 627 | ) 628 | assertThat(versionNameFileAppC.readText()).isEqualTo(GenerateAppVersionInfo.VERSION_NAME_FALLBACK) 629 | } 630 | 631 | // 1st appC release 632 | val commitId4 = gitClient.commit(message = "appC release.") 633 | gitClient.tag(name = "10.3.5-alpha03+appC", message = "4th tag", commitId = commitId4) 634 | 635 | runner.runAndCheckResult( 636 | "generateAppVersionInfoForRelease" 637 | ) { 638 | assertThat(task(":app-a:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 639 | assertThat(versionCodeFileAppA.readText()).isEqualTo("100") 640 | assertThat(versionNameFileAppA.readText()).isEqualTo("0.1.0+appA") 641 | 642 | assertThat(task(":app-b:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 643 | assertThat(versionCodeFileAppB.readText()).isEqualTo("10203") 644 | assertThat(versionNameFileAppB.readText()).isEqualTo("1.2.3-rc02+appB") 645 | 646 | assertThat(task(":app-c:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 647 | assertThat(versionCodeFileAppC.readText()).isEqualTo("100305") 648 | assertThat(versionNameFileAppC.readText()).isEqualTo("10.3.5-alpha03+appC") 649 | } 650 | 651 | gitClient.commit(message = "Commit d.") 652 | 653 | // 2nd appA release 654 | val commitId5 = gitClient.commit(message = "appA release.") 655 | gitClient.tag(name = "0.2.1+appA", message = "4th tag", commitId = commitId5) 656 | 657 | runner.runAndCheckResult( 658 | "generateAppVersionInfoForRelease" 659 | ) { 660 | assertThat(task(":app-a:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 661 | assertThat(versionCodeFileAppA.readText()).isEqualTo("201") 662 | assertThat(versionNameFileAppA.readText()).isEqualTo("0.2.1+appA") 663 | 664 | assertThat(task(":app-b:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 665 | assertThat(versionCodeFileAppB.readText()).isEqualTo("10203") 666 | assertThat(versionNameFileAppB.readText()).isEqualTo("1.2.3-rc02+appB") 667 | 668 | assertThat(task(":app-c:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 669 | assertThat(versionCodeFileAppC.readText()).isEqualTo("100305") 670 | assertThat(versionNameFileAppC.readText()).isEqualTo("10.3.5-alpha03+appC") 671 | } 672 | } 673 | 674 | @Test 675 | fun `overrideVersionCode and overrideVersionName configurations work in groovy`() { 676 | GitClient.initialize(fixtureDir.root).apply { 677 | val commitId = commit(message = "1st commit.") 678 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 679 | commit(message = "2nd commit.") 680 | } 681 | 682 | val versionCodeFileForDebug = File(fixtureDir.root, "app/build/outputs/app_versioning/debug/version_code.txt") 683 | val versionNameFileForDebug = File(fixtureDir.root, "app/build/outputs/app_versioning/debug/version_name.txt") 684 | val versionCodeFileForRelease = File( 685 | fixtureDir.root, 686 | "app/build/outputs/app_versioning/release/version_code.txt" 687 | ) 688 | val versionNameFileForRelease = File( 689 | fixtureDir.root, 690 | "app/build/outputs/app_versioning/release/version_name.txt" 691 | ) 692 | 693 | val extensions = """ 694 | import io.github.reactivecircus.appversioning.SemVer 695 | appVersioning { 696 | overrideVersionCode { gitTag, providers, variantInfo -> 697 | def semVer = SemVer.fromGitTag(gitTag) 698 | semVer.major * 10000 + semVer.minor * 100 + semVer.patch + gitTag.commitsSinceLatestTag 699 | } 700 | overrideVersionName { gitTag, providers, variantInfo -> 701 | def suffix 702 | if (variantInfo.debugBuild == true) { 703 | suffix = " (" + variantInfo.variantName + ")" 704 | } else { 705 | suffix = "" 706 | } 707 | "Version " + gitTag.toString() + suffix 708 | } 709 | } 710 | """.trimIndent() 711 | 712 | val runner = withFixtureRunner( 713 | fixtureDir = fixtureDir, 714 | subprojects = listOf(AppProjectTemplate(pluginExtension = extensions, useKts = false)) 715 | ) 716 | 717 | runner.runAndCheckResult( 718 | "generateAppVersionInfoForDebug" 719 | ) { 720 | assertThat(task(":app:generateAppVersionInfoForDebug")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 721 | assertThat(versionCodeFileForDebug.readText()).isEqualTo("10204") 722 | assertThat(versionNameFileForDebug.readText()).isEqualTo("Version 1.2.3 (debug)") 723 | } 724 | 725 | runner.runAndCheckResult( 726 | "generateAppVersionInfoForRelease" 727 | ) { 728 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 729 | assertThat(versionCodeFileForRelease.readText()).isEqualTo("10204") 730 | assertThat(versionNameFileForRelease.readText()).isEqualTo("Version 1.2.3") 731 | } 732 | } 733 | 734 | @Test 735 | fun `GenerateAppVersionInfo is incremental without custom versionCode and versionName generation rules`() { 736 | GitClient.initialize(fixtureDir.root).apply { 737 | val commitId = commit(message = "1st commit.") 738 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 739 | } 740 | 741 | val runner = withFixtureRunner( 742 | fixtureDir = fixtureDir, 743 | subprojects = listOf(AppProjectTemplate()) 744 | ) 745 | 746 | runner.runAndCheckResult( 747 | "generateAppVersionInfoForRelease" 748 | ) { 749 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 750 | } 751 | 752 | runner.runAndCheckResult( 753 | "generateAppVersionInfoForRelease" 754 | ) { 755 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE) 756 | } 757 | } 758 | 759 | @Test 760 | fun `GenerateAppVersionInfo is incremental with custom versionCode and versionName generation rules`() { 761 | GitClient.initialize(fixtureDir.root).apply { 762 | val commitId = commit(message = "1st commit.") 763 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 764 | commit(message = "2nd commit.") 765 | } 766 | 767 | val extensions = """ 768 | import io.github.reactivecircus.appversioning.toSemVer 769 | appVersioning { 770 | overrideVersionCode { gitTag, _, _ -> 771 | val semVer = gitTag.toSemVer() 772 | semVer.major * 10000 + semVer.minor * 100 + semVer.patch + gitTag.commitsSinceLatestTag 773 | } 774 | overrideVersionName { gitTag, _, _ -> 775 | "Version " + gitTag.toString() 776 | } 777 | } 778 | """.trimIndent() 779 | 780 | val runner = withFixtureRunner( 781 | fixtureDir = fixtureDir, 782 | subprojects = listOf(AppProjectTemplate(pluginExtension = extensions)) 783 | ) 784 | 785 | runner.runAndCheckResult( 786 | "generateAppVersionInfoForRelease" 787 | ) { 788 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 789 | } 790 | 791 | runner.runAndCheckResult( 792 | "generateAppVersionInfoForRelease" 793 | ) { 794 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE) 795 | } 796 | } 797 | 798 | @Test 799 | fun `GenerateAppVersionInfo is cacheable without custom versionCode and versionName generation rules`() { 800 | GitClient.initialize(fixtureDir.root).apply { 801 | val commitId = commit(message = "1st commit.") 802 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 803 | } 804 | 805 | val runner = withFixtureRunner( 806 | fixtureDir = fixtureDir, 807 | subprojects = listOf(AppProjectTemplate()) 808 | ) 809 | 810 | runner.runAndCheckResult( 811 | "generateAppVersionInfoForRelease", 812 | "--build-cache" 813 | ) { 814 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 815 | } 816 | 817 | runner.runAndCheckResult( 818 | "clean", 819 | "generateAppVersionInfoForRelease", 820 | "--build-cache" 821 | ) { 822 | assertThat(task(":app:clean")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 823 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.FROM_CACHE) 824 | } 825 | } 826 | 827 | @Test 828 | fun `GenerateAppVersionInfo is cacheable with custom versionCode and versionName generation rules`() { 829 | GitClient.initialize(fixtureDir.root).apply { 830 | val commitId = commit(message = "1st commit.") 831 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 832 | commit(message = "2nd commit.") 833 | } 834 | 835 | val extensions = """ 836 | import io.github.reactivecircus.appversioning.toSemVer 837 | appVersioning { 838 | overrideVersionCode { gitTag, _, _ -> 839 | val semVer = gitTag.toSemVer() 840 | semVer.major * 10000 + semVer.minor * 100 + semVer.patch + gitTag.commitsSinceLatestTag 841 | } 842 | overrideVersionName { gitTag, _, _ -> 843 | "Version " + gitTag.toString() 844 | } 845 | } 846 | """.trimIndent() 847 | 848 | val runner = withFixtureRunner( 849 | fixtureDir = fixtureDir, 850 | subprojects = listOf(AppProjectTemplate(pluginExtension = extensions)) 851 | ) 852 | 853 | runner.runAndCheckResult( 854 | "generateAppVersionInfoForRelease", 855 | "--build-cache" 856 | ) { 857 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 858 | } 859 | 860 | runner.runAndCheckResult( 861 | "clean", 862 | "generateAppVersionInfoForRelease", 863 | "--build-cache" 864 | ) { 865 | assertThat(task(":app:clean")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 866 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.FROM_CACHE) 867 | } 868 | } 869 | 870 | @Test 871 | fun `GenerateAppVersionInfo is incremental for specific build variants`() { 872 | GitClient.initialize(fixtureDir.root).apply { 873 | val commitId = commit(message = "1st commit.") 874 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 875 | } 876 | 877 | val flavors = listOf("mock", "prod") 878 | val runner = withFixtureRunner( 879 | fixtureDir = fixtureDir, 880 | subprojects = listOf(AppProjectTemplate(flavors = flavors)) 881 | ) 882 | 883 | runner.runAndCheckResult( 884 | "generateAppVersionInfoForProdRelease" 885 | ) { 886 | assertThat(task(":app:generateAppVersionInfoForProdRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 887 | } 888 | 889 | runner.runAndCheckResult( 890 | "generateAppVersionInfoForMockRelease" 891 | ) { 892 | assertThat(task(":app:generateAppVersionInfoForMockRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 893 | } 894 | } 895 | 896 | @Test 897 | fun `GenerateAppVersionInfo is cacheable for specific build variants`() { 898 | GitClient.initialize(fixtureDir.root).apply { 899 | val commitId = commit(message = "1st commit.") 900 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 901 | } 902 | 903 | val flavors = listOf("mock", "prod") 904 | val runner = withFixtureRunner( 905 | fixtureDir = fixtureDir, 906 | subprojects = listOf(AppProjectTemplate(flavors = flavors)) 907 | ) 908 | 909 | runner.runAndCheckResult( 910 | "generateAppVersionInfoForProdRelease", 911 | "--build-cache" 912 | ) { 913 | assertThat(task(":app:generateAppVersionInfoForProdRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 914 | } 915 | 916 | runner.runAndCheckResult( 917 | "clean", 918 | "generateAppVersionInfoForMockRelease", 919 | "--build-cache" 920 | ) { 921 | assertThat(task(":app:clean")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 922 | assertThat(task(":app:generateAppVersionInfoForMockRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 923 | } 924 | } 925 | 926 | @Test 927 | fun `GenerateAppVersionInfo (up-to-date) is re-executed after changing git refs`() { 928 | val gitClient = GitClient.initialize(fixtureDir.root).apply { 929 | val commitId = commit(message = "1st commit.") 930 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 931 | } 932 | 933 | val runner = withFixtureRunner( 934 | fixtureDir = fixtureDir, 935 | subprojects = listOf(AppProjectTemplate()) 936 | ) 937 | 938 | runner.runAndCheckResult( 939 | "generateAppVersionInfoForRelease" 940 | ) { 941 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 942 | } 943 | 944 | runner.runAndCheckResult( 945 | "generateAppVersionInfoForRelease" 946 | ) { 947 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE) 948 | } 949 | 950 | gitClient.commit(message = "2nd commit.") 951 | 952 | runner.runAndCheckResult( 953 | "generateAppVersionInfoForRelease" 954 | ) { 955 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 956 | } 957 | } 958 | 959 | @Test 960 | fun `GenerateAppVersionInfo (from cache) is re-executed after changing git refs`() { 961 | val gitClient = GitClient.initialize(fixtureDir.root).apply { 962 | val commitId = commit(message = "1st commit.") 963 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 964 | } 965 | val commitId2 = gitClient.commit(message = "2nd commit.") 966 | 967 | val runner = withFixtureRunner( 968 | fixtureDir = fixtureDir, 969 | subprojects = listOf(AppProjectTemplate()) 970 | ) 971 | 972 | runner.runAndCheckResult( 973 | "generateAppVersionInfoForRelease", 974 | "--build-cache" 975 | ) { 976 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 977 | } 978 | 979 | runner.runAndCheckResult( 980 | "clean", 981 | "generateAppVersionInfoForRelease", 982 | "--build-cache" 983 | ) { 984 | assertThat(task(":app:clean")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 985 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.FROM_CACHE) 986 | } 987 | 988 | gitClient.tag(name = "1.3.0", message = "2nd tag", commitId = commitId2) 989 | 990 | runner.runAndCheckResult( 991 | "clean", 992 | "generateAppVersionInfoForRelease", 993 | "--build-cache" 994 | ) { 995 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 996 | } 997 | } 998 | 999 | @Test 1000 | fun `GenerateAppVersionInfo (up-to-date) is re-executed after changing git HEAD`() { 1001 | val gitClient = GitClient.initialize(fixtureDir.root).apply { 1002 | val commitId1 = commit(message = "1st commit.") 1003 | tag(name = "1.2.3", message = "1st tag", commitId = commitId1) 1004 | val commitId2 = commit(message = "2nd commit.") 1005 | tag(name = "1.2.4", message = "2st tag", commitId = commitId2) 1006 | } 1007 | 1008 | val runner = withFixtureRunner( 1009 | fixtureDir = fixtureDir, 1010 | subprojects = listOf(AppProjectTemplate()) 1011 | ) 1012 | 1013 | runner.runAndCheckResult( 1014 | "generateAppVersionInfoForRelease" 1015 | ) { 1016 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 1017 | } 1018 | 1019 | runner.runAndCheckResult( 1020 | "generateAppVersionInfoForRelease" 1021 | ) { 1022 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE) 1023 | } 1024 | 1025 | gitClient.checkoutTag(tag = "1.2.3") 1026 | 1027 | runner.runAndCheckResult( 1028 | "generateAppVersionInfoForRelease" 1029 | ) { 1030 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 1031 | } 1032 | } 1033 | 1034 | @Test 1035 | fun `GenerateAppVersionInfo (from cache) is re-executed after changing git HEAD`() { 1036 | val gitClient = GitClient.initialize(fixtureDir.root).apply { 1037 | val commitId1 = commit(message = "1st commit.") 1038 | tag(name = "1.2.3", message = "1st tag", commitId = commitId1) 1039 | val commitId2 = commit(message = "2nd commit.") 1040 | tag(name = "1.2.4", message = "2st tag", commitId = commitId2) 1041 | } 1042 | 1043 | val runner = withFixtureRunner( 1044 | fixtureDir = fixtureDir, 1045 | subprojects = listOf(AppProjectTemplate()) 1046 | ) 1047 | 1048 | runner.runAndCheckResult( 1049 | "generateAppVersionInfoForRelease", 1050 | "--build-cache" 1051 | ) { 1052 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 1053 | } 1054 | 1055 | runner.runAndCheckResult( 1056 | "clean", 1057 | "generateAppVersionInfoForRelease", 1058 | "--build-cache" 1059 | ) { 1060 | assertThat(task(":app:clean")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 1061 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.FROM_CACHE) 1062 | } 1063 | 1064 | gitClient.checkoutTag(tag = "1.2.3") 1065 | 1066 | runner.runAndCheckResult( 1067 | "clean", 1068 | "generateAppVersionInfoForRelease", 1069 | "--build-cache" 1070 | ) { 1071 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 1072 | } 1073 | } 1074 | } 1075 | -------------------------------------------------------------------------------- /src/functionalTest/kotlin/io/github/reactivecircus/appversioning/tasks/PrintAppVersionInfoTest.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("FunctionName", "DuplicatedCode") 2 | 3 | package io.github.reactivecircus.appversioning.tasks 4 | 5 | import com.google.common.truth.Truth.assertThat 6 | import io.github.reactivecircus.appversioning.fixtures.AppProjectTemplate 7 | import io.github.reactivecircus.appversioning.fixtures.withFixtureRunner 8 | import io.github.reactivecircus.appversioning.internal.GitClient 9 | import org.gradle.testkit.runner.TaskOutcome 10 | import org.junit.Rule 11 | import org.junit.Test 12 | import org.junit.rules.TemporaryFolder 13 | 14 | class PrintAppVersionInfoTest { 15 | 16 | @get:Rule 17 | val fixtureDir = TemporaryFolder() 18 | 19 | @Test 20 | fun `PrintAppVersionInfo prints latest generated versionCode and versionName to the console when they are available`() { 21 | GitClient.initialize(fixtureDir.root).apply { 22 | val commitId = commit(message = "1st commit.") 23 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 24 | } 25 | 26 | val flavors = listOf("mock", "prod") 27 | val runner = withFixtureRunner( 28 | fixtureDir = fixtureDir, 29 | subprojects = listOf(AppProjectTemplate(flavors = flavors)) 30 | ) 31 | 32 | runner.runAndCheckResult( 33 | "generateAppVersionInfoForProdRelease" 34 | ) { 35 | assertThat(task(":app:generateAppVersionInfoForProdRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 36 | } 37 | 38 | runner.runAndCheckResult( 39 | "printAppVersionInfoForProdRelease" 40 | ) { 41 | assertThat(task(":app:printAppVersionInfoForProdRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 42 | assertThat(output).contains( 43 | """ 44 | App version info generated by Android App Versioning plugin: 45 | Project: ":app" 46 | Build variant: prodRelease 47 | versionCode: 10203 48 | versionName: "1.2.3" 49 | """.trimIndent() 50 | ) 51 | } 52 | } 53 | 54 | @Test 55 | fun `PrintAppVersionInfo prints warning message to the console when they are not available`() { 56 | GitClient.initialize(fixtureDir.root).apply { 57 | val commitId = commit(message = "1st commit.") 58 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 59 | } 60 | 61 | withFixtureRunner( 62 | fixtureDir = fixtureDir, 63 | subprojects = listOf(AppProjectTemplate()) 64 | ).runAndCheckResult( 65 | "printAppVersionInfoForRelease" 66 | ) { 67 | assertThat(task(":app:printAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 68 | assertThat(output).contains( 69 | "No app version info (versionCode and versionName) generated by the Android App Versioning plugin for the \"release\" variant of project \":app\" is available." 70 | ) 71 | } 72 | } 73 | 74 | @Test 75 | fun `PrintAppVersionInfo prints latest generate versionCode and versionName (or warning message if not available) to the console for each project with AppVersioningPlugin applied`() { 76 | GitClient.initialize(fixtureDir.root).apply { 77 | commit(message = "Initial commit.") 78 | // appA release 79 | val commitId1 = commit(message = "appA release.") 80 | tag(name = "0.1.0+appA", message = "1st tag", commitId = commitId1) 81 | // appB release 82 | val commitId2 = commit(message = "appB release.") 83 | tag(name = "1.2.3+appB", message = "2nd tag", commitId = commitId2) 84 | // appC release 85 | val commitId4 = commit(message = "appC release.") 86 | tag(name = "10.3.5+appC", message = "4th tag", commitId = commitId4) 87 | } 88 | 89 | val tagFilterAppA = "[0-9]*.[0-9]*.[0-9]*+appA" 90 | val tagFilterAppB = "[0-9]*.[0-9]*.[0-9]*+appB" 91 | val tagFilterAppC = "[0-9]*.[0-9]*.[0-9]*+appC" 92 | 93 | val extensionsAppA = """ 94 | appVersioning { 95 | tagFilter.set("$tagFilterAppA") 96 | } 97 | """.trimIndent() 98 | 99 | val extensionsAppB = """ 100 | appVersioning { 101 | tagFilter.set("$tagFilterAppB") 102 | } 103 | """.trimIndent() 104 | 105 | val extensionsAppC = """ 106 | appVersioning { 107 | tagFilter.set("$tagFilterAppC") 108 | } 109 | """.trimIndent() 110 | 111 | val runner = withFixtureRunner( 112 | fixtureDir = fixtureDir, 113 | subprojects = listOf( 114 | AppProjectTemplate( 115 | projectName = "app-a", 116 | pluginExtension = extensionsAppA 117 | ), 118 | AppProjectTemplate( 119 | projectName = "app-b", 120 | pluginExtension = extensionsAppB 121 | ), 122 | AppProjectTemplate( 123 | projectName = "app-c", 124 | pluginExtension = extensionsAppC 125 | ) 126 | ) 127 | ) 128 | 129 | runner.runAndCheckResult( 130 | "app-a:generateAppVersionInfoForRelease", 131 | "app-b:generateAppVersionInfoForRelease" 132 | ) { 133 | assertThat(task(":app-a:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 134 | assertThat(task(":app-b:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 135 | } 136 | 137 | runner.runAndCheckResult( 138 | "printAppVersionInfoForRelease" 139 | ) { 140 | assertThat(task(":app-a:printAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 141 | assertThat(output).contains( 142 | """ 143 | App version info generated by Android App Versioning plugin: 144 | Project: ":app-a" 145 | Build variant: release 146 | versionCode: 100 147 | versionName: "0.1.0+appA" 148 | """.trimIndent() 149 | ) 150 | 151 | assertThat(task(":app-b:printAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 152 | assertThat(output).contains( 153 | """ 154 | App version info generated by Android App Versioning plugin: 155 | Project: ":app-b" 156 | Build variant: release 157 | versionCode: 10203 158 | versionName: "1.2.3+appB" 159 | """.trimIndent() 160 | ) 161 | 162 | assertThat(task(":app-a:printAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 163 | assertThat(output).contains( 164 | "No app version info (versionCode and versionName) generated by the Android App Versioning plugin for the \"release\" variant of project \":app-c\" is available." 165 | ) 166 | } 167 | } 168 | 169 | @Test 170 | fun `PrintAppVersionInfo is not incremental`() { 171 | GitClient.initialize(fixtureDir.root).apply { 172 | val commitId = commit(message = "1st commit.") 173 | tag(name = "1.2.3", message = "1st tag", commitId = commitId) 174 | } 175 | 176 | val runner = withFixtureRunner( 177 | fixtureDir = fixtureDir, 178 | subprojects = listOf(AppProjectTemplate()) 179 | ) 180 | 181 | runner.runAndCheckResult( 182 | "generateAppVersionInfoForRelease" 183 | ) { 184 | assertThat(task(":app:generateAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 185 | } 186 | 187 | runner.runAndCheckResult( 188 | "printAppVersionInfoForRelease" 189 | ) { 190 | assertThat(task(":app:printAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 191 | assertThat(output).contains( 192 | """ 193 | App version info generated by Android App Versioning plugin: 194 | Project: ":app" 195 | Build variant: release 196 | versionCode: 10203 197 | versionName: "1.2.3" 198 | """.trimIndent() 199 | ) 200 | } 201 | 202 | runner.runAndCheckResult( 203 | "printAppVersionInfoForRelease" 204 | ) { 205 | assertThat(task(":app:printAppVersionInfoForRelease")?.outcome).isEqualTo(TaskOutcome.SUCCESS) 206 | assertThat(output).contains( 207 | """ 208 | App version info generated by Android App Versioning plugin: 209 | Project: ":app" 210 | Build variant: release 211 | versionCode: 10203 212 | versionName: "1.2.3" 213 | """.trimIndent() 214 | ) 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/reactivecircus/appversioning/AppVersioningExtension.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning 2 | 3 | import groovy.lang.Closure 4 | import org.gradle.api.model.ObjectFactory 5 | import org.gradle.api.provider.ProviderFactory 6 | import org.gradle.kotlin.dsl.property 7 | 8 | /** 9 | * Extension for [AppVersioningPlugin]. 10 | */ 11 | @Suppress("UnstableApiUsage", "unused") 12 | open class AppVersioningExtension internal constructor(objects: ObjectFactory) { 13 | 14 | /** 15 | * Whether to enable the plugin. 16 | * 17 | * Default is `true`. 18 | */ 19 | val enabled = objects.property().convention(DEFAULT_ENABLED) 20 | 21 | /** 22 | * Whether to only generate version name and version code for `release` builds. 23 | * 24 | * Default is `false`. 25 | */ 26 | val releaseBuildOnly = objects.property().convention(DEFAULT_RELEASE_BUILD_ONLY) 27 | 28 | /** 29 | * Whether to fetch git tags from remote when no git tags can be found locally. 30 | * 31 | * Default is `false`. 32 | */ 33 | val fetchTagsWhenNoneExistsLocally = objects.property().convention( 34 | DEFAULT_FETCH_TAGS_WHEN_NONE_EXISTS_LOCALLY 35 | ) 36 | 37 | /** 38 | * Git root directory used for fetching git tags. 39 | * Use this to explicitly set the git root directory when the root Gradle project is not the git root directory. 40 | */ 41 | val gitRootDirectory = objects.directoryProperty().convention(null) 42 | 43 | /** 44 | * Bare Git repository directory. 45 | * Use this to explicitly set the directory of a bare git repository (e.g. `app.git`) instead of the standard `.git`. 46 | * Setting this will override the value of [gitRootDirectory] property. 47 | */ 48 | val bareGitRepoDirectory = objects.directoryProperty().convention(null) 49 | 50 | /** 51 | * Custom glob pattern for matching git tags. 52 | */ 53 | val tagFilter = objects.property().convention(null) 54 | 55 | /** 56 | * Provides a custom rule for generating versionCode by implementing a [GitTag], [ProviderFactory], [VariantInfo] -> Int lambda. 57 | * [GitTag] is generated from latest git tag lazily by the plugin during task execution. 58 | * [ProviderFactory] can be used for fetching environment variables, Gradle and system properties. 59 | * [VariantInfo] can be used to customize version code based on the build variants (product flavors and build types). 60 | * 61 | * By default the plugin attempts to generate the versionCode by converting a SemVer compliant tag to an integer 62 | * using positional notation: versionCode = MAJOR * 10000 + MINOR * 100 + PATCH 63 | * 64 | * If your tags don't follow semantic versioning, you don't like the default formula used to convert a SemVer tag to versionCode, 65 | * or if you want to fully customize how the versionCode is generated, you can implement this lambda to provide your own versionCode generation rule. 66 | * 67 | * Note that you can use the `gitTag.toSemVer()` extension (or `SemVer.fromGitTag(gitTag)` if you use groovy) to get a type-safe `SemVer` model 68 | * if your custom rule is still based on semantic versioning. 69 | */ 70 | fun overrideVersionCode(customizer: VersionCodeCustomizer) { 71 | kotlinVersionCodeCustomizer.set(customizer) 72 | } 73 | 74 | /** 75 | * Same as `overrideVersionCode(customizer: VersionCodeCustomizer)` above but for groovy support. 76 | */ 77 | fun overrideVersionCode(customizer: Closure) { 78 | groovyVersionCodeCustomizer.set(customizer.dehydrate()) 79 | } 80 | 81 | /** 82 | * Provides a custom rule for generating versionName by implementing a [GitTag], [ProviderFactory], [VariantInfo] -> String lambda. 83 | * [GitTag] is generated from latest git tag lazily by the plugin during task execution. 84 | * [ProviderFactory] can be used for fetching environment variables, Gradle and system properties. 85 | * [VariantInfo] can be used to customize version name based on the build variants (product flavors and build types). 86 | * 87 | * This is useful if you want to fully customize how the versionName is generated. 88 | * If not specified, versionName will be the name of the latest git tag. 89 | */ 90 | fun overrideVersionName(customizer: VersionNameCustomizer) { 91 | kotlinVersionNameCustomizer.set(customizer) 92 | } 93 | 94 | /** 95 | * Same as `overrideVersionName(customizer: VersionNameCustomizer)` above but for groovy support. 96 | */ 97 | fun overrideVersionName(customizer: Closure) { 98 | groovyVersionNameCustomizer.set(customizer.dehydrate()) 99 | } 100 | 101 | /** 102 | * A lambda (Kotlin function type) for specifying a custom rule for generating versionCode. 103 | */ 104 | internal val kotlinVersionCodeCustomizer = objects.property() 105 | 106 | /** 107 | * A lambda (Groovy closure) for specifying a custom rule for generating versionCode. 108 | */ 109 | internal val groovyVersionCodeCustomizer = objects.property>() 110 | 111 | /** 112 | * A lambda (Kotlin function type) for specifying a custom rule for generating versionName. 113 | */ 114 | internal val kotlinVersionNameCustomizer = objects.property() 115 | 116 | /** 117 | * A lambda (Groovy closure) for specifying a custom rule for generating versionName. 118 | */ 119 | internal val groovyVersionNameCustomizer = objects.property>() 120 | 121 | companion object { 122 | internal const val DEFAULT_ENABLED = true 123 | internal const val DEFAULT_RELEASE_BUILD_ONLY = false 124 | internal const val DEFAULT_FETCH_TAGS_WHEN_NONE_EXISTS_LOCALLY = false 125 | } 126 | } 127 | 128 | internal typealias VersionCodeCustomizer = (GitTag, ProviderFactory, VariantInfo) -> Int 129 | internal typealias VersionNameCustomizer = (GitTag, ProviderFactory, VariantInfo) -> String 130 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/reactivecircus/appversioning/AppVersioningPlugin.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning 2 | 3 | import com.android.build.api.variant.ApplicationAndroidComponentsExtension 4 | import com.android.build.api.variant.ApplicationVariant 5 | import com.android.build.gradle.AppPlugin 6 | import io.github.reactivecircus.appversioning.tasks.GenerateAppVersionInfo 7 | import io.github.reactivecircus.appversioning.tasks.PrintAppVersionInfo 8 | import org.gradle.api.Plugin 9 | import org.gradle.api.Project 10 | import org.gradle.api.tasks.TaskProvider 11 | import org.gradle.kotlin.dsl.getByType 12 | import org.gradle.kotlin.dsl.withType 13 | import org.gradle.language.nativeplatform.internal.BuildType 14 | import java.io.File 15 | import java.util.Locale 16 | import java.util.concurrent.atomic.AtomicBoolean 17 | 18 | /** 19 | * A plugin that generates and sets the version code and version name for an Android app using the latest git tag. 20 | */ 21 | class AppVersioningPlugin : Plugin { 22 | 23 | override fun apply(project: Project) { 24 | val androidAppPluginApplied = AtomicBoolean(false) 25 | val pluginDisabled = AtomicBoolean(false) 26 | val appVersioningExtension = project.extensions.create("appVersioning", AppVersioningExtension::class.java) 27 | project.plugins.withType { 28 | androidAppPluginApplied.set(true) 29 | val extension = project.extensions.getByType() 30 | extension.onVariants(selector = extension.selector().all()) { variant -> 31 | if (pluginDisabled.get()) return@onVariants 32 | if (!appVersioningExtension.enabled.get()) { 33 | project.logger.quiet("Android App Versioning plugin is disabled.") 34 | pluginDisabled.set(true) 35 | return@onVariants 36 | } 37 | if (!appVersioningExtension.releaseBuildOnly.get() || variant.buildType == BuildType.RELEASE.name) { 38 | val generateAppVersionInfo = project.registerGenerateAppVersionInfoTask( 39 | variant = variant, 40 | extension = appVersioningExtension 41 | ) 42 | 43 | val generatedVersionCode = generateAppVersionInfo.flatMap { 44 | it.versionCodeFile.map { file -> file.asFile.readText().trim().toInt() } 45 | } 46 | val generatedVersionName = generateAppVersionInfo.flatMap { 47 | it.versionNameFile.map { file -> file.asFile.readText().trim() } 48 | } 49 | 50 | project.registerPrintAppVersionInfoTask(variantName = variant.name) 51 | 52 | variant.outputs.forEach { output -> 53 | output.versionCode.set(generatedVersionCode) 54 | output.versionName.set(generatedVersionName) 55 | } 56 | } 57 | } 58 | } 59 | 60 | project.afterEvaluate { 61 | check(androidAppPluginApplied.get()) { 62 | "Android App Versioning plugin should only be applied to an Android Application project but ${project.displayName} doesn't have the 'com.android.application' plugin applied." 63 | } 64 | } 65 | } 66 | 67 | private fun Project.registerGenerateAppVersionInfoTask( 68 | variant: ApplicationVariant, 69 | extension: AppVersioningExtension, 70 | ): TaskProvider = tasks.register( 71 | "${GenerateAppVersionInfo.TASK_NAME_PREFIX}For${variant.name.capitalize()}", 72 | GenerateAppVersionInfo::class.java 73 | ) { 74 | group = APP_VERSIONING_TASK_GROUP 75 | description = "${GenerateAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the ${variant.name} variant." 76 | gitRefsDirectory.set(findGitRefsDirectory(extension)) 77 | gitHead.set(findGitHeadFile(extension)) 78 | rootProjectDirectory.set(project.rootProject.rootDir) 79 | rootProjectDisplayName.set(project.rootProject.displayName) 80 | fetchTagsWhenNoneExistsLocally.set(extension.fetchTagsWhenNoneExistsLocally) 81 | tagFilter.set(extension.tagFilter) 82 | kotlinVersionCodeCustomizer.set(extension.kotlinVersionCodeCustomizer) 83 | kotlinVersionNameCustomizer.set(extension.kotlinVersionNameCustomizer) 84 | groovyVersionCodeCustomizer.set(extension.groovyVersionCodeCustomizer) 85 | groovyVersionNameCustomizer.set(extension.groovyVersionNameCustomizer) 86 | versionCodeFile.set( 87 | layout.buildDirectory.file("$APP_VERSIONING_TASK_OUTPUT_DIR/${variant.name}/$VERSION_CODE_RESULT_FILE") 88 | ) 89 | versionNameFile.set( 90 | layout.buildDirectory.file("$APP_VERSIONING_TASK_OUTPUT_DIR/${variant.name}/$VERSION_NAME_RESULT_FILE") 91 | ) 92 | variantInfo.set( 93 | VariantInfo( 94 | buildType = variant.buildType, 95 | flavorName = variant.flavorName.orEmpty(), 96 | variantName = variant.name 97 | ) 98 | ) 99 | } 100 | 101 | private fun Project.registerPrintAppVersionInfoTask( 102 | variantName: String 103 | ): TaskProvider = tasks.register( 104 | "${PrintAppVersionInfo.TASK_NAME_PREFIX}For${variantName.capitalize()}", 105 | PrintAppVersionInfo::class.java 106 | ) { 107 | group = APP_VERSIONING_TASK_GROUP 108 | description = "${PrintAppVersionInfo.TASK_DESCRIPTION_PREFIX} for the $variantName variant." 109 | 110 | versionCodeFile.set( 111 | layout.buildDirectory.file("$APP_VERSIONING_TASK_OUTPUT_DIR/$variantName/$VERSION_CODE_RESULT_FILE") 112 | .flatMap { provider { if (it.asFile.exists()) it else null } } 113 | ) 114 | versionNameFile.set( 115 | layout.buildDirectory.file("$APP_VERSIONING_TASK_OUTPUT_DIR/$variantName/$VERSION_NAME_RESULT_FILE") 116 | .flatMap { provider { if (it.asFile.exists()) it else null } } 117 | ) 118 | projectName.set(project.name) 119 | buildVariantName.set(variantName) 120 | } 121 | 122 | private fun Project.findGitRefsDirectory(extension: AppVersioningExtension): File? { 123 | return when { 124 | extension.bareGitRepoDirectory.isPresent -> extension.bareGitRepoDirectory.let { bareGitRepoDirectory -> 125 | bareGitRepoDirectory.asFile.orNull?.resolve(REFS_DIRECTORY)?.takeIf { it.exists() } 126 | } 127 | extension.gitRootDirectory.isPresent -> extension.gitRootDirectory.let { gitRootDirectory -> 128 | gitRootDirectory.asFile.orNull?.resolve(STANDARD_GIT_REFS_DIRECTORY)?.takeIf { it.exists() } 129 | } 130 | else -> project.rootProject.file(STANDARD_GIT_REFS_DIRECTORY).takeIf { it.exists() } 131 | } 132 | } 133 | 134 | private fun Project.findGitHeadFile(extension: AppVersioningExtension): File? { 135 | return when { 136 | extension.bareGitRepoDirectory.isPresent -> extension.bareGitRepoDirectory.let { bareGitRepoDirectory -> 137 | bareGitRepoDirectory.asFile.orNull?.resolve(HEAD_FILE)?.takeIf { it.exists() } 138 | } 139 | extension.gitRootDirectory.isPresent -> extension.gitRootDirectory.let { gitRootDirectory -> 140 | gitRootDirectory.asFile.orNull?.resolve(STANDARD_GIT_HEAD_FILE)?.takeIf { it.exists() } 141 | } 142 | else -> project.rootProject.file(STANDARD_GIT_HEAD_FILE).takeIf { it.exists() } 143 | } 144 | } 145 | 146 | private fun String.capitalize(): String { 147 | return replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } 148 | } 149 | 150 | companion object 151 | } 152 | 153 | private const val APP_VERSIONING_TASK_GROUP = "versioning" 154 | private const val APP_VERSIONING_TASK_OUTPUT_DIR = "outputs/app_versioning" 155 | private const val REFS_DIRECTORY = "refs" 156 | private const val HEAD_FILE = "HEAD" 157 | private const val STANDARD_GIT_REFS_DIRECTORY = ".git/$REFS_DIRECTORY" 158 | private const val STANDARD_GIT_HEAD_FILE = ".git/$HEAD_FILE" 159 | private const val VERSION_CODE_RESULT_FILE = "version_code.txt" 160 | private const val VERSION_NAME_RESULT_FILE = "version_name.txt" 161 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/reactivecircus/appversioning/GitTag.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning 2 | 3 | /** 4 | * Type-safe representation of a git tag. 5 | */ 6 | data class GitTag( 7 | val rawTagName: String, 8 | val commitsSinceLatestTag: Int, 9 | val commitHash: String 10 | ) { 11 | override fun toString() = rawTagName 12 | } 13 | 14 | /** 15 | * Parses the output of `git describe --tags --long` into a [GitTag]. 16 | */ 17 | internal fun String.toGitTag(): GitTag { 18 | val result = requireNotNull("(.*)-(\\d+)-g([0-9,a-f]{7,40})\$".toRegex().matchEntire(this)) { 19 | "$this is not a valid git tag." 20 | } 21 | val (rawTagName, commitsSinceLatestTag, commitHash) = result.destructured 22 | return GitTag(rawTagName, commitsSinceLatestTag.toInt(), commitHash) 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/reactivecircus/appversioning/SemVer.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning 2 | 3 | import kotlin.math.pow 4 | 5 | /** 6 | * Type-safe representation of a version number that follows [Semantic Versioning 2.0.0](https://semver.org/#semantic-versioning-200). 7 | */ 8 | data class SemVer( 9 | val major: Int, 10 | val minor: Int, 11 | val patch: Int, 12 | val preRelease: String? = null, 13 | val buildMetadata: String? = null 14 | ) { 15 | override fun toString() = buildString { 16 | append("$major.$minor.$patch") 17 | if (preRelease != null) { 18 | append("-$preRelease") 19 | } 20 | if (buildMetadata != null) { 21 | append("+$buildMetadata") 22 | } 23 | } 24 | 25 | companion object { 26 | @JvmStatic 27 | @JvmOverloads 28 | fun fromGitTag(gitTag: GitTag, allowPrefixV: Boolean = true): SemVer = gitTag.toSemVer(allowPrefixV) 29 | } 30 | } 31 | 32 | /** 33 | * Generates an Integer representation of the [SemVer] using positional notation. 34 | * @param maxDigitsPerComponent number of digits allocated to the MINOR and PATCH components. 35 | * @throws [IllegalArgumentException] when MINOR or PATCH version is out of range allowed by [maxDigitsPerComponent], or when the result is above [Int.MAX_VALUE]. 36 | */ 37 | @Suppress("MagicNumber") 38 | internal fun SemVer.toInt(maxDigitsPerComponent: Int): Int { 39 | require(maxDigitsPerComponent > 0) 40 | require(patch < 10.0.pow(maxDigitsPerComponent)) 41 | require(minor < 10.0.pow(maxDigitsPerComponent)) 42 | val result = major * 10.0.pow(maxDigitsPerComponent * 2) + minor * 10.0.pow(maxDigitsPerComponent) + patch 43 | require(result <= Int.MAX_VALUE) 44 | return result.toInt() 45 | } 46 | 47 | /** 48 | * Tries to create a [SemVer] from a [GitTag] by parsing its `rawTagName` field. 49 | * @param allowPrefixV whether prefixing a semantic version with a “v” is allowed. 50 | * @throws [IllegalArgumentException] when the `rawTagName` of the [GitTag] is not a valid [SemVer]. 51 | */ 52 | @Suppress("DestructuringDeclarationWithTooManyEntries") 53 | fun GitTag.toSemVer(allowPrefixV: Boolean = true): SemVer { 54 | val result = requireNotNull(SEM_VER_REGEX.toRegex().matchEntire(rawTagName)) { 55 | "\"$rawTagName\" is not a valid SemVer." 56 | } 57 | val (prefixV, major, minor, patch, preRelease, buildMetadata) = result.destructured 58 | require(prefixV.isEmpty() || allowPrefixV) { 59 | "\"$rawTagName\" is not a valid SemVer as prefix \"v\" is not allowed unless `allowPrefixV` is set to true." 60 | } 61 | return SemVer( 62 | major = major.toInt(), 63 | minor = minor.toInt(), 64 | patch = patch.toInt(), 65 | preRelease = preRelease.ifEmpty { null }, 66 | buildMetadata = buildMetadata.ifEmpty { null } 67 | ) 68 | } 69 | 70 | /** 71 | * SemVer regex from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string 72 | * allowing an optional "v" prefix. 73 | */ 74 | private const val SEM_VER_REGEX = 75 | "^(v)?(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?\$" 76 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/reactivecircus/appversioning/VariantInfo.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning 2 | 3 | import org.gradle.language.nativeplatform.internal.BuildType 4 | import java.io.Serializable 5 | 6 | class VariantInfo( 7 | val buildType: String?, 8 | val flavorName: String, 9 | val variantName: String 10 | ) : Serializable { 11 | val isDebugBuild: Boolean get() = buildType == BuildType.DEBUG.name 12 | val isReleaseBuild: Boolean get() = buildType == BuildType.RELEASE.name 13 | 14 | companion object { 15 | private const val serialVersionUID = 1L 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/reactivecircus/appversioning/internal/GitClient.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning.internal 2 | 3 | import java.io.File 4 | import java.util.concurrent.TimeUnit 5 | 6 | class GitClient private constructor(private val projectDir: File) { 7 | 8 | fun listLocalTags(): List { 9 | return listOf("git", "tag", "--list").execute(projectDir).lines() 10 | } 11 | 12 | fun fetchRemoteTags() { 13 | listOf("git", "fetch", "--tags").execute(projectDir) 14 | } 15 | 16 | fun describeLatestTag(pattern: String? = null): String? { 17 | val command = buildList { 18 | add("git") 19 | add("describe") 20 | if (pattern != null) { 21 | add("--match") 22 | add(pattern) 23 | } 24 | add("--tags") 25 | add("--long") 26 | } 27 | return runCatching { command.execute(projectDir) }.getOrNull() 28 | } 29 | 30 | fun commit(message: String, allowEmpty: Boolean = true): CommitId { 31 | val commitCommand = buildList { 32 | add("git") 33 | add("commit") 34 | if (allowEmpty) { 35 | add("--allow-empty") 36 | } 37 | add("-m") 38 | add(message) 39 | } 40 | commitCommand.execute(projectDir) 41 | 42 | val getCommitIdCommand = listOf("git", "rev-parse", "--short", "HEAD") 43 | return CommitId(getCommitIdCommand.execute(projectDir)) 44 | } 45 | 46 | fun tag(name: String, message: String, commitId: CommitId? = null) { 47 | val command = buildList { 48 | add("git") 49 | add("tag") 50 | add("-a") 51 | add(name) 52 | if (commitId != null) { 53 | add(commitId.value) 54 | } 55 | add("-m") 56 | add(message) 57 | } 58 | command.execute(projectDir) 59 | } 60 | 61 | fun checkoutTag(tag: String) { 62 | val commitCommand = buildList { 63 | add("git") 64 | add("checkout") 65 | add(tag) 66 | } 67 | commitCommand.execute(projectDir) 68 | } 69 | 70 | companion object { 71 | 72 | fun initialize(projectDir: File): GitClient { 73 | return GitClient(projectDir).apply { 74 | listOf("git", "init").execute(projectDir) 75 | } 76 | } 77 | 78 | fun open(projectDir: File): GitClient { 79 | require(projectDir.isInValidGitRepo) 80 | return GitClient(projectDir) 81 | } 82 | } 83 | } 84 | 85 | @JvmInline 86 | value class CommitId(val value: String) 87 | 88 | private val File.isInValidGitRepo: Boolean 89 | get() = runCatching { 90 | listOf("git", "rev-parse", "--is-inside-work-tree").execute(this).toBoolean() 91 | }.getOrDefault(false) 92 | 93 | @Suppress("UseCheckOrError") 94 | private fun List.execute(workingDir: File, timeoutInSeconds: Long = DEFAULT_COMMAND_TIMEOUT_SECONDS): String { 95 | val process = ProcessBuilder(this) 96 | .directory(workingDir) 97 | .start() 98 | if (!process.waitFor(timeoutInSeconds, TimeUnit.SECONDS)) { 99 | process.destroy() 100 | throw IllegalStateException("Execution timeout: $this") 101 | } 102 | if (process.exitValue() != 0) { 103 | throw IllegalStateException("Execution failed with exit code: ${process.exitValue()}: $this") 104 | } 105 | return process.inputStream.bufferedReader().readText().trim() 106 | } 107 | 108 | private const val DEFAULT_COMMAND_TIMEOUT_SECONDS = 5L 109 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/reactivecircus/appversioning/tasks/GenerateAppVersionInfo.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage", "DuplicatedCode") 2 | 3 | package io.github.reactivecircus.appversioning.tasks 4 | 5 | import groovy.lang.Closure 6 | import io.github.reactivecircus.appversioning.GitTag 7 | import io.github.reactivecircus.appversioning.VariantInfo 8 | import io.github.reactivecircus.appversioning.VersionCodeCustomizer 9 | import io.github.reactivecircus.appversioning.VersionNameCustomizer 10 | import io.github.reactivecircus.appversioning.internal.GitClient 11 | import io.github.reactivecircus.appversioning.toGitTag 12 | import io.github.reactivecircus.appversioning.toInt 13 | import io.github.reactivecircus.appversioning.toSemVer 14 | import org.gradle.api.DefaultTask 15 | import org.gradle.api.file.DirectoryProperty 16 | import org.gradle.api.file.RegularFileProperty 17 | import org.gradle.api.logging.Logging 18 | import org.gradle.api.provider.Property 19 | import org.gradle.api.provider.ProviderFactory 20 | import org.gradle.api.tasks.CacheableTask 21 | import org.gradle.api.tasks.IgnoreEmptyDirectories 22 | import org.gradle.api.tasks.Input 23 | import org.gradle.api.tasks.InputDirectory 24 | import org.gradle.api.tasks.InputFile 25 | import org.gradle.api.tasks.Internal 26 | import org.gradle.api.tasks.Optional 27 | import org.gradle.api.tasks.OutputFile 28 | import org.gradle.api.tasks.PathSensitive 29 | import org.gradle.api.tasks.PathSensitivity 30 | import org.gradle.api.tasks.TaskAction 31 | import org.gradle.work.NormalizeLineEndings 32 | import org.gradle.workers.WorkAction 33 | import org.gradle.workers.WorkParameters 34 | import org.gradle.workers.WorkerExecutor 35 | import javax.inject.Inject 36 | 37 | /** 38 | * Generates app's versionCode and versionName based on git tags. 39 | */ 40 | @CacheableTask 41 | abstract class GenerateAppVersionInfo @Inject constructor( 42 | private val workerExecutor: WorkerExecutor 43 | ) : DefaultTask() { 44 | 45 | @get:Optional 46 | @get:InputDirectory 47 | @get:PathSensitive(PathSensitivity.RELATIVE) 48 | @get:IgnoreEmptyDirectories 49 | @get:NormalizeLineEndings 50 | abstract val gitRefsDirectory: DirectoryProperty 51 | 52 | @get:Optional 53 | @get:InputFile 54 | @get:PathSensitive(PathSensitivity.RELATIVE) 55 | @get:NormalizeLineEndings 56 | abstract val gitHead: RegularFileProperty 57 | 58 | @get:Internal 59 | abstract val rootProjectDirectory: DirectoryProperty 60 | 61 | @get:Internal 62 | abstract val rootProjectDisplayName: Property 63 | 64 | @get:Input 65 | abstract val fetchTagsWhenNoneExistsLocally: Property 66 | 67 | @get:Optional 68 | @get:Input 69 | abstract val tagFilter: Property 70 | 71 | @get:Optional 72 | @get:Input 73 | abstract val kotlinVersionCodeCustomizer: Property 74 | 75 | @get:Optional 76 | @get:Input 77 | abstract val kotlinVersionNameCustomizer: Property 78 | 79 | @get:Optional 80 | @get:Input 81 | abstract val groovyVersionCodeCustomizer: Property> 82 | 83 | @get:Optional 84 | @get:Input 85 | abstract val groovyVersionNameCustomizer: Property> 86 | 87 | @get:OutputFile 88 | abstract val versionCodeFile: RegularFileProperty 89 | 90 | @get:OutputFile 91 | abstract val versionNameFile: RegularFileProperty 92 | 93 | @get:Input 94 | abstract val variantInfo: Property 95 | 96 | @TaskAction 97 | fun generate() { 98 | workerExecutor.noIsolation().submit(GenerateAppVersionInfoWorkAction::class.java) { 99 | gitRefsDirectory.set(this@GenerateAppVersionInfo.gitRefsDirectory) 100 | gitHead.set(this@GenerateAppVersionInfo.gitHead) 101 | rootProjectDirectory.set(this@GenerateAppVersionInfo.rootProjectDirectory) 102 | rootProjectDisplayName.set(this@GenerateAppVersionInfo.rootProjectDisplayName) 103 | fetchTagsWhenNoneExistsLocally.set(this@GenerateAppVersionInfo.fetchTagsWhenNoneExistsLocally) 104 | tagFilter.set(this@GenerateAppVersionInfo.tagFilter) 105 | kotlinVersionCodeCustomizer.set(this@GenerateAppVersionInfo.kotlinVersionCodeCustomizer) 106 | kotlinVersionNameCustomizer.set(this@GenerateAppVersionInfo.kotlinVersionNameCustomizer) 107 | groovyVersionCodeCustomizer.set(this@GenerateAppVersionInfo.groovyVersionCodeCustomizer) 108 | groovyVersionNameCustomizer.set(this@GenerateAppVersionInfo.groovyVersionNameCustomizer) 109 | versionCodeFile.set(this@GenerateAppVersionInfo.versionCodeFile) 110 | versionNameFile.set(this@GenerateAppVersionInfo.versionNameFile) 111 | variantInfo.set(this@GenerateAppVersionInfo.variantInfo) 112 | } 113 | } 114 | 115 | companion object { 116 | const val TASK_NAME_PREFIX = "generateAppVersionInfo" 117 | const val TASK_DESCRIPTION_PREFIX = "Generates app's versionCode and versionName based on git tags" 118 | const val VERSION_CODE_FALLBACK = 0 119 | const val VERSION_NAME_FALLBACK = "" 120 | } 121 | } 122 | 123 | private interface GenerateAppVersionInfoWorkParameters : WorkParameters { 124 | val gitRefsDirectory: DirectoryProperty 125 | val gitHead: RegularFileProperty 126 | val rootProjectDirectory: DirectoryProperty 127 | val rootProjectDisplayName: Property 128 | val fetchTagsWhenNoneExistsLocally: Property 129 | val tagFilter: Property 130 | val kotlinVersionCodeCustomizer: Property 131 | val kotlinVersionNameCustomizer: Property 132 | val groovyVersionCodeCustomizer: Property> 133 | val groovyVersionNameCustomizer: Property> 134 | val versionCodeFile: RegularFileProperty 135 | val versionNameFile: RegularFileProperty 136 | val variantInfo: Property 137 | } 138 | 139 | private abstract class GenerateAppVersionInfoWorkAction @Inject constructor( 140 | private val providers: ProviderFactory 141 | ) : WorkAction { 142 | 143 | private val logger = Logging.getLogger(GenerateAppVersionInfo::class.java) 144 | 145 | @Suppress("LongMethod") 146 | override fun execute() { 147 | val gitRefsDirectory = parameters.gitRefsDirectory 148 | val gitHead = parameters.gitHead 149 | val rootProjectDirectory = parameters.rootProjectDirectory 150 | val rootProjectDisplayName = parameters.rootProjectDisplayName 151 | val fetchTagsWhenNoneExistsLocally = parameters.fetchTagsWhenNoneExistsLocally 152 | val tagFilter = parameters.tagFilter 153 | val kotlinVersionCodeCustomizer = parameters.kotlinVersionCodeCustomizer 154 | val kotlinVersionNameCustomizer = parameters.kotlinVersionNameCustomizer 155 | val groovyVersionCodeCustomizer = parameters.groovyVersionCodeCustomizer 156 | val groovyVersionNameCustomizer = parameters.groovyVersionNameCustomizer 157 | val versionCodeFile = parameters.versionCodeFile 158 | val versionNameFile = parameters.versionNameFile 159 | val variantInfo = parameters.variantInfo 160 | 161 | check(gitRefsDirectory.isPresent && gitHead.isPresent) { 162 | "Android App Versioning Gradle Plugin works with git tags but ${rootProjectDisplayName.get()} is not a git root directory, and a valid gitRootDirectory is not provided." 163 | } 164 | 165 | val gitClient = GitClient.open(rootProjectDirectory.get().asFile) 166 | 167 | val gitTag: GitTag = gitClient.describeLatestTag(tagFilter.orNull)?.toGitTag() ?: if (fetchTagsWhenNoneExistsLocally.get()) { 168 | val tagsList = gitClient.listLocalTags() 169 | if (tagsList.isEmpty()) { 170 | logger.warn("No git tags found. Fetching tags from remote.") 171 | gitClient.fetchRemoteTags() 172 | } 173 | gitClient.describeLatestTag(tagFilter.orNull)?.toGitTag() 174 | } else { 175 | null 176 | } ?: run { 177 | logger.warn( 178 | """ 179 | No git tags found. Falling back to version code ${GenerateAppVersionInfo.VERSION_CODE_FALLBACK} and version name "${GenerateAppVersionInfo.VERSION_NAME_FALLBACK}". 180 | If you want to fallback to the versionCode and versionName set via the DSL or manifest, or stop generating versionCode and versionName from Git tags: 181 | appVersioning { 182 | enabled.set(false) 183 | } 184 | """.trimIndent() 185 | ) 186 | versionCodeFile.get().asFile.writeText(GenerateAppVersionInfo.VERSION_CODE_FALLBACK.toString()) 187 | versionNameFile.get().asFile.writeText(GenerateAppVersionInfo.VERSION_NAME_FALLBACK) 188 | return 189 | } 190 | 191 | val versionCode: Int = generateVersionCodeFromGitTag( 192 | variantInfo, 193 | gitTag, 194 | kotlinVersionCodeCustomizer, 195 | groovyVersionCodeCustomizer 196 | ) 197 | versionCodeFile.get().asFile.writeText(versionCode.toString()) 198 | logger.quiet("Generated app version code: $versionCode.") 199 | 200 | val versionName: String = when { 201 | kotlinVersionNameCustomizer.isPresent -> kotlinVersionNameCustomizer.get().invoke( 202 | gitTag, 203 | providers, 204 | variantInfo.get() 205 | ) 206 | groovyVersionNameCustomizer.isPresent -> groovyVersionNameCustomizer.get().call( 207 | gitTag, 208 | providers, 209 | variantInfo.get() 210 | ) 211 | else -> gitTag.toString() 212 | } 213 | versionNameFile.get().asFile.writeText(versionName) 214 | logger.quiet("Generated app version name: \"$versionName\".") 215 | } 216 | 217 | private fun generateVersionCodeFromGitTag( 218 | variantInfo: Property, 219 | gitTag: GitTag, 220 | kotlinVersionCodeCustomizer: Property, 221 | groovyVersionCodeCustomizer: Property> 222 | ): Int = when { 223 | kotlinVersionCodeCustomizer.isPresent -> kotlinVersionCodeCustomizer.get().invoke( 224 | gitTag, 225 | providers, 226 | variantInfo.get() 227 | ) 228 | groovyVersionCodeCustomizer.isPresent -> groovyVersionCodeCustomizer.get().call( 229 | gitTag, 230 | providers, 231 | variantInfo.get() 232 | ) 233 | else -> { 234 | // no custom rule for generating versionCode has been provided, attempt calculation based on SemVer 235 | val semVer = runCatching { 236 | gitTag.toSemVer() 237 | }.getOrNull() 238 | checkNotNull(semVer) { 239 | """ 240 | Could not generate versionCode as "${gitTag.rawTagName}" does not follow semantic versioning. 241 | Please either ensure latest git tag follows semantic versioning, or provide a custom rule for generating versionCode using the `overrideVersionCode` lambda. 242 | """.trimIndent() 243 | } 244 | runCatching { 245 | semVer.toInt(maxDigitsPerComponent = MAX_DIGITS_PER_SEM_VER_COMPONENT) 246 | }.getOrElse { 247 | Int.MAX_VALUE 248 | throw IllegalStateException( 249 | """ 250 | Could not generate versionCode from "${gitTag.rawTagName}" as the SemVer cannot be represented as an Integer. 251 | This is usually because MAJOR or MINOR version is greater than 99, as by default maximum of 2 digits is allowed for MINOR and PATCH components of a SemVer tag. 252 | Another reason might be that the overall positional notation of the SemVer (MAJOR * 10000 + MINOR * 100 + PATCH) is greater than the maximum value of an integer (2147483647). 253 | As a workaround you can provide a custom rule for generating versionCode using the `overrideVersionCode` lambda. 254 | """.trimIndent() 255 | ) 256 | } 257 | } 258 | } 259 | 260 | companion object { 261 | private const val MAX_DIGITS_PER_SEM_VER_COMPONENT = 2 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/reactivecircus/appversioning/tasks/PrintAppVersionInfo.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning.tasks 2 | 3 | import org.gradle.api.DefaultTask 4 | import org.gradle.api.file.RegularFileProperty 5 | import org.gradle.api.provider.Property 6 | import org.gradle.api.tasks.Internal 7 | import org.gradle.api.tasks.TaskAction 8 | 9 | /** 10 | * Prints the latest versionCode and versionName generated by the Android App Versioning plugin to the console, 11 | * or a warning message if the versionCode and versionName generated by this plugin are not available. 12 | * Note that these might not be the final versionCode and versionName used in the merged manifest file in the APK. 13 | */ 14 | abstract class PrintAppVersionInfo : DefaultTask() { 15 | 16 | @get:Internal 17 | abstract val versionCodeFile: RegularFileProperty 18 | 19 | @get:Internal 20 | abstract val versionNameFile: RegularFileProperty 21 | 22 | @get:Internal 23 | abstract val projectName: Property 24 | 25 | @get:Internal 26 | abstract val buildVariantName: Property 27 | 28 | @TaskAction 29 | fun print() { 30 | if (versionCodeFile.isPresent && versionNameFile.isPresent) { 31 | logger.quiet( 32 | """ 33 | App version info generated by Android App Versioning plugin: 34 | Project: ":${projectName.get()}" 35 | Build variant: ${buildVariantName.get()} 36 | versionCode: ${versionCodeFile.get().asFile.readText().trim().toInt()} 37 | versionName: "${versionNameFile.get().asFile.readText().trim()}" 38 | """.trimIndent() 39 | ) 40 | } else { 41 | logger.warn( 42 | "No app version info (versionCode and versionName) generated by the Android App Versioning plugin for the \"${buildVariantName.get()}\" variant of project \":${projectName.get()}\" is available." 43 | ) 44 | } 45 | } 46 | 47 | companion object { 48 | const val TASK_NAME_PREFIX = "printAppVersionInfo" 49 | const val TASK_DESCRIPTION_PREFIX = "Prints the versionCode and versionName generated by Android App Versioning plugin (if available) to the console" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/reactivecircus/appversioning/GitTagTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.gradle.internal.impldep.org.junit.Assert.assertThrows 5 | import org.junit.Test 6 | 7 | class GitTagTest { 8 | 9 | @Test 10 | fun `valid tag description can be converted to a GitTag`() { 11 | assertThat("0.1.0-3-g9c28ad3".toGitTag()) 12 | .isEqualTo( 13 | GitTag( 14 | rawTagName = "0.1.0", 15 | commitsSinceLatestTag = 3, 16 | commitHash = "9c28ad3" 17 | ) 18 | ) 19 | assertThat("0.1.0-0-g9c28ad3".toGitTag()) 20 | .isEqualTo( 21 | GitTag( 22 | rawTagName = "0.1.0", 23 | commitsSinceLatestTag = 0, 24 | commitHash = "9c28ad3" 25 | ) 26 | ) 27 | assertThat("1.0-20-g36e7453".toGitTag()) 28 | .isEqualTo( 29 | GitTag( 30 | rawTagName = "1.0", 31 | commitsSinceLatestTag = 20, 32 | commitHash = "36e7453" 33 | ) 34 | ) 35 | assertThat("1.0.0-alpha03-20-g36e7453".toGitTag()) 36 | .isEqualTo( 37 | GitTag( 38 | rawTagName = "1.0.0-alpha03", 39 | commitsSinceLatestTag = 20, 40 | commitHash = "36e7453" 41 | ) 42 | ) 43 | assertThat("1.0.0-alpha03-20-g36e7453".toGitTag()) 44 | .isEqualTo( 45 | GitTag( 46 | rawTagName = "1.0.0-alpha03", 47 | commitsSinceLatestTag = 20, 48 | commitHash = "36e7453" 49 | ) 50 | ) 51 | assertThat("1.0.0-alpha+001-20-g36e7453".toGitTag()) 52 | .isEqualTo( 53 | GitTag( 54 | rawTagName = "1.0.0-alpha+001", 55 | commitsSinceLatestTag = 20, 56 | commitHash = "36e7453" 57 | ) 58 | ) 59 | } 60 | 61 | @Test 62 | fun `converting invalid tag description to GitTag throws exception`() { 63 | listOf( 64 | "1.0.0-g36e7453", 65 | "1.0.0-a-g36e7453", 66 | "1.0.0-a3-g36e7453", 67 | "1.0.0-3-36e7453", 68 | "1.0.0-3-g36e745", 69 | "1.0.0-a-g36e745g" 70 | ).forEach { tagDescription -> 71 | assertThrows(IllegalArgumentException::class.java) { 72 | tagDescription.toGitTag() 73 | } 74 | } 75 | } 76 | 77 | @Test 78 | fun `GitTag#toString() returns rawTagName`() { 79 | val gitTag = GitTag( 80 | rawTagName = "1.0.0-alpha03", 81 | commitsSinceLatestTag = 1, 82 | commitHash = "36e7453" 83 | ) 84 | assertThat(gitTag.toString()) 85 | .isEqualTo( 86 | "1.0.0-alpha03" 87 | ) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/reactivecircus/appversioning/SemVerTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.gradle.internal.impldep.org.junit.Assert.assertThrows 5 | import org.junit.Test 6 | 7 | class SemVerTest { 8 | 9 | @Test 10 | fun `SemVer can be converted to integer representation using positional notation`() { 11 | assertThat( 12 | SemVer( 13 | major = 0, 14 | minor = 0, 15 | patch = 3 16 | ).toInt(maxDigitsPerComponent = 2) 17 | ) 18 | .isEqualTo(3) 19 | 20 | assertThat( 21 | SemVer( 22 | major = 0, 23 | minor = 1, 24 | patch = 3 25 | ).toInt(maxDigitsPerComponent = 2) 26 | ) 27 | .isEqualTo(103) 28 | 29 | assertThat( 30 | SemVer( 31 | major = 2, 32 | minor = 1, 33 | patch = 3 34 | ).toInt(maxDigitsPerComponent = 2) 35 | ) 36 | .isEqualTo(20103) 37 | 38 | assertThat( 39 | SemVer( 40 | major = 0, 41 | minor = 0, 42 | patch = 99 43 | ).toInt(maxDigitsPerComponent = 2) 44 | ) 45 | .isEqualTo(99) 46 | 47 | assertThat( 48 | SemVer( 49 | major = 0, 50 | minor = 99, 51 | patch = 99 52 | ).toInt(maxDigitsPerComponent = 2) 53 | ) 54 | .isEqualTo(9999) 55 | 56 | assertThat( 57 | SemVer( 58 | major = 100, 59 | minor = 99, 60 | patch = 99 61 | ).toInt(maxDigitsPerComponent = 2) 62 | ) 63 | .isEqualTo(1009999) 64 | } 65 | 66 | @Test 67 | fun `converting SemVer to integer throws exception when maxDigitsPerComponent is not positive`() { 68 | assertThrows(IllegalArgumentException::class.java) { 69 | SemVer( 70 | major = 1, 71 | minor = 2, 72 | patch = 3 73 | ).toInt(maxDigitsPerComponent = -1) 74 | } 75 | assertThrows(IllegalArgumentException::class.java) { 76 | SemVer( 77 | major = 1, 78 | minor = 2, 79 | patch = 3 80 | ).toInt(maxDigitsPerComponent = 0) 81 | } 82 | } 83 | 84 | @Test 85 | fun `converting SemVer to integer throws exception when a version component is out of range`() { 86 | assertThrows(IllegalArgumentException::class.java) { 87 | SemVer( 88 | major = 1, 89 | minor = 2, 90 | patch = 10 91 | ).toInt(maxDigitsPerComponent = 1) 92 | } 93 | assertThrows(IllegalArgumentException::class.java) { 94 | SemVer( 95 | major = 1, 96 | minor = 10, 97 | patch = 3 98 | ).toInt(maxDigitsPerComponent = 1) 99 | } 100 | assertThrows(IllegalArgumentException::class.java) { 101 | SemVer( 102 | major = 1, 103 | minor = 2, 104 | patch = 100 105 | ).toInt(maxDigitsPerComponent = 2) 106 | } 107 | assertThrows(IllegalArgumentException::class.java) { 108 | SemVer( 109 | major = 1, 110 | minor = 100, 111 | patch = 3 112 | ).toInt(maxDigitsPerComponent = 2) 113 | } 114 | } 115 | 116 | @Test 117 | fun `converting SemVer to integer throws exception when result is out of range`() { 118 | assertThat( 119 | SemVer( 120 | major = 0, 121 | minor = 0, 122 | patch = Int.MAX_VALUE 123 | ).toInt(maxDigitsPerComponent = 10) 124 | ).isEqualTo(Int.MAX_VALUE) 125 | 126 | assertThrows(IllegalArgumentException::class.java) { 127 | SemVer( 128 | major = 0, 129 | minor = 1, 130 | patch = Int.MAX_VALUE 131 | ).toInt(maxDigitsPerComponent = 10) 132 | } 133 | } 134 | 135 | @Test 136 | fun `SemVer compliant GitTag can be converted to a SemVer`() { 137 | assertThat( 138 | GitTag( 139 | rawTagName = "0.0.4", 140 | commitsSinceLatestTag = 0, 141 | commitHash = "9c28ad3" 142 | ).toSemVer() 143 | ).isEqualTo( 144 | SemVer( 145 | major = 0, 146 | minor = 0, 147 | patch = 4 148 | ) 149 | ) 150 | assertThat( 151 | GitTag( 152 | rawTagName = "1.2.3", 153 | commitsSinceLatestTag = 0, 154 | commitHash = "9c28ad3" 155 | ).toSemVer() 156 | ).isEqualTo( 157 | SemVer( 158 | major = 1, 159 | minor = 2, 160 | patch = 3 161 | ) 162 | ) 163 | assertThat( 164 | GitTag( 165 | rawTagName = "10.20.30", 166 | commitsSinceLatestTag = 0, 167 | commitHash = "9c28ad3" 168 | ).toSemVer() 169 | ).isEqualTo( 170 | SemVer( 171 | major = 10, 172 | minor = 20, 173 | patch = 30 174 | ) 175 | ) 176 | assertThat( 177 | GitTag( 178 | rawTagName = "1.1.2-prerelease+meta", 179 | commitsSinceLatestTag = 0, 180 | commitHash = "9c28ad3" 181 | ).toSemVer() 182 | ).isEqualTo( 183 | SemVer( 184 | major = 1, 185 | minor = 1, 186 | patch = 2, 187 | preRelease = "prerelease", 188 | buildMetadata = "meta" 189 | ) 190 | ) 191 | assertThat( 192 | GitTag( 193 | rawTagName = "1.1.2+meta", 194 | commitsSinceLatestTag = 0, 195 | commitHash = "9c28ad3" 196 | ).toSemVer() 197 | ).isEqualTo( 198 | SemVer( 199 | major = 1, 200 | minor = 1, 201 | patch = 2, 202 | buildMetadata = "meta" 203 | ) 204 | ) 205 | assertThat( 206 | GitTag( 207 | rawTagName = "1.1.2+meta-valid", 208 | commitsSinceLatestTag = 0, 209 | commitHash = "9c28ad3" 210 | ).toSemVer() 211 | ).isEqualTo( 212 | SemVer( 213 | major = 1, 214 | minor = 1, 215 | patch = 2, 216 | buildMetadata = "meta-valid" 217 | ) 218 | ) 219 | assertThat( 220 | GitTag( 221 | rawTagName = "1.0.0-alpha", 222 | commitsSinceLatestTag = 0, 223 | commitHash = "9c28ad3" 224 | ).toSemVer() 225 | ).isEqualTo( 226 | SemVer( 227 | major = 1, 228 | minor = 0, 229 | patch = 0, 230 | preRelease = "alpha" 231 | ) 232 | ) 233 | assertThat( 234 | GitTag( 235 | rawTagName = "1.0.0-beta", 236 | commitsSinceLatestTag = 0, 237 | commitHash = "9c28ad3" 238 | ).toSemVer() 239 | ).isEqualTo( 240 | SemVer( 241 | major = 1, 242 | minor = 0, 243 | patch = 0, 244 | preRelease = "beta" 245 | ) 246 | ) 247 | assertThat( 248 | GitTag( 249 | rawTagName = "1.0.0-alpha.beta", 250 | commitsSinceLatestTag = 0, 251 | commitHash = "9c28ad3" 252 | ).toSemVer() 253 | ).isEqualTo( 254 | SemVer( 255 | major = 1, 256 | minor = 0, 257 | patch = 0, 258 | preRelease = "alpha.beta" 259 | ) 260 | ) 261 | assertThat( 262 | GitTag( 263 | rawTagName = "1.0.0-alpha.beta.1", 264 | commitsSinceLatestTag = 0, 265 | commitHash = "9c28ad3" 266 | ).toSemVer() 267 | ).isEqualTo( 268 | SemVer( 269 | major = 1, 270 | minor = 0, 271 | patch = 0, 272 | preRelease = "alpha.beta.1" 273 | ) 274 | ) 275 | assertThat( 276 | GitTag( 277 | rawTagName = "1.0.0-alpha.1", 278 | commitsSinceLatestTag = 0, 279 | commitHash = "9c28ad3" 280 | ).toSemVer() 281 | ).isEqualTo( 282 | SemVer( 283 | major = 1, 284 | minor = 0, 285 | patch = 0, 286 | preRelease = "alpha.1" 287 | ) 288 | ) 289 | assertThat( 290 | GitTag( 291 | rawTagName = "1.0.0-alpha0.valid", 292 | commitsSinceLatestTag = 0, 293 | commitHash = "9c28ad3" 294 | ).toSemVer() 295 | ).isEqualTo( 296 | SemVer( 297 | major = 1, 298 | minor = 0, 299 | patch = 0, 300 | preRelease = "alpha0.valid" 301 | ) 302 | ) 303 | assertThat( 304 | GitTag( 305 | rawTagName = "1.0.0-alpha.0valid", 306 | commitsSinceLatestTag = 0, 307 | commitHash = "9c28ad3" 308 | ).toSemVer() 309 | ).isEqualTo( 310 | SemVer( 311 | major = 1, 312 | minor = 0, 313 | patch = 0, 314 | preRelease = "alpha.0valid" 315 | ) 316 | ) 317 | assertThat( 318 | GitTag( 319 | rawTagName = "1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay", 320 | commitsSinceLatestTag = 0, 321 | commitHash = "9c28ad3" 322 | ).toSemVer() 323 | ).isEqualTo( 324 | SemVer( 325 | major = 1, 326 | minor = 0, 327 | patch = 0, 328 | preRelease = "alpha-a.b-c-somethinglong", 329 | buildMetadata = "build.1-aef.1-its-okay" 330 | ) 331 | ) 332 | assertThat( 333 | GitTag( 334 | rawTagName = "1.0.0-rc.1+build.1", 335 | commitsSinceLatestTag = 0, 336 | commitHash = "9c28ad3" 337 | ).toSemVer() 338 | ).isEqualTo( 339 | SemVer( 340 | major = 1, 341 | minor = 0, 342 | patch = 0, 343 | preRelease = "rc.1", 344 | buildMetadata = "build.1" 345 | ) 346 | ) 347 | assertThat( 348 | GitTag( 349 | rawTagName = "1.0.0-alpha.beta.1", 350 | commitsSinceLatestTag = 0, 351 | commitHash = "9c28ad3" 352 | ).toSemVer() 353 | ).isEqualTo( 354 | SemVer( 355 | major = 1, 356 | minor = 0, 357 | patch = 0, 358 | preRelease = "alpha.beta.1" 359 | ) 360 | ) 361 | assertThat( 362 | GitTag( 363 | rawTagName = "2.0.0-rc.1+build.123", 364 | commitsSinceLatestTag = 0, 365 | commitHash = "9c28ad3" 366 | ).toSemVer() 367 | ).isEqualTo( 368 | SemVer( 369 | major = 2, 370 | minor = 0, 371 | patch = 0, 372 | preRelease = "rc.1", 373 | buildMetadata = "build.123" 374 | ) 375 | ) 376 | assertThat( 377 | GitTag( 378 | rawTagName = "1.2.3-beta", 379 | commitsSinceLatestTag = 0, 380 | commitHash = "9c28ad3" 381 | ).toSemVer() 382 | ).isEqualTo( 383 | SemVer( 384 | major = 1, 385 | minor = 2, 386 | patch = 3, 387 | preRelease = "beta" 388 | ) 389 | ) 390 | assertThat( 391 | GitTag( 392 | rawTagName = "10.2.3-DEV-SNAPSHOT", 393 | commitsSinceLatestTag = 0, 394 | commitHash = "9c28ad3" 395 | ).toSemVer() 396 | ).isEqualTo( 397 | SemVer( 398 | major = 10, 399 | minor = 2, 400 | patch = 3, 401 | preRelease = "DEV-SNAPSHOT" 402 | ) 403 | ) 404 | assertThat( 405 | GitTag( 406 | rawTagName = "1.2.3-SNAPSHOT-123", 407 | commitsSinceLatestTag = 0, 408 | commitHash = "9c28ad3" 409 | ).toSemVer() 410 | ).isEqualTo( 411 | SemVer( 412 | major = 1, 413 | minor = 2, 414 | patch = 3, 415 | preRelease = "SNAPSHOT-123" 416 | ) 417 | ) 418 | assertThat( 419 | GitTag( 420 | rawTagName = "1.0.0", 421 | commitsSinceLatestTag = 0, 422 | commitHash = "9c28ad3" 423 | ).toSemVer() 424 | ).isEqualTo( 425 | SemVer( 426 | major = 1, 427 | minor = 0, 428 | patch = 0 429 | ) 430 | ) 431 | assertThat( 432 | GitTag( 433 | rawTagName = "2.0.0", 434 | commitsSinceLatestTag = 0, 435 | commitHash = "9c28ad3" 436 | ).toSemVer() 437 | ).isEqualTo( 438 | SemVer( 439 | major = 2, 440 | minor = 0, 441 | patch = 0 442 | ) 443 | ) 444 | assertThat( 445 | GitTag( 446 | rawTagName = "1.1.7", 447 | commitsSinceLatestTag = 0, 448 | commitHash = "9c28ad3" 449 | ).toSemVer() 450 | ).isEqualTo( 451 | SemVer( 452 | major = 1, 453 | minor = 1, 454 | patch = 7 455 | ) 456 | ) 457 | assertThat( 458 | GitTag( 459 | rawTagName = "2.0.0+build.1848", 460 | commitsSinceLatestTag = 0, 461 | commitHash = "9c28ad3" 462 | ).toSemVer() 463 | ).isEqualTo( 464 | SemVer( 465 | major = 2, 466 | minor = 0, 467 | patch = 0, 468 | buildMetadata = "build.1848" 469 | ) 470 | ) 471 | assertThat( 472 | GitTag( 473 | rawTagName = "2.0.1-alpha.1227", 474 | commitsSinceLatestTag = 0, 475 | commitHash = "9c28ad3" 476 | ).toSemVer() 477 | ).isEqualTo( 478 | SemVer( 479 | major = 2, 480 | minor = 0, 481 | patch = 1, 482 | preRelease = "alpha.1227" 483 | ) 484 | ) 485 | assertThat( 486 | GitTag( 487 | rawTagName = "1.0.0-alpha+beta", 488 | commitsSinceLatestTag = 0, 489 | commitHash = "9c28ad3" 490 | ).toSemVer() 491 | ).isEqualTo( 492 | SemVer( 493 | major = 1, 494 | minor = 0, 495 | patch = 0, 496 | preRelease = "alpha", 497 | buildMetadata = "beta" 498 | ) 499 | ) 500 | assertThat( 501 | GitTag( 502 | rawTagName = "1.2.3----RC-SNAPSHOT.12.9.1--.12+788", 503 | commitsSinceLatestTag = 0, 504 | commitHash = "9c28ad3" 505 | ).toSemVer() 506 | ).isEqualTo( 507 | SemVer( 508 | major = 1, 509 | minor = 2, 510 | patch = 3, 511 | preRelease = "---RC-SNAPSHOT.12.9.1--.12", 512 | buildMetadata = "788" 513 | ) 514 | ) 515 | assertThat( 516 | GitTag( 517 | rawTagName = "1.2.3----R-S.12.9.1--.12+meta", 518 | commitsSinceLatestTag = 0, 519 | commitHash = "9c28ad3" 520 | ).toSemVer() 521 | ).isEqualTo( 522 | SemVer( 523 | major = 1, 524 | minor = 2, 525 | patch = 3, 526 | preRelease = "---R-S.12.9.1--.12", 527 | buildMetadata = "meta" 528 | ) 529 | ) 530 | assertThat( 531 | GitTag( 532 | rawTagName = "1.2.3----RC-SNAPSHOT.12.9.1--.12", 533 | commitsSinceLatestTag = 0, 534 | commitHash = "9c28ad3" 535 | ).toSemVer() 536 | ).isEqualTo( 537 | SemVer( 538 | major = 1, 539 | minor = 2, 540 | patch = 3, 541 | preRelease = "---RC-SNAPSHOT.12.9.1--.12" 542 | ) 543 | ) 544 | assertThat( 545 | GitTag( 546 | rawTagName = "1.0.0+0.build.1-rc.10000aaa-kk-0.1", 547 | commitsSinceLatestTag = 0, 548 | commitHash = "9c28ad3" 549 | ).toSemVer() 550 | ).isEqualTo( 551 | SemVer( 552 | major = 1, 553 | minor = 0, 554 | patch = 0, 555 | buildMetadata = "0.build.1-rc.10000aaa-kk-0.1" 556 | ) 557 | ) 558 | assertThat( 559 | GitTag( 560 | rawTagName = "999999999.99999999.9999999", 561 | commitsSinceLatestTag = 0, 562 | commitHash = "9c28ad3" 563 | ).toSemVer() 564 | ).isEqualTo( 565 | SemVer( 566 | major = 999999999, 567 | minor = 99999999, 568 | patch = 9999999 569 | ) 570 | ) 571 | assertThat( 572 | GitTag( 573 | rawTagName = "1.0.0-0A.is.legal", 574 | commitsSinceLatestTag = 0, 575 | commitHash = "9c28ad3" 576 | ).toSemVer() 577 | ).isEqualTo( 578 | SemVer( 579 | major = 1, 580 | minor = 0, 581 | patch = 0, 582 | preRelease = "0A.is.legal" 583 | ) 584 | ) 585 | } 586 | 587 | @Test 588 | fun `converting non-SemVer compliant GitTag to SemVer throws exception`() { 589 | listOf( 590 | "1", 591 | "1.2", 592 | "1.2.3-0123", 593 | "1.2.3-0123.0123", 594 | "1.1.2+.123", 595 | "+invalid", 596 | "-invalid", 597 | "-invalid+invalid", 598 | "-invalid.01", 599 | "alpha", 600 | "alpha.beta", 601 | "alpha.beta.1", 602 | "alpha.1", 603 | "alpha+beta", 604 | "alpha_beta", 605 | "alpha.", 606 | "alpha..", 607 | "beta", 608 | "1.0.0-alpha_beta", 609 | "-alpha.", 610 | "1.0.0-alpha..", 611 | "1.0.0-alpha..1", 612 | "1.0.0-alpha...1", 613 | "1.0.0-alpha....1", 614 | "1.0.0-alpha.....1", 615 | "1.0.0-alpha......1", 616 | "1.0.0-alpha.......1", 617 | "01.1.1", 618 | "1.01.1", 619 | "1.1.01", 620 | "1.2", 621 | "1.2.3.DEV", 622 | "1.2-SNAPSHOT", 623 | "1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788", 624 | "1.2-RC-SNAPSHOT", 625 | "-1.0.3-gamma+b7718", 626 | "+justmeta", 627 | "9.8.7+meta+meta", 628 | "9.8.7-whatever+meta+meta", 629 | "99999999999999999999999.999999999999999999.99999999999999999----RC-SNAPSHOT.12.09.1--------------------------------..12" 630 | ).forEach { version -> 631 | assertThrows(IllegalArgumentException::class.java) { 632 | GitTag( 633 | rawTagName = version, 634 | commitsSinceLatestTag = 0, 635 | commitHash = "9c28ad3" 636 | ).toSemVer() 637 | } 638 | } 639 | } 640 | 641 | @Test 642 | fun `SemVer compliant GitTag with a prefix v can be converted to a GitTag when allowPrefixV is true`() { 643 | assertThat( 644 | GitTag( 645 | rawTagName = "v1.2.3", 646 | commitsSinceLatestTag = 3, 647 | commitHash = "9c28ad3" 648 | ).toSemVer(allowPrefixV = true) 649 | ) 650 | .isEqualTo( 651 | SemVer( 652 | major = 1, 653 | minor = 2, 654 | patch = 3 655 | ) 656 | ) 657 | } 658 | 659 | @Test 660 | fun `converting SemVer compliant GitTag with a prefix v throws exception when allowPrefixV is false`() { 661 | assertThrows(IllegalArgumentException::class.java) { 662 | GitTag( 663 | rawTagName = "v1.2.3", 664 | commitsSinceLatestTag = 3, 665 | commitHash = "9c28ad3" 666 | ).toSemVer(allowPrefixV = false) 667 | } 668 | } 669 | 670 | @Test 671 | fun `SemVer can be created from fromGitTag() companion function`() { 672 | val gitTag = GitTag( 673 | rawTagName = "1.2.3", 674 | commitsSinceLatestTag = 0, 675 | commitHash = "9c28ad3" 676 | ) 677 | assertThat(gitTag.toSemVer()) 678 | .isEqualTo(SemVer.fromGitTag(gitTag)) 679 | } 680 | 681 | @Test 682 | fun `SemVer#toString() returns the SemVer in string representation`() { 683 | listOf( 684 | "0.0.4", 685 | "1.2.3", 686 | "10.20.30", 687 | "1.1.2-prerelease+meta", 688 | "1.1.2+meta", 689 | "1.1.2+meta-valid", 690 | "1.0.0-alpha", 691 | "1.0.0-beta", 692 | "1.0.0-alpha.beta", 693 | "1.0.0-alpha.beta.1", 694 | "1.0.0-alpha.1", 695 | "1.0.0-alpha0.valid", 696 | "1.0.0-alpha.0valid", 697 | "1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay", 698 | "1.0.0-rc.1+build.1", 699 | "2.0.0-rc.1+build.123", 700 | "1.2.3-beta", 701 | "10.2.3-DEV-SNAPSHOT", 702 | "1.2.3-SNAPSHOT-123", 703 | "1.0.0", 704 | "2.0.0", 705 | "1.1.7", 706 | "2.0.0+build.1848", 707 | "2.0.1-alpha.1227", 708 | "1.0.0-alpha+beta", 709 | "1.2.3----RC-SNAPSHOT.12.9.1--.12+788", 710 | "1.2.3----R-S.12.9.1--.12+meta", 711 | "1.2.3----RC-SNAPSHOT.12.9.1--.12", 712 | "1.0.0+0.build.1-rc.10000aaa-kk-0.1", 713 | "999999999.99999999.9999999", 714 | "1.0.0-0A.is.legal" 715 | ).forEach { version -> 716 | assertThat( 717 | GitTag( 718 | rawTagName = version, 719 | commitsSinceLatestTag = 0, 720 | commitHash = "9c28ad3" 721 | ).toSemVer().toString() 722 | ).isEqualTo(version) 723 | } 724 | } 725 | } 726 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/reactivecircus/appversioning/VariantInfoTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.reactivecircus.appversioning 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.junit.Test 5 | 6 | class VariantInfoTest { 7 | 8 | @Test 9 | fun `VariantInfo#isDebugBuild returns true when builtType is 'debug'`() { 10 | assertThat( 11 | VariantInfo( 12 | buildType = "debug", 13 | flavorName = "", 14 | variantName = "debug" 15 | ).isDebugBuild 16 | ).isTrue() 17 | 18 | assertThat( 19 | VariantInfo( 20 | buildType = "release", 21 | flavorName = "", 22 | variantName = "release" 23 | ).isDebugBuild 24 | ).isFalse() 25 | 26 | assertThat( 27 | VariantInfo( 28 | buildType = null, 29 | flavorName = "", 30 | variantName = "" 31 | ).isDebugBuild 32 | ).isFalse() 33 | 34 | assertThat( 35 | VariantInfo( 36 | buildType = "DEBUG", 37 | flavorName = "prod", 38 | variantName = "prodDebug" 39 | ).isDebugBuild 40 | ).isFalse() 41 | } 42 | 43 | @Test 44 | fun `VariantInfo#isReleaseBuild returns true when builtType is 'release'`() { 45 | assertThat( 46 | VariantInfo( 47 | buildType = "release", 48 | flavorName = "", 49 | variantName = "release" 50 | ).isReleaseBuild 51 | ).isTrue() 52 | 53 | assertThat( 54 | VariantInfo( 55 | buildType = "debug", 56 | flavorName = "", 57 | variantName = "debug" 58 | ).isReleaseBuild 59 | ).isFalse() 60 | 61 | assertThat( 62 | VariantInfo( 63 | buildType = null, 64 | flavorName = "", 65 | variantName = "" 66 | ).isReleaseBuild 67 | ).isFalse() 68 | 69 | assertThat( 70 | VariantInfo( 71 | buildType = "RELEASE", 72 | flavorName = "prod", 73 | variantName = "prodRelease" 74 | ).isDebugBuild 75 | ).isFalse() 76 | } 77 | } 78 | --------------------------------------------------------------------------------