Files

303 lines
8.9 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
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>]''';