@bravemobile/react-native-code-push 13.1.0 → 13.3.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.
Files changed (31) hide show
  1. package/README.md +18 -1
  2. package/android/app/proguard-rules.pro +0 -7
  3. package/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java +3 -0
  4. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +25 -0
  5. package/cli/commands/createHistoryCommand/createReleaseHistory.test.ts +2 -2
  6. package/cli/commands/releaseCommand/addToReleaseHistory.ts +6 -0
  7. package/cli/commands/releaseCommand/index.test.ts +29 -0
  8. package/cli/commands/releaseCommand/index.ts +9 -0
  9. package/cli/commands/releaseCommand/release.test.ts +28 -0
  10. package/cli/commands/releaseCommand/release.ts +2 -0
  11. package/cli/commands/updateHistoryCommand/index.test.ts +129 -0
  12. package/cli/commands/updateHistoryCommand/index.ts +28 -2
  13. package/cli/commands/updateHistoryCommand/updateReleaseHistory.ts +3 -0
  14. package/cli/dist/commands/createHistoryCommand/createReleaseHistory.test.js +2 -2
  15. package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +5 -1
  16. package/cli/dist/commands/releaseCommand/index.js +7 -1
  17. package/cli/dist/commands/releaseCommand/index.test.js +22 -0
  18. package/cli/dist/commands/releaseCommand/release.js +2 -2
  19. package/cli/dist/commands/releaseCommand/release.test.js +18 -1
  20. package/cli/dist/commands/updateHistoryCommand/index.js +23 -2
  21. package/cli/dist/commands/updateHistoryCommand/index.test.js +102 -0
  22. package/cli/dist/commands/updateHistoryCommand/updateReleaseHistory.js +4 -1
  23. package/cli/dist/utils/fsUtils.js +35 -9
  24. package/cli/package.json +5 -1
  25. package/cli/utils/fsUtils.ts +43 -9
  26. package/ios/CodePush/CodePush.h +8 -3
  27. package/ios/CodePush/CodePush.mm +61 -1
  28. package/package.json +14 -6
  29. package/src/CodePush.js +16 -3
  30. package/src/CodePush.test.js +57 -0
  31. package/typings/react-native-code-push.d.ts +20 -0
package/README.md CHANGED
@@ -394,7 +394,18 @@ module.exports = Config;
394
394
  - Modifying an existing release history with the `update-history` command.
395
395
 
396
396
 
397
- **(2) For `code-push.config.ts` (TypeScript) to work properly, you may need to update your `tsconfig.json`.**
397
+ **(2) A `code-push.config.ts` (TypeScript) file needs a loader installed.**
398
+
399
+ Install `tsx`, which requires no further setup:
400
+
401
+ ```bash
402
+ npm install --save-dev tsx
403
+ ```
404
+
405
+ `ts-node` also keeps working, and is used when `tsx` is not installed. Note that `ts-node` is no
406
+ longer maintained, so support for it is **deprecated and will be removed in a future major
407
+ version** — please migrate to `tsx`. Until then, `ts-node` needs the following `tsconfig.json`
408
+ setup:
398
409
 
399
410
  ```diff
400
411
  {
@@ -414,6 +425,8 @@ module.exports = Config;
414
425
 
415
426
  ```
416
427
 
428
+ A `code-push.config.js` (JavaScript) file needs neither.
429
+
417
430
 
418
431
  ### 6. Diff Updates (Optional)
419
432
 
@@ -488,6 +501,8 @@ npx code-push release --framework expo --binary-version 1.0.0 --app-version 1.0.
488
501
  > `--app-version` should be greater than `--binary-version` (SemVer comparison).
489
502
 
490
503
  - `--rollout`: The rollout percentage for the update. (0~100, inclusive)
504
+ - `--minimum-background-duration`: The number of seconds the app must have been in the background before this update is applied on resume, for `ON_NEXT_RESUME` and `ON_NEXT_SUSPEND` installs only. (whole seconds, 0 or greater)
505
+ - The value set on the release takes precedence over the `minimumBackgroundDuration` passed to `sync`, and `0` applies the update on the next resume.
491
506
 
492
507
  #### `update-history`
493
508
 
@@ -496,6 +511,8 @@ Update the release history for a specific CodePush update.
496
511
  - Use the `--mandatory` option to make the update as mandatory or optional.
497
512
  - Use the `--rollout` option to change the rollout percentage of the update. (0~100, inclusive)
498
513
  - If the rollout percentage is reduced, users who fall outside the new target will have their rollout canceled and rollback to the previous latest version.
514
+ - Use the `--minimum-background-duration` option to change how many seconds the app must have been in the background before the update is applied on resume, for `ON_NEXT_RESUME` and `ON_NEXT_SUSPEND` installs only. (whole seconds, 0 or greater)
515
+ - It can be lowered after a release has gone out - setting it to `0`, for example, applies the update on the next resume instead of waiting.
499
516
 
500
517
  **Example:**
501
518
  - Rollback the CodePush update `1.0.1` (targeting the binary app version `1.0.0`).
@@ -21,10 +21,6 @@
21
21
  private final ** mBundleLoader;
22
22
  }
23
23
  # bridgeless
24
- -keepclassmembers class com.facebook.react.defaults.DefaultReactHostDelegate {
25
- private ** jsBundleLoader;
26
- }
27
- # bridgeless
28
24
  -keepclassmembers class * extends com.facebook.react.runtime.ReactHostDelegate {
29
25
  private ** jsBundleLoader;
30
26
  private ** _jsBundleLoader;
@@ -35,9 +31,6 @@
35
31
  private final ** reactHostDelegate; # RN 0.81+
36
32
  }
37
33
 
38
- # Can't find referenced class org.bouncycastle.**
39
- -dontwarn com.nimbusds.jose.**
40
-
41
34
  # The binary patch applier is found by name from the native library.
42
35
  -keepclasseswithmembernames class com.microsoft.codepush.react.HDiffPatchNative {
43
36
  native <methods>;
@@ -51,6 +51,9 @@ public class CodePush extends TurboReactPackage {
51
51
  return mServerUrl;
52
52
  }
53
53
 
54
+ /**
55
+ * Entry point used by React Native autolinking to create and retain the package instance.
56
+ */
54
57
  public static synchronized CodePush getInstance(Context context, boolean isDebugMode) {
55
58
  if (mCurrentInstance == null) {
56
59
  mCurrentInstance = new CodePush(context, isDebugMode);
@@ -59,6 +59,15 @@ public class CodePushNativeModule extends NativeCodePushSpec {
59
59
  private boolean _restartInProgress = false;
60
60
  private ArrayList<Boolean> _restartQueue = new ArrayList<>();
61
61
 
62
+ /**
63
+ * Whether the React Native instance this module was created for is still the one running.
64
+ *
65
+ * A reload replaces the instance while work this module started can still be in flight,
66
+ * and a frame callback posted to the process-wide choreographer outlives the executor
67
+ * this module shuts down. Read from whichever thread that work is on, so it is volatile.
68
+ */
69
+ private volatile boolean mGenerationAlive = true;
70
+
62
71
  public CodePushNativeModule(ReactApplicationContext reactContext, CodePush codePush, CodePushUpdateManager codePushUpdateManager, CodePushTelemetryManager codePushTelemetryManager, SettingsManager settingsManager) {
63
72
  super(reactContext);
64
73
 
@@ -85,6 +94,7 @@ public class CodePushNativeModule extends NativeCodePushSpec {
85
94
 
86
95
  @Override
87
96
  public void invalidate() {
97
+ mGenerationAlive = false;
88
98
  clearLifecycleEventListener();
89
99
  mBackgroundExecutor.shutdownNow();
90
100
  super.invalidate();
@@ -264,6 +274,14 @@ public class CodePushNativeModule extends NativeCodePushSpec {
264
274
  }
265
275
 
266
276
  private void emitDownloadProgressEvent(DownloadProgress downloadProgress) {
277
+ // Every progress event arrives here, including the ones a frame callback delivers
278
+ // after this module was invalidated: shutting the executor down does not cancel a
279
+ // callback already posted to the choreographer, and the context it would emit
280
+ // through has been destroyed by then.
281
+ if (!mGenerationAlive) {
282
+ return;
283
+ }
284
+
267
285
  if (mEventEmitterCallback != null) {
268
286
  emitOnDownloadProgress(downloadProgress.createWritableMap());
269
287
  return;
@@ -275,6 +293,13 @@ public class CodePushNativeModule extends NativeCodePushSpec {
275
293
  }
276
294
 
277
295
  private void restartAppInternal(boolean onlyIfUpdateIsPending) {
296
+ // A restart requested by an instance that has been replaced would reload the one
297
+ // that replaced it, which nobody asked for.
298
+ if (!mGenerationAlive) {
299
+ CodePushUtils.log("Ignoring a restart requested by a React Native instance that is no longer running");
300
+ return;
301
+ }
302
+
278
303
  if (this._restartInProgress) {
279
304
  CodePushUtils.log("Restart request queued until the current restart is completed");
280
305
  this._restartQueue.add(onlyIfUpdateIsPending);
@@ -96,8 +96,8 @@ describe("staging the release history a config is handed", () => {
96
96
  };
97
97
 
98
98
  await Promise.all([
99
- updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined),
100
- updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined),
99
+ updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined, undefined),
100
+ updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined, undefined),
101
101
  ]);
102
102
 
103
103
  expect(staged.ios).toContain("ios-url");
@@ -15,6 +15,7 @@ export async function addToReleaseHistory(
15
15
  enable: boolean,
16
16
  rollout: number | undefined,
17
17
  diffPackages: Record<string, string> | undefined,
18
+ minimumBackgroundDuration: number | undefined,
18
19
  ): Promise<void> {
19
20
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
20
21
 
@@ -49,6 +50,11 @@ export async function addToReleaseHistory(
49
50
  newReleaseHistory[appVersion].rollout = rollout;
50
51
  }
51
52
 
53
+ // An entry without it leaves the wait to the sync option, so 0 has to be written.
54
+ if (typeof minimumBackgroundDuration === 'number') {
55
+ newReleaseHistory[appVersion].minimumBackgroundDuration = minimumBackgroundDuration;
56
+ }
57
+
52
58
  try {
53
59
  await stageReleaseHistoryFile(binaryVersion, newReleaseHistory, platform, (jsonFilePath) =>
54
60
  setReleaseHistory(binaryVersion, jsonFilePath, newReleaseHistory, platform, identifier));
@@ -32,6 +32,7 @@ const ARG_INDEX = {
32
32
  onOversizedPatch: 20,
33
33
  bundleDownloader: 21,
34
34
  diffBaseCount: 22,
35
+ minimumBackgroundDuration: 23,
35
36
  } as const;
36
37
 
37
38
  /**
@@ -147,4 +148,32 @@ describe("release command options", () => {
147
148
  const { release } = await import("./release.js");
148
149
  expect(jest.mocked(release)).not.toHaveBeenCalled();
149
150
  });
151
+
152
+ it("passes the chosen minimum background duration through to the release", async () => {
153
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', '600']);
154
+
155
+ expect(args[ARG_INDEX.minimumBackgroundDuration]).toBe(600);
156
+ });
157
+
158
+ it("leaves the minimum background duration unset when the option is not given, so the sync option decides", async () => {
159
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']);
160
+
161
+ expect(args[ARG_INDEX.minimumBackgroundDuration]).toBeUndefined();
162
+ });
163
+
164
+ it.each([
165
+ ['is negative', '-1'],
166
+ ['is not a number at all', 'soon'],
167
+ ])("rejects a minimum background duration that %s", async (_caseName, value) => {
168
+ jest.spyOn(console, 'error').mockImplementation(() => {});
169
+ jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
170
+ throw new Error(`process.exit(${code})`);
171
+ }) as never);
172
+
173
+ await expect(parseReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', value]))
174
+ .rejects.toThrow('process.exit(1)');
175
+
176
+ const { release } = await import("./release.js");
177
+ expect(jest.mocked(release)).not.toHaveBeenCalled();
178
+ });
150
179
  });
@@ -31,6 +31,7 @@ type Options = {
31
31
  binaryBundlePath?: string;
32
32
  onOversizedPatch: OversizedPatchPolicy;
33
33
  diffBaseCount: number;
34
+ minimumBackgroundDuration?: number;
34
35
  }
35
36
 
36
37
  program.command('release')
@@ -57,6 +58,7 @@ program.command('release')
57
58
  .choices(OVERSIZED_PATCH_POLICIES)
58
59
  .default(DEFAULT_OVERSIZED_PATCH_POLICY))
59
60
  .option('--diff-base-count <number>', 'how many recent releases to build asset diff archives against (0 disables). Requires `bundleDownloader` in the config file.', parseDecimalInt, DEFAULT_DIFF_BASE_COUNT)
61
+ .option('--minimum-background-duration <seconds>', 'seconds the app must have been in the background before this update is applied on resume. Overrides the minimumBackgroundDuration sync option.', parseDecimalInt)
60
62
  .action(async (options: Options) => {
61
63
  const config = findAndReadConfigFile(process.cwd(), options.config);
62
64
 
@@ -70,6 +72,12 @@ program.command('release')
70
72
  process.exit(1);
71
73
  }
72
74
 
75
+ if (options.minimumBackgroundDuration !== undefined
76
+ && (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) {
77
+ console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.');
78
+ process.exit(1);
79
+ }
80
+
73
81
  if (options.hashCalc && !options.skipBundle) {
74
82
  console.error('--hash-calc option can be used only when --skip-bundle is set to true.');
75
83
  process.exit(1);
@@ -102,6 +110,7 @@ program.command('release')
102
110
  options.onOversizedPatch,
103
111
  config.bundleDownloader,
104
112
  options.diffBaseCount,
113
+ options.minimumBackgroundDuration,
105
114
  )
106
115
 
107
116
  console.log('🚀 Release completed.')
@@ -142,6 +142,7 @@ type ReleaseOverrides = {
142
142
  releaseHistory?: ReleaseHistoryInterface;
143
143
  bundleDownloader?: CliConfigInterface['bundleDownloader'];
144
144
  diffBaseCount?: number;
145
+ minimumBackgroundDuration?: number;
145
146
  };
146
147
 
147
148
  async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}) {
@@ -176,6 +177,7 @@ async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}
176
177
  overrides.onOversizedPatch,
177
178
  overrides.bundleDownloader,
178
179
  overrides.diffBaseCount,
180
+ overrides.minimumBackgroundDuration,
179
181
  );
180
182
 
181
183
  return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved };
@@ -829,3 +831,29 @@ describe("release with asset diff bases", () => {
829
831
  expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
830
832
  });
831
833
  });
834
+
835
+ describe("release --minimum-background-duration", () => {
836
+ it("records the background wait on the release it publishes", async () => {
837
+ const staged = await stageBundleOutput("minimum-background-duration");
838
+
839
+ const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 600 });
840
+
841
+ expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(600);
842
+ });
843
+
844
+ it("releases a background wait of zero seconds as zero, not as an unset option", async () => {
845
+ const staged = await stageBundleOutput("zero-minimum-background-duration");
846
+
847
+ const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 0 });
848
+
849
+ expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
850
+ });
851
+
852
+ it("leaves the release saying nothing about the background wait when the option is not given", async () => {
853
+ const staged = await stageBundleOutput("no-minimum-background-duration");
854
+
855
+ const { releaseHistories } = await runRelease(staged);
856
+
857
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
858
+ });
859
+ });
@@ -49,6 +49,7 @@ export async function release(
49
49
  onOversizedPatch: OversizedPatchPolicy = DEFAULT_OVERSIZED_PATCH_POLICY,
50
50
  bundleDownloader?: CliConfigInterface['bundleDownloader'],
51
51
  diffBaseCount: number = DEFAULT_DIFF_BASE_COUNT,
52
+ minimumBackgroundDuration?: number,
52
53
  ): Promise<void> {
53
54
  if (baseBundlePath) {
54
55
  // Checked before the bundler runs, so the wrong base bundle costs a second rather
@@ -167,6 +168,7 @@ export async function release(
167
168
  enable,
168
169
  rollout,
169
170
  Object.keys(diffPackages).length > 0 ? diffPackages : undefined,
171
+ minimumBackgroundDuration,
170
172
  )
171
173
 
172
174
  if (!skipCleanup) {
@@ -0,0 +1,129 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
4
+ import type { CliConfigInterface, ReleaseHistoryInterface } from "../../../typings/react-native-code-push.d.ts";
5
+
6
+ /**
7
+ * Checks the command definition against the entry it saves. Everything this command does
8
+ * ends up in the release history the config is handed, so an option that never reaches it
9
+ * leaves the release exactly as it was - which the command still reports as a success.
10
+ */
11
+
12
+ const BINARY_VERSION = '1.0.0';
13
+ const APP_VERSION = '1.0.1';
14
+
15
+ let mockConfig: CliConfigInterface;
16
+
17
+ jest.mock("../../utils/fsUtils.js", () => ({
18
+ findAndReadConfigFile: () => mockConfig,
19
+ }));
20
+
21
+ /**
22
+ * Puts one released version in the config's history and records every history it is
23
+ * handed back, so a case can read the entry as the consumer would store it.
24
+ */
25
+ function stageReleaseHistory(): ReleaseHistoryInterface[] {
26
+ const releaseHistory: ReleaseHistoryInterface = {
27
+ [APP_VERSION]: {
28
+ enabled: true,
29
+ mandatory: false,
30
+ downloadUrl: 'https://cdn.example.com/bundle',
31
+ packageHash: 'a3f1c0',
32
+ },
33
+ };
34
+ const saved: ReleaseHistoryInterface[] = [];
35
+
36
+ mockConfig = {
37
+ bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }),
38
+ getReleaseHistory: async () => releaseHistory,
39
+ // The command edits the history in place, so what it saved is copied out here.
40
+ setReleaseHistory: async (_binaryVersion, _jsonFilePath, releaseInfo) => {
41
+ saved.push(structuredClone(releaseInfo));
42
+ },
43
+ };
44
+
45
+ return saved;
46
+ }
47
+
48
+ /**
49
+ * Parses an `update-history` invocation against the real command definition. Commander is
50
+ * asked to throw instead of exiting, and to keep its diagnostics to itself, so a rejected
51
+ * option can be asserted on without ending the worker or the output.
52
+ */
53
+ async function parseUpdateHistoryCommand(args: string[]): Promise<void> {
54
+ const { program } = await import("commander");
55
+ await import("./index.js");
56
+
57
+ const updateHistoryCommand = program.commands.find((command) => command.name() === 'update-history');
58
+ updateHistoryCommand?.exitOverride();
59
+ updateHistoryCommand?.configureOutput({ writeErr: () => {} });
60
+
61
+ await program.parseAsync(['update-history', ...args], { from: 'user' });
62
+ }
63
+
64
+ async function runUpdateHistoryCommand(args: string[]): Promise<void> {
65
+ await parseUpdateHistoryCommand(['-b', BINARY_VERSION, '-v', APP_VERSION, ...args]);
66
+ }
67
+
68
+ let saved: ReleaseHistoryInterface[];
69
+
70
+ beforeEach(() => {
71
+ jest.resetModules();
72
+ saved = stageReleaseHistory();
73
+ jest.spyOn(console, 'log').mockImplementation(() => {});
74
+ jest.spyOn(console, 'error').mockImplementation(() => {});
75
+ jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
76
+ throw new Error(`process.exit(${code})`);
77
+ }) as never);
78
+ });
79
+
80
+ afterEach(() => {
81
+ jest.restoreAllMocks();
82
+ // The command writes its JSON under the directory it was invoked in.
83
+ fs.rmSync(path.resolve(process.cwd(), "codepush-release-history"), { recursive: true, force: true });
84
+ });
85
+
86
+ describe("update-history command options", () => {
87
+ it("lowers the background wait of a release that is already out to zero seconds", async () => {
88
+ await runUpdateHistoryCommand(['--minimum-background-duration', '0']);
89
+
90
+ expect(saved).toHaveLength(1);
91
+ expect(saved[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
92
+ });
93
+
94
+ it("leaves the entry saying nothing about the background wait when only --enable is given", async () => {
95
+ await runUpdateHistoryCommand(['--enable', 'false']);
96
+
97
+ expect(saved[0][APP_VERSION].enabled).toBe(false);
98
+ expect(saved[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
99
+ });
100
+
101
+ it("saves the rollout percentage when --rollout is the only option given", async () => {
102
+ await runUpdateHistoryCommand(['--rollout', '50']);
103
+
104
+ expect(saved[0][APP_VERSION].rollout).toBe(50);
105
+ });
106
+
107
+ it("exits without saving anything when no option says what to change", async () => {
108
+ await expect(runUpdateHistoryCommand([])).rejects.toThrow('process.exit(1)');
109
+
110
+ expect(saved).toHaveLength(0);
111
+ });
112
+
113
+ it("rejects a negative background wait and saves nothing", async () => {
114
+ await expect(runUpdateHistoryCommand(['--minimum-background-duration', '-1']))
115
+ .rejects.toThrow('process.exit(1)');
116
+
117
+ expect(saved).toHaveLength(0);
118
+ });
119
+
120
+ it.each([
121
+ ['a percentage above 100', '150'],
122
+ ['a percentage that is not a number', 'abc'],
123
+ ])("rejects %s and saves nothing", async (_scenario, rollout) => {
124
+ await expect(runUpdateHistoryCommand(['--rollout', rollout]))
125
+ .rejects.toThrow('process.exit(1)');
126
+
127
+ expect(saved).toHaveLength(0);
128
+ });
129
+ });
@@ -12,6 +12,7 @@ type Options = {
12
12
  mandatory?: boolean;
13
13
  enable?: boolean;
14
14
  rollout?: number;
15
+ minimumBackgroundDuration?: number;
15
16
  }
16
17
 
17
18
  program.command('update-history')
@@ -24,14 +25,32 @@ program.command('update-history')
24
25
  .option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, undefined)
25
26
  .option('-e, --enable <bool>', 'make the release to be enabled', parseBoolean, undefined)
26
27
  .option('--rollout <number>', 'rollout percentage (0-100)', parseFloat, undefined)
28
+ .option('--minimum-background-duration <seconds>', 'seconds the app must have been in the background before this update is applied on resume. Overrides the minimumBackgroundDuration sync option.', parseDecimalInt, undefined)
27
29
  .action(async (options: Options) => {
28
30
  const config = findAndReadConfigFile(process.cwd(), options.config);
29
31
 
30
- if (typeof options.mandatory !== "boolean" && typeof options.enable !== "boolean") {
32
+ if (typeof options.mandatory !== "boolean"
33
+ && typeof options.enable !== "boolean"
34
+ && typeof options.rollout !== "number"
35
+ && typeof options.minimumBackgroundDuration !== "number") {
31
36
  console.error('No options specified. Exiting the program.')
32
37
  process.exit(1)
33
38
  }
34
39
 
40
+ // `Number.isFinite` is what rejects a non-numeric `--rollout`: `parseFloat` turns it into
41
+ // NaN, and both `NaN < 0` and `NaN > 100` are false.
42
+ if (options.rollout !== undefined
43
+ && (!Number.isFinite(options.rollout) || options.rollout < 0 || options.rollout > 100)) {
44
+ console.error('Rollout percentage number must be between 0 and 100 (inclusive).');
45
+ process.exit(1);
46
+ }
47
+
48
+ if (options.minimumBackgroundDuration !== undefined
49
+ && (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) {
50
+ console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.');
51
+ process.exit(1);
52
+ }
53
+
35
54
  await updateReleaseHistory(
36
55
  options.appVersion,
37
56
  options.binaryVersion,
@@ -41,10 +60,17 @@ program.command('update-history')
41
60
  options.identifier,
42
61
  options.mandatory,
43
62
  options.enable,
44
- options.rollout
63
+ options.rollout,
64
+ options.minimumBackgroundDuration
45
65
  )
46
66
  });
47
67
 
68
+ // Not `parseInt` itself: commander hands a coercion function the current value as its
69
+ // second argument, which `parseInt` reads as the radix.
70
+ function parseDecimalInt(value: string): number {
71
+ return parseInt(value, 10);
72
+ }
73
+
48
74
  function parseBoolean(value: string) {
49
75
  if (value === 'true') return true;
50
76
  if (value === 'false') return false;
@@ -11,6 +11,7 @@ export async function updateReleaseHistory(
11
11
  mandatory: boolean | undefined,
12
12
  enable: boolean | undefined,
13
13
  rollout: number | undefined,
14
+ minimumBackgroundDuration: number | undefined,
14
15
  ): Promise<void> {
15
16
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
16
17
 
@@ -20,6 +21,8 @@ export async function updateReleaseHistory(
20
21
  if (typeof mandatory === "boolean") updateInfo.mandatory = mandatory;
21
22
  if (typeof enable === "boolean") updateInfo.enabled = enable;
22
23
  if (typeof rollout === "number") updateInfo.rollout = rollout;
24
+ // 0 is what lowers the wait to "apply on the next resume", so truthiness cannot decide here.
25
+ if (typeof minimumBackgroundDuration === "number") updateInfo.minimumBackgroundDuration = minimumBackgroundDuration;
23
26
 
24
27
  try {
25
28
  await stageReleaseHistoryFile(binaryVersion, releaseHistory, platform, (jsonFilePath) =>
@@ -68,8 +68,8 @@ describe("staging the release history a config is handed", () => {
68
68
  staged[platform] = fs.readFileSync(jsonFilePath, "utf8");
69
69
  };
70
70
  await Promise.all([
71
- updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined),
72
- updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined),
71
+ updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined, undefined),
72
+ updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined, undefined),
73
73
  ]);
74
74
  expect(staged.ios).toContain("ios-url");
75
75
  expect(staged.ios).not.toContain("android-url");
@@ -1,5 +1,5 @@
1
1
  import { stageReleaseHistoryFile } from "../../functions/stageReleaseHistoryFile.js";
2
- export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownloadUrl, binaryPatchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, diffPackages) {
2
+ export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownloadUrl, binaryPatchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, diffPackages, minimumBackgroundDuration) {
3
3
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
4
4
  const updateInfo = releaseHistory[appVersion];
5
5
  if (updateInfo) {
@@ -26,6 +26,10 @@ export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownl
26
26
  if (typeof rollout === 'number') {
27
27
  newReleaseHistory[appVersion].rollout = rollout;
28
28
  }
29
+ // An entry without it leaves the wait to the sync option, so 0 has to be written.
30
+ if (typeof minimumBackgroundDuration === 'number') {
31
+ newReleaseHistory[appVersion].minimumBackgroundDuration = minimumBackgroundDuration;
32
+ }
29
33
  try {
30
34
  await stageReleaseHistoryFile(binaryVersion, newReleaseHistory, platform, (jsonFilePath) => setReleaseHistory(binaryVersion, jsonFilePath, newReleaseHistory, platform, identifier));
31
35
  }
@@ -27,6 +27,7 @@ program.command('release')
27
27
  .choices(OVERSIZED_PATCH_POLICIES)
28
28
  .default(DEFAULT_OVERSIZED_PATCH_POLICY))
29
29
  .option('--diff-base-count <number>', 'how many recent releases to build asset diff archives against (0 disables). Requires `bundleDownloader` in the config file.', parseDecimalInt, DEFAULT_DIFF_BASE_COUNT)
30
+ .option('--minimum-background-duration <seconds>', 'seconds the app must have been in the background before this update is applied on resume. Overrides the minimumBackgroundDuration sync option.', parseDecimalInt)
30
31
  .action(async (options) => {
31
32
  const config = findAndReadConfigFile(process.cwd(), options.config);
32
33
  if (typeof options.rollout === 'number' && (options.rollout < 0 || options.rollout > 100)) {
@@ -37,13 +38,18 @@ program.command('release')
37
38
  console.error('--diff-base-count must be a whole number of releases, 0 or greater.');
38
39
  process.exit(1);
39
40
  }
41
+ if (options.minimumBackgroundDuration !== undefined
42
+ && (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) {
43
+ console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.');
44
+ process.exit(1);
45
+ }
40
46
  if (options.hashCalc && !options.skipBundle) {
41
47
  console.error('--hash-calc option can be used only when --skip-bundle is set to true.');
42
48
  process.exit(1);
43
49
  }
44
50
  // Resolved before bundling so a wrong path fails now rather than after a build.
45
51
  const baseBundlePath = resolveBinaryBundlePathOption(options.binaryBundlePath);
46
- await release(config.bundleUploader, config.getReleaseHistory, config.setReleaseHistory, options.binaryVersion, options.appVersion, options.framework, options.platform, options.identifier, options.outputPath, options.entryFile, options.jsBundleName, options.mandatory, options.enable, options.rollout, options.skipBundle, options.skipCleanup, `${options.outputPath}/${options.outputBundleDir}`, options.outputMetroDir, options.hashCalc, baseBundlePath, options.onOversizedPatch, config.bundleDownloader, options.diffBaseCount);
52
+ await release(config.bundleUploader, config.getReleaseHistory, config.setReleaseHistory, options.binaryVersion, options.appVersion, options.framework, options.platform, options.identifier, options.outputPath, options.entryFile, options.jsBundleName, options.mandatory, options.enable, options.rollout, options.skipBundle, options.skipCleanup, `${options.outputPath}/${options.outputBundleDir}`, options.outputMetroDir, options.hashCalc, baseBundlePath, options.onOversizedPatch, config.bundleDownloader, options.diffBaseCount, options.minimumBackgroundDuration);
47
53
  console.log('🚀 Release completed.');
48
54
  });
49
55
  // Not `parseInt` itself: commander hands a coercion function the current value as its
@@ -29,6 +29,7 @@ const ARG_INDEX = {
29
29
  onOversizedPatch: 20,
30
30
  bundleDownloader: 21,
31
31
  diffBaseCount: 22,
32
+ minimumBackgroundDuration: 23,
32
33
  };
33
34
  /**
34
35
  * Parses a `release` invocation against the real command definition. Commander is asked
@@ -118,4 +119,25 @@ describe("release command options", () => {
118
119
  const { release } = await import("./release.js");
119
120
  expect(jest.mocked(release)).not.toHaveBeenCalled();
120
121
  });
122
+ it("passes the chosen minimum background duration through to the release", async () => {
123
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', '600']);
124
+ expect(args[ARG_INDEX.minimumBackgroundDuration]).toBe(600);
125
+ });
126
+ it("leaves the minimum background duration unset when the option is not given, so the sync option decides", async () => {
127
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']);
128
+ expect(args[ARG_INDEX.minimumBackgroundDuration]).toBeUndefined();
129
+ });
130
+ it.each([
131
+ ['is negative', '-1'],
132
+ ['is not a number at all', 'soon'],
133
+ ])("rejects a minimum background duration that %s", async (_caseName, value) => {
134
+ jest.spyOn(console, 'error').mockImplementation(() => { });
135
+ jest.spyOn(process, 'exit').mockImplementation(((code) => {
136
+ throw new Error(`process.exit(${code})`);
137
+ }));
138
+ await expect(parseReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', value]))
139
+ .rejects.toThrow('process.exit(1)');
140
+ const { release } = await import("./release.js");
141
+ expect(jest.mocked(release)).not.toHaveBeenCalled();
142
+ });
121
143
  });
@@ -9,7 +9,7 @@ import { resolveAssetDiffBases } from "../../functions/resolveAssetDiffBases.js"
9
9
  import { BINARY_PATCH_ARCHIVE_SUFFIX, BINARY_PATCH_BASE_RECORD_NAME, DEFAULT_OVERSIZED_PATCH_POLICY, extractCodePushBundleContents, formatBinaryPatchSummary, hashBundleFile, isPatchArchiveOversized, makeBinaryPatchBundle, readBinaryPatchBaseRecord, } from "../../functions/makeBinaryPatchBundle.js";
10
10
  /** How many of the most recent releases a release builds asset diff archives against. */
11
11
  export const DEFAULT_DIFF_BASE_COUNT = 3;
12
- export async function release(bundleUploader, getReleaseHistory, setReleaseHistory, binaryVersion, appVersion, framework, platform, identifier, outputPath, entryFile, jsBundleName, mandatory, enable, rollout, skipBundle, skipCleanup, bundleDirectory, outputMetroDir, hashCalc, baseBundlePath, onOversizedPatch = DEFAULT_OVERSIZED_PATCH_POLICY, bundleDownloader, diffBaseCount = DEFAULT_DIFF_BASE_COUNT) {
12
+ export async function release(bundleUploader, getReleaseHistory, setReleaseHistory, binaryVersion, appVersion, framework, platform, identifier, outputPath, entryFile, jsBundleName, mandatory, enable, rollout, skipBundle, skipCleanup, bundleDirectory, outputMetroDir, hashCalc, baseBundlePath, onOversizedPatch = DEFAULT_OVERSIZED_PATCH_POLICY, bundleDownloader, diffBaseCount = DEFAULT_DIFF_BASE_COUNT, minimumBackgroundDuration) {
13
13
  if (baseBundlePath) {
14
14
  // Checked before the bundler runs, so the wrong base bundle costs a second rather
15
15
  // than a full build.
@@ -92,7 +92,7 @@ export async function release(bundleUploader, getReleaseHistory, setReleaseHisto
92
92
  `(${error instanceof Error ? error.message : String(error)}).`);
93
93
  }
94
94
  }
95
- await addToReleaseHistory(appVersion, binaryVersion, downloadUrl, patchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, Object.keys(diffPackages).length > 0 ? diffPackages : undefined);
95
+ await addToReleaseHistory(appVersion, binaryVersion, downloadUrl, patchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, Object.keys(diffPackages).length > 0 ? diffPackages : undefined, minimumBackgroundDuration);
96
96
  if (!skipCleanup) {
97
97
  cleanUpOutputs(outputPath);
98
98
  }