// 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 -v. /// 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 -v. 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? _stdinSubscription; final PubVersionFinder _pubVersionFinder; @override Future 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 _publishAllChangedPackages({ required GitDir baseGitDir, _RemoteInfo? remoteForTagPush, }) async { final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); final List 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(['tag', '--sort=-committerdate']); final List existingTags = (existingTagsResult.stdout as String) .split('\n') ..removeWhere((String element) => element.isEmpty); final List packagesReleased = []; final List packagesFailed = []; 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 _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 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 _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 -v, and, if [remoteForTagPush] // is provided, push it to that remote. // // Return `true` if successful, `false` otherwise. Future _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', ['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 _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 _checkGitStatus(Directory packageDir) async { final io.ProcessResult statusResult = await processRunner.run( 'git', ['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 _verifyRemote(String remote) async { final io.ProcessResult getRemoteUrlResult = await processRunner.run( 'git', ['remote', 'get-url', remote], workingDir: packagesDir, exitOnError: true, logOnError: true, ); return getRemoteUrlResult.stdout as String?; } Future _publish(Directory packageDir) async { final List 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', ['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 _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', ['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, }