Dynamic Android dependency versions done right

Lately I have been reading blog posts on how dynamic Android dependency versions do more harm than good. While it is true that having deterministic builds are extremely important, that does not mean you should throw away all dynamic versioning.

First an intro to app versioning conventions

First I would like to point out how most Android libraries version themselves. In a typical build.gradle, you will see something like this:

dependencies {
	compile 'com.squareup.retrofit:retrofit:1.9.2'
}

This means pull in the version of retrofit where the major version is 1, the minor version is 9, and the patch version is 2.

dependencies {
	compile 'your.namespace:library:major.minor.patch'
}

When do you change the version number?

For the most part, there are conventions that define when to increment each version component. Here are the rules according to semantic versioning.

  1. Major Increment - When a library makes breaking changes. This likely means that if you pull in a new version, your whole build will break. An example of this would be removing an API method that existed in the previous version.
  2. Minor Increment - When a library adds significant new functionality without breaking backwards compatibility. An example would be adding new API methods. Your app can continue functioning the way it always has without needing to use any new features.
  3. Patch Increment - When a library fixes bugs. This is the most common type of version increment. There are no breaking changes and no new features, only bug fixes. Ideally you would always want the latest patch version in your build.

So why don’t you always want the latest?

If you really really wanted the latest always, you could just place a wildcard + in place of any version number like this:

dependencies {
	compile 'com.squareup.retrofit:retrofit:+'
}

This is a bad idea, because if Square decides to release a new version with a major api bump, your build will be broken. Even if it worked flawlessly on 1.9.2.

OK, so can’t you just set the major version and grab the latest minor/patch version?

This can be accomplished by pinning the major version, and leaving either the patch version or both the patch and minor version as a wildcard.

dependencies {
	compile 'com.squareup.retrofit:retrofit:1.+'
}

Even though this should theoretically be safe, everyone makes mistakes. Including library authors. Deterministic builds are very important. In order to make your life easier, you want to make sure that the only changes that occur between your builds are the changes you are making to your app.

So why not pin the exact version (major, minor and patch)?

You can definitely do this, but there are some downsides

  1. You wont pull in bug fixes until you manually change the patch version. You may not even be aware of a critical bug in a library until you happen to read about it or your users report a crash
  2. Manually updating dependencies is tedious. You need to go through each dependency and specify the exact new version you would like to pull in.
  3. Its not easy to find out what the latest version of a library is. There are some plugins that can query JCenter or MavenCentral for you, but it is another step to take in addition to the manual version updates.

How to have both repeatable builds and dynamic dependency versioning!

Using the awesome Dependency Lock Plugin by Netflix, you can have both deterministic builds and dynamic dependency versioning.

With the dependency lock plugin, you decide when to update to the latest version of your dependencies by running a gradle command. Until you do this, every single build will use the exact same version of each dependency

Specifying dynamic dependency versions

With the plugin, it is safe to pin only the major version of a library.

dependencies {
	compile 'com.squareup.retrofit:retrofit:1.+'
}

When you are ready to update your dependencies to the latest versions that meet your criteria, you simply run

./gradlew --refresh-dependencies generateLock saveLock

This generates a dependencies.lock file that defines the exact versions of each dependency in your application.

{
  "com.squareup.retrofit:retrofit": { "locked": "1.9.2", "requested": "1.+" }
}

How does this work with releases?

A big benefit to the dependencies.lock file is that it is included in source control. So in our workflow, right at the beginning of a development cycle we do the following.

  1. Run ./gradlew --refresh-dependencies generateLock saveLock on your master branch to get the latest dependency versions.
  2. Develop your new features or bug fixes with the latest dependencies pulled in. This will allow you to find any issues with the new dependency versions well before release.

So now, if months down the line, you need to reproduce a bug in a specific release, you can simply do a git checkout release-branch-version, and you can build with the exact versions of the dependencies you shipped with.

Summary

Don’t be afraid of dynamic dependency versioning. Just make sure to use the dependency lock plugin to give you deterministic builds.

Finally, make sure that you create release branches or tags and commit your dependencies.lock file. This way you can always go back and build your app as it was released.