Using Fastlane to archive and upload iOS builds

Pushing out a new version of your app is exciting, especially when it includes a big new feature. But I've always found releasing new versions of my app to be a huge pain. It's fiddly, it involves lots of GUI that I wouldn't otherwise interact with, and there's lots of waiting around in-between steps.

I recently took the plunge and set up Fastlane to make this process simpler, easier, and faster. It's been a rocky road to get Fastlane working, and I keep hitting snags in using it, but when it does work, I love it.

I'd never interacted with Xcode via the command line before using Fastlane, so if you have done, it might be a lot easier for you to figure out. But in case you're put off by the idea of using Fastlane, or you're stuck on something that's going wrong and you've ended up here via search, I hope sharing some of the things that confused me or tripped me up along the way might be helpful to you.

I'm not going to explain every bit of Fastlane, as the docs and other blog posts will help with that. I just want to point out some of the things that weren't easy or obvious for me.

Fastlane uses a fastfile, which is just a Ruby file that defines the steps Fastlane should work through. You can have different "lanes" in your Fastfile, such as a beta lane and a release lane, each with different steps or parameters. beta and release are the two lanes I have, so I'll work through these two and point out what's been tricky so far in getting them set up.

Here's what my beta lane looks like (with identifying info removed):

desc "Submit a new Beta Build to Apple TestFlight"
  desc "This will also make sure the profile is up to date"
  lane :beta do

    ensure_git_status_clean

    increment_build_number(
      build_number: Time.now.to_i
    )
    increment_version_number
    build_number = get_build_number
    version_number = get_version_number
    changelog = read_changelog

    sync_code_signing(type: "appstore",
      app_identifier: [bundle ID here, e.g. co.companyName.productName])

    build_app(scheme: [app scheme name here],
      clean: true,
      export_method: "app-store",)

    upload_to_testflight(wait_for_uploaded_build: true,
                            changelog: changelog,
                            distribute_external: true)

    stamp_changelog(section_identifier: "Version #{version_number}, Build #{build_number}") # Stamp Unreleased section with newly released build number

    clean_build_artifacts

    # commit to git the changes from bumping version number
    commit_version_bump(message: "Committed version bump",
                        include: "CHANGELOG.md")
  end

The fastfile that was created for me when I set up Fastlane had some of these steps already included, so that helped me get started.

The very first step is ensure_git_status_clean. It's a good idea, obviously, to make sure you've committed your changes before archiving a new build. But this step is also necessary in my case because of my last step: commit_version_bump. This last step makes a commit for me after bumping the version and build numbers and uploading my new build, so that my git status is once again clean before I start on new work. But this final step has clear expectations on which files should be changed by a version bump (the Xcode project file and the plist), and if any other files have changes, this step will fail. So making sure my git status is clean from the beginning helps me avoid failures at the end of the lane.

The step increment_build_number uses Time.now.to_i, which is something I picked up in a blog post, I think. It just uses time passed in seconds to ensure that my build number is always higher than the previous one, which is a requirement for uploading the build to iTunesConnect.

My changelog = read_changelog step comes from a Fastlane plug-in I use. You can find it on GitHub here. With this plug-in, I can keep a Markdown file in my app project called CHANGELOG.md and add all new changes (in the format defined by Keep a CHANGELOG) under the heading "Unreleased". The plug-in finds that file, reads the unreleased section, and uses that for my release notes when uploading the build. The stamp_changelog step near the end of my lane updates my CHANGELOG.md file, moving everything from the heading "Unreleased" under a new heading created with the parameters I pass to this step. In my case, I pass these parameters: stamp_changelog(section_identifier: "Version #{version_number}, Build #{build_number}" and end up with a new heading in my changelog that looks like this: ## [Version 111.7.9, Build 1517198901] - 2018-01-29. The "Unreleased" section becomes empty again, and I can fill it up with new work.

The step sync_code_signing caused me a lot of headaches. Fastlane is made up of a lot of useful tools put together. One of those tools is match, which handles this code signing process. The Fastlane docs recommend using match, but its main benefit seems to be that it's helpful for teams of more than one person who need to keep their code signing details in-sync across computers.

Since I work alone on a single machine, it didn't seem worth the hassle of using match initially. I used another option for code signing, but I found I kept running into a problem where new provisioning profiles were created for my app every time I uploaded a new build. While it worked, it was annoying and obviously not optimal to create a new profile every time, rather than using an existing one. Eventually I went to the effort of setting up match, and so far it seems to do its job well. When I first set it up I had a lot of trouble with certificates not matching up. If you go this route, I'd suggest following the Fastlane docs that recommend using the nuke command to get rid of all existing certificates and profiles and starting over. Also check your local keychain and iTunesConnect for any duplicate certificates that could be causing issues.

I also got tripped up for a while on the type parameter for this step. The docs have only this to say about the type parameter: Define the profile type, can be appstore, adhoc, development, enterprise. I had no idea what this parameter actually did, or which of the values I should use. Since I was trying to upload my build to TestFlight when I initially tried to set up match, I set this parameter to be development. It turns out, even for sending a build to TestFlight, I needed to use appstore for this parameter. This was one of many cases where more detailed docs could have helped me avoid a lot of trouble, since I spent a long time scouring the docs, but more detail about what this parameter is and how to choose its value is either not there, or very hard to find.

The step build_app is straightforward. Although I've hit errors during this step sometimes, I don't think it's ever been related to how this step is actually set up in my Fastfile. It just happens to be the step where problems with code signing settings in Xcode and the like tend to show themselves.

upload_to_testflight is the step where the build is actually sent to TestFlight. I've set wait_for_uploaded_build to true, so that Fastlane hangs in Terminal until the build has finished processing. That means I don't have to wait around wondering and checking if the build has finished processing—I know that when Fastlane finishes, the build is ready to be used in iTunesConnect. I also set distribute_external to true, which means Fastlane automatically submits the build to TestFlight review for me. So I don't have to do anything! Once Fastlane finishes running my beta lane, my build is submitted and waiting for review. It's way easier and simpler than handling all that with a combo of Xcode, Application Loader (Xcode started failing to upload my builds months ago and I couldn't figure out why, so I switched to Application Loader, which never fails), and iTunesConnect.

I also have a parameter in this step called changelog, where I pass in the value from the read_changelog step—i.e. everything from my CHANGELOG.md file under the "Unreleased" heading. Here's a tip: I found it very difficult to understand the errors I kept getting early on about not submitting any release notes, and spent hours scouring the Fastlane docs and GitHub issues. It turns out, this changelog parameter is required when uploading to iTunesConnect (at least for TestFlight), but I thought it was optional so I was omitting it initially. Once I added the changelog plug-in (you don't have to do this, but I found it the easiest way to create release notes) and set it up to send my changelog updates through as the release notes for iTunesConnect, I stopped getting errors when trying to upload to TestFlight.

Just before committing my version bump is the step clean_build_artifacts. This just cleans up anything that was changed as part of the build and upload process, so that when you get to the commit_version_bump step, only the files Fastlane expects to have been changed have changes, and that step will succeed.

In the commit_version_bump step, you can see that one of my parameters is include, and I'm passing in my changelog file. This is because the previous step stamp_changelog, which I explained earlier, changes my changelog file by creating a new header and moving everything from "Unreleased" to the new version header. My commit_version_bump step kept failing because the changelog file was changed and it didn't expect that. The include parameter lets me tell this step to expect this extra file to also be changed, and to commit it as part of the version bump commit.

Phew! That's it for my beta lane. My release lane is mostly the same, but the one difference I want to point out is in the read_changelog step. I wanted to keep two different changelog files in my project: one for my beta users, and one for the App Store. TestFlight users often get several builds for a new feature, with each build fixing bugs they've reported, or adjusting the behaviour. But App Store users will usually only see the final build after all that beta testing, so their release notes are more succinct and focus on the new features being released.

The changelog plug-in I use lets you determine the path where your changelog file is kept, so I keep a separate changelog file for App Store releases, and pass the path in like this: changelog = read_changelog(changelog_path: './release_CHANGELOG.md').

Hopefully that helps you avoid some of the mistakes I've run into. I've found Fastlane to be alternately a huge headache and a huge relief. If you can be bothered to work through the confusing issues it can cause, and you hate creating new releases of your app as much as I do, getting Fastlane set up is definitely worth it.