From 8b2ab6b64e7e87de59ce15d1ac16c5a674fa58af Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Fri, 26 Jun 2026 19:38:09 -0700 Subject: [PATCH] ci: add workspace validation and release scripts --- .github/workflows/ci.yml | 56 ++++++++++++++ .gitignore | 1 + scripts/bootstrap_dev.sh | 16 ++++ scripts/build.dart | 147 +++++++++++++++++++++++++++++++++++++ scripts/dev.dart | 81 ++++++++++++++++++++ scripts/dev.sh | 8 ++ scripts/package_release.sh | 8 ++ scripts/test.dart | 53 +++++++++++++ scripts/test.sh | 5 ++ tool/check_coverage.dart | 82 +++++++++++++++++++++ tool/check_docs.dart | 56 ++++++++++++++ 11 files changed, 513 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/bootstrap_dev.sh create mode 100644 scripts/build.dart create mode 100644 scripts/dev.dart create mode 100755 scripts/dev.sh create mode 100755 scripts/package_release.sh create mode 100644 scripts/test.dart create mode 100755 scripts/test.sh create mode 100644 tool/check_coverage.dart create mode 100644 tool/check_docs.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..22d7f96 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +# Runs analyzer, tests, coverage, docs, and tagged desktop packaging. +name: CI + +on: + push: + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart pub get + - run: dart analyze + - run: dart run scripts/test.dart + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-${{ matrix.os }} + path: | + coverage/ + doc/api/ + + package: + if: startsWith(github.ref, 'refs/tags/') + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux + - os: windows-latest + platform: windows + - os: macos-latest + platform: macos + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - run: dart pub get + - run: dart run scripts/build.dart --platform ${{ matrix.platform }} --output build/releases + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.platform }} + path: build/releases/ diff --git a/.gitignore b/.gitignore index 8ca8f09..9bd2159 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ pubspec.lock build/ coverage/ +doc/api/ # IDE .idea/ diff --git a/scripts/bootstrap_dev.sh b/scripts/bootstrap_dev.sh new file mode 100755 index 0000000..7bd460d --- /dev/null +++ b/scripts/bootstrap_dev.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Prepare local dependencies and the default scheduler SQLite location. +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +scheduler_dir="${SCHEDULER_HOME:-"$HOME/ADHD_Scheduler"}" + +cd "$root_dir" +dart pub get + +mkdir -p "$scheduler_dir/backups" +if [[ ! -e "$scheduler_dir/scheduler.sqlite" ]]; then + : > "$scheduler_dir/scheduler.sqlite" +fi + +printf 'Scheduler SQLite path: %s\n' "$scheduler_dir/scheduler.sqlite" diff --git a/scripts/build.dart b/scripts/build.dart new file mode 100644 index 0000000..cea0002 --- /dev/null +++ b/scripts/build.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +/// Builds platform-specific Flutter desktop release artifacts. +Future main(List arguments) async { + final platform = _option(arguments, '--platform') ?? 'current'; + final outputDirectory = + Directory(_option(arguments, '--output') ?? 'build/releases'); + await outputDirectory.create(recursive: true); + + final resolved = platform == 'current' ? _currentPlatform() : platform; + switch (resolved) { + case 'macos': + await _buildMacOs(outputDirectory); + break; + case 'windows': + await _buildWindows(outputDirectory); + break; + case 'linux': + await _buildLinux(outputDirectory); + break; + default: + throw ArgumentError.value( + platform, + '--platform', + 'Expected current, macos, windows, or linux.', + ); + } +} + +Future _buildMacOs(Directory outputDirectory) async { + await _run('flutter', ['build', 'macos', '--release']); + final releaseDir = Directory('build/macos/Build/Products/Release'); + final apps = releaseDir + .listSync() + .whereType() + .where((entry) => entry.path.endsWith('.app')) + .toList(growable: false); + if (apps.isEmpty) { + throw StateError('No macOS .app found in ${releaseDir.path}.'); + } + for (final app in apps) { + await _copyDirectory( + app, Directory(_join(outputDirectory.path, _basename(app.path)))); + } +} + +Future _buildWindows(Directory outputDirectory) async { + await _run('flutter', ['build', 'windows', '--release']); + final bundle = Directory('build/windows/x64/runner/Release'); + if (!await bundle.exists()) { + throw StateError('No Windows release bundle found in ${bundle.path}.'); + } + await _copyDirectory( + bundle, Directory(_join(outputDirectory.path, 'windows'))); + await _run('dart', ['run', 'msix:create']); + final msixFiles = Directory.current + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.msix')) + .toList(growable: false); + if (msixFiles.isEmpty) { + throw StateError('No .msix artifact produced by dart run msix:create.'); + } + for (final file in msixFiles) { + await file.copy(_join(outputDirectory.path, _basename(file.path))); + } +} + +Future _buildLinux(Directory outputDirectory) async { + await _run('flutter', ['build', 'linux', '--release']); + await _run('appimage-builder', [ + '--recipe', + 'AppImageBuilder.yml', + '--skip-test', + ]); + final appImages = Directory.current + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.AppImage')) + .toList(growable: false); + if (appImages.isEmpty) { + throw StateError('No AppImage artifact produced by appimage-builder.'); + } + for (final file in appImages) { + await file.copy(_join(outputDirectory.path, _basename(file.path))); + } +} + +Future _run(String executable, List arguments) async { + stdout.writeln('> $executable ${arguments.join(' ')}'); + final result = await Process.run( + executable, + arguments, + runInShell: Platform.isWindows, + ); + stdout.write(result.stdout); + stderr.write(result.stderr); + if (result.exitCode != 0) { + throw ProcessException( + executable, + arguments, + 'Command failed with exit code ${result.exitCode}.', + result.exitCode, + ); + } +} + +Future _copyDirectory(Directory source, Directory destination) async { + await destination.create(recursive: true); + await for (final entity in source.list(recursive: false)) { + final targetPath = _join(destination.path, _basename(entity.path)); + if (entity is Directory) { + await _copyDirectory(entity, Directory(targetPath)); + } else if (entity is File) { + await entity.copy(targetPath); + } + } +} + +String _currentPlatform() { + if (Platform.isMacOS) return 'macos'; + if (Platform.isWindows) return 'windows'; + if (Platform.isLinux) return 'linux'; + throw UnsupportedError('No Flutter desktop build target for this platform.'); +} + +String _basename(String path) { + final normalized = path.replaceAll('\\', '/'); + return normalized.substring(normalized.lastIndexOf('/') + 1); +} + +String _join(String first, String second) { + final separator = Platform.pathSeparator; + final left = first.endsWith(separator) + ? first.substring(0, first.length - separator.length) + : first; + final right = second.startsWith(separator) ? second.substring(1) : second; + return '$left$separator$right'; +} + +String? _option(List arguments, String name) { + final index = arguments.indexOf(name); + if (index == -1 || index + 1 >= arguments.length) { + return null; + } + return arguments[index + 1]; +} diff --git a/scripts/dev.dart b/scripts/dev.dart new file mode 100644 index 0000000..7d2d014 --- /dev/null +++ b/scripts/dev.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:scheduler_backup/backup.dart'; + +/// Starts local code generation and a Flutter desktop dev session. +Future main(List arguments) async { + final sqlitePath = + _option(arguments, '--sqlite') ?? defaultSchedulerSqliteFile().path; + final device = _option(arguments, '--device') ?? _defaultDesktopDevice(); + + stdout.writeln('SQLite path: $sqlitePath'); + final buildRunner = await _start( + 'dart', + [ + 'run', + 'build_runner', + 'watch', + '--delete-conflicting-outputs', + ], + ); + stdout.writeln('Started build_runner watch (pid ${buildRunner.pid}).'); + + _watchForExit(ProcessSignal.sigint, 130, buildRunner); + if (!Platform.isWindows) { + _watchForExit(ProcessSignal.sigterm, 143, buildRunner); + } + + try { + final flutter = await _start( + 'flutter', + [ + 'run', + '-d', + device, + '--dart-define=SCHEDULER_SQLITE_PATH=$sqlitePath', + ], + ); + stdout.writeln( + 'Started Flutter desktop with hot reload (pid ${flutter.pid}).'); + exitCode = await flutter.exitCode; + } finally { + buildRunner.kill(); + } +} + +void _watchForExit(ProcessSignal signal, int code, Process buildRunner) { + signal.watch().listen((_) { + buildRunner.kill(); + exit(code); + }); +} + +Future _start(String executable, List arguments) async { + stdout.writeln('> $executable ${arguments.join(' ')}'); + try { + return Process.start( + executable, + arguments, + mode: ProcessStartMode.inheritStdio, + runInShell: Platform.isWindows, + ); + } on ProcessException catch (error) { + stderr.writeln(error.message); + stderr.writeln('Unable to start: $executable'); + rethrow; + } +} + +String _defaultDesktopDevice() { + if (Platform.isMacOS) return 'macos'; + if (Platform.isWindows) return 'windows'; + return 'linux'; +} + +String? _option(List arguments, String name) { + final index = arguments.indexOf(name); + if (index == -1 || index + 1 >= arguments.length) { + return null; + } + return arguments[index + 1]; +} diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..8dd6556 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Compatibility wrapper for the Dart desktop development runner. +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root_dir" + +exec dart run scripts/dev.dart "$@" diff --git a/scripts/package_release.sh b/scripts/package_release.sh new file mode 100755 index 0000000..5d70313 --- /dev/null +++ b/scripts/package_release.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Compatibility wrapper for the Dart release packaging runner. +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root_dir" + +exec dart run scripts/build.dart "$@" diff --git a/scripts/test.dart b/scripts/test.dart new file mode 100644 index 0000000..6169e1a --- /dev/null +++ b/scripts/test.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +/// Runs tests, combines coverage, enforces coverage, and checks Dart docs. +Future main(List arguments) async { + await _deleteCoverageDirectory(); + await _run('dart', ['pub', 'global', 'activate', 'coverage']); + await _run('dart', ['test', '--coverage=coverage']); + await _run('dart', [ + 'pub', + 'global', + 'run', + 'coverage:format_coverage', + '--lcov', + '--in=coverage', + '--out=coverage/lcov.info', + '--packages=.dart_tool/package_config.json', + '--report-on=packages', + ]); + await _run('dart', [ + 'run', + 'tool/check_coverage.dart', + 'coverage/lcov.info', + '80', + ]); + await _run('dart', ['run', 'tool/check_docs.dart']); +} + +Future _deleteCoverageDirectory() async { + final coverage = Directory('coverage'); + if (await coverage.exists()) { + await coverage.delete(recursive: true); + } +} + +Future _run(String executable, List arguments) async { + final command = '$executable ${arguments.join(' ')}'; + stdout.writeln('> $command'); + final result = await Process.run( + executable, + arguments, + runInShell: Platform.isWindows, + ); + stdout.write(result.stdout); + stderr.write(result.stderr); + if (result.exitCode != 0) { + throw ProcessException( + executable, + arguments, + 'Command failed with exit code ${result.exitCode}.', + result.exitCode, + ); + } +} diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..ab1acb8 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +dart analyze +dart run scripts/test.dart diff --git a/tool/check_coverage.dart b/tool/check_coverage.dart new file mode 100644 index 0000000..c89aeea --- /dev/null +++ b/tool/check_coverage.dart @@ -0,0 +1,82 @@ +import 'dart:io'; + +/// Checks an LCOV report against a package-library coverage threshold. +void main(List arguments) { + if (arguments.length != 2) { + stderr.writeln('Usage: dart run tool/check_coverage.dart '); + exitCode = 64; + return; + } + + final lcovFile = File(arguments[0]); + final threshold = double.tryParse(arguments[1]); + if (threshold == null) { + stderr.writeln('Coverage threshold must be numeric: ${arguments[1]}'); + exitCode = 64; + return; + } + if (!lcovFile.existsSync()) { + stderr.writeln('Coverage file not found: ${lcovFile.path}'); + exitCode = 1; + return; + } + + final coverage = _readCoverage(lcovFile); + if (coverage.totalLines == 0) { + stderr.writeln('Coverage file has no package lib/ lines.'); + exitCode = 1; + return; + } + + final percent = coverage.percent; + stdout.writeln( + 'Package lib coverage: ${percent.toStringAsFixed(2)}% ' + '(${coverage.coveredLines}/${coverage.totalLines})', + ); + if (percent < threshold) { + stderr.writeln( + 'Coverage ${percent.toStringAsFixed(2)}% is below ' + '${threshold.toStringAsFixed(2)}%.', + ); + exitCode = 1; + } +} + +({int coveredLines, int totalLines, double percent}) _readCoverage(File file) { + var includeFile = false; + var coveredLines = 0; + var totalLines = 0; + + for (final line in file.readAsLinesSync()) { + if (line.startsWith('SF:')) { + includeFile = _includeSourceFile(line.substring(3)); + continue; + } + if (!includeFile || !line.startsWith('DA:')) { + continue; + } + final comma = line.indexOf(','); + if (comma == -1) { + continue; + } + totalLines += 1; + final hits = int.tryParse(line.substring(comma + 1).split(',').first) ?? 0; + if (hits > 0) { + coveredLines += 1; + } + } + + final percent = totalLines == 0 ? 0.0 : coveredLines * 100 / totalLines; + return ( + coveredLines: coveredLines, + totalLines: totalLines, + percent: percent, + ); +} + +bool _includeSourceFile(String path) { + final normalized = path.replaceAll('\\', '/'); + return normalized.contains('/packages/') && + normalized.contains('/lib/') && + !normalized.endsWith('.g.dart'); +} diff --git a/tool/check_docs.dart b/tool/check_docs.dart new file mode 100644 index 0000000..7aca779 --- /dev/null +++ b/tool/check_docs.dart @@ -0,0 +1,56 @@ +import 'dart:io'; + +/// Generates Dart docs for each package with public libraries and fails on warnings. +Future main(List arguments) async { + final outputRoot = Directory(_join(_join('doc', 'api'), 'packages')); + if (await outputRoot.exists()) { + await outputRoot.delete(recursive: true); + } + await outputRoot.create(recursive: true); + + final packages = Directory('packages') + .listSync() + .whereType() + .where((directory) { + return File(_join(directory.path, 'pubspec.yaml')).existsSync() && + Directory(_join(directory.path, 'lib')).existsSync(); + }).toList(growable: false) + ..sort((left, right) => left.path.compareTo(right.path)); + + var failed = false; + for (final package in packages) { + final outputPath = _join(outputRoot.absolute.path, _basename(package.path)); + stdout.writeln('> dart doc --output $outputPath (${package.path})'); + final result = await Process.run( + 'dart', + ['doc', '--output', outputPath], + workingDirectory: package.path, + runInShell: Platform.isWindows, + ); + final output = '${result.stdout}${result.stderr}'; + stdout.write(output); + if (result.exitCode != 0 || _hasWarnings(output)) { + failed = true; + } + } + + if (failed) { + stderr.writeln('Dart documentation gate failed.'); + exitCode = 1; + } +} + +bool _hasWarnings(String output) { + return output.contains(' warning:') || + RegExp(r'Found [1-9][0-9]* warnings').hasMatch(output); +} + +String _join(String first, String second) { + final separator = Platform.pathSeparator; + return '$first$separator$second'; +} + +String _basename(String path) { + final normalized = path.replaceAll('\\', '/'); + return normalized.substring(normalized.lastIndexOf('/') + 1); +}