Skip to content
Open
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
85 changes: 80 additions & 5 deletions packages/shorebird_cli/lib/src/code_push_client_wrapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ class CodePushClientWrapper {
/// The underlying code push client.
final CodePushClient codePushClient;

/// Patch ids promoted by this wrapper instance — command-scoped, so a
/// fresh `shorebird patch` gets an empty set and cross-invocation reuse
/// still prompts. Suppresses the append-after-promotion warning for the
/// second platform of a single `--platforms ios,android` invocation.
final Set<int> _patchesPromotedThisRun = {};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

bdero's review bot:

🧹 The name reads as if it tracks global state — it actually tracks "promotions made by this wrapper instance," which has command-scoped lifetime via the scoped ref. A field comment noting the scope (and that cross-invocation reuse correctly prompts because the set starts empty) would save the next reader the trace.


/// Create an app with the given [organizationId] and [appName].
Future<App> createApp({required int organizationId, String? appName}) async {
late final String displayName;
Expand Down Expand Up @@ -872,18 +878,24 @@ aar artifact already exists, continuing...''');
}

/// Creates a patch for the given [appId], [releaseId], and [metadata].
///
/// When [clientPatchId] is supplied, the server treats the call as
/// idempotent: if a patch on this release already has that id, the
/// existing patch is returned instead of creating a new one.
@visibleForTesting
Future<Patch> createPatch({
Future<CreatePatchResponse> createPatch({
required String appId,
required int releaseId,
required Json metadata,
String? clientPatchId,
}) async {
final createPatchProgress = logger.progress('Creating patch');
try {
final patch = await codePushClient.createPatch(
appId: appId,
releaseId: releaseId,
metadata: metadata,
clientPatchId: clientPatchId,
);
createPatchProgress.complete();
return patch;
Expand All @@ -896,7 +908,7 @@ aar artifact already exists, continuing...''');
@visibleForTesting
Future<void> createPatchArtifacts({
required String appId,
required Patch patch,
required CreatePatchResponse patch,
required ReleasePlatform platform,
required Map<Arch, PatchArtifactBundle> patchArtifactBundles,
}) async {
Expand Down Expand Up @@ -943,21 +955,37 @@ aar artifact already exists, continuing...''');

/// Publishes a patch to the Shorebird server. This consists of creating a
/// patch, uploading patch artifacts, and promoting the patch to a specific
/// channel based on the provided [track].
Future<void> publishPatch({
/// channel based on the provided [track]. Returns the resulting
/// [CreatePatchResponse] so callers can compose a final success message
/// that spans every platform in the invocation.
Future<CreatePatchResponse> publishPatch({
required String appId,
required int releaseId,
required Json metadata,
required ReleasePlatform platform,
required DeploymentTrack track,
required Map<Arch, PatchArtifactBundle> patchArtifactBundles,
String? clientPatchId,
}) async {
final patch = await createPatch(
appId: appId,
releaseId: releaseId,
metadata: metadata,
clientPatchId: clientPatchId,
);

// When the create call was idempotent (a clientPatchId hit on an existing
// patch row that's already on the stable channel), uploading this
// platform's artifacts will make them go live immediately to that
// platform's stable users. The server is permissive; the CLI is
// responsible for making sure the developer knows.
if (clientPatchId != null) {
await _confirmAppendToPromotedPatch(
patch: patch,
platform: platform,
);
}

await createPatchArtifacts(
appId: appId,
patch: patch,
Expand All @@ -970,8 +998,55 @@ aar artifact already exists, continuing...''');
await createChannel(appId: appId, name: track.channel);

await promotePatch(appId: appId, patchId: patch.id, channel: channel);
_patchesPromotedThisRun.add(patch.id);

return patch;
}

logger.success('\n✅ Published Patch ${patch.number}!');
/// Detects the "idempotent hit on an already-promoted patch" case: the
/// caller passed a clientPatchId that matched an existing patch which is
/// already promoted to the stable channel. Uploading this platform's
/// artifacts will go live immediately to that platform's stable users.
///
/// In an interactive terminal we surface that fact and prompt before
/// continuing. In CI we proceed silently — the iOS-promotes-then-Android-
/// completes flow is the canonical use case and prompting would deadlock.
///
/// Skipped entirely when this run is the one that promoted the patch
/// (e.g. the second platform of a `--platforms ios,android` invocation),
/// since the user already opted in by passing both platforms together.
///
/// The patch's current channel comes from [CreatePatchResponse.channel],
/// which the server populates on the idempotent return path. Older servers
/// that don't echo `channel` leave it null and the prompt simply doesn't
/// fire — append-after-promotion stays allowed per the design.
Future<void> _confirmAppendToPromotedPatch({
required CreatePatchResponse patch,
required ReleasePlatform platform,
}) async {
// If this run promoted the patch itself (e.g. the first platform of
// `--platforms ios,android`), the "already on stable" state is our own
// doing and the prompt would be spurious.
if (_patchesPromotedThisRun.contains(patch.id)) return;

if (patch.channel != DeploymentTrack.stable.channel) return;

final platformName = platform.displayName;
final message =
'Patch ${patch.number} is already promoted to the stable track. '
'Uploading $platformName artifacts will go live to $platformName '
'stable users immediately.';

if (shorebirdEnv.canAcceptUserInput) {
logger.warn(message);
if (!logger.confirm('Continue?')) {
logger.info('Aborting.');
throw ProcessExit(ExitCode.success.code);
}
} else {
// CI: log so the action is traceable, but proceed.
logger.info(message);
}
}

/// Returns a GCP download link for measuring download speed.
Expand Down
135 changes: 134 additions & 1 deletion packages/shorebird_cli/lib/src/commands/patch/patch_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:shorebird_cli/src/commands/patch/patch.dart';
import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/extensions/string.dart';
import 'package:shorebird_cli/src/formatters/formatters.dart';
Expand All @@ -30,6 +31,7 @@ import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_cli/src/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:uuid/uuid.dart';

/// Signature for a function that returns a [Patcher] for a given [ReleaseType].
typedef ResolvePatcher = Patcher Function(ReleaseType releaseType);
Expand Down Expand Up @@ -98,6 +100,19 @@ To target the latest release (e.g. the release that was most recently updated) u
help: 'The track to publish the patch to.',
defaultsTo: DeploymentTrack.stable.channel,
)
..addOption(
'patch-id',
help: '''
A stable correlation key (e.g. a git SHA) used to unify a logical patch
across platforms. When two invocations on the same release supply the same
--patch-id, the server returns the existing patch instead of allocating a
new number — the iOS and Android halves of the same change end up sharing
one patch number visible to end users.

When omitted on a multi-platform invocation, the CLI generates a fresh
correlation key locally so the platforms in this single command share one
patch.''',
)
..addFlag(
'staging',
negatable: false,
Expand Down Expand Up @@ -215,6 +230,80 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl
/// The deployment track to publish the patch to.
DeploymentTrack get track => DeploymentTrack(results['track'] as String);

/// The correlation key used to make patch creation idempotent across
/// platforms within this invocation. When the user passes `--patch-id`,
/// that value is used as-is. When the user omits it on a multi-platform
/// invocation, a fresh UUID is generated so the platforms in this single
/// command end up sharing one patch number on the server.
///
/// Returns null only for single-platform invocations with no `--patch-id`,
/// preserving the legacy behavior where the server allocates a fresh patch
/// number per call.
///
/// Resolved on first read and cached for the rest of the command so every
/// platform's `createPatch` sees the same value. Requires `argResults` to
/// be wired before first access — every code path through `run()` (and
/// every test going through `runWithOverrides`) satisfies that.
late final String? clientPatchId = _resolveClientPatchId();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

bdero's review bot:

🧹 late final String? clientPatchId = _resolveClientPatchId(); works, but _resolveClientPatchId() reads results[...] and calls Uuid().v4(), which means the order of first access decides which Uuid instance is materialized. Tests have to go through runWithOverrides for argResults to be wired before the first access — they do — but a getter (or a method returning a memoized value) reads more obviously than late final-with-side-effect.


String? _resolveClientPatchId() {
// `run()` already rejects an empty `--patch-id`, so non-null here implies
// non-empty. Empty-to-null coalescence lives at the client boundary
// (`CodePushClient.createPatch`) and stays the single normalizer.
final explicit = results['patch-id'] as String?;
if (explicit != null) return explicit;
if (results.releaseTypes.length > 1) return const Uuid().v4();
return null;
}
Comment on lines +249 to +257
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

bdero's review bot:

💡 if (explicit != null && explicit.isNotEmpty) return explicit; treats --patch-id="" and --patch-id (omitted) the same. The failure mode: someone writes --patch-id=${{ env.PATCH_SHA }} in their CI template, the env var is undefined, the flag arrives as "", and on a multi-platform invocation we silently generate a UUID — the iOS bot and the Android bot never converge because each generates its own. Loud failure when the flag is present but empty would surface this on the first broken CI run instead of after someone notices the patches aren't unifying.


/// Patches collected from each platform's [createPatch] run, used to emit
/// a single aggregated success message after the fan-out completes.
@visibleForTesting
final platformPatches = <ReleasePlatform, CreatePatchResponse>{};

/// Warns when `--patch-id` is the current `HEAD` commit but the working
/// tree has uncommitted changes — the SHA wouldn't actually identify the
/// code being shipped. Detection is intentionally narrow (literal equality
/// with `git rev-parse HEAD`) so non-SHA ids like `hotfix-login` or CI run
/// IDs never trigger a false positive. Non-blocking: local debug patches
/// over uncommitted code are a legitimate flow.
@visibleForTesting
Future<void> warnIfDirtyTreeMatchesPatchId() async {
final patchId = results['patch-id'] as String?;
if (patchId == null || patchId.isEmpty) return;

final projectRoot = shorebirdEnv.getShorebirdProjectRoot();
if (projectRoot == null) return;

final String head;
try {
head = await git.revParse(
revision: 'HEAD',
directory: projectRoot.path,
);
} on Exception {
return;
}
if (patchId != head) return;

final String porcelain;
try {
porcelain = await git.status(
directory: projectRoot.path,
args: ['--porcelain'],
);
} on Exception {
return;
}
if (porcelain.isEmpty) return;

logger.warn(
'--patch-id is set to HEAD ($patchId), but the working tree has '
'uncommitted changes — this SHA does not identify the code being '
'shipped.',
);
}

@override
Future<int> run() async {
if (results.releaseTypes.isEmpty) {
Expand All @@ -231,6 +320,20 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl
return ExitCode.usage.code;
}

// Reject `--patch-id=` (present but empty) outright. The typical cause is
// an unexpanded CI template variable; silently treating it as omitted
// would mean a multi-platform invocation generates a fresh UUID per bot,
// so iOS and Android never converge on the same patch number.
final patchIdRaw = results['patch-id'] as String?;
if (patchIdRaw != null && patchIdRaw.isEmpty) {
logger.err(
r'''--patch-id was provided but is empty. This usually means an unexpanded template variable in your CI config (e.g. --patch-id=${{ env.PATCH_SHA }} where PATCH_SHA is not set). Pass a non-empty value or omit the flag.''',
);
return ExitCode.usage.code;
}

await warnIfDirtyTreeMatchesPatchId();

final patcherFutures = results.releaseTypes
.map(_resolvePatcher)
.map(createPatch);
Expand All @@ -239,9 +342,37 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl
await patcherFuture;
}

logUnifiedSuccess();

return ExitCode.success.code;
}

/// Emits one aggregated success line per patch number that ended up with
/// at least one published platform — replaces the old per-platform log
/// that used to live inside the wrapper's `publishPatch`.
@visibleForTesting
void logUnifiedSuccess() {
if (platformPatches.isEmpty) return;

// Group platforms by the patch number they ended up under. Under unified
// numbering this is almost always a single group, but pinning by number
// keeps the message correct in degenerate cases (different releases per
// platform, the user explicitly skipping --patch-id on a multi-platform
// call against a server that hasn't been updated, etc.).
final byPatchNumber = <int, List<ReleasePlatform>>{};
for (final entry in platformPatches.entries) {
byPatchNumber.putIfAbsent(entry.value.number, () => []).add(entry.key);
}

for (final number in byPatchNumber.keys.toList()..sort()) {
final platforms =
byPatchNumber[number]!.map((p) => p.displayName).toList()..sort();
logger.success(
'\n✅ Published Patch $number (${platforms.join(', ')})!',
);
}
}

/// Returns a [Patcher] for the given [ReleaseType].
@visibleForTesting
Patcher getPatcher(ReleaseType releaseType) {
Expand Down Expand Up @@ -574,13 +705,15 @@ Building patch with Flutter $flutterVersionString
baseMetadata,
);

await patcher.uploadPatchArtifacts(
final publishedPatch = await patcher.uploadPatchArtifacts(
appId: appId,
releaseId: release.id,
metadata: updateMetadata.toJson(),
track: track,
artifacts: patchArtifactBundles,
clientPatchId: clientPatchId,
);
platformPatches[patcher.releaseType.releasePlatform] = publishedPatch;
},
values: {shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv)},
);
Expand Down
10 changes: 7 additions & 3 deletions packages/shorebird_cli/lib/src/commands/patch/patcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,21 +134,25 @@ More info: ${troubleshootingUrl.toLink()}.
return metadata;
}

/// Uploads the patch artifacts to the CodePush server.
Future<void> uploadPatchArtifacts({
/// Uploads the patch artifacts to the CodePush server. Returns the resulting
/// [CreatePatchResponse] so the orchestrating command can emit one
/// aggregated success message across every platform in the invocation.
Future<CreatePatchResponse> uploadPatchArtifacts({
required String appId,
required int releaseId,
required Map<String, dynamic> metadata,
required Map<Arch, PatchArtifactBundle> artifacts,
required DeploymentTrack track,
String? clientPatchId,
}) async {
await codePushClientWrapper.publishPatch(
return codePushClientWrapper.publishPatch(
appId: appId,
releaseId: releaseId,
metadata: metadata,
platform: releaseType.releasePlatform,
track: track,
patchArtifactBundles: artifacts,
clientPatchId: clientPatchId,
);
}

Expand Down
Loading
Loading