Skip to content

Commit 58f514b

Browse files
authored
feat: let a release history entry set its own minimumBackgroundDuration (#199)
## Summary Apps using this fork hardcode the wait in `CodePush.sync({ installMode: ON_NEXT_RESUME, minimumBackgroundDuration: 3600 })`, so every update waits the same hour. This lets each release decide for itself. - A release history entry (`ReleaseInfo`) can carry `minimumBackgroundDuration` (seconds). When present it takes precedence over the `sync` option; when absent the `sync` option applies as before. `0` is a valid value and means "on the next resume", so the value is resolved with `??` and `typeof === 'number'` checks rather than truthiness. - The value travels the existing path: release history → `UpdateCheckResponse.minimum_background_duration` → `RemotePackage.minimumBackgroundDuration` → `localPackage.install(installMode, minimumBackgroundDuration)`. The deprecated `updateChecker` path goes through the same mapper, so it works there too. Native code is untouched. - CLI: `release` and `update-history` gain `--minimum-background-duration <seconds>` (whole number, 0 or greater). `update-history` is how a team lowers the wait to `0` after a release has gone out. - `update-history` used to exit with "No options specified" when `--rollout` was the only option. Now `--rollout` and the new option both count. Since `--rollout` can reach the write path on its own, the command validates it the same way `release` does, plus a finiteness check so `--rollout abc` cannot write `null` into the history. - Docs: root `README.md`, `docs/api-js.md`, and the CLI reference in `cli/README.md` / `cli/README.ko.md`. ## Verification - `npx jest --watchman=false src/CodePush.test.js`: 32 tests, including three that assert the value the native bridge receives for a history value, no history value, and a history value of `0` - `npm run --workspace cli test`: 12 suites, 116 tests - `npm run typecheck` and ESLint on changed files - E2E (`npm run e2e -- --app RN0840 --platform both`, iPhone 17 simulator + Galaxy S23 over USB): all phases pass on both platforms, first try. Two new phase-4 scenarios cover the feature end to end: `05` releases with `--minimum-background-duration 0` while the app's `sync` asks for 20 seconds and the update is applied on the first resume; `06` releases with `20` while `sync` asks for no wait, and the update is not applied after a 2 second background but is after 20 seconds. The runner also asserts the served history entry carries the value. ## Follow-up `release --rollout abc` still passes `NaN` through its range check (pre-existing). Aligning it with the `update-history` check is a one-line change, left out of this PR.
1 parent 569424c commit 58f514b

24 files changed

Lines changed: 509 additions & 18 deletions

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,8 @@ npx code-push release --framework expo --binary-version 1.0.0 --app-version 1.0.
501501
> `--app-version` should be greater than `--binary-version` (SemVer comparison).
502502
503503
- `--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.
504506

505507
#### `update-history`
506508

@@ -509,6 +511,8 @@ Update the release history for a specific CodePush update.
509511
- Use the `--mandatory` option to make the update as mandatory or optional.
510512
- Use the `--rollout` option to change the rollout percentage of the update. (0~100, inclusive)
511513
- 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.
512516

513517
**Example:**
514518
- Rollback the CodePush update `1.0.1` (targeting the binary app version `1.0.0`).

‎cli/README.ko.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ npx code-push release [options]
111111
| `-m, --mandatory <bool>` | 필수 업데이트로 설정 | `false` |
112112
| `--enable <bool>` | 릴리스 활성화 여부 | `true` |
113113
| `--rollout <number>` | 롤아웃 비율 (0–100) | — |
114+
| `--minimum-background-duration <seconds>` | 이 업데이트가 적용되기 전까지 앱이 백그라운드에 머물러야 하는 시간(초). `ON_NEXT_RESUME`, `ON_NEXT_SUSPEND` 설치에만 적용되며 sync 옵션의 `minimumBackgroundDuration`보다 우선합니다. `0`이면 다음 포그라운드 진입 때 바로 적용합니다 | — |
114115
| `--skip-bundle <bool>` | 번들 단계 건너뛰기 (기존 번들 사용) | `false` |
115116
| `--hash-calc <bool>` | 기존 번들에서 해시 계산 (`--skip-bundle true` 필요) | — |
116117
| `--skip-cleanup <bool>` | 출력 디렉토리 정리 건너뛰기 | `false` |
@@ -300,8 +301,9 @@ npx code-push update-history [options]
300301
| `-m, --mandatory <bool>` | 필수 업데이트 플래그 설정 | — |
301302
| `-e, --enable <bool>` | 릴리스 활성화 또는 비활성화 | — |
302303
| `--rollout <number>` | 롤아웃 비율 (0–100) | — |
304+
| `--minimum-background-duration <seconds>` | 이 업데이트가 적용되기 전까지 앱이 백그라운드에 머물러야 하는 시간(초). `ON_NEXT_RESUME`, `ON_NEXT_SUSPEND` 설치에만 적용되며 sync 옵션의 `minimumBackgroundDuration`보다 우선합니다. `0`이면 다음 포그라운드 진입 때 바로 적용합니다 | — |
303305

304-
`--mandatory`, `--enable`, `--rollout` 중 하나 이상을 반드시 지정해야 합니다.
306+
`--mandatory`, `--enable`, `--rollout`, `--minimum-background-duration` 중 하나 이상을 반드시 지정해야 합니다.
305307

306308
**예시:**
307309

@@ -353,7 +355,8 @@ npx code-push show-history -b 1.0.0 -p ios
353355
"mandatory": false,
354356
"downloadUrl": "https://storage.example.com/bundles/ios/staging/a1b2c3...",
355357
"packageHash": "a1b2c3...",
356-
"rollout": 100
358+
"rollout": 100,
359+
"minimumBackgroundDuration": 600
357360
},
358361
"1.0.2": {
359362
"enabled": true,

‎cli/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ npx code-push release [options]
109109
| `-m, --mandatory <bool>` | Make the release mandatory | `false` |
110110
| `--enable <bool>` | Enable the release | `true` |
111111
| `--rollout <number>` | Rollout percentage (0-100) | — |
112+
| `--minimum-background-duration <seconds>` | Seconds the app must have been in the background before this update is applied on resume (`ON_NEXT_RESUME` / `ON_NEXT_SUSPEND` installs only). Overrides the `minimumBackgroundDuration` sync option; `0` applies it on the next resume | — |
112113
| `--skip-bundle <bool>` | Skip bundle step (use existing bundle) | `false` |
113114
| `--hash-calc <bool>` | Calculate hash from existing bundle (requires `--skip-bundle true`) | — |
114115
| `--skip-cleanup <bool>` | Skip output directory cleanup | `false` |
@@ -302,8 +303,9 @@ npx code-push update-history [options]
302303
| `-m, --mandatory <bool>` | Set mandatory flag | — |
303304
| `-e, --enable <bool>` | Enable or disable the release | — |
304305
| `--rollout <number>` | Rollout percentage (0-100) | — |
306+
| `--minimum-background-duration <seconds>` | Seconds the app must have been in the background before this update is applied on resume (`ON_NEXT_RESUME` / `ON_NEXT_SUSPEND` installs only). Overrides the `minimumBackgroundDuration` sync option; `0` applies it on the next resume | — |
305307

306-
You must pass at least one of `--mandatory`, `--enable`, or `--rollout`.
308+
You must pass at least one of `--mandatory`, `--enable`, `--rollout`, or `--minimum-background-duration`.
307309

308310
```bash
309311
# Disable a release
@@ -351,7 +353,8 @@ The release history is a JSON object keyed by app version. For example, the hist
351353
"mandatory": false,
352354
"downloadUrl": "https://storage.example.com/bundles/ios/staging/a1b2c3...",
353355
"packageHash": "a1b2c3...",
354-
"rollout": 100
356+
"rollout": 100,
357+
"minimumBackgroundDuration": 600
355358
},
356359
"1.0.2": {
357360
"enabled": true,

‎cli/commands/createHistoryCommand/createReleaseHistory.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ describe("staging the release history a config is handed", () => {
9696
};
9797

9898
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),
101101
]);
102102

103103
expect(staged.ios).toContain("ios-url");

‎cli/commands/releaseCommand/addToReleaseHistory.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export async function addToReleaseHistory(
1515
enable: boolean,
1616
rollout: number | undefined,
1717
diffPackages: Record<string, string> | undefined,
18+
minimumBackgroundDuration: number | undefined,
1819
): Promise<void> {
1920
const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
2021

@@ -49,6 +50,11 @@ export async function addToReleaseHistory(
4950
newReleaseHistory[appVersion].rollout = rollout;
5051
}
5152

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+
5258
try {
5359
await stageReleaseHistoryFile(binaryVersion, newReleaseHistory, platform, (jsonFilePath) =>
5460
setReleaseHistory(binaryVersion, jsonFilePath, newReleaseHistory, platform, identifier));

‎cli/commands/releaseCommand/index.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const ARG_INDEX = {
3232
onOversizedPatch: 20,
3333
bundleDownloader: 21,
3434
diffBaseCount: 22,
35+
minimumBackgroundDuration: 23,
3536
} as const;
3637

3738
/**
@@ -147,4 +148,32 @@ describe("release command options", () => {
147148
const { release } = await import("./release.js");
148149
expect(jest.mocked(release)).not.toHaveBeenCalled();
149150
});
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+
});
150179
});

‎cli/commands/releaseCommand/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type Options = {
3131
binaryBundlePath?: string;
3232
onOversizedPatch: OversizedPatchPolicy;
3333
diffBaseCount: number;
34+
minimumBackgroundDuration?: number;
3435
}
3536

3637
program.command('release')
@@ -57,6 +58,7 @@ program.command('release')
5758
.choices(OVERSIZED_PATCH_POLICIES)
5859
.default(DEFAULT_OVERSIZED_PATCH_POLICY))
5960
.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)
6062
.action(async (options: Options) => {
6163
const config = findAndReadConfigFile(process.cwd(), options.config);
6264

@@ -70,6 +72,12 @@ program.command('release')
7072
process.exit(1);
7173
}
7274

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+
7381
if (options.hashCalc && !options.skipBundle) {
7482
console.error('--hash-calc option can be used only when --skip-bundle is set to true.');
7583
process.exit(1);
@@ -102,6 +110,7 @@ program.command('release')
102110
options.onOversizedPatch,
103111
config.bundleDownloader,
104112
options.diffBaseCount,
113+
options.minimumBackgroundDuration,
105114
)
106115

107116
console.log('🚀 Release completed.')

‎cli/commands/releaseCommand/release.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ type ReleaseOverrides = {
142142
releaseHistory?: ReleaseHistoryInterface;
143143
bundleDownloader?: CliConfigInterface['bundleDownloader'];
144144
diffBaseCount?: number;
145+
minimumBackgroundDuration?: number;
145146
};
146147

147148
async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}) {
@@ -176,6 +177,7 @@ async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}
176177
overrides.onOversizedPatch,
177178
overrides.bundleDownloader,
178179
overrides.diffBaseCount,
180+
overrides.minimumBackgroundDuration,
179181
);
180182

181183
return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved };
@@ -829,3 +831,29 @@ describe("release with asset diff bases", () => {
829831
expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
830832
});
831833
});
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+
});

‎cli/commands/releaseCommand/release.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export async function release(
4949
onOversizedPatch: OversizedPatchPolicy = DEFAULT_OVERSIZED_PATCH_POLICY,
5050
bundleDownloader?: CliConfigInterface['bundleDownloader'],
5151
diffBaseCount: number = DEFAULT_DIFF_BASE_COUNT,
52+
minimumBackgroundDuration?: number,
5253
): Promise<void> {
5354
if (baseBundlePath) {
5455
// Checked before the bundler runs, so the wrong base bundle costs a second rather
@@ -167,6 +168,7 @@ export async function release(
167168
enable,
168169
rollout,
169170
Object.keys(diffPackages).length > 0 ? diffPackages : undefined,
171+
minimumBackgroundDuration,
170172
)
171173

172174
if (!skipCleanup) {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)