feat(shorebird_cli): make --obfuscate a first-class CLI flag (#3626)

This commit is contained in:
Brandon DeRosier
2026-02-26 22:45:46 -08:00
committed by GitHub
parent 1bed5379f4
commit 47cc90af48
40 changed files with 2007 additions and 351 deletions
+1 -1
View File
@@ -1 +1 @@
22f15e658f317f11954ee1933083a8d6a1bd7aae
14f2a1545b43ddf6ba5d825a4a79310c74806a90
+4
View File
@@ -17,6 +17,7 @@ words:
- armeabi
- Azul
- backboardd
- bdero
- bintools
- bitcode
- bryanoltman
@@ -105,10 +106,12 @@ words:
- sideload
- sideloadable
- sideloaded
- sideloading
- signup
- SIGSTOP
- storepass # From .github/workflows/e2e.yaml
- storyboardc
- symbolication
- syskeys # From adb.dart
- subosito # From .github dir, doesn't show up in "**" check?
- swiftshader # From .github dir, doesn't show up in "**" check?
@@ -119,6 +122,7 @@ words:
- udid # Unique Device Identifier
- unawaited
- unmockable
- unobfuscated
- unpadded
- Unpatchable
- unsets
@@ -368,24 +368,41 @@ class ArtifactManager {
return ipaFiles.single;
}
/// Returns the shorebird release supplement directory for the given
/// [platformSubdir] (e.g. 'ios', 'macos', 'android').
///
/// If [create] is true, the directory is created if it doesn't exist.
/// Returns null if the directory doesn't exist and [create] is false.
Directory? getReleaseSupplementDirectory({
required String platformSubdir,
bool create = false,
}) {
final projectRoot = shorebirdEnv.getShorebirdProjectRoot();
if (projectRoot == null) return null;
final releaseSupplementDir = Directory(
p.join(projectRoot.path, 'build', platformSubdir, 'shorebird'),
);
if (!releaseSupplementDir.existsSync()) {
if (create) {
releaseSupplementDir.createSync(recursive: true);
} else {
logger.detail(
'No release supplements found at ${releaseSupplementDir.path}',
);
return null;
}
}
return releaseSupplementDir;
}
/// Returns the path to the shorebird release supplement directory for iOS.
///
/// Returns null if there is no supplement directory
/// (e.g. when using older Flutter revisions).
Directory? getIosReleaseSupplementDirectory() {
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
final releaseSupplementDir = Directory(
p.join(projectRoot.path, 'build', 'ios', 'shorebird'),
);
if (!releaseSupplementDir.existsSync()) {
logger.detail(
'No iOS release supplements found at ${releaseSupplementDir.path}',
);
return null;
}
return releaseSupplementDir;
return getReleaseSupplementDirectory(platformSubdir: 'ios');
}
/// Returns the path to the shorebird release supplement directory for macOS.
@@ -393,19 +410,7 @@ class ArtifactManager {
/// Returns null if there is no supplement directory
/// (e.g. when using older Flutter revisions).
Directory? getMacosReleaseSupplementDirectory() {
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
final releaseSupplementDir = Directory(
p.join(projectRoot.path, 'build', 'macos', 'shorebird'),
);
if (!releaseSupplementDir.existsSync()) {
logger.detail(
'No macOS release supplements found at ${releaseSupplementDir.path}',
);
return null;
}
return releaseSupplementDir;
return getReleaseSupplementDirectory(platformSubdir: 'macos');
}
/// Name of the App.xcframework generated by `shorebird release ios-framework`
@@ -744,7 +744,6 @@ aar artifact already exists, continuing...''');
required String runnerPath,
required bool isCodesigned,
required String? podfileLockHash,
required String? supplementPath,
}) async {
final createArtifactProgress = logger.progress('Uploading artifacts');
final thinnedArchiveDirectory = await _thinXcarchive(
@@ -791,30 +790,6 @@ aar artifact already exists, continuing...''');
);
}
if (supplementPath != null) {
final zippedSupplement = await Directory(
supplementPath,
).zipToTempFile(name: 'ios_supplement');
try {
await codePushClient.createReleaseArtifact(
appId: appId,
releaseId: releaseId,
artifactPath: zippedSupplement.path,
arch: 'ios_supplement',
platform: ReleasePlatform.ios,
hash: sha256.convert(await zippedSupplement.readAsBytes()).toString(),
canSideload: false,
podfileLockHash: podfileLockHash,
);
} catch (error) {
_handleErrorAndExit(
error,
progress: createArtifactProgress,
message: 'Error uploading release supplements: $error',
);
}
}
createArtifactProgress.complete();
}
@@ -824,7 +799,6 @@ aar artifact already exists, continuing...''');
required String appId,
required int releaseId,
required String appFrameworkPath,
required String? supplementPath,
}) async {
final createArtifactProgress = logger.progress('Uploading artifacts');
final appFrameworkDirectory = Directory(appFrameworkPath);
@@ -850,33 +824,50 @@ aar artifact already exists, continuing...''');
);
}
if (supplementPath != null) {
final zippedSupplement = await Directory(
supplementPath,
).zipToTempFile(name: 'ios_framework_supplement');
try {
await codePushClient.createReleaseArtifact(
appId: appId,
releaseId: releaseId,
artifactPath: zippedSupplement.path,
arch: 'ios_framework_supplement',
platform: ReleasePlatform.ios,
hash: sha256.convert(await zippedSupplement.readAsBytes()).toString(),
canSideload: false,
podfileLockHash: null,
);
} catch (error) {
_handleErrorAndExit(
error,
progress: createArtifactProgress,
message: 'Error uploading release supplements: $error',
);
}
}
createArtifactProgress.complete();
}
/// Zips and uploads a supplement directory as a release artifact.
Future<void> createSupplementReleaseArtifact({
required String appId,
required int releaseId,
required ReleasePlatform platform,
required String supplementDirectoryPath,
required String arch,
}) async {
final createSupplementProgress = logger.progress(
'Uploading supplement artifacts',
);
final zippedSupplement = await Directory(
supplementDirectoryPath,
).zipToTempFile(name: arch);
try {
await codePushClient.createReleaseArtifact(
appId: appId,
releaseId: releaseId,
artifactPath: zippedSupplement.path,
arch: arch,
platform: platform,
hash: sha256.convert(await zippedSupplement.readAsBytes()).toString(),
// Supplements are auxiliary snapshot metadata used during patching and
// can't produce a working app on their own, so sideloading isn't
// applicable.
canSideload: false,
// Supplement artifacts contain only Dart snapshot metadata (e.g. class
// tables, dispatch tables) and have no dependency on native pods, so
// the podfile lock hash is not applicable here.
podfileLockHash: null,
);
} catch (error) {
_handleErrorAndExit(
error,
progress: createSupplementProgress,
message: 'Error uploading supplement artifacts: $error',
);
}
createSupplementProgress.complete();
}
/// Creates a patch for the given [appId], [releaseId], and [metadata].
@visibleForTesting
Future<Patch> createPatch({
@@ -40,6 +40,9 @@ class AarPatcher extends Patcher {
@override
String get primaryReleaseArtifactArch => 'aar';
@override
String? get supplementaryReleaseArtifactArch => 'aar_supplement';
@override
ReleaseType get releaseType => ReleaseType.aar;
@@ -75,9 +78,10 @@ class AarPatcher extends Patcher {
@override
Future<File> buildPatchArtifact({String? releaseVersion}) async {
final buildArgs = [...argResults.forwardedArgs, ...extraBuildArgs];
await artifactBuilder.buildAar(
buildNumber: buildNumber,
args: argResults.forwardedArgs,
args: buildArgs,
base64PublicKey: argResults.encodedPublicKey,
);
@@ -94,7 +98,7 @@ class AarPatcher extends Patcher {
required String appId,
required int releaseId,
required File releaseArtifact,
File? supplementArtifact,
Directory? supplementDirectory,
}) async {
final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts(
appId: appId,
@@ -51,6 +51,9 @@ See more info about the issue ${link(uri: Uri.parse('https://github.com/shorebir
@override
String get primaryReleaseArtifactArch => 'aab';
@override
String? get supplementaryReleaseArtifactArch => 'android_supplement';
@override
Future<DiffStatus> assertUnpatchableDiffs({
required ReleaseArtifact releaseArtifact,
@@ -87,12 +90,15 @@ See more info about the issue ${link(uri: Uri.parse('https://github.com/shorebir
logger.warn(updaterPatchErrorWarning);
}
final buildArgs = [
...argResults.forwardedArgs,
...extraBuildArgs,
...buildNameAndNumberArgsFromReleaseVersion(releaseVersion),
];
final aabFile = await artifactBuilder.buildAppBundle(
flavor: flavor,
target: target,
args:
argResults.forwardedArgs +
buildNameAndNumberArgsFromReleaseVersion(releaseVersion),
args: buildArgs,
base64PublicKey: argResults.encodedPublicKey,
);
@@ -123,7 +129,7 @@ Looked in:
required String appId,
required int releaseId,
required File releaseArtifact,
File? supplementArtifact,
Directory? supplementDirectory,
Duration downloadMessageTimeout = const Duration(minutes: 1),
}) async {
final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts(
@@ -104,8 +104,9 @@ class IosFrameworkPatcher extends Patcher {
@override
Future<File> buildPatchArtifact({String? releaseVersion}) async {
final buildArgs = [...argResults.forwardedArgs, ...extraBuildArgs];
final buildResult = await artifactBuilder.buildIosFramework(
args: argResults.forwardedArgs,
args: buildArgs,
base64PublicKey: argResults.encodedPublicKey,
);
@@ -116,7 +117,10 @@ class IosFrameworkPatcher extends Patcher {
appDillPath: buildResult.kernelFile.path,
outFilePath: _aotOutputPath,
genSnapshotArtifact: ShorebirdArtifact.genSnapshotIos,
additionalArgs: IosPatcher.splitDebugInfoArgs(splitDebugInfoPath),
additionalArgs: [
...IosPatcher.splitDebugInfoArgs(splitDebugInfoPath),
...obfuscationGenSnapshotArgs,
],
);
// Copy the kernel file to the build directory so that it can be used
@@ -136,7 +140,7 @@ class IosFrameworkPatcher extends Patcher {
required String appId,
required int releaseId,
required File releaseArtifact,
File? supplementArtifact,
Directory? supplementDirectory,
}) async {
final unzipProgress = logger.progress('Extracting release artifact');
late final String releaseXcframeworkPath;
@@ -149,13 +153,8 @@ class IosFrameworkPatcher extends Patcher {
releaseXcframeworkPath = tempDir.path;
}
final releaseSupplementDir = Directory.systemTemp.createTempSync();
if (supplementArtifact != null) {
await artifactManager.extractZip(
zipFile: supplementArtifact,
outputDirectory: releaseSupplementDir,
);
}
final releaseSupplementDir =
supplementDirectory ?? Directory.systemTemp.createTempSync();
unzipProgress.complete(
'Extracted release artifact to $releaseXcframeworkPath',
@@ -180,7 +179,10 @@ class IosFrameworkPatcher extends Patcher {
final result = await apple.runLinker(
kernelFile: File(_appDillCopyPath),
releaseArtifact: releaseArtifactFile,
splitDebugInfoArgs: IosPatcher.splitDebugInfoArgs(splitDebugInfoPath),
splitDebugInfoArgs: [
...IosPatcher.splitDebugInfoArgs(splitDebugInfoPath),
...obfuscationGenSnapshotArgs,
],
aotOutputFile: File(_aotOutputPath),
vmCodeFile: File(_vmcodeOutputPath),
);
@@ -167,15 +167,19 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
throw ProcessExit(ExitCode.software.code);
}
final buildArgs = [
...argResults.forwardedArgs,
...extraBuildArgs,
...buildNameAndNumberArgsFromReleaseVersion(releaseVersion),
];
// If buildIpa is called with a different codesign value than the
// release was, we will erroneously report native diffs.
final ipaBuildResult = await artifactBuilder.buildIpa(
codesign: shouldCodesign,
flavor: flavor,
target: target,
args:
argResults.forwardedArgs +
buildNameAndNumberArgsFromReleaseVersion(releaseVersion),
args: buildArgs,
base64PublicKey: argResults.encodedPublicKey,
);
@@ -186,7 +190,10 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
appDillPath: ipaBuildResult.kernelFile.path,
outFilePath: _aotOutputPath,
genSnapshotArtifact: ShorebirdArtifact.genSnapshotIos,
additionalArgs: splitDebugInfoArgs(splitDebugInfoPath),
additionalArgs: [
...splitDebugInfoArgs(splitDebugInfoPath),
...obfuscationGenSnapshotArgs,
],
);
// Copy the kernel file to the build directory so that it can be used
@@ -201,7 +208,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
required String appId,
required int releaseId,
required File releaseArtifact,
File? supplementArtifact,
Directory? supplementDirectory,
}) async {
// Verify that we have built a patch .xcarchive
if (artifactManager.getXcarchiveDirectory()?.path == null) {
@@ -221,13 +228,8 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
releaseXcarchivePath = tempDir.path;
}
final releaseSupplementDir = Directory.systemTemp.createTempSync();
if (supplementArtifact != null) {
await artifactManager.extractZip(
zipFile: supplementArtifact,
outputDirectory: releaseSupplementDir,
);
}
final releaseSupplementDir =
supplementDirectory ?? Directory.systemTemp.createTempSync();
unzipProgress.complete();
final appDirectory = artifactManager.getIosAppDirectory(
@@ -253,7 +255,10 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
final result = await apple.runLinker(
kernelFile: File(_appDillCopyPath),
releaseArtifact: releaseArtifactFile,
splitDebugInfoArgs: splitDebugInfoArgs(splitDebugInfoPath),
splitDebugInfoArgs: [
...splitDebugInfoArgs(splitDebugInfoPath),
...obfuscationGenSnapshotArgs,
],
aotOutputFile: File(_aotOutputPath),
vmCodeFile: File(_vmcodeOutputPath),
);
@@ -51,6 +51,7 @@ class LinuxPatcher extends Patcher {
Future<File> buildPatchArtifact({String? releaseVersion}) async {
await artifactBuilder.buildLinuxApp(
base64PublicKey: argResults.encodedPublicKey,
args: extraBuildArgs,
);
return artifactManager.linuxBundleDirectory.zipToTempFile();
}
@@ -60,7 +61,7 @@ class LinuxPatcher extends Patcher {
required String appId,
required int releaseId,
required File releaseArtifact,
File? supplementArtifact,
Directory? supplementDirectory,
}) async {
final createDiffProgress = logger.progress('Creating patch artifacts');
final patchArtifactPath = p.join(
@@ -122,6 +123,9 @@ class LinuxPatcher extends Patcher {
@override
String get primaryReleaseArtifactArch => 'bundle';
@override
String? get supplementaryReleaseArtifactArch => 'linux_supplement';
@override
ReleaseType get releaseType => ReleaseType.linux;
}
@@ -57,6 +57,9 @@ class MacosPatcher extends Patcher {
@override
String get primaryReleaseArtifactArch => 'app';
@override
String? get supplementaryReleaseArtifactArch => 'macos_supplement';
@override
Future<void> assertPreconditions() async {
try {
@@ -135,15 +138,19 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
throw ProcessExit(ExitCode.software.code);
}
final buildArgs = [
...argResults.forwardedArgs,
...extraBuildArgs,
...buildNameAndNumberArgsFromReleaseVersion(releaseVersion),
];
// If buildMacos is called with a different codesign value than the
// release was, we will erroneously report native diffs.
final macosBuildResult = await artifactBuilder.buildMacos(
codesign: codesign,
flavor: flavor,
target: target,
args:
argResults.forwardedArgs +
buildNameAndNumberArgsFromReleaseVersion(releaseVersion),
args: buildArgs,
base64PublicKey: argResults.encodedPublicKey,
);
@@ -154,6 +161,10 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
appDillPath: macosBuildResult.kernelFile.path,
outFilePath: _arm64AotOutputPath,
genSnapshotArtifact: ShorebirdArtifact.genSnapshotMacosArm64,
additionalArgs: [
...IosPatcher.splitDebugInfoArgs(splitDebugInfoPath),
...obfuscationGenSnapshotArgs,
],
);
if (!File(_arm64AotOutputPath).existsSync()) {
@@ -164,6 +175,10 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
appDillPath: macosBuildResult.kernelFile.path,
outFilePath: _x64AotOutputPath,
genSnapshotArtifact: ShorebirdArtifact.genSnapshotMacosX64,
additionalArgs: [
...IosPatcher.splitDebugInfoArgs(splitDebugInfoPath),
...obfuscationGenSnapshotArgs,
],
);
if (!File(_x64AotOutputPath).existsSync()) {
@@ -217,7 +232,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
required String appId,
required int releaseId,
required File releaseArtifact,
File? supplementArtifact,
Directory? supplementDirectory,
}) async {
final unzipProgress = logger.progress('Extracting release artifact');
final releaseAppDirectory = Directory.systemTemp.createTempSync();
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:meta/meta.dart';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
@@ -153,6 +154,12 @@ To target the latest release (e.g. the release that was most recently updated) u
CommonArguments.splitDebugInfoArg.name,
help: CommonArguments.splitDebugInfoArg.description,
)
..addFlag(
CommonArguments.obfuscateArg.name,
help: CommonArguments.obfuscateArg.description,
negatable: false,
hide: true,
)
..addOption(
CommonArguments.minLinkPercentage.name,
help: CommonArguments.minLinkPercentage.description,
@@ -401,6 +408,83 @@ Building with Flutter $flutterVersionString to determine the release version...
? await downloadReleaseArtifact(releaseArtifact: supplementalArtifact)
: null;
// Download and extract the supplement archive (if present).
Directory? supplementDirectory;
File? obfuscationMapFile;
if (supplementArchive != null) {
supplementDirectory = Directory.systemTemp.createTempSync();
await artifactManager.extractZip(
zipFile: supplementArchive,
outputDirectory: supplementDirectory,
);
final candidateMapFile = File(
p.join(supplementDirectory.path, 'obfuscation_map.json'),
);
if (candidateMapFile.existsSync()) {
obfuscationMapFile = candidateMapFile;
logger.info(
'Release was built with obfuscation. '
'Applying obfuscation map to patch build.',
);
}
}
// If the user explicitly passed --obfuscate but the release has no
// obfuscation map, the patch would be obfuscated against a non-obfuscated
// release, producing a broken patch.
// Also check rest for `-- --obfuscate`, which bypasses the parser but
// still flows through forwardedArgs to the Flutter build command.
final userPassedObfuscate =
(results.wasParsed('obfuscate') && results['obfuscate'] == true) ||
results.rest.any((a) => a == '--obfuscate');
if (userPassedObfuscate && obfuscationMapFile == null) {
logger.err(
'--obfuscate was passed, but the release was not built with '
'obfuscation. A patch cannot change the obfuscation mode of a '
'release.',
);
throw ProcessExit(ExitCode.software.code);
}
if (userPassedObfuscate && obfuscationMapFile != null) {
logger.info(
'--obfuscate is not needed for patching. Obfuscation is applied '
'automatically when the release was built with --obfuscate.',
);
}
patcher.obfuscationMapPath = obfuscationMapFile?.path;
// Build extra args to inject into the Flutter build command. These use
// --extra-gen-snapshot-options= because they're passed through Flutter's
// build system, which forwards them to gen_snapshot. This is distinct from
// patcher.obfuscationGenSnapshotArgs, which produces bare gen_snapshot
// flags (e.g. --load-obfuscation-map=...) for direct gen_snapshot/linker
// calls made by Apple patchers outside the Flutter build.
final extraBuildArgs = <String>[];
if (obfuscationMapFile != null) {
extraBuildArgs.addAll([
'--obfuscate',
'--extra-gen-snapshot-options='
'--load-obfuscation-map=${obfuscationMapFile.path}',
// Strip unobfuscated DWARF debug info from the compiled snapshot so
// it doesn't leak identifiers that obfuscation was meant to hide.
'--extra-gen-snapshot-options=--strip',
]);
}
// Flutter requires --split-debug-info with --obfuscate. Auto-add it
// if --obfuscate will be in the build args (from the user or from
// the obfuscation map injection above) but --split-debug-info is not.
final hasObfuscate =
(results.wasParsed('obfuscate') && results['obfuscate'] == true) ||
extraBuildArgs.contains('--obfuscate');
final hasSplitDebugInfo = results.wasParsed('split-debug-info');
if (hasObfuscate && !hasSplitDebugInfo) {
extraBuildArgs.add(
'--split-debug-info=${p.join('build', 'shorebird', 'symbols')}',
);
}
patcher.extraBuildArgs = extraBuildArgs;
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: release.flutterRevision,
);
@@ -417,7 +501,9 @@ Building with Flutter $flutterVersionString to determine the release version...
Building patch with Flutter $flutterVersionString
''');
patchArtifactFile = await _tryBuildingArtifact<File>(
() => patcher.buildPatchArtifact(releaseVersion: release.version),
() => patcher.buildPatchArtifact(
releaseVersion: release.version,
),
);
}
@@ -431,7 +517,7 @@ Building patch with Flutter $flutterVersionString
appId: appId,
releaseId: release.id,
releaseArtifact: releaseArchive,
supplementArtifact: supplementArchive,
supplementDirectory: supplementDirectory,
);
final dryRun = results['dry-run'] == true;
@@ -57,6 +57,34 @@ More info: ${troubleshootingUrl.toLink()}.
/// The target script to run, if any.
final String? target;
/// The path to the obfuscation map downloaded from the release, if any.
/// Set by the patch command after downloading the map. Used by Apple
/// patchers to pass obfuscation flags to gen_snapshot and the linker.
String? obfuscationMapPath;
/// Extra build arguments injected by the patch command. These are included
/// in the Flutter build command args by patchers. Currently used to inject
/// obfuscation flags when the release was built with obfuscation.
List<String> extraBuildArgs = const [];
/// Additional gen_snapshot arguments needed to match the release's
/// obfuscation flags. Used by Apple patchers for [buildElfAotSnapshot]
/// and linker calls.
List<String> get obfuscationGenSnapshotArgs => [
if (obfuscationMapPath != null) ...[
'--obfuscate',
'--load-obfuscation-map=$obfuscationMapPath',
// --dwarf-stack-traces must match the release build so the patch
// produces the same stack trace format for correct symbolication.
// --split-debug-info already implies --dwarf-stack-traces, so we only
// need to pass it as a standalone flag when split debug info isn't used.
if (splitDebugInfoPath == null) '--dwarf-stack-traces',
// Strip DWARF debug info so obfuscated snapshots don't leak the
// identifiers that obfuscation was meant to hide.
'--strip',
],
];
/// The type of artifact we are creating a release for.
ReleaseType get releaseType;
@@ -96,7 +124,7 @@ More info: ${troubleshootingUrl.toLink()}.
required String appId,
required int releaseId,
required File releaseArtifact,
File? supplementArtifact,
Directory? supplementDirectory,
});
/// Updates the provided metadata to include patcher-specific fields.
@@ -37,6 +37,9 @@ class WindowsPatcher extends Patcher {
@override
String get primaryReleaseArtifactArch => primaryWindowsReleaseArtifactArch;
@override
String? get supplementaryReleaseArtifactArch => 'windows_supplement';
@override
ReleaseType get releaseType => ReleaseType.windows;
@@ -71,9 +74,10 @@ class WindowsPatcher extends Patcher {
@override
Future<File> buildPatchArtifact({String? releaseVersion}) async {
final buildArgs = [...argResults.forwardedArgs, ...extraBuildArgs];
final releaseDir = await artifactBuilder.buildWindowsApp(
target: target,
args: argResults.forwardedArgs,
args: buildArgs,
base64PublicKey: argResults.encodedPublicKey,
);
return releaseDir.zipToTempFile();
@@ -84,7 +88,7 @@ class WindowsPatcher extends Patcher {
required String appId,
required int releaseId,
required File releaseArtifact,
File? supplementArtifact,
Directory? supplementDirectory,
}) async {
final createDiffProgress = logger.progress('Creating patch artifacts');
final patchArtifactPath = p.join(
@@ -44,6 +44,12 @@ class AarReleaser extends Releaser {
@override
ReleaseType get releaseType => ReleaseType.aar;
@override
String get supplementPlatformSubdir => 'android';
@override
String get supplementArtifactArch => 'aar_supplement';
@override
String get artifactDisplayName => 'Android archive';
@@ -70,17 +76,23 @@ class AarReleaser extends Releaser {
logger.err('Missing required argument: --release-version');
throw ProcessExit(ExitCode.usage.code);
}
await assertObfuscationIsSupported();
}
@override
Future<FileSystemEntity> buildReleaseArtifacts() async {
final base64PublicKey = await getEncodedPublicKey();
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await artifactBuilder.buildAar(
buildNumber: buildNumber,
targetPlatforms: architectures,
args: argResults.forwardedArgs,
args: buildArgs,
base64PublicKey: base64PublicKey,
);
verifyObfuscationMap();
// Copy release AAR to a new directory to avoid overwriting with
// subsequent patch builds.
@@ -126,6 +138,8 @@ class AarReleaser extends Releaser {
extractedAarDir: extractedAarDir.path,
architectures: architectures,
);
await uploadSupplementArtifact(appId: appId, releaseId: release.id);
}
@override
@@ -31,6 +31,12 @@ class AndroidReleaser extends Releaser {
@override
ReleaseType get releaseType => ReleaseType.android;
@override
String get supplementPlatformSubdir => 'android';
@override
String get supplementArtifactArch => 'android_supplement';
@override
String get artifactDisplayName => 'Android app bundle';
@@ -98,26 +104,33 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec
);
throw ProcessExit(ExitCode.unavailable.code);
}
await assertObfuscationIsSupported();
}
@override
Future<FileSystemEntity> buildReleaseArtifacts() async {
final base64PublicKey = await getEncodedPublicKey();
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
final aab = await artifactBuilder.buildAppBundle(
flavor: flavor,
target: target,
targetPlatforms: architectures,
args: argResults.forwardedArgs,
args: buildArgs,
base64PublicKey: base64PublicKey,
);
verifyObfuscationMap();
if (generateApk) {
logger.info('Building APK');
await artifactBuilder.buildApk(
flavor: flavor,
target: target,
targetPlatforms: architectures,
args: argResults.forwardedArgs,
args: buildArgs,
base64PublicKey: base64PublicKey,
);
}
@@ -150,12 +163,12 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec
Future<void> uploadReleaseArtifacts({
required String appId,
required Release release,
}) {
}) async {
final aabFile = shorebirdAndroidArtifacts.findAab(
project: projectRoot,
flavor: flavor,
);
return codePushClientWrapper.createAndroidReleaseArtifacts(
await codePushClientWrapper.createAndroidReleaseArtifacts(
appId: appId,
releaseId: release.id,
projectRoot: projectRoot.path,
@@ -164,6 +177,8 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec
architectures: architectures,
flavor: flavor,
);
await uploadSupplementArtifact(appId: appId, releaseId: release.id);
}
@override
@@ -42,12 +42,20 @@ class IosFrameworkReleaser extends Releaser {
@override
ReleaseType get releaseType => ReleaseType.iosFramework;
@override
String get supplementPlatformSubdir => 'ios';
@override
String get supplementArtifactArch => 'ios_framework_supplement';
@override
Future<void> assertArgsAreValid() async {
if (!argResults.wasParsed('release-version')) {
logger.err('Missing required argument: --release-version');
throw ProcessExit(ExitCode.usage.code);
}
await assertObfuscationIsSupported();
}
@override
@@ -79,10 +87,14 @@ class IosFrameworkReleaser extends Releaser {
}
final base64PublicKey = await getEncodedPublicKey();
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await artifactBuilder.buildIosFramework(
args: argResults.forwardedArgs,
args: buildArgs,
base64PublicKey: base64PublicKey,
);
verifyObfuscationMap();
// Copy release xcframework to a new directory to avoid overwriting with
// subsequent patch builds.
@@ -117,13 +129,14 @@ class IosFrameworkReleaser extends Releaser {
Future<void> uploadReleaseArtifacts({
required Release release,
required String appId,
}) {
return codePushClientWrapper.createIosFrameworkReleaseArtifacts(
}) async {
await codePushClientWrapper.createIosFrameworkReleaseArtifacts(
appId: appId,
releaseId: release.id,
appFrameworkPath: p.join(releaseDirectory.path, 'App.xcframework'),
supplementPath: artifactManager.getIosReleaseSupplementDirectory()?.path,
);
await uploadSupplementArtifact(appId: appId, releaseId: release.id);
}
@override
@@ -37,6 +37,12 @@ class IosReleaser extends Releaser {
@override
ReleaseType get releaseType => ReleaseType.ios;
@override
String get supplementPlatformSubdir => 'ios';
@override
String get supplementArtifactArch => 'ios_supplement';
@override
String get artifactDisplayName => 'iOS app';
@@ -46,22 +52,13 @@ class IosReleaser extends Releaser {
logger.err(
'''
The "--release-version" flag is only supported for aar and ios-framework releases.
To change the version of this release, change your app's version in your pubspec.yaml.''',
);
throw ProcessExit(ExitCode.usage.code);
}
if (argResults.rest.contains('--obfuscate')) {
// Obfuscated releases break patching, so we don't support them.
// See https://github.com/shorebirdtech/shorebird/issues/1619
logger
..err('Shorebird does not currently support obfuscation on iOS.')
..info(
'''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''',
);
throw ProcessExit(ExitCode.unavailable.code);
}
await assertObfuscationIsSupported();
}
@override
@@ -103,14 +100,21 @@ To change the version of this release, change your app's version in your pubspec
}
final base64PublicKey = await getEncodedPublicKey();
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await artifactBuilder.buildIpa(
codesign: codesign,
flavor: flavor,
target: target,
args: argResults.forwardedArgs,
args: buildArgs,
base64PublicKey: base64PublicKey,
);
verifyObfuscationMap();
final xcarchiveDirectory = artifactManager.getXcarchiveDirectory();
if (xcarchiveDirectory == null) {
logger.err('Unable to find .xcarchive directory');
@@ -172,8 +176,9 @@ To change the version of this release, change your app's version in your pubspec
.path,
isCodesigned: codesign,
podfileLockHash: podfileLockHash,
supplementPath: artifactManager.getIosReleaseSupplementDirectory()?.path,
);
await uploadSupplementArtifact(appId: appId, releaseId: release.id);
}
@override
@@ -29,6 +29,12 @@ class LinuxReleaser extends Releaser {
@override
ReleaseType get releaseType => ReleaseType.linux;
@override
String get supplementPlatformSubdir => 'linux';
@override
String get supplementArtifactArch => 'linux_supplement';
@override
String get artifactDisplayName => 'Linux app';
@@ -43,6 +49,8 @@ To change the version of this release, change your app's version in your pubspec
);
throw ProcessExit(ExitCode.usage.code);
}
await assertObfuscationIsSupported();
}
@override
@@ -65,11 +73,15 @@ To change the version of this release, change your app's version in your pubspec
@override
Future<FileSystemEntity> buildReleaseArtifacts() async {
final base64PublicKey = await getEncodedPublicKey();
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await artifactBuilder.buildLinuxApp(
target: target,
args: argResults.forwardedArgs,
args: buildArgs,
base64PublicKey: base64PublicKey,
);
verifyObfuscationMap();
return artifactManager.linuxBundleDirectory;
}
@@ -92,9 +104,13 @@ Linux release created at ${artifactManager.linuxBundleDirectory.path}.
Future<void> uploadReleaseArtifacts({
required Release release,
required String appId,
}) => codePushClientWrapper.createLinuxReleaseArtifacts(
appId: appId,
releaseId: release.id,
bundle: artifactManager.linuxBundleDirectory,
);
}) async {
await codePushClientWrapper.createLinuxReleaseArtifacts(
appId: appId,
releaseId: release.id,
bundle: artifactManager.linuxBundleDirectory,
);
await uploadSupplementArtifact(appId: appId, releaseId: release.id);
}
}
@@ -37,6 +37,12 @@ class MacosReleaser extends Releaser {
@override
ReleaseType get releaseType => ReleaseType.macos;
@override
String get supplementPlatformSubdir => 'macos';
@override
String get supplementArtifactArch => 'macos_supplement';
@override
String get artifactDisplayName => 'macOS app';
@@ -46,22 +52,13 @@ class MacosReleaser extends Releaser {
logger.err(
'''
The "--release-version" flag is only supported for aar and ios-framework releases.
To change the version of this release, change your app's version in your pubspec.yaml.''',
);
throw ProcessExit(ExitCode.usage.code);
}
if (argResults.rest.contains('--obfuscate')) {
// Obfuscated releases break patching, so we don't support them.
// See https://github.com/shorebirdtech/shorebird/issues/1619
logger
..err('Shorebird does not currently support obfuscation on macOS.')
..info(
'''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''',
);
throw ProcessExit(ExitCode.unavailable.code);
}
await assertObfuscationIsSupported();
}
@override
@@ -94,14 +91,21 @@ To change the version of this release, change your app's version in your pubspec
}
final base64PublicKey = await getEncodedPublicKey();
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await artifactBuilder.buildMacos(
codesign: codesign,
flavor: flavor,
target: target,
args: argResults.forwardedArgs,
args: buildArgs,
base64PublicKey: base64PublicKey,
);
verifyObfuscationMap();
final appDirectory = artifactManager.getMacOSAppDirectory(flavor: flavor);
if (appDirectory == null) {
logger.err('Unable to find .app directory');
@@ -160,6 +164,8 @@ To change the version of this release, change your app's version in your pubspec
isCodesigned: codesign,
podfileLockHash: podfileLockHash,
);
await uploadSupplementArtifact(appId: appId, releaseId: release.id);
}
@override
@@ -153,6 +153,11 @@ of the iOS app that is using this module. (aar and ios-framework only)''',
..addOption(
CommonArguments.splitDebugInfoArg.name,
help: CommonArguments.splitDebugInfoArg.description,
)
..addFlag(
CommonArguments.obfuscateArg.name,
help: CommonArguments.obfuscateArg.description,
negatable: false,
);
}
@@ -1,16 +1,26 @@
import 'dart:io';
import 'package:args/args.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/metadata/metadata.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
export 'package:pub_semver/pub_semver.dart';
/// Minimum Flutter version for obfuscation support across all platforms.
final _minimumObfuscationFlutterVersion = Version(3, 41, 2);
/// {@template releaser}
/// Executes platform-specific functionality to create a release.
/// {@endtemplate}
@@ -87,4 +97,123 @@ abstract class Releaser {
///
/// Returns null if no public key is configured.
Future<String?> getEncodedPublicKey() => argResults.getEncodedPublicKey();
/// Whether the user is building with obfuscation.
bool get useObfuscation => argResults['obfuscate'] == true;
/// Path where the obfuscation map is saved during obfuscated builds.
String get obfuscationMapPath => p.join(
projectRoot.path,
'build',
'shorebird',
'obfuscation_map.json',
);
/// Auto-adds --split-debug-info when --obfuscate is used without it.
void addSplitDebugInfoDefault(List<String> buildArgs) {
if (useObfuscation &&
!buildArgs.any((a) => a.startsWith('--split-debug-info'))) {
buildArgs.add(
'--split-debug-info=${p.join('build', 'shorebird', 'symbols')}',
);
}
}
/// Adds obfuscation-related gen_snapshot options to [buildArgs].
///
/// When obfuscation is enabled, passes --save-obfuscation-map to capture the
/// mapping and --strip to remove unobfuscated DWARF debugging information
/// from the compiled snapshot (the DWARF sections would otherwise leak
/// identifiers that obfuscation was meant to hide).
void addObfuscationMapArgs(List<String> buildArgs) {
if (!useObfuscation) return;
final mapDir = Directory(p.dirname(obfuscationMapPath));
if (!mapDir.existsSync()) mapDir.createSync(recursive: true);
buildArgs.addAll([
'--extra-gen-snapshot-options=--save-obfuscation-map=$obfuscationMapPath',
'--extra-gen-snapshot-options=--strip',
]);
}
/// Platform subdirectory for the supplement directory (e.g. 'android',
/// 'ios'). Used to construct `build/<platformSubdir>/shorebird/`.
String get supplementPlatformSubdir;
/// Arch string for the supplement artifact on the server (e.g.
/// 'android_supplement').
String get supplementArtifactArch;
/// Assembles the supplement directory: copies the obfuscation map (if
/// present) into the platform supplement dir. Returns the directory, or null
/// if empty.
Directory? assembleSupplementDirectory() {
final obfuscationMapFile = File(obfuscationMapPath);
final hasObfuscationMap = useObfuscation && obfuscationMapFile.existsSync();
final supplementDir = artifactManager.getReleaseSupplementDirectory(
platformSubdir: supplementPlatformSubdir,
create: hasObfuscationMap,
);
if (hasObfuscationMap && supplementDir != null) {
obfuscationMapFile.copySync(
p.join(supplementDir.path, 'obfuscation_map.json'),
);
}
return supplementDir;
}
/// Uploads the supplement artifact (e.g. obfuscation map) if one was
/// assembled. Call this at the end of [uploadReleaseArtifacts].
///
// TODO(bdero): This is a separate network call from the primary artifact
// upload, so an interruption between the two leaves the release without its
// supplement. Now that the supplement drives patching decisions (e.g.
// obfuscation), we should make this atomic or recoverable.
// https://github.com/shorebirdtech/shorebird/issues/3630
Future<void> uploadSupplementArtifact({
required String appId,
required int releaseId,
}) async {
final supplementDir = assembleSupplementDirectory();
if (supplementDir != null) {
await codePushClientWrapper.createSupplementReleaseArtifact(
appId: appId,
releaseId: releaseId,
platform: releaseType.releasePlatform,
supplementDirectoryPath: supplementDir.path,
arch: supplementArtifactArch,
);
}
}
/// Asserts that the current Flutter version supports obfuscation, if
/// obfuscation is enabled.
Future<void> assertObfuscationIsSupported() async {
if (!useObfuscation) return;
final flutterVersion = await shorebirdFlutter.resolveFlutterVersion(
shorebirdEnv.flutterRevision,
);
if (flutterVersion != null &&
flutterVersion < _minimumObfuscationFlutterVersion) {
logger.err(
'Obfuscation on ${releaseType.releasePlatform.displayName} requires Flutter '
'$_minimumObfuscationFlutterVersion or later '
'(current: $flutterVersion).',
);
throw ProcessExit(ExitCode.unavailable.code);
}
}
/// Verifies the obfuscation map was generated after build.
void verifyObfuscationMap() {
if (!useObfuscation) return;
final mapFile = File(obfuscationMapPath);
if (!mapFile.existsSync()) {
logger.err(
'Obfuscation was enabled but the obfuscation map was not '
'generated at $obfuscationMapPath',
);
throw ProcessExit(ExitCode.software.code);
}
logger.detail('Obfuscation map saved to $obfuscationMapPath');
}
}
@@ -32,6 +32,12 @@ class WindowsReleaser extends Releaser {
@override
ReleaseType get releaseType => ReleaseType.windows;
@override
String get supplementPlatformSubdir => 'windows';
@override
String get supplementArtifactArch => 'windows_supplement';
@override
String get artifactDisplayName => 'Windows app';
@@ -41,11 +47,13 @@ class WindowsReleaser extends Releaser {
logger.err(
'''
The "--release-version" flag is only supported for aar and ios-framework releases.
To change the version of this release, change your app's version in your pubspec.yaml.''',
);
throw ProcessExit(ExitCode.usage.code);
}
await assertObfuscationIsSupported();
}
@override
@@ -68,11 +76,16 @@ To change the version of this release, change your app's version in your pubspec
@override
Future<FileSystemEntity> buildReleaseArtifacts() async {
final base64PublicKey = await getEncodedPublicKey();
return artifactBuilder.buildWindowsApp(
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
final result = await artifactBuilder.buildWindowsApp(
target: target,
args: argResults.forwardedArgs,
args: buildArgs,
base64PublicKey: base64PublicKey,
);
verifyObfuscationMap();
return result;
}
@override
@@ -107,6 +120,8 @@ To change the version of this release, change your app's version in your pubspec
projectRoot: projectRoot.path,
releaseZipPath: zippedRelease.path,
);
await uploadSupplementArtifact(appId: appId, releaseId: release.id);
}
@override
@@ -137,6 +137,16 @@ Command that reads data from stdin and outputs a base64 signature to stdout.
description: 'The version of the release (e.g. "1.0.0").',
);
/// The Flutter --obfuscate argument.
static const obfuscateArg = ArgumentDescriber(
name: 'obfuscate',
description:
'In a release build, this flag removes identifiers and replaces '
'them with randomized values for the purposes of source code '
'obfuscation. This flag must always be combined with '
'"--split-debug-info" command, to not break crash reports.',
);
/// An argument that allows the user to specify a directory where program
/// symbols are stored.
static const splitDebugInfoArg = ArgumentDescriber(
@@ -251,6 +251,16 @@ extension ForwardedArgs on ArgResults {
}
}
/// Returns `['--$name']` when the boolean flag [name] was parsed and is
/// `true`, or an empty iterable otherwise.
Iterable<String> _flagNamed(String name) {
if (!wasParsed(name)) {
return [];
}
final value = this[name] as bool;
return value ? ['--$name'] : [];
}
/// A list of arguments parsed by Shorebird commands that will be forwarded
/// to the underlying Flutter commands (that is, placed after `--`).
List<String> get forwardedArgs {
@@ -269,6 +279,7 @@ extension ForwardedArgs on ArgResults {
..._argsNamed(CommonArguments.splitDebugInfoArg.name),
..._argsNamed(CommonArguments.exportMethodArg.name),
..._argsNamed(CommonArguments.exportOptionsPlistArg.name),
..._flagNamed(CommonArguments.obfuscateArg.name),
]);
return forwarded;
@@ -1836,7 +1836,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console'
const podfileLockHash = 'podfile-lock-hash';
final xcarchivePath = p.join('path', 'to', 'app.xcarchive');
final runnerPath = p.join('path', 'to', 'runner.app');
final releaseSupplementPath = p.join('path', 'to', 'supplement');
void setUpProjectRoot({String? flavor}) {
Directory(
@@ -1845,9 +1844,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console'
Directory(
p.join(projectRoot.path, runnerPath),
).createSync(recursive: true);
Directory(
p.join(projectRoot.path, releaseSupplementPath),
).createSync(recursive: true);
}
setUp(() {
@@ -1895,7 +1891,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console'
runnerPath: p.join(projectRoot.path, runnerPath),
isCodesigned: true,
podfileLockHash: podfileLockHash,
supplementPath: p.join(projectRoot.path, releaseSupplementPath),
),
),
exitsWithCode(ExitCode.software),
@@ -1935,7 +1930,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console'
runnerPath: p.join(projectRoot.path, runnerPath),
isCodesigned: false,
podfileLockHash: podfileLockHash,
supplementPath: p.join(projectRoot.path, releaseSupplementPath),
),
),
exitsWithCode(ExitCode.software),
@@ -1975,47 +1969,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console'
runnerPath: p.join(projectRoot.path, runnerPath),
isCodesigned: false,
podfileLockHash: podfileLockHash,
supplementPath: p.join(projectRoot.path, releaseSupplementPath),
),
),
exitsWithCode(ExitCode.software),
);
verify(() => progress.fail(any(that: contains(error)))).called(1);
},
);
test(
'exits with code 70 when supplement artifact creation fails',
() async {
const error = 'something went wrong';
when(
() => codePushClient.createReleaseArtifact(
appId: any(named: 'appId'),
artifactPath: any(
named: 'artifactPath',
that: endsWith('ios_supplement.zip'),
),
releaseId: any(named: 'releaseId'),
arch: any(named: 'arch'),
platform: any(named: 'platform'),
hash: any(named: 'hash'),
canSideload: any(named: 'canSideload'),
podfileLockHash: any(named: 'podfileLockHash'),
),
).thenThrow(error);
setUpProjectRoot();
await expectLater(
() async => runWithOverrides(
() async => codePushClientWrapper.createIosReleaseArtifacts(
appId: app.appId,
releaseId: releaseId,
xcarchivePath: p.join(projectRoot.path, xcarchivePath),
runnerPath: p.join(projectRoot.path, runnerPath),
isCodesigned: false,
podfileLockHash: podfileLockHash,
supplementPath: p.join(projectRoot.path, releaseSupplementPath),
),
),
exitsWithCode(ExitCode.software),
@@ -2048,7 +2001,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console'
runnerPath: p.join(projectRoot.path, runnerPath),
isCodesigned: true,
podfileLockHash: podfileLockHash,
supplementPath: p.join(projectRoot.path, releaseSupplementPath),
),
);
@@ -2158,15 +2110,11 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console'
group('createMacosReleaseArtifacts', () {
final appPath = p.join('path', 'to', 'Runner.app');
final releaseSupplementPath = p.join('path', 'to', 'supplement');
void setUpProjectRoot({String? flavor}) {
Directory(
p.join(projectRoot.path, appPath),
).createSync(recursive: true);
Directory(
p.join(projectRoot.path, releaseSupplementPath),
).createSync(recursive: true);
}
setUp(() {
@@ -2240,15 +2188,11 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console'
group('createIosFrameworkReleaseArtifacts', () {
final frameworkPath = p.join('path', 'to', 'App.xcframework');
final releaseSupplementPath = p.join('path', 'to', 'supplement');
void setUpProjectRoot({String? flavor}) {
Directory(
p.join(projectRoot.path, frameworkPath),
).createSync(recursive: true);
Directory(
p.join(projectRoot.path, releaseSupplementPath),
).createSync(recursive: true);
}
setUp(() {
@@ -2289,7 +2233,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console'
appId: app.appId,
releaseId: releaseId,
appFrameworkPath: p.join(projectRoot.path, frameworkPath),
supplementPath: null,
),
),
exitsWithCode(ExitCode.software),
@@ -2297,46 +2240,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console'
},
);
test(
'exits with code 70 when supplement artifact creation fails',
() async {
const error = 'something went wrong';
when(
() => codePushClient.createReleaseArtifact(
appId: any(named: 'appId'),
artifactPath: any(
named: 'artifactPath',
that: endsWith('ios_framework_supplement.zip'),
),
releaseId: any(named: 'releaseId'),
arch: any(named: 'arch'),
platform: any(named: 'platform'),
hash: any(named: 'hash'),
canSideload: any(named: 'canSideload'),
podfileLockHash: any(named: 'podfileLockHash'),
),
).thenThrow(error);
await expectLater(
() async => runWithOverrides(
() async =>
codePushClientWrapper.createIosFrameworkReleaseArtifacts(
appId: app.appId,
releaseId: releaseId,
appFrameworkPath: p.join(projectRoot.path, frameworkPath),
supplementPath: p.join(
projectRoot.path,
releaseSupplementPath,
),
),
),
exitsWithCode(ExitCode.software),
);
verify(() => progress.fail(any(that: contains(error)))).called(1);
},
);
test('completes successfully when release artifact is created', () async {
await expectLater(
runWithOverrides(
@@ -2344,7 +2247,6 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console'
appId: app.appId,
releaseId: releaseId,
appFrameworkPath: p.join(projectRoot.path, frameworkPath),
supplementPath: null,
),
),
completes,
@@ -449,6 +449,48 @@ void main() {
});
});
group('when obfuscationMapPath is set', () {
late File obfuscationMapFile;
setUp(() {
obfuscationMapFile =
File(
p.join(
Directory.systemTemp.createTempSync().path,
'obfuscation_map.json',
),
)
..createSync(recursive: true)
..writeAsStringSync('{"key": "value"}');
});
test(
'passes obfuscationGenSnapshotArgs to buildElfAotSnapshot',
() async {
patcher.obfuscationMapPath = obfuscationMapFile.path;
await runWithOverrides(patcher.buildPatchArtifact);
final captured = verify(
() => artifactBuilder.buildElfAotSnapshot(
appDillPath: any(named: 'appDillPath'),
outFilePath: any(named: 'outFilePath'),
genSnapshotArtifact: any(named: 'genSnapshotArtifact'),
additionalArgs: captureAny(named: 'additionalArgs'),
),
).captured;
final args = captured.last as List<String>;
expect(args, contains('--obfuscate'));
expect(
args.any(
(a) => a.startsWith('--load-obfuscation-map='),
),
isTrue,
);
},
);
});
group('when platform was specified via arg results rest', () {
setUp(() {
when(() => argResults.rest).thenReturn(['ios', '--verbose']);
@@ -539,7 +581,7 @@ void main() {
canSideload: true,
);
late File releaseArtifactFile;
late File supplementArtifactFile;
late Directory supplementDirectory;
void setUpProjectRootArtifacts() {
File(
@@ -582,12 +624,7 @@ void main() {
'release.xcframework',
),
)..createSync(recursive: true);
supplementArtifactFile = File(
p.join(
Directory.systemTemp.createTempSync().path,
'ios_framework_supplement.zip',
),
)..createSync(recursive: true);
supplementDirectory = Directory.systemTemp.createTempSync();
when(
() => codePushClientWrapper.getReleaseArtifact(
@@ -784,6 +821,57 @@ void main() {
).called(1);
});
group('when obfuscationMapPath is set', () {
late File obfuscationMapFile;
setUp(() {
obfuscationMapFile =
File(
p.join(
Directory.systemTemp.createTempSync().path,
'obfuscation_map.json',
),
)
..createSync(recursive: true)
..writeAsStringSync('{"key": "value"}');
});
test(
'passes obfuscationGenSnapshotArgs to runLinker',
() async {
patcher.obfuscationMapPath = obfuscationMapFile.path;
await runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
releaseArtifact: releaseArtifactFile,
),
);
final captured = verify(
() => apple.runLinker(
kernelFile: any(named: 'kernelFile'),
aotOutputFile: any(named: 'aotOutputFile'),
releaseArtifact: any(named: 'releaseArtifact'),
vmCodeFile: any(named: 'vmCodeFile'),
splitDebugInfoArgs: captureAny(
named: 'splitDebugInfoArgs',
),
),
).captured;
final args = captured.last as List<String>;
expect(args, contains('--obfuscate'));
expect(
args.any(
(a) => a.startsWith('--load-obfuscation-map='),
),
isTrue,
);
},
);
});
test('returns linked patch artifact in patch bundle', () async {
final patchBundle = await runWithOverrides(
() => patcher.createPatchArtifacts(
@@ -818,21 +906,12 @@ void main() {
p.join(outDir.path, 'ios-arm64', 'App.framework', 'App'),
).createSync(recursive: true);
});
when(
() => artifactManager.extractZip(
zipFile: supplementArtifactFile,
outputDirectory: any(named: 'outputDirectory'),
),
).thenAnswer((invocation) async {
final outDir =
invocation.namedArguments[#outputDirectory] as Directory;
File(
p.join(outDir.path, 'App.ct.link'),
).createSync(recursive: true);
File(
p.join(outDir.path, 'App.class_table.json'),
).createSync(recursive: true);
});
File(
p.join(supplementDirectory.path, 'App.ct.link'),
).createSync(recursive: true);
File(
p.join(supplementDirectory.path, 'App.class_table.json'),
).createSync(recursive: true);
});
test('returns linked patch artifact in patch bundle', () async {
@@ -841,7 +920,7 @@ void main() {
appId: appId,
releaseId: releaseId,
releaseArtifact: releaseArtifactFile,
supplementArtifact: supplementArtifactFile,
supplementDirectory: supplementDirectory,
),
);
@@ -864,7 +943,7 @@ void main() {
appId: appId,
releaseId: releaseId,
releaseArtifact: releaseArtifactFile,
supplementArtifact: supplementArtifactFile,
supplementDirectory: supplementDirectory,
),
);
expect(patcher.linkPercentage, isNotNull);
@@ -758,6 +758,84 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
await runWithOverrides(patcher.buildPatchArtifact);
expect(copiedKernelFile.existsSync(), isTrue);
});
group('when extraBuildArgs has obfuscation flags', () {
late File obfuscationMapFile;
setUp(() {
obfuscationMapFile =
File(
p.join(
Directory.systemTemp.createTempSync().path,
'obfuscation_map.json',
),
)
..createSync(recursive: true)
..writeAsStringSync('{"key": "value"}');
});
test('includes obfuscation flags in build args', () async {
patcher.obfuscationMapPath = obfuscationMapFile.path;
patcher.extraBuildArgs = [
'--obfuscate',
'--extra-gen-snapshot-options='
'--load-obfuscation-map=${obfuscationMapFile.path}',
'--split-debug-info=build/shorebird/symbols',
];
await runWithOverrides(patcher.buildPatchArtifact);
final captured = verify(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
args: captureAny(named: 'args'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).captured;
final args = captured.last as List<String>;
expect(args, contains('--obfuscate'));
expect(
args.any((a) => a.startsWith('--split-debug-info=')),
isTrue,
);
expect(
args,
contains(
'--extra-gen-snapshot-options='
'--load-obfuscation-map=${obfuscationMapFile.path}',
),
);
});
});
group('when extraBuildArgs is empty', () {
test('does not inject obfuscation flags', () async {
await runWithOverrides(patcher.buildPatchArtifact);
final captured = verify(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
args: captureAny(named: 'args'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).captured;
final args = captured.last as List<String>;
expect(args, isNot(contains('--obfuscate')));
expect(
args.any(
(a) => a.startsWith(
'--extra-gen-snapshot-options=--load-obfuscation-map',
),
),
isFalse,
);
});
});
});
});
@@ -783,7 +861,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
canSideload: true,
);
late File releaseArtifactFile;
late File supplementArtifactFile;
late Directory supplementDirectory;
void setUpProjectRootArtifacts() {
File(
@@ -839,12 +917,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
'release.xcarchive',
),
)..createSync(recursive: true);
supplementArtifactFile = File(
p.join(
Directory.systemTemp.createTempSync().path,
'ios_supplement.zip',
),
)..createSync(recursive: true);
supplementDirectory = Directory.systemTemp.createTempSync();
when(
() => codePushClientWrapper.getReleaseArtifact(
@@ -1115,21 +1188,12 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
group('when class table link info & debug info are present', () {
setUp(() {
when(
() => artifactManager.extractZip(
zipFile: supplementArtifactFile,
outputDirectory: any(named: 'outputDirectory'),
),
).thenAnswer((invocation) async {
final outDir =
invocation.namedArguments[#outputDirectory] as Directory;
File(
p.join(outDir.path, 'App.ct.link'),
).createSync(recursive: true);
File(
p.join(outDir.path, 'App.class_table.json'),
).createSync(recursive: true);
});
File(
p.join(supplementDirectory.path, 'App.ct.link'),
).createSync(recursive: true);
File(
p.join(supplementDirectory.path, 'App.class_table.json'),
).createSync(recursive: true);
});
test('returns linked patch artifact in patch bundle', () async {
@@ -1138,7 +1202,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
appId: appId,
releaseId: releaseId,
releaseArtifact: releaseArtifactFile,
supplementArtifact: supplementArtifactFile,
supplementDirectory: supplementDirectory,
),
);
@@ -1160,7 +1224,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
appId: appId,
releaseId: releaseId,
releaseArtifact: releaseArtifactFile,
supplementArtifact: supplementArtifactFile,
supplementDirectory: supplementDirectory,
),
);
expect(patcher.linkPercentage, isNotNull);
@@ -252,6 +252,52 @@ void main() {
);
});
});
group('when extraBuildArgs has obfuscation flags', () {
setUp(() {
final releaseDir = Directory(
p.join(
projectRoot.path,
'build',
'linux',
'x64',
'release',
'bundle',
),
)..createSync(recursive: true);
when(
() => artifactManager.linuxBundleDirectory,
).thenReturn(releaseDir);
when(
() => artifactBuilder.buildLinuxApp(
base64PublicKey: any(named: 'base64PublicKey'),
args: any(named: 'args'),
),
).thenAnswer((_) async => {});
});
test('passes extraBuildArgs to buildLinuxApp', () async {
patcher.extraBuildArgs = [
'--obfuscate',
'--split-debug-info=build/shorebird/symbols',
];
await runWithOverrides(() => patcher.buildPatchArtifact());
final captured = verify(
() => artifactBuilder.buildLinuxApp(
base64PublicKey: any(named: 'base64PublicKey'),
args: captureAny(named: 'args'),
),
).captured;
final args = captured.last as List<String>;
expect(args, contains('--obfuscate'));
expect(
args.any((a) => a.startsWith('--split-debug-info=')),
isTrue,
);
});
});
});
group('createPatchArtifacts', () {
@@ -342,7 +388,7 @@ void main() {
appId: 'com.example.app',
releaseId: 1,
releaseArtifact: releaseArtifact,
supplementArtifact: File('supplement.zip'),
supplementDirectory: Directory('supplement'),
),
);
@@ -183,6 +183,15 @@ void main() {
});
});
group('supplementaryReleaseArtifactArch', () {
test('is "macos_supplement"', () {
expect(
patcher.supplementaryReleaseArtifactArch,
'macos_supplement',
);
});
});
group('releaseType', () {
test('is ReleaseType.macos', () {
expect(patcher.releaseType, ReleaseType.macos);
@@ -866,6 +875,84 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
await runWithOverrides(patcher.buildPatchArtifact);
expect(copiedKernelFile.existsSync(), isTrue);
});
group('when extraBuildArgs has obfuscation flags', () {
late File obfuscationMapFile;
setUp(() {
obfuscationMapFile =
File(
p.join(
Directory.systemTemp.createTempSync().path,
'obfuscation_map.json',
),
)
..createSync(recursive: true)
..writeAsStringSync('{"key": "value"}');
});
test('includes obfuscation flags in build args', () async {
patcher.obfuscationMapPath = obfuscationMapFile.path;
patcher.extraBuildArgs = [
'--obfuscate',
'--extra-gen-snapshot-options='
'--load-obfuscation-map=${obfuscationMapFile.path}',
'--split-debug-info=build/shorebird/symbols',
];
await runWithOverrides(patcher.buildPatchArtifact);
final captured = verify(
() => artifactBuilder.buildMacos(
codesign: any(named: 'codesign'),
args: captureAny(named: 'args'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).captured;
final args = captured.last as List<String>;
expect(args, contains('--obfuscate'));
expect(
args.any((a) => a.startsWith('--split-debug-info=')),
isTrue,
);
expect(
args,
contains(
'--extra-gen-snapshot-options='
'--load-obfuscation-map=${obfuscationMapFile.path}',
),
);
});
});
group('when extraBuildArgs is empty', () {
test('does not inject obfuscation flags', () async {
await runWithOverrides(patcher.buildPatchArtifact);
final captured = verify(
() => artifactBuilder.buildMacos(
codesign: any(named: 'codesign'),
args: captureAny(named: 'args'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).captured;
final args = captured.last as List<String>;
expect(args, isNot(contains('--obfuscate')));
expect(
args.any(
(a) => a.startsWith(
'--extra-gen-snapshot-options=--load-obfuscation-map',
),
),
isFalse,
);
});
});
});
});
@@ -200,6 +200,7 @@ void main() {
when(
() => argResults.wasParsed(CommonArguments.signCmd.name),
).thenReturn(false);
when(() => argResults.rest).thenReturn([]);
when(aotTools.isLinkDebugInfoSupported).thenAnswer((_) async => true);
@@ -209,6 +210,12 @@ void main() {
message: any(named: 'message'),
),
).thenAnswer((_) async => File(''));
when(
() => artifactManager.extractZip(
zipFile: any(named: 'zipFile'),
outputDirectory: any(named: 'outputDirectory'),
),
).thenAnswer((_) async {});
when(() => cache.updateAll()).thenAnswer((_) async => {});
@@ -291,7 +298,7 @@ void main() {
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
releaseArtifact: any(named: 'releaseArtifact'),
supplementArtifact: any(named: 'supplementArtifact'),
supplementDirectory: any(named: 'supplementDirectory'),
),
).thenAnswer((_) async => patchArtifactBundles);
when(
@@ -541,7 +548,7 @@ void main() {
appId: appId,
releaseId: release.id,
releaseArtifact: any(named: 'releaseArtifact'),
supplementArtifact: any(named: 'supplementArtifact'),
supplementDirectory: any(named: 'supplementDirectory'),
),
).called(1);
});
@@ -565,12 +572,36 @@ void main() {
appId: appId,
releaseId: release.id,
releaseArtifact: any(named: 'releaseArtifact'),
supplementArtifact: any(named: 'supplementArtifact'),
supplementDirectory: any(named: 'supplementDirectory'),
),
).called(1);
});
});
});
group(
'when --obfuscate is passed but release has no obfuscation map',
() {
setUp(() {
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => argResults['obfuscate']).thenReturn(true);
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(() => command.createPatch(patcher)),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
'--obfuscate was passed, but the release was not built with '
'obfuscation. A patch cannot change the obfuscation mode of '
'a release.',
),
).called(1);
});
},
);
});
});
@@ -633,7 +633,7 @@ class _TestPatcher extends Patcher {
required String appId,
required int releaseId,
required File releaseArtifact,
File? supplementArtifact,
Directory? supplementDirectory,
}) {
throw UnimplementedError();
}
@@ -443,7 +443,7 @@ void main() {
appId: 'com.example.app',
releaseId: 1,
releaseArtifact: releaseArtifact,
supplementArtifact: File('supplement.zip'),
supplementDirectory: Directory('supplement'),
),
);
@@ -5,7 +5,9 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/commands/release/aar_releaser.dart';
@@ -18,6 +20,7 @@ import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
@@ -33,6 +36,7 @@ void main() {
late ArgResults argResults;
late ArtifactBuilder artifactBuilder;
late ArtifactManager artifactManager;
late CodePushClientWrapper codePushClientWrapper;
late CodeSigner codeSigner;
late Directory projectRoot;
@@ -41,6 +45,7 @@ void main() {
late Progress progress;
late ShorebirdProcess shorebirdProcess;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late ShorebirdValidator shorebirdValidator;
late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts;
late AarReleaser aarReleaser;
@@ -50,6 +55,7 @@ void main() {
body,
values: {
artifactBuilderRef.overrideWith(() => artifactBuilder),
artifactManagerRef.overrideWith(() => artifactManager),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
codeSignerRef.overrideWith(() => codeSigner),
engineConfigRef.overrideWith(() => const EngineConfig.empty()),
@@ -57,6 +63,7 @@ void main() {
osInterfaceRef.overrideWith(() => operatingSystemInterface),
processRef.overrideWith(() => shorebirdProcess),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdFlutterRef.overrideWith(() => shorebirdFlutter),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
shorebirdAndroidArtifactsRef.overrideWith(
() => shorebirdAndroidArtifacts,
@@ -74,6 +81,7 @@ void main() {
setUp(() {
argResults = MockArgResults();
artifactBuilder = MockArtifactBuilder();
artifactManager = MockArtifactManager();
codePushClientWrapper = MockCodePushClientWrapper();
codeSigner = MockCodeSigner();
operatingSystemInterface = MockOperatingSystemInterface();
@@ -82,6 +90,7 @@ void main() {
logger = MockShorebirdLogger();
shorebirdProcess = MockShorebirdProcess();
shorebirdEnv = MockShorebirdEnv();
shorebirdFlutter = MockShorebirdFlutter();
shorebirdValidator = MockShorebirdValidator();
shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts();
@@ -233,6 +242,58 @@ void main() {
);
});
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults.wasParsed('release-version')).thenReturn(true);
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
});
group('when Flutter version supports obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 41, 2));
});
test('returns normally', () async {
await expectLater(
runWithOverrides(aarReleaser.assertArgsAreValid),
completes,
);
});
});
group('when Flutter version does not support obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 27, 4));
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(aarReleaser.assertArgsAreValid),
exitsWithCode(ExitCode.unavailable),
);
});
});
});
group('when --obfuscate is not passed', () {
setUp(() {
when(() => argResults.wasParsed('release-version')).thenReturn(true);
});
test('returns normally', () async {
await expectLater(
runWithOverrides(aarReleaser.assertArgsAreValid),
completes,
);
});
});
});
group('buildReleaseArtifacts', () {
@@ -411,6 +472,93 @@ void main() {
},
);
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
// Simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildAar(
buildNumber: any(named: 'buildNumber'),
targetPlatforms: any(named: 'targetPlatforms'),
args: any(named: 'args'),
),
).thenAnswer((_) async {
final mapPath = p.join(
projectRoot.path,
'build',
'shorebird',
'obfuscation_map.json',
);
File(mapPath)
..createSync(recursive: true)
..writeAsStringSync('{}');
});
});
test('injects --save-obfuscation-map into build args', () async {
await runWithOverrides(aarReleaser.buildReleaseArtifacts);
final captured = verify(
() => artifactBuilder.buildAar(
buildNumber: any(named: 'buildNumber'),
targetPlatforms: any(named: 'targetPlatforms'),
args: captureAny(named: 'args'),
),
).captured;
final args = captured.last as List<String>;
expect(
args.any(
(a) => a.startsWith(
'--extra-gen-snapshot-options=--save-obfuscation-map=',
),
),
isTrue,
);
});
test('logs detail about map location', () async {
await runWithOverrides(aarReleaser.buildReleaseArtifacts);
verify(
() => logger.detail(
any(that: startsWith('Obfuscation map saved to')),
),
).called(1);
});
group('when obfuscation map is not generated', () {
setUp(() {
// Override to NOT create the map file.
when(
() => artifactBuilder.buildAar(
buildNumber: any(named: 'buildNumber'),
targetPlatforms: any(named: 'targetPlatforms'),
args: any(named: 'args'),
),
).thenAnswer((_) async {});
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(aarReleaser.buildReleaseArtifacts),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
any(
that: contains(
'Obfuscation was enabled but the obfuscation map was not',
),
),
),
).called(1);
});
});
});
});
});
@@ -5,7 +5,9 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/commands/release/android_releaser.dart';
@@ -20,6 +22,7 @@ import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
@@ -34,6 +37,7 @@ void main() {
group(AndroidReleaser, () {
late ArgResults argResults;
late ArtifactBuilder artifactBuilder;
late ArtifactManager artifactManager;
late CodePushClientWrapper codePushClientWrapper;
late CodeSigner codeSigner;
late Doctor doctor;
@@ -44,6 +48,7 @@ void main() {
late Progress progress;
late ShorebirdProcess shorebirdProcess;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late ShorebirdValidator shorebirdValidator;
late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts;
late AndroidReleaser androidReleaser;
@@ -53,6 +58,7 @@ void main() {
body,
values: {
artifactBuilderRef.overrideWith(() => artifactBuilder),
artifactManagerRef.overrideWith(() => artifactManager),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
codeSignerRef.overrideWith(() => codeSigner),
doctorRef.overrideWith(() => doctor),
@@ -61,6 +67,7 @@ void main() {
osInterfaceRef.overrideWith(() => operatingSystemInterface),
processRef.overrideWith(() => shorebirdProcess),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdFlutterRef.overrideWith(() => shorebirdFlutter),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
shorebirdAndroidArtifactsRef.overrideWith(
() => shorebirdAndroidArtifacts,
@@ -78,6 +85,7 @@ void main() {
setUp(() {
argResults = MockArgResults();
artifactBuilder = MockArtifactBuilder();
artifactManager = MockArtifactManager();
codePushClientWrapper = MockCodePushClientWrapper();
codeSigner = MockCodeSigner();
doctor = MockDoctor();
@@ -88,6 +96,7 @@ void main() {
logger = MockShorebirdLogger();
shorebirdProcess = MockShorebirdProcess();
shorebirdEnv = MockShorebirdEnv();
shorebirdFlutter = MockShorebirdFlutter();
shorebirdValidator = MockShorebirdValidator();
shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts();
@@ -277,6 +286,60 @@ To change the version of this release, change your app's version in your pubspec
);
});
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults['artifact']).thenReturn('aab');
when(() => argResults['split-per-abi']).thenReturn(false);
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
});
group('when Flutter version supports obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 41, 2));
});
test('returns normally', () async {
await expectLater(
runWithOverrides(androidReleaser.assertArgsAreValid),
completes,
);
});
});
group('when Flutter version does not support obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 27, 4));
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(androidReleaser.assertArgsAreValid),
exitsWithCode(ExitCode.unavailable),
);
});
});
});
group('when --obfuscate is not passed', () {
setUp(() {
when(() => argResults['artifact']).thenReturn('aab');
when(() => argResults['split-per-abi']).thenReturn(false);
});
test('returns normally', () async {
await expectLater(
runWithOverrides(androidReleaser.assertArgsAreValid),
completes,
);
});
});
});
group('buildReleaseArtifacts', () {
@@ -157,6 +157,58 @@ void main() {
);
});
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults.wasParsed('release-version')).thenReturn(true);
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
});
group('when Flutter version supports obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 41, 2));
});
test('returns normally', () async {
await expectLater(
runWithOverrides(iosFrameworkReleaser.assertArgsAreValid),
completes,
);
});
});
group('when Flutter version does not support obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 27, 4));
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(iosFrameworkReleaser.assertArgsAreValid),
exitsWithCode(ExitCode.unavailable),
);
});
});
});
group('when --obfuscate is not passed', () {
setUp(() {
when(() => argResults.wasParsed('release-version')).thenReturn(true);
});
test('returns normally', () async {
await expectLater(
runWithOverrides(iosFrameworkReleaser.assertArgsAreValid),
completes,
);
});
});
});
group('assertPreconditions', () {
@@ -426,6 +478,93 @@ void main() {
expect(xcframework.path, p.join(projectRoot.path, 'release'));
verify(() => artifactBuilder.buildIosFramework(args: [])).called(1);
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
// Simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildIosFramework(
args: any(named: 'args'),
),
).thenAnswer((_) async {
final mapPath = p.join(
projectRoot.path,
'build',
'shorebird',
'obfuscation_map.json',
);
File(mapPath)
..createSync(recursive: true)
..writeAsStringSync('{}');
return AppleBuildResult(kernelFile: File('/path/to/app.dill'));
});
});
test('injects --save-obfuscation-map into build args', () async {
await runWithOverrides(iosFrameworkReleaser.buildReleaseArtifacts);
final captured = verify(
() => artifactBuilder.buildIosFramework(
args: captureAny(named: 'args'),
),
).captured;
final args = captured.last as List<String>;
expect(
args.any(
(a) => a.startsWith(
'--extra-gen-snapshot-options=--save-obfuscation-map=',
),
),
isTrue,
);
});
test('logs detail about map location', () async {
await runWithOverrides(iosFrameworkReleaser.buildReleaseArtifacts);
verify(
() => logger.detail(
any(that: startsWith('Obfuscation map saved to')),
),
).called(1);
});
group('when obfuscation map is not generated', () {
setUp(() {
// Override to NOT create the map file.
when(
() => artifactBuilder.buildIosFramework(
args: any(named: 'args'),
),
).thenAnswer(
(_) async =>
AppleBuildResult(kernelFile: File('/path/to/app.dill')),
);
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(
iosFrameworkReleaser.buildReleaseArtifacts,
),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
any(
that: contains(
'Obfuscation was enabled but the obfuscation map was not',
),
),
),
).called(1);
});
});
});
});
group('getReleaseVersion', () {
@@ -468,7 +607,6 @@ void main() {
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
appFrameworkPath: any(named: 'appFrameworkPath'),
supplementPath: any(named: 'supplementPath'),
),
).thenAnswer((_) async {});
});
@@ -490,7 +628,6 @@ void main() {
'release',
ArtifactManager.appXcframeworkName,
),
supplementPath: null,
),
).called(1);
});
@@ -237,7 +237,7 @@ void main() {
() => logger.err(
'''
The "--release-version" flag is only supported for aar and ios-framework releases.
To change the version of this release, change your app's version in your pubspec.yaml.''',
),
).called(1);
@@ -246,25 +246,39 @@ To change the version of this release, change your app's version in your pubspec
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults.rest).thenReturn(['--obfuscate']);
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
});
test('logs error and exits', () async {
await expectLater(
runWithOverrides(iosReleaser.assertArgsAreValid),
exitsWithCode(ExitCode.unavailable),
);
group('when Flutter version supports obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 41, 2));
});
verify(
() => logger.err(
'Shorebird does not currently support obfuscation on iOS.',
),
).called(1);
verify(
() => logger.info(
'''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''',
),
).called(1);
test('returns normally', () async {
await expectLater(
runWithOverrides(iosReleaser.assertArgsAreValid),
completes,
);
});
});
group('when Flutter version does not support obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 27, 4));
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(iosReleaser.assertArgsAreValid),
exitsWithCode(ExitCode.unavailable),
);
});
});
});
@@ -532,6 +546,158 @@ To change the version of this release, change your app's version in your pubspec
verify(() => logger.err('Unable to find .app directory')).called(1);
});
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
// By default, simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: any(named: 'args'),
),
).thenAnswer((_) async {
final mapPath = p.join(
projectRoot.path,
'build',
'shorebird',
'obfuscation_map.json',
);
File(mapPath)
..createSync(recursive: true)
..writeAsStringSync('{}');
return AppleBuildResult(
kernelFile: File('/path/to/app.dill'),
);
});
});
test('injects --save-obfuscation-map into build args', () async {
await runWithOverrides(iosReleaser.buildReleaseArtifacts);
final captured = verify(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: captureAny(named: 'args'),
),
).captured;
final args = captured.last as List<String>;
expect(
args.any(
(a) => a.startsWith(
'--extra-gen-snapshot-options=--save-obfuscation-map=',
),
),
isTrue,
);
});
test('auto-defaults --split-debug-info when not provided', () async {
await runWithOverrides(iosReleaser.buildReleaseArtifacts);
final captured = verify(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: captureAny(named: 'args'),
),
).captured;
final args = captured.last as List<String>;
expect(
args.any(
(a) => a.startsWith('--split-debug-info='),
),
isTrue,
);
});
group('when --split-debug-info is also provided', () {
setUp(() {
when(
() => argResults.wasParsed('split-debug-info'),
).thenReturn(true);
when(
() => argResults['split-debug-info'],
).thenReturn('custom/symbols');
});
test('preserves user-provided --split-debug-info', () async {
await runWithOverrides(iosReleaser.buildReleaseArtifacts);
final captured = verify(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: captureAny(named: 'args'),
),
).captured;
final args = captured.last as List<String>;
expect(
args,
contains('--split-debug-info=custom/symbols'),
);
// Should not contain a second auto-defaulted one.
expect(
args.where((a) => a.startsWith('--split-debug-info=')).length,
equals(1),
);
});
});
test('logs detail about map location', () async {
await runWithOverrides(iosReleaser.buildReleaseArtifacts);
verify(
() => logger.detail(
any(that: startsWith('Obfuscation map saved to')),
),
).called(1);
});
group('when obfuscation map is not generated', () {
setUp(() {
// Override to NOT create the map file.
when(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: any(named: 'args'),
),
).thenAnswer(
(_) async =>
AppleBuildResult(kernelFile: File('/path/to/app.dill')),
);
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(iosReleaser.buildReleaseArtifacts),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
any(
that: contains(
'Obfuscation was enabled but the obfuscation map was not',
),
),
),
).called(1);
});
});
});
});
group('getReleaseVersion', () {
@@ -674,6 +840,9 @@ To change the version of this release, change your app's version in your pubspec
setUp(() {
when(() => argResults['codesign']).thenReturn(codesign);
when(
() => shorebirdEnv.getShorebirdProjectRoot(),
).thenReturn(projectRoot);
xcarchiveDirectory = Directory.systemTemp.createTempSync();
iosAppDirectory = Directory.systemTemp.createTempSync();
@@ -706,7 +875,6 @@ To change the version of this release, change your app's version in your pubspec
runnerPath: any(named: 'runnerPath'),
isCodesigned: any(named: 'isCodesigned'),
podfileLockHash: any(named: 'podfileLockHash'),
supplementPath: any(named: 'supplementPath'),
),
).thenAnswer((_) async => {});
when(() => shorebirdEnv.iosPodfileLockFile).thenReturn(podfileLockFile);
@@ -729,10 +897,46 @@ To change the version of this release, change your app's version in your pubspec
isCodesigned: codesign,
podfileLockHash:
'${sha256.convert(utf8.encode(podfileLockContent))}',
supplementPath: supplementDirectory.path,
),
).called(1);
});
group('when obfuscation map exists', () {
setUp(() {
// Create the obfuscation map file at the expected path.
File(
p.join(
projectRoot.path,
'build',
'shorebird',
'obfuscation_map.json',
),
)
..createSync(recursive: true)
..writeAsStringSync('{"key": "value"}');
});
test('passes obfuscation map path to wrapper', () async {
await runWithOverrides(
() => iosReleaser.uploadReleaseArtifacts(
release: release,
appId: appId,
),
);
verify(
() => codePushClientWrapper.createIosReleaseArtifacts(
appId: appId,
releaseId: release.id,
xcarchivePath: xcarchiveDirectory.path,
runnerPath: iosAppDirectory.path,
isCodesigned: codesign,
podfileLockHash:
'${sha256.convert(utf8.encode(podfileLockContent))}',
),
).called(1);
});
});
});
group('updatedReleaseMetadata', () {
@@ -16,6 +16,7 @@ import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
@@ -154,6 +155,53 @@ To change the version of this release, change your app's version in your pubspec
).called(1);
});
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
});
group('when Flutter version supports obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 41, 2));
});
test('returns normally', () async {
await expectLater(
runWithOverrides(releaser.assertArgsAreValid),
completes,
);
});
});
group('when Flutter version does not support obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 27, 4));
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(releaser.assertArgsAreValid),
exitsWithCode(ExitCode.unavailable),
);
});
});
});
group('when --obfuscate is not passed', () {
test('returns normally', () async {
await expectLater(
runWithOverrides(releaser.assertArgsAreValid),
completes,
);
});
});
});
group('assertPreconditions', () {
@@ -356,6 +404,93 @@ To change the version of this release, change your app's version in your pubspec
).called(1);
});
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
// Simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildLinuxApp(
target: any(named: 'target'),
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer((_) async {
final mapPath = p.join(
projectRoot.path,
'build',
'shorebird',
'obfuscation_map.json',
);
File(mapPath)
..createSync(recursive: true)
..writeAsStringSync('{}');
});
});
test('injects --save-obfuscation-map into build args', () async {
await runWithOverrides(releaser.buildReleaseArtifacts);
final captured = verify(
() => artifactBuilder.buildLinuxApp(
target: any(named: 'target'),
args: captureAny(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).captured;
final args = captured.last as List<String>;
expect(
args.any(
(a) => a.startsWith(
'--extra-gen-snapshot-options=--save-obfuscation-map=',
),
),
isTrue,
);
});
test('logs detail about map location', () async {
await runWithOverrides(releaser.buildReleaseArtifacts);
verify(
() => logger.detail(
any(that: startsWith('Obfuscation map saved to')),
),
).called(1);
});
group('when obfuscation map is not generated', () {
setUp(() {
// Override to NOT create the map file.
when(
() => artifactBuilder.buildLinuxApp(
target: any(named: 'target'),
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer((_) async {});
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(releaser.buildReleaseArtifacts),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
any(
that: contains(
'Obfuscation was enabled but the obfuscation map was not',
),
),
),
).called(1);
});
});
});
});
group('getReleaseVersion', () {
@@ -219,7 +219,7 @@ void main() {
() => logger.err(
'''
The "--release-version" flag is only supported for aar and ios-framework releases.
To change the version of this release, change your app's version in your pubspec.yaml.''',
),
).called(1);
@@ -228,25 +228,39 @@ To change the version of this release, change your app's version in your pubspec
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults.rest).thenReturn(['--obfuscate']);
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
});
test('logs error and exits', () async {
await expectLater(
runWithOverrides(releaser.assertArgsAreValid),
exitsWithCode(ExitCode.unavailable),
);
group('when Flutter version supports obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 41, 2));
});
verify(
() => logger.err(
'Shorebird does not currently support obfuscation on macOS.',
),
).called(1);
verify(
() => logger.info(
'''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''',
),
).called(1);
test('returns normally', () async {
await expectLater(
runWithOverrides(releaser.assertArgsAreValid),
completes,
);
});
});
group('when Flutter version does not support obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 27, 4));
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(releaser.assertArgsAreValid),
exitsWithCode(ExitCode.unavailable),
);
});
});
});
@@ -435,6 +449,163 @@ To change the version of this release, change your app's version in your pubspec
verify(() => logger.err('Unable to find .app directory')).called(1);
});
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
// By default, simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildMacos(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer((_) async {
final mapPath = p.join(
projectRoot.path,
'build',
'shorebird',
'obfuscation_map.json',
);
File(mapPath)
..createSync(recursive: true)
..writeAsStringSync('{}');
return AppleBuildResult(
kernelFile: File('/path/to/app.dill'),
);
});
});
test('injects --save-obfuscation-map into build args', () async {
await runWithOverrides(releaser.buildReleaseArtifacts);
final captured = verify(
() => artifactBuilder.buildMacos(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: captureAny(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).captured;
final args = captured.last as List<String>;
expect(
args.any(
(a) => a.startsWith(
'--extra-gen-snapshot-options=--save-obfuscation-map=',
),
),
isTrue,
);
});
test('auto-defaults --split-debug-info when not provided', () async {
await runWithOverrides(releaser.buildReleaseArtifacts);
final captured = verify(
() => artifactBuilder.buildMacos(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: captureAny(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).captured;
final args = captured.last as List<String>;
expect(
args.any(
(a) => a.startsWith('--split-debug-info='),
),
isTrue,
);
});
group('when --split-debug-info is also provided', () {
setUp(() {
when(
() => argResults.wasParsed('split-debug-info'),
).thenReturn(true);
when(
() => argResults['split-debug-info'],
).thenReturn('custom/symbols');
});
test('preserves user-provided --split-debug-info', () async {
await runWithOverrides(releaser.buildReleaseArtifacts);
final captured = verify(
() => artifactBuilder.buildMacos(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: captureAny(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).captured;
final args = captured.last as List<String>;
expect(
args,
contains('--split-debug-info=custom/symbols'),
);
// Should not contain a second auto-defaulted one.
expect(
args.where((a) => a.startsWith('--split-debug-info=')).length,
equals(1),
);
});
});
test('logs detail about map location', () async {
await runWithOverrides(releaser.buildReleaseArtifacts);
verify(
() => logger.detail(
any(that: startsWith('Obfuscation map saved to')),
),
).called(1);
});
group('when obfuscation map is not generated', () {
setUp(() {
// Override to NOT create the map file.
when(
() => artifactBuilder.buildMacos(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer(
(_) async =>
AppleBuildResult(kernelFile: File('/path/to/app.dill')),
);
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(releaser.buildReleaseArtifacts),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
any(
that: contains(
'Obfuscation was enabled but the obfuscation map was not',
),
),
),
).called(1);
});
});
});
});
group('getReleaseVersion', () {
@@ -577,6 +748,9 @@ To change the version of this release, change your app's version in your pubspec
setUp(() {
when(() => argResults['codesign']).thenReturn(codesign);
when(
() => shorebirdEnv.getShorebirdProjectRoot(),
).thenReturn(projectRoot);
appDirectory = Directory.systemTemp.createTempSync();
@@ -644,6 +818,42 @@ To change the version of this release, change your app's version in your pubspec
),
).called(1);
});
group('when obfuscation map exists', () {
setUp(() {
// Create the obfuscation map file at the expected path.
File(
p.join(
projectRoot.path,
'build',
'shorebird',
'obfuscation_map.json',
),
)
..createSync(recursive: true)
..writeAsStringSync('{"key": "value"}');
});
test('passes obfuscation map path to wrapper', () async {
await runWithOverrides(
() => releaser.uploadReleaseArtifacts(
release: release,
appId: appId,
),
);
verify(
() => codePushClientWrapper.createMacosReleaseArtifacts(
appId: appId,
releaseId: release.id,
appPath: appDirectory.path,
isCodesigned: codesign,
podfileLockHash:
'${sha256.convert(utf8.encode(podfileLockContent))}',
),
).called(1);
});
});
});
group('updatedReleaseMetadata', () {
@@ -18,6 +18,7 @@ import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
@@ -160,12 +161,59 @@ void main() {
() => logger.err(
'''
The "--release-version" flag is only supported for aar and ios-framework releases.
To change the version of this release, change your app's version in your pubspec.yaml.''',
),
).called(1);
});
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
});
group('when Flutter version supports obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 41, 2));
});
test('returns normally', () async {
await expectLater(
runWithOverrides(releaser.assertArgsAreValid),
completes,
);
});
});
group('when Flutter version does not support obfuscation', () {
setUp(() {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 27, 4));
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(releaser.assertArgsAreValid),
exitsWithCode(ExitCode.unavailable),
);
});
});
});
group('when --obfuscate is not passed', () {
test('returns normally', () async {
await expectLater(
runWithOverrides(releaser.assertArgsAreValid),
completes,
);
});
});
});
group('assertPreconditions', () {
@@ -371,6 +419,94 @@ To change the version of this release, change your app's version in your pubspec
).called(1);
});
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
// Simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildWindowsApp(
target: any(named: 'target'),
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer((_) async {
final mapPath = p.join(
projectRoot.path,
'build',
'shorebird',
'obfuscation_map.json',
);
File(mapPath)
..createSync(recursive: true)
..writeAsStringSync('{}');
return projectRoot;
});
});
test('injects --save-obfuscation-map into build args', () async {
await runWithOverrides(releaser.buildReleaseArtifacts);
final captured = verify(
() => artifactBuilder.buildWindowsApp(
target: any(named: 'target'),
args: captureAny(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).captured;
final args = captured.last as List<String>;
expect(
args.any(
(a) => a.startsWith(
'--extra-gen-snapshot-options=--save-obfuscation-map=',
),
),
isTrue,
);
});
test('logs detail about map location', () async {
await runWithOverrides(releaser.buildReleaseArtifacts);
verify(
() => logger.detail(
any(that: startsWith('Obfuscation map saved to')),
),
).called(1);
});
group('when obfuscation map is not generated', () {
setUp(() {
// Override to NOT create the map file.
when(
() => artifactBuilder.buildWindowsApp(
target: any(named: 'target'),
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer((_) async => projectRoot);
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(releaser.buildReleaseArtifacts),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
any(
that: contains(
'Obfuscation was enabled but the obfuscation map was not',
),
),
),
).called(1);
});
});
});
});
group('getReleaseVersion', () {
@@ -138,6 +138,10 @@ void main() {
'platforms',
allowed: ReleaseType.values.map((e) => e.cliName),
)
..addFlag(
CommonArguments.obfuscateArg.name,
negatable: false,
)
..addFlag('verbose', abbr: 'v');
});
@@ -317,6 +321,23 @@ void main() {
);
});
});
group('when --obfuscate flag is provided', () {
test('forwards it', () {
final args = ['--verbose', '--obfuscate'];
final result = parser.parse(args);
expect(result.forwardedArgs, hasLength(1));
expect(result.forwardedArgs, contains('--obfuscate'));
});
});
group('when --obfuscate flag is not provided', () {
test('does not forward it', () {
final args = ['--verbose'];
final result = parser.parse(args);
expect(result.forwardedArgs, isNot(contains('--obfuscate')));
});
});
});
group('CodeSign', () {