> 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/release-orchestration-scheduled-golive-android-ios.md).

# Release Orchestration and Scheduled Go-Live for Android and iOS

Orchestrate Android and iOS releases with immutable artifacts, protected environments, scheduled gates, and explicit store states

## Result

A scheduled pipeline does not become a go-live pipeline merely because it runs on time. A reliable release must lock the source, binary, and policy before changing any state in Google Play or the App Store.

After tracing the GitLab pipeline, Fastfile, Makefile, and release scripts in the reference source, I chose this target architecture:

```
Release proposal
  version + notes + source SHA
              │ review/merge
              ▼
Protected release tag
              │
      required quality gates
              │
       ┌──────┴──────┐
       ▼             ▼
Build Android     Build iOS
       │             │
 AAB + hash       IPA + hash
       └──────┬──────┘
              ▼
     Verify release manifest
              │
       ┌──────┴──────┐
       ▼             ▼
Submit Android    Submit iOS review
protected env     protected env
resource group    resource group
       │             │
store state poll / callback
       │             │
       ├── Android rollout approval
       └── iOS release approval
              │
         Observe + halt
```

Android and iOS are each built exactly once from the same release source. Downstream jobs receive only the AAB/IPA, manifest, and checksum from the build job; they do not run `flutter build` again during upload or review submission.

The observable outcomes are:

* A release ID locks the version, build number, source SHA, and toolchain used.
* The AAB/IPA is stored as an immutable artifact with a SHA-256 checksum verified before every mutation.
* A scheduled job only prepares a proposal or checks whether the release is eligible; it does not automatically receive all production permissions.
* Android and iOS use two separate protected environments and two separate `resource_group` values.
* Retrying the same release ID never uploads or releases a different binary.
* Notifications distinguish `uploaded`, `processing`, `submitted`, `approved`, `rolling_out`, `available`, and `halted`.
* A failure on one platform does not falsely report the other platform as not started or mark the entire release green.

This is a target architecture derived from the source, not a claim that the current production pipeline already operates this way. I verified only source and syntax; I did not build, sign, upload, submit for review, or release a real app.

## Problem

The current flow uses a job with a go-live-like name to run a release preparation script:

```
scheduled pipeline
        │
        ├── skips tests/quality scans through a schedule variable
        └── runs the release preparation script
                    │
                    ├── retrieves release notes from an external system
                    ├── increments version/build number
                    ├── updates Android/iOS metadata
                    ├── creates or checks out a release branch
                    ├── stages the entire working tree
                    ├── commits + pushes
                    └── creates a merge request

Actual outcome: a release proposal has been created
Not: a binary has been built, accepted by a store, or delivered to users
```

The job name obscures the boundary. This schedule prepares a version, metadata, and merge request; it does not create an AAB/IPA, submit to a store, or prove that users have received the app.

Downstream jobs also rebuild the source inside the submission lane. Therefore, the binary previously tested or distributed internally is not necessarily the binary sent to a store. Even the same commit SHA can produce different output when the toolchain, dependency cache, timestamp, signing input, or post-processing changes.

The Android store lane calls `supply` without explicitly setting `track`, `release_status`, or `rollout`. Fastlane currently defaults these to `production` and `completed`, respectively. A release job must not depend on defaults that can send a binary directly to production.

The iOS store lane uses `submit_for_review: true` and `automatic_release: false`. A green job proves only that the submission request completed; Apple still has processing, App Review, and developer release stages. Calling this state “go-live succeeded” sends notifications ahead of reality.

The pipeline also does not declare an `environment`, `resource_group`, or deployment approval for store jobs. Two pipelines can mutate the same track or version while a schedule runs with the schedule owner's permissions.

During research, I also found credential-like files and secret literals tracked in the release area. This article does not reproduce their names or values. A secret that has entered Git must be treated as exposed to anyone who can read its history and rotated or revoked through a separate process.

## Solution

### Start after the required quality gate

Release orchestration starts only after analysis, tests, and the quality gate pass. A release preparation job must not use a schedule variable to skip those gates and then proceed to production.

The GitLab CI article below owns the base pipeline, exit codes, and artifact contract. This article only adds stages with release permissions.

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

The Sonar gate must also be a required job before release builds. If the scanner remains manual or is allowed to fail, finish the gate before connecting store credentials to the pipeline.

{% content-ref url="/pages/ZZRdEyLYqf40AkIrL89T" %}
[Sonar Quality Gate for Flutter Monorepo](/flutter/my-flutter/quality-delivery/sonar-quality-gate-flutter.md)
{% endcontent-ref %}

I use a protected release tag as the build entry point. If the organization uses an approved commit instead of a tag, the manifest must still record the full SHA, and the pipeline must reject source changes after approval.

### Distinguish the nine release states

```
1. Planned
       │ release manifest + approved source SHA
2. Built
       │ immutable AAB / IPA + checksum
3. Verified
       │ quality, signature, version, size policy
4. Uploaded
       │ store accepted binary; processing may continue
5. Processed
       │ binary can be selected by the store workflow
6. Submitted
       │ review or track mutation requested
7. Approved / Ready
       │ eligible for release, not necessarily public
8. Rolling out / Available
       │ some or all users can receive the version
9. Halted / Completed / Superseded
```

Each platform has its own state machine. The overall release is complete only when policy explicitly defines the terminal state for both, such as Android `completed` and iOS `Ready for Distribution`.

Schedule, approval, and store state answer three different questions:

```
Approval: may this release change production?
Schedule: what is the earliest time the job may check whether it can run?
Store state: is the binary ready and eligible for a state transition?
```

A schedule does not replace approval. It only provides the earliest time at which the pipeline can query the evidence and state again.

### Create a release manifest without secrets

The manifest locks the release identity and policy:

```yaml
schema_version: 1
release_id: "v1.4.0+240"
source:
  commit_sha: "<full-commit-sha>"
  tag: "v1.4.0"
toolchain:
  flutter: "<pinned-version>"
  fastlane: "<locked-version>"
android:
  artifact: "artifacts/android/app.aab"
  sha256: "<sha256>"
  version_name: "1.4.0"
  version_code: 240
  target_track: "<approved-track>"
  release_status: "<draft|inProgress|completed>"
  rollout_fraction: null
ios:
  artifact: "artifacts/ios/app.ipa"
  sha256: "<sha256>"
  short_version: "1.4.0"
  build_number: 240
  release_mode: "<manual|phased>"
release_notes:
  vi: "release-notes/vi.txt"
  en: "release-notes/en.txt"
```

`release_id` is the audit and idempotency key. A mutation key can combine the platform, application, version, build, and target state:

```
<platform>:<application>:<version>:<build>:<target-state>
```

The manifest contains no service-account JSON, API key, cookie, signing password, or private endpoint. A retry first queries the store using this key; if the target state has already been reached with the exact build, the job ends as an idempotent no-op.

### Prepare releases with an allowlist

Scheduled preparation should only create a proposal and must not receive store credentials. It performs these steps:

1. Receive a reviewed version, source ref, and release notes.
2. Validate the semantic version, build number, locale, length, and required fields.
3. Verify that the release ID does not already exist.
4. Change only `pubspec.yaml`, release notes, and metadata on an allowlist.
5. Fail if the working tree contains files outside the allowlist.
6. Create the branch/merge request idempotently; if it already exists, return the existing link.
7. Fail on HTTP non-2xx responses and validate IDs in the response.

A minimal preflight:

```bash
set -euo pipefail

test -n "${RELEASE_VERSION:-}"
test -n "${RELEASE_SOURCE_SHA:-}"
test -s release-notes/vi.txt
test -s release-notes/en.txt

case "$RELEASE_VERSION" in
  [0-9]*.[0-9]*.[0-9]*) ;;
  *) echo "Invalid release version" >&2; exit 1 ;;
esac

allowed='^(pubspec.yaml|release-notes/|fastlane/metadata/)'
changed="$({ git diff --name-only HEAD --; git ls-files --others --exclude-standard; } | sort -u)"
unexpected="$(printf '%s\n' "$changed" | awk -v pattern="$allowed" 'NF && $0 !~ pattern')"
test -z "$unexpected"
```

This pattern illustrates the contract; it is not a complete semantic-version parser. A repository should use a tested validator instead of extending the shell pattern until it becomes unreadable.

Do not use `git add .`. If the version or release notes cannot be found, the job must fail with a `blocked-input` state instead of returning green as a no-op.

### Use typed inputs when GitLab supports them

GitLab introduced scheduled pipeline inputs in GitLab 17.11. When the instance supports them, I apply the allowlist when the pipeline is created:

```yaml
spec:
  inputs:
    release_action:
      options: [prepare, submit, release, halt]
      default: prepare
    platform:
      options: [android, ios, both]
      default: both
    release_version:
      type: string
      default: ""
---
```

An input is configuration, not a secret. Store credentials remain in protected variables or a secret manager and are scoped to the environment.

The source does not confirm the GitLab version. If the instance does not support scheduled inputs, use a variable with an exact allowlist or regex in the first command. Unknown or empty values must fail; never fall back to a production default.

### Build the AAB and IPA exactly once

The build jobs receive the approved source and create artifacts plus platform manifests:

```yaml
build_android_release:
  stage: build_release
  needs:
    - job: release_quality_gate
  script:
    - ./ci/build_android_release.sh
    - ./ci/write_android_manifest.sh
  artifacts:
    expire_in: 30 days
    paths:
      - artifacts/android/app.aab
      - artifacts/android/manifest.json
      - artifacts/android/app.aab.sha256

build_ios_release:
  stage: build_release
  needs:
    - job: release_quality_gate
  script:
    - bundle exec fastlane ios build_release_artifact
    - ./ci/write_ios_manifest.sh
  artifacts:
    expire_in: 30 days
    paths:
      - artifacts/ios/app.ipa
      - artifacts/ios/app.dSYM.zip
      - artifacts/ios/manifest.json
      - artifacts/ios/app.ipa.sha256
```

This is target configuration, not the names of lanes currently present in the source. Artifact retention must follow the organization's release and audit policy; `30 days` is only an illustrative value.

`release_quality_gate` represents the required gate in the protected-tag pipeline itself. If the organization reuses evidence from an earlier pipeline instead of rerunning it, this job must prove that the tag points to the exact approved full SHA; comparing only a version or branch name is insufficient.

The Fastlane iOS article already covers building and signing an IPA in detail. It also explains why an upload job must not invoke a build again.

{% content-ref url="/pages/AfcP6VFNqQRoYmvOMT1Z" %}
[Fastlane iOS, Code Signing, and TestFlight](/flutter/my-flutter/quality-delivery/fastlane-ios-code-signing-testflight.md)
{% endcontent-ref %}

Flavor and scheme are reviewed build inputs, not values inferred by the submission job. If the project does not clearly separate environments yet, stabilize its flavors first.

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

### Verify artifacts before store mutations

`verify_release` receives artifacts from both builds and checks that:

* The source SHA, tag, version, and build number match the release manifest.
* The AAB/IPA exists, is not empty, and has the correct checksum.
* The Android package/version code and iOS short version/build number are correct.
* The binary was signed with an identity allowed by policy.
* Release notes include every required locale.
* Size or performance results pass when they are required gates.

The checksum preflight can run in a Linux verification job:

```bash
set -euo pipefail

sha256sum --check artifacts/android/app.aab.sha256
sha256sum --check artifacts/ios/app.ipa.sha256

test "$(jq -r '.source.commit_sha' release-manifest.json)" = "$CI_COMMIT_SHA"
test "$(jq -r '.android.version_code' release-manifest.json)" -gt 0
test "$(jq -r '.ios.build_number' release-manifest.json)" -gt 0
```

A checksum proves that an artifact did not change after the hash was created; it does not by itself prove the signing identity or source provenance. Those require platform-specific metadata and verification tools.

### Protect production environments and serialize jobs

A store job must declare its environment so GitLab can attach the correct permissions and approvals, and use `resource_group` to prevent two pipelines from mutating the same store concurrently:

```yaml
submit_android_store:
  stage: submit_store
  environment:
    name: production/android-store
    deployment_tier: production
  resource_group: production-android-store
  needs:
    - job: verify_release
      artifacts: true
  script:
    - bundle exec fastlane android upload_existing_aab manifest:release-manifest.json
  rules:
    - if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/'
      when: manual
    - when: never
  allow_failure: false

submit_ios_review:
  stage: submit_store
  environment:
    name: production/ios-store
    deployment_tier: production
  resource_group: production-ios-store
  needs:
    - job: verify_release
      artifacts: true
  script:
    - bundle exec fastlane ios upload_existing_ipa manifest:release-manifest.json
  rules:
    - if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/'
      when: manual
    - when: never
  allow_failure: false
```

Separate resource groups let Android and iOS run independently while serializing mutations on each platform. Protected environments and approvals are governance policies outside YAML; this article does not explain how to grant production access to yourself.

If the GitLab version and tier support `manual_confirmation`, its message should name the platform, version/build, and target state. Otherwise, the manual job and protected-environment approval remain the boundary; do not simulate approval with a Boolean variable.

### Orchestrate Android tracks and staged rollouts

The Android lane receives an existing AAB and explicitly sets every value that affects production:

```ruby
lane :upload_existing_aab do |options|
  manifest = JSON.parse(File.read(options[:manifest]))
  android = manifest.fetch("android")

  params = {
    aab: android.fetch("artifact"),
    track: android.fetch("target_track"),
    release_status: android.fetch("release_status"),
    skip_upload_metadata: true,
    skip_upload_images: true,
    skip_upload_screenshots: true
  }

  rollout = android["rollout_fraction"]
  params[:rollout] = rollout unless rollout.nil?

  supply(**params)
end
```

Do not hard-code a universal rollout percentage. The track, status, and fraction come from the approved manifest. The validator must reject fractions outside the policy's allowed range or a fraction paired with an incompatible status. Do not let Fastlane select its production/completed defaults.

| State                          | Meaning                                             | Fully live?         |
| ------------------------------ | --------------------------------------------------- | ------------------- |
| Artifact verified              | AAB/hash/version is correct                         | No                  |
| Uploaded internal/closed track | Selected testers can receive it after processing    | No                  |
| Production `inProgress`        | A staged rollout serves a percentage of users       | No                  |
| Production `halted`            | The rollout does not expand to more users           | No                  |
| Production `completed`         | Rollout is marked complete, but propagation remains | Verify in the store |

Halting does not downgrade users who have already updated. When a binary is defective, the team needs a fixed build with a higher version code or a kill switch designed in advance.

### Orchestrate iOS review and release

iOS separates three mutations:

1. Upload the signed IPA and wait for processing.
2. Select the exact build and metadata, then submit it to App Review.
3. After approval, release manually or start a phased release according to policy.

The submission lane only receives an existing IPA:

```ruby
lane :upload_existing_ipa do |options|
  manifest = JSON.parse(File.read(options[:manifest]))
  ios = manifest.fetch("ios")

  deliver(
    ipa: ios.fetch("artifact"),
    submit_for_review: true,
    automatic_release: false,
    phased_release: ios.fetch("release_mode") == "phased"
  )
end
```

`automatic_release: false` keeps post-approval release as a separate mutation. `phased_release: true` configures a phased update; it does not turn an upload or review submission into an immediate release.

| State                          | Meaning                                                  | Live?                       |
| ------------------------------ | -------------------------------------------------------- | --------------------------- |
| Uploaded                       | The binary was accepted and may still be processing      | No                          |
| Waiting for Review / In Review | App Review is processing the submission                  | No                          |
| Pending Developer Release      | Approved but waiting for release                         | No                          |
| Phased Release                 | Some users receive the version through automatic updates | Partially                   |
| Ready for Distribution         | The version was released according to availability       | Yes, with propagation delay |

Do not occupy a runner with a multi-hour sleep while waiting for App Review. The pipeline records the store build/version ID, then a scheduled observer job with a bounded timeout or a verified callback queries it again.

### Make scheduled go-live idempotent

The go-live job may run only when:

* The release manifest has been approved.
* The artifact checksum and store build identity have been verified.
* The store is in an eligible state.
* The current time has passed the earliest release time.
* Environment approval is valid.
* No freeze or active mutation exists on the same platform.

The job flow is:

```
load manifest
→ verify source SHA + artifact/store build ID
→ query current store state
→ if target is already reached: succeed as an idempotent no-op
→ if state is not eligible: fail blocked-state
→ request rollout/release
→ store the redacted response ID
→ poll with a bounded timeout
→ emit the exact observed state
```

If iOS has not been approved at the scheduled time, the job returns `blocked-review`; it does not rebuild or upload again, and it does not release Android again in an attempt to “synchronize” the platforms. Their states may diverge, but both platforms use the same release ID and audit contract.

### Record notifications and audits with exact states

Notifications are generated from state records instead of lane names:

```json
{
  "release_id": "v1.4.0+240",
  "platform": "ios",
  "state": "submitted_for_review",
  "artifact_sha256": "<sha256>",
  "pipeline_url": "<pipeline-url>",
  "store_build_id": "<redacted-id>",
  "observed_at": "<iso-8601>"
}
```

At minimum, the audit trail must answer who prepared and approved the release, which source SHA created the binary, which hash was submitted, which job changed the state, and the final store state that was observed.

Do not put tokens, cookies, account emails, private endpoints, or full store responses in logs or artifacts. A notification failure retries only the notification; it neither rolls back the store mutation nor calls the upload again.

### Failure, retry, halt, and rollback

| Failure                                   | Action                                                          |
| ----------------------------------------- | --------------------------------------------------------------- |
| Missing version/release notes             | Fail preparation; do not create an empty proposal               |
| Release ID already exists                 | Query the existing record; do not create a duplicate            |
| File outside the allowlist changed        | Fail before commit; do not stage the entire working tree        |
| API returns non-2xx                       | Fail with a redacted response                                   |
| Artifact hash mismatch                    | Stop before the store; create a new pipeline/release ID         |
| Store already has the exact build         | Idempotent success after verifying identity/state               |
| Same version but conflicting binary       | Fail with a conflict; do not overwrite blindly                  |
| Store processing timeout                  | Keep the `processing` state and poll again with limits          |
| Android rollout failure                   | Halt and prepare a higher version code if needed                |
| iOS phased-release failure                | Pause and use a fixed version/kill switch under incident policy |
| One platform succeeds and the other fails | Record a partial state; do not report the release complete      |
| Notification failure                      | Retry notification separately; do not repeat the store mutation |

Rollback does not mean reinstalling an older binary on devices. Android halt and iOS pause only stop the rollout from expanding; users who already received the version need a hotfix or a compatible kill switch.

Remote Config can reduce the blast radius when a feature was designed behind a feature flag. It does not replace artifact rollback or store release policy.

{% content-ref url="/pages/VlHUWEnrJDCc9sRZw2rf" %}
[Firebase Remote Config and feature flags](/flutter/my-flutter/security-observability/firebase-remote-config-feature-flags.md)
{% endcontent-ref %}

### Verify before enabling production mutations

Static checks do not require store credentials:

```bash
ruby -e 'require "yaml"; YAML.safe_load(File.read(".gitlab-ci.yml"), aliases: true)'
ruby -c fastlane/Fastfile
zsh -n auto_release.sh
zsh -n update_release_note.sh
zsh -n notify_golive_thread.sh
git diff --check -- .gitlab-ci.yml fastlane/Fastfile auto_release.sh
```

These commands check only syntax and static parsing. They do not prove that signing, permissions, or store APIs work.

Contract tests should cover:

1. An invalid version or release note fails before changing the repository.
2. Preparing the same release ID twice returns only one proposal.
3. A working tree file outside the allowlist causes a failure.
4. HTTP fixtures for 401/403/409/429/500 return classified failures.
5. A wrong source SHA or artifact checksum fails before the store.
6. A retry when the store already has the exact build is a no-op.
7. Two Android release pipelines are serialized; likewise for iOS.
8. A notification failure does not call the store mutation again.

Minimum acceptance cases on an authorized test project or store sandbox:

| Case                                               | Android             | iOS             | Overall release |
| -------------------------------------------------- | ------------------- | --------------- | --------------- |
| Both artifacts verified, not submitted             | `verified`          | `verified`      | Not released    |
| Android internal, iOS processing                   | `uploaded_internal` | `processing`    | Partial         |
| Android rollout, iOS waiting for developer release | `rolling_out`       | `ready_manual`  | Partial         |
| Android halted, iOS phased release paused          | `halted`            | `paused`        | Incident        |
| Android completed, iOS available                   | `completed`         | `available`     | Complete        |
| Duplicate schedule for the same release ID         | No new mutation     | No new mutation | Idempotent      |

Do not test a destructive case on a production app merely to prove the article.

### Related articles

The basic Android Fastlane flow and service-account setup already have a dedicated article. This article owns track/state, artifact provenance, and orchestration; it does not repeat how to grant Google Play permissions.

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

Firebase App Distribution is appropriate for tester builds before a store release. A successful upload there is not a production go-live.

{% content-ref url="/pages/g61dHdAGfBEB5glZyihO" %}
[Firebase App Distribution (Android)](/flutter/my-flutter/quality-delivery/firebase-app-distribution.md)
{% endcontent-ref %}

### Verified versions and scope

The reference source was inspected on `2026-09-02` with:

* Flutter `3.41.2` from `.fvmrc`.
* Dart SDK from `3.11.0` up to but excluding `4.0.0`.
* Fastlane `2.232.2` from `Gemfile.lock`.
* CocoaPods `1.16.2`.
* Bundler `4.0.7`.
* GitLab, GitLab Runner, executor, tier, schedule owner, and protected environment were not confirmed.

Passing read-only checks included parsing the GitLab YAML, Ruby syntax for the Fastfile, shell syntax for three release/notification scripts, tracing the build/submit call graph, and inventorying release files tracked by Git. I did not use credentials or call the real GitLab, Google Play, App Store Connect, or notification APIs.

### References

* [GitLab — Scheduled pipelines](https://docs.gitlab.com/ci/pipelines/schedules/)
* [GitLab — CI/CD inputs](https://docs.gitlab.com/ci/inputs/)
* [GitLab — Deployment safety](https://docs.gitlab.com/ci/environments/deployment_safety/)
* [GitLab — Protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
* [GitLab — CI/CD YAML syntax](https://docs.gitlab.com/ci/yaml/)
* [Fastlane — `supply`](https://docs.fastlane.tools/actions/supply/)
* [Fastlane — `deliver` / appstore](https://docs.fastlane.tools/actions/appstore/)
* [Google Play Developer API — APKs and tracks](https://developers.google.com/android-publisher/tracks)
* [Google Play Console — Staged rollouts](https://support.google.com/googleplay/android-developer/answer/6346149)
* [Apple — Overview of publishing an app](https://developer.apple.com/help/app-store-connect/manage-your-apps-availability/overview-of-publishing-your-app-on-the-app-store)
* [Apple — Release a version update in phases](https://developer.apple.com/help/app-store-connect/update-your-app/release-a-version-update-in-phases)

## Conclusion

A schedule only determines when a pipeline may check the release conditions. The manifest, immutable artifacts, checksums, store state, and protected approval determine which binary is released and who may change production.

When Android and iOS share a release ID but retain separate states, the pipeline can handle partial success, retries, and halts without falsely claiming that both platforms are live. This approach fits auditable scheduled releases; it does not replace App Review, store propagation, or incident policy.

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