hotfix: Pull-it 배포 사전검증과 롤백을 보장한다 (#160) #192
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 Pull-it backend and worker to Yeon Pi | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| operation: | |
| description: 'preflight은 Pi에 쓰지 않고 전송 경로만 검증합니다.' | |
| required: true | |
| default: preflight | |
| type: choice | |
| options: [preflight, deploy] | |
| push: | |
| branches: [develop] | |
| paths: | |
| - src/** | |
| - gradle/** | |
| - build.gradle | |
| - settings.gradle | |
| - Dockerfile | |
| - docker-compose.prod.yml | |
| - ops/production/** | |
| - .github/actions/** | |
| - .github/workflows/cd.yml | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: pullit-backend-production | |
| cancel-in-progress: false | |
| jobs: | |
| verify: | |
| if: github.event_name == 'push' || inputs.operation == 'deploy' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| persist-credentials: false | |
| - uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: '21' | |
| - name: Verify the release candidate before it can be published | |
| run: ./gradlew codeCheck build --stacktrace --no-daemon | |
| transport-preflight: | |
| needs: verify | |
| if: always() && (github.event_name == 'workflow_dispatch' && inputs.operation == 'preflight' || needs.verify.result == 'success') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| environment: pullit-backend-production | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/install-cloudflared | |
| - name: Verify Cloudflare Access and the restricted Pi command path | |
| env: | |
| DEPLOY_KEY: ${{ secrets.PULLIT_DEPLOY_SSH_PRIVATE_KEY }} | |
| KNOWN_HOSTS: ${{ vars.PULLIT_DEPLOY_SSH_KNOWN_HOSTS }} | |
| DEPLOY_HOST: ${{ vars.PULLIT_DEPLOY_HOST }} | |
| PULLIT_CLOUDFLARE_ACCESS_CLIENT_ID: ${{ secrets.PULLIT_CLOUDFLARE_ACCESS_CLIENT_ID }} | |
| PULLIT_CLOUDFLARE_ACCESS_CLIENT_SECRET: ${{ secrets.PULLIT_CLOUDFLARE_ACCESS_CLIENT_SECRET }} | |
| DB_USERNAME: ${{ vars.PULLIT_DB_USERNAME }} | |
| DB_PASSWORD: ${{ secrets.DB_PASSWORD }} | |
| DB_ROOT_PASSWORD: ${{ secrets.DB_ROOT_PASSWORD }} | |
| PULLIT_DATABASE_NAME: ${{ vars.PULLIT_DATABASE_NAME }} | |
| RABBITMQ_DEFAULT_USER: ${{ vars.RABBITMQ_DEFAULT_USER }} | |
| RABBITMQ_DEFAULT_PASS: ${{ secrets.RABBITMQ_DEFAULT_PASS }} | |
| JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} | |
| KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }} | |
| KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }} | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} | |
| S3_BUCKET_NAME: ${{ vars.S3_BUCKET_NAME }} | |
| S3_REGION: ${{ vars.S3_REGION }} | |
| S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} | |
| S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} | |
| MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} | |
| MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| required=(DEPLOY_KEY KNOWN_HOSTS DEPLOY_HOST PULLIT_CLOUDFLARE_ACCESS_CLIENT_ID PULLIT_CLOUDFLARE_ACCESS_CLIENT_SECRET DB_USERNAME DB_PASSWORD DB_ROOT_PASSWORD PULLIT_DATABASE_NAME RABBITMQ_DEFAULT_USER RABBITMQ_DEFAULT_PASS JWT_SECRET_KEY KAKAO_REST_API_KEY KAKAO_CLIENT_SECRET GEMINI_API_KEY S3_BUCKET_NAME S3_REGION S3_ACCESS_KEY S3_SECRET_KEY MINIO_ROOT_USER MINIO_ROOT_PASSWORD) | |
| for name in "${required[@]}"; do test -n "${!name}"; done | |
| case "$PULLIT_CLOUDFLARE_ACCESS_CLIENT_ID" in 'CF-Access-Client-Id:'*) exit 1 ;; esac | |
| case "$PULLIT_CLOUDFLARE_ACCESS_CLIENT_SECRET" in 'CF-Access-Client-Secret:'*) exit 1 ;; esac | |
| status="$(curl --silent --show-error --connect-timeout 10 --max-time 30 \ | |
| --output /dev/null --write-out '%{http_code}' \ | |
| --header "CF-Access-Client-Id: $PULLIT_CLOUDFLARE_ACCESS_CLIENT_ID" \ | |
| --header "CF-Access-Client-Secret: $PULLIT_CLOUDFLARE_ACCESS_CLIENT_SECRET" "https://$DEPLOY_HOST")" | |
| test "$status" = 200 || { echo "Cloudflare Access service-token probe returned HTTP $status" >&2; exit 1; } | |
| install -d -m 700 ~/.ssh | |
| printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519 | |
| printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts | |
| chmod 600 ~/.ssh/id_ed25519 | |
| printf 'Host %s\n' "$DEPLOY_HOST" > ~/.ssh/config | |
| cat >> ~/.ssh/config <<'EOF' | |
| ProxyCommand cloudflared access ssh --hostname %h --service-token-id "$PULLIT_CLOUDFLARE_ACCESS_CLIENT_ID" --service-token-secret "$PULLIT_CLOUDFLARE_ACCESS_CLIENT_SECRET" | |
| EOF | |
| chmod 600 ~/.ssh/config | |
| ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=20 "pullit-deploy@$DEPLOY_HOST" \ | |
| 'test -d /opt/pullit/incoming && test -w /opt/pullit/incoming && sudo -n -l /usr/local/sbin/pullit-deploy >/dev/null' | |
| build: | |
| needs: [verify, transport-preflight] | |
| if: github.event_name == 'push' || inputs.operation == 'deploy' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| persist-credentials: false | |
| - uses: docker/setup-qemu-action@v3 | |
| - uses: docker/setup-buildx-action@v3 | |
| - uses: docker/login-action@v3 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| push: true | |
| platforms: linux/arm64 | |
| tags: ghcr.io/hyeonjun0527/pullit-backend:${{ github.sha }} | |
| labels: org.opencontainers.image.source=https://github.com/${{ github.repository }} | |
| deploy: | |
| needs: [verify, transport-preflight, build] | |
| if: github.event_name == 'push' || inputs.operation == 'deploy' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| environment: pullit-backend-production | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/install-cloudflared | |
| - name: Create isolated runtime environment and deploy it | |
| env: | |
| PULLIT_BACKEND_IMAGE: ghcr.io/hyeonjun0527/pullit-backend:${{ github.sha }} | |
| DB_USERNAME: ${{ vars.PULLIT_DB_USERNAME }} | |
| DB_PASSWORD: ${{ secrets.DB_PASSWORD }} | |
| DB_ROOT_PASSWORD: ${{ secrets.DB_ROOT_PASSWORD }} | |
| PULLIT_DATABASE_NAME: ${{ vars.PULLIT_DATABASE_NAME }} | |
| RABBITMQ_DEFAULT_USER: ${{ vars.RABBITMQ_DEFAULT_USER }} | |
| RABBITMQ_DEFAULT_PASS: ${{ secrets.RABBITMQ_DEFAULT_PASS }} | |
| JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} | |
| KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }} | |
| KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }} | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} | |
| S3_BUCKET_NAME: ${{ vars.S3_BUCKET_NAME }} | |
| S3_REGION: ${{ vars.S3_REGION }} | |
| S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} | |
| S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} | |
| MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} | |
| MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} | |
| SENTRY_DSN: ${{ secrets.SENTRY_DSN }} | |
| DEPLOY_KEY: ${{ secrets.PULLIT_DEPLOY_SSH_PRIVATE_KEY }} | |
| DEPLOY_HOST: ${{ vars.PULLIT_DEPLOY_HOST }} | |
| KNOWN_HOSTS: ${{ vars.PULLIT_DEPLOY_SSH_KNOWN_HOSTS }} | |
| PULLIT_CLOUDFLARE_ACCESS_CLIENT_ID: ${{ secrets.PULLIT_CLOUDFLARE_ACCESS_CLIENT_ID }} | |
| PULLIT_CLOUDFLARE_ACCESS_CLIENT_SECRET: ${{ secrets.PULLIT_CLOUDFLARE_ACCESS_CLIENT_SECRET }} | |
| run: | | |
| set -euo pipefail | |
| required=(PULLIT_BACKEND_IMAGE DB_USERNAME DB_PASSWORD DB_ROOT_PASSWORD PULLIT_DATABASE_NAME RABBITMQ_DEFAULT_USER RABBITMQ_DEFAULT_PASS JWT_SECRET_KEY KAKAO_REST_API_KEY KAKAO_CLIENT_SECRET GEMINI_API_KEY S3_BUCKET_NAME S3_REGION S3_ACCESS_KEY S3_SECRET_KEY MINIO_ROOT_USER MINIO_ROOT_PASSWORD DEPLOY_KEY DEPLOY_HOST KNOWN_HOSTS PULLIT_CLOUDFLARE_ACCESS_CLIENT_ID PULLIT_CLOUDFLARE_ACCESS_CLIENT_SECRET) | |
| for name in "${required[@]}"; do test -n "${!name}"; done | |
| umask 077 | |
| staging="$(mktemp -d)"; trap 'rm -rf "$staging"' EXIT | |
| { | |
| printf 'PULLIT_BACKEND_IMAGE=%s\n' "$PULLIT_BACKEND_IMAGE" | |
| printf 'DB_USERNAME=%s\n' "$DB_USERNAME" | |
| printf 'DB_PASSWORD=%s\n' "$DB_PASSWORD" | |
| printf 'DB_ROOT_PASSWORD=%s\n' "$DB_ROOT_PASSWORD" | |
| printf 'PULLIT_DATABASE_NAME=%s\n' "$PULLIT_DATABASE_NAME" | |
| printf 'RABBITMQ_DEFAULT_USER=%s\n' "$RABBITMQ_DEFAULT_USER" | |
| printf 'RABBITMQ_DEFAULT_PASS=%s\n' "$RABBITMQ_DEFAULT_PASS" | |
| printf 'JWT_SECRET_KEY=%s\n' "$JWT_SECRET_KEY" | |
| printf 'KAKAO_REST_API_KEY=%s\n' "$KAKAO_REST_API_KEY" | |
| printf 'KAKAO_CLIENT_SECRET=%s\n' "$KAKAO_CLIENT_SECRET" | |
| printf 'GEMINI_API_KEY=%s\n' "$GEMINI_API_KEY" | |
| printf 'S3_BUCKET_NAME=%s\n' "$S3_BUCKET_NAME" | |
| printf 'S3_REGION=%s\n' "$S3_REGION" | |
| printf 'S3_ACCESS_KEY=%s\n' "$S3_ACCESS_KEY" | |
| printf 'S3_SECRET_KEY=%s\n' "$S3_SECRET_KEY" | |
| printf 'MINIO_ROOT_USER=%s\n' "$MINIO_ROOT_USER" | |
| printf 'MINIO_ROOT_PASSWORD=%s\n' "$MINIO_ROOT_PASSWORD" | |
| printf 'SENTRY_DSN=%s\n' "$SENTRY_DSN" | |
| } > "$staging/pullit-production.env" | |
| printf '%s\n' "$DEPLOY_KEY" > ~/.ssh-key-pullit | |
| chmod 600 ~/.ssh-key-pullit | |
| printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh-known-hosts-pullit | |
| install -d -m 700 ~/.ssh | |
| printf 'Host %s\n' "$DEPLOY_HOST" > ~/.ssh/config | |
| cat >> ~/.ssh/config <<'EOF' | |
| ProxyCommand cloudflared access ssh --hostname %h --service-token-id "$PULLIT_CLOUDFLARE_ACCESS_CLIENT_ID" --service-token-secret "$PULLIT_CLOUDFLARE_ACCESS_CLIENT_SECRET" | |
| EOF | |
| chmod 600 ~/.ssh/config | |
| ssh_opts=(-i ~/.ssh-key-pullit -o BatchMode=yes -o UserKnownHostsFile=~/.ssh-known-hosts-pullit) | |
| ssh "${ssh_opts[@]}" "pullit-deploy@$DEPLOY_HOST" "mkdir -p /opt/pullit/incoming/backend/${{ github.sha }}" | |
| scp "${ssh_opts[@]}" -r "$staging/." "pullit-deploy@$DEPLOY_HOST:/opt/pullit/incoming/backend/${{ github.sha }}/" | |
| ssh "${ssh_opts[@]}" "pullit-deploy@$DEPLOY_HOST" "sudo /usr/local/sbin/pullit-deploy backend ${{ github.sha }}" |