Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions src/cli/commands/releaseTrain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,29 +43,33 @@ export const registerReleaseTrainCommand = (program: Command): void => {
.option('--json', 'machine-readable output for CI/agents', false);
addEnvFlags(releaseTrainCommand).action(
(action: string, trainId: string | undefined, commandOptions: ReleaseTrainOptions) => {
const optionalOptions: {
const releaseTrainOptions: {
app?: string;
profile: string;
platform?: string;
ota: boolean;
hold?: boolean;
channel: string;
runtimeVersion?: string;
watch?: boolean;
json?: boolean;
} = {};
if (commandOptions.app !== undefined) optionalOptions.app = commandOptions.app;
if (commandOptions.platform !== undefined) optionalOptions.platform = commandOptions.platform;
if (commandOptions.hold !== undefined) optionalOptions.hold = commandOptions.hold;
if (commandOptions.runtimeVersion !== undefined)
optionalOptions.runtimeVersion = commandOptions.runtimeVersion;
if (commandOptions.watch !== undefined) optionalOptions.watch = commandOptions.watch;
if (commandOptions.json !== undefined) optionalOptions.json = commandOptions.json;
const releaseTrainOptions = {
env: string[];
includeLocal: boolean;
} = {
profile: commandOptions.profile,
ota: commandOptions.ota,
channel: commandOptions.channel,
env: commandOptions.env,
includeLocal: commandOptions.includeLocal,
...optionalOptions,
};
Comment on lines +46 to 64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The CLI registers the shared --print-env option through addEnvFlags, but this newly constructed options object omits printEnv. As a result, invoking release-train --print-env silently discards the user's request and executes the train normally instead of printing the resolved environment or rejecting the unsupported option. Preserve the flag if release-train supports it, or stop registering it for this command. [api mismatch]

Severity Level: Major ⚠️
-`release-train --print-env` runs the train command unexpectedly.
- ⚠️ CI/debug environment inspection output is unavailable.
- ⚠️ Users may trigger status, startup, or store API work accidentally.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/cli/commands/releaseTrain.ts
**Line:** 46:64
**Comment:**
	*Api Mismatch: The CLI registers the shared `--print-env` option through `addEnvFlags`, but this newly constructed options object omits `printEnv`. As a result, invoking `release-train --print-env` silently discards the user's request and executes the train normally instead of printing the resolved environment or rejecting the unsupported option. Preserve the flag if release-train supports it, or stop registering it for this command.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

if (commandOptions.app !== undefined) releaseTrainOptions.app = commandOptions.app;
if (commandOptions.platform !== undefined)
releaseTrainOptions.platform = commandOptions.platform;
if (commandOptions.hold !== undefined) releaseTrainOptions.hold = commandOptions.hold;
if (commandOptions.runtimeVersion !== undefined)
releaseTrainOptions.runtimeVersion = commandOptions.runtimeVersion;
if (commandOptions.watch !== undefined) releaseTrainOptions.watch = commandOptions.watch;
if (commandOptions.json !== undefined) releaseTrainOptions.json = commandOptions.json;
if (trainId === undefined) {
return runCliProgram(releaseTrainCommandProgram({ action, options: releaseTrainOptions }));
}
Expand Down
85 changes: 62 additions & 23 deletions src/core/releaseTrain/builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,42 @@ import { GoogleStoreClientLive } from '../services/googleStoreClient.js';
import { createLogger, makeLaunchLoggerTest } from '../services/logger.js';
import { makeLaunchPathsTest } from '../services/paths.js';
import { LaunchSecretStoreTest } from '../services/secretStore.js';
import { buildTrainRuntime, type TrainRuntimeRequirements } from './builder.js';
/** A minimal config - `storage: "local"` so OTA is gated off; the engine never reaches a store client. */
const config = (overrides: Partial<LaunchConfig> = {}): LaunchConfig => {
return {
profiles: {},
credentials: 'local',
storage: 'local',
buildEngine: 'fastlane',
submit: 'app-store-connect',
...overrides,
};
};
/** A minimal app; pass `bundleId` / `packageName` to declare a native platform. */
const app = (overrides: Partial<AppDescriptor> = {}): AppDescriptor => {
return { name: 'Demo', dir: '/tmp/demo', configPath: '/tmp/demo/app.json', ...overrides };
};
import {
createTrainRuntime,
isStoredAndroidBuild,
isUsableIosStoreBuild,
marketingVersionMissingMessage,
type TrainRuntimeRequirements,
usableMarketingVersion,
} from './builder.js';

/** Minimal config - local storage gates OTA off so the engine never reaches a store client. */
const config = (overrides: Partial<LaunchConfig> = {}): LaunchConfig => ({
profiles: {},
credentials: 'local',
storage: 'local',
buildEngine: 'fastlane',
submit: 'app-store-connect',
...overrides,
});

/** Minimal app; pass bundleId / packageName to declare a native platform. */
const app = (overrides: Partial<AppDescriptor> = {}): AppDescriptor => ({
name: 'Demo',
dir: '/tmp/demo',
configPath: '/tmp/demo/app.json',
...overrides,
});

const profile: BuildProfile = { name: 'production' };
const logger = Effect.runSync(createLogger(false).pipe(Effect.provide(makeLaunchLoggerTest([]))));
/** Construct an engine the same way the `release-train` command does, with the given app/config. */

const engineFor = (
appOverrides: Partial<AppDescriptor>,
configOverrides: Partial<LaunchConfig> = {},
) => {
return buildTrainRuntime(config(configOverrides), app(appOverrides), profile, {}, false, logger)
.engine;
};
) =>
createTrainRuntime(config(configOverrides), app(appOverrides), profile, {}, false, logger).engine;

/** Run one live-engine guard with deterministic platform services. */
const runEngineGuard = <Success, Failure>(
engineOperation: Effect.Effect<Success, Failure, TrainRuntimeRequirements>,
): Promise<Success> =>
Expand All @@ -51,6 +59,7 @@ const runEngineGuard = <Success, Failure>(
Effect.provide(NodeContext.layer),
),
);

const iosCar: NativeCar = { kind: 'ios', state: 'building', updatedAt: '2026-06-25T00:00:00Z' };
const androidCar: NativeCar = {
kind: 'android',
Expand All @@ -65,22 +74,51 @@ const otaCar: OtaCar = {
state: 'pending',
updatedAt: '2026-06-25T00:00:00Z',
};
describe('buildTrainRuntime - engine guards fail loudly before any store call', () => {

describe('createTrainRuntime pure helpers', () => {
it('accepts only processed, non-expired App Store builds', () => {
expect(isUsableIosStoreBuild({ processingState: 'VALID', expired: false })).toBe(true);
expect(isUsableIosStoreBuild({ processingState: 'VALID', expired: true })).toBe(false);
expect(isUsableIosStoreBuild({ processingState: 'PROCESSING', expired: false })).toBe(false);
});

it('matches stored Android builds by app name and platform', () => {
expect(isStoredAndroidBuild({ appName: 'Demo', platform: 'android' }, 'Demo')).toBe(true);
expect(isStoredAndroidBuild({ appName: 'Demo', platform: 'ios' }, 'Demo')).toBe(false);
expect(isStoredAndroidBuild({ appName: 'Other', platform: 'android' }, 'Demo')).toBe(false);
});

it('names the marketing-version fix in the failure message', () => {
expect(marketingVersionMissingMessage('Demo')).toContain('Demo');
expect(marketingVersionMissingMessage('Demo')).toContain('version');
});

it('keeps only non-empty marketing versions', () => {
expect(usableMarketingVersion(undefined)).toBeNull();
expect(usableMarketingVersion('')).toBeNull();
expect(usableMarketingVersion('1.2.3')).toBe('1.2.3');
});
});

describe('createTrainRuntime - engine guards fail loudly before any store call', () => {
it('rejects an iOS submit when the app declares no bundle id', async () => {
await expect(
runEngineGuard(engineFor({ packageName: 'com.demo' }).submitNative(iosCar)),
).rejects.toThrow('has no iOS bundle id');
});

it('rejects an Android submit when the app declares no package name', async () => {
await expect(
runEngineGuard(engineFor({ bundleId: 'com.demo' }).submitNative(androidCar)),
).rejects.toThrow('has no Android package');
});

it('rejects an OTA publish when storage is not a cloud provider', async () => {
await expect(
runEngineGuard(engineFor({ bundleId: 'com.demo' }, { storage: 'local' }).publishOta(otaCar)),
).rejects.toThrow('OTA needs a cloud storage provider');
});

it('keeps a native car put on read when its platform is undeclared (no client constructed)', async () => {
expect(await runEngineGuard(engineFor({ packageName: 'com.demo' }).readNative(iosCar))).toBe(
'building',
Expand All @@ -89,6 +127,7 @@ describe('buildTrainRuntime - engine guards fail loudly before any store call',
'building',
);
});

it('is a no-op to release a non-iOS car or an iOS car with no bundle id', async () => {
await expect(
runEngineGuard(engineFor({ bundleId: 'com.demo' }).releaseNative(androidCar)),
Expand Down
Loading
Loading