GitHub Actions CI/CD — Lint, Type, Test, Seed, Deploy
What? (Concept Overview)
A CI/CD pipeline gates every PR on lint + type-check + tests against a real Postgres + Redis service container and ships every merge to OCI Container Registry on GitHub, then triggers a remote redeploy over SSH. Convention over configuration: maintainers review GitHub Actions YAML, not bespoke shell scripts. The pipeline is reproducible because service containers create ephemeral dependencies on every run.
Project Context
The FCA Support Agent ships two workflows:
.github/workflows/ci.yml— lint (Ruff), type-check (mypy), test (pytest with coverage) gated on a Postgres + Redis service container.github/workflows/deploy.yml— Build + push web/frontend container images to GHCR, then SSH into the target server to pull and restart
How? (Quick Reference Blocks)
3.1 CI Workflow Skeleton
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg15
env:
POSTGRES_USER: fca_user
POSTGRES_PASSWORD: fca_password
POSTGRES_DB: fca_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U fca_user"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports: ["6379:6379"]
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: "3.12"}
- run: pip install -r requirements.txt
- run: ruff check . # lint
- run: mypy app # type-check
- run: pytest -v --cov=app
env:
DATABASE_URL: postgresql+asyncpg://fca_user:fca_password@localhost:5432/fca_test
REDIS_URL: redis://localhost:6379/03.2 Deploy Workflow — Build & Push Multi-Image
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push web image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository_owner }}/fca-multi-agent-support/fca-app:latest
- name: Build & push frontend image
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
tags: ghcr.io/${{ github.repository_owner }}/fca-multi-agent-support/fca-frontend:latest3.3 Trigger Remote Redeploy Over SSH
# .github/workflows/deploy.yml — tail
- name: Trigger remote redeploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_SSH_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd ~/fca-multi-agent-support
docker compose pull
docker compose up -d
docker image prune -fWhy? (Parameter Breakdown
- Service containers
--health-cmd— GitHub Actions supports healthcheck-based gating; the runner waits until Postgres (pg_isready) and Redis (redis-cli ping) report ready before running tests. Without this, tests racepip installagainst DB startup and produce flaky failures. postgres+asyncpgURL inside CI — Mirrors prod; tests that use a sync URL hide async-specific bugs (asyncpg.OperationalError: too many connections).pip install -r requirements.txtinstead ofpip install -e .— The lockfile (pip-compile →requirements.txt) is the source of truth for prod; PRs must NOT introduce unconstrained deps. Use-eonly for packages that change alongside this repo.- Multi-image mono-build in deploy — Two builds (web + frontend) emit separately because they have different Dockerfiles and different consumers. Single-image deploy would bundle Streamlit + FastAPI needlessly; multi-image deploys auto-scale them independently.
docker compose pull && up— Always pull so the local cache doesn’t serve a stale image tag. Theprune -fcleans dangling images so disk usage doesn’t grow unbounded on long-lived deploy hosts.- SSH trigger (not kubernetes manifest edit) — Simplest deploy mechanism for a single-VM setup. Skip it when you scale: a GitHub-Actions → Helm/ArgoCD pipeline is the multi-cluster upgrade.
Common Pitfalls
- Caching pip without a key that invalidates on
requirements.txtchange. Piperequirements.txthash intoactions/cache@v4key; otherwise stale cache ships broken installs in CI. - Running tests without
pytest-asynciomode declared. Async tests in pytest need@pytest.mark.asyncioorasyncio: autoinpyproject.toml. CI fails with “coroutine was never awaited” without it.
Real-World Interview Prep
Q1: How would you migrate this single-VM deploy pipeline to Kubernetes?
A: Three migrations. (1) Replace SSH deploy step with kubectl apply -f k8s/ after build. (2) Move secrets to Kubernetes secrets bound from a sealed-secrets controller (Bitnami sealed-secrets, External Secrets Operator). (3) Replace docker compose pull with kubectl set image deployment/fca-web fca-web=<new-tag> --record for rolling updates. Add a canary step that ships to 10% of pods via Argo Rollouts and waits for the SLO.
Q2: Your CI is slow. First three things you’d profile?
A: (1) pip install time → cache with actions/setup-python@v5 + cache: 'pip'. (2) Postgres fixture boot → reuse the service container across jobs. (3) Test discovery → split unit/integration tests; run unit tests on every PR, integration tests only on main. Beyond that, parallel matrix builds by Python version.
Q3: Why appleboy/ssh-action rather than fabric or ansible?
A: Native GitHub Actions integration; no Python runtime on the runner required; secret injection is built-in. fabric/ansible would require installing Python dependencies on the runner and managing credential storage separately. The trade-off: appleboy/ssh-action is great for one-line deploy scripts, but for complex multi-step orchestration move to Ansible or Pulumi.
Top-to-Bottom Code Walkthrough (.github/workflows/ci.yml + .github/workflows/deploy.yml)
CI/CD is the last line of defence. No PR merges without green tests; no production deploy without a passing CI.
ci.yml — triggered on push and pull_request
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg15
env:
POSTGRES_USER: fca_user
POSTGRES_PASSWORD: fca_password
POSTGRES_DB: fca_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U fca_user"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379GitHub Actions service containers spin up Postgres and Redis as sidecars. The CI test runner connects to them via localhost:5432 and localhost:6379 — same URLs the app uses.
Steps
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Lint
run: |
black --check app/
isort --check app/
flake8 app/
- name: Type-check
run: mypy app/
- name: Test
env:
DATABASE_URL: postgresql+asyncpg://fca_user:fca_password@localhost:5432/fca_test
REDIS_URL: redis://localhost:6379/0
run: pytest --cov=app --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v4actions/checkout@v4— clone the repo.actions/setup-python@v5— install Python 3.11 + cache pip packages for speed.- Lint step —
black --check,isort --check,flake8enforce style. - Type-check —
mypy app/static analysis. - Test step — runs pytest with coverage; env vars point at the service containers.
- Coverage upload — pushes the
coverage.xmlartifact to codecov.io.
What blocks a PR
Any failure stops the merge button. Coverage drop below threshold fails the build.
deploy.yml — triggered on tag push
name: Deploy
on:
push:
tags: ['v*.*.*']
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t fca-app:${{ github.ref_name }} .
- name: Push to GHCR
run: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker push ghcr.io/davidsandeep1996-spec/fca-multi-agent-support/fca-app:${{ github.ref_name }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: SSH to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/fca
git pull
docker compose pull
docker compose up -dTag-triggered deploy — pushing v1.2.3 builds the image and sshes to the VPS to run docker compose pull && up -d. Manual deploys are still possible via the Actions UI.
Common Pitfalls
Caching secrets in workflow logs — the secrets.* reference is automatically redacted; never echo $GITHUB_TOKEN plain.
Skipping actions/setup-python cache: 'pip' — without cache, install takes 3 minutes instead of 30 seconds. Set cache key on requirements.txt hash.
Allowing merges with failing tests — branch protection on main requires ci.yml to pass. Don’t disable without a written exception.
Real-World Interview Prep
Q1: Why service containers instead of mocking databases?
A: Service containers run the real database. Mocking might miss connection-pool issues, race conditions, or SQL syntax errors. Real Postgres-in-CI gives high confidence the test passes in production too.
Q2: What’s the trade-off between GHCR (free, public) and private registries?
A: GHCR is free for public images. For private BFSI workloads, use AWS ECR / GCP Artifact Registry. Costs are minor but VPC isolation matters for compliance.
Q3: How do you roll back a bad deploy?
A: Re-tag the previous working image as :latest and ssh into the VPS: docker compose pull && docker compose up -d. Add a “rollback” job in Actions that’s a one-click button.