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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,8 @@ npx code-push release --framework expo --binary-version 1.0.0 --app-version 1.0.
> `--app-version` should be greater than `--binary-version` (SemVer comparison).

- `--rollout`: The rollout percentage for the update. (0~100, inclusive)
- `--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)
- The value set on the release takes precedence over the `minimumBackgroundDuration` passed to `sync`, and `0` applies the update on the next resume.

#### `update-history`

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

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

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

**예시:**

Expand Down Expand Up @@ -353,7 +355,8 @@ npx code-push show-history -b 1.0.0 -p ios
"mandatory": false,
"downloadUrl": "https://storage.example.com/bundles/ios/staging/a1b2c3...",
"packageHash": "a1b2c3...",
"rollout": 100
"rollout": 100,
"minimumBackgroundDuration": 600
},
"1.0.2": {
"enabled": true,
Expand Down
7 changes: 5 additions & 2 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ npx code-push release [options]
| `-m, --mandatory <bool>` | Make the release mandatory | `false` |
| `--enable <bool>` | Enable the release | `true` |
| `--rollout <number>` | Rollout percentage (0-100) | — |
| `--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 | — |
| `--skip-bundle <bool>` | Skip bundle step (use existing bundle) | `false` |
| `--hash-calc <bool>` | Calculate hash from existing bundle (requires `--skip-bundle true`) | — |
| `--skip-cleanup <bool>` | Skip output directory cleanup | `false` |
Expand Down Expand Up @@ -302,8 +303,9 @@ npx code-push update-history [options]
| `-m, --mandatory <bool>` | Set mandatory flag | — |
| `-e, --enable <bool>` | Enable or disable the release | — |
| `--rollout <number>` | Rollout percentage (0-100) | — |
| `--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 | — |

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

```bash
# Disable a release
Expand Down Expand Up @@ -351,7 +353,8 @@ The release history is a JSON object keyed by app version. For example, the hist
"mandatory": false,
"downloadUrl": "https://storage.example.com/bundles/ios/staging/a1b2c3...",
"packageHash": "a1b2c3...",
"rollout": 100
"rollout": 100,
"minimumBackgroundDuration": 600
},
"1.0.2": {
"enabled": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ describe("staging the release history a config is handed", () => {
};

await Promise.all([
updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined),
updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined),
updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined, undefined),
updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined, undefined),
]);

expect(staged.ios).toContain("ios-url");
Expand Down
6 changes: 6 additions & 0 deletions cli/commands/releaseCommand/addToReleaseHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export async function addToReleaseHistory(
enable: boolean,
rollout: number | undefined,
diffPackages: Record<string, string> | undefined,
minimumBackgroundDuration: number | undefined,
): Promise<void> {
const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);

Expand Down Expand Up @@ -49,6 +50,11 @@ export async function addToReleaseHistory(
newReleaseHistory[appVersion].rollout = rollout;
}

// An entry without it leaves the wait to the sync option, so 0 has to be written.
if (typeof minimumBackgroundDuration === 'number') {
newReleaseHistory[appVersion].minimumBackgroundDuration = minimumBackgroundDuration;
}

try {
await stageReleaseHistoryFile(binaryVersion, newReleaseHistory, platform, (jsonFilePath) =>
setReleaseHistory(binaryVersion, jsonFilePath, newReleaseHistory, platform, identifier));
Expand Down
29 changes: 29 additions & 0 deletions cli/commands/releaseCommand/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const ARG_INDEX = {
onOversizedPatch: 20,
bundleDownloader: 21,
diffBaseCount: 22,
minimumBackgroundDuration: 23,
} as const;

/**
Expand Down Expand Up @@ -147,4 +148,32 @@ describe("release command options", () => {
const { release } = await import("./release.js");
expect(jest.mocked(release)).not.toHaveBeenCalled();
});

it("passes the chosen minimum background duration through to the release", async () => {
const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', '600']);

expect(args[ARG_INDEX.minimumBackgroundDuration]).toBe(600);
});

it("leaves the minimum background duration unset when the option is not given, so the sync option decides", async () => {
const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']);

expect(args[ARG_INDEX.minimumBackgroundDuration]).toBeUndefined();
});

it.each([
['is negative', '-1'],
['is not a number at all', 'soon'],
])("rejects a minimum background duration that %s", async (_caseName, value) => {
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code})`);
}) as never);

await expect(parseReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', value]))
.rejects.toThrow('process.exit(1)');

const { release } = await import("./release.js");
expect(jest.mocked(release)).not.toHaveBeenCalled();
});
});
9 changes: 9 additions & 0 deletions cli/commands/releaseCommand/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type Options = {
binaryBundlePath?: string;
onOversizedPatch: OversizedPatchPolicy;
diffBaseCount: number;
minimumBackgroundDuration?: number;
}

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

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

if (options.minimumBackgroundDuration !== undefined
&& (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) {
console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.');
process.exit(1);
}

if (options.hashCalc && !options.skipBundle) {
console.error('--hash-calc option can be used only when --skip-bundle is set to true.');
process.exit(1);
Expand Down Expand Up @@ -102,6 +110,7 @@ program.command('release')
options.onOversizedPatch,
config.bundleDownloader,
options.diffBaseCount,
options.minimumBackgroundDuration,
)

console.log('🚀 Release completed.')
Expand Down
28 changes: 28 additions & 0 deletions cli/commands/releaseCommand/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ type ReleaseOverrides = {
releaseHistory?: ReleaseHistoryInterface;
bundleDownloader?: CliConfigInterface['bundleDownloader'];
diffBaseCount?: number;
minimumBackgroundDuration?: number;
};

async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}) {
Expand Down Expand Up @@ -176,6 +177,7 @@ async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}
overrides.onOversizedPatch,
overrides.bundleDownloader,
overrides.diffBaseCount,
overrides.minimumBackgroundDuration,
);

return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved };
Expand Down Expand Up @@ -829,3 +831,29 @@ describe("release with asset diff bases", () => {
expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
});
});

describe("release --minimum-background-duration", () => {
it("records the background wait on the release it publishes", async () => {
const staged = await stageBundleOutput("minimum-background-duration");

const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 600 });

expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(600);
});

it("releases a background wait of zero seconds as zero, not as an unset option", async () => {
const staged = await stageBundleOutput("zero-minimum-background-duration");

const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 0 });

expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
});

it("leaves the release saying nothing about the background wait when the option is not given", async () => {
const staged = await stageBundleOutput("no-minimum-background-duration");

const { releaseHistories } = await runRelease(staged);

expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
});
});
2 changes: 2 additions & 0 deletions cli/commands/releaseCommand/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export async function release(
onOversizedPatch: OversizedPatchPolicy = DEFAULT_OVERSIZED_PATCH_POLICY,
bundleDownloader?: CliConfigInterface['bundleDownloader'],
diffBaseCount: number = DEFAULT_DIFF_BASE_COUNT,
minimumBackgroundDuration?: number,
): Promise<void> {
if (baseBundlePath) {
// Checked before the bundler runs, so the wrong base bundle costs a second rather
Expand Down Expand Up @@ -167,6 +168,7 @@ export async function release(
enable,
rollout,
Object.keys(diffPackages).length > 0 ? diffPackages : undefined,
minimumBackgroundDuration,
)

if (!skipCleanup) {
Expand Down
129 changes: 129 additions & 0 deletions cli/commands/updateHistoryCommand/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import fs from "fs";
import path from "path";
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
import type { CliConfigInterface, ReleaseHistoryInterface } from "../../../typings/react-native-code-push.d.ts";

/**
* Checks the command definition against the entry it saves. Everything this command does
* ends up in the release history the config is handed, so an option that never reaches it
* leaves the release exactly as it was - which the command still reports as a success.
*/

const BINARY_VERSION = '1.0.0';
const APP_VERSION = '1.0.1';

let mockConfig: CliConfigInterface;

jest.mock("../../utils/fsUtils.js", () => ({
findAndReadConfigFile: () => mockConfig,
}));

/**
* Puts one released version in the config's history and records every history it is
* handed back, so a case can read the entry as the consumer would store it.
*/
function stageReleaseHistory(): ReleaseHistoryInterface[] {
const releaseHistory: ReleaseHistoryInterface = {
[APP_VERSION]: {
enabled: true,
mandatory: false,
downloadUrl: 'https://cdn.example.com/bundle',
packageHash: 'a3f1c0',
},
};
const saved: ReleaseHistoryInterface[] = [];

mockConfig = {
bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }),
getReleaseHistory: async () => releaseHistory,
// The command edits the history in place, so what it saved is copied out here.
setReleaseHistory: async (_binaryVersion, _jsonFilePath, releaseInfo) => {
saved.push(structuredClone(releaseInfo));
},
};

return saved;
}

/**
* Parses an `update-history` invocation against the real command definition. Commander is
* asked to throw instead of exiting, and to keep its diagnostics to itself, so a rejected
* option can be asserted on without ending the worker or the output.
*/
async function parseUpdateHistoryCommand(args: string[]): Promise<void> {
const { program } = await import("commander");
await import("./index.js");

const updateHistoryCommand = program.commands.find((command) => command.name() === 'update-history');
updateHistoryCommand?.exitOverride();
updateHistoryCommand?.configureOutput({ writeErr: () => {} });

await program.parseAsync(['update-history', ...args], { from: 'user' });
}

async function runUpdateHistoryCommand(args: string[]): Promise<void> {
await parseUpdateHistoryCommand(['-b', BINARY_VERSION, '-v', APP_VERSION, ...args]);
}

let saved: ReleaseHistoryInterface[];

beforeEach(() => {
jest.resetModules();
saved = stageReleaseHistory();
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code})`);
}) as never);
});

afterEach(() => {
jest.restoreAllMocks();
// The command writes its JSON under the directory it was invoked in.
fs.rmSync(path.resolve(process.cwd(), "codepush-release-history"), { recursive: true, force: true });
});

describe("update-history command options", () => {
it("lowers the background wait of a release that is already out to zero seconds", async () => {
await runUpdateHistoryCommand(['--minimum-background-duration', '0']);

expect(saved).toHaveLength(1);
expect(saved[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
});

it("leaves the entry saying nothing about the background wait when only --enable is given", async () => {
await runUpdateHistoryCommand(['--enable', 'false']);

expect(saved[0][APP_VERSION].enabled).toBe(false);
expect(saved[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
});

it("saves the rollout percentage when --rollout is the only option given", async () => {
await runUpdateHistoryCommand(['--rollout', '50']);

expect(saved[0][APP_VERSION].rollout).toBe(50);
});

it("exits without saving anything when no option says what to change", async () => {
await expect(runUpdateHistoryCommand([])).rejects.toThrow('process.exit(1)');

expect(saved).toHaveLength(0);
});

it("rejects a negative background wait and saves nothing", async () => {
await expect(runUpdateHistoryCommand(['--minimum-background-duration', '-1']))
.rejects.toThrow('process.exit(1)');

expect(saved).toHaveLength(0);
});

it.each([
['a percentage above 100', '150'],
['a percentage that is not a number', 'abc'],
])("rejects %s and saves nothing", async (_scenario, rollout) => {
await expect(runUpdateHistoryCommand(['--rollout', rollout]))
.rejects.toThrow('process.exit(1)');

expect(saved).toHaveLength(0);
});
});
Loading