Create DigitalOcean App Workflow
This GitHub Action workflow automatically deploys preview environments to DigitalOcean App Platform when a pull request is created.
Trigger Conditions
- Runs on pull requests to the
masterbranch - Skips if PR title contains "DO NOT PREVIEW"
Workflow Steps
1. Environment Setup
- Checks out code
- Installs required tools:
jq,doctl,sshpass - Authenticates with DigitalOcean API
2. Database Preparation
- Sanitizes branch name for use in database/app names (max 18 characters)
- Dumps production database from managed database
- Creates new database on droplet with branch-specific name
- Imports database dump to new database
3. App Service Deployment
- Generates app spec with all environment variables
- Creates or updates app service with branch-specific name
- Waits for app to be live and retrieves URL
4. Admin Service Deployment
- Generates admin spec using app URL for API connection
- Creates or updates admin service
- Waits for admin to be live
5. Final Configuration
- Updates app service with live URLs for APP_URL and ADMIN_URL
- Sends deployment notification email with both URLs
Complete Workflow
name: Deploy to DigitalOcean App Platform
on:
pull_request:
branches:
- master
jobs:
deploy:
if: ${{ ! contains(github.event.pull_request.title, 'DO NOT PRIVIEW') }}
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Install jq
run: sudo apt-get update && sudo apt-get install -y jq
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DO_API_TOKEN }}
- name: Authenticate doctl
run: doctl auth init -t ${{ secrets.DO_API_TOKEN }}
- name: Sanitize Branch Name
id: sanitize_branch
run: |
ORIGINAL_BRANCH="${{ github.head_ref }}"
SANITIZED_BRANCH=$(echo "${ORIGINAL_BRANCH}" | sed 's/[^a-zA-Z0-9]/-/g')
SANITIZED_BRANCH=${SANITIZED_BRANCH:0:18}
echo "SANITIZED_BRANCH=${SANITIZED_BRANCH}" >> $GITHUB_OUTPUT
- name: Install sshpass
run: |
sudo apt-get update
sudo apt-get install -y sshpass
- name: Dump IBA Database from Managed Database
env:
SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}
MANAGED_DB_HOST: ${{ secrets.MANAGED_DB_HOST }}
MANAGED_DB_PORT: ${{ secrets.MANAGED_DB_PORT }}
MANAGED_DB_USER: ${{ secrets.MANAGED_DB_USER }}
MANAGED_DB_PASSWORD: ${{ secrets.MANAGED_DB_PASSWORD }}
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_DROPLET_PASSWORD: ${{ secrets.DO_DROPLET_PASSWORD }}
run: |
sshpass -p "${DO_DROPLET_PASSWORD}" ssh -o StrictHostKeyChecking=no root@${DO_DROPLET_IP} << EOF
set -ex
echo "Starting mysqldump at: \$(date)"
rm -rf /tmp/iba_dump.sql
mysqldump \
--set-gtid-purged=OFF \
--host="${MANAGED_DB_HOST}" \
--port="${MANAGED_DB_PORT}" \
--user="${MANAGED_DB_USER}" \
--password="${MANAGED_DB_PASSWORD}" \
--ssl-mode=REQUIRED \
--routines \
--events \
--triggers \
IBA > /tmp/iba_dump.sql
echo "Mysqldump finished at: \$(date)"
EOF
- name: Drop Database ON Droplet
env:
SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}
MANAGED_DB_HOST: ${{ secrets.MANAGED_DB_HOST }}
MANAGED_DB_PORT: ${{ secrets.MANAGED_DB_PORT }}
MANAGED_DB_USER: ${{ secrets.MANAGED_DB_USER }}
MANAGED_DB_PASSWORD: ${{ secrets.MANAGED_DB_PASSWORD }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_DROPLET_PASSWORD: ${{ secrets.DO_DROPLET_PASSWORD }}
run: |
NEW_DB="${SANITIZED_BRANCH}_iba"
echo "New database name: ${NEW_DB}"
sshpass -p "${DO_DROPLET_PASSWORD}" ssh -o StrictHostKeyChecking=no root@${DO_DROPLET_IP} << EOF
set -ex
echo "Dropping existing database \${NEW_DB} (if exists) and creating new database..."
docker exec -i mysql-container mysql -uroot -p"${DB_PASSWORD}" -e "DROP DATABASE IF EXISTS \`${NEW_DB}\`"
EOF
- name: Create Database ON Droplet
env:
SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}
MANAGED_DB_HOST: ${{ secrets.MANAGED_DB_HOST }}
MANAGED_DB_PORT: ${{ secrets.MANAGED_DB_PORT }}
MANAGED_DB_USER: ${{ secrets.MANAGED_DB_USER }}
MANAGED_DB_PASSWORD: ${{ secrets.MANAGED_DB_PASSWORD }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_DROPLET_PASSWORD: ${{ secrets.DO_DROPLET_PASSWORD }}
run: |
NEW_DB="${SANITIZED_BRANCH}_iba"
echo "New database name: ${NEW_DB}"
sshpass -p "${DO_DROPLET_PASSWORD}" ssh -o StrictHostKeyChecking=no root@${DO_DROPLET_IP} << EOF
set -ex
docker exec -i mysql-container mysql -uroot -p"${DB_PASSWORD}" -e "CREATE DATABASE \`${NEW_DB}\`"
echo "Created existing database \${NEW_DB} (if exists) and creating new database..."
EOF
- name: Import Database ON Droplet
env:
SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}
MANAGED_DB_HOST: ${{ secrets.MANAGED_DB_HOST }}
MANAGED_DB_PORT: ${{ secrets.MANAGED_DB_PORT }}
MANAGED_DB_USER: ${{ secrets.MANAGED_DB_USER }}
MANAGED_DB_PASSWORD: ${{ secrets.MANAGED_DB_PASSWORD }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_DROPLET_PASSWORD: ${{ secrets.DO_DROPLET_PASSWORD }}
run: |
NEW_DB="${SANITIZED_BRANCH}_iba"
echo "New database name: ${NEW_DB}"
sshpass -p "${DO_DROPLET_PASSWORD}" ssh -o StrictHostKeyChecking=no root@${DO_DROPLET_IP} << EOF
set -ex
docker exec -i mysql-container mysql -uroot -p"${DB_PASSWORD}" "${NEW_DB}" < /tmp/iba_dump.sql
EOF
- name: Generate App Spec
env:
REPO_CLONE_URL: "https://${{ secrets.GIT_DO_USERNAME }}:${{ secrets.GIT_DO_TOKEN }}@github.com/${{ github.repository }}.git"
BRANCH: ${{ github.head_ref }}
SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}
CRYPTO_KEY: ${{ secrets.CRYPTO_KEY }}
ADMIN_URL: ${{ secrets.ADMIN_URL }}
APP_URL: ${{ secrets.APP_URL }}
IS_PRODUCTION: ${{ secrets.IS_PRODUCTION }}
NODE_ENV: ${{ secrets.NODE_ENV }}
USE_NPM_INSTALL: ${{ secrets.USE_NPM_INSTALL }}
NODE_MODULES_CACHE: ${{ secrets.NODE_MODULES_CACHE }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GOOGLE_TRASLATE_TEXT_KEY: ${{ secrets.GOOGLE_TRASLATE_TEXT_KEY }}
JIRA_ACCOUNT_EMAIL: ${{ secrets.JIRA_ACCOUNT_EMAIL }}
JIRA_ACCOUNT_TOKEN: ${{ secrets.JIRA_ACCOUNT_TOKEN }}
JIRA_ISSUE_TYPE_ID: ${{ secrets.JIRA_ISSUE_TYPE_ID }}
JIRA_MEMBER_ALEX: ${{ secrets.JIRA_MEMBER_ALEX }}
JIRA_MEMBER_VIVEK: ${{ secrets.JIRA_MEMBER_VIVEK }}
JIRA_PROJECT_ID: ${{ secrets.JIRA_PROJECT_ID }}
JWT_ALGO: ${{ secrets.JWT_ALGO }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
LOGS_DB_HOST: ${{ secrets.LOGS_DB_HOST }}
LOGS_DB_NAME: ${{ secrets.LOGS_DB_NAME }}
LOGS_DB_PASSWORD: ${{ secrets.LOGS_DB_PASSWORD }}
LOGS_DB_PORT: ${{ secrets.LOGS_DB_PORT }}
LOGS_DB_USER: ${{ secrets.LOGS_DB_USER }}
DB_HOST: ${{ secrets.DB_HOST }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_NAME: "${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}_iba"
DB_PORT: ${{ secrets.DB_PORT }}
MANDRILL_API_KEY: ${{ secrets.MANDRILL_API_KEY }}
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
EMAIL_ALERT: ${{ secrets.EMAIL_ALERT }}
POSTMAN_TOKEN: ${{ secrets.POSTMAN_TOKEN }}
RECAPTCHA_SECRETE_KEY: ${{ secrets.RECAPTCHA_SECRETE_KEY }}
RECAPTCHA_SITE_KEY: ${{ secrets.RECAPTCHA_SITE_KEY }}
S3_BUCKET_ACCESS_KEY_ID: ${{ secrets.S3_BUCKET_ACCESS_KEY_ID }}
S3_BUCKET_SECRET_ACCESS_KEY: ${{ secrets.S3_BUCKET_SECRET_ACCESS_KEY }}
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
S3_BASE_URL: ${{ secrets.S3_BASE_URL }}
S3_REGION: ${{ secrets.S3_REGION }}
STRIPE_PK: ${{ secrets.STRIPE_PK }}
STRIPE_SK: ${{ secrets.STRIPE_SK }}
TWILIO_ACCOUNTSID: ${{ secrets.TWILIO_ACCOUNTSID }}
TWILIO_AUTHTOKEN: ${{ secrets.TWILIO_AUTHTOKEN }}
FACEBOOK_CLIENT_ID: ${{ secrets.FACEBOOK_CLIENT_ID }}
FACEBOOK_CLIENT_SECRET: ${{ secrets.FACEBOOK_CLIENT_SECRET }}
GOOGLE_TRASLATE_CLIENT_EMAIL: ${{ secrets.GOOGLE_TRASLATE_CLIENT_EMAIL }}
GOOGLE_TRASLATE_PRIVATEKEY: ${{ secrets.GOOGLE_TRASLATE_PRIVATEKEY }}
GA_PROPERTY_ID: ${{ secrets.GA_PROPERTY_ID }}
GOOGLE_APPLICATION_CLIENT_EMAIL: ${{ secrets.GOOGLE_APPLICATION_CLIENT_EMAIL }}
GOOGLE_APPLICATION_PRIVATEKEY: ${{ secrets.GOOGLE_APPLICATION_PRIVATEKEY }}
GOOGLE_APPLICATION_WALLET_CLIENT_EMAIL: ${{ secrets.GOOGLE_APPLICATION_WALLET_CLIENT_EMAIL }}
GOOGLE_APPLICATION_WALLET_PRIVATEKEY: ${{ secrets.GOOGLE_APPLICATION_WALLET_PRIVATEKEY }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
TWILIO_PHONE_US: ${{ secrets.TWILIO_PHONE_US }}
TWILIO_PHONE_UK: ${{ secrets.TWILIO_PHONE_UK }}
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PORT: ${{ secrets.REDIS_PORT }}
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
CACHE_ENABLED: ${{ secrets.CACHE_ENABLED }}
run: |
cat > spec.json <<EOF
{
"name": "${SANITIZED_BRANCH}-app-preview",
"region": "nyc",
"services": [
{
"name": "${SANITIZED_BRANCH}-app-preview",
"git": {
"repo_clone_url": "$REPO_CLONE_URL",
"branch": "$BRANCH"
},
"build_command": "echo build",
"run_command": "npm start",
"source_dir": "app",
"instance_size_slug": "apps-s-1vcpu-1gb",
"http_port": 8080,
"health_check": {
"http_path": "/api"
},
"instance_count": 1,
"environment_slug": "node-js",
"envs": [
{ "key": "CRYPTO_KEY", "value": "$CRYPTO_KEY", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "ADMIN_URL", "value": "$ADMIN_URL", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "APP_URL", "value": "$APP_URL", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "IS_PRODUCTION", "value": "$IS_PRODUCTION", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "NODE_ENV", "value": "$NODE_ENV", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "USE_NPM_INSTALL", "value": "$USE_NPM_INSTALL", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "NODE_MODULES_CACHE", "value": "$NODE_MODULES_CACHE", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "GOOGLE_CLIENT_ID", "value": "$GOOGLE_CLIENT_ID", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "GOOGLE_CLIENT_SECRET", "value": "$GOOGLE_CLIENT_SECRET", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "GOOGLE_TRASLATE_TEXT_KEY", "value": "$GOOGLE_TRASLATE_TEXT_KEY", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "JIRA_ACCOUNT_EMAIL", "value": "$JIRA_ACCOUNT_EMAIL", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "JIRA_ACCOUNT_TOKEN", "value": "$JIRA_ACCOUNT_TOKEN", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "JIRA_ISSUE_TYPE_ID", "value": "$JIRA_ISSUE_TYPE_ID", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "JIRA_MEMBER_ALEX", "value": "$JIRA_MEMBER_ALEX", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "JIRA_MEMBER_VIVEK", "value": "$JIRA_MEMBER_VIVEK", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "JIRA_PROJECT_ID", "value": "$JIRA_PROJECT_ID", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "JWT_ALGO", "value": "$JWT_ALGO", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "JWT_SECRET", "value": "$JWT_SECRET", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "LOGS_DB_HOST", "value": "$LOGS_DB_HOST", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "LOGS_DB_NAME", "value": "$LOGS_DB_NAME", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "LOGS_DB_PASSWORD", "value": "$LOGS_DB_PASSWORD", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "LOGS_DB_PORT", "value": "$LOGS_DB_PORT", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "LOGS_DB_USER", "value": "$LOGS_DB_USER", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "DB_HOST", "value": "$DB_HOST", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "DB_USER", "value": "$DB_USER", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "DB_PASSWORD", "value": "$DB_PASSWORD", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "DB_NAME", "value": "$DB_NAME", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "DB_PORT", "value": "$DB_PORT", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "MANDRILL_API_KEY", "value": "$MANDRILL_API_KEY", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "FROM_EMAIL", "value": "$FROM_EMAIL", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "EMAIL_ALERT", "value": "$EMAIL_ALERT", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "POSTMAN_TOKEN", "value": "$POSTMAN_TOKEN", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "RECAPTCHA_SECRETE_KEY", "value": "$RECAPTCHA_SECRETE_KEY", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "RECAPTCHA_SITE_KEY", "value": "$RECAPTCHA_SITE_KEY", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "S3_BUCKET_ACCESS_KEY_ID", "value": "$S3_BUCKET_ACCESS_KEY_ID", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "S3_BUCKET_SECRET_ACCESS_KEY", "value": "$S3_BUCKET_SECRET_ACCESS_KEY", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "S3_BUCKET_NAME", "value": "$S3_BUCKET_NAME", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "S3_BASE_URL", "value": "$S3_BASE_URL", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "S3_REGION", "value": "$S3_REGION", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "STRIPE_PK", "value": "$STRIPE_PK", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "STRIPE_SK", "value": "$STRIPE_SK", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "TWILIO_ACCOUNTSID", "value": "$TWILIO_ACCOUNTSID", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "TWILIO_AUTHTOKEN", "value": "$TWILIO_AUTHTOKEN", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "FACEBOOK_CLIENT_ID", "value": "$FACEBOOK_CLIENT_ID", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "FACEBOOK_CLIENT_SECRET", "value": "$FACEBOOK_CLIENT_SECRET", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "GOOGLE_TRASLATE_CLIENT_EMAIL", "value": "$GOOGLE_TRASLATE_CLIENT_EMAIL", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "GOOGLE_TRASLATE_PRIVATEKEY", "value": "$GOOGLE_TRASLATE_PRIVATEKEY", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "GA_PROPERTY_ID", "value": "$GA_PROPERTY_ID", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "GOOGLE_APPLICATION_CLIENT_EMAIL", "value": "$GOOGLE_APPLICATION_CLIENT_EMAIL", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "GOOGLE_APPLICATION_PRIVATEKEY", "value": "$GOOGLE_APPLICATION_PRIVATEKEY", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "GOOGLE_APPLICATION_WALLET_CLIENT_EMAIL", "value": "$GOOGLE_APPLICATION_WALLET_CLIENT_EMAIL", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "GOOGLE_APPLICATION_WALLET_PRIVATEKEY", "value": "$GOOGLE_APPLICATION_WALLET_PRIVATEKEY", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "OPENAI_KEY", "value": "$OPENAI_KEY", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "TWILIO_PHONE_US", "value": "$TWILIO_PHONE_US", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "TWILIO_PHONE_UK", "value": "$TWILIO_PHONE_UK", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "REDIS_HOST", "value": "$REDIS_HOST", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "REDIS_PORT", "value": "$REDIS_PORT", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "REDIS_PASSWORD", "value": "$REDIS_PASSWORD", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "CACHE_ENABLED", "value": "$CACHE_ENABLED", "scope": "RUN_AND_BUILD_TIME" }
],
"routes": [
{
"path": "/",
"preserve_path_prefix": false
}
]
}
],
"static_sites": [],
"databases": [],
"envs": []
}
EOF
- name: Get App ID
id: get_app
run: |
APP_NAME="${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}-app-preview"
APP_ID=$(doctl apps list --format Spec.Name,ID --no-header | awk -v app="$APP_NAME" '$1 == app {print $2}')
echo "APP_ID=${APP_ID}" >> $GITHUB_OUTPUT
- name: Update App
if: steps.get_app.outputs.APP_ID != ''
run: doctl apps update ${{ steps.get_app.outputs.APP_ID }} --spec spec.json
- name: Create App
if: steps.get_app.outputs.APP_ID == ''
run: doctl apps create --spec spec.json
- name: Retrieve App ID Again
id: get_new_app
run: |
APP_NAME="${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}-app-preview"
APP_ID=$(doctl apps list --format Spec.Name,ID --no-header | awk -v app="$APP_NAME" '$1 == app {print $2}')
echo "APP_ID=${APP_ID}" >> $GITHUB_OUTPUT
- name: Poll for APP and Get LIVE_URL
if: steps.get_new_app.outputs.APP_ID != ''
id: app_poll
run: |
#!/bin/bash
set -e
set -o pipefail
APP_ID="${{ steps.get_new_app.outputs.APP_ID }}"
MAX_ATTEMPTS=60
SLEEP_SECONDS=10
ATTEMPT=1
LIVE_URL=""
echo "Starting to poll for Default Ingress URL for App ID: $APP_ID"
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
echo "Attempt $ATTEMPT of $MAX_ATTEMPTS: Checking if app is live..."
LIVE_URL=$(doctl apps get "$APP_ID" --format "Default Ingress" --no-header | xargs)
if [[ -n "$LIVE_URL" ]]; then
echo "Default Ingress URL found: $LIVE_URL"
echo "LIVE_URL=$LIVE_URL" >> $GITHUB_OUTPUT
break
else
echo "Default Ingress URL not found yet. Sleeping for $SLEEP_SECONDS seconds before retrying..."
sleep $SLEEP_SECONDS
fi
ATTEMPT=$((ATTEMPT + 1))
done
if [[ -z "$LIVE_URL" ]]; then
echo "Error: Default Ingress URL was not found after $MAX_ATTEMPTS attempts."
exit 1
fi
- name: Generate Admin Spec
env:
REPO_CLONE_URL: "https://${{ secrets.GIT_DO_USERNAME }}:${{ secrets.GIT_DO_TOKEN }}@github.com/${{ github.repository }}.git"
BRANCH: ${{ github.head_ref }}
SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}
NEXT_PUBLIC_API_URL: ${{ steps.app_poll.outputs.LIVE_URL }}
NEXT_JWT_SECRET: ${{ secrets.JWT_SECRET }}
NEXT_PUBLIC_ENCRYPT_SECRET: ${{ secrets.CRYPTO_KEY }}
NODE_ENV: ${{ secrets.NODE_ENV }}
USE_NPM_INSTALL: ${{ secrets.USE_NPM_INSTALL }}
NODE_MODULES_CACHE: ${{ secrets.NODE_MODULES_CACHE }}
run: |
cat > spec-admin.json <<EOF
{
"name": "${SANITIZED_BRANCH}-admin-preview",
"region": "nyc",
"services": [
{
"name": "${SANITIZED_BRANCH}-admin-preview",
"git": {
"repo_clone_url": "$REPO_CLONE_URL",
"branch": "$BRANCH"
},
"build_command": "npm run build",
"run_command": "npm start",
"source_dir": "admin",
"instance_size_slug": "apps-s-1vcpu-0.5gb",
"http_port": 8080,
"health_check": {
"http_path": "/api/health"
},
"instance_count": 1,
"environment_slug": "node-js",
"envs": [
{ "key": "NEXT_PUBLIC_API_URL", "value": "$NEXT_PUBLIC_API_URL", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "NEXT_PUBLIC_ENCRYPT_SECRET", "value": "$NEXT_PUBLIC_ENCRYPT_SECRET", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "NEXT_JWT_SECRET", "value": "$NEXT_JWT_SECRET", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "NODE_ENV", "value": "$NODE_ENV", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "USE_NPM_INSTALL", "value": "$USE_NPM_INSTALL", "scope": "RUN_AND_BUILD_TIME" },
{ "key": "NODE_MODULES_CACHE", "value": "$NODE_MODULES_CACHE", "scope": "RUN_AND_BUILD_TIME" },
],
"routes": [
{
"path": "/",
"preserve_path_prefix": false
}
]
}
],
"static_sites": [],
"databases": [],
"envs": []
}
EOF
- name: Get Admin ID
id: get_admin
run: |
ADMIN_NAME="${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}-admin-preview"
ADMIN_ID=$(doctl apps list --format Spec.Name,ID --no-header | awk -v app="$ADMIN_NAME" '$1 == app {print $2}')
echo "ADMIN_ID=${ADMIN_ID}" >> $GITHUB_OUTPUT
- name: Update Admin
if: steps.get_admin.outputs.ADMIN_ID != ''
run: doctl apps update ${{ steps.get_admin.outputs.ADMIN_ID }} --spec spec-admin.json
- name: Create Admin
if: steps.get_admin.outputs.ADMIN_ID == ''
run: doctl apps create --spec spec-admin.json
- name: Retrieve Admin ID Again
id: get_new_admin
run: |
ADMIN_NAME="${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}-admin-preview"
ADMIN_ID=$(doctl apps list --format Spec.Name,ID --no-header | awk -v app="$ADMIN_NAME" '$1 == app {print $2}')
echo "ADMIN_ID=${ADMIN_ID}" >> $GITHUB_OUTPUT
- name: Poll for ADMIN and Get LIVE_URL
if: steps.get_new_admin.outputs.ADMIN_ID != ''
id: admin_poll
run: |
#!/bin/bash
set -e
set -o pipefail
APP_ID="${{ steps.get_new_admin.outputs.ADMIN_ID }}"
MAX_ATTEMPTS=60
SLEEP_SECONDS=10
ATTEMPT=1
LIVE_URL=""
echo "Starting to poll for Default Ingress URL for App ID: $APP_ID"
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
echo "Attempt $ATTEMPT of $MAX_ATTEMPTS: Checking if app is live..."
LIVE_URL=$(doctl apps get "$APP_ID" --format "Default Ingress" --no-header | xargs)
if [[ -n "$LIVE_URL" ]]; then
echo "Default Ingress URL found: $LIVE_URL"
echo "LIVE_URL=$LIVE_URL" >> $GITHUB_OUTPUT
break
else
echo "Default Ingress URL not found yet. Sleeping for $SLEEP_SECONDS seconds before retrying..."
sleep $SLEEP_SECONDS
fi
ATTEMPT=$((ATTEMPT + 1))
done
if [[ -z "$LIVE_URL" ]]; then
echo "Error: Default Ingress URL was not found after $MAX_ATTEMPTS attempts."
exit 1
fi
- name: Update spec.json with LIVE_URLS
if: steps.app_poll.outputs.LIVE_URL != ''
run: |
echo "${{ steps.app_poll.outputs.LIVE_URL }}"
echo "${{ steps.admin_poll.outputs.LIVE_URL }}"
SPEC_FILE="spec.json"
if [[ ! -f "$SPEC_FILE" ]]; then
echo "Error: $SPEC_FILE does not exist."
exit 1
fi
cp "$SPEC_FILE" "${SPEC_FILE}.bak"
UPDATED_SPEC=$(jq --arg app_url "${{ steps.app_poll.outputs.LIVE_URL }}" --arg admin_url "${{ steps.admin_poll.outputs.LIVE_URL }}" '
.services[].envs |= map(
if .key == "APP_URL" then .value = $app_url
elif .key == "ADMIN_URL" then .value = $admin_url
else . end
)
' "$SPEC_FILE")
if [[ $? -ne 0 ]]; then
echo "Error: Failed to update $SPEC_FILE with the new APP_URL and ADMIN_URL."
exit 1
fi
echo "$UPDATED_SPEC" > "$SPEC_FILE"
echo "spec.json has been updated with APP_URL: ${{ steps.app_poll.outputs.LIVE_URL }} and ADMIN_URL: ${{ steps.admin_poll.outputs.LIVE_URL }}"
- name: Update App with Updated spec.json
if: steps.get_new_app.outputs.APP_ID != ''
run: |
APP_ID="${{ steps.get_new_app.outputs.APP_ID }}"
echo "Updating the app platform with the new spec.json..."
doctl apps update "$APP_ID" --spec "spec.json"
if [[ $? -eq 0 ]]; then
echo "App platform updated successfully with the new APP_URL."
else
echo "Error: Failed to update the app platform."
exit 1
fi
- name: Send Deployment Notification Email
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.mandrillapp.com
server_port: 587
username: ${{ secrets.FROM_EMAIL }}
password: ${{ secrets.MANDRILL_API_KEY }}
subject: "Deployment Successful"
body: |
Hello,
Your deployment has been successfully completed.
**App URL:** ${{ steps.app_poll.outputs.LIVE_URL }}
**Admin URL:** ${{ steps.admin_poll.outputs.LIVE_URL }}
Regards,
Your Deployment Team
to: ${{ secrets.EMAIL_ALERT }}
from: ${{ secrets.FROM_EMAIL }}
Required Secrets
The workflow requires the following GitHub secrets to be configured:
- DigitalOcean:
DO_API_TOKEN,DO_DROPLET_IP,DO_DROPLET_PASSWORD - Database:
MANAGED_DB_HOST,MANAGED_DB_PORT,MANAGED_DB_USER,MANAGED_DB_PASSWORD,DB_*credentials - Git:
GIT_DO_USERNAME,GIT_DO_TOKEN - Application Environment Variables: All app-specific secrets (JWT, Stripe, Twilio, Google, etc.)
- Email:
MANDRILL_API_KEY,FROM_EMAIL,EMAIL_ALERT