From 6130c186b178929bca8dc0afcdae29e2fc0504bd Mon Sep 17 00:00:00 2001 From: Factory Test Date: Wed, 30 Sep 2026 07:03:15 -0500 Subject: [PATCH 1/2] Fix runtime SQLAlchemy asyncio dependency and test production migrations --- .github/workflows/ci.yml | 46 ++++++++++++++++++++++- CONTRIBUTING.md | 7 ++++ pyproject.toml | 6 ++- tests/test_production_dependency_audit.py | 21 +++++++++++ uv.lock | 9 ++++- 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbfbcce70..f8052170a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,48 @@ jobs: if-no-files-found: ignore retention-days: 7 + production-runtime-migrations: + name: Production Runtime Migrations + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + CI: true + DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/comic_pile_test + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: comic_pile_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + python-version: "3.14" + enable-cache: true + + # Match Deploy Production exactly; dev tools can mask missing runtime + # dependencies (Playwright brings greenlet into the ordinary CI image). + - name: Install locked runtime dependencies + run: uv sync --locked --no-dev + + - name: Verify async runtime imports + run: .venv/bin/python -c "import greenlet; import app.database; import app.models" + + - name: Apply all migrations from scratch with production dependencies + run: .venv/bin/alembic upgrade head + migration-health: name: Migration Health runs-on: ubuntu-latest @@ -319,7 +361,7 @@ jobs: check-all-jobs: name: CI Summary runs-on: ubuntu-latest - needs: [build, frontend-typecheck, python-lint, python-typecheck, frontend-unit-tests, ui-audit, migration-health, test-unit] + needs: [build, frontend-typecheck, python-lint, python-typecheck, frontend-unit-tests, ui-audit, production-runtime-migrations, migration-health, test-unit] if: always() steps: - name: Check job results @@ -330,6 +372,7 @@ jobs: echo "Python Type Check (ty): ${{ needs.python-typecheck.result }}" echo "Frontend Unit Tests: ${{ needs.frontend-unit-tests.result }}" echo "Rendered UI Audit: ${{ needs.ui-audit.result }}" + echo "Production Runtime Migrations: ${{ needs.production-runtime-migrations.result }}" echo "Migration Health: ${{ needs.migration-health.result }}" echo "Backend Tests: ${{ needs.test-unit.result }}" if [ "${{ needs.build.result }}" != "success" ] || \ @@ -338,6 +381,7 @@ jobs: [ "${{ needs.python-typecheck.result }}" != "success" ] || \ [ "${{ needs.frontend-unit-tests.result }}" != "success" ] || \ [ "${{ needs.ui-audit.result }}" != "success" ] || \ + [ "${{ needs.production-runtime-migrations.result }}" != "success" ] || \ [ "${{ needs.migration-health.result }}" != "success" ] || \ [ "${{ needs.test-unit.result }}" != "success" ]; then echo "::error::One or more required CI jobs failed. Please review the failures." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a182f6696..0eddc1773 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,13 @@ Fix the problem instead. 3. Run `make dev` and open the app at `http://localhost:5173`. 4. API docs are available at `http://localhost:8000/docs`. +Production migrations install dependencies with `uv sync --locked --no-dev`. +This keeps the `migrate` and `server` default groups but excludes development +tools. Keep `sqlalchemy[asyncio]` in the project dependencies: both the async +application and Alembic's model imports require greenlet. The Production +Runtime Migrations CI job tests this install separately from the dev image, +where Playwright can otherwise mask a missing greenlet dependency. + ### API Development - REST endpoints are defined in `app/api/` diff --git a/pyproject.toml b/pyproject.toml index f5f716ed4..310527bbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,9 @@ dependencies = [ # until the route-introspection layer is migrated off the internals that # 0.137.0 documented as no longer public API. "fastapi>=0.100.0,<0.137.0", - "sqlalchemy>=2.0.53", + # Async application imports also run while Alembic loads model metadata. + # SQLAlchemy 2.1 requires the asyncio extra to install greenlet. + "sqlalchemy[asyncio]>=2.0.53", "pillow>=11.0.0", "pydantic[email]>=2.0.0", "python-multipart>=0.0.6", @@ -177,4 +179,4 @@ LOG_LEVEL = "debug" SECRET_KEY = "dev-secret-key-change-in-production" AUTO_BACKUP_ENABLED = "false" PORT = "8000" -typeStubPaths = [] \ No newline at end of file +typeStubPaths = [] diff --git a/tests/test_production_dependency_audit.py b/tests/test_production_dependency_audit.py index 911c6fb1c..59c26f368 100644 --- a/tests/test_production_dependency_audit.py +++ b/tests/test_production_dependency_audit.py @@ -139,6 +139,27 @@ def test_default_groups_cover_all_non_vercel_runtimes() -> None: assert default_groups == ["dev", "migrate", "server"] +def test_async_database_runtime_installs_greenlet_without_dev_tools() -> None: + """SQLAlchemy async support must not depend on Playwright's dev dependency.""" + dependencies = _pyproject()["project"]["dependencies"] + assert any(dependency.startswith("sqlalchemy[asyncio]") for dependency in dependencies) + + package = _lock_project_package() + sqlalchemy = next(item for item in package["dependencies"] if item["name"] == "sqlalchemy") + assert "asyncio" in sqlalchemy["extra"] + sqlalchemy_metadata = next( + item for item in package["metadata"]["requires-dist"] if item["name"] == "sqlalchemy" + ) + assert "asyncio" in sqlalchemy_metadata["extras"] + + locked_sqlalchemy = next( + item for item in _lockfile()["package"] if item["name"] == "sqlalchemy" + ) + assert any( + item["name"] == "greenlet" for item in locked_sqlalchemy["optional-dependencies"]["asyncio"] + ) + + def test_lockfile_project_metadata_matches_the_slim_set() -> None: """uv.lock must agree with the trimmed production dependency set.""" package = _lock_project_package() diff --git a/uv.lock b/uv.lock index 65b280a64..d947c5128 100644 --- a/uv.lock +++ b/uv.lock @@ -261,7 +261,7 @@ dependencies = [ { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, { name = "slowapi" }, - { name = "sqlalchemy" }, + { name = "sqlalchemy", extra = ["asyncio"] }, { name = "upstash-redis" }, ] @@ -311,7 +311,7 @@ requires-dist = [ { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "python-multipart", specifier = ">=0.0.6" }, { name = "slowapi", specifier = ">=0.1.9" }, - { name = "sqlalchemy", specifier = ">=2.0.53" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.53" }, { name = "upstash-redis", specifier = ">=1.7.0" }, ] @@ -1427,6 +1427,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/7a/7d1879d78c086737acee5f3250cbff9d285e5bd7cb6bc2c730f24e8d400e/sqlalchemy-2.1.1-py3-none-any.whl", hash = "sha256:4357c1a222e141662251a59d3702f9b124294941171dfa6bd570513e9f4905ee", size = 2047471, upload-time = "2026-09-25T15:20:37.797Z" }, ] +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "starlette" version = "1.0.0" From 217e3cfad67ad6ab7239a4de0195e53984bd08f2 Mon Sep 17 00:00:00 2001 From: Factory Test Date: Wed, 30 Sep 2026 07:12:14 -0500 Subject: [PATCH 2/2] Clear repository-local Git state before pre-push validation --- .githooks/pre-push | 6 ++ tests/test_pre_push_git_environment.py | 79 ++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tests/test_pre_push_git_environment.py diff --git a/.githooks/pre-push b/.githooks/pre-push index c2800edb8..29a9d1d92 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -3,6 +3,12 @@ set -euo pipefail +# Git exports repository-local settings to hooks. Tests create independent +# repositories, so do not let those children inherit this worktree's Git state. +while IFS= read -r git_local_variable; do + unset "$git_local_variable" +done < <(git rev-parse --local-env-vars) + echo "Running pre-push CI parity checks..." # Ensure we're running at repo root diff --git a/tests/test_pre_push_git_environment.py b/tests/test_pre_push_git_environment.py new file mode 100644 index 000000000..21c605d45 --- /dev/null +++ b/tests/test_pre_push_git_environment.py @@ -0,0 +1,79 @@ +"""Regression coverage for repository isolation during pre-push validation.""" + +import os +import subprocess +from pathlib import Path + + +def test_pre_push_clears_repository_local_git_environment(tmp_path: Path) -> None: + """Run every hook gate while ensuring child Git commands use their own repo.""" + hook = Path(__file__).resolve().parents[1] / ".githooks" / "pre-push" + local_variables = subprocess.check_output( + ["git", "rev-parse", "--local-env-vars"], text=True + ).splitlines() + environment = {key: value for key, value in os.environ.items() if key not in local_variables} + repository = tmp_path / "repository" + repository.mkdir() + subprocess.run(["git", "init", "-q", str(repository)], env=environment, check=True) + subprocess.run( + ["git", "-C", str(repository), "config", "user.email", "test@example.com"], + env=environment, + check=True, + ) + subprocess.run( + ["git", "-C", str(repository), "config", "user.name", "Test"], + env=environment, + check=True, + ) + tree = subprocess.check_output( + ["git", "-C", str(repository), "mktree"], input="", text=True, env=environment + ).strip() + commit = subprocess.check_output( + ["git", "-C", str(repository), "commit-tree", tree, "-m", "seed"], + text=True, + env=environment, + ).strip() + subprocess.run( + ["git", "-C", str(repository), "update-ref", "HEAD", commit], + env=environment, + check=True, + ) + + (repository / "pyproject.toml").touch() + (repository / "frontend" / "node_modules").mkdir(parents=True) + (repository / "scripts").mkdir() + (repository / "scripts" / "lint.sh").write_text("exit 0\n") + tools_directory = tmp_path / "tools" + tools_directory.mkdir() + for tool in ("python", "pnpm"): + executable = tools_directory / tool + executable.write_text( + "#!/bin/bash\n" + "set -eu\n" + "for variable in GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_CONFIG_PARAMETERS; do\n" + ' if [[ -v "$variable" ]]; then echo "Leaked $variable" >&2; exit 1; fi\n' + "done\n" + 'printf "%s\\n" "$*" >> "$GATE_LOG"\n' + ) + executable.chmod(0o755) + gate_log = tmp_path / "gates.log" + environment.update( + { + "PATH": f"{tools_directory}:{environment['PATH']}", + "VIRTUAL_ENV": str(tmp_path / "venv"), + "GIT_DIR": str(repository / ".git"), + "GIT_WORK_TREE": str(repository), + "GIT_INDEX_FILE": str(repository / ".git" / "index"), + "GIT_CONFIG_PARAMETERS": "'core.hooksPath'='/unexpected/hooks'", + "GATE_LOG": str(gate_log), + } + ) + result = subprocess.run( + ["bash", str(hook)], cwd=repository, env=environment, capture_output=True, text=True + ) + assert result.returncode == 0, result.stdout + result.stderr + assert gate_log.read_text().splitlines() == [ + "-m pytest tests/ --cov=comic_pile --cov-report=xml", + "test", + "run audit:ui", + ]