The first version of my Slack integration stopped at notification. A message could tell me that a build had completed, but it could not connect the request, the pull request, the archived commit, and the TestFlight release into one traceable operation.
That gap matters when an app has several branches and several possible build systems. “The build passed” is not enough. I need to know which commit passed, which version was archived, which tester group received it, and whether the release that Apple accepted is the one I intended to ship.
The current loop starts and ends in one Slack thread:
Slack request
│
▼
GitHub PR ── manual verification ──> Xcode Cloud
│ │
│ ▼
└────────────── green exact commit ──> release/<version>.<build>
│
▼
private internal TestFlight
│
▼
Apple accepted webhook
│
▼
merge + immutable tag
This is part three of Building Indie Ops. Part one covers the Slack and provider layer. Part two covers durable feedback and GitHub triage.
The example in this post is PassMaker, the first app for which I wired the complete Xcode Cloud path. The workflow is designed to be reusable across the rest of the portfolio, but Apple configuration is still app-specific and must be explicitly registered.
Xcode Cloud Is the Build Authority
I moved the hosted iOS path to Xcode Cloud. The important part is not the vendor name; it is the boundary:
- GitHub owns source control and pull requests.
- Indie Ops owns the Slack request, correlation, permissions, and durable state.
- Xcode Cloud owns the macOS build, tests, archive, and TestFlight distribution.
- App Store Connect owns the terminal release state.
Indie Ops never pretends that a GitHub Actions job starting an Xcode Cloud run is the same as an Xcode Cloud result. The Linux GitHub Action only starts the workflow. The authenticated Xcode Cloud webhook is the terminal source of truth and maps the result back to the originating GitHub pull request and Slack thread.
The personal App Store Connect team is the only approved scope for this automation. A repository registry must contain the team's Xcode Cloud ID and both workflow IDs before Slack build commands are enabled. An automation that can select an arbitrary Apple team is a bug, not a flexible configuration system.
The Development Loop Is Deliberately Manual
The normal sequence is:
- Mention Cursor in an app's Slack channel with a bug or idea.
- Cursor creates a structured GitHub issue, implements it on a dedicated branch, and opens an unmerged pull request.
- Reply
Please build and test this PRin the same thread. - Indie Ops starts the manual Xcode Cloud PR Verification workflow for the exact pull-request branch.
- If verification fails, Cursor fixes the same branch and the command is requested again.
- After the exact head passes, reply
Make a new build. - Indie Ops reserves the version/build identity, creates a release branch, and lets Xcode Cloud distribute it to the private internal TestFlight group.
- App Store acceptance triggers the automatic finishing path.
Pull-request creation and pushes do not start Xcode Cloud automatically. That is intentional. An iOS build consumes a different kind of resource from a TypeScript check, and an agent pushing a half-finished commit should not silently create a TestFlight artifact.
The Slack message is the approval boundary for the expensive or externally visible steps.
Starting Verification Without a macOS Runner
The manual verification action runs on Linux and uses a pinned asc CLI release to start Xcode Cloud:
name: On-demand Xcode Cloud build
on:
workflow_dispatch:
inputs:
app_slug:
required: true
type: string
workflow_id:
required: true
type: string
branch:
required: true
type: string
request_id:
required: true
type: string
The important inputs are the app mapping, the registered workflow ID, the source branch, and the request ID used for idempotency. The action installs the exact CLI version, verifies its checksum, authenticates with the read-only App Store Connect key, and runs:
asc xcode-cloud run \
--app "$APP_ID" \
--workflow-id "$WORKFLOW_ID" \
--branch "$SOURCE_BRANCH" \
--output json
The action does not wait for the build and does not spend a macOS runner reproducing work Xcode Cloud is about to perform. The Xcode Cloud webhook later reports the build result with the commit it actually built.
That separation fixed a confusing earlier design where a GitHub Actions success could be mistaken for an iOS build success. Starting the remote workflow is one state. The remote workflow finishing is another.
Stale Results Must Fail Closed
An agent can push a new commit after a build has started. If the old build reports success later, attaching it to the current pull request without checking the SHA would produce a false green result.
Indie Ops correlates the Xcode Cloud result with the request ID and the expected pull-request head. A terminal result whose commit no longer matches is stale. It is recorded, but it cannot approve the release path.
The same rule applies to a release branch. The version commit, the archived commit, the App Store version, and the final tag must all point to the same intended release. A passing build is not a transferable certificate for a different source tree.
Reserving a Release Identity
After verification passes, Make a new build does more than start another workflow. It reserves a semantic version and a build suffix, then creates a branch such as:
release/2.4.0.3
The release branch is created from the verified SHA. Indie Ops commits the new MARKETING_VERSION and CURRENT_PROJECT_VERSION into the Xcode project before Xcode Cloud observes the branch.
This distinction matters because the version in the repository and the build number assigned by Apple are not the same identity. A reservation such as 2.4.0 (3) can produce an Xcode Cloud build with a different internal build number. The branch and tag describe my release reservation; Apple's build record describes its own artifact identity.
The repository's ci_scripts/ci_post_clone.sh is an idempotent identity check and fallback. It is not the primary version allocator. The source branch must already contain the version commit when Xcode Cloud starts.
Private TestFlight Is a Distribution Boundary
The release workflow watches only branches beginning with release/. It archives the app and distributes it to the configured private internal TestFlight group.
There is no ad-hoc or hosted device-preview fallback hidden behind the same Slack command. Remote testing uses the production bundle and private internal TestFlight. A separate development bundle would need its own App Store Connect app record, Xcode Cloud product, signing configuration, and release policy.
That separation is now explicit because the old hosted device-preview experiment created too many overlapping identities. The hosted Slack preview command is retired. Local installable previews can return later through a local-Mac workflow with the two-bundle-ID setup documented independently from the Xcode Cloud release loop.
Apple Acceptance Finishes the Release
The old workflow required a human to remember one more command after App Store Connect accepted a release. The new path uses Apple's signed webhook as the trigger and still verifies the result through asc before mutating GitHub.
The callback workflow:
- Receives the event ID, app slug, App Store version ID, and the release context.
- Authenticates with the read-only App Store Connect key.
- Fetches the version by ID and verifies its version string and accepted state.
- Posts a signed success or failure callback to Indie Ops.
- Indie Ops checks that the event and release are still pending.
- The verified release pull request is merged into the default branch.
- The superseded implementation pull request is closed.
- The reserved immutable
v2.4.0.3tag is pushed on the exact archived commit.
The callback uses if: always() so failed authentication or a changed Apple response shape still produces a controlled failure state. Without that, the Slack thread would remain on “waiting for acceptance” and the operator would not know whether Apple, GitHub, or the callback had failed.
Finish the release remains available as a recovery command. It is not part of the normal success path.
What I Removed Along the Way
Automatic builds for every pull request
It is easy to configure and hard to operate. Every exploratory commit becomes a remote build, agent retries become duplicate work, and the build surface no longer communicates approval. Manual verification is a more honest signal.
Treating Codemagic as a permanent second authority
Codemagic helped me prove the shape of the workflow, but keeping two hosted iOS authorities meant two signing models, two sets of status semantics, and two possible release artifacts. Once Xcode Cloud handled verification and private TestFlight distribution, I removed the duplicate path instead of keeping it as an undocumented fallback.
Using a finish command as the release design
A recovery command is useful. A release system that depends on somebody remembering it is not. The acceptance webhook now drives the normal merge and tag path, while the command exists for repair.
Assuming a successful run proves the release
Verification, release archiving, TestFlight availability, and App Store acceptance are separate states. Each transition has its own identity and callback. Compressing them into one green check made failures hard to diagnose and stale artifacts easy to accept.
The Credential Boundaries
The Slack token stays in the Worker. The App Store Connect key is available only to the short-lived runner that needs it. The Xcode Cloud webhook has its own secret. GitHub receives status and issue/PR links, but it does not receive the Slack bot credential.
The Worker validates the app against the registry before selecting an App Store Connect app ID or workflow ID. It accepts only the registered personal team and the two approved PassMaker workflows. A signed callback is still checked against the pending release and expected commit before it can merge or tag anything.
The result is a chain of narrow permissions:
Slack user approval
│
▼
Indie Ops route + release state
│
├── GitHub token: branch, PR, status, tag
├── ASC key: read and start approved workflows
└── Xcode Cloud webhook secret: terminal build event
No single message contains enough information to select an arbitrary repository, Apple team, or release artifact.
The Result
I can now look at one Slack thread and answer:
- which pull request was requested for verification;
- which commit Xcode Cloud actually built;
- whether that exact commit passed;
- which version/build identity was reserved;
- whether the private TestFlight archive was distributed;
- whether Apple accepted the intended App Store version; and
- whether the immutable tag was created on the archived commit.
That is a better result than “the build passed” because it preserves the chain of custody from request to artifact.
The broader lesson is the same one that shaped the feedback flow: make each boundary explicit, give every asynchronous operation an identity, and let the system record an honest intermediate state. Slack is a convenient place to ask for work. It is not a substitute for the state machine underneath it.