chore: harden fly deploy workflow #544
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: deploy | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| on: | |
| workflow_dispatch: | |
| push: | |
| branches: | |
| - 'main' | |
| pull_request: {} | |
| jobs: | |
| setup: | |
| name: 🔧 Setup | |
| timeout-minutes: 15 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, windows-latest, macos-latest] | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - name: ⎔ Setup node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 26 | |
| # SKIP_PLAYGROUND keeps typecheck/lint meaningful: problem playground apps | |
| # intentionally fail solution tests/types, which would create false CI red. | |
| # Use bash explicitly: Windows runners default to PowerShell, which cannot | |
| # parse this retry loop. | |
| - name: ▶️ Add repo | |
| shell: bash | |
| env: | |
| # Kept getting npm ECOMPROMISED errors on windows. This fixed it. | |
| npm_config_cache: ${{ runner.temp }}/npm-cache | |
| SKIP_PLAYWRIGHT: true | |
| SKIP_PLAYGROUND: true | |
| run: | | |
| set -euo pipefail | |
| attempt=1 | |
| max_attempts=3 | |
| delay_seconds=20 | |
| while true; do | |
| echo "::group::epicshop add attempt ${attempt}/${max_attempts}" | |
| set +e | |
| npx --yes epicshop@latest add ${{ github.event.repository.name }}#${{ github.head_ref || github.ref_name }} ./workshop | |
| status=$? | |
| set -e | |
| echo "::endgroup::" | |
| if [ "$status" -eq 0 ]; then | |
| exit 0 | |
| fi | |
| if [ "$attempt" -ge "$max_attempts" ]; then | |
| echo "epicshop add failed after ${max_attempts} attempts" >&2 | |
| exit "$status" | |
| fi | |
| echo "epicshop add failed (exit ${status}); retrying in ${delay_seconds}s..." | |
| sleep "$delay_seconds" | |
| delay_seconds=$((delay_seconds * 2)) | |
| attempt=$((attempt + 1)) | |
| done | |
| - name: ʦ TypeScript | |
| run: npm run typecheck | |
| working-directory: ./workshop | |
| - name: ⬣ Lint | |
| run: npm run lint | |
| working-directory: ./workshop | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=8192 | |
| tests: | |
| name: 🧪 Test | |
| timeout-minutes: 15 | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: ⬇️ Checkout repo | |
| uses: actions/checkout@v4 | |
| - name: ⎔ Setup node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 26 | |
| - name: 📦 Install dependencies | |
| run: npm ci | |
| - name: 🎭 Install Playwright browser | |
| if: ${{ hashFiles('epicshop/test.js') != '' }} | |
| run: npx playwright install --with-deps chromium | |
| - name: ◭ Generate Prisma clients | |
| if: ${{ hashFiles('epicshop/test.js') != '' }} | |
| run: | | |
| set -euo pipefail | |
| shopt -s globstar nullglob | |
| schemas=(./exercises/**/*.solution.*/prisma/schema.prisma) | |
| if [ "${#schemas[@]}" -eq 0 ]; then | |
| echo "No solution-app Prisma schemas found; skipping Prisma client generation." | |
| exit 0 | |
| fi | |
| for schema in "${schemas[@]}"; do | |
| app_dir="${schema%/prisma/schema.prisma}" | |
| echo "::group::prisma generate in ${app_dir}" | |
| (cd "$app_dir" && npx prisma generate) | |
| echo "::endgroup::" | |
| done | |
| # Workshops ship epicshop/test.js (not .ts). `..s` runs solution apps only. | |
| - name: 🧪 Run solution tests | |
| run: | | |
| set -euo pipefail | |
| if [ -f ./epicshop/test.js ]; then | |
| node ./epicshop/test.js ..s | |
| else | |
| echo "No epicshop/test.js in this workshop; skipping solution-app test runner." | |
| fi | |
| deploy: | |
| name: 🚀 Deploy | |
| # Retries + Fly health waits need headroom beyond a single deploy attempt. | |
| timeout-minutes: 30 | |
| runs-on: ubuntu-latest | |
| # Deploy main on push or manual dispatch (non-forks). Dispatch is needed for | |
| # sequenced recovery / re-deploys without inventing empty commits. | |
| if: | |
| ${{ github.ref == 'refs/heads/main' && github.repository_owner == | |
| 'epicweb-dev' && (github.event_name == 'push' || github.event_name == | |
| 'workflow_dispatch') }} | |
| steps: | |
| - name: ⬇️ Checkout repo | |
| uses: actions/checkout@v4 | |
| - name: 🎈 Setup Fly | |
| uses: superfly/flyctl-actions/setup-flyctl@1.5 | |
| - name: 🚀 Deploy | |
| working-directory: ./epicshop | |
| env: | |
| FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| # Mass epicshop updates can fan out dozens of Fly deploys at once. | |
| # Fly lease contention and machines API health-check waits then flake. | |
| # Retry with exponential backoff so a single thundering-herd event does | |
| # not leave workshop apps stranded on a failed rolling update. | |
| max_attempts=4 | |
| delay_seconds=45 | |
| attempt=1 | |
| # Support both `app: my-app` and `app: 'my-app'` / `app: "my-app"`. | |
| app_name="$( | |
| awk ' | |
| /^app:[[:space:]]*/ { | |
| value = $0 | |
| sub(/^app:[[:space:]]*/, "", value) | |
| gsub(/^['\''"]|['\''"]$/, "", value) | |
| print value | |
| exit | |
| } | |
| ' fly.yaml | |
| )" | |
| if [ -z "$app_name" ]; then | |
| echo "Could not parse app name from fly.yaml" >&2 | |
| exit 1 | |
| fi | |
| while true; do | |
| echo "::group::flyctl deploy attempt ${attempt}/${max_attempts}" | |
| set +e | |
| flyctl deploy --remote-only \ | |
| --build-arg EPICSHOP_GITHUB_REPO=https://github.com/${{ github.repository }} \ | |
| --build-arg EPICSHOP_COMMIT_SHA=${{ github.sha }} | |
| status=$? | |
| set -e | |
| echo "::endgroup::" | |
| if [ "$status" -eq 0 ]; then | |
| # flyctl can treat a stopped scale-to-zero machine as "good" without | |
| # ever proving the new image boots. Wake the app and require health. | |
| echo "::group::post-deploy health check for ${app_name}" | |
| healthy=0 | |
| for probe in 1 2 3 4 5 6 7 8 9 10; do | |
| set +e | |
| code="$(curl -sS -o /tmp/epicshop-health -w '%{http_code}' --max-time 30 \ | |
| "https://${app_name}.fly.dev/resources/healthcheck")" | |
| curl_status=$? | |
| set -e | |
| body="$(tr -d '\n' </tmp/epicshop-health | head -c 80)" | |
| echo "probe ${probe}: curl_exit=${curl_status} http=${code} body=${body}" | |
| if [ "$curl_status" -eq 0 ] && [ "$code" = "200" ]; then | |
| healthy=1 | |
| break | |
| fi | |
| sleep 15 | |
| done | |
| echo "::endgroup::" | |
| if [ "$healthy" -eq 1 ]; then | |
| exit 0 | |
| fi | |
| echo "Deploy reported success but app failed post-deploy health checks" >&2 | |
| status=1 | |
| fi | |
| if [ "$attempt" -ge "$max_attempts" ]; then | |
| echo "flyctl deploy failed after ${max_attempts} attempts" >&2 | |
| exit "$status" | |
| fi | |
| echo "Deploy attempt ${attempt} failed (exit ${status}); retrying in ${delay_seconds}s..." | |
| sleep "$delay_seconds" | |
| delay_seconds=$((delay_seconds * 2)) | |
| attempt=$((attempt + 1)) | |
| done |