Skip to content

docs(portfolio): highlight live bookstore demo #98

docs(portfolio): highlight live bookstore demo

docs(portfolio): highlight live bookstore demo #98

Workflow file for this run

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