docs(portfolio): highlight live bookstore demo #98
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI/CD Pipeline | |
| on: | |
| workflow_dispatch: | |
| push: | |
| branches: [master, develop] | |
| pull_request: | |
| branches: [master, develop] | |
| permissions: | |
| contents: read | |
| env: | |
| JAVA_VERSION: "17" | |
| NODE_VERSION: "20" | |
| DOCKERHUB_NAMESPACE_DEFAULT: nguyenson1710 | |
| jobs: | |
| backend-test: | |
| name: Backend Test (MySQL 8.0) | |
| runs-on: ubuntu-latest | |
| concurrency: | |
| group: ci-backend-test-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| MYSQL_HOST: 127.0.0.1 | |
| MYSQL_PORT: 3306 | |
| MYSQL_DATABASE: bookstore_test | |
| MYSQL_USERNAME: bookstore | |
| MYSQL_PASSWORD: bookpass | |
| services: | |
| mysql: | |
| image: mysql:8.0 | |
| env: | |
| MYSQL_ROOT_PASSWORD: root | |
| MYSQL_DATABASE: bookstore_test | |
| MYSQL_USER: bookstore | |
| MYSQL_PASSWORD: bookpass | |
| ports: | |
| - 3306:3306 | |
| options: >- | |
| --health-cmd="mysqladmin ping -u root -proot" | |
| --health-interval=10s | |
| --health-timeout=5s | |
| --health-retries=15 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Java ${{ env.JAVA_VERSION }} | |
| uses: actions/setup-java@v4 | |
| with: | |
| java-version: ${{ env.JAVA_VERSION }} | |
| distribution: temurin | |
| cache: maven | |
| - name: Install readiness clients | |
| run: sudo apt-get update && sudo apt-get install -y netcat-openbsd default-mysql-client | |
| - name: Make scripts executable | |
| run: chmod +x scripts/wait-for-services.sh | |
| - name: Wait for MySQL TCP port | |
| run: ./scripts/wait-for-services.sh --timeout 180 --interval 5 ${MYSQL_HOST}:${MYSQL_PORT} | |
| - name: Wait for MySQL query readiness | |
| run: | | |
| for attempt in $(seq 1 60); do | |
| if mysqladmin ping --protocol=tcp -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USERNAME}" -p"${MYSQL_PASSWORD}" --silent >/dev/null 2>&1 && \ | |
| mysql --protocol=tcp -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USERNAME}" -p"${MYSQL_PASSWORD}" -D "${MYSQL_DATABASE}" -e "SELECT 1" >/dev/null 2>&1; then | |
| echo "MySQL is accepting authenticated queries." | |
| exit 0 | |
| fi | |
| echo "Waiting for MySQL authenticated query readiness (${attempt}/60)..." | |
| sleep 3 | |
| done | |
| echo "MySQL did not become query-ready within the expected time window." | |
| exit 1 | |
| - name: Debug MySQL runner state on failure | |
| if: failure() | |
| run: | | |
| docker ps -a | |
| sudo ss -ltnp || true | |
| mysqladmin ping --protocol=tcp -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USERNAME}" -p"${MYSQL_PASSWORD}" || true | |
| mysql --protocol=tcp -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USERNAME}" -p"${MYSQL_PASSWORD}" -e "SHOW DATABASES;" || true | |
| - name: Run backend tests | |
| run: | | |
| cd backend | |
| mvn clean test \ | |
| -Dspring.datasource.url=jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE} \ | |
| -Dspring.datasource.username=${MYSQL_USERNAME} \ | |
| -Dspring.datasource.password=${MYSQL_PASSWORD} \ | |
| -Dspring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver \ | |
| -Dspring.jpa.database-platform=org.hibernate.dialect.MySQLDialect \ | |
| -DskipFrontendBuild=true | |
| - name: Upload backend test results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: backend-test-results | |
| path: backend/target/surefire-reports/ | |
| retention-days: 30 | |
| if-no-files-found: ignore | |
| - name: Generate backend coverage report | |
| run: | | |
| cd backend | |
| mvn jacoco:report -DskipTests=true | |
| - name: Check backend coverage thresholds | |
| run: | | |
| cd backend | |
| LINE_COVERAGE=$(awk -F, 'NR > 1 { missed += $8; covered += $9 } END { total = missed + covered; if (total == 0) print 0; else printf "%.0f", (covered / total) * 100 }' target/site/jacoco/jacoco.csv) | |
| echo "Backend line coverage: ${LINE_COVERAGE}%" | |
| if [ "${LINE_COVERAGE}" -lt 50 ]; then | |
| echo "ERROR: Backend coverage (${LINE_COVERAGE}%) is below threshold (50%)" | |
| exit 1 | |
| fi | |
| echo "Backend coverage check passed: ${LINE_COVERAGE}%" | |
| - name: Upload backend coverage | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: backend-coverage | |
| path: backend/target/site/jacoco/ | |
| if-no-files-found: ignore | |
| - name: Publish backend coverage to Codecov | |
| uses: codecov/codecov-action@v4 | |
| with: | |
| files: backend/target/site/jacoco/jacoco.xml | |
| flags: backend | |
| name: backend | |
| fail_ci_if_error: false | |
| frontend-test: | |
| name: Frontend Test (Vitest) | |
| runs-on: ubuntu-latest | |
| concurrency: | |
| group: ci-frontend-test-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Node.js ${{ env.NODE_VERSION }} | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: npm | |
| cache-dependency-path: frontend/package-lock.json | |
| - name: Install frontend dependencies | |
| run: | | |
| cd frontend | |
| npm ci | |
| - name: Run frontend unit tests | |
| run: | | |
| cd frontend | |
| npm run test:run -- --reporter=verbose | |
| - name: Upload frontend test results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: frontend-test-results | |
| path: | | |
| frontend/coverage/ | |
| frontend/src/test-results/ | |
| retention-days: 30 | |
| if-no-files-found: ignore | |
| - name: Run frontend coverage | |
| run: | | |
| cd frontend | |
| npm run test:coverage -- --reporter=verbose | |
| - name: Check frontend coverage thresholds | |
| run: | | |
| cd frontend | |
| LINE_COVERAGE=$(node -e "const fs=require('fs'); const file='coverage/coverage-summary.json'; if (!fs.existsSync(file)) { process.stdout.write('0'); process.exit(0); } const summary=JSON.parse(fs.readFileSync(file, 'utf8')); process.stdout.write(String(Math.floor(summary.total.lines.pct || 0)));") | |
| echo "Frontend line coverage: ${LINE_COVERAGE}%" | |
| if [ "${LINE_COVERAGE}" -lt 15 ]; then | |
| echo "ERROR: Frontend coverage (${LINE_COVERAGE}%) is below threshold (15%)" | |
| exit 1 | |
| fi | |
| echo "Frontend coverage check passed: ${LINE_COVERAGE}%" | |
| - name: Publish frontend coverage to Codecov | |
| uses: codecov/codecov-action@v4 | |
| with: | |
| files: frontend/coverage/coverage-final.json | |
| flags: frontend | |
| name: frontend | |
| fail_ci_if_error: false | |
| docker-publish-backend: | |
| name: Docker Publish Backend (GHCR disabled) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 75 | |
| needs: [backend-test, frontend-test, frontend-build, e2e-test, code-quality, security] | |
| if: ${{ false }} | |
| concurrency: | |
| group: ci-docker-publish-backend-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Resolve backend image version | |
| id: backend-version | |
| run: | | |
| version=$(awk ' | |
| /<artifactId>ecommerce-bookstore<\/artifactId>/ { capture=1; next } | |
| capture && /<version>/ { | |
| line = $0 | |
| sub(/^.*<version>/, "", line) | |
| sub(/<\/version>.*$/, "", line) | |
| print line | |
| exit | |
| } | |
| ' backend/pom.xml) | |
| if [ -z "${version}" ]; then | |
| echo "Failed to resolve backend version from backend/pom.xml" >&2 | |
| exit 1 | |
| fi | |
| major="${version%%.*}" | |
| echo "version=${version}" >> "$GITHUB_OUTPUT" | |
| echo "major=${major}" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "tags<<EOF" | |
| if [ "${GITHUB_REF}" = "refs/heads/master" ]; then | |
| echo "type=raw,value=latest" | |
| echo "type=raw,value=v${version}" | |
| echo "type=raw,value=v${major}" | |
| else | |
| echo "type=raw,value=develop" | |
| echo "type=raw,value=develop-v${version}" | |
| fi | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Extract metadata (backend) | |
| id: meta-backend | |
| uses: docker/metadata-action@v5 | |
| with: | |
| images: ${{ env.GHCR_BACKEND_IMAGE }} | |
| tags: ${{ steps.backend-version.outputs.tags }} | |
| labels: | | |
| org.opencontainers.image.version=${{ steps.backend-version.outputs.version }} | |
| - name: Build and push backend Docker image | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: ./Dockerfile.backend | |
| push: true | |
| tags: ${{ steps.meta-backend.outputs.tags }} | |
| labels: ${{ steps.meta-backend.outputs.labels }} | |
| cache-from: type=gha,scope=backend-image | |
| cache-to: type=gha,mode=max,scope=backend-image | |
| platforms: linux/amd64,linux/arm64 | |
| docker-publish-frontend: | |
| name: Docker Publish Frontend (GHCR disabled) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 75 | |
| needs: [backend-test, frontend-test, frontend-build, e2e-test, code-quality, security] | |
| if: ${{ false }} | |
| concurrency: | |
| group: ci-docker-publish-frontend-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Resolve frontend image version | |
| id: frontend-version | |
| run: | | |
| version=$(python - <<'PY' | |
| import json | |
| from pathlib import Path | |
| print(json.loads(Path("frontend/package.json").read_text(encoding="utf-8"))["version"]) | |
| PY | |
| ) | |
| if [ -z "${version}" ]; then | |
| echo "Failed to resolve frontend version from frontend/package.json" >&2 | |
| exit 1 | |
| fi | |
| major="${version%%.*}" | |
| echo "version=${version}" >> "$GITHUB_OUTPUT" | |
| echo "major=${major}" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "tags<<EOF" | |
| if [ "${GITHUB_REF}" = "refs/heads/master" ]; then | |
| echo "type=raw,value=latest" | |
| echo "type=raw,value=v${version}" | |
| echo "type=raw,value=v${major}" | |
| else | |
| echo "type=raw,value=develop" | |
| echo "type=raw,value=develop-v${version}" | |
| fi | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Extract metadata (frontend) | |
| id: meta-frontend | |
| uses: docker/metadata-action@v5 | |
| with: | |
| images: ${{ env.GHCR_FRONTEND_IMAGE }} | |
| tags: ${{ steps.frontend-version.outputs.tags }} | |
| labels: | | |
| org.opencontainers.image.version=${{ steps.frontend-version.outputs.version }} | |
| - name: Build and push frontend Docker image | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: ./Dockerfile.frontend | |
| push: true | |
| tags: ${{ steps.meta-frontend.outputs.tags }} | |
| labels: ${{ steps.meta-frontend.outputs.labels }} | |
| build-args: | | |
| APP_GIT_SHA=${{ github.sha }} | |
| APP_VERSION=${{ steps.frontend-version.outputs.version }} | |
| cache-from: type=gha,scope=frontend-image | |
| cache-to: type=gha,mode=max,scope=frontend-image | |
| platforms: linux/amd64 | |
| dockerhub-publish-backend: | |
| name: Docker Publish Backend (Docker Hub) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 75 | |
| needs: [backend-test, frontend-test, frontend-build, e2e-test, code-quality, security] | |
| if: github.event_name == 'push' && (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master') | |
| concurrency: | |
| group: ci-dockerhub-publish-backend-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Resolve Docker Hub target | |
| id: dockerhub | |
| env: | |
| DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} | |
| DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} | |
| DOCKERHUB_NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE }} | |
| run: | | |
| if [ -z "${DOCKERHUB_USERNAME}" ] || [ -z "${DOCKERHUB_TOKEN}" ]; then | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| echo "namespace=" >> "$GITHUB_OUTPUT" | |
| echo "Docker Hub secrets are not configured. Skipping backend publish." | |
| exit 0 | |
| fi | |
| namespace="${DOCKERHUB_NAMESPACE:-$DOCKERHUB_NAMESPACE_DEFAULT}" | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| echo "namespace=${namespace}" >> "$GITHUB_OUTPUT" | |
| - name: Set up Docker Buildx | |
| if: steps.dockerhub.outputs.enabled == 'true' | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Log in to Docker Hub | |
| if: steps.dockerhub.outputs.enabled == 'true' | |
| uses: docker/login-action@v3 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Resolve backend Docker Hub version | |
| if: steps.dockerhub.outputs.enabled == 'true' | |
| id: dockerhub-backend-version | |
| run: | | |
| version=$(awk ' | |
| /<artifactId>ecommerce-bookstore<\/artifactId>/ { capture=1; next } | |
| capture && /<version>/ { | |
| line = $0 | |
| sub(/^.*<version>/, "", line) | |
| sub(/<\/version>.*$/, "", line) | |
| print line | |
| exit | |
| } | |
| ' backend/pom.xml) | |
| if [ -z "${version}" ]; then | |
| echo "Failed to resolve backend version from backend/pom.xml" >&2 | |
| exit 1 | |
| fi | |
| major="${version%%.*}" | |
| echo "version=${version}" >> "$GITHUB_OUTPUT" | |
| echo "major=${major}" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "tags<<EOF" | |
| if [ "${GITHUB_REF}" = "refs/heads/master" ]; then | |
| echo "type=raw,value=latest" | |
| echo "type=raw,value=v${version}" | |
| echo "type=raw,value=v${major}" | |
| else | |
| echo "type=raw,value=develop" | |
| echo "type=raw,value=develop-v${version}" | |
| fi | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Extract metadata (backend Docker Hub) | |
| if: steps.dockerhub.outputs.enabled == 'true' | |
| id: meta-dockerhub-backend | |
| uses: docker/metadata-action@v5 | |
| with: | |
| images: docker.io/${{ steps.dockerhub.outputs.namespace }}/ecommerce-bookstore-backend | |
| tags: ${{ steps.dockerhub-backend-version.outputs.tags }} | |
| labels: | | |
| org.opencontainers.image.version=${{ steps.dockerhub-backend-version.outputs.version }} | |
| - name: Build and push backend Docker Hub image | |
| if: steps.dockerhub.outputs.enabled == 'true' | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: ./Dockerfile.backend | |
| push: true | |
| tags: ${{ steps.meta-dockerhub-backend.outputs.tags }} | |
| labels: ${{ steps.meta-dockerhub-backend.outputs.labels }} | |
| cache-from: type=gha,scope=backend-dockerhub-image | |
| cache-to: type=gha,mode=max,scope=backend-dockerhub-image | |
| platforms: linux/amd64,linux/arm64 | |
| - name: Summarize backend Docker Hub image | |
| if: always() | |
| run: | | |
| { | |
| echo "### Docker Hub backend publish" | |
| if [ "${{ steps.dockerhub.outputs.enabled }}" = "true" ]; then | |
| echo "- Image: \`docker.io/${{ steps.dockerhub.outputs.namespace }}/ecommerce-bookstore-backend\`" | |
| echo "- Version: \`v${{ steps.dockerhub-backend-version.outputs.version }}\`" | |
| if [ "${GITHUB_REF_NAME}" = "master" ]; then | |
| echo "- Tags: \`latest\`, \`v${{ steps.dockerhub-backend-version.outputs.version }}\`, \`v${{ steps.dockerhub-backend-version.outputs.major }}\`" | |
| else | |
| echo "- Tags: \`develop\`, \`develop-v${{ steps.dockerhub-backend-version.outputs.version }}\`" | |
| fi | |
| else | |
| echo "- Skipped: Docker Hub secrets are not configured." | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| dockerhub-publish-frontend: | |
| name: Docker Publish Frontend (Docker Hub) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 75 | |
| needs: [backend-test, frontend-test, frontend-build, e2e-test, code-quality, security] | |
| if: github.event_name == 'push' && (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master') | |
| concurrency: | |
| group: ci-dockerhub-publish-frontend-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Resolve Docker Hub target | |
| id: dockerhub | |
| env: | |
| DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} | |
| DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} | |
| DOCKERHUB_NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE }} | |
| run: | | |
| if [ -z "${DOCKERHUB_USERNAME}" ] || [ -z "${DOCKERHUB_TOKEN}" ]; then | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| echo "namespace=" >> "$GITHUB_OUTPUT" | |
| echo "Docker Hub secrets are not configured. Skipping frontend publish." | |
| exit 0 | |
| fi | |
| namespace="${DOCKERHUB_NAMESPACE:-$DOCKERHUB_NAMESPACE_DEFAULT}" | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| echo "namespace=${namespace}" >> "$GITHUB_OUTPUT" | |
| - name: Set up Docker Buildx | |
| if: steps.dockerhub.outputs.enabled == 'true' | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Log in to Docker Hub | |
| if: steps.dockerhub.outputs.enabled == 'true' | |
| uses: docker/login-action@v3 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Resolve frontend Docker Hub version | |
| if: steps.dockerhub.outputs.enabled == 'true' | |
| id: dockerhub-frontend-version | |
| run: | | |
| version=$(python - <<'PY' | |
| import json | |
| from pathlib import Path | |
| print(json.loads(Path("frontend/package.json").read_text(encoding="utf-8"))["version"]) | |
| PY | |
| ) | |
| if [ -z "${version}" ]; then | |
| echo "Failed to resolve frontend version from frontend/package.json" >&2 | |
| exit 1 | |
| fi | |
| major="${version%%.*}" | |
| echo "version=${version}" >> "$GITHUB_OUTPUT" | |
| echo "major=${major}" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "tags<<EOF" | |
| if [ "${GITHUB_REF}" = "refs/heads/master" ]; then | |
| echo "type=raw,value=latest" | |
| echo "type=raw,value=v${version}" | |
| echo "type=raw,value=v${major}" | |
| else | |
| echo "type=raw,value=develop" | |
| echo "type=raw,value=develop-v${version}" | |
| fi | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Extract metadata (frontend Docker Hub) | |
| if: steps.dockerhub.outputs.enabled == 'true' | |
| id: meta-dockerhub-frontend | |
| uses: docker/metadata-action@v5 | |
| with: | |
| images: docker.io/${{ steps.dockerhub.outputs.namespace }}/ecommerce-bookstore-frontend | |
| tags: ${{ steps.dockerhub-frontend-version.outputs.tags }} | |
| labels: | | |
| org.opencontainers.image.version=${{ steps.dockerhub-frontend-version.outputs.version }} | |
| - name: Build and push frontend Docker Hub image | |
| if: steps.dockerhub.outputs.enabled == 'true' | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: ./Dockerfile.frontend | |
| push: true | |
| tags: ${{ steps.meta-dockerhub-frontend.outputs.tags }} | |
| labels: ${{ steps.meta-dockerhub-frontend.outputs.labels }} | |
| build-args: | | |
| APP_GIT_SHA=${{ github.sha }} | |
| APP_VERSION=${{ steps.dockerhub-frontend-version.outputs.version }} | |
| cache-from: type=gha,scope=frontend-dockerhub-image | |
| cache-to: type=gha,mode=max,scope=frontend-dockerhub-image | |
| platforms: linux/amd64 | |
| - name: Summarize frontend Docker Hub image | |
| if: always() | |
| run: | | |
| { | |
| echo "### Docker Hub frontend publish" | |
| if [ "${{ steps.dockerhub.outputs.enabled }}" = "true" ]; then | |
| echo "- Image: \`docker.io/${{ steps.dockerhub.outputs.namespace }}/ecommerce-bookstore-frontend\`" | |
| echo "- Version: \`v${{ steps.dockerhub-frontend-version.outputs.version }}\`" | |
| if [ "${GITHUB_REF_NAME}" = "master" ]; then | |
| echo "- Tags: \`latest\`, \`v${{ steps.dockerhub-frontend-version.outputs.version }}\`, \`v${{ steps.dockerhub-frontend-version.outputs.major }}\`" | |
| else | |
| echo "- Tags: \`develop\`, \`develop-v${{ steps.dockerhub-frontend-version.outputs.version }}\`" | |
| fi | |
| else | |
| echo "- Skipped: Docker Hub secrets are not configured." | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| frontend-build: | |
| name: Frontend Build | |
| runs-on: ubuntu-latest | |
| needs: [frontend-test] | |
| concurrency: | |
| group: ci-frontend-build-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Node.js ${{ env.NODE_VERSION }} | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: npm | |
| cache-dependency-path: frontend/package-lock.json | |
| - name: Install dependencies | |
| run: | | |
| cd frontend | |
| npm ci | |
| - name: Build frontend | |
| run: | | |
| cd frontend | |
| npm run build | |
| - name: Upload build artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: frontend-build | |
| path: frontend/.next/ | |
| retention-days: 7 | |
| if-no-files-found: ignore | |
| e2e-test: | |
| name: E2E Test (Playwright) | |
| runs-on: ubuntu-latest | |
| needs: [backend-test, frontend-build] | |
| concurrency: | |
| group: ci-e2e-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Node.js ${{ env.NODE_VERSION }} | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: npm | |
| cache-dependency-path: frontend/package-lock.json | |
| - name: Install frontend dependencies | |
| run: | | |
| cd frontend | |
| npm ci | |
| - name: Make scripts executable | |
| run: chmod +x scripts/wait-for-services.sh scripts/run-e2e.sh | |
| - name: Install netcat | |
| run: sudo apt-get update && sudo apt-get install -y netcat-openbsd | |
| - name: Start E2E stack with Docker Compose | |
| run: | | |
| docker compose -f docker-compose.e2e.yml \ | |
| --profile e2e \ | |
| up --build -d mysql backend frontend | |
| - name: Wait for MySQL | |
| run: ./scripts/wait-for-services.sh --timeout 180 --interval 5 127.0.0.1:3307 | |
| - name: Wait for backend | |
| run: ./scripts/wait-for-services.sh --timeout 180 --interval 10 http://127.0.0.1:8081/api/actuator/health/liveness | |
| - name: Wait for frontend | |
| run: ./scripts/wait-for-services.sh --timeout 120 --interval 5 http://127.0.0.1:3001/ | |
| - name: Install Playwright browsers | |
| run: | | |
| cd frontend | |
| npx playwright install --with-deps chromium | |
| - name: Fix Playwright output directory permissions | |
| run: | | |
| sudo rm -rf frontend/test-results frontend/playwright-report | |
| mkdir -p frontend/test-results frontend/playwright-report | |
| - name: Run Playwright portfolio smoke | |
| run: | | |
| cd frontend | |
| npm run test:e2e:portfolio -- --reporter=html,line --retries=3 | |
| env: | |
| BASE_URL: http://localhost:3001 | |
| API_URL: http://localhost:3001/api | |
| PLAYWRIGHT_BASE_URL: http://localhost:3001 | |
| - name: Upload Playwright test results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-test-results | |
| path: | | |
| frontend/playwright-report/ | |
| frontend/test-results/ | |
| retention-days: 30 | |
| - name: Upload Playwright screenshots | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-screenshots | |
| path: frontend/test-results/ | |
| retention-days: 30 | |
| - name: Upload Playwright HTML report | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-html-report | |
| path: frontend/playwright-report/ | |
| retention-days: 30 | |
| - name: Stop E2E stack | |
| if: always() | |
| run: | | |
| docker compose -f docker-compose.e2e.yml --profile e2e down -v --remove-orphans | |
| code-quality: | |
| name: Code Quality | |
| runs-on: ubuntu-latest | |
| concurrency: | |
| group: ci-code-quality-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Determine diff base | |
| id: diff-base | |
| run: | | |
| if [[ "${{ github.event_name }}" == "pull_request" ]]; then | |
| echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT" | |
| elif [[ -n "${{ github.event.before }}" && "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then | |
| echo "base=${{ github.event.before }}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "base=$(git rev-parse HEAD^)" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Setup Java ${{ env.JAVA_VERSION }} | |
| uses: actions/setup-java@v4 | |
| with: | |
| java-version: ${{ env.JAVA_VERSION }} | |
| distribution: temurin | |
| cache: maven | |
| - name: Run backend changed-file Checkstyle | |
| run: | | |
| mapfile -t backend_files < <(bash scripts/collect-changed-files.sh "${{ steps.diff-base.outputs.base }}" "${GITHUB_SHA}" | grep '^backend/src/.*\.java$' | sed 's#^backend/##' || true) | |
| if [[ "${#backend_files[@]}" -eq 0 ]]; then | |
| echo "No changed backend Java files to check." | |
| exit 0 | |
| fi | |
| includes=$(IFS=,; echo "${backend_files[*]}") | |
| cd backend | |
| mvn -q checkstyle:check "-Dcheckstyle.includes=${includes}" | |
| - name: Setup Node.js ${{ env.NODE_VERSION }} | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: npm | |
| cache-dependency-path: frontend/package-lock.json | |
| - name: Install frontend dependencies | |
| run: | | |
| cd frontend | |
| npm ci | |
| - name: Run frontend linter | |
| run: | | |
| cd frontend | |
| npm run lint | |
| - name: Run frontend changed-file Prettier check | |
| run: | | |
| mapfile -t frontend_files < <(bash scripts/collect-changed-files.sh "${{ steps.diff-base.outputs.base }}" "${GITHUB_SHA}" | grep '^frontend/src/.*\.\(ts\|tsx\|js\|jsx\)$' || true) | |
| if [[ "${#frontend_files[@]}" -eq 0 ]]; then | |
| echo "No changed frontend source files to format-check." | |
| exit 0 | |
| fi | |
| cd frontend | |
| for i in "${!frontend_files[@]}"; do | |
| frontend_files[$i]="${frontend_files[$i]#frontend/}" | |
| done | |
| npx prettier --check "${frontend_files[@]}" | |
| security: | |
| name: Security Scan | |
| permissions: | |
| contents: read | |
| security-events: write | |
| runs-on: ubuntu-latest | |
| concurrency: | |
| group: ci-security-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Generate Trivy SARIF report | |
| uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 | |
| with: | |
| scan-type: fs | |
| scan-ref: . | |
| scanners: vuln | |
| format: sarif | |
| output: trivy-results.sarif | |
| severity: HIGH,CRITICAL | |
| ignore-unfixed: true | |
| exit-code: "0" | |
| timeout: 20m | |
| version: v0.69.3 | |
| - name: Upload Trivy scan results | |
| if: always() | |
| continue-on-error: true | |
| uses: github/codeql-action/upload-sarif@v3 | |
| with: | |
| sarif_file: trivy-results.sarif | |
| - name: Enforce high and critical vulnerability baseline | |
| uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 | |
| with: | |
| scan-type: fs | |
| scan-ref: . | |
| scanners: vuln | |
| format: table | |
| severity: HIGH,CRITICAL | |
| ignore-unfixed: true | |
| exit-code: "1" | |
| timeout: 20m | |
| version: v0.69.3 | |
| deploy-staging: | |
| name: Deploy to Staging (Render) | |
| runs-on: ubuntu-latest | |
| needs: [backend-test, frontend-test, code-quality, dockerhub-publish-backend, dockerhub-publish-frontend] | |
| if: github.ref == 'refs/heads/develop' && github.event_name == 'push' | |
| concurrency: | |
| group: ci-deploy-staging-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: false | |
| steps: | |
| - name: Trigger Render backend deploy | |
| run: | | |
| if [ -z "${{ secrets.RENDER_DEPLOY_HOOK_BACKEND_STAGING }}" ]; then | |
| echo "WARNING: RENDER_DEPLOY_HOOK_BACKEND_STAGING secret not set. Skipping." | |
| exit 0 | |
| fi | |
| echo "Triggering Render backend staging deploy..." | |
| curl -fsS "${{ secrets.RENDER_DEPLOY_HOOK_BACKEND_STAGING }}" > /dev/null | |
| echo "Backend deploy triggered." | |
| - name: Trigger Render frontend deploy | |
| run: | | |
| if [ -z "${{ secrets.RENDER_DEPLOY_HOOK_FRONTEND_STAGING }}" ]; then | |
| echo "WARNING: RENDER_DEPLOY_HOOK_FRONTEND_STAGING secret not set. Skipping." | |
| exit 0 | |
| fi | |
| echo "Triggering Render frontend staging deploy..." | |
| curl -fsS "${{ secrets.RENDER_DEPLOY_HOOK_FRONTEND_STAGING }}" > /dev/null | |
| echo "Frontend deploy triggered." | |
| deploy-production: | |
| name: Deploy to Production (Render) | |
| runs-on: ubuntu-latest | |
| needs: [backend-test, frontend-test, e2e-test, code-quality, security, dockerhub-publish-backend, dockerhub-publish-frontend] | |
| if: github.ref == 'refs/heads/master' && github.event_name == 'push' | |
| concurrency: | |
| group: ci-deploy-production-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: false | |
| env: | |
| PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://bookstore-web-dr1k.onrender.com' }} | |
| RENDER_FRONTEND_DEPLOY_MODE: ${{ vars.RENDER_FRONTEND_DEPLOY_MODE || 'source' }} | |
| steps: | |
| - name: Trigger Render backend deploy | |
| run: | | |
| if [ -z "${{ secrets.RENDER_DEPLOY_HOOK_BACKEND }}" ]; then | |
| echo "WARNING: RENDER_DEPLOY_HOOK_BACKEND secret not set. Skipping deploy." | |
| echo "Set it in GitHub repo Settings > Secrets > Actions" | |
| exit 0 | |
| fi | |
| echo "Triggering Render backend production deploy..." | |
| curl -fsS "${{ secrets.RENDER_DEPLOY_HOOK_BACKEND }}" > /dev/null | |
| echo "Backend deploy triggered." | |
| - name: Trigger Render frontend deploy | |
| run: | | |
| if [ -z "${{ secrets.RENDER_DEPLOY_HOOK_FRONTEND }}" ]; then | |
| echo "WARNING: RENDER_DEPLOY_HOOK_FRONTEND secret not set. Skipping deploy." | |
| echo "Set it in GitHub repo Settings > Secrets > Actions" | |
| exit 0 | |
| fi | |
| echo "Triggering Render frontend production deploy..." | |
| deploy_hook="${{ secrets.RENDER_DEPLOY_HOOK_FRONTEND }}" | |
| dockerhub_username="${{ secrets.DOCKERHUB_USERNAME }}" | |
| dockerhub_namespace="${{ vars.DOCKERHUB_NAMESPACE }}" | |
| deploy_mode="${RENDER_FRONTEND_DEPLOY_MODE:-source}" | |
| if [ "${deploy_mode}" = "image" ] && [ -n "${dockerhub_username}" ]; then | |
| namespace="${dockerhub_namespace:-$DOCKERHUB_NAMESPACE_DEFAULT}" | |
| image_url="docker.io/${namespace}/ecommerce-bookstore-frontend:latest" | |
| encoded_image_url=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "${image_url}") | |
| separator="?" | |
| if [[ "${deploy_hook}" == *"?"* ]]; then | |
| separator="&" | |
| fi | |
| echo "Using Render image-backed deploy mode with Docker Hub latest tag." | |
| curl -fsS "${deploy_hook}${separator}imgURL=${encoded_image_url}" > /dev/null | |
| else | |
| if [ "${deploy_mode}" = "image" ]; then | |
| echo "WARNING: RENDER_FRONTEND_DEPLOY_MODE=image but Docker Hub credentials are unavailable; falling back to source deploy hook." | |
| else | |
| echo "Using Render source/Blueprint deploy mode." | |
| fi | |
| curl -fsS "${deploy_hook}" > /dev/null | |
| fi | |
| echo "Frontend deploy triggered." | |
| - name: Verify production frontend commit | |
| run: | | |
| health_url="${PRODUCTION_FRONTEND_URL%/}/api/health" | |
| expected_commit="${GITHUB_SHA}" | |
| echo "Waiting for production frontend to report commit ${expected_commit}..." | |
| for attempt in $(seq 1 60); do | |
| body=$(curl -fsS --max-time 20 "${health_url}" || true) | |
| observed_commit=$(python3 -c 'import json, sys; print(json.load(sys.stdin).get("frontend", {}).get("commit", ""))' <<< "${body}" 2>/dev/null || true) | |
| if [ "${observed_commit}" = "${expected_commit}" ]; then | |
| echo "Production frontend is serving expected commit ${observed_commit}." | |
| exit 0 | |
| fi | |
| short_observed="${observed_commit:-missing}" | |
| echo "Attempt ${attempt}/60: production frontend reports ${short_observed}; waiting..." | |
| sleep 20 | |
| done | |
| echo "::error::Production frontend did not report commit ${expected_commit}. Check that RENDER_DEPLOY_HOOK_FRONTEND targets the bookstore-web production service and that Render's latest deploy completed successfully." | |
| exit 1 |