Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
61 changes: 41 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ permissions:

env:
CARGO_TERM_COLOR: always
# CI-only: skip debuginfo in dev/test builds. Cuts compile+link time ~30-40%
# and shrinks caches; the only cost is line numbers in CI backtraces.
CARGO_PROFILE_DEV_DEBUG: 0

jobs:
# Decide which areas changed so we only run the jobs that matter. Pure doc edits
Expand Down Expand Up @@ -91,12 +94,16 @@ jobs:
toolchain: "1.94"
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: apps/backend
# Share the dependency cache with the linux test lanes (deps are
# identical; clippy's final artifacts differ but the heavy 3rd-party
# layer is reused). rust-cache prunes to deps before saving, so a
# single shared cache serves lint + all 3 db lanes.
shared-key: linux
# Workspace root is the REPO root (Cargo.lock + target/ live there);
# cargo just runs from apps/backend. Pointing rust-cache at the member
# dir would cache the nonexistent apps/backend/target.
workspaces: ". -> target"
# Lint has its own key: GH caches are immutable and clippy saves
# check-artifacts that are useless for `cargo test` codegen, so
# sharing a key with the test lanes lets whichever job finishes first
# (always lint) poison the cache for the others.
shared-key: linux-lint
cache-on-failure: true
- run: cargo fmt --all -- --check
# --no-default-features drops `embed-dashboard`, whose RustEmbed derive needs
# apps/dashboard/dist at compile time (not built in CI).
Expand Down Expand Up @@ -127,9 +134,9 @@ jobs:
fail-fast: false
matrix:
# SQLite runs fully parallel (in-memory, isolated per test). Postgres and
# MySQL run database-per-test against one shared server, so test threads are
# capped to keep the connection demand well under the server's ceiling on
# high-core runners.
# MySQL run database-per-test against one shared server, so concurrent
# tests are capped to keep the connection demand well under the server's
# ceiling on high-core runners.
include:
- db: sqlite
test_database: sqlite
Expand All @@ -138,11 +145,11 @@ jobs:
- db: postgres
test_database: postgres
test_database_url: postgres://postgres:postgres@localhost:5432/postgres
test_args: "-- --test-threads=4"
test_args: "--test-threads 4"
- db: mysql
test_database: mysql
test_database_url: mysql://root:root@localhost:3306/mysql
test_args: "-- --test-threads=4"
test_args: "--test-threads 4"
env:
TEST_DATABASE: ${{ matrix.test_database }}
TEST_DATABASE_URL: ${{ matrix.test_database_url }}
Expand Down Expand Up @@ -185,15 +192,24 @@ jobs:
toolchain: "1.94"
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: apps/backend
# Workspace root is the REPO root (Cargo.lock + target/ live there).
workspaces: ". -> target"
# All 3 db lanes compile an identical binary (db is chosen at runtime
# via TEST_DATABASE), so one shared cache lets lanes 2 & 3 restore
# fully warm. Shared with the lint job too (deps are identical).
shared-key: linux
# via TEST_DATABASE), so one shared cache serves all of them; the
# save race between lanes is harmless. NOT shared with lint: clippy
# saves check-artifacts that are useless for test codegen.
shared-key: linux-test
cache-on-failure: true
- uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7
with:
tool: cargo-nextest
# nextest runs all test binaries' tests in ONE parallel pool (cargo test
# runs the binaries sequentially). Process-per-test also isolates env-var
# mutation between tests.
# --no-default-features drops `embed-dashboard` (RustEmbed needs a built
# apps/dashboard/dist at compile time, which CI doesn't produce).
- name: cargo test
run: cargo test --no-default-features --locked ${{ matrix.test_args }}
- name: cargo nextest
run: cargo nextest run --no-default-features --locked ${{ matrix.test_args }}

# Cross-platform coverage. The binary ships on Windows/macOS/Linux, so the suite
# must run off Linux too. Service containers don't run on these runners, so only
Expand Down Expand Up @@ -224,11 +240,16 @@ jobs:
toolchain: "1.94"
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: apps/backend
# Workspace root is the REPO root (Cargo.lock + target/ live there).
workspaces: ". -> target"
shared-key: ${{ matrix.os }}
cache-on-failure: true
- uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7
with:
tool: cargo-nextest
# --no-default-features drops `embed-dashboard` (no built dashboard/dist in CI).
- name: cargo test
run: cargo test --no-default-features --locked
- name: cargo nextest
run: cargo nextest run --no-default-features --locked

frontend:
name: Frontend (dashboard)
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ jobs:
toolchain: "1.94"
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: apps/backend
# Workspace root is the REPO root (Cargo.lock + target/ live there);
# cargo runs from apps/backend but artifacts land in <root>/target.
workspaces: ". -> target"
key: release-${{ matrix.os }}-${{ matrix.arch }}

# Build cargo directly (dist is already present from the artifact); keep the
Expand Down
44 changes: 37 additions & 7 deletions apps/backend/tests/common/test_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
//! - **SQLite** stays `sqlite::memory:` — no Docker, identical to the original
//! behaviour, so a bare `cargo test` keeps working with zero setup.
//! - **Postgres / MySQL** get a fresh `cms_test_<uuidv7>` database created here
//! and dropped on teardown (best-effort). A one-time startup sweep drops any
//! leftover `cms_test_*` databases from a previous aborted run, so local
//! re-runs are self-healing.
//! and dropped on teardown (best-effort). A startup sweep drops leftover
//! `cms_test_*` databases from a previous aborted run (only ones older than
//! [`SWEEP_MIN_AGE_MS`], so concurrent test processes never sweep each
//! other), keeping local re-runs self-healing.

use sqlx::Connection;
use tokio::sync::OnceCell;
Expand Down Expand Up @@ -180,8 +181,35 @@ async fn drop_database(admin_url: &str, db_name: &str, backend: Backend) -> Resu
}

/// Run the leftover-database sweep exactly once per test process.
///
/// Under `cargo nextest` every test runs in its own process, so the sweep runs
/// many times concurrently — the age gate in `sweep_leftovers` is what keeps
/// those concurrent sweeps from dropping each other's live databases.
static SWEEP: OnceCell<()> = OnceCell::const_new();

/// Only databases older than this are swept. Live tests never get close (a
/// single test finishes in seconds); anything past it is a leak from an
/// aborted run.
const SWEEP_MIN_AGE_MS: u64 = 5 * 60 * 1000;

/// Age gate for the sweep: `true` when the database was created more than
/// [`SWEEP_MIN_AGE_MS`] ago. The name embeds a UUIDv7 (`cms_test_<uuid>`) whose
/// first 12 hex chars are the 48-bit unix-ms creation time. Names that don't
/// parse are legacy leftovers — treat as stale so they still get reclaimed.
fn is_stale(db_name: &str) -> bool {
let Some(ts_hex) = db_name.strip_prefix(DB_PREFIX).and_then(|uuid| uuid.get(..12)) else {
return true;
};
let Ok(created_ms) = u64::from_str_radix(ts_hex, 16) else {
return true;
};
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_millis() as u64;
now_ms.saturating_sub(created_ms) > SWEEP_MIN_AGE_MS
}

async fn ensure_swept(admin_url: &str, backend: Backend) {
SWEEP
.get_or_init(|| async {
Expand All @@ -192,9 +220,11 @@ async fn ensure_swept(admin_url: &str, backend: Backend) {
.await;
}

/// Drop every pre-existing `cms_test_*` database — reclaims leaks from a
/// previous run that aborted before teardown. CI containers are ephemeral, so
/// this mainly keeps local dev idempotent.
/// Drop stale `cms_test_*` databases — reclaims leaks from a previous run that
/// aborted before teardown. CI containers are ephemeral, so this mainly keeps
/// local dev idempotent. Only databases older than [`SWEEP_MIN_AGE_MS`] are
/// dropped so concurrent test processes (nextest) can't sweep away databases
/// of tests that are still running.
async fn sweep_leftovers(admin_url: &str, backend: Backend) -> Result<(), sqlx::Error> {
let names: Vec<String> = match backend {
Backend::Postgres => {
Expand Down Expand Up @@ -223,7 +253,7 @@ async fn sweep_leftovers(admin_url: &str, backend: Backend) -> Result<(), sqlx::
Backend::Sqlite => return Ok(()),
};

for name in names {
for name in names.into_iter().filter(|n| is_stale(n)) {
drop_database(admin_url, &name, backend).await?;
}
Ok(())
Expand Down
8 changes: 8 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ ignore = [
# vulnerability and there is no safe upgrade available. Revisit when `image`
# drops the dependency.
"RUSTSEC-2024-0436",
# quick-xml < 0.41 CPU/memory DoS when parsing hostile XML (quadratic
# duplicate-attribute check; unbounded namespace allocations). Pulled in only
# via object_store's S3 client, which parses XML *responses from the
# configured S3 endpoint* — an operator-chosen, trusted-ish peer, not
# arbitrary user input. No upgrade path yet: even object_store 0.14 pins
# quick-xml ^0.40.1 (< 0.41). Revisit when object_store moves to >= 0.41.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
]

[licenses]
Expand Down
Loading