From edc574df2eab46b1f86f8eae1253914caf748bb1 Mon Sep 17 00:00:00 2001 From: Ryan Ciehanski Date: Sun, 27 Sep 2026 02:20:47 -0500 Subject: [PATCH] feat(app): record an app's platform and refuse a release that doesn't match it --- script/command-executor.ts | 22 +++- script/command-parser.ts | 19 +++- script/management-sdk.ts | 9 +- script/ota-runtime.ts | 106 ++++++++++++++++++++ script/types/rest-definitions.ts | 2 + test/cli.ts | 166 +++++++++++++++++++++++++++++++ test/management-sdk.ts | 11 +- 7 files changed, 323 insertions(+), 12 deletions(-) create mode 100644 script/ota-runtime.ts diff --git a/script/command-executor.ts b/script/command-executor.ts index 1086fbb..1090508 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -7,6 +7,7 @@ import * as crypto from "crypto"; import debugCommand from "./commands/debug"; import * as fs from "fs"; import * as hashUtils from "./hash-utils"; +import { appPlatformLabel, assertReleasePlatform, checkReleaseProjectKind } from "./ota-runtime"; import * as chalk from "chalk"; const g2js = require("gradle-to-js/lib/parser"); import * as moment from "moment"; @@ -204,7 +205,7 @@ function accessKeyRemove(command: cli.IAccessKeyRemoveCommand): Promise { } function appAdd(command: cli.IAppAddCommand): Promise { - return sdk.addApp(command.appName).then((app: App): Promise => { + return sdk.addApp(command.appName, command.platform).then((app: App): Promise => { log('Successfully added the "' + command.appName + '" app, along with the following default deployments:'); const deploymentListCommand: cli.IDeploymentListCommand = { type: cli.CommandType.deploymentList, @@ -249,6 +250,14 @@ export function signatureManifestBase(filePath: string): string { return path.dirname(filePath); } +// undefined = the app couldn't be fetched (the release itself will report why); null = no platform set. +function getAppPlatform(appName: string): Promise { + return sdk.getApp(appName).then( + (app: App): string | null => (app && app.platform) || null, + (): undefined => undefined + ); +} + export function resolvePrivateKey(value: string): string { return value.trimStart().startsWith("-----BEGIN") ? value // inline PEM content @@ -754,10 +763,10 @@ function printAppList(format: string, apps: App[]): void { if (format === "json") { printJson(apps); } else if (format === "table") { - const headers = ["Name", "Deployments"]; + const headers = ["Name", "Platform", "Deployments"]; printTable(headers, (dataSource: any[]): void => { apps.forEach((app: App, index: number): void => { - const row = [app.name, wordwrap(50)(app.deployments.join(", "))]; + const row = [app.name, appPlatformLabel(app.platform), wordwrap(50)(app.deployments.join(", "))]; dataSource.push(row); }); }); @@ -1337,7 +1346,12 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = return ( sdk .getDeployment(command.appName, command.deploymentName) - .then((): any => { + .then(() => getAppPlatform(command.appName)) + .then((appPlatform: string | null | undefined): any => { + 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)); + releaseCommand.package = outputFolder; switch (platform) { diff --git a/script/command-parser.ts b/script/command-parser.ts index 72e43ae..ce76fcc 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -348,6 +348,21 @@ yargs 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"') + .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.', + type: "string", + }) + .check((argv: any): any => { + const validPlatforms = ["ios", "android", "expo-cng-ios", "expo-cng-android"]; + if (argv.platform && !validPlatforms.includes(argv.platform)) { + throw new Error("--platform must be one of: " + validPlatforms.join(", ")); + } + return true; + }) .example("app add MyApp", 'Adds app "MyApp"'); addCommonConfiguration(yargs); @@ -1050,7 +1065,9 @@ export function createCommand(): cli.ICommand { if (arg2) { cmd = { type: cli.CommandType.appAdd }; - (cmd).appName = arg2; + const appAddCommand = cmd; + appAddCommand.appName = arg2; + appAddCommand.platform = argv["platform"] as any; } break; diff --git a/script/management-sdk.ts b/script/management-sdk.ts index 851542b..0b8014a 100644 --- a/script/management-sdk.ts +++ b/script/management-sdk.ts @@ -230,9 +230,12 @@ class AccountManager { return this.get(urlEncode([`/apps/${appName}`])).then((res: JsonResponse) => res.body.app); } - public addApp(appName: string): Promise { - const app: App = { name: appName }; - return this.post(urlEncode(["/apps/"]), JSON.stringify(app), /*expectResponseBody=*/ false).then(() => app); + public addApp(appName: string, platform?: string): Promise { + const app: App = { name: appName, ...(platform ? { platform } : {}) }; + // Resolve with the server's app, not the one sent: only its copy carries generated fields. + return this.post(urlEncode(["/apps/"]), JSON.stringify(app), /*expectResponseBody=*/ true).then( + (res: JsonResponse) => res.body.app as App + ); } public removeApp(appName: string): Promise { diff --git a/script/ota-runtime.ts b/script/ota-runtime.ts new file mode 100644 index 0000000..edbe90e --- /dev/null +++ b/script/ota-runtime.ts @@ -0,0 +1,106 @@ +// App platforms, and the guards that keep a release-react bundle going to the app it was built for. +// 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"; + +// 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)", + android: "React Native (Android)", + "expo-cng-ios": "Expo CNG (iOS)", + "expo-cng-android": "Expo CNG (Android)", + "expo-v1": "Expo Updates v1", +}; + +/** Human-readable app type. Apps created before platforms existed are plain React Native apps. */ +export function appPlatformLabel(platform: string | null | undefined): string { + return (platform && PLATFORM_LABELS[platform]) || "React Native"; +} + +const quoteArg = (value: string): string => (/\s/.test(value) ? `"${value}"` : value); + +function releaseReactHint(appName: string, deploymentName: string, appPlatform: string | null | undefined): string { + const platform = appPlatform && /ios$/.test(appPlatform) ? "ios" : appPlatform && /android$/.test(appPlatform) ? "android" : ""; + return `dpctl release-react ${quoteArg(appName)} ${platform} -d ${quoteArg(deploymentName)}`; +} + +/** "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"; + if (platform === "android" || platform === "expo-cng-android") return "android"; + return null; +} + +// `release-react MyApp-iOS android` would ship Android JavaScript to every iOS device, and the server +// cannot tell. Apps with no platform are not checked. +export function assertReleasePlatform(opts: { + appName: string; + deploymentName: string; + appPlatform: string | null | undefined; + releasePlatform: string; +}): void { + const { appName, deploymentName, appPlatform } = opts; + const expectedOs = osForAppPlatform(appPlatform); + const releaseOs = opts.releasePlatform.toLowerCase(); + if (!expectedOs || releaseOs === expectedOs) return; + throw new Error( + `"${appName}" is a ${appPlatformLabel(appPlatform)} app, so it can't take a bundle built for ${releaseOs}. ` + + `Run this instead:\n ${releaseReactHint(appName, deploymentName, appPlatform)}` + ); +} + +const EXPO_CONFIG_FILES = ["app.json", "app.config.js", "app.config.ts", "app.config.mjs", "app.config.cjs"]; + +// "expo-cng" when the app config registers the CodePush config plugin, "bare" for React Native with no +// `expo` package, null otherwise. The `expo` dependency alone proves nothing: bare apps use Expo modules. +export function detectCodePushProjectKind(projectRoot: string = process.cwd()): "expo-cng" | "bare" | null { + let pkg: any; + try { + pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8")); + } catch { + return null; + } + const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; + const usesConfigPlugin = EXPO_CONFIG_FILES.some((file: string) => { + try { + // Both plugin forms count. `/expo` is the subpath the docs used before the SDK shipped an + // app.plugin.js; the bare package name is what they use now. Any fork matches, same as the + // dependency check above. + return /react-native-code-push/.test(fs.readFileSync(path.join(projectRoot, file), "utf8")); + } catch { + return false; + } + }); + if (usesConfigPlugin) return "expo-cng"; + if (!deps["expo"] && deps["react-native"]) return "bare"; + return null; +} + +// Throws for an Expo CNG app released from a non-Expo folder. The reverse only warns: the bundle is still +// valid, and an app's platform can't be changed, so a bare app that moved to CNG must still release. +export function checkReleaseProjectKind(opts: { + appName: string; + appPlatform: string | null | undefined; + projectRoot?: string; +}): string | null { + const { appName, appPlatform } = opts; + if (!osForAppPlatform(appPlatform)) return null; + const kind = detectCodePushProjectKind(opts.projectRoot); + const appIsExpoCng = appPlatform === "expo-cng-ios" || appPlatform === "expo-cng-android"; + + if (appIsExpoCng && kind === "bare") { + throw new Error( + `"${appName}" is an ${appPlatformLabel(appPlatform)} app, but this project doesn't use Expo, so it's ` + + `almost certainly the wrong folder. Run release-react from the Expo project this app belongs to.` + ); + } + if (!appIsExpoCng && kind === "expo-cng") { + return ( + `This is an Expo CNG project, but "${appName}" was created as a ${appPlatformLabel(appPlatform)} app. ` + + `The release will still work. If this isn't the app you meant, stop now and check the app name.` + ); + } + return null; +} diff --git a/script/types/rest-definitions.ts b/script/types/rest-definitions.ts index b2d128b..bfa33dd 100644 --- a/script/types/rest-definitions.ts +++ b/script/types/rest-definitions.ts @@ -115,6 +115,8 @@ 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. + platform?: string | null; /*generated*/ collaborators?: CollaboratorMap; /*key*/ name: string; /*generated*/ deployments?: string[]; diff --git a/test/cli.ts b/test/cli.ts index a881fd0..d06aa5d 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -10,6 +10,7 @@ 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 * as hashUtils from "../script/hash-utils"; import * as os from "os"; import moment = require("moment"); @@ -120,6 +121,12 @@ 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"] }); + } + public getApps(): Q.Promise { return Q([ { @@ -133,6 +140,7 @@ export class SdkStub { { id: "app-b-id", name: "b", + platform: "expo-v1", collaborators: { "a@a.com": { permission: "Owner", isCurrentAccount: true }, }, @@ -591,6 +599,7 @@ describe("CLI", () => { { id: "app-b-id", name: "b", + platform: "expo-v1", collaborators: { "a@a.com": { permission: "Owner", @@ -606,6 +615,103 @@ describe("CLI", () => { }); }); + it("appList table shows each app's platform, with legacy apps as React Native", (done: Mocha.Done): void => { + var command: cli.IAppListCommand = { + type: cli.CommandType.appList, + format: "table", + }; + + cmdexec.execute(command).done((): void => { + var output: string = log.args.map((args: any[]) => String(args[0])).join("\n"); + var rowFor = (name: string): string => output.split("\n").find((line: string) => line.includes(` ${name} `)) || ""; + assert.ok(output.includes("Platform"), output); + assert.ok(rowFor("a").includes("React Native"), output); + assert.ok(!rowFor("a").includes("Expo"), output); + assert.ok(rowFor("b").includes("Expo Updates v1"), output); + done(); + }); + }); + + it("appAdd passes platform to SDK when provided", (done: Mocha.Done): void => { + var command: cli.IAppAddCommand = { + type: cli.CommandType.appAdd, + appName: "a", + os: "", + platform: "expo-cng-ios", + }; + + var addApp: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "addApp"); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(addApp); + sinon.assert.calledWith(addApp, "a", "expo-cng-ios"); + done(); + }); + }); + + it("appAdd works without platform for backwards compatibility", (done: Mocha.Done): void => { + var command: cli.IAppAddCommand = { + type: cli.CommandType.appAdd, + appName: "a", + os: "", + platform: null, + }; + + var addApp: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "addApp"); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(addApp); + sinon.assert.calledWith(addApp, "a", null); + done(); + }); + }); + + it("refuses a release-react platform that isn't the app's", (): void => { + const run = (appPlatform: string | null, releasePlatform: string) => () => + assertReleasePlatform({ appName: "MyApp-iOS", deploymentName: "Production", appPlatform, releasePlatform }); + + assert.throws(run("ios", "android"), /can't take a bundle built for android[\s\S]*release-react MyApp-iOS ios -d Production/); + assert.throws(run("expo-cng-ios", "android"), /Expo CNG \(iOS\)/); + assert.throws(run("expo-cng-android", "IOS"), /release-react MyApp-iOS android/); + assert.doesNotThrow(run("ios", "ios")); + assert.doesNotThrow(run("expo-cng-android", "Android")); + assert.doesNotThrow(run(null, "android"), "apps created before platforms existed are not checked"); + }); + + it("tells an Expo CNG project from a bare one by the config plugin, not the expo package", (): void => { + const dirs: string[] = []; + const project = (dependencies: { [name: string]: string }, appJson?: object): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-test-kind-")); + dirs.push(dir); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "p", dependencies })); + if (appJson) fs.writeFileSync(path.join(dir, "app.json"), JSON.stringify(appJson)); + return dir; + }; + const cngConfig = { expo: { plugins: [["@code-push-next/react-native-code-push/expo", { ios: {} }]] } }; + const cng = project({ expo: "~53.0.0", "react-native": "0.79.3" }, cngConfig); + // The bare package name is the plugin form the docs use now that the SDK ships an app.plugin.js. + const cngBareName = project({ expo: "~53.0.0", "react-native": "0.79.3" }, + { expo: { plugins: [["@deploypulseio/react-native-code-push", { ios: {} }]] } }); + const bare = project({ "react-native": "0.79.3" }); + const bareWithExpoModules = project({ expo: "~53.0.0", "react-native": "0.79.3" }); // no config plugin + + assert.equal(detectCodePushProjectKind(cng), "expo-cng"); + assert.equal(detectCodePushProjectKind(cngBareName), "expo-cng", "the bare plugin name counts too"); + assert.equal(detectCodePushProjectKind(bare), "bare"); + assert.equal(detectCodePushProjectKind(bareWithExpoModules), null, "the expo package alone proves nothing"); + + // An Expo CNG app from a project with no Expo at all is the wrong folder: blocked. + assert.throws(() => checkReleaseProjectKind({ appName: "MyApp-iOS", appPlatform: "expo-cng-ios", projectRoot: bare }), /doesn't use Expo/); + // A bare app from an Expo CNG project still works (platform can't be changed after creation): warned only. + const warning = checkReleaseProjectKind({ appName: "MyApp-iOS", appPlatform: "ios", projectRoot: cng }); + assert.ok(warning && warning.includes("The release will still work"), String(warning)); + // Matching kinds, or a project it can't classify, say nothing. + assert.strictEqual(checkReleaseProjectKind({ appName: "A", appPlatform: "expo-cng-ios", projectRoot: cng }), null); + assert.strictEqual(checkReleaseProjectKind({ appName: "A", appPlatform: "ios", projectRoot: bare }), null); + assert.strictEqual(checkReleaseProjectKind({ appName: "A", appPlatform: "expo-cng-ios", projectRoot: bareWithExpoModules }), null); + dirs.forEach((dir: string) => fs.rmSync(dir, { recursive: true, force: true })); + }); + it("appRemove removes app", (done: Mocha.Done): void => { var command: cli.IAppRemoveCommand = { type: cli.CommandType.appRemove, @@ -1349,6 +1455,66 @@ describe("CLI", () => { .done(); }); + it("release-react refuses a bundle built for a platform that isn't the app's", (done: Mocha.Done): void => { + sandbox.stub(cmdexec.sdk, "getApp").callsFake(() => Q({ id: "app-a-id", name: "a", platform: "ios" })); + + var command: cli.IReleaseReactCommand = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + deploymentName: "Staging", + description: "Wrong platform", + mandatory: false, + rollout: null, + platform: "android", + }; + + ensureInTestAppDirectory(); + + var release: sinon.SinonSpy = sandbox.spy(cmdexec, "release"); + + cmdexec + .execute(command) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.ok(/can't take a bundle built for android/.test(err.message), err.message); + sinon.assert.notCalled(release); + sinon.assert.notCalled(spawn); + done(); + }) + .done(); + }); + + it("release-react refuses an Expo CNG app released from a project with no Expo", (done: Mocha.Done): void => { + // The test app is a bare React Native project, so this is the wrong folder for a CNG app. + sandbox.stub(cmdexec.sdk, "getApp").callsFake(() => Q({ id: "app-a-id", name: "a", platform: "expo-cng-ios" })); + + var command: cli.IReleaseReactCommand = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + deploymentName: "Staging", + description: "Wrong folder", + mandatory: false, + rollout: null, + platform: "ios", + }; + + ensureInTestAppDirectory(); + + var release: sinon.SinonSpy = sandbox.spy(cmdexec, "release"); + + cmdexec + .execute(command) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.ok(/doesn't use Expo/.test(err.message), err.message); + sinon.assert.notCalled(release); + done(); + }) + .done(); + }); + it("release-react fails if platform is invalid", (done: Mocha.Done): void => { var command: cli.IReleaseReactCommand = { type: cli.CommandType.releaseReact, diff --git a/test/management-sdk.ts b/test/management-sdk.ts index 0af3c5b..f5ad4e8 100644 --- a/test/management-sdk.ts +++ b/test/management-sdk.ts @@ -123,12 +123,15 @@ describe("Management SDK", () => { ); }); - it("addApp handles successful response", (done: Mocha.Done) => { - mockReturn(JSON.stringify({ success: true }), 201, { + it("addApp resolves with the app the server created", (done: Mocha.Done) => { + mockReturn(JSON.stringify({ app: { id: "server-generated-id", name: "appName", platform: "ios" } }), 201, { location: "/appName", }); - manager.addApp("appName").done((obj) => { - assert.ok(obj); + manager.addApp("appName", "ios").done((app: any) => { + assert.equal(app.name, "appName"); + assert.equal(app.platform, "ios"); + // Only the server's copy carries generated fields, so this fails if the posted object is resolved. + assert.equal(app.id, "server-generated-id"); done(); }, rejectHandler); });