diff --git a/.github/CICD.md b/.github/CICD.md index f5421a5f1..8bae373c5 100644 --- a/.github/CICD.md +++ b/.github/CICD.md @@ -150,10 +150,10 @@ Formplayer assets are **not committed to git** and are ignored via `.gitignore`. #### Build Types - **Pull Requests**: Debug APK (unsigned), **arm64-v8a only** (smoke) -- **Push to main/dev**: Release universal APK + AAB (signed) — CI artifacts -- **GitHub Release**: Release universal APK + AAB (signed); assets attached to the release +- **Push to main/dev**: Release ARM-universal APK + ARM AAB (signed) — CI artifacts +- **GitHub Release**: Release ARM-universal APK + ARM AAB (signed); assets attached to the release -APK and AAB are built in **one** Gradle invocation (`assembleRelease bundleRelease`) so Metro/native work is shared. GitHub release/distribution should use the **universal APK** to avoid wrong-ABI selection and cross-ABI `versionCode` update failures. F-Droid keeps its own per-ABI builds from the same source tag via metadata/build properties; that split path is not the GitHub release artifact. Gradle uses `gradle/actions/setup-gradle` (daemon left on). Vendored Notifee (`formulus/third_party/notifee`) is cached across runs (key = hash of `vendor-notifee-core.mjs`). CI enables parallel workers (local `gradle.properties` keeps them low for laptops). +APK and AAB are built in **one** Gradle invocation (`assembleRelease bundleRelease`) so Metro/native work is shared. GitHub and Play builds contain `armeabi-v7a` and `arm64-v8a`, covering physical ARM devices without carrying x86/x86_64 native libraries in direct downloads. GitHub release/distribution uses the single ARM-universal APK to avoid wrong-ABI selection and cross-ABI `versionCode` update failures. F-Droid keeps four per-ABI builds—including x86/x86_64—from the same source tag via metadata/build properties; that path is independent of the normal release ABI set. Gradle uses `gradle/actions/setup-gradle` (daemon left on). Vendored Notifee (`formulus/third_party/notifee`) is cached across runs (key = hash of `vendor-notifee-core.mjs`). CI enables parallel workers (local `gradle.properties` keeps them low for laptops). NDK `ccache` is **not** wired yet: it needs CI-only CMake launcher hooks into React Native’s native build and can flake; revisit if release builds are still too slow after the above. diff --git a/.github/workflows/formulus-android.yml b/.github/workflows/formulus-android.yml index ea6c75a46..062462edc 100644 --- a/.github/workflows/formulus-android.yml +++ b/.github/workflows/formulus-android.yml @@ -29,8 +29,8 @@ concurrency: env: NODE_VERSION: '24' - # Full ABI set matches formulus/android/gradle.properties (F-Droid / Play shippable). - FORMULUS_ABIS_FULL: armeabi-v7a,arm64-v8a,x86,x86_64 + # GitHub and Play target physical ARM devices; F-Droid requests all four ABIs in separate builds. + FORMULUS_ABIS_RELEASE: armeabi-v7a,arm64-v8a FORMULUS_ABIS_SMOKE: arm64-v8a jobs: @@ -120,6 +120,10 @@ jobs: working-directory: formulus run: pnpm install --frozen-lockfile + - name: Validate native versions and build numbers + working-directory: formulus + run: pnpm run validate:native-versions + # vendor-notifee-core.mjs is a no-op when third_party/notifee is already at the pinned commit. - name: Cache vendored Notifee uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 @@ -185,8 +189,8 @@ jobs: # ABI policy: # - PR: arm64-v8a only debug smoke build - # - push to main/dev + release: universal APK + AAB for GitHub/direct distribution - # - F-Droid: per-ABI APKs from the same source tag via fdroiddata metadata/build props + # - push to main/dev + release: ARM32/ARM64 universal APK + AAB for physical devices + # - F-Droid: four per-ABI APKs, including x86/x86_64, via fdroiddata metadata/build props # Local gradle.properties keeps parallel/workers low for laptops; CI overrides those. # setup-gradle prefers leaving the daemon on (no --no-daemon). - name: Build debug APK (PR, arm64-v8a only) @@ -204,23 +208,23 @@ jobs: if: github.event_name == 'push' working-directory: formulus/android env: - ORG_GRADLE_PROJECT_reactNativeArchitectures: ${{ env.FORMULUS_ABIS_SMOKE }} + ORG_GRADLE_PROJECT_reactNativeArchitectures: ${{ env.FORMULUS_ABIS_RELEASE }} run: > ./gradlew assembleRelease bundleRelease --stacktrace -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 - -PreactNativeArchitectures=${{ env.FORMULUS_ABIS_SMOKE }} + -PreactNativeArchitectures=${{ env.FORMULUS_ABIS_RELEASE }} - name: Build release universal APK + AAB (GitHub Release) if: github.event_name == 'release' working-directory: formulus/android env: - ORG_GRADLE_PROJECT_reactNativeArchitectures: ${{ env.FORMULUS_ABIS_FULL }} + ORG_GRADLE_PROJECT_reactNativeArchitectures: ${{ env.FORMULUS_ABIS_RELEASE }} run: > ./gradlew assembleRelease bundleRelease --stacktrace -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 - -PreactNativeArchitectures=${{ env.FORMULUS_ABIS_FULL }} + -PreactNativeArchitectures=${{ env.FORMULUS_ABIS_RELEASE }} - name: Upload APK artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 diff --git a/AGENTS.md b/AGENTS.md index 447a225c8..419a3eda4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,8 @@ For stable, you usually **do not** re-bump client manifests if they already matc ### Increment rules - **Semver:** bump `MAJOR.MINOR.PATCH` in client manifests to match the release line (e.g. `1.1.1`). -- **Android `versionCode`:** upstream `formulus/android/app/build.gradle` keeps one monotonic base code for Play and GitHub universal APKs (+10 per release is a common convention; +1 per shipped alpha build is also fine). F-Droid per-ABI codes are derived in F-Droid metadata/build configuration. +- **Android `versionCode`:** use four-code-aligned upstream bases (`40`, `44`, `48`, …) for each distinct APK/AAB shipped on a GitHub Release or uploaded to Play. Play and the GitHub universal APK use the base; F-Droid derives four ABI codes (`base` through `base + 3`). Before release, confirm the base exceeds the highest code in Play and the previous F-Droid block. See [`formulus/android/ANDROID_RELEASE.md`](formulus/android/ANDROID_RELEASE.md). +- **iOS build number:** keep both `CURRENT_PROJECT_VERSION` values equal to the Android base; jumping by four is valid. Run `pnpm run validate:native-versions` to check alignment. - **In-app version display:** Formulus About/Settings use native `versionName` + `versionCode` via [`AppVersionService`](formulus/src/services/AppVersionService.ts); Desktop About uses Tauri `getVersion()`. ### Commands diff --git a/formulus/android/ANDROID_RELEASE.md b/formulus/android/ANDROID_RELEASE.md index df888fd70..54142a56b 100644 --- a/formulus/android/ANDROID_RELEASE.md +++ b/formulus/android/ANDROID_RELEASE.md @@ -4,7 +4,7 @@ Formulus ships from one FOSS codebase. Play Store, GitHub sideloads, and F-Droid ## Shared release prep -1. Bump `versionName` / `versionCode` in `app/build.gradle` (or run `pnpm run sync:version` from `formulus/`). +1. Set `versionCode` in `app/build.gradle` to the next four-code block (`40`, `44`, `48`, …), and set both iOS `CURRENT_PROJECT_VERSION` values to the same number. `pnpm run sync:version` updates the Android `versionName` from `package.json`; it does not choose a build number. 2. From `formulus/`: ```sh pnpm install --frozen-lockfile @@ -14,42 +14,53 @@ Formulus ships from one FOSS codebase. Play Store, GitHub sideloads, and F-Droid ``` 3. Tag the release commit on the upstream repo, e.g. `v1.0.2` (F-Droid update checks use stable tags matching `v[\d.]+$`). 4. For a release that touches Android packaging, verify both outputs locally or in CI: - - universal release APK for GitHub/direct installs - - single-ABI APK path via `-PabiFilters=` for F-Droid + - ARM-universal (`armeabi-v7a` + `arm64-v8a`) release APK and AAB for GitHub/Play + - single-ABI APK path via `-PabiFilters=` for all four F-Droid ABIs ## Google Play - **Artifact:** Android App Bundle (AAB) — `./gradlew bundleRelease` in `formulus/android/`. +- **ABIs:** `armeabi-v7a` and `arm64-v8a`, covering 32-bit and 64-bit ARM physical devices. x86/x86_64 are omitted from Play distribution. - **CI:** GitHub Actions builds `bundleRelease` on `main`, `dev`, and GitHub Releases; uploads the AAB artifact and attaches it to releases. - **versionCode:** Uses `defaultConfig.versionCode` in the AAB (single code per release). - **Signing:** See [SIGNING_CONFIG.md](./SIGNING_CONFIG.md). CI uses repository secrets; local builds use `android/local.properties`. ## GitHub Releases / direct sideloads -- **Artifact:** One signed **universal APK** for manual installs and updaters such as Obtainium. -- **Gradle:** `assembleRelease` should emit a universal APK for normal/release builds. +- **Artifact:** One signed **ARM-universal APK** containing `armeabi-v7a` and `arm64-v8a` for manual installs and updaters such as Obtainium. +- **Gradle:** `assembleRelease` emits the ARM-universal APK for normal/release builds; x86/x86_64 are omitted to avoid carrying emulator-focused native libraries in direct downloads. - **Why:** A single sideload artifact avoids wrong-ABI selection and cross-ABI `versionCode` downgrade/update failures. - **versionCode:** Uses `defaultConfig.versionCode` unchanged. ## F-Droid -- **Artifact:** One APK per ABI, built from source by F-Droid. -- **Metadata:** `fdroiddata/metadata/org.opendataensemble.formulus.yml` — four builds with `abiFilters` and explicit `versionCode` per ABI. -- **Gradle:** F-Droid passes `-PabiFilters=` to force a single ABI build from the same upstream tag/source. This path keeps ABI-specific outputs only for F-Droid. +- **Artifact:** One APK per ABI, built from source by F-Droid. F-Droid retains `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64` even though normal GitHub/Play builds use only ARM. +- **Metadata:** `fdroiddata/metadata/org.opendataensemble.formulus.yml` — four builds with `abiFilters`. `VercodeOperation` derives their codes as source `versionCode + 0`, `+1`, `+2`, and `+3`. +- **Gradle:** F-Droid passes `-PabiFilters=` and replaces the source `versionCode` with the derived code for each single-ABI build. This path keeps ABI-specific outputs only for F-Droid. - **Init steps:** Same as shared prep above (`vendor:notifee`, `patch:android-foss`, `generate`). After tagging, update the metadata commit SHA and version fields, then open/update the fdroiddata merge request. ## Version codes -| Channel | versionCode | -|---------|-------------| -| Play AAB | `defaultConfig.versionCode` | -| GitHub universal APK | `defaultConfig.versionCode` | -| F-Droid per-ABI build | Set in metadata (for example `3601`–`3604` when upstream `defaultConfig.versionCode` is `36`) | +| Channel | versionCode when the upstream base is `40` | +|---------|--------------------------------------------| +| Play AAB | `40` | +| GitHub universal APK | `40` | +| iOS build number (`CURRENT_PROJECT_VERSION`) | `40` | +| F-Droid `armeabi-v7a` / `arm64-v8a` / `x86` / `x86_64` | `40` / `41` / `42` / `43` | -Rules: +### Four-code block policy -- Upstream Gradle keeps a single monotonic `defaultConfig.versionCode` for Play and GitHub sideloads. -- Do **not** apply per-ABI `versionCodeOverride` to the normal GitHub release build. -- F-Droid-specific per-ABI version codes live in F-Droid metadata/build configuration, not in the default upstream release path. +Use upstream base codes `40`, `44`, `48`, `52`, and so on. Each distinct APK/AAB shipped on a GitHub Release or uploaded to Play advances to the next block; ordinary branch CI artifacts do not consume a block. F-Droid can then safely consume the release base and the following three codes for a stable release. + +- After base `40` (F-Droid block `40`–`43`), the next distinct APK/AAB uses base `44`. +- A stable release may retain its pre-release base only when it promotes the exact same app build rather than uploading a newly built Play artifact. +- Before releasing, verify that the chosen base is greater than every code already uploaded to Google Play and greater than the previous F-Droid block. If an external channel is unexpectedly ahead, choose the next multiple of four above its highest code. +- Keep both iOS `CURRENT_PROJECT_VERSION` values equal to the Android base. iOS build numbers need not be consecutive, so advancing by four is valid. +- Do **not** apply per-ABI `versionCodeOverride` to the normal GitHub universal build. +- Run `pnpm run validate:native-versions`; CI runs the same check and rejects a base that is not divisible by four or Android/iOS version drift. + +### Migration from the old split APKs + +The `1.3.0` F-Droid builds used codes `35`–`38`, while old `1.3.1` GitHub split APKs could use codes `36`–`39`. Base `40` is therefore the first code that upgrades every old GitHub split and starts a non-overlapping F-Droid block. The already-tagged `v1.3.1` source remains unsuitable for a new F-Droid build at base `36`; F-Droid should resume from the next stable tag containing this corrected base. diff --git a/formulus/android/app/build.gradle b/formulus/android/app/build.gradle index 17aceb0a6..bc335d7f4 100644 --- a/formulus/android/app/build.gradle +++ b/formulus/android/app/build.gradle @@ -99,7 +99,7 @@ android { applicationId = "org.opendataensemble.formulus" minSdk = rootProject.ext.minSdkVersion targetSdk = rootProject.ext.targetSdkVersion - versionCode = 36 + versionCode = 40 versionName = "1.3.1" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", (findProperty("newArchEnabled") ?: "false").toString() @@ -116,7 +116,9 @@ android { abi { enable true reset() - include "armeabi-v7a", "arm64-v8a", "x86", "x86_64" + // GitHub/Play target physical ARM devices. F-Droid bypasses this block + // and requests each ARM/x86 ABI explicitly via -PabiFilters. + include "armeabi-v7a", "arm64-v8a" universalApk true } } diff --git a/formulus/android/gradle.properties b/formulus/android/gradle.properties index c101597d1..c7ddde5bb 100644 --- a/formulus/android/gradle.properties +++ b/formulus/android/gradle.properties @@ -24,10 +24,10 @@ android.useAndroidX=true # Use this property to specify which architecture you want to build. # You can also override it from the CLI using # ./gradlew -PreactNativeArchitectures=x86_64 -# Include armeabi-v7a so the APK runs on both 32-bit and 64-bit ARM devices. -# CI: PRs and push to main/dev override to arm64-v8a only; GitHub Release builds use all four -# (F-Droid / Play). See .github/workflows/formulus-android.yml. -reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 +# Normal APK/AAB builds target physical 32-bit and 64-bit ARM devices. +# F-Droid overrides this property with one ABI per metadata build, including x86/x86_64. +# Emulator builds can override it from the CLI as shown above. +reactNativeArchitectures=armeabi-v7a,arm64-v8a # Use this property to enable support to the new architecture. # This will allow you to use TurboModules and the Fabric render in diff --git a/formulus/ios/Formulus.xcodeproj/project.pbxproj b/formulus/ios/Formulus.xcodeproj/project.pbxproj index 9109986dc..d49a2aaa5 100644 --- a/formulus/ios/Formulus.xcodeproj/project.pbxproj +++ b/formulus/ios/Formulus.xcodeproj/project.pbxproj @@ -310,7 +310,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = 57WY3GA5K7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Formulus/Info.plist; @@ -347,7 +347,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = 57WY3GA5K7; INFOPLIST_FILE = Formulus/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Formulus; diff --git a/formulus/package.json b/formulus/package.json index a5f039d45..fa190dae4 100644 --- a/formulus/package.json +++ b/formulus/package.json @@ -20,6 +20,7 @@ "generate:api": "pnpm dlx @openapitools/openapi-generator-cli generate -i ../synkronus/openapi/synkronus.yaml -g typescript-axios -o src/api/synkronus/generated \"--additional-properties=supportsES6=true,useSingleRequestParameter=true,modelPropertyNaming=original\" && pnpm run format", "generate_qr": "tsx scripts/generateQR.ts", "sync:version": "node scripts/syncNativeVersion.js", + "validate:native-versions": "node scripts/validateNativeVersions.js", "vendor:notifee": "node scripts/vendor-notifee-core.mjs", "patch:android-foss": "node scripts/patch-android-foss.mjs", "prebuild": "pnpm run sync:version && pnpm run generate" diff --git a/formulus/scripts/validateNativeVersions.js b/formulus/scripts/validateNativeVersions.js new file mode 100644 index 000000000..fd9674f40 --- /dev/null +++ b/formulus/scripts/validateNativeVersions.js @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(scriptsDir, '..'); + +const packageJson = JSON.parse( + fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'), +); +const androidGradle = fs.readFileSync( + path.join(rootDir, 'android', 'app', 'build.gradle'), + 'utf8', +); +const iosProject = fs.readFileSync( + path.join(rootDir, 'ios', 'Formulus.xcodeproj', 'project.pbxproj'), + 'utf8', +); + +function requireMatch(content, pattern, field) { + const match = content.match(pattern); + if (!match) { + throw new Error(`Could not find ${field}`); + } + return match[1]; +} + +function uniqueMatches(content, pattern, field) { + const values = [...content.matchAll(pattern)].map(match => match[1]); + if (values.length === 0) { + throw new Error(`Could not find ${field}`); + } + return [...new Set(values)]; +} + +const androidVersionName = requireMatch( + androidGradle, + /versionName\s*=\s*["']([^"']+)["']/, + 'Android versionName', +); +const androidVersionCode = Number.parseInt( + requireMatch(androidGradle, /versionCode\s*=\s*(\d+)/, 'Android versionCode'), + 10, +); +const iosMarketingVersions = uniqueMatches( + iosProject, + /MARKETING_VERSION = ([^;]+);/g, + 'iOS MARKETING_VERSION', +); +const iosBuildNumbers = uniqueMatches( + iosProject, + /CURRENT_PROJECT_VERSION = (\d+);/g, + 'iOS CURRENT_PROJECT_VERSION', +).map(Number); + +const errors = []; + +if (androidVersionName !== packageJson.version) { + errors.push( + `Android versionName ${androidVersionName} does not match package.json ${packageJson.version}`, + ); +} +if ( + iosMarketingVersions.length !== 1 || + iosMarketingVersions[0] !== packageJson.version +) { + errors.push( + `iOS MARKETING_VERSION values (${iosMarketingVersions.join(', ')}) do not match package.json ${packageJson.version}`, + ); +} +if (androidVersionCode % 4 !== 0) { + errors.push( + `Android versionCode ${androidVersionCode} is not aligned to a four-code F-Droid block; use 40, 44, 48, ...`, + ); +} +if (iosBuildNumbers.length !== 1 || iosBuildNumbers[0] !== androidVersionCode) { + errors.push( + `iOS CURRENT_PROJECT_VERSION values (${iosBuildNumbers.join(', ')}) do not match Android versionCode ${androidVersionCode}`, + ); +} + +if (errors.length > 0) { + console.error(`Native version validation failed:\n- ${errors.join('\n- ')}`); + process.exit(1); +} + +console.log( + `Native versions valid: ${packageJson.version} (${androidVersionCode}); F-Droid block ${androidVersionCode}-${androidVersionCode + 3}`, +);