diff --git a/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart b/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart index dc22e30da..76a5bce29 100644 --- a/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart +++ b/packages/shorebird_cli/lib/src/artifact_builder/artifact_builder.dart @@ -69,6 +69,25 @@ extension on String { /// This allow us to just call var?.toPublicKeyEnv() instead of doing /// a ternary operation to check if the value is null. Map toPublicKeyEnv() => {'SHOREBIRD_PUBLIC_KEY': this}; + + /// Returns a map with the SHOREBIRD_MODULE_VERSION environment variable. + Map toModuleVersionEnv() => { + 'SHOREBIRD_MODULE_VERSION': this, + }; +} + +/// Combines public-key and module-version env vars into a single map for +/// passing through to `flutter build`. Returns `null` when neither is set +/// so we don't override the parent process env with an empty map. +Map? _buildEnv({ + String? base64PublicKey, + String? moduleVersion, +}) { + if (base64PublicKey == null && moduleVersion == null) return null; + return { + ...?base64PublicKey?.toPublicKeyEnv(), + ...?moduleVersion?.toModuleVersionEnv(), + }; } /// @{template artifact_builder} @@ -286,6 +305,7 @@ Reason: Exited with code $exitCode.''', Iterable? targetPlatforms, List args = const [], String? base64PublicKey, + String? moduleVersion, }) async { return _runShorebirdBuildCommand(() async { const executable = 'flutter'; @@ -304,7 +324,10 @@ Reason: Exited with code $exitCode.''', final exitCode = await process.stream( executable, arguments, - environment: base64PublicKey?.toPublicKeyEnv(), + environment: _buildEnv( + base64PublicKey: base64PublicKey, + moduleVersion: moduleVersion, + ), // Never run in shell because we always have a fully resolved // executable path. runInShell: false, @@ -506,6 +529,7 @@ Reason: Exited with code $exitCode.''', Future buildIosFramework({ List args = const [], String? base64PublicKey, + String? moduleVersion, }) async { final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; // Delete the .dart_tool directory to ensure that the app is rebuilt. This @@ -530,7 +554,10 @@ Reason: Exited with code $exitCode.''', final exitCode = await process.stream( executable, arguments, - environment: base64PublicKey?.toPublicKeyEnv(), + environment: _buildEnv( + base64PublicKey: base64PublicKey, + moduleVersion: moduleVersion, + ), // Never run in shell because we always have a fully resolved // executable path. runInShell: false, diff --git a/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart index 53064e7f6..742d0e6fa 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/aar_patcher.dart @@ -79,10 +79,12 @@ class AarPatcher extends Patcher { @override Future buildPatchArtifact({String? releaseVersion}) async { final buildArgs = [...argResults.forwardedArgs, ...extraBuildArgs]; + await artifactBuilder.buildAar( buildNumber: buildNumber, args: buildArgs, base64PublicKey: argResults.encodedPublicKey, + moduleVersion: releaseVersion, ); return File( diff --git a/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart index db822f625..d372471e1 100644 --- a/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart @@ -72,11 +72,7 @@ class AarReleaser extends Releaser { @override Future assertArgsAreValid() async { - if (!argResults.wasParsed('release-version')) { - logger.err('Missing required argument: --release-version'); - throw ProcessExit(ExitCode.usage.code); - } - + await resolveModuleReleaseVersionArgs(); await assertObfuscationIsSupported(); } @@ -86,12 +82,15 @@ class AarReleaser extends Releaser { final buildArgs = [...argResults.forwardedArgs]; addSplitDebugInfoDefault(buildArgs); addObfuscationMapArgs(buildArgs); + await artifactBuilder.buildAar( buildNumber: buildNumber, targetPlatforms: architectures, args: buildArgs, base64PublicKey: base64PublicKey, + moduleVersion: moduleVersion, ); + verifyObfuscationMap(); // Copy release AAR to a new directory to avoid overwriting with @@ -111,7 +110,7 @@ class AarReleaser extends Releaser { Future getReleaseVersion({ required FileSystemEntity releaseArtifactRoot, }) async { - return argResults['release-version'] as String; + return moduleReleaseVersion; } @override diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart index b6d89b010..d6e9edc22 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart @@ -12,7 +12,6 @@ import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/xcodebuild.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/flutter_version_constraints.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'; @@ -50,11 +49,7 @@ class IosFrameworkReleaser extends Releaser { @override Future assertArgsAreValid() async { - if (!argResults.wasParsed('release-version')) { - logger.err('Missing required argument: --release-version'); - throw ProcessExit(ExitCode.usage.code); - } - + await resolveModuleReleaseVersionArgs(); await assertObfuscationIsSupported(); } @@ -93,6 +88,7 @@ class IosFrameworkReleaser extends Releaser { await artifactBuilder.buildIosFramework( args: buildArgs, base64PublicKey: base64PublicKey, + moduleVersion: moduleVersion, ); verifyObfuscationMap(); @@ -122,7 +118,7 @@ class IosFrameworkReleaser extends Releaser { Future getReleaseVersion({ required FileSystemEntity releaseArtifactRoot, }) async { - return argResults['release-version'] as String; + return moduleReleaseVersion; } @override diff --git a/packages/shorebird_cli/lib/src/commands/release/release_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_command.dart index 7401a5426..049b3af59 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_command.dart @@ -135,7 +135,16 @@ Defaults to "latest" which builds using the latest stable Flutter version.''', 'release-version', help: ''' The version of the associated release (e.g. "1.0.0"). This should be the version -of the iOS app that is using this module. (aar and ios-framework only)''', +of the host app that is using this module. (aar and ios-framework only) +Cannot be used with --module-version.''', + ) + ..addOption( + 'module-version', + help: ''' +The module version to embed in the artifact. This version identifies the module +independently of the host app's version, enabling add-to-app use cases. +Use "git" to automatically use the current git commit hash. +Cannot be used with --release-version. (aar and ios-framework only)''', ) ..addMultiOption( 'target-platform', diff --git a/packages/shorebird_cli/lib/src/commands/release/releaser.dart b/packages/shorebird_cli/lib/src/commands/release/releaser.dart index 61bf55b50..319927aec 100644 --- a/packages/shorebird_cli/lib/src/commands/release/releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/releaser.dart @@ -6,6 +6,7 @@ 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/executables/git.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/flutter_version_constraints.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; @@ -101,6 +102,111 @@ abstract class Releaser { /// passed either to Shorebird directly or forwarded to Flutter. bool get useObfuscation => argResults.flagPresent('obfuscate'); + // -- Module version support (shared by AAR and iOS framework releasers) -- + + /// The module version baked into the artifact. Null when using + /// --release-version (no module version baked in). + String? moduleVersion; + + /// The explicit --release-version value. Null in the module-version flow. + String? explicitReleaseVersion; + + /// Resolves the full git commit hash for the project. + Future getGitHash() async { + try { + return await git.revParse( + revision: 'HEAD', + directory: projectRoot.path, + ); + } on ProcessException { + logger.err( + 'Failed to determine git revision. ' + 'Provide --module-version explicitly or run from a git repository.', + ); + throw ProcessExit(ExitCode.software.code); + } + } + + /// Checks that the current Flutter version supports module versions. + Future assertFlutterSupportsModuleVersion() async { + final flutterVersion = await shorebirdFlutter.resolveFlutterVersion( + shorebirdEnv.flutterRevision, + ); + if (flutterVersion == null || + flutterVersion < minimumModuleVersionFlutterVersion) { + logger.err( + 'Module versions require ' + 'Flutter $minimumModuleVersionFlutterVersion or later ' + '(current: $flutterVersion).', + ); + throw ProcessExit(ExitCode.unavailable.code); + } + } + + /// Resolves --release-version / --module-version arguments for module + /// releasers (AAR, iOS framework). Call from [assertArgsAreValid]. + /// + /// Sets [moduleVersion] and [explicitReleaseVersion] based on the args. + Future resolveModuleReleaseVersionArgs() async { + final hasReleaseVersion = argResults.wasParsed('release-version'); + final hasModuleVersion = argResults.wasParsed('module-version'); + + if (hasReleaseVersion && hasModuleVersion) { + logger.err( + '--release-version and --module-version cannot be used together.', + ); + throw ProcessExit(ExitCode.usage.code); + } + + if (hasReleaseVersion) { + explicitReleaseVersion = argResults['release-version'] as String; + } else if (hasModuleVersion) { + await assertFlutterSupportsModuleVersion(); + + final moduleVersionArg = argResults['module-version'] as String; + if (moduleVersionArg == 'git') { + moduleVersion = await getGitHash(); + } else { + moduleVersion = moduleVersionArg; + } + } else { + if (!shorebirdEnv.canAcceptUserInput) { + logger.err( + 'No --release-version or --module-version provided. ' + 'In non-interactive mode, one of these is required.\n' + 'Use --module-version=git to use the git commit hash.', + ); + throw ProcessExit(ExitCode.usage.code); + } + + await assertFlutterSupportsModuleVersion(); + + final gitHash = await getGitHash(); + + moduleVersion = logger.prompt( + 'Module version', + defaultValue: gitHash, + ); + } + + if (moduleVersion != null) { + logger.info( + 'This ${artifactDisplayName.toLowerCase()} will embed module ' + 'version ${lightCyan.wrap(moduleVersion)}, allowing it to work ' + 'across host apps with different version numbers.\n' + 'Module versions are not enforced to be unique by an app store. ' + 'If you rebuild your module with different native dependencies ' + 'and reuse the same module version, patches may have unexpected ' + 'behavior at runtime. We recommend using the git commit hash as ' + 'the module version (--module-version=git).', + ); + } + } + + /// Returns the release version for module releasers. Uses module version + /// when set, otherwise the explicit --release-version. + String get moduleReleaseVersion => moduleVersion ?? explicitReleaseVersion!; + /// Path where the obfuscation map is saved during obfuscated builds. String get obfuscationMapPath => p.join( projectRoot.path, diff --git a/packages/shorebird_cli/lib/src/config/shorebird_yaml.dart b/packages/shorebird_cli/lib/src/config/shorebird_yaml.dart index f7906e856..b108555ca 100644 --- a/packages/shorebird_cli/lib/src/config/shorebird_yaml.dart +++ b/packages/shorebird_cli/lib/src/config/shorebird_yaml.dart @@ -25,6 +25,7 @@ class ShorebirdYaml { this.baseUrl, this.autoUpdate, this.patchVerification, + this.moduleVersion, }); /// Creates a [ShorebirdYaml] from a JSON map. @@ -61,6 +62,13 @@ class ShorebirdYaml { /// The patch verification mode for the app. final PatchVerification? patchVerification; + + /// The module version baked into the artifact at build time. + /// + /// Used for add-to-app (AAR) releases where the host app's version differs + /// from the module's version. When present, the engine uses this instead of + /// the host app's version to identify the release for patch checking. + final String? moduleVersion; } /// Extension on [ShorebirdYaml] to get the app id for a specific flavor. diff --git a/packages/shorebird_cli/lib/src/config/shorebird_yaml.g.dart b/packages/shorebird_cli/lib/src/config/shorebird_yaml.g.dart index 32a49a5d3..138c3c7be 100644 --- a/packages/shorebird_cli/lib/src/config/shorebird_yaml.g.dart +++ b/packages/shorebird_cli/lib/src/config/shorebird_yaml.g.dart @@ -20,6 +20,7 @@ ShorebirdYaml _$ShorebirdYamlFromJson(Map json) => $checkedCreate( 'base_url', 'auto_update', 'patch_verification', + 'module_version', ], ); final val = ShorebirdYaml( @@ -34,6 +35,7 @@ ShorebirdYaml _$ShorebirdYamlFromJson(Map json) => $checkedCreate( 'patch_verification', (v) => $enumDecodeNullable(_$PatchVerificationEnumMap, v), ), + moduleVersion: $checkedConvert('module_version', (v) => v as String?), ); return val; }, @@ -42,6 +44,7 @@ ShorebirdYaml _$ShorebirdYamlFromJson(Map json) => $checkedCreate( 'baseUrl': 'base_url', 'autoUpdate': 'auto_update', 'patchVerification': 'patch_verification', + 'moduleVersion': 'module_version', }, ); @@ -53,6 +56,7 @@ Map _$ShorebirdYamlToJson( 'base_url': instance.baseUrl, 'auto_update': instance.autoUpdate, 'patch_verification': _$PatchVerificationEnumMap[instance.patchVerification], + 'module_version': instance.moduleVersion, }; const _$PatchVerificationEnumMap = { diff --git a/packages/shorebird_cli/lib/src/flutter_version_constraints.dart b/packages/shorebird_cli/lib/src/flutter_version_constraints.dart index 7d46bdd82..1a36f6d35 100644 --- a/packages/shorebird_cli/lib/src/flutter_version_constraints.dart +++ b/packages/shorebird_cli/lib/src/flutter_version_constraints.dart @@ -24,6 +24,13 @@ final minimumSupportedLinuxFlutterVersion = Version(3, 27, 4); /// available in this version. final minimumSupportedWindowsFlutterVersion = Version(3, 32, 6); +/// Minimum Flutter version for module version support. +/// +/// This version introduced SHOREBIRD_MODULE_VERSION env var support in the +/// Flutter tool, which allows AAR releases to embed a version identity +/// independent of the host app's version. +final minimumModuleVersionFlutterVersion = Version(3, 41, 4); + /// Minimum Flutter version for obfuscation support across all platforms. /// /// Obfuscation requires gen_snapshot changes (--save-obfuscation-map and diff --git a/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart index e73b96550..586c65ad9 100644 --- a/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart @@ -4,8 +4,8 @@ import 'package:args/args.dart'; 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:scoped_deps/scoped_deps.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'; @@ -13,6 +13,7 @@ import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/commands/release/aar_releaser.dart'; import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/engine_config.dart'; +import 'package:shorebird_cli/src/executables/git.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/metadata/metadata.dart'; import 'package:shorebird_cli/src/os/operating_system_interface.dart'; @@ -40,6 +41,7 @@ void main() { late CodePushClientWrapper codePushClientWrapper; late CodeSigner codeSigner; late Directory projectRoot; + late Git gitClient; late ShorebirdLogger logger; late OperatingSystemInterface operatingSystemInterface; late Progress progress; @@ -59,6 +61,7 @@ void main() { codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), codeSignerRef.overrideWith(() => codeSigner), engineConfigRef.overrideWith(() => const EngineConfig.empty()), + gitRef.overrideWith(() => gitClient), loggerRef.overrideWith(() => logger), osInterfaceRef.overrideWith(() => operatingSystemInterface), processRef.overrideWith(() => shorebirdProcess), @@ -84,6 +87,7 @@ void main() { artifactManager = MockArtifactManager(); codePushClientWrapper = MockCodePushClientWrapper(); codeSigner = MockCodeSigner(); + gitClient = MockGit(); operatingSystemInterface = MockOperatingSystemInterface(); progress = MockProgress(); projectRoot = Directory.systemTemp.createTempSync(); @@ -122,9 +126,7 @@ void main() { }); group('minimumFlutterVersion', () { - test('is null', () { - // Shorebird has always had aar support, so we don't need to - // specify a minimum Flutter version. + test('is null (checked conditionally in assertArgsAreValid)', () { expect(aarReleaser.minimumFlutterVersion, isNull); }); }); @@ -217,22 +219,29 @@ void main() { }); group('assertArgsAreValid', () { - group('when release-version was not provided', () { - setUp(() { - when(() => argResults.wasParsed('release-version')).thenReturn(false); - }); + group( + 'when both --release-version and --module-version are provided', + () { + setUp(() { + when( + () => argResults.wasParsed('release-version'), + ).thenReturn(true); + when(() => argResults.wasParsed('module-version')).thenReturn(true); + }); - test('exits with code 64', () async { - await expectLater( - () => runWithOverrides(aarReleaser.assertArgsAreValid), - exitsWithCode(ExitCode.usage), - ); - }); - }); + test('exits with usage error', () async { + await expectLater( + () => runWithOverrides(aarReleaser.assertArgsAreValid), + exitsWithCode(ExitCode.usage), + ); + }); + }, + ); - group('when arguments are valid', () { + group('when --release-version is provided', () { setUp(() { when(() => argResults.wasParsed('release-version')).thenReturn(true); + when(() => argResults['release-version']).thenReturn('1.0.0'); }); test('returns normally', () { @@ -243,9 +252,128 @@ void main() { }); }); + group('when --module-version is provided', () { + setUp(() { + when(() => argResults.wasParsed('module-version')).thenReturn(true); + when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); + }); + + group('with value "git"', () { + setUp(() { + when(() => argResults['module-version']).thenReturn('git'); + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 4)); + when( + () => gitClient.revParse( + revision: any(named: 'revision'), + directory: any(named: 'directory'), + ), + ).thenAnswer( + (_) async => 'abc1234abc1234abc1234abc1234abc1234abc12', + ); + }); + + test('resolves git hash as module version', () async { + await expectLater( + runWithOverrides(aarReleaser.assertArgsAreValid), + completes, + ); + }); + }); + + group('with explicit value', () { + setUp(() { + when(() => argResults['module-version']).thenReturn('v2.0.0'); + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 4)); + }); + + test('uses provided value', () async { + await expectLater( + runWithOverrides(aarReleaser.assertArgsAreValid), + completes, + ); + }); + }); + + group('when Flutter version is too old', () { + setUp(() { + when(() => argResults['module-version']).thenReturn('git'); + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 2)); + }); + + test('exits with unavailable', () async { + await expectLater( + () => runWithOverrides(aarReleaser.assertArgsAreValid), + exitsWithCode(ExitCode.unavailable), + ); + }); + }); + }); + + group('when neither flag is provided', () { + setUp(() { + when(() => argResults.wasParsed('release-version')).thenReturn(false); + when(() => argResults.wasParsed('module-version')).thenReturn(false); + when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); + }); + + group('in non-interactive mode', () { + setUp(() { + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); + }); + + test('exits with usage error', () async { + await expectLater( + () => runWithOverrides(aarReleaser.assertArgsAreValid), + exitsWithCode(ExitCode.usage), + ); + }); + }); + + group('in interactive mode', () { + setUp(() { + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 4)); + when( + () => gitClient.revParse( + revision: any(named: 'revision'), + directory: any(named: 'directory'), + ), + ).thenAnswer( + (_) async => 'abc1234abc1234abc1234abc1234abc1234abc12', + ); + when( + () => logger.prompt( + any(), + defaultValue: any(named: 'defaultValue'), + ), + ).thenReturn('abc1234abc1234abc1234abc1234abc1234abc12'); + }); + + test('prompts for module version with git hash default', () async { + await runWithOverrides(aarReleaser.assertArgsAreValid); + + verify( + () => logger.prompt( + 'Module version', + defaultValue: 'abc1234abc1234abc1234abc1234abc1234abc12', + ), + ).called(1); + }); + }); + }); + group('when --obfuscate is passed', () { setUp(() { when(() => argResults.wasParsed('release-version')).thenReturn(true); + when(() => argResults['release-version']).thenReturn('1.0.0'); when(() => argResults['obfuscate']).thenReturn(true); when(() => argResults.wasParsed('obfuscate')).thenReturn(true); when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); @@ -285,6 +413,7 @@ void main() { group('when --obfuscate is not passed', () { setUp(() { when(() => argResults.wasParsed('release-version')).thenReturn(true); + when(() => argResults['release-version']).thenReturn('1.0.0'); }); test('returns normally', () async { @@ -324,17 +453,23 @@ void main() { File(aarPath).createSync(recursive: true); } - setUp(() { + setUp(() async { when(() => argResults['artifact']).thenReturn('apk'); + when(() => argResults.wasParsed('release-version')).thenReturn(true); + when(() => argResults['release-version']).thenReturn('1.0.0'); when( () => artifactBuilder.buildAar( buildNumber: any(named: 'buildNumber'), targetPlatforms: any(named: 'targetPlatforms'), args: any(named: 'args'), + moduleVersion: any(named: 'moduleVersion'), ), ).thenAnswer((_) async => File('')); setUpProjectRootArtifacts(); + + // Resolve versions before build tests. + await runWithOverrides(aarReleaser.assertArgsAreValid); }); group('when build succeeds', () { @@ -397,6 +532,7 @@ void main() { targetPlatforms: any(named: 'targetPlatforms'), args: any(named: 'args'), base64PublicKey: any(named: 'base64PublicKey'), + moduleVersion: any(named: 'moduleVersion'), ), ).thenAnswer((_) async => File('')); @@ -439,6 +575,7 @@ void main() { targetPlatforms: any(named: 'targetPlatforms'), args: any(named: 'args'), base64PublicKey: any(named: 'base64PublicKey'), + moduleVersion: any(named: 'moduleVersion'), ), ).thenAnswer((_) async => File('')); @@ -483,6 +620,7 @@ void main() { buildNumber: any(named: 'buildNumber'), targetPlatforms: any(named: 'targetPlatforms'), args: any(named: 'args'), + moduleVersion: any(named: 'moduleVersion'), ), ).thenAnswer((_) async { final mapPath = p.join( @@ -505,6 +643,7 @@ void main() { buildNumber: any(named: 'buildNumber'), targetPlatforms: any(named: 'targetPlatforms'), args: captureAny(named: 'args'), + moduleVersion: any(named: 'moduleVersion'), ), ).captured; @@ -537,6 +676,7 @@ void main() { buildNumber: any(named: 'buildNumber'), targetPlatforms: any(named: 'targetPlatforms'), args: any(named: 'args'), + moduleVersion: any(named: 'moduleVersion'), ), ).thenAnswer((_) async {}); }); @@ -593,15 +733,16 @@ void main() { when(() => argResults.wasParsed('obfuscate')).thenReturn(true); // Create the obfuscation map at the expected build output location. - final mapFile = File( - p.join( - projectRoot.path, - 'build', - 'shorebird', - 'obfuscation_map.json', - ), - )..createSync(recursive: true); - mapFile.writeAsStringSync('{"key": "value"}'); + File( + p.join( + projectRoot.path, + 'build', + 'shorebird', + 'obfuscation_map.json', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('{"key": "value"}'); }); test('copies map into supplement directory and returns it', () { @@ -678,17 +819,50 @@ void main() { }); group('getReleaseVersion', () { - const releaseVersion = '1.0.0'; - setUp(() { - when(() => argResults['release-version']).thenReturn(releaseVersion); + group('when --release-version is provided', () { + setUp(() { + when(() => argResults.wasParsed('release-version')).thenReturn(true); + when(() => argResults['release-version']).thenReturn('1.0.0'); + }); + + test('returns the explicit release version', () async { + await runWithOverrides(aarReleaser.assertArgsAreValid); + final result = await runWithOverrides( + () => aarReleaser.getReleaseVersion( + releaseArtifactRoot: Directory(''), + ), + ); + expect(result, '1.0.0'); + }); }); - test('returns value from argResults', () async { - final result = await runWithOverrides( - () => - aarReleaser.getReleaseVersion(releaseArtifactRoot: Directory('')), - ); - expect(result, releaseVersion); + group('when --module-version=git is provided', () { + setUp(() { + when(() => argResults.wasParsed('module-version')).thenReturn(true); + when(() => argResults['module-version']).thenReturn('git'); + when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); + when( + () => shorebirdFlutter.resolveFlutterVersion(any()), + ).thenAnswer((_) async => Version(3, 41, 4)); + when( + () => gitClient.revParse( + revision: any(named: 'revision'), + directory: any(named: 'directory'), + ), + ).thenAnswer( + (_) async => 'abc1234abc1234abc1234abc1234abc1234abc12', + ); + }); + + test('returns git hash as release version', () async { + await runWithOverrides(aarReleaser.assertArgsAreValid); + final result = await runWithOverrides( + () => aarReleaser.getReleaseVersion( + releaseArtifactRoot: Directory(''), + ), + ); + expect(result, 'abc1234abc1234abc1234abc1234abc1234abc12'); + }); }); }); diff --git a/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart index 49ddadabd..945054008 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart @@ -135,6 +135,7 @@ void main() { group('when split-per-abi is true', () { setUp(() { when(() => argResults.wasParsed('release-version')).thenReturn(false); + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); }); test('exits with code 64', () async { @@ -148,6 +149,7 @@ void main() { group('when arguments are valid', () { setUp(() { when(() => argResults.wasParsed('release-version')).thenReturn(true); + when(() => argResults['release-version']).thenReturn('1.2.3'); }); test('returns normally', () { @@ -161,6 +163,7 @@ void main() { group('when --obfuscate is passed', () { setUp(() { when(() => argResults.wasParsed('release-version')).thenReturn(true); + when(() => argResults['release-version']).thenReturn('1.2.3'); when(() => argResults['obfuscate']).thenReturn(true); when(() => argResults.wasParsed('obfuscate')).thenReturn(true); when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef'); @@ -200,6 +203,7 @@ void main() { group('when --obfuscate is not passed', () { setUp(() { when(() => argResults.wasParsed('release-version')).thenReturn(true); + when(() => argResults['release-version']).thenReturn('1.2.3'); }); test('returns normally', () async { @@ -570,10 +574,15 @@ void main() { group('getReleaseVersion', () { const releaseVersion = '1.0.0'; setUp(() { + when(() => argResults.wasParsed('release-version')).thenReturn(true); when(() => argResults['release-version']).thenReturn(releaseVersion); }); test('returns value from argResults', () async { + // assertArgsAreValid must run first to populate explicitReleaseVersion + // from --release-version; getReleaseVersion then reads it back via + // moduleReleaseVersion. + await runWithOverrides(iosFrameworkReleaser.assertArgsAreValid); final result = await runWithOverrides( () => iosFrameworkReleaser.getReleaseVersion( releaseArtifactRoot: Directory(''), diff --git a/packages/shorebird_cli/test/src/config/shorebird_yaml_test.dart b/packages/shorebird_cli/test/src/config/shorebird_yaml_test.dart index 736be9582..31944d24f 100644 --- a/packages/shorebird_cli/test/src/config/shorebird_yaml_test.dart +++ b/packages/shorebird_cli/test/src/config/shorebird_yaml_test.dart @@ -122,6 +122,31 @@ patch_verification: invalid_value ); }); + test('can be deserialized without module_version', () { + const yaml = ''' +app_id: test_app_id +'''; + final shorebirdYaml = checkedYamlDecode( + yaml, + (m) => ShorebirdYaml.fromJson(m!), + ); + expect(shorebirdYaml.appId, 'test_app_id'); + expect(shorebirdYaml.moduleVersion, isNull); + }); + + test('can be deserialized with module_version', () { + const yaml = ''' +app_id: test_app_id +module_version: abc1234 +'''; + final shorebirdYaml = checkedYamlDecode( + yaml, + (m) => ShorebirdYaml.fromJson(m!), + ); + expect(shorebirdYaml.appId, 'test_app_id'); + expect(shorebirdYaml.moduleVersion, 'abc1234'); + }); + group('AppIdExtension', () { test('getAppId returns base app id when no flavor is provided', () { const shorebirdYaml = ShorebirdYaml(appId: 'test_app_id'); diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/patch_check/patch_check_request.dart b/packages/shorebird_code_push_protocol/lib/src/messages/patch_check/patch_check_request.dart index 88688cd10..0cdc4a879 100644 --- a/packages/shorebird_code_push_protocol/lib/src/messages/patch_check/patch_check_request.dart +++ b/packages/shorebird_code_push_protocol/lib/src/messages/patch_check/patch_check_request.dart @@ -17,6 +17,7 @@ class PatchCheckRequest { this.patchNumber, this.patchHash, this.clientId, + this.moduleVersion, }); /// Converts a `Map` to a [PatchCheckRequest]. @@ -33,6 +34,7 @@ class PatchCheckRequest { appId: json['app_id'] as String, channel: json['channel'] as String, clientId: json['client_id'] as String?, + moduleVersion: json['module_version'] as String?, ), ); } @@ -46,7 +48,7 @@ class PatchCheckRequest { return PatchCheckRequest.fromJson(json); } - /// The release version of the app. + /// The release version of the host app (e.g. "1.0.0+1"). final String releaseVersion; /// The highest patch number the client has already downloaded. @@ -73,6 +75,13 @@ class PatchCheckRequest { /// unique per app. Optional for backward compatibility. final String? clientId; + /// The module version for add-to-app releases (AAR/iOS framework). + /// + /// When present, the server uses this instead of [releaseVersion] for patch + /// lookup. The [releaseVersion] still contains the host app's version for + /// analytics purposes. + final String? moduleVersion; + /// Converts a [PatchCheckRequest] to a `Map`. Map toJson() { return { @@ -84,6 +93,7 @@ class PatchCheckRequest { 'app_id': appId, 'channel': channel, 'client_id': clientId, + 'module_version': moduleVersion, }; } @@ -97,6 +107,7 @@ class PatchCheckRequest { appId, channel, clientId, + moduleVersion, ]); @override @@ -110,6 +121,7 @@ class PatchCheckRequest { arch == other.arch && appId == other.appId && channel == other.channel && - clientId == other.clientId; + clientId == other.clientId && + moduleVersion == other.moduleVersion; } }