Skip to content

docs: document API key as application self-identification mechanism (… #620

docs: document API key as application self-identification mechanism (…

docs: document API key as application self-identification mechanism (… #620

Workflow file for this run

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"