name: CD - Deploy - API on: workflow_dispatch: inputs: api_log_lvl: description: 'Log level for the API' type: choice options: - debug - info - warn default: info show_upcoming_changes: description: 'Show upcoming changes (enables upcoming certifications and challenges)' type: boolean default: false jobs: setup-jobs: name: Setup Jobs runs-on: ubuntu-22.04 outputs: site_tld: ${{ steps.setup.outputs.site_tld }} tgt_env_short: ${{ steps.setup.outputs.tgt_env_short }} tgt_env_long: ${{ steps.setup.outputs.tgt_env_long }} api_log_lvl: ${{ steps.setup.outputs.api_log_lvl }} show_upcoming_changes: ${{ steps.setup.outputs.show_upcoming_changes }} steps: - name: Setup id: setup run: | BRANCH="${{ github.ref_name }}" echo "Current branch: $BRANCH" # Convert boolean input to string 'true' or 'false' if [[ "${{ inputs.show_upcoming_changes }}" == "true" ]]; then echo "show_upcoming_changes=true" >> $GITHUB_OUTPUT else echo "show_upcoming_changes=false" >> $GITHUB_OUTPUT fi case "$BRANCH" in "prod-current") echo "site_tld=org" >> $GITHUB_OUTPUT echo "tgt_env_short=prd" >> $GITHUB_OUTPUT echo "tgt_env_long=production" >> $GITHUB_OUTPUT echo "api_log_lvl=${{ inputs.api_log_lvl || 'info' }}" >> $GITHUB_OUTPUT ;; *) echo "site_tld=dev" >> $GITHUB_OUTPUT echo "tgt_env_short=stg" >> $GITHUB_OUTPUT echo "tgt_env_long=staging" >> $GITHUB_OUTPUT echo "api_log_lvl=${{ inputs.api_log_lvl || 'info' }}" >> $GITHUB_OUTPUT ;; esac build: name: Build & Push needs: setup-jobs uses: ./.github/workflows/docker-docr.yml with: site_tld: ${{ needs.setup-jobs.outputs.site_tld }} app: api show_upcoming_changes: ${{ needs.setup-jobs.outputs.show_upcoming_changes }} secrets: inherit deploy: name: Deploy to Docker Swarm -- ${{ needs.setup-jobs.outputs.tgt_env_short }} runs-on: ubuntu-22.04 needs: [setup-jobs, build] env: TS_USERNAME: ${{ secrets.TS_USERNAME }} TS_MACHINE_NAME: ${{ secrets.TS_MACHINE_NAME }} permissions: deployments: write environment: name: ${{ needs.setup-jobs.outputs.tgt_env_short }}-api steps: - name: Setup and connect to Tailscale network uses: tailscale/github-action@6cae46e2d796f265265cfcf628b72a32b4d7cade # v3 with: oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} hostname: gha-${{needs.setup-jobs.outputs.tgt_env_short}}-api-ci-${{ github.run_id }} tags: tag:ci version: latest - name: Wait for Tailscale Network Readiness run: | echo "Waiting for Tailscale network to be ready..." max_wait=60 elapsed=0 while [ $elapsed -lt $max_wait ]; do if tailscale status --json | jq -e '.BackendState == "Running"' > /dev/null 2>&1; then echo "Tailscale network is ready" break fi sleep 2 elapsed=$((elapsed + 2)) done if [ $elapsed -ge $max_wait ]; then echo "Tailscale network not ready after ${max_wait}s" exit 1 fi - name: Configure SSH & Check Connection run: | mkdir -p ~/.ssh echo "Host * UserKnownHostsFile=/dev/null StrictHostKeyChecking no" > ~/.ssh/config chmod 644 ~/.ssh/config validate_connection() { local machine_name=$1 local max_retries=3 local retry_delay=5 for attempt in $(seq 1 $max_retries); do echo "Connection attempt $attempt/$max_retries to $machine_name" if ! tailscale status | grep -q "$machine_name"; then echo "Machine $machine_name not found in Tailscale network" if [ $attempt -eq $max_retries ]; then return 1 fi sleep $retry_delay continue fi MACHINE_IP=$(tailscale ip -4 $machine_name) if ssh -o ConnectTimeout=10 -o BatchMode=yes $TS_USERNAME@$MACHINE_IP "echo 'Connection test'; docker --version" > /dev/null 2>&1; then echo "Successfully validated connection to $machine_name" return 0 fi echo "SSH validation failed for $machine_name" if [ $attempt -lt $max_retries ]; then sleep $retry_delay fi done echo "Failed to establish connection to $machine_name after $max_retries attempts" return 1 } echo -e "\nLOG:Validating connection to $TS_MACHINE_NAME..." if ! validate_connection "$TS_MACHINE_NAME"; then echo "Error: Failed to establish reliable connection to $TS_MACHINE_NAME" exit 1 fi - name: Deploy with Docker Stack env: AGE_ENCRYPTED_ASC_SECRETS: ${{ secrets.AGE_ENCRYPTED_ASC_SECRETS }} AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }} # Variable set from GitHub "Environment" secrets (AGE encrypted) # DOCKER_REGISTRY # MONGOHQ_URL # SENTRY_DSN # SENTRY_ENVIRONMENT # AUTH0_CLIENT_ID # AUTH0_CLIENT_SECRET # AUTH0_DOMAIN # JWT_SECRET # COOKIE_SECRET # COOKIE_DOMAIN # SES_ID # SES_SECRET # GROWTHBOOK_FASTIFY_API_HOST # GROWTHBOOK_FASTIFY_CLIENT_KEY # HOME_LOCATION # API_LOCATION # STRIPE_SECRET_KEY # LOKI_URL # Variables set from SetupJob DEPLOYMENT_VERSION: ${{ needs.build.outputs.tagname }} DEPLOYMENT_ENV: ${{ needs.setup-jobs.outputs.tgt_env_long }} DEPLOYMENT_TLD: ${{ needs.setup-jobs.outputs.site_tld }} FCC_API_LOG_LEVEL: ${{ needs.setup-jobs.outputs.api_log_lvl }} SHOW_UPCOMING_CHANGES: ${{ needs.setup-jobs.outputs.show_upcoming_changes }} # Stack name STACK_NAME: ${{ needs.setup-jobs.outputs.tgt_env_short }}-api run: | REMOTE_SCRIPT=" set -e echo -e '\nLOG:Deploying API to $TS_MACHINE_NAME...' cd /home/$TS_USERNAME/docker-swarm-config/stacks/api echo -e '\nLOG:Checking if age is installed...' which age > /dev/null echo -e '\nLOG:Decrypting secrets...' echo \"$AGE_ENCRYPTED_ASC_SECRETS\" > secrets.age.asc echo \"$AGE_SECRET_KEY\" > age.key && chmod 600 age.key age --identity age.key --decrypt secrets.age.asc > .env rm -f age.key secrets.age.asc echo -e '\nLOG:Cleaning up .env file...' touch .env.tmp while IFS= read -r line; do if [[ \$line =~ ^[A-Za-z0-9_]+=.*$ ]]; then # Extract the key (part before the first =) key=\${line%%=*} # Remove any previous line with this key sed -i \"/^\${key}=/d\" .env.tmp fi # Append the current line echo \"\$line\" >> .env.tmp done < .env mv .env.tmp .env echo -e '\nLOG:Adding deployment variables...' { echo \"DEPLOYMENT_VERSION=$DEPLOYMENT_VERSION\" echo \"DEPLOYMENT_TLD=$DEPLOYMENT_TLD\" echo \"DEPLOYMENT_ENV=$DEPLOYMENT_ENV\" echo \"FCC_API_LOG_LEVEL=$FCC_API_LOG_LEVEL\" echo \"SHOW_UPCOMING_CHANGES=$SHOW_UPCOMING_CHANGES\" } >> .env echo -e '\nLOG:Sourcing environment...' REQUIRED_VARS=( \"DOCKER_REGISTRY\" \"MONGOHQ_URL\" \"SENTRY_DSN\" \"SENTRY_ENVIRONMENT\" \"AUTH0_CLIENT_ID\" \"AUTH0_CLIENT_SECRET\" \"AUTH0_DOMAIN\" \"JWT_SECRET\" \"COOKIE_SECRET\" \"COOKIE_DOMAIN\" \"SES_ID\" \"SES_SECRET\" \"GROWTHBOOK_FASTIFY_API_HOST\" \"GROWTHBOOK_FASTIFY_CLIENT_KEY\" \"HOME_LOCATION\" \"API_LOCATION\" \"STRIPE_SECRET_KEY\" \"LOKI_URL\" \"DEPLOYMENT_VERSION\" \"DEPLOYMENT_TLD\" \"DEPLOYMENT_ENV\" \"FCC_API_LOG_LEVEL\" ) while IFS='=' read -r key value; do if [[ -n \"\$key\" && ! \"\$key\" =~ ^# ]]; then export \"\${key}=\${value}\" fi done < .env MISSING_VARS=() for var in \"\${REQUIRED_VARS[@]}\"; do if [[ -z \"\${!var}\" ]]; then MISSING_VARS+=(\"\$var\") fi done if [[ \${#MISSING_VARS[@]} -gt 0 ]]; then echo \"ERROR: The following required environment variables are missing or empty:\" for var in \"\${MISSING_VARS[@]}\"; do echo \" - \$var\" done exit 1 fi rm -rf .env echo -e '\nLOG:Validating deployment version...' if [[ \"\$DEPLOYMENT_VERSION\" != \"$DEPLOYMENT_VERSION\" ]]; then echo \"Error: Version mismatch. Expected: $DEPLOYMENT_VERSION, Got: \$DEPLOYMENT_VERSION\" exit 1 fi env | grep -E 'DEPLOYMENT_VERSION' echo -e '\nLOG:Checking stack configuration...' CONFIG_OUTPUT=\"/dev/null\" if [[ \"\$FCC_API_LOG_LEVEL\" == \"debug\" ]]; then CONFIG_FILENAME=\"debug-docker-stack-config-\${DEPLOYMENT_VERSION}.yml\" echo -e '\nLOG:Saving stack configuration for debugging...' CONFIG_OUTPUT=\"\$CONFIG_FILENAME\" fi docker stack config -c stack-api.yml > \$CONFIG_OUTPUT echo -e '\nLOG:Deploying stack...' docker stack deploy -c stack-api.yml --prune --with-registry-auth --detach=false $STACK_NAME echo -e '\nLOG:Finished deployment.' " MACHINE_IP=$(tailscale ip -4 $TS_MACHINE_NAME) ssh $TS_USERNAME@$MACHINE_IP "$REMOTE_SCRIPT"