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
37 changes: 37 additions & 0 deletions .github/workflows/Release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,43 @@ jobs:
run: npx expo prebuild --platform android --no-install --clean
working-directory: ./econoflow-mobile

# `android.cmakeVersion` is not a preference: Expo's root project plugin assigns it
# straight to `externalNativeBuild.cmake.version` of every module, so AGP looks for
# exactly that CMake and nothing else. It is set in app.json and written into the
# generated gradle.properties by plugins/with-detox-android.js during prebuild, which is
# why this step runs after prebuild and reads the version from that file. The hosted
# runner image ships a different SDK CMake (3.31.5), which is why the release build
# stopped at
# [CXX1300] CMake '3.30.4' was not found in SDK, PATH, or by cmake.dir property
# Installing the pinned version next to the image's own makes the build independent of
# which CMake the image happens to carry.
- name: Install the pinned Android SDK CMake
if: steps.mobile-changes.outputs.mobile == 'true'
run: |
set -euo pipefail

# Read the version from source instead of repeating it here, so the two cannot drift.
CMAKE_VERSION="$(sed -n 's/^[[:space:]]*android\.cmakeVersion[[:space:]]*=[[:space:]]*//p' gradle.properties)"

# A future Expo upgrade that drops the pin must fail loudly here rather than as an
# sdkmanager parse error.
if [ -z "$CMAKE_VERSION" ]; then
echo "::error::android.cmakeVersion is not set in econoflow-mobile/android/gradle.properties; nothing to install"
exit 1
fi

echo "Installing Android SDK CMake ${CMAKE_VERSION}"
SDKMANAGER="$(find "${ANDROID_HOME}/cmdline-tools" -type f -name sdkmanager | head -n 1)"
# sdkmanager is a Java 21-friendly tool and needs Java 17+; the image exposes each
# installed JDK as its own variable, and the job has only set up JDK 17's default.
JAVA_HOME="${JAVA_HOME_21_X64:-$JAVA_HOME}" "${SDKMANAGER}" \
--install "cmake;${CMAKE_VERSION}" --sdk_root="${ANDROID_HOME}"

# Prove the install landed: a "not found" here still reads as CXX1300 at compile time.
test -x "${ANDROID_HOME}/cmake/${CMAKE_VERSION}/bin/cmake"
"${ANDROID_HOME}/cmake/${CMAKE_VERSION}/bin/cmake" --version
working-directory: ./econoflow-mobile

- name: Cache Gradle
if: steps.mobile-changes.outputs.mobile == 'true'
uses: actions/cache@v6
Expand Down
7 changes: 6 additions & 1 deletion econoflow-mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@
}
],
"expo-font",
"./plugins/with-detox-android"
[
"./plugins/with-detox-android",
{
"cmakeVersion": "3.30.4"
}
]
],
"extra": {
"eas": {
Expand Down
16 changes: 11 additions & 5 deletions econoflow-mobile/plugins/with-detox-android.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
const { withGradleProperties } = require('expo/config-plugins');

/** The CMake release to pin when the app config does not name one (Expo's historical value). */
const DEFAULT_CMAKE_VERSION = '3.30.4';

/**
* Keeps the native build inside the toolchain's limits when `npx expo prebuild` regenerates
* `android/` (which is not committed).
*
* `android.cmakeVersion` selects a CMake whose Ninja understands long paths. AGP's default
* (3.22.1) ships Ninja 1.10, which stops a deep checkout with "Filename longer than 260
* characters" while naming the object files of the codegen sources under `node_modules`.
* Expo reads this Gradle property and applies it to every native module.
* Expo reads this Gradle property and assigns it to `externalNativeBuild.cmake.version` of
* every module, so the value is a hard requirement on the SDK - not a preference.
*
* The CMake release itself is an SDK package, not something prebuild can install:
* sdkmanager "cmake;3.30.4"
* The version belongs in the tracked `app.json` (`["cmakeVersion", "x.y.z"]`), because the
* generated `gradle.properties` is only a copy of it. CI derives the CMake package to install
* from that copy, so pin the version here and let the job install it:
* scripts/cra-compliance.test.mjs asserts the two stay in step.
*/
module.exports = function withDetoxAndroid(config) {
module.exports = function withDetoxAndroid(config, { cmakeVersion = DEFAULT_CMAKE_VERSION } = {}) {
return withGradleProperties(config, (cfg) => {
const properties = cfg.modResults;
const key = 'android.cmakeVersion';

if (!properties.some((entry) => entry.type === 'property' && entry.key === key)) {
properties.push({ type: 'property', key, value: '3.30.4' });
properties.push({ type: 'property', key, value: cmakeVersion });
}

return cfg;
Expand Down
2 changes: 1 addition & 1 deletion econoflow-mobile/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ CMake derives that object-file name from the absolute path of the generated JNI
|---------|--------|
| `npm run android:short-paths` (junction of `node_modules` into `C:\n\econoflow-mobile`) | CMake resolves a junction to its real path, so the object names still start `D_/Sources/…` |
| Building the project from a short root (`C:\eco\…`) with a physical copy of `node_modules` | The 260-character error is gone (objects start `C_/eco/…`), but the build then stops in ninja with `mkdir(...): No such file or directory` on the same deep object paths. `cmd` and .NET create those directories without complaint from the same working directory — the failure is inside this machine's Ninja/cmd interaction, not in the project |
| `android.cmakeVersion=3.30.4` (Expo's override; `sdkmanager "cmake;3.30.4"`) | Ships the same Ninja 1.10.2 binary |
| `android.cmakeVersion=3.30.4` (written by `plugins/with-detox-android.js` from `app.json`; `sdkmanager "cmake;3.30.4"`) | Ships the same Ninja 1.10.2 binary |
| Rewriting `build.ninja` to shorten/escape the offending outputs | No effect: the statement ninja was failing on is already correctly escaped (`C$:/…`), which was confirmed by inspection |
| `HKLM\…\FileSystem\LongPathsEnabled=1` | Already enabled; it only covers tools that opt in |

Expand Down
54 changes: 54 additions & 0 deletions scripts/cra-compliance.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,60 @@ test('the release workflow publishes an SBOM artefact for the image it pushes',
);
});

// ── Mobile release build ─────────────────────────────────────────────────────

test('the release workflow installs the CMake version the Android build is pinned to', () => {
// `android.cmakeVersion` REPLACES the CMake version AGP would use for every module
// (Expo's ExpoRootProjectPlugin does
// `android.externalNativeBuild.cmake.version = cmakeVersion`), so the version it names
// has to be installed in the SDK, not merely present on the runner. It was not: the
// runner image ships SDK CMake 3.31.5, while the pin is 3.30.4, and the master release
// build died with
// [CXX1300] CMake '3.30.4' was not found in SDK, PATH, or by cmake.dir property
//
// The version is asserted at its source, app.json: `android/` is gitignored and
// regenerated by `expo prebuild`, so the generated gradle.properties only exists after
// prebuild has run and cannot be the contract.
const path = '.github/workflows/Release.yml';
const workflow = read(path);

const plugins = JSON.parse(read('econoflow-mobile/app.json')).expo.plugins ?? [];
const prebuildPlugin = plugins.find(entry => (
Array.isArray(entry) && entry[0] === './plugins/with-detox-android'
));
assert.ok(
prebuildPlugin,
'app.json no longer runs ./plugins/with-detox-android, so prebuild writes no CMake pin',
);

const pinnedCmake = /^\d+\.\d+\.\d+$/.exec(String(prebuildPlugin[1]?.cmakeVersion));
assert.ok(
pinnedCmake,
`app.json's with-detox-android arguments carry no cmakeVersion (got ${JSON.stringify(prebuildPlugin[1])})`,
);

assert.match(
workflow,
/sdkmanager/,
`${path} never runs sdkmanager, so it cannot install the SDK CMake the build is pinned to`,
);

// The version is read from the generated gradle.properties at build time
// (`cmake;<derived version>`), so what matters is that the derived value reaches
// sdkmanager - not that a literal version sits in the workflow, a second copy to drift.
assert.match(
workflow,
/android\\\.cmakeVersion/,
`${path} does not read android.cmakeVersion from gradle.properties`,
);
assert.match(
workflow,
/"cmake;\$\{CMAKE_VERSION\}"/,
`${path} does not install the CMake version the build pins, ` +
'so the native build fails with [CXX1300] CMake not found in SDK',
);
});

// ── IIS host configuration ───────────────────────────────────────────────────

test('web.config sets clickjacking protection', () => {
Expand Down
Loading