ci: add Forgejo CI (lint + unit + Postgres integration) + ci-requirements.md
Single-repo CI now that plugins are bundled in-tree. Three lanes on push to dev/main, modeled on FabledCurator's canonical ci.yml (minus frontend/Redis): - lint: ruff check steward/ plugins/ tests/ (no dep install) - unit: pytest -m 'not integration' — whole current suite (testing=True mocks DB) - integration: postgres:16-alpine service via socket-discovered bridge IP; a boot-and-migrate test creates the real app, running core + all bundled-plugin migrations, then round-trips the DB — guards the folded-in migration graph. Registers the 'integration' pytest marker; adds tests/integration/test_boot.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user