Skip to content

Fly Deployment

Fly Deployment #1244

name: Fly Deployment
# This workflow is triggered by a pull request or push to the main branch.
# Renovate and Nx migrations are ignored.
# Deployments run when the preview label is present (auto-added on PR open) or when closing PRs for cleanup.
# Can also be triggered manually for re-deployments.
on:
workflow_dispatch:
inputs:
app:
description: "App to deploy (leave empty for all affected apps)"
required: false
type: choice
options:
- ""
- cms
- web
tenant:
description: "Tenant ID (leave empty for all tenants, only applies to
multi-tenant apps)"
required: false
type: string
environment:
description: "Target environment"
required: true
type: choice
options:
- preview
- production
default: production
console-logs:
description: "Show native logs from e.g. Fly and Docker"
required: false
type: boolean
default: false
# Primary trigger once this workflow is on the default branch:
# deployment only starts after CI checks pass.
workflow_run:
workflows: ["CI"]
types: [completed]
pull_request:
types:
- opened
- reopened
- synchronize
- closed
push:
branches:
- main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NX_NO_CLOUD: true
# Label used to indicate that a preview deployment is enabled for the PR
FLY_PREVIEW_LABEL: preview-deploy
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.head_ref || github.ref }}
cancel-in-progress: false
jobs:
analyze-conditions:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.conditions.outputs.skip }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build conditions action
run: pnpm nx build fly-conditions-action
- name: Analyze deployment conditions
id: conditions
uses: ./packages/fly-conditions-action
with:
preview-label: ${{ env.FLY_PREVIEW_LABEL }}
token: ${{ secrets.GITHUB_TOKEN }}
pre-deploy:
needs: analyze-conditions
if: ${{ needs.analyze-conditions.outputs.skip != 'true' }}
runs-on: ubuntu-latest
outputs:
apps: ${{ steps.pre-deploy.outputs.apps }}
environment: ${{ steps.pre-deploy.outputs.environment }}
app-tenants: ${{ steps.pre-deploy.outputs.app-tenants }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build pre-deploy action
run: pnpm nx build nx-pre-deploy-action
- name: Set SHAs for affected calculations
uses: nrwl/nx-set-shas@v4
with:
main-branch-name: main
set-environment-variables-for-job: true
- name: Resolve PR number
id: resolve-pr
env:
WR_PRS: ${{ toJSON(github.event.workflow_run.pull_requests) }}
DIRECT_PR: ${{ github.event.number }}
run: |
PR=$(echo "$WR_PRS" | jq -r 'first | .number // empty')
echo "number=${PR:-$DIRECT_PR}" >> "$GITHUB_OUTPUT"
- name: Run pre-deploy
id: pre-deploy
uses: ./packages/nx-pre-deploy-action
with:
infisical-client-id: ${{ secrets.INFISICAL_READ_CLIENT_ID }}
infisical-client-secret: ${{ secrets.INFISICAL_READ_CLIENT_SECRET }}
infisical-project-id: ${{ secrets.INFISICAL_PROJECT_ID }}
manual-app: ${{ github.event.inputs.app }}
manual-tenant: ${{ github.event.inputs.tenant }}
manual-environment: ${{ github.event.inputs.environment }}
env:
# Must be defined since `github.json` is validated and cms app uses env
POSTGRES_PREVIEW: ${{ vars.FLY_POSTGRES_PREVIEW }}
PR_NUMBER: ${{ steps.resolve-pr.outputs.number }}
build:
needs: pre-deploy
if: >-
${{ needs.pre-deploy.outputs.environment != '' &&
needs.pre-deploy.outputs.apps != '[]' && github.event.action != 'closed'
}}
runs-on: ubuntu-latest
env:
SENTRY_ORG: ${{ secrets.INF_SENTRY_ORG }}
outputs:
images: ${{ steps.build.outputs.images }}
steps:
- uses: actions/create-github-app-token@v1
id: generate-token
with:
app-id: ${{ secrets.CDWR_ACTIONS_BOT_ID }}
private-key: ${{ secrets.CDWR_ACTIONS_BOT_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build build action
run: pnpm nx build fly-build-action
- name: Install Fly CLI
uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: ${{ vars.FLY_CLI_VERSION }}
# The Sentry webpack plugin uploads source maps during the Docker build and expects
# the release to already exist (next.config.mjs: create: false). So the release is
# created here, before the build. If anything fails afterward it is deleted below.
- name: Create Sentry release
id: sentry-release
if: env.SENTRY_ORG != ''
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ secrets.INF_SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.INF_SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.INF_SENTRY_PROJECT }}
with:
environment: ${{ needs.pre-deploy.outputs.environment }}
release: ${{ github.sha }}
set_commits: "auto"
finalize: false
- name: Resolve flags
id: flags
run: |
echo "console-logs=${{ github.event.inputs.console-logs == 'true' && 'true' || vars.FLY_CONSOLE_LOGS }}" >> "$GITHUB_OUTPUT"
- name: Build Docker images
id: build
uses: ./packages/fly-build-action
with:
fly-api-token: ${{ secrets.FLY_API_TOKEN }}
fly-org: ${{ vars.FLY_ORG }}
fly-trace-cli: ${{ vars.FLY_TRACE_CLI }}
fly-console-logs: ${{ steps.flags.outputs.console-logs }}
token: ${{ steps.generate-token.outputs.token }}
apps: ${{ needs.pre-deploy.outputs.apps }}
environment: ${{ needs.pre-deploy.outputs.environment }}
app-details: ${{ needs.pre-deploy.outputs.app-tenants }}
# Build arguments for Docker build (client-side vars + Sentry source map upload)
build-args: |
NEXT_PUBLIC_DEPLOY_ENV=${{ needs.pre-deploy.outputs.environment }}
NEXT_PUBLIC_SENTRY_DSN=${{ secrets.INF_SENTRY_DSN }}
NEXT_PUBLIC_SENTRY_RELEASE=${{ github.sha }}
SENTRY_ORG=${{ secrets.INF_SENTRY_ORG }}
SENTRY_PROJECT=${{ secrets.INF_SENTRY_PROJECT }}
SENTRY_RELEASE=${{ github.sha }}
SENTRY_AUTH_TOKEN=${{ secrets.INF_SENTRY_AUTH_TOKEN }}
opt-out-depot-builder: ${{ vars.FLY_OPT_OUT_DEPOT }}
# Upload images map as artifact — job outputs are suppressed by GitHub
# when they match registered secret patterns (Fly registry URLs can trigger this).
# Artifact files bypass secret scanning and are read by the action's Node process.
- name: Upload images artifact
uses: actions/upload-artifact@v4
with:
name: fly-built-images-${{ github.run_id }}
path: ${{ steps.build.outputs.images-path }}
retention-days: 1
if-no-files-found: error
# Clean up the Sentry release if the build failed.
- name: Delete Sentry release on build failure
if: >-
env.SENTRY_ORG != '' && steps.sentry-release.outcome == 'success' &&
steps.build.outcome != 'success'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.INF_SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.INF_SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.INF_SENTRY_PROJECT }}
run: pnpm sentry-cli releases delete "${{ github.sha }}" || true
deploy:
needs: [pre-deploy, build]
if: needs.build.result == 'success'
runs-on: ubuntu-latest
# Not possible to provide url since we might have multiple deployments
environment: ${{ needs.pre-deploy.outputs.environment }}
outputs:
environment: ${{ steps.deployment.outputs.environment }}
deployed: ${{ steps.deployment.outputs.deployed }}
failed: ${{ steps.deployment.outputs.failed }}
projects: ${{ steps.deployment.outputs.projects }}
steps:
- uses: actions/create-github-app-token@v1
id: generate-token
with:
app-id: ${{ secrets.CDWR_ACTIONS_BOT_ID }}
private-key: ${{ secrets.CDWR_ACTIONS_BOT_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build deployment action
run: pnpm nx build fly-deployment-action
- name: Install Fly CLI
uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: ${{ vars.FLY_CLI_VERSION }}
- name: Resolve flags
id: flags
run: |
echo "console-logs=${{ github.event.inputs.console-logs == 'true' && 'true' || vars.FLY_CONSOLE_LOGS }}" >> "$GITHUB_OUTPUT"
- name: Download images artifact
uses: actions/download-artifact@v4
with:
name: fly-built-images-${{ github.run_id }}
path: ${{ runner.temp }}/fly-images
- name: Deploy to Fly
id: deployment
uses: ./packages/fly-deployment-action
with:
fly-api-token: ${{ secrets.FLY_API_TOKEN }}
fly-org: ${{ vars.FLY_ORG }}
fly-region: ${{ vars.FLY_REGION }}
fly-trace-cli: ${{ vars.FLY_TRACE_CLI }}
fly-console-logs: ${{ steps.flags.outputs.console-logs }}
token: ${{ steps.generate-token.outputs.token }}
apps: ${{ needs.pre-deploy.outputs.apps }}
environment: ${{ needs.pre-deploy.outputs.environment }}
app-details: ${{ needs.pre-deploy.outputs.app-tenants }}
images-path: ${{ runner.temp }}/fly-images/fly-built-images.json
# Runtime environment variables
env: |
INFISICAL_SITE=${{ vars.INFISICAL_SITE }}
SENTRY_DSN=${{ secrets.INF_SENTRY_DSN }}
SENTRY_ORG=${{ secrets.INF_SENTRY_ORG }}
SENTRY_PROJECT=${{ secrets.INF_SENTRY_PROJECT }}
SENTRY_RELEASE=${{ github.sha }}
# Sensitive secrets are also passed to app env on deployment
secrets: |
INFISICAL_CLIENT_ID=${{ secrets.INFISICAL_READ_CLIENT_ID }}
INFISICAL_CLIENT_SECRET=${{ secrets.INFISICAL_READ_CLIENT_SECRET }}
INFISICAL_PROJECT_ID=${{ secrets.INFISICAL_PROJECT_ID }}
SENTRY_AUTH_TOKEN=${{ secrets.INF_SENTRY_AUTH_TOKEN }}
opt-out-depot-builder: ${{ vars.FLY_OPT_OUT_DEPOT }}
- name: Finalize Sentry release
if: steps.deployment.outcome == 'success'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.INF_SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.INF_SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.INF_SENTRY_PROJECT }}
run: pnpm sentry-cli releases finalize "${{ github.sha }}"
# Clean up the Sentry release if deployment failed.
- name: Delete Sentry release on deployment failure
if: always() && steps.deployment.outcome != 'success'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.INF_SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.INF_SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.INF_SENTRY_PROJECT }}
run: pnpm sentry-cli releases delete "${{ github.sha }}" || true
# Upload projects as artifact — job outputs containing Fly.io URLs can be suppressed
# by GitHub Actions secret scanning. Artifact files bypass secret scanning.
- name: Upload projects artifact
if: always() && steps.deployment.outputs.projects-path != ''
uses: actions/upload-artifact@v4
with:
name: fly-deployed-projects-${{ github.run_id }}
path: ${{ steps.deployment.outputs.projects-path }}
retention-days: 1
if-no-files-found: warn
pr-comment:
needs: [pre-deploy, deploy]
if: >-
always() &&
(github.event_name == 'pull_request' && github.event.action != 'closed' ||
github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') &&
needs.deploy.result != 'skipped'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: generate-token
with:
app-id: ${{ secrets.CDWR_ACTIONS_BOT_ID }}
private-key: ${{ secrets.CDWR_ACTIONS_BOT_PRIVATE_KEY }}
- name: Resolve PR number
id: resolve-pr
env:
WR_PRS: ${{ toJSON(github.event.workflow_run.pull_requests) }}
DIRECT_PR: ${{ github.event.number }}
run: |
PR=$(echo "$WR_PRS" | jq -r 'first | .number // empty')
echo "number=${PR:-$DIRECT_PR}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build PR comment action
run: pnpm nx build pr-comment-action
- name: Download projects artifact
uses: actions/download-artifact@v4
with:
name: fly-deployed-projects-${{ github.run_id }}
path: ${{ runner.temp }}/fly-projects
continue-on-error: true
- name: Post PR comment
uses: ./packages/pr-comment-action
with:
pull-request: ${{ steps.resolve-pr.outputs.number }}
environment: ${{ needs.deploy.outputs.environment }}
deployed: ${{ needs.deploy.outputs.deployed }}
failed: ${{ needs.deploy.outputs.failed }}
projects-path: ${{ runner.temp }}/fly-projects/fly-projects.json
token: ${{ steps.generate-token.outputs.token }}
notify:
needs: [pre-deploy, deploy]
if: >-
always() &&
needs.deploy.result != 'skipped'
runs-on: ubuntu-latest
steps:
- name: Resolve PR number
id: resolve-pr
env:
WR_PRS: ${{ toJSON(github.event.workflow_run.pull_requests) }}
DIRECT_PR: ${{ github.event.number }}
run: |
PR=$(echo "$WR_PRS" | jq -r 'first | .number // empty')
echo "number=${PR:-$DIRECT_PR}" >> "$GITHUB_OUTPUT"
- name: Download projects artifact
uses: actions/download-artifact@v4
with:
name: fly-deployed-projects-${{ github.run_id }}
path: ${{ runner.temp }}/fly-projects
continue-on-error: true
- name: Build Slack payload
id: build-payload
env:
ENVIRONMENT: ${{ needs.pre-deploy.outputs.environment }}
DEPLOY_RESULT: ${{ needs.deploy.result }}
PR_NUMBER: ${{ steps.resolve-pr.outputs.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
PROJECTS_FILE: ${{ runner.temp }}/fly-projects/fly-projects.json
run: |
STATUS_EMOJI=$([ "$DEPLOY_RESULT" = "success" ] && echo "✅" || echo "❌")
ENV_LABEL=$([ "$ENVIRONMENT" = "production" ] && echo "Production" || echo "Preview")
PR_SUFFIX=$([ -n "$PR_NUMBER" ] && echo " · PR #${PR_NUMBER}" || echo "")
HEADER="${STATUS_EMOJI} ${ENV_LABEL} Deployment${PR_SUFFIX}"
APPS_TEXT=""
if [ -f "$PROJECTS_FILE" ]; then
APPS_TEXT=$(jq -r '
.[] |
if .action == "deploy" then "✅ *" + .name + "* — <" + .url + "|" + .url + ">"
elif .action == "failed" then "❌ *" + .appOrProject + "* — " + .error
else empty
end
' "$PROJECTS_FILE")
fi
PAYLOAD=$(jq -nc \
--arg header "$HEADER" \
--arg apps "$APPS_TEXT" \
--arg run_url "$RUN_URL" \
'{
"blocks": (
[{"type":"header","text":{"type":"plain_text","text":$header,"emoji":true}}]
+ (if ($apps | length) > 0 then [{"type":"section","text":{"type":"mrkdwn","text":$apps}}] else [] end)
+ [{"type":"context","elements":[{"type":"mrkdwn","text":("<" + $run_url + "|View workflow run>")}]}]
)
}')
echo "payload<<PAYLOAD_EOF" >> "$GITHUB_OUTPUT"
echo "$PAYLOAD" >> "$GITHUB_OUTPUT"
echo "PAYLOAD_EOF" >> "$GITHUB_OUTPUT"
- name: Send Slack notification
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_WEBHOOK }}
webhook-type: incoming-webhook
errors: true
payload: ${{ steps.build-payload.outputs.payload }}
destroy:
needs: pre-deploy
if: >-
github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: generate-token
with:
app-id: ${{ secrets.CDWR_ACTIONS_BOT_ID }}
private-key: ${{ secrets.CDWR_ACTIONS_BOT_PRIVATE_KEY }}
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build destroy action
run: pnpm nx build fly-destroy-action
- name: Install Fly CLI
uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: ${{ vars.FLY_CLI_VERSION }}
- name: Resolve flags
id: flags
run: |
echo "console-logs=${{ github.event.inputs.console-logs == 'true' && 'true' || vars.FLY_CONSOLE_LOGS }}" >> "$GITHUB_OUTPUT"
- name: Destroy preview apps
id: destroy
uses: ./packages/fly-destroy-action
with:
fly-api-token: ${{ secrets.FLY_API_TOKEN }}
fly-trace-cli: ${{ vars.FLY_TRACE_CLI }}
fly-console-logs: ${{ steps.flags.outputs.console-logs }}
token: ${{ steps.generate-token.outputs.token }}
summary:
needs:
[
analyze-conditions,
pre-deploy,
build,
deploy,
destroy,
pr-comment,
notify
]
if: always()
runs-on: ubuntu-latest
steps:
- name: Deployment Summary
env:
SKIPPED: ${{ needs.analyze-conditions.outputs.skip }}
ENVIRONMENT: ${{ needs.pre-deploy.outputs.environment }}
BUILD_RESULT: ${{ needs.build.result }}
DEPLOY_RESULT: ${{ needs.deploy.result }}
DESTROY_RESULT: ${{ needs.destroy.result }}
IS_CLOSED_PR:
${{ github.event_name == 'pull_request' && github.event.action ==
'closed' }}
IS_MANUAL: ${{ github.event_name == 'workflow_dispatch' }}
MANUAL_APP: ${{ github.event.inputs.app }}
MANUAL_TENANT: ${{ github.event.inputs.tenant }}
CONSOLE_LOGS: ${{ github.event.inputs.console-logs }}
run: |
if [ "$IS_MANUAL" = "true" ]; then
echo "🔧 Manual deployment triggered" >> $GITHUB_STEP_SUMMARY
[ -n "$MANUAL_APP" ] && echo " - App: $MANUAL_APP" >> $GITHUB_STEP_SUMMARY
[ -n "$MANUAL_TENANT" ] && echo " - Tenant: $MANUAL_TENANT" >> $GITHUB_STEP_SUMMARY
echo " - Environment: $ENVIRONMENT" >> $GITHUB_STEP_SUMMARY
[ "$CONSOLE_LOGS" = "true" ] && echo " - Console logs: enabled" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
if [ "$IS_CLOSED_PR" = "true" ]; then
echo "🚪 PR closed - running cleanup" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$DESTROY_RESULT" = "success" ]; then
echo "✅ Preview apps destroyed successfully" >> $GITHUB_STEP_SUMMARY
elif [ "$DESTROY_RESULT" = "skipped" ]; then
echo "⏭️ No preview apps to destroy" >> $GITHUB_STEP_SUMMARY
else
echo "❌ Preview app cleanup failed or was cancelled" >> $GITHUB_STEP_SUMMARY
fi
exit 0
fi
if [ "$SKIPPED" = "true" ]; then
echo "⏭️ Deployment skipped - conditions not met" >> $GITHUB_STEP_SUMMARY
elif [ "$ENVIRONMENT" = "" ]; then
echo "⏭️ No affected apps" >> $GITHUB_STEP_SUMMARY
elif [ "$DEPLOY_RESULT" = "success" ]; then
echo "✅ Build and deployment completed successfully" >> $GITHUB_STEP_SUMMARY
elif [ "$DEPLOY_RESULT" = "skipped" ] && [ "$BUILD_RESULT" = "skipped" ]; then
echo "⏭️ No apps to deploy" >> $GITHUB_STEP_SUMMARY
else
echo "❌ Build or deployment failed or was cancelled" >> $GITHUB_STEP_SUMMARY
fi