Continuous Deployment for iOS
Making our deployment better with continuous improvements
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.

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.

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.
- Keep the same format like
[X].[Y].[Z]
- Keep the version as simple as possible
- Increment the version for each release
- Automate versioning
All things considered, we took a simple approach based on time.
- X: The last two figures of the current year
- Y: The current month
- Z: An incremental number that is bumped on each release and reset at the start of a month
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.

Todoist iOS Internal Releases thread in Doist Releases channel

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 .

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.

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.