> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/quality-delivery/fastlane-ios-code-signing-testflight.md).

# Fastlane iOS, Code Signing, and TestFlight

Separate code signing, IPA building, and TestFlight upload in Fastlane so an iOS pipeline uses the right artifact, fewer privileges, and accurate status reporting

## Result

A reliable iOS pipeline should not rebuild the app while uploading it to TestFlight. The IPA that passed the quality gate should be built and signed exactly once, protected with a checksum, and passed unchanged to the upload job.

After tracing the GitLab pipeline, Fastfile, export configuration, and signing settings in the reference source, I chose this target state:

```
              GitLab protected pipeline
                         │
                    quality passed
                         │
                         ▼
┌─────────────────────────────────────────────────┐
│ Build IPA                                       │
│ setup_ci → match readonly → build_app           │
│ Secret: signing read-only                       │
└───────────────────────┬─────────────────────────┘
                        │
          IPA + dSYM + manifest + checksum
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│ Upload TestFlight                               │
│ verify checksum → upload existing IPA           │
│ Secret: App Store Connect API key               │
└───────────────────────┬─────────────────────────┘
                        │
                        ▼
          Accepted → Processing → Complete
                                    │
                       ┌────────────┴────────────┐
                       ▼                         ▼
                Internal testing       External beta review
```

The build job only reads signing material, creates the IPA and dSYM, and packages traceability metadata. The upload job receives only the App Store Connect API key, verifies the checksum, and uploads the exact IPA produced by the build. One job therefore does not need both signing and distribution privileges.

The observable outcomes are:

* Ruby, Bundler, and Fastlane are pinned in the repository; local development and CI both use `bundle exec`.
* CI uses a temporary keychain, while `match` runs read-only during a normal build.
* The build number is unique and traceable to its pipeline.
* The IPA, dSYM, manifest, and SHA-256 checksum travel together as an expiring artifact.
* The upload job fails before calling Apple if the checksum does not match.
* The pipeline reports only the state it actually verifies: upload accepted, processing complete, internal testing, or external Beta App Review.

This is a target architecture derived from the source, not a claim that the current production pipeline already follows the diagram. I verified the source and configuration syntax, but I did not sign or build an iOS app or perform a real App Store Connect upload.

## Problem

A Fastlane lane can easily grow into this sequence of responsibilities:

```
load every credential
→ modify environment files
→ sync signing with write access
→ build Flutter
→ archive and export the IPA
→ upload
→ wait for Apple processing
→ add tester groups
→ send notifications
```

When everything lives in one lane, a failure makes three questions difficult to answer:

1. Which IPA was built and signed?
2. Which IPA was actually uploaded?
3. Has Apple only accepted the binary, or can testers already use the build?

The source I inspected has 11 iOS lanes, 11 calls to `match`, 5 calls to `gym`, and 2 calls to `upload_to_testflight`. Both TestFlight lanes rerun the Flutter build and `gym`; the submission job does not receive an IPA from an earlier build job. The source therefore has no artifact contract proving that the uploaded binary is the one that passed the quality gate.

The shared iOS setup also creates an App Store Connect key file before every lane, including lanes that do not upload. Four `match` calls use a mode that can update signing, while the source does not use `setup_ci`, does not set `readonly`, and has no clear cleanup for the temporary key file. Each lane consequently has a larger blast radius than its actual responsibility requires.

The current build number is assembled from a timestamp with minute-level precision. Two pipelines running within the same minute can produce the same number, while App Store Connect requires a new build identity for a new binary.

I also found credential-like JSON schemas, beta review login metadata, and a literal keychain password tracked in Git under the Fastlane area. This article does not disclose file names, identifiers, or values. Sensitive data that has entered Git must be treated as exposed to anyone who can read its history and must be rotated or revoked outside the documentation workflow.

Finally, “upload succeeded” does not mean “testers can see the build.” Apple processes the binary after upload. Internal testing, external testing, and Beta App Review are separate states, so the pipeline must not collapse all of them into one `deployed` label.

## Solution

### Start after the quality gate

This article begins after monorepo analysis, tests, and reports have passed. GitLab still owns triggers, runner selection, `rules`, `needs`, and artifacts; Fastlane owns only iOS building, signing, and uploading.

If your quality gate is still coupled to the release job, establish the base pipeline first. The following article explains the boundary between GitLab CI, Make, and Melos that this workflow uses as a prerequisite.

{% content-ref url="/pages/iXaQgqQ1eqF71OtJywau" %}
[GitLab CI for a Flutter Monorepo](/flutter/my-flutter/quality-delivery/gitlab-ci-flutter-monorepo.md)
{% endcontent-ref %}

iOS schemes and configurations are inputs to the build lane and should not be created dynamically by the upload job. The Flavor article owns the multi-environment setup; this article uses only a neutral scheme as an example.

{% content-ref url="/pages/1lpIWX1RW20PkI7TGvxs" %}
[Flavor](/flutter/my-flutter/architecture-state/flavor.md)
{% endcontent-ref %}

### Pin Ruby, Bundler, and Fastlane

The source locks Fastlane `2.232.2`, CocoaPods `1.16.2`, and Bundler `4.0.7`, but it does not pin Ruby. Fastlane also enters the dependency graph through a plugin rather than a direct declaration. I make Fastlane a direct dependency so removing or upgrading that plugin cannot silently change the release tool:

```ruby
source "https://rubygems.org"

ruby File.read(".ruby-version").strip

gem "cocoapods", "1.16.2"
gem "fastlane", "2.232.2"
```

Commit `.ruby-version`, `Gemfile`, and `Gemfile.lock`. The runner installs the selected Ruby first, and every command then goes through the bundle:

```bash
ruby --version
gem install bundler -v 4.0.7
bundle config set path vendor/bundle
bundle install --jobs 4 --retry 3
bundle exec fastlane --version
```

Do not run an unpinned `gem install fastlane` in every job. That makes the effective version depend on runner state and the time when the pipeline executes.

Choose the Ruby version for the macOS/Xcode image you manage, then verify it against the lockfile. The reference source does not pin Ruby, so this article does not invent a version.

### Separate build-job and upload-job privileges

I use two security boundaries instead of one shared list of secrets:

| Job               | Requires                                                                | Does not require                                               |
| ----------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------- |
| Build IPA         | Read-only signing repository, decryption passphrase, temporary keychain | App Store Connect upload key                                   |
| Upload TestFlight | App Store Connect API key, IPA artifact                                 | Signing repository, certificate private key, keychain password |

In GitLab, a private key should be supplied as a file-type variable or requested from an external secret provider for the specific job. Pass the CI-provided file path directly to Fastlane; there is no need to `echo` a base64 string into the working tree.

A minimal public example can use neutral variable names:

```
MATCH_GIT_BASIC_AUTHORIZATION
MATCH_PASSWORD
ASC_KEY_ID
ASC_ISSUER_ID
ASC_KEY_FILE
```

`ASC_KEY_FILE` is the path of a file-type variable, not the `.p8` content. Variables must be protected, masked, or hidden where their data type permits, and appear only in the protected environment or job that needs them. With external secrets, each job requests the right secret at runtime, creating a clearer boundary than injecting every project variable into every job.

Do not print `env`, enable shell tracing around key-handling commands, or retain credential files in artifacts.

### Use a temporary keychain and read-only match in CI

`setup_ci` creates a temporary keychain and sets `MATCH_READONLY` for CI. I still pass `readonly: is_ci` explicitly so the lane contract remains visible during review:

```ruby
platform :ios do
  private_lane :prepare_signing do
    setup_ci if is_ci

    match(
      type: "appstore",
      readonly: is_ci
    )
  end
end
```

During normal builds, CI should not use `force: true` or create new certificates and profiles. If a profile is missing, expired, or revoked, the job must stop. Updating signing belongs to a separate administrative workflow with an authorized operator, an audit log, and write access to signing storage.

Read-only mode does not eliminate every risk. The build job still needs a private key to sign the IPA, so it must run on a controlled macOS runner, must not share that runner with untrusted pipelines, and must clean the workspace and keychain after the job.

A project with app extensions needs profile mappings for every target, not only the main app. I do not include real bundle IDs, team IDs, profile names, or certificate fingerprints in the public article.

### Create a unique, traceable build number

App Store Connect uses the marketing-version and build-number pair to identify a build. Within one GitLab project, `$CI_PIPELINE_IID` increases per project and is safer than a timestamp with only minute-level precision:

```ruby
private_lane :apply_ci_build_number do
  build_number = ENV.fetch("CI_PIPELINE_IID")

  UI.user_error!("Build number must be numeric") unless build_number.match?(/\A\d+\z/)

  increment_build_number(
    xcodeproj: "ios/Runner.xcodeproj",
    build_number: build_number
  )

  build_number
end
```

If multiple apps or independent pipelines write to the same App Store Connect record, a project-local IID does not provide a shared namespace. Use a centralized allocator or a numeric rule that remains within `CFBundleVersion` limits instead of concatenating arbitrary strings.

The artifact manifest should contain the build number, commit SHA, pipeline URL or ID, scheme, Flutter version, and checksum. Do not put secrets or runner-local paths in the manifest.

### Build and sign the IPA exactly once

The build lane prepares signing, creates the build number, builds Flutter, and exports the IPA. It does not have an upload API key:

```ruby
require "json"

platform :ios do
  desc "Build a signed IPA for the TestFlight artifact"
  lane :build_testflight do
    prepare_signing
    build_number = apply_ci_build_number

    sh(
      "fvm", "flutter", "build", "ios",
      "--release",
      "--no-codesign",
      "--flavor", "staging",
      "-t", "lib/main_staging.dart"
    )

    ipa_path = build_app(
      workspace: "ios/Runner.xcworkspace",
      scheme: "Staging",
      configuration: "Release-staging",
      export_method: "app-store",
      export_options: "ios/ExportOptions.plist",
      output_directory: "build/ios",
      output_name: "app.ipa",
      clean: true
    )

    manifest = {
      build_number: build_number,
      commit_sha: ENV.fetch("CI_COMMIT_SHA"),
      pipeline_id: ENV.fetch("CI_PIPELINE_ID"),
      ipa: File.basename(ipa_path)
    }

    File.write(
      "build/ios/manifest.json",
      JSON.pretty_generate(manifest)
    )
  end
end
```

`Staging`, the configuration, the flavor, and the entry point are placeholders. Replace them with mappings verified for your project, but do not let the upload job select a different mapping.

`flutter build ios --no-codesign` prepares the Flutter/Xcode input; `build_app` archives it and exports the signed IPA according to `ExportOptions.plist`. With manual signing, the export options must cover every target and extension. After the lane finishes, the job should find at least:

```
build/ios/app.ipa
build/ios/app.dSYM.zip
build/ios/manifest.json
build/ios/SHA256SUMS
```

The dSYM path depends on the `build_app` configuration. Set a fixed output or copy the result into the artifact contract before the job ends. If the IPA, dSYM, or manifest is missing, the build job must fail instead of uploading an incomplete artifact.

### Upload the IPA that was already built

The upload lane receives the IPA path as a required input. It does not call Flutter build, `gym`, `build_app`, or `match`:

```ruby
platform :ios do
  desc "Upload an existing IPA to TestFlight"
  lane :upload_testflight do |options|
    ipa_path = File.expand_path(options.fetch(:ipa))
    UI.user_error!("IPA not found: #{ipa_path}") unless File.file?(ipa_path)

    api_key = app_store_connect_api_key(
      key_id: ENV.fetch("ASC_KEY_ID"),
      issuer_id: ENV.fetch("ASC_ISSUER_ID"),
      key_filepath: ENV.fetch("ASC_KEY_FILE"),
      in_house: false
    )

    upload_to_testflight(
      api_key: api_key,
      ipa: ipa_path,
      skip_waiting_for_build_processing: true,
      distribute_external: false,
      notify_external_testers: false
    )
  end
end
```

I use upload-only as the baseline. With `skip_waiting_for_build_processing: true`, the lane can confirm that Apple accepted the upload, but it cannot claim that processing has completed or testers can see the build.

If the pipeline must distribute to external testers immediately, create a separate lane or policy: wait for processing, declare a tester group, provide beta review metadata, and accept the Beta App Review state. Do not store demo passwords or review contacts in the repository; they are credentials too.

### Distinguish TestFlight states

| State               | What the pipeline can claim                                            | What it must not infer                  |
| ------------------- | ---------------------------------------------------------------------- | --------------------------------------- |
| Upload accepted     | Apple accepted the binary for processing                               | Processing has completed                |
| Processing complete | The build appears in a completed state                                 | The build belongs to a tester group     |
| Internal testing    | The build was assigned to internal testers according to policy         | External testers can use it             |
| External testing    | The build met Beta App Review requirements and was assigned to a group | The app has been released to production |

An upload can succeed while processing later fails. External testing also depends on tester groups and Beta App Review; the first build of a version can have different review requirements from later builds.

Jobs, notifications, and release dashboards should therefore use precise terms such as `uploaded`, `processing`, `ready for internal testing`, or `beta review pending`. Avoid one `deployed` label for the entire sequence.

### Connect the two jobs with a checksummed artifact

The public pipeline below runs manually only for protected tags, retains the artifact for one day, and serializes TestFlight operations:

```yaml
stages:
  - build
  - distribute

build_ios_testflight:
  stage: build
  tags:
    - macos
  environment:
    name: ios-signing
  rules:
    - if: '$CI_COMMIT_TAG && $CI_COMMIT_REF_PROTECTED == "true"'
      when: manual
    - when: never
  script:
    - bundle config set path vendor/bundle
    - bundle install
    - bundle exec fastlane ios build_testflight
    - test -f build/ios/app.ipa
    - test -f build/ios/app.dSYM.zip
    - test -f build/ios/manifest.json
    - shasum -a 256 build/ios/app.ipa > build/ios/SHA256SUMS
  artifacts:
    access: maintainer
    expire_in: 1 day
    paths:
      - build/ios/app.ipa
      - build/ios/app.dSYM.zip
      - build/ios/manifest.json
      - build/ios/SHA256SUMS

upload_ios_testflight:
  stage: distribute
  tags:
    - macos
  environment:
    name: ios-testflight
  needs:
    - job: build_ios_testflight
      artifacts: true
  resource_group: testflight
  rules:
    - if: '$CI_COMMIT_TAG && $CI_COMMIT_REF_PROTECTED == "true"'
      when: manual
    - when: never
  script:
    - shasum -a 256 -c build/ios/SHA256SUMS
    - bundle config set path vendor/bundle
    - bundle install
    - bundle exec fastlane ios upload_testflight ipa:build/ios/app.ipa
```

`macos`, the environment names, and the job names are placeholders. The actual runner, branch convention, and protected-environment policy depend on your infrastructure.

`needs:artifacts` transfers the output from the build job instead of rebuilding it. `artifacts:access` limits who can download the artifact, `expire_in` reduces binary retention, and `resource_group` prevents two jobs with TestFlight side effects from running concurrently.

Artifact access and protected variables do not replace runner isolation. Anyone who can modify the pipeline on a protected ref can still attempt to read secrets, so merge permissions, manual-job permissions, and variable administration must be restricted together.

GitLab keywords vary by server version. The source does not confirm the GitLab or GitLab Runner version, so this YAML must pass CI Lint on the actual instance before it is merged.

### Verify before enabling upload

The first static checks require no Apple credentials:

```bash
ruby -c fastlane/Fastfile
plutil -lint ios/ExportOptions.plist
bundle check
bundle exec fastlane lanes
```

`ruby -c` verifies syntax only. `fastlane lanes` confirms that the Fastfile and plugins load inside the bundle, but it does not prove that signing or uploading works.

On an authorized macOS signing runner, the build job should verify:

1. Log Ruby, Bundler, Fastlane, CocoaPods, Flutter, and Xcode versions without dumping the entire environment.
2. The job does not receive the App Store Connect upload key.
3. `match` runs read-only and uses a temporary keychain.
4. The IPA build number matches the manifest and the expected commit SHA.
5. `codesign --verify` passes for the main app and its extensions.
6. The dSYM exists, its UUID matches the binary, and the checksum is created only after the IPA is complete.
7. Artifact retention is correct and only the permitted role can download it.

On an authorized app or test account, the upload job should verify:

1. The job does not receive the signing-repository password or certificate private key.
2. Changing one byte in the IPA makes checksum verification fail before any Apple call.
3. Upload-only reports `accepted`, not `distributed`.
4. When processing waits are enabled, `Processing`, `Failed`, and `Complete` are mapped separately.
5. Two nearby pipelines are serialized by `resource_group` according to the selected policy.
6. An untrusted merge request cannot see signing or upload jobs or protected secrets.

Do not use a production app to demonstrate a negative test. An accidental upload cannot be “undone” like a local file; use an authorized app or test record, or stop at dry validation until operational approval is available.

### Failure, retry, and rollback

Do not blindly retry the entire build-and-upload lane:

| Failure                                           | Action                                                                         |
| ------------------------------------------------- | ------------------------------------------------------------------------------ |
| Temporary network failure during `bundle install` | Retry with a limit; do not change the lockfile                                 |
| Signing storage is unavailable                    | Check the read-only deploy credential; do not grant write access automatically |
| A profile is missing, expired, or revoked         | Stop the build; update it through the signing-administration workflow          |
| `build_app` or codesign fails                     | Do not upload; retain the logs needed for diagnosis                            |
| Duplicate build number                            | Create a new pipeline and build number                                         |
| Upload timeout                                    | Check App Store Connect before retrying to avoid a duplicate upload            |
| Apple processing fails                            | Report processing failure, fix the binary, and create a new build              |
| Beta App Review is pending or rejected            | Report the review state separately; do not bypass it with a production release |

A safe rollback disables or locks the upload job, preserves the quality and build contracts, and keeps using the latest successfully processed TestFlight build. Uploading a new TestFlight build does not replace the production binary automatically.

If a secret has ever been committed, deleting the file in a new commit is not enough. Rotate or revoke the credential first, then audit Git history, CI logs, caches, and artifacts through the incident process. The public article does not describe how to grant new production roles, certificates, or real keys.

### Trade-offs of separate jobs

This design adds artifact storage, one IPA transfer between jobs, and a manifest to maintain. In return, the pipeline gains clearer provenance, upload retries do not require rebuilds, and each job holds fewer privileges.

For a small project released manually from one controlled machine, two lanes are still useful even if they are not yet separate GitLab jobs. Keep the contract: the upload lane receives an IPA path and never rebuilds it.

If the artifact is too large or retention rules are strict, replace GitLab artifacts with object storage that provides checksums, encryption, and access logs. Do not pass only a public URL or a path on a persistent runner; neither is a trustworthy artifact boundary.

Android uses a different workflow and different credentials from iOS. The Fastlane Google Play article is the parallel branch for Android, but in CI you should still avoid committing a service-account key and separate the build artifact from upload privileges.

{% content-ref url="/pages/Uxl2ZCTTHotwyeBqW6SB" %}
[Fastlane - Google Play](/flutter/my-flutter/quality-delivery/fastlane-google-play.md)
{% endcontent-ref %}

### Verified versions and scope

I inspected the reference source on `2026-09-02` with:

* Flutter `3.41.2` from the FVM configuration.
* Fastlane `2.232.2` from the lockfile.
* CocoaPods `1.16.2` from the lockfile.
* Bundler `4.0.7` from the lockfile.
* Ruby not pinned in the source.
* Xcode, macOS runner, GitLab, and GitLab Runner versions not confirmed.

The successful read-only checks were parsing `.gitlab-ci.yml`, running `ruby -c` on the Fastfile, and running `plutil -lint` on the export options. `bundle check`, `bundle exec fastlane --version`, and `bundle exec fastlane lanes` could not run on the research machine because the exact Bundler `4.0.7` was missing. I did not install dependencies, call `match`, build iOS, or upload to TestFlight during research.

Apple and GitLab can change requirements and keywords, so recheck the documentation and run CI Lint whenever you upgrade Xcode, Fastlane, or GitLab.

### References

* [Fastlane — iOS setup](https://docs.fastlane.tools/getting-started/ios/setup/)
* [Fastlane — setup\_ci](https://docs.fastlane.tools/actions/setup_ci/)
* [Fastlane — match](https://docs.fastlane.tools/actions/match/)
* [Fastlane — build\_ios\_app](https://docs.fastlane.tools/actions/build_ios_app/)
* [Fastlane — App Store Connect API](https://docs.fastlane.tools/app-store-connect-api/)
* [Fastlane — app\_store\_connect\_api\_key](https://docs.fastlane.tools/actions/app_store_connect_api_key/)
* [Fastlane — pilot and upload\_to\_testflight](https://docs.fastlane.tools/actions/pilot/)
* [Apple — Upload builds](https://developer.apple.com/help/app-store-connect/manage-builds/upload-builds/)
* [Apple — TestFlight overview](https://developer.apple.com/help/app-store-connect/test-a-beta-version/testflight-overview)
* [Apple — Invite external testers](https://developer.apple.com/help/app-store-connect/test-a-beta-version/invite-external-testers)
* [Apple — CFBundleVersion](https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleversion)
* [GitLab — CI/CD variables](https://docs.gitlab.com/ci/variables/)
* [GitLab — External secrets](https://docs.gitlab.com/ci/secrets/)
* [GitLab — Job artifacts](https://docs.gitlab.com/ci/jobs/job_artifacts/)
* [GitLab — Resource groups](https://docs.gitlab.com/ci/resource_groups/)
* [GitLab — Predefined variables](https://docs.gitlab.com/ci/variables/predefined_variables/)

## Conclusion

The most important contract is that the signed IPA is built exactly once and becomes an immutable artifact accompanied by its checksum, manifest, and dSYM. The TestFlight job only uploads that artifact; it does not rebuild it.

Signing and uploading are separate privileges, so they should not appear in every lane. With the correct boundaries, the pipeline can say precisely whether it has built, uploaded, completed processing, or distributed a build to testers. App Store submission and go-live remain a separate workflow and must not be coupled implicitly to this TestFlight article.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
