From 7a7693682924584fe1afc3719a3fdfe8b3041d77 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Mon, 31 Aug 2026 16:03:02 +0100 Subject: [PATCH 1/6] docs(agents): promote v1.0.8 release learnings Record three confirmed v1.0.8 failure modes (expo prebuild native regressions, npm 10/11 lockfile divergence, local EAS build preconditions) and promote each into docs/releasing.md, a new docs/development/dependencies.md, and the release-preflight script. Co-Authored-By: Claude Fable 5 --- ...6-08-31-expo-prebuild-native-regression.md | 66 ++++++++++++++++ ...026-08-31-local-eas-build-preconditions.md | 55 ++++++++++++++ .../2026-08-31-npm-lockfile-divergence.md | 55 ++++++++++++++ AGENTS.md | 1 + docs/development/dependencies.md | 76 +++++++++++++++++++ docs/index.md | 1 + docs/releasing.md | 35 +++++++++ 7 files changed, 289 insertions(+) create mode 100644 .agents/learnings/promoted/2026-08-31-expo-prebuild-native-regression.md create mode 100644 .agents/learnings/promoted/2026-08-31-local-eas-build-preconditions.md create mode 100644 .agents/learnings/promoted/2026-08-31-npm-lockfile-divergence.md create mode 100644 docs/development/dependencies.md diff --git a/.agents/learnings/promoted/2026-08-31-expo-prebuild-native-regression.md b/.agents/learnings/promoted/2026-08-31-expo-prebuild-native-regression.md new file mode 100644 index 0000000..34a3031 --- /dev/null +++ b/.agents/learnings/promoted/2026-08-31-expo-prebuild-native-regression.md @@ -0,0 +1,66 @@ +--- +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. diff --git a/.agents/learnings/promoted/2026-08-31-local-eas-build-preconditions.md b/.agents/learnings/promoted/2026-08-31-local-eas-build-preconditions.md new file mode 100644 index 0000000..f6fa84d --- /dev/null +++ b/.agents/learnings/promoted/2026-08-31-local-eas-build-preconditions.md @@ -0,0 +1,55 @@ +--- +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. diff --git a/.agents/learnings/promoted/2026-08-31-npm-lockfile-divergence.md b/.agents/learnings/promoted/2026-08-31-npm-lockfile-divergence.md new file mode 100644 index 0000000..79b256f --- /dev/null +++ b/.agents/learnings/promoted/2026-08-31-npm-lockfile-divergence.md @@ -0,0 +1,55 @@ +--- +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`. diff --git a/AGENTS.md b/AGENTS.md index a1122af..dc14b5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ 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` | diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md new file mode 100644 index 0000000..459581f --- /dev/null +++ b/docs/development/dependencies.md @@ -0,0 +1,76 @@ +# 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. diff --git a/docs/index.md b/docs/index.md index 2744e05..26df3e9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,6 +8,7 @@ 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) diff --git a/docs/releasing.md b/docs/releasing.md index 4b9e71c..d6c38e9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -249,6 +249,23 @@ 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. @@ -292,6 +309,24 @@ 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 From ecc31a13a2fae616cbfb8a32ef52c2cba6808fe1 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Mon, 31 Aug 2026 16:03:04 +0100 Subject: [PATCH 2/6] feat(release): enforce manifest and Info.plist release invariants Extend release-preflight to reject an Android manifest missing the READ_MEDIA_IMAGES/READ_MEDIA_VIDEO tools:node="remove" attributes and an iOS Info.plist that regained dev-launcher-only keys, both regressions introduced silently by expo prebuild during the SDK 54 to 56 upgrade. Co-Authored-By: Claude Fable 5 --- apps/mobile/AGENTS.md | 2 ++ apps/mobile/scripts/release-preflight.mjs | 12 +++++++ .../mobile/scripts/release-preflight.test.mjs | 32 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 0d0a1f9..994f8d1 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -13,3 +13,5 @@ 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`. diff --git a/apps/mobile/scripts/release-preflight.mjs b/apps/mobile/scripts/release-preflight.mjs index 67e6c61..970b143 100644 --- a/apps/mobile/scripts/release-preflight.mjs +++ b/apps/mobile/scripts/release-preflight.mjs @@ -39,6 +39,7 @@ 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'); @@ -70,6 +71,17 @@ 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(`]*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'; diff --git a/apps/mobile/scripts/release-preflight.test.mjs b/apps/mobile/scripts/release-preflight.test.mjs index 192c7b4..683014b 100644 --- a/apps/mobile/scripts/release-preflight.test.mjs +++ b/apps/mobile/scripts/release-preflight.test.mjs @@ -67,6 +67,38 @@ 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, + '', + '', + ); + 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, + '\n', + 'NSBonjourServices\n_expo._tcp\n\n', + ); + 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'); From 13094267a671b194ce5d9e27c3028b9abafe4204 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Mon, 31 Aug 2026 17:56:41 +0100 Subject: [PATCH 3/6] 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 --- .dockerignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.dockerignore b/.dockerignore index f6e50f1..cb7f5a2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -39,3 +39,11 @@ terraform .claude .codex .ralph + +# The admin app builds from this same monorepo context (apps/admin/Dockerfile). +!apps/admin +!apps/admin/** + +# Host build artefacts must never leak into any image context. +**/node_modules +**/dist From d825e3d80c4a5e510d458a96a1e376daace6a5bf Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Mon, 31 Aug 2026 18:03:34 +0100 Subject: [PATCH 4/6] 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 --- .github/workflows/verify.yml | 11 ++++++++--- docs/operations/backend-deployments.md | 12 ++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index f870058..3fa551a 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -24,9 +24,14 @@ jobs: echo "master is frozen; target staging or main" >&2 exit 1 fi - if [ "$BASE_BRANCH" = "main" ] && [ "$HEAD_BRANCH" != "staging" ]; then - echo "Only staging may be promoted to main" >&2 - exit 1 + if [ "$BASE_BRANCH" = "main" ]; then + case "$HEAD_BRANCH" in + staging|hotfix/*|release/*) ;; + *) + echo "main takes staging promotions, or a hotfix/* or release/* branch for direct work" >&2 + exit 1 + ;; + esac fi if [ "$BASE_BRANCH" != "staging" ] && [ "$BASE_BRANCH" != "main" ]; then echo "Feature pull requests target staging; promotions target main" >&2 diff --git a/docs/operations/backend-deployments.md b/docs/operations/backend-deployments.md index 2ccb482..dab7dbb 100644 --- a/docs/operations/backend-deployments.md +++ b/docs/operations/backend-deployments.md @@ -14,10 +14,14 @@ not deploy the retired Supabase data plane. | `master` | frozen legacy default | none | none | Normal feature pull requests target `staging`. After CI and authenticated staging -acceptance pass, open a promotion pull request from `staging` to `main`. Do not -add production-only commits to `main`: production should run the exact commit -already exercised in staging. Direct pushes to either deployment branch are for -explicit incident recovery only. +acceptance pass, open a promotion pull request from `staging` to `main`. Prefer +that route: production then runs the exact commit already exercised in staging. + +When production needs a change that cannot wait for the next promotion, open a +pull request into `main` from a `hotfix/*` or `release/*` branch. CI enforces +that naming so a direct-to-main change is always deliberate and visible. Land +the same commit on `staging` too, or the next promotion will revert it. Direct +pushes to either deployment branch remain blocked by branch protection. Each Dokploy Compose application is connected to `jjbrunton/Sauci`, watches its single branch with push-triggered automatic deployment, and uses From 636811e66abdd70903abb82540a0dca1ea114ec2 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Mon, 31 Aug 2026 18:17:10 +0100 Subject: [PATCH 5/6] 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 --- infra/backend/README.md | 9 ++++++--- infra/backend/compose.production.yaml | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/infra/backend/README.md b/infra/backend/README.md index 23ba6cd..d3d8daf 100644 --- a/infra/backend/README.md +++ b/infra/backend/README.md @@ -87,9 +87,12 @@ Before the first deployment, provide: 5. A rollback and restore point before any migrated production data is imported. 6. A separately generated `MEDIA_SIGNING_SECRET` (at least 32 characters) and the HTTPS API origin as `MEDIA_PUBLIC_BASE_URL`. -7. The public web origin is fixed to `https://sauci.app` by - `CORS_ALLOWED_ORIGINS` in the Compose contract. Do not broaden it without a - reviewed browser client requirement. +7. Browser origins are fixed by `CORS_ALLOWED_ORIGINS` in the Compose contract: + `https://sauci.app` (public web) and `https://manage.sauci.app` (admin + console, which calls `/v1/admin/*` with a bearer token). Do not broaden the + list without a reviewed browser client requirement, and add any new admin + host here rather than as a Dokploy override, so the allowlist stays + reviewable in git. 8. A non-production hosted Auth project and disposable, onboarding-complete test identities mapped into the staging database for authenticated mobile acceptance. diff --git a/infra/backend/compose.production.yaml b/infra/backend/compose.production.yaml index bc29ed9..ee1ae4a 100644 --- a/infra/backend/compose.production.yaml +++ b/infra/backend/compose.production.yaml @@ -8,7 +8,8 @@ x-common-environment: &common-environment x-api-environment: &api-environment <<: *common-environment PORT: 3003 - CORS_ALLOWED_ORIGINS: https://sauci.app + # https://manage.sauci.app is the admin console; it calls /v1/admin/* from the browser. + CORS_ALLOWED_ORIGINS: https://sauci.app,https://manage.sauci.app DATABASE_POOL_MAX: ${API_DATABASE_POOL_MAX:-10} DATABASE_POOL_IDLE_TIMEOUT_MS: ${DATABASE_POOL_IDLE_TIMEOUT_MS:-30000} DATABASE_POOL_CONNECTION_TIMEOUT_MS: ${DATABASE_POOL_CONNECTION_TIMEOUT_MS:-10000} From 6883befff395f7d08eb6c8d987ed1bb8d85cd9f9 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Mon, 31 Aug 2026 19:39:23 +0100 Subject: [PATCH 6/6] fix(mobile): persist matches tutorial dismissal --- apps/mobile/app/(app)/matches.tsx | 23 +++- .../__tests__/lib/matchesTutorialSeen.test.ts | 45 +++++++ .../src/features/profile/ProfileScreen.tsx | 3 +- apps/mobile/src/lib/matchesTutorialSeen.ts | 121 ++++++++++-------- 4 files changed, 134 insertions(+), 58 deletions(-) create mode 100644 apps/mobile/src/__tests__/lib/matchesTutorialSeen.test.ts diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx index 527d82e..4811723 100755 --- a/apps/mobile/app/(app)/matches.tsx +++ b/apps/mobile/app/(app)/matches.tsx @@ -69,10 +69,12 @@ 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(null); const [headerHeight, setHeaderHeight] = useState(130); @@ -104,21 +106,30 @@ 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) { - const seen = await hasSeenMatchesTutorial(); - if (!seen) { + if (matches.length > 0 && userId) { + const seen = await hasSeenMatchesTutorial(userId); + if (isActive && !tutorialDismissedRef.current && !seen) { setShowTutorial(true); } } }; - checkTutorial(); - }, [matches.length]) + void checkTutorial(); + + return () => { + isActive = false; + }; + }, [matches.length, userId]) ); const handleTutorialComplete = async () => { - await markMatchesTutorialSeen(); + if (!userId) return; + + tutorialDismissedRef.current = true; setShowTutorial(false); + await markMatchesTutorialSeen(userId); }; // Load whichever view is showing, once. Returning to this tab reuses what is diff --git a/apps/mobile/src/__tests__/lib/matchesTutorialSeen.test.ts b/apps/mobile/src/__tests__/lib/matchesTutorialSeen.test.ts new file mode 100644 index 0000000..15ac230 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/matchesTutorialSeen.test.ts @@ -0,0 +1,45 @@ +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); + }); +}); diff --git a/apps/mobile/src/features/profile/ProfileScreen.tsx b/apps/mobile/src/features/profile/ProfileScreen.tsx index 6593636..fbeddb8 100644 --- a/apps/mobile/src/features/profile/ProfileScreen.tsx +++ b/apps/mobile/src/features/profile/ProfileScreen.tsx @@ -223,7 +223,8 @@ export function ProfileScreen() { label="Reset Matches Tutorial" description="Show the tutorial again" onPress={async () => { - await resetMatchesTutorial(); + if (!user?.id) return; + await resetMatchesTutorial(user.id); Alert.alert("Success", "Matches tutorial has been reset."); }} /> diff --git a/apps/mobile/src/lib/matchesTutorialSeen.ts b/apps/mobile/src/lib/matchesTutorialSeen.ts index 4ff4d20..f18ee3f 100644 --- a/apps/mobile/src/lib/matchesTutorialSeen.ts +++ b/apps/mobile/src/lib/matchesTutorialSeen.ts @@ -1,51 +1,70 @@ -import * as SecureStore from "expo-secure-store"; -import { Platform } from "react-native"; - -const STORAGE_KEY = "matches_tutorial_seen"; - -async function getStorage(): Promise { - 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 { - 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 { - return getStorage(); -} - -export async function markMatchesTutorialSeen(): Promise { - await setStorage(true); -} - -export async function resetMatchesTutorial(): Promise { - await setStorage(false); -} +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 { + 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 { + 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 { + return getStorage(userId); +} + +export async function markMatchesTutorialSeen(userId: string): Promise { + await setStorage(userId, true); +} + +export async function resetMatchesTutorial(userId: string): Promise { + await setStorage(userId, false); +}