name: Gitea Actions CI on: push: branches: [master, main] pull_request: # Single persistent vm-runner with host-network job containers: serialize runs # so PostgreSQL/Redis service ports never collide across concurrent runs. concurrency: group: gitea-actions-ci cancel-in-progress: false jobs: # Jobs activate only when their locked dependency manifests exist. detect: runs-on: ubuntu-latest outputs: backend: ${{ steps.dirs.outputs.backend }} frontend: ${{ steps.dirs.outputs.frontend }} steps: - uses: actions/checkout@v4 - id: dirs run: | echo "backend=$([ -f backend/uv.lock ] && echo true || echo false)" >> "$GITHUB_OUTPUT" echo "frontend=$([ -f frontend/pnpm-lock.yaml ] && echo true || echo false)" >> "$GITHUB_OUTPUT" backend: name: Backend (lint / type / test / build) needs: detect if: needs.detect.outputs.backend == 'true' runs-on: ubuntu-latest defaults: run: working-directory: backend services: postgres: image: postgres:17-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: ${{ secrets.CI_POSTGRES_PASSWORD }} POSTGRES_DB: finance_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 12 redis: image: redis:7-alpine ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 5s --health-retries 12 env: DATABASE_URL: postgresql+psycopg://postgres:${{ secrets.CI_POSTGRES_PASSWORD }}@localhost:5432/finance_test REDIS_URL: redis://localhost:6379/0 CELERY_BROKER_URL: redis://localhost:6379/1 IMPORTS_KEK_B64: ${{ secrets.CI_IMPORTS_KEK_B64 }} CI_RLS_DATABASE_PASSWORD: ${{ secrets.CI_RLS_DATABASE_PASSWORD }} MINIO_ROOT_PASSWORD: ${{ secrets.CI_MINIO_ROOT_PASSWORD }} RLS_DATABASE_URL: postgresql+psycopg://finance_app_test:${{ secrets.CI_RLS_DATABASE_PASSWORD }}@localhost:5432/finance_test S3_ENDPOINT_URL: http://localhost:9000 S3_REGION: us-east-1 S3_ACCESS_KEY_ID: minioadmin S3_SECRET_ACCESS_KEY: ${{ secrets.CI_MINIO_ROOT_PASSWORD }} S3_BUCKET_STATEMENTS: finance-statements steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 with: python-version: "3.14" enable-cache: true cache-python: true cache-dependency-glob: backend/uv.lock - name: Install dependencies run: uv sync --locked - name: Lint (ruff check) run: uv run ruff check . - name: Format (ruff format) run: uv run ruff format --check . - name: Type check (ty) run: uv run ty check - name: Create restricted app role and migrate run: | uv run python -c "import os; from psycopg import sql; from sqlalchemy import create_engine,text; e=create_engine(os.environ['DATABASE_URL']); c=e.connect(); raw=c.connection.driver_connection; role=sql.SQL('CREATE ROLE finance_app_test LOGIN PASSWORD {} NOSUPERUSER NOBYPASSRLS').format(sql.Literal(os.environ['CI_RLS_DATABASE_PASSWORD'])).as_string(raw); c.exec_driver_sql(role); c.execute(text('GRANT CONNECT ON DATABASE finance_test TO finance_app_test')); c.execute(text('GRANT USAGE ON SCHEMA public TO finance_app_test')); c.execute(text('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO finance_app_test')); c.commit(); c.close(); e.dispose()" uv run alembic upgrade head # docker.io denies minio/minio from this network (run 246); quay.io serves it. # Unique container name keeps parallel/retried runs off each other. - name: Start MinIO (S3-compatible test storage) run: | docker run -d --name minio-ci-${{ github.run_id }}-${{ github.run_attempt }} --network host \ -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD \ quay.io/minio/minio:latest server /data for i in $(seq 1 30); do if curl -sf http://localhost:9000/minio/health/live > /dev/null; then exit 0 fi sleep 2 done docker logs minio-ci-${{ github.run_id }}-${{ github.run_attempt }} echo "MinIO did not become healthy" && exit 1 - name: Test (pytest) run: uv run pytest -ra - name: Build provider-neutral image run: docker build -f ../infra/docker/Dockerfile.backend -t finance-api:ci-${{ github.run_id }}-${{ github.run_attempt }} . - name: Cleanup Docker resources if: always() run: | docker rm -f minio-ci-${{ github.run_id }}-${{ github.run_attempt }} || true docker rmi -f finance-api:ci-${{ github.run_id }}-${{ github.run_attempt }} || true frontend: name: Frontend (lint / type / test / build) needs: detect if: needs.detect.outputs.frontend == 'true' runs-on: ubuntu-latest defaults: run: working-directory: frontend steps: - uses: actions/checkout@v4 # pnpm 10 is required: frontend/pnpm-workspace.yaml is config-only # (allowBuilds) and pnpm 9 rejects it with "packages field missing or empty". - uses: pnpm/action-setup@v4 with: version: 10 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm cache-dependency-path: frontend/pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile - name: Lint run: pnpm lint - name: Type check run: pnpm typecheck - name: Test (vitest, non-watch) run: pnpm test - name: Build run: pnpm build compose: name: Compose & nginx validation runs-on: ubuntu-latest env: # Unique project name per run: this runner reuses the host, and the # compose file pins "name: finance", which would collide across runs. COMPOSE_PROJECT_NAME: finance-ci-${{ github.run_id }}-${{ github.run_attempt }} # Backend job binds 5432/6379/9000 on the shared host network in the # same run, so compose services use non-overlapping host ports. POSTGRES_HOST_PORT: "15432" REDIS_HOST_PORT: "16379" MINIO_API_HOST_PORT: "19000" MINIO_CONSOLE_HOST_PORT: "19001" MAILPIT_SMTP_HOST_PORT: "11025" MAILPIT_UI_HOST_PORT: "18025" steps: - uses: actions/checkout@v4 - name: Prepare env from example (placeholders only) run: cp infra/.env.example infra/.env - name: Validate compose file run: docker compose -f infra/docker-compose.yml config -q # docker.io denies minio/minio from this network (run 246); pre-pull from # quay.io and retag so compose finds the image locally and never pulls # the blocked reference. - name: Pre-pull MinIO via quay.io run: | docker pull quay.io/minio/minio:latest docker tag quay.io/minio/minio:latest minio/minio:latest - name: Boot infra services and wait for healthchecks run: docker compose -f infra/docker-compose.yml up -d --wait --wait-timeout 180 postgres redis minio mailpit - name: Show service status run: docker compose -f infra/docker-compose.yml ps - name: Validate nginx config run: | docker run --rm --network none --add-host api:127.0.0.1 \ -v "$PWD/infra/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \ nginx:1.27-alpine nginx -t - name: Teardown if: always() run: docker compose -f infra/docker-compose.yml down -v --remove-orphans