name: CI on: pull_request: push: branches: - main tags: - "v*" workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: infra: name: Infra (Compose + Scripts) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Validate Docker Compose config run: docker compose -f infra/docker-compose.yml config -q - name: Validate Docker Compose with .env.example run: docker compose -f infra/docker-compose.yml --env-file .env.example config -q - name: Validate root Docker Compose config run: docker compose -f docker-compose.yml config -q - name: Validate root Docker Compose with .env.example run: docker compose -f docker-compose.yml --env-file .env.example config -q - name: Validate startup scripts run: | bash -n start-unified.sh bash -n start.sh bash -n install.sh - name: Run startup preflight tests run: bash scripts/test-start-unified-preflight.sh backend: name: Backend (Go) runs-on: ubuntu-latest defaults: run: working-directory: app/backend steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Go uses: actions/setup-go@v5 with: go-version-file: app/backend/go.mod cache: true - name: Vet run: go vet ./... - name: Run backend tests run: go test ./... frontend: name: Frontend (React) runs-on: ubuntu-latest defaults: run: working-directory: app/frontend steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22" cache: npm cache-dependency-path: app/frontend/package-lock.json - name: Install dependencies run: npm ci - name: Lint run: npm run lint - name: Build (typecheck + bundle) run: npm run build:check - name: Test run: npm test smoke: name: Compose boot smoke runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 - name: Boot stack run: | BETTER_AUTH_SECRET=$(openssl rand -hex 32) BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik - name: Wait for backend health run: | for _ in $(seq 1 60); do if curl -sf http://localhost:8082/health; then exit 0; fi sleep 5 done docker compose -f infra/docker-compose.yml logs --tail 50 backend exit 1 - name: Check frontend responds run: | for _ in $(seq 1 30); do if curl -sf http://localhost:3000/ -o /dev/null; then exit 0; fi sleep 2 done docker compose -f infra/docker-compose.yml logs --tail 50 frontend exit 1 - name: Teardown if: always() run: docker compose -f infra/docker-compose.yml down -v docker: name: Build & publish images runs-on: ubuntu-latest needs: [backend, frontend, smoke] permissions: contents: read packages: write steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GHCR if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Backend image metadata id: meta-backend uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }}-backend tags: | type=ref,event=branch type=ref,event=pr type=ref,event=tag type=semver,pattern={{version}} type=sha,format=short type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Frontend image metadata id: meta-frontend uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }}-frontend tags: | type=ref,event=branch type=ref,event=pr type=ref,event=tag type=semver,pattern={{version}} type=sha,format=short type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Build backend image uses: docker/build-push-action@v6 with: context: app/backend load: true push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta-backend.outputs.tags }} labels: ${{ steps.meta-backend.outputs.labels }} cache-from: type=gha,scope=backend cache-to: type=gha,mode=max,scope=backend - name: Build frontend image uses: docker/build-push-action@v6 with: context: app/frontend load: true push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta-frontend.outputs.tags }} labels: ${{ steps.meta-frontend.outputs.labels }} cache-from: type=gha,scope=frontend cache-to: type=gha,mode=max,scope=frontend - name: Scan backend image uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: ${{ fromJSON(steps.meta-backend.outputs.json).tags[0] }} format: table exit-code: "0" ignore-unfixed: true vuln-type: os,library severity: CRITICAL,HIGH - name: Scan frontend image uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: ${{ fromJSON(steps.meta-frontend.outputs.json).tags[0] }} format: table exit-code: "0" ignore-unfixed: true vuln-type: os,library severity: CRITICAL,HIGH