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
6 changes: 6 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 45 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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" ] || \
Expand All @@ -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."
Expand Down
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -177,4 +179,4 @@ LOG_LEVEL = "debug"
SECRET_KEY = "dev-secret-key-change-in-production"
AUTO_BACKUP_ENABLED = "false"
PORT = "8000"
typeStubPaths = []
typeStubPaths = []
79 changes: 79 additions & 0 deletions tests/test_pre_push_git_environment.py
Original file line number Diff line number Diff line change
@@ -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",
]
21 changes: 21 additions & 0 deletions tests/test_production_dependency_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 7 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading