Replacing Xcode Cloud with ASC CLI for Faster, Local Builds

Xcode Cloud gives you 25 hours per month of cloud compute with the Apple Developer Program. If your goal is shipping to TestFlight or the App Store on every git push or PR merged, I would still use it. It is built for that exact thing.
But at the last startup I worked at, I mostly remember being annoyed by the speed of it. Sometimes even a small app could take 20 or 30 minutes by the time the runner started, archived, uploaded, processed, and finally showed up after the meeting was over where we wanted to discuss a small change.
It is much better when I am already sitting in front of a MacBook M5 that could have done the job locally (and faster) while I am reviewing the code diff in Cursor.
When I started working on asccli.sh, I obviously wanted the App Store Connect side automated. But I also wanted a simple release pipeline that let me stay local: bump the build number, archive, export, upload, and send it to an external TestFlight group without leaving terminal.
Xcode Cloud and Git Push
This is the part I do not want to pretend otherwise about. If your ideal workflow is:
- Push commit
- Let a remote machine do the rest
- Get a TestFlight build while your laptop stays asleep
Xcode Cloud it is. If I were setting up a team workflow where every branch push needs a predictable cloud pipeline, I would still reach for it first and I want a cleaner remote environment where less local state involved.
The Local Release Loop
What I wanted was a different loop. I was already at the machine and knew I wanted to ship a build. I did not want to wait for a remote runner, because I am still learning the art of patience. The shape I had in mind was simple:
asc builds latest --app "APP_ID" --version "2.1.0" --platform IOS --next
asc xcode version edit --build-number "NEXT_BUILD"
asc xcode archive ...
asc xcode export ...
asc builds upload ...
asc builds add-groups ... --submit --confirm
That is the nice part about asc now. The App Store Connect half and the local Xcode half can live in the same CLI.
First I can ask App Store Connect for the next safe build number:
asc builds latest --app "APP_ID" --version "2.1.0" --platform IOS --next
Then I can bump the project locally:
asc xcode version edit --build-number "NEXT_BUILD"
Or if I do not care about syncing to the next remote number and just want to increment locally:
asc xcode version bump --type build
After that, the rest is just local compute:
asc xcode archive \
--workspace App.xcworkspace \
--scheme App \
--archive-path .asc/artifacts/App.xcarchive \
--overwrite \
--output json
asc xcode export \
--archive-path .asc/artifacts/App.xcarchive \
--export-options ExportOptions.plist \
--ipa-path .asc/artifacts/App.ipa \
--overwrite \
--output json
And then the App Store Connect side picks up again:
asc builds upload \
--app "APP_ID" \
--ipa .asc/artifacts/App.ipa \
--wait
asc builds add-groups \
--build "BUILD_ID" \
--group "External Testers" \
--submit \
--confirm
That is already enough to replace the Xcode Cloud release loop when I am working locally. If I want the shorter version, asc publish testflight can collapse the upload and distribution part into one command:
asc publish testflight \
--app "APP_ID" \
--ipa .asc/artifacts/App.ipa \
--group "External Testers" \
--wait
I still like the more explicit version while building the workflow itself, because it is easier to see where time is going and where things fail.
The Release Workflow
The release loop got more interesting for me once asc workflow became good enough to keep this pipeline inside the repo.
That means I can commit a .asc/workflow.json, validate it, dry-run it, and run the exact same flow locally or in CI later.
Here is the simple local beta pipeline for Foundation Lab:
{
"env": {
"APP_ID": "6747745091",
"VERSION": "1.0",
"PROJECT": "/Users/rudrank/Developer/Apps/Foundation-Models-Framework-Example/FoundationLab.xcodeproj",
"SCHEME": "Foundation Lab",
"ARCHIVE_PATH": ".asc/artifacts/App.xcarchive",
"IPA_PATH": ".asc/artifacts/App.ipa",
"GROUP_ID": "e601afa2-e59f-472f-9280-e35ab2f9bbe9"
},
"workflows": {
"beta-local": {
"description": "Build locally and push to external TestFlight",
"steps": [
{
"name": "resolve_next_build",
"run": "asc builds latest --app $APP_ID --version $VERSION --platform IOS --next",
"outputs": {
"NEXT_BUILD": "$.nextBuildNumber"
}
},
{
"name": "bump_build",
"run": "asc xcode version edit --project $PROJECT --build-number ${steps.resolve_next_build.NEXT_BUILD}"
},
{
"name": "archive",
"run": "asc xcode archive --project $PROJECT --scheme \"$SCHEME\" --archive-path $ARCHIVE_PATH"
},
{
"name": "export",
"run": "asc xcode export --archive-path $ARCHIVE_PATH --export-options ExportOptions.plist --ipa-path $IPA_PATH"
},
{
"name": "publish_beta",
"run": "asc publish testflight --app $APP_ID --ipa $IPA_PATH --group $GROUP_ID --wait"
}
]
}
}
}
Then the flow becomes:
asc workflow validate
asc workflow run --dry-run beta-local
asc workflow run beta-local
The workflow runner is not just a thin shell wrapper as it can pass structured outputs from one step into the next.
That ${steps.resolve_build.BUILD_ID} syntax means one step can emit machine-readable state and the next one can consume it without weird shell parsing. This is exactly the kind of thing I wanted for agents in terminal. Not "please scrape this log output and hope the format did not change," but "here is the value from the previous step, now keep going."
If I am already at the machine and in terminal, I want the shortest path from "this change is done" to "external testers have it." instead of relying on Xcode Cloud.
As the profiles are already configured on the machine, it becomes a faster way to send builds!
Happy shipping!