[deps] Update dawidd6/action-send-mail action to v16 #7573
Workflow file for this run
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: Run XTM Hub CI/CD | |
| on: | |
| workflow_dispatch: | |
| push: | |
| branches: | |
| - "main" | |
| - "development" | |
| paths: | |
| - "apps/**" | |
| - ".github/workflows/dockerbuild-ci.yml" | |
| - "package.json" | |
| - "yarn.lock" | |
| tags: | |
| - "v*" | |
| pull_request: | |
| branches: | |
| - "main" | |
| - "development" | |
| - "issue/*" | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| detect-changes: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| code: ${{ steps.filter.outputs.code }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dorny/paths-filter@v3 | |
| id: filter | |
| with: | |
| filters: | | |
| code: | |
| - "apps/**" | |
| - ".github/workflows/dockerbuild-ci.yml" | |
| - "package.json" | |
| - "yarn.lock" | |
| run-lint: | |
| name: Lint ${{ matrix.label }} | |
| needs: detect-changes | |
| if: needs.detect-changes.outputs.code == 'true' || github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - label: portal-api | |
| working-directory: apps/portal-api | |
| - label: portal-front | |
| working-directory: apps/portal-front | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v6 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version-file: .nvmrc | |
| - name: Enable Corepack | |
| run: | | |
| corepack enable | |
| corepack prepare yarn@4.13.0 --activate | |
| - name: Install dependencies | |
| run: yarn install --immutable | |
| - name: Run lint | |
| working-directory: ${{ matrix.working-directory }} | |
| run: yarn lint | |
| build-images-tests: | |
| needs: | |
| - detect-changes | |
| if: needs.detect-changes.outputs.code == 'true' || github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| packages: write | |
| strategy: | |
| fail-fast: true | |
| matrix: | |
| include: | |
| - image: portal-front | |
| dockerfile: apps/portal-front/Dockerfile | |
| - image: portal-api | |
| dockerfile: apps/portal-api/Dockerfile | |
| - image: portal-e2e-tests | |
| dockerfile: apps/portal-e2e-tests/Dockerfile | |
| - image: portal-front-test | |
| dockerfile: apps/portal-front/test.Dockerfile | |
| - image: portal-api-test | |
| dockerfile: apps/portal-api/test.Dockerfile | |
| outputs: | |
| docker-tags: ${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} | |
| docker-labels: ${{ steps.meta.outputs.labels }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Setup Docker layer cache | |
| uses: actions/cache@v4 | |
| with: | |
| path: /tmp/.buildx-cache | |
| key: ${{ runner.os }}-buildx-${{ matrix.image }}-${{ github.sha }} | |
| restore-keys: | | |
| ${{ runner.os }}-buildx-${{ matrix.image }}- | |
| ${{ runner.os }}-buildx- | |
| - name: Login to DockerHub | |
| uses: docker/login-action@v3 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Docker meta deps | |
| id: meta | |
| uses: docker/metadata-action@v5 | |
| with: | |
| images: filigran/${{ matrix.image }} | |
| tags: | | |
| type=ref,event=branch | |
| type=ref,event=pr | |
| type=semver,pattern={{version}} | |
| type=semver,pattern={{major}}.{{minor}} | |
| - name: Copy migrations | |
| run: | | |
| cp -r ./apps/portal-api/src/migrations ./apps/portal-e2e-tests/migrations | |
| cp -r ./apps/portal-api/tests/seeds ./apps/portal-e2e-tests/seeds | |
| cp ./apps/portal-api/tests/test-utils.ts ./apps/portal-e2e-tests/test-utils.ts | |
| - name: Build and push portal image | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: ${{ matrix.dockerfile }} | |
| push: true | |
| tags: ${{ steps.meta.outputs.tags }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| build-args: | | |
| APP_VERSION=0.0.0-e2e | |
| cache-from: | | |
| type=local,src=/tmp/.buildx-cache | |
| type=registry,ref=filigran/${{ matrix.image }}:main | |
| type=registry,ref=filigran/${{ matrix.image }}:development | |
| type=registry,ref=filigran/${{ matrix.image }}:cache-${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} | |
| cache-to: | | |
| type=local,dest=/tmp/.buildx-cache-new,mode=max | |
| type=registry,ref=filigran/${{ matrix.image }}:cache-${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }},mode=max | |
| - name: Move cache | |
| run: | | |
| rm -rf /tmp/.buildx-cache | |
| mv /tmp/.buildx-cache-new /tmp/.buildx-cache | |
| run-e2e-tests: | |
| needs: build-images-tests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| packages: write | |
| pull-requests: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Login to DockerHub | |
| uses: docker/login-action@v3 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Install docker | |
| uses: docker/setup-docker-action@v4 | |
| - name: Copy migrations | |
| run: | | |
| cp -r ./apps/portal-api/src/migrations ./apps/portal-e2e-tests/migrations | |
| cp -r ./apps/portal-api/tests/seeds ./apps/portal-e2e-tests/seeds | |
| cp ./apps/portal-api/tests/test-utils.ts ./apps/portal-e2e-tests/test-utils.ts | |
| - name: Launch docker compose | |
| run: | | |
| mkdir -p ctrf | |
| export IMAGE_TAGS="${{ needs.build-images-tests.outputs.docker-tags }}" \ | |
| TEAMS_WEBHOOK_E2E="${{ secrets.TEAMS_WEBHOOK_E2E }}" \ | |
| GITHUB_PR_TITLE="${{ github.event.pull_request.title }}" \ | |
| GITHUB_PR_NUMBER="${{ github.event.pull_request.number }}" \ | |
| GITHUB_RUN_ID="${{ github.run_id }}" | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml run portal-e2e-tests || TEST_EXIT_CODE=$? | |
| CONTAINER_ID=$(docker ps -a --filter "name=portal-e2e-tests-run" --format "{{.ID}}" | head -1) | |
| docker cp $CONTAINER_ID:/app/apps/portal-e2e-tests/ctrf/. ./ctrf/ | |
| exit ${TEST_EXIT_CODE:-0} | |
| - name: Publish Test Report | |
| if: always() | |
| uses: ctrf-io/github-test-reporter@v1 | |
| with: | |
| report-path: "./ctrf/*.json" | |
| pull-request: true | |
| summary-report: true | |
| failed-report: true | |
| flaky-report: true | |
| insights-report: true | |
| suite-folded-report: true | |
| overwrite-comment: true | |
| report-order: "summary-report,failed-report,flaky-report,insights-report,test-report" | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Collect service logs on failure | |
| if: failure() | |
| run: | | |
| echo "=== Collecting logs from all services ===" | |
| echo "" | |
| echo "=== Portal API Logs ===" | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs --tail=100 portal-api || echo "Could not collect portal-api logs" | |
| echo "" | |
| echo "=== Portal API Test Logs ===" | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs --tail=100 portal-api-test || echo "Could not collect portal-api-test logs" | |
| echo "" | |
| echo "=== Portal Front Logs ===" | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs --tail=100 portal-front || echo "Could not collect portal-front logs" | |
| echo "" | |
| echo "=== Portal Front Test Logs ===" | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs --tail=100 portal-front-test || echo "Could not collect portal-front-test logs" | |
| echo "" | |
| echo "=== PostgreSQL Logs ===" | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs --tail=100 portal-postgres || echo "Could not collect portal-postgres logs" | |
| echo "" | |
| echo "=== MinIO Logs ===" | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs --tail=100 portal-minio || echo "Could not collect portal-minio logs" | |
| echo "" | |
| echo "=== E2E Tests Logs ===" | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs --tail=100 portal-e2e-tests || echo "Could not collect portal-e2e-tests logs" | |
| echo "" | |
| echo "=== Docker container status ===" | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml ps -a || echo "Could not get container status" | |
| - name: Save logs as artifacts | |
| if: failure() | |
| run: | | |
| mkdir -p logs | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs portal-api > logs/portal-api.log 2>&1 || true | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs portal-api-test > logs/portal-api-test.log 2>&1 || true | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs portal-front > logs/portal-front.log 2>&1 || true | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs portal-front-test > logs/portal-front-test.log 2>&1 || true | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs portal-postgres > logs/portal-postgres.log 2>&1 || true | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs portal-minio > logs/portal-minio.log 2>&1 || true | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml logs portal-e2e-tests > logs/portal-e2e-tests.log 2>&1 || true | |
| - name: Upload logs | |
| if: failure() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: service-logs-${{ github.run_id }} | |
| path: logs/ | |
| retention-days: 30 | |
| run-front-unit-tests: | |
| needs: build-images-tests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| packages: write | |
| if: contains(github.ref , 'refs/tags/') == false | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Login to DockerHub | |
| uses: docker/login-action@v3 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Install docker | |
| uses: docker/setup-docker-action@v4 | |
| - name: Run front unit tests | |
| run: | | |
| export IMAGE_TAGS=${{ needs.build-images-tests.outputs.docker-tags }} | |
| mkdir -p coverage | |
| docker run --rm \ | |
| -v $(pwd)/coverage:/app/apps/portal-front/coverage \ | |
| filigran/portal-front-test:${IMAGE_TAGS} | |
| - name: Upload coverage to Codecov | |
| uses: codecov/codecov-action@v5 | |
| with: | |
| fail_ci_if_error: false | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| run-api-unit-tests: | |
| needs: build-images-tests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| packages: write | |
| if: contains(github.ref , 'refs/tags/') == false | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Login to DockerHub | |
| uses: docker/login-action@v3 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Install docker | |
| uses: docker/setup-docker-action@v4 | |
| - name: Launch docker compose | |
| run: | | |
| mkdir -p coverage | |
| export IMAGE_TAGS=${{ needs.build-images-tests.outputs.docker-tags }} | |
| docker compose -f ./xtm-hub-dev/docker-compose-ci.yml run portal-api-test | |
| - name: Upload coverage to Codecov | |
| uses: codecov/codecov-action@v5 | |
| with: | |
| fail_ci_if_error: false | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| deploy-feature-branch: | |
| needs: build-images-tests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| if: | | |
| github.event_name == 'pull_request' && | |
| !contains(github.event.pull_request.labels.*.name, 'skip-feature-env') | |
| steps: | |
| - name: Install AWX cli | |
| run: pip install awxkit | |
| - name: Deploy | |
| run: | | |
| max_attempts=5 | |
| for i in $(seq 1 $max_attempts); do | |
| if awx --conf.host https://awx.filigran.io \ | |
| --conf.token ${{ secrets.AWX_TOKEN }} \ | |
| -f human job_templates launch 'Deploy XTM Hub feature branch for testing' \ | |
| --inventory eu-west-staging \ | |
| --extra_vars "{\"env\":\"Development\",\"xtmhub_version\":\"pr-${{ github.event.number }}\",\"xtmhub_node_env\":\"development\"}"; then | |
| echo "✅ Deployment successful" | |
| exit 0 | |
| fi | |
| if [ $i -lt $max_attempts ]; then | |
| delay=$((2 ** i)) | |
| echo "⚠️ Attempt $i/$max_attempts failed, retrying in ${delay}s..." | |
| sleep $delay | |
| fi | |
| done | |
| echo "❌ All $max_attempts attempts failed" | |
| exit 1 | |
| build-images-prod: | |
| needs: | |
| - run-lint | |
| - run-e2e-tests | |
| - run-front-unit-tests | |
| - run-api-unit-tests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| packages: write | |
| strategy: | |
| fail-fast: true | |
| matrix: | |
| include: | |
| - image: portal-front | |
| dockerfile: apps/portal-front/Dockerfile | |
| - image: portal-api | |
| dockerfile: apps/portal-api/Dockerfile | |
| if: | | |
| always() && | |
| github.event_name != 'pull_request' && | |
| (needs.run-lint.result == 'success') && | |
| (needs.run-e2e-tests.result == 'success') && | |
| (needs.run-front-unit-tests.result == 'skipped' || needs.run-front-unit-tests.result == 'success') && | |
| (needs.run-api-unit-tests.result == 'skipped' || needs.run-api-unit-tests.result == 'success') | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Setup Docker layer cache | |
| uses: actions/cache@v4 | |
| with: | |
| path: /tmp/.buildx-cache | |
| key: ${{ runner.os }}-buildx-prod-${{ matrix.image }}-${{ github.sha }} | |
| restore-keys: | | |
| ${{ runner.os }}-buildx-prod-${{ matrix.image }}- | |
| ${{ runner.os }}-buildx-${{ matrix.image }}- | |
| ${{ runner.os }}-buildx- | |
| - name: Login to DockerHub | |
| uses: docker/login-action@v3 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Docker meta deps | |
| id: meta | |
| uses: docker/metadata-action@v5 | |
| with: | |
| images: filigran/${{ matrix.image }}-prod | |
| tags: | | |
| type=ref,event=branch | |
| type=ref,event=pr | |
| type=semver,pattern={{version}} | |
| type=semver,pattern={{major}}.{{minor}} | |
| ${{ contains(github.ref, 'refs/tags/') && 'latest' }} | |
| - name: Determine version | |
| id: version | |
| run: | | |
| if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then | |
| echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV | |
| elif [[ "${GITHUB_REF_NAME}" == "main" ]]; then | |
| echo "VERSION=staging-${GITHUB_SHA::8}" >> $GITHUB_ENV | |
| elif [[ "${GITHUB_REF_NAME}" == "development" ]]; then | |
| echo "VERSION=dev-${GITHUB_SHA::8}" >> $GITHUB_ENV | |
| else | |
| echo "VERSION=local-${GITHUB_SHA::8}" >> $GITHUB_ENV | |
| fi | |
| - name: Build and push images | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: ${{ matrix.dockerfile }} | |
| push: true | |
| tags: ${{ steps.meta.outputs.tags }} | |
| build-args: | | |
| APP_VERSION=${{ env.VERSION }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| cache-from: | | |
| type=local,src=/tmp/.buildx-cache | |
| type=registry,ref=filigran/${{ matrix.image }}:main | |
| type=registry,ref=filigran/${{ matrix.image }}:development | |
| type=registry,ref=filigran/${{ matrix.image }}:latest | |
| type=registry,ref=filigran/${{ matrix.image }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} | |
| cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max | |
| - name: Move cache | |
| run: | | |
| rm -rf /tmp/.buildx-cache | |
| mv /tmp/.buildx-cache-new /tmp/.buildx-cache | |
| deploy: | |
| needs: build-images-prod | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| if: | | |
| always() && | |
| github.event_name != 'pull_request' && | |
| (needs.build-images-prod.result == 'success') | |
| steps: | |
| - name: Install AWX cli | |
| run: pip install awxkit | |
| - name: Deploy on Production | |
| if: contains(github.ref, 'refs/tags/') | |
| run: | | |
| export VERSION=$(echo "${GITHUB_REF_NAME}" | sed 's/^v//') | |
| max_attempts=5 | |
| for i in $(seq 1 $max_attempts); do | |
| if awx --conf.host https://awx.filigran.io \ | |
| --conf.token ${{ secrets.AWX_TOKEN }} \ | |
| -f human job_templates launch 'Deploy XTM Hub' \ | |
| --inventory eu-west-production \ | |
| --extra_vars "{\"env\":\"Production\",\"xtmhub_version\":\"$VERSION\"}"; then | |
| echo "✅ Deployment successful" | |
| exit 0 | |
| fi | |
| if [ $i -lt $max_attempts ]; then | |
| delay=$((2 ** i)) | |
| echo "⚠️ Attempt $i/$max_attempts failed, retrying in ${delay}s..." | |
| sleep $delay | |
| fi | |
| done | |
| echo "❌ All $max_attempts attempts failed" | |
| exit 1 | |
| - name: Send n8n Webhook Notification | |
| if: contains(github.ref, 'refs/tags/') | |
| run: | | |
| echo "Sending n8n webhook notification..." | |
| response=$(curl -X POST \ | |
| -w "\n%{http_code}" \ | |
| -o /tmp/webhook_response.txt \ | |
| "${{ secrets.N8N_NOTIF_DEPLOYMENT_WEBHOOK }}?version=${GITHUB_REF_NAME}&status=completed") | |
| http_code=$(echo "$response" | tail -n1) | |
| response_body=$(cat /tmp/webhook_response.txt) | |
| if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then | |
| echo "✅ Webhook notification sent successfully (HTTP $http_code)" | |
| echo "Response: $response_body" | |
| else | |
| echo "⚠️ Webhook notification failed (HTTP $http_code)" | |
| echo "Response: $response_body" | |
| fi | |
| - name: Deploy on Staging | |
| if: github.ref == 'refs/heads/main' | |
| run: | | |
| max_attempts=5 | |
| for i in $(seq 1 $max_attempts); do | |
| if awx --conf.host https://awx.filigran.io \ | |
| --conf.token ${{ secrets.AWX_TOKEN }} \ | |
| -f human job_templates launch 'Deploy XTM Hub' \ | |
| --inventory eu-west-staging \ | |
| --extra_vars '{"env":"Staging"}'; then | |
| echo "✅ Deployment successful" | |
| exit 0 | |
| fi | |
| if [ $i -lt $max_attempts ]; then | |
| delay=$((2 ** i)) | |
| echo "⚠️ Attempt $i/$max_attempts failed, retrying in ${delay}s..." | |
| sleep $delay | |
| fi | |
| done | |
| echo "❌ All $max_attempts attempts failed" | |
| exit 1 | |
| - name: Deploy on Development | |
| if: github.ref == 'refs/heads/development' | |
| run: | | |
| max_attempts=5 | |
| for i in $(seq 1 $max_attempts); do | |
| if awx --conf.host https://awx.filigran.io \ | |
| --conf.token ${{ secrets.AWX_TOKEN }} \ | |
| -f human job_templates launch 'Deploy XTM Hub' \ | |
| --inventory eu-west-staging \ | |
| --extra_vars '{"env":"Development"}'; then | |
| echo "✅ Deployment successful" | |
| exit 0 | |
| fi | |
| if [ $i -lt $max_attempts ]; then | |
| delay=$((2 ** i)) | |
| echo "⚠️ Attempt $i/$max_attempts failed, retrying in ${delay}s..." | |
| sleep $delay | |
| fi | |
| done | |
| echo "❌ All $max_attempts attempts failed" | |
| exit 1 | |
| - name: Deploy on Prerelease | |
| if: github.ref == 'refs/heads/development' | |
| run: | | |
| max_attempts=5 | |
| for i in $(seq 1 $max_attempts); do | |
| if awx --conf.host https://awx.filigran.io \ | |
| --conf.token ${{ secrets.AWX_TOKEN }} \ | |
| -f human job_templates launch 'Deploy XTM Hub' \ | |
| --inventory eu-west-staging \ | |
| --extra_vars '{"env":"Prerelease"}'; then | |
| echo "✅ Deployment successful" | |
| exit 0 | |
| fi | |
| if [ $i -lt $max_attempts ]; then | |
| delay=$((2 ** i)) | |
| echo "⚠️ Attempt $i/$max_attempts failed, retrying in ${delay}s..." | |
| sleep $delay | |
| fi | |
| done | |
| echo "❌ All $max_attempts attempts failed" | |
| exit 1 |