Initial commit
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
import 'dart:io';
|
||||
|
||||
const _fallbackVersion = '0.0.1';
|
||||
final _versionPattern = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$');
|
||||
|
||||
typedef _Release = ({
|
||||
String platform,
|
||||
String prefix,
|
||||
String source,
|
||||
_Version version,
|
||||
});
|
||||
|
||||
final class _Version implements Comparable<_Version> {
|
||||
const _Version(this.major, this.minor, this.patch);
|
||||
|
||||
factory _Version.parse(String value) {
|
||||
final match = _versionPattern.firstMatch(value);
|
||||
if (match == null) {
|
||||
throw FormatException(
|
||||
"Unsupported version '$value'. Expected MAJOR.MINOR.PATCH.",
|
||||
);
|
||||
}
|
||||
return _Version(
|
||||
int.parse(match[1]!),
|
||||
int.parse(match[2]!),
|
||||
int.parse(match[3]!),
|
||||
);
|
||||
}
|
||||
|
||||
final int major;
|
||||
final int minor;
|
||||
final int patch;
|
||||
|
||||
_Version get nextPatch => _Version(major, minor, patch + 1);
|
||||
int get buildNumber => major * 1000000 + minor * 1000 + patch;
|
||||
|
||||
@override
|
||||
int compareTo(_Version other) {
|
||||
final majorComparison = major.compareTo(other.major);
|
||||
if (majorComparison != 0) return majorComparison;
|
||||
final minorComparison = minor.compareTo(other.minor);
|
||||
if (minorComparison != 0) return minorComparison;
|
||||
return patch.compareTo(other.patch);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$major.$minor.$patch';
|
||||
}
|
||||
|
||||
Future<void> main(List<String> arguments) async {
|
||||
try {
|
||||
await _run(arguments);
|
||||
} on FormatException catch (error) {
|
||||
stderr.writeln('error: ${error.message}');
|
||||
stderr.writeln(_usage);
|
||||
exitCode = 2;
|
||||
} on StateError catch (error) {
|
||||
stderr.writeln('error: ${error.message}');
|
||||
exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run(List<String> arguments) async {
|
||||
if (arguments.isEmpty || arguments.contains('--help')) {
|
||||
stdout.writeln(_usage);
|
||||
if (arguments.isEmpty) exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
final command = arguments.first;
|
||||
final options = _parseOptions(arguments.skip(1).toList());
|
||||
final projectRoot = options.projectRoot ?? _defaultProjectRoot;
|
||||
|
||||
switch (command) {
|
||||
case 'current':
|
||||
_expectOnePlatform(options);
|
||||
stdout.writeln(
|
||||
await _currentVersion(options.positional.single, projectRoot),
|
||||
);
|
||||
case 'next':
|
||||
_expectOnePlatform(options);
|
||||
final release = await _nextRelease(
|
||||
options.positional.single,
|
||||
projectRoot,
|
||||
);
|
||||
stdout.writeln(
|
||||
'${release.platform} ${release.version} ${release.source}',
|
||||
);
|
||||
case 'build-number':
|
||||
if (options.positional.length != 1 || options.hasTagOptions) {
|
||||
throw const FormatException('build-number requires one version.');
|
||||
}
|
||||
stdout.writeln(_Version.parse(options.positional.single).buildNumber);
|
||||
case 'tag':
|
||||
if (options.positional.length != 1) {
|
||||
throw const FormatException('tag requires one platform.');
|
||||
}
|
||||
await _tag(options.positional.single, projectRoot, options);
|
||||
default:
|
||||
throw FormatException("Unknown command '$command'.");
|
||||
}
|
||||
}
|
||||
|
||||
void _expectOnePlatform(_Options options) {
|
||||
if (options.positional.length != 1 || options.hasTagOptions) {
|
||||
throw const FormatException('current/next requires one platform.');
|
||||
}
|
||||
}
|
||||
|
||||
({String platform, String prefix}) _resolvePlatform(String value) {
|
||||
return switch (value.trim().toLowerCase()) {
|
||||
'windows' ||
|
||||
'pc' ||
|
||||
'macos' ||
|
||||
'darwin' => (platform: 'windows', prefix: 'v'),
|
||||
'android' => (platform: 'android', prefix: 'android-v'),
|
||||
_ => throw FormatException(
|
||||
"Unsupported platform '$value'. Expected windows, pc, macos, or android.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Future<_Version> _currentVersion(String platform, String projectRoot) async {
|
||||
final pubspec = _pubspecVersion(projectRoot);
|
||||
final latest = await _latestTag(platform, projectRoot);
|
||||
return latest == null || latest.version.compareTo(pubspec) < 0
|
||||
? pubspec
|
||||
: latest.version;
|
||||
}
|
||||
|
||||
Future<_Release> _nextRelease(String platform, String projectRoot) async {
|
||||
final resolved = _resolvePlatform(platform);
|
||||
final pubspec = _pubspecVersion(projectRoot);
|
||||
final latest = await _latestTag(resolved.platform, projectRoot);
|
||||
if (latest == null || latest.version.compareTo(pubspec) < 0) {
|
||||
return (
|
||||
platform: resolved.platform,
|
||||
prefix: resolved.prefix,
|
||||
source: 'pubspec:$pubspec',
|
||||
version: pubspec,
|
||||
);
|
||||
}
|
||||
return (
|
||||
platform: resolved.platform,
|
||||
prefix: resolved.prefix,
|
||||
source: latest.tag,
|
||||
version: latest.version.nextPatch,
|
||||
);
|
||||
}
|
||||
|
||||
_Version _pubspecVersion(String projectRoot) {
|
||||
final pubspec = File('$projectRoot${Platform.pathSeparator}pubspec.yaml');
|
||||
if (!pubspec.existsSync()) return _Version.parse(_fallbackVersion);
|
||||
final match = RegExp(
|
||||
r'^version:\s*(\d+\.\d+\.\d+(?:\+[0-9A-Za-z.-]+)?)\s*$',
|
||||
multiLine: true,
|
||||
).firstMatch(pubspec.readAsStringSync());
|
||||
return _Version.parse(match?[1] ?? _fallbackVersion);
|
||||
}
|
||||
|
||||
Future<({String tag, _Version version})?> _latestTag(
|
||||
String platform,
|
||||
String projectRoot,
|
||||
) async {
|
||||
final resolved = _resolvePlatform(platform);
|
||||
final result = await Process.run('git', [
|
||||
'tag',
|
||||
'--list',
|
||||
'${resolved.prefix}*',
|
||||
], workingDirectory: projectRoot);
|
||||
if (result.exitCode != 0) return null;
|
||||
|
||||
final pattern = RegExp(
|
||||
'^${RegExp.escape(resolved.prefix)}'
|
||||
r'(\d+\.\d+\.\d+(?:\+[0-9A-Za-z.-]+)?)$',
|
||||
);
|
||||
({String tag, _Version version})? latest;
|
||||
for (final tag in '${result.stdout}'.split(RegExp(r'\r?\n'))) {
|
||||
final match = pattern.firstMatch(tag.trim());
|
||||
if (match == null) continue;
|
||||
final candidate = (tag: tag.trim(), version: _Version.parse(match[1]!));
|
||||
if (latest == null || candidate.version.compareTo(latest.version) > 0) {
|
||||
latest = candidate;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
Future<void> _tag(
|
||||
String requestedPlatform,
|
||||
String projectRoot,
|
||||
_Options options,
|
||||
) async {
|
||||
final platforms = requestedPlatform.toLowerCase() == 'both'
|
||||
? const ['windows', 'android']
|
||||
: [_resolvePlatform(requestedPlatform).platform];
|
||||
|
||||
for (final platform in platforms) {
|
||||
final release = await _nextRelease(platform, projectRoot);
|
||||
final tag = '${release.prefix}${release.version}';
|
||||
final existing = await _git(['tag', '--list', tag], projectRoot);
|
||||
if ('${existing.stdout}'.trim().isNotEmpty) {
|
||||
throw StateError("Tag '$tag' already exists. Aborting.");
|
||||
}
|
||||
|
||||
stdout.writeln('[${release.platform}] ${release.source} -> $tag');
|
||||
if (options.dryRun) {
|
||||
stdout.writeln(' DryRun: skip create/push.');
|
||||
continue;
|
||||
}
|
||||
|
||||
await _git(['tag', tag], projectRoot);
|
||||
if (options.noPush) {
|
||||
stdout.writeln(' Created local tag (--no-push).');
|
||||
continue;
|
||||
}
|
||||
await _git(['push', options.remote, tag], projectRoot);
|
||||
stdout.writeln(' Pushed to ${options.remote}.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<ProcessResult> _git(List<String> arguments, String projectRoot) async {
|
||||
final result = await Process.run(
|
||||
'git',
|
||||
arguments,
|
||||
workingDirectory: projectRoot,
|
||||
);
|
||||
if (result.exitCode != 0) {
|
||||
throw StateError(
|
||||
'${result.stderr}'.trim().isEmpty
|
||||
? 'git ${arguments.join(' ')} failed with exit ${result.exitCode}.'
|
||||
: '${result.stderr}'.trim(),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
_Options _parseOptions(List<String> arguments) {
|
||||
final positional = <String>[];
|
||||
String? projectRoot;
|
||||
var dryRun = false;
|
||||
var noPush = false;
|
||||
var remote = 'origin';
|
||||
|
||||
for (var index = 0; index < arguments.length; index++) {
|
||||
final argument = arguments[index];
|
||||
switch (argument) {
|
||||
case '--dry-run':
|
||||
dryRun = true;
|
||||
case '--no-push':
|
||||
noPush = true;
|
||||
case '--project-root' || '--remote':
|
||||
if (++index >= arguments.length) {
|
||||
throw FormatException('$argument requires a value.');
|
||||
}
|
||||
if (argument == '--project-root') {
|
||||
projectRoot = arguments[index];
|
||||
} else {
|
||||
remote = arguments[index];
|
||||
}
|
||||
default:
|
||||
if (argument.startsWith('--project-root=')) {
|
||||
projectRoot = argument.substring('--project-root='.length);
|
||||
} else if (argument.startsWith('--remote=')) {
|
||||
remote = argument.substring('--remote='.length);
|
||||
} else if (argument.startsWith('-')) {
|
||||
throw FormatException("Unknown option '$argument'.");
|
||||
} else {
|
||||
positional.add(argument);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (remote.isEmpty) throw const FormatException('remote cannot be empty.');
|
||||
return _Options(positional, projectRoot, dryRun, noPush, remote);
|
||||
}
|
||||
|
||||
final class _Options {
|
||||
const _Options(
|
||||
this.positional,
|
||||
this.projectRoot,
|
||||
this.dryRun,
|
||||
this.noPush,
|
||||
this.remote,
|
||||
);
|
||||
|
||||
final List<String> positional;
|
||||
final String? projectRoot;
|
||||
final bool dryRun;
|
||||
final bool noPush;
|
||||
final String remote;
|
||||
|
||||
bool get hasTagOptions => dryRun || noPush || remote != 'origin';
|
||||
}
|
||||
|
||||
String get _defaultProjectRoot =>
|
||||
File.fromUri(Platform.script).parent.parent.path;
|
||||
|
||||
const _usage = '''Usage:
|
||||
dart scripts/version.dart current <pc|windows|macos|android>
|
||||
dart scripts/version.dart next <pc|windows|macos|android>
|
||||
dart scripts/version.dart build-number <MAJOR.MINOR.PATCH>
|
||||
dart scripts/version.dart tag <pc|windows|android|both> [--dry-run] [--no-push] [--remote=<name>]''';
|
||||
Reference in New Issue
Block a user