build(check): the checks compare the generated code, as CI did alone

On 23 Oct a push went red in CI because an endpoint's changed comment
hadn't been generated into the module's client: only CI compared the
committed generated code with the generator's output. tool/check.dart now
runs every server's generation (Melos' `generate`, about 15 s) after
fetching the packages and fails, naming the files, where the working
tree's generated code changed by it; the files stay written anew, to be
committed. It compares the files that differ from the committed ones
before and after by their content, so uncommitted changes count as they
do everywhere else in the checks.

It refuses a Serverpod CLI at another version than the lock holds
Serverpod at (`dart install serverpod_cli@<version>`), and the root's
tests hold CI's SERVERPOD_VERSION to the lock. CI keeps installing the
CLI at that version and puts it on the job's PATH; its own comparison
step goes, as the checks run it.

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-23 15:14:23 +02:00
commit 3def5126a3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 206 additions and 18 deletions

View file

@ -25,7 +25,8 @@ jobs:
# The Flutter the app is developed with; move it together with the local one.
FLUTTER_VERSION: 3.47.5
# The Serverpod CLI, for the generated-code check: the servers' Serverpod version
# (every package pins it); move it together with them.
# (every package pins it; the root's tests hold it to the lock); move it together
# with them.
SERVERPOD_VERSION: 4.0.4
steps:
# Before anything else runs, the job proves it can neither see nor reach production
@ -107,23 +108,18 @@ jobs:
if: env.CHECKED_BEFORE != 'true'
run: flutter pub get --enforce-lockfile || flutter pub get --enforce-lockfile
# The generated code (each server's lib/src/generated, its client package) is
# committed, so it has to be exactly what the generator makes of the models and
# endpoints: a model changed without regenerating fails here. Every server package,
# the apps' and the shared module, through Melos' `generate` script.
- name: Generated code
# The Serverpod CLI at SERVERPOD_VERSION, for the checks' generated-code step
# (tool/check.dart): the generated code is committed, so it has to be exactly what
# the generator makes of the models and endpoints, and the step refuses a CLI at
# another version than the lock holds Serverpod at.
- name: Serverpod CLI
if: env.CHECKED_BEFORE != 'true'
run: |
bin="${XDG_STATE_HOME:-$HOME/.local/state}/Dart/install/bin"
if ! "$bin/serverpod" --no-analytics version 2>/dev/null | grep -qx "Serverpod version: $SERVERPOD_VERSION"; then
dart install "serverpod_cli@$SERVERPOD_VERSION"
fi
PATH="$bin:$PATH" dart run melos run generate
if [ -n "$(git status --porcelain)" ]; then
git status --short
echo 'The generated code differs from what the generator makes now: run `serverpod generate` and commit.' >&2
exit 1
fi
echo "$bin" >> "$FORGEJO_PATH"
# The test run mode's passwords, which passwords.yaml holds on a developer's machine
# and which is never committed. Without them the server tests stop without a word.

View file

@ -75,7 +75,7 @@ Checklist after doing changes, in this order:
- `dart format` (CLI) on the files you changed
- `create_migration` and `apply_migrations` (MCP - only if necessary)
- `dart run tool/check.dart` (CLI): the packages the lock names (`flutter pub get --enforce-lockfile`, so a merge never leaves the resolution behind), format check of our own Dart files, `dart analyze --fatal-infos` over the whole workspace, the tools' own tests, and every package's tests through Melos. With `--changed-since=origin/main` it tests only the packages changed since then and those depending on them. The pre-push hook runs it in full, so a push only goes through when it passes.
- `dart run tool/check.dart` (CLI): the packages the lock names (`flutter pub get --enforce-lockfile`, so a merge never leaves the resolution behind), the generated code (every server's, `dart run melos run generate`, about 15 s: it fails, naming the files, where they differ from what the generator makes of the models and endpoints now, leaving them written anew to be committed, and refuses a Serverpod CLI at another version than the lock holds Serverpod at, `dart install serverpod_cli@<version>`), format check of our own Dart files, `dart analyze --fatal-infos` over the whole workspace, the tools' own tests, and every package's tests through Melos. With `--changed-since=origin/main` it tests only the packages changed since then and those depending on them. The pre-push hook runs it in full, so a push only goes through when it passes.
- Do `serverpod` MCP `hot_restart` if required (hot reload is done automatically). Will also hot restart Flutter app
- Check `serverpod` MCP `tail_server_logs` and `tail_flutter_logs` for any issues.
@ -84,5 +84,5 @@ Checklist after doing changes, in this order:
## CI and deploys
- **CI** (`.forgejo/workflows/ci.yaml`) runs `dart run tool/check.dart` on every push of a branch (not of a tag: a release pushes its tag right after `main`, on the same commit), on the Forgejo runner on learn.halkyon.eu's server (`deploy/runner/`), except for a commit that passed before: the job marks passed commits in the runner's home (`checked/`, kept 90 days), so a branch's commit fast-forwarded into `main` goes straight to its deploy. It checks every package, whichever a push changed. Jobs run directly on the host as the unprivileged user `runner`, never in containers: Docker access would be root on the production server. The runner's systemd unit keeps jobs away from production (no `/home`, no Docker socket, Docker's container networks unreachable, memory and CPU capped); keep it that way when changing it. `runner` has user ID 61000 and must never share one with a container's processes (PostgreSQL and Learn's server run as 999, not remapped): the same user ID can read their environment and files. The job's first step (*Sandbox*) fails if any of this leaks. Flutter is pinned in the workflow (`FLUTTER_VERSION`): move it together with the local one. Every server package's tests get throwaway passwords from the environment (`SERVERPOD_…`, new for every job), since no `passwords.yaml` is ever committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI also runs `dart run melos run generate` with the Serverpod CLI at `SERVERPOD_VERSION` and fails if the committed generated code of any server differs from its output. So never run `dart format` over a server's `lib/` or `test/` as a whole: it reformats the generated code (`lib/src/generated/`, `serverpod_test_tools.dart`); format the files you wrote. CI runs on pushes only: a pull request from a fork would run its code on the production server.
- **CI** (`.forgejo/workflows/ci.yaml`) runs `dart run tool/check.dart` on every push of a branch (not of a tag: a release pushes its tag right after `main`, on the same commit), on the Forgejo runner on learn.halkyon.eu's server (`deploy/runner/`), except for a commit that passed before: the job marks passed commits in the runner's home (`checked/`, kept 90 days), so a branch's commit fast-forwarded into `main` goes straight to its deploy. It checks every package, whichever a push changed. Jobs run directly on the host as the unprivileged user `runner`, never in containers: Docker access would be root on the production server. The runner's systemd unit keeps jobs away from production (no `/home`, no Docker socket, Docker's container networks unreachable, memory and CPU capped); keep it that way when changing it. `runner` has user ID 61000 and must never share one with a container's processes (PostgreSQL and Learn's server run as 999, not remapped): the same user ID can read their environment and files. The job's first step (*Sandbox*) fails if any of this leaks. Flutter is pinned in the workflow (`FLUTTER_VERSION`): move it together with the local one. Every server package's tests get throwaway passwords from the environment (`SERVERPOD_…`, new for every job), since no `passwords.yaml` is ever committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI installs the Serverpod CLI at `SERVERPOD_VERSION` (which the root's tests hold to the lock's Serverpod), so the checks' generated-code step fails there too if the committed generated code of any server differs from what the generator makes, as it does on the laptop since 23 Oct 2026 (before, only CI compared, and a push went red for an endpoint's changed comment). So never run `dart format` over a server's `lib/` or `test/` as a whole: it reformats the generated code (`lib/src/generated/`, `serverpod_test_tools.dart`); format the files you wrote. CI runs on pushes only: a pull request from a fork would run its code on the production server.
- **A green `main` deploys learn.halkyon.eu**: this repository's `main`, whichever app or package a push changed, through `deploy/redeploy.sh` (Learn's `AGENTS.md`, *CI and deploys*, with the rules a deploy checks, the sidecars, the backups and the releases).

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:test/test.dart';
import '../tool/src/check_files.dart';
@ -105,4 +107,78 @@ void main() {
'--include-dependents',
]);
});
group('the generated code', () {
test("the CLI's version is the one the lock holds Serverpod at", () {
const lock = '''
packages:
serverpod:
dependency: transitive
description:
name: serverpod
sha256: "0"
url: "https://pub.dev"
source: hosted
version: "4.0.4"
sdks:
dart: ">=3.13.0 <4.0.0"
''';
expect(lockedVersion(lock, 'serverpod'), '4.0.4');
expect(lockedVersion(lock, 'serverpod_cli'), isNull);
expect(
serverpodCliVersion('Some notice\nServerpod version: 4.0.4\n'),
'4.0.4',
);
expect(serverpodCliVersion('serverpod: command not found'), isNull);
});
test('CI installs the CLI at the version the lock holds Serverpod at, so '
"check.dart's comparison holds there too", () {
final pinned = RegExp(
r'^\s*SERVERPOD_VERSION: (\S+)$',
multiLine: true,
).firstMatch(File('.forgejo/workflows/ci.yaml').readAsStringSync());
expect(
pinned?.group(1),
lockedVersion(File('pubspec.lock').readAsStringSync(), 'serverpod'),
);
});
test('differs where the generator changed, removed or added a file', () {
final before = {
'apps/learn/halkyon_learn_server/lib/src/studyset/studyset.spy.yaml':
'a',
'apps/learn/halkyon_learn_server/lib/src/generated/protocol.dart': 'b',
};
final after = {
...before,
// Written anew from the changed model.
'apps/learn/halkyon_learn_server/lib/src/generated/protocol.dart': 'c',
// A model's class no longer generated.
'apps/learn/halkyon_learn_client/lib/src/protocol/gone.dart':
absentFile,
// A new model's class.
'apps/learn/halkyon_learn_client/lib/src/protocol/new.dart': 'd',
};
expect(differingFiles(before, after), [
'apps/learn/halkyon_learn_client/lib/src/protocol/gone.dart',
'apps/learn/halkyon_learn_client/lib/src/protocol/new.dart',
'apps/learn/halkyon_learn_server/lib/src/generated/protocol.dart',
]);
});
test('a file written back as committed differs from its change before, '
'and changes the generator left alone count for nothing', () {
expect(
differingFiles(
{
'apps/todo/halkyon_todo_client/lib/src/protocol/client.dart': 'a',
'apps/todo/halkyon_todo_server/lib/src/greeting.dart': 'b',
},
{'apps/todo/halkyon_todo_server/lib/src/greeting.dart': 'b'},
),
['apps/todo/halkyon_todo_client/lib/src/protocol/client.dart'],
);
});
});
}

View file

@ -1,7 +1,7 @@
// 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).
// The checks every push has to pass: the packages the lock names, the
// generated code, 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
@ -15,6 +15,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart';
import 'package:crypto/crypto.dart';
import 'src/check_files.dart';
@ -56,6 +57,8 @@ Future<void> main(List<String> arguments) async {
('flutter', ['pub', 'get', '--enforce-lockfile']),
], quiet: true);
await _generatedCode();
final own = (await _output('git', [
'ls-files',
'*.dart',
@ -92,6 +95,83 @@ Future<void> main(List<String> arguments) async {
stdout.writeln('✓ All checks passed.');
}
/// Fails the check unless the generated code (each server's
/// `lib/src/generated/` and test tools, its client package's
/// `lib/src/protocol/`) is what Serverpod's generator makes of the models and
/// endpoints now: it is committed, and a model or endpoint changed without
/// generating again failed only in CI before (23 Oct 2026). Every server
/// package, the apps' and the shared module, through Melos' `generate`
/// script, with the CLI at the version the lock holds Serverpod at, as CI
/// installs it. What it writes stays in the working tree, to be committed.
Future<void> _generatedCode() async {
stdout.writeln('▸ Generated code');
final wanted = lockedVersion(
File('pubspec.lock').readAsStringSync(),
'serverpod',
);
final ProcessResult cli;
try {
cli = await Process.run('serverpod', ['--no-analytics', 'version']);
} on ProcessException {
stderr.writeln(
"✗ The Serverpod CLI isn't on the PATH: "
'dart install serverpod_cli@$wanted',
);
exit(1);
}
final found = serverpodCliVersion('${cli.stdout}');
if (wanted == null || found != wanted) {
stderr.writeln(
'✗ The Serverpod CLI is at ${found ?? 'an unknown version'}, the lock '
'holds Serverpod at $wanted: dart install serverpod_cli@$wanted',
);
exit(1);
}
final before = await _uncommittedFiles();
final code = await _run('dart', [
'run',
'melos',
'run',
'generate',
], quiet: true);
if (code != 0) {
stderr.writeln('✗ Generated code failed (serverpod generate).');
exit(code);
}
final differing = differingFiles(before, await _uncommittedFiles());
if (differing.isNotEmpty) {
stderr
..writeln(
'✗ The generated code differs from what the generator makes of the '
'models and endpoints now; it has been written anew, commit it:',
)
..writeAll(differing.map((path) => ' $path\n'));
exit(1);
}
}
/// The working tree's files that differ from the committed ones, untracked
/// files not ignored among them, each with a digest of its content, or
/// [absentFile] where it was deleted.
Future<Map<String, String>> _uncommittedFiles() async {
final paths = (await _output('git', [
'ls-files',
'--modified',
'--deleted',
'--others',
'--exclude-standard',
'-z',
])).split('\x00').where((path) => path.isNotEmpty).toSet();
return {
for (final path in paths)
path: switch (File(path)) {
final file when file.existsSync() =>
'${sha256.convert(file.readAsBytesSync())}',
_ => absentFile,
},
};
}
/// The packages changed since [commit], in the working tree too (committed
/// or not, and files new to git), or null for every package
/// ([packagesTouched]).

View file

@ -1,6 +1,8 @@
/// What `tool/check.dart` checks, as pure functions its tests reach.
library;
import 'package:yaml/yaml.dart';
/// Code generators' output, which is never formatted by hand: Serverpod's
/// client packages from top to bottom (but for [_written]), a server's
/// generated sources and test tools, and gen-l10n's localizations.
@ -63,3 +65,37 @@ List<String> melosFilters({List<String>? touched}) => [
'--include-dependents',
],
];
/// The version [package] is locked at in [lock] (a `pubspec.lock`'s text),
/// or null where the lock doesn't name it.
String? lockedVersion(String lock, String package) => switch (loadYaml(lock)) {
{'packages': final Map<Object?, Object?> packages} =>
switch (packages[package]) {
{'version': final String version} => version,
_ => null,
},
_ => null,
};
/// The version of the Serverpod CLI that `serverpod version` reported in
/// [output], or null where it reported none.
String? serverpodCliVersion(String output) => RegExp(
r'^Serverpod version: (\S+)$',
multiLine: true,
).firstMatch(output)?.group(1);
/// How a file absent from the working tree shows in a [differingFiles]
/// snapshot.
const absentFile = 'absent';
/// The files whose content differs between the snapshots [before] and
/// [after], sorted. A snapshot maps each file that differs from the
/// committed one (relative to the repository's root) to a digest of its
/// content, or [absentFile]; a file it doesn't name is as committed.
List<String> differingFiles(
Map<String, String> before,
Map<String, String> after,
) => [
for (final path in {...before.keys, ...after.keys})
if (before[path] != after[path]) path,
]..sort();