build: Melos, a Dart check and every path for the single repository

- Melos 8.9 as the root's dev dependency, with scripts across the
  workspace (analyze, test:dart, test:flutter, generate, check) and its
  filters (--diff, --include-dependents); cli_util overridden to 0.5.2
  for it, since Learn's flutter_launcher_icons asks for 0.4 and uses only
  its logger, unchanged in 0.5.
- tool/check.dart in place of tool/check.sh: packages, formatting of our
  own files, analysis, the tools' tests, then every package's tests
  through Melos (--changed-since=<commit> for only what changed and what
  depends on it). tool/pre-push is the hook: the signature check, then
  the checks. CI generates every server's code and runs the same check.
- The images build from the root with a workspace narrowed to what they
  need (tool/workspace_only.sh); compose, the deploy's migration test,
  the snap, the release script, the editor's launch files and Learn's
  asset tools (run from apps/learn) follow the new layout.
- The project's source is named as project-halkyon/halkyon (source link,
  snap, F-Droid listing, release uploads); the AGPL packages carry the
  licence's own text.

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 20:39:09 +02:00
commit af1dbee509
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 3771 additions and 792 deletions

134
tool/check.dart Normal file
View file

@ -0,0 +1,134 @@
// The checks every push has to pass: the packages the lock names, formatting,
// static analysis (infos are fatal too), and every package's tests. The
// pre-push hook (tool/pre-push) runs it, and so does CI on every push
// (.forgejo/workflows/ci.yaml).
//
// dart run tool/check.dart everything
// dart run tool/check.dart --changed-since=origin/main
// the tests of what changed since then, and of what
// depends on it
//
// The tests run through Melos' scripts (`test:dart`, `test:flutter`, the root
// pubspec), Melos itself a dependency of the root.
// It checks the working tree, so uncommitted changes count as well.
import 'dart:io';
import 'package:args/args.dart';
import 'src/check_files.dart';
Future<void> main(List<String> arguments) async {
final options =
(ArgParser()
..addOption(
'changed-since',
help:
'Test only the packages changed since this commit, and those '
'depending on them.',
)
..addFlag('help', abbr: 'h', negatable: false))
.parse(arguments);
if (options.flag('help')) {
stdout.writeln(
'Usage: dart run tool/check.dart [--changed-since=<commit>]',
);
return;
}
final changedSince = options.option('changed-since');
Directory.current = (await _output('git', [
'rev-parse',
'--show-toplevel',
])).trim();
if ((await Process.run('git', ['diff', '--quiet', 'HEAD'])).exitCode != 0) {
stdout.writeln(
'Note: the working tree has uncommitted changes; they are checked too.',
);
}
// The packages the checked-out pubspecs and lock name, fetched first: after
// a merge or a switch of branch the workspace's resolution can still be the
// one from before, and the analysis then failed on packages it couldn't
// find (2026-10-13, 2026-10-17). The lock decides (--enforce-lockfile), so a
// check never changes it.
await _step('Packages', [
('flutter', ['pub', 'get', '--enforce-lockfile']),
], quiet: true);
final own = (await _output('git', [
'ls-files',
'*.dart',
])).split('\n').where(isOwnDartFile).toList();
await _step('Formatting', [
('dart', ['format', '--output=none', '--set-exit-if-changed', ...own]),
]);
await _step('Analysis', [
('dart', ['analyze', '--fatal-infos']),
]);
await _step('Tools', [
('dart', ['test', 'test/']),
]);
// The sync tests need `serverpod start` running (`requires-server`); run
// them by hand with `flutter test -t requires-server` in Learn's app.
final filters = melosFilters(changedSince: changedSince);
await _step('Tests', [
('dart', ['run', 'melos', 'run', 'test:dart', ...filters]),
('dart', ['run', 'melos', 'run', 'test:flutter', ...filters]),
]);
stdout.writeln('✓ All checks passed.');
}
/// Runs [commands] one after another as the step [name], and ends the check
/// at the first that fails, with its exit code.
Future<void> _step(
String name,
List<(String, List<String>)> commands, {
bool quiet = false,
}) async {
stdout.writeln('▸ $name');
for (final (executable, arguments) in commands) {
final code = await _run(executable, arguments, quiet: quiet);
if (code != 0) {
stderr.writeln('✗ $name failed ($executable ${arguments.first}).');
exit(code);
}
}
}
/// Runs [executable] and returns its exit code; its output is passed on as
/// it comes, or if [quiet] only should it fail.
Future<int> _run(
String executable,
List<String> arguments, {
bool quiet = false,
}) async {
if (quiet) {
final result = await Process.run(executable, arguments);
if (result.exitCode != 0) {
stderr
..write(result.stdout)
..write(result.stderr);
}
return result.exitCode;
}
final process = await Process.start(
executable,
arguments,
mode: ProcessStartMode.inheritStdio,
);
return await process.exitCode;
}
/// What [executable] writes, failing the check if it fails.
Future<String> _output(String executable, List<String> arguments) async {
final result = await Process.run(executable, arguments);
if (result.exitCode != 0) {
stderr.write(result.stderr);
exit(result.exitCode);
}
return result.stdout as String;
}

View file

@ -1,69 +0,0 @@
#!/usr/bin/env bash
# The checks every push has to pass: formatting, static analysis (infos are fatal too), and
# the server's and the app's tests. The pre-push hook runs this script, and so does CI on
# every push (.forgejo/workflows/ci.yaml).
#
# Install the hook in a fresh clone: ln -s ../../tool/check.sh .git/hooks/pre-push
# Skip it for one push: git push --no-verify
#
# It checks the working tree, so uncommitted changes count as well.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# As a hook in a worktree, git hands this its GIT_DIR, an absolute path to the worktree's
# folder in the main repository. pub runs git with -C on its git dependencies' caches, which
# GIT_DIR overrides, so it judged the workspace's resolution stale, resolved it with dart
# alone and failed ("halkyon_learn_flutter requires the Flutter SDK"; 2026-10-16). Nothing
# below needs them: git finds the repository from the folder it runs in.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY GIT_PREFIX
# As the pre-push hook, first: commits going to main must be signed as deploying it requires
# (tool/check_signatures.sh), which takes a moment, where the rest takes minutes.
if [ "$(basename "$0")" = pre-push ]; then
tool/check_signatures.sh "$@"
fi
if ! git diff --quiet HEAD; then
echo 'Note: the working tree has uncommitted changes; they are checked too.'
fi
# The packages the checked-out pubspecs and lock name, fetched first: after a merge or a
# switch of branch the workspace's resolution can still be the one from before, and the
# analysis then failed on packages it couldn't find (2026-10-13, 2026-10-17). The lock
# decides (--enforce-lockfile), so a check never changes it.
echo '▸ Packages'
flutter pub get --enforce-lockfile > /dev/null
# Our own Dart files. Generated code is left out: the client package as a whole, the
# server's generated sources and test tools, and the app's localizations (gen-l10n). (A
# loop instead of mapfile: macOS ships bash 3.)
own=()
while IFS= read -r file; do own+=("$file"); done < <(
git ls-files '*.dart' |
grep -v -E '^halkyon_learn_client/|/lib/src/generated/|/serverpod_test_tools\.dart$|/lib/l10n/app_localizations'
)
echo '▸ Formatting'
if [ ${#own[@]} -gt 0 ]; then
dart format --output=none --set-exit-if-changed "${own[@]}"
fi
echo '▸ Analysis'
dart analyze --fatal-infos
echo '▸ Server tests'
if git ls-files 'halkyon_learn_server/test/*_test.dart' | grep -q .; then
(cd halkyon_learn_server && dart test)
else
echo 'No server tests yet.'
fi
echo '▸ Curator tests'
(cd halkyon_learn_curator && dart test)
echo '▸ App tests'
# The sync tests need `serverpod start` running; run them by hand with
# `flutter test -t requires-server` in the app package.
(cd halkyon_learn_flutter && flutter test -x requires-server)
echo '✓ All checks passed.'

View file

@ -4,7 +4,7 @@
# push of an unsigned commit fails on the laptop instead of after CI (it did on 2026-10-09,
# when 37 such commits had kept learn.halkyon.eu at v0.2.2 since 2026-10-07).
#
# tool/check.sh runs it first as the pre-push hook, passing on git's arguments (the remote) and
# tool/pre-push runs it first as the pre-push hook, passing on git's arguments (the remote) and
# stdin (a line per ref pushed: local ref, local commit, remote ref, remote commit). It checks
# the commits pushed to main that no ref of that remote has yet. By hand, for what main would
# bring:

20
tool/pre-push Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# The pre-push hook: commits going to main must be signed as deploying it requires
# (tool/check_signatures.sh, a moment), then the checks CI runs (tool/check.dart,
# minutes). A push only goes through when both pass.
#
# Install it in a fresh clone: ln -s ../../tool/pre-push .git/hooks/pre-push
# Skip it for one push: git push --no-verify
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# As a hook in a worktree, git hands this its GIT_DIR, an absolute path to the worktree's
# folder in the main repository. pub runs git with -C on its git dependencies' caches, which
# GIT_DIR overrides, so it judged the workspace's resolution stale, resolved it with dart
# alone and failed ("halkyon_learn_flutter requires the Flutter SDK"; 2026-10-16). Nothing
# below needs them: git finds the repository from the folder it runs in.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY GIT_PREFIX
# git's arguments (the remote) and stdin (the refs pushed) go to the signature check.
tool/check_signatures.sh "$@"
exec dart run tool/check.dart

View file

@ -39,8 +39,8 @@ cd "$(git rev-parse --show-toplevel)"
readonly server_url='https://learn.halkyon.eu/'
readonly forgejo='https://code.halkyon.app'
readonly repository='project-halkyon/halkyon_learn'
readonly pubspec='halkyon_learn_flutter/pubspec.yaml'
readonly repository='project-halkyon/halkyon'
readonly pubspec='apps/learn/halkyon_learn_flutter/pubspec.yaml'
readonly certificate_file='tool/release/android-certificate.sha256'
readonly fdroid_certificate_file='tool/release/fdroid-index-certificate.sha256'
# The Snap Store channel the snap goes to, e.g. edge; empty: not to the Store yet,
@ -241,7 +241,7 @@ trap cleanup EXIT
step "Building the APK from $tag"
git worktree add --quiet --detach "$source" "$tag"
(
cd "$source/halkyon_learn_flutter"
cd "$source/apps/learn/halkyon_learn_flutter"
flutter pub get --enforce-lockfile
HALKYON_ANDROID_KEYSTORE="$keystore" \
HALKYON_ANDROID_KEY_ALIAS="$key_alias" \
@ -250,7 +250,7 @@ git worktree add --quiet --detach "$source" "$tag"
# The Gradle daemon would otherwise live on with the password in its environment.
(cd android && ./gradlew --stop >/dev/null) || true
)
cp "$source/halkyon_learn_flutter/build/app/outputs/flutter-apk/app-release.apk" "$out/$apk"
cp "$source/apps/learn/halkyon_learn_flutter/build/app/outputs/flutter-apk/app-release.apk" "$out/$apk"
unset password
step 'Checking the APK'

View file

@ -6,7 +6,7 @@ License: AGPL-3.0-or-later
AuthorName: Halkyon
AuthorEmail: support@halkyon.app
WebSite: https://halkyon.app/learn
SourceCode: https://code.halkyon.app/project-halkyon/halkyon_learn
SourceCode: https://code.halkyon.app/project-halkyon/halkyon
AntiFeatures:
NonFreeNet: YouTube videos in study material play through YouTube, and the

24
tool/src/check_files.dart Normal file
View file

@ -0,0 +1,24 @@
/// What `tool/check.dart` checks, as pure functions its tests reach.
library;
/// Code generators' output, which is never formatted by hand: Serverpod's
/// client packages from top to bottom, a server's generated sources and test
/// tools, and gen-l10n's localizations.
final _generated = RegExp(
'(^|/)[a-z_]+_client/'
'|/lib/src/generated/'
r'|/serverpod_test_tools\.dart$'
r'|/lib/l10n/[a-z_]*localizations[a-z_]*\.dart$',
);
/// Whether [path] (relative to the repository's root) is a Dart file of our
/// own, which the formatting check covers.
bool isOwnDartFile(String path) =>
path.endsWith('.dart') && !_generated.hasMatch(path);
/// The filters handed to Melos for the tests: every package, or with
/// [changedSince] only the packages changed since that commit and those
/// depending on them.
List<String> melosFilters({String? changedSince}) => [
if (changedSince != null) ...['--diff=$changedSince', '--include-dependents'],
];

19
tool/workspace_only.sh Executable file
View file

@ -0,0 +1,19 @@
#!/bin/sh
# Narrows the workspace in ./pubspec.yaml to the members named, for an image that builds
# only those (the Dockerfiles of Learn's server and curator): pub then resolves without
# the other members, whose folders the image doesn't hold. Fails if one named isn't a
# member, so a renamed package fails the build loudly.
#
# sh tool/workspace_only.sh apps/learn/halkyon_learn_server packages/halkyon_base_server
set -eu
awk -v keep="$*" '
BEGIN { n = split(keep, k, " "); for (i = 1; i <= n; i++) wanted[" - " k[i]] = 1 }
/^workspace:/ { block = 1; print; next }
block && /^ - / { if ($0 in wanted) { print; found[$0] = 1 }; next }
{ block = 0; print }
END {
for (w in wanted) if (!(w in found)) { print "Not a workspace member:" substr(w, 4) > "/dev/stderr"; failed = 1 }
exit failed
}
' pubspec.yaml >pubspec.yaml.narrowed
mv pubspec.yaml.narrowed pubspec.yaml