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
164 changes: 164 additions & 0 deletions .github/CI-CD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# CI/CD Guide

How automated checks and deployment work for this project.

**Contributing?** You only need the [CI](#ci-what-runs-on-your-pr) section —
it explains what runs on your pull request and how to reproduce any failure
locally. The deployment section is for maintainers with production access.

---

## CI (what runs on your PR)

Every pull request runs [`ci.yml`](workflows/ci.yml). Six jobs run in parallel,
so you get all the failures at once instead of one at a time.

| Job | What it checks |
|---|---|
| **Lint & type-check** | ESLint and TypeScript across every workspace, then a full build |
| **Prisma schema** | Migrations apply cleanly to a fresh database, and `schema.prisma` matches them |
| **Python checks** | The GenAI service compiles, and `ruff` passes |
| **Build images** | All four Dockerfiles still build |
| **Validate compose** | Both `docker-compose` files are well-formed |
| **Secret scan** | No credentials committed anywhere in history |

### Reproducing a failure locally

Run the same commands CI does, from the repo root:

```bash
# Lint & type-check
pnpm run lint
pnpm run check-types
pnpm run build

# Python checks (needs apps/genAI/.venv, see below)
cd apps/genAI
python -m compileall -q app
pip install ruff && ruff check app

# Docker build (one service)
docker build -f apps/frontend/Dockerfile.prod .

# Compose validation
docker compose -f docker-compose.yml config --quiet
```

For Prisma, CI applies migrations to a throwaway database. To do the same
against your local one:

```bash
cd packages/db
pnpm exec prisma validate
pnpm exec prisma migrate deploy
```

If you changed `schema.prisma`, you must also commit a migration. CI fails
otherwise:

```bash
cd packages/db
pnpm exec prisma migrate dev --name describe_your_change
```

### Working on the GenAI service

The Python service is not covered by `pnpm install`. Set it up once:

```bash
cd apps/genAI
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env # then add your own API keys
```

It needs free API keys from [Groq](https://console.groq.com/keys),
[Google AI Studio](https://aistudio.google.com/apikey), and
[Tavily](https://tavily.com). Without them the service still starts and
`/health` works — only the AI endpoints return an error telling you which key
is missing.

### Things to know

-**ESLint currently reports pre-existing errors** in the frontend. That step
is set to report without blocking, so it will not fail your PR. Please do not
add new ones — and a PR that cleans them up is welcome.
-**Ruff is enforced** for Python. Most issues auto-fix with
`ruff check app --fix`.
-**Docker builds are cached**, but a cold run takes a while. The GenAI image
is the slowest because of its ML dependencies.

---

## Deployment (maintainers)

>This section needs Docker Hub and production server access. Contributors can
>skip it — deploys happen automatically after a PR is merged.

Pushing to `main` triggers [`cd.yml`](workflows/cd.yml), which builds and
pushes four images to Docker Hub, scans them with Trivy, then deploys over SSH.

### Required secrets

Configured under **Settings → Secrets and variables → Actions**.

Repository secrets:

| Secret | Purpose |
|---|---|
| `DOCKERHUB_USERNAME` | Docker Hub account owning the images |
| `DOCKERHUB_TOKEN` | Docker Hub access token, not a password |
| `DATABASE_URL` | Build arg so `prisma generate` can run during the image build |

Environment secrets, on an environment named `production`:

| Secret | Purpose |
|---|---|
| `SSH_HOST` | Server hostname or IP |
| `SSH_USER` | User permitted to run `docker` |
| `SSH_PRIVATE_KEY` | Private key, full PEM including BEGIN/END lines |
| `SSH_PORT` | Optional, defaults to `22` |
| `DEPLOY_PATH` | Absolute path to the repo checkout on the server |

Keeping deploy credentials on the environment rather than the repository limits
them to the `deploy` job, and lets you require a reviewer before anything
reaches production (**Environments → production → Required reviewers**).

### Server prerequisites

The pipeline does not bootstrap the server. Before the first deploy:

1. Docker and the Compose plugin installed.
2. Repository cloned at `DEPLOY_PATH`.
3. `.env.prod` present there with production values: `POSTGRES_USER`,
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `DATABASE_URL`, `REDIS_PASSWORD`,
`JWT_SECRET`, `FRONTEND_URL`, the GenAI keys, and `ALLOWED_ORIGINS`.
4. The public key matching `SSH_PRIVATE_KEY` in `~/.ssh/authorized_keys`.

`.env.prod` is never committed and never written by the pipeline. It lives only
on the server.

### Image tags and rollback

Every build publishes `:latest` and `:<commit-sha>`. The deploy pins the SHA, so
the running version is always unambiguous — and rolling back is one command:

```bash
cd <DEPLOY_PATH>
IMAGE_TAG=<last-good-sha> docker compose -f docker-compose.prod.yml up -d
```

### Database migrations

Migrations are **not** run by the deploy job. The `backend` service applies them
itself on startup, via its compose command. CI proves on every PR that the
migration history still applies to a clean database, so a broken migration is
caught before it reaches the server.

### Known gaps

-Trivy scanning is report-only; findings appear in logs but do not block a
deploy.
-No staging environment — `main` goes straight to production.
-`docker compose up -d` recreates changed containers, so deploys have brief
downtime. There is no rolling update.
166 changes: 166 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
name: CD

on:
push:
branches: [main]
# Allows a manual redeploy from the Actions tab without a new commit.
workflow_dispatch:

# Never let two deploys touch the server at once, and do not cancel a
# deploy midway: a half-applied rollout is worse than a queued one.
concurrency:
group: production-deploy
cancel-in-progress: false

permissions:
contents: read

env:
REGISTRY_NAMESPACE: codeheist

jobs:
build-and-push:
name: Build & push ${{ matrix.service }}
runs-on: ubuntu-latest
timeout-minutes: 45

strategy:
fail-fast: false
matrix:
include:
- service: frontend
image: rexial-frontend
dockerfile: apps/frontend/Dockerfile.prod
- service: http-server
image: rexial-http-server
dockerfile: apps/http-server/Dockerfile.prod
- service: ws-server
image: rexial-ws-server
dockerfile: apps/ws-server/Dockerfile.prod
- service: genai
image: rexial-genai
dockerfile: apps/genAI/Dockerfile.prod

steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
push: true
# Tag with the commit SHA as well as latest, so a bad deploy can
# be rolled back to an exact known-good image.
tags: |
${{ env.REGISTRY_NAMESPACE }}/${{ matrix.image }}:latest
${{ env.REGISTRY_NAMESPACE }}/${{ matrix.image }}:${{ github.sha }}
build-args: |
DATABASE_URL=${{ secrets.DATABASE_URL }}
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}

- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: ${{ env.REGISTRY_NAMESPACE }}/${{ matrix.image }}:${{ github.sha }}
severity: HIGH,CRITICAL
ignore-unfixed: true
format: table
# Reported, not enforced: a CVE in a base image should not block
# a deploy until you have decided how to triage it.
exit-code: "0"

deploy:
name: Deploy to production
needs: build-and-push
runs-on: ubuntu-latest
timeout-minutes: 20

# Attach to a GitHub Environment so you can require manual approval
# and scope the deploy secrets to production only.
environment:
name: production

steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT || 22 }}
script_stops: true
script: |
set -euo pipefail

cd ${{ secrets.DEPLOY_PATH }}

# Take the exact commit this workflow built.
git fetch --all
git checkout main
git reset --hard ${{ github.sha }}

# Pin to this commit's images rather than :latest, so the
# deployed version is unambiguous and rollback is exact.
export IMAGE_TAG=${{ github.sha }}

echo "Pulling images tagged $IMAGE_TAG"
docker compose -f docker-compose.prod.yml pull \
frontend backend ws-server genai

# Migrations are not run here: the backend service applies
# them itself on startup via its compose command
# (db:generate:prod && db:migrate && start).
echo "Starting services"
docker compose -f docker-compose.prod.yml up -d

echo "Waiting for containers to settle"
sleep 20

docker compose -f docker-compose.prod.yml ps

# Fail the deploy if any expected service is not running.
for svc in frontend backend ws-server genai db redis; do
state=$(docker compose -f docker-compose.prod.yml ps \
--format '{{.State}}' "$svc" || true)
echo " $svc: ${state:-missing}"
if [ "$state" != "running" ]; then
echo "ERROR: $svc is not running"
docker compose -f docker-compose.prod.yml logs --tail 50 "$svc"
exit 1
fi
done

docker image prune -f
echo "Deployment complete"

rollback-hint:
name: Report failure
needs: deploy
if: failure()
runs-on: ubuntu-latest

steps:
- name: Explain how to roll back
run: |
echo "Deploy of ${{ github.sha }} failed."
echo ""
echo "Every build is tagged with its commit SHA, so rolling back"
echo "is a matter of pinning the last known-good one:"
echo ""
echo " ssh <user>@<host>"
echo " cd <deploy path>"
echo " IMAGE_TAG=<last-good-sha> \\"
echo " docker compose -f docker-compose.prod.yml up -d"
echo ""
echo "Past tags: https://hub.docker.com/r/codeheist/rexial-http-server/tags"
exit 1
Loading
Loading