feat(ios): App Store signing and a one-command release script #10

Merged
uwayss merged 12 commits from fix/ios-and-deployment into main 2026-08-26 09:38:46 +00:00
Owner

Everything needed to archive Muhsin for the App Store and build a matching
Play bundle, plus the tooling to do both in one command.

iOS signing

The ios directory is regenerated on every prebuild, so anything set in
Xcode's Signing pane survives exactly until the next one. withIosSigning
reapplies it each time: it stamps DEVELOPMENT_TEAM from APPLE_TEAM_ID in
.env, and deletes the aps-environment entitlement that expo-notifications
adds whether or not the app uses remote push. Only local notifications are
ever scheduled here, and those need no entitlement.

That second half reverses the reasoning in 203972d, which set the
entitlement to production rather than removing it because removing it
trips ITMS-90078. It does -- but that is a post-upload warning mail, not a
rejection, and the build still reaches TestFlight. Keeping it would oblige
the App ID to carry the Push Notifications capability so signing matches,
for a capability nothing in the app exercises.

Verified against a real prebuild: Muhsin.entitlements comes out as an
empty dict and DEVELOPMENT_TEAM is set on both build configurations.

Release script

npm run bundle goes from a clean tree to a signed .aab and an .xcarchive.
Nothing is passed in -- version, build number and version code come from
app.json, keystore and team id from .env, signing from the two config
plugins -- so a release cannot disagree with the config it was built from.

Artifacts land in build/ rather than dist/, because expo export clears
dist/ and would quietly delete an archive between a build and its upload.
Only the newest run is kept, and the directory is cleared before building
rather than after, since the point is to have the space free while
xcodebuild needs it.

The iOS side stops at the archive; distribution goes through Xcode's
Organizer while the certificates and profiles for this app do not exist yet.

dotenv

withReleaseSigning hand-rolled a KEY=value parser to avoid a dependency.
It was wrong in quiet ways -- a trailing comment became part of the value,
and export KEY=value gave back a key named "export KEY". Neither shape is
in .env today, which is exactly why nobody would notice one appearing. Now
extracted to plugins/readEnv.js on top of dotenv, shared by both plugins
and the release script.

Notes

  • Includes the previously unpushed work from the TestFlight readiness pass:
    export compliance, expo-updates, the RTL reload fix and the iOS rate row.
  • The reminder-time picker on iOS is untouched and still broken -- tracked
    separately.
  • Branch is 3 behind main (the adaptive-icon fix in #7). No file overlap.

Checks: tsc --noEmit, expo lint and prettier --check all clean.

Everything needed to archive Muhsin for the App Store and build a matching Play bundle, plus the tooling to do both in one command. ## iOS signing The ios directory is regenerated on every prebuild, so anything set in Xcode's Signing pane survives exactly until the next one. `withIosSigning` reapplies it each time: it stamps DEVELOPMENT_TEAM from APPLE_TEAM_ID in .env, and deletes the aps-environment entitlement that expo-notifications adds whether or not the app uses remote push. Only local notifications are ever scheduled here, and those need no entitlement. That second half reverses the reasoning in 203972d, which set the entitlement to production rather than removing it because removing it trips ITMS-90078. It does -- but that is a post-upload warning mail, not a rejection, and the build still reaches TestFlight. Keeping it would oblige the App ID to carry the Push Notifications capability so signing matches, for a capability nothing in the app exercises. Verified against a real prebuild: `Muhsin.entitlements` comes out as an empty dict and DEVELOPMENT_TEAM is set on both build configurations. ## Release script `npm run bundle` goes from a clean tree to a signed .aab and an .xcarchive. Nothing is passed in -- version, build number and version code come from app.json, keystore and team id from .env, signing from the two config plugins -- so a release cannot disagree with the config it was built from. Artifacts land in build/ rather than dist/, because `expo export` clears dist/ and would quietly delete an archive between a build and its upload. Only the newest run is kept, and the directory is cleared before building rather than after, since the point is to have the space free while xcodebuild needs it. The iOS side stops at the archive; distribution goes through Xcode's Organizer while the certificates and profiles for this app do not exist yet. ## dotenv `withReleaseSigning` hand-rolled a KEY=value parser to avoid a dependency. It was wrong in quiet ways -- a trailing comment became part of the value, and `export KEY=value` gave back a key named "export KEY". Neither shape is in .env today, which is exactly why nobody would notice one appearing. Now extracted to `plugins/readEnv.js` on top of dotenv, shared by both plugins and the release script. ## Notes - Includes the previously unpushed work from the TestFlight readiness pass: export compliance, expo-updates, the RTL reload fix and the iOS rate row. - The reminder-time picker on iOS is untouched and still broken -- tracked separately. - Branch is 3 behind main (the adaptive-icon fix in #7). No file overlap. Checks: `tsc --noEmit`, `expo lint` and `prettier --check` all clean.
assets/splash-icon.png has never been referenced. There is no splash key
in app.json and expo-splash-screen is not installed, so nothing consumed
it and prebuild ignored it.

Leaving it in the tree implied a splash screen was configured when the
app in fact launches straight into the first frame, which is the intent.
expo-dev-client only backs the development build: it adds the dev menu
and the launcher used by expo run, and contributes nothing to a Release
archive. Declaring it as a runtime dependency put its native code in the
dependency graph of a store build for no reason.

Nothing imports it from src/ -- it is wired in natively at prebuild --
so the move is a manifest change only. The lockfile churn is npm
re-marking its transitive deps devOptional.
CFBundleVersion fell back to the "1" that @expo/config-plugins hardcodes
when ios.buildNumber is unset. That uploads once; App Store Connect
rejects every later build of the same version as a duplicate, which is
exactly the wrong failure mode heading into a TestFlight beta.

Set to 3 to track the Android versionCode, so the two platforms stay
readable against each other.

Quoted deliberately. getBuildNumber() in @expo/config-plugins passes the
value through to Info.plist with no coercion, so a bare 3 serialises as
<integer> where Apple requires <string>.
expo-updates has been a dependency since the SDK 57 upgrade but was
never configured: no updates.url and no runtimeVersion, so its native
code shipped inert and no build could ever receive an update.

Point it at the self-hosted manifest endpoint rather than EAS Update,
which keeps the release path on infrastructure this project already
owns and matches the offline, no-third-party stance in the readme --
the client only ever talks to ota.uwayss.com.

runtimeVersion uses the appVersion policy, so the native runtime is
keyed to the version field and any change that needs new native code
forces a fresh binary instead of being served a bundle it cannot run.
The rate action opens market://details?id=com.uwayss.muhsin, a Play
Store deep link. iOS has no handler for the market: scheme, so
Linking.openURL rejected and the rejection went to a .catch that only
writes to console -- the row looked live and did nothing when tapped.

Hide it on iOS rather than branch the URL: the App Store equivalent
needs a numeric app ID that will not exist until the first build is
accepted. The row can come back, with the branch, once it does.
Every build uploaded without ITSAppUsesNonExemptEncryption lands in App
Store Connect as Missing Compliance and cannot be handed to a tester
until the encryption questionnaire is answered by hand -- per build, not
once per app.

The app is offline by design: no network calls, no custom cryptography,
and the only encryption it relies on is the HTTPS that expo-updates uses
to reach the manifest, which is exempt. Answering false in the config
settles it ahead of time for every future upload.
expo-notifications writes aps-environment into the entitlements
unconditionally, whatever the app actually uses, and its mode prop
defaults to development. An App Store archive carrying a development
value risks being rejected at upload for not matching the distribution
provisioning profile.

Only local notifications are scheduled here -- nothing requests a device
or Expo push token -- so the entitlement is dead weight either way, but
setting it to production is the quiet path. Dropping it outright would
trip Apple's ITMS-90078 warning, because the framework still links the
APNs symbols whether or not they are called.

The App ID needs the Push Notifications capability enabled for signing
to succeed. Xcode adds it on archive under automatic signing.
I18nManager.forceRTL only applies on the next launch, but it flips
I18nManager.isRTL right away. Every style that reads isRTL does so at
StyleSheet construction time, so screens already mounted kept the old
direction while anything mounted afterwards picked up the new one, and
the app sat half-mirrored until the user closed it by hand. iOS offers
no supported way for an app to terminate itself, so there was nothing
better to offer them than the alert asking them to do it.

expo-updates is configured now, so reloadAsync can restart the JS
runtime in place and the whole tree rebuilds against one direction. The
call is guarded: a dev client without updates enabled throws, and the
alert has already been dismissed by then, so a failure leaves the old
close-and-reopen behaviour rather than an error the user cannot act on.

The alert copy said "please close and reopen the app" over an OK button.
Both strings now describe the restart that actually happens.
Play rejects an upload whose version code it has already seen, and App
Store Connect does the same with a build number, so both have to move
before the next bundle regardless of what changed in between.

The marketing version stays at 0.1. Nothing user-facing shipped since
build 3; this is the same app going out to a second store.
withReleaseSigning hand-rolled a KEY=value parser to avoid a dependency.
That parser was wrong in ways that stay quiet until they are not: a
trailing comment became part of the value, and `export KEY=value` gave
back a key literally named "export KEY". Neither shape appears in .env
today, which is exactly why nobody would notice one being introduced.

dotenv has no dependencies of its own and sits in devDependencies, so
Metro never sees it -- nothing under plugins/ or scripts/ is reachable
from index.ts. dotenv.parse rather than dotenv.config: config() writes
into process.env and refuses to overwrite what is already there, which
would leave .env.local's precedence at the mercy of whatever the shell
happened to export.

Extracted to plugins/readEnv.js because the iOS signing plugin and the
release script both need the same two files read in the same order.
The ios directory is regenerated on every prebuild, so a team selected
in Xcode's Signing pane survives exactly until the next one. Without
DEVELOPMENT_TEAM set, xcodebuild refuses to archive at all. app.json has
an ios.appleTeamId field for this, but that commits the account id to
the repo; reading APPLE_TEAM_ID from .env keeps it out, the same way
withReleaseSigning already handles the keystore credentials. A missing
value only warns, because simulator builds do not need a team -- the
release script is where it becomes fatal.

This also drops aps-environment, reversing the reasoning in 203972d.
That commit set the entitlement to production rather than removing it,
on the grounds that removing it trips ITMS-90078. It does, but that is a
post-upload warning mail, not a rejection, and the build still reaches
TestFlight. Weighed against it: keeping the entitlement obliges the App
ID to carry the Push Notifications capability so that signing matches,
for a capability nothing in the app exercises. Only local notifications
are ever scheduled here and those need no entitlement.

Registered first in the plugins array, which reads backwards and is not.
Mods compose so the last one registered runs first, so listing it early
is what makes the delete run after expo-notifications adds the key.
Listing it last silently does nothing, which `expo config --type
introspect` will show either way.
feat(release): add a bundle script for store artifacts
All checks were successful
Checks / checks (pull_request) Successful in 2m17s
551c4ff2ef
One command to go from a clean tree to a signed AAB and an xcarchive.
Everything it needs is already declared: version, build number and
version code come from app.json, the keystore and team id from .env, and
the signing itself from the two config plugins. Nothing is passed in, so
a release cannot disagree with the config it was built from.

Prebuild runs every time and without --no-clean, so the native projects
are always regenerated rather than merged into whatever an earlier run
left behind. --clean is not passed because it no longer means anything:
as of SDK 57 the Expo CLI cleans by default and the flag survives only
as an ignored alias.

The workspace and scheme are read from whatever prebuild produced rather
than hardcoded. Both are derived from expo.name, and hardcoding turns a
rename into an opaque xcodebuild failure minutes into the run.

Artifacts land in build/, not dist/ -- expo export clears dist/, which
would quietly delete an archive between a build and its upload. Only the
newest run is kept, and the directory is cleared before building rather
than after, since the point is to have the space free while xcodebuild
needs it.

Validation happens up front: a missing keystore, blank credentials or an
absent team id all fail in a second rather than partway through a build.
The iOS side stops at the archive. Distribution goes through Xcode's
Organizer, which is worth the clicks while the certificates and profiles
for this app do not exist yet.
uwayss merged commit f4eeeba873 into main 2026-08-26 09:38:46 +00:00
uwayss deleted branch fix/ios-and-deployment 2026-08-26 09:38:46 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
uwayss/muhsin!10
No description provided.