iOS quickstart
Put Greenroom on an iOS app that builds for the Simulator.
The iOS setup is the same four files as web, with one structural difference: your workflow first builds a simulator .app in a job with no secrets, and Greenroom runs against that artifact in a second job. Native Swift, SwiftUI, and React Native / Expo apps all work; the requirement is a simulator build with no dev-server dependency.
1. Install the GitHub App
Same as web: install the Greenroom GitHub App on the repository. Read-only access, always.
2. Add the workflow
Create .github/workflows/greenroom.yml. The job calls Greenroom's reusable workflow on a macOS runner; your prepare script builds the simulator app. This example is React Native with CocoaPods; a native SwiftUI app just drops the node and pod lines:
name: Greenroom
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
greenroom:
permissions:
contents: read
id-token: write
# Greenroom's reusable workflow, pinned to a reviewed commit. The job is
# defined there, so a pull request cannot change what it does.
uses: justindc100/greenroom-action/.github/workflows/pass.yml@3b2754eafa6ed5f4d122a0bb3ea7dee7005f65b8
with:
platform: ios
runs-on: macos-26
app-id: my-app
app: REPLACE_WITH_TEST_BUNDLE_ID
device: Greenroom QA
# Required: supply a simulator build script for your Xcode or Expo project.
prepare: |
./scripts/build-simulator.sh
artifact-path: build/REPLACE_WITH_APP_NAME.app
allowed-hosts: REPLACE_WITH_TEST_BACKEND_HOST
# Source upload is off by default. Enable only after repository and
# Greenroom workspace policy have both been reviewed.
source-upload-allowed: "false"Why this shape: the job holds no credentials beyond the OIDC identity used to talk to Greenroom, so your build scripts run without any secret to leak, and the job itself is defined in Greenroom's repository at a pinned commit, so a pull request cannot change what happens after the build. See Security model.
The simulator warm-up is experimental and off by default
By default the job prepares the way runner 0.3.13 did, the path every accepted release canary has run: your prepare script builds the app and creates or boots the simulator named by device (if it leaves the device shut down, the pass boots it and waits up to 10 minutes for a fresh simulator's first-boot data migration), and Greenroom's XCTest automation runner is compiled at the pass's first use. Nothing runs between the checkout and your prepare step.
ios-warm: "true" in the with: block turns on an experimental warm-up: before it downloads your artifact or runs prepare, the reusable workflow finds or creates the simulator named by device, boots it, and compiles the XCTest runner while the simulator boots. Both are slow on a fresh hosted Mac and independent of each other, so they overlap instead of adding up, and the simulator's UDID is exported to your prepare step as GREENROOM_SIMULATOR_UDID. It is off by default because of what the two hosted runs that used it showed (GitHub macos-26, iOS 26.5, Greenroom's own fixture, 2026-09-09):
- The customer-visible
preparestep took 123 s and 104 s of its 600 s window (477 s and 496 s left), against 359 s without the warm-up on the same day. The mechanism does what it is for. - Both passes then failed the same way: on the hosted Mac, agent-device's daemon interrupted and restarted its XCTest runner during the walk (
** BUILD INTERRUPTED **in the runner log, a newxcodebuildat once), the app session did not survive the restart, and every later observation returnedSESSION_NOT_FOUND. Time from job start to the minted run was also longer than without the warm-up (512 s and 410 s against about 360 s) because XCTest readiness still waited on the boot. An idle Mac does not reproduce the restart; its trigger is not yet established.
Leave the warm-up off unless you are measuring it together with Greenroom. If you turn it on, two rules for your prepare script follow:
- Do not create a second simulator with the same name. If your script creates its own, look the name up first and reuse the existing device; a duplicate name fails the pass with
iOS simulator name … is ambiguous. xcrun simctl booton that device exits withcurrent state: Booted; treat that as success. Waiting onxcrun simctl bootstatus <udid> -bis still fine and returns at once.
With the warm-up on, it leaves its agent-device daemon and the XCTest runner it started running, in agent-device's default state directory, so that the pass reuses that runner instead of starting another. If you run agent-device prepare ios-runner yourself (for a launch check before the pass), run it in that same default state directory. A prepare script that drives agent-device under its own AGENT_DEVICE_STATE_DIR would instead be told the runner is owned by another daemon; for that script, set ios-warm-stop-daemon: "true" as well and the warm-up stops its daemon and releases the runner before your prepare runs (your script then pays the runner start again, about 70 s on a hosted Mac).
Whether or not the warm-up ran, when the iOS job fails it attaches agent-device's session logs (the runner's build and start output, the daemon's request logs and event stream, the daemon log and, with the warm-up on, its receipt) as a workflow artifact named greenroom-agent-device-logs-<app-id>-<attempt>, kept for 14 days. They hold session names, simulator UDIDs, xcodebuild output and the accessibility snapshots the pass already uploads as evidence; no secrets.
Measured preparation time on macos-26
Measured on GitHub's macos-26 runners on 2026-09-06 (Actions logs of the accepted 0.3.12 canary and the Suelto pilot; iOS 26.5 runtime):
| Phase | Cold hosted runner | Notes |
|---|---|---|
| Simulator first boot (data migration) | 150 s to 395 s | varies run to run on the same image |
Install the .app | 2 s to 114 s | the high value includes a pilot's session import |
| XCTest runner first use | 187 s to 252 s | of which the runner build is 118 s to 165 s; start and health check 65 s to 68 s |
Run one after another these consumed 574 s of a 600 s preparation window and 628 s of a canary's prepare step; the same fixture prepared in 359 s on 2026-09-09. Since runner 0.3.17 the iOS job also turns Spotlight indexing off on GitHub-hosted Macs (sudo mdutil -a -i off, skipped on self-hosted runners, never a failed job) before anything boots: on the hosted image the indexer was saturating the 3-vCPU host as jobs started, and the pass's first captures after open were paying for it. Budget your own prepare script's deadline against their sum, and give the whole job the reusable workflow's 45-minute limit rather than a tight one of your own. With the experimental warm-up above, preparation costs about the larger of the boot and the runner build plus the install and start phases (123 s and 104 s measured), but see its failure record before turning it on.
Caching guidance
- CocoaPods: the hosted image's CocoaPods (1.17.0 on the image dated 2026-08-31) refuses a
Podfile.lockwritten by another version. Pin the version your lockfile records in the build job (aGemfilewithbundle exec pod install, orgem install cocoapods -v <version>), or regenerate the lockfile with the image's version and commit it.npm run setup:app -- --checkreports this ascocoapods-lockfile-driftbefore you open a pull request. - Homebrew:
brew --prefix <formula>prints a path and succeeds even when the formula is not installed. Install what your build needs (brew install <formula>) or testbrew list --versions <formula>first; the check reports a formula that is only located asbrew-formula-not-installed. - Your build: cache
Pods/and the XcodeDerivedDataof the build job withactions/cachekeyed onPodfile.lockand your source revision. This shortens the build job; it does not change the pass job's preparation, which starts from a fresh simulator every time by design (resetStrategy: fresh_install). - XCTest runner: Greenroom does not cache its runner build across jobs on purpose: a restored build from another Xcode version would fail at first use. Hiding the build behind the simulator boot is what the experimental warm-up above does.
3. Add the environment manifest
iOS accepts two network attestations, and the recommended one requires no infrastructure:
sandboxed_backend(recommended): your simulator build is compiled against a test or staging backend, so the app cannot reach production by construction. Greenroom does not intercept or record simulator traffic under this attestation; the guarantee is your build configuration, so list the test hosts the build actually uses and keep production hosts inproductionHosts.external_proxy: for teams that already run an egress proxy or firewall on their CI hosts, the strictest option. The proxy denies everything outsideallowedHosts.
{
"schemaVersion": "1.0",
"classification": "test",
"isolated": true,
"resetStrategy": "fresh_install",
"networkControl": "sandboxed_backend",
"allowedHosts": ["api.staging.example.test", "sentry.example.test"],
"productionHosts": ["api.example.com"],
"sandboxPurchases": true,
"notes": "Release simulator build pinned to the staging tenant."
}Two iOS-specific rules the schema enforces:
resetStrategymust befresh_install. Every walk starts from a clean install of the artifact on a dedicated simulator, so state from one walk can never leak into the next.networkControl: "playwright"is rejected for iOS; it belongs to web runs.
The general rules apply too: list every host including analytics and crash reporters, and never let a host appear in both allowedHosts and productionHosts. See the manifest reference.
4. Add the state contract
Same file as web, with an iOS router kind. For SwiftUI, graphPaths names your target's source root; for Expo Router, the app/ directory:
{
"schemaVersion": "1.0",
"routerKind": "swiftui",
"graphPaths": ["Sources/YourApp"],
"entryState": "today",
"states": [
{ "id": "today", "screen": "TodayView", "goal": "Review today's plan" },
{ "id": "settings-profile", "screen": "ProfileView", "goal": "Update the profile height" }
]
}Ship it at onboarding. Without a contract, coverage authority stays requested-goal-only and clean runs cap at inconclusive. See the state contract reference.
5. Merge setup, then open a test pull request
Review and merge the three setup files into the base branch first. The policy files must exist on the base revision before a pass can start. Then open a separate test PR that changes a mapped screen.
Every PR gets walked and receives its handoff. Model routing is fixed per role by Greenroom's reviewed defaults; iOS apps differ enough that a per-app driver override can matter, and that is something the Greenroom operator applies after measuring your app, not an automatic step.
Let your coding agent set it up
If you work with Claude Code or Codex, point it at the Greenroom onboarding skill and ask it to set up Greenroom on the repository. The agent detects Expo, React Native or SwiftUI and the router, drafts the simulator build for prepare (CocoaPods pinned to your lockfile, the Sentry upload switched off, the .app copied to build/), gathers the hosts the app contacts, writes the manifest and the state contract with the pin this page quotes, and for a signed-in app adds the auth block and the session secret and removes any in-app import path (testing authenticated apps). It runs the same checks as --check and stops with a reviewable diff and the steps only you can do, never committing, pushing, or handling a secret value.