T3 Code: standalone iPad Release builds with Apple Personal Team signing

An agent-reproducible runbook for building, signing, inspecting, installing, launching, and verifying the real self-contained T3 Code app on a paired iPad—without a paid Apple Developer Program setup or full App Store distribution credentials.

What “without the Apple dev stuff fully there” actually means

This workflow does not eliminate Apple signing. It uses Xcode’s free Personal Team, an Apple Development certificate, a paired iPad in Developer Mode, and a free provisioning profile. It does not require a paid Apple Developer Program membership, a distribution certificate, App Store Connect upload, TestFlight, or an App Store build.

Native configuration
Release
Production JS is embedded
Runtime dependency
No Metro
Launches standalone
Distribution
Personal Team
Direct device install

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.

How this differs from other iOS delivery paths
PathJS sourceSigningNeeds MetroThis workflow
Expo dev clientLoaded from MetroDevelopmentYesNo
Personal Team ReleaseEmbedded main.jsbundleDevelopment / free profileNoYes
App Store/TestFlightEmbeddedDistributionNoNo

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

Never silently trade source or device data for a successful install

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/ios as 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 status is clean before the Xcode build.
  • Never print .env values, 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.

Production capabilities removed for a Personal Team device build
CapabilityWhy omittedResult
Widget extension / App GroupRequires extension and group provisioningNo home-screen Agent Activity widget
Push notificationsFree profile does not carry production push entitlementNo remote push/live activity delivery
Native Sign in with AppleCapability is not available to this local Personal Team setupApple sign-in path omitted
Associated domainsProfile must explicitly authorize the entitlementNo universal-link/web-credential association in this build

The durable configuration pattern is:

  • T3CODE_IOS_PERSONAL_TEAM=1 activates 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, and com.apple.security.application-groups from generated entitlements.

If the requested PR predates that support, do not commit unrelated signing changes to it. Use one of these approaches:

  1. Preferred: generate in a throwaway worktree with a temporary patch, leaving the user’s checkout untouched.
  2. 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
Acceptance evidence for a standalone Release

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

Observed failure modes in this workflow
FailureLikely causeRecovery
No Account for Team and No profiles ... were foundThe 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 profileiPadOS 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 MetroDebug/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 domainsProduction 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 driftThe 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 modulesA 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 APIsStale 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 disappearsNative 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

  1. Read repository and mobile instructions in full.
  2. Record current branch/SHA and require a clean tree.
  3. Resolve PR head metadata; fetch, switch, and fast-forward only.
  4. Run pnpm install --frozen-lockfile.
  5. Run vp check, vp run typecheck, and native lint when applicable.
  6. Run Expo compatibility and autolinking checks; record warnings without unauthorized upgrades.
  7. Discover the iPad live with devicectl; require paired/booted.
  8. Resolve the actual Personal Team ID and matching unexpired profile; do not infer it from the certificate label.
  9. Apply Personal Team capability reduction in an isolated or reversible way.
  10. Run clean Expo prebuild and Pods; confirm every required local native module is linked.
  11. Restore tracked source and prove the requested branch remains clean.
  12. Inspect Xcode build settings for Release, bundle ID, team, and destination.
  13. Build to a unique Derived Data path with full logs.
  14. Inspect bundle identity, signature, provisioning, executable UUID, and embedded JS before install.
  15. Install, query installed identity, launch, and verify process presence after a delay.
  16. Obtain user visual confirmation for the actual iPad interaction/layout.
  17. 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>
Final accuracy rule

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.