name: CD Pipeline on: push: branches: [ main ] tags: [ 'v*.*.*' ] workflow_dispatch: inputs: environment: description: 'Deployment environment' required: true default: 'staging' type: choice options: - staging - production version: description: 'Version to deploy (leave empty for latest)' required: false env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} HELM_VERSION: '3.13.0' jobs: # Prepare deployment prepare-deployment: name: Prepare Deployment runs-on: ubuntu-latest outputs: environment: ${{ steps.determine-env.outputs.environment }} version: ${{ steps.determine-version.outputs.version }} should-deploy: ${{ steps.check-deploy.outputs.should-deploy }} steps: - uses: actions/checkout@v4 - name: Determine environment id: determine-env run: | if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then echo "environment=staging" >> $GITHUB_OUTPUT elif [[ "${{ github.ref }}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "environment=production" >> $GITHUB_OUTPUT else echo "environment=development" >> $GITHUB_OUTPUT fi - name: Determine version id: determine-version run: | if [[ "${{ github.event_name }}" == "workflow_dispatch" && -n "${{ github.event.inputs.version }}" ]]; then echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT elif [[ "${{ github.ref }}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT else echo "version=${{ github.sha }}" >> $GITHUB_OUTPUT fi - name: Check deployment conditions id: check-deploy run: | ENV="${{ steps.determine-env.outputs.environment }}" if [[ "$ENV" == "production" && ! "${{ github.ref }}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should-deploy=false" >> $GITHUB_OUTPUT echo "::warning::Production deployment requires a semantic version tag" else echo "should-deploy=true" >> $GITHUB_OUTPUT fi # Database Migration database-migration: name: Database Migration runs-on: ubuntu-latest needs: [prepare-deployment] if: needs.prepare-deployment.outputs.should-deploy == 'true' environment: ${{ needs.prepare-deployment.outputs.environment }} steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '18.x' - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ vars.AWS_REGION }} - name: Get database connection string id: get-db-connection run: | SECRET_ARN="${{ secrets.DB_SECRET_ARN }}" DB_SECRET=$(aws secretsmanager get-secret-value --secret-id $SECRET_ARN --query SecretString --output text) echo "::add-mask::$DB_SECRET" echo "DB_CONNECTION=$DB_SECRET" >> $GITHUB_ENV - name: Run migrations run: | cd migrations npm ci npm run migrate:up env: MONGODB_URI: ${{ env.DB_CONNECTION }} MIGRATION_ENV: ${{ needs.prepare-deployment.outputs.environment }} # Deploy to Kubernetes deploy-kubernetes: name: Deploy to Kubernetes runs-on: ubuntu-latest needs: [prepare-deployment, database-migration] if: needs.prepare-deployment.outputs.should-deploy == 'true' environment: name: ${{ needs.prepare-deployment.outputs.environment }} url: ${{ steps.deploy.outputs.app-url }} strategy: matrix: region: [us-east-1, eu-west-1, ap-southeast-1] steps: - uses: actions/checkout@v4 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ matrix.region }} - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - name: Setup kubectl uses: azure/setup-kubectl@v3 with: version: 'v1.28.0' - name: Setup Helm uses: azure/setup-helm@v3 with: version: ${{ env.HELM_VERSION }} - name: Update kubeconfig run: | aws eks update-kubeconfig --name marketing-agent-${{ needs.prepare-deployment.outputs.environment }}-${{ matrix.region }} - name: Create namespace run: | kubectl create namespace marketing-agent-${{ needs.prepare-deployment.outputs.environment }} --dry-run=client -o yaml | kubectl apply -f - - name: Deploy with Helm id: deploy run: | NAMESPACE="marketing-agent-${{ needs.prepare-deployment.outputs.environment }}" RELEASE_NAME="marketing-agent" helm upgrade --install $RELEASE_NAME ./helm/marketing-agent \ --namespace $NAMESPACE \ --values ./helm/marketing-agent/values.yaml \ --values ./helm/marketing-agent/values.${{ needs.prepare-deployment.outputs.environment }}.yaml \ --set global.image.tag=${{ needs.prepare-deployment.outputs.version }} \ --set global.image.registry=${{ steps.login-ecr.outputs.registry }} \ --set global.region=${{ matrix.region }} \ --set-string global.environment=${{ needs.prepare-deployment.outputs.environment }} \ --wait \ --timeout 10m # Get the application URL APP_URL=$(kubectl get ingress -n $NAMESPACE -o jsonpath='{.items[0].spec.rules[0].host}') echo "app-url=https://$APP_URL" >> $GITHUB_OUTPUT - name: Verify deployment run: | NAMESPACE="marketing-agent-${{ needs.prepare-deployment.outputs.environment }}" kubectl rollout status deployment -n $NAMESPACE --timeout=5m kubectl get pods -n $NAMESPACE - name: Run smoke tests run: | APP_URL="${{ steps.deploy.outputs.app-url }}" ./scripts/smoke-tests.sh $APP_URL # Deploy Static Assets to CDN deploy-cdn: name: Deploy Static Assets to CDN runs-on: ubuntu-latest needs: [prepare-deployment, database-migration] if: needs.prepare-deployment.outputs.should-deploy == 'true' steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '18.x' cache: 'npm' - name: Build frontend working-directory: ./frontend run: | npm ci npm run build env: VITE_API_URL: ${{ vars.API_URL }} VITE_ENVIRONMENT: ${{ needs.prepare-deployment.outputs.environment }} - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1 - name: Deploy to S3 run: | BUCKET_NAME="marketing-agent-frontend-${{ needs.prepare-deployment.outputs.environment }}" aws s3 sync ./frontend/dist s3://$BUCKET_NAME \ --delete \ --cache-control "public, max-age=31536000" \ --exclude "index.html" \ --exclude "*.map" # Upload index.html with no-cache aws s3 cp ./frontend/dist/index.html s3://$BUCKET_NAME/index.html \ --cache-control "no-cache, no-store, must-revalidate" - name: Invalidate CloudFront run: | DISTRIBUTION_ID="${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}" aws cloudfront create-invalidation \ --distribution-id $DISTRIBUTION_ID \ --paths "/*" # Post-deployment tasks post-deployment: name: Post Deployment Tasks runs-on: ubuntu-latest needs: [prepare-deployment, deploy-kubernetes, deploy-cdn] if: always() && needs.prepare-deployment.outputs.should-deploy == 'true' steps: - uses: actions/checkout@v4 - name: Update deployment status uses: actions/github-script@v7 with: script: | const environment = '${{ needs.prepare-deployment.outputs.environment }}'; const version = '${{ needs.prepare-deployment.outputs.version }}'; const status = '${{ needs.deploy-kubernetes.result }}'; // Create deployment status await github.rest.repos.createDeploymentStatus({ owner: context.repo.owner, repo: context.repo.repo, deployment_id: context.payload.deployment.id, state: status === 'success' ? 'success' : 'failure', environment_url: status === 'success' ? '${{ needs.deploy-kubernetes.outputs.app-url }}' : '', description: `Deployed version ${version} to ${environment}` }); - name: Send deployment notification if: success() uses: 8398a7/action-slack@v3 with: status: custom custom_payload: | { "text": "Deployment Successful! :rocket:", "attachments": [{ "color": "good", "fields": [ { "title": "Environment", "value": "${{ needs.prepare-deployment.outputs.environment }}", "short": true }, { "title": "Version", "value": "${{ needs.prepare-deployment.outputs.version }}", "short": true }, { "title": "Deployed By", "value": "${{ github.actor }}", "short": true }, { "title": "Repository", "value": "${{ github.repository }}", "short": true } ] }] } webhook_url: ${{ secrets.SLACK_WEBHOOK }} - name: Create release notes if: startsWith(github.ref, 'refs/tags/') uses: actions/github-script@v7 with: script: | const tag = context.ref.replace('refs/tags/', ''); const { data: commits } = await github.rest.repos.compareCommits({ owner: context.repo.owner, repo: context.repo.repo, base: 'v1.0.0', // Previous release tag head: tag }); const releaseNotes = commits.commits .map(commit => `- ${commit.commit.message}`) .join('\n'); await github.rest.repos.createRelease({ owner: context.repo.owner, repo: context.repo.repo, tag_name: tag, name: `Release ${tag}`, body: releaseNotes, draft: false, prerelease: false }); # Rollback on failure rollback: name: Rollback Deployment runs-on: ubuntu-latest needs: [prepare-deployment, deploy-kubernetes] if: failure() && needs.prepare-deployment.outputs.environment == 'production' steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ vars.AWS_REGION }} - name: Rollback Kubernetes deployment run: | aws eks update-kubeconfig --name marketing-agent-production NAMESPACE="marketing-agent-production" helm rollback marketing-agent -n $NAMESPACE - name: Send rollback notification uses: 8398a7/action-slack@v3 with: status: custom custom_payload: | { "text": "Production Deployment Failed - Rollback Initiated! :warning:", "attachments": [{ "color": "danger", "fields": [ { "title": "Environment", "value": "production", "short": true }, { "title": "Failed Version", "value": "${{ needs.prepare-deployment.outputs.version }}", "short": true } ] }] } webhook_url: ${{ secrets.SLACK_WEBHOOK }}