Easier iOS App Versioning with Git

Posted by Grego on April 6, 2017

Tracking which version of your app you are working with can be difficult, especially when it leaves development (source code) and moves on to testers or end-users via the App Store (binary only). You may need to revisit older versions of your app for regression testing or bug stomping.

Many workflows use Git tags to simplify tracking code revisions. Before submitting to the App Store you add the tag submission/1.1 for version 1.1 of your app. Once the submission is approved and released, the tag is renamed to app-store/1.1.

This can be simplified by instead (or additionally) adding the current git revision to your builds with a simple shell script and some swift.

This guide uses Swift, but Objective-C should work just as easily

First, we need to retrieve the hash for the current commit, which can be done on the command line:

git rev-parse HEAD

Now we need to integrate it into our app. What I’ve done is created a bash script and a code template and added them to my build process in Xcode.

Recently I've been creating a strings file on iOS. Similar to, but different from, the Android Strings.xml files. In my case the file is pure Swift. For those unfamiliar with Android development, this file is where I would put all the user-facing strings, most of which need translations later.

Set Up Structure

I create a directory called Localization to store my strings files. The directory structure looks like this:

Localization
├── Strings.swift
├── generated
│   └── Strings+Git.swift
└── src
    ├── Strings+Git.swift.tpl
    └── make-git-strings.sh

2 directories, 4 files

The template file (.tpl) and build script go in the src subdirectory, while the output goes to the generated subdirectory. I never add the generated swift file, but I do add the empty generated directory to git.

Create Code Template

My template file is an extension of an enum I already have called kStrings. Create the base enum Strings.swift:

import Foundation

enum kStrings {

}

Create the git extension as a template, Strings+Git.swift.tpl

// GIT: $GIT_HASH
// vim: ft=swift
import Foundation

/**
 * Git information extension for kStrings,
 * Auto-generated by `Strings+Git.swift.tpl`.
 * Do NOT Manually edit this extension
 */
extension kStrings {
  enum Git {
    enum Hash {
      static let full = "$GIT_HASH"
      static let short = full.substring(to: toIndex)

      private static let toIndex = full.index(full.startIndex, offsetBy: 7)
    }
  }
}

Create Bash Script

Then the make-git-strings.sh script can be as simple as:

GIT_HASH=$(git rev-parse HEAD)
FULL_PATH=$(dirname $0)
IN_FILE="$FULL_PATH/Strings+Git.swift.tpl"
OUT_FILE="$FULL_PATH/../generated/Strings+Git.swift"

rm -f $OUT_FILE
sed s/\$GIT_HASH/$GIT_HASH/g $IN_FILE > $OUT_FILE

We must run this script once before we are able to add our Strings+Git.swift.

chmod u+x make-git-strings.sh
./make-git-strings.sh

Make sure to add the generated Strings+Git.swift file to your .gitignore before adding it to your project, or you may accidentally commit the file to git. This file should not be committed since we want to make sure it is generated and updated with the right information each time. Instead, commit the template file and make any changes there.

Add Generated Files to Project

Add the generated Strings+Git.swift to your project.

Next, we add this shell script to our Xcode build process. This will make sure our build always has the latest git information in the code.

Go to your Project, then Build Phases and click the + to add a new Run Script Phase. In the script editor, enter the path to the bash script, in my case it looks like this:

${SRCROOT}/GitDemoApp/src/Localization/src/make-git-strings.sh

Change the path relative to $SRCROOT to find the right spot. The order here is also important. Make sure this step appears before the Compile Sources step, since it’s generating one of those sources we want to compile.

Usage Example

Use kStrings.Git.Hash.full to get the full SHA-1 hash, or kStrings.Git.Hash.short to get a truncated (7-character) version of the hash.

Here’s an example view controller that shows both the full hash and short hash of the current git commit in a label:

import UIKit

class AboutViewController: UIViewController {
  // MARK: - View Properties {{{
  private lazy var aboutLabel: UILabel! = {
    let label = UILabel(frame: .zero)
    label.text = "Git Hash\n\(kStrings.Git.Hash.full)\n\n"
      + "Short Hash\n\(kStrings.Git.Hash.short)"
    label.numberOfLines = 0
    label.textAlignment = .center
    label.sizeToFit()

    return label
  }()
  // }}} View Properties

  // MARK: - Life Cycle Methods {{{
  override func viewDidLoad() {
    super.viewDidLoad()

    view.backgroundColor = .white
    view.addSubview(aboutLabel)

    initConstraints()
  }

  private func initConstraints() {
    aboutLabel.translatesAutoresizingMaskIntoConstraints = false

    let constraints = [
      aboutLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
      aboutLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor)
    ]

    NSLayoutConstraint.activate(constraints)
  }
  // }}} Life Cycle Methods
}

Improving the Script

To reduce any source of error when building a production (release) build to make sure I have all the latest changes committed, I enhance the script to check for a dirty working tree, and return a non-zero value to stop the build from continuing.

My builds have an extra Custom Build Setting called OBF_ENVIRONMENT which tells me which environment the app is intended for. If the OBF_ENVIRONMENT is PRODUCTION and the source tree is dirty, I cancel the build.

There is also an option to force it anyway by adding --force to the end of the command line in case we need to create a release version for testing bugs so we don’t have to commit changes constantly.

My enhance script looks like this:

GIT_HASH=$(git rev-parse HEAD)
FULL_PATH=$(dirname $0)
IN_FILE="$FULL_PATH/Strings+Git.swift.tpl"
OUT_FILE="$FULL_PATH/../generated/Strings+Git.swift"

TRUE=0
FALSE=1

function isDirty() {
    changeOutput=$(git diff --shortstat 2> /dev/null | tail -n1)
    if [[ -n "$changeOutput" ]]; then
        # if output not null, it's dirty
        echo "Git Dirty!"
        return $TRUE
    else
        echo "Git Clean!"
        return $FALSE
    fi
}

if [[ $1 == "--force" ]]; then
    echo "Should Force!"
    shouldForce=$TRUE
else
    echo "No Force!"
    shouldForce=$FALSE
fi

if [[ $OBF_ENVIRONMENT == "PRODUCTION" ]] && isDirty \
        && [[ $shouldForce == $FALSE ]]; then
    echo "Error: Attempted to produce production build with dirty working tree."
    echo "Could not generate git hash strings file extension."
    echo "Please commit your working copy and try again."
    exit 1
fi

rm -f $OUT_FILE
sed s/\$GIT_HASH/$GIT_HASH/g $IN_FILE > $OUT_FILE

Conclusion

This technique is mostly for developer use. Apple still requires the version numbers of your app and build numbers to increment each time you submit to the App Store. So we still can’t replace a version number with the git hash completely. The git hash really shines just for figuring out exactly which git commit of the code you are looking at so you can check it out again.