Automated Deployment of a Flutter App to Testers and App Stores (Android & iOS) Using GitHub Actions
⚠️ This article reflects the setup and tools as they were at the time of writing. Since then, some commands, configurations, or third-party tools (such as GitHub Actions, Flutter, Firebase CLI, or App Store requirements) may have changed.
During a project with the start-up Omedo, I had the opportunity to develop a fully automated deployment pipeline targeting Firebase App Distribution, as well as the Google Play and Apple App Stores.
This experience allowed me to apply several best practices I had previously developed at SNCF Connect and Tech, particularly around CI/CD and mobile release management.
Enforcing Code Formatting and Linting with Lefthook
To streamline the review process and maintain a consistent codebase, I rely on automated formatting and linting enforced by the CI pipeline. In my view, it’s the pipeline’s responsibility to ensure all new code is correctly formatted and free from fatal warnings — and to fail the build if necessary.
To achieve this, each developer must install Lefthook, a tool that checks for formatting and code issues before a commit is made.
Once installed, Lefthook requires a configuration file named lefthook.yaml placed at the root of the project. This file defines the checks that should be performed before allowing a commit.
Lefthook is highly customizable. You can:
- Define pre-commit hooks such as flutter format or dart analyze
- Exclude specific files or directories (like generated files)
- Create a robust safeguard against code regressions or inconsistent styles
Here’s an example configuration:
In the lefthook.yaml configuration file, we define three jobs:
- echo-files: this job simply echoes the list of staged files. It’s purely informative and helps developers confirm which files are about to be committed.
- mobile-lint: this step analyzes each modified file using dart analyze. If any file contains a warning, the commit is blocked, and the developer must fix the issue before proceeding. This ensures that only clean code reaches the main branch.
- mobile-format: in large teams, formatting inconsistencies can easily creep in—though ideally, they shouldn’t. This job enforces a uniform code style across the codebase by running flutter format on all staged files.
The pipeline jobs
The flutter cache job
This job serves as a utility step to optimize CI/CD performance. With GitHub Actions, accounts are limited to 2,000 free minutes per month — unless you’re using self-hosted runners, which I highly recommend for larger teams or frequent deployments.
It’s also important to note that usage costs vary by runner type: 1 minute on macOS counts as 10 minutes, compared to a single minute on a Linux runner. This multiplier can quickly exhaust your quota if not managed carefully.
To reduce build times and minimize usage, the flutter-cache job stores the Flutter SDK setup in cache and restores it on subsequent runs. This job should be placed at the beginning of your pipeline to ensure Flutter is available and efficiently configured for all following steps.
Jobs to Support Code Reviews and Maintain Code Quality
To ensure robust and consistent code reviews, it’s essential to include automated jobs that verify whether new code complies with the project’s formatting and quality standards.
This can be achieved with two key jobs:
- Formatting Check: this job verifies that the codebase adheres to the expected formatting rules (e.g., via flutter format). If the code is not properly formatted, the job fails and blocks the commit until it is corrected.
- Static Analysis: this job analyzes the code using tools like dart analyze. If any warnings are detected — such as type errors, unused imports, or potential bugs — the pipeline fails immediately, prompting the developer to resolve the issues before proceeding.
With these jobs in place, it becomes easy to identify when a developer hasn’t followed the project’s formatting standards.
The rules enforced in the CI pipeline should mirror those defined in the lefthook.yaml file. This acts as a redundant safeguard — ensuring consistency both locally (before committing) and remotely (during CI). It’s a double check that protects both your codebase and your eyes during the review process.
Each job should fail immediately if any committed file violates the project’s formatting or linting rules. This strict enforcement helps maintain a clean, uniform, and professional codebase.
Securing Production with Tests and Code Coverage
The most critical job in any automated pipeline is the test job. Automated testing helps safeguard the stability and behavior of your production code, ensuring that new changes do not introduce regressions.
A strong test suite empowers developers to make changes with confidence. It allows teams to introduce new features without the fear of breaking existing functionality. Testing not only protects your product — it also improves development velocity by reducing manual QA overhead and catching issues early.
Additionally, integrating code coverage tracking can highlight untested areas of your codebase, guiding teams toward better overall test quality.
At SNCF, we used SonarQube to manage code quality and coverage. However, for my GitHub projects, I personally use Codecov, which remains free for public repositories.
Codecov is a code coverage analysis tool that helps you visualize which parts of your application are tested and which are not. It integrates easily with GitHub Actions and provides detailed reports through its web interface, making it easier to track and improve test coverage over time.
Good to know : Codecov does not store your source code. Your code remains in your GitHub repository at all times. When displaying coverage reports, Codecov retrieves the relevant files using an OAuth access token provided by GitHub. You can read more about how this works here.
In Flutter projects, it’s common to have many generated files (e.g., via build_runner). In most cases, I don’t consider these files necessary to test — though this may vary depending on the context. For this reason, I exclude them from the lcov.info file used by Codecov to calculate coverage.
This job serves three main purposes:
- Run tests with randomized order: I intentionally randomize the test execution order to avoid hidden dependencies between tests. This helps ensure that each test is fully isolated and not reliant on shared state set by previous tests — preventing bugs from being masked.
- Exclude generated files from code coverage: in Flutter projects, many files are auto-generated. These typically don’t need to be tested directly. To exclude them from the coverage report, I use the lcov command to filter them out of the final lcov.info file.
- Upload coverage to Codecov: once the tests are complete and the coverage data has been cleaned, the final lcov.info file is uploaded to Codecov. This keeps the project’s code coverage metrics up to date and visible for the team.
The build jobs
At this stage, your codebase has been linted, formatted, tested, and validated. It’s now time to build the application binaries — APK for Android and IPA for iOS.
Android build
To sign both the debug and release versions of your Android app, you’ll need to generate a keystore. I choose to store the keystore file within the project repository (in a secured path), and keep sensitive credentials — like the keystore password — in GitHub Secrets.
You can read more about the Android app signing process in the official documentation.
Here’s an example of signing your app via Gradle:
Building the APK for Android is relatively straightforward. You can generate the release APK by running the following command:
flutter build apk lib/main_development.dart --profile --flavor developmentYou can find an example code below :
In this example, I’ve chosen to build the app in profile mode, but you can switch to — release or — debug depending on your needs.
Additionally, this project uses build flavors, which is why the — flavor argument is included in the command.
iOS build
Building an iOS app is slightly more involved than Android due to Apple’s code signing and platform-specific requirements. Let’s walk through the key aspects of the iOS build process step by step.
Creation of the Certificate and the Provisioning profile
The certificate
Just open the Keychain Access app > Certificate Assistant > Request a Certificate From a Certificate Authority
With your Apple account, just go to the certificate section, and click and the + button.
Select Apple Development then continue, and upload your saved certificate.
then you can download your newly added certificate
First, download your Apple distribution certificate and import it into Keychain Access on your Mac.
Next, select both the certificate and its associated private key. Right-click the selection and choose “Export 2 items…” from the context menu.
Save the exported file (typically as a .p12) in a secure location — you’ll need it in the next step when setting up code signing in your CI environment.
The provisioning profile
With you Apple Account, go to the Profile section and click on the plus button.
Select the iOS App Development and click on continue, in the next screen, select your App Id.
then select the certificate we created before
and finally the device on which you want to test
After creating your provisioning profile, give it a clear name, then download it. Just like your certificate, this file will be needed later during the CI build process.
Store the Certificate and provisioning profile in GitHub Actions
To automate the iOS build in GitHub Actions, you’ll need to store both the certificate (.p12 file) and the provisioning profile (.mobileprovision file) securely in your repository’s Actions Secrets.
To do this safely, encode the files to Base64 using the following command:
cat Certificate.p12 | base64 | pbcopy In the GitHub Actions workflow, we use several secrets to handle the iOS code signing process. These include both Base64-encoded assets (from the previous steps) and passwords required for the build.
- secrets.IOS_DEBUG_P12_BASE64
- secrets.IOS_DEBUG_PROVISIONING_PROFILE_BASE64
- secrets.IOS_DEBUG_P12_PASSWORD
- secrets.IOS_DEBUG_KEYCHAIN_PASSWORD
The import in the remote machine
In a CI environment, a single machine may execute multiple workflows concurrently. This depends on the number of runners allocated to that machine. As a result, it’s possible for several iOS jobs to run at the same time — potentially interfering with each other if they share a common keychain.
To avoid this, I recommend creating a custom keychain for each job at runtime.
In my setup, the keychain name is dynamically generated using a combination of:
- the job name
- the runner_id
- the run_number
- and the run_attempt
This value is stored in an environment variable when the job starts, for example:
env:
KEYCHAIN: job-${{ github.job }}-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}To import your signing certificate into the custom keychain, use the apple-actions/import-codesign-certs@v1 GitHub Action. This action requires the following inputs:
- The name of the keychain you created earlier
- The Base64-encoded certificate (.p12)
- The certificate password
Installing the provisioning profile
Next, you need to install the provisioning profile on the runner. This involves decoding the Base64-encoded file and placing it in the appropriate system directory:
This step copies the decoded .mobileprovision file to the ~/Library/MobileDevice/Provisioning Profiles/ directory, which is where Xcode expects to find active provisioning profiles.
Create the Export Options file
The export options plist file (commonly called the export profile) allows you to configure export settings for your iOS build without needing to modify them manually in Xcode.
This file is required when exporting the .ipa using xcodebuild or through flutter build ipa. It defines parameters like the method of distribution, team ID, signing options, and provisioning preferences.
In this file, don’t forget to change the package name of the app, the name of the provisioning profile, and the team ID.
Finally, build and upload the IPA
Like with Android, I chose to build in profile mode, but feel free to change it. Here, I also specify the DevExportOptions file that we created earlier.
flutter build ipa lib/main_development.dart --profile --flavor development --export-options-plist=ios/UL/DevExportOptions.plistDon’t forget to clean
In theory, you don’t know which runner your job will be executed on. And you certainly don’t want to leave your certificate and provisioning profile on an unknown machine.
The purpose of this task is cleanup: we delete the keychain and the provisioning profile with the following commands:
security delete-keychain "${{ env.KEYCHAIN }}".keychain rm ~/Library/MobileDevice/Provisioning\ Profiles/runner_${{ env.UUID }}.mobileprovisionYou can find an extract of the complete iOS task bellow :
The deploy jobs
For debug/profile applications, I personally use App Distribution, which is free. I can invite testers by email and deliver apps directly to their phones.
Here, I use the Firebase CLI to upload the APK and the IPA. It’s good to know that App Distribution doesn’t currently support App Bundles, so you can only use APKs. You can find the app ID for Android and iOS directly in your Firebase project console.
You can learn more about here : https://firebase.google.com/docs/app-distribution/android/distribute-cli
For production, it’s pretty similar, but you have to deal with the Google Play Console and the Apple Store. You can find an example here:
For Android, you can use the r0adkll action, for iOS, you can directly use xcrun command line.
The complete pipeline and expected behavior summary
To summarize this entire pipeline, we have:
- flutter_cache: Caches the Flutter SDK to allow formatting, analysis, and test tasks to access it easily.
- formatting / analysis / tests: These tasks should fail fast, because it doesn’t make sense to build the app if the tests don’t pass. Allowing this could lead to unpredictable behavior in production and potentially harm your business.
- build_android / build_ios: These should be launched only if the previous set of tasks has completed successfully. With these tasks, you can retrieve the uploaded artifacts directly through the GitHub interface.
- deploy (optional): You can launch the full pipeline with the deploy parameter set to true to upload the build to Firebase App Distribution.
The complete pipeline
And that’s it! I hope this post helped you create an automated pipeline with GitHub Actions, and that you enjoyed it!
You can find the complete pipeline below in case you missed anything.
