From a749da0b48da802e739f26849f4e351b9ca2917f Mon Sep 17 00:00:00 2001 From: Danil Kostromin Date: Sat, 27 Sep 2025 00:24:37 +0300 Subject: [PATCH] feat(cd): add github job to preview deploy Signed-off-by: Danil Kostromin --- .github/actions/deploy/action.yml | 188 ++++++++++++++ .github/workflows/ci.yml | 396 +++++++++++++++++++++++++++++- 2 files changed, 578 insertions(+), 6 deletions(-) create mode 100644 .github/actions/deploy/action.yml diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml new file mode 100644 index 000000000..5c235553d --- /dev/null +++ b/.github/actions/deploy/action.yml @@ -0,0 +1,188 @@ +name: 'Deploy Bublik' +description: 'Composite action to deploy or destroy Bublik environment' + +inputs: + action: + description: 'Action to perform: deploy or destroy' + required: true + default: 'deploy' + + pr_id: + description: 'Pull Request ID' + required: false + + environment_name: + description: 'Name for environment' + required: true + + run_bootstrap: + description: 'Should run bootstrap' + required: false + default: 'false' + + environment_domain: + description: 'Domain for environment' + required: true + + docker_repo: + description: 'Docker repository URL' + required: false + default: 'https://github.com/ts-factory/bublik-docker' + docker_branch: + description: 'Docker branch to use' + required: false + default: 'main' + frontend_repo: + description: 'Frontend repository URL' + required: false + default: 'https://github.com/ts-factory/bublik-ui.git' + frontend_branch: + description: 'Frontend branch to use' + required: true + backend_repo: + description: 'Backend repository URL' + required: false + default: 'https://github.com/ts-factory/bublik.git' + backend_branch: + description: 'Backend branch to use' + required: false + default: 'main' + + ssh_private_key: + description: 'SSH private key for deployment' + required: true + ansible_host: + description: 'Ansible host for deployment' + required: true + ansible_user: + description: 'Ansible user for deployment' + required: true + ansible_port: + description: 'Ansible port for deployment' + required: false + default: '22' + + admin_email: + description: 'Django superuser email' + required: false + admin_password: + description: 'Django superuser password' + required: false + +outputs: + preview_url: + description: 'URL of the deployed preview environment' + value: https://${{ inputs.environment_domain }} + environment_name: + description: 'Name of the environment' + value: ${{ inputs.environment_name }} + +runs: + using: 'composite' + steps: + - name: Checkout bublik-docker + uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/bublik-docker + path: bublik-docker + + - name: Set up Python + uses: actions/setup-python@v4 + env: + RUNNER_TOOL_CACHE: ${{ github.workspace }}/_work/_tool + AGENT_TOOLSDIRECTORY: ${{ github.workspace }}/_work/_tool + with: + python-version: '3.11' + + - name: Install Ansible + shell: bash + run: | + python -m pip install --upgrade pip + pip install ansible + + - name: Install Ansible requirements + shell: bash + working-directory: bublik-docker/ansible + run: ansible-galaxy install -r requirements.yml + + - name: Set up SSH configuration + shell: bash + run: | + mkdir -p ~/.ssh + echo "${{ inputs.ssh_private_key }}" > ~/.ssh/preview + chmod 600 ~/.ssh/preview + + ssh-keyscan -H -p ${{ inputs.ansible_port }} ${{ inputs.ansible_host }} >> ~/.ssh/known_hosts 2>/dev/null || true + + - name: Generate Ansible inventory + shell: bash + working-directory: bublik-docker/ansible + run: | + cat > inventory/inventory.yml << EOF + all: + children: + preview_server: + hosts: + preview: + ansible_host: ${{ inputs.ansible_host }} + ansible_user: ${{ inputs.ansible_user }} + ansible_ssh_private_key_file: ~/.ssh/preview + ansible_command_timeout: 1200 + ansible_connect_timeout: 60 + ansible_port: ${{ inputs.ansible_port }} + ansible_ssh_common_args: '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' + EOF + + - name: Test SSH connection + shell: bash + run: | + echo "Testing SSH connection..." + ssh -i ~/.ssh/preview -p ${{ inputs.ansible_port }} -o ConnectTimeout=30 -o StrictHostKeyChecking=no ${{ inputs.ansible_user }}@${{ inputs.ansible_host }} "echo 'SSH connection successful'" + + - name: Deploy preview environment + if: inputs.action == 'deploy' + shell: bash + working-directory: bublik-docker/ansible + run: | + echo "๐Ÿš€ Deploying PR #${{ inputs.pr_id }} preview environment" + echo "๐Ÿ“Œ Configuration:" + echo " Docker Repo: ${{ inputs.docker_repo }}" + echo " Docker Branch: ${{ inputs.docker_branch }}" + echo " Frontend Repo: ${{ inputs.frontend_repo }}" + echo " Frontend Branch: ${{ inputs.frontend_branch }}" + echo " Backend Branch: ${{ inputs.backend_repo }}" + echo " Backend Branch: ${{ inputs.backend_branch }}" + echo " Preview URL: https://${{ inputs.environment_domain }}" + + ansible-playbook -i ./inventory/inventory.yml deploy.yml \ + --extra-vars "pr_id=${{ inputs.pr_id }}" \ + --extra-vars "environment_domain=${{ inputs.environment_domain }}" \ + --extra-vars "environment_name=${{ inputs.environment_name }}" \ + --extra-vars "run_bootstrap=${{ inputs.run_bootstrap }}" \ + --extra-vars "docker_repo=${{ inputs.docker_repo }}" \ + --extra-vars "docker_branch=${{ inputs.docker_branch }}" \ + --extra-vars "frontend_repo=${{ inputs.frontend_repo }}" \ + --extra-vars "frontend_branch=${{ inputs.frontend_branch }}" \ + --extra-vars "backend_repo=${{ inputs.backend_repo }}" \ + --extra-vars "backend_branch=${{ inputs.backend_branch }}" \ + --extra-vars "django_superuser_email=${{ inputs.admin_email }}" \ + --extra-vars "django_superuser_password=${{ inputs.admin_password }}" \ + -vv + + - name: Destroy preview environment + if: inputs.action == 'destroy' + shell: bash + working-directory: bublik-docker/ansible + run: | + echo "๐Ÿ—‘๏ธ Destroying PR #${{ inputs.pr_id }} preview environment" + + ansible-playbook -i ./inventory/inventory.yml destroy.yml \ + --extra-vars "environment_name=${{ inputs.environment_name }}" \ + -vv + + - name: Cleanup SSH keys + if: always() + shell: bash + run: | + rm -f ~/.ssh/preview + rm -f ~/.ssh/config diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af5c1947e..622fad926 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,13 @@ -name: CI +name: CI / CD + on: pull_request: branches: [main] + types: [opened, synchronize, reopened, closed] + push: + branches: [main] + tags: ['v*'] + env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 NODE_OPTIONS: '--max-old-space-size=4096' @@ -9,6 +15,7 @@ env: jobs: lint: runs-on: ubuntu-latest + if: github.event.action != 'closed' steps: - name: Code Checkout uses: actions/checkout@v4 @@ -21,12 +28,14 @@ jobs: node-version: 20 cache: 'pnpm' - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile + - name: Code Linting run: pnpm run lint test: runs-on: ubuntu-latest + if: github.event.action != 'closed' steps: - name: Code Checkout uses: actions/checkout@v4 @@ -39,12 +48,13 @@ jobs: node-version: 20 cache: 'pnpm' - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Code Testing run: pnpm run test format-check: runs-on: ubuntu-latest + if: github.event.action != 'closed' steps: - name: Code Checkout uses: actions/checkout@v4 @@ -57,12 +67,13 @@ jobs: node-version: 20 cache: 'pnpm' - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Check code formatting run: pnpm run format:check check-build: runs-on: ubuntu-latest + if: github.event.action != 'closed' steps: - name: Code Checkout uses: actions/checkout@v4 @@ -75,12 +86,13 @@ jobs: node-version: 20 cache: 'pnpm' - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Check Bublik UI run: pnpm run build check-json-logs-build: runs-on: ubuntu-latest + if: github.event.action != 'closed' steps: - name: Code Checkout uses: actions/checkout@v4 @@ -93,6 +105,378 @@ jobs: node-version: 20 cache: 'pnpm' - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Check JSON App run: pnpm run bublik-json:build + + e2e-tests: + runs-on: ubuntu-latest + needs: [deploy-staging, deploy-preview] + if: always() && (needs.deploy-preview.result == 'success' || needs.deploy-staging.result == 'success') + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set environment URL from job outputs + run: | + if [[ "${{ needs.deploy-staging.result }}" == "success" ]]; then + echo "ENV_URL=${{ needs.deploy-staging.outputs.deployment_url }}" >> $GITHUB_ENV + echo "ENVIRONMENT_TYPE=staging" >> $GITHUB_ENV + elif [[ "${{ needs.deploy-preview.result }}" == "success" ]]; then + echo "ENV_URL=${{ needs.deploy-preview.outputs.deployment_url }}" >> $GITHUB_ENV + echo "ENVIRONMENT_TYPE=preview" >> $GITHUB_ENV + fi + echo "Testing environment: $ENV_URL" + + - name: Wait for deployment to be ready + run: | + echo "Waiting for $ENV_URL to be ready..." + timeout 300 bash -c 'until curl -f -s -L "$ENV_URL" > /dev/null; do echo "Waiting for site to be ready..."; sleep 15; done' + + - name: Run E2E Tests + run: | + echo "Running E2E tests against: $ENV_URL" + # pnpm run test:e2e --baseURL="$ENV_URL" + env: + BASE_URL: ${{ env.ENV_URL }} + ENVIRONMENT_TYPE: ${{ env.ENVIRONMENT_TYPE }} + + prepare-preview: + runs-on: ubuntu-latest + needs: [lint, test, format-check, check-build, check-json-logs-build] + if: github.event.action != 'closed' && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') + outputs: + pr_id: ${{ steps.set_pr_id.outputs.pr_id }} + frontend_branch: ${{ steps.set_branches.outputs.frontend_branch }} + backend_branch: ${{ steps.set_branches.outputs.backend_branch }} + docker_branch: ${{ steps.set_branches.outputs.docker_branch }} + environment_url: ${{ steps.set_env_url.outputs.environment_url }} + is_staging: ${{ steps.check_staging.outputs.is_staging }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set PR ID + id: set_pr_id + run: | + PR_ID="${{ github.event.pull_request.number }}" + echo "pr_id=$PR_ID" >> $GITHUB_OUTPUT + echo "๐Ÿ“‹ PR ID: $PR_ID" + + - name: Check if staging deployment + id: check_staging + run: | + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "is_staging=true" >> $GITHUB_OUTPUT + else + echo "is_staging=false" >> $GITHUB_OUTPUT + fi + + - name: Set environment URL + id: set_env_url + run: | + if [[ "${{ steps.check_staging.outputs.is_staging }}" == "true" ]]; then + ENV_URL="https://staging.${{ vars.PREVIEW_TRAEFIK_DOMAIN }}" + else + ENV_URL="https://pr-${{ steps.set_pr_id.outputs.pr_id }}.${{ vars.PREVIEW_TRAEFIK_DOMAIN }}" + fi + echo "environment_url=$ENV_URL" >> $GITHUB_OUTPUT + + - name: Set Branch Variables + id: set_branches + run: | + if [[ "${{ steps.check_staging.outputs.is_staging }}" == "true" ]]; then + FRONTEND_BRANCH="main" + backend_branch="main" + docker_branch="main" + else + FRONTEND_BRANCH="${{ github.head_ref }}" + PR_BODY=$(echo '${{ github.event.pull_request.body }}' | tr -d '\r') + backend_branch=$(echo "$PR_BODY" | grep -oiE 'backend:\s*[A-Za-z0-9._/-]+' | awk '{print $2}' | head -1) + docker_branch=$(echo "$PR_BODY" | grep -oiE 'docker:\s*[A-Za-z0-9._/-]+' | awk '{print $2}' | head -1) + if [ -z "$backend_branch" ]; then + backend_branch="main" + fi + if [ -z "$docker_branch" ]; then + docker_branch="main" + fi + fi + echo "frontend_branch=$FRONTEND_BRANCH" >> $GITHUB_OUTPUT + echo "backend_branch=$backend_branch" >> $GITHUB_OUTPUT + echo "docker_branch=$docker_branch" >> $GITHUB_OUTPUT + echo "๐Ÿ“Œ Branches to checkout:" + echo " Docker Branch: $docker_branch" + echo " Frontend Branch: $FRONTEND_BRANCH" + echo " Backend Branch: $backend_branch" + + deploy-staging: + needs: [lint, test, format-check, check-build, check-json-logs-build] + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.action != 'closed' + timeout-minutes: 45 + + environment: + name: staging + url: https://staging.${{ vars.PREVIEW_TRAEFIK_DOMAIN }} + + concurrency: + group: deploy-staging + cancel-in-progress: true + + outputs: + deployment_url: ${{ steps.set-url.outputs.deployment_url }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set Deployment URL + id: set-url + run: | + STAGING_URL="https://staging.${{ vars.PREVIEW_TRAEFIK_DOMAIN }}" + echo "STAGING_URL=$STAGING_URL" >> $GITHUB_ENV + echo "deployment_url=$STAGING_URL" >> $GITHUB_OUTPUT + echo "Deployment URL: $STAGING_URL" + + - name: Deploy Staging + id: deploy + uses: ./.github/actions/deploy + with: + action: 'deploy' + environment_name: staging + environment_domain: staging.${{ vars.PREVIEW_TRAEFIK_DOMAIN }} + docker_repo: ${{ vars.PREVIEW_DOCKER_REPO || 'https://github.com/ts-factory/bublik-docker.git' }} + docker_branch: main + frontend_repo: ${{ vars.PREVIEW_FRONTEND_REPO || 'https://github.com/ts-factory/bublik-ui.git' }} + frontend_branch: main + backend_repo: ${{ vars.PREVIEW_BACKEND_REPO || 'https://github.com/ts-factory/bublik.git' }} + backend_branch: main + run_bootstrap: 'true' + ssh_private_key: ${{ secrets.PREVIEW_SSH_PRIVATE_KEY }} + ansible_host: ${{ secrets.PREVIEW_ANSIBLE_HOST }} + ansible_user: ${{ vars.PREVIEW_ANSIBLE_USER }} + ansible_port: ${{ secrets.PREVIEW_ANSIBLE_PORT || '22' }} + admin_email: ${{ secrets.PREVIEW_DJANGO_SUPERUSER_EMAIL }} + admin_password: ${{ secrets.PREVIEW_DJANGO_SUPERUSER_PASSWORD }} + + - name: Notify staging deployment + if: always() + uses: actions/github-script@v7 + with: + script: | + const status = '${{ job.status }}' === 'success' ? 'โœ… **Staging deployment successful!**' : 'โŒ **Staging deployment failed!**'; + const emoji = '${{ job.status }}' === 'success' ? '๐ŸŽ‰' : 'โš ๏ธ'; + const message = '${{ job.status }}' === 'success' ? + 'Staging environment has been updated successfully!' : + 'Please check the workflow logs for error details.'; + + console.log(`${status}\n\n๐ŸŒ **Staging URL:** ${{ env.STAGING_URL }}\n\n${emoji} ${message}`); + + deploy-production: + needs: [lint, test, format-check, check-build, check-json-logs-build] + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + timeout-minutes: 60 + + environment: + name: production + url: https://${{ vars.PREVIEW_TRAEFIK_DOMAIN }} + + concurrency: + group: deploy-production + cancel-in-progress: true + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set environment URL + run: echo "PROD_URL=${{ job.environment.url }}" >> $GITHUB_ENV + + - name: Deploy Production + id: deploy + uses: ./.github/actions/deploy + with: + action: 'deploy' + environment_name: production + environment_domain: ${{ vars.PREVIEW_TRAEFIK_DOMAIN }} + docker_repo: ${{ vars.PREVIEW_DOCKER_REPO }} + docker_branch: main + frontend_repo: ${{ vars.PREVIEW_FRONTEND_REPO }} + frontend_branch: main + backend_repo: ${{ vars.PREVIEW_BACKEND_REPO }} + backend_branch: main + ssh_private_key: ${{ secrets.PREVIEW_SSH_PRIVATE_KEY }} + ansible_host: ${{ secrets.PREVIEW_ANSIBLE_HOST }} + ansible_user: ${{ vars.PREVIEW_ANSIBLE_USER }} + ansible_port: ${{ secrets.PREVIEW_ANSIBLE_PORT || '22' }} + admin_email: ${{ secrets.PREVIEW_DJANGO_SUPERUSER_EMAIL }} + admin_password: ${{ secrets.PREVIEW_DJANGO_SUPERUSER_PASSWORD }} + + deploy-preview: + needs: prepare-preview + runs-on: ubuntu-latest + if: github.event.action != 'closed' && needs.prepare-preview.outputs.is_staging == 'false' + timeout-minutes: 45 + + outputs: + deployment_url: ${{ steps.set-url.outputs.deployment_url }} + + environment: + name: pr-${{ needs.prepare-preview.outputs.pr_id }} + url: ${{ needs.prepare-preview.outputs.environment_url }} + + concurrency: + group: deploy-preview-${{ needs.prepare-preview.outputs.pr_id }} + cancel-in-progress: true + + permissions: + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set Deployment URL + id: set-url + run: | + echo "PREVIEW_URL=${{ needs.prepare-preview.outputs.environment_url }}" >> $GITHUB_ENV + echo "deployment_url=${{ needs.prepare-preview.outputs.environment_url }}" >> $GITHUB_OUTPUT + echo "Deployment URL: ${{ needs.prepare-preview.outputs.environment_url }}" + + - name: Deploy PR Preview + id: deploy + uses: ./.github/actions/deploy + with: + action: 'deploy' + pr_id: ${{ needs.prepare-preview.outputs.pr_id }} + environment_name: pr-${{ needs.prepare-preview.outputs.pr_id }} + environment_domain: pr-${{ needs.prepare-preview.outputs.pr_id }}.${{ vars.PREVIEW_TRAEFIK_DOMAIN }} + run_bootstrap: 'true' + docker_repo: ${{ vars.PREVIEW_DOCKER_REPO || 'https://github.com/ts-factory/bublik-docker.git' }} + docker_branch: ${{ needs.prepare-preview.outputs.docker_branch }} + frontend_repo: ${{ vars.PREVIEW_FRONTEND_REPO || 'https://github.com/ts-factory/bublik-ui.git' }} + frontend_branch: ${{ needs.prepare-preview.outputs.frontend_branch }} + backend_repo: ${{ vars.PREVIEW_BACKEND_REPO || 'https://github.com/ts-factory/bublik.git' }} + backend_branch: ${{ needs.prepare-preview.outputs.backend_branch }} + ssh_private_key: ${{ secrets.PREVIEW_SSH_PRIVATE_KEY }} + ansible_host: ${{ secrets.PREVIEW_ANSIBLE_HOST }} + ansible_user: ${{ vars.PREVIEW_ANSIBLE_USER }} + ansible_port: ${{ secrets.PREVIEW_ANSIBLE_PORT || '22' }} + admin_email: ${{ secrets.PREVIEW_DJANGO_SUPERUSER_EMAIL }} + admin_password: ${{ secrets.PREVIEW_DJANGO_SUPERUSER_PASSWORD }} + + - name: Deployment result comment + if: always() + uses: marocchino/sticky-pull-request-comment@v2 + with: + number: ${{ needs.prepare-preview.outputs.pr_id }} + header: deploy-preview + message: | + ${{ job.status == 'success' && 'โœ… **Deployment successful!**' || 'โŒ **Deployment failed!**' }} + + ### ๐Ÿš€ Deployment Summary for PR #${{ needs.prepare-preview.outputs.pr_id }} + + **๐ŸŒ Preview URL:** [${{ needs.prepare-preview.outputs.environment_url }}](${{ needs.prepare-preview.outputs.environment_url }}) + + **๐Ÿ“ฆ Repository Configuration:** + - **Docker Repo:** `${{ vars.PREVIEW_DOCKER_REPO || 'https://github.com/ts-factory/bublik-docker.git' }}` + - **Docker Branch:** `${{ needs.prepare-preview.outputs.docker_branch }}` + - **Frontend Repo:** `${{ vars.PREVIEW_FRONTEND_REPO || 'https://github.com/ts-factory/bublik-ui.git' }}` + - **Frontend Branch:** `${{ needs.prepare-preview.outputs.frontend_branch }}` + - **Backend Repo:** `${{ vars.PREVIEW_BACKEND_REPO || 'https://github.com/ts-factory/bublik.git' }}` + - **Backend Branch:** `${{ needs.prepare-preview.outputs.backend_branch }}` + + **๐Ÿท๏ธ Environment:** `pr-${{ needs.prepare-preview.outputs.pr_id }}` + + ${{ job.status == 'success' && '๐ŸŽ‰ Your preview environment is ready for testing!' || 'โš ๏ธ Please check the workflow logs for error details.' }} + + --- + This comment is automatically updated by the deployment workflow. + + destroy-preview: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + timeout-minutes: 15 + + concurrency: + group: destroy-preview-${{ github.event.pull_request.number }} + cancel-in-progress: false + + permissions: + pull-requests: write + deployments: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Destroy Preview + id: destroy + uses: ./.github/actions/deploy + with: + action: 'destroy' + pr_id: ${{ github.event.pull_request.number }} + environment_name: pr-${{ github.event.pull_request.number }} + ssh_private_key: ${{ secrets.PREVIEW_SSH_PRIVATE_KEY }} + ansible_host: ${{ secrets.PREVIEW_ANSIBLE_HOST }} + ansible_user: ${{ vars.PREVIEW_ANSIBLE_USER }} + ansible_port: ${{ secrets.PREVIEW_ANSIBLE_PORT || '22' }} + + - name: List deployments for this PR + env: + GITHUB_TOKEN: '${{ github.token }}' + id: list + uses: octokit/request-action@v2.x + with: + route: GET /repos/${{ github.repository }}/deployments + environment: pr-${{ github.event.pull_request.number }} + + - name: Mark deployments as inactive + if: steps.list.outputs.data != '[]' + env: + GITHUB_TOKEN: '${{ github.token }}' + run: | + echo '${{ steps.list.outputs.data }}' | jq -r '.[].id' | while read dep_id; do + gh api \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + /repos/${{ github.repository }}/deployments/$dep_id/statuses \ + -f state=inactive \ + -f description="Deployment marked as inactive" + done + + - name: Destroy result comment + if: always() && github.event_name != 'workflow_dispatch' + uses: marocchino/sticky-pull-request-comment@v2 + with: + number: ${{ github.event.pull_request.number }} + header: destroy-preview + message: | + ${{ job.status == 'success' && '๐Ÿ—‘๏ธ **Preview environment destroyed successfully!**' || 'โŒ **Failed to destroy preview environment!**' }} + + ### ๐Ÿงน Cleanup Summary for PR #${{ github.event.pull_request.number }} + + **๐Ÿท๏ธ Environment:** `pr-${{ github.event.pull_request.number }}` + **๐Ÿ“Š Status:** ${{ job.status == 'success' && 'โœ… Cleaned up' || 'โŒ Cleanup failed' }} + + ${{ job.status == 'success' && 'โœจ All resources have been cleaned up successfully.' || 'โš ๏ธ Some resources may still be running. Please check manually.' }} + + --- + This comment is automatically updated by the cleanup workflow.