· 5 min read
Xcode Cloud for Flutter: The ci_post_clone.sh Script
How to build a Flutter app on Xcode Cloud with one ci_post_clone.sh script, where the file must live, and the mistakes that break the first build.
Xcode Cloud does not know what Flutter is. Point it at a Flutter repository and the first build fails within a minute, usually with a missing Generated.xcconfig or a missing Pods file. The fix for Xcode Cloud and Flutter is one shell script called ci_post_clone.sh. This post shows the script, where it has to live, and the small mistakes that break the build most often.
Why the first Xcode Cloud build fails
Xcode Cloud does a simple thing. It clones your repository, opens the Xcode workspace and runs xcodebuild. For a native iOS app that is enough.
A Flutter project is different. The ios/ folder in git is not a complete Xcode project. Three things are missing on a clean machine:
- The Flutter SDK. The build machine has Xcode, not Flutter.
ios/Flutter/Generated.xcconfig. Flutter writes this file when you runflutter pub get. It is git-ignored, and the Xcode project includes it.- The
Pods/folder. Most teams do not commit it, sopod installhas to run first.
On your laptop all three already exist, so you never notice. On Xcode Cloud none of them do.
The ci_post_clone.sh script for Xcode Cloud and Flutter
Xcode Cloud runs custom scripts at three fixed points if it finds them in a folder named ci_scripts:
ci_post_clone.shruns right after the clone.ci_pre_xcodebuild.shruns before eachxcodebuildaction.ci_post_xcodebuild.shruns after it.
Apple describes them in Writing custom build scripts. For Flutter you need only the first one. Its job is to make the clean machine look like your laptop before Xcode starts.
Here is the script. It follows the one in the Flutter deployment docs, with two changes I explain below.
#!/bin/sh
set -e
cd "$CI_PRIMARY_REPOSITORY_PATH"
FLUTTER_VERSION="3.x.y" # the version you build with locally
git clone https://github.com/flutter/flutter.git \
--depth 1 -b "$FLUTTER_VERSION" "$HOME/flutter"
export PATH="$PATH:$HOME/flutter/bin"
flutter precache --ios
flutter pub get
HOMEBREW_NO_AUTO_UPDATE=1 brew install cocoapods
cd ios
pod install
Step by step:
- Go to the repo root. The script starts inside
ios/ci_scripts. - Clone a pinned Flutter version and put it on the
PATH. - Download the iOS engine artifacts with
flutter precache --ios. - Run
flutter pub get. This also writesGenerated.xcconfig. - Install CocoaPods with Homebrew and run
pod install.
That is the whole iOS pipeline. After the script ends, Xcode Cloud archives the Runner scheme the same way Xcode does on your machine. Signing and the upload to TestFlight are handled by the workflow you set up in Xcode or App Store Connect. The script does not touch certificates at all.
Change 1: pin the Flutter version
The docs example clones the stable branch. That means your CI can move to a new Flutter release on a day when you changed nothing. When a build then fails, you do not know if it was your commit or the SDK.
Clone a version tag instead, and keep it equal to the version you use locally. Updating Flutter becomes a one-line commit that you can see in history and revert.
Change 2: set -e and no exit 0
With set -e the script stops at the first failed command. Without it, a failed pub get is ignored, the script carries on, and you get a confusing Xcode error five minutes later. Fail early, at the line that actually broke.
Five mistakes that break the build
These are the ones worth checking before you push.
1. The folder is in the wrong place. ci_scripts must sit next to the Xcode project or workspace. In a Flutter repo that is ios/ci_scripts/, not the repository root. If it is in the root, Xcode Cloud never looks at it and gives you no hint.
2. The script is not executable. Git stores the executable bit. Set it once and commit it:
chmod +x ios/ci_scripts/ci_post_clone.sh
git add ios/ci_scripts/ci_post_clone.sh
git commit -m "ci: add Xcode Cloud post-clone script"
3. The script assumes the wrong working directory. It starts in the ci_scripts folder, not in the repo root. Use $CI_PRIMARY_REPOSITORY_PATH instead of relative paths like ../... It reads better and survives moving files around.
4. The workflow points at the project, not the workspace. With CocoaPods you must build Runner.xcworkspace. Also check that the Runner scheme is shared, so that ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme is in git. A default Flutter project already has it.
5. Build-time values are missing. If your app reads --dart-define values, a plain pub get does not pass them. Add the values as environment variables in the workflow, mark the sensitive ones as secret, and write them into the Xcode config from the script:
flutter build ios --config-only --release \
--dart-define=API_URL="$API_URL"
--config-only updates the project configuration and stops. Xcode Cloud does the real build afterwards.
What you no longer have to maintain
I like this setup because of what is absent from it.
There is no signing code. With automatic signing, Xcode Cloud manages certificates and provisioning profiles for the archive. You do not export a .p12, encode it in base64 and paste it into a secrets page.
There is no build number logic. Xcode Cloud gives each build its own number and exposes it as CI_BUILD_NUMBER. You can set the next number in the workflow settings if you need to continue from an existing sequence.
There is no macOS runner to pay for elsewhere. The Apple Developer Program includes a monthly allowance of Xcode Cloud compute hours, while hosted macOS runners on general CI services are billed at a higher rate than Linux ones.
That leads to the split I use: iOS builds on Xcode Cloud, Android builds on GitHub Actions on a Linux runner. Each platform builds where it is cheapest and needs the least glue. I packaged that setup as an open-source skill for AI coding assistants, goodiny777/mobile-cicd-skill, which generates the scripts and workflows for a project.
One honest limit: every build starts on a fresh machine, so the script clones Flutter and installs pods every time. The shallow clone (--depth 1) keeps that step short, but it is not free. For a release pipeline that runs a few times a week, I think that is a fair price for having almost nothing to maintain.
Takeaways
- Xcode Cloud builds a Flutter app once
ci_post_clone.shinstalls Flutter, runsflutter pub getand runspod install. - The script lives in
ios/ci_scripts/and must be executable in git. - Pin the Flutter version with a tag. Do not track
stablein CI. - Use
set -eand$CI_PRIMARY_REPOSITORY_PATHso failures are early and paths are clear. - Leave signing and build numbers to Xcode Cloud. The less your pipeline does, the less can break.