1. Delivery model and terminology
The output is a signed .app compiled with Xcode’s Release configuration. React Native’s production JavaScript bundle is embedded as main.jsbundle, so the app does not need Expo Go, an Expo development client, Metro, or a Mac-side dev server after installation.
The app is still signed with an Apple Development identity and free provisioning profile. That is why the installed app can have get-task-allow = true even though its native build configuration is Release. Do not call it an App Store/distribution build. The accurate description is: self-contained Release app, development-signed for direct device installation.
| Path | JS source | Signing | Needs Metro | This workflow |
|---|---|---|---|---|
| Expo dev client | Loaded from Metro | Development | Yes | No |
| Personal Team Release | Embedded main.jsbundle | Development / free profile | No | Yes |
| App Store/TestFlight | Embedded | Distribution | No | No |
2. Prerequisites
- A Mac with the project-supported Xcode installed and selected by
xcode-select. - The T3 Code repository and its required package manager versions.
- An iPad paired with this Mac, trusted, unlocked during setup, and running Developer Mode.
- An Apple ID added to Xcode at least once, yielding a Personal Team.
- A valid Apple Development signing identity in the login keychain.
- Either an authenticated Xcode account that can create a free provisioning profile or an unexpired cached profile that already includes the target iPad.
- A unique bundle ID controlled by the Personal Team, such as
sh.example.t3code.
Agent placeholders used below:
<REPO>- Absolute T3 Code checkout path
<DEVICE_UDID>- Physical iPad UDID resolved live from CoreDevice
<PERSONAL_TEAM_ID>- Team identifier read from the provisioning profile or Xcode account metadata
<PERSONAL_BUNDLE_ID>- Unique Personal Team application bundle ID
<SHA>- Exact commit being delivered
3. Safety and source-preservation rules
Require a clean checkout before branch changes. Do not overwrite another branch’s tracked files. Do not uninstall an existing iPad app to free a Personal Team slot without explicit user approval; uninstalling can delete that app’s local data.
- Record the starting branch and SHA before switching branches.
- Use
git pull --ff-only; do not create an accidental merge. - Treat generated
apps/mobile/iosas disposable CNG output when the repository ignores it. - If a branch lacks Personal Team configuration support, make the adaptation in an isolated worktree or as a temporary, reversible patch. Restore tracked files and prove
git statusis clean before the Xcode build. - Never print
.envvalues, Clerk keys, relay credentials, observability tokens, Apple account passwords, or private certificates into logs or reports. - Use a dedicated Derived Data path so artifact identity cannot be confused with an older build.
4. Resolve, checkout, and validate the requested branch
For a pull request, resolve its exact head branch and head SHA before changing the checkout:
cd <REPO>
git status --short --branch
git rev-parse HEAD
gh pr view <PR_NUMBER> --repo pingdotgg/t3code \
--json number,title,state,headRefName,headRefOid,headRepositoryOwner,baseRefName,url
git fetch origin <HEAD_BRANCH>
git switch --track -c <HEAD_BRANCH> origin/<HEAD_BRANCH> # first checkout only
git pull --ff-only origin <HEAD_BRANCH>
git status --short --branch
git rev-parse HEAD
If the local branch already exists, use git switch <HEAD_BRANCH> and then the same fast-forward-only pull.
Refresh dependencies whenever the lockfile differs across branches:
pnpm install --frozen-lockfile
This matters because stale workspace links can produce type errors against a package version from the previous branch even when the manifest and lockfile are correct.
Run the repository’s required gates:
vp check
vp run typecheck
vp run lint:mobile # required when native mobile code changed
Run Expo’s compatibility and native-module discovery checks from apps/mobile using installed local binaries:
cd apps/mobile
CI=1 ./node_modules/.bin/expo install --check
./node_modules/.bin/expo-modules-autolinking verify --verbose
./node_modules/.bin/expo-modules-autolinking resolve --platform apple > /tmp/t3code-autolinking-apple.json
Do not automatically upgrade a PR’s dependencies merely because expo install --check recommends newer patch versions. Record the mismatch and ask before changing the branch. Autolinking warnings about local file dependencies should be inspected with resolve; a workspace symlink appearing through two search paths is not automatically proof of two distinct native binaries.
5. Discover and qualify the physical iPad
Use CoreDevice’s JSON output for automation:
rm -f /tmp/t3code-devices.json
xcrun devicectl list devices --json-output /tmp/t3code-devices.json
jq '.result.devices[] | {
name: .deviceProperties.name,
identifier,
udid: .hardwareProperties.udid,
osVersion: .deviceProperties.osVersionNumber,
bootState: .deviceProperties.bootState,
transport: .connectionProperties.transportType,
pairingState: .connectionProperties.pairingState
}' /tmp/t3code-devices.json
Require the intended device to be paired and booted. A local-network transport is valid after pairing; a cable is still useful for first trust, Developer Mode, or recovery. Parameterize <DEVICE_UDID> from live output instead of embedding a personal device identifier in reusable automation.
6. Discover the real signing team and cached profile
List valid code-signing identities:
security find-identity -v -p codesigning
The parenthetical value shown in an identity label is not necessarily the Personal Team identifier. Using it as DEVELOPMENT_TEAM can produce both “No Account for Team” and “No profiles were found.” Read the actual team from Xcode metadata or a provisioning profile.
Inspect cached profiles without exposing private key material:
setopt null_glob
for profile in \
"$HOME/Library/MobileDevice/Provisioning Profiles"/*.mobileprovision \
"$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"/*.mobileprovision
do
decoded="/tmp/$(basename "$profile").plist"
security cms -D -i "$profile" > "$decoded" 2>/dev/null || continue
/usr/libexec/PlistBuddy -c 'Print :Name' "$decoded" 2>/dev/null
/usr/libexec/PlistBuddy -c 'Print :TeamIdentifier:0' "$decoded" 2>/dev/null
/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' "$decoded" 2>/dev/null
/usr/libexec/PlistBuddy -c 'Print :ExpirationDate' "$decoded" 2>/dev/null
/usr/libexec/PlistBuddy -c 'Print :ProvisionedDevices' "$decoded" 2>/dev/null
done
Select a profile whose application identifier ends in .<PERSONAL_BUNDLE_ID>, whose device list includes <DEVICE_UDID>, and whose expiration is in the future. Its TeamIdentifier is <PERSONAL_TEAM_ID>.
7. Adapt T3 Code for Personal Team capabilities
Free Personal Teams cannot support the production app’s complete capability set. T3 Code’s local Personal Team mode must use a unique bundle ID and omit capabilities that would make signing fail.
| Capability | Why omitted | Result |
|---|---|---|
| Widget extension / App Group | Requires extension and group provisioning | No home-screen Agent Activity widget |
| Push notifications | Free profile does not carry production push entitlement | No remote push/live activity delivery |
| Native Sign in with Apple | Capability is not available to this local Personal Team setup | Apple sign-in path omitted |
| Associated domains | Profile must explicitly authorize the entitlement | No universal-link/web-credential association in this build |
The durable configuration pattern is:
T3CODE_IOS_PERSONAL_TEAM=1activates local Personal Team mode.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=<PERSONAL_BUNDLE_ID>replaces the production bundle ID.- The Expo config omits the widget plugin and associated domains in Personal Team mode.
- The Clerk Expo plugin is configured without native Apple Sign-In in Personal Team mode.
- A small config plugin removes
aps-environment,com.apple.developer.applesignin, andcom.apple.security.application-groupsfrom generated entitlements.
If the requested PR predates that support, do not commit unrelated signing changes to it. Use one of these approaches:
- Preferred: generate in a throwaway worktree with a temporary patch, leaving the user’s checkout untouched.
- Acceptable: apply a narrowly scoped temporary patch, run prebuild, reverse the exact patch, and verify the tracked tree is clean. The generated ignored
ios/directory retains the desired native configuration.
Never leave the only copy of this adaptation as a hand-edited generated Xcode project.
8. Generate and synchronize the iOS project
From apps/mobile, run a clean CNG prebuild with production variant, Personal Team mode, and the selected bundle ID:
cd <REPO>/apps/mobile
set -o pipefail
T3CODE_IOS_PERSONAL_TEAM=1 \
T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=<PERSONAL_BUNDLE_ID> \
APP_VARIANT=production \
EXPO_NO_GIT_STATUS=1 \
./node_modules/.bin/expo prebuild --clean --platform ios \
2>&1 | tee /tmp/t3code-prebuild.log
A clean prebuild deletes and recreates ios/, runs config plugins, discovers local Expo modules, and installs CocoaPods. This is necessary when a branch adds native modules or changes native configuration.
After prebuild, confirm the new local module appears in Podfile.lock and the Pods project. Confirm the generated app has the selected bundle ID, no widget target in Personal Team mode, and no unsupported entitlements.
plutil -p ios/T3Code/T3Code.entitlements
rg 'T3NativeControls|T3ReviewDiffNative|T3TerminalNative' ios/Podfile.lock
git status --short --branch # tracked source must be restored and clean
9. Resolve Xcode settings and build the controlled Release artifact
First inspect effective settings for the exact workspace, scheme, configuration, and device:
xcodebuild \
-workspace ios/T3Code.xcworkspace \
-scheme T3Code \
-configuration Release \
-destination 'id=<DEVICE_UDID>' \
-showBuildSettings \
DEVELOPMENT_TEAM=<PERSONAL_TEAM_ID> \
CODE_SIGN_STYLE=Automatic \
> /tmp/t3code-build-settings.txt 2>&1
rg '^\\s*(PRODUCT_BUNDLE_IDENTIFIER|PRODUCT_NAME|DEVELOPMENT_TEAM|CODE_SIGN_STYLE|IPHONEOS_DEPLOYMENT_TARGET|TARGET_BUILD_DIR) = ' \
/tmp/t3code-build-settings.txt
Require PRODUCT_BUNDLE_IDENTIFIER = <PERSONAL_BUNDLE_ID>, DEVELOPMENT_TEAM = <PERSONAL_TEAM_ID>, configuration Release, and the intended device destination.
Build to an explicit, commit-specific Derived Data path:
cd <REPO>
DERIVED="/tmp/t3code-release-<SHA>"
rm -rf "$DERIVED"
set -o pipefail
xcodebuild \
-workspace apps/mobile/ios/T3Code.xcworkspace \
-scheme T3Code \
-configuration Release \
-destination 'id=<DEVICE_UDID>' \
-derivedDataPath "$DERIVED" \
-allowProvisioningUpdates \
DEVELOPMENT_TEAM=<PERSONAL_TEAM_ID> \
CODE_SIGN_STYLE=Automatic \
build 2>&1 | tee /tmp/t3code-ipad-release-build.log
The expected artifact is:
/tmp/t3code-release-<SHA>/Build/Products/Release-iphoneos/T3Code.app
Retain the complete log and actual exit status. Do not use warning volume as the verdict; require ** BUILD SUCCEEDED **.
10. Inspect the exact artifact before device mutation
Never install “the newest app” found in Derived Data. Inspect the exact path produced above.
APP="/tmp/t3code-release-<SHA>/Build/Products/Release-iphoneos/T3Code.app"
PLIST="$APP/Info.plist"
/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$PLIST"
/usr/libexec/PlistBuddy -c 'Print :CFBundleDisplayName' "$PLIST"
/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$PLIST"
/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST"
codesign -dv --verbose=2 "$APP" 2>&1 | \
rg '^(Identifier|TeamIdentifier|Authority)='
codesign --verify --deep --strict "$APP"
find "$APP" -maxdepth 2 -type f -name 'main.jsbundle' -print
shasum -a 256 "$APP/main.jsbundle"
xcrun dwarfdump --uuid "$APP/T3Code"
Decode the embedded provisioning profile and verify team, application identifier, target device, and expiration:
security cms -D -i "$APP/embedded.mobileprovision" \
> /tmp/t3code-embedded-profile.plist
/usr/libexec/PlistBuddy -c 'Print :Name' /tmp/t3code-embedded-profile.plist
/usr/libexec/PlistBuddy -c 'Print :TeamIdentifier:0' /tmp/t3code-embedded-profile.plist
/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' /tmp/t3code-embedded-profile.plist
/usr/libexec/PlistBuddy -c 'Print :ExpirationDate' /tmp/t3code-embedded-profile.plist
/usr/libexec/PlistBuddy -c 'Print :ProvisionedDevices' /tmp/t3code-embedded-profile.plist
The artifact is from Release-iphoneos, has the expected bundle ID and display name, passes strict code-sign verification, embeds the expected Personal Team profile, and contains a non-empty main.jsbundle. Those facts—not the presence or absence of expo-dev-client in package.json—prove that Metro is not required.
11. Install, query, launch, and observe as separate gates
Install the inspected artifact:
xcrun devicectl device install app \
--device <DEVICE_UDID> \
"$APP" \
--json-output /tmp/t3code-install.json
Then independently prove the expected identity is installed:
xcrun devicectl device info apps \
--device <DEVICE_UDID> \
--bundle-id <PERSONAL_BUNDLE_ID> \
--json-output /tmp/t3code-apps.json
jq '.result.apps[] | {name,bundleIdentifier,version,url}' /tmp/t3code-apps.json
Launch explicitly and retain the launched PID:
xcrun devicectl device process launch \
--terminate-existing \
--device <DEVICE_UDID> \
<PERSONAL_BUNDLE_ID> \
--json-output /tmp/t3code-launch.json
PID=$(jq -r '.result.process.processIdentifier' /tmp/t3code-launch.json)
sleep 5
xcrun devicectl device info processes \
--device <DEVICE_UDID> \
--json-output /tmp/t3code-processes.json
jq --argjson pid "$PID" \
'.result.runningProcesses[] | select(.processIdentifier == $pid)' \
/tmp/t3code-processes.json
Installation, launch, and process presence are distinct claims. A process surviving the delay proves startup stability, not interactive correctness. For layout changes, obtain user confirmation on the physical iPad or capture device-visible evidence through an approved device UI workflow.
12. Known failures and exact recovery
| Failure | Likely cause | Recovery |
|---|---|---|
No Account for Team and No profiles ... were found | The certificate label’s parenthetical ID was incorrectly used as DEVELOPMENT_TEAM, or Xcode lacks a usable account/profile. | Decode the cached profile and use its real TeamIdentifier. Confirm the profile matches bundle ID, device, and expiration. |
maximum number of installed apps using a free developer profile | iPadOS is already tracking three free-profile apps for the team. | List installed apps, show the exact candidates, and ask the user which one may be removed. Then run xcrun devicectl device uninstall app --device <DEVICE_UDID> <BUNDLE_ID>. Never choose silently. |
| App installs but opens a dev launcher or asks for Metro | Debug/dev-client build or missing production JS bundle. | Rebuild with -configuration Release, inspect the exact Release-iphoneos app, and require main.jsbundle. |
| Signing rejects widgets, App Groups, push, Apple Sign-In, or associated domains | Production capabilities are still present in the generated project. | Regenerate using Personal Team mode that omits those plugins/entitlements. Do not patch only the final .app. |
expo install --check reports patch-version drift | The requested branch predates current Expo compatibility recommendations. | Record the exact mismatch. Do not mutate the PR unless dependency updates are authorized. Judge build success separately. |
| Autolinking reports duplicate local modules | A file dependency is visible from both its source directory and workspace link. | Inspect resolve --platform apple and Pod resolution. Confirm whether distinct podspec paths/versions actually exist before changing dependencies. |
| Typecheck fails immediately after branch switch with unexpected package APIs | Stale node_modules links from the previous lockfile. | Run pnpm install --frozen-lockfile, verify the installed package version, and rerun the full gate. |
| Launch command succeeds but app disappears | Native startup crash. | Query processes after a delay, inventory fresh systemCrashLogs, and correlate executable/framework UUIDs with the exact artifact before diagnosing. |
Free-profile operational limits
- Free provisioning profiles are short-lived. Inspect the actual expiration; expect periodic rebuild/reinstall.
- Only a small number of Personal Team apps can be active on a device at once; the observed limit was three.
- Reinstalling the same bundle ID updates that app. Installing a new bundle ID consumes another slot.
- Removing an app can delete its local data.
- A cached profile can allow a build even when the Xcode account is not currently usable, but only until the certificate/profile or device authorization stops being valid.
13. Condensed agent reproduction checklist
- Read repository and mobile instructions in full.
- Record current branch/SHA and require a clean tree.
- Resolve PR head metadata; fetch, switch, and fast-forward only.
- Run
pnpm install --frozen-lockfile. - Run
vp check,vp run typecheck, and native lint when applicable. - Run Expo compatibility and autolinking checks; record warnings without unauthorized upgrades.
- Discover the iPad live with
devicectl; require paired/booted. - Resolve the actual Personal Team ID and matching unexpired profile; do not infer it from the certificate label.
- Apply Personal Team capability reduction in an isolated or reversible way.
- Run clean Expo prebuild and Pods; confirm every required local native module is linked.
- Restore tracked source and prove the requested branch remains clean.
- Inspect Xcode build settings for Release, bundle ID, team, and destination.
- Build to a unique Derived Data path with full logs.
- Inspect bundle identity, signature, provisioning, executable UUID, and embedded JS before install.
- Install, query installed identity, launch, and verify process presence after a delay.
- Obtain user visual confirmation for the actual iPad interaction/layout.
- Report exact commit, warnings, limitations, log paths, device mutations, and final Git status.
14. Required handoff ledger
target: <device name and OS>
configuration: Release
commit: <full SHA and clean/dirty state>
dependency_alignment: pass|fail|warning
native_sync: pass|fail
static_checks: pass|fail
built_artifact: pass|fail
artifact_identity: pass|fail
installed: pass|fail
launched: pass|fail
process_present_after_delay: pass|fail
interaction_or_visual: user_confirmed|not_run
crash_correlation: pass|fail|not_applicable|not_run
personal_team_profile_expiration: <timestamp>
removed_device_apps: []
known_gaps: []
retained_logs:
- /tmp/t3code-prebuild.log
- /tmp/t3code-ipad-release-build.log
- /tmp/t3code-install.json
- /tmp/t3code-launch.json
final_git_status: <exact output>
Stop at the highest proven rung. “Built” does not mean installed; “installed” does not mean launched; “launched” does not prove the process stayed alive; process presence does not prove the iPad UI is interactively correct.