Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ jobs:
# in the `rest` section.
test-sections:
name: Tests (${{ matrix.section }})
runs-on: ubuntu-latest
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.
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:
Expand All @@ -39,9 +43,13 @@ jobs:
cache: true

- 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
Expand All @@ -54,7 +62,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:
Expand All @@ -79,7 +91,8 @@ jobs:

analyze:
name: Code Analysis
runs-on: ubuntu-latest
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

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions docs/ci-runners.md
Original file line number Diff line number Diff line change
@@ -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: ${{ <pull request from a fork> && '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.
92 changes: 92 additions & 0 deletions scripts/ci/setup-runner.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/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=<registration 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).
# 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 libsqlite3-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
31 changes: 19 additions & 12 deletions test/features/workspace/generic_sql_workspace_history_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart' as material;
Expand All @@ -14,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();

Expand Down Expand Up @@ -82,12 +86,15 @@ void main() {
}

Future<void> run(WidgetTester tester, GenericSqlWorkspaceState state) async {
await tester.runAsync(() async {
await state.execute();
// History and audit rows are written without being awaited.
await Future<void>.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<void>.delayed(const Duration(milliseconds: 150)),
);
await tester.pump(const Duration(milliseconds: 50));
}
}

Future<List<SqlQueryHistoryEntry>> history(
Expand All @@ -104,7 +111,7 @@ void main() {
Future<List<MutationAuditEntry>> 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,
Expand All @@ -118,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,
Expand All @@ -134,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(),
Expand All @@ -148,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')),
Expand All @@ -161,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,
Expand All @@ -174,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(
Expand Down
Loading
Loading