mirror of
https://github.com/flutter/packages.git
synced 2025-06-05 10:57:11 +08:00
589 lines
20 KiB
Dart
589 lines
20 KiB
Dart
// Copyright 2013 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.
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io' as io;
|
|
|
|
import 'package:file/file.dart';
|
|
import 'package:git/git.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:meta/meta.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:pub_semver/pub_semver.dart';
|
|
import 'package:pubspec_parse/pubspec_parse.dart';
|
|
import 'package:yaml/yaml.dart';
|
|
|
|
import 'common/core.dart';
|
|
import 'common/git_version_finder.dart';
|
|
import 'common/plugin_command.dart';
|
|
import 'common/process_runner.dart';
|
|
import 'common/pub_version_finder.dart';
|
|
|
|
@immutable
|
|
class _RemoteInfo {
|
|
const _RemoteInfo({required this.name, required this.url});
|
|
|
|
/// The git name for the remote.
|
|
final String name;
|
|
|
|
/// The remote's URL.
|
|
final String url;
|
|
}
|
|
|
|
/// Wraps pub publish with a few niceties used by the flutter/plugin team.
|
|
///
|
|
/// 1. Checks for any modified files in git and refuses to publish if there's an
|
|
/// issue.
|
|
/// 2. Tags the release with the format <package-name>-v<package-version>.
|
|
/// 3. Pushes the release to a remote.
|
|
///
|
|
/// Both 2 and 3 are optional, see `plugin_tools help publish-plugin` for full
|
|
/// usage information.
|
|
///
|
|
/// [processRunner], [print], and [stdin] can be overriden for easier testing.
|
|
class PublishPluginCommand extends PluginCommand {
|
|
/// Creates an instance of the publish command.
|
|
PublishPluginCommand(
|
|
Directory packagesDir, {
|
|
ProcessRunner processRunner = const ProcessRunner(),
|
|
Print print = print,
|
|
io.Stdin? stdinput,
|
|
GitDir? gitDir,
|
|
http.Client? httpClient,
|
|
}) : _pubVersionFinder =
|
|
PubVersionFinder(httpClient: httpClient ?? http.Client()),
|
|
_print = print,
|
|
_stdin = stdinput ?? io.stdin,
|
|
super(packagesDir, processRunner: processRunner, gitDir: gitDir) {
|
|
argParser.addOption(
|
|
_packageOption,
|
|
help: 'The package to publish.'
|
|
'If the package directory name is different than its pubspec.yaml name, then this should specify the directory.',
|
|
);
|
|
argParser.addMultiOption(_pubFlagsOption,
|
|
help:
|
|
'A list of options that will be forwarded on to pub. Separate multiple flags with commas.');
|
|
argParser.addFlag(
|
|
_tagReleaseOption,
|
|
help: 'Whether or not to tag the release.',
|
|
defaultsTo: true,
|
|
negatable: true,
|
|
);
|
|
argParser.addFlag(
|
|
_pushTagsOption,
|
|
help:
|
|
'Whether or not tags should be pushed to a remote after creation. Ignored if tag-release is false.',
|
|
defaultsTo: true,
|
|
negatable: true,
|
|
);
|
|
argParser.addOption(
|
|
_remoteOption,
|
|
help:
|
|
'The name of the remote to push the tags to. Ignored if push-tags or tag-release is false.',
|
|
// Flutter convention is to use "upstream" for the single source of truth, and "origin" for personal forks.
|
|
defaultsTo: 'upstream',
|
|
);
|
|
argParser.addFlag(
|
|
_allChangedFlag,
|
|
help:
|
|
'Release all plugins that contains pubspec changes at the current commit compares to the base-sha.\n'
|
|
'The $_packageOption option is ignored if this is on.',
|
|
defaultsTo: false,
|
|
);
|
|
argParser.addFlag(
|
|
_dryRunFlag,
|
|
help:
|
|
'Skips the real `pub publish` and `git tag` commands and assumes both commands are successful.\n'
|
|
'This does not run `pub publish --dry-run`.\n'
|
|
'If you want to run the command with `pub publish --dry-run`, use `pub-publish-flags=--dry-run`',
|
|
defaultsTo: false,
|
|
negatable: true,
|
|
);
|
|
argParser.addFlag(_skipConfirmationFlag,
|
|
help: 'Run the command without asking for Y/N inputs.\n'
|
|
'This command will add a `--force` flag to the `pub publish` command if it is not added with $_pubFlagsOption\n'
|
|
'It also skips the y/n inputs when pushing tags to remote.\n',
|
|
defaultsTo: false,
|
|
negatable: true);
|
|
}
|
|
|
|
static const String _packageOption = 'package';
|
|
static const String _tagReleaseOption = 'tag-release';
|
|
static const String _pushTagsOption = 'push-tags';
|
|
static const String _pubFlagsOption = 'pub-publish-flags';
|
|
static const String _remoteOption = 'remote';
|
|
static const String _allChangedFlag = 'all-changed';
|
|
static const String _dryRunFlag = 'dry-run';
|
|
static const String _skipConfirmationFlag = 'skip-confirmation';
|
|
|
|
static const String _pubCredentialName = 'PUB_CREDENTIALS';
|
|
|
|
// Version tags should follow <package-name>-v<semantic-version>. For example,
|
|
// `flutter_plugin_tools-v0.0.24`.
|
|
static const String _tagFormat = '%PACKAGE%-v%VERSION%';
|
|
|
|
@override
|
|
final String name = 'publish-plugin';
|
|
|
|
@override
|
|
final String description =
|
|
'Attempts to publish the given plugin and tag its release on GitHub.\n'
|
|
'If running this on CI, an environment variable named $_pubCredentialName must be set to a String that represents the pub credential JSON.\n'
|
|
'WARNING: Do not check in the content of pub credential JSON, it should only come from secure sources.';
|
|
|
|
final Print _print;
|
|
final io.Stdin _stdin;
|
|
StreamSubscription<String>? _stdinSubscription;
|
|
final PubVersionFinder _pubVersionFinder;
|
|
|
|
@override
|
|
Future<void> run() async {
|
|
final String package = getStringArg(_packageOption);
|
|
final bool publishAllChanged = getBoolArg(_allChangedFlag);
|
|
if (package.isEmpty && !publishAllChanged) {
|
|
_print(
|
|
'Must specify a package to publish. See `plugin_tools help publish-plugin`.');
|
|
throw ToolExit(1);
|
|
}
|
|
|
|
_print('Checking local repo...');
|
|
// Ensure there are no symlinks in the path, as it can break
|
|
// GitDir's allowSubdirectory:true.
|
|
final String packagesPath = packagesDir.resolveSymbolicLinksSync();
|
|
if (!await GitDir.isGitDir(packagesPath)) {
|
|
_print('$packagesPath is not a valid Git repository.');
|
|
throw ToolExit(1);
|
|
}
|
|
final GitDir baseGitDir = gitDir ??
|
|
await GitDir.fromExisting(packagesPath, allowSubdirectory: true);
|
|
|
|
final bool shouldPushTag = getBoolArg(_pushTagsOption);
|
|
_RemoteInfo? remote;
|
|
if (shouldPushTag) {
|
|
final String remoteName = getStringArg(_remoteOption);
|
|
final String? remoteUrl = await _verifyRemote(remoteName);
|
|
if (remoteUrl == null) {
|
|
printError(
|
|
'Unable to find URL for remote $remoteName; cannot push tags');
|
|
throw ToolExit(1);
|
|
}
|
|
remote = _RemoteInfo(name: remoteName, url: remoteUrl);
|
|
}
|
|
_print('Local repo is ready!');
|
|
if (getBoolArg(_dryRunFlag)) {
|
|
_print('=============== DRY RUN ===============');
|
|
}
|
|
|
|
bool successful;
|
|
if (publishAllChanged) {
|
|
successful = await _publishAllChangedPackages(
|
|
baseGitDir: baseGitDir,
|
|
remoteForTagPush: remote,
|
|
);
|
|
} else {
|
|
successful = await _publishAndTagPackage(
|
|
packageDir: _getPackageDir(package),
|
|
remoteForTagPush: remote,
|
|
);
|
|
}
|
|
|
|
_pubVersionFinder.httpClient.close();
|
|
await _finish(successful);
|
|
}
|
|
|
|
Future<bool> _publishAllChangedPackages({
|
|
required GitDir baseGitDir,
|
|
_RemoteInfo? remoteForTagPush,
|
|
}) async {
|
|
final GitVersionFinder gitVersionFinder = await retrieveVersionFinder();
|
|
final List<String> changedPubspecs =
|
|
await gitVersionFinder.getChangedPubSpecs();
|
|
if (changedPubspecs.isEmpty) {
|
|
_print('No version updates in this commit.');
|
|
return true;
|
|
}
|
|
|
|
_print('Getting existing tags...');
|
|
final io.ProcessResult existingTagsResult =
|
|
await baseGitDir.runCommand(<String>['tag', '--sort=-committerdate']);
|
|
final List<String> existingTags = (existingTagsResult.stdout as String)
|
|
.split('\n')
|
|
..removeWhere((String element) => element.isEmpty);
|
|
|
|
final List<String> packagesReleased = <String>[];
|
|
final List<String> packagesFailed = <String>[];
|
|
|
|
for (final String pubspecPath in changedPubspecs) {
|
|
final File pubspecFile = packagesDir.fileSystem
|
|
.directory(baseGitDir.path)
|
|
.childFile(pubspecPath);
|
|
final _CheckNeedsReleaseResult result = await _checkNeedsRelease(
|
|
pubspecFile: pubspecFile,
|
|
existingTags: existingTags,
|
|
);
|
|
switch (result) {
|
|
case _CheckNeedsReleaseResult.release:
|
|
break;
|
|
case _CheckNeedsReleaseResult.noRelease:
|
|
continue;
|
|
case _CheckNeedsReleaseResult.failure:
|
|
packagesFailed.add(pubspecFile.parent.basename);
|
|
continue;
|
|
}
|
|
_print('\n');
|
|
if (await _publishAndTagPackage(
|
|
packageDir: pubspecFile.parent,
|
|
remoteForTagPush: remoteForTagPush,
|
|
)) {
|
|
packagesReleased.add(pubspecFile.parent.basename);
|
|
} else {
|
|
packagesFailed.add(pubspecFile.parent.basename);
|
|
}
|
|
_print('\n');
|
|
}
|
|
if (packagesReleased.isNotEmpty) {
|
|
_print('Packages released: ${packagesReleased.join(', ')}');
|
|
}
|
|
if (packagesFailed.isNotEmpty) {
|
|
_print(
|
|
'Failed to release the following packages: ${packagesFailed.join(', ')}, see above for details.');
|
|
}
|
|
return packagesFailed.isEmpty;
|
|
}
|
|
|
|
// Publish the package to pub with `pub publish`.
|
|
// If `_tagReleaseOption` is on, git tag the release.
|
|
// If `remoteForTagPush` is non-null, the tag will be pushed to that remote.
|
|
// Returns `true` if publishing and tagging are successful.
|
|
Future<bool> _publishAndTagPackage({
|
|
required Directory packageDir,
|
|
_RemoteInfo? remoteForTagPush,
|
|
}) async {
|
|
if (!await _publishPlugin(packageDir: packageDir)) {
|
|
return false;
|
|
}
|
|
if (getBoolArg(_tagReleaseOption)) {
|
|
if (!await _tagRelease(
|
|
packageDir: packageDir,
|
|
remoteForPush: remoteForTagPush,
|
|
)) {
|
|
return false;
|
|
}
|
|
}
|
|
_print('Released [${packageDir.basename}] successfully.');
|
|
return true;
|
|
}
|
|
|
|
// Returns a [_CheckNeedsReleaseResult] that indicates the result.
|
|
Future<_CheckNeedsReleaseResult> _checkNeedsRelease({
|
|
required File pubspecFile,
|
|
required List<String> existingTags,
|
|
}) async {
|
|
if (!pubspecFile.existsSync()) {
|
|
_print('''
|
|
The file at The pubspec file at ${pubspecFile.path} does not exist. Publishing will not happen for ${pubspecFile.parent.basename}.
|
|
Safe to ignore if the package is deleted in this commit.
|
|
''');
|
|
return _CheckNeedsReleaseResult.noRelease;
|
|
}
|
|
|
|
final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync());
|
|
|
|
if (pubspec.name == 'flutter_plugin_tools') {
|
|
// Ignore flutter_plugin_tools package when running publishing through flutter_plugin_tools.
|
|
// TODO(cyanglaz): Make the tool also auto publish flutter_plugin_tools package.
|
|
// https://github.com/flutter/flutter/issues/85430
|
|
return _CheckNeedsReleaseResult.noRelease;
|
|
}
|
|
|
|
if (pubspec.publishTo == 'none') {
|
|
return _CheckNeedsReleaseResult.noRelease;
|
|
}
|
|
|
|
if (pubspec.version == null) {
|
|
_print(
|
|
'No version found. A package that intentionally has no version should be marked "publish_to: none"');
|
|
return _CheckNeedsReleaseResult.failure;
|
|
}
|
|
|
|
// Check if the package named `packageName` with `version` has already published.
|
|
final Version version = pubspec.version!;
|
|
final PubVersionFinderResponse pubVersionFinderResponse =
|
|
await _pubVersionFinder.getPackageVersion(package: pubspec.name);
|
|
if (pubVersionFinderResponse.versions.contains(version)) {
|
|
final String tagsForPackageWithSameVersion = existingTags.firstWhere(
|
|
(String tag) =>
|
|
tag.split('-v').first == pubspec.name &&
|
|
tag.split('-v').last == version.toString(),
|
|
orElse: () => '');
|
|
_print(
|
|
'The version $version of ${pubspec.name} has already been published');
|
|
if (tagsForPackageWithSameVersion.isEmpty) {
|
|
_print(
|
|
'However, the git release tag for this version (${pubspec.name}-v$version) is not found. Please manually fix the tag then run the command again.');
|
|
return _CheckNeedsReleaseResult.failure;
|
|
} else {
|
|
_print('skip.');
|
|
return _CheckNeedsReleaseResult.noRelease;
|
|
}
|
|
}
|
|
return _CheckNeedsReleaseResult.release;
|
|
}
|
|
|
|
// Publish the plugin.
|
|
//
|
|
// Returns `true` if successful, `false` otherwise.
|
|
Future<bool> _publishPlugin({required Directory packageDir}) async {
|
|
final bool gitStatusOK = await _checkGitStatus(packageDir);
|
|
if (!gitStatusOK) {
|
|
return false;
|
|
}
|
|
final bool publishOK = await _publish(packageDir);
|
|
if (!publishOK) {
|
|
return false;
|
|
}
|
|
_print('Package published!');
|
|
return true;
|
|
}
|
|
|
|
// Tag the release with <plugin-name>-v<version>, and, if [remoteForTagPush]
|
|
// is provided, push it to that remote.
|
|
//
|
|
// Return `true` if successful, `false` otherwise.
|
|
Future<bool> _tagRelease({
|
|
required Directory packageDir,
|
|
_RemoteInfo? remoteForPush,
|
|
}) async {
|
|
final String tag = _getTag(packageDir);
|
|
_print('Tagging release $tag...');
|
|
if (!getBoolArg(_dryRunFlag)) {
|
|
final io.ProcessResult result = await processRunner.run(
|
|
'git',
|
|
<String>['tag', tag],
|
|
workingDir: packageDir,
|
|
exitOnError: false,
|
|
logOnError: true,
|
|
);
|
|
if (result.exitCode != 0) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (remoteForPush == null) {
|
|
return true;
|
|
}
|
|
|
|
_print('Pushing tag to ${remoteForPush.name}...');
|
|
return await _pushTagToRemote(
|
|
tag: tag,
|
|
remote: remoteForPush,
|
|
);
|
|
}
|
|
|
|
Future<void> _finish(bool successful) async {
|
|
await _stdinSubscription?.cancel();
|
|
_stdinSubscription = null;
|
|
if (successful) {
|
|
_print('Done!');
|
|
} else {
|
|
_print('Failed, see above for details.');
|
|
throw ToolExit(1);
|
|
}
|
|
}
|
|
|
|
// Returns the packageDirectory based on the package name.
|
|
// Throws ToolExit if the `package` doesn't exist.
|
|
Directory _getPackageDir(String package) {
|
|
final Directory packageDir = packagesDir.childDirectory(package);
|
|
if (!packageDir.existsSync()) {
|
|
_print('${packageDir.absolute.path} does not exist.');
|
|
throw ToolExit(1);
|
|
}
|
|
return packageDir;
|
|
}
|
|
|
|
Future<bool> _checkGitStatus(Directory packageDir) async {
|
|
final io.ProcessResult statusResult = await processRunner.run(
|
|
'git',
|
|
<String>['status', '--porcelain', '--ignored', packageDir.absolute.path],
|
|
workingDir: packageDir,
|
|
logOnError: true,
|
|
exitOnError: false,
|
|
);
|
|
if (statusResult.exitCode != 0) {
|
|
return false;
|
|
}
|
|
|
|
final String statusOutput = statusResult.stdout as String;
|
|
if (statusOutput.isNotEmpty) {
|
|
_print(
|
|
"There are files in the package directory that haven't been saved in git. Refusing to publish these files:\n\n"
|
|
'$statusOutput\n'
|
|
'If the directory should be clean, you can run `git clean -xdf && git reset --hard HEAD` to wipe all local changes.');
|
|
}
|
|
return statusOutput.isEmpty;
|
|
}
|
|
|
|
Future<String?> _verifyRemote(String remote) async {
|
|
final io.ProcessResult getRemoteUrlResult = await processRunner.run(
|
|
'git',
|
|
<String>['remote', 'get-url', remote],
|
|
workingDir: packagesDir,
|
|
exitOnError: true,
|
|
logOnError: true,
|
|
);
|
|
return getRemoteUrlResult.stdout as String?;
|
|
}
|
|
|
|
Future<bool> _publish(Directory packageDir) async {
|
|
final List<String> publishFlags = getStringListArg(_pubFlagsOption);
|
|
_print(
|
|
'Running `pub publish ${publishFlags.join(' ')}` in ${packageDir.absolute.path}...\n');
|
|
if (getBoolArg(_dryRunFlag)) {
|
|
return true;
|
|
}
|
|
|
|
if (getBoolArg(_skipConfirmationFlag)) {
|
|
publishFlags.add('--force');
|
|
}
|
|
if (publishFlags.contains('--force')) {
|
|
_ensureValidPubCredential();
|
|
}
|
|
|
|
final io.Process publish = await processRunner.start(
|
|
'flutter', <String>['pub', 'publish'] + publishFlags,
|
|
workingDirectory: packageDir);
|
|
publish.stdout
|
|
.transform(utf8.decoder)
|
|
.listen((String data) => _print(data));
|
|
publish.stderr
|
|
.transform(utf8.decoder)
|
|
.listen((String data) => _print(data));
|
|
_stdinSubscription ??= _stdin
|
|
.transform(utf8.decoder)
|
|
.listen((String data) => publish.stdin.writeln(data));
|
|
final int result = await publish.exitCode;
|
|
if (result != 0) {
|
|
_print('Publish ${packageDir.basename} failed.');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
String _getTag(Directory packageDir) {
|
|
final File pubspecFile = packageDir.childFile('pubspec.yaml');
|
|
final YamlMap pubspecYaml =
|
|
loadYaml(pubspecFile.readAsStringSync()) as YamlMap;
|
|
final String name = pubspecYaml['name'] as String;
|
|
final String version = pubspecYaml['version'] as String;
|
|
// We should have failed to publish if these were unset.
|
|
assert(name.isNotEmpty && version.isNotEmpty);
|
|
return _tagFormat
|
|
.replaceAll('%PACKAGE%', name)
|
|
.replaceAll('%VERSION%', version);
|
|
}
|
|
|
|
// Pushes the `tag` to `remote`
|
|
//
|
|
// Return `true` if successful, `false` otherwise.
|
|
Future<bool> _pushTagToRemote({
|
|
required String tag,
|
|
required _RemoteInfo remote,
|
|
}) async {
|
|
assert(remote != null && tag != null);
|
|
if (!getBoolArg(_skipConfirmationFlag)) {
|
|
_print('Ready to push $tag to ${remote.url} (y/n)?');
|
|
final String? input = _stdin.readLineSync();
|
|
if (input?.toLowerCase() != 'y') {
|
|
_print('Tag push canceled.');
|
|
return false;
|
|
}
|
|
}
|
|
if (!getBoolArg(_dryRunFlag)) {
|
|
final io.ProcessResult result = await processRunner.run(
|
|
'git',
|
|
<String>['push', remote.name, tag],
|
|
workingDir: packagesDir,
|
|
exitOnError: false,
|
|
logOnError: true,
|
|
);
|
|
if (result.exitCode != 0) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void _ensureValidPubCredential() {
|
|
final String credentialsPath = _credentialsPath;
|
|
final File credentialFile = packagesDir.fileSystem.file(credentialsPath);
|
|
if (credentialFile.existsSync() &&
|
|
credentialFile.readAsStringSync().isNotEmpty) {
|
|
return;
|
|
}
|
|
final String? credential = io.Platform.environment[_pubCredentialName];
|
|
if (credential == null) {
|
|
printError('''
|
|
No pub credential available. Please check if `$credentialsPath` is valid.
|
|
If running this command on CI, you can set the pub credential content in the $_pubCredentialName environment variable.
|
|
''');
|
|
throw ToolExit(1);
|
|
}
|
|
credentialFile.openSync(mode: FileMode.writeOnlyAppend)
|
|
..writeStringSync(credential)
|
|
..closeSync();
|
|
}
|
|
|
|
/// Returns the correct path where the pub credential is stored.
|
|
@visibleForTesting
|
|
static String getCredentialPath() {
|
|
return _credentialsPath;
|
|
}
|
|
}
|
|
|
|
/// The path in which pub expects to find its credentials file.
|
|
final String _credentialsPath = () {
|
|
// This follows the same logic as pub:
|
|
// https://github.com/dart-lang/pub/blob/d99b0d58f4059d7bb4ac4616fd3d54ec00a2b5d4/lib/src/system_cache.dart#L34-L43
|
|
String? cacheDir;
|
|
final String? pubCache = io.Platform.environment['PUB_CACHE'];
|
|
print(pubCache);
|
|
if (pubCache != null) {
|
|
cacheDir = pubCache;
|
|
} else if (io.Platform.isWindows) {
|
|
final String? appData = io.Platform.environment['APPDATA'];
|
|
if (appData == null) {
|
|
printError('"APPDATA" environment variable is not set.');
|
|
} else {
|
|
cacheDir = p.join(appData, 'Pub', 'Cache');
|
|
}
|
|
} else {
|
|
final String? home = io.Platform.environment['HOME'];
|
|
if (home == null) {
|
|
printError('"HOME" environment variable is not set.');
|
|
} else {
|
|
cacheDir = p.join(home, '.pub-cache');
|
|
}
|
|
}
|
|
|
|
if (cacheDir == null) {
|
|
printError('Unable to determine pub cache location');
|
|
throw ToolExit(1);
|
|
}
|
|
|
|
return p.join(cacheDir, 'credentials.json');
|
|
}();
|
|
|
|
enum _CheckNeedsReleaseResult {
|
|
// The package needs to be released.
|
|
release,
|
|
|
|
// The package does not need to be released.
|
|
noRelease,
|
|
|
|
// There's an error when trying to determine whether the package needs to be released.
|
|
failure,
|
|
}
|