@bravemobile/react-native-code-push 10.0.0 → 11.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CodePush.js CHANGED
@@ -9,6 +9,54 @@ const PackageMixins = require("./package-mixins")(NativeCodePush);
9
9
 
10
10
  const DEPLOYMENT_KEY = 'deprecated_deployment_key';
11
11
 
12
+ /**
13
+ * @param deviceId {string}
14
+ * @returns {number}
15
+ */
16
+ function hashDeviceId(deviceId) {
17
+ let hash = 0;
18
+ for (let i = 0; i < deviceId.length; i++) {
19
+ hash = ((hash << 5) - hash) + deviceId.charCodeAt(i);
20
+ hash |= 0; // Convert to 32bit int
21
+ }
22
+ return Math.abs(hash);
23
+ }
24
+
25
+ /**
26
+ * @param clientId {string}
27
+ * @param packageHash {string}
28
+ * @returns {number}
29
+ */
30
+ function getBucket(clientId, packageHash) {
31
+ const hash = hashDeviceId(`${clientId ?? ''}_${packageHash ?? ''}`);
32
+ return (Math.abs(hash) % 100);
33
+ }
34
+
35
+ /**
36
+ * Note that the `clientUniqueId` value may not guarantee the same value if the app is deleted and re-installed.
37
+ * In other words, if a user re-installs the app, the result of this function may change.
38
+ * @returns {Promise<boolean>}
39
+ */
40
+ async function decideLatestReleaseIsInRollout(versioning, clientId, onRolloutSkipped) {
41
+ const [latestVersion, latestReleaseInfo] = versioning.findLatestRelease();
42
+
43
+ if (latestReleaseInfo.rollout === undefined || latestReleaseInfo.rollout >= 100) {
44
+ return true;
45
+ }
46
+
47
+ const bucket = getBucket(clientId, latestReleaseInfo.packageHash);
48
+ const inRollout = bucket < latestReleaseInfo.rollout;
49
+
50
+ log(`Bucket: ${bucket}, rollout: ${latestReleaseInfo.rollout} → ${inRollout ? 'IN' : 'OUT'}`);
51
+
52
+ if (!inRollout) {
53
+ log(`Skipping update due to rollout. Bucket ${bucket} is not smaller than rollout range ${latestReleaseInfo.rollout}.`);
54
+ onRolloutSkipped?.(latestVersion);
55
+ }
56
+
57
+ return inRollout;
58
+ }
59
+
12
60
  async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
13
61
  /*
14
62
  * Before we ask the server if an update exists, we
@@ -42,6 +90,9 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
42
90
  }
43
91
  }
44
92
 
93
+ /**
94
+ * @type {RemotePackage|null|undefined}
95
+ */
45
96
  const update = await (async () => {
46
97
  try {
47
98
  const updateRequest = {
@@ -58,8 +109,8 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
58
109
  */
59
110
  const updateChecker = sharedCodePushOptions.updateChecker;
60
111
  if (updateChecker) {
112
+ // We do not provide rollout functionality. This could be implemented in the `updateChecker`.
61
113
  const { update_info } = await updateChecker(updateRequest);
62
-
63
114
  return mapToRemotePackageMetadata(update_info);
64
115
  } else {
65
116
  /**
@@ -77,6 +128,9 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
77
128
 
78
129
  const versioning = new SemverVersioning(releaseHistory);
79
130
 
131
+ const isInRollout = await decideLatestReleaseIsInRollout(versioning, nativeConfig.clientUniqueId, sharedCodePushOptions?.onRolloutSkipped);
132
+ versioning.setIsLatestReleaseInRollout(isInRollout);
133
+
80
134
  const shouldRollbackToBinary = versioning.shouldRollbackToBinary(runtimeVersion)
81
135
  if (shouldRollbackToBinary) {
82
136
  // Reset to latest major version and restart
@@ -217,7 +271,7 @@ async function getCurrentPackage() {
217
271
  async function getUpdateMetadata(updateState) {
218
272
  let updateMetadata = await NativeCodePush.getUpdateMetadata(updateState || CodePush.UpdateState.RUNNING);
219
273
  if (updateMetadata) {
220
- updateMetadata = {...PackageMixins.local, ...updateMetadata};
274
+ updateMetadata = { ...PackageMixins.local, ...updateMetadata };
221
275
  updateMetadata.failedInstall = await NativeCodePush.isFailedUpdate(updateMetadata.packageHash);
222
276
  updateMetadata.isFirstRun = await NativeCodePush.isFirstRun(updateMetadata.packageHash);
223
277
  }
@@ -424,47 +478,47 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
424
478
  mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE,
425
479
  minimumBackgroundDuration: 0,
426
480
  updateDialog: null,
427
- ...options
481
+ ...options,
428
482
  };
429
483
 
430
484
  syncStatusChangeCallback = typeof syncStatusChangeCallback === "function"
431
485
  ? syncStatusChangeCallback
432
486
  : (syncStatus) => {
433
- switch(syncStatus) {
434
- case CodePush.SyncStatus.CHECKING_FOR_UPDATE:
435
- log("Checking for update.");
436
- break;
437
- case CodePush.SyncStatus.AWAITING_USER_ACTION:
438
- log("Awaiting user action.");
439
- break;
440
- case CodePush.SyncStatus.DOWNLOADING_PACKAGE:
441
- log("Downloading package.");
442
- break;
443
- case CodePush.SyncStatus.INSTALLING_UPDATE:
444
- log("Installing update.");
445
- break;
446
- case CodePush.SyncStatus.UP_TO_DATE:
447
- log("App is up to date.");
448
- break;
449
- case CodePush.SyncStatus.UPDATE_IGNORED:
450
- log("User cancelled the update.");
451
- break;
452
- case CodePush.SyncStatus.UPDATE_INSTALLED:
453
- if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) {
454
- log("Update is installed and will be run on the next app restart.");
455
- } else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) {
456
- if (syncOptions.minimumBackgroundDuration > 0) {
457
- log(`Update is installed and will be run after the app has been in the background for at least ${syncOptions.minimumBackgroundDuration} seconds.`);
458
- } else {
459
- log("Update is installed and will be run when the app next resumes.");
460
- }
487
+ switch (syncStatus) {
488
+ case CodePush.SyncStatus.CHECKING_FOR_UPDATE:
489
+ log("Checking for update.");
490
+ break;
491
+ case CodePush.SyncStatus.AWAITING_USER_ACTION:
492
+ log("Awaiting user action.");
493
+ break;
494
+ case CodePush.SyncStatus.DOWNLOADING_PACKAGE:
495
+ log("Downloading package.");
496
+ break;
497
+ case CodePush.SyncStatus.INSTALLING_UPDATE:
498
+ log("Installing update.");
499
+ break;
500
+ case CodePush.SyncStatus.UP_TO_DATE:
501
+ log("App is up to date.");
502
+ break;
503
+ case CodePush.SyncStatus.UPDATE_IGNORED:
504
+ log("User cancelled the update.");
505
+ break;
506
+ case CodePush.SyncStatus.UPDATE_INSTALLED:
507
+ if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) {
508
+ log("Update is installed and will be run on the next app restart.");
509
+ } else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) {
510
+ if (syncOptions.minimumBackgroundDuration > 0) {
511
+ log(`Update is installed and will be run after the app has been in the background for at least ${syncOptions.minimumBackgroundDuration} seconds.`);
512
+ } else {
513
+ log("Update is installed and will be run when the app next resumes.");
461
514
  }
462
- break;
463
- case CodePush.SyncStatus.UNKNOWN_ERROR:
464
- log("An unknown error occurred.");
465
- break;
466
- }
467
- };
515
+ }
516
+ break;
517
+ case CodePush.SyncStatus.UNKNOWN_ERROR:
518
+ log("An unknown error occurred.");
519
+ break;
520
+ }
521
+ };
468
522
 
469
523
  let remotePackageLabel;
470
524
  try {
@@ -497,7 +551,7 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
497
551
 
498
552
  if (!remotePackage || updateShouldBeIgnored) {
499
553
  if (updateShouldBeIgnored) {
500
- log("An update is available, but it is being ignored due to having been previously rolled back.");
554
+ log("An update is available, but it is being ignored due to having been previously rolled back.");
501
555
  }
502
556
 
503
557
  const currentPackage = await CodePush.getCurrentPackage();
@@ -536,7 +590,7 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
536
590
  onPress: () => {
537
591
  syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_IGNORED);
538
592
  resolve(CodePush.SyncStatus.UPDATE_IGNORED);
539
- }
593
+ },
540
594
  });
541
595
  }
542
596
 
@@ -544,10 +598,10 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
544
598
  // right of any other button, add it last
545
599
  dialogButtons.push({
546
600
  text: installButtonText,
547
- onPress:() => {
601
+ onPress: () => {
548
602
  doDownloadAndInstall()
549
603
  .then(resolve, reject);
550
- }
604
+ },
551
605
  })
552
606
 
553
607
  // If the update has a description, and the developer
@@ -609,6 +663,9 @@ let CodePush;
609
663
  *
610
664
  * onSyncError: (label: string, error: Error) => void | undefined,
611
665
  * setOnSyncError(onSyncErrorFunction: (label: string, error: Error) => void | undefined): void,
666
+ *
667
+ * onRolloutSkipped: (label: string, error: Error) => void | undefined,
668
+ * setOnRolloutSkipped(onRolloutSkippedFunction: (label: string, error: Error) => void | undefined): void,
612
669
  * }}
613
670
  */
614
671
  const sharedCodePushOptions = {
@@ -653,6 +710,12 @@ const sharedCodePushOptions = {
653
710
  if (typeof onSyncErrorFunction !== 'function') throw new Error('Please pass a function to onSyncError');
654
711
  this.onSyncError = onSyncErrorFunction;
655
712
  },
713
+ onRolloutSkipped: undefined,
714
+ setOnRolloutSkipped(onRolloutSkippedFunction) {
715
+ if (!onRolloutSkippedFunction) return;
716
+ if (typeof onRolloutSkippedFunction !== 'function') throw new Error('Please pass a function to onRolloutSkipped');
717
+ this.onRolloutSkipped = onRolloutSkippedFunction;
718
+ },
656
719
  }
657
720
 
658
721
  function codePushify(options = {}) {
@@ -671,7 +734,7 @@ function codePushify(options = {}) {
671
734
  throw new Error(
672
735
  `Unable to find the "Component" class, please either:
673
736
  1. Upgrade to a newer version of React Native that supports it, or
674
- 2. Call the codePush.sync API in your component instead of using the @codePush decorator`
737
+ 2. Call the codePush.sync API in your component instead of using the @codePush decorator`,
675
738
  );
676
739
  }
677
740
 
@@ -688,6 +751,7 @@ function codePushify(options = {}) {
688
751
  sharedCodePushOptions.setOnDownloadStart(options.onDownloadStart);
689
752
  sharedCodePushOptions.setOnDownloadSuccess(options.onDownloadSuccess);
690
753
  sharedCodePushOptions.setOnSyncError(options.onSyncError);
754
+ sharedCodePushOptions.setOnRolloutSkipped(options.onRolloutSkipped);
691
755
 
692
756
  const decorator = (RootComponent) => {
693
757
  class CodePushComponent extends React.Component {
@@ -730,7 +794,7 @@ function codePushify(options = {}) {
730
794
  }
731
795
 
732
796
  render() {
733
- const props = {...this.props};
797
+ const props = { ...this.props };
734
798
 
735
799
  // We can set ref property on class components only (not stateless)
736
800
  // Check it by render method
@@ -777,7 +841,7 @@ if (NativeCodePush) {
777
841
  IMMEDIATE: NativeCodePush.codePushInstallModeImmediate, // Restart the app immediately
778
842
  ON_NEXT_RESTART: NativeCodePush.codePushInstallModeOnNextRestart, // Don't artificially restart the app. Allow the update to be "picked up" on the next app restart
779
843
  ON_NEXT_RESUME: NativeCodePush.codePushInstallModeOnNextResume, // Restart the app the next time it is resumed from the background
780
- ON_NEXT_SUSPEND: NativeCodePush.codePushInstallModeOnNextSuspend // Restart the app _while_ it is in the background,
844
+ ON_NEXT_SUSPEND: NativeCodePush.codePushInstallModeOnNextSuspend, // Restart the app _while_ it is in the background,
781
845
  // but only after it has been in the background for "minimumBackgroundDuration" seconds (0 by default),
782
846
  // so that user context isn't lost unless the app suspension is long enough to not matter
783
847
  },
@@ -790,17 +854,17 @@ if (NativeCodePush) {
790
854
  CHECKING_FOR_UPDATE: 5,
791
855
  AWAITING_USER_ACTION: 6,
792
856
  DOWNLOADING_PACKAGE: 7,
793
- INSTALLING_UPDATE: 8
857
+ INSTALLING_UPDATE: 8,
794
858
  },
795
859
  CheckFrequency: {
796
860
  ON_APP_START: 0,
797
861
  ON_APP_RESUME: 1,
798
- MANUAL: 2
862
+ MANUAL: 2,
799
863
  },
800
864
  UpdateState: {
801
865
  RUNNING: NativeCodePush.codePushUpdateStateRunning,
802
866
  PENDING: NativeCodePush.codePushUpdateStatePending,
803
- LATEST: NativeCodePush.codePushUpdateStateLatest
867
+ LATEST: NativeCodePush.codePushUpdateStateLatest,
804
868
  },
805
869
  DeploymentStatus: {
806
870
  FAILED: "DeploymentFailed",
@@ -814,11 +878,11 @@ if (NativeCodePush) {
814
878
  optionalIgnoreButtonLabel: "Ignore",
815
879
  optionalInstallButtonLabel: "Install",
816
880
  optionalUpdateMessage: "An update is available. Would you like to install it?",
817
- title: "Update available"
881
+ title: "Update available",
818
882
  },
819
883
  DEFAULT_ROLLBACK_RETRY_OPTIONS: {
820
884
  delayInHours: 24,
821
- maxRetryAttempts: 1
885
+ maxRetryAttempts: 1,
822
886
  },
823
887
  });
824
888
  } else {
package/README.md CHANGED
@@ -8,17 +8,9 @@
8
8
 
9
9
  ### 🚀 New Architecture support
10
10
 
11
- Tested on the React Native template apps
12
-
13
- | RN Version | Old Architecture | New Architecture | New Architecture Bridgeless |
14
- |--------|--------|--------|--------|
15
- | 0.73.11 | ✅ | ✅ | Unsupported |
16
- | 0.74.7 | ✅ | ✅ | ✅ |
17
- | 0.75.5 | ✅ | ✅ | ✅ |
18
- | 0.76.7 | ✅ | ✅ | ✅ |
19
- | 0.77.1 | ✅ | ✅ | ✅ |
20
- | 0.78.0 | ✅ | ✅ | ✅ |
11
+ Supports React Native 0.74 ~ 0.80.
21
12
 
13
+ (Tested on the React Native CLI template apps)
22
14
 
23
15
  ## 🚗 Migration Guide
24
16
 
@@ -24,10 +24,6 @@
24
24
  private ** mReactHost; # bridgeless
25
25
  public void reload(...); # RN 0.74 and above
26
26
  }
27
- # RN 0.74 and above
28
- -keepclassmembers class com.facebook.react.ReactActivity {
29
- public ** getReactDelegate(...);
30
- }
31
27
  # bridgeless
32
28
  -keepclassmembers class com.facebook.react.defaults.DefaultReactHostDelegate {
33
29
  private ** jsBundleLoader;
@@ -6,6 +6,7 @@ import android.os.AsyncTask;
6
6
  import android.os.Handler;
7
7
  import android.os.Looper;
8
8
  import android.view.View;
9
+ import android.view.Choreographer;
9
10
 
10
11
  import androidx.annotation.OptIn;
11
12
 
@@ -25,7 +26,6 @@ import com.facebook.react.bridge.ReactMethod;
25
26
  import com.facebook.react.bridge.ReadableMap;
26
27
  import com.facebook.react.bridge.WritableMap;
27
28
  import com.facebook.react.common.annotations.UnstableReactNativeAPI;
28
- import com.facebook.react.modules.core.ChoreographerCompat;
29
29
  import com.facebook.react.modules.core.DeviceEventManagerModule;
30
30
  import com.facebook.react.modules.core.ReactChoreographer;
31
31
  import com.facebook.react.runtime.ReactHostDelegate;
@@ -99,7 +99,7 @@ public class CodePushNativeModule extends ReactContextBaseJavaModule {
99
99
  }
100
100
 
101
101
  private void loadBundleLegacy() {
102
- final Activity currentActivity = getCurrentActivity();
102
+ final Activity currentActivity = getReactApplicationContext().getCurrentActivity();
103
103
  if (currentActivity == null) {
104
104
  // The currentActivity can be null if it is backgrounded / destroyed, so we simply
105
105
  // no-op to prevent any null pointer exceptions.
@@ -128,7 +128,7 @@ public class CodePushNativeModule extends ReactContextBaseJavaModule {
128
128
 
129
129
  ReactHost reactHost = resolveReactHost();
130
130
  if (reactHost == null) {
131
- // Bridge, Old Architecture and RN < 0.74 (we support Bridgeless >= 0.74)
131
+ // Bridge, Old Architecture
132
132
  setJSBundleLoaderBridge(instanceManager, latestJSBundleLoader);
133
133
  return;
134
134
  }
@@ -184,29 +184,14 @@ public class CodePushNativeModule extends ReactContextBaseJavaModule {
184
184
  new Handler(Looper.getMainLooper()).post(new Runnable() {
185
185
  @Override
186
186
  public void run() {
187
- try {
188
- // reload method introduced in RN 0.74 (https://github.com/reactwg/react-native-new-architecture/discussions/174)
189
- // so, we need to check if reload method exists and call it
190
- try {
191
- ReactDelegate reactDelegate = resolveReactDelegate();
192
- if (reactDelegate == null) {
193
- throw new NoSuchMethodException("ReactDelegate doesn't have reload method in RN < 0.74");
194
- }
187
+ ReactDelegate reactDelegate = resolveReactDelegate();
188
+ assert reactDelegate != null;
195
189
 
196
- resetReactRootViews(reactDelegate);
190
+ resetReactRootViews(reactDelegate);
197
191
 
198
- Method reloadMethod = reactDelegate.getClass().getMethod("reload");
199
- reloadMethod.invoke(reactDelegate);
200
- } catch (NoSuchMethodException e) {
201
- // RN < 0.74 calls ReactInstanceManager.recreateReactContextInBackground() directly
202
- instanceManager.recreateReactContextInBackground();
203
- }
204
- mCodePush.initializeUpdateAfterRestart();
205
- } catch (Exception e) {
206
- // The recreation method threw an unknown exception
207
- // so just simply fallback to restarting the Activity (if it exists)
208
- loadBundleLegacy();
209
- }
192
+ reactDelegate.reload();
193
+
194
+ mCodePush.initializeUpdateAfterRestart();
210
195
  }
211
196
  });
212
197
 
@@ -223,7 +208,7 @@ public class CodePushNativeModule extends ReactContextBaseJavaModule {
223
208
  // React Native uses the id field to track react tags and will overwrite this field.
224
209
  // If that is fine, explicitly overwrite the id field to View.NO_ID before calling addRootView."
225
210
  private void resetReactRootViews(ReactDelegate reactDelegate) {
226
- ReactActivity currentActivity = (ReactActivity) getCurrentActivity();
211
+ ReactActivity currentActivity = (ReactActivity) getReactApplicationContext().getCurrentActivity();
227
212
  if (currentActivity != null) {
228
213
  ReactRootView reactRootView = reactDelegate.getReactRootView();
229
214
  if (reactRootView != null) {
@@ -242,18 +227,12 @@ public class CodePushNativeModule extends ReactContextBaseJavaModule {
242
227
  }
243
228
 
244
229
  private ReactDelegate resolveReactDelegate() {
245
- ReactActivity currentActivity = (ReactActivity) getCurrentActivity();
230
+ ReactActivity currentActivity = (ReactActivity) getReactApplicationContext().getCurrentActivity();
246
231
  if (currentActivity == null) {
247
232
  return null;
248
233
  }
249
234
 
250
- try {
251
- Method getReactDelegateMethod = currentActivity.getClass().getMethod("getReactDelegate");
252
- return (ReactDelegate) getReactDelegateMethod.invoke(currentActivity);
253
- } catch (Exception e) {
254
- // RN < 0.74 doesn't have getReactDelegate method
255
- return null;
256
- }
235
+ return currentActivity.getReactDelegate();
257
236
  }
258
237
 
259
238
  private ReactHost resolveReactHost() {
@@ -278,7 +257,7 @@ public class CodePushNativeModule extends ReactContextBaseJavaModule {
278
257
  return instanceManager;
279
258
  }
280
259
 
281
- final Activity currentActivity = getCurrentActivity();
260
+ final Activity currentActivity = getReactApplicationContext().getCurrentActivity();
282
261
  if (currentActivity == null) {
283
262
  return null;
284
263
  }
@@ -390,7 +369,7 @@ public class CodePushNativeModule extends ReactContextBaseJavaModule {
390
369
  getReactApplicationContext().runOnUiQueueThread(new Runnable() {
391
370
  @Override
392
371
  public void run() {
393
- ReactChoreographer.getInstance().postFrameCallback(ReactChoreographer.CallbackType.TIMERS_EVENTS, new ChoreographerCompat.FrameCallback() {
372
+ ReactChoreographer.getInstance().postFrameCallback(ReactChoreographer.CallbackType.TIMERS_EVENTS, new Choreographer.FrameCallback() {
394
373
  @Override
395
374
  public void doFrame(long frameTimeNanos) {
396
375
  if (!latestDownloadProgress.isCompleted()) {
@@ -25,6 +25,7 @@ const fs = require("fs");
25
25
  * @param identifier {string?}
26
26
  * @param mandatory {boolean?}
27
27
  * @param enable {boolean?}
28
+ * @param rollout {number?}
28
29
  * @returns {Promise<void>}
29
30
  */
30
31
  async function addToReleaseHistory(
@@ -38,6 +39,7 @@ async function addToReleaseHistory(
38
39
  identifier,
39
40
  mandatory,
40
41
  enable,
42
+ rollout
41
43
  ) {
42
44
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
43
45
 
@@ -54,6 +56,10 @@ async function addToReleaseHistory(
54
56
  mandatory: mandatory,
55
57
  downloadUrl: bundleDownloadUrl,
56
58
  packageHash: packageHash,
59
+ };
60
+
61
+ if (typeof rollout === 'number') {
62
+ newReleaseHistory[appVersion].rollout = rollout;
57
63
  }
58
64
 
59
65
  try {
@@ -16,6 +16,7 @@ program.command('release')
16
16
  .option('-j, --js-bundle-name <string>', 'JS bundle file name (default-ios: "main.jsbundle" / default-android: "index.android.bundle")')
17
17
  .option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, false)
18
18
  .option('--enable <bool>', 'make the release to be enabled', parseBoolean, true)
19
+ .option('--rollout <number>', 'rollout percentage (0-100)', parseFloat)
19
20
  .option('--skip-bundle <bool>', 'skip bundle process', parseBoolean, false)
20
21
  .option('--skip-cleanup <bool>', 'skip cleanup process', parseBoolean, false)
21
22
  .option('--output-bundle-dir <string>', 'name of directory containing the bundle file created by the "bundle" command', OUTPUT_BUNDLE_DIR)
@@ -32,6 +33,7 @@ program.command('release')
32
33
  * @param {string} options.bundleName
33
34
  * @param {string} options.mandatory
34
35
  * @param {string} options.enable
36
+ * @param {number} options.rollout
35
37
  * @param {string} options.skipBundle
36
38
  * @param {string} options.skipCleanup
37
39
  * @param {string} options.outputBundleDir
@@ -40,6 +42,11 @@ program.command('release')
40
42
  .action(async (options) => {
41
43
  const config = findAndReadConfigFile(process.cwd(), options.config);
42
44
 
45
+ if (typeof options.rollout === 'number' && (options.rollout < 0 || options.rollout > 100)) {
46
+ console.error('Rollout percentage number must be between 0 and 100 (inclusive).');
47
+ process.exit(1);
48
+ }
49
+
43
50
  await release(
44
51
  config.bundleUploader,
45
52
  config.getReleaseHistory,
@@ -54,6 +61,7 @@ program.command('release')
54
61
  options.bundleName,
55
62
  options.mandatory,
56
63
  options.enable,
64
+ options.rollout,
57
65
  options.skipBundle,
58
66
  options.skipCleanup,
59
67
  `${options.outputPath}/${options.outputBundleDir}`,
@@ -33,6 +33,7 @@ const { addToReleaseHistory } = require("./addToReleaseHistory");
33
33
  * @param jsBundleName {string}
34
34
  * @param mandatory {boolean}
35
35
  * @param enable {boolean}
36
+ * @param rollout {number}
36
37
  * @param skipBundle {boolean}
37
38
  * @param skipCleanup {boolean}
38
39
  * @param bundleDirectory {string}
@@ -52,6 +53,7 @@ async function release(
52
53
  jsBundleName,
53
54
  mandatory,
54
55
  enable,
56
+ rollout,
55
57
  skipBundle,
56
58
  skipCleanup,
57
59
  bundleDirectory,
@@ -82,6 +84,7 @@ async function release(
82
84
  identifier,
83
85
  mandatory,
84
86
  enable,
87
+ rollout,
85
88
  )
86
89
 
87
90
  if (!skipCleanup) {
@@ -12,6 +12,7 @@ program.command('update-history')
12
12
  .option('-c, --config <path>', 'set config file name (JS/TS)', CONFIG_FILE_NAME)
13
13
  .option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, undefined)
14
14
  .option('-e, --enable <bool>', 'make the release to be enabled', parseBoolean, undefined)
15
+ .option('--rollout <number>', 'rollout percentage (0-100)', parseFloat, undefined)
15
16
  /**
16
17
  * @param {Object} options
17
18
  * @param {string} options.appVersion
@@ -21,6 +22,7 @@ program.command('update-history')
21
22
  * @param {string} options.config
22
23
  * @param {string} options.mandatory
23
24
  * @param {string} options.enable
25
+ * @param {number} options.rollout
24
26
  * @return {void}
25
27
  */
26
28
  .action(async (options) => {
@@ -40,6 +42,7 @@ program.command('update-history')
40
42
  options.identifier,
41
43
  options.mandatory,
42
44
  options.enable,
45
+ options.rollout
43
46
  )
44
47
  });
45
48
 
@@ -23,6 +23,7 @@ const path = require('path');
23
23
  * @param identifier {string?}
24
24
  * @param mandatory {boolean?}
25
25
  * @param enable {boolean?}
26
+ * @param rollout {number?}
26
27
  * @returns {Promise<void>}
27
28
  */
28
29
  async function updateReleaseHistory(
@@ -34,6 +35,7 @@ async function updateReleaseHistory(
34
35
  identifier,
35
36
  mandatory,
36
37
  enable,
38
+ rollout
37
39
  ) {
38
40
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
39
41
 
@@ -42,6 +44,7 @@ async function updateReleaseHistory(
42
44
 
43
45
  if (typeof mandatory === "boolean") updateInfo.mandatory = mandatory;
44
46
  if (typeof enable === "boolean") updateInfo.enabled = enable;
47
+ if (typeof rollout === "number") updateInfo.rollout = rollout;
45
48
 
46
49
  try {
47
50
  const JSON_FILE_NAME = `${binaryVersion}.json`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bravemobile/react-native-code-push",
3
- "version": "10.0.0",
3
+ "version": "11.1.0",
4
4
  "description": "React Native plugin for the CodePush service",
5
5
  "main": "CodePush.js",
6
6
  "typings": "typings/react-native-code-push.d.ts",
@@ -29,6 +29,7 @@ export interface ReleaseInfo {
29
29
  mandatory: boolean;
30
30
  downloadUrl: string;
31
31
  packageHash: string;
32
+ rollout?: number;
32
33
  }
33
34
 
34
35
  // from code-push SDK
@@ -93,6 +94,10 @@ export interface CodePushOptions extends SyncOptions {
93
94
  * Callback function that is called when sync process failed.
94
95
  */
95
96
  onSyncError?: (label: string, error: Error) => void;
97
+ /**
98
+ * Callback function that is called when rollout is skipped.
99
+ */
100
+ onRolloutSkipped?: (label: string, error: Error) => void;
96
101
  }
97
102
 
98
103
  export interface DownloadProgress {
@@ -24,13 +24,19 @@ class BaseVersioning {
24
24
  this.sortedReleaseHistory = Object.entries(releaseHistory).sort(
25
25
  ([a], [b]) => this.sortingMethod(a, b)
26
26
  );
27
+
28
+ /** @type {boolean} */
29
+ this.isLatestReleaseInRollout = true;
27
30
  }
28
31
 
29
32
  /**
30
33
  * @return {[ReleaseVersion, ReleaseInfo][]}
31
34
  */
32
35
  get sortedEnabledReleaseHistory() {
33
- return this.sortedReleaseHistory.filter(([_, bundle]) => bundle.enabled);
36
+ const list = this.sortedReleaseHistory.filter(([_, bundle]) => bundle.enabled)
37
+ // If the latest release is not a rollout target, exclude it from the list.
38
+ // - This also means that rollout deployment can only be applied to the latest release.
39
+ return this.isLatestReleaseInRollout ? list : list.slice(1);
34
40
  }
35
41
 
36
42
  /**
@@ -42,6 +48,11 @@ class BaseVersioning {
42
48
  );
43
49
  }
44
50
 
51
+ /** @param isLatestReleaseInRollout {boolean} */
52
+ setIsLatestReleaseInRollout(isLatestReleaseInRollout) {
53
+ this.isLatestReleaseInRollout = isLatestReleaseInRollout;
54
+ }
55
+
45
56
  /**
46
57
  * find latest release in releaseHistory
47
58
  * @return {[ReleaseVersion, ReleaseInfo]}
@@ -48,6 +48,36 @@ describe('Semver Versioning Test', () => {
48
48
  { enabled: true, mandatory: true, downloadUrl: 'R3', packageHash: 'P3' }
49
49
  ])
50
50
  })
51
+
52
+ it('should return the previous enabled release if the latest release is not a rollout target', () => {
53
+ const RELEASED_BUNDLES = {
54
+ '1.0.0': { enabled: true, mandatory: false, ...MOCK_INFOS },
55
+ '1.1.0': { enabled: false, mandatory: false, ...MOCK_INFOS }, // not enabled
56
+ '1.2.0': { enabled: true, mandatory: false, ...MOCK_INFOS, rollout: 30 },
57
+ };
58
+ const LATEST_IS_ROLLOUT_TARGET = false;
59
+
60
+ const versioning = new SemverVersioning(RELEASED_BUNDLES);
61
+ versioning.setIsLatestReleaseInRollout(LATEST_IS_ROLLOUT_TARGET);
62
+
63
+ const [latestVersion] = versioning.findLatestRelease();
64
+ expect(latestVersion).toEqual('1.0.0');
65
+ })
66
+
67
+ it('should return the latest release if the latest release is a rollout target', () => {
68
+ const RELEASED_BUNDLES = {
69
+ '1.0.0': { enabled: true, mandatory: false, ...MOCK_INFOS },
70
+ '1.1.0': { enabled: true, mandatory: false, ...MOCK_INFOS },
71
+ '1.2.0': { enabled: true, mandatory: false, ...MOCK_INFOS, rollout: 30 },
72
+ };
73
+ const LATEST_IS_ROLLOUT_TARGET = true;
74
+
75
+ const versioning = new SemverVersioning(RELEASED_BUNDLES);
76
+ versioning.setIsLatestReleaseInRollout(LATEST_IS_ROLLOUT_TARGET);
77
+
78
+ const [latestVersion] = versioning.findLatestRelease();
79
+ expect(latestVersion).toEqual('1.2.0');
80
+ })
51
81
  })
52
82
 
53
83
  describe('checkIsMandatory', () => {
@@ -159,6 +189,26 @@ describe('Semver Versioning Test', () => {
159
189
  expect(new SemverVersioning(RELEASED_BUNDLES).checkIsMandatory('1.1.1')).toBe(false);
160
190
  expect(new SemverVersioning(RELEASED_BUNDLES).checkIsMandatory('1.2.0')).toBe(true);
161
191
  });
192
+
193
+ it('When having not-enabled releases and latest mandatory version is not a rollout target', () => {
194
+ const RELEASED_BUNDLES = {
195
+ '1.0.0': FIRST_RELEASE_INFO,
196
+ '1.0.1': { enabled: true, mandatory: true, ...MOCK_INFOS },
197
+ '1.1.0': { enabled: false, mandatory: false, ...MOCK_INFOS },
198
+ '1.1.1': { enabled: true, mandatory: true, ...MOCK_INFOS },
199
+ '1.2.0': { enabled: true, mandatory: true, ...MOCK_INFOS, rollout: 30 },
200
+ };
201
+ const LATEST_IS_ROLLOUT_TARGET = false;
202
+
203
+ const versioning = new SemverVersioning(RELEASED_BUNDLES);
204
+ versioning.setIsLatestReleaseInRollout(LATEST_IS_ROLLOUT_TARGET);
205
+
206
+ expect(versioning.checkIsMandatory('1.0.0')).toBe(true); // update to 1.1.1
207
+ expect(versioning.checkIsMandatory('1.0.1')).toBe(true); // update to 1.1.1
208
+ expect(versioning.checkIsMandatory('1.1.0')).toBe(true); // update to 1.1.1
209
+ expect(versioning.checkIsMandatory('1.1.1')).toBe(false); // up to date
210
+ expect(versioning.checkIsMandatory('1.2.0')).toBe(true); // rollback target is treated as mandatory
211
+ })
162
212
  })
163
213
  })
164
214
 
@@ -201,5 +251,33 @@ describe('Semver Versioning Test', () => {
201
251
  expect(new SemverVersioning(RELEASED_BUNDLES_1).shouldRollbackToBinary('1.2.0')).toBe(false)
202
252
  expect(new SemverVersioning(RELEASED_BUNDLES_2).shouldRollbackToBinary('1.2.0-rc.2')).toBe(false)
203
253
  })
254
+
255
+ it ('should return true if the latest release is not a rollout target', () => {
256
+ const RELEASED_BUNDLES = {
257
+ '1.0.0': { enabled: true, mandatory: false, ...MOCK_INFOS },
258
+ '1.1.0': { enabled: true, mandatory: false, ...MOCK_INFOS, rollout: 30 },
259
+ };
260
+ const LATEST_IS_ROLLOUT_TARGET = false;
261
+
262
+ const versioning = new SemverVersioning(RELEASED_BUNDLES);
263
+ versioning.setIsLatestReleaseInRollout(LATEST_IS_ROLLOUT_TARGET);
264
+
265
+ // If the latest release was a rollout target and the rollout value is later reduced, the app might run on version 1.1.0.
266
+ expect(versioning.shouldRollbackToBinary('1.1.0')).toBe(true);
267
+ })
268
+
269
+ it ('should return false if the latest release and the previous release are both rollout targets', () => {
270
+ const RELEASED_BUNDLES = {
271
+ '1.0.0': { enabled: true, mandatory: false, ...MOCK_INFOS },
272
+ '1.1.0': { enabled: true, mandatory: false, ...MOCK_INFOS, rollout: 30 },
273
+ '1.2.0': { enabled: true, mandatory: false, ...MOCK_INFOS, rollout: 30 },
274
+ };
275
+ const LATEST_IS_ROLLOUT_TARGET = true;
276
+
277
+ const versioning = new SemverVersioning(RELEASED_BUNDLES);
278
+ versioning.setIsLatestReleaseInRollout(LATEST_IS_ROLLOUT_TARGET);
279
+
280
+ expect(versioning.shouldRollbackToBinary('1.0.0')).toBe(false); // update to 1.1.0
281
+ })
204
282
  })
205
283
  })