

With a growing mobile team and weekly releases, it’s essential to have a reliable CI pipeline. We need to be able to build and test our apps and deploy them to the App Store and Google Play Store. We also maintain internal preview versions to test the latest features.
Learn how we use ClickUp, Fastlane, and GitHub Actions to automate our Continuous Integration (CI) and Continuous Delivery (CD).
The life of a bug 🐜
Let’s start by quickly going over our process of managing and fixing bugs.
- A bug (or feature request) gets reported and a task is created in ClickUp
- The task is assigned to a developer and fixed in a PR against the staging branch
- The CI runs all tests, builds the app, deploys a web preview, and uploads everything to the ClickUp task
- Our QA team verifies the fix and if it is good, the task is marked as done
- The PR gets merged into staging automatically
- The staging branch gets built and deployed to our internal TestFlight
- Every Wednesday a release branch is created, built, and tested
- On Fridays, we create a release on GitHub and the CI deploys the release to the App Store and Play Store.
A ClickUp task contains everything about the bug. We use Custom Statuses like In Progress or Code Review to keep track of the bug. The CI workflows change the status automatically. Custom Fields contain additional information like who reported the bug, who works on it when it will be released etc.
PR workflow 📜
The first two steps outlined above are not really CI-related, but the third one is interesting…
Our development workflow runs for any PR. It checks lints, formatting and runs all tests before it starts building the Android and iOS artifacts.
name: development
on: pull_request
jobs:
cancel:
name: 'Cancel Previous Runs'
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: styfle/cancel-workflow-action@0.9.1
with:
access_token: ${{ github.token }}
check:
name: Check formatting
runs-on: ubuntu-latest
needs: cancel
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
- name: Setup Flutter
uses: subosito/flutter-action@v1
- name: Install dependencies
run: flutter pub get
- name: Check formatting
run: flutter format --dry-run . --set-exit-if-changed
- name: Check lints
run: flutter analyze
- name: Run tests
run: flutter test
# build, post task message etc.When a build is successful, the CI will post a message in the linked task. QA Engineers can go to the PR, download the build artifacts or use the web preview.

An automated CI message posted in the linked ClickUp task after a successful build
Setting up Flutter on the CI runner 🛠
We use the well-known GitHub action subosito/flutter-action to set up Flutter on the CI. By default, it will install the latest stable Flutter release. To avoid breaking your CI workflows when a new Flutter version is released, you should specify the version manually.
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v1
with:
flutter-version: 2.8.1If you have multiple workflows, it is better to store the flutter version in a file. We use FLUTTER_VERSION in the root of the repository.
steps:
- uses: actions/checkout@v2
- name: Read FLUTTER_VERSION
id: get-version
run: echo "::set-output name=version::$(cat FLUTTER_VERSION)"
- uses: subosito/flutter-action@v1
with:
flutter-version: ${{ steps.get-version.outputs.version }}Another easy solution is to store the Flutter version as GitHub secret and access it using {{ secrets.FLUTER_VERSION }}.
Web Preview 🕸
Thanks to Flutter’s ability to run on the web, we can create a fully functional web preview of pull requests. Using the device_preview package, the device size and settings can be adjusted.
The preview has proven to be very useful and is not only used by our QA team. Designers and Product managers also like it to quickly iterate on new features.

via Flutter
How to create a web preview 🐶
To get started, make sure that your app is compatible with Flutter web—not all APIs are supported.
In our app, for example, we needed to disable push notifications and web sockets.
This sample workflow builds a web preview of your Flutter app and uploads it to an S3 bucket. We use an ENABLE_DEVICE_PREVIEW environment variable to disable the device_preview in production.
build-preview:
name: Build preview
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- uses: actions/checkout@v2
- name: Setup Flutter
uses: subosito/flutter-action@v1
- name: Install dependencies
run: flutter pub get
- name: Build web
run: flutter build web --release --dart-define ENABLE_DEVICE_PREVIEW=true
- name: Fix base href
uses: jacobtomlinson/gha-find-replace@0.1.4
with:
find: '<base href="/">'
replace: '<base href="/flutter-preview-${{ github.head_ref }}/">'
include: build/web/index.html
- name: Deploy to S3
uses: reggionick/s3-deploy@v3
with:
folder: build/web
bucket: yourbucket/flutter-preview-${{ github.head_ref }}
bucket-region: us-west-2
no-cache: trueThe Fix base href step is needed because the preview will not be at the root of the bucket.
And some code to conditionally enable the device_preview.
import 'package:device_preview/device_preview.dart';
const _preview = bool.fromEnvironment('ENABLE_DEVICE_PREVIEW', defaultValue: false);
void main() => runApp(
DevicePreview(
enabled: _preview,
builder: (context) => MyApp(),
),
)Environment variables are a powerful tool and allow Flutter’s tree shaking algorithm to drop debug code for release builds.
Fastlane 💨
Fastlane greatly simplifies building, signing, and deploying Flutter apps. It manages our certificates, provisioning profiles, and other settings. We use GitHub secrets to store passwords and tokens securely.
Useful Fastlane actions:
- fastlane match to create and store iOS keys and profiles in a GitHub repository
- build_app to build iOS and Android apps
- upload_to_testflight and deliver to deploy iOS builds
- upload_to_play_store to deploy Android builds
Sample iOS dev build 🍏
platform :ios do
desc "Create a dev build"
lane :build_dev do
setup_ci
api_key = app_store_connect_api_key(
key_id: ENV["IOS_KEY_ID"],
issuer_id: ENV["IOS_ISSUER_ID"],
key_content: ENV["IOS_APP_STORE_API_KEY"],
)
match(
type: "development",
app_identifier: ["com.example.mobile"],
api_key: api_key,
git_basic_authorization: Base64.strict_encode64(ENV["GH_USER_TOKEN"])
)
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: "development",
export_options: {
method: "development",
compileBitcode: false,
provisioningProfiles: {
"com.example.mobile" => "match Development com.example.mobile"
}
}
)
end
endDon’t forget setup_ci it will save you from weird errors 👾. Learn more about Fastlane for Flutter apps.
Android signing 🔒
The easiest way to sign Android release builds securely is to store the tokens as GitHub secrets and use environment variables and a temporary key.jks created by the CI:
android {
// ...
signingConfigs {
release {
storeFile file('key.jks')
storePassword System.env['ANDROID_STORE_PASSWORD']
keyAlias System.env['ANDROID_KEY_ALIAS']
keyPassword System.env['ANDROID_KEY_PASSWORD']
}
}
}We store the key.jks as base64 encoded string in Github Secrets and decode it in the workflow:
- name: Decode Android Keystore
uses: timheuer/base64-to-file@v1.1
with:
fileName: 'android/app/key.jks'
encodedString: ${{ secrets.ANDROID_STORE_CONTENT }}Release & prod workflow 🚀
The pre-release workflow runs for branches that start with release/v. It strips all debugging and internal code to make sure we test the same code that will be released.
name: pre-release
on:
push:
branches:
- 'release/v*'
# build, test, and post slack messageAdditionally, the pre-release workflow posts to various Slack channels to notify QA and marketing teams about a new release using incoming webhooks.
After everything is tested thoroughly, we create a release on GitHub that triggers the prod workflow. It builds and signs the App with production certificates and sends it to the App Store.
name: prod
on:
release:
types:
- created
# build and deploMore tricks for your CI 🦾
If you use the push trigger for GitHub Actions, you will likely run into problems if there are multiple pushes to the same branch in quick succession. More than one instance of the workflow will start and eat build minutes or cause other problems.
- We recommend using the Cancel Workflow Action to cancel all previous instances of the workflow
- If you are looking for an easy and maintainable solution to generate sequential build numbers, try the Build Number Generator. You can also use the
GITHUB_RUN_IDbut that cannot be customized - Check out the ClickUp GitHub app to see branches, commits, and GitHub status right in your ClickUp tasks. Use ClickUp Automations or ClickUp’s public API for advanced automations
Summary 🍩
Building your CI is a fun process. It’s easy to get started and you can evolve it as you go. Our CI lives after one of ClickUp’s Core Values: Progress towards perfection. We’re constantly working on CI improvements for our QA and engineering teams.
The combination of ClickUp, GitHub Actions, and Fastlane is very powerful and allows building a flexible and fully automated CI/CD pipeline in less than an hour. Give it a try!
We have many cool topics in the pipeline, so keep checking out the ClickUp Engineering blog! 🦄


