diff --git a/.github/workflows/native-build-smoke.yml b/.github/workflows/native-build-smoke.yml index 61e60882..f18e8071 100644 --- a/.github/workflows/native-build-smoke.yml +++ b/.github/workflows/native-build-smoke.yml @@ -16,11 +16,21 @@ on: - src/styles/mobile.css - src/styles/mobile-ios.css +# Cancel an in-flight run when a newer commit lands on the same ref, so we don't +# burn two parallel macOS iOS builds while iterating. +concurrency: + group: native-build-smoke-${{ github.ref }} + cancel-in-progress: true + +# iOS and Android run as independent jobs so a failure in one never masks the +# other's evidence (ERY-108). GitHub schedules them in parallel; neither +# `needs` the other, so an iOS pod-resolution failure no longer skips the +# Android assemble steps. jobs: - native-build-smoke: - name: iOS bootstrap + Android smoke + ios-smoke: + name: iOS bootstrap + simulator build runs-on: macos-15 - timeout-minutes: 60 + timeout-minutes: 45 permissions: contents: read @@ -33,13 +43,7 @@ jobs: node-version: "20" cache: npm - - name: Setup Java 17 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: "17" - - - name: Capture native toolchain versions + - name: Capture iOS toolchain versions shell: bash run: | set -euo pipefail @@ -49,11 +53,7 @@ jobs: echo "npm $(npm -v)" xcodebuild -version echo "CocoaPods $(pod --version)" - java -version - echo "ANDROID_HOME=${ANDROID_HOME:-unset}" - ls "$ANDROID_HOME/platforms" | grep "android-35" - ls "$ANDROID_HOME/build-tools" | grep "35.0" - } 2>&1 | tee artifacts/toolchain.log + } 2>&1 | tee artifacts/ios-toolchain.log - name: Bootstrap iOS workspace shell: bash @@ -77,7 +77,70 @@ jobs: CODE_SIGNING_ALLOWED=NO \ build 2>&1 | tee artifacts/ios-build.log | xcbeautify + - name: Upload iOS smoke artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-smoke-ios-${{ github.run_id }} + if-no-files-found: warn + path: | + artifacts/*.log + ios/App/build/** + + android-smoke: + name: Android debug + release assemble + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: npm + + # Capacitor 7's android module compiles with source/target 21, so the + # Gradle build fails under JDK 17 with "invalid source release: 21" + # (ERY-108). JDK 21 is the supported toolchain for Capacitor 7 Android. + - name: Setup Java 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + + - name: Capture Android toolchain versions + shell: bash + run: | + set -euo pipefail + mkdir -p artifacts + { + echo "Node $(node -v)" + echo "npm $(npm -v)" + java -version + echo "ANDROID_HOME=${ANDROID_HOME:-unset}" + ls "$ANDROID_HOME/platforms" | grep "android-35" || echo "android-35 platform missing" + ls "$ANDROID_HOME/build-tools" | grep "35" || echo "build-tools 35 missing" + } 2>&1 | tee artifacts/android-toolchain.log + + # Independent job: install JS deps here. In the old single-job layout the + # Android steps inherited node_modules from `npm run ios:init`; the split + # job has no such predecessor, so `vite` (and the rest of the toolchain) + # must be installed before `android:assemble:*` can run `npm run build`. + - name: Install JS dependencies + shell: bash + run: | + set -euo pipefail + mkdir -p artifacts + npm ci 2>&1 | tee artifacts/android-npm-ci.log + + # `if: always()` on the release step guarantees the release assemble runs + # even if the debug assemble fails, so we always capture pass/fail for both. - name: Assemble Android debug APK + id: android_debug shell: bash run: | set -euo pipefail @@ -85,19 +148,19 @@ jobs: npm run android:assemble:debug 2>&1 | tee artifacts/android-debug.log - name: Assemble Android release APK + if: always() shell: bash run: | set -euo pipefail mkdir -p artifacts npm run android:assemble:release 2>&1 | tee artifacts/android-release.log - - name: Upload native smoke artifacts + - name: Upload Android smoke artifacts if: always() uses: actions/upload-artifact@v4 with: - name: native-build-smoke-${{ github.run_id }} + name: native-smoke-android-${{ github.run_id }} if-no-files-found: warn path: | artifacts/*.log android/app/build/outputs/** - ios/App/build/** diff --git a/docs/2026-05-24-ery-71-native-packaging-validation.md b/docs/2026-05-24-ery-71-native-packaging-validation.md index cf335e0e..757d7bed 100644 --- a/docs/2026-05-24-ery-71-native-packaging-validation.md +++ b/docs/2026-05-24-ery-71-native-packaging-validation.md @@ -41,6 +41,55 @@ First macOS lane run: GitHub Actions run [`26370608416`](https://github.com/Shee - The iOS failure masked Android validation (steps skipped). Split iOS and Android into independent jobs (or `if: always()` gating) so a failure in one still produces evidence for the other on the same run. +## ERY-108 fix (2026-05-24) + +Both blockers from run `26370608416` are addressed on branch `engineer/ery-108-ios-pod-fix`: + +1. **iOS deployment target pinned to 15.5.** `scripts/ios-init.sh` now adds the iOS platform _before_ building the web bundle. Capacitor's `cap add ios` only runs `pod install` (via `cap sync`) when `dist/` already exists, so adding the platform first skips the premature pod install that resolved against the default `platform :ios, '14.0'`. A new `patch_ios_deployment_target` helper then pins the target in three places — the Podfile `platform :ios` line, a `post_install` floor that raises every transitive Pods target, and the `App.xcodeproj` `IPHONEOS_DEPLOYMENT_TARGET` — before `cap sync` runs pod install against the corrected Podfile. The helper is idempotent and re-asserts the floor on every sync. Patch logic verified locally against the extracted Capacitor 7 `ios-pods-template` (Podfile → `15.5`, all four pbxproj configs → `15.5`, `ruby -c` clean); the native toolchain itself is unavailable on the engineer host, so pod resolution + simulator build are proven only in CI. +2. **iOS and Android split into independent jobs.** `.github/workflows/native-build-smoke.yml` now has separate `ios-smoke` (macos-15) and `android-smoke` (ubuntu-latest) jobs with no `needs` edge, so an iOS failure no longer skips Android. The Android release assemble carries `if: always()` so it runs even when the debug assemble fails — both APK results are always captured. Each job uploads its own artifact bundle. + +### Re-run evidence + +**Split-lane run 1** — [26370807000](https://github.com/SheetMetalConnect/eryxon-flow/actions/runs/26370807000) on PR [#600](https://github.com/SheetMetalConnect/eryxon-flow/pull/600), `engineer/ery-108-ios-pod-fix`, 2026-05-24. + +| Check | Status | Evidence | +| --- | --- | --- | +| iOS `pod install` / `npm run ios:init` (add ios + pin 15.5 + sync) | **PASS** | iOS job step "Bootstrap iOS workspace" succeeded — the 15.5 pin resolves `GoogleMLKit/BarcodeScanning 7.0.0`. This clears the original ERY-108 blocker. | +| iOS simulator build of `ios/App/App.xcworkspace` | NOT CAPTURED | xcodebuild step was still running; run superseded by the npm-ci fix below (concurrency cancel). Re-proven in run 2. | +| Job split (Android no longer masked by iOS) | **PASS** | Android job ran to completion and produced its own logs while iOS was still building — confirms the masking fix. | +| `npm run android:assemble:debug` | FAIL → fixed | `sh: 1: vite: not found`. Root cause: the independent Android job had no `npm ci` step (the old single job inherited `node_modules` from `ios:init`). Fixed by adding an "Install JS dependencies" step. | +| `npm run android:assemble:release` | FAIL (same cause) | Ran anyway via `if: always()` — confirms the gating works; same `vite: not found` root cause. | + +Note: run 1's iOS job actually completed fully green before the concurrency cancel — `Build iOS simulator target` succeeded, so the iOS acceptance criterion (pod install + simulator build) is met as of run 1. + +**Split-lane run 2** — [26370892728](https://github.com/SheetMetalConnect/eryxon-flow/actions/runs/26370892728), commit `4967746`, 2026-05-24. + +| Check | Status | Evidence | +| --- | --- | --- | +| Android `npm ci` (split-job dependency install) | **PASS** | "Install JS dependencies" step succeeded — clears the `vite: not found` error from run 1. | +| `npm run android:assemble:debug` | FAIL → fixed | Gradle now compiles all Capacitor plugins, then fails at `:capacitor-android:compileDebugJavaWithJavac`: `error: invalid source release: 21`. Capacitor 7's android module targets Java 21; the job pinned JDK 17. Fixed by bumping the Android job to `java-version: "21"`. | +| `npm run android:assemble:release` | FAIL (same cause) | Ran via `if: always()`; same JDK-21 root cause. | + +**Split-lane run 3** — [26370991082](https://github.com/SheetMetalConnect/eryxon-flow/actions/runs/26370991082), commit `031ef00`, 2026-05-24. **All jobs green.** + +| Check | Status | Evidence | +| --- | --- | --- | +| iOS `pod install` / `npm run ios:init` | **PASS** | `Updating iOS native dependencies with pod install in 20.19s` — GoogleMLKit 7.0.0 resolves under the 15.5 pin. | +| iOS simulator build of `ios/App/App.xcworkspace` | **PASS** | `xcodebuild … build` → `** BUILD SUCCEEDED **`. | +| `npm run android:assemble:debug` | **PASS** | `BUILD SUCCESSFUL in 2m 23s` → `app/build/outputs/apk/debug/app-debug.apk`. | +| `npm run android:assemble:release` | **PASS** | `BUILD SUCCESSFUL in 2m 6s` → `app/build/outputs/apk/release/app-release-unsigned.apk`. | + +## Acceptance — met (ERY-108) + +- ✅ iOS `pod install` resolves and the simulator build of `ios/App/App.xcworkspace` succeeds. +- ✅ Android debug + release APK smokes run independently of iOS and both produce APKs (`if: always()` + separate jobs). +- ✅ Evidence recorded above. Both native lanes pass green on run [26370991082](https://github.com/SheetMetalConnect/eryxon-flow/actions/runs/26370991082) — ERY-81 native iOS + Android smoke unblocked. + +### Fix summary (PR #600) + +- `scripts/ios-init.sh`: add iOS platform before the web build (skips premature pod install) + idempotent `patch_ios_deployment_target` pinning iOS 15.5 in the Podfile, post_install floor, and `App.xcodeproj`. +- `.github/workflows/native-build-smoke.yml`: independent `ios-smoke` (macos-15) + `android-smoke` (ubuntu-latest, JDK 21) jobs; Android job runs `npm ci`; release assemble gated `if: always()`; concurrency group cancels superseded runs. + ## Remaining Apple-specific gaps ## Remaining Apple-specific gaps diff --git a/scripts/ios-init.sh b/scripts/ios-init.sh index e368d6a9..b4b49029 100755 --- a/scripts/ios-init.sh +++ b/scripts/ios-init.sh @@ -41,6 +41,38 @@ ensure_native_build_bindings() { fi } +# @capacitor-mlkit/barcode-scanning@7.5.0 pulls in GoogleMLKit/BarcodeScanning +# (= 7.0.0), which declares a minimum deployment target of iOS 15.5. The +# Capacitor 7 template ships a Podfile pinned to `platform :ios, '14.0'`, so a +# stock `cap add ios` fails CocoaPods resolution before any build runs +# (ERY-108). Pin the whole project to 15.5 so pod install resolves. +IOS_DEPLOYMENT_TARGET="15.5" + +# Raise the iOS deployment target in the generated (uncommitted) iOS project so +# CocoaPods can resolve GoogleMLKit 7.0.0. Idempotent: safe to re-run on every +# init/sync. We touch three places: +# - Podfile `platform :ios` line (controls the App pod target floor) +# - Podfile post_install (raises every transitive Pods target) +# - App.xcodeproj deployment target (so the app target links the 15.5 pods) +# `cap sync` only rewrites the capacitor_pods block + require_relative line, so +# these edits survive the sync that runs pod install. +patch_ios_deployment_target() { + local podfile="ios/App/Podfile" + local pbxproj="ios/App/App.xcodeproj/project.pbxproj" + + if [[ -f "$podfile" ]]; then + echo "▶ Pinning iOS deployment target to ${IOS_DEPLOYMENT_TARGET} (GoogleMLKit 7.0.0 needs >= 15.5)..." + sed -i '' -E "s/^platform :ios, '[0-9.]+'/platform :ios, '${IOS_DEPLOYMENT_TARGET}'/" "$podfile" + if ! grep -q "ERYXON_DEPLOYMENT_FLOOR" "$podfile"; then + perl -0pi -e "s/(post_install do \|installer\|\n)/\$1 # ERYXON_DEPLOYMENT_FLOOR: GoogleMLKit\/BarcodeScanning 7.0.0 requires iOS 15.5+\n installer.pods_project.targets.each do |t|\n t.build_configurations.each do |c|\n if c.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < ${IOS_DEPLOYMENT_TARGET}\n c.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '${IOS_DEPLOYMENT_TARGET}'\n end\n end\n end\n/" "$podfile" + fi + fi + + if [[ -f "$pbxproj" ]]; then + sed -i '' -E "s/IPHONEOS_DEPLOYMENT_TARGET = [0-9.]+;/IPHONEOS_DEPLOYMENT_TARGET = ${IOS_DEPLOYMENT_TARGET};/g" "$pbxproj" + fi +} + if [[ "${OSTYPE:-}" != darwin* ]]; then echo "⚠ Capacitor's iOS toolchain only runs on macOS — this script will" >&2 echo " configure the project but you must finish 'cap add ios' on a Mac." >&2 @@ -54,15 +86,25 @@ else fi ensure_native_build_bindings -echo "▶ Building production web bundle..." -npm run build - if [[ ! -d ios ]]; then echo "▶ Generating native iOS project (npx cap add ios)..." + # `cap add ios` only runs `cap sync` (and therefore `pod install`) when the + # web bundle (dist/) already exists. We deliberately add the platform BEFORE + # building so that first pod install is skipped — it would otherwise resolve + # against the default 'platform :ios, 14.0' Podfile and fail on GoogleMLKit + # 7.0.0. Removing any stale dist/ keeps this deterministic on dev machines. + rm -rf dist npx cap add ios + patch_ios_deployment_target fi +echo "▶ Building production web bundle..." +npm run build + echo "▶ Syncing web bundle and native plugins into ios/..." +# Re-assert the deployment floor before sync in case the iOS project predates +# this pin, then sync (which runs pod install against the corrected Podfile). +patch_ios_deployment_target npx cap sync ios # Ensure the generated Info.plist carries the strings ML Kit / Biometric