Compare commits

Author SHA1 Message Date
jjbruntonandClaude Opus 5 3a019e62d3 fix(api): allow the admin console origin through CORS
The production allowlist held only https://sauci.app, so every
/v1/admin/* preflight from https://manage.sauci.app returned 204 with no
Access-Control-Allow-Origin and the browser blocked the request. Login
then reported "Access denied. You are not an administrator." because the
admin app treats any /v1/admin/me failure as a permissions failure.

Add the admin console origin and record in the README that new admin
hosts belong in the Compose contract rather than a Dokploy override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:26 +01:00
jjbrunton 84712a950e Merge pull request #26 from jjbrunton/hotfix/admin-docker-context
fix(admin): restore admin docker build context, allow hotfix branches into main
2026-08-31 18:08:31 +01:00
jjbruntonandClaude Opus 5 d14869d745 ci(branch-policy): allow hotfix and release branches into main
The policy accepted only `staging` as a head for main, so any change
production needed before the next promotion had nowhere to go: the PR was
unmergeable by CI and branch protection blocks direct pushes.

Accept `hotfix/*` and `release/*` heads as well. The naming requirement
keeps a direct-to-main change deliberate and visible, and the master
freeze plus the "features target staging" rule are unchanged. Document
the escape hatch and the need to land the same commit on staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:48 +01:00
jjbruntonandClaude Opus 5 f1573f4906 fix(admin): keep admin app in the docker build context
The root .dockerignore excluded apps/admin, so the monorepo build context
dropped that workspace entirely: npm ci skipped it and
`npm run build -w @sauci/admin` failed with "No workspaces found".
The ignore file was authored for the mcp image and later extended with
re-includes for web; admin never got the same treatment.

Re-include apps/admin, and re-exclude node_modules/dist last so the
re-includes cannot pull host build artefacts into any image context.

Verified with `docker build -f apps/admin/Dockerfile .`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:48 +01:00
jjbrunton 83df3b45f8 Merge pull request #23 from jjbrunton/staging
Release v1.0.8: Expo SDK 56 and EAS Observe
2026-08-31 15:47:29 +01:00
14 changed files with 58 additions and 469 deletions

No files matched your search

@@ -1,66 +0,0 @@
---
id: 2026-08-31-expo-prebuild-native-regression
date: 2026-08-31
status: promoted
scope: apps/mobile native projects (ios/, android/) and expo prebuild
evidence: v1.0.8 SDK 54 to 56 upgrade, commits 604eec3, 7aca39a, 6bd04a6, 79233f5, b8cb2add
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, and docs/releasing.md
review_after: 2026-11-30
---
## Symptom
`expo prebuild --clean` during the SDK 54 to 56 upgrade (and the
@bacons/apple-targets 0.1 to 5.0 bump) silently regressed four checked-in
native invariants. Each regression was caught only by a release gate or by
manual diffing against the last shipped tag (3fc6f8a, v1.0.7), not by
prebuild itself.
## Reproduction
Run `expo prebuild --clean` in apps/mobile against the upgraded SDK and diff
the regenerated ios/ and android/ projects against the previously committed
projects.
## Cause
1. Widget identity: apple-targets 5.x derives PRODUCT_BUNDLE_IDENTIFIER from
the target folder name, changing com.sauci.app.LiveDrawWidget to
com.sauci.app.widget, and repointed CODE_SIGN_ENTITLEMENTS to the
gitignored ios/.targets/LiveDrawWidget/generated.entitlements.
2. Android manifest lost the tools:node="remove" attributes on
READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, so the built AAB regained
Play-forbidden permissions (verified via bundletool dump on the rejected
build 50 AAB).
3. iOS Info.plist gained dev-launcher-only keys NSBonjourServices
(_expo._tcp), NSLocalNetworkUsageDescription, and RCTMetroPort.
4. android/gradle.properties reset to stock -Xmx2048m
-XX:MaxMetaspaceSize=512m, causing Metaspace OOM in kspReleaseKotlin/CMake
under RN 0.85.
Separately, a package absent from the new SDK's bundledNativeModules
(expo-av) has no compatible release and fails the iOS archive (EXAV.h
imports removed ExpoModulesCore headers).
## Remediation
Fixed by restoring widget identity and tracking
targets/widget/generated.entitlements with a pinned bundleIdentifier
(604eec3); restoring manifest tools:node="remove" attributes (7aca39a);
removing the dev-launcher-only Info.plist keys (6bd04a6); raising
gradle.properties memory to 4096m/1024m (79233f5); and migrating expo-av to
expo-video/expo-audio (b8cb2add).
Extend apps/mobile/scripts/release-preflight.mjs with executable checks for
items 2 and 3 (item 1 is already covered by the existing LiveDrawWidget
preflight check; item 4 hard-fails the build by itself). Add an "After
native regeneration" checklist to docs/releasing.md.
## Evidence
`node apps/mobile/scripts/release-preflight.mjs` fails if the tracked
AndroidManifest.xml lacks tools:node="remove" on READ_MEDIA_IMAGES or
READ_MEDIA_VIDEO, or if ios/Sauci/Info.plist contains NSBonjourServices,
NSLocalNetworkUsageDescription, or RCTMetroPort.
`node --test apps/mobile/scripts/release-preflight.test.mjs` covers the new
checks.
@@ -1,55 +0,0 @@
---
id: 2026-08-31-local-eas-build-preconditions
date: 2026-08-31
status: promoted
scope: apps/mobile local EAS release builds
evidence: v1.0.8 local EAS build session, commit d8bedea
destination: docs/releasing.md
review_after: 2026-11-30
---
## Symptom
Local EAS release builds failed before native compilation for environment
reasons unrelated to the app source, and a successful iOS build left the
tracked source in a state that fails release-preflight afterward.
## Reproduction
Run `eas build --local` from a non-interactive shell without first exporting
ANDROID_HOME/ANDROID_SDK_ROOT/JAVA_HOME: fails with "SDK location not
found" because non-interactive shells do not load the user profile. Run it
with a non-empty EAS_LOCAL_BUILD_WORKINGDIR: fails with "Workingdir is not
empty". Run it outside apps/mobile: eas cannot find the project. Pipe the
build log through `tail`: the full log needed for diagnosis is lost. After a
successful iOS build, EAS auto-increment updates both Info.plists but not
the widget target's CURRENT_PROJECT_VERSION in the pbxproj, so
release-preflight fails until it is synced.
## Cause
`eas` must be installed as a global CLI (scripts call bare `eas`; eas-cli
23.0.0 verified). Non-interactive shells used for automation do not source
the profile that normally exports the Android/Java environment variables.
EAS_LOCAL_BUILD_WORKINGDIR must not exist or be empty. EAS iOS
auto-increment writes plist versions but does not touch the widget target's
pbxproj CURRENT_PROJECT_VERSION, so it drifts from the app's build number by
one increment after every successful build.
## Remediation
Before a local EAS build: verify `eas` is on PATH, explicitly export
ANDROID_HOME, ANDROID_SDK_ROOT, and JAVA_HOME (JDK 17), ensure
EAS_LOCAL_BUILD_WORKINGDIR does not exist or is empty, and run from
apps/mobile. Capture the full build log to a file rather than piping through
`tail`. After a successful build, sync the widget's CURRENT_PROJECT_VERSION
to the new build number (commit d8bedea, sed 46 to 47) and sync
ios/Podfile.lock from the successful build's working directory when
dependencies changed, since the archive's pod install resolution is
authoritative and local CocoaPods here is broken.
## Evidence
docs/releasing.md's local-build section documents the environment
preconditions and the post-build widget CURRENT_PROJECT_VERSION and
Podfile.lock sync steps; `npm run release:preflight` passes after the sync.
@@ -1,55 +0,0 @@
---
id: 2026-08-31-npm-lockfile-divergence
date: 2026-08-31
status: promoted
scope: root package.json, package-lock.json, apps/web dependencies, CI
evidence: v1.0.8 lockfile investigation, commits 90cde4a, 9609a90
destination: docs/development/dependencies.md
review_after: 2026-11-30
---
## Symptom
CI and the API Docker image (node 20.19.4/npm 10.8.2, per .nvmrc) broke
invisibly while a lockfile regenerated on this dev machine (node 26/npm
11.19) passed local `npm ci`. Pushes to staging bypass required status
checks, so the red CI ran unnoticed for hours.
## Reproduction
Regenerate package-lock.json with npm 11 on a wildcard-range dependency
(react-native-worklets depends on `@react-native/metro-config: "*"`), then
run `npm ci` under node 20.19.4/npm 10.8.2. npm 10 requires 0.85.3 at that
tree position; npm 11 resolves/hoists 0.87.x there. `npm ci` fails with
"Invalid: lock file's ... does not satisfy". The reverse direction also
fails: a lock regenerated by npm 10 fails `npm ci` under npm 11 with missing
platform-optional binaries (@next/swc-linux-x64-gnu breaking `next build`,
@rollup/rollup-linux-x64-gnu breaking the API Docker tsup build, lightningcss
variants breaking local npm ci), matching the npm/cli#4828-style
optional-dependency resolution gap.
## Cause
Two different npm major versions in active use (CI/Docker on npm 10, this
dev machine on npm 11) resolve wildcard/range dependencies to different
versions at the same tree position, and each npm's own platform-optional
binary selection is not portable to the other npm's lockfile.
## Remediation
Pin the wildcard-range dependency via a root package.json override
(`"@react-native/metro-config": "0.85.3"`, alongside the existing
rollup/react-native/expo pins). Pin apps/web `@next/swc` linux/darwin
binaries as direct optionalDependencies (must be bumped together with any
`next` version bump). Regenerate the lock and validate it both ways: local
`npm ci` plus `npm run verify:fast`, and
`docker run --platform linux/amd64 node:20.19.4 ... npm ci` (the
`--platform` flag is mandatory; Apple Silicon Docker otherwise runs arm64
and the platform-binary check is meaningless). After any push that bypassed
required status checks, check `gh run list --branch staging`.
## Evidence
commits 90cde4a and 9609a90 add the override and optionalDependencies pins
and record a lockfile that passes both the amd64-emulated `npm ci` under
node 20.19.4 and local `npm ci` plus `npm run verify:fast`.
-1
View File
@@ -76,7 +76,6 @@ environment limitation; do not call it a passing product check.
|---|---|
| System boundaries and data flow | `docs/architecture/system.md` |
| Local services, ports, fixtures | `docs/development/local-stack.md` |
| Dependency and lockfile discipline | `docs/development/dependencies.md` |
| Test layers and required gates | `docs/testing/strategy.md` |
| Database migration safety | `docs/operations/database-migrations.md` |
| Agent roles and effort routing | `docs/agents/routing.md` |
-2
View File
@@ -13,5 +13,3 @@ native iOS/Android targets.
- Add stable accessibility labels or `testID` values for critical interactions.
- Verify focused Jest tests, typecheck, lint, then native/Expo behavior as risk
requires. UI work needs observable simulator evidence.
- Before and after any `expo prebuild`, follow the "After native regeneration"
checklist in `docs/releasing.md`.
+6 -17
View File
@@ -69,12 +69,10 @@ export default function MatchesScreen() {
checkNudgeCooldown,
} = useMatchStore();
const { user, couple, partner } = useAuthStore();
const userId = user?.id;
const router = useRouter();
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const [showTutorial, setShowTutorial] = useState(false);
const tutorialDismissedRef = useRef(false);
const [nudgeFeedback, setNudgeFeedback] = useState<string | null>(null);
const [headerHeight, setHeaderHeight] = useState(130);
@@ -106,30 +104,21 @@ export default function MatchesScreen() {
// Check if tutorial should be shown when screen is focused or matches change
useFocusEffect(
useCallback(() => {
let isActive = true;
const checkTutorial = async () => {
if (matches.length > 0 && userId) {
const seen = await hasSeenMatchesTutorial(userId);
if (isActive && !tutorialDismissedRef.current && !seen) {
if (matches.length > 0) {
const seen = await hasSeenMatchesTutorial();
if (!seen) {
setShowTutorial(true);
}
}
};
void checkTutorial();
return () => {
isActive = false;
};
}, [matches.length, userId])
checkTutorial();
}, [matches.length])
);
const handleTutorialComplete = async () => {
if (!userId) return;
tutorialDismissedRef.current = true;
await markMatchesTutorialSeen();
setShowTutorial(false);
await markMatchesTutorialSeen(userId);
};
// Load whichever view is showing, once. Returning to this tab reuses what is
-12
View File
@@ -39,7 +39,6 @@ export function validateReleaseSources({ root = repoRoot, isTracked } = {}) {
const androidGradle = file('apps/mobile/android/app/build.gradle');
const androidStrings = file('apps/mobile/android/app/src/main/res/values/strings.xml');
const xcode = file('apps/mobile/ios/Sauci.xcodeproj/project.pbxproj');
const androidManifest = file('apps/mobile/android/app/src/main/AndroidManifest.xml');
const iosInfo = file('apps/mobile/ios/Sauci/Info.plist');
const widgetInfo = file('apps/mobile/targets/widget/Info.plist');
const iosExpo = file('apps/mobile/ios/Sauci/Supporting/Expo.plist');
@@ -71,17 +70,6 @@ export function validateReleaseSources({ root = repoRoot, isTracked } = {}) {
fail(`iOS app, widget, and effective widget Xcode build numbers disagree: app ${appBuild}; widget plist ${widgetBuild}; widget settings ${widgetBuildSettings.join(', ')}`);
}
for (const permission of ['READ_MEDIA_IMAGES', 'READ_MEDIA_VIDEO']) {
const removed = new RegExp(`<uses-permission[^>]*android:name="android\\.permission\\.${permission}"[^>]*tools:node="remove"`).test(androidManifest);
if (!removed) fail(`AndroidManifest.xml must keep tools:node="remove" on ${permission} (expo prebuild regresses this)`);
}
const forbiddenPlistKeys = ['NSBonjourServices', 'NSLocalNetworkUsageDescription', 'RCTMetroPort'];
const foundDevLauncherKeys = forbiddenPlistKeys.filter((key) => iosInfo.includes(key));
if (foundDevLauncherKeys.length) {
fail(`ios/Sauci/Info.plist must not contain dev-launcher-only keys reintroduced by expo prebuild: ${foundDevLauncherKeys.join(', ')}`);
}
const tracked = isTracked ?? ((relative) => gitStatus(['ls-files', '--error-unmatch', relative], root) === 0);
if (!tracked(certificatePath)) fail(`${certificatePath} must be tracked`);
const archiveIgnore = existsSync(join(root, '.easignore')) ? '.easignore' : '.gitignore';
@@ -67,38 +67,6 @@ test('rejects an OTA certificate without its root archive allowlist', () => {
}
});
test('rejects an Android manifest that lost the media permission removal (expo prebuild regression)', () => {
const root = fixture();
try {
const manifest = 'apps/mobile/android/app/src/main/AndroidManifest.xml';
replace(
root,
manifest,
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>',
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>',
);
assert.throws(() => validate(root), /READ_MEDIA_IMAGES \(expo prebuild regresses this\)/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('rejects an iOS Info.plist that regained dev-launcher-only keys (expo prebuild regression)', () => {
const root = fixture();
try {
const infoPlist = 'apps/mobile/ios/Sauci/Info.plist';
replace(
root,
infoPlist,
'</dict>\n</plist>',
'<key>NSBonjourServices</key>\n<array><string>_expo._tcp</string></array>\n</dict>\n</plist>',
);
assert.throws(() => validate(root), /dev-launcher-only keys reintroduced by expo prebuild: NSBonjourServices/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('requires the local EAS plugin executable rather than its package directory', () => {
const root = mkdtempSync(join(tmpdir(), 'sauci-eas-plugin-'));
const executable = join(root, 'bin-run');
@@ -1,45 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";
import {
hasSeenMatchesTutorial,
markMatchesTutorialSeen,
resetMatchesTutorial,
} from "@/lib/matchesTutorialSeen";
jest.mock("expo-secure-store", () => ({
getItemAsync: jest.fn(),
}));
describe("matchesTutorialSeen", () => {
beforeEach(async () => {
await AsyncStorage.clear();
jest.mocked(SecureStore.getItemAsync).mockReset();
jest.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
});
it("persists a dismissal for the signed-in account", async () => {
await markMatchesTutorialSeen("user-1");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true);
});
it("does not dismiss the tutorial for another account on the same device", async () => {
await markMatchesTutorialSeen("user-1");
await expect(hasSeenMatchesTutorial("user-2")).resolves.toBe(false);
});
it("migrates an existing legacy dismissal into durable account storage", async () => {
jest.mocked(SecureStore.getItemAsync).mockResolvedValue("true");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true);
await expect(AsyncStorage.getItem("matches_tutorial_seen_user-1")).resolves.toBe("true");
});
it("lets the development reset show the tutorial for the current account again", async () => {
await markMatchesTutorialSeen("user-1");
await resetMatchesTutorial("user-1");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(false);
});
});
@@ -223,8 +223,7 @@ export function ProfileScreen() {
label="Reset Matches Tutorial"
description="Show the tutorial again"
onPress={async () => {
if (!user?.id) return;
await resetMatchesTutorial(user.id);
await resetMatchesTutorial();
Alert.alert("Success", "Matches tutorial has been reset.");
}}
/>
+51 -70
View File
@@ -1,70 +1,51 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const STORAGE_KEY_PREFIX = "matches_tutorial_seen";
const LEGACY_STORAGE_KEY = "matches_tutorial_seen";
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
async function getStorage(userId: string): Promise<boolean> {
try {
const key = buildKey(userId);
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
return window.localStorage.getItem(key) === "true";
}
return false;
}
const storedValue = await AsyncStorage.getItem(key);
if (storedValue !== null) {
return storedValue === "true";
}
// Keep dismissals made before the account-scoped AsyncStorage key was
// introduced. Future reads use the durable app storage key above.
const legacyValue = await SecureStore.getItemAsync(LEGACY_STORAGE_KEY);
if (legacyValue === "true") {
await AsyncStorage.setItem(key, "true");
return true;
}
return false;
} catch (error) {
console.error("Error reading matches tutorial state:", error);
}
return false;
}
async function setStorage(userId: string, seen: boolean): Promise<void> {
try {
const data = seen ? "true" : "false";
const key = buildKey(userId);
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(key, data);
}
} else {
await AsyncStorage.setItem(key, data);
}
} catch (error) {
console.error("Error saving matches tutorial state:", error);
}
}
/** The Matches tutorial is shown at most once per signed-in account. */
export async function hasSeenMatchesTutorial(userId: string): Promise<boolean> {
return getStorage(userId);
}
export async function markMatchesTutorialSeen(userId: string): Promise<void> {
await setStorage(userId, true);
}
export async function resetMatchesTutorial(userId: string): Promise<void> {
await setStorage(userId, false);
}
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const STORAGE_KEY = "matches_tutorial_seen";
async function getStorage(): Promise<boolean> {
try {
let data: string | null = null;
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(STORAGE_KEY);
}
} else {
data = await SecureStore.getItemAsync(STORAGE_KEY);
}
return data === "true";
} catch (error) {
console.error("Error reading matches tutorial state:", error);
}
return false;
}
async function setStorage(seen: boolean): Promise<void> {
try {
const data = seen ? "true" : "false";
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, data);
}
} else {
await SecureStore.setItemAsync(STORAGE_KEY, data);
}
} catch (error) {
console.error("Error saving matches tutorial state:", error);
}
}
export async function hasSeenMatchesTutorial(): Promise<boolean> {
return getStorage();
}
export async function markMatchesTutorialSeen(): Promise<void> {
await setStorage(true);
}
export async function resetMatchesTutorial(): Promise<void> {
await setStorage(false);
}
-76
View File
@@ -1,76 +0,0 @@
# Dependency and lockfile discipline
## Two npm realities
CI runners and the API Docker image use the node version pinned in
`.nvmrc` (node 20.19.4 with npm 10.8.2). A developer machine can run a newer
npm (for example npm 11). npm 10 and npm 11 resolve wildcard or range
dependencies differently at the same place in the dependency tree, and each
npm's platform-optional binary selection is not portable to a lockfile
regenerated by the other npm. A lockfile that passes `npm ci` under one npm
major version can fail it under the other with either an "Invalid: lock
file's ... does not satisfy" error or missing platform-optional binaries
(for example `@next/swc-linux-x64-gnu` breaking `next build`,
`@rollup/rollup-linux-x64-gnu` breaking the API Docker `tsup` build, or a
missing `lightningcss` variant breaking local `npm ci`).
## Pin wildcard-range dependencies with root overrides
When a transitive dependency declares a wildcard or wide range (for example
`react-native-worklets` depending on `@react-native/metro-config: "*"`), pin
the exact version the app needs in the root `package.json` `overrides` block
rather than leaving it to whichever npm resolved the lockfile last:
```json
"overrides": {
"rollup": "4.54.0",
"react-native": "0.85.3",
"expo": "~56.0.0",
"@react-native/metro-config": "0.85.3"
}
```
## Pin platform-optional binaries that must travel with the framework version
`apps/web/package.json` pins the `@next/swc` linux and darwin binaries as
direct `optionalDependencies` so npm does not have to infer them per
platform:
```json
"optionalDependencies": {
"@next/swc-linux-x64-gnu": "15.5.24",
"@next/swc-linux-x64-musl": "15.5.24",
"@next/swc-darwin-arm64": "15.5.24"
}
```
These versions must be bumped together with any `next` version bump, or the
pinned binaries will drift from the framework version and `npm ci` or
`next build` can fail on the platform that needs the stale binary.
## Validate a regenerated lockfile both ways
After changing `overrides` or any dependency version, regenerate
`package-lock.json` and validate it under both npm majors before committing:
```bash
# Local npm (matches this machine's npm major)
npm ci
npm run verify:fast
# The CI/Docker npm major, emulated on Apple Silicon
docker run --rm --platform linux/amd64 -v "$PWD:/repo" -w /repo node:20.19.4 npm ci
```
The `--platform linux/amd64` flag is mandatory on Apple Silicon: without it,
Docker runs the arm64 image and the platform-binary check proves nothing.
## After any bypassed push
Pushes to `staging` bypass required status checks. After any such push, check
`gh run list --branch staging` to confirm CI is actually green rather than
assuming it ran and passed.
Do not pipe `npm` commands through `tail` when diagnosing a lockfile or build
failure; it can swallow the command's exit code and the detail needed to find
the root cause.
-1
View File
@@ -8,7 +8,6 @@ historical notes. Agents should load only the section relevant to their task.
- [System architecture](architecture/system.md)
- [Client data freshness](architecture/client-data-freshness.md)
- [Local full-stack development](development/local-stack.md)
- [Dependency and lockfile discipline](development/dependencies.md)
- [Testing strategy](testing/strategy.md)
- [Database migration safety](operations/database-migrations.md)
- [Backend deployment and branching](operations/backend-deployments.md)
-35
View File
@@ -249,23 +249,6 @@ and dependency installation failures occur before native compilation: fix and
preflight those inputs without starting another build. Make one meaningful build
per newly proven input state.
Before starting: confirm `eas` is installed and on PATH (scripts call bare
`eas`); explicitly export `ANDROID_HOME`, `ANDROID_SDK_ROOT`, and `JAVA_HOME`
(JDK 17) rather than relying on a shell profile, because non-interactive shells
used for automation do not load it ("SDK location not found" otherwise); ensure
`EAS_LOCAL_BUILD_WORKINGDIR` does not exist or is empty ("Workingdir is not
empty" otherwise); and run every command from `apps/mobile`. Capture the full
build log to a file; do not pipe it through `tail`, which drops the detail
needed to diagnose a failure.
After a successful iOS build, EAS auto-increment updates both app and widget
Info.plists but not the widget target's `CURRENT_PROJECT_VERSION` in the
pbxproj, so `release:preflight` fails until it is synced to the new build
number (for example `sed -i '' 's/CURRENT_PROJECT_VERSION = 46;/CURRENT_PROJECT_VERSION = 47;/' ios/Sauci.xcodeproj/project.pbxproj`
for both Debug and Release). If native dependencies changed, also sync
`ios/Podfile.lock` from the successful build's working directory: the archive's
`pod install` resolution is authoritative when local CocoaPods is broken.
Report provider progress by phase: packaging, native compilation, submission
queue, or store processing. Do not describe every failure as a rebuild.
@@ -309,24 +292,6 @@ verify it, and commit it before starting the release from a clean worktree. Use
5.x, a non-clean prebuild against an already-generated iOS project fails while
updating the widget's Xcode target.
**After native regeneration:** `expo prebuild --clean` can silently regress
checked-in native invariants (confirmed during the SDK 54 to 56 upgrade). Before
committing a regenerated project:
- Diff pbxproj signing and identity keys (PRODUCT_BUNDLE_IDENTIFIER,
CODE_SIGN_ENTITLEMENTS) against the last release tag; apple-targets can derive
a widget's bundle identifier from its folder name instead of the pinned config.
- Semantic-diff Info.plist and entitlements files against the last release tag;
prebuild can reintroduce dev-launcher-only keys (NSBonjourServices,
NSLocalNetworkUsageDescription, RCTMetroPort) and drop widget entitlements.
- Check android/gradle.properties for a reset `org.gradle.jvmargs` (prebuild
resets it to the stock -Xmx2048m/512m, which OOMs release Kotlin/CMake tasks).
- Run `npm run release:preflight`; it enforces the Android manifest media
permission removal and rejects the dev-launcher-only Info.plist keys.
- Treat `expo-doctor` unmaintained-package warnings as release blockers on an
SDK upgrade: a package absent from the new SDK's bundledNativeModules can fail
the iOS archive with no compatible release available.
**Out of memory:**
```bash
# Increase Gradle memory