diff --git a/tools/find_release.dart b/tools/find_release.dart new file mode 100644 index 00000000000..a0b04afe3d4 --- /dev/null +++ b/tools/find_release.dart @@ -0,0 +1,305 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// A tool to find the lowest Dart and Flutter release containing a given +/// commit. +/// +/// Usage: +/// +/// `dart tools/find_release.dart --commit= --channel=` +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:http/http.dart' as http; +import 'package:pub_semver/pub_semver.dart'; + +final parser = ArgParser() + ..addOption( + 'commit', + abbr: 'c', + help: 'The commit to search for', + mandatory: true, + ) + ..addOption( + 'channel', + help: 'The channel to search for the commit in, dev only supports the dart ' + 'sdk since flutter does not do dev releases.', + allowed: ['dev', 'stable', 'beta'], + mandatory: true, + ) + ..addFlag( + 'help', + abbr: 'h', + negatable: false, + help: 'Show usage information', + ); + +void main(List arguments) async { + try { + final parsedArgs = parser.parse(arguments); + if (parsedArgs['help'] as bool) { + print(parser.usage); + exit(0); + } + final commit = parsedArgs['commit'] as String; + final channel = parsedArgs['channel'] as String; + print('Searching for commit $commit in channel $channel'); + + final repo = await findRepoForCommit(commit); + if (repo == null) { + print('Commit not found in Dart or Flutter repositories.'); + exit(1); + } + print('Found commit in $repo'); + + print('Fetching lowest release tag for $commit...'); + final tag = await fetchLowestVersionTag(repo, commit); + if (tag == null) { + print('No release tags found for commit $commit in $repo'); + exit(1); + } + print('Lowest release tag for $commit was: $tag'); + + if (repo == flutterSdkRepo || (channel == 'beta' || channel == 'stable')) { + print('Checking for lowest flutter release newer than $tag...'); + final flutterRelease = await fetchLowestFlutterRelease( + repo, + channel, + tag, + ); + if (flutterRelease == null) { + print( + 'No Flutter releases found ' + '${repo == dartSdkRepo ? 'with a Dart sdk ' : ''}' + 'newer than $tag', + ); + } else { + print('Lowest Flutter release: $flutterRelease'); + } + } else { + print( + 'Skipping flutter version check for channel $channel, only `beta` and ' + '`stable` are supported for flutter version checks', + ); + } + + if (repo == dartSdkRepo) { + print('Checking for lowest Dart release newer than $tag...'); + final dartRelease = await fetchLowestDartReleaseFromGcs( + 'channels/$channel/release/', + tag, + ); + if (dartRelease == null) { + print('No Dart releases found that were newer than $tag'); + } else { + print('Lowest Dart release: $dartRelease'); + } + } else { + print('Skipping dart version check as this was a flutter commit'); + } + } on FormatException catch (e) { + print(e.message); + print(parser.usage); + exit(1); + } +} + +const dartSdkRepo = 'dart-lang/sdk'; +const flutterSdkRepo = 'flutter/flutter'; + +/// Searches the Dart and Flutter repos for the given [sha], and returns the +/// org/repo string where it was found. +Future findRepoForCommit(String sha) async { + if (await checkCommit(dartSdkRepo, sha)) return dartSdkRepo; + if (await checkCommit(flutterSdkRepo, sha)) return flutterSdkRepo; + return null; +} + +/// Returns whether or not [sha] exists in [repo] (which should be an org/repo +/// string). +Future checkCommit(String repo, String sha) async { + try { + await makeGhApiRequest('repos/$repo/commits/$sha'); + return true; + } on RequestException catch (_) { + return false; + } +} + +/// Returns the lowest tag that parses as a semver version containing [commit]. +Future fetchLowestVersionTag( + String repo, + String commit, +) async { + // Note that this is not an official endpoint, it was reverse engineered from + // the commit page on github, and could break in the future. + final response = await makeGhApiRequest( + '$repo/branch_commits/$commit.json', + isApiRequest: false, + ); + final tags = (response['tags'] as List).cast(); + Version? lowest; + for (final tag in tags) { + try { + final version = Version.parse(tag); + if (lowest == null || version < lowest) { + lowest = version; + } + } on FormatException catch (_) { + continue; + } + } + return lowest; +} + +/// Makes a request to [path] against the github API. +/// +/// If [isApiRequest] is false, treats this as a regular github request instead +/// of an official API request. +/// +/// Throws a [RequestException] if the request fails. +Future> makeGhApiRequest( + String path, { + bool isApiRequest = true, +}) async { + var uri = Uri.https( + isApiRequest ? 'api.github.com' : 'github.com', + '/$path', + ); + final response = await http.get(uri); + if (response.statusCode != 200) { + throw RequestException(response, uri); + } + return jsonDecode(response.body) as Map; +} + +/// Returns the lowest Dart release version in the GCS storage bucket starting +/// with [prefix] and greater than or equal to [minVersion]. +/// +/// Throws a [RequestException] if the request fails. +Future fetchLowestDartReleaseFromGcs( + String prefix, + Version minVersion, +) async { + final uri = Uri.https( + 'storage.googleapis.com', + '/storage/v1/b/dart-archive/o', + { + 'delimiter': '/', + 'prefix': prefix, + 'alt': 'json', + }, + ); + final response = await http.get(uri); + if (response.statusCode != 200) { + throw RequestException(response, uri); + } + Version? lowest; + try { + final releases = ((jsonDecode(response.body) + as Map)['prefixes'] as List) + .cast(); + for (var release in releases) { + final versionPart = release.split('/')[3]; + try { + final version = Version.parse(versionPart); + if (version >= minVersion && (lowest == null || version < lowest)) { + lowest = version; + } + } on FormatException catch (_) { + continue; + } + } + } catch (e) { + throw RequestException(response, uri); + } + return lowest; +} + +/// Returns the lowest Flutter version greater than or equal to [minVersion]. +/// +/// The [minVersion] is either a Dart or Flutter version, depending on [repo]. +/// +/// If [repo] is [flutterSdkRepo], then it will only return a version from +/// [channel], otherwise it may contain a release from any channel. This is +/// because the release channels do not always correspond to each other. +/// +/// Throws a [RequestException] if the request fails. +Future fetchLowestFlutterRelease( + String repo, // The repo that `minVersion` corresponds to. + String channel, + Version minVersion, +) async { + final uri = Uri.https( + 'storage.googleapis.com', + '/flutter_infra_release/releases/releases_linux.json', + ); + final response = await http.get(uri); + if (response.statusCode != 200) { + throw RequestException(response, uri); + } + Version? lowest; + try { + final releases = ((jsonDecode(response.body) + as Map)['releases'] as List) + .cast>(); + for (var release in releases) { + // We do search for dart releases in any flutter channel because sometimes + // stable flutter releases contain dart sdk releases from beta channels. + if (repo == flutterSdkRepo && release['channel'] != channel) continue; + + var versionStr = switch (repo) { + dartSdkRepo => release['dart_sdk_version'] as String?, + flutterSdkRepo => release['version'] as String, + _ => throw FormatException('Unknown repository $repo'), + }; + if (versionStr == null) continue; + // Some versions look like `3.11.0 (build 3.11.0-93.1.beta)` + if (versionStr.contains('(build')) { + versionStr = versionStr.split('(build ').last; + versionStr = versionStr.substring(0, versionStr.length - 1); + } + + try { + final version = Version.parse(versionStr); + if (version >= minVersion) { + // This is a potential candidate, now check if the flutter version + // is the lowest we have seen. + final flutterVersion = switch (repo) { + flutterSdkRepo => version, + dartSdkRepo => Version.parse(release['version'] as String), + _ => throw StateError('Unexpected repo string $repo'), + }; + if (lowest == null || flutterVersion < lowest) { + lowest = flutterVersion; + } + } + } on FormatException catch (_) { + continue; + } + } + } catch (e) { + throw RequestException(response, uri, e); + } + return lowest; +} + +class RequestException implements Exception { + final http.Response response; + final Uri uri; + final Object? error; + + RequestException(this.response, this.uri, [this.error]); + + @override + String toString() => ''' +uri: $uri +statusCode: ${response.statusCode} +body: ${response.body} +error: $error +'''; +} diff --git a/tools/pubspec.yaml b/tools/pubspec.yaml index 593a58b1879..4b41d49b17f 100644 --- a/tools/pubspec.yaml +++ b/tools/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: http: path: pool: + pub_semver: smith: test_runner: yaml: