Thursday, July 14, 2011

Auto-Incrementing Build Numbers for Release Builds in Xcode

I use the wonderful Test Flight to distribute builds. One thing that Test Flight is a little picky about is build numbers. When you upload a build, it uses the build number to see if you're uploading a replacement or a new build. It will let you create a new build even if you don't remember to increment the build number, but it's an extra manual step, and then you end up with two builds with the same build number.

Because I'm forgetful, I wanted to automated this process. Basically, I wanted to increment the version short string any time we do an Archive and increment the bundle build ID any time we do a Release configuration build, but leave the version numbers alone on Debug builds.

Unfortunately, several of our projects are ones that we inherited or took over, so not every project uses the same version numbering scheme. How we increment 1.0b5 is different from how we increment 1.0.12, or a simple build number like 1058.

The way I deal with this is a Run Script Build Phase in my application's executable target that runs the following Ruby script (make sure you set the "shell" field to /usr/bin/ruby, and make sure the script is the last build phase in the application). Feel free to use this script if you wish and modify it to meet your needs. If you improve it, I'd be glad to incorporate improvements back into it. One item of note: the way that I differentiate between Archive builds and other Release configuration builds might be a bit fragile since I'm relying on an undocumented naming pattern in an environment variable.

Note: I'm aware of agvtool. I avoided it for two reasons. First, I wanted more control over the numbering schemes, and second, I tried using agvtool in a build script a few years back, but at that time, there were issues when you bumped the version numbers of a project that was currently open. Those issues may have been resolved, but I didn't want to fight that battle again.

def get_file_as_string(filename)
    data = ''
    f = File.open(filename, "r") 
    f.each_line do |line|
        data += line
    end
    return data
end


def handle_alpha_beta(old_value, letter, infoplist, start_of_value, end_of_value)
    parts = old_value.split(letter)
    version_num = parts[0]
    alpha_num = parts[1].to_i

    alpha_num = alpha_num + 1
    new_version = version_num.to_s + letter + alpha_num.to_s
    print "Assigning new version: " + new_version + "\n"
    new_key = "<string>#{new_version}</string>"

    part_1 = infoplist[0, start_of_value - '<string>'.length];
    part_2 = new_key 
    part_3 = infoplist[end_of_value + "</string>".length, infoplist.length - (end_of_value - start_of_value + (new_key.length - 1))]    

    new_info_plist = part_1 + part_2 + part_3
    new_info_plist
end

def find_and_increment_version_number_with_key(key, infoplist)

    start_of_key = infoplist.index(key)
    start_of_value = infoplist.index("<string>", start_of_key) + "<string>".length
    end_of_value = infoplist.index("</string>", start_of_value)
    old_value = infoplist[start_of_value, end_of_value - start_of_value]

    print "Old version for " + key + ": " + old_value + "\n"
    print old_value.class.to_s + "\n"
    old_value_int = old_value.to_i
    print old_value_int.class.to_s + "\n"
    if (old_value.index("a") != nil) # alpha
        infoplist = handle_alpha_beta(old_value, "a", infoplist, start_of_value, end_of_value)
    elsif (old_value.index("b") != nil) # beta
        infoplist = handle_alpha_beta(old_value, "b", infoplist, start_of_value, end_of_value)
    elsif (old_value.index(".") != nil) # release dot version
        parts = old_value.split(".")
        last_part = parts.last.to_i
        last_part = last_part + 1
        parts.delete(parts.last)

        new_version = ""
        first = true
        parts.each do |one_part|
            if (first)
                first = false
            else
                new_version = new_version + "."
            end
            new_version = new_version + one_part
        end
        new_version = new_version.to_s + "." + last_part.to_s
        print "New version: " + new_version.to_s + "\n"
        new_key = "<string>#{new_version}</string>"
        infoplist = "#{infoplist[0, start_of_value - '<string>'.length]}#{new_key}#{infoplist[end_of_value + '</string>'.length, infoplist.length - (end_of_value+1)]}"
    elsif (old_value.to_i != nil) # straight integer build number
        new_version = old_value.to_i + 1
        print "New version: " + new_version.to_s + "\n"
        new_key = "<string>#{new_version}</string>"

        part_1 = infoplist[0, start_of_value - '<string>'.length]
        part_2 = new_key
        part_3 = infoplist[end_of_value + "</string>".length, infoplist.length - (end_of_value+1)]
        infoplist = part_1 + part_2 + part_3
    end 
    infoplist
end



config = ENV['CONFIGURATION'].upcase
config_build_dir = ENV['CONFIGURATION_BUILD_DIR']

archive_action = false
if (config_build_dir.include?("ArchiveIntermediates"))
    archive_action = true
end

print "Archive: " + archive_action.to_s + "\n"


print config

if (config == "RELEASE")
    print " incrementing build numbers\n"
    project_dir = ENV['PROJECT_DIR']
    infoplist_file = ENV['INFOPLIST_FILE']
    plist_filename = "#{project_dir}/#{infoplist_file}"

    infoplist = get_file_as_string(plist_filename)
    infoplist = find_and_increment_version_number_with_key("CFBundleVersion", infoplist)
    if (archive_action)
        infoplist = find_and_increment_version_number_with_key("CFBundleShortVersionString", infoplist)
    end
    File.open(plist_filename, 'w') {|f| f.write(infoplist) }
else
    print " not incrementing build numbers"
end




7 comments:

Aviel said...

Was about to implement one myself, for the same reason! THANK YOU!

Goldfrapp said...

Thanks for that. Spent a few hours testing the Apple-generic(agvtool) and this. This obviously offers more flexibility.
I customized it so the ShortVersionString gets incremented every time I build a release (rarely) and the Version gets incremented no matter whether it's for Release or Debug.

Matt Mathias said...

Jeff,

This is awesome. I use TestFlight as well and build numbers have become a bane of mine. This script is exactly what I was looking for!

P.S.
I love your blog and have gotten a ton of use from it. If your looking for a volunteer to help maintain a github repo with your code snips and scripts please consider me!

Matt said...

Jeff, as always, your posts are interesting and useful. I appreciate the effort you put into your blog. As you probably know there are a gazillion articles/approaches about auto-incrementing build numbers in iOS projects. But given the hight quality of your writing, I thought I'd start with your approach.

I have a question, a comment and a bug fix.

Bug fix first: Version numbers with matching parts (e.g. 1.1, 2.2.2, 3.0.3) get mangled (becoming .2, .3, 0.4 respectively). The problem lies in the line: parts.delete(parts.last). Replacing it with parts = parts[0, parts.length - 1] should do the trick.

The comment: one problem with the current way of dealing with configurations is that in Xcode 4, Profile builds use the Release configuration. So whenever you exercise your code under Instruments, you'll bump your version number. I'm still trying to wrap my head around Xcode 4 Schemes, so I'm not yet sure what a good solution is.

Now for the question. This is a minor point, but I want to verify whether I understand the build process. In a sense, this script only affects the next build, not the current one. Looking at the logs I see that the run script build phase occurs after ProcessInfoPlistFile. Is there any reason you wouldn't want to run this script as a pre-build action (again, Xcode 4 Schemes)?

Jeff LaMarche said...

Matt:

I'm honestly a little confused about why

parts.delete(parts.last)

and

parts = parts[0, parts.length - 1]

would do different things, but appreciate the fix. My Ruby is a bit rusty, not having done anything of any complexity in three or four years, but those seem like they say to do the same thing...

Yes, the internal build number, but not the marketing build number, gets incremented when you do a non-Archive release build. That's the functionality we wanted here, but you're welcome to change it.

And yes, this bumps the number AFTER building, which (again) is the functionality we wanted for MartianCraft. You can move the build-phase earlier in the process if you want to bump the number before doing the build. I think this is a six-of-one-half-dozen-of-the-other situation. I prefer knowing that my next build will have the number in my Info.plist now. Fortunately, it's simple enough to change if you prefer it the other way.

Jeff

Matt said...

Jeff,

Sorry if this is a dupe, but it's been a couple of days so it's possible I only imagined sending this:

parts.delete(parts.last) is not deleting based on index. It deletes all array entries equal to the entry in the last slot of parts. In other words it works like removeObject: on NSMutableArray, rather than what you want: removeObjectAtIndex:

By and large I agree with your preference about relating the build to the current number in the plist. Mostly I was wondering about the build pre-action because I'm trying to understand schemes in Xcode 4.

Dirk de Kok said...

A very neat way of incrementing build number. I was thinking about integration with a versioning system, i.e. a tag on a SVN repository and let this tag be the same as your build number