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
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,50 @@ jobs:
- name: Run JavaScript verification gate
run: yarn verify:quick

- name: Pack the verified CLI
run: |
mkdir -p "$RUNNER_TEMP/cli-package"
npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/cli-package"

- name: Share the package with runtime checks
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cli-compatibility-package
path: ${{ runner.temp }}/cli-package/*.tgz
retention-days: 3

cli-compatibility:
name: CLI (${{ matrix.os }}, Node ${{ matrix.node }})
needs: javascript
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
node: 20.19.4
- os: ubuntu-latest
node: 22.0.0
- os: ubuntu-latest
node: 22.13.0
- os: ubuntu-latest
node: 24.15.0
- os: windows-latest
node: 20.19.4
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cli-compatibility-package
path: ${{ runner.temp }}/cli-package
- name: Test the installed package with native Node imports
shell: bash
run: node scripts/check-cli-compatibility.cjs "$RUNNER_TEMP"/cli-package/*.tgz

android:
name: Android
runs-on: ubuntu-latest
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ tests, and implementation changes are welcome.

## Before You Start

- Use Node.js 20.19.4 or newer.
- Use Node.js 22.13.0 for development, matching `.nvmrc`. The package requires
Node.js 20.19.4 or newer.
- Search existing issues before opening a new one.
- Use [GitHub Discussions](https://github.com/GFean/react-native-bundle-drop/discussions)
for questions and early-stage ideas.
Expand All @@ -21,6 +22,7 @@ major feature, architectural change, breaking change, or backend-contract change
Install dependencies and build the package:

```bash
nvm use
corepack yarn install
yarn build
```
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ runtime identity. Native changes still require a new App Store or Play Store bui
| Node.js | 20.19.4 or newer |
| React | 17 or newer |

The Node requirement applies to installation of the package, including projects
that use only the mobile SDK. Development and
package builds use Node 22.13.0, as specified in `.nvmrc`.

Run `bundle-drop --help` to browse commands grouped into Setup, Analysis,
Releases, and Account. Existing command names and flags remain unchanged.
For iOS metadata, `--plist-file` and Sight accept XML Info.plist files; binary
and OpenStep property lists are not supported.

## Installation

### Expo
Expand Down Expand Up @@ -115,6 +124,15 @@ canonical packaging. Each revision uses its own JavaScript engine configuration:
Hermes is compiled with project-local tooling when enabled. JavaScript attribution
and raw asset sizes remain separate; they are not bytecode attribution.

Bare iOS upload and Sight honor explicit Bundle Drop and native Hermes settings first.
For the standard React Native **0.81.x** template, they also recognize the installed
React Native helpers' implicit Hermes default when those helpers match the verified
0.81.5 files. This conservative fallback is skipped
for custom engine configuration, community JSC indicators, or unfamiliar templates
and versions. For a custom project, set `hermesBytecode: { ios: true }` (or `false`
for JavaScript) in `bundle.drop.config.js` to match the engine in your native app.
The fallback does not evaluate Ruby or change your Podfile.

Measurement adds compilation and archive compression after the JavaScript build, once
per side in a comparison. Expo's Hermes path also performs a separate bytecode
export to preserve its upload-specific minification behavior. It requires a resolvable app/runtime identity. When that
Expand Down
15 changes: 15 additions & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,22 @@ module.exports = {
roots: ['<rootDir>/src'],
testMatch: ['<rootDir>/src/tests/**/*.test.ts'],
clearMocks: true,
resolver: '<rootDir>/scripts/jest-resolver.cjs',
transform: {
'^.+\\.[jt]sx?$': ['ts-jest', {
tsconfig: {
allowJs: true,
isolatedModules: true,
module: 'CommonJS',
moduleResolution: 'Node',
},
}],
},
transformIgnorePatterns: [
'/node_modules/(?!(plist)/)',
],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^react-native$': '<rootDir>/src/tests/mocks/modules/react-native.ts',
'^react-native/Libraries/Image/AssetRegistry$': '<rootDir>/src/tests/mocks/modules/assetRegistry.ts',
'^bundle-drop-config$': '<rootDir>/src/tests/mocks/modules/bundle-drop-config.ts',
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"clean": "rimraf lib dist",
"build": "npm run clean && tsc -p tsconfig.build.json && node scripts/make-cli-executable.js",
"test": "jest --runInBand",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test:cli:compat": "node scripts/check-cli-compatibility.cjs",
"test:coverage": "jest --runInBand --coverage",
"coverage:check": "node scripts/check-coverage-thresholds.cjs",
"coverage:gate": "yarn test:coverage && yarn coverage:check",
Expand All @@ -50,7 +52,7 @@
"codeql:local:fast": "node scripts/run-codeql-local.cjs fast",
"codeql:local:full": "node scripts/run-codeql-local.cjs full",
"codeql:local:compare": "node scripts/run-codeql-local.cjs compare",
"verify:quick": "yarn build && yarn coverage:gate && yarn test:expo:plugin && yarn package:check",
"verify:quick": "yarn typecheck && yarn build && yarn coverage:gate && yarn test:expo:plugin && yarn package:check",
"verify:native": "yarn test:android && yarn test:ios",
"verify:release": "yarn verify:quick && yarn verify:native && yarn audit:production",
"prepack": "npm run build",
Expand Down Expand Up @@ -172,12 +174,12 @@
"adm-zip": "0.6.1",
"axios": "1.20.0",
"chalk": "4.1.2",
"commander": "7.2.0",
"commander": "14.0.3",
"diff": "9.0.0",
"figures": "3.2.0",
"form-data": "4.0.6",
"fs-extra": "11.4.0",
"plist": "3.1.1",
"plist": "5.0.0",
"prompts": "2.4.2"
},
"publishConfig": {
Expand Down
79 changes: 79 additions & 0 deletions scripts/check-cli-compatibility.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env node
// Test the published file layout and native ESM interop, outside Jest and this
// checkout's node_modules. Run after an explicit build, or supply a tarball.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');

const root = path.resolve(__dirname, '..');
const nodeDirectory = path.dirname(process.execPath);
const npmCli = [
path.join(nodeDirectory, 'node_modules/npm/bin/npm-cli.js'),
path.resolve(nodeDirectory, '../lib/node_modules/npm/bin/npm-cli.js'),
].find(file => fs.existsSync(file));
assert.ok(npmCli, 'npm must be installed alongside the Node executable');
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-drop-cli-compat-'));
const env = { ...process.env };
delete env.NODE_PATH;
delete env.NODE_OPTIONS;
delete env.FORCE_COLOR;
delete env.NO_COLOR;

function run(args, cwd, expectedStatus = 0, extraEnv = {}) {
const result = spawnSync(process.execPath, args, {
cwd, env: { ...env, ...extraEnv }, encoding: 'utf8',
timeout: 180_000, maxBuffer: 8 * 1024 * 1024,
});
assert.ifError(result.error);
assert.equal(result.status, expectedStatus, `${args.join(' ')}\n${result.stdout}\n${result.stderr}`);
return result.stdout + result.stderr;
}

try {
let tarball = process.argv[2] && path.resolve(process.argv[2]);
if (!tarball) {
const packed = run([npmCli, 'pack', '--ignore-scripts', '--json', '--pack-destination', temporary], root);
tarball = path.join(temporary, JSON.parse(packed)[0].filename);
}
const fixture = path.join(temporary, 'consumer');
fs.mkdirSync(fixture);
fs.writeFileSync(path.join(fixture, 'package.json'), JSON.stringify({
name: 'bundle-drop-cli-compatibility', version: '1.0.0', private: true,
}));
// This is a CLI-only consumer: mobile peer dependencies are deliberately not
// installed. Real mobile peer compatibility is exercised by Metro fixtures.
run([npmCli, 'install', tarball, '--ignore-scripts', '--omit=peer', '--no-audit', '--no-fund'], fixture);
const installed = path.join(fixture, 'node_modules/@gfean/react-native-bundle-drop');
const cli = path.join(installed, 'lib/CLI/cli.js');
const help = run([cli, '--help'], fixture, 0, { FORCE_COLOR: '0' });
for (const group of ['Setup', 'Analysis', 'Releases', 'Account']) assert.ok(help.includes(group), group);
assert.ok(!/\u001b\[/.test(help), 'FORCE_COLOR=0 must suppress ANSI colors');
for (const command of ['init', 'sync', 'doctor', 'sight', 'upload', 'eas-receipt', 'login', 'logout', 'whoami']) {
assert.ok(run([cli, command, '--help'], fixture).includes('Usage:'), command);
}
assert.ok(run([cli, 'help', 'sight'], fixture).includes('--compare'));
const version = JSON.parse(fs.readFileSync(path.join(installed, 'package.json'))).version;
assert.ok(run([cli, '--cli-version'], fixture).includes(version));
assert.match(run([cli, 'sight', '--does-not-exist'], fixture, 1), /unknown option/);
assert.match(run([cli, 'upload'], fixture, 1), /missing required argument/);
assert.match(run([cli, 'sight', '--fetch'], fixture, 1), /require --compare/);
assert.match(run([cli, 'sight', '--compare', '', '--platform', 'ios'], fixture, 1), /requires a Git ref/);
for (const level of ['1', '3']) assert.match(run([cli, '--help'], fixture, 0, { FORCE_COLOR: level }), /\u001b\[/);
assert.ok(!/\u001b\[/.test(run([cli, '--help'], fixture, 0, { NO_COLOR: '1' })));

const xmlFile = path.join(fixture, 'Info with spaces.plist');
fs.writeFileSync(xmlFile, '<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleShortVersionString</key><string>1.2.3</string></dict></plist>');
const reader = path.join(installed, 'lib/CLI/utils/read-xml-plist.js');
const probe = `const assert=require('node:assert/strict');require(process.argv[1]).readXmlPlist(process.argv[2]).then(value=>assert.equal(value.CFBundleShortVersionString,'1.2.3')).catch(error=>{console.error(error);process.exitCode=1;});`;
run(['-e', probe, reader, xmlFile], fixture);
for (const contents of ['bplist00invalid', '{ CFBundleShortVersionString = "1.2.3"; }', '<plist><array/></plist>']) {
fs.writeFileSync(xmlFile, contents);
const reject = `require(process.argv[1]).readXmlPlist(process.argv[2]).then(()=>{process.exitCode=1},()=>{});`;
run(['-e', reject, reader, xmlFile], fixture);
}
console.log(`Packed CLI compatibility passed: ${process.platform}, Node ${process.version}, package ${version}`);
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
23 changes: 16 additions & 7 deletions scripts/check-cli-isolation.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ const path = require('path');
const ts = require('typescript');

const forbidden = /(?:^|\/)CLI\/scripts\/sight-(?:compare(?:\/|$)|(?:artifacts|assets|ota|session|cli)\.)/;
const cliDependencies = new Set(['chalk', 'commander', 'figures', 'plist']);

function relativeModules(file) {
function importedModules(file) {
const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
const modules = [];
function visit(node) {
Expand All @@ -22,22 +23,30 @@ function relativeModules(file) {
ts.forEachChild(node, visit);
}
visit(source);
return modules.filter(name => name.startsWith('.'));
return modules;
}

function checkCliIsolation(root, entries) {
const visited = new Set();
function visit(file, chain) {
if (visited.has(file)) return;
const relative = path.relative(root, file).split(path.sep).join('/');
if (forbidden.test(relative)) throw new Error(`Sight CLI code is reachable from an SDK/tooling entrypoint: ${[...chain, relative].join(' -> ')}`);
const sight = forbidden.test(relative);
if (sight || relative.includes('CLI/utils/read-xml-plist.')) throw new Error(`${sight ? 'Sight CLI' : 'CLI'} code is reachable from an SDK/tooling entrypoint: ${[...chain, relative].join(' -> ')}`);
visited.add(file);
for (const name of relativeModules(file)) {
for (const name of importedModules(file)) {
if (cliDependencies.has(name.split('/')[0])) {
throw new Error(`CLI dependency is reachable from an SDK/tooling entrypoint: ${[...chain, relative, name].join(' -> ')}`);
}
if (!name.startsWith('.')) continue;
const base = path.resolve(path.dirname(file), name);
const stem = base.replace(/\.[cm]?js$/, '');
const declaration = file.endsWith('.d.ts');
const candidates = declaration
? [base + '.d.ts', path.join(base, 'index.d.ts'), base]
: [base + '.js', base + '.cjs', base + '.mjs', base + '.ts', base + '.tsx', path.join(base, 'index.js'), path.join(base, 'index.ts'), path.join(base, 'index.tsx'), base];
? [stem + '.d.ts', path.join(base, 'index.d.ts'), base]
: /\.tsx?$/.test(file)
? [stem + '.ts', stem + '.tsx', base, path.join(base, 'index.ts'), path.join(base, 'index.tsx'), path.join(base, 'index.js')]
: [base, base + '.js', base + '.cjs', base + '.mjs', path.join(base, 'index.js')];
const resolved = candidates.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
if (resolved && /\.(?:[cm]?js|tsx?)$/.test(resolved)) visit(resolved, [...chain, relative]);
}
Expand All @@ -53,5 +62,5 @@ if (require.main === module) {
'lib/index.js', 'lib/bootstrap.js', 'lib/index.d.ts', 'lib/metro.js', 'lib/metro.d.ts', 'app.plugin.js',
'src/index.tsx', 'src/bootstrap.ts', 'src/metro.ts',
]);
console.log(`CLI isolation check passed: ${modules.length} SDK/tooling modules inspected; no Sight comparison imports.`);
console.log(`CLI isolation check passed: ${modules.length} SDK/tooling modules inspected; no Sight or CLI dependency imports.`);
}
2 changes: 2 additions & 0 deletions scripts/check-package-contents.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ const expectedScripts = new Set([
'clean',
'build',
'test',
'typecheck',
'test:cli:compat',
'test:coverage',
'coverage:check',
'coverage:gate',
Expand Down
8 changes: 8 additions & 0 deletions scripts/jest-resolver.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Plist exposes only an ESM import entry. Production uses native import();
// Jest transforms that entry for its CommonJS test environment.
module.exports = (request, options) => options.defaultResolver(
request,
request === 'plist'
? { ...options, conditions: [...(options.conditions || []), 'import'] }
: options,
);
Loading
Loading