build(release): a Dart release tool for every app
tool/release.dart releases one app: `dart run tool/release.dart learn [major | minor | patch] [--dry-run]`, each app configured in Dart beside it (apps/learn/release.dart). Every step and check of tool/release.sh is kept: the repository as pushed, resuming after a release commit and tag, Flutter as CI pins it, the tools, the key and its certificate pin, secrets asked for without echo and never written down, CI green on the newest commit, the signed release commit and tag by a key deploys accept, the APK built from a checkout of the tag and checked, the snap, the signed checksums, pushing only after both builds passed, the release on Forgejo, the F-Droid repository, the Snap Store once a channel is set. New: tags per app (learn-v0.19.0, Learn's v… counting as its history); only commits touching the app decide its version (its folder, the shared packages it uses by path, the root pubspec and lock, its release files, where its packages were before the move); --dry-run reads all of it, lists every step and the notes, and what a release would refuse, changing nothing. The pure parts are tested in test/release/. Learn releases build 21 with tool/release.sh, then switches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1b62c8098c
commit
e65f61bdd1
9 changed files with 2334 additions and 12 deletions
103
tool/release.dart
Normal file
103
tool/release.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// Releases one of Halkyon's apps (ADR 0015): works out the next version from
|
||||
// the commits touching the app since its last release, writes it into the
|
||||
// app's pubspec in a signed commit with a signed tag (`learn-v0.19.0`), builds
|
||||
// the Android APK (signed with the app's key, from a checkout of the tag) and
|
||||
// the Linux snap, checks them, signs a list of their checksums with GPG,
|
||||
// pushes, and publishes: a release on code.halkyon.app, the APK in the
|
||||
// project's F-Droid repository, and, once a channel is set, the snap in the
|
||||
// Snap Store. Run on a developer's laptop, never in CI: the app's key never
|
||||
// leaves it. Each app's configuration is beside it (`apps/learn/release.dart`).
|
||||
//
|
||||
// dart run tool/release.dart learn the version the commits call for
|
||||
// dart run tool/release.dart learn minor or major, or patch: that one
|
||||
// dart run tool/release.dart learn --dry-run what it would do, changing nothing
|
||||
//
|
||||
// Versions follow semantic versioning, with a build number (+N) that rises by
|
||||
// one with every release: Android installs only a higher one over an older,
|
||||
// and the server tells apps below the build it supports to update. Since the
|
||||
// app's last release (its own tags, `learn-v…`, and its history's, `v…`), only
|
||||
// commits touching it count (its folder, the shared packages it uses, the
|
||||
// workspace's pubspec and lock, its release files): a feat raises the minor
|
||||
// number, a fix or perf the patch number, a breaking one (`!` or a
|
||||
// `BREAKING CHANGE:` line) the major number from 1.0 on, the minor before.
|
||||
// Other types release nothing by themselves. Raising minimumAppBuild in the
|
||||
// server, for changes older apps can't follow, stays a decision of its own.
|
||||
//
|
||||
// It shows the version and its commits and asks before it changes anything.
|
||||
// It needs main clean and as pushed, CI green on its newest commit, and asks
|
||||
// for the app key's password, the F-Droid repository key's password and a
|
||||
// Forgejo token (scope write:repository) first; then it runs on its own until
|
||||
// GPG asks for its passphrase (and ssh for the upload key's, unless an agent
|
||||
// holds them). None of them is ever written down. Nothing is pushed or
|
||||
// published before both builds passed their checks; a run that stopped after
|
||||
// making the release commit and tag carries on with them when started again.
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:args/args.dart';
|
||||
|
||||
import 'src/release/apps.dart';
|
||||
import 'src/release/releaser.dart';
|
||||
import 'src/release/version.dart';
|
||||
|
||||
Future<void> main(List<String> arguments) async {
|
||||
final parser = ArgParser()
|
||||
..addFlag(
|
||||
'dry-run',
|
||||
negatable: false,
|
||||
help:
|
||||
'Show the version, the commits that count and every step a release '
|
||||
'would take, changing nothing and asking for no secret.',
|
||||
)
|
||||
..addFlag('help', abbr: 'h', negatable: false);
|
||||
final usage =
|
||||
'Usage: dart run tool/release.dart <app> [major | minor | patch] '
|
||||
'[--dry-run]\nApps: ${releasedApps.keys.join(', ')}\n${parser.usage}';
|
||||
|
||||
final ArgResults options;
|
||||
try {
|
||||
options = parser.parse(arguments);
|
||||
} on FormatException catch (error) {
|
||||
stderr.writeln('✗ ${error.message}\n$usage');
|
||||
exit(64);
|
||||
}
|
||||
final rest = options.rest;
|
||||
if (options.flag('help') || rest.isEmpty || rest.length > 2) {
|
||||
(options.flag('help') ? stdout : stderr).writeln(usage);
|
||||
exit(options.flag('help') ? 0 : 64);
|
||||
}
|
||||
|
||||
final app = releasedApps[rest.first];
|
||||
if (app == null) {
|
||||
stderr.writeln(
|
||||
'✗ No app "${rest.first}": one of ${releasedApps.keys.join(', ')}.',
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
final Bump? chosen;
|
||||
if (rest.length == 2) {
|
||||
chosen = Bump.values.asNameMap()[rest[1]];
|
||||
if (chosen == null) {
|
||||
stderr.writeln('✗ ${rest[1]} is none of major, minor, patch.');
|
||||
exit(1);
|
||||
}
|
||||
} else {
|
||||
chosen = null;
|
||||
}
|
||||
|
||||
final top = await Process.run('git', ['rev-parse', '--show-toplevel']);
|
||||
if (top.exitCode != 0) {
|
||||
stderr.writeln('✗ Not in the repository.');
|
||||
exit(1);
|
||||
}
|
||||
try {
|
||||
await Releaser(
|
||||
app: app,
|
||||
root: Directory('${top.stdout}'.trim()),
|
||||
chosen: chosen,
|
||||
dryRun: options.flag('dry-run'),
|
||||
).run();
|
||||
} on ReleaseFailure catch (failure) {
|
||||
stderr.writeln('✗ $failure');
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
201
tool/src/release/app_release.dart
Normal file
201
tool/src/release/app_release.dart
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/// How an app is released: what `tool/release.dart` reads of it.
|
||||
library;
|
||||
|
||||
import 'version.dart';
|
||||
|
||||
/// Everything a release of one app names: its identity, where its Flutter
|
||||
/// package is, the instance its builds connect to, where it is published and
|
||||
/// the files that pin its keys. Each app's lives beside it
|
||||
/// (`apps/<id>/release.dart`) and is registered in `apps.dart`.
|
||||
final class const AppRelease({
|
||||
/// The app's short name: the release tool's argument and its tags' prefix
|
||||
/// (`learn` makes `learn-v0.19.0`). Never change it once released.
|
||||
required final String id,
|
||||
|
||||
/// Its name as people know it, in the release's title and notes.
|
||||
required final String name,
|
||||
|
||||
/// Its Android application ID, which every APK must carry.
|
||||
required final String applicationId,
|
||||
|
||||
/// Its folder, relative to the repository's root: commits changing it count
|
||||
/// for its next version.
|
||||
required final String directory,
|
||||
|
||||
/// Its Flutter package, whose pubspec holds the version and which the APK is
|
||||
/// built from.
|
||||
required final String flutterPackage,
|
||||
|
||||
/// The stem of the files a release publishes (`halkyon-learn` makes
|
||||
/// `halkyon-learn-0.19.0.apk`).
|
||||
required final String artifactName,
|
||||
|
||||
/// The instance the builds connect to (`--dart-define=SERVER_URL`), with
|
||||
/// its trailing slash, as the app takes it.
|
||||
required final String serverUrl,
|
||||
|
||||
/// The Forgejo the repository and its releases are on.
|
||||
required final String forgejo,
|
||||
|
||||
/// The repository on [forgejo], `<owner>/<name>`.
|
||||
required final String repository,
|
||||
|
||||
/// The key the APK is signed with, and the file pinning its certificate.
|
||||
required final AndroidSigning android,
|
||||
|
||||
/// Its listing in the project's F-Droid repository, if it has one.
|
||||
final FdroidListing? fdroid,
|
||||
|
||||
/// Its snap, if it has one.
|
||||
final SnapRelease? snap,
|
||||
|
||||
/// The prefix of the app's tags from before tags carried its id (`v` for
|
||||
/// Learn's `v0.17.0`): they count as its releases too.
|
||||
final String? legacyTagPrefix,
|
||||
|
||||
/// Where the app's packages were before it moved into [directory] (Learn's
|
||||
/// at the root until 19 Oct 2026): a commit from then counts by them.
|
||||
final List<String> formerPaths = const [],
|
||||
}) {
|
||||
/// The prefix of the app's tags: `learn-v`.
|
||||
String get tagPrefix => '$id-v';
|
||||
|
||||
/// The pubspec holding the app's version and build.
|
||||
String get pubspec => '$flutterPackage/pubspec.yaml';
|
||||
|
||||
/// The instance's host, as the release notes name it.
|
||||
String get instance => Uri.parse(serverUrl).host;
|
||||
|
||||
/// The APK a release of [version] publishes.
|
||||
String apkFile(Version version) => '$artifactName-$version.apk';
|
||||
|
||||
/// The snap a release of [version] publishes.
|
||||
String snapFile(Version version) => '${snap?.name}_${version}_amd64.snap';
|
||||
|
||||
/// The files besides the app's folder that make a release what it is, so
|
||||
/// that a commit changing one counts for the next version: the key pins, the
|
||||
/// F-Droid listing, the snap's recipe.
|
||||
List<String> get releaseFiles => [
|
||||
android.certificateFile,
|
||||
if (fdroid case final fdroid?) ...[
|
||||
fdroid.listing,
|
||||
fdroid.indexCertificateFile,
|
||||
],
|
||||
if (snap case final snap?) snap.directory,
|
||||
];
|
||||
|
||||
/// What is wrong with this configuration, empty when nothing is.
|
||||
List<String> problems() {
|
||||
final problems = <String>[];
|
||||
if (!RegExp(r'^[a-z][a-z0-9]*$').hasMatch(id)) {
|
||||
problems.add('The id "$id" is not lower-case letters and digits.');
|
||||
}
|
||||
if (!RegExp(r'^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$')
|
||||
.hasMatch(applicationId)) {
|
||||
problems.add(
|
||||
"The application ID $applicationId isn't one Android takes.",
|
||||
);
|
||||
}
|
||||
if (!flutterPackage.startsWith('$directory/')) {
|
||||
problems.add("The Flutter package $flutterPackage isn't in $directory.");
|
||||
}
|
||||
if (!serverUrl.startsWith('https://')) {
|
||||
problems.add("The server URL $serverUrl isn't HTTPS.");
|
||||
}
|
||||
if (!serverUrl.endsWith('/')) {
|
||||
problems.add(
|
||||
"The server URL $serverUrl doesn't end with a slash, as the app is "
|
||||
'built with it.',
|
||||
);
|
||||
}
|
||||
if (!RegExp(r'^[^/\s]+/[^/\s]+$').hasMatch(repository)) {
|
||||
problems.add("The repository $repository isn't <owner>/<name>.");
|
||||
}
|
||||
if (legacyTagPrefix case final legacy? when legacy.startsWith(tagPrefix)) {
|
||||
problems.add(
|
||||
"The legacy tag prefix $legacy would match the app's own tags.",
|
||||
);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/// This configuration with the values given replaced.
|
||||
AppRelease copyWith({
|
||||
String? id,
|
||||
String? applicationId,
|
||||
String? flutterPackage,
|
||||
String? serverUrl,
|
||||
SnapRelease? snap,
|
||||
}) => AppRelease(
|
||||
id: id ?? this.id,
|
||||
name: name,
|
||||
applicationId: applicationId ?? this.applicationId,
|
||||
directory: directory,
|
||||
flutterPackage: flutterPackage ?? this.flutterPackage,
|
||||
artifactName: artifactName,
|
||||
serverUrl: serverUrl ?? this.serverUrl,
|
||||
forgejo: forgejo,
|
||||
repository: repository,
|
||||
android: android,
|
||||
fdroid: fdroid,
|
||||
snap: snap ?? this.snap,
|
||||
legacyTagPrefix: legacyTagPrefix,
|
||||
formerPaths: formerPaths,
|
||||
);
|
||||
}
|
||||
|
||||
/// The app's Android key: a PKCS #12 keystore outside the repository, and the
|
||||
/// file pinning its certificate's SHA-256 (nothing signed otherwise is
|
||||
/// released).
|
||||
final class const AndroidSigning({
|
||||
/// The file, relative to the root, holding the certificate's SHA-256 as
|
||||
/// keytool prints it.
|
||||
required final String certificateFile,
|
||||
|
||||
/// The environment variable naming the keystore, if not the default.
|
||||
required final String keystoreVariable,
|
||||
|
||||
/// The keystore's default path, relative to the home folder.
|
||||
required final String defaultKeystore,
|
||||
|
||||
/// The environment variable naming the key's alias, if not the default.
|
||||
required final String aliasVariable,
|
||||
|
||||
/// The key's default alias.
|
||||
required final String defaultAlias,
|
||||
});
|
||||
|
||||
/// The app's listing in the project's F-Droid repository, which
|
||||
/// [script] (Bash: glue for fdroidserver) updates and uploads.
|
||||
final class const FdroidListing({
|
||||
/// The script that checks the repository's tools and key (`--check`) and
|
||||
/// publishes an APK in it.
|
||||
required final String script,
|
||||
|
||||
/// The repository's settings and the app's listing.
|
||||
required final String listing,
|
||||
|
||||
/// The file pinning the SHA-256 of the certificate signing the
|
||||
/// repository's indexes.
|
||||
required final String indexCertificateFile,
|
||||
|
||||
/// The repository's address, as the release notes name it.
|
||||
required final String repositoryUrl,
|
||||
});
|
||||
|
||||
/// The app's snap, built from [directory]'s `snapcraft.yaml` at the root.
|
||||
final class const SnapRelease({
|
||||
/// The snap's name.
|
||||
required final String name,
|
||||
|
||||
/// The part building the app, cleaned before each release so it is built
|
||||
/// anew from the tagged commit.
|
||||
required final String part,
|
||||
|
||||
/// The folder holding `snapcraft.yaml`.
|
||||
required final String directory,
|
||||
|
||||
/// The Snap Store channel the snap goes to (`edge`); null: not to the Store
|
||||
/// yet, only attached to the release.
|
||||
final String? storeChannel,
|
||||
});
|
||||
10
tool/src/release/apps.dart
Normal file
10
tool/src/release/apps.dart
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/// The apps `tool/release.dart` releases, by their ids.
|
||||
library;
|
||||
|
||||
import '../../../apps/learn/release.dart';
|
||||
import 'app_release.dart';
|
||||
|
||||
/// Every app with a release configuration, by the id the tool takes. A new
|
||||
/// app's configuration lives beside it (`apps/<id>/release.dart`) and is
|
||||
/// added here.
|
||||
final releasedApps = <String, AppRelease>{learnRelease.id: learnRelease};
|
||||
29
tool/src/release/log.dart
Normal file
29
tool/src/release/log.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/// Reading git's log as commits.
|
||||
library;
|
||||
|
||||
import 'version.dart';
|
||||
|
||||
/// The format [parseLog] reads, for `git log --format=<it> --name-only`:
|
||||
/// each commit's hash, subject and body between separators no message holds,
|
||||
/// the files it changed after them.
|
||||
const logFormat = '%x1e%H%x1f%s%x1f%b%x1f';
|
||||
|
||||
/// The commits in [output], as `git log --format=$logFormat --name-only`
|
||||
/// writes them, in its order.
|
||||
List<Commit> parseLog(String output) => [
|
||||
for (final record in output.split('\x1e'))
|
||||
if (record.trim().isNotEmpty) _commit(record),
|
||||
];
|
||||
|
||||
Commit _commit(String record) {
|
||||
final fields = record.split('\x1f');
|
||||
return Commit(
|
||||
hash: fields[0].trim(),
|
||||
subject: fields[1],
|
||||
body: fields[2],
|
||||
files: [
|
||||
for (final line in fields.length > 3 ? fields[3].split('\n') : <String>[])
|
||||
if (line.trim().isNotEmpty) line.trim(),
|
||||
],
|
||||
);
|
||||
}
|
||||
74
tool/src/release/notes.dart
Normal file
74
tool/src/release/notes.dart
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/// The notes a release is published with on Forgejo.
|
||||
library;
|
||||
|
||||
import 'app_release.dart';
|
||||
import 'version.dart';
|
||||
|
||||
/// What the release of [version] of [app] says: what it brings since
|
||||
/// [previousTag] (the features and fixes among [commits], oldest first, by
|
||||
/// their descriptions), how to install each file, and how to check them.
|
||||
String releaseNotes({
|
||||
required AppRelease app,
|
||||
required Version version,
|
||||
required String? previousTag,
|
||||
required List<Commit> commits,
|
||||
required String certificate,
|
||||
required String? fdroidFingerprint,
|
||||
required String signer,
|
||||
}) {
|
||||
List<String> changes(String types) => [
|
||||
for (final commit in commits)
|
||||
if (RegExp('^($types)(\\([^)]*\\))?!?: (.*)').firstMatch(commit.subject)
|
||||
case final match?)
|
||||
'- ${match.group(3)}',
|
||||
];
|
||||
final features = changes('feat');
|
||||
final fixes = changes('fix|perf');
|
||||
final summary = previousTag == null
|
||||
? 'The first release.'
|
||||
: features.isEmpty && fixes.isEmpty
|
||||
? 'No features or fixes since $previousTag.'
|
||||
: [
|
||||
if (features.isNotEmpty) '### Features\n\n${features.join('\n')}',
|
||||
if (fixes.isNotEmpty) '### Fixes\n\n${fixes.join('\n')}',
|
||||
].join('\n\n');
|
||||
final apk = app.apkFile(version);
|
||||
final snap = app.snap;
|
||||
final snapFile = app.snapFile(version);
|
||||
final linux = switch (snap?.storeChannel) {
|
||||
final channel? when channel.isNotEmpty =>
|
||||
'`$snapFile`, also in the Snap Store: '
|
||||
'`snap install ${snap!.name} --$channel`.',
|
||||
_ =>
|
||||
'`$snapFile`: `sudo snap install --dangerous $snapFile` (not in the '
|
||||
'Snap Store yet).',
|
||||
};
|
||||
final title =
|
||||
'${app.name} $version for Android${snap == null ? '' : ' and Linux'}. '
|
||||
'${snap == null ? 'It connects' : 'Both connect'} to ${app.instance}, '
|
||||
"the project's instance.";
|
||||
final android =
|
||||
'- **Android:** `$apk`, one APK for every phone, signed with the '
|
||||
"app's key; the SHA-256 of its certificate is `$certificate`.";
|
||||
final fdroid = switch (app.fdroid) {
|
||||
final fdroid? when fdroidFingerprint != null =>
|
||||
'- **F-Droid:** the same APK in the repository '
|
||||
'${fdroid.repositoryUrl}?fingerprint=$fdroidFingerprint',
|
||||
_ => null,
|
||||
};
|
||||
final checksums =
|
||||
"`SHA256SUMS` lists the files' checksums, signed with the GPG key "
|
||||
'`$signer` (`SHA256SUMS.asc`).';
|
||||
return [
|
||||
title,
|
||||
summary,
|
||||
[
|
||||
'### Installing',
|
||||
'',
|
||||
android,
|
||||
?fdroid,
|
||||
if (snap != null) '- **Linux:** $linux',
|
||||
].join('\n'),
|
||||
checksums,
|
||||
].join('\n\n');
|
||||
}
|
||||
1034
tool/src/release/releaser.dart
Normal file
1034
tool/src/release/releaser.dart
Normal file
File diff suppressed because it is too large
Load diff
102
tool/src/release/relevance.dart
Normal file
102
tool/src/release/relevance.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/// Which changes count for an app's next version (decided 2026-10-19): only
|
||||
/// commits touching it, so a Todo feature never raises Learn's version.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
import 'app_release.dart';
|
||||
|
||||
/// The paths, relative to the root, whose changes count for [app]: its
|
||||
/// folder, every shared package its packages use by path (transitively,
|
||||
/// their runtime dependencies), the workspace's pubspec and lock, its
|
||||
/// release files, and where its packages were before they moved. [rootPubspec] is the root's pubspec, [pubspecs] each
|
||||
/// workspace member's by its folder.
|
||||
List<String> relevantPaths(
|
||||
AppRelease app, {
|
||||
required String rootPubspec,
|
||||
required Map<String, String> pubspecs,
|
||||
}) {
|
||||
final members = _workspace(rootPubspec);
|
||||
final appPackages = members.where(
|
||||
(member) => p.posix.isWithin(app.directory, member),
|
||||
);
|
||||
final used = <String>{};
|
||||
final pending = [...appPackages];
|
||||
while (pending.isNotEmpty) {
|
||||
final package = pending.removeLast();
|
||||
for (final dependency in _pathDependencies(package, pubspecs[package])) {
|
||||
if (used.add(dependency)) {
|
||||
pending.add(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
final shared =
|
||||
used
|
||||
.where((package) => !p.posix.isWithin(app.directory, package))
|
||||
.toList()
|
||||
..sort();
|
||||
return [
|
||||
app.directory,
|
||||
...shared,
|
||||
'pubspec.yaml',
|
||||
'pubspec.lock',
|
||||
...app.releaseFiles,
|
||||
...app.formerPaths,
|
||||
];
|
||||
}
|
||||
|
||||
/// [relevantPaths] read from the pubspecs under [root].
|
||||
List<String> readRelevantPaths(AppRelease app, Directory root) {
|
||||
String read(String path) => File(p.join(root.path, path)).readAsStringSync();
|
||||
final rootPubspec = read('pubspec.yaml');
|
||||
return relevantPaths(
|
||||
app,
|
||||
rootPubspec: rootPubspec,
|
||||
pubspecs: {
|
||||
for (final member in _workspace(rootPubspec))
|
||||
member: read('$member/pubspec.yaml'),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Which of [paths] the changed [files] fall under: a path itself, or a file
|
||||
/// inside the folder it names.
|
||||
List<String> touchedPaths(Iterable<String> files, List<String> paths) => [
|
||||
for (final path in paths)
|
||||
if (files.any((file) => file == path || p.posix.isWithin(path, file))) path,
|
||||
];
|
||||
|
||||
/// The members the root pubspec's workspace lists.
|
||||
List<String> _workspace(String rootPubspec) {
|
||||
final yaml = loadYaml(rootPubspec);
|
||||
if (yaml is! YamlMap || yaml['workspace'] is! YamlList) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final member in yaml['workspace'] as YamlList)
|
||||
p.posix.normalize(member.toString()),
|
||||
];
|
||||
}
|
||||
|
||||
/// The folders [package]'s pubspec depends on by path, among its runtime
|
||||
/// dependencies (what only its tests use doesn't make a release), relative
|
||||
/// to the root.
|
||||
Iterable<String> _pathDependencies(String package, String? pubspec) sync* {
|
||||
if (pubspec == null) {
|
||||
return;
|
||||
}
|
||||
final yaml = loadYaml(pubspec);
|
||||
if (yaml is! YamlMap || yaml['dependencies'] is! YamlMap) {
|
||||
return;
|
||||
}
|
||||
for (final dependency in (yaml['dependencies'] as YamlMap).values) {
|
||||
if (dependency is YamlMap && dependency['path'] is String) {
|
||||
yield p.posix.normalize(
|
||||
p.posix.join(package, dependency['path'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
tool/src/release/tags.dart
Normal file
53
tool/src/release/tags.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/// An app's release tags: `learn-v0.19.0`, its history's `v0.17.0` too.
|
||||
library;
|
||||
|
||||
import 'app_release.dart';
|
||||
import 'version.dart';
|
||||
|
||||
/// The tag a release of [version] of [app] gets.
|
||||
String tagOf(AppRelease app, Version version) => '${app.tagPrefix}$version';
|
||||
|
||||
/// The patterns of [app]'s tags for `git describe --match`, its own and its
|
||||
/// history's.
|
||||
List<String> tagPatterns(AppRelease app) => [
|
||||
'${app.tagPrefix}[0-9]*',
|
||||
if (app.legacyTagPrefix case final legacy?) '$legacy[0-9]*',
|
||||
];
|
||||
|
||||
/// The version [tag] releases of [app], or null when it is none of its tags.
|
||||
Version? versionOfTag(AppRelease app, String tag) {
|
||||
for (final prefix in [app.tagPrefix, ?app.legacyTagPrefix]) {
|
||||
if (tag.startsWith(prefix)) {
|
||||
return Version.tryParse(tag.substring(prefix.length));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Why a run can't carry on with the release commit and tag
|
||||
/// ([resumedTag], HEAD) of a run that stopped, or null when it can: HEAD as
|
||||
/// pushed or the release commit ahead, no other version asked for, and the
|
||||
/// pubspec declaring the tag's [declared] version.
|
||||
String? resumeRefusal({
|
||||
required AppRelease app,
|
||||
required String resumedTag,
|
||||
required Version declared,
|
||||
required bool originMainIsAncestor,
|
||||
required int aheadOfOriginMain,
|
||||
required Bump? chosen,
|
||||
}) {
|
||||
if (!originMainIsAncestor) {
|
||||
return 'main and origin/main have gone apart.';
|
||||
}
|
||||
if (aheadOfOriginMain > 1) {
|
||||
return 'main is more than the release commit ahead of origin/main.';
|
||||
}
|
||||
if (chosen != null) {
|
||||
return 'HEAD is $resumedTag already; carrying on with it takes no '
|
||||
'argument.';
|
||||
}
|
||||
if (resumedTag != tagOf(app, declared)) {
|
||||
return 'HEAD is $resumedTag, but ${app.pubspec} says $declared.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
97
tool/src/release/version.dart
Normal file
97
tool/src/release/version.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/// Versions and the commits that decide them, as releases read them.
|
||||
library;
|
||||
|
||||
/// A version, `<major>.<minor>.<patch>`, as semantic versioning has it.
|
||||
final class const Version(final int major, final int minor, final int patch) {
|
||||
/// [text] as a version, or null when it is none (no prefix, no suffix).
|
||||
static Version? tryParse(String text) {
|
||||
final match = RegExp(r'^(\d+)\.(\d+)\.(\d+)$').firstMatch(text);
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
return Version(
|
||||
int.parse(match.group(1)!),
|
||||
int.parse(match.group(2)!),
|
||||
int.parse(match.group(3)!),
|
||||
);
|
||||
}
|
||||
|
||||
/// This version raised by [bump], the parts after it starting again.
|
||||
Version bumped(Bump bump) => switch (bump) {
|
||||
Bump.major => Version(major + 1, 0, 0),
|
||||
Bump.minor => Version(major, minor + 1, 0),
|
||||
Bump.patch => Version(major, minor, patch + 1),
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Version &&
|
||||
other.major == major &&
|
||||
other.minor == minor &&
|
||||
other.patch == patch;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(major, minor, patch);
|
||||
|
||||
@override
|
||||
String toString() => '$major.$minor.$patch';
|
||||
}
|
||||
|
||||
/// Which part of a version a release raises.
|
||||
enum Bump() {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
}
|
||||
|
||||
/// The version and build number a pubspec declares (`version: 0.17.0+20`),
|
||||
/// or null when its version line isn't `<major>.<minor>.<patch>+<build>`.
|
||||
(Version, int)? declaredVersion(String pubspec) {
|
||||
final match = RegExp(
|
||||
r'^version:\s*(\d+\.\d+\.\d+)\+(\d+)\s*$',
|
||||
multiLine: true,
|
||||
).firstMatch(pubspec);
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
return (Version.tryParse(match.group(1)!)!, int.parse(match.group(2)!));
|
||||
}
|
||||
|
||||
/// A commit, as far as a release reads it: its subject and body, and the
|
||||
/// files it changed (none for a merge).
|
||||
final class const Commit({
|
||||
required final String hash,
|
||||
required final String subject,
|
||||
final String body = '',
|
||||
final List<String> files = const [],
|
||||
});
|
||||
|
||||
/// A conventional commit's type and scope, as a subject starts with them.
|
||||
const _type = r'^[a-z]+(\([^)]*\))?';
|
||||
|
||||
/// Whether [commit] breaks what came before: `!` after its type, or a line of
|
||||
/// its body starting `BREAKING CHANGE:`.
|
||||
bool isBreaking(Commit commit) =>
|
||||
RegExp('$_type!:').hasMatch(commit.subject) ||
|
||||
RegExp('^BREAKING CHANGE:', multiLine: true).hasMatch(commit.body);
|
||||
|
||||
/// The part of [current] that [commits] raise: a breaking change the major
|
||||
/// number from 1.0 on and the minor one before, a feature the minor number, a
|
||||
/// fix or a speed-up the patch number; null when none of them is among them,
|
||||
/// as other types (docs, test, chore, …) release nothing by themselves.
|
||||
Bump? bumpFor(Version current, Iterable<Commit> commits) {
|
||||
if (commits.any(isBreaking)) {
|
||||
return current.major >= 1 ? Bump.major : Bump.minor;
|
||||
}
|
||||
if (commits.any(
|
||||
(commit) => RegExp(r'^feat(\([^)]*\))?:').hasMatch(commit.subject),
|
||||
)) {
|
||||
return Bump.minor;
|
||||
}
|
||||
if (commits.any(
|
||||
(commit) => RegExp(r'^(fix|perf)(\([^)]*\))?:').hasMatch(commit.subject),
|
||||
)) {
|
||||
return Bump.patch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Loading…
Reference in a new issue