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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,9 @@ dpctl release-react <appName> <platform>
[--targetBinaryVersion <targetBinaryVersion>]
[--rollout <rolloutPercentage>]
[--private-key <privateKeyPathOrPem>]
[--useHermes]
[--extraHermesFlags <hermesFlags>]
[--podFile <podFile>]
```

The `release-react` command is a React Native-specific version of the "vanilla" [`release`](#releasing-app-updates) command, which supports all of the same parameters (e.g. `--mandatory`, `--description`), yet simplifies the process of releasing updates by performing the following additional behavior:
Expand All @@ -486,6 +489,8 @@ The `release-react` command is a React Native-specific version of the "vanilla"

2. Inferring the [`targetBinaryVersion`](#target-binary-version-parameter) of this release by using the version name that is specified in your project's `Info.plist` (for iOS) and `build.gradle` (for Android) files.

3. Compiling the bundle to Hermes bytecode when Hermes is enabled for the platform (see the [Hermes parameters](#hermes-parameters)).

To illustrate the difference that the `release-react` command can make, the following is an example of how you might generate and release an update for a React Native app using the "vanilla" `release` command:

```shell
Expand Down Expand Up @@ -615,6 +620,52 @@ This is the same parameter as the one described in the [above section](#private-
dpctl release-react MyApp-iOS ios --private-key ./private.pem
```

#### Hermes parameters

When Hermes is enabled, `release-react` compiles the JS bundle to Hermes bytecode with the `hermesc` that ships with React Native. If you asked for a source map with `--sourcemapOutput`, it composes the Hermes map with it so stack traces resolve to your original source. Hermes is detected automatically:

| Platform | Detected from |
| -------- | ------------------------------------------------------------------------------------------------------- |
| Android | `hermesEnabled=true` in `android/gradle.properties`, or `enableHermes: true` in `android/app/build.gradle` |
| iOS | `:hermes_enabled => true` in `ios/Podfile`, or the Podfile `expo prebuild` generates |

The React Native 0.70+ iOS template enables Hermes without writing either, so pass `--useHermes` when releasing for iOS from those projects.

- `--useHermes` compiles to Hermes bytecode even when Hermes isn't detected. There is no short flag: `-h` is help.
- `--extraHermesFlags` (`-hf`) passes extra flags to `hermesc`.
- `--podFile` (`-pod`) is the Podfile to check when it isn't `ios/Podfile`.

```shell
dpctl release-react MyApp-iOS ios --useHermes --sourcemapOutput ./main.jsbundle.map
```

`bundle-react` accepts the same three parameters.

### Releasing Updates (Expo Updates)

```shell
dpctl release-expo <appName>
[--deploymentName <deploymentName>]
[--platform <ios|android>]
[--runtimeVersion <runtimeVersion>]
[--exportDir <exportDir>]
[--metadata <json>]
```

`release-expo` is for apps that use `expo-updates` rather than the CodePush SDK. The app must have been created with `--platform expo-v1`. Run it from your project folder:

```shell
dpctl release-expo MyExpoApp -d Production
```

It does what `eas update` does before uploading:

1. Runs `npx expo export` for iOS and Android, using the Expo CLI installed in your project.
2. Resolves each platform's runtime version from your app config with `npx expo-updates runtimeversion:resolve`, so every runtime version policy works, `fingerprint` included.
3. Uploads each platform as its own release, then removes its temporary files.

Both platforms are exported and packaged before either is uploaded, so a problem with one can't leave the other released on its own. If you run `release-expo` on a CodePush app, dpctl stops before exporting and tells you to use `release-react` instead.

## Debugging DeployPulse Integration

Once you've released an update, React Native plugin has been integrated into your app, it can be helpful to diagnose how the plugin is behaving, especially if you run into an issue and want to understand why. In order to debug the DeployPulse update discovery experience, you can run the following command in order to easily view the diagnostic logs produced by the CodePush plugin within your app:
Expand Down
16 changes: 16 additions & 0 deletions script/child-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* The environment to hand a build tool.
*
* `release-react` and `release-expo` shell out to Metro, the Expo CLI and the Hermes compiler, all of
* which execute code from the app's own node_modules: Babel plugins, Metro transformers, Expo config
* plugins. A child inherits process.env by default, so with `DEPLOYPULSE_ACCESS_KEY` set (the documented
* way to authenticate in CI) any one of those transitive dependencies could read a full-access key and
* publish an OTA update to every app in the account. Nothing in a bundler needs the credential.
*
* A key read from the session file was never in the environment, so this closes the whole path.
*/
export function envWithoutCredentials(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.DEPLOYPULSE_ACCESS_KEY;
return env;
}
130 changes: 129 additions & 1 deletion script/command-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import * as crypto from "crypto";
import debugCommand from "./commands/debug";
import * as fs from "fs";
import * as hashUtils from "./hash-utils";
import * as recursiveFs from "recursive-fs";
import * as yazl from "yazl";
import slash = require("slash");
import { envWithoutCredentials } from "./child-env";
import { compileHermesIfEnabled } from "./react-native-utils";
import { appPlatformLabel, assertReleasePlatform, checkReleaseProjectKind } from "./ota-runtime";
import * as chalk from "chalk";
const g2js = require("gradle-to-js/lib/parser");
Expand Down Expand Up @@ -598,6 +603,9 @@ export function execute(command: cli.ICommand) {
case cli.CommandType.appSetPublicKey:
return appSetPublicKey(<cli.IAppSetPublicKeyCommand>command);

case cli.CommandType.bundleReact:
return bundleReact(<cli.IBundleReactCommand>command);

case cli.CommandType.appTransfer:
return appTransfer(<cli.IAppTransferCommand>command);

Expand Down Expand Up @@ -1457,6 +1465,21 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise<void> =
command.sourcemapOutput
)
)
// Hermes runs after bundling and before the upload: it rewrites the bundle in place.
.then(() =>
Q(
compileHermesIfEnabled({
platform,
bundleName,
outputFolder,
sourcemapOutput: command.sourcemapOutput,
useHermes: command.useHermes,
extraHermesFlags: command.extraHermesFlags,
podFile: command.podFile,
log,
})
)
)
.then(() => {
log(chalk.cyan("\nReleasing update contents to DeployPulse:\n"));
return release(releaseCommand);
Expand All @@ -1473,6 +1496,111 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise<void> =
);
};

export const bundleReact = (command: cli.IBundleReactCommand): Promise<void> => {
let bundleName: string = command.bundleName;
let entryFile: string = command.entryFile;
const isTempDir = !command.outputDir;
const outputFolder: string = command.outputDir || path.join(os.tmpdir(), "dpctl");
const platform: string = (command.platform = command.platform.toLowerCase());
const outputZipPath: string = path.resolve(command.outputPath || "bundle.zip");

return Q(<void>null)
.then((): void => {
switch (platform) {
case "android":
case "ios":
case "windows":
if (!bundleName) {
bundleName = platform === "ios" ? "main.jsbundle" : `index.${platform}.bundle`;
}
break;
default:
throw new Error('Platform must be either "android", "ios" or "windows".');
}

// Only the read is guarded. Wrapping the checks too made their messages unreachable, so a project
// with no "name" was reported as an unreadable package.json.
let projectPackageJson: any;
try {
projectPackageJson = require(path.join(process.cwd(), "package.json"));
} catch {
throw new Error(
'Unable to find or read "package.json" in the CWD. The "bundle-react" command must be executed in a React Native project folder.'
);
}
if (!projectPackageJson.name) {
throw new Error('The "package.json" file in the CWD does not have the "name" field set.');
}
if (!projectPackageJson.dependencies?.["react-native"]) {
throw new Error("The project in the CWD is not a React Native project.");
}

if (!entryFile) {
entryFile = `index.${platform}.js`;
if (fileDoesNotExistOrIsDirectory(entryFile)) entryFile = "index.js";
if (fileDoesNotExistOrIsDirectory(entryFile)) {
throw new Error(`Entry file "index.${platform}.js" or "index.js" does not exist.`);
}
} else if (fileDoesNotExistOrIsDirectory(entryFile)) {
throw new Error(`Entry file "${entryFile}" does not exist.`);
}
})
.then(() => createEmptyTempReleaseFolder(outputFolder))
.then(() => deleteFolder(`${os.tmpdir()}/react-*`, /*glob*/ true))
.then(() =>
runReactNativeBundleCommand(bundleName, command.development || false, entryFile, outputFolder, platform, command.sourcemapOutput)
)
// Before the signature step, which hashes outputFolder: signing the JS and then swapping in
// bytecode would produce a zip whose signature fails verification on device.
.then(() =>
Q(
compileHermesIfEnabled({
platform,
bundleName,
outputFolder,
sourcemapOutput: command.sourcemapOutput,
useHermes: command.useHermes,
extraHermesFlags: command.extraHermesFlags,
podFile: command.podFile,
log,
})
)
)
.then((): Promise<string | undefined> => {
if (!command.privateKey) return Q(undefined);
const privateKey = resolvePrivateKey(command.privateKey);
return hashUtils
.generatePackageHashFromDirectory(outputFolder, signatureManifestBase(outputFolder))
.then((packageHash: string) => createRS256JWT(privateKey, packageHash));
})
.then((signatureJwt: string | undefined): Promise<void> => {
return Promise<void>((resolve, reject) => {
recursiveFs.readdirr(outputFolder, (error?: any, _dirs?: string[], files?: string[]) => {
if (error) { reject(error); return; }
const baseDir = path.dirname(outputFolder);
const zipFile = new yazl.ZipFile();
const writeStream = fs.createWriteStream(outputZipPath);
zipFile.outputStream.pipe(writeStream).on("error", reject).on("close", resolve);
for (const file of files) {
zipFile.addFile(file, slash(path.relative(baseDir, file)));
}
if (signatureJwt) {
zipFile.addBuffer(Buffer.from(signatureJwt), "CodePush/.codepushrelease");
}
zipFile.end();
});
});
})
.then((): void => {
log(chalk.green(`\nSuccessfully created bundle: ${outputZipPath}\n`));
if (isTempDir) deleteFolder(outputFolder);
})
.catch((err: Error) => {
if (isTempDir) deleteFolder(outputFolder);
throw err;
});
};

function rollback(command: cli.IRollbackCommand): Promise<void> {
return confirm().then((wasConfirmed: boolean) => {
if (!wasConfirmed) {
Expand Down Expand Up @@ -1551,7 +1679,7 @@ export const runReactNativeBundleCommand = (
}

log(chalk.cyan('Running "react-native bundle" command:\n'));
const reactNativeBundleProcess = spawn("node", reactNativeBundleArgs);
const reactNativeBundleProcess = spawn("node", reactNativeBundleArgs, { env: envWithoutCredentials() });
log(`node ${reactNativeBundleArgs.join(" ")}`);

return Promise<void>((resolve, reject, notify) => {
Expand Down
Loading
Loading