docs: document API key as application self-identification mechanism (… #620
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: | |
| push: | |
| branches: [staging, main] | |
| # Allow manual deployment trigger (for hotfixes) | |
| workflow_dispatch: | |
| inputs: | |
| branch: | |
| description: 'Branch to deploy' | |
| required: true | |
| default: 'staging' | |
| type: choice | |
| options: | |
| - staging | |
| - main | |
| jobs: | |
| # Deployment Job with Container Cleanup | |
| deploy: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: write | |
| # Run on: direct push to staging or main, merged PRs to staging/main, or manual dispatch | |
| if: | | |
| (github.event_name == 'push' && github.ref == 'refs/heads/staging') || | |
| (github.event_name == 'push' && github.ref == 'refs/heads/main') || | |
| (github.event_name == 'pull_request' && github.event.pull_request.merged == true) || | |
| (github.event_name == 'workflow_dispatch') | |
| steps: | |
| - name: Set deployment variables | |
| id: vars | |
| run: | | |
| # Robust lowercase owner for GHCR (some shells reject ${var,,}) | |
| OWNER_LC=$(echo "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]') | |
| IMAGE_REPO="ghcr.io/${OWNER_LC}/cms-backend" | |
| # Determine target branch | |
| if [[ "${{ github.event_name }}" == "push" ]]; then | |
| TARGET_BRANCH="${{ github.ref_name }}" | |
| elif [[ "${{ github.event_name }}" == "pull_request" ]]; then | |
| TARGET_BRANCH="${{ github.event.pull_request.base.ref }}" | |
| elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then | |
| TARGET_BRANCH="${{ github.ref_name }}" | |
| fi | |
| if [[ "$TARGET_BRANCH" == "staging" ]]; then | |
| echo "HOST=${{ secrets.STAGING_HOST }}" >> $GITHUB_ENV | |
| echo "ENV_NAME=staging" >> $GITHUB_ENV | |
| echo "ENV_URL=https://staging.api.cms.itqan.dev" >> $GITHUB_ENV | |
| echo "COMPOSE_FILE=deployment/docker/docker-compose.staging.yml" >> $GITHUB_ENV | |
| echo "BRANCH=staging" >> $GITHUB_ENV | |
| echo "IMAGE_REPO=${IMAGE_REPO}" >> $GITHUB_ENV | |
| echo "IMAGE_TAG=staging-${GITHUB_SHA::7}" >> $GITHUB_ENV | |
| echo "image_repo=${IMAGE_REPO}" >> $GITHUB_OUTPUT | |
| echo "image_tag=staging-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT | |
| echo "branch=staging" >> $GITHUB_OUTPUT | |
| elif [[ "$TARGET_BRANCH" == "main" ]]; then | |
| echo "HOST=${{ secrets.PROD_HOST }}" >> $GITHUB_ENV | |
| echo "ENV_NAME=production" >> $GITHUB_ENV | |
| echo "ENV_URL=https://api.cms.itqan.dev" >> $GITHUB_ENV | |
| echo "COMPOSE_FILE=deployment/docker/docker-compose.production.yml" >> $GITHUB_ENV | |
| echo "BRANCH=main" >> $GITHUB_ENV | |
| echo "IMAGE_REPO=${IMAGE_REPO}" >> $GITHUB_ENV | |
| echo "IMAGE_TAG=prod-${GITHUB_SHA::7}" >> $GITHUB_ENV | |
| echo "image_repo=${IMAGE_REPO}" >> $GITHUB_OUTPUT | |
| echo "image_tag=prod-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT | |
| echo "branch=main" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push image to GHCR | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: deployment/docker/Dockerfile.backend | |
| push: true | |
| tags: ${{ steps.vars.outputs.image_repo }}:${{ steps.vars.outputs.image_tag }} | |
| cache-from: type=registry,ref=${{ steps.vars.outputs.image_repo }}:buildcache-${{ steps.vars.outputs.branch }} | |
| cache-to: type=registry,ref=${{ steps.vars.outputs.image_repo }}:buildcache-${{ steps.vars.outputs.branch }},mode=max,compression=zstd | |
| - name: Deploy to ${{ env.ENV_NAME }} | |
| uses: appleboy/ssh-action@v1.0.3 | |
| with: | |
| host: ${{ env.HOST }} | |
| username: ${{ secrets.SSH_USER }} | |
| key: ${{ secrets.SSH_PRIVATE_KEY }} | |
| script: | | |
| set -e | |
| echo "🚀 Starting deployment to ${{ env.ENV_NAME }} environment..." | |
| echo "📋 Target: ${{ env.ENV_URL }}" | |
| echo "🌿 Branch: ${{ env.BRANCH }}" | |
| # Navigate to application directory | |
| cd /srv/cms-backend | |
| # Pull latest changes | |
| echo "📥 Fetching latest code from GitHub..." | |
| git fetch origin ${{ env.BRANCH }} | |
| git reset --hard origin/${{ env.BRANCH }} | |
| # Display current commit | |
| echo "📋 Current commit: $(git rev-parse --short HEAD) - $(git log -1 --pretty=%B)" | |
| # Generate environment file from GitHub Secrets (per environment) | |
| echo "🧩 Generating environment file from secrets..." | |
| mkdir -p deployment/docker | |
| if [ "${{ env.ENV_NAME }}" = "staging" ]; then | |
| { | |
| echo "DJANGO_SETTINGS_MODULE=config.settings.staging"; | |
| echo "SECRET_KEY=${{ secrets.STAGING_SECRET_KEY }}"; | |
| echo "DEBUG=False"; | |
| echo "SITE_DOMAIN=staging.api.cms.itqan.dev"; | |
| echo "DB_ENGINE=django.db.backends.postgresql"; | |
| echo "DB_NAME=${{ secrets.STAGING_DB_NAME }}"; | |
| echo "DB_USER=${{ secrets.STAGING_DB_USER }}"; | |
| echo "DB_PASSWORD=${{ secrets.STAGING_DB_PASSWORD }}"; | |
| echo "DB_HOST=${{ secrets.STAGING_DB_HOST }}"; | |
| echo "DB_PORT=25060"; | |
| echo "PGSSLMODE=require"; | |
| echo "ALLOWED_HOSTS=staging.api.cms.itqan.dev"; | |
| echo "CORS_ALLOWED_ORIGINS=https://staging.cms.itqan.dev,http://localhost:3000"; | |
| echo "CLOUDFLARE_R2_BUCKET=${{ secrets.STAGING_CLOUDFLARE_R2_BUCKET }}"; | |
| echo "CLOUDFLARE_R2_ENDPOINT=${{ secrets.CLOUDFLARE_R2_ENDPOINT }}"; | |
| echo "CLOUDFLARE_R2_ACCESS_KEY_ID=${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}"; | |
| echo "CLOUDFLARE_R2_SECRET_ACCESS_KEY=${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}"; | |
| echo "CLOUDFLARE_R2_PUBLIC_BASE_URL=${{ secrets.STAGING_CLOUDFLARE_R2_PUBLIC_BASE_URL }}"; | |
| echo "RABBITMQ_USER=${{ secrets.RABBITMQ_USER }}"; | |
| echo "RABBITMQ_PASS=${{ secrets.RABBITMQ_PASS }}"; | |
| echo "CELERY_BROKER_URL=amqp://${{ secrets.RABBITMQ_USER }}:${{ secrets.RABBITMQ_PASS }}@rabbitmq:5672//"; | |
| echo "CELERY_RESULT_BACKEND=redis://redis:6379/0"; | |
| echo "REDIS_URL=redis://redis:6379/1"; | |
| echo "FLOWER_USER=${{ secrets.FLOWER_USER }}"; | |
| echo "FLOWER_PASS=${{ secrets.FLOWER_PASS }}"; | |
| echo "SENTRY_ENABLED=True"; | |
| echo "SENTRY_DSN=${{ secrets.STAGING_SENTRY_DSN }}"; | |
| echo "ENV_NAME=staging"; | |
| echo "DEFAULT_SERVER_NAME=cms-backend-staging"; | |
| echo "MIXPANEL_ENABLED=True"; | |
| echo "MIXPANEL_PROJECT_TOKEN=${{ secrets.STAGING_MIXPANEL_PROJECT_TOKEN }}"; | |
| echo "MIXPANEL_PROJECT_ID=${{ vars.STAGING_MIXPANEL_PROJECT_ID }}"; | |
| echo "MIXPANEL_INGEST_HOST=${{ vars.STAGING_MIXPANEL_INGEST_HOST }}"; | |
| echo "CF_ZONE_ID=${{ secrets.STAGING_CF_ZONE_ID }}"; | |
| echo "CF_API_TOKEN=${{ secrets.STAGING_CF_API_TOKEN }}"; | |
| echo "CF_R2_CUSTOM_DOMAIN=${{ vars.STAGING_CF_R2_CUSTOM_DOMAIN }}"; | |
| echo "ENABLE_AUDIO_USAGE_SYNC=${{ vars.STAGING_ENABLE_AUDIO_USAGE_SYNC }}"; | |
| echo "AUDIO_USAGE_SYNC_WINDOW_HOURS=${{ vars.STAGING_AUDIO_USAGE_SYNC_WINDOW_HOURS }}"; | |
| echo "EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend"; | |
| echo "EMAIL_HOST=${{ secrets.EMAIL_HOST }}"; | |
| echo "EMAIL_PORT=${{ secrets.EMAIL_PORT }}"; | |
| echo "EMAIL_HOST_USER=${{ secrets.EMAIL_HOST_USER }}"; | |
| echo "EMAIL_HOST_PASSWORD=${{ secrets.EMAIL_HOST_PASSWORD }}"; | |
| echo "EMAIL_SENDER_NAME=${{ secrets.EMAIL_SENDER_NAME }}"; | |
| echo "EMAIL_SENDER_EMAIL=${{ secrets.EMAIL_SENDER_EMAIL }}"; | |
| echo "WEBAUTHN_RP_ID=${{ vars.STAGING_WEBAUTHN_RP_ID }}"; | |
| echo "ALLAUTH_JWT_PRIVATE_KEY=${{ secrets.STAGING_ALLAUTH_JWT_PRIVATE_KEY }}"; | |
| echo "FRONTEND_BASE_URL=${{ secrets.STAGING_FRONTEND_BASE_URL }}"; | |
| echo "GOOGLE_CLIENT_ID=${{ secrets.STAGING_GOOGLE_CLIENT_ID }}"; | |
| echo "GOOGLE_CLIENT_SECRET=${{ secrets.STAGING_GOOGLE_CLIENT_SECRET }}"; | |
| echo "GITHUB_CLIENT_ID=${{ secrets.STAGING_GITHUB_CLIENT_ID }}"; | |
| echo "GITHUB_CLIENT_SECRET=${{ secrets.STAGING_GITHUB_CLIENT_SECRET }}"; | |
| echo "SAML_IDP_ENABLED=${{ vars.STAGING_SAML_IDP_ENABLED }}"; | |
| echo "SAML_IDP_KEY_FILE=${{ secrets.SAML_IDP_KEY_FILE }}"; | |
| echo "SAML_IDP_CERT_FILE=${{ secrets.SAML_IDP_CERT_FILE }}"; | |
| echo "SAML_IDP_BASE_URL=${{ vars.STAGING_SAML_IDP_BASE_URL }}"; | |
| echo "XMLSEC_BINARY=${{ vars.STAGING_XMLSEC_BINARY }}"; | |
| echo "ENFORCE_ASSET_ACCESS_ON_PUBLIC_API=${{ vars.STAGING_ENFORCE_ASSET_ACCESS_ON_PUBLIC_API }}"; | |
| } > deployment/docker/.env | |
| elif [ "${{ env.ENV_NAME }}" = "production" ]; then | |
| { | |
| echo "DJANGO_SETTINGS_MODULE=config.settings.production"; | |
| echo "SECRET_KEY=${{ secrets.PROD_SECRET_KEY }}"; | |
| echo "DEBUG=False"; | |
| echo "SITE_DOMAIN=api.cms.itqan.dev"; | |
| echo "DB_ENGINE=django.db.backends.postgresql"; | |
| echo "DB_NAME=${{ secrets.PROD_DB_NAME }}"; | |
| echo "DB_USER=${{ secrets.PROD_DB_USER }}"; | |
| echo "DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}"; | |
| echo "DB_HOST=${{ secrets.PROD_DB_HOST }}"; | |
| echo "DB_PORT=25061"; | |
| echo "PGSSLMODE=require"; | |
| echo "ALLOWED_HOSTS=api.cms.itqan.dev"; | |
| echo "CORS_ALLOWED_ORIGINS=https://cms.itqan.dev"; | |
| echo "CLOUDFLARE_R2_BUCKET=${{ secrets.PROD_CLOUDFLARE_R2_BUCKET }}"; | |
| echo "CLOUDFLARE_R2_ENDPOINT=${{ secrets.CLOUDFLARE_R2_ENDPOINT }}"; | |
| echo "CLOUDFLARE_R2_ACCESS_KEY_ID=${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}"; | |
| echo "CLOUDFLARE_R2_SECRET_ACCESS_KEY=${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}"; | |
| echo "CLOUDFLARE_R2_PUBLIC_BASE_URL=${{ secrets.PROD_CLOUDFLARE_R2_PUBLIC_BASE_URL }}"; | |
| echo "EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend"; | |
| echo "EMAIL_HOST=${{ secrets.EMAIL_HOST }}"; | |
| echo "EMAIL_PORT=${{ secrets.EMAIL_PORT }}"; | |
| echo "EMAIL_HOST_USER=${{ secrets.EMAIL_HOST_USER }}"; | |
| echo "EMAIL_HOST_PASSWORD=${{ secrets.EMAIL_HOST_PASSWORD }}"; | |
| echo "EMAIL_SENDER_NAME=${{ secrets.EMAIL_SENDER_NAME }}"; | |
| echo "EMAIL_SENDER_EMAIL=${{ secrets.EMAIL_SENDER_EMAIL }}"; | |
| echo "WEBAUTHN_RP_ID=${{ vars.PROD_WEBAUTHN_RP_ID }}"; | |
| echo "RABBITMQ_USER=${{ secrets.RABBITMQ_USER }}"; | |
| echo "RABBITMQ_PASS=${{ secrets.RABBITMQ_PASS }}"; | |
| echo "CELERY_BROKER_URL=amqp://${{ secrets.RABBITMQ_USER }}:${{ secrets.RABBITMQ_PASS }}@rabbitmq:5672//"; | |
| echo "CELERY_RESULT_BACKEND=redis://redis:6379/0"; | |
| echo "REDIS_URL=redis://redis:6379/1"; | |
| echo "FLOWER_USER=${{ secrets.FLOWER_USER }}"; | |
| echo "FLOWER_PASS=${{ secrets.FLOWER_PASS }}"; | |
| echo "SENTRY_ENABLED=True"; | |
| echo "SENTRY_DSN=${{ secrets.PROD_SENTRY_DSN }}"; | |
| echo "ENV_NAME=production"; | |
| echo "DEFAULT_SERVER_NAME=cms-backend-production"; | |
| echo "MIXPANEL_ENABLED=${{ vars.PROD_MIXPANEL_ENABLED }}"; | |
| echo "MIXPANEL_PROJECT_TOKEN=${{ secrets.PROD_MIXPANEL_PROJECT_TOKEN }}"; | |
| echo "MIXPANEL_PROJECT_ID=${{ vars.PROD_MIXPANEL_PROJECT_ID }}"; | |
| echo "MIXPANEL_INGEST_HOST=${{ vars.PROD_MIXPANEL_INGEST_HOST }}"; | |
| echo "CF_ZONE_ID=${{ secrets.PROD_CF_ZONE_ID }}"; | |
| echo "CF_API_TOKEN=${{ secrets.PROD_CF_API_TOKEN }}"; | |
| echo "CF_R2_CUSTOM_DOMAIN=${{ vars.PROD_CF_R2_CUSTOM_DOMAIN }}"; | |
| echo "ENABLE_AUDIO_USAGE_SYNC=${{ vars.PROD_ENABLE_AUDIO_USAGE_SYNC }}"; | |
| echo "AUDIO_USAGE_SYNC_WINDOW_HOURS=${{ vars.PROD_AUDIO_USAGE_SYNC_WINDOW_HOURS }}"; | |
| echo "ALLAUTH_JWT_PRIVATE_KEY=${{ secrets.PRODUCTION_ALLAUTH_JWT_PRIVATE_KEY }}"; | |
| echo "FRONTEND_BASE_URL=${{ secrets.PRODUCTION_FRONTEND_BASE_URL }}"; | |
| echo "GOOGLE_CLIENT_ID=${{ secrets.PROD_GOOGLE_CLIENT_ID }}"; | |
| echo "GOOGLE_CLIENT_SECRET=${{ secrets.PROD_GOOGLE_CLIENT_SECRET }}"; | |
| echo "GITHUB_CLIENT_ID=${{ secrets.PROD_GITHUB_CLIENT_ID }}"; | |
| echo "GITHUB_CLIENT_SECRET=${{ secrets.PROD_GITHUB_CLIENT_SECRET }}"; | |
| echo "SAML_IDP_ENABLED=${{ vars.PROD_SAML_IDP_ENABLED }}"; | |
| echo "SAML_IDP_KEY_FILE=${{ secrets.SAML_IDP_KEY_FILE }}"; | |
| echo "SAML_IDP_CERT_FILE=${{ secrets.SAML_IDP_CERT_FILE }}"; | |
| echo "SAML_IDP_BASE_URL=${{ vars.PROD_SAML_IDP_BASE_URL }}"; | |
| echo "XMLSEC_BINARY=${{ vars.PROD_XMLSEC_BINARY }}"; | |
| echo "ENFORCE_ASSET_ACCESS_ON_PUBLIC_API=${{ vars.PROD_ENFORCE_ASSET_ACCESS_ON_PUBLIC_API }}"; | |
| } > deployment/docker/.env | |
| fi | |
| # Append image coordinates for docker compose variable substitution | |
| echo "IMAGE_REPO=${{ env.IMAGE_REPO }}" >> deployment/docker/.env | |
| echo "IMAGE_TAG=${{ env.IMAGE_TAG }}" >> deployment/docker/.env | |
| echo "✅ Environment file generated at deployment/docker/.env (with IMAGE_REPO/IMAGE_TAG)" | |
| # NOTE: We intentionally do NOT run `docker compose down` here. | |
| # Tearing the whole stack down (including Caddy/redis/rabbitmq) caused | |
| # a full outage on every deploy. Instead we keep infra + Caddy running | |
| # and recreate only the application services below (rolling-ish swap), | |
| # so TLS/edge never drops and the app blip is just a container restart. | |
| # Authenticate Docker to GHCR for all environments | |
| if [ -n "${{ secrets.GHCR_DEPLOY_USER }}" ] && [ -n "${{ secrets.GHCR_DEPLOY_TOKEN }}" ]; then | |
| echo "🔐 Logging in to GHCR on remote host for ${{ env.ENV_NAME }} environment..." | |
| echo "${{ secrets.GHCR_DEPLOY_TOKEN }}" | docker login ghcr.io -u "${{ secrets.GHCR_DEPLOY_USER }}" --password-stdin || { | |
| echo "❌ GHCR login failed on remote host"; exit 1; } | |
| else | |
| echo "⚠️ GHCR_DEPLOY_USER/TOKEN not set; using GitHub token for ${{ env.ENV_NAME }} environment" | |
| echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.repository_owner }}" --password-stdin || { | |
| echo "❌ GHCR login failed with GitHub token"; exit 1; } | |
| fi | |
| # Deploy with Docker Compose (pull and start) | |
| echo "🔧 Pulling and starting services..." | |
| IMG_REPO='${{ env.IMAGE_REPO }}' | |
| IMG_TAG='${{ env.IMAGE_TAG }}' | |
| echo "Using image: ${IMG_REPO}:${IMG_TAG}" | |
| export IMAGE_REPO="${IMG_REPO}" IMAGE_TAG="${IMG_TAG}" | |
| COMPOSE="docker compose -f ${{ env.COMPOSE_FILE }}" | |
| # Pull fresh image from GHCR for all environments | |
| echo "📦 Pulling fresh image from GHCR for ${{ env.ENV_NAME }} environment..." | |
| $COMPOSE pull | |
| # Make sure long-lived infra is up (no-op if already running, no restart). | |
| # Caddy + redis + rabbitmq stay alive across deploys, so the edge never drops. | |
| echo "🧱 Ensuring infrastructure (caddy, redis, rabbitmq) is up..." | |
| $COMPOSE up -d --no-recreate caddy redis rabbitmq | |
| # Run DB migrations ONCE as a one-off container, BEFORE recreating web/celery. | |
| # This avoids two app containers racing on migrate, and keeps the new image's | |
| # migrations backward-compatible with the still-running old web container. | |
| echo "🗄️ Running database migrations (one-off)..." | |
| $COMPOSE run --rm --no-deps web python manage.py migrate --noinput | |
| # Ensure staticfiles and media volumes are owned by appuser (UID 1001) for existing root-owned volumes | |
| echo "🔒 Ensuring volume permissions for appuser..." | |
| $COMPOSE run --rm --no-deps --user root web chown -R appuser:appgroup /app/staticfiles /app/media | |
| echo "🗂️ Collecting static files (one-off)..." | |
| $COMPOSE run --rm --no-deps web python manage.py collectstatic --noinput | |
| echo "🌐 Compiling translations (one-off)..." | |
| $COMPOSE run --rm --no-deps web python manage.py compilemessages --locale ar | |
| # Recreate the application services on the new image. Caddy keeps proxying; | |
| # the only interruption is the few seconds the new `web` takes to boot gunicorn. | |
| echo "🔄 Recreating application services (web, celery, flower) on new image..." | |
| $COMPOSE up -d --no-deps --force-recreate web celery-worker celery-beat flower | |
| # Wait for the new web container to report healthy (compose healthcheck), | |
| # rather than a blind sleep. | |
| echo "⏳ Waiting for web to become healthy..." | |
| for i in $(seq 1 30); do | |
| STATUS=$($COMPOSE ps web --format '{{.Health}}' 2>/dev/null || echo "") | |
| if [ "$STATUS" = "healthy" ]; then | |
| echo "✅ web container is healthy." | |
| break | |
| fi | |
| echo " ...web status: '${STATUS:-starting}' (${i}/30)" | |
| sleep 3 | |
| done | |
| # External health check | |
| echo "🔍 Performing external health check..." | |
| if curl -f -s ${{ env.ENV_URL }}/health > /dev/null; then | |
| echo "✅ Health check passed!" | |
| else | |
| echo "⚠️ Health check failed, checking container status..." | |
| $COMPOSE ps | |
| $COMPOSE logs web --tail 20 | |
| fi | |
| # Clean up old Docker images | |
| echo "🧹 Cleaning up old Docker images..." | |
| # Remove dangling images | |
| docker image prune -f || true | |
| # Remove old CMS images, keeping only the 3 most recent for rollback | |
| echo "📦 CMS images before cleanup: $(docker images 'ghcr.io/*/cms-backend' -q | wc -l)" | |
| docker images "ghcr.io/*/cms-backend" --format "{{.ID}} {{.CreatedAt}}" | \ | |
| sort -k2,3 -r | tail -n +4 | awk '{print $1}' | \ | |
| xargs -r docker rmi -f 2>/dev/null || true | |
| echo "📦 CMS images after cleanup: $(docker images 'ghcr.io/*/cms-backend' -q | wc -l)" | |
| # Remove old caddy images, keeping 2 recent | |
| docker images "caddy" --format "{{.ID}} {{.CreatedAt}}" | \ | |
| sort -k2,3 -r | tail -n +3 | awk '{print $1}' | \ | |
| xargs -r docker rmi -f 2>/dev/null || true | |
| # Final cleanup of orphaned layers | |
| docker system prune -f || true | |
| echo "🎉 Deployment to ${{ env.ENV_NAME }} completed successfully!" | |
| echo "🌐 Application URL: ${{ env.ENV_URL }}" | |
| - name: Notify Slack | |
| if: always() | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK }} | |
| # Pass GitHub context through env (never interpolate into the script | |
| # body) so commit messages with quotes/newlines/backticks can't break | |
| # the shell or inject commands. | |
| JOB_STATUS: ${{ job.status }} | |
| ENV_NAME: ${{ env.ENV_NAME }} | |
| ENV_URL: ${{ env.ENV_URL }} | |
| BRANCH: ${{ env.BRANCH }} | |
| ACTOR: ${{ github.actor }} | |
| COMMIT_MSG: ${{ github.event.head_commit.message }} | |
| run: | | |
| if [ -z "$SLACK_WEBHOOK_URL" ]; then | |
| echo "⚠️ SLACK_DEPLOY_WEBHOOK secret not set, skipping notification" | |
| exit 0 | |
| fi | |
| if [ -z "$COMMIT_MSG" ]; then | |
| COMMIT_MSG=$(git log -1 --pretty=%s) | |
| fi | |
| # Only the first line keeps the Slack message tidy. | |
| COMMIT_MSG=$(printf '%s' "$COMMIT_MSG" | head -n1) | |
| if [ "$JOB_STATUS" = "success" ]; then | |
| EMOJI=":white_check_mark:" | |
| STATUS="Deployed" | |
| else | |
| EMOJI=":x:" | |
| STATUS="Failed" | |
| fi | |
| ENV_LABEL=$(printf '%s' "$ENV_NAME" | tr '[:lower:]' '[:upper:]') | |
| SHORT_SHA=${GITHUB_SHA::7} | |
| TEXT=$(printf '%s\n\n:link: <%s/admin/|View Admin>\n:seedling: Branch: `%s`\n:package: Commit: `%s`\n:memo: %s\n:bust_in_silhouette: By: %s' \ | |
| "${EMOJI} *[${ENV_LABEL}] CMS Backend ${STATUS}*" \ | |
| "$ENV_URL" "$BRANCH" "$SHORT_SHA" "$COMMIT_MSG" "$ACTOR") | |
| # Build a valid JSON payload with jq so all values are properly escaped. | |
| PAYLOAD=$(jq -n --arg text "$TEXT" \ | |
| '{blocks: [{type: "section", text: {type: "mrkdwn", text: $text}}]}') | |
| curl -s -X POST "$SLACK_WEBHOOK_URL" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$PAYLOAD" |