diff --git a/.agents/learnings/promoted/2026-08-28-maestro-stable-login-readiness.md b/.agents/learnings/promoted/2026-08-28-maestro-stable-login-readiness.md
new file mode 100644
index 0000000..da1aa23
--- /dev/null
+++ b/.agents/learnings/promoted/2026-08-28-maestro-stable-login-readiness.md
@@ -0,0 +1,37 @@
+---
+id: 2026-08-28-maestro-stable-login-readiness
+date: 2026-08-28
+status: promoted
+scope: native Auth-shell acceptance flows
+evidence: iOS simulator login-shell acceptance on current main
+destination: e2e/maestro
+review_after: 2027-02-28
+---
+
+## Symptom
+
+The credential-free native smoke failed even though the current app reached the
+login screen and displayed its required controls.
+
+## Reproduction
+
+Run `e2e/maestro/login-screen-smoke.yaml` against current main. The flow waited
+for the retired tagline `Explore intimacy together`; the app displayed updated
+marketing copy while `Sauci` and `Password` were present.
+
+## Cause
+
+Login readiness was coupled to mutable marketing copy rather than a stable
+required control.
+
+## Remediation
+
+Wait for the password control that defines login readiness, while retaining the
+brand and password assertions. Apply the same readiness condition to the
+authenticated and credential-free flows.
+
+## Evidence
+
+Simulator read-back confirmed the current login shell exposes `Sauci` and
+`Password`. The maintained Maestro flows now wait for that stable control rather
+than the tagline.
diff --git a/.agents/learnings/promoted/2026-08-28-mobile-release-source-integrity.md b/.agents/learnings/promoted/2026-08-28-mobile-release-source-integrity.md
new file mode 100644
index 0000000..63122e2
--- /dev/null
+++ b/.agents/learnings/promoted/2026-08-28-mobile-release-source-integrity.md
@@ -0,0 +1,40 @@
+---
+id: 2026-08-28-mobile-release-source-integrity
+date: 2026-08-28
+status: promoted
+scope: mobile release build and upload workflow
+evidence: clean-source local EAS builds for iOS 43 and Android 46
+destination: scripts/lint-harness.mjs
+review_after: 2027-02-28
+---
+
+## Symptom
+
+A build-only request initially regenerated tracked native projects. The generated
+Android widget provider replaced the checked-in implementation, and the Android
+release script would also have uploaded after building.
+
+## Reproduction
+
+Run the former mobile release scripts or run `expo prebuild` before a local EAS
+build, then inspect `git diff`. The reproduced diff included the Android widget
+provider, widget XML, and Xcode project rather than version metadata alone.
+
+## Cause
+
+The release scripts combined three independently reviewed operations: native
+regeneration, artifact creation, and store upload. Sauci already tracks native
+projects, and EAS skips prebuild when those directories exist.
+
+## Remediation
+
+Keep native regeneration explicit, make release scripts artifact-only, expose
+upload separately, and lint the scripts so those responsibilities cannot be
+silently recombined.
+
+## Evidence
+
+Corrected local EAS logs reported `PREBUILD` skipped. Both production artifacts
+built successfully from the clean tracked sources; the post-build worktree
+contained only the four expected build-number files. `npm run lint:harness`
+enforces the release-script boundary.
diff --git a/.codex/skills/release-mobile/SKILL.md b/.codex/skills/release-mobile/SKILL.md
index 9096503..f7151cd 100644
--- a/.codex/skills/release-mobile/SKILL.md
+++ b/.codex/skills/release-mobile/SKILL.md
@@ -9,7 +9,15 @@ Read `docs/releasing.md`, `apps/mobile/RELEASING.md` when present, and the mobil
scoped instructions. Confirm target platform and release profile. Run repository
verification plus a native acceptance pass before starting a release build.
-Use the package scripts in `apps/mobile/package.json`; preserve platform signing
-and versioning. Building or uploading to a store is an external mutation: obtain
-explicit authorization for the requested target, then report the build artifact,
-version/build number, upload result, and read-back state.
+Before a build from the current upstream branch, fetch, require a clean worktree,
+and prove `HEAD` matches its upstream. Sauci tracks its native iOS and Android
+projects, so routine release builds must package those checked-in sources. Do not
+run `expo prebuild` first; native regeneration is a separate reviewed change.
+
+Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
+Never infer upload or submission authority from build authorization. Store upload
+uses a separately authorized command. Preserve platform signing and local EAS
+versioning. After a build, verify that tracked changes are limited to the expected
+version metadata, then independently read the artifact version and signature.
+Report the artifact, version/build number, upload result when authorized, and
+read-back state.
diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle
index bf620c3..7dadd75 100644
--- a/apps/mobile/android/app/build.gradle
+++ b/apps/mobile/android/app/build.gradle
@@ -91,7 +91,7 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 45
+ versionCode 46
versionName "1.0.5"
}
signingConfigs {
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
index bd9f434..2353eb8 100644
--- a/apps/mobile/app.json
+++ b/apps/mobile/app.json
@@ -21,7 +21,7 @@
"group.com.sauci.app"
]
},
- "buildNumber": "42",
+ "buildNumber": "43",
"usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": {
@@ -183,7 +183,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
- "versionCode": 45,
+ "versionCode": 46,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
diff --git a/apps/mobile/ios/Sauci/Info.plist b/apps/mobile/ios/Sauci/Info.plist
index 604a2c1..0470971 100644
--- a/apps/mobile/ios/Sauci/Info.plist
+++ b/apps/mobile/ios/Sauci/Info.plist
@@ -47,7 +47,7 @@
CFBundleVersion
- 42
+ 43
GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED
LSMinimumSystemVersion
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 8a550cc..2e56507 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -13,10 +13,11 @@
"test": "jest",
"test:watch": "jest --watch",
"test:ci": "jest --runInBand --ci",
- "release:ios": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile production --local",
- "release:ios:preview": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile preview --local",
- "release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local && bundle exec fastlane android upload",
- "release:android:preview": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile preview --local"
+ "release:ios": "dotenv -e .env -- eas build --platform ios --profile production --local",
+ "release:ios:preview": "dotenv -e .env -- eas build --platform ios --profile preview --local",
+ "release:android": "dotenv -e .env -- eas build --platform android --profile production --local",
+ "release:android:preview": "dotenv -e .env -- eas build --platform android --profile preview --local",
+ "release:android:upload": "bundle exec fastlane android upload"
},
"dependencies": {
"@bacons/apple-targets": "^0.1.6",
diff --git a/apps/mobile/targets/widget/Info.plist b/apps/mobile/targets/widget/Info.plist
index e8b5245..8a159fe 100644
--- a/apps/mobile/targets/widget/Info.plist
+++ b/apps/mobile/targets/widget/Info.plist
@@ -8,7 +8,7 @@
com.apple.widgetkit-extension
CFBundleVersion
- 42
+ 43
CFBundleShortVersionString
1.0.5
diff --git a/docs/releasing.md b/docs/releasing.md
index 7a16a8c..6a51f90 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -18,17 +18,33 @@ The `--local` flag runs the EAS build process on your machine instead of Expo's
### Quick Start
-```bash
-cd apps/mobile
-
-# Android - produces .aab file (for Play Store)
-eas build --platform android --profile production --local
-
-# iOS - produces .ipa file (for App Store)
-eas build --platform ios --profile production --local
-```
-
-Output files are placed in the current directory by default.
+```bash
+cd apps/mobile
+
+# Android - produces .aab file (for Play Store)
+npm run release:android
+
+# iOS - produces .ipa file (for App Store)
+npm run release:ios
+```
+
+Output files are placed in the current directory by default.
+
+These scripts create signed artifacts only. They do not upload or submit them.
+Store mutation always requires a separate, explicitly authorized command.
+
+### Source integrity
+
+Sauci commits its native `ios/` and `android/` projects. Start a release from a
+clean worktree whose `HEAD` matches its fetched upstream, and do not run
+`expo prebuild` before a routine release build. EAS detects the native projects
+and packages the checked-in sources directly.
+
+After the build, inspect `git diff`. Only the expected local EAS version fields
+may change: `app.json`, the platform native version file, and the iOS widget
+`Info.plist` for an iOS build. Any widget implementation, resource, or Xcode
+project rewrite means the source was regenerated and the artifact must be rebuilt
+from the clean tracked sources.
### Building an APK (for Testing)
@@ -116,13 +132,11 @@ export JAVA_HOME=$(/usr/libexec/java_home -v 17)
cd android && ./gradlew --stop && cd ..
```
-**Clean build after dependency changes:**
-```bash
-# Remove native folders and rebuild
-rm -rf android ios
-npx expo prebuild --clean
-eas build --platform android --profile production --local
-```
+**Native regeneration after dependency changes:**
+
+Treat `expo prebuild` as a source change, not a release step. Run it separately
+only when native dependencies intentionally changed, review the full native diff,
+verify it, and commit it before starting the release from a clean worktree.
**Out of memory:**
```bash
@@ -163,13 +177,10 @@ MYAPP_UPLOAD_KEY_PASSWORD=
**Important:** Never commit these credentials to git.
-### Step 3: Prebuild (if needed)
-
-If the `android/` folder doesn't exist or native dependencies changed:
-
-```bash
-npx expo prebuild --platform android --clean
-```
+### Step 3: Confirm tracked native sources
+
+The committed `android/` project is the release source. If native dependencies
+changed, regenerate and review that project as a separate change before release.
### Step 4: Build the Release Bundle
@@ -206,11 +217,10 @@ Expected SHA1: `C3:70:C5:8C:85:E5:B5:92:97:82:A9:B0:47:98:40:99:AC:C8:47:43`
## iOS Release Build
-### Step 1: Prebuild (if needed)
-
-```bash
-npx expo prebuild --platform ios --clean
-```
+### Step 1: Confirm tracked native sources
+
+The committed `ios/` project is the release source. If native dependencies
+changed, regenerate and review that project as a separate change before release.
### Step 2: Install Pods
@@ -245,9 +255,11 @@ cd ..
## Version Management
-Version is managed by EAS with `appVersionSource: "remote"` in `eas.json`.
+Version is managed locally by EAS with `appVersionSource: "local"` in
+`apps/mobile/eas.json`. Production builds auto-increment the platform build
+number and synchronize the tracked native version file.
-To sync or bump versions:
+To inspect or deliberately set versions:
```bash
# Check current version
@@ -258,7 +270,7 @@ eas build:version:set --platform android --version 1.0.1
eas build:version:set --platform ios --version 1.0.1
```
-For local builds, you may need to update `app.json` version manually:
+The user-facing version remains in `app.json`:
```json
{
@@ -288,14 +300,16 @@ If Play Store rejects with "signed with wrong key":
3. Verify fingerprint matches expected SHA1
4. Replace your local keystore file
-### Build Failures After Dependency Changes
-
-```bash
-# Clean and rebuild
-cd android && ./gradlew clean && cd ..
-npx expo prebuild --platform android --clean
-cd android && ./gradlew bundleRelease
-```
+### Build Failures After Dependency Changes
+
+```bash
+# Clean the existing tracked Android project and rebuild it
+cd android && ./gradlew clean && cd ..
+cd android && ./gradlew bundleRelease
+```
+
+If native regeneration is genuinely required, do it as a separate reviewed and
+committed source change before running the release build.
### iOS Signing Issues
diff --git a/e2e/maestro/login-screen-smoke.yaml b/e2e/maestro/login-screen-smoke.yaml
index 9a0b9e9..a725dcf 100644
--- a/e2e/maestro/login-screen-smoke.yaml
+++ b/e2e/maestro/login-screen-smoke.yaml
@@ -12,7 +12,7 @@ name: Standalone-backend build reaches the Auth login screen
- tapOn:
point: "94%,44%"
- extendedWaitUntil:
- visible: "Explore intimacy together"
+ visible: "Password"
timeout: 30000
- assertVisible: "Sauci"
- assertVisible: "Password"
diff --git a/e2e/maestro/login-smoke.yaml b/e2e/maestro/login-smoke.yaml
index e240366..66a66fe 100644
--- a/e2e/maestro/login-smoke.yaml
+++ b/e2e/maestro/login-smoke.yaml
@@ -12,7 +12,7 @@ name: Hosted Auth fixture reaches the standalone API-backed app
- tapOn:
point: "94%,44%"
- extendedWaitUntil:
- visible: "Explore intimacy together"
+ visible: "Password"
timeout: 30000
- tapOn: "Password"
- tapOn: "Enter your email"
diff --git a/scripts/lint-harness.mjs b/scripts/lint-harness.mjs
index 1a4c470..9db150f 100644
--- a/scripts/lint-harness.mjs
+++ b/scripts/lint-harness.mjs
@@ -37,6 +37,27 @@ for (const file of allFiles) {
if (!scoped) {
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
+ const mobilePackageFile = path.join(root, 'apps', 'mobile', 'package.json');
+ const mobilePackageJson = JSON.parse(await readFile(mobilePackageFile, 'utf8'));
+ const releaseBuildScripts = Object.entries(mobilePackageJson.scripts ?? {})
+ .filter(([name]) => /^release:(ios|android)(:preview)?$/.test(name));
+ for (const [name, command] of releaseBuildScripts) {
+ if (/\bexpo\s+prebuild\b/.test(command)) {
+ fail(
+ mobilePackageFile,
+ `${name} regenerates tracked native projects before packaging`,
+ 'keep native regeneration as an explicit reviewed change and let the release script build the checked-in native sources',
+ );
+ }
+ if (/\b(?:fastlane|eas)\b[^\n]*(?:upload|submit)|\bupload_to_/.test(command)) {
+ fail(
+ mobilePackageFile,
+ `${name} combines build creation with store mutation`,
+ 'keep build scripts artifact-only and expose upload or submission as a separately authorized command',
+ );
+ }
+ }
+
for (const app of (await readdir(path.join(root, 'apps'), { withFileTypes: true })).filter((entry) => entry.isDirectory())) {
const packageFile = path.join(root, 'apps', app.name, 'package.json');
if (!(await exists(packageFile))) continue;