303 lines
8.9 KiB
Dart
303 lines
8.9 KiB
Dart
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>]''';
|