Skip to content

Commit 3ab52d6

Browse files
robingenzclaude
andcommitted
ci: the native code builds inside the demo on both platforms, and the size baseline with its guard
The baseline is measured on the demo at f81de28: the release APK grows by 578 KB, the simulator app by 3.6 MB. ci.yml installs the package from the commit into the demo at the baseline's commit and builds it; baseline.yml fails a pull request whose binaries grew more than 10 %. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 86af7a0 commit 3ab52d6

8 files changed

Lines changed: 663 additions & 0 deletions

File tree

‎.github/workflows/baseline.yml‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Baseline
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
11+
env:
12+
JAVA_VERSION: 21
13+
14+
jobs:
15+
size:
16+
name: Guard the binary size against the baseline
17+
runs-on: macos-latest
18+
steps:
19+
- uses: actions/checkout@v7
20+
with:
21+
path: package
22+
- name: Read the pinned demo commit
23+
id: baseline
24+
run: echo "demo=$(node -p "require('./package/benchmarks/baseline.json').demo.commit")" >> "$GITHUB_OUTPUT"
25+
- uses: actions/checkout@v7
26+
with:
27+
repository: hotcodepush-team/react-native-code-push-demo
28+
ref: ${{ steps.baseline.outputs.demo }}
29+
path: demo
30+
- uses: actions/setup-node@v6
31+
with:
32+
node-version-file: package/.nvmrc
33+
- uses: actions/setup-java@v5
34+
with:
35+
distribution: zulu
36+
java-version: ${{ env.JAVA_VERSION }}
37+
- uses: maxim-lobanov/setup-xcode@v1
38+
with:
39+
xcode-version: latest-stable
40+
- name: Pack the package from this pull request
41+
working-directory: package
42+
run: npm ci && npm run build && npm pack --pack-destination "$RUNNER_TEMP"
43+
- name: Prepare the demo with the package
44+
run: node package/benchmarks/prepare-demo.mjs demo "$RUNNER_TEMP/with" with "$(ls "$RUNNER_TEMP"/hotcodepush-react-native-code-push-*.tgz)" --no-embed
45+
- name: Measure the sizes and compare them with the baseline
46+
run: node package/benchmarks/measure-size.mjs "$RUNNER_TEMP/with" > "$RUNNER_TEMP/sizes.json" && node package/benchmarks/guard-size.mjs package/benchmarks/baseline.json "$RUNNER_TEMP/sizes.json" >> "$GITHUB_STEP_SUMMARY"

‎.github/workflows/ci.yml‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ on:
99
permissions:
1010
contents: read
1111

12+
env:
13+
JAVA_VERSION: 21
14+
15+
# The native code compiles inside an app: the platform jobs install the package from this commit into the demo at
16+
# the commit the baseline names and build it. Debug builds, which bundle no JavaScript and so run no embed step.
1217
jobs:
1318
javascript:
1419
name: Lint, typecheck, test and build the JavaScript
@@ -24,6 +29,67 @@ jobs:
2429
- run: npm run typecheck
2530
- run: npm test
2631
- run: npm run build
32+
android:
33+
name: Build the Android library inside the demo
34+
runs-on: ubuntu-latest
35+
steps:
36+
- uses: actions/checkout@v7
37+
with:
38+
path: package
39+
- name: Read the pinned demo commit
40+
id: baseline
41+
run: echo "demo=$(node -p "require('./package/benchmarks/baseline.json').demo.commit")" >> "$GITHUB_OUTPUT"
42+
- uses: actions/checkout@v7
43+
with:
44+
repository: hotcodepush-team/react-native-code-push-demo
45+
ref: ${{ steps.baseline.outputs.demo }}
46+
path: demo
47+
- uses: actions/setup-node@v6
48+
with:
49+
node-version-file: package/.nvmrc
50+
- uses: actions/setup-java@v5
51+
with:
52+
distribution: zulu
53+
java-version: ${{ env.JAVA_VERSION }}
54+
- uses: gradle/actions/setup-gradle@v5
55+
- name: Pack the package from this commit
56+
working-directory: package
57+
run: npm ci && npm run build && npm pack --pack-destination "$RUNNER_TEMP"
58+
- name: Prepare the demo with the package
59+
run: node package/benchmarks/prepare-demo.mjs demo "$RUNNER_TEMP/with" with "$(ls "$RUNNER_TEMP"/hotcodepush-react-native-code-push-*.tgz)" --no-embed
60+
- run: ./gradlew assembleDebug --console=plain
61+
working-directory: ${{ runner.temp }}/with/android
62+
ios:
63+
name: Build the iOS module inside the demo
64+
runs-on: macos-latest
65+
steps:
66+
- uses: actions/checkout@v7
67+
with:
68+
path: package
69+
- name: Read the pinned demo commit
70+
id: baseline
71+
run: echo "demo=$(node -p "require('./package/benchmarks/baseline.json').demo.commit")" >> "$GITHUB_OUTPUT"
72+
- uses: actions/checkout@v7
73+
with:
74+
repository: hotcodepush-team/react-native-code-push-demo
75+
ref: ${{ steps.baseline.outputs.demo }}
76+
path: demo
77+
- uses: maxim-lobanov/setup-xcode@v1
78+
with:
79+
xcode-version: latest-stable
80+
- uses: actions/setup-node@v6
81+
with:
82+
node-version-file: package/.nvmrc
83+
- run: brew install swiftlint
84+
- run: swiftlint lint --quiet --strict ios
85+
working-directory: package
86+
- name: Pack the package from this commit
87+
working-directory: package
88+
run: npm ci && npm run build && npm pack --pack-destination "$RUNNER_TEMP"
89+
- name: Prepare the demo with the package
90+
run: node package/benchmarks/prepare-demo.mjs demo "$RUNNER_TEMP/with" with "$(ls "$RUNNER_TEMP"/hotcodepush-react-native-code-push-*.tgz)" --no-embed
91+
- run: xcodebuild -workspace HotCodePushDemo.xcworkspace -scheme HotCodePushDemo -configuration Debug -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO -quiet build
92+
working-directory: ${{ runner.temp }}/with/ios
2793
preview:
2894
name: Publish the preview build to pkg.pr.new
2995
runs-on: ubuntu-latest

‎benchmarks/README.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# The size and cold-start baseline
2+
3+
What the package adds to an app, measured on the demo app and guarded from then on: the numbers are in `baseline.json`, the method is this page, the harness is the scripts beside it.
4+
5+
## What is measured
6+
7+
| Number | How |
8+
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9+
| Binary size added, Android | the release APK of the demo built with the package minus the same build without it, in bytes |
10+
| Binary size added, iOS | the Release simulator `HotCodePushDemo.app` — every file summed — built with the package minus the same build without it, in bytes |
11+
| Cold start added, Android | from the activity's start (`ActivityTaskManager: START` in logcat) to the first paint, on the Pixel_9_Pro emulator; the median of five cold launches with the package minus the median without |
12+
| Cold start added, iOS | from the launch call to the first paint, on an iPhone simulator; the same medians |
13+
14+
The first paint is the `[baseline] first paint` line both variants log on their first animation frame, read from the JavaScript console: logcat on Android, the unified log on iOS.
15+
Both numbers come from release builds, since a debug build asks Metro for its JavaScript and the SDK stays off in it.
16+
The "without" variant is the demo with the package and every line `init` wired removed — the embed step in Xcode and Gradle, the bundle the two apps ask the SDK for, the pinned pod — and the screen swapped for the same screen with nothing behind it.
17+
18+
The sizes are measured with the embed step unwired in the "with" variant too, `--no-embed`: the step needs a login, the resource file it writes weighs a few kilobytes against a binary of tens of megabytes, and the guard in CI holds no credential.
19+
The cold starts are measured with the embed step in place, against a stack the CLI is logged in to, since an app without its resource file starts no SDK.
20+
21+
## Where the bytes sit
22+
23+
On the current baseline the Android release APK grows by about 578 KB.
24+
About 310 KB of it is `libappmodules.so`, the Turbo Module's generated C++ once for each of the four ABIs the demo ships, of which an app bundle delivers one; about 270 KB is `classes2.dex`, the package's code and the shared core's, with OkHttp 5 and Okio in place of the OkHttp 4 React Native brings; the rest is the package's JavaScript in the bundle.
25+
The simulator app grows by about 3.6 MB, nearly all of it in the `HotCodePushDemo` binary: the module and the core are linked statically, and a simulator build is a two-slice fat binary, so a device build carries about half of that; the rest is the core's privacy-manifest bundle and about 12 KB of JavaScript.
26+
27+
## Running it
28+
29+
```sh
30+
npm run build && npm pack --pack-destination /tmp
31+
node benchmarks/prepare-demo.mjs ../react-native-code-push-demo /tmp/baseline/with with /tmp/hotcodepush-react-native-code-push-0.0.0.tgz --no-embed
32+
node benchmarks/prepare-demo.mjs ../react-native-code-push-demo /tmp/baseline/without without
33+
node benchmarks/measure-size.mjs /tmp/baseline/with
34+
node benchmarks/measure-size.mjs /tmp/baseline/without
35+
```
36+
37+
For the cold starts, prepare the "with" variant again without `--no-embed`, with `HOTCODEPUSH_TOKEN` set and `hotcodepush.json` naming an app of that account, then:
38+
39+
```sh
40+
node benchmarks/measure-cold-start.mjs /tmp/baseline/with --android emulator-5554 --ios <udid>
41+
node benchmarks/measure-cold-start.mjs /tmp/baseline/without --android emulator-5554 --ios <udid>
42+
```
43+
44+
Start the Android emulator headless, `emulator -avd Pixel_9_Pro -no-window -gpu host`: macOS drops a windowed emulator to background priority within minutes, which inflates the load average and every cold start with it.
45+
46+
The demo is `hotcodepush-team/react-native-code-push-demo` at the commit `baseline.json` names; a new baseline names the commit it was measured against.
47+
48+
## The guard
49+
50+
`baseline.yml` runs on every pull request to `main`: it checks the demo out at the pinned commit, packs the package from the pull request, prepares the "with" variant, rebuilds both sizes and fails when either grew more than 10 % over the committed number; the sizes land in the job summary. Sizes are deterministic on a runner, cold starts are not — an emulator's timing on a shared runner is noise — so the cold-start check runs locally by script, and a change that could move it reruns the script and commits the new numbers with its reason.
51+
`ci.yml` prepares the same variant to compile the native code inside the demo on both platforms.
52+
53+
The same harness later measures the competitors for `/benchmarks`.

‎benchmarks/baseline.json‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"measuredAt": "2026-10-03",
3+
"package": {
4+
"commit": "86af7a05ebf8bfac615f21af8e683f3b8f340342"
5+
},
6+
"demo": {
7+
"commit": "f81de286cfe01f65d67f020ff408078b686cba86"
8+
},
9+
"android": {
10+
"releaseApkBytes": {
11+
"with": 47543773,
12+
"without": 46966076,
13+
"added": 577697
14+
}
15+
},
16+
"ios": {
17+
"simulatorAppBytes": {
18+
"with": 40884443,
19+
"without": 37248622,
20+
"added": 3635821
21+
}
22+
}
23+
}

‎benchmarks/guard-size.mjs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env node
2+
// Compares a fresh "with" measurement against the committed baseline and fails above the tolerance;
3+
// prints the table the job summary shows.
4+
import { readFileSync } from 'node:fs';
5+
6+
const [baselinePath, measuredPath] = process.argv.slice(2);
7+
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
8+
const measured = JSON.parse(readFileSync(measuredPath, 'utf8'));
9+
const tolerance = 0.1;
10+
11+
const rows = [
12+
[
13+
'Android release APK',
14+
baseline.android.releaseApkBytes.with,
15+
measured.android.releaseApkBytes,
16+
],
17+
[
18+
'iOS simulator app',
19+
baseline.ios.simulatorAppBytes.with,
20+
measured.ios.simulatorAppBytes,
21+
],
22+
];
23+
let failed = false;
24+
console.log('| Binary | Baseline | Now | Change |');
25+
console.log('|---|---|---|---|');
26+
for (const [name, before, now] of rows) {
27+
const change = (now - before) / before;
28+
const grew = change > tolerance;
29+
failed ||= grew;
30+
console.log(
31+
`| ${name} | ${before} | ${now} | ${(change * 100).toFixed(1)} %${grew ? ' — over the 10 % tolerance' : ''} |`,
32+
);
33+
}
34+
if (failed) {
35+
console.error(
36+
'A binary grew more than 10 % over the baseline; rerun the measurement and commit the new baseline with the reason, or shrink the change.',
37+
);
38+
process.exit(1);
39+
}

0 commit comments

Comments
 (0)