Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 97 additions & 85 deletions docs/personal/release-pipeline.md
Original file line number Diff line number Diff line change
@@ -1,94 +1,106 @@
# Release pipeline: personal T3 Code mobile builds

> Ported 2026-07-25 from the (archived) planning repo [r4iju/t3-code](https://github.com/r4iju/t3-code);
> this copy is now the living runbook. Historical decision links below point at that repo's issues.

Spec for [issue #6](https://github.com/r4iju/t3-code/issues/6). Decisions settled on the
[wayfinder map](https://github.com/r4iju/t3-code/issues/1) on 2026-07-23: v1 is upstream's
`apps/mobile` as-is (#5), fork delta stays env/config-only with ad-hoc `upstream/main` merges (#8),
distribution is personal/internal only, connectivity is direct LAN only.

## Principles

- **The fork ships upstream's app; we ship the pipeline.** No feature delta, so the pipeline is the
product of this effort.
- **Env/config-only delta.** Pipeline config lives in EAS environment variables, additive files, and
this repo's docs — upstream files are edited only when unavoidable (e.g. if `app.config.ts` lacks
an override hook for bundle identity).
- **decent-measure is the style reference, not a template to copy.** Where its choices fight the
env/config-only rule (local credentials + fastlane), we deviate deliberately.

## Identity

- Own bundle/application ID per variant, own EAS project, own App Store Connect record — upstream's
`com.t3tools.t3code.*` IDs stay untouched for clean upstream merges:
- iOS/Android production: `com.raijustudios.t3code`
- Development variant keeps upstream's dev ID locally (dev builds are never distributed).
- Apple team `C7X9BCC7LP` (same as decent-measure). App name on TestFlight: "T3 Code (personal)".
- Prefer setting identity via EAS env vars / `APP_VARIANT` hooks if upstream's `app.config.ts`
supports it; otherwise a minimal, well-marked edit in `app.config.ts` (allowed "when unavoidable").

## Build & distribute

| Platform | Profile | Output | Distribution |
| -------- | --------------------- | ------ | ----------------------------------------------- |
| iOS | upstream `production` | .ipa | `eas submit` → **TestFlight internal** |
| Android | upstream `production` | .aab | `eas submit` → **Google Play internal testing** |

> **Decision change (2026-07-23):** Android originally shipped as a preview APK via EAS internal
> link. Changed to match decent-measure: production AAB submitted to the Play internal testing
> track with the same Play service account (`barbellry-…json`). Requires a Play Console app record
> for `com.raijustudios.t3code` (manual — Play has no app-creation API) and the fork's
> `T3CODE_ANDROID_PACKAGE` env hook (upstream has no Android identity override).

- **EAS cloud is the default builder**; the free tier covers occasional personal builds.
Local `eas build --local` is the documented fallback (needs Xcode 26.1+ — ticket #9 — and
JDK 17 + `ANDROID_HOME`; see friction log on
[issue #3](https://github.com/r4iju/t3-code/issues/3)).
- **Credentials: EAS-managed (remote)** for both platforms. This deviates from decent-measure's
local-credentials + fastlane setup on purpose — remote credentials mean zero credential files in
the fork, consistent with env/config-only. Submit credentials (ASC API key for iOS, Play service
account key for Android, both shared with decent-measure) live in the gitignored
`apps/mobile/credentials/` and are referenced by the `personal` submit profile — nothing secret
is committed.
- **Versioning:** follow upstream's app version (their `appVersion` runtime policy); build numbers
auto-increment via EAS remote version source.
- **T3 Connect env vars stay unset.** LAN-only scope; cloud UI stays disabled in our builds.

## Release ritual
# Release runbook: personal T3 Code

How this fork ships: sync upstream, build and deploy the desktop app to every Mac, build and
submit the mobile apps. Originally ported 2026-07-25 from the archived planning repo
[r4iju/t3-code](https://github.com/r4iju/t3-code); historical decision links point there.

**The fork ships upstream's app; we ship the pipeline.** The delta stays env/config-only plus
additive files (`docs/personal/`, `scripts/personal/`). Upstream files are edited only when
unavoidable. Before each release, review the exit-path register in
[contributing-upstream.md](./contributing-upstream.md): every feature delta must be moving
toward upstream or have a written reason to stay.

## Machines

| Machine | SSH | Role |
| ------------------------ | ------------------------ | -------------------------------------------------- |
| studio (Mac Studio) | — | Builds and signs releases; T3 Code host |
| matebook | `emanuel@matebook.lan` | T3 Code host |
| sm-em (work MacBook Pro) | `emanuelfranzen@Mac.lan` | T3 Code host |
| iPhone / Android | — | Mobile clients: TestFlight / Play internal testing |

Every Mac runs "T3 Code (Alpha)" from `/Applications`, built from this fork. A LAN server is
the desktop app with Settings → Connections → Network access on (`0.0.0.0:3773`, pairing QR).
Pairing tokens expire in ~5 minutes; mint one right before pairing (`t3 auth pairing create`).

## 1. Sync upstream

Direct pushes to `main` are blocked, so a sync is a PR:

```bash
cd ~/code/t3code
git fetch upstream && git merge upstream/main # ad-hoc sync (#8)
cd apps/mobile
# T3CODE_EAS_OWNER / T3CODE_EAS_PROJECT_ID / T3CODE_ANDROID_PACKAGE env vars required locally
vp run eas:ios:prod # → .ipa
vp run eas:android:prod # → .aab
eas submit -p ios --latest --profile personal # → TestFlight internal
eas submit -p android --latest --profile personal # → Play internal testing
git -C ~/code/t3code fetch upstream
git -C ~/code/t3code worktree add ~/code/t3code-wt-sync -b sync/upstream-$(date +%Y%m%d) origin/main
cd ~/code/t3code-wt-sync && git merge upstream/main && vp i
git push -u origin HEAD && gh pr create --fill && gh pr merge --merge
git -C ~/code/t3code pull --ff-only
git -C ~/code/t3code worktree remove ~/code/t3code-wt-sync
```

No CI initially — releases are manual and occasional. If cadence grows, lift upstream's
fingerprint-based EAS workflow (`.github/workflows/mobile-eas-*.yml`) into the fork.
`gh` resolves to `origin` (`remote.origin.gh-resolved base`); upstream PRs need an explicit
`--repo pingdotgg/t3code`.

## 2. Desktop: build, sign, deploy

Before each release, review the exit-path register in
[contributing-upstream.md](./contributing-upstream.md) — every feature delta on this fork
must be moving toward upstream or have a written reason to stay.
On studio, from a clean `~/code/t3code` on `main`:

## Server operations (decided with the map)
```bash
scripts/personal/t3-alpha-build 0.0.47
scripts/personal/t3-alpha-deploy release/T3-Code-0.0.47-arm64.zip
```

- **Version:** the next patch after the last deploy. Every package.json is set to it for the
build and restored afterwards, so the app and its server report the same version.
- **Signing:** the build is signed with the Apple Development certificate in studio's keychain.
macOS privacy grants (Screen Recording, Accessibility, …), keychain access and Little Snitch
rules are tied to that signature, so they carry over between builds. An unsigned build is a
new app to all of them; the installer refuses one.
- **Deploy:** copies the zip and installer to matebook and sm-em over SSH, installs, and waits
for each result. Studio goes last because its restart ends any T3 session driving the
deploy. Name hosts to deploy to a subset: `… .zip emanuel@matebook.lan local`.
- **Target Macs must be logged in:** the installer launches the app in the GUI session.
- **Failures roll back** to the previous bundle automatically. Each Mac logs to
`~/Library/Logs/t3-alpha-install.log`; `scripts/personal/t3-alpha-install --status` shows
what runs, `--restart` relaunches. Only the latest `.bak-*` bundle is kept.

## 3. Mobile: build and submit

Identity is the fork's own (`com.raijustudios.t3code`, Apple team `C7X9BCC7LP`, EAS project
`@expomozdom/t3-code`); upstream's `com.t3tools.t3code.*` IDs stay untouched. The identity env
vars live in EAS (production and preview):

```
T3CODE_EAS_OWNER=expomozdom
T3CODE_EAS_PROJECT_ID=041ec0cd-429a-40d9-8d00-9fcf196ebb59
T3CODE_ANDROID_PACKAGE=com.raijustudios.t3code
T3CODE_IOS_PERSONAL_TEAM=1
T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=com.raijustudios.t3code
```

The LAN server is the **desktop app with Settings → Connections → Network access toggled on**
(binds `0.0.0.0:3773`, stable port, built-in pairing QR). No launchd service, no headless `t3 serve`
for daily use. Device pairing management: `t3 auth` (`pairing create`, `session list/revoke`).
Pairing tokens expire in ~5 minutes — mint right before pairing a new device.
From `apps/mobile`, logged in to EAS as `expomozdom`:

## Implementation checklist (post-map execution)
```bash
eas build --profile production -p ios --non-interactive --no-wait
eas build --profile production -p android --non-interactive --no-wait
eas submit -p ios --latest --profile personal # → TestFlight internal
eas submit -p android --latest --profile personal # → Play internal testing
```

1. Create the App Store Connect app record for `com.raijustudios.t3code`.
2. `eas init` in the fork's `apps/mobile` against a personal EAS project; set EAS env vars/secrets
(ASC API key; identity overrides if the env hook exists).
3. First iOS `production` build + submit; install from TestFlight.
4. Create the Play Console app record for `com.raijustudios.t3code` (manual); first Android
`production` AAB build + submit to the internal testing track; install from Play.
5. Pair both against the desktop app's network endpoint (`http://<lan-ip>:3773`).
6. Verify local-build fallback once Xcode 26.1+ lands (#9).
- EAS cloud builds by default; `eas build --local` is the fallback (Xcode 26.1+, JDK 17 +
`ANDROID_HOME`).
- Credentials are EAS-managed. Submit credentials (ASC API key, Play service account) live in
the gitignored `apps/mobile/credentials/`.
- Version follows upstream's app version; build numbers auto-increment remotely.
- Do not dispatch `.github/workflows/mobile-eas-production.yml` on the fork: it has no
`EXPO_TOKEN` secret and silently no-ops.
- T3 Connect env vars stay unset: LAN-only scope.

## One-time setup

- **Signing certificate** (studio): "Apple Development: Emanuel Franzen (T4J48V44Q2)" in the
login keychain; allow `codesign` access once. A renewed certificate keeps its name, so grants
survive renewal. Signing with another identity (`T3_ALPHA_SIGN_IDENTITY`, for example a
Developer ID) makes every Mac re-grant permissions once. To build on another Mac, export the
certificate with its key as `.p12` and import it there.
- **First signed install on a Mac:** grant macOS permissions and Little Snitch rules once more;
they stick from then on.
- **SSH:** studio needs key-based SSH to every deploy target.
62 changes: 62 additions & 0 deletions scripts/personal/t3-alpha-build
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/bin/bash
# t3-alpha-build — build a signed "T3 Code (Alpha)" zip (macOS arm64) from the
# checkout this script lives in. Runs on the Mac that holds the signing
# certificate; every other Mac only installs the result (t3-alpha-deploy).
#
# Mirrors upstream's release job: align every package.json to the release
# version so the server reports it too, build, then restore the manifests.
#
# The zip is then re-signed in place with a stable identity. An ad-hoc build's
# designated requirement is its own cdhash, so TCC grants, keychain ACLs and
# Little Snitch rules never carried over to the next build.
#
# Usage: t3-alpha-build <version> # -> release/T3-Code-<version>-arm64.zip

set -euo pipefail

VERSION="${1:?usage: t3-alpha-build <version>}"
SIGN_IDENTITY="${T3_ALPHA_SIGN_IDENTITY:-Apple Development: Emanuel Franzen (T4J48V44Q2)}"
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
ZIP="$REPO/release/T3-Code-$VERSION-arm64.zip"
cd "$REPO"
# The artifact build spawns `vp`, which only exists in the repo's node_modules.
export PATH="$REPO/node_modules/.bin:$PATH"
unset ELECTRON_RUN_AS_NODE

if ! git diff --quiet HEAD; then
echo "error: $REPO has uncommitted changes; build from a clean checkout." >&2
exit 1
fi

STAGE="$(mktemp -d /tmp/t3-alpha-build.XXXXXX)"
# The manifests were clean above, so any package.json diff is the version bump.
trap 'rm -rf "$STAGE"; git -C "$REPO" diff --name-only -z -- "*package.json" | xargs -0 git -C "$REPO" checkout --' EXIT

vp i
node scripts/update-release-package-versions.ts "$VERSION"
node scripts/build-desktop-artifact.ts --platform mac --target zip --arch arm64 --build-version "$VERSION"

# Inside-out: every Mach-O file (native modules and helpers are not all +x),
# then nested bundles deepest first, then the app itself. The build has no
# hardened runtime or entitlements to carry over, but preserve them if it ever does.
sign_app() { # <app>
local app="$1" f
local opts=(--force --timestamp=none --preserve-metadata=identifier,entitlements --sign "$SIGN_IDENTITY")
while IFS= read -r f; do
codesign "${opts[@]}" "$f"
done < <(find "$app/Contents" -type f -print0 | xargs -0 file | awk -F': ' '/Mach-O/ { print $1 }')
while IFS= read -r f; do
codesign "${opts[@]}" "$f"
done < <(find "$app/Contents" -type d \( -name '*.framework' -o -name '*.app' \) | awk '{ print gsub("/", "/") "\t" $0 }' | sort -rn | cut -f2-)
codesign "${opts[@]}" "$app"
codesign --verify --deep --strict "$app"
}

ditto -x -k "$ZIP" "$STAGE"
APP="$(find "$STAGE" -maxdepth 1 -name '*.app' | head -1)"
sign_app "$APP" 2> >(grep -v 'replacing existing signature' >&2)
rm -f "$ZIP" "$ZIP.blockmap"
ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP"

echo "Built and signed $ZIP"
codesign -d -r- "$APP" 2>&1 | tail -1
57 changes: 57 additions & 0 deletions scripts/personal/t3-alpha-deploy
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/bin/bash
# t3-alpha-deploy — install a zip from t3-alpha-build on every Mac. Remote hosts
# go first over SSH, `local` last: installing here restarts the T3 Code this
# may be running inside, which ends the session driving the deploy.
#
# Each host gets this checkout's t3-alpha-install copied next to the zip, so
# no Mac carries its own installer copy that can drift.
#
# Usage: t3-alpha-deploy <zip> [host ...]
# hosts default to: emanuel@matebook.lan emanuelfranzen@Mac.lan local

set -uo pipefail

ZIP="${1:?usage: t3-alpha-deploy <zip> [host ...]}"
shift
HOSTS=("$@")
[[ ${#HOSTS[@]} -eq 0 ]] && HOSTS=(emanuel@matebook.lan emanuelfranzen@Mac.lan local)
INSTALLER="$(cd "$(dirname "$0")" && pwd)/t3-alpha-install"
REMOTE_LOG='~/Library/Logs/t3-alpha-install.log'
INFO_PLIST="$(unzip -Z1 "$ZIP" | grep -E '^[^/]+\.app/Contents/Info\.plist$')"
VERSION="$(unzip -p "$ZIP" "$INFO_PLIST" | plutil -extract CFBundleShortVersionString raw -)"
[[ -n "$VERSION" ]] || { echo "error: cannot read the app version from $ZIP" >&2; exit 1; }

deploy_remote() { # <host>
local host="$1" zip_name before pid result
zip_name="$(basename "$ZIP")"
echo "== $host: copying $zip_name"
scp -q "$ZIP" "$INSTALLER" "$host:/tmp/" || return 1
before=$(ssh "$host" "cat $REMOTE_LOG 2>/dev/null | wc -l" | tr -d ' ')
pid=$(ssh "$host" "/bin/bash /tmp/t3-alpha-install /tmp/$zip_name --expect-version $VERSION </dev/null" |
sed -nE 's/.*\(pid ([0-9]+)\).*/\1/p')
[[ -n "$pid" ]] || { echo " installer did not start"; return 1; }
# The installer detaches so it survives the app it replaces; wait for it to exit.
while ssh "$host" "kill -0 $pid 2>/dev/null"; do sleep 3; done
ssh "$host" "rm -f /tmp/$zip_name /tmp/t3-alpha-install"
result=$(ssh "$host" "tail -n +$((before + 1)) $REMOTE_LOG")
sed 's/^/ /' <<<"$result"
grep -q "SUCCESS:" <<<"$result"
}

failed=()
for host in "${HOSTS[@]}"; do
[[ "$host" == local ]] && continue
deploy_remote "$host" || failed+=("$host")
done

if [[ ${#failed[@]} -gt 0 ]]; then
echo "Failed on: ${failed[*]}. Skipping this Mac." >&2
exit 1
fi

for host in "${HOSTS[@]}"; do
if [[ "$host" == local ]]; then
echo "== local: installing (T3 Code here restarts; follow ~/Library/Logs/t3-alpha-install.log)"
"$INSTALLER" "$ZIP" --expect-version "$VERSION"
fi
done
Loading
Loading