| // 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:file/local.dart'; | |
| import 'package:path/path.dart' as path; | |
| import 'package:platform/platform.dart' show LocalPlatform, Platform; | |
| import 'package:process/process.dart'; | |
| import 'prepare_package/transactional_update.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. | |
| final 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 = '$UnpublishException: $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 { | |
| // 1. Load metadata to find paths to remove. | |
| final Map<String, Object?> initialJsonData = await _loadMetadata(); | |
| final List<Map<String, String>> initialReleases = | |
| (initialJsonData['releases']! as List<Object?>).map<Map<String, String>>((Object? entry) { | |
| final mapEntry = entry! as Map<String, Object?>; | |
| return mapEntry.cast<String, String>(); | |
| }).toList(); | |
| final Map<Channel, Map<String, String>> paths = await _getArchivePaths(initialReleases); | |
| // 2. Remove archives first. | |
| await _cloudRemoveArchive(paths); | |
| // 3. Transactional update of metadata. | |
| final metadataGsPath = '$gsReleaseFolder/${getMetadataFilename(platform)}'; | |
| await transactionalUpdate( | |
| gsPath: metadataGsPath, | |
| fs: const LocalFileSystem(), | |
| dryRun: !confirmed, | |
| runGsUtil: (List<String> args) => _runGsUtil(args, confirm: confirmed), | |
| callback: (String currentContents) async { | |
| if (currentContents.isEmpty) { | |
| return ''; | |
| } | |
| Map<String, Object?> jsonData; | |
| try { | |
| jsonData = json.decode(currentContents) as Map<String, Object?>; | |
| } on FormatException catch (e) { | |
| throw Exception('Unable to parse JSON metadata received from cloud: $e'); | |
| } | |
| final List<Map<String, String>> releases = (jsonData['releases']! as List<Object?>) | |
| .map<Map<String, String>>((Object? entry) { | |
| final mapEntry = entry! as Map<String, Object?>; | |
| return mapEntry.cast<String, String>(); | |
| }) | |
| .toList(); | |
| 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, Object?>)[getChannelName(channel)], | |
| )) { | |
| continue; | |
| } | |
| final Map<String, String> replacementRelease = releases.firstWhere( | |
| (Map<String, String> value) => value['channel'] == getChannelName(channel), | |
| ); | |
| (jsonData['current_release']! as Map<String, Object?>)[getChannelName(channel)] = | |
| replacementRelease['hash']; | |
| print( | |
| '${confirmed ? 'Reverting' : 'Would revert'} current ${getChannelName(channel)} ' | |
| '${getPublishedPlatform(platform)} release to ${replacementRelease['hash']} (version ${replacementRelease['version']}).', | |
| ); | |
| } | |
| const encoder = JsonEncoder.withIndent(' '); | |
| return encoder.convert(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, Object?>> _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, Object?> jsonData; | |
| try { | |
| jsonData = json.decode(currentMetadata) as Map<String, Object?>; | |
| } on FormatException catch (e) { | |
| throw UnpublishException('Unable to parse JSON metadata received from cloud: $e'); | |
| } | |
| return jsonData; | |
| } | |
| 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); | |
| } | |
| } | |
| 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); | |
| } | |
| } |