Deploy #31
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy | |
| on: | |
| workflow_run: | |
| workflows: [Release] | |
| types: [completed] | |
| env: | |
| IMAGE_NAME: networkcat/rustyip | |
| permissions: | |
| contents: read | |
| jobs: | |
| deploy: | |
| name: Deploy | |
| runs-on: ubuntu-latest | |
| if: github.event.workflow_run.conclusion == 'success' | |
| environment: production | |
| concurrency: | |
| group: deploy-production | |
| cancel-in-progress: false | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Resolve image tag from release | |
| id: image | |
| run: | | |
| TAG="${{ github.event.workflow_run.head_branch }}" | |
| TAG="${TAG#v}" | |
| echo "full=${{ env.IMAGE_NAME }}:${TAG}" >> "$GITHUB_OUTPUT" | |
| # ---- Terraform: provision infrastructure ---- | |
| - name: Setup Terraform | |
| uses: hashicorp/setup-terraform@v3 | |
| with: | |
| terraform_wrapper: false | |
| - name: Terraform init | |
| working-directory: deploy/terraform | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.TF_STATE_ACCESS_KEY }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.TF_STATE_SECRET_KEY }} | |
| run: | | |
| terraform init \ | |
| -backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \ | |
| -backend-config="endpoints={s3=\"${{ secrets.TF_STATE_ENDPOINT }}\"}" | |
| - name: Terraform apply | |
| working-directory: deploy/terraform | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.TF_STATE_ACCESS_KEY }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.TF_STATE_SECRET_KEY }} | |
| run: | | |
| terraform apply -auto-approve \ | |
| -var="vultr_api_key=${{ secrets.VULTR_API_KEY }}" \ | |
| -var="ssh_public_key=${{ secrets.SSH_PUBLIC_KEY }}" | |
| - name: Extract VPS IP (masked) | |
| id: vps | |
| working-directory: deploy/terraform | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.TF_STATE_ACCESS_KEY }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.TF_STATE_SECRET_KEY }} | |
| run: | | |
| VPS_IP=$(terraform output -raw instance_ip) | |
| echo "::add-mask::${VPS_IP}" | |
| echo "ip=${VPS_IP}" >> "$GITHUB_OUTPUT" | |
| # ---- SSH setup ---- | |
| - name: Wait for SSH to become available | |
| run: | | |
| VPS_IP="${{ steps.vps.outputs.ip }}" | |
| echo "Waiting for SSH port 22 on ${VPS_IP}..." | |
| for i in $(seq 1 30); do | |
| if nc -z -w5 "$VPS_IP" 22 2>/dev/null; then | |
| echo "SSH port open on attempt $i" | |
| break | |
| fi | |
| echo "SSH port not ready yet (attempt $i/30), retrying in 10s..." | |
| sleep 10 | |
| done | |
| - name: Setup SSH key | |
| run: | | |
| mkdir -p ~/.ssh | |
| echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key | |
| chmod 600 ~/.ssh/deploy_key | |
| for i in $(seq 1 10); do | |
| keys=$(ssh-keyscan -H "${{ steps.vps.outputs.ip }}" 2>&1) || true | |
| # Filter to only key lines (ignore stderr/warnings) | |
| key_lines=$(echo "$keys" | grep -v '^#' | grep -v '^$' | grep -v 'getaddrinfo' || true) | |
| if [ -n "$key_lines" ]; then | |
| echo "$key_lines" >> ~/.ssh/known_hosts | |
| echo "ssh-keyscan succeeded on attempt $i" | |
| exit 0 | |
| fi | |
| echo "ssh-keyscan attempt $i failed, retrying in 15s..." | |
| echo "Output was: $keys" | |
| sleep 15 | |
| done | |
| echo "ssh-keyscan failed after 10 attempts" | |
| exit 1 | |
| # ---- Detect first deployment ---- | |
| - name: Detect first deployment | |
| id: detect | |
| run: | | |
| set +e | |
| ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \ | |
| -i ~/.ssh/deploy_key root@"${{ steps.vps.outputs.ip }}" \ | |
| "test -f /opt/rustyip/.active_color" | |
| if [ $? -ne 0 ]; then | |
| echo "first_deploy=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "first_deploy=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # ---- Ansible: configure VPS and deploy ---- | |
| - name: Install Ansible | |
| run: pip install ansible | |
| - name: Generate Ansible inventory | |
| run: | | |
| cat > deploy/ansible/inventory.yml <<EOF | |
| all: | |
| hosts: | |
| vps: | |
| ansible_host: "${{ steps.vps.outputs.ip }}" | |
| ansible_user: root | |
| ansible_ssh_private_key_file: ~/.ssh/deploy_key | |
| ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new" | |
| EOF | |
| - name: Run Ansible playbook | |
| working-directory: deploy/ansible | |
| env: | |
| APP_IMAGE: ${{ steps.image.outputs.full }} | |
| SITE_DOMAIN: ${{ secrets.SITE_DOMAIN }} | |
| DB_UPDATE_URL: ${{ secrets.DB_UPDATE_URL }} | |
| ORIGIN_CERT: ${{ secrets.ORIGIN_CERT }} | |
| ORIGIN_KEY: ${{ secrets.ORIGIN_KEY }} | |
| IPV4_DOMAIN: ${{ secrets.IPV4_DOMAIN }} | |
| IPV4_ORIGIN_CERT: ${{ secrets.IPV4_ORIGIN_CERT }} | |
| IPV4_ORIGIN_KEY: ${{ secrets.IPV4_ORIGIN_KEY }} | |
| DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} | |
| DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} | |
| FIRST_DEPLOY: ${{ steps.detect.outputs.first_deploy }} | |
| run: | | |
| ansible-playbook -i inventory.yml playbook.yml | |
| # ---- Verify deployment ---- | |
| - name: Verify deployment health | |
| env: | |
| SSH_OPTS: -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -i ~/.ssh/deploy_key | |
| VPS_HOST: root@${{ steps.vps.outputs.ip }} | |
| run: | | |
| DEPLOY_COLOR=$(ssh $SSH_OPTS "$VPS_HOST" "cat /opt/rustyip/.active_color") | |
| echo "Active deployment color: ${DEPLOY_COLOR}" | |
| for i in $(seq 1 30); do | |
| if ssh $SSH_OPTS "$VPS_HOST" \ | |
| "docker exec rustyip-app-${DEPLOY_COLOR} wget -qO /dev/null http://127.0.0.1:3000/health" 2>/dev/null; then | |
| echo "Deployment verified: app-${DEPLOY_COLOR} is healthy" | |
| exit 0 | |
| fi | |
| echo "Waiting for health check... (attempt $i/30)" | |
| sleep 4 | |
| done | |
| echo "Health check failed after 120 seconds" | |
| exit 1 | |
| # ---- Cleanup ---- | |
| - name: Cleanup SSH key | |
| if: always() | |
| run: rm -f ~/.ssh/deploy_key |