Skip to content

chore: harden fly deploy workflow #143

chore: harden fly deploy workflow

chore: harden fly deploy workflow #143

Workflow file for this run

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