diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..251d2bd --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,92 @@ +name: CI + +# CI lanes per FabledRulebook forgejo.md "CI philosophy": +# - lint: ruff only, no dep install — fast-fail for the common lint bounce. +# - unit: pytest -m "not integration"; no service containers, no DB. +# - integration: postgres service container; boots the real app + runs all +# (core + bundled-plugin) migrations. +# Single repo: first-party plugins are bundled in-tree under plugins/, so the +# unit lane imports them directly — no cross-repo checkout. + +on: + push: + branches: [dev, main] + # pull_request intentionally absent — push on [dev, main] already fires CI for + # every dev commit and dev→main PRs. Single-operator repo, no fork PRs. + +jobs: + # Fast-fail lint lane. ruff is pre-installed in the ci-python image, so this + # runs with NO dependency install and surfaces lint bounces in seconds. + lint: + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v4 + - name: Ruff lint + run: ruff check steward/ plugins/ tests/ + + unit: + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v4 + - name: Install Steward + test deps + # uv: 5-10x faster wheel resolve than pip on cold caches. Falls back to + # pip on uv-missing runners. Steward declares its deps in pyproject; the + # [dev] extra adds pytest + pytest-asyncio. + run: | + if command -v uv >/dev/null 2>&1; then + uv pip install --system -e '.[dev]' + else + pip install -e '.[dev]' + fi + - name: Pytest (unit only — integration runs in its own lane) + run: pytest tests/ -v -m "not integration" + + integration: + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + env: + STEWARD_SECRET_KEY: ci_integration_placeholder + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: steward + POSTGRES_PASSWORD: steward + POSTGRES_DB: steward + options: >- + --health-cmd "pg_isready -U steward" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - name: Boot + migrate against Postgres + # act_runner (swarm-runner) puts service containers on the default bridge + # with no embedded DNS, so the hostname `postgres` won't resolve — we + # discover the service container's bridge IP via the docker socket. The + # job name `integration` (no underscore) is the docker-ps name filter. + run: | + set -euxo pipefail + echo "=== container landscape (diagnostic for filter scoping) ===" + docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' + echo "=== end landscape ===" + PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1) + test -n "$PG" + PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") + test -n "$PG_IP" + export STEWARD_DATABASE_URL="postgresql+asyncpg://steward:steward@$PG_IP:5432/steward" + for i in $(seq 1 60); do + (echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break + sleep 2 + done + if command -v uv >/dev/null 2>&1; then + uv pip install --system -e '.[dev]' + else + pip install -e '.[dev]' + fi + pytest tests/integration -v -m integration --durations=10 diff --git a/ci-requirements.md b/ci-requirements.md new file mode 100644 index 0000000..01a73e4 --- /dev/null +++ b/ci-requirements.md @@ -0,0 +1,47 @@ +# CI Requirements — Steward + +> Spec: https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md + +## Runtime image + +git.fabledsword.com/bvandeusen/ci-python:3.14 + +## Image deps used + +- python 3.14 +- ruff (analyzer for `steward/`, `plugins/`, `tests/`) +- docker CLI (integration lane: bridge-IP discovery of the Postgres service container) + +## Per-job tool installs + +- `uv pip install --system -e '.[dev]'` (pip fallback) — in `unit` and `integration` + jobs. Steward declares its deps in `pyproject.toml`; the `[dev]` extra adds + `pytest` + `pytest-asyncio`. No `requirements.txt` is tracked. + +## Lanes + +- **lint** — `ruff check steward/ plugins/ tests/`, no dep install. +- **unit** — `pytest -m "not integration"`. The whole current suite runs here: + it builds the app with `testing=True`, which mocks `db_sessionmaker`, so no DB + is touched. First-party plugins are bundled in-tree under `plugins/`, so plugin + unit tests import them directly — no cross-repo checkout. +- **integration** — `pytest tests/integration -m integration` against a + `postgres:16-alpine` service. The single test boots the real app + (`create_app(testing=False)`), which runs core + every bundled plugin's + migrations, then round-trips the DB. This is the guard that the folded-in + plugin migration graph resolves. + +## Notes + +- Steward has **no frontend and no Redis/Celery** (no external workers), so there + is no frontend-build lane and the only service container is Postgres. +- Integration uses Forgejo Actions `services:` + a socket-discovered bridge IP + because `act_runner` (swarm-runner v0.6+) puts services on the default bridge + with no embedded DNS. FabledCurator's `ci.yml` is the canonical example of the + pattern; Steward's is the same shape minus Redis and minus sharding. +- The job name `integration` has no underscore — `act_runner` strips underscores + from job names when building service-container labels, so the `docker ps` + name filter uses the bare job name. +- Migrations run via the app itself (`create_app` → `run_core_migrations`, async + through `asyncpg`), so the integration lane needs no separate `alembic upgrade` + step and no psycopg/sync driver. diff --git a/pyproject.toml b/pyproject.toml index 465001e..fe4179c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ steward = "steward.cli:main" [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +markers = [ + "integration: requires a live Postgres; runs only in the integration CI lane", +] [tool.hatch.build.targets.wheel] packages = ["steward"] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_boot.py b/tests/integration/test_boot.py new file mode 100644 index 0000000..5ef5bad --- /dev/null +++ b/tests/integration/test_boot.py @@ -0,0 +1,42 @@ +"""Integration: boot the real app against a live Postgres. + +Creating the app with testing=False runs the core Alembic migrations AND every +bundled plugin's migrations (discovered across all plugin roots). This is the +canonical guard that the folded-in plugin migration graph resolves end-to-end — +a pure-unit test can't catch a broken plugin revision chain. + +Requires STEWARD_DATABASE_URL pointing at a live Postgres; the integration CI +lane provides it. Skipped automatically when unset so the unit lane stays green. +""" +from __future__ import annotations + +import asyncio +import os + +import pytest + +pytestmark = pytest.mark.integration + + +@pytest.mark.skipif( + not os.environ.get("STEWARD_DATABASE_URL"), + reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)", +) +def test_real_boot_runs_all_migrations(): + from sqlalchemy import text + + from steward.app import create_app + + # testing=False → init_db + run_core_migrations (core + all bundled-plugin + # migrations) + load_settings_sync + load_plugins all execute here. + app = create_app(testing=False) + assert app is not None + + async def _roundtrip(): + async with app.db_sessionmaker() as session: + # app_settings exists only if core migrations actually applied. + result = await session.execute(text("SELECT COUNT(*) FROM app_settings")) + return result.scalar() + + count = asyncio.run(_roundtrip()) + assert count is not None