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:
Tizian 「ティツィアーン」 2026-10-19 21:12:31 +02:00
commit e65f61bdd1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 2334 additions and 12 deletions

View file

@ -0,0 +1,77 @@
import 'dart:io';
import 'package:test/test.dart';
import '../../apps/learn/release.dart';
import '../../tool/src/release/apps.dart';
import '../../tool/src/release/version.dart';
import 'fixtures.dart';
void main() {
test("an app's release names its files and tags by its id", () {
expect(testApp.tagPrefix, 'notes-v');
expect(testApp.pubspec, 'apps/notes/halkyon_notes_flutter/pubspec.yaml');
expect(testApp.instance, 'notes.halkyon.eu');
expect(
testApp.apkFile(const Version(0, 19, 0)),
'halkyon-notes-0.19.0.apk',
);
expect(
testApp.snapFile(const Version(0, 19, 0)),
'halkyon-notes_0.19.0_amd64.snap',
);
});
test('a configuration that would release something wrong is refused', () {
expect(testApp.problems(), isEmpty);
expect(
testApp.copyWith(id: 'Notes App').problems(),
contains('The id "Notes App" is not lower-case letters and digits.'),
);
expect(
testApp.copyWith(serverUrl: 'http://notes.halkyon.eu/').problems(),
contains("The server URL http://notes.halkyon.eu/ isn't HTTPS."),
);
expect(
testApp.copyWith(serverUrl: 'https://notes.halkyon.eu').problems(),
contains(
"The server URL https://notes.halkyon.eu doesn't end with a slash, "
'as the app is built with it.',
),
);
expect(
testApp
.copyWith(flutterPackage: 'apps/todo/halkyon_todo_flutter')
.problems(),
contains(
"The Flutter package apps/todo/halkyon_todo_flutter isn't in "
'apps/notes.',
),
);
expect(
testApp.copyWith(applicationId: 'notes').problems(),
contains("The application ID notes isn't one Android takes."),
);
});
test("Learn's release is registered, sound, and names files that exist", () {
expect(releasedApps['learn'], same(learnRelease));
expect(learnRelease.problems(), isEmpty);
// The test runs in the repository's root, as `dart test` does.
for (final path in [
learnRelease.pubspec,
learnRelease.android.certificateFile,
...learnRelease.releaseFiles,
]) {
expect(
FileSystemEntity.typeSync(path),
isNot(FileSystemEntityType.notFound),
reason: path,
);
}
expect(
File('${learnRelease.snap!.directory}/snapcraft.yaml').readAsStringSync(),
contains('name: ${learnRelease.snap!.name}\n'),
);
});
}

View file

@ -0,0 +1,34 @@
import '../../tool/src/release/app_release.dart';
/// An app as a test needs it: Learn's shape, under names of its own.
const testApp = AppRelease(
id: 'notes',
name: 'Halkyon Notes',
applicationId: 'app.halkyon.notes',
directory: 'apps/notes',
flutterPackage: 'apps/notes/halkyon_notes_flutter',
artifactName: 'halkyon-notes',
serverUrl: 'https://notes.halkyon.eu/',
forgejo: 'https://code.example.org',
repository: 'project-halkyon/halkyon',
android: AndroidSigning(
certificateFile: 'tool/release/notes-certificate.sha256',
keystoreVariable: 'HALKYON_NOTES_KEYSTORE',
defaultKeystore: '.local/share/halkyon-notes/android-release.p12',
aliasVariable: 'HALKYON_NOTES_KEY_ALIAS',
defaultAlias: 'halkyon-notes',
),
fdroid: FdroidListing(
script: 'tool/fdroid_repo.sh',
listing: 'tool/release/fdroid',
indexCertificateFile: 'tool/release/fdroid-index-certificate.sha256',
repositoryUrl: 'https://halkyon.app/fdroid/repo',
),
snap: SnapRelease(
name: 'halkyon-notes',
part: 'halkyon-notes',
directory: 'snap',
),
legacyTagPrefix: 'v',
formerPaths: ['halkyon_notes_flutter', 'halkyon_notes_server'],
);

View file

@ -0,0 +1,37 @@
import 'package:test/test.dart';
import '../../tool/src/release/log.dart';
import '../../tool/src/release/version.dart';
void main() {
test("git's log is read as commits with their bodies and files", () {
// As `git log --format=<logFormat> --name-only` writes it.
const output =
'\x1eaaa111\x1ffeat(app): one\x1fWhy it matters.\n\n'
'BREAKING CHANGE: older apps stop.\n\x1f\n\n'
'apps/notes/a.dart\npackages/halkyon_ui/b.dart\n'
'\x1ebbb222\x1fchore: merge\x1f\x1f\n'
'\x1eccc333\x1ffix: two\x1f\x1f\n\npubspec.lock\n';
final commits = parseLog(output);
expect(commits.map((commit) => commit.hash), [
'aaa111',
'bbb222',
'ccc333',
]);
expect(commits.first.subject, 'feat(app): one');
expect(isBreaking(commits.first), isTrue);
expect(commits.first.files, [
'apps/notes/a.dart',
'packages/halkyon_ui/b.dart',
]);
// A merge lists no files of its own.
expect(commits[1].files, isEmpty);
expect(commits[2].files, ['pubspec.lock']);
});
test('no commits, no output', () {
expect(parseLog(''), isEmpty);
});
}

View file

@ -0,0 +1,129 @@
import 'package:test/test.dart';
import '../../tool/src/release/app_release.dart';
import '../../tool/src/release/notes.dart';
import '../../tool/src/release/version.dart';
import 'fixtures.dart';
void main() {
const certificate =
'27e243fcbdc826ff98ab7dbb10436c98c3bea24b2a015e94e4cfc7d1fc2a006c';
const fingerprint =
'E8082788756F6B7E682AA22FCEAD24B53E2A8B0912AD66D65E8C2D4F07BB94B0';
const signer = '0123456789ABCDEF0123456789ABCDEF01234567';
String notes({
AppRelease app = testApp,
String? previousTag = 'notes-v0.18.0',
List<Commit> commits = const [],
}) => releaseNotes(
app: app,
version: const Version(0, 19, 0),
previousTag: previousTag,
commits: commits,
certificate: certificate,
fdroidFingerprint: fingerprint,
signer: signer,
);
test("a release's notes list its features and fixes, oldest first, "
'without their types and scopes', () {
final text = notes(
commits: const [
Commit(hash: 'a', subject: 'feat(app): tiles on the first start'),
Commit(hash: 'b', subject: 'docs: why'),
Commit(hash: 'c', subject: 'fix(sync)!: no lost rating'),
Commit(hash: 'd', subject: 'perf: faster library'),
Commit(hash: 'e', subject: 'feat: flags'),
],
);
expect(
text,
startsWith(
'Halkyon Notes 0.19.0 for Android and Linux. Both connect to '
"notes.halkyon.eu, the project's instance.\n"
'\n'
'### Features\n'
'\n'
'- tiles on the first start\n'
'- flags\n'
'\n'
'### Fixes\n'
'\n'
'- no lost rating\n'
'- faster library\n'
'\n'
'### Installing\n',
),
);
});
test('they say how to install and check every file', () {
final text = notes(
commits: const [Commit(hash: 'a', subject: 'fix: x')],
);
expect(
text,
contains(
'- **Android:** `halkyon-notes-0.19.0.apk`, one APK for every phone, '
"signed with the app's key; the SHA-256 of its certificate is "
'`$certificate`.\n',
),
);
expect(
text,
contains(
'- **F-Droid:** the same APK in the repository '
'https://halkyon.app/fdroid/repo?fingerprint=$fingerprint\n',
),
);
expect(
text,
contains(
'- **Linux:** `halkyon-notes_0.19.0_amd64.snap`: `sudo snap install '
'--dangerous halkyon-notes_0.19.0_amd64.snap` (not in the Snap Store '
'yet).\n',
),
);
expect(
text,
endsWith(
"`SHA256SUMS` lists the files' checksums, signed with the GPG key "
'`$signer` (`SHA256SUMS.asc`).',
),
);
});
test('a snap in the Store is installed from its channel', () {
final text = notes(
app: testApp.copyWith(
snap: const SnapRelease(
name: 'halkyon-notes',
part: 'halkyon-notes',
directory: 'snap',
storeChannel: 'edge',
),
),
);
expect(
text,
contains(
'- **Linux:** `halkyon-notes_0.19.0_amd64.snap`, also in the Snap '
'Store: `snap install halkyon-notes --edge`.\n',
),
);
});
test('the first release says so, one without features or fixes too', () {
expect(notes(previousTag: null), contains('\n\nThe first release.\n\n'));
expect(
notes(
commits: const [Commit(hash: 'a', subject: 'docs: x')],
),
contains('\n\nNo features or fixes since notes-v0.18.0.\n\n'),
);
});
}

View file

@ -0,0 +1,128 @@
import 'package:test/test.dart';
import '../../tool/src/release/relevance.dart';
import '../../tool/src/release/version.dart';
import 'fixtures.dart';
void main() {
const rootPubspec = '''
name: _
workspace:
- apps/notes/halkyon_notes_client
- apps/notes/halkyon_notes_flutter
- apps/notes/halkyon_notes_server
- apps/todo/halkyon_todo_flutter
- packages/halkyon_app
- packages/halkyon_base_client
- packages/halkyon_base_server
- packages/halkyon_core
- packages/halkyon_ui
- packages/halkyon_ui/example
''';
const pubspecs = {
'apps/notes/halkyon_notes_client': '''
name: halkyon_notes_client
dependencies:
halkyon_base_client:
path: ../../../packages/halkyon_base_client
''',
'apps/notes/halkyon_notes_flutter': '''
name: halkyon_notes_flutter
dependencies:
halkyon_app:
path: ../../../packages/halkyon_app
halkyon_notes_client:
path: ../halkyon_notes_client
intl: ^0.20.2
dev_dependencies:
halkyon_ui_example:
path: ../../../packages/halkyon_ui/example
''',
'apps/notes/halkyon_notes_server': '''
name: halkyon_notes_server
dependencies:
halkyon_base_server:
path: ../../../packages/halkyon_base_server
''',
'apps/todo/halkyon_todo_flutter': '''
name: halkyon_todo_flutter
dependencies:
halkyon_core:
path: ../../../packages/halkyon_core
''',
'packages/halkyon_app': '''
name: halkyon_app
dependencies:
halkyon_ui:
path: ../halkyon_ui
halkyon_base_client:
path: ../halkyon_base_client
''',
'packages/halkyon_base_client': 'name: halkyon_base_client\n',
'packages/halkyon_base_server': 'name: halkyon_base_server\n',
'packages/halkyon_core': 'name: halkyon_core\n',
'packages/halkyon_ui': 'name: halkyon_ui\n',
'packages/halkyon_ui/example': 'name: halkyon_ui_example\n',
};
final paths = relevantPaths(
testApp,
rootPubspec: rootPubspec,
pubspecs: pubspecs,
);
test("an app's changes are its folder's, those of the shared packages it "
'uses, the workspace and its release files', () {
expect(paths, [
'apps/notes',
'packages/halkyon_app',
'packages/halkyon_base_client',
'packages/halkyon_base_server',
// Through halkyon_app, by path from there.
'packages/halkyon_ui',
'pubspec.yaml',
'pubspec.lock',
'tool/release/notes-certificate.sha256',
'tool/release/fdroid',
'tool/release/fdroid-index-certificate.sha256',
'snap',
// Where its packages were before it moved into its folder.
'halkyon_notes_flutter',
'halkyon_notes_server',
]);
// Neither another app's packages, nor one only the app's tests use.
expect(paths, isNot(contains('packages/halkyon_core')));
expect(paths, isNot(contains('packages/halkyon_ui/example')));
});
test('a commit counts when it changes one of them, and says which', () {
const ours = Commit(
hash: 'a',
subject: 'feat(app): x',
files: [
'packages/halkyon_ui/lib/src/theme/brand.dart',
'apps/notes/halkyon_notes_flutter/lib/main.dart',
'apps/todo/halkyon_todo_flutter/lib/main.dart',
],
);
expect(touchedPaths(ours.files, paths), [
'apps/notes',
'packages/halkyon_ui',
]);
// A package's name starting like another's isn't that one.
expect(
touchedPaths(['packages/halkyon_ui_extras/lib/a.dart'], paths),
isEmpty,
);
expect(touchedPaths(['apps/notes-web/index.html'], paths), isEmpty);
expect(touchedPaths(['packages/halkyon_core/lib/a.dart'], paths), isEmpty);
expect(touchedPaths(['pubspec.lock'], paths), ['pubspec.lock']);
expect(touchedPaths(['snap/snapcraft.yaml'], paths), ['snap']);
expect(touchedPaths(['README.md', 'docs/adr/0015.md'], paths), isEmpty);
// A commit from before the move counts by where the app was then.
expect(touchedPaths(['halkyon_notes_server/lib/server.dart'], paths), [
'halkyon_notes_server',
]);
});
}

View file

@ -0,0 +1,65 @@
import 'package:test/test.dart';
import '../../tool/src/release/tags.dart';
import '../../tool/src/release/version.dart';
import 'fixtures.dart';
void main() {
test("an app's tags carry its name", () {
expect(tagOf(testApp, const Version(0, 19, 0)), 'notes-v0.19.0');
expect(tagPatterns(testApp), ['notes-v[0-9]*', 'v[0-9]*']);
});
test("the tags of its history count as well as its own, another app's "
'not', () {
expect(versionOfTag(testApp, 'notes-v0.19.0'), const Version(0, 19, 0));
expect(versionOfTag(testApp, 'v0.17.0'), const Version(0, 17, 0));
expect(versionOfTag(testApp, 'todo-v1.0.0'), isNull);
expect(versionOfTag(testApp, 'notes-v0.19'), isNull);
});
group('a run carrying on with its release commit and tag', () {
String? refusal({
String tag = 'notes-v0.19.0',
Version declared = const Version(0, 19, 0),
bool originMainIsAncestor = true,
int aheadOfOriginMain = 1,
Bump? chosen,
}) => resumeRefusal(
app: testApp,
resumedTag: tag,
declared: declared,
originMainIsAncestor: originMainIsAncestor,
aheadOfOriginMain: aheadOfOriginMain,
chosen: chosen,
);
test('goes on when HEAD is the tag, at most the release commit ahead', () {
expect(refusal(), isNull);
expect(refusal(aheadOfOriginMain: 0), isNull);
});
test('stops when main and origin/main went apart, or more came', () {
expect(
refusal(originMainIsAncestor: false),
'main and origin/main have gone apart.',
);
expect(
refusal(aheadOfOriginMain: 2),
'main is more than the release commit ahead of origin/main.',
);
});
test('takes no other version than the tag says', () {
expect(
refusal(chosen: Bump.minor),
'HEAD is notes-v0.19.0 already; carrying on with it takes no argument.',
);
expect(
refusal(declared: const Version(0, 18, 0)),
'HEAD is notes-v0.19.0, but apps/notes/halkyon_notes_flutter/'
'pubspec.yaml says 0.18.0.',
);
});
});
}

View file

@ -0,0 +1,88 @@
import 'package:test/test.dart';
import '../../tool/src/release/version.dart';
void main() {
Commit commit(String subject, [String body = '']) =>
Commit(hash: 'c0ffee', subject: subject, body: body);
group('a version', () {
test('reads as semantic versioning writes it, and nothing else', () {
expect(Version.tryParse('0.17.0'), const Version(0, 17, 0));
expect(Version.tryParse('1.2.3')?.toString(), '1.2.3');
for (final text in [
'1.2',
'1.2.3.4',
'v1.2.3',
'1.2.3-beta',
'',
'x.y.z',
]) {
expect(Version.tryParse(text), isNull, reason: text);
}
});
test('is raised by one part, the parts after it starting again', () {
const version = Version(1, 4, 2);
expect(version.bumped(Bump.major), const Version(2, 0, 0));
expect(version.bumped(Bump.minor), const Version(1, 5, 0));
expect(version.bumped(Bump.patch), const Version(1, 4, 3));
});
test("is read with its build from a pubspec's version line", () {
expect(declaredVersion('name: x\nversion: 0.17.0+20\n'), (
const Version(0, 17, 0),
20,
));
expect(declaredVersion('name: x\nversion: 0.17.0\n'), isNull);
expect(declaredVersion('name: x\n'), isNull);
});
});
group('the commits since the last release', () {
const beforeOne = Version(0, 17, 0);
const afterOne = Version(1, 2, 0);
test('a feature raises the minor number, a fix or speed-up the patch', () {
expect(bumpFor(beforeOne, [commit('feat(study): x')]), Bump.minor);
expect(bumpFor(beforeOne, [commit('feat: x')]), Bump.minor);
expect(bumpFor(beforeOne, [commit('fix(sync): x')]), Bump.patch);
expect(bumpFor(beforeOne, [commit('perf: x')]), Bump.patch);
expect(
bumpFor(beforeOne, [commit('fix: x'), commit('feat(app): y')]),
Bump.minor,
);
});
test('a breaking change raises the major number from 1.0 on, the minor '
'one before', () {
expect(bumpFor(afterOne, [commit('refactor(auth)!: x')]), Bump.major);
expect(bumpFor(beforeOne, [commit('refactor(auth)!: x')]), Bump.minor);
expect(
bumpFor(afterOne, [
commit('docs: x', 'Why.\n\nBREAKING CHANGE: older apps stop.'),
]),
Bump.major,
);
// Only at a line's start does it count.
expect(
bumpFor(afterOne, [commit('docs: x', 'No BREAKING CHANGE: here.')]),
isNull,
);
});
test('other types release nothing by themselves', () {
expect(
bumpFor(beforeOne, [
commit('docs: x'),
commit('chore(release): learn-v0.18.0'),
commit('build: y'),
commit('Merge branch z'),
commit('halkyon_app: an untyped subject'),
]),
isNull,
);
expect(bumpFor(beforeOne, const []), isNull);
});
});
}