greenroom
Concepts

Testing authenticated apps

The one supported way for a signed-in iOS journey to start authenticated on the simulator, and the rules for the session that makes it possible.

An app with a sign-in wall needs a session before Greenroom can inspect its authenticated screens. The job's GitHub OIDC token authenticates the runner to Greenroom; it does not sign the runner into your app.

This page is the contract. Follow it alone and a pull request on an Expo or React Native app that stores its session in expo-secure-store (or any generic Keychain item) starts its walk already signed in, with no code in your app for Greenroom and no engineer from Greenroom.

Two kinds of checks

CheckStarting stateWhat it proves
Routine release journeyDisposable account with a valid session in an isolated backendThe requested signed-in behavior works for that account, role and entitlement
Sign-in journeySigned out, using an approved provider test identityThe tested sign-in, cancellation, recovery or sign-out flow works

Greenroom supports the first kind through session import, described below. Starting with a session does not test Apple, Google, password, MFA or account creation; those journeys, purchases, physical devices and credential entry by the virtual user are outside this contract. A synthetic subscription grants access only for that test; it does not verify a purchase, receipt, RevenueCat or App Store configuration.

The contract in one paragraph

You declare, in .greenroom/environment.json, that the app starts with an imported session and where the app reads it from. You issue a disposable session for a disposable account on an isolated backend, using your backend's normal session issuer, and store it as one repository secret. Your workflow hands that secret to Greenroom's reusable workflow as its session secret. On every pass, the runner installs your simulator build fresh, resets the simulator Keychain, writes the session into the Keychain from outside the app, launches the app once, and walks the requested screens. It never signs out, never buys, never types credentials, never logs the session, and never touches production. Your app must not contain any import path: the build Greenroom walks is the build you ship.

Step 1: declare the mechanism

The auth block of the environment manifest has one of two shapes. Absent, it means signed_out: the walk starts on whatever a fresh install shows.

{
  "schemaVersion": "1.0",
  "classification": "test",
  "isolated": true,
  "resetStrategy": "fresh_install",
  "networkControl": "sandboxed_backend",
  "allowedHosts": ["api.example.test"],
  "productionHosts": ["api.example.com"],
  "auth": {
    "mechanism": "session_import",
    "secret": "GREENROOM_SESSION",
    "target": {
      "kind": "expo-secure-store",
      "service": "app:no-auth",
      "accounts": ["refresh_token"]
    },
    "reset": "fresh_install"
  }
}
FieldMeaning
mechanismsigned_out (default) or session_import. There is no password, OTP or provider mechanism.
secretThe name of the repository secret that carries the session payload (A-Z, 0-9, _). The manifest is stored with every run, so it never carries the value.
target.kindexpo-secure-store for apps that persist their session with expo-secure-store; keychain for a plain generic-password item (react-native-keychain, a native SecItemAdd).
target.serviceThe Keychain service the app reads from. For expo-secure-store this is the store's keychainService option followed by :no-auth; with no option that is app:no-auth. Items stored with requireAuthentication: true are not supported. For keychain, the service string the app queries.
target.accountsThe keys the app reads, one to eight. For expo-secure-store these are the keys you pass to setItemAsync; for keychain, the account or username strings.
target.accessGroupOptional. Declare it only if the app reads its items from an explicit Keychain access group. By default the runner's writer is signed with your build's own entitlements and bundle identifier, so the simulator files the items exactly where your app's own writes go.
resetMust be fresh_install, and resetStrategy must be fresh_install too. Keychain items survive an uninstall; the runner resets the simulator Keychain before every install so a stale session can never sign in silently.

Session import is iOS only. A manifest that declares it for a web run is rejected.

In .greenroom/state-contract.json, a screen that is only reachable signed in says so with "identity": { "authenticated": true }. A contract that claims a signed-in start while the manifest is signed_out is rejected at preflight, before any spend, instead of producing a walk that stops at the sign-in wall.

Step 2: issue a disposable session

The payload is one JSON object whose keys are exactly the declared accounts and whose values are the strings the app expects to find there. For an expo-secure-store app that keeps a refresh token:

{ "refresh_token": "rt_3f9c…" }

Rules for the session:

  • A disposable account on an isolated backend. The account is synthetic, holds only synthetic data, and lives in the test or staging backend your allowedHosts name. Never a production account, never a personal one, never a backend that can reach production data.
  • Issued by your normal session issuer. Call the same function or endpoint your app's sign-in calls (a createSession for a user id, a test-only issuance endpoint that only exists on the isolated backend). Do not add a bypass to the app and do not put backend signing keys in the app or in Greenroom.
  • Long enough for a pass. The reusable workflow job runs up to 45 minutes; a refresh token that the app exchanges on launch is the right shape. A short-lived access token alone expires before the walk.
  • Store it as a repository secret with the name the manifest declares. Give it its own name; do not reuse a secret that anything else reads.

Rotating refresh tokens

The contract stores one session and presents it on every pass, which assumes the stored refresh token can be presented more than once. If your backend rotates refresh tokens (the refresh handler issues a new one, invalidates the presented one, and revokes the family when a rotated token is presented again), the repository secret works for exactly one pass: the app rotates it during the first walk, and the second pass stops at the sign-in wall. Check your refresh handler before the first authenticated pass. Two options:

  • Per-pass issuance. The session is minted for each pass instead of stored once: your prepare step issues it against the isolated backend and hands it to the runner through a file the workflow exports (a source: prepare mode beside the repository secret). This also removes the constraint that the backend must exist before the job. It is the primary path for rotating backends and is available from the runner release after 0.3.16; until it ships, reissue the repository secret before each pass.
  • A reuse allowance on the isolated backend for the one synthetic account: skip rotation, or tolerate re-presentation without family revocation, for that account only. This is a backend decision, never an app change, and must not exist on production.

Step 3: hand it to the workflow

Pass the secret to the reusable workflow under the session name in .github/workflows/greenroom.yml. Nothing else changes in the workflow.

jobs:
  greenroom:
    uses: justindc100/greenroom-action/.github/workflows/pass.yml@3b2754eafa6ed5f4d122a0bb3ea7dee7005f65b8
    with:
      platform: ios
      # ... the rest of your iOS inputs
    secrets:
      session: ${{ secrets.GREENROOM_SESSION }}

Leave secrets.session out for a signed_out manifest. If the manifest declares session_import and the secret is missing or is not the JSON object above, the job stops at preflight with a named error and no run is started.

What the runner does with it

  1. Validates the manifest's auth block and the payload before a run is minted. A missing secret, an undeclared account or a value that is not a string is a preflight error, not a spend.
  2. Boots the named simulator, uninstalls any prior copy of the app, installs your build, then resets the simulator Keychain (simctl keychain reset).
  3. Compiles a small writer on the CI host against the iPhoneSimulator SDK, signs it ad hoc with your build's bundle identifier and its keychain entitlements, and runs it inside the simulator once. The writer reads a mode-0600 file, deletes it, and adds one generic-password item per account in the declared service, shaped the way expo-secure-store (or a plain Keychain read) expects, stored with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Accessibility is a write-time attribute that reads do not filter on, so an app that writes its own items as WHEN_UNLOCKED or AFTER_FIRST_UNLOCK (with or without THIS_DEVICE_ONLY) reads the imported items all the same; a booted simulator is always past first unlock. Only items your app reads with requireAuthentication or a passcode-bound accessibility are outside this contract. It prints counts and status codes only.
  4. Launches the app for the first time. Your app finds its session the way it always does, refreshes it against your backend, and lands on its signed-in screens.
  5. Walks the requested screens. The payload appears in no argument, no child environment, no log line, no report, no screenshot and no evidence object; the work directory is removed when the import finishes, on success or failure.

The runner never signs out, never purchases (purchases are a separate, off-by-default permission that also needs sandboxPurchases), never enters credentials, and never modifies the app bundle.

Rules your build must satisfy

  • No import path in the app. Remove any simulator-only importer, launch argument, URL scheme, file watcher or debug endpoint that accepts a session. Greenroom writes to the simulator's Keychain from outside the app, so the walked build is the shipped build.
  • A signed simulator build. Xcode's default "Sign to Run Locally" (ad hoc) is enough. A build made with CODE_SIGNING_ALLOWED=NO is unsigned and cannot use the Keychain at all (errSecMissingEntitlement, -34018), for your app and for the writer alike.
  • The app refreshes over an allowed host. The backend the imported session belongs to must be in allowedHosts; a refresh request to any other host stops the walk.
  • A fresh device per job. The runner installs fresh and resets the Keychain, but the simulator it is told to use should be one this job owns (create it in your prepare step on a hosted runner), not a shared one with other apps' state.

System alerts at first launch

A signed-in app often asks for a permission as soon as an onboarded user reaches the home screen (notifications are the common one). On every fresh install that iOS alert appears before your entry screen. The virtual user sees the alert as the whole screen, never grants a permission (tapping Allow is a policy denial, deterministic in the runner and repeated in the driver's instructions), may decline it (Don't Allow, Not Now), and continues on whatever the app shows next. Two things follow for your contract:

  • Write the alert into the entry state's goal: "On first launch decline the notifications alert (Don't Allow), then confirm Today lists exactly one session." A goal that does not mention it leaves the driver to guess whether the alert is part of the journey.
  • The app must proceed after a decline. A screen that is unusable until the permission is granted is out of scope for the walk; the simulator cannot pre-grant notifications (simctl privacy grant notifications is unsupported) and Greenroom does not edit the simulator's privacy database.

An alert an earlier walk left open survives uninstall and reinstall; fresh_install resets your app's data, not SpringBoard's pending alerts, so goals should dismiss what they trigger.

Public SDK keys in the build

prepare runs with no credentials and cannot read repository secrets. If your app refuses to start without an SDK key (RevenueCat's configure throws without one, and then nothing after it runs), export a test project's public key in prepare through a GitHub Actions variable and list the SDK's host in allowedHosts. Public SDK keys are the ones designed to ship inside the app binary; a private key, secret or service-role key never goes in prepare, and the setup check refuses a value that looks like one. The environment manifest reference has the rule.

Secret handling and cleanup

  • Treat the payload as a credential: repository secret only, never a workflow input, an environment file, a commit, a PR comment or a chat message.
  • Rotate on a schedule that fits your token lifetime, and rotate immediately if the value was ever printed. The runner masks nothing itself; it relies on never emitting the value.
  • Revoke the session family and delete the account's owned data when the account is retired. A revoked session makes the next pass stop at the sign-in wall with a blocked walk, which is the correct signal to reissue.
  • Keep the account's role minimal. Separate free, entitled, expired and signed-out states into separate accounts and, if needed, separate manifests on separate branches.

What the failures mean

What you seeWhyWhat to do
Preflight: GREENROOM_SESSION is not setThe workflow did not receive the session secretStore the payload under the manifest's secret name and add secrets: session: to the caller
Preflight: state contract claims an authenticated start … no session import mechanismA state says authenticated: true but the manifest is signed_outDeclare auth.mechanism: session_import, or drop the claim
Preflight: payload is missing a string value for account …The JSON object does not match target.accountsReissue the payload with exactly the declared keys
Install: the Keychain writer failed inside the simulatorThe build is unsigned, or the simulator could not run the writerSign the simulator build; check the Actions log for the status code
Walk blocked at the sign-in wallThe app did not accept the imported sessionCheck the service and account names, the token's lifetime and that the app refreshes against an allowed host

Let your coding agent set it up

The Greenroom onboarding skill gives Claude Code or Codex this contract as a procedure: it finds the Keychain service and the session keys your app reads on launch, drafts the auth block and the secrets: session: line, marks the signed-in states with identity.authenticated, greps for and removes in-app import paths (simulator-only importers, launch arguments, URL schemes and debug endpoints that accept a session), and hands you the issuance steps above as a checklist. The session value itself never passes through the agent: you issue it and store it as the repository secret.

Release evidence

A useful first authenticated check verifies a small persisted change across navigation and relaunch, with the exact app commit, artifact digest and starting test state recorded. Expand to session expiry, sign-out, role boundaries and subscription states after that path works. Keep physical-device provider sign-in and purchases as separate release checks when the simulator cannot establish them. Read the security model for the difference between native backend attestation and enforced egress restrictions.

On this page