Continuous Deployment for iOS

Making our deployment better with continuous improvements

By Hiroki Nagasawa

One of our engineering values at Doist is Continuous Improvement. We apply it in various situations, such as opportunistic refactoring, or performance improvements. Continuing our efforts around Continuous Deployment (CD), in this post, I’ll focus on CD for iOS to ship our apps faster and get continuous feedback from not only customers but also our team.

CD encourages that all the code you merge to the main branch is deployable at any time. We pair it with feature flags , to turn certain functionality on and off remotely without deploying code. Feature flags allow us to work in small batches. This enables small and atomic pull requests, making reviews easier, and reducing lead time.

At Doist, we have 9 iOS developers working on Todoist and Twist . As we’re a relatively small team, we are improving our deployment process to help us focus on impactful things. Plus, CD on iOS requires a different approach as the release process is tightly connected with the App Store and App Store Review.

With this in mind, I’ll share how we approached some of the challenges related to CD. Let’s dive in.

Release cycle

First, let’s talk about our release cycle. Uploading a release build is always triggered from the main branch and is automated as scheduled events. We mainly use GitHub Actions and Fastlane to automate our workflows.

In production, we release our apps every week on the same day of the week via the App Store. Having a fixed release cycle eliminates unnecessary communication regarding releases and fits our asynchronous approach to work.

Internally, we release the apps every day via TestFlight. We’re limited to 100 internal testers as of today, but being able to start testing immediately without needing a review is a huge advantage. We also recommend that Doisters turn on Automatic Updates in the TestFlight app so that they can use the most up-to-date version as soon as it’s available.

Feature flags

We use feature flags to isolate code that we don’t want to release in production yet. For example, the code may contain work in progress, untranslated content, or new features that we plan to release on a specific date. With that, the main branch is always ready to ship at any time, making the release cycle independent from the development process.

When it comes to new features, a new feature will be only available internally first. And then, in the case of Todoist, public beta users will see it if they turn on Experimental Features . Once we improve it based on feedback, we’ll officially release it to everyone via feature flags.

For better internal testing, we provide a debug view to override feature flags locally.

Feature flags debug view

One of the pitfalls that we didn’t initially pay attention to is a flag freeze. When a flag value is updated remotely, a flag value in the app needs to be synced. Flag freeze is to keep the flag value unchanged until future evaluation. It’s needed to prevent unexpected states due to flags being dynamically updated while the app is running. In the worst case, the app will crash. Even though this crash can be seen as a one-time issue, we took it seriously as it can lead to data loss if a user is trying to save something.

One way to avoid that is to keep a flag value consistent until the next launch. This will result in consistent behavior within the same session, for both customers and our team.

Creating a remote feature flag doesn’t come for free as we need to set it up and manage it on a dashboard. In case we need a local feature flag that we can enable in development, we have another mechanism called Debug Feature Flag based on the debug build configuration in our projects.

public struct DebugFeatureFlag {
    private let isEnabledInDebug: Bool

    public init(isEnabled: Bool) {
        isEnabledInDebug = isEnabled
    }

    public var isEnabled: Bool {
        #if DEBUG
        return isEnabledInDebug
        #else
        return false
        #endif
    }
}

This is useful for simple use cases, such as isolating untranslated content, or small features that we won’t release soon. When it’s ready, what we have to do is remove the flag locally.

In a dedicated file, this is how we use it.

if DebugFeatureFlag.myFeatureFlag.isEnabled {
    // Debug code
} else {
    // Production code
}

...

private extension DebugFeatureFlag {
    static let myFeatureFlag = DebugFeatureFlag(isEnabled: true)
}

Another common challenge after adopting feature flags is managing their ownership and expiration. We make it explicit in the code itself, following this format.

// Owner: [Name], When to remove: [Tentative Date]
let xxx = RoxFlag()

In addition to that, we use a monthly check on Twist.

Feature flags monthly check

A monthly reminder using Twist Team check-in Integration

This is not the ideal workflow, but it works well without too much effort. We like starting small first to understand the root problem and trying to apply Continuous Improvement where possible.

Versioning

Before introducing CD, we used Semantic Versioning ([MAJOR].[MINOR].[PATCH]) like most apps in the App Store. At the same time, we struggled to decide when to bump a major or minor version.

Now that most of our new features are released via feature flags, we wondered if Semantic Versioning still made sense to us. Releasing a feature doesn’t require releasing new versions anymore. An automated versioning scheme might suit automated releases better.

Here were some of our goals.

All things considered, we took a simple approach based on time.

For example, if it’s February 2022 and the last tag is release/22.2.6, the next version would be 22.2.7. If it’s February 2022 and the last tag is release/21.12.4, the next version would be 22.2.0.

We also create a tag like release/X.Y.Z for each release. That’s it.

Here is the simplified logic in Fastfile.

lane :bump_version do
  sh('git fetch --tags --quiet')
  latest_version = Gem::Version.new(last_git_tag(pattern: "release/*").split(/\//).last)
  latest_major = latest_version.segments[0]
  latest_minor = latest_version.segments[1]
  latest_patch = latest_version.segments[2]

  if !latest_major || !latest_minor || !latest_patch
    UI.user_error!("Unexpected version number: #{latest_version}")
  end

  year = Date.today.strftime('%y').to_i
  month = Date.today.strftime('%-m').to_i

  major = year
  minor = month
  patch = latest_major != year || latest_minor != month ? 0 : latest_patch.next

  increment_version_number(
    version_number: "#{major}.#{minor}.#{patch}",
    xcodeproj: "Todoist.xcodeproj"
  )
end

When it comes to a build number, it’s generated based on the current time.

lane :bump_build do
  increment_build_number(
    build_number: Time.now.to_i
  )
end

One thing to note is that we don’t check either the version or the build number into source control. Both are generated on the fly during deployment. If CI is unavailable, we can do that locally as well. Not having to commit those changes to our repositories simplifies the deployment process.

Release notes

It is important to get early feedback. Internally, we share concise and easy to understand release notes so that everyone at Doist knows what’s new. To generate them, we don’t rely on pull request titles or commit messages, since this can be time-consuming, or not very meaningful for our team. Instead, we add a changelog to each pull request focusing on behavior rather than technical details, and collect them all using GitHub Actions.

The following is a part of our pull request template.

## Changelog

<!-- Is there anything to include in the changelog? If not, remove this whole section. -->
<!-- For issues from Doist/Issues use "fixes" keyword next to the link, so the status can be updated there automatically. -->

<!--
### user_facing
* Fix description (fixes link)
* Change description (spec link)

### doist
* ...

### dev
* ...
-->

We fill in the information with a reference (user_facing, doist, and dev), depending on the target group.

How do we let our team know about release notes? We send them to Twist. First, we push a git tag after uploading a release build. It triggers a workflow that generates release notes by comparing it with the previous release using actions/github-script . Then, we send it to Twist and publish it as a release on GitHub.

Release notes on Twist

Todoist iOS Internal Releases thread in Doist Releases channel

Release notes on Github
Releases in Todoist iOS repository

For release notes in the App Store, we rely on our changelog team to make them even more user-friendly and give our customers the right level of detail. In the future, we hope to find a more streamlined and automated way.

Syncing translations

We use Transifex as our localization platform at Doist. Syncing translations is automatically done from the main branch. We have a workflow that runs every day. It pushes source files and pulls all translations.

desc "Push source files (en) to transifex."
lane :translations_push do
  sh "pip3 install \"transifex-client>=#{min_transifex_client_version}\" && tx push -s"
end

desc "Pull all translations including unreviewed translations from transifex."
lane :translations_pull do
  sh "pip3 install \"transifex-client>=#{min_transifex_client_version}\" && tx pull -f"
end

desc "Commit updated and new translations to sync."
lane :translations_sync do
  translations_push
  translations_pull

  # Commit new translations
  git_add
  git_commit(path: "*.strings", message: "[fastlane] Pull new strings", allow_nothing_to_commit: true)
  git_commit(path: "*.stringsdict", message: "[fastlane] Pull new stringsdict", allow_nothing_to_commit: true)

  git_commit(path: ".tx/config", message: "[fastlane] Pull new transifex config", allow_nothing_to_commit: true)
end

To import the lanes, we use the import_from_git action in Fastfile.

import_from_git(
  url: "git@github.com:Doist/doist-apple-configs.git",
  dependencies: ["Fastlane/Transifex/Fastfile"],
  ...
)

Lastly, a bot creates a new pull request with this change when needed using peter-evans/create-pull-request .

Pull request to sync translations

After checks pass and a reviewer approves the pull request, it gets merged. The only thing we have to be careful about is not exposing untranslated content to customers by leveraging feature flags as we already mentioned.

Managing metadata and screenshots

If an iOS app supports multiple languages in the App Store, metadata and screenshots can be hard to manage. One way of managing those resources is to use Fastlane deliver .

As it allows specifying paths for metadata and screenshots, there are a few options. The simplest is to directly have them in an app’s repository.

Using a separate repository and adding them as a git submodule is another option. We did this for a long time, but it was painful to keep the submodule up-to-date, even with tools like Dependabot.

With the introduction of CD, updating them became even more challenging. We had to keep track of them with a version tag and to update them before automated deployment. Instead, we wanted to separate metadata and screenshots completely from our deployment process. Having a smooth deployment process was critical.

As a result, we have a separate repository to upload metadata and screenshots to App Store Connect via deliver’s actions.

desc "Upload metadata (without screenshots) to the App Store"
lane :upload_metadata do
  deliver(skip_metadata: false)
end

desc "Upload screenshots (without metadata) to the App Store"
lane :upload_screenshots do
  deliver(
    skip_screenshots: false,
    overwrite_screenshots: true,
  )
end

With this, we don’t update any resources directly in App Store Connect. To edit them, we commit changes to git and upload them via the actions.

Managing provisioning profiles and certificates

If you work with multiple developers, managing provisioning profiles and certificates can be painful. We use Fastlane match to simplify code signing. Following the guide from codesigning.guide , we have a private repository to sync our provisioning profiles and certificates.

To make use of Xcode’s automatic code signing, we rely on it in a local environment, and disable it when uploading a release build.

lane :deploy_production do |options|
  sync_code_signing
  disable_automatic_code_signing(path: "Todoist.xcodeproj")
  build_app
  enable_automatic_code_signing(path: "Todoist.xcodeproj")
  upload_to_app_store
end

We also need to specify the generated match env variable in Provisioning Profiles.

Managing provisioning profiles

The only thing we do manually is to handle expired or revoked certificates. Currently, it’s not possible to automate it easily, so we have a detailed guideline in our internal documentation. It’s not a frequent issue, but we’re looking for better ways to manage it.

Rotating release manager

At Doist, we have a hero role on each team . On the iOS team, the hero is responsible for handling issues and doing the manual work for releasing the apps.

With this approach, we’re getting feedback from different points of view. For example, a less experienced release manager might question some of the established processes and habits. The key to having better CD is to continuously improve based on feedback from the team itself.

Conclusion

In this post, I described our approaches to make our deployment faster and easier as a result of adopting Continuous Deployment. This improves our approach to product development, as it incentivizes us to work in a way that makes us proud and passionate about what we do. Each team is different, but I hope you get some ideas of how to improve your own deployment workflow from this post.