feat(shorebird_code_push_protocol): add release analysis API surface (#3744)

This commit is contained in:
Mac
2026-05-15 17:34:08 -06:00
committed by GitHub
parent 60136f9bae
commit 6a39cee4f1
14 changed files with 627 additions and 7 deletions
+1
View File
@@ -142,6 +142,7 @@ words:
- timebase # Mach timebase (mach_timebase_info)
- udevadm # From .github/workflows/e2e.yaml
- udid # Unique Device Identifier
- unanalyzed
- unawaited
- unmockable
- unobfuscated
@@ -43,6 +43,7 @@ export 'package:shorebird_code_push_protocol/src/models/app.dart';
export 'package:shorebird_code_push_protocol/src/models/app_collaborator_role.dart';
export 'package:shorebird_code_push_protocol/src/models/app_metadata.dart';
export 'package:shorebird_code_push_protocol/src/models/channel.dart';
export 'package:shorebird_code_push_protocol/src/models/latest_release.dart';
export 'package:shorebird_code_push_protocol/src/models/organization.dart';
export 'package:shorebird_code_push_protocol/src/models/organization_membership.dart';
export 'package:shorebird_code_push_protocol/src/models/organization_type.dart';
@@ -50,9 +51,11 @@ export 'package:shorebird_code_push_protocol/src/models/organization_user.dart';
export 'package:shorebird_code_push_protocol/src/models/patch.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_artifact.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_check_metadata.dart';
export 'package:shorebird_code_push_protocol/src/models/pending_release.dart';
export 'package:shorebird_code_push_protocol/src/models/private_user.dart';
export 'package:shorebird_code_push_protocol/src/models/public_user.dart';
export 'package:shorebird_code_push_protocol/src/models/release.dart';
export 'package:shorebird_code_push_protocol/src/models/release_analysis.dart';
export 'package:shorebird_code_push_protocol/src/models/release_artifact.dart';
export 'package:shorebird_code_push_protocol/src/models/release_patch.dart';
export 'package:shorebird_code_push_protocol/src/models/release_platform.dart';
@@ -75,12 +75,9 @@ class PatchCheckRequest {
/// unique per app. Optional for backward compatibility.
final String? clientId;
/// The number of the patch currently running on the device, if any.
///
/// Supersedes [patchNumber] for newer updater clients. Unlike
/// [patchNumber], this does not affect the server's response;
/// [patchNumber] is retained for compatibility with legacy clients that
/// rely on the server's short-circuit path.
/// The patch number currently running on the device, if any.
/// Supersedes `patch_number` for newer clients; unlike
/// `patch_number`, this does not affect the server's response.
final int? currentPatchNumber;
/// Converts a [PatchCheckRequest] to a `Map<String, dynamic>`.
@@ -1,5 +1,8 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/latest_release.dart';
import 'package:shorebird_code_push_protocol/src/models/pending_release.dart';
import 'package:shorebird_code_push_protocol/src/models/release_platform.dart';
/// {@template app_metadata}
/// A single app which contains zero or more releases.
@@ -14,6 +17,10 @@ class AppMetadata {
required this.updatedAt,
this.latestReleaseVersion,
this.latestPatchNumber,
this.platforms,
this.latestReleases,
this.pendingReleases,
this.iconUrl,
});
/// Converts a `Map<String, dynamic>` to an [AppMetadata].
@@ -28,6 +35,23 @@ class AppMetadata {
latestPatchNumber: json['latest_patch_number'] as int?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
platforms: (json['platforms'] as List?)
?.map<ReleasePlatform>((e) => ReleasePlatform.fromJson(e as String))
.toList(),
latestReleases: (json['latest_releases'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(
ReleasePlatform.fromJson(key),
LatestRelease.fromJson(value as Map<String, dynamic>),
),
),
pendingReleases: (json['pending_releases'] as Map<String, dynamic>?)
?.map(
(key, value) => MapEntry(
ReleasePlatform.fromJson(key),
PendingRelease.fromJson(value as Map<String, dynamic>),
),
),
iconUrl: json['icon_url'] as String?,
),
);
}
@@ -59,6 +83,38 @@ class AppMetadata {
/// The date and time the app was last updated.
final DateTime updatedAt;
/// Every platform the app has shipped to (i.e. has at least one
/// release artifact for). Independent of `latest_releases`:
/// an app can list a platform here even if no release on that
/// platform has been analyzed yet.
final List<ReleasePlatform>? platforms;
/// The latest analyzed release per platform. A platform is
/// omitted when no release for that platform has been analyzed
/// yet. When the latest release for a platform is not yet
/// analyzed but a previous one is, the previous release is
/// returned here and `pending_releases.{platform}` identifies
/// the unanalyzed newer release.
final Map<ReleasePlatform, LatestRelease>? latestReleases;
/// The newest unanalyzed release per platform, whenever one
/// exists. A platform is omitted when its most recent release
/// has already been analyzed. Independent of `latest_releases`:
/// both can be present (a newer release than the analyzed one
/// is being processed) or only `pending_releases` can be
/// present (no release on the platform has been analyzed yet).
final Map<ReleasePlatform, PendingRelease>? pendingReleases;
/// Server-emitted URL for the app's launcher icon, sourced from
/// the most recent analyzed iOS release (or Android, when iOS
/// has no analyzed release with an icon). Requires the same
/// auth as the rest of the apps API. The URL embeds the picked
/// release's id as a `v` query parameter so it can be cached
/// indefinitely; when a different release becomes the icon
/// source the URL changes and clients re-fetch. Omitted when
/// no analyzed release has an icon.
final String? iconUrl;
/// Converts an [AppMetadata] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
@@ -68,6 +124,14 @@ class AppMetadata {
'latest_patch_number': latestPatchNumber,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'platforms': platforms?.map((e) => e.toJson()).toList(),
'latest_releases': latestReleases?.map(
(key, value) => MapEntry(key.toJson(), value.toJson()),
),
'pending_releases': pendingReleases?.map(
(key, value) => MapEntry(key.toJson(), value.toJson()),
),
'icon_url': iconUrl,
};
}
@@ -79,6 +143,10 @@ class AppMetadata {
latestPatchNumber,
createdAt,
updatedAt,
listHash(platforms),
mapHash(latestReleases),
mapHash(pendingReleases),
iconUrl,
]);
@override
@@ -90,6 +158,10 @@ class AppMetadata {
latestReleaseVersion == other.latestReleaseVersion &&
latestPatchNumber == other.latestPatchNumber &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt;
updatedAt == other.updatedAt &&
listsEqual(platforms, other.platforms) &&
mapsEqual(latestReleases, other.latestReleases) &&
mapsEqual(pendingReleases, other.pendingReleases) &&
iconUrl == other.iconUrl;
}
}
@@ -0,0 +1,130 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/release_analysis.dart';
import 'package:shorebird_code_push_protocol/src/models/release_status.dart';
/// {@template latest_release}
/// Per-platform projection of an analyzed release as surfaced by
/// `AppMetadata.latest_releases`. Each entry corresponds to the
/// most recent analyzed release on the keying platform, so
/// `analysis` is always populated and `status` is the single
/// per-platform status (rather than the cross-platform
/// `platform_statuses` map carried by `Release`).
/// {@endtemplate}
@immutable
class LatestRelease {
/// {@macro latest_release}
const LatestRelease({
required this.id,
required this.version,
required this.flutterRevision,
required this.createdAt,
required this.updatedAt,
required this.status,
required this.analysis,
this.flutterVersion,
this.notes,
});
/// Converts a `Map<String, dynamic>` to a [LatestRelease].
factory LatestRelease.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'LatestRelease',
json,
() => LatestRelease(
id: json['id'] as int,
version: json['version'] as String,
flutterRevision: json['flutter_revision'] as String,
flutterVersion: json['flutter_version'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
status: ReleaseStatus.fromJson(json['status'] as String),
notes: json['notes'] as String?,
analysis: ReleaseAnalysis.fromJson(
json['analysis'] as Map<String, dynamic>,
),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static LatestRelease? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return LatestRelease.fromJson(json);
}
/// The ID of the release.
final int id;
/// The version of the release.
final String version;
/// The Flutter revision used to create the release.
final String flutterRevision;
/// The Flutter version used to create the release. Optional
/// because it was added later; older releases do not have it.
final String? flutterVersion;
/// The date and time the release was created.
final DateTime createdAt;
/// The date and time the release was last updated.
final DateTime updatedAt;
/// The status of a release.
final ReleaseStatus status;
/// Freeform notes associated with the release, if any.
final String? notes;
/// Analyzer-extracted metadata for a release artifact on a single
/// platform.
final ReleaseAnalysis analysis;
/// Converts a [LatestRelease] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'id': id,
'version': version,
'flutter_revision': flutterRevision,
'flutter_version': flutterVersion,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'status': status.toJson(),
'notes': notes,
'analysis': analysis.toJson(),
};
}
@override
int get hashCode => Object.hashAll([
id,
version,
flutterRevision,
flutterVersion,
createdAt,
updatedAt,
status,
notes,
analysis,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is LatestRelease &&
id == other.id &&
version == other.version &&
flutterRevision == other.flutterRevision &&
flutterVersion == other.flutterVersion &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt &&
status == other.status &&
notes == other.notes &&
analysis == other.analysis;
}
}
@@ -0,0 +1,74 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
/// {@template pending_release}
/// A newer release that has been created but not yet analyzed.
/// Surfaced alongside the most recent analyzed release so clients
/// can show an "analyzing…" indicator without losing the stable
/// icon and metadata.
/// {@endtemplate}
@immutable
class PendingRelease {
/// {@macro pending_release}
const PendingRelease({
required this.id,
required this.version,
required this.createdAt,
});
/// Converts a `Map<String, dynamic>` to a [PendingRelease].
factory PendingRelease.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'PendingRelease',
json,
() => PendingRelease(
id: json['id'] as int,
version: json['version'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static PendingRelease? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return PendingRelease.fromJson(json);
}
/// The ID of the pending release.
final int id;
/// The version of the pending release.
final String version;
/// The date and time the pending release was created.
final DateTime createdAt;
/// Converts a [PendingRelease] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'id': id,
'version': version,
'created_at': createdAt.toIso8601String(),
};
}
@override
int get hashCode => Object.hashAll([
id,
version,
createdAt,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PendingRelease &&
id == other.id &&
version == other.version &&
createdAt == other.createdAt;
}
}
@@ -0,0 +1,93 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
/// {@template release_analysis}
/// Analyzer-extracted metadata for a release artifact on a single
/// platform.
/// {@endtemplate}
@immutable
class ReleaseAnalysis {
/// {@macro release_analysis}
const ReleaseAnalysis({
required this.displayName,
required this.packageName,
required this.minSdkVersion,
required this.targetSdkVersion,
required this.architectures,
});
/// Converts a `Map<String, dynamic>` to a [ReleaseAnalysis].
factory ReleaseAnalysis.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'ReleaseAnalysis',
json,
() => ReleaseAnalysis(
displayName: json['display_name'] as String,
packageName: json['package_name'] as String,
minSdkVersion: json['min_sdk_version'] as String,
targetSdkVersion: json['target_sdk_version'] as String,
architectures: (json['architectures'] as List).cast<String>(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static ReleaseAnalysis? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return ReleaseAnalysis.fromJson(json);
}
/// The user-visible application name extracted from the artifact
/// (e.g. AndroidManifest `application:label`). May differ from
/// the user-curated `App.display_name`.
final String displayName;
/// The application package name (e.g. `com.example.app`).
final String packageName;
/// The minimum SDK level required to install the artifact
/// (Android API level for android, iOS deployment target for ios).
final String minSdkVersion;
/// The SDK level the artifact targets (Android targetSdk for
/// android, iOS SDK for ios).
final String targetSdkVersion;
/// CPU architectures present in the artifact
/// (e.g. `["arm64-v8a", "armeabi-v7a"]`).
final List<String> architectures;
/// Converts a [ReleaseAnalysis] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'display_name': displayName,
'package_name': packageName,
'min_sdk_version': minSdkVersion,
'target_sdk_version': targetSdkVersion,
'architectures': architectures,
};
}
@override
int get hashCode => Object.hashAll([
displayName,
packageName,
minSdkVersion,
targetSdkVersion,
listHash(architectures),
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ReleaseAnalysis &&
displayName == other.displayName &&
packageName == other.packageName &&
minSdkVersion == other.minSdkVersion &&
targetSdkVersion == other.targetSdkVersion &&
listsEqual(architectures, other.architectures);
}
}
@@ -0,0 +1,39 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('LatestRelease', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = LatestRelease(
id: 0,
version: 'example',
flutterRevision: 'example',
createdAt: DateTime.utc(2024),
updatedAt: DateTime.utc(2024),
status: ReleaseStatus.values.first,
analysis: const ReleaseAnalysis(
displayName: 'example',
packageName: 'example',
minSdkVersion: 'example',
targetSdkVersion: 'example',
architectures: <String>['example'],
),
);
final parsed = LatestRelease.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(LatestRelease.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => LatestRelease.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,29 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('PendingRelease', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = PendingRelease(
id: 0,
version: 'example',
createdAt: DateTime.utc(2024),
);
final parsed = PendingRelease.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(PendingRelease.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => PendingRelease.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,31 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('ReleaseAnalysis', () {
test('round-trips via maybeFromJson/toJson', () {
const instance = ReleaseAnalysis(
displayName: 'example',
packageName: 'example',
minSdkVersion: 'example',
targetSdkVersion: 'example',
architectures: <String>['example'],
);
final parsed = ReleaseAnalysis.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(ReleaseAnalysis.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => ReleaseAnalysis.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -18,6 +18,49 @@ void main() {
);
});
test('can be (de)serialized with platforms, latestReleases, '
'pendingReleases, and iconUrl', () {
final appMetadata = AppMetadata(
appId: '30370f27-dbf1-4673-8b20-fb096e38dffa',
displayName: 'My App',
latestReleaseVersion: '1.0.0',
latestPatchNumber: 1,
createdAt: DateTime(2022),
updatedAt: DateTime(2023),
platforms: const [ReleasePlatform.android, ReleasePlatform.ios],
latestReleases: {
ReleasePlatform.android: LatestRelease(
id: 535,
version: '1.2.3+5',
flutterRevision: 'abc123',
flutterVersion: '3.27.0',
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
status: ReleaseStatus.active,
analysis: const ReleaseAnalysis(
displayName: 'My App',
packageName: 'com.example.app',
minSdkVersion: '24',
targetSdkVersion: '34',
architectures: ['arm64-v8a'],
),
),
},
pendingReleases: {
ReleasePlatform.ios: PendingRelease(
id: 700,
version: '1.2.4+6',
createdAt: DateTime(2024),
),
},
iconUrl: '/api/v1/app-icons/30370f27-dbf1-4673-8b20-fb096e38dffa?v=535',
);
expect(
AppMetadata.fromJson(appMetadata.toJson()).toJson(),
equals(appMetadata.toJson()),
);
});
group('equality', () {
test('should return true if all properties are equal', () {
final appMetadata1 = AppMetadata(
@@ -0,0 +1,70 @@
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group(LatestRelease, () {
test('can be (de)serialized', () {
final latest = LatestRelease(
id: 535,
version: '1.2.3+5',
flutterRevision: 'abc123',
flutterVersion: '3.27.0',
createdAt: DateTime(2023, 3),
updatedAt: DateTime(2023, 4),
status: ReleaseStatus.active,
notes: 'launch notes',
analysis: const ReleaseAnalysis(
displayName: 'My App',
packageName: 'com.example.app',
minSdkVersion: '24',
targetSdkVersion: '34',
architectures: ['arm64-v8a'],
),
);
expect(
LatestRelease.fromJson(latest.toJson()).toJson(),
equals(latest.toJson()),
);
});
test('is equatable', () {
const analysis = ReleaseAnalysis(
displayName: 'My App',
packageName: 'com.example.app',
minSdkVersion: '24',
targetSdkVersion: '34',
architectures: ['arm64-v8a'],
);
final latest1 = LatestRelease(
id: 535,
version: '1.2.3+5',
flutterRevision: 'abc123',
createdAt: DateTime(2023, 3),
updatedAt: DateTime(2023, 4),
status: ReleaseStatus.active,
analysis: analysis,
);
final latest1Copy = LatestRelease(
id: 535,
version: '1.2.3+5',
flutterRevision: 'abc123',
createdAt: DateTime(2023, 3),
updatedAt: DateTime(2023, 4),
status: ReleaseStatus.active,
analysis: analysis,
);
final latest2 = LatestRelease(
id: 600,
version: '1.2.4+6',
flutterRevision: 'abc123',
createdAt: DateTime(2023, 3),
updatedAt: DateTime(2023, 4),
status: ReleaseStatus.active,
analysis: analysis,
);
expect(latest1, equals(latest1Copy));
expect(latest1, isNot(equals(latest2)));
});
});
}
@@ -0,0 +1,18 @@
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group(PendingRelease, () {
test('can be (de)serialized', () {
final pending = PendingRelease(
id: 537,
version: '1.2.4+6',
createdAt: DateTime(2024),
);
expect(
PendingRelease.fromJson(pending.toJson()).toJson(),
equals(pending.toJson()),
);
});
});
}
@@ -0,0 +1,20 @@
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group(ReleaseAnalysis, () {
test('can be (de)serialized', () {
const analysis = ReleaseAnalysis(
displayName: 'My App',
packageName: 'com.example.app',
minSdkVersion: '24',
targetSdkVersion: '34',
architectures: ['arm64-v8a', 'armeabi-v7a'],
);
expect(
ReleaseAnalysis.fromJson(analysis.toJson()).toJson(),
equals(analysis.toJson()),
);
});
});
}