Skip to content
Open
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
97 changes: 97 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: CI/CD

on:
workflow_dispatch:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
ci:
name: Run Tests
runs-on: ubuntu-latest

services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: texet
POSTGRES_USER: texet
POSTGRES_PASSWORD: texet_ci_password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U texet -d texet"
--health-interval 5s
--health-timeout 3s
--health-retries 20

env:
DATABASE_URL: postgresql+asyncpg://texet:texet_ci_password@127.0.0.1:5432/texet
DATABASE_URL_TEST: postgresql+asyncpg://texet:texet_ci_password@127.0.0.1:5432/texet_test
OPENAI_API_KEY: ci-test-key
OPENAI_MODEL: gpt-4o-mini
SMS_OUTBOUND_URL: ""
ADMIN_USERNAME: admin
ADMIN_PASSWORD: admin
ADMIN_SECRET_KEY: ci-admin-secret

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Install dependencies
run: uv sync --frozen

- name: Run tests with coverage
run: uv run pytest --cov

deploy_backend:
name: Deploy Backend to Elastic Beanstalk
runs-on: ubuntu-latest
needs: ci
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
environment: production
concurrency:
group: eb-production-deploy
cancel-in-progress: false
permissions:
id-token: write
contents: read

env:
AWS_REGION: us-east-1

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
aws-region: ${{ env.AWS_REGION }}

- name: Deploy to Elastic Beanstalk
uses: aws-actions/aws-elasticbeanstalk-deploy@v1.0.0
with:
aws-region: ${{ env.AWS_REGION }}
application-name: ${{ secrets.AWS_BACKEND_APPLICATION_NAME }}
environment-name: ${{ secrets.AWS_PROD_BACKEND_ENVIRONMENT_NAME }}
version-label: ${{ github.sha }}
create-application-if-not-exists: false
create-environment-if-not-exists: false
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,12 @@ uses Docker Compose; use `uv` when you need to add/upgrade packages or run local
- `make migration name=...` creates a new Alembic revision (requires the DB running).
- `make migrate` applies Alembic migrations (requires the DB running).

### CI/CD

- Workflow: `.github/workflows/ci-cd.yml`
- Quick onboarding and maintenance guide: `docs/ci-cd.md`
- Production runbook (developers, operators, researchers, students): `docs/operations-playbook.md`

## Notes

- Do not commit `.env` files or real API keys. Rotate any keys that have been shared.
Expand Down
49 changes: 49 additions & 0 deletions docs/ci-cd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# CI/CD Quick Guide

## What runs on GitHub

- Workflow file: `.github/workflows/ci-cd.yml`
Triggers:
- `pull_request`: run CI only.
- `push` to `main`: run CI, then deploy if CI passes.
- `workflow_dispatch`: optional manual run.

## CI (tests)

- Job: `ci`
- Runner: `ubuntu-latest`
- Database: Postgres service container (`postgres:16-alpine`)
- Test command: `uv run pytest --cov`
DB URLs are provided via:
- `DATABASE_URL`
- `DATABASE_URL_TEST`

Why Postgres is required:

- Test fixtures create/reset `texet_test` and apply Alembic migrations before tests.
- Many tests assert database behavior directly.

No-internet test guard:

- `tests/conftest.py` blocks external hostname resolution during tests.
- Only loopback and configured DB hosts are allowed.
- If a test accidentally calls a real external API, it fails fast.

## CD (Elastic Beanstalk)

- Job: `deploy_backend`
- Runs only on `push` to `main` and only after CI succeeds.
- Uses GitHub Environment: `production` (for protection rules/approvals).
- Auth: GitHub OIDC with `aws-actions/configure-aws-credentials@v4`.
- Deploy: `aws-actions/aws-elasticbeanstalk-deploy@v1.0.0`.

Required GitHub secrets:

- `AWS_ACCOUNT_ID`
- `AWS_ROLE_NAME`
- `AWS_BACKEND_APPLICATION_NAME`
- `AWS_PROD_BACKEND_ENVIRONMENT_NAME`

## Notes

- If test setup changes, update CI env vars and test fixtures together.
55 changes: 55 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from __future__ import annotations

import asyncio
import ipaddress
import os
import socket
from collections.abc import Generator
from pathlib import Path

import asyncpg
Expand Down Expand Up @@ -38,6 +41,58 @@ def _load_env_file(path: Path) -> None:
_load_env_file(PROJECT_ROOT / ".env.db")


def _allowed_test_hosts() -> set[str]:
hosts = {"localhost", "127.0.0.1", "::1", "db"}
for env_name in ("DATABASE_URL_TEST", "DATABASE_URL"):
raw_url = os.getenv(env_name)
if not raw_url:
continue
parsed = make_url(raw_url)
if parsed.host:
hosts.add(parsed.host)
return {host.lower() for host in hosts}


def _is_allowed_host(host: object, allowed_hosts: set[str]) -> bool:
if host is None:
return True
if isinstance(host, bytes):
host = host.decode("utf-8", errors="ignore")
if not isinstance(host, str):
return True

normalized = host.strip().lower()
if not normalized:
return True
if normalized in allowed_hosts:
return True

try:
ip = ipaddress.ip_address(normalized)
except ValueError:
return False
return ip.is_loopback


@pytest.fixture(scope="session", autouse=True)
def block_external_network() -> Generator[None, None, None]:
"""Block accidental internet calls while allowing local/test DB traffic."""

allowed_hosts = _allowed_test_hosts()
real_getaddrinfo = socket.getaddrinfo

def guarded_getaddrinfo(host: object, *args: object, **kwargs: object) -> object:
if _is_allowed_host(host, allowed_hosts):
return real_getaddrinfo(host, *args, **kwargs)
raise RuntimeError(f"External network access is blocked during tests: host={host!r}")

socket.getaddrinfo = guarded_getaddrinfo
try:
yield
finally:
socket.getaddrinfo = real_getaddrinfo


async def _ensure_test_db(database_url: str, reset: bool = False) -> None:
url = make_url(database_url)
if not url.database:
Expand Down