mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
WIP Commits separated as follows: - Update lints in analysis_options files - Run `dart fix --apply` - Clean up leftover analysis issues - Run `dart format .` in the right places. Local analysis and testing passes. Checking CI now. Part of https://github.com/flutter/flutter/issues/178827 - Adoption of flutter_lints in examples/api coming in a separate change (cc @loic-sharma) ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
527 lines
19 KiB
Dart
527 lines
19 KiB
Dart
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
/// This script removes published archives from the cloud storage and the
|
|
/// corresponding JSON metadata file that the website uses to determine what
|
|
/// releases are available.
|
|
///
|
|
/// If asked to remove a release that is currently the release on that channel,
|
|
/// it will replace that release with the next most recent release on that
|
|
/// channel.
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io' hide Platform;
|
|
|
|
import 'package:args/args.dart';
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:platform/platform.dart' show LocalPlatform, Platform;
|
|
import 'package:process/process.dart';
|
|
|
|
const String gsBase = 'gs://flutter_infra_release';
|
|
const String releaseFolder = '/releases';
|
|
const String gsReleaseFolder = '$gsBase$releaseFolder';
|
|
const String baseUrl = 'https://storage.googleapis.com/flutter_infra_release';
|
|
|
|
/// Exception class for when a process fails to run, so we can catch
|
|
/// it and provide something more readable than a stack trace.
|
|
class UnpublishException implements Exception {
|
|
UnpublishException(this.message, [this.result]);
|
|
|
|
final String message;
|
|
final ProcessResult? result;
|
|
int get exitCode => result?.exitCode ?? -1;
|
|
|
|
@override
|
|
String toString() {
|
|
var output = runtimeType.toString();
|
|
output += ': $message';
|
|
final String stderr = result?.stderr as String? ?? '';
|
|
if (stderr.isNotEmpty) {
|
|
output += ':\n$stderr';
|
|
}
|
|
return output;
|
|
}
|
|
}
|
|
|
|
enum Channel { dev, beta, stable }
|
|
|
|
String getChannelName(Channel channel) {
|
|
return switch (channel) {
|
|
Channel.beta => 'beta',
|
|
Channel.dev => 'dev',
|
|
Channel.stable => 'stable',
|
|
};
|
|
}
|
|
|
|
Channel fromChannelName(String? name) {
|
|
return switch (name) {
|
|
'beta' => Channel.beta,
|
|
'dev' => Channel.dev,
|
|
'stable' => Channel.stable,
|
|
_ => throw ArgumentError('Invalid channel name.'),
|
|
};
|
|
}
|
|
|
|
enum PublishedPlatform { linux, macos, windows }
|
|
|
|
String getPublishedPlatform(PublishedPlatform platform) {
|
|
return switch (platform) {
|
|
PublishedPlatform.linux => 'linux',
|
|
PublishedPlatform.macos => 'macos',
|
|
PublishedPlatform.windows => 'windows',
|
|
};
|
|
}
|
|
|
|
PublishedPlatform fromPublishedPlatform(String name) {
|
|
return switch (name) {
|
|
'linux' => PublishedPlatform.linux,
|
|
'macos' => PublishedPlatform.macos,
|
|
'windows' => PublishedPlatform.windows,
|
|
_ => throw ArgumentError('Invalid published platform name.'),
|
|
};
|
|
}
|
|
|
|
/// A helper class for classes that want to run a process, optionally have the
|
|
/// stderr and stdout reported as the process runs, and capture the stdout
|
|
/// properly without dropping any.
|
|
class ProcessRunner {
|
|
/// Creates a [ProcessRunner].
|
|
///
|
|
/// The [processManager], [subprocessOutput], and [platform] arguments must
|
|
/// not be null.
|
|
ProcessRunner({
|
|
this.processManager = const LocalProcessManager(),
|
|
this.subprocessOutput = true,
|
|
this.defaultWorkingDirectory,
|
|
this.platform = const LocalPlatform(),
|
|
}) {
|
|
environment = Map<String, String>.from(platform.environment);
|
|
}
|
|
|
|
/// The platform to use for a starting environment.
|
|
final Platform platform;
|
|
|
|
/// Set [subprocessOutput] to show output as processes run. Stdout from the
|
|
/// process will be printed to stdout, and stderr printed to stderr.
|
|
final bool subprocessOutput;
|
|
|
|
/// Set the [processManager] in order to inject a test instance to perform
|
|
/// testing.
|
|
final ProcessManager processManager;
|
|
|
|
/// Sets the default directory used when `workingDirectory` is not specified
|
|
/// to [runProcess].
|
|
final Directory? defaultWorkingDirectory;
|
|
|
|
/// The environment to run processes with.
|
|
late Map<String, String> environment;
|
|
|
|
/// Run the command and arguments in `commandLine` as a sub-process from
|
|
/// `workingDirectory` if set, or the [defaultWorkingDirectory] if not. Uses
|
|
/// [Directory.current] if [defaultWorkingDirectory] is not set.
|
|
///
|
|
/// Set `failOk` if [runProcess] should not throw an exception when the
|
|
/// command completes with a non-zero exit code.
|
|
Future<String> runProcess(
|
|
List<String> commandLine, {
|
|
Directory? workingDirectory,
|
|
bool failOk = false,
|
|
}) async {
|
|
workingDirectory ??= defaultWorkingDirectory ?? Directory.current;
|
|
if (subprocessOutput) {
|
|
stderr.write('Running "${commandLine.join(' ')}" in ${workingDirectory.path}.\n');
|
|
}
|
|
final output = <int>[];
|
|
final stdoutComplete = Completer<void>();
|
|
final stderrComplete = Completer<void>();
|
|
late Process process;
|
|
Future<int> allComplete() async {
|
|
await stderrComplete.future;
|
|
await stdoutComplete.future;
|
|
return process.exitCode;
|
|
}
|
|
|
|
try {
|
|
process = await processManager.start(
|
|
commandLine,
|
|
workingDirectory: workingDirectory.absolute.path,
|
|
environment: environment,
|
|
);
|
|
process.stdout.listen((List<int> event) {
|
|
output.addAll(event);
|
|
if (subprocessOutput) {
|
|
stdout.add(event);
|
|
}
|
|
}, onDone: () async => stdoutComplete.complete());
|
|
if (subprocessOutput) {
|
|
process.stderr.listen((List<int> event) {
|
|
stderr.add(event);
|
|
}, onDone: () async => stderrComplete.complete());
|
|
} else {
|
|
stderrComplete.complete();
|
|
}
|
|
} on ProcessException catch (e) {
|
|
final message =
|
|
'Running "${commandLine.join(' ')}" in ${workingDirectory.path} '
|
|
'failed with:\n$e';
|
|
throw UnpublishException(message);
|
|
} on ArgumentError catch (e) {
|
|
final message =
|
|
'Running "${commandLine.join(' ')}" in ${workingDirectory.path} '
|
|
'failed with:\n$e';
|
|
throw UnpublishException(message);
|
|
}
|
|
|
|
final int exitCode = await allComplete();
|
|
if (exitCode != 0 && !failOk) {
|
|
final message = 'Running "${commandLine.join(' ')}" in ${workingDirectory.path} failed';
|
|
throw UnpublishException(message, ProcessResult(0, exitCode, null, 'returned $exitCode'));
|
|
}
|
|
return utf8.decoder.convert(output).trim();
|
|
}
|
|
}
|
|
|
|
class ArchiveUnpublisher {
|
|
ArchiveUnpublisher(
|
|
this.tempDir,
|
|
this.revisionsBeingRemoved,
|
|
this.channels,
|
|
this.platform, {
|
|
this.confirmed = false,
|
|
ProcessManager? processManager,
|
|
bool subprocessOutput = true,
|
|
}) : assert(revisionsBeingRemoved.length == 40),
|
|
metadataGsPath = '$gsReleaseFolder/${getMetadataFilename(platform)}',
|
|
_processRunner = ProcessRunner(
|
|
processManager: processManager ?? const LocalProcessManager(),
|
|
subprocessOutput: subprocessOutput,
|
|
);
|
|
|
|
final PublishedPlatform platform;
|
|
final String metadataGsPath;
|
|
final Set<Channel> channels;
|
|
final Set<String> revisionsBeingRemoved;
|
|
final bool confirmed;
|
|
final Directory tempDir;
|
|
final ProcessRunner _processRunner;
|
|
static String getMetadataFilename(PublishedPlatform platform) =>
|
|
'releases_${getPublishedPlatform(platform)}.json';
|
|
|
|
/// Remove the archive from Google Storage.
|
|
Future<void> unpublishArchive() async {
|
|
final Map<String, dynamic> jsonData = await _loadMetadata();
|
|
final List<Map<String, String>> releases = (jsonData['releases'] as List<dynamic>)
|
|
.map<Map<String, String>>((dynamic entry) {
|
|
final mapEntry = entry as Map<String, dynamic>;
|
|
return mapEntry.cast<String, String>();
|
|
})
|
|
.toList();
|
|
final Map<Channel, Map<String, String>> paths = await _getArchivePaths(releases);
|
|
releases.removeWhere(
|
|
(Map<String, String> value) =>
|
|
revisionsBeingRemoved.contains(value['hash']) &&
|
|
channels.contains(fromChannelName(value['channel'])),
|
|
);
|
|
releases.sort((Map<String, String> a, Map<String, String> b) {
|
|
final DateTime aDate = DateTime.parse(a['release_date']!);
|
|
final DateTime bDate = DateTime.parse(b['release_date']!);
|
|
return bDate.compareTo(aDate);
|
|
});
|
|
jsonData['releases'] = releases;
|
|
for (final Channel channel in channels) {
|
|
if (!revisionsBeingRemoved.contains(
|
|
(jsonData['current_release'] as Map<String, dynamic>)[getChannelName(channel)],
|
|
)) {
|
|
// Don't replace the current release if it's not one of the revisions we're removing.
|
|
continue;
|
|
}
|
|
final Map<String, String> replacementRelease = releases.firstWhere(
|
|
(Map<String, String> value) => value['channel'] == getChannelName(channel),
|
|
);
|
|
(jsonData['current_release'] as Map<String, dynamic>)[getChannelName(channel)] =
|
|
replacementRelease['hash'];
|
|
print(
|
|
'${confirmed ? 'Reverting' : 'Would revert'} current ${getChannelName(channel)} '
|
|
'${getPublishedPlatform(platform)} release to ${replacementRelease['hash']} (version ${replacementRelease['version']}).',
|
|
);
|
|
}
|
|
await _cloudRemoveArchive(paths);
|
|
await _updateMetadata(jsonData);
|
|
}
|
|
|
|
Future<Map<Channel, Map<String, String>>> _getArchivePaths(
|
|
List<Map<String, String>> releases,
|
|
) async {
|
|
final hashes = <String>{};
|
|
final paths = <Channel, Map<String, String>>{};
|
|
for (final revision in releases) {
|
|
final String hash = revision['hash']!;
|
|
final Channel channel = fromChannelName(revision['channel']);
|
|
hashes.add(hash);
|
|
if (revisionsBeingRemoved.contains(hash) && channels.contains(channel)) {
|
|
paths[channel] ??= <String, String>{};
|
|
paths[channel]![hash] = revision['archive']!;
|
|
}
|
|
}
|
|
final Set<String> missingRevisions = revisionsBeingRemoved.difference(
|
|
hashes.intersection(revisionsBeingRemoved),
|
|
);
|
|
if (missingRevisions.isNotEmpty) {
|
|
final bool plural = missingRevisions.length > 1;
|
|
throw UnpublishException(
|
|
'Revision${plural ? 's' : ''} $missingRevisions ${plural ? 'are' : 'is'} not present in the server metadata.',
|
|
);
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _loadMetadata() async {
|
|
final metadataFile = File(path.join(tempDir.absolute.path, getMetadataFilename(platform)));
|
|
// Always run this, even in dry runs.
|
|
await _runGsUtil(<String>['cp', metadataGsPath, metadataFile.absolute.path], confirm: true);
|
|
final String currentMetadata = metadataFile.readAsStringSync();
|
|
if (currentMetadata.isEmpty) {
|
|
throw UnpublishException('Empty metadata received from server');
|
|
}
|
|
|
|
Map<String, dynamic> jsonData;
|
|
try {
|
|
jsonData = json.decode(currentMetadata) as Map<String, dynamic>;
|
|
} on FormatException catch (e) {
|
|
throw UnpublishException('Unable to parse JSON metadata received from cloud: $e');
|
|
}
|
|
|
|
return jsonData;
|
|
}
|
|
|
|
Future<void> _updateMetadata(Map<String, dynamic> jsonData) async {
|
|
// We can't just cat the metadata from the server with 'gsutil cat', because
|
|
// Windows wants to echo the commands that execute in gsutil.bat to the
|
|
// stdout when we do that. So, we copy the file locally and then read it
|
|
// back in.
|
|
final metadataFile = File(path.join(tempDir.absolute.path, getMetadataFilename(platform)));
|
|
const encoder = JsonEncoder.withIndent(' ');
|
|
metadataFile.writeAsStringSync(encoder.convert(jsonData));
|
|
print(
|
|
'${confirmed ? 'Overwriting' : 'Would overwrite'} $metadataGsPath with contents of ${metadataFile.absolute.path}',
|
|
);
|
|
await _cloudReplaceDest(metadataFile.absolute.path, metadataGsPath);
|
|
}
|
|
|
|
Future<String> _runGsUtil(
|
|
List<String> args, {
|
|
Directory? workingDirectory,
|
|
bool failOk = false,
|
|
bool confirm = false,
|
|
}) async {
|
|
final command = <String>['gsutil', '--', ...args];
|
|
if (confirm) {
|
|
return _processRunner.runProcess(command, workingDirectory: workingDirectory, failOk: failOk);
|
|
} else {
|
|
print('Would run: ${command.join(' ')}');
|
|
return '';
|
|
}
|
|
}
|
|
|
|
Future<void> _cloudRemoveArchive(Map<Channel, Map<String, String>> paths) async {
|
|
final files = <String>[];
|
|
print('${confirmed ? 'Removing' : 'Would remove'} the following release archives:');
|
|
for (final Channel channel in paths.keys) {
|
|
final Map<String, String> hashes = paths[channel]!;
|
|
for (final String hash in hashes.keys) {
|
|
final file = '$gsReleaseFolder/${hashes[hash]}';
|
|
files.add(file);
|
|
print(' $file');
|
|
}
|
|
}
|
|
await _runGsUtil(<String>['rm', ...files], failOk: true, confirm: confirmed);
|
|
}
|
|
|
|
Future<String> _cloudReplaceDest(String src, String dest) async {
|
|
assert(dest.startsWith('gs:'), '_cloudReplaceDest must have a destination in cloud storage.');
|
|
assert(!src.startsWith('gs:'), '_cloudReplaceDest must have a local source file.');
|
|
// We often don't have permission to overwrite, but
|
|
// we have permission to remove, so that's what we do first.
|
|
await _runGsUtil(<String>['rm', dest], failOk: true, confirm: confirmed);
|
|
String? mimeType;
|
|
if (dest.endsWith('.tar.xz')) {
|
|
mimeType = 'application/x-gtar';
|
|
}
|
|
if (dest.endsWith('.zip')) {
|
|
mimeType = 'application/zip';
|
|
}
|
|
if (dest.endsWith('.json')) {
|
|
mimeType = 'application/json';
|
|
}
|
|
final args = <String>[
|
|
// Use our preferred MIME type for the files we care about
|
|
// and let gsutil figure it out for anything else.
|
|
if (mimeType != null) ...<String>['-h', 'Content-Type:$mimeType'],
|
|
...<String>['cp', src, dest],
|
|
];
|
|
return _runGsUtil(args, confirm: confirmed);
|
|
}
|
|
}
|
|
|
|
void _printBanner(String message) {
|
|
final banner = '*** $message ***';
|
|
print('\n');
|
|
print('*' * banner.length);
|
|
print(banner);
|
|
print('*' * banner.length);
|
|
print('\n');
|
|
}
|
|
|
|
/// Prepares a flutter git repo to be removed from the published cloud storage.
|
|
Future<void> main(List<String> rawArguments) async {
|
|
final List<String> allowedChannelValues = Channel.values
|
|
.map<String>((Channel channel) => getChannelName(channel))
|
|
.toList();
|
|
final List<String> allowedPlatformNames = PublishedPlatform.values
|
|
.map<String>((PublishedPlatform platform) => getPublishedPlatform(platform))
|
|
.toList();
|
|
final argParser = ArgParser();
|
|
argParser.addOption(
|
|
'temp_dir',
|
|
help:
|
|
'A location where temporary files may be written. Defaults to a '
|
|
'directory in the system temp folder. If a temp_dir is not '
|
|
'specified, then by default a generated temporary directory will be '
|
|
'created, used, and removed automatically when the script exits.',
|
|
);
|
|
argParser.addMultiOption(
|
|
'revision',
|
|
help:
|
|
'The Flutter git repo revisions to remove from the published site. '
|
|
'Must be full 40-character hashes. More than one may be specified, '
|
|
'either by giving the option more than once, or by giving a comma '
|
|
'separated list. Required.',
|
|
);
|
|
argParser.addMultiOption(
|
|
'channel',
|
|
allowed: allowedChannelValues,
|
|
help:
|
|
'The Flutter channels to remove the archives corresponding to the '
|
|
'revisions given with --revision. More than one may be specified, '
|
|
'either by giving the option more than once, or by giving a '
|
|
'comma separated list. If not specified, then the archives from all '
|
|
'channels that a revision appears in will be removed.',
|
|
);
|
|
argParser.addMultiOption(
|
|
'platform',
|
|
allowed: allowedPlatformNames,
|
|
help:
|
|
'The Flutter platforms to remove the archive from. May specify more '
|
|
'than one, either by giving the option more than once, or by giving a '
|
|
'comma separated list. If not specified, then the archives from all '
|
|
'platforms that a revision appears in will be removed.',
|
|
);
|
|
argParser.addFlag(
|
|
'confirm',
|
|
help:
|
|
'If set, will actually remove the archive from Google Cloud Storage '
|
|
'upon successful execution of this script. Published archives will be '
|
|
'removed from this directory: $baseUrl$releaseFolder. This option '
|
|
'must be set to perform any action on the server, otherwise only a dry '
|
|
'run is performed.',
|
|
);
|
|
argParser.addFlag('help', negatable: false, help: 'Print help for this command.');
|
|
|
|
final ArgResults parsedArguments = argParser.parse(rawArguments);
|
|
|
|
if (parsedArguments['help'] as bool) {
|
|
print(argParser.usage);
|
|
exit(0);
|
|
}
|
|
|
|
void errorExit(String message, {int exitCode = -1}) {
|
|
stderr.write('Error: $message\n\n');
|
|
stderr.write('${argParser.usage}\n');
|
|
exit(exitCode);
|
|
}
|
|
|
|
final revisions = parsedArguments['revision'] as List<String>;
|
|
if (revisions.isEmpty) {
|
|
errorExit('Invalid argument: at least one --revision must be specified.');
|
|
}
|
|
for (final revision in revisions) {
|
|
if (revision.length != 40) {
|
|
errorExit(
|
|
'Invalid argument: --revision "$revision" must be the entire hash, not just a prefix.',
|
|
);
|
|
}
|
|
if (revision.contains(RegExp(r'[^a-fA-F0-9]'))) {
|
|
errorExit('Invalid argument: --revision "$revision" contains non-hex characters.');
|
|
}
|
|
}
|
|
|
|
final tempDirArg = parsedArguments['temp_dir'] as String;
|
|
Directory tempDir;
|
|
var removeTempDir = false;
|
|
if (tempDirArg.isEmpty) {
|
|
tempDir = Directory.systemTemp.createTempSync('flutter_package.');
|
|
removeTempDir = true;
|
|
} else {
|
|
tempDir = Directory(tempDirArg);
|
|
if (!tempDir.existsSync()) {
|
|
errorExit("Temporary directory $tempDirArg doesn't exist.");
|
|
}
|
|
}
|
|
|
|
if (!(parsedArguments['confirm'] as bool)) {
|
|
_printBanner(
|
|
'This will be just a dry run. To actually perform the changes below, re-run with --confirm argument.',
|
|
);
|
|
}
|
|
|
|
final channelArg = parsedArguments['channel'] as List<String>;
|
|
final channelOptions = channelArg.isNotEmpty ? channelArg : allowedChannelValues;
|
|
final Set<Channel> channels = channelOptions
|
|
.map<Channel>((String value) => fromChannelName(value))
|
|
.toSet();
|
|
final platformArg = parsedArguments['platform'] as List<String>;
|
|
final platformOptions = platformArg.isNotEmpty ? platformArg : allowedPlatformNames;
|
|
final List<PublishedPlatform> platforms = platformOptions
|
|
.map<PublishedPlatform>((String value) => fromPublishedPlatform(value))
|
|
.toList();
|
|
var exitCode = 0;
|
|
late String message;
|
|
late String stack;
|
|
try {
|
|
for (final platform in platforms) {
|
|
final publisher = ArchiveUnpublisher(
|
|
tempDir,
|
|
revisions.toSet(),
|
|
channels,
|
|
platform,
|
|
confirmed: parsedArguments['confirm'] as bool,
|
|
);
|
|
await publisher.unpublishArchive();
|
|
}
|
|
} on UnpublishException catch (e, s) {
|
|
exitCode = e.exitCode;
|
|
message = e.message;
|
|
stack = s.toString();
|
|
} catch (e, s) {
|
|
exitCode = -1;
|
|
message = e.toString();
|
|
stack = s.toString();
|
|
} finally {
|
|
if (removeTempDir) {
|
|
tempDir.deleteSync(recursive: true);
|
|
}
|
|
if (exitCode != 0) {
|
|
errorExit('$message\n$stack', exitCode: exitCode);
|
|
}
|
|
if (!(parsedArguments['confirm'] as bool)) {
|
|
_printBanner(
|
|
'This was just a dry run. To actually perform the above changes, re-run with --confirm argument.',
|
|
);
|
|
}
|
|
exit(0);
|
|
}
|
|
}
|