@akobor

Programmatic versioning in a KMP mobile app

Cover Image for Programmatic versioning in a KMP mobile app
Adam Kobor
Adam Kobor
| 9 min read

Adjusting version numbers in a Kotlin Multiplatform mobile app can be tiresome and annoying, considering that you have to update the version number in multiple places. On Android, you have to update the versionCode and versionName in the build.gradle(.kts) file, while on iOS you have to update the CFBundleShortVersionString and CFBundleVersion in the Info.plist file. It's easy to see that the problem is not the Android side, because you can easily automate this process with Gradle tasks, but the iOS side is a bit more tricky and the chances are high that you would like to use the same version on both platform, considering that you're developing a cross-platform mobile app.

The goal would be to:

  • calculate the version number and the readable version automatically, based on something that is related to the Git repository
  • do the calculation in our KMP project's build.gradle.kts
  • set the Android and iOS versions directly in there, programmatically, on-the-fly, during the build, to be able to do the same on CI/CD pipelines too

The key points and assumptions:

  • the version number will depend on our Git repository, in this example on the Git tags, which are following the semantic versioning rules (e.g. 1.2.4)
  • we'll use this semantic version for versionName and CFBundleShortVersionString as-is
  • we'll generate the versionCode and CFBundleVersion based on the semantic version, by transforming it to a monotonically increasing number, that fulfills Android's and iOS's requirements regarding the version code

The problem on iOS

First of all the Info.plist file is practically a text file, and it's checked in. Everything that could be changed in there is changed by XCode most of the time (of course, you can do it manually, but it won't help us in this case), so you can't really automate this process with Gradle tasks, or it would be ugly as hell. There are tools like PlistBuddy, that can change the content of a Info.plist file, but it's not the most convenient stuff to use with Gradle either.

.xcconfig to the rescue?

Using a .xcconfig file is a common practice in the XCode universe (however its documentation is annoyingly bad and incomplete), because it's a simple text file that can be used to store build settings that are common across multiple targets. You can include these files in your XCode project, and you can reference them in your Info.plist file. This way you can easily change the version number in one place, and it will be reflected in your Info.plist file.

For example:

// Config.xcconfig
BUNDLE_SHORT_VERSION_STRING = 1.0.0
BUNDLE_VERSION = 1

// Info.plist
<key>CFBundleShortVersionString</key>
<string>$(BUNDLE_SHORT_VERSION_STRING)</string>
<key>CFBundleVersion</key>
<string>$(BUNDLE_VERSION)</string>

That's nice, but there might be a problem with this approach in our case: if that's your "default" or "base" .xcconfig file, then it probably already contains some other settings that are not related to the version number, and you don't want to manage them every time when you update the versions.

You could say that "OK, let's create then a new .xcconfig file with Gradle and define the version numbers in there", but it's not that easy, because you have to reference this file in your XCode project, and if the file is not there yet, you'll have a chicken-egg problem during the build. So the other problem with generating a file like this is the "when?", but fortunately it's easy to solve. Let's see how!

The solution

Leveraging the #include directive in .xcconfig files

The #include directive in .xcconfig files is a powerful tool, because it allows you to include another .xcconfig file in the current one. This means that you can create a "base" .xcconfig file that contains the common settings, and you can create a "version" .xcconfig file that contains only the version numbers, and the latter shouldn't even need to be referenced by your project, because it will be included in the "base" file indirectly. In our case the base file is the Config.xcconfig file, and the version file is the Versions.xcconfig file and they are in the same folder in our example.

// Config.xcconfig
#include? "Versions.xcconfig"

OTHER_SETTING=SomeOtherSetting
ANOTHER_ONE_AGAIN=AnotherOne

The trick above is that we use a ? mark after the #include directive, which means that the file is optional, and if it's not there, the build won't fail, it will just ignore the #include directive. The content of the Versions.xcconfig file is not relevant at this point, we'll generate it via Gradle. You should also add it to your .gitignore file, because it's generated during the build every time, and it should be only ephemeral between different building environments.

The Gradle part

Now in your build.gradle.kts you have to take care of the following things:

  1. Reading (or setting) the semantic version (probably from the Git tags). Of course, you can simply just hard-code your semantic version in there, and change it every time you would like to release a new version of your app, but if you're anyway using tags in Git, it's easy to automate this process too. In this example we'll use the lovely-gradle-plugin that sets the Gradle project's version based on the latest Git tag.
  2. Sanitizing the semantic version because it could be the case that your repository is in a dirty state locally (or even on CI, because the iOS code signing there usually means that the project's config files are modified temporarily to add a new provisioning profile or something like that), and you don't want to use a "dirty" (i.e. not something like "1.2.3") version for your app.
  3. Calculating the numeric version code from the semantic version.
  4. Generating the Versions.xcconfig file with the calculated version parts.
// build.gradle.kts

// Initialize the lovely-gradle-plugin
lovely {
    gitProject()
}

// Setting the Android versions
android {
    // ...
    defaultConfig {
        // ...
        versionCode = getNumericAppVersion()
        versionName = getAppVersionString()
    }
}

/**
 * Returns the readable version name of the app, which always contains the major, minor, and patch
 * version numbers. E.g. "1.2.34".
 */
private fun getAppVersionString(): String {
    val (major,minor,patch) =  getSanitizedVersionParts()
    return "$major.$minor.$patch"
}

/**
 * Returns the major, minor, and patch version numbers of the app. The version numbers are taken
 * from the root project's version, which is transitively set by the lovely-gradle-plugin, based on
 * the git tags.
 * We only care about the latest tag, and nothing else, that's why we take the first three parts of version only, but
 * it's for the sake of this example, you can modify it to fit your needs.
 */
private fun getSanitizedVersionParts(): Triple<Int, Int, Int> {
    // Strip down everything after the first dash, e.g. "1.2.34-11" -> "1.2.34"
    // In case it's a multi-module project, you should probably use "rootProject" instead of "project"
    val sanitizedVersionString = project.version.toString().let { appVersionString ->
        appVersionString.indexOfOrNull('-')?.let { indexOfFirstDash ->
            appVersionString.substring(0, indexOfFirstDash)
        } ?: appVersionString
    }
    // Split the version string into major, minor, and patch numbers and ignore non-numeric parts,
    // e.g. "1.2.34.dirty" -> [1, 2, 34]
    return sanitizedVersionString
        .split('.')
        .take(3)
        .map { it.toInt() }
        .let { Triple(it[0], it[1], it[2]) }
}


/**
 * Generates a numeric version code from the (version) string.
 * Returns a monotonic increasing number, that fulfills the requirements of the version code on both
 * platforms.
 */
private fun getNumericAppVersion(): Int {
    val (major, minor, patch) = getSanitizedVersionParts()
    return major * 1_000_000 + minor * 1_000 + patch
}

/**
 * Generates a Xcode configuration file with the version code and version name. The file is used by
 * the Xcode project to set the version code and version name in the Info.plist file. Should be invoked as a
 * pre-action script inside your .xcscheme file.
 */
tasks.register("bootstrapXcodeVersionConfig") {
    // Point this to the directory where your Config.xccconfig file is
    val configFile = file(project.rootDir.toString() + "/iosApp/Configuration/Versions.xcconfig")
    outputs.file(configFile)
    val content = """
        BUNDLE_VERSION=${getNumericAppVersion()}
        BUNDLE_SHORT_VERSION_STRING=${getAppVersionString()}
    """.trimIndent()

    outputs.upToDateWhen {
        configFile.takeIf { it.exists() }?.readText() == content
    }
    doLast {
        configFile.writeText(content)
    }
}

If you run the bootstrapXcodeVersionConfig Gradle task, you should see your new file generated:

// Versions.xcconfig
BUNDLE_VERSION=3001000
BUNDLE_SHORT_VERSION_STRING=3.1.0

Invoking the Gradle task before the XCode build

OK, but how can you make sure that this task is executed before the XCode build? Well, you can add a pre-action script to your .xcscheme file, that will invoke the Gradle task before the build. You can do this manually, by editing an already existing and used .xcscheme file, but XCode tends to mess things up if you change a scheme file manually in an existing project, so it might be better to do it via XCode's UI. Also, after this change, the other collaborators might need to do a completely new checkout of the project, because XCode can't really handle the changes in the .xcscheme file properly in certain cases (based on my experience, some kind of heavy caching is happening in the background).

It should look like this on the UI:

XCode pre-action script

And like this in your .xcscheme file:

<?xml version="1.0" encoding="UTF-8"?>
<Scheme>
<!--...-->
   <BuildAction>
   <!--...-->
      <PreActions>
         <ExecutionAction
            ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
            <ActionContent
               title = "Generate Versions.xcconfig"
               scriptText = "cd &quot;$SRCROOT/..&quot;&#10;./gradlew :composeApp:bootstrapXcodeVersionConfig&#10;">
               <EnvironmentBuildable>
                  <BuildableReference>
                    <!--...-->
                  </BuildableReference>
               </EnvironmentBuildable>
            </ActionContent>
         </ExecutionAction>
      </PreActions>
      <BuildActionEntries>
      <!--...-->
      </BuildActionEntries>
   </BuildAction>
<!--...-->
</Scheme>

Wiring everything together in Info.plist

Now you can say the following in your Info.plist:

// Info.plist
<key>CFBundleShortVersionString</key>
<string>$(BUNDLE_SHORT_VERSION_STRING)</string>
<key>CFBundleVersion</key>
<string>$(BUNDLE_VERSION)</string>

And that's it! You can execute your builds now and the versions will be set to the correct values automatically, on both platforms, you don't need to change them manually anymore.

Possible caveat

If you're on a CI/CD platform, make sure that the step when you're fetching your repository is also fetching the tags and it's not a shallow clone, because the lovely-gradle-plugin uses git describe under the hood, and it needs the tags to work properly.


Comments