# Generated by deploykit — CI/CD for a Turbo monorepo on Fly.io. name: Deploy on: pull_request: types: [opened, synchronize, reopened, closed] push: branches: [main] workflow_dispatch: inputs: app: description: 'App to deploy to production' default: all type: choice options: ["all","web","marketing"] # Grouped per event so a production dispatch never shares a group with (and # cancels) a staging push. Only PR runs are cancelled mid-flight — previews are # disposable; staging/production deploys queue instead of dying mid-deploy. concurrency: group: deploy-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read pull-requests: write issues: write # preview comments are issue comments (updated in place) env: FLY_ORG: acme jobs: changes: if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') runs-on: ubuntu-latest outputs: apps: ${{ steps.filter.outputs.changes }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: dorny/paths-filter@v3 id: filter with: filters: | web: - 'apps/web/**' - 'packages/ui/**' - 'packages/database/**' marketing: - 'apps/marketing/**' - 'packages/ui/**' preview: needs: changes if: github.event_name == 'pull_request' && github.event.action != 'closed' && needs.changes.outputs.apps != '[]' runs-on: ubuntu-latest strategy: fail-fast: false matrix: app: ${{ fromJSON(needs.changes.outputs.apps) }} concurrency: group: preview-${{ github.event.number }}-${{ matrix.app }} cancel-in-progress: true steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@master - name: Deploy (preview) env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} SECRET_DATABASE_URL: ${{ secrets.DATABASE_URL }} SECRET_SESSION_SECRET: ${{ secrets.SESSION_SECRET }} SECRET_VITE_API_URL: ${{ secrets.VITE_API_URL }} run: | set -euo pipefail APP="${{ matrix.app }}" FLY_APP="acme-$APP-pr-${{ github.event.number }}" echo "Deploying $APP -> $FLY_APP" flyctl status --app "$FLY_APP" >/dev/null 2>&1 || flyctl apps create "$FLY_APP" --org "$FLY_ORG" BUILD_ARGS=() case "$APP" in web) ROOT="apps/web" if [ -n "$SECRET_DATABASE_URL" ]; then flyctl secrets set --stage --app "$FLY_APP" DATABASE_URL="$SECRET_DATABASE_URL"; fi if [ -n "$SECRET_SESSION_SECRET" ]; then flyctl secrets set --stage --app "$FLY_APP" SESSION_SECRET="$SECRET_SESSION_SECRET"; fi if [ -n "$SECRET_VITE_API_URL" ]; then BUILD_ARGS+=(--build-arg "VITE_API_URL=$SECRET_VITE_API_URL"); fi ;; marketing) ROOT="apps/marketing" ;; esac flyctl deploy . \ --config "$ROOT/fly.toml" \ --dockerfile "$ROOT/Dockerfile" \ --app "$FLY_APP" \ --remote-only --ha=false ${BUILD_ARGS[@]+"${BUILD_ARGS[@]}"} - name: Comment preview URL env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Upsert (via a hidden per-app marker) so repeated pushes update one # comment instead of stacking a new one per deploy. run: | APP="${{ matrix.app }}" URL="https://acme-$APP-pr-${{ github.event.number }}.fly.dev" MARKER="" BODY=$(printf '🚀 Preview for `%s`: %s\n\n%s' "$APP" "$URL" "$MARKER") COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.number }}/comments" \ --paginate --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" | head -n1) if [ -n "$COMMENT_ID" ]; then gh api --method PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$BODY" >/dev/null else gh pr comment ${{ github.event.number }} --repo ${{ github.repository }} --body "$BODY" fi teardown: if: github.event_name == 'pull_request' && github.event.action == 'closed' runs-on: ubuntu-latest strategy: fail-fast: false matrix: app: ["web","marketing"] steps: - uses: superfly/flyctl-actions/setup-flyctl@master - name: Destroy preview env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} run: | flyctl apps destroy "acme-${{ matrix.app }}-pr-${{ github.event.number }}" --yes || true staging: needs: changes if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.apps != '[]' runs-on: ubuntu-latest environment: staging strategy: fail-fast: false matrix: app: ${{ fromJSON(needs.changes.outputs.apps) }} steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@master - name: Deploy (staging) env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} SECRET_DATABASE_URL: ${{ secrets.DATABASE_URL }} SECRET_SESSION_SECRET: ${{ secrets.SESSION_SECRET }} SECRET_VITE_API_URL: ${{ secrets.VITE_API_URL }} run: | set -euo pipefail APP="${{ matrix.app }}" FLY_APP="acme-$APP-staging" echo "Deploying $APP -> $FLY_APP" flyctl status --app "$FLY_APP" >/dev/null 2>&1 || flyctl apps create "$FLY_APP" --org "$FLY_ORG" BUILD_ARGS=() case "$APP" in web) ROOT="apps/web" if [ -n "$SECRET_DATABASE_URL" ]; then flyctl secrets set --stage --app "$FLY_APP" DATABASE_URL="$SECRET_DATABASE_URL"; fi if [ -n "$SECRET_SESSION_SECRET" ]; then flyctl secrets set --stage --app "$FLY_APP" SESSION_SECRET="$SECRET_SESSION_SECRET"; fi if [ -n "$SECRET_VITE_API_URL" ]; then BUILD_ARGS+=(--build-arg "VITE_API_URL=$SECRET_VITE_API_URL"); fi ;; marketing) ROOT="apps/marketing" ;; esac flyctl deploy . \ --config "$ROOT/fly.toml" \ --dockerfile "$ROOT/Dockerfile" \ --app "$FLY_APP" \ --remote-only --ha=false ${BUILD_ARGS[@]+"${BUILD_ARGS[@]}"} # Run in extra regions too (stateless multi-region), best-effort. for R in lhr; do flyctl scale count 1 --region "$R" --app "$FLY_APP" --yes || echo "::warning::could not scale $FLY_APP into $R" done production: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest environment: production strategy: fail-fast: false matrix: app: ["web","marketing"] steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@master - name: Deploy (production) if: github.event.inputs.app == 'all' || github.event.inputs.app == matrix.app env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} SECRET_DATABASE_URL: ${{ secrets.DATABASE_URL }} SECRET_SESSION_SECRET: ${{ secrets.SESSION_SECRET }} SECRET_VITE_API_URL: ${{ secrets.VITE_API_URL }} run: | set -euo pipefail APP="${{ matrix.app }}" FLY_APP="acme-$APP-prod" echo "Deploying $APP -> $FLY_APP" flyctl status --app "$FLY_APP" >/dev/null 2>&1 || flyctl apps create "$FLY_APP" --org "$FLY_ORG" BUILD_ARGS=() case "$APP" in web) ROOT="apps/web" if [ -n "$SECRET_DATABASE_URL" ]; then flyctl secrets set --stage --app "$FLY_APP" DATABASE_URL="$SECRET_DATABASE_URL"; fi if [ -n "$SECRET_SESSION_SECRET" ]; then flyctl secrets set --stage --app "$FLY_APP" SESSION_SECRET="$SECRET_SESSION_SECRET"; fi if [ -n "$SECRET_VITE_API_URL" ]; then BUILD_ARGS+=(--build-arg "VITE_API_URL=$SECRET_VITE_API_URL"); fi ;; marketing) ROOT="apps/marketing" ;; esac flyctl deploy . \ --config "$ROOT/fly.toml" \ --dockerfile "$ROOT/Dockerfile" \ --app "$FLY_APP" \ --remote-only ${BUILD_ARGS[@]+"${BUILD_ARGS[@]}"} # Run in extra regions too (stateless multi-region), best-effort. for R in lhr; do flyctl scale count 1 --region "$R" --app "$FLY_APP" --yes || echo "::warning::could not scale $FLY_APP into $R" done