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
2 changes: 2 additions & 0 deletions cspell.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ words:
- reimplementation
- reinit
- requirepass # From .github dir, doesn't show up in "**" check?
- resends # "server resends the same patch on rollforward"
- reusables
- rollforward # POST /.../patches/<id>/rollforward — un-rolls-back a patch
- spawnee
- Retryable
- RSAPKCS
Expand Down
50 changes: 50 additions & 0 deletions packages/shorebird_cli/lib/src/code_push_client_wrapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,56 @@ aar artifact already exists, continuing...''');
}
}

/// Rolls back the patch identified by [patchId] under [releaseId].
///
/// [patchNumber] is used purely for the human-readable progress message;
/// pass it through when the caller already has it on hand.
Future<void> rollbackPatch({
required String appId,
required int releaseId,
required int patchId,
int? patchNumber,
}) async {
final label = patchNumber != null ? 'patch $patchNumber' : 'patch';
final progress = logger.progress('Rolling back $label');
try {
await codePushClient.rollbackPatch(
appId: appId,
releaseId: releaseId,
patchId: patchId,
);
progress.complete();
} catch (error) {
_handleErrorAndExit(error, progress: progress);
}
}

/// Rolls forward (un-rolls-back) the patch identified by [patchId] under
/// [releaseId]. Returns the patch to its active state; the server resends
/// the same patch artifact to devices on the next patch check.
///
/// [patchNumber] is used purely for the human-readable progress message;
/// pass it through when the caller already has it on hand.
Future<void> rollforwardPatch({
required String appId,
required int releaseId,
required int patchId,
int? patchNumber,
}) async {
final label = patchNumber != null ? 'patch $patchNumber' : 'patch';
final progress = logger.progress('Rolling forward $label');
try {
await codePushClient.rollforwardPatch(
appId: appId,
releaseId: releaseId,
patchId: patchId,
);
progress.complete();
} catch (error) {
_handleErrorAndExit(error, progress: progress);
}
}

/// 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].
Expand Down
2 changes: 2 additions & 0 deletions packages/shorebird_cli/lib/src/commands/patches/patches.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ export 'patches_command.dart';
export 'patches_info_command.dart';
export 'patches_list_command.dart';
export 'promote_command.dart';
export 'rollback_command.dart';
export 'rollforward_command.dart';
export 'set_track_command.dart';
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class PatchesCommand extends ShorebirdCommand {
addSubcommand(PatchesInfoCommand());
addSubcommand(PatchesListCommand());
addSubcommand(PromoteCommand());
addSubcommand(RollbackCommand());
addSubcommand(RollforwardCommand());
addSubcommand(SetTrackCommand());
}

Expand Down
155 changes: 155 additions & 0 deletions packages/shorebird_cli/lib/src/commands/patches/rollback_command.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import 'package:collection/collection.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/json_output.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/shorebird_command.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';

/// {@template rollback_command}
/// Rolls back a patch on a release. Devices on this release that next call
/// the patch-check endpoint will receive the patch number in
/// `rolled_back_patch_numbers`, prompting them to revert to the prior patch
/// (or the base release if no other patch is available on the channel).
///
/// Sample usage:
/// ```sh
/// shorebird patches rollback --release-version=1.0.0+1 --patch-number=1
/// ```
/// {@endtemplate}
class RollbackCommand extends ShorebirdCommand {
/// {@macro rollback_command}
RollbackCommand() {
argParser
..addOption(
CommonArguments.releaseVersionArg.name,
help: CommonArguments.patchReleaseVersionDescription,
mandatory: true,
)
..addOption(
'patch-number',
help: 'The patch number to roll back (e.g. "1").',
mandatory: true,
)
..addOption(
CommonArguments.appIdArg.name,
help: CommonArguments.appIdArg.description,
)
..addOption(
CommonArguments.flavorArg.name,
help: 'The product flavor to use (e.g. "prod").',
);
}

@override
String get name => 'rollback';

@override
String get description =>
'Rolls back a patch on a release.\n\n'
'Example output:\n'
' Patch 1 on release 1.0.0+1 has been rolled back.\n\n'
'${ShorebirdCommand.jsonHint(
'shorebird patches rollback --release-version 1.0.0+1 '
'--patch-number 1 --app-id <id> --json',
)}';

@override
Future<int> run() async {
final (:appId, :errorCode) = await resolveAppId();
if (errorCode != null) return errorCode;

final releaseVersion =
results[CommonArguments.releaseVersionArg.name] as String;
final patchNumber = int.parse(results['patch-number'] as String);

final Release release;
final List<ReleasePatch> patches;
try {
release = await codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
);
patches = await codePushClientWrapper.getReleasePatches(
appId: appId,
releaseId: release.id,
);
} on ProcessExit catch (e) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.fetchFailed,
message: 'Failed to fetch patches for release "$releaseVersion".',
);
return e.exitCode;
}
rethrow;
}

final patch = patches.firstWhereOrNull((p) => p.number == patchNumber);
if (patch == null) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.usageError,
message:
'No patch found with number $patchNumber '
'for release "$releaseVersion".',
);
return ExitCode.usage.code;
}
logger
..err('No patch found with number $patchNumber')
..info(
'Available patches: ${patches.map((p) => p.number).join(', ')}',
);
return ExitCode.usage.code;
}

if (patch.isRolledBack) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.usageError,
message: 'Patch $patchNumber is already rolled back.',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Take it or leave it - End state is the same is the same on success or hitting this route. Returning a non zero exit suggests that something went wrong even though the end state is correct. Consider adding some flag IE --require-change, I am awful at naming to please feel free to change it, if you want the behavior to be there on default or non default. Changing to this would also match other CLI tool behaviors. This is in line w/ current set-track behavior hence take it or leave it.

);
return ExitCode.usage.code;
}
logger.err('Patch $patchNumber is already rolled back');
return ExitCode.usage.code;
}

try {
await codePushClientWrapper.rollbackPatch(
appId: appId,
releaseId: release.id,
patchId: patch.id,
patchNumber: patch.number,
);
} on ProcessExit catch (e) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.softwareError,
message:
'Failed to roll back patch $patchNumber '
'of release "$releaseVersion".',
);
return e.exitCode;
}
rethrow;
}

if (isJsonMode) {
emitJsonSuccess({
'release_version': releaseVersion,
'patch_number': patchNumber,
'action': 'rollback',
Comment on lines +143 to +145
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Take it or leave it - Consider printing the patch object similar to patches info on rollback and rollforward. Would reduce the need to do follow up patches info call.

});
return ExitCode.success.code;
}

logger.success(
'Patch $patchNumber on release $releaseVersion has been rolled back.',
);
return ExitCode.success.code;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import 'package:collection/collection.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/json_output.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/shorebird_command.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';

/// {@template rollforward_command}
/// Rolls forward (reactivates) a previously rolled-back patch on a release.
/// The server flips `is_rolled_back` from `true` to `false` on the same
/// patch row, so the same patch artifact (same hash, same number) becomes
/// active again. Devices on this release will pick the patch back up on
/// their next launch's auto-update cycle.
///
/// Sample usage:
/// ```sh
/// shorebird patches rollforward --release-version=1.0.0+1 --patch-number=1
/// ```
/// {@endtemplate}
class RollforwardCommand extends ShorebirdCommand {
/// {@macro rollforward_command}
RollforwardCommand() {
argParser
..addOption(
CommonArguments.releaseVersionArg.name,
help: CommonArguments.patchReleaseVersionDescription,
mandatory: true,
)
..addOption(
'patch-number',
help: 'The patch number to roll forward (e.g. "1").',
mandatory: true,
)
..addOption(
CommonArguments.appIdArg.name,
help: CommonArguments.appIdArg.description,
)
..addOption(
CommonArguments.flavorArg.name,
help: 'The product flavor to use (e.g. "prod").',
);
}

@override
String get name => 'rollforward';

@override
String get description =>
'Rolls forward (reactivates) a previously rolled-back patch.\n\n'
'Example output:\n'
' Patch 1 on release 1.0.0+1 has been rolled forward.\n\n'
'${ShorebirdCommand.jsonHint(
'shorebird patches rollforward --release-version 1.0.0+1 '
'--patch-number 1 --app-id <id> --json',
)}';

@override
Future<int> run() async {
final (:appId, :errorCode) = await resolveAppId();
if (errorCode != null) return errorCode;

final releaseVersion =
results[CommonArguments.releaseVersionArg.name] as String;
final patchNumber = int.parse(results['patch-number'] as String);

final Release release;
final List<ReleasePatch> patches;
try {
release = await codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
);
patches = await codePushClientWrapper.getReleasePatches(
appId: appId,
releaseId: release.id,
);
} on ProcessExit catch (e) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.fetchFailed,
message: 'Failed to fetch patches for release "$releaseVersion".',
);
return e.exitCode;
}
rethrow;
}

final patch = patches.firstWhereOrNull((p) => p.number == patchNumber);
if (patch == null) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.usageError,
message:
'No patch found with number $patchNumber '
'for release "$releaseVersion".',
);
return ExitCode.usage.code;
}
logger
..err('No patch found with number $patchNumber')
..info(
'Available patches: ${patches.map((p) => p.number).join(', ')}',
);
return ExitCode.usage.code;
}

if (!patch.isRolledBack) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.usageError,
message: 'Patch $patchNumber is already active (not rolled back).',
);
return ExitCode.usage.code;
}
logger.err('Patch $patchNumber is already active (not rolled back)');
return ExitCode.usage.code;
}

try {
await codePushClientWrapper.rollforwardPatch(
appId: appId,
releaseId: release.id,
patchId: patch.id,
patchNumber: patch.number,
);
} on ProcessExit catch (e) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.softwareError,
message:
'Failed to roll forward patch $patchNumber '
'of release "$releaseVersion".',
);
return e.exitCode;
}
rethrow;
}

if (isJsonMode) {
emitJsonSuccess({
'release_version': releaseVersion,
'patch_number': patchNumber,
'action': 'rollforward',
});
return ExitCode.success.code;
}

logger.success(
'Patch $patchNumber on release $releaseVersion has been rolled forward.',
);
return ExitCode.success.code;
}
}
Loading
Loading