Published on

Flutter CI/CD with Codemagic and AWS CodeCommit

Authors
  • avatar
    Name
    Phat Tran
    Twitter

The entire release process for our Flutter super app hangs on one convention: push a git tag that matches build/<env>-vX.Y.Z+N, and Codemagic takes it from there. It builds the right flavor, signs it, sends the Android build to Firebase App Distribution and the iOS build to TestFlight, then reports to Telegram.

I set this pipeline up for FE Online 2.0. In the config below the project is renamed to Acme App, but the rest is the real thing: a Flutter monorepo managed with melos, hosted on AWS CodeCommit, with three environments (UAT, STAGING, PRODUCTION).

The trigger format:

build/<env>-v{versionNumber}+{buildNumber}
# Example
build/uat-v2.5.0+120

Connecting CodeCommit

Codemagic does not treat CodeCommit as a first-class provider, so the connection goes through SSH.

  1. In Codemagic, click Add application, pick Other, then Connect via SSH. Paste the repo's SSH URL and select Flutter as the project type.
  2. Create an SSH key pair as described in Connecting repository via SSH. The public key goes into AWS IAM, the private key into Codemagic.
  3. In CodeCommit, under Settings → Triggers, add a webhook pointing at the Codemagic endpoint so tag pushes actually reach Codemagic. The steps are in Setting up webhooks for AWS CodeCommit.

iOS signing

Two one-time jobs at team level.

First, connect the Apple Developer Portal: Teams → General settings → Team integrations → Developer Portal → Connect, then upload an App Store Connect API key. Signing in with an Apple ID also works, but the API key is the recommended route.

Second, upload the signing assets under Teams → Code signing identities: the distribution certificate as a .p12 and the distribution provisioning profile as a .mobileprovision. Codemagic decrypts and installs both during every iOS build, so nobody touches a keychain by hand.

Secrets

Under Teams → Global variables and secrets we keep two groups. firebase_credentials holds FIREBASE_SERVICE_ACCOUNT, which the Android workflow uses to publish builds. Telegram holds TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, and TELEGRAM_TOPIC_ID for the build notifications. Codemagic stores the values AES-encrypted and redacts them from build logs.

The codemagic.yaml

Create codemagic.yaml in the repo root. Below are the two UAT workflows, Android and iOS. The staging and production workflows are copies with the flavor-specific lines changed, so I will spare you the repetition.

workflows:
  android-uat-workflow:
    name: android-acme-app-uat
    instance_type: mac_mini_m2
    max_build_duration: 120

    environment:
      groups:
        - firebase_credentials
        - Telegram
      vars:
        PACKAGE_NAME: 'com.acme.app.uat'
        APP_SCHEME: 'uat'
        JAVA_TOOL_OPTIONS: '-Xmx5g'
      flutter: 3.13.3

    triggering:
      events:
        - tag
      tag_patterns:
        - pattern: 'build/uat-*'
          include: true

    scripts:
      - name: Initialize development environment
        script: |
          flutter pub global activate melos
          flutter pub global run melos clean
          flutter pub global run melos bootstrap
          sh gen.sh
      - name: Configure script permissions
        script: |
          chmod +x ./scripts/*.sh
      - name: Process version tag
        script: |
          ./scripts/cicd_extract_tag.sh
      - name: Build UAT APK
        script: |
          cd apps/mobile_app
          if flutter build apk --release \
            --flavor uat \
            --build-name=$APP_BUILD_VERSION \
            --build-number=$APP_BUILD_NUMBER; then
            echo "BUILD_STATUS=success" >> $CM_ENV
          else
            echo "BUILD_STATUS=failed" >> $CM_ENV
            exit 1
          fi

    artifacts:
      - apps/mobile_app/build/**/outputs/**/*.apk
      - apps/mobile_app/build/**/outputs/**/mapping.txt
      - flutter_drive.log

    publishing:
      firebase:
        firebase_service_account: $FIREBASE_SERVICE_ACCOUNT
        android:
          app_id: 1:123456789012:android:0a1b2c3d4e5f67890abcde # from google-services.json
          groups:
            - all
          artifact_type: 'apk'
      scripts:
        - name: Send build report to Telegram
          script: |
            ./scripts/cicd_send_sms_telegram.sh

  ios-uat-workflow:
    name: ios-acme-app-uat
    instance_type: mac_mini_m2
    max_build_duration: 120

    integrations:
      app_store_connect: Acme Codemagic App Manager API Key

    environment:
      ios_signing:
        distribution_type: app_store
        bundle_identifier: com.acme.app.uat
      vars:
        APP_ID: 1234567890 # numeric Apple ID of the app in App Store Connect
        APP_SCHEME: 'uat'
      groups:
        - Telegram
      flutter: 3.13.3
      xcode: 16.2
      cocoapods: default

    triggering:
      events:
        - tag
      tag_patterns:
        - pattern: 'build/uat-*'
          include: true

    scripts:
      - name: Configure Xcode code signing
        script: |
          xcode-project use-profiles
      - name: Initialize development environment
        script: |
          flutter pub global activate melos
          flutter pub global run melos clean
          flutter pub global run melos bootstrap
          sh gen.sh
      - name: Configure script permissions
        script: |
          chmod +x ./scripts/*.sh
      - name: Process version tag
        script: |
          ./scripts/cicd_extract_tag.sh
      - name: Install Flutter dependencies
        script: |
          cd apps/mobile_app
          flutter pub get
      - name: Setup iOS dependencies
        script: |
          cd apps/mobile_app/ios
          pod deintegrate
          pod install --repo-update
      - name: Build UAT IPA
        script: |
          cd apps/mobile_app
          if flutter build ipa --release \
            --build-name=$APP_BUILD_VERSION \
            --build-number=$APP_BUILD_NUMBER \
            --export-options-plist=/Users/builder/export_options.plist \
            --flavor uat; then
            echo "BUILD_STATUS=success" >> $CM_ENV
          else
            echo "BUILD_STATUS=failed" >> $CM_ENV
            exit 1
          fi

    artifacts:
      - apps/mobile_app/build/ios/ipa/*.ipa
      - $CM_BUILD_DIR/build/ios/ipa/*.ipa
      - /tmp/xcodebuild_logs/*.log
      - flutter_drive.log

    publishing:
      app_store_connect:
        auth: integration
        submit_to_testflight: true
      scripts:
        - name: Send build report to Telegram
          script: |
            ./scripts/cicd_send_sms_telegram.sh

The two scripts doing the real work

The yaml references two shell scripts, and the tag parser is the heart of the whole convention, so here they are, trimmed to their essentials.

cicd_extract_tag.sh turns the tag into build variables. Codemagic puts the triggering tag into CM_TAG, and anything you append to the file at $CM_ENV becomes an environment variable for every later step. That is how --build-name and --build-number in the build step get their values:

#!/bin/sh
# CM_TAG holds the tag that triggered the build, e.g. build/uat-v2.5.0+120

VERSION_PART="${CM_TAG##*-v}"                     # 2.5.0+120

echo "APP_BUILD_VERSION=${VERSION_PART%%+*}" >> $CM_ENV   # 2.5.0
echo "APP_BUILD_NUMBER=${VERSION_PART##*+}" >> $CM_ENV    # 120

Push build/uat-v2.5.0+120 and the pipeline builds version 2.5.0 with build number 120. The version lives in the tag and nowhere else, so there is no version constant in the repo to forget to bump.

cicd_send_sms_telegram.sh closes the loop. It reads the BUILD_STATUS value that the build step wrote to $CM_ENV and posts the outcome to the team chat:

#!/bin/sh
if [ "$BUILD_STATUS" = "success" ]; then
  TEXT="✅ $APP_SCHEME $APP_BUILD_VERSION+$APP_BUILD_NUMBER is on its way to testers"
else
  TEXT="❌ $APP_SCHEME $APP_BUILD_VERSION+$APP_BUILD_NUMBER failed — check the Codemagic logs"
fi

curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
  -d chat_id="$TELEGRAM_CHAT_ID" \
  -d message_thread_id="$TELEGRAM_TOPIC_ID" \
  -d text="$TEXT" > /dev/null

A few parts that are easy to miss

  • instance_type: mac_mini_m2 is Apple Silicon, which builds faster. For the Android-only workflow a linux_x2 instance would also do the job at a lower per-minute rate; we kept both workflows on the same instance type for simplicity.
  • tag_patterns is the whole trigger. Only tags matching build/uat-* start these workflows, which is how one repo carries three environments without the workflows stepping on each other.
  • The melos steps exist because this is a monorepo. A plain flutter pub get at the root would not link the packages together.
  • The BUILD_STATUS value written to $CM_ENV feeds the Telegram script in publishing.scripts, so the chat message can say whether the build actually succeeded.
  • artifacts lists what survives after the build VM is destroyed: the APK or IPA, mapping files, logs.
  • publishing.firebase hands the APK to the tester group. publishing.scripts runs afterwards, which is where the Telegram notification goes, and where a JIRA update or GitHub release would go too.

Day to day, the routine is short. Finish the work, push a tag like build/uat-v2.5.0+120, and go make coffee. By the time you are back, Telegram either confirms the build went out or tells you what to go fix.