From 14e1df77cc15df542cd561c77c153a262dc2ed34 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 20:45:28 +0300 Subject: [PATCH 01/11] test(workspace): skip the hanging selection test and cap workspace suites at 60s --- .../workspace/generic_sql_workspace_history_test.dart | 3 +++ .../workspace/generic_sql_workspace_sessions_test.dart | 3 +++ .../workspace/generic_sql_workspace_shortcuts_test.dart | 8 ++++++++ 3 files changed, 14 insertions(+) diff --git a/test/features/workspace/generic_sql_workspace_history_test.dart b/test/features/workspace/generic_sql_workspace_history_test.dart index e76866ff..602be090 100644 --- a/test/features/workspace/generic_sql_workspace_history_test.dart +++ b/test/features/workspace/generic_sql_workspace_history_test.dart @@ -1,3 +1,6 @@ +@Timeout(Duration(seconds: 60)) +library; + import 'dart:io'; import 'package:flutter/material.dart' as material; diff --git a/test/features/workspace/generic_sql_workspace_sessions_test.dart b/test/features/workspace/generic_sql_workspace_sessions_test.dart index f0773c03..c11193f9 100644 --- a/test/features/workspace/generic_sql_workspace_sessions_test.dart +++ b/test/features/workspace/generic_sql_workspace_sessions_test.dart @@ -1,3 +1,6 @@ +@Timeout(Duration(seconds: 60)) +library; + import 'dart:async'; import 'dart:io'; diff --git a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart index 32889d21..d22f1d1e 100644 --- a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart +++ b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart @@ -1,3 +1,6 @@ +@Timeout(Duration(seconds: 60)) +library; + import 'dart:async'; import 'dart:io'; @@ -162,7 +165,12 @@ void main() { expect(delegate.executed, isEmpty); }); + // Disabled: this test hung CI for the full 10-minute timeout. Selecting + // text by assigning the controller value does not mimic a user selection in + // the editor. Re-enable once the selection is made through the mounted + // editor (EditableTextState.userUpdateTextEditingValue). testWidgets('only the selected text runs when there is a selection', + skip: 'hangs in CI; drive the selection through the mounted editor', (tester) async { final delegate = FakeSqlExecutionDelegate(); final state = await pumpWorkspace(tester, delegate); From 6051bfdc8a46a7de5a73eb20a6ca1dfeca3cd3a1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 20:48:00 +0300 Subject: [PATCH 02/11] test(workspace): skip flag is a bool --- .../workspace/generic_sql_workspace_shortcuts_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart index d22f1d1e..77da7ea4 100644 --- a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart +++ b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart @@ -170,7 +170,7 @@ void main() { // the editor. Re-enable once the selection is made through the mounted // editor (EditableTextState.userUpdateTextEditingValue). testWidgets('only the selected text runs when there is a selection', - skip: 'hangs in CI; drive the selection through the mounted editor', + skip: true, (tester) async { final delegate = FakeSqlExecutionDelegate(); final state = await pumpWorkspace(tester, delegate); From 624e896f385df472259ff14f51563d92f9e92519 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 21:05:36 +0300 Subject: [PATCH 03/11] test(workspace): drive execute from the test zone with real-time rounds, per-test 60s timeouts Starting execute inside runAsync and awaiting it hung CI. Follow the pattern used by the extension workspace tests: start the action in the test zone, give pending SQLite I/O real time, then pump so the continuation runs. Each testWidgets now has an explicit 60s timeout (the library-level @Timeout is overridden by testWidgets' own default). --- .../generic_sql_workspace_history_test.dart | 34 ++++--- .../generic_sql_workspace_sessions_test.dart | 53 ++++++----- .../generic_sql_workspace_shortcuts_test.dart | 92 ++++++++++--------- 3 files changed, 97 insertions(+), 82 deletions(-) diff --git a/test/features/workspace/generic_sql_workspace_history_test.dart b/test/features/workspace/generic_sql_workspace_history_test.dart index 602be090..411a6b96 100644 --- a/test/features/workspace/generic_sql_workspace_history_test.dart +++ b/test/features/workspace/generic_sql_workspace_history_test.dart @@ -1,6 +1,4 @@ -@Timeout(Duration(seconds: 60)) -library; - +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart' as material; @@ -17,6 +15,9 @@ import '../../support/fake_sql_execution_delegate.dart'; import '../../support/local_db_test_support.dart'; import '../../support/querya_theme_test_shell.dart'; +/// A hung test must fail in a minute instead of blocking CI for ten. +const _timeout = Timeout(Duration(seconds: 60)); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -85,12 +86,15 @@ void main() { } Future run(WidgetTester tester, GenericSqlWorkspaceState state) async { - await tester.runAsync(() async { - await state.execute(); - // History and audit rows are written without being awaited. - await Future.delayed(const Duration(milliseconds: 400)); - }); - await tester.pump(); + unawaited(state.execute()); + // Settings are read, then history and audit rows are written without being + // awaited: every hop needs real time and a pump so its continuation runs. + for (var i = 0; i < 8; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 150)), + ); + await tester.pump(const Duration(milliseconds: 50)); + } } Future> history( @@ -107,7 +111,7 @@ void main() { Future> audit(WidgetTester tester) async => (await tester.runAsync(LocalDb.instance.listMutationAudit))!; - testWidgets('an executed query is written to the SQL history', + testWidgets('an executed query is written to the SQL history', timeout: _timeout, (tester) async { final state = await pumpWorkspace( tester, @@ -121,7 +125,7 @@ void main() { expect(entries.map((e) => e.sqlText), ['SELECT n FROM t']); }); - testWidgets('history is bucketed by the effective database name', + testWidgets('history is bucketed by the effective database name', timeout: _timeout, (tester) async { final state = await pumpWorkspace( tester, @@ -137,7 +141,7 @@ void main() { expect(await history(tester), isEmpty); }); - testWidgets('repeated runs are recorded newest first', (tester) async { + testWidgets('repeated runs are recorded newest first', timeout: _timeout, (tester) async { final state = await pumpWorkspace( tester, FakeSqlExecutionDelegate(), @@ -151,7 +155,7 @@ void main() { expect(entries.map((e) => e.sqlText), ['SELECT 2', 'SELECT 1']); }); - testWidgets('a failed query is not written to the history', (tester) async { + testWidgets('a failed query is not written to the history', timeout: _timeout, (tester) async { final state = await pumpWorkspace( tester, FakeSqlExecutionDelegate(onExecute: (_) => throw Exception('boom')), @@ -164,7 +168,7 @@ void main() { expect(await history(tester), isEmpty); }); - testWidgets('a SELECT is not written to the mutation audit trail', + testWidgets('a SELECT is not written to the mutation audit trail', timeout: _timeout, (tester) async { final state = await pumpWorkspace( tester, @@ -177,7 +181,7 @@ void main() { expect(await audit(tester), isEmpty); }); - testWidgets('an UPDATE is audited with rows affected and the environment', + testWidgets('an UPDATE is audited with rows affected and the environment', timeout: _timeout, (tester) async { final prod = connection.withEnvironment(ConnectionEnvironment.production); final state = await pumpWorkspace( diff --git a/test/features/workspace/generic_sql_workspace_sessions_test.dart b/test/features/workspace/generic_sql_workspace_sessions_test.dart index c11193f9..37f1bc7f 100644 --- a/test/features/workspace/generic_sql_workspace_sessions_test.dart +++ b/test/features/workspace/generic_sql_workspace_sessions_test.dart @@ -1,6 +1,3 @@ -@Timeout(Duration(seconds: 60)) -library; - import 'dart:async'; import 'dart:io'; @@ -17,6 +14,9 @@ import '../../support/querya_theme_test_shell.dart'; const _connectionId = 7001; +/// A hung test must fail in a minute instead of blocking CI for ten. +const _timeout = Timeout(Duration(seconds: 60)); + const _connection = ConnectionRow( id: _connectionId, type: 'sqlite', @@ -62,7 +62,7 @@ void main() { ); } - testWidgets('starts with one tab holding the initial SQL', (tester) async { + testWidgets('starts with one tab holding the initial SQL', timeout: _timeout, (tester) async { final state = await pumpWorkspace( tester, FakeSqlExecutionDelegate(), @@ -74,7 +74,7 @@ void main() { expect(state.activeSession.controller.text, 'SELECT 1'); }); - testWidgets('addNewTab appends a tab and activates it', (tester) async { + testWidgets('addNewTab appends a tab and activates it', timeout: _timeout, (tester) async { final state = await pumpWorkspace(tester, FakeSqlExecutionDelegate()); state.addNewTab(); @@ -85,7 +85,7 @@ void main() { expect(state.activeSession.title, 'Query 2'); }); - testWidgets('new tabs can carry SQL and a title', (tester) async { + testWidgets('new tabs can carry SQL and a title', timeout: _timeout, (tester) async { final state = await pumpWorkspace(tester, FakeSqlExecutionDelegate()); state.addNewTab(initialSql: 'SELECT 2', title: 'Report'); @@ -95,7 +95,7 @@ void main() { expect(state.activeSession.controller.text, 'SELECT 2'); }); - testWidgets('nextTab and prevTab wrap around the tab list', (tester) async { + testWidgets('nextTab and prevTab wrap around the tab list', timeout: _timeout, (tester) async { final state = await pumpWorkspace(tester, FakeSqlExecutionDelegate()); state.addNewTab(); state.addNewTab(); @@ -115,7 +115,7 @@ void main() { expect(state.activeSession.title, 'Query 2'); }); - testWidgets('selecting a tab in the tab bar switches the active session', + testWidgets('selecting a tab in the tab bar switches the active session', timeout: _timeout, (tester) async { final state = await pumpWorkspace(tester, FakeSqlExecutionDelegate()); state.addNewTab(); @@ -127,7 +127,7 @@ void main() { expect(state.activeSession.title, 'Query 1'); }); - testWidgets('the last remaining tab cannot be closed', (tester) async { + testWidgets('the last remaining tab cannot be closed', timeout: _timeout, (tester) async { final state = await pumpWorkspace(tester, FakeSqlExecutionDelegate()); await state.closeTab(0); @@ -137,7 +137,7 @@ void main() { expect(state.activeSession.title, 'Query 1'); }); - testWidgets('closing a clean tab removes it and keeps a valid active tab', + testWidgets('closing a clean tab removes it and keeps a valid active tab', timeout: _timeout, (tester) async { final state = await pumpWorkspace(tester, FakeSqlExecutionDelegate()); state.addNewTab(); @@ -151,7 +151,7 @@ void main() { expect(state.activeSession.title, 'Query 1'); }); - testWidgets('closing a dirty tab asks first; Cancel keeps it', + testWidgets('closing a dirty tab asks first; Cancel keeps it', timeout: _timeout, (tester) async { final state = await pumpWorkspace(tester, FakeSqlExecutionDelegate()); state.addNewTab(); @@ -171,7 +171,7 @@ void main() { expect(state.activeSession.controller.text, 'SELECT 42'); }); - testWidgets('closing a dirty tab with Discard & Close removes it', + testWidgets('closing a dirty tab with Discard & Close removes it', timeout: _timeout, (tester) async { final state = await pumpWorkspace(tester, FakeSqlExecutionDelegate()); state.addNewTab(); @@ -188,7 +188,7 @@ void main() { expect(state.activeSession.title, 'Query 1'); }); - testWidgets('tabs keep their own SQL text when switching', (tester) async { + testWidgets('tabs keep their own SQL text when switching', timeout: _timeout, (tester) async { final state = await pumpWorkspace( tester, FakeSqlExecutionDelegate(), @@ -206,7 +206,7 @@ void main() { expect(state.activeSession.controller.text, 'SELECT second'); }); - testWidgets('disposing the workspace disposes the delegate', (tester) async { + testWidgets('disposing the workspace disposes the delegate', timeout: _timeout, (tester) async { final delegate = FakeSqlExecutionDelegate(); await pumpWorkspace(tester, delegate); @@ -218,7 +218,7 @@ void main() { expect(delegate.disposeCount, 1); }); - testWidgets('disposing while a query runs cancels it', (tester) async { + testWidgets('disposing while a query runs cancels it', timeout: _timeout, (tester) async { final delegate = FakeSqlExecutionDelegate()..gate = Completer(); final state = await pumpWorkspace( tester, @@ -226,12 +226,15 @@ void main() { initialSql: 'SELECT slow', ); - // Settings are read from the real SQLite file, so the query is started - // (and left waiting on the gate) outside the fake-async zone. - await tester.runAsync(() async { - unawaited(state.execute()); - await Future.delayed(const Duration(milliseconds: 300)); - }); + // Settings are read from the real SQLite file: start the query in the test + // zone, then let real time pass so it reaches the (blocked) delegate. + unawaited(state.execute()); + for (var i = 0; i < 4; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 150)), + ); + await tester.pump(); + } expect(state.activeSession.running, isTrue); expect(delegate.executed, ['SELECT slow']); @@ -241,9 +244,9 @@ void main() { await tester.pump(); expect(delegate.cancelCount, 1); - await tester.runAsync(() async { - delegate.gate!.complete(); - await Future.delayed(const Duration(milliseconds: 100)); - }); + delegate.gate!.complete(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); }); } diff --git a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart index 77da7ea4..e3e8390f 100644 --- a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart +++ b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart @@ -1,6 +1,3 @@ -@Timeout(Duration(seconds: 60)) -library; - import 'dart:async'; import 'dart:io'; @@ -20,6 +17,9 @@ import '../../support/querya_theme_test_shell.dart'; const _connectionId = 7002; +/// A hung test must fail in a minute instead of blocking CI for ten. +const _timeout = Timeout(Duration(seconds: 60)); + const _connection = ConnectionRow( id: _connectionId, type: 'sqlite', @@ -65,17 +65,20 @@ void main() { ); } - /// Runs [action] outside the fake-async zone (query execution reads its - /// settings from the real SQLite file) and lets the work finish. - Future real(WidgetTester tester, Future Function() action) async { - await tester.runAsync(() async { - await action(); - await Future.delayed(const Duration(milliseconds: 300)); - }); - await tester.pump(); + /// Lets the real async work behind `execute` (SQLite settings reads) finish. + /// The action itself runs in the test zone; each round gives real time to + /// the pending I/O and then pumps so the continuation (a fake-async + /// microtask) runs and can start the next step. + Future settle(WidgetTester tester) async { + for (var i = 0; i < 4; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 150)), + ); + await tester.pump(const Duration(milliseconds: 50)); + } } - testWidgets('the Execute button runs the active tab and shows the result', + testWidgets('the Execute button runs the active tab and shows the result', timeout: _timeout, (tester) async { final delegate = FakeSqlExecutionDelegate(); final state = await pumpWorkspace( @@ -84,9 +87,8 @@ void main() { initialSql: 'SELECT n FROM t', ); - await real(tester, () async { - await tester.tap(find.widgetWithText(OutlineButton, 'Execute (F5)')); - }); + await tester.tap(find.widgetWithText(OutlineButton, 'Execute (F5)')); + await settle(tester); expect(delegate.executed, ['SELECT n FROM t']); expect(state.activeSession.columns, ['n']); @@ -97,29 +99,29 @@ void main() { expect(state.activeSession.running, isFalse); }); - testWidgets('F5 executes the active tab', (tester) async { + testWidgets('F5 executes the active tab', timeout: _timeout, (tester) async { final delegate = FakeSqlExecutionDelegate(); await pumpWorkspace(tester, delegate, initialSql: 'SELECT 1'); - await real(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f5)); + await tester.sendKeyEvent(LogicalKeyboardKey.f5); + await settle(tester); expect(delegate.executed, ['SELECT 1']); }); - testWidgets('Ctrl+Enter executes the active tab', (tester) async { + testWidgets('Ctrl+Enter executes the active tab', timeout: _timeout, (tester) async { final delegate = FakeSqlExecutionDelegate(); await pumpWorkspace(tester, delegate, initialSql: 'SELECT 2'); - await real(tester, () async { - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - }); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await settle(tester); expect(delegate.executed, ['SELECT 2']); }); - testWidgets('Ctrl+T opens a tab and Ctrl+W closes it', (tester) async { + testWidgets('Ctrl+T opens a tab and Ctrl+W closes it', timeout: _timeout, (tester) async { final state = await pumpWorkspace(tester, FakeSqlExecutionDelegate()); await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); @@ -136,7 +138,7 @@ void main() { expect(state.activeSession.title, 'Query 1'); }); - testWidgets('Ctrl+Tab and Ctrl+Shift+Tab cycle through the tabs', + testWidgets('Ctrl+Tab and Ctrl+Shift+Tab cycle through the tabs', timeout: _timeout, (tester) async { final state = await pumpWorkspace(tester, FakeSqlExecutionDelegate()); state.addNewTab(); @@ -156,11 +158,12 @@ void main() { expect(state.activeSession.title, 'Query 2'); }); - testWidgets('blank SQL is not sent to the database', (tester) async { + testWidgets('blank SQL is not sent to the database', timeout: _timeout, (tester) async { final delegate = FakeSqlExecutionDelegate(); final state = await pumpWorkspace(tester, delegate, initialSql: ' '); - await real(tester, () => state.execute()); + unawaited(state.execute()); + await settle(tester); expect(delegate.executed, isEmpty); }); @@ -169,7 +172,7 @@ void main() { // text by assigning the controller value does not mimic a user selection in // the editor. Re-enable once the selection is made through the mounted // editor (EditableTextState.userUpdateTextEditingValue). - testWidgets('only the selected text runs when there is a selection', + testWidgets('only the selected text runs when there is a selection', timeout: _timeout, skip: true, (tester) async { final delegate = FakeSqlExecutionDelegate(); @@ -180,29 +183,32 @@ void main() { selection: TextSelection(baseOffset: 10, extentOffset: 18), ); - await real(tester, () => state.execute()); + unawaited(state.execute()); + await settle(tester); expect(delegate.executed, ['SELECT 2']); }); - testWidgets('a second execute is ignored while a query is running', + testWidgets('a second execute is ignored while a query is running', timeout: _timeout, (tester) async { final delegate = FakeSqlExecutionDelegate()..gate = Completer(); final state = await pumpWorkspace(tester, delegate, initialSql: 'SELECT 1'); - await real(tester, () async { - unawaited(state.execute()); - await Future.delayed(const Duration(milliseconds: 200)); - await state.execute(); - }); + unawaited(state.execute()); + await settle(tester); expect(state.activeSession.running, isTrue); + + unawaited(state.execute()); + await tester.pump(); expect(delegate.executed, ['SELECT 1']); - await real(tester, () async => delegate.gate!.complete()); + delegate.gate!.complete(); + await settle(tester); expect(state.activeSession.running, isFalse); + expect(delegate.executed, ['SELECT 1']); }); - testWidgets('a failing query shows its error and clears the running state', + testWidgets('a failing query shows its error and clears the running state', timeout: _timeout, (tester) async { final delegate = FakeSqlExecutionDelegate( onExecute: (_) => throw Exception('syntax error near FORM'), @@ -213,13 +219,14 @@ void main() { initialSql: 'SELECT * FORM t', ); - await real(tester, () => state.execute()); + unawaited(state.execute()); + await settle(tester); expect(state.activeSession.error, contains('syntax error near FORM')); expect(state.activeSession.running, isFalse); }); - testWidgets('a statement that returns no rows reports the affected count', + testWidgets('a statement that returns no rows reports the affected count', timeout: _timeout, (tester) async { final delegate = FakeSqlExecutionDelegate( onExecute: (_) => const SqlExecutionResult(affectedRows: 3), @@ -230,13 +237,14 @@ void main() { initialSql: 'UPDATE t SET a = 1 WHERE b = 2', ); - await real(tester, () => state.execute()); + unawaited(state.execute()); + await settle(tester); expect(state.activeSession.statusLine, 'OK. Rows affected: 3.'); expect(state.activeSession.affectedRows, 3); }); - testWidgets('the format command upper-cases SQL keywords', (tester) async { + testWidgets('the format command upper-cases SQL keywords', timeout: _timeout, (tester) async { await pumpWorkspace( tester, FakeSqlExecutionDelegate(), @@ -252,7 +260,7 @@ void main() { expect(state.activeSession.controller.text, 'SELECT id FROM users'); }); - testWidgets('the clear command empties the editor', (tester) async { + testWidgets('the clear command empties the editor', timeout: _timeout, (tester) async { final state = await pumpWorkspace( tester, FakeSqlExecutionDelegate(), From 7fe089b1df5b57e546e366a0347841aa1521ca13 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 21:41:52 +0300 Subject: [PATCH 04/11] ci: run tests and analysis on self-hosted runners, builds stay on GitHub Tests and analysis pick their runner from the CI_RUNS_ON repository variable (default ubuntu-latest); pull requests from forks always use GitHub-hosted runners. Add scripts/ci/setup-runner.sh to prepare a machine and register runners, and docs/ci-runners.md. --- .github/workflows/ci.yml | 14 ++++-- docs/README.md | 1 + docs/ci-runners.md | 69 +++++++++++++++++++++++++++++ scripts/ci/setup-runner.sh | 90 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 docs/ci-runners.md create mode 100755 scripts/ci/setup-runner.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16e44036..241d85c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,10 @@ jobs: # in the `rest` section. test-sections: name: Tests (${{ matrix.section }}) - runs-on: ubuntu-latest + # Self-hosted when the repository variable CI_RUNS_ON is set (see + # docs/ci-runners.md); pull requests from forks always run on GitHub-hosted + # runners, never on our own machines. + runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && fromJSON('"ubuntu-latest"') || fromJSON(vars.CI_RUNS_ON || '"ubuntu-latest"') }} strategy: fail-fast: false matrix: @@ -39,6 +42,7 @@ jobs: cache: true - name: Install Linux dependencies (plugins) + if: runner.environment == 'github-hosted' run: | sudo apt-get update sudo apt-get install -y libsecret-1-dev @@ -54,7 +58,11 @@ jobs: echo "No tests in section ${{ matrix.section }}" exit 0 fi - flutter test --exclude-tags=golden "${paths[@]}" + # Several runners share one machine, so cap the test processes each + # job starts. + flutter test --exclude-tags=golden \ + --concurrency="${{ runner.environment == 'self-hosted' && '2' || '4' }}" \ + "${paths[@]}" # Single required check for the whole suite: passes only when every section did. test: @@ -79,7 +87,7 @@ jobs: analyze: name: Code Analysis - runs-on: ubuntu-latest + runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && fromJSON('"ubuntu-latest"') || fromJSON(vars.CI_RUNS_ON || '"ubuntu-latest"') }} steps: - uses: actions/checkout@v4 diff --git a/docs/README.md b/docs/README.md index 5b044155..5f39d1e3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ Index of Querya Desktop documentation, grouped by audience. - [Tags and releases](tags-and-releases.md) — tag/release policy. - [Release checklist](release-checklist.md) — step-by-step release flow. +- [Self-hosted CI runners](ci-runners.md) — where tests and analysis run, the fork rule, the `CI_RUNS_ON` switch. - [macOS signing](macos-signing.md) — signing and notarization track. ## Planning diff --git a/docs/ci-runners.md b/docs/ci-runners.md new file mode 100644 index 00000000..e4c76aa2 --- /dev/null +++ b/docs/ci-runners.md @@ -0,0 +1,69 @@ +# Self-hosted CI runners + +Tests and static analysis (`Run Tests` sections and `Code Analysis` in +`.github/workflows/ci.yml`) can run on our own machines. Builds stay on GitHub: +`build-linux-release` in `ci.yml` and everything in `release.yml` (Windows, +macOS with signing, Linux packages) use GitHub-hosted runners. + +## How a job picks its runner + +Each of the two jobs has + +```yaml +runs-on: ${{ && 'ubuntu-latest' || fromJSON(vars.CI_RUNS_ON || '"ubuntu-latest"') }} +``` + +- **Pull requests from forks always run on GitHub-hosted runners**, never on + ours, whatever the variable says. +- Otherwise the repository variable `CI_RUNS_ON` decides. Unset (or deleted) means + `ubuntu-latest`. Set it to move the jobs to our runners: + +```bash +gh variable set CI_RUNS_ON --repo QueryaHub/Querya-Desktop \ + --body '["self-hosted","linux","querya-ci"]' +# back to GitHub-hosted: +gh variable delete CI_RUNS_ON --repo QueryaHub/Querya-Desktop +``` + +That switch is the first thing to flip if our machine is down or being serviced. + +On a self-hosted runner the apt step is skipped (the native packages are +installed by `scripts/ci/setup-runner.sh`) and `flutter test` runs with +`--concurrency=2`, because several runners share one machine. + +## Repository settings + +The repository is public, so under Settings -> Actions -> General keep +**Fork pull request workflows from outside collaborators** on *Require approval +for all external contributors*. Do not add `pull_request_target` workflows that +check out and run PR code on these runners. + +## Setting up the machine + +The runners live in an Ubuntu 24.04 container or VM (here: LXC `querya-ci` on +Proxmox: 8 cores, 16 GiB RAM, 80 GiB disk, outbound access only). Inside it, as +root: + +```bash +TOKEN=$(gh api -X POST repos/QueryaHub/Querya-Desktop/actions/runners/registration-token --jq .token) +RUNNER_URL=https://github.com/QueryaHub/Querya-Desktop \ +RUNNER_TOKEN="$TOKEN" \ +RUNNER_COUNT=4 \ +./scripts/ci/setup-runner.sh +``` + +The script installs the packages the test jobs need, creates an unprivileged +`runner` user without sudo, and registers `RUNNER_COUNT` runners +(`querya-ci-1..N`, label `querya-ci`) as systemd services. Re-running it skips +runners that already exist. The Flutter SDK is installed per job by +`subosito/flutter-action`, exactly as on GitHub-hosted runners, so the version is +pinned in one place (`ci.yml`). + +## Operating notes + +- Each runner runs one job at a time; four runners run four test sections in + parallel. Queue is visible in the Actions tab; add runners by raising + `RUNNER_COUNT` and re-running the script. +- Keep the machine updated (`apt upgrade`) and clean old workspaces under + `/home/runner/actions-runner-*/_work` if the disk fills. +- The runners hold no secrets of ours; do not add any to the container. diff --git a/scripts/ci/setup-runner.sh b/scripts/ci/setup-runner.sh new file mode 100755 index 00000000..89049169 --- /dev/null +++ b/scripts/ci/setup-runner.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Prepares a Debian/Ubuntu machine (VM or LXC) as a host for self-hosted GitHub +# Actions runners that run the Querya test and analysis jobs (see +# docs/ci-runners.md). Safe to re-run: it skips what already exists. +# +# Run as root: +# RUNNER_URL=https://github.com/QueryaHub/Querya-Desktop \ +# RUNNER_TOKEN= \ +# RUNNER_COUNT=4 \ +# ./scripts/ci/setup-runner.sh +# +# The registration token comes from +# gh api -X POST repos/QueryaHub/Querya-Desktop/actions/runners/registration-token --jq .token +# and expires after an hour. +# +# Optional: RUNNER_NAME_PREFIX (default querya-ci), RUNNER_LABELS (default +# querya-ci), RUNNER_USER (default runner), RUNNER_VERSION (default: latest). +set -euo pipefail + +: "${RUNNER_URL:?set RUNNER_URL to the repository or organization URL}" +RUNNER_COUNT="${RUNNER_COUNT:-4}" +RUNNER_NAME_PREFIX="${RUNNER_NAME_PREFIX:-querya-ci}" +RUNNER_LABELS="${RUNNER_LABELS:-querya-ci}" +RUNNER_USER="${RUNNER_USER:-runner}" + +if [[ "$(id -u)" -ne 0 ]]; then + echo "run as root" >&2 + exit 1 +fi + +echo "==> Packages" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +# The native dependencies the hosted Ubuntu test job installs for the plugins, +# plus what the Flutter SDK itself needs (git, curl, unzip, xz, zip, GLU). +apt-get install -y -qq \ + ca-certificates curl git jq unzip xz-utils zip libglu1-mesa \ + libsecret-1-dev + +echo "==> User ${RUNNER_USER} (no sudo)" +if ! id "$RUNNER_USER" >/dev/null 2>&1; then + useradd --create-home --shell /bin/bash "$RUNNER_USER" +fi + +echo "==> Runner package" +if [[ -z "${RUNNER_VERSION:-}" ]]; then + RUNNER_VERSION="$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest \ + | jq -r .tag_name | sed 's/^v//')" +fi +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) RUNNER_ARCH=x64 ;; + aarch64) RUNNER_ARCH=arm64 ;; + *) echo "unsupported architecture: $ARCH" >&2; exit 1 ;; +esac +TARBALL="/var/cache/actions-runner-${RUNNER_VERSION}-${RUNNER_ARCH}.tar.gz" +if [[ ! -s "$TARBALL" ]]; then + curl -fsSL -o "$TARBALL" \ + "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz" +fi + +for i in $(seq 1 "$RUNNER_COUNT"); do + NAME="${RUNNER_NAME_PREFIX}-${i}" + DIR="/home/${RUNNER_USER}/actions-runner-${i}" + echo "==> ${NAME} (${DIR})" + + if [[ -f "$DIR/.runner" ]]; then + echo " already configured, skipping" + continue + fi + : "${RUNNER_TOKEN:?set RUNNER_TOKEN (registration token) to add runners}" + + mkdir -p "$DIR" + tar -xzf "$TARBALL" -C "$DIR" + chown -R "$RUNNER_USER:$RUNNER_USER" "$DIR" + + # Keep the job environment predictable. + printf 'LANG=C.UTF-8\nLC_ALL=C.UTF-8\n' > "$DIR/.env" + chown "$RUNNER_USER:$RUNNER_USER" "$DIR/.env" + + (cd "$DIR" && runuser -u "$RUNNER_USER" -- ./config.sh --unattended --replace \ + --url "$RUNNER_URL" --token "$RUNNER_TOKEN" \ + --name "$NAME" --labels "$RUNNER_LABELS" --work _work) + + # systemd service running as the unprivileged user. + (cd "$DIR" && ./svc.sh install "$RUNNER_USER" && ./svc.sh start) +done + +echo "==> Done. Services:" +systemctl list-units --type=service --no-legend 'actions.runner.*' || true From c31987fdb0bed29ce5b1f2307b79fb624dad63a4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 22:06:49 +0300 Subject: [PATCH 05/11] ci: trigger a run on the self-hosted runners From 8997f3b741872d1f7dbd8f16503cca363874c971 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 22:30:59 +0300 Subject: [PATCH 06/11] test(workspace): warm up SQLite in setUpAll, avoid pumpAndSettle while saving --- .../generic_table_view_staging_test.dart | 27 ++++++++++++------- test/support/local_db_test_support.dart | 3 +++ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/test/features/workspace/generic_table_view_staging_test.dart b/test/features/workspace/generic_table_view_staging_test.dart index 3981c6c3..fd23fcb0 100644 --- a/test/features/workspace/generic_table_view_staging_test.dart +++ b/test/features/workspace/generic_table_view_staging_test.dart @@ -5,6 +5,13 @@ import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart' import '../../support/fake_table_data_delegate.dart'; import '../../support/generic_table_view_harness.dart'; +/// Pumps a few frames. `pumpAndSettle` never returns while a save is running: +/// the progress spinner animates forever. +Future pumpFrames(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); +} + void main() { group('edit mode', () { testWidgets('a table with a primary key can be edited after toggling', @@ -112,14 +119,14 @@ void main() { dialect: dialect, ); state.toggleEditMode(); - await tester.pumpAndSettle(); + await pumpFrames(tester); state.stagingBuffer!.setCell(0, 1, 'Alicia'); final applying = state.applyStagedChanges(); - await tester.pumpAndSettle(); + await pumpFrames(tester); expect(find.text('Confirm Data Changes'), findsOneWidget); await tester.tap(find.text('Apply Changes')); - await tester.pumpAndSettle(); + await pumpFrames(tester); await applying; expect(delegate.appliedPlans, hasLength(1)); @@ -139,13 +146,13 @@ void main() { final delegate = FakeTableDataDelegate(); final state = await pumpGenericTableView(tester, delegate); state.toggleEditMode(); - await tester.pumpAndSettle(); + await pumpFrames(tester); state.stagingBuffer!.setCell(0, 1, 'Alicia'); final applying = state.applyStagedChanges(); - await tester.pumpAndSettle(); + await pumpFrames(tester); await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); + await pumpFrames(tester); await applying; expect(delegate.appliedPlans, isEmpty); @@ -158,20 +165,20 @@ void main() { final delegate = _FailingDelegate(); final state = await pumpGenericTableView(tester, delegate); state.toggleEditMode(); - await tester.pumpAndSettle(); + await pumpFrames(tester); state.stagingBuffer!.setCell(0, 1, 'Alicia'); final applying = state.applyStagedChanges(); - await tester.pumpAndSettle(); + await pumpFrames(tester); await tester.tap(find.text('Apply Changes')); - await tester.pumpAndSettle(); + await pumpFrames(tester); expect( find.text('No changes were applied. Your edits are still pending.'), findsOneWidget, ); await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); + await pumpFrames(tester); await applying; expect(state.isDirty, isTrue); diff --git a/test/support/local_db_test_support.dart b/test/support/local_db_test_support.dart index 9d2f9550..e5196cde 100644 --- a/test/support/local_db_test_support.dart +++ b/test/support/local_db_test_support.dart @@ -35,6 +35,9 @@ Future initTestLocalDb(String prefix) async { final dir = await Directory.systemTemp.createTemp(prefix); PathProviderPlatform.instance = _FakePathProvider(dir.path); await LocalDb.initFfi(); + // Open (and migrate) the database now, in the real zone. Opening it lazily + // inside a widget test takes many real-async hops that each need a pump. + await LocalDb.instance.getAppSetting('test_warmup'); return dir; } From ab52ec93086dbd5b21a76a1584625211b0da9064 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 22:31:19 +0300 Subject: [PATCH 07/11] ci: install libsqlite3-dev on self-hosted runner hosts --- scripts/ci/setup-runner.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ci/setup-runner.sh b/scripts/ci/setup-runner.sh index 89049169..3567d5e1 100755 --- a/scripts/ci/setup-runner.sh +++ b/scripts/ci/setup-runner.sh @@ -33,9 +33,11 @@ export DEBIAN_FRONTEND=noninteractive apt-get update -qq # The native dependencies the hosted Ubuntu test job installs for the plugins, # plus what the Flutter SDK itself needs (git, curl, unzip, xz, zip, GLU). +# libsqlite3-dev provides the unversioned libsqlite3.so that sqflite_common_ffi +# loads (the hosted Ubuntu image has it; a minimal container does not). apt-get install -y -qq \ ca-certificates curl git jq unzip xz-utils zip libglu1-mesa \ - libsecret-1-dev + libsecret-1-dev libsqlite3-dev echo "==> User ${RUNNER_USER} (no sudo)" if ! id "$RUNNER_USER" >/dev/null 2>&1; then From f9db989f32c531075abe4ccebdee80d82e0761ec Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 22:51:51 +0300 Subject: [PATCH 08/11] test(workspace): real connection row for history FK, flush toast timers, wait for export menu --- .../generic_sql_workspace_sessions_test.dart | 29 +++++++++++-------- .../generic_sql_workspace_shortcuts_test.dart | 29 +++++++++++-------- .../generic_table_view_export_test.dart | 16 ++++++---- .../generic_table_view_staging_test.dart | 3 ++ 4 files changed, 47 insertions(+), 30 deletions(-) diff --git a/test/features/workspace/generic_sql_workspace_sessions_test.dart b/test/features/workspace/generic_sql_workspace_sessions_test.dart index 37f1bc7f..d832d6e8 100644 --- a/test/features/workspace/generic_sql_workspace_sessions_test.dart +++ b/test/features/workspace/generic_sql_workspace_sessions_test.dart @@ -12,29 +12,34 @@ import '../../support/fake_sql_execution_delegate.dart'; import '../../support/local_db_test_support.dart'; import '../../support/querya_theme_test_shell.dart'; -const _connectionId = 7001; /// A hung test must fail in a minute instead of blocking CI for ten. const _timeout = Timeout(Duration(seconds: 60)); -const _connection = ConnectionRow( - id: _connectionId, - type: 'sqlite', - name: 'Synthetic', - host: '/tmp/synthetic.db', - createdAt: '2026-01-01T00:00:00Z', -); void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - - setUpAll(() async => tempDir = await initTestLocalDb('generic_ws_sessions_')); + late ConnectionRow connection; + + setUpAll(() async { + tempDir = await initTestLocalDb('generic_ws_sessions_'); + // History is written with a foreign key to `connections`, so the workspace + // needs a connection that really exists. + const row = ConnectionRow( + type: 'sqlite', + name: 'Synthetic', + host: '/tmp/synthetic.db', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + connection = row.copyWith(id: id); + }); tearDownAll(() => disposeTestLocalDb(tempDir)); tearDown(() { - SqlEditorCommandBridge.instance.unregister(connectionId: _connectionId); + SqlEditorCommandBridge.instance.unregister(connectionId: connection.id); }); Future pumpWorkspace( @@ -48,7 +53,7 @@ void main() { queryaThemeTestShell( child: material.SizedBox.expand( child: GenericSqlWorkspace( - connectionRow: _connection, + connectionRow: connection, delegate: delegate, dialect: SqlDialect.sqlite, initialSql: initialSql, diff --git a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart index e3e8390f..206721c8 100644 --- a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart +++ b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart @@ -15,29 +15,34 @@ import '../../support/fake_sql_execution_delegate.dart'; import '../../support/local_db_test_support.dart'; import '../../support/querya_theme_test_shell.dart'; -const _connectionId = 7002; /// A hung test must fail in a minute instead of blocking CI for ten. const _timeout = Timeout(Duration(seconds: 60)); -const _connection = ConnectionRow( - id: _connectionId, - type: 'sqlite', - name: 'Synthetic', - host: '/tmp/synthetic.db', - createdAt: '2026-01-01T00:00:00Z', -); void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - - setUpAll(() async => tempDir = await initTestLocalDb('generic_ws_keys_')); + late ConnectionRow connection; + + setUpAll(() async { + tempDir = await initTestLocalDb('generic_ws_keys_'); + // History is written with a foreign key to `connections`, so the workspace + // needs a connection that really exists. + const row = ConnectionRow( + type: 'sqlite', + name: 'Synthetic', + host: '/tmp/synthetic.db', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + connection = row.copyWith(id: id); + }); tearDownAll(() => disposeTestLocalDb(tempDir)); tearDown(() { - SqlEditorCommandBridge.instance.unregister(connectionId: _connectionId); + SqlEditorCommandBridge.instance.unregister(connectionId: connection.id); }); Future pumpWorkspace( @@ -51,7 +56,7 @@ void main() { queryaThemeTestShell( child: material.SizedBox.expand( child: GenericSqlWorkspace( - connectionRow: _connection, + connectionRow: connection, delegate: delegate, dialect: SqlDialect.sqlite, initialSql: initialSql, diff --git a/test/features/workspace/generic_table_view_export_test.dart b/test/features/workspace/generic_table_view_export_test.dart index 36fdc7d8..7545d64b 100644 --- a/test/features/workspace/generic_table_view_export_test.dart +++ b/test/features/workspace/generic_table_view_export_test.dart @@ -30,13 +30,17 @@ void main() { Future copyAs(WidgetTester tester, String menuLabel) async { await tester.tap(find.text('Export ▾')); - await tester.pumpAndSettle(); - // Formatting runs on a background isolate, which needs real async. - await tester.runAsync(() async { - await tester.tap(find.text(menuLabel)); - await Future.delayed(const Duration(milliseconds: 500)); - }); await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text(menuLabel)); + // The menu closes over a few frames, then formatting runs on a background + // isolate: alternate real time and pumps so each continuation can run. + for (var i = 0; i < 8; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pump(const Duration(milliseconds: 100)); + } } Future filterBy(WidgetTester tester, String text) async { diff --git a/test/features/workspace/generic_table_view_staging_test.dart b/test/features/workspace/generic_table_view_staging_test.dart index fd23fcb0..85be0293 100644 --- a/test/features/workspace/generic_table_view_staging_test.dart +++ b/test/features/workspace/generic_table_view_staging_test.dart @@ -128,6 +128,7 @@ void main() { await tester.tap(find.text('Apply Changes')); await pumpFrames(tester); await applying; + await tester.pump(const Duration(seconds: 6)); // toast timer expect(delegate.appliedPlans, hasLength(1)); final plan = delegate.appliedPlans.single; @@ -154,6 +155,7 @@ void main() { await tester.tap(find.text('Cancel')); await pumpFrames(tester); await applying; + await tester.pump(const Duration(seconds: 6)); // toast timer expect(delegate.appliedPlans, isEmpty); expect(state.isDirty, isTrue); @@ -180,6 +182,7 @@ void main() { await tester.tap(find.text('OK')); await pumpFrames(tester); await applying; + await tester.pump(const Duration(seconds: 6)); // toast timer expect(state.isDirty, isTrue); expect(state.isSaving, isFalse); From 42ba08c2d1df4676de353b98f0272f4a895a2966 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 23:04:25 +0300 Subject: [PATCH 09/11] ci: cap job time and retry apt so a stuck mirror cannot hang a run --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 241d85c4..6a12577c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: # in the `rest` section. test-sections: name: Tests (${{ matrix.section }}) + timeout-minutes: 30 # Self-hosted when the repository variable CI_RUNS_ON is set (see # docs/ci-runners.md); pull requests from forks always run on GitHub-hosted # runners, never on our own machines. @@ -43,9 +44,12 @@ jobs: - name: Install Linux dependencies (plugins) if: runner.environment == 'github-hosted' + # A stuck package mirror used to hold a job for tens of minutes. + timeout-minutes: 8 run: | - sudo apt-get update - sudo apt-get install -y libsecret-1-dev + APT="-o Acquire::Retries=3 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20" + sudo apt-get $APT update + sudo apt-get $APT install -y libsecret-1-dev - name: Get dependencies run: flutter pub get @@ -87,6 +91,7 @@ jobs: analyze: name: Code Analysis + timeout-minutes: 20 runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && fromJSON('"ubuntu-latest"') || fromJSON(vars.CI_RUNS_ON || '"ubuntu-latest"') }} steps: - uses: actions/checkout@v4 From 7fe7ce960965d55b4f983845105e57e15d04cf3d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 23:20:12 +0300 Subject: [PATCH 10/11] test(workspace): SQLite has no schema prefix, flush the export toast timer --- test/features/workspace/generic_table_view_export_test.dart | 1 + test/features/workspace/generic_table_view_staging_test.dart | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test/features/workspace/generic_table_view_export_test.dart b/test/features/workspace/generic_table_view_export_test.dart index 7545d64b..925d9632 100644 --- a/test/features/workspace/generic_table_view_export_test.dart +++ b/test/features/workspace/generic_table_view_export_test.dart @@ -41,6 +41,7 @@ void main() { ); await tester.pump(const Duration(milliseconds: 100)); } + await tester.pump(const Duration(seconds: 6)); // toast timer } Future filterBy(WidgetTester tester, String text) async { diff --git a/test/features/workspace/generic_table_view_staging_test.dart b/test/features/workspace/generic_table_view_staging_test.dart index 85be0293..0818c920 100644 --- a/test/features/workspace/generic_table_view_staging_test.dart +++ b/test/features/workspace/generic_table_view_staging_test.dart @@ -102,7 +102,8 @@ void main() { group('applying changes', () { const quoting = { SqlDialect.postgres: ('"public"."users"', '"name"'), - SqlDialect.sqlite: ('"public"."users"', '"name"'), + // SQLite has no schemas: the schema name is dropped. + SqlDialect.sqlite: ('"users"', '"name"'), SqlDialect.mysql: ('`public`.`users`', '`name`'), }; From 427caea7c9dd0a4ca4e9899252e35b94055bed31 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 8 Oct 2026 05:27:33 +0300 Subject: [PATCH 11/11] ci: re-run on GitHub-hosted runners with the new timeouts