Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/CICD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 12 additions & 8 deletions .github/workflows/formulus-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 28 additions & 17 deletions formulus/android/ANDROID_RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=<abi>` for F-Droid
- ARM-universal (`armeabi-v7a` + `arm64-v8a`) release APK and AAB for GitHub/Play
- single-ABI APK path via `-PabiFilters=<abi>` 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=<abi>` 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=<abi>` 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.
6 changes: 4 additions & 2 deletions formulus/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
}
Expand Down
8 changes: 4 additions & 4 deletions formulus/android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task> -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
Expand Down
4 changes: 2 additions & 2 deletions formulus/ios/Formulus.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions formulus/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
89 changes: 89 additions & 0 deletions formulus/scripts/validateNativeVersions.js
Original file line number Diff line number Diff line change
@@ -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}`,
);
Loading