diff --git a/README.md b/README.md index 7366682..a3176e0 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ Before upgrading a CI pipeline to 1.2.0, note two changes: an unknown flag is no - [Releasing Updates](#releasing-updates) - [Releasing Updates (General)](#releasing-updates-general) - [Releasing Updates (React Native)](#releasing-updates-react-native) + - [Releasing Updates (Expo Updates)](#releasing-updates-expo-updates) - [Debugging DeployPulse Integration](#debugging-deploypulse-integration) - [Patching Update Metadata](#patching-update-metadata) - [Promoting Updates](#promoting-updates) @@ -157,6 +158,15 @@ dpctl app add MyApp-iOS _NOTE: Using the same app for iOS and Android may cause installation exceptions because the DeployPulse update package produced for iOS will have different content from the update produced for Android._ +You can also record what kind of app it is with `--platform`: `ios`, `android`, `expo-cng-ios`, `expo-cng-android` or `expo-v1`. The platform can't be changed after the app is created. dpctl uses it to stop you running `release-react` on an Expo Updates app, or `release-expo` on a CodePush app. + +``` +dpctl app add MyApp-iOS --platform ios +dpctl app add MyExpoApp --platform expo-v1 +``` + +Apps that use `expo-updates` must be created with `--platform expo-v1`, since that is the only type the Expo Updates release endpoint accepts. They are also the exception to one app per platform: a single `expo-v1` app serves both, and `dpctl release-expo` releases iOS and Android to it separately. + All new apps automatically come with two deployments (`Staging` and `Production`) so that you can begin distributing updates to multiple channels without needing to do anything extra (see deployment instructions below). After you create an app, the CLI will output the deployment keys for the `Staging` and `Production` deployments, which you can begin using to configure your mobile clients with the [React Native](http://github.com/deploypulseio/react-native-code-push) SDK. If you decide that you don't like the name you gave to an app, you can rename it at any time using the following command: @@ -182,6 +192,8 @@ you can run the following command: dpctl app ls ``` +The list shows each app's platform, which tells you whether to release it with `dpctl release-react` or `dpctl release-expo`. + ### Code Signing - Set Public Key To enable bundle integrity verification on an app, upload your RSA public key using the following command: @@ -311,13 +323,15 @@ When the metrics cell reports `No installs recorded`, that indicates that the se ## Releasing Updates -Once your app has been configured to query for updates against the DeployPulse API, you can begin releasing updates to it. In order to provide both simplicity and flexibility, the dpctl CLI includes two different commands for releasing updates: +Once your app has been configured to query for updates against the DeployPulse API, you can begin releasing updates to it. In order to provide both simplicity and flexibility, the dpctl CLI includes three different commands for releasing updates: 1. [General](#releasing-updates-general) - Releases an update to the DeployPulse API that was generated by an external tool or build script (e.g. a Gulp task, the `react-native bundle` command). This provides the most flexibility in terms of fitting into existing workflows, since it strictly deals with CodePush-specific step, and leaves the app-specific compilation process to you. 2. [React Native](#releasing-updates-react-native) - Performs the same functionality as the general release command, but also handles the task of generating the updated app contents for you (JS bundle and assets), instead of requiring you to run both `react-native bundle` and then `dpctl release`. -Which of these commands you should use is mostly a matter of requirements and/or preference. However, we generally recommend using the platform-specific command to start (since it greatly simplifies the experience), and then leverage the general-purpose `release` command if/when greater control is needed. +3. [Expo Updates](#releasing-updates-expo-updates) - For apps that use `expo-updates` instead of the CodePush SDK. Exports your project with `npx expo export` and releases iOS and Android in one command. + +Expo Updates apps always use `release-expo`. For CodePush apps, which of the other two commands you use is mostly a matter of requirements and/or preference. However, we generally recommend using the platform-specific command to start (since it greatly simplifies the experience), and then leverage the general-purpose `release` command if/when greater control is needed. ### Releasing Updates (General) @@ -491,6 +505,8 @@ The `release-react` command is a React Native-specific version of the "vanilla" 3. Compiling the bundle to Hermes bytecode when Hermes is enabled for the platform (see the [Hermes parameters](#hermes-parameters)). +If the app is an Expo Updates app, `release-react` stops before bundling and tells you to use [`release-expo`](#releasing-updates-expo-updates) instead. + 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 @@ -666,6 +682,39 @@ It does what `eas update` does before uploading: 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. +#### Deployment name parameter + +This is the same parameter as the one described in the [above section](#deployment-name-parameter). + +#### Platform parameter + +`ios` or `android` releases only that platform. If left unspecified, both are released. + +_NOTE: This parameter can be set using either --platform or -p_ + +#### Runtime version parameter + +The runtime version the update targets. If left unspecified, it is resolved for each platform from your app config. Pass it when you aren't running dpctl from the project folder. With the `fingerprint` policy iOS and Android usually have different runtime versions, so in that case release each platform separately with its own value. + +_NOTE: This parameter can be set using either --runtimeVersion or -r_ + +#### Export directory parameter + +The folder written by an earlier `npx expo export`, to release instead of exporting again, for example from a previous CI step. dpctl never deletes it. + +```shell +npx expo export --output-dir dist +dpctl release-expo MyExpoApp -d Production --exportDir dist +``` + +#### Metadata parameter + +Extra metadata to attach to the release, as a JSON string. It is included in the manifest that devices receive. + +```shell +dpctl release-expo MyExpoApp --metadata '{"channel":"beta"}' +``` + ## 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: @@ -841,6 +890,33 @@ dpctl rollback MyApp-iOS Production --targetRelease v34 _NOTE: The release produced by a rollback will be annotated in the output of the `deployment history` command to help identify them more easily._ +### Rolling back an Expo Updates app + +On an app created with `--platform expo-v1`, `rollback` works on a channel rather than a deployment, and +releases have no labels, so `--targetRelease` does not apply: + +``` +dpctl rollback MyExpoApp production +``` + +A channel serves one release per platform **and** runtime version, so this rolls back each of them. Narrow it +with `--platform ios|android` or `--runtimeVersion 1.2.0`. + +The previous release is published again as a new release. That is what actually moves devices: `expo-updates` +only loads an update newer than the one it launched, so taking the bad release out of service on its own +would strand every device that already downloaded it. The rolled back release is disabled at the same time, +which also clears any partial rollout it had, so the channel is free to take a new release. + +When a channel has no earlier release, or every release on it is bad, send devices back to the bundle built +into the store binary instead: + +``` +dpctl rollback MyExpoApp production --toEmbedded +``` + +That issues the Expo `rollBackToEmbedded` directive. Devices return to the bundle they shipped with until the +channel gets a new release, which supersedes the rollback. + ## Auto-Rollback Auto-rollback automatically reverts a deployment to its previous release when the error rate on the current release exceeds a configured threshold. This protects your users from bad releases without requiring manual intervention. diff --git a/script/command-executor.ts b/script/command-executor.ts index 401ecd3..bdf6c98 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -7,12 +7,13 @@ import * as crypto from "crypto"; import debugCommand from "./commands/debug"; import * as fs from "fs"; import * as hashUtils from "./hash-utils"; +import * as expoUtils from "./expo-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 { appPlatformLabel, assertReleasePlatform, assertReleaseRuntime, checkReleaseProjectKind } from "./ota-runtime"; import * as chalk from "chalk"; const g2js = require("gradle-to-js/lib/parser"); import * as moment from "moment"; @@ -260,6 +261,251 @@ function appRename(command: cli.IAppRenameCommand): Promise { } /** Must match the base packageFileFromPath zips with, or the signature covers keys the package lacks. */ +/** + * The platform, for deciding WHICH implementation runs (CodePush or Expo Updates). Only a genuine 404 + * is allowed to be inconclusive; anything else propagates. Swallowing a 500 here meant `dpctl rollback` + * on an Expo app ran the CodePush rollback and announced "MyApp is a CodePush app", mid-incident. + */ +function getAppPlatformForRouting(appName: string): Promise { + return sdk.getApp(appName).then( + (app: App): string | null => (app && app.platform) || null, + (error: any): undefined => { + if (error && error.statusCode === AccountManager.ERROR_NOT_FOUND) return undefined; + throw error; + } + ); +} + +function uploadProgressBar(): (currentProgress: number) => void { + let lastTotalProgress = 0; + const progressBar = new progress("Upload progress:[:bar] :percent :etas", { + complete: "=", + incomplete: " ", + width: 50, + total: 100, + }); + return (currentProgress: number): void => { + progressBar.tick(currentProgress - lastTotalProgress); + lastTotalProgress = currentProgress; + }; +} + +const EXPO_PLATFORM_LABELS: { [platform: string]: string } = { ios: "iOS", android: "Android" }; + +// Copies what the source channel serves onto the destination. CodePush-only options are refused rather +// than silently ignored: an Expo release has no mandatory flag or binary version. +function promoteExpo(command: cli.IPromoteCommand): Promise { + const unsupported: string[] = []; + if (command.label) unsupported.push("--label"); + if (command.description) unsupported.push("--description"); + if (command.mandatory !== null && command.mandatory !== undefined) unsupported.push("--mandatory"); + if (command.disabled !== null && command.disabled !== undefined) unsupported.push("--disabled"); + if (command.appStoreVersion) unsupported.push("--targetBinaryVersion"); + if (unsupported.length) { + throw new Error( + `"${command.appName}" uses Expo Updates, where promote copies the releases a channel serves as they are. ` + + `Not supported: ${unsupported.join(", ")}.` + ); + } + + return Q( + (async () => { + const source: Deployment = await sdk.getDeployment(command.appName, command.sourceDeploymentName); + if (!source || !source.key) { + throw new Error(`Could not read the key of the "${command.sourceDeploymentName}" channel.`); + } + const result = await sdk.promoteExpo(source.key, command.destDeploymentName, { + platform: command.platform, + rollout: command.rollout, + }); + + result.promotions.forEach((entry) => { + const where = `${EXPO_PLATFORM_LABELS[entry.platform] ?? entry.platform} (runtime version ${entry.runtimeVersion})`; + log( + `Promoted ${where}${entry.label ? ` as ${entry.label}` : ""} from "${command.sourceDeploymentName}" to ` + + `"${command.destDeploymentName}" on "${command.appName}".` + ); + }); + result.skipped.forEach((entry) => { + log( + chalk.yellow( + `Skipped ${EXPO_PLATFORM_LABELS[entry.platform] ?? entry.platform} (runtime version ${entry.runtimeVersion}): ${entry.reason}.` + ) + ); + }); + log("Devices pick this up on their next update check."); + })() + ); +} + +// Only the rollout can change. Expo releases have no label, mandatory flag or target binary version, so +// those options are refused rather than silently ignored. +function patchExpo(command: cli.IPatchCommand): Promise { + const unsupported: string[] = []; + if (command.label) unsupported.push("--label"); + if (command.description !== null && command.description !== undefined) unsupported.push("--description"); + if (command.disabled !== null && command.disabled !== undefined) unsupported.push("--disabled"); + if (command.mandatory !== null && command.mandatory !== undefined) unsupported.push("--mandatory"); + if (command.appStoreVersion) unsupported.push("--targetBinaryVersion"); + if (unsupported.length) { + throw new Error(`"${command.appName}" uses Expo Updates, where patch can only change --rollout. Not supported: ${unsupported.join(", ")}.`); + } + if (!command.rollout) { + throw new Error("Specify the new percentage with --rollout, e.g. --rollout 50%."); + } + + return Q( + (async () => { + const deployment: Deployment = await sdk.getDeployment(command.appName, command.deploymentName); + if (!deployment || !deployment.key) { + throw new Error(`Could not read the key of the "${command.deploymentName}" channel.`); + } + const releases = await sdk.patchExpoRollout(deployment.key, command.rollout); + const what = releases.map((r) => `${EXPO_PLATFORM_LABELS[r.platform] ?? r.platform} (runtime version ${r.runtimeVersion})`).join(", "); + const reach = command.rollout === 100 ? "every device" : `${command.rollout}% of devices`; + log(`Successfully rolled out ${what} on the "${command.deploymentName}" channel of "${command.appName}" to ${reach}.`); + })() + ); +} + +// A channel serves one release per platform AND runtime version, so this rolls back each unless narrowed. +// The previous release is published again rather than the bad one disabled, because expo-updates only loads +// an update NEWER than the one it launched. --toEmbedded goes back to the bundle in the store binary. +function rollbackExpo(command: cli.IRollbackCommand): Promise { + if (command.targetRelease) { + throw new Error( + `"${command.appName}" uses Expo Updates, where releases have no labels, so --targetRelease does not apply. ` + + `Roll back to the previous release with:\n dpctl rollback ${command.appName} ${command.deploymentName}` + ); + } + + const what = command.toEmbedded ? "the bundle built into the store binary" : "the previous release"; + const scope = [ + command.platform ? EXPO_PLATFORM_LABELS[command.platform] ?? command.platform : null, + command.runtimeVersion ? `runtime version ${command.runtimeVersion}` : null, + ] + .filter(Boolean) + .join(", "); + + return confirm( + `Roll the "${command.deploymentName}" channel of "${command.appName}"${scope ? ` (${scope})` : ""} back to ${what}?` + ).then((wasConfirmed: boolean) => { + if (!wasConfirmed) { + log("Rollback cancelled."); + return; + } + + return Q( + (async () => { + const deployment: Deployment = await sdk.getDeployment(command.appName, command.deploymentName); + if (!deployment || !deployment.key) { + throw new Error(`Could not read the key of the "${command.deploymentName}" channel.`); + } + const result = await sdk.rollbackExpo(deployment.key, { + platform: command.platform, + runtimeVersion: command.runtimeVersion, + toEmbedded: command.toEmbedded, + }); + + result.rollbacks.forEach((entry) => { + const where = `${EXPO_PLATFORM_LABELS[entry.platform] ?? entry.platform} (runtime version ${entry.runtimeVersion})`; + const how = entry.mode === "embedded" ? "back to the embedded bundle" : "back to the previous release"; + log(`Rolled ${where} ${how} on the "${command.deploymentName}" channel of "${command.appName}".`); + }); + result.skipped.forEach((entry) => { + log( + chalk.yellow( + `Skipped ${EXPO_PLATFORM_LABELS[entry.platform] ?? entry.platform} (runtime version ${entry.runtimeVersion}): ${entry.reason}.` + ) + ); + }); + log("Devices pick this up on their next update check."); + })() + ); + }); +} + +export const releaseExpo = (command: cli.IReleaseExpoCommand): Promise => { + const projectRoot = process.cwd(); + const platforms: string[] = command.platform ? [command.platform] : ["ios", "android"]; + const metadata: object | undefined = command.metadata ? JSON.parse(command.metadata) : undefined; + + const run = async () => { + const deployment: Deployment = await sdk.getDeployment(command.appName, command.deploymentName); + if (!deployment || !deployment.key) { + throw new Error(`Could not read the key of the "${command.deploymentName}" deployment, which Expo Updates releases are uploaded to.`); + } + + assertReleaseRuntime({ + expected: "expo-updates", + appName: command.appName, + deploymentName: command.deploymentName, + appPlatform: await getAppPlatform(command.appName), + projectRoot, + }); + + // Exporting and resolving runtime versions both read the project; only a prebuilt export with + // an explicit runtime version can be released from anywhere. + if (!command.exportDir || !command.runtimeVersion) { + expoUtils.assertExpoProject(projectRoot); + } + + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-expo-")); + try { + const exportDir = command.exportDir ? path.resolve(command.exportDir) : path.join(workDir, "dist"); + if (!command.exportDir) { + const labels = platforms.map((platform: string) => EXPO_PLATFORM_LABELS[platform]); + log(chalk.cyan(`Exporting ${labels.join(" and ")} with "npx expo export":\n`)); + await expoUtils.runExpoExport(projectRoot, exportDir, platforms); + } + const exportMetadata = expoUtils.readExportMetadata(exportDir); + + // Resolve and package every platform before uploading any, so a problem with the second one + // can't leave the first released on its own. + const prepared: Array<{ platform: string; runtimeVersion: string; zip: expoUtils.PlatformZip }> = []; + for (const platform of platforms) { + const runtimeVersion = command.runtimeVersion || (await expoUtils.resolveRuntimeVersion(projectRoot, platform)); + const zip = await expoUtils.zipPlatformExport(exportDir, exportMetadata, platform, path.join(workDir, `${platform}.zip`)); + prepared.push({ platform, runtimeVersion, zip }); + } + + const released: string[] = []; + for (const { platform, runtimeVersion, zip } of prepared) { + const label = EXPO_PLATFORM_LABELS[platform]; + log( + chalk.cyan( + `\nReleasing ${label} (runtime version ${runtimeVersion}, ${zip.assetCount} asset(s)) ` + + `to the "${command.deploymentName}" deployment of "${command.appName}":\n` + ) + ); + try { + await sdk.releaseExpo(deployment.key, zip.zipPath, platform, runtimeVersion, metadata, command.rollout, command.description, uploadProgressBar()); + } catch (error: any) { + if (released.length && error) { + error.message = `${released.join(" and ")} was released, but ${label} failed: ${error.message}`; + } + throw error; + } + released.push(label); + } + + const reach = command.rollout && command.rollout < 100 ? ` for ${command.rollout}% of devices` : ""; + log(`Successfully released ${released.join(" and ")} to the "${command.deploymentName}" deployment of the "${command.appName}" app${reach}.`); + } finally { + // Only the temporary folder: an --exportDir the user passed is never inside it. Swallowed, because + // a rejection here would REPLACE the in-flight error, and "EBUSY android.zip" is a poor substitute + // for "iOS was released, but Android failed: ". + try { + await rimraf(workDir); + } catch { + /* best effort */ + } + } + }; + + return Q(run()); +}; + export function signatureManifestBase(filePath: string): string { return path.dirname(filePath); } @@ -672,6 +918,9 @@ export function execute(command: cli.ICommand) { case cli.CommandType.release: return release(command); + case cli.CommandType.releaseExpo: + return releaseExpo(command); + case cli.CommandType.releaseReact: return releaseReact(command); @@ -1246,6 +1495,10 @@ function printTable(columnNames: string[], readData: (dataSource: any[]) => void } function promote(command: cli.IPromoteCommand): Promise { + return getAppPlatformForRouting(command.appName).then((platform) => (platform === "expo-v1" ? promoteExpo(command) : promoteCodePush(command))); +} + +function promoteCodePush(command: cli.IPromoteCommand): Promise { const packageInfo: PackageInfo = { appVersion: command.appStoreVersion, description: command.description, @@ -1274,6 +1527,10 @@ function promote(command: cli.IPromoteCommand): Promise { } function patch(command: cli.IPatchCommand): Promise { + return getAppPlatformForRouting(command.appName).then((platform) => (platform === "expo-v1" ? patchExpo(command) : patchCodePush(command))); +} + +function patchCodePush(command: cli.IPatchCommand): Promise { const packageInfo: PackageInfo = { appVersion: command.appStoreVersion, description: command.description, @@ -1382,6 +1639,7 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = .getDeployment(command.appName, command.deploymentName) .then(() => getAppPlatform(command.appName)) .then((appPlatform: string | null | undefined): any => { + assertReleaseRuntime({ expected: "codepush", appName: command.appName, deploymentName: command.deploymentName, appPlatform }); assertReleasePlatform({ appName: command.appName, deploymentName: command.deploymentName, appPlatform, releasePlatform: platform }); const projectWarning = checkReleaseProjectKind({ appName: command.appName, appPlatform, projectRoot: process.cwd() }); if (projectWarning) console.warn(chalk.yellow("[Warning] " + projectWarning)); @@ -1602,6 +1860,21 @@ export const bundleReact = (command: cli.IBundleReactCommand): Promise => }; function rollback(command: cli.IRollbackCommand): Promise { + return getAppPlatformForRouting(command.appName).then((platform) => (platform === "expo-v1" ? rollbackExpo(command) : rollbackCodePush(command))); +} + +function rollbackCodePush(command: cli.IRollbackCommand): Promise { + const expoOnly: string[] = []; + if (command.platform) expoOnly.push("--platform"); + if (command.runtimeVersion) expoOnly.push("--runtimeVersion"); + if (command.toEmbedded) expoOnly.push("--toEmbedded"); + if (expoOnly.length) { + throw new Error( + `${expoOnly.join(" and ")} only appl${expoOnly.length > 1 ? "y" : "ies"} to Expo Updates apps, and ` + + `"${command.appName}" is a CodePush app. Roll back to an earlier release with --targetRelease instead.` + ); + } + return confirm().then((wasConfirmed: boolean) => { if (!wasConfirmed) { log("Rollback cancelled."); diff --git a/script/command-parser.ts b/script/command-parser.ts index 2f48230..38a473d 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -371,15 +371,16 @@ yargs .usage(USAGE_PREFIX + " app add ") .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments .example("app add MyApp --platform ios", 'Adds a React Native iOS app named "MyApp"') + .example("app add MyApp --platform expo-v1", 'Adds an Expo Updates v1 app named "MyApp"') .option("platform", { alias: "p", default: null, demand: false, - description: 'Target platform: "ios", "android", "expo-cng-ios" or "expo-cng-android". Cannot be changed after creation.', + description: 'Target platform: "ios", "android", "expo-cng-ios", "expo-cng-android", or "expo-v1". Cannot be changed after creation.', type: "string", }) .check((argv: any): any => { - const validPlatforms = ["ios", "android", "expo-cng-ios", "expo-cng-android"]; + const validPlatforms = ["ios", "android", "expo-cng-ios", "expo-cng-android", "expo-v1"]; if (argv.platform && !validPlatforms.includes(argv.platform)) { throw new Error("--platform must be one of: " + validPlatforms.join(", ")); } @@ -659,6 +660,129 @@ yargs addCommonConfiguration(yargs); }) + .command("release-expo", "Release an Expo Updates update to an app deployment", (yargs: yargs.Argv) => { + yargs + .usage(USAGE_PREFIX + " release-expo [options]") + .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option argument + .example( + "release-expo MyApp", + 'Exports iOS and Android with "npx expo export" and releases both to the "preview" channel of "MyApp"' + ) + .example("release-expo MyApp -d production --platform ios", 'Releases only iOS to the "production" channel') + .example( + "release-expo MyApp -d production --rollout 10%", + 'Releases to 10% of devices on the "production" channel. Raise it later with "patch MyApp production --rollout 50%"' + ) + .example( + "release-expo MyApp --exportDir ./dist --runtimeVersion 1.0.2", + "Releases an export built earlier, for example in a previous CI step" + ) + .option("deploymentName", { + alias: ["d", "deployment"], + // Expo apps have channels named after the eas.json profiles (development, preview, production), not + // CodePush's Staging/Production. Defaulting to preview keeps the CodePush habit of never shipping to + // production by accident, on a channel every Expo app actually has. + default: "preview", + demand: false, + description: "Channel to release the update to (development, preview or production by default)", + type: "string", + }) + .option("platform", { + alias: "p", + choices: ["ios", "android"], + demand: false, + description: "Release only this platform. Omit to release iOS and Android", + type: "string", + }) + .option("runtimeVersion", { + alias: "r", + demand: false, + description: "Runtime version to target. Omit to resolve it per platform from your app config, the same way expo-updates does", + type: "string", + }) + .option("description", { + alias: "des", + default: null, + demand: false, + description: "Description of the changes made to the app with this release", + type: "string", + }) + .option("exportDir", { + demand: false, + description: 'Folder written by "npx expo export" to release instead of exporting. dpctl never deletes it', + type: "string", + }) + .option("metadata", { + default: null, + demand: false, + description: "Extra metadata to attach to the release, as a JSON string", + type: "string", + }) + // No -r alias: on this command -r is --runtimeVersion. + .option("rollout", { + demand: false, + description: "Percentage of devices this release should be available to. Omit to release to every device", + type: "string", + }) + .check((argv: any): any => { + if (!isValidRollout(argv)) { + throw new Error("--rollout must be a whole percentage from 1 to 100, e.g. 25 or 25%"); + } + if (argv.metadata) { + try { + JSON.parse(argv.metadata); + } catch { + throw new Error("--metadata must be valid JSON"); + } + } + return true; + }); + + addCommonConfiguration(yargs); + }) + .command("rollback", "Rollback the latest release for an app deployment", (yargs: yargs.Argv) => { + yargs + .usage(USAGE_PREFIX + " rollback [options]") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("rollback MyApp Production", 'Performs a rollback on the "Production" deployment of "MyApp"') + .example( + "rollback MyApp Production --targetRelease v4", + 'Performs a rollback on the "Production" deployment of "MyApp" to the v4 release' + ) + .example( + "rollback MyApp production --toEmbedded", + 'Expo Updates: sends devices on the "production" channel back to the bundle in the store binary' + ) + .option("targetRelease", { + alias: "r", + default: null, + demand: false, + description: + "Label of the release to roll the specified deployment back to (e.g. v4). If omitted, the deployment will roll back to the previous release.", + type: "string", + }) + .option("platform", { + alias: "p", + choices: ["ios", "android"], + demand: false, + description: "Expo Updates apps only: roll back only this platform. Omit to roll back both", + type: "string", + }) + .option("runtimeVersion", { + demand: false, + description: "Expo Updates apps only: roll back only this runtime version. Omit to roll back every runtime version the channel serves", + type: "string", + }) + .option("toEmbedded", { + default: false, + demand: false, + description: + "Expo Updates apps only: send devices back to the bundle built into the store binary, instead of to the previous release", + type: "boolean", + }); + + addCommonConfiguration(yargs); + }) .command("org", "View and switch the organization your commands run against", (yargs: yargs.Argv) => { isValidCommandCategory = true; yargs @@ -1068,26 +1192,6 @@ yargs addCommonConfiguration(yargs); }) - .command("rollback", "Rollback the latest release for an app deployment", (yargs: yargs.Argv) => { - yargs - .usage(USAGE_PREFIX + " rollback [options]") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments - .example("rollback MyApp Production", 'Performs a rollback on the "Production" deployment of "MyApp"') - .example( - "rollback MyApp Production --targetRelease v4", - 'Performs a rollback on the "Production" deployment of "MyApp" to the v4 release' - ) - .option("targetRelease", { - alias: "r", - default: null, - demand: false, - description: - "Label of the release to roll the specified deployment back to (e.g. v4). If omitted, the deployment will roll back to the previous release.", - type: "string", - }); - - addCommonConfiguration(yargs); - }) .command("session", "View and manage the current login sessions associated with your account", (yargs: yargs.Argv) => { isValidCommandCategory = true; yargs @@ -1494,6 +1598,7 @@ export function createCommand(): cli.ICommand { deploymentPromoteCommand.noDuplicateReleaseError = argv["noDuplicateReleaseError"] as any; deploymentPromoteCommand.rollout = getRolloutValue(argv["rollout"] as any); deploymentPromoteCommand.appStoreVersion = argv["targetBinaryVersion"] as any; + deploymentPromoteCommand.platform = argv["platform"] ? String(argv["platform"]) : undefined; } break; @@ -1534,6 +1639,23 @@ export function createCommand(): cli.ICommand { } break; + case "release-expo": + if (arg1) { + cmd = { type: cli.CommandType.releaseExpo }; + + const releaseExpoCommand = cmd; + + releaseExpoCommand.appName = arg1; + releaseExpoCommand.deploymentName = argv["deploymentName"] as any; + releaseExpoCommand.platform = argv["platform"] ? String(argv["platform"]) : undefined; + releaseExpoCommand.runtimeVersion = argv["runtimeVersion"] ? String(argv["runtimeVersion"]) : undefined; + releaseExpoCommand.exportDir = argv["exportDir"] ? String(argv["exportDir"]) : undefined; + releaseExpoCommand.description = argv["description"] ? backslash(String(argv["description"])) : undefined; + releaseExpoCommand.metadata = argv["metadata"] as any; + releaseExpoCommand.rollout = getRolloutValue(argv["rollout"] as any); + } + break; + case "release-react": if (arg1 && arg2) { cmd = { type: cli.CommandType.releaseReact }; @@ -1574,6 +1696,9 @@ export function createCommand(): cli.ICommand { rollbackCommand.appName = arg1; rollbackCommand.deploymentName = arg2; rollbackCommand.targetRelease = argv["targetRelease"] as any; + rollbackCommand.platform = argv["platform"] ? String(argv["platform"]) : undefined; + rollbackCommand.runtimeVersion = argv["runtimeVersion"] ? String(argv["runtimeVersion"]) : undefined; + rollbackCommand.toEmbedded = Boolean(argv["toEmbedded"]); } break; diff --git a/script/expo-utils.ts b/script/expo-utils.ts new file mode 100644 index 0000000..e4e18ae --- /dev/null +++ b/script/expo-utils.ts @@ -0,0 +1,162 @@ +// `release-expo`: what `eas update` does before it uploads, pointed at DeployPulse. +// +// `npx expo export` writes one folder for every platform, and each platform can have its own runtime +// version. The server takes one platform per upload, so the export is split into a zip per platform. + +import * as fs from "fs"; +import * as path from "path"; +import * as yazl from "yazl"; +import { envWithoutCredentials } from "./child-env"; +const childProcess = require("child_process"); + +export interface ExpoExportMetadata { + version: number; + bundler: string; + fileMetadata: { [platform: string]: { bundle: string; assets?: Array<{ path: string; ext: string }> } }; +} + +export interface PlatformZip { + zipPath: string; + bundlePath: string; + assetCount: number; + bytes: number; +} + +const NOT_AN_EXPO_PROJECT = 'Run "dpctl release-expo" from your Expo project folder, or pass both --exportDir and --runtimeVersion.'; + +// `--no` stops npx from downloading a package the project doesn't have: releasing with whatever +// version of the Expo CLI happens to be newest on npm would not match the app's own SDK. +// On Windows `npx` is a .cmd shim, which Node only runs through a shell, so arguments need quoting. +function spawnNpx(args: string[], cwd: string, captureOutput: boolean): any { + const isWindows = process.platform === "win32"; + const finalArgs = ["--no", ...args].map((arg: string) => (isWindows && /\s/.test(arg) ? `"${arg}"` : arg)); + return childProcess.spawn("npx", finalArgs, { + cwd, + env: envWithoutCredentials(), + shell: isWindows, + stdio: captureOutput ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"], + }); +} + +export function assertExpoProject(projectRoot: string): void { + let pkg: any; + try { + pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8")); + } catch { + throw new Error(`No package.json in "${projectRoot}". ${NOT_AN_EXPO_PROJECT}`); + } + const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; + if (!deps.expo) { + throw new Error(`The project in "${projectRoot}" does not depend on "expo". ${NOT_AN_EXPO_PROJECT}`); + } +} + +export function runExpoExport(projectRoot: string, outputDir: string, platforms: string[]): Promise { + const args = ["expo", "export", "--output-dir", outputDir]; + platforms.forEach((platform: string) => args.push("--platform", platform)); + + return new Promise((resolve, reject) => { + const child = spawnNpx(args, projectRoot, /*captureOutput*/ false); + child.on("error", (err: Error) => reject(new Error(`Could not run "npx expo export": ${err.message}`))); + child.on("close", (code: number) => { + if (code === 0) return resolve(); + reject(new Error(`"npx expo export" exited with code ${code}. Its output above says why.`)); + }); + }); +} + +/** The runtime version expo-updates will report for `platform`, resolved from the app config. */ +export function resolveRuntimeVersion(projectRoot: string, platform: string): Promise { + const command = `npx expo-updates runtimeversion:resolve --platform ${platform}`; + + return new Promise((resolve, reject) => { + const child = spawnNpx(["expo-updates", "runtimeversion:resolve", "--platform", platform], projectRoot, /*captureOutput*/ true); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + child.on("error", (err: Error) => reject(new Error(`Could not run "${command}": ${err.message}`))); + child.on("close", (code: number) => { + if (code !== 0) { + return reject( + new Error( + `Could not resolve the ${platform} runtime version with "${command}" (exit code ${code}). ` + + `Check that expo-updates is installed, or pass --runtimeVersion.` + + (stderr.trim() ? `\n${stderr.trim()}` : "") + ) + ); + } + + // The resolver prints a single JSON line. Take the last one so a warning printed by a config + // plugin can't break parsing. + const jsonLine = stdout + .split(/\r?\n/) + .reverse() + .find((line: string) => line.trim().startsWith("{")); + let info: any; + try { + info = JSON.parse(jsonLine); + } catch { + return reject(new Error(`Unexpected output from "${command}": ${stdout.trim() || "(nothing)"}`)); + } + if (!info || typeof info.runtimeVersion !== "string" || !info.runtimeVersion) { + return reject(new Error(`Your app config has no ${platform} runtime version. Set "runtimeVersion" in app.json, or pass --runtimeVersion.`)); + } + resolve(info.runtimeVersion); + }); + }); +} + +export function readExportMetadata(exportDir: string): ExpoExportMetadata { + const file = path.join(exportDir, "metadata.json"); + let metadata: any; + try { + metadata = JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + throw new Error(`No readable metadata.json in "${exportDir}". --exportDir must be the folder "npx expo export" wrote, not a folder inside it.`); + } + if (!metadata || typeof metadata.fileMetadata !== "object" || !metadata.fileMetadata) { + throw new Error(`"${file}" is not an Expo export metadata file.`); + } + return metadata; +} + +/** Zip one platform's bundle and assets, laid out exactly as `expo export` wrote them. */ +export function zipPlatformExport(exportDir: string, metadata: ExpoExportMetadata, platform: string, zipPath: string): Promise { + const entry = metadata.fileMetadata[platform]; + if (!entry || !entry.bundle) { + return Promise.reject( + new Error(`The export in "${exportDir}" has no ${platform} bundle. Export ${platform} as well, or pick the platforms it has with --platform.`) + ); + } + + const assetPaths = Array.from(new Set((entry.assets || []).map((asset) => asset.path))); + const files = [entry.bundle, ...assetPaths]; + const missing = files.find((file: string) => !fs.existsSync(path.join(exportDir, file))); + if (missing) { + return Promise.reject(new Error(`"${missing}" is listed in metadata.json but missing from "${exportDir}".`)); + } + + // metadata.json goes in too, trimmed to this platform. Asset files are named by hash with no + // extension, so it is the only record of each asset's type, which the server needs to describe + // assets correctly in the manifest. + const platformMetadata: ExpoExportMetadata = { ...metadata, fileMetadata: { [platform]: entry } }; + + return new Promise((resolve, reject) => { + const zip = new yazl.ZipFile(); + // pipe() returns the DESTINATION, so the handlers below belong to the write stream. Readable.pipe + // does not forward source errors, so without this an error from yazl itself (a file that changed or + // vanished between the existence sweep and the read) is unhandled: the process dies with a raw + // stack, this promise never settles, and the caller's cleanup never runs. + zip.outputStream.on("error", reject); + zip.outputStream + .pipe(fs.createWriteStream(zipPath)) + .on("error", reject) + .on("close", () => + resolve({ zipPath, bundlePath: entry.bundle, assetCount: assetPaths.length, bytes: fs.statSync(zipPath).size }) + ); + zip.addBuffer(Buffer.from(JSON.stringify(platformMetadata)), "metadata.json"); + files.forEach((file: string) => zip.addFile(path.join(exportDir, file), file.split(path.sep).join("/"))); + zip.end(); + }); +} diff --git a/script/management-sdk.ts b/script/management-sdk.ts index a00c6a3..63f0dfe 100644 --- a/script/management-sdk.ts +++ b/script/management-sdk.ts @@ -164,6 +164,153 @@ class AccountManager { return this.get(urlEncode(["/orgs"])).then((res: JsonResponse) => res.body.orgs as Org[]); } + // The Expo release endpoint sits beside the manifest routes, not under /apps, and is addressed by + // deployment key, so callers look the key up first. + public releaseExpo( + deploymentKey: string, + zipFilePath: string, + platform: string, + runtimeVersion: string, + metadata?: object, + rollout?: number, + description?: string, + uploadProgressCallback?: (progress: number) => void + ): Promise { + const url = `${this._serverUrl}/expo/v1/${encodeURIComponent(deploymentKey)}/release`; + const releaseInfo = JSON.stringify({ + platform, + runtimeVersion, + ...(metadata ? { metadata } : {}), + ...(rollout ? { rollout } : {}), + ...(description ? { description } : {}), + }); + + return Promise((resolve, reject) => { + const request: superagent.Request = superagent + .post(url) + .attach("bundle", fs.createReadStream(zipFilePath), "bundle.zip") + .field("releaseInfo", releaseInfo); + + this.attachCredentials(request); + + request + .on("progress", (event: any) => { + if (uploadProgressCallback && event && event.total > 0) { + uploadProgressCallback((event.loaded / event.total) * 100); + } + }) + .end((err: any, res: superagent.Response) => { + if (err && !res) { + reject(this.getCodePushError(err, res)); + return; + } + if (!res.ok) { + reject(expoRouteError(res)); + return; + } + resolve(null); + }); + }); + } + + // Raises every rollout in progress on the channel. Keyed by deployment key, like releaseExpo. + public patchExpoRollout( + deploymentKey: string, + rollout: number + ): Promise> { + const url = `${this._serverUrl}/expo/v1/${encodeURIComponent(deploymentKey)}/release`; + + return Promise>((resolve, reject) => { + const request: superagent.Request = superagent.patch(url).set("Content-Type", "application/json").send({ rollout }); + + this.attachCredentials(request); + + request.end((err: any, res: superagent.Response) => { + if (err && !res) { + reject(this.getCodePushError(err, res)); + return; + } + if (!res.ok) { + reject(expoRouteError(res)); + return; + } + resolve(res.body?.releases ?? []); + }); + }); + } + + /** + * Roll an Expo Updates channel back. Addressed by deployment key, like releaseExpo. Resolves to what was + * rolled back, one entry per platform and runtime version, and what was skipped and why. + */ + public rollbackExpo( + deploymentKey: string, + options: { platform?: string; runtimeVersion?: string; toEmbedded?: boolean } = {} + ): Promise<{ + rollbacks: Array<{ platform: string; runtimeVersion: string; mode: string; releaseId: string }>; + skipped: Array<{ platform: string; runtimeVersion: string; reason: string }>; + }> { + const url = `${this._serverUrl}/expo/v1/${encodeURIComponent(deploymentKey)}/rollback`; + const body: Record = {}; + if (options.platform) body.platform = options.platform; + if (options.runtimeVersion) body.runtimeVersion = options.runtimeVersion; + if (options.toEmbedded) body.toEmbedded = true; + + return Promise<{ rollbacks: any[]; skipped: any[] }>((resolve, reject) => { + const request: superagent.Request = superagent.post(url).set("Content-Type", "application/json").send(body); + + this.attachCredentials(request); + + request.end((err: any, res: superagent.Response) => { + if (err && !res) { + reject(this.getCodePushError(err, res)); + return; + } + if (!res.ok) { + reject(expoRouteError(res)); + return; + } + resolve({ rollbacks: res.body?.rollbacks ?? [], skipped: res.body?.skipped ?? [] }); + }); + }); + } + + /** + * Promote an Expo Updates channel's releases onto another channel of the same app. Addressed by the source + * channel's deployment key, like the other Expo routes; the destination is named, and resolved server-side + * inside the same app. + */ + public promoteExpo( + deploymentKey: string, + to: string, + options: { platform?: string; rollout?: number } = {} + ): Promise<{ + promotions: Array<{ platform: string; runtimeVersion: string; label: string | null }>; + skipped: Array<{ platform: string; runtimeVersion: string; reason: string }>; + }> { + const url = `${this._serverUrl}/expo/v1/${encodeURIComponent(deploymentKey)}/promote`; + const body: Record = { to }; + if (options.platform) body.platform = options.platform; + if (options.rollout) body.rollout = options.rollout; + + return Promise<{ promotions: any[]; skipped: any[] }>((resolve, reject) => { + const request: superagent.Request = superagent.post(url).set("Content-Type", "application/json").send(body); + + this.attachCredentials(request); + + request.end((err: any, res: superagent.Response) => { + if (err && !res) { + reject(this.getCodePushError(err, res)); + return; + } + if (!res.ok) { + reject(expoRouteError(res)); + return; + } + resolve({ promotions: res.body?.promotions ?? [], skipped: res.body?.skipped ?? [] }); + }); + }); + } public getAccessKeys(): Promise { return this.get(urlEncode(["/accessKeys"])).then((res: JsonResponse) => { const accessKeys: AccessKey[] = []; @@ -663,4 +810,14 @@ class AccountManager { } } +function expoRouteError(res: superagent.Response): CodePushError { + let message: string = res.text; + try { + message = JSON.parse(res.text).message || message; + } catch { + /* not JSON; keep the raw text */ + } + return { message, statusCode: res.status }; +} + export = AccountManager; diff --git a/script/ota-runtime.ts b/script/ota-runtime.ts index edbe90e..811525e 100644 --- a/script/ota-runtime.ts +++ b/script/ota-runtime.ts @@ -1,10 +1,12 @@ -// App platforms, and the guards that keep a release-react bundle going to the app it was built for. +// Which update system an app uses: expo-v1 apps take `release-expo`, everything else `release-react`. // The server rejects a mismatch too, but only after a minute of bundling and without naming the right // command. import * as fs from "fs"; import * as path from "path"; +export type OtaRuntime = "codepush" | "expo-updates"; + // Kept in step with the dashboard's platform.ts, so both name an app type the same way. const PLATFORM_LABELS: { [platform: string]: string } = { ios: "React Native (iOS)", @@ -19,6 +21,27 @@ export function appPlatformLabel(platform: string | null | undefined): string { return (platform && PLATFORM_LABELS[platform]) || "React Native"; } +export function runtimeForAppPlatform(platform: string | null | undefined): OtaRuntime | null { + if (!platform) return null; + return platform === "expo-v1" ? "expo-updates" : "codepush"; +} + +// Guess from package.json, for apps with no platform set. Null when it can't tell, so an unusual +// project is never blocked on a guess. Any `*/react-native-code-push` counts: forks are common. +export function detectProjectRuntime(projectRoot: string = process.cwd()): OtaRuntime | null { + let pkg: any; + try { + pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8")); + } catch { + return null; + } + const deps = Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }); + const hasCodePush = deps.some((name: string) => name === "react-native-code-push" || name.endsWith("/react-native-code-push")); + const hasExpoUpdates = deps.includes("expo-updates"); + if (hasCodePush === hasExpoUpdates) return null; + return hasCodePush ? "codepush" : "expo-updates"; +} + const quoteArg = (value: string): string => (/\s/.test(value) ? `"${value}"` : value); function releaseReactHint(appName: string, deploymentName: string, appPlatform: string | null | undefined): string { @@ -26,6 +49,46 @@ function releaseReactHint(appName: string, deploymentName: string, appPlatform: return `dpctl release-react ${quoteArg(appName)} ${platform} -d ${quoteArg(deploymentName)}`; } +function releaseExpoHint(appName: string, deploymentName: string): string { + return `dpctl release-expo ${quoteArg(appName)} -d ${quoteArg(deploymentName)}`; +} + +// Throws, naming the command to run instead, when this is the wrong update system for the app. A +// platform from the server decides; otherwise the project files do, and if they can't, nothing blocks. +export function assertReleaseRuntime(opts: { + expected: OtaRuntime; + appName: string; + deploymentName: string; + appPlatform: string | null | undefined; + projectRoot?: string; +}): void { + const { expected, appName, deploymentName, appPlatform } = opts; + const fromServer = runtimeForAppPlatform(appPlatform); + const actual = fromServer || detectProjectRuntime(opts.projectRoot); + + if (expected === "codepush" && actual === "expo-updates") { + const why = fromServer + ? `"${appName}" is an Expo Updates app, so it can't take a CodePush release.` + : `This project uses expo-updates, not the CodePush SDK, so it can't take a CodePush release.`; + throw new Error(`${why} Run this instead:\n ${releaseExpoHint(appName, deploymentName)}`); + } + + if (expected === "expo-updates" && actual === "codepush") { + const why = fromServer + ? `"${appName}" is a CodePush app, so it can't take an Expo Updates release.` + : `This project uses the CodePush SDK, not expo-updates, so it can't take an Expo Updates release.`; + throw new Error(`${why} Run this instead:\n ${releaseReactHint(appName, deploymentName, appPlatform)}`); + } + + // Only expo-v1 apps take Expo releases, and a platform can't be changed after creation. + if (expected === "expo-updates" && appPlatform === null) { + throw new Error( + `"${appName}" isn't set up for Expo Updates, so the server would reject this release. ` + + `Expo Updates needs an app created for it:\n dpctl app add --platform expo-v1` + ); + } +} + /** "ios" or "android" for a CodePush app's platform; null for apps with no platform or no OS in it. */ export function osForAppPlatform(platform: string | null | undefined): "ios" | "android" | null { if (platform === "ios" || platform === "expo-cng-ios") return "ios"; diff --git a/script/types/cli.ts b/script/types/cli.ts index 951bc63..e293264 100644 --- a/script/types/cli.ts +++ b/script/types/cli.ts @@ -37,6 +37,7 @@ export enum CommandType { patch, promote, release, + releaseExpo, releaseReact, rollback, sessionList, @@ -207,6 +208,7 @@ export interface IPromoteCommand extends ICommand, IPackageInfo { sourceDeploymentName: string; destDeploymentName: string; noDuplicateReleaseError?: boolean; + platform?: string; } export interface IReleaseBaseCommand extends ICommand, IPackageInfo { @@ -237,6 +239,17 @@ export interface IReleaseReactCommand extends IReleaseBaseCommand { podFile?: string; } +export interface IReleaseExpoCommand extends ICommand { + appName: string; + deploymentName: string; + platform?: string; + runtimeVersion?: string; + exportDir?: string; + metadata?: string; + rollout?: number; + description?: string; +} + export interface IBundleReactCommand extends ICommand { platform: string; bundleName?: string; @@ -255,6 +268,9 @@ export interface IRollbackCommand extends ICommand { appName: string; deploymentName: string; targetRelease: string; + platform?: string; + runtimeVersion?: string; + toEmbedded?: boolean; } export interface ISessionListCommand extends ICommand { diff --git a/script/types/rest-definitions.ts b/script/types/rest-definitions.ts index bfa33dd..8863666 100644 --- a/script/types/rest-definitions.ts +++ b/script/types/rest-definitions.ts @@ -115,8 +115,10 @@ export interface CollaboratorMap { /*inout*/ export interface App { /*generated*/ id?: string; - // ios, android, expo-cng-ios or expo-cng-android. Unset on apps created before platforms existed. + // ios, android, expo-cng-ios, expo-cng-android or expo-v1. Unset on apps created before platforms existed. platform?: string | null; + /** Expo Updates apps only: the public ID that goes in `updates.url`. */ + /*generated*/ expoProjectId?: string; /*generated*/ collaborators?: CollaboratorMap; /*key*/ name: string; /*generated*/ deployments?: string[]; diff --git a/test/cli.ts b/test/cli.ts index ae8dc06..60ce939 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -10,13 +10,14 @@ import * as path from "path"; import * as codePush from "../script/types"; import * as cli from "../script/types/cli"; import * as cmdexec from "../script/command-executor"; -import { assertReleasePlatform, checkReleaseProjectKind, detectCodePushProjectKind } from "../script/ota-runtime"; +import { assertReleasePlatform, checkReleaseProjectKind, detectCodePushProjectKind, detectProjectRuntime } from "../script/ota-runtime"; import { getiOSHermesEnabled } from "../script/react-native-utils"; import * as rnUtils from "../script/react-native-utils"; import * as hashUtilsForTest from "../script/hash-utils"; import * as hashUtils from "../script/hash-utils"; import * as os from "os"; import moment = require("moment"); +const yauzl = require("yauzl"); function assertJsonDescribesObject(json: string, object: Object): void { // Make sure JSON is indented correctly @@ -140,7 +141,13 @@ export class SdkStub { public getApp(appName: string): Q.Promise { // No platform: apps created before platforms existed are the common case, and the release guards // deliberately do not fire for them. - return Q({ id: "app-a-id", name: appName, platform: null, deployments: ["Production", "Staging"] }); + const platforms: { [name: string]: string } = { "expo-app": "expo-v1", "ios-app": "ios" }; + return Q({ + id: "app-a-id", + name: appName, + platform: platforms[appName] ?? null, + deployments: ["Production", "Staging"], + }); } public getApps(): Q.Promise { @@ -174,7 +181,7 @@ export class SdkStub { } public getDeployment(appName: string, deploymentName: string): Q.Promise { - if (appName === "a") { + if (appName === "a" || appName === "expo-app" || appName === "ios-app") { if (deploymentName === "Production") { return Q(this.productionDeployment); } else if (deploymentName === "Staging") { @@ -243,6 +250,10 @@ export class SdkStub { }); } + public patchExpoRollout(deploymentKey: string, rollout: number): Q.Promise { + return Q([{ id: "release-id", platform: "ios", runtimeVersion: "1.0.0", rollout }]); + } + public patchRelease(): Q.Promise { return Q(null); } @@ -310,6 +321,38 @@ export class SdkStub { public deleteAutoRollbackConfig(): Q.Promise { return Q(null); } + + public releaseExpo( + deploymentKey: string, + zipFilePath: string, + platform: string, + runtimeVersion: string, + metadata?: object, + uploadProgressCallback?: (progress: number) => void + ): Q.Promise { + return Q(null); + } + + public promoteExpo(deploymentKey: string, to: string, options: { platform?: string; rollout?: number } = {}): Q.Promise { + return Q({ + promotions: [{ platform: options.platform ?? "ios", runtimeVersion: "1.0.0", label: "v1" }], + skipped: [], + }); + } + + public rollbackExpo(deploymentKey: string, options: { platform?: string; runtimeVersion?: string; toEmbedded?: boolean } = {}): Q.Promise { + return Q({ + rollbacks: [ + { + platform: options.platform ?? "ios", + runtimeVersion: options.runtimeVersion ?? "1.0.0", + mode: options.toEmbedded ? "embedded" : "republish", + releaseId: "new-release-id", + }, + ], + skipped: [], + }); + } } describe("CLI", () => { @@ -1280,6 +1323,60 @@ describe("CLI", () => { .done(); }); + it("patch on an Expo Updates app raises the channel's rollout through the Expo route", (done: Mocha.Done): void => { + var command: cli.IPatchCommand = { + type: cli.CommandType.patch, + appName: "expo-app", + deploymentName: "Production", + label: null, + disabled: null, + description: null, + mandatory: null, + rollout: 50, + appStoreVersion: null, + }; + + var patchExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "patchExpoRollout"); + var patchCodePush: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "patchRelease"); + + cmdexec + .execute(command) + .then(() => { + sinon.assert.calledOnceWithExactly(patchExpo, "6", 50); + sinon.assert.notCalled(patchCodePush); + sinon.assert.calledWithMatch(log, sinon.match("to 50% of devices")); + done(); + }) + .catch(done) + .done(); + }); + + it("patch on an Expo Updates app refuses options Expo releases don't have", (done: Mocha.Done): void => { + var command: cli.IPatchCommand = { + type: cli.CommandType.patch, + appName: "expo-app", + deploymentName: "Production", + label: "v3", + disabled: null, + description: "notes", + mandatory: null, + rollout: 50, + appStoreVersion: null, + }; + + var patchExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "patchExpoRollout"); + + cmdexec + .execute(command) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.ok(err.message.includes("can only change --rollout"), err.message); + assert.ok(err.message.includes("--label, --description"), err.message); + sinon.assert.notCalled(patchExpo); + done(); + }) + .done(); + }); it("promote works successfully", (done: Mocha.Done): void => { var command: cli.IPromoteCommand = { type: cli.CommandType.promote, @@ -1353,6 +1450,151 @@ describe("CLI", () => { }); }); + it("promote on an Expo Updates app copies the channel's releases to the destination", (done: Mocha.Done): void => { + var command: cli.IPromoteCommand = { + type: cli.CommandType.promote, + appName: "expo-app", + sourceDeploymentName: "Staging", + destDeploymentName: "Production", + description: null, + label: null, + mandatory: null, + disabled: null, + rollout: 25, + appStoreVersion: null, + }; + + var promoteExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "promoteExpo"); + var promoteCodePush: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "promote"); + + cmdexec + .execute(command) + .then(() => { + sinon.assert.calledOnceWithExactly(promoteExpo, "6", "Production", { platform: undefined, rollout: 25 }); + sinon.assert.notCalled(promoteCodePush); + sinon.assert.calledWithMatch(log, sinon.match("Promoted")); + done(); + }) + .catch(done) + .done(); + }); + + it("promote on an Expo Updates app refuses the options Expo releases do not have", (done: Mocha.Done): void => { + var command: cli.IPromoteCommand = { + type: cli.CommandType.promote, + appName: "expo-app", + sourceDeploymentName: "Staging", + destDeploymentName: "Production", + description: "notes", + label: "v3", + mandatory: null, + disabled: null, + rollout: null, + appStoreVersion: null, + }; + + var promoteExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "promoteExpo"); + + cmdexec + .execute(command) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.ok(err.message.includes("--label"), err.message); + assert.ok(err.message.includes("--description"), err.message); + sinon.assert.notCalled(promoteExpo); + done(); + }) + .done(); + }); + + it("rollback on an Expo Updates app rolls the channel back to the previous release", (done: Mocha.Done): void => { + var command: cli.IRollbackCommand = { + type: cli.CommandType.rollback, + appName: "expo-app", + deploymentName: "Production", + targetRelease: null, + }; + + var rollbackExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "rollbackExpo"); + var rollbackCodePush: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "rollback"); + + cmdexec + .execute(command) + .then(() => { + sinon.assert.calledOnceWithExactly(rollbackExpo, "6", { platform: undefined, runtimeVersion: undefined, toEmbedded: undefined }); + sinon.assert.notCalled(rollbackCodePush); + sinon.assert.calledWithMatch(log, sinon.match("back to the previous release")); + done(); + }) + .catch(done) + .done(); + }); + + it("rollback --toEmbedded on an Expo Updates app sends devices to the embedded bundle", (done: Mocha.Done): void => { + var command: cli.IRollbackCommand = { + type: cli.CommandType.rollback, + appName: "expo-app", + deploymentName: "Production", + targetRelease: null, + platform: "android", + toEmbedded: true, + }; + + var rollbackExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "rollbackExpo"); + + cmdexec + .execute(command) + .then(() => { + sinon.assert.calledOnceWithExactly(rollbackExpo, "6", { platform: "android", runtimeVersion: undefined, toEmbedded: true }); + sinon.assert.calledWithMatch(log, sinon.match("back to the embedded bundle")); + done(); + }) + .catch(done) + .done(); + }); + + it("rollback on an Expo Updates app refuses --targetRelease, which Expo releases have no label for", (done: Mocha.Done): void => { + var command: cli.IRollbackCommand = { + type: cli.CommandType.rollback, + appName: "expo-app", + deploymentName: "Production", + targetRelease: "v4", + }; + + var rollbackExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "rollbackExpo"); + + cmdexec + .execute(command) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.ok(err.message.includes("--targetRelease does not apply"), err.message); + sinon.assert.notCalled(rollbackExpo); + done(); + }) + .done(); + }); + + it("rollback on a CodePush app refuses the Expo-only options", (done: Mocha.Done): void => { + var command: cli.IRollbackCommand = { + type: cli.CommandType.rollback, + appName: "ios-app", + deploymentName: "Production", + targetRelease: null, + toEmbedded: true, + }; + + var rollback: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "rollback"); + + cmdexec + .execute(command) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.ok(err.message.includes("--toEmbedded"), err.message); + sinon.assert.notCalled(rollback); + done(); + }) + .done(); + }); it("release doesn't allow non valid semver ranges", (done: Mocha.Done): void => { var command: cli.IReleaseCommand = { type: cli.CommandType.release, @@ -2425,6 +2667,233 @@ describe("CLI", () => { } ); } + // --------------------------------------------------------------------------- + // release-expo and the wrong-command check + // --------------------------------------------------------------------------- + + const EXPO_EXPORT_DIR = path.join(__dirname, "resources", "ExpoExport"); + const EXPO_PROJECT_DIR = path.join(__dirname, "resources", "ExpoProject"); + const TEST_APP_DIR = path.join(__dirname, "resources", "TestApp"); + + function zipEntryNames(zipPath: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (err: Error, zipFile: any) => { + if (err) return reject(err); + const names: string[] = []; + zipFile.on("entry", (entry: any) => { + names.push(entry.fileName); + zipFile.readEntry(); + }); + zipFile.on("end", () => resolve(names.sort())); + zipFile.on("error", reject); + zipFile.readEntry(); + }); + }); + } + + // Runs the command with the working directory set to `dir`, then asserts on how it settled. + function executeIn(dir: string, command: cli.ICommand, done: Mocha.Done, check: (error?: any) => void, expectFailure: boolean): void { + const previous = process.cwd(); + process.chdir(dir); + cmdexec + .execute(command) + .then( + (): void => { + process.chdir(previous); + if (expectFailure) throw new Error("Should have rejected"); + check(); + }, + (error: any): void => { + process.chdir(previous); + if (!expectFailure) throw error; + check(error); + } + ) + .then(() => done(), done) + .done(); + } + + it("release-expo uploads one zip per platform with the deployment key and runtime version", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "Production", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + }; + var entriesByPlatform: { [platform: string]: string[] } = {}; + var releaseExpo: sinon.SinonStub = sandbox + .stub(cmdexec.sdk, "releaseExpo") + .callsFake((key: string, zipPath: string, platform: string) => + Q(zipEntryNames(zipPath)).then((names: string[]) => { + entriesByPlatform[platform] = names; + }) + ); + + executeIn(TEST_APP_DIR, command, done, () => { + sinon.assert.calledTwice(releaseExpo); + assert.deepEqual( + releaseExpo.args.map((args: any[]) => [args[0], args[2], args[3]]), + [ + ["6", "ios", "1.0.2"], + ["6", "android", "1.0.2"], + ] + ); + assert.deepEqual(entriesByPlatform.ios, ["_expo/static/js/ios/index-ios.hbc", "assets/0a1b2c3d", "metadata.json"]); + assert.deepEqual(entriesByPlatform.android, ["_expo/static/js/android/index-android.hbc", "assets/0a1b2c3d", "metadata.json"]); + // The temporary zips are removed; the export folder that was passed in is left alone. + assert.ok(!fs.existsSync(releaseExpo.args[0][1])); + assert.ok(fs.existsSync(path.join(EXPO_EXPORT_DIR, "metadata.json"))); + assert.ok( + log.args.some((args: any[]) => String(args[0]).includes('Successfully released iOS and Android to the "Production" deployment')) + ); + }, false); + }); + + it("release-expo --platform releases only that platform, with metadata", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "Staging", + platform: "android", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "exposdk:52.0.0", + metadata: '{"channel":"stable"}', + }; + var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); + + executeIn(TEST_APP_DIR, command, done, () => { + sinon.assert.calledOnce(releaseExpo); + assert.equal(releaseExpo.args[0][2], "android"); + assert.equal(releaseExpo.args[0][3], "exposdk:52.0.0"); + assert.deepEqual(releaseExpo.args[0][4], { channel: "stable" }); + }, false); + }); + + it("release-expo uploads nothing when the export is missing a platform", (done: Mocha.Done): void => { + var exportDir: string = fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-test-export-")); + fs.mkdirSync(path.join(exportDir, "_expo")); + fs.writeFileSync(path.join(exportDir, "_expo", "index-ios.hbc"), "ios-bytecode"); + fs.writeFileSync( + path.join(exportDir, "metadata.json"), + JSON.stringify({ version: 0, bundler: "metro", fileMetadata: { ios: { bundle: "_expo/index-ios.hbc", assets: [] } } }) + ); + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "Staging", + exportDir, + runtimeVersion: "1.0.2", + }; + var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); + + executeIn(TEST_APP_DIR, command, done, (error: any) => { + fs.rmSync(exportDir, { recursive: true, force: true }); + assert.ok(error.message.includes("has no android bundle"), error.message); + sinon.assert.notCalled(releaseExpo); + }, true); + }); + + it("release-expo refuses a CodePush app and names release-react", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "ios-app", + deploymentName: "Production", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + }; + var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); + + executeIn(TEST_APP_DIR, command, done, (error: any) => { + assert.ok(error.message.includes("is a CodePush app"), error.message); + assert.ok(error.message.includes("dpctl release-react ios-app ios -d Production"), error.message); + sinon.assert.notCalled(releaseExpo); + }, true); + }); + + it("release-expo refuses an app with no platform, which the server would reject", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "a", + deploymentName: "Staging", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + }; + var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); + + executeIn(TEST_APP_DIR, command, done, (error: any) => { + assert.ok(error.message.includes("isn't set up for Expo Updates"), error.message); + sinon.assert.notCalled(releaseExpo); + }, true); + }); + + it("release-expo rejects when the deployment does not exist", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "NonExistent", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + }; + var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); + + executeIn(TEST_APP_DIR, command, done, () => sinon.assert.notCalled(releaseExpo), true); + }); + + it("release-react refuses an Expo Updates app and names release-expo", (done: Mocha.Done): void => { + var command: cli.IReleaseReactCommand = { + type: cli.CommandType.releaseReact, + appName: "expo-app", + appStoreVersion: null, + deploymentName: "Staging", + description: null, + mandatory: false, + rollout: null, + platform: "ios", + }; + + executeIn(TEST_APP_DIR, command, done, (error: any) => { + assert.ok(error.message.includes("is an Expo Updates app"), error.message); + assert.ok(error.message.includes("dpctl release-expo expo-app -d Staging"), error.message); + sinon.assert.notCalled(spawn); + }, true); + }); + + it("release-react falls back to package.json when the app has no platform", (done: Mocha.Done): void => { + var command: cli.IReleaseReactCommand = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + deploymentName: "Staging", + description: null, + mandatory: false, + rollout: null, + platform: "android", + }; + + executeIn(EXPO_PROJECT_DIR, command, done, (error: any) => { + assert.ok(error.message.includes("This project uses expo-updates"), error.message); + sinon.assert.notCalled(spawn); + }, true); + }); + + it("detectProjectRuntime only answers when the dependencies are unambiguous", (): void => { + const dirs: string[] = []; + const projectWith = (dependencies: { [name: string]: string } | null): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-test-runtime-")); + dirs.push(dir); + if (dependencies) fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "p", dependencies })); + return dir; + }; + + assert.equal(detectProjectRuntime(projectWith({ "@code-push-next/react-native-code-push": "10.4.3" })), "codepush"); + assert.equal(detectProjectRuntime(projectWith({ "expo-updates": "0.26.0" })), "expo-updates"); + assert.equal(detectProjectRuntime(projectWith({ "expo-updates": "0.26.0", "react-native-code-push": "9.0.0" })), null); + assert.equal(detectProjectRuntime(projectWith({ "react-native": "0.76.5" })), null); + assert.equal(detectProjectRuntime(projectWith(null)), null); + dirs.forEach((dir: string) => fs.rmSync(dir, { recursive: true, force: true })); + }); + }); describe("getiOSHermesEnabled", () => { diff --git a/test/command-parser.ts b/test/command-parser.ts index dbac3a8..35069ee 100644 --- a/test/command-parser.ts +++ b/test/command-parser.ts @@ -131,6 +131,38 @@ describe("command line", function () { assert.ok(!UNKNOWN_ARGUMENT.test(result.output), result.output.slice(0, 300)); assert.ok(NOT_LOGGED_IN.test(result.output), result.output.slice(0, 300)); }); + + // --------------------------------------------------------------------------- + // Expo Updates. release-expo is a new command, and rollback grew options that only apply to it. + // --------------------------------------------------------------------------- + + it("release-expo is registered and documents the export it takes", () => { + const result = run("release-expo", "-h"); + assert.strictEqual(result.status, 0); + assert.ok(/Usage: dpctl release-expo/.test(result.output), result.output.slice(0, 400)); + ["--exportDir", "--runtimeVersion", "--platform"].forEach((flag: string) => { + assert.ok(result.output.indexOf(flag) >= 0, `${flag} is missing from release-expo help`); + }); + }); + + it("rollback accepts the Expo options", () => { + const result = run("rollback", "myapp", "Production", "--platform", "ios", "--runtimeVersion", "1.0.0", "--toEmbedded"); + assert.ok(!UNKNOWN_ARGUMENT.test(result.output), result.output.slice(0, 300)); + assert.ok(NOT_LOGGED_IN.test(result.output), result.output.slice(0, 300)); + }); + + it("each command is registered once", () => { + // A command registered twice silently wins with its last builder, so its newest options vanish + // from the parse while still showing up in the source. + const listed = run("--help") + .output.split("\n") + .map((line: string) => /^\s{2}dpctl ([a-z][a-z-]*)/.exec(line)) + .filter((match: RegExpExecArray | null): match is RegExpExecArray => !!match) + .map((match: RegExpExecArray) => match[1]); + const duplicates = listed.filter((name: string, index: number) => listed.indexOf(name) !== index); + assert.deepStrictEqual(duplicates, [], `registered more than once: ${duplicates.join(", ")}`); + assert.ok(listed.indexOf("release-expo") >= 0, "release-expo should be listed in help"); + }); }); // --------------------------------------------------------------------------- diff --git a/test/management-sdk.ts b/test/management-sdk.ts index 8607cda..4999ce4 100644 --- a/test/management-sdk.ts +++ b/test/management-sdk.ts @@ -440,6 +440,45 @@ describe("Management SDK", () => { }, rejectHandler); }); + // The Expo routes are addressed by deployment key rather than app and deployment name, and they build + // their requests directly instead of going through get/post, so what they send is worth pinning down. + + it("the Expo routes live under /expo/v1 and are addressed by deployment key", (done: Mocha.Done) => { + mockReturn(JSON.stringify({ releases: [] }), 200); + manager.patchExpoRollout("key with spaces", 25).then(() => { + assert.strictEqual(lastRequestUrl, "https://api.deploypulse.io/expo/v1/key%20with%20spaces/release"); + return manager.rollbackExpo("key with spaces", { toEmbedded: true }); + }).then(() => { + assert.strictEqual(lastRequestUrl, "https://api.deploypulse.io/expo/v1/key%20with%20spaces/rollback"); + return manager.promoteExpo("key with spaces", "Production"); + }).then(() => { + assert.strictEqual(lastRequestUrl, "https://api.deploypulse.io/expo/v1/key%20with%20spaces/promote"); + done(); + }, rejectHandler); + }); + + it("Expo requests carry the same credentials as every other route", (done: Mocha.Done) => { + mockReturn(JSON.stringify({ releases: [] }), 200); + manager.setOrgId("org-id-acme"); + manager.patchExpoRollout("dkey", 25).then(() => { + assert.strictEqual(lastRequestHeaders["Authorization"], "Bearer dummyAccessKey"); + assert.strictEqual(lastRequestHeaders["x-org-id"], "org-id-acme"); + done(); + }, rejectHandler); + }); + + it("an Expo route error reports the server's message, not the raw body", (done: Mocha.Done) => { + mockReturn(JSON.stringify({ message: "No release to roll back to on this channel." }), 409, {}, /*throwOnError=*/ false); + manager.rollbackExpo("dkey").then( + () => done(new Error("Should have rejected")), + (error: any) => { + assert.strictEqual(error.message, "No release to roll back to on this channel."); + assert.strictEqual(error.statusCode, 409); + done(); + } + ); + }); + it("getAutoRollbackConfig reads autoRollbackConfig, the key the API actually sends", (done: Mocha.Done) => { mockReturn(JSON.stringify({ autoRollbackConfig: { enabled: true, threshold: 25 } }), 200); manager.getAutoRollbackConfig("appName", "Staging").done((config: any) => { @@ -459,17 +498,20 @@ function rejectHandler(val: any): void { // Wrapper for superagent-mock that abstracts away information not needed for SDK tests let lastRequestBody: any; let lastRequestHeaders: any; +let lastRequestUrl: string; -function mockReturn(bodyText: string, statusCode: number, header = {}): void { +function mockReturn(bodyText: string, statusCode: number, header = {}, throwOnError = true): void { lastRequestBody = undefined; + lastRequestUrl = undefined; require("superagent-mock")(request, [ { pattern: "https://api.deploypulse.io/(.*)", fixtures: function (match: any, params: any, headers: any): any { lastRequestBody = params; lastRequestHeaders = headers || {}; + lastRequestUrl = match[0]; var isOk = statusCode >= 200 && statusCode < 300; - if (!isOk) { + if (!isOk && throwOnError) { var err: any = new Error(bodyText); err.status = statusCode; throw err; diff --git a/test/resources/ExpoExport/_expo/static/js/android/index-android.hbc b/test/resources/ExpoExport/_expo/static/js/android/index-android.hbc new file mode 100644 index 0000000..b7b8082 --- /dev/null +++ b/test/resources/ExpoExport/_expo/static/js/android/index-android.hbc @@ -0,0 +1 @@ +android-bytecode \ No newline at end of file diff --git a/test/resources/ExpoExport/_expo/static/js/ios/index-ios.hbc b/test/resources/ExpoExport/_expo/static/js/ios/index-ios.hbc new file mode 100644 index 0000000..a7e5470 --- /dev/null +++ b/test/resources/ExpoExport/_expo/static/js/ios/index-ios.hbc @@ -0,0 +1 @@ +ios-bytecode \ No newline at end of file diff --git a/test/resources/ExpoExport/assets/0a1b2c3d b/test/resources/ExpoExport/assets/0a1b2c3d new file mode 100644 index 0000000..5d61cfb --- /dev/null +++ b/test/resources/ExpoExport/assets/0a1b2c3d @@ -0,0 +1 @@ +png-bytes \ No newline at end of file diff --git a/test/resources/ExpoExport/metadata.json b/test/resources/ExpoExport/metadata.json new file mode 100644 index 0000000..d6df491 --- /dev/null +++ b/test/resources/ExpoExport/metadata.json @@ -0,0 +1 @@ +{"version":0,"bundler":"metro","fileMetadata":{"ios":{"bundle":"_expo/static/js/ios/index-ios.hbc","assets":[{"path":"assets/0a1b2c3d","ext":"png"}]},"android":{"bundle":"_expo/static/js/android/index-android.hbc","assets":[{"path":"assets/0a1b2c3d","ext":"png"}]}}} diff --git a/test/resources/ExpoProject/package.json b/test/resources/ExpoProject/package.json new file mode 100644 index 0000000..9d93760 --- /dev/null +++ b/test/resources/ExpoProject/package.json @@ -0,0 +1,8 @@ +{ + "name": "ExpoProject", + "dependencies": { + "expo": "~52.0.0", + "expo-updates": "~0.26.0", + "react-native": "0.76.5" + } +}