Tired of manually testing, building, and sharing your Flutter app every time you make a change?
Flutter makes it easy to build beautiful cross-platform apps—but when it comes to repetitive tasks like testing, compiling, and distributing, things can get time-consuming fast.
That’s where GitLab CI/CD steps in. With a well-defined pipeline, you can:
- Ensure consistent builds with a Dockerized environment.
- Save time by caching dependencies.
- Share APKs with your team effortlessly (e.g., via Google Chat).
- Keep sensitive files secure with cleanup steps, including the keystore used for signing.
Let’s explore each part of this configuration, along with how to manage the Android signing process.
1. Docker Image
yaml
image:ghcr.io/cirruslabs/flutter:3.27.3
The pipeline uses the official Flutter Docker image (ghcr.io/cirruslabs/flutter:3.27.3), which comes pre-installed with Flutter SDK version 3.27.3 and all necessary tools (like Android SDK). This ensures a consistent build environment across all jobs.
2. Pipeline Stages
yaml
stages:
-test
-build
The pipeline defines two stages: test and build. While this example only implements the build stage, the test stage is reserved for future testing jobs (e.g., running unit or widget tests).
3. Caching Dependencies
yaml
cache:
key:"${CI_COMMIT_REF_NAME}-flutter-android"
paths:
-.pub-cache/
-build/
Caching speeds up builds by reusing dependencies between pipeline runs. The key is tied to the branch name (${CI_COMMIT_REF_NAME}), ensuring separate caches for different branches. The cached paths include:
- .pub-cache/: Flutter’s package cache.
- build/: Build artifacts, including the generated keystore directory, to avoid redundant recompilation.
4. The build_android Job
This is the heart of the pipeline, responsible for building and distributing the Android APK. To create a signed release APK, we also need to handle the Android keystore securely.
Stage
yaml
stage:build
The job belongs to the build stage.
Script
yaml
script:
-echo"Current Branch {your branch name}"
-flutterclean
-flutterpackagespubrunbuild_runnerbuild--delete-conflicting-outputs
-flutterpubget
-flutterbuildapk--release--flavorprod
-echo"Uploading APK to Google Chat Channel..."
-|
COMMIT_MESSAGE=$(git log -1 --pretty=%B)
APK_URL="${CI_JOB_URL}/artifacts/raw/build/app/outputs/flutter-apk/app-prod-release.apk"
curl -X POST -H 'Content-Type: application/json' \
-d "{\"text\": \"New Android Build from commit changes: *${COMMIT_MESSAGE}* \nDownload it here: <$APK_URL|APK Link>\"}" \
"${GOOGLE_CHAT_WEBHOOK_URL}"
Here’s what happens step-by-step:
- Echo the branch: Logs the branch name (your branch name) for debugging.
- Clean the project: flutter clean removes old build artifacts.
- Run build_runner: Generates code with build_runner, deleting any conflicting outputs.
- Fetch dependencies: flutter pub get ensures all packages are up-to-date.
- Build the APK: flutter build apk --release --flavor prod creates a release APK for the prod flavor, using the signing configuration defined in the Gradle file (see below).
- Notify via Google Chat:
- Extracts the latest commit message (git log -1 --pretty=%B).
- CI_JOB_URL will be your CI_JOB_URL is a predefined environment variable in GitLab CI/CD that provides the full URL to the specific job being executed in the pipeline. This URL points to the job’s page in the GitLab UI, where you can view logs, status, and artifacts. In the context of this pipeline, CI_JOB_URL is used to construct a link to the APK artifact, making it accessible to team members via the Google Chat notification. For example, if the job ID is 1234 and the project is hosted at https://gitlab.com/mygroup/myproject, the CI_JOB_URL might look like https://gitlab.com/mygroup/myproject/-/jobs/1234.
- Constructs a URL to the APK artifact.
- Sends a POST request to a Google Chat webhook with the commit message and APK download link.
Signing the APK
To sign the APK, you’ll need to update your android/app/build.gradle file with the following configuration:
Gradle
signingConfigs {
release {
storeFile file(getKeystoreFile())
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias System.getenv("ANDROID_KEY_ALIAS")
keyPassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
}
}
def getKeystoreFile() {
def keystorePath = "$buildDir/keystore/my-release-key.jks"
def keystoreBase64 = System.getenv("ANDROID_KEYSTORE_FILE_DATA")
println"Keystore path: $keystorePath"
println"Base64 available: ${keystoreBase64 ? 'Yes' : 'No'}"
if (!keystoreBase64) {
thrownew GradleException("ANDROID_KEYSTORE_FILE_DATA environment variable is not set")
}
def keystoreFile = newFile(keystorePath)
if (!keystoreFile.exists()) {
println"Creating keystore file at $keystorePath"
def decodedBytes = new ByteArrayInputStream(Base64.decoder.decode(keystoreBase64))
keystoreFile.parentFile.mkdirs() // Ensure the 'keystore' directory exists
keystoreFile.withOutputStream { out ->
out << decodedBytes
}
println"Keystore file created: ${keystoreFile.exists()}"
} else {
println"Keystore file already exists"
}
return keystorePath
}
This script does the following:
- Defines the signing configuration: Uses environment variables for the keystore password, alias, and file data, keeping sensitive information out of the codebase.
- Generates the keystore file: Takes a Base64-encoded keystore (stored in ANDROID_KEYSTORE_FILE_DATA) and decodes it into a .jks file at build/keystore/my-release-key.jks. If the file already exists (e.g., from cache), it reuses it.
- Error handling: Throws an exception if the required environment variable is missing, ensuring the pipeline fails fast if misconfigured.
You’ll need to set these environment variables in GitLab CI/CD (see "How to Set This Up" below).
Artifacts
yaml
artifacts:
paths:
-build/app/outputs/flutter-apk/app-prod-release.apk
expire_in:1day
The signed APK is stored as an artifact, accessible via the GitLab UI or the URL in the Google Chat message. It expires after 1 day to save storage.
Trigger Condition
yaml
only:
-your branch name
The job runs only on the your mentioned branch, making it branch-specific. Adjust this to main or another branch as needed.
5. Cleanup with after_script
yaml
after_script:
-rm-fbuild/app/keystore/my-release-key.jks
After the job completes (successfully or not), sensitive files like the Google Play API key and the generated keystore are deleted to prevent accidental exposure.
How to Set This Up: Config & Gitlab Variables
- Add the .gitlab-ci.yml file: Place this configuration in your Flutter project’s root directory.
- Update the build.gradle: Add the signing configuration and getKeystoreFile() function to android/app/build.gradle.
- Configure secrets: In GitLab, go to Settings > CI/CD > Variables and add the following as protected variables:
- GOOGLE_CHAT_WEBHOOK_URL: The webhook URL for Google Chat notifications.
- ANDROID_KEYSTORE_FILE_DATA: Base64-encoded keystore file (e.g., base64 my-release-key.jks | tr -d '\n').
- ANDROID_KEYSTORE_PASSWORD: The keystore password.
- ANDROID_KEY_ALIAS: The key alias.
- Push to the branch: Commit and push to your branch to trigger the pipeline.
- Monitor the pipeline: Check the GitLab CI/CD dashboard for job status and logs.
Extending the Pipeline
This is a solid starting point, but you can enhance it:
- Add a test job with flutter test to run unit tests.
- Integrate Fastlane to upload the APK to Google Play.
- Support multiple flavors (e.g., dev, staging) with dynamic variables.
Conclusion
This GitLab CI/CD pipeline automates the build and distribution of a Flutter Android APK, complete with secure signing using a dynamically generated keystore.
With this setup, you won’t need to worry about manually building or signing your APK. Everything happens automatically and securely. Your team gets notified instantly, and your build pipeline becomes faster and more reliable.
Start using this today and enjoy stress-free Flutter deployments!
Happy coding!