← All posts

4 min read

CI/CD for Flutter with Azure DevOps: From Commit to Store

A working Azure Pipelines setup for Flutter — analyze, test, build signed Android and iOS releases, and ship them without touching a laptop.

  • Flutter
  • Azure DevOps
  • CI/CD
  • DevOps
Cover illustration for CI/CD for Flutter with Azure DevOps: From Commit to Store

For years my release process was: bump the version, run flutter build on my laptop, drag the file into the store console, and hope I’d remembered everything. It works until it doesn’t — the wrong keystore, a forgotten build number, a debug flag left on.

Over the last year I’ve moved every project I lead onto automated pipelines, mostly with Azure DevOps. This post is the setup I use, step by step. The same structure translates easily to GitHub Actions or Codemagic.

What the pipeline should do

On every pull request:

  1. Install a pinned Flutter version.
  2. Run flutter analyze and the formatter check.
  3. Run unit and widget tests.

On every merge to main, additionally:

  1. Build a signed Android App Bundle.
  2. Build a signed iOS IPA.
  3. Publish both as artifacts and push them to internal testing tracks.

The rule behind all of it: a human should never build a release on their own machine. If it can only be built on one laptop, it’s not a process — it’s a risk.

Step 1: Pin and install Flutter

Pin the exact Flutter version the team uses. Different Flutter versions produce different builds, and “works on my machine” often means “my machine has a different Flutter”.

I keep it as a pipeline variable and clone that tag directly — no marketplace extension required:

variables:
  FLUTTER_VERSION: '3.35.0'

steps:
  - script: |
      git clone https://github.com/flutter/flutter.git --depth 1 -b $(FLUTTER_VERSION) $(Agent.ToolsDirectory)/flutter
      echo "##vso[task.prependpath]$(Agent.ToolsDirectory)/flutter/bin"
    displayName: Install Flutter $(FLUTTER_VERSION)

  - script: flutter --version && flutter pub get
    displayName: Get dependencies

The ##vso[task.prependpath] logging command adds Flutter to PATH for all subsequent steps.

Step 2: Quality gates on every PR

  - script: dart format --output=none --set-exit-if-changed .
    displayName: Check formatting

  - script: flutter analyze
    displayName: Analyze

  - script: flutter test --coverage
    displayName: Run tests

Then enable a branch policy on main that requires this pipeline to pass before a pull request can be merged. That turns the checks from a suggestion into a rule.

Step 3: Sign Android builds with secure files

Never commit your keystore or its passwords. In Azure DevOps:

  1. Upload the .jks file to Pipelines → Library → Secure files.
  2. Create a variable group with KEYSTORE_PASSWORD, KEY_ALIAS and KEY_PASSWORD, marked as secret.

In the pipeline, download the keystore and write the key.properties file your build.gradle reads:

  - task: DownloadSecureFile@1
    name: keystore
    inputs:
      secureFile: upload-keystore.jks

  - script: |
      cat > android/key.properties <<EOF
      storeFile=$(keystore.secureFilePath)
      storePassword=$(KEYSTORE_PASSWORD)
      keyAlias=$(KEY_ALIAS)
      keyPassword=$(KEY_PASSWORD)
      EOF
    displayName: Configure signing

  - script: |
      flutter build appbundle --release \
        --build-number=$(Build.BuildId) \
        --dart-define=ENV=production
    displayName: Build Android App Bundle

Step 4: Build numbers you never think about

Both stores reject an upload with a build number they’ve already seen. Using $(Build.BuildId) (a number that only ever increases) as --build-number means you never bump it by hand again. The human-readable version (1.4.0) stays in pubspec.yaml and changes only when you mean it to.

Step 5: iOS on a macOS agent

iOS builds need a Mac. Microsoft-hosted macos-latest agents work well. Upload your distribution certificate (.p12) and provisioning profile as secure files, then install them with the built-in tasks:

- job: ios
  pool:
    vmImage: macos-latest
  steps:
    - task: InstallAppleCertificate@2
      inputs:
        certSecureFile: distribution.p12
        certPwd: $(P12_PASSWORD)

    - task: InstallAppleProvisioningProfile@1
      inputs:
        provProfileSecureFile: AppStore.mobileprovision

    # ...install Flutter as in step 1...

    - script: |
        flutter build ipa --release \
          --build-number=$(Build.BuildId) \
          --export-options-plist=ios/ExportOptions.plist
      displayName: Build IPA

The ExportOptions.plist tells Xcode which signing method and profile to use. Commit it to the repo — it contains no secrets.

Step 6: Publish artifacts and ship to testers

  - task: PublishPipelineArtifact@1
    inputs:
      targetPath: build/app/outputs/bundle/release
      artifact: android-release

From there, you can upload automatically:

  • Google Play: the Google Play extension for Azure DevOps, with a service account JSON, can publish the bundle to the internal testing track.
  • App Store: the App Store extension, or fastlane pilot, can upload the IPA to TestFlight using an App Store Connect API key.

I push every main build to internal testing automatically, and promote to production manually from the store consoles. Automation gets the build to testers; a human still decides when real users get it.

Step 7: Environments with dart-define

Different API URLs and keys for development, staging and production go in via --dart-define (or --dart-define-from-file with a JSON file per environment), read in Dart with String.fromEnvironment. The pipeline decides the environment; the code never hard-codes it.

Things that tripped me up

  • Caching the pub cache saves a surprising amount of time. The Cache@2 task keyed on pubspec.lock cut minutes off each run.
  • CocoaPods versions on hosted macOS agents change over time. If iOS builds break suddenly with no code change, check the agent image release notes first.
  • Secrets in logs. Azure masks secret variables, but not values derived from them. Don’t echo anything built from a secret.
  • Long-running pipelines get ignored. Keep the PR pipeline under ten minutes; move slow jobs (iOS builds, E2E tests) to the main pipeline or run them in parallel.

Takeaways

  • Pin your Flutter version, and install it in the pipeline.
  • Gate every PR on format, analyze and tests with a branch policy.
  • Store keystores, certificates and passwords as secure files and secret variables.
  • Use the pipeline’s build ID as your build number.
  • Automate delivery to testers; keep the production release a deliberate human step.

Once it’s set up, releasing becomes boring — and boring releases are exactly what you want. If you’re setting up CI/CD for a Flutter app and want a hand, reach out.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).