name: CI Pipeline on: push: branches: [main, develop] tags: ['v*', '[0-9]+.*'] pull_request: branches: [main] permissions: contents: read packages: write concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: IMAGE_NAME: christianlouis/docuelevate jobs: # ══════════════════════════════════════════════════════════════════════════ # Stage 1: Static Analysis (Fast Fail Gates) # ══════════════════════════════════════════════════════════════════════════ lint: name: Ruff Lint & Format runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: 'pip' - name: Install Ruff run: pip install ruff - name: Check for merge conflict markers run: | if git grep -rn -E '^(<{7} |>{7} |={7}$)' -- '.'; then echo "ERROR: Merge conflict markers found." exit 1 fi - run: ruff check app/ tests/ - run: ruff format --check app/ tests/ html-lint: name: HTML Accessibility Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: 'pip' - run: pip install djlint>=1.36.0 - run: djlint frontend/templates/ --lint # ══════════════════════════════════════════════════════════════════════════ # Stage 2: Parallel Heavy Lifters (Consolidated for Efficiency) # ══════════════════════════════════════════════════════════════════════════ mypy: name: Mypy Type Check runs-on: ubuntu-latest needs: [lint] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: 'pip' - name: Install Dependencies run: pip install -r requirements-dev.txt - run: mypy app/ dependency-scan: name: Dependency Scan runs-on: ubuntu-latest needs: [lint] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: 'pip' - run: pip install pip-audit>=2.7.0 - run: pip-audit -r requirements.txt --desc on run-tests: name: Execute All Tests (Quick + Integration) runs-on: ubuntu-latest needs: [lint] services: redis: image: redis:7 ports: ["6379:6379"] options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 rabbitmq: image: rabbitmq:3-management ports: ["5672:5672", "15672:15672"] options: --health-cmd "rabbitmq-diagnostics -q ping" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: 'pip' - name: Install Dependencies run: | python -m pip install --upgrade pip pip install -r requirements-dev.txt - name: Run Tests run: > pytest tests/ -v --timeout=300 --cov=app --cov-report=xml:coverage.xml --junitxml=junit.xml -o junit_family=legacy -m "not e2e" - name: Upload Unified Coverage to Codecov if: always() uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml fail_ci_if_error: true # ══════════════════════════════════════════════════════════════════════════ # Stage 3: Build & Push (Quality Gate) # ══════════════════════════════════════════════════════════════════════════ build: name: Build & Push Docker Image runs-on: ubuntu-latest needs: [run-tests, mypy, dependency-scan, html-lint] if: github.event_name == 'push' steps: - name: Checkout Code uses: actions/checkout@v4 - name: Generate Build Metadata run: | chmod +x scripts/generate_build_metadata.sh ./scripts/generate_build_metadata.sh - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata for tags id: meta uses: docker/metadata-action@v5 with: images: | ${{ env.IMAGE_NAME }} ghcr.io/${{ github.repository_owner }}/docuelevate tags: | type=ref,event=branch type=sha,prefix={{branch}}- type=semver,pattern={{version}} type=raw,value=latest,enable={{is_default_branch}} - name: Build and Push Docker Image uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max sbom: true provenance: mode=max # ══════════════════════════════════════════════════════════════════════════ # Stage 4: GitOps Update # ══════════════════════════════════════════════════════════════════════════ update-k8s-manifest: name: Update Preprod K8s Manifest runs-on: ubuntu-latest needs: [build] if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - name: Compute image tag id: tag run: | SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) echo "tag=main-${SHORT_SHA}" >> "$GITHUB_OUTPUT" echo "image=ghcr.io/${{ github.repository_owner }}/docuelevate:main-${SHORT_SHA}" >> "$GITHUB_OUTPUT" - name: Checkout k8s-cluster-state uses: actions/checkout@v4 with: repository: christianlouis/k8s-cluster-state token: ${{ secrets.GH_PAT }} path: k8s-cluster-state - name: Update image tag in preprod manifest uses: mikefarah/yq@v4.44.6 env: IMAGE: ${{ steps.tag.outputs.image }} with: cmd: | yq -i '(.. | select(tag == "!!str") | select(test("^(ghcr\\.io/christianlouis/docuelevate|christianlouis/docuelevate):"))) = strenv(IMAGE)' \ k8s-cluster-state/apps/docuelevate/preprod/docuelevate-stack.yaml - name: Commit and push run: | cd k8s-cluster-state git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add apps/docuelevate/preprod/docuelevate-stack.yaml if git diff --staged --quiet; then echo "No changes to commit" else git commit -m "chore(preprod): update docuelevate image to ${{ steps.tag.outputs.tag }}" git push fi