190 add a staging environment for previewing and testing ahead of a new deployment #12
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
| # ============================================================================= | |
| # PR Preview Deployment Workflow | |
| # ============================================================================= | |
| # Purpose: Deploys isolated PR preview environments to RPi5 via Tailscale | |
| # Features: ARM64 native builds, multi-tier caching, secure VPN deployment | |
| # Target: Raspberry Pi 5 (ARM64) with Docker Compose via Tailscale SSH | |
| # ============================================================================= | |
| name: Deploy PR Preview to RPi5 | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| branches: | |
| - main | |
| workflow_dispatch: | |
| inputs: | |
| backend_branch: | |
| description: "Backend branch to deploy" | |
| required: true | |
| default: "main" | |
| type: string | |
| pr_number: | |
| description: "PR number (auto-detected for PR triggers)" | |
| required: false | |
| type: string | |
| force_rebuild: | |
| description: "Force rebuild without cache" | |
| required: false | |
| default: false | |
| type: boolean | |
| concurrency: | |
| group: preview-deploy-${{ github.event.pull_request.number || github.run_id }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| packages: write | |
| pull-requests: write | |
| attestations: write | |
| id-token: write | |
| env: | |
| REGISTRY: ghcr.io | |
| IMAGE_NAME: ${{ github.repository }} | |
| CARGO_TERM_COLOR: always | |
| CARGO_INCREMENTAL: 0 | |
| RUST_BACKTRACE: short | |
| jobs: | |
| # =========================================================================== | |
| # JOB 1: Lint & Format Check | |
| # =========================================================================== | |
| lint: | |
| name: Lint & Format | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| toolchain: stable | |
| components: clippy, rustfmt | |
| - name: Use cached dependencies | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "pr-preview" | |
| key: "lint" | |
| cache-all-crates: true | |
| - name: Run clippy | |
| run: cargo clippy --all-targets -- -D warnings | |
| - name: Run format check | |
| run: cargo fmt --all -- --check | |
| # =========================================================================== | |
| # JOB 2: Build & Test | |
| # =========================================================================== | |
| test: | |
| name: Build & Test | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| toolchain: stable | |
| targets: x86_64-unknown-linux-gnu | |
| - name: Set OpenSSL Paths | |
| run: | | |
| echo "OPENSSL_LIB_DIR=/usr/lib/x86_64-linux-gnu" >> $GITHUB_ENV | |
| echo "OPENSSL_INCLUDE_DIR=/usr/include/x86_64-linux-gnu" >> $GITHUB_ENV | |
| - name: Use cached dependencies | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "pr-preview" | |
| key: "test" | |
| cache-all-crates: true | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Build | |
| run: cargo build --all-targets | |
| - name: Run tests | |
| run: cargo test | |
| # =========================================================================== | |
| # JOB 3: Native ARM64 Image Build On Neo (aka "The One") | |
| # =========================================================================== | |
| build-arm64-image: | |
| name: Build ARM64 Backend Image | |
| runs-on: [self-hosted, Linux, ARM64, neo] | |
| environment: pr-preview | |
| needs: [lint, test] | |
| outputs: | |
| pr_number: ${{ steps.context.outputs.pr_number }} | |
| image_tag_pr: ${{ steps.context.outputs.image_tag_pr }} | |
| image_tag_sha: ${{ steps.context.outputs.image_tag_sha }} | |
| backend_branch: ${{ steps.context.outputs.backend_branch }} | |
| is_native_arm64: ${{ steps.context.outputs.is_native_arm64 }} | |
| steps: | |
| - name: Set Deployment Context | |
| id: context | |
| run: | | |
| if [[ "$(uname -m)" == "aarch64" ]]; then | |
| echo "is_native_arm64=true" >> $GITHUB_OUTPUT | |
| echo "::notice::🚀 Running on native ARM64 runner (Neo)" | |
| else | |
| echo "::error::Not running on ARM64 architecture - check runner configuration" | |
| exit 1 | |
| fi | |
| if [[ "${{ github.event_name }}" == "pull_request" ]]; then | |
| PR_NUM="${{ github.event.pull_request.number }}" | |
| BACKEND_BRANCH="${{ github.head_ref }}" | |
| else | |
| PR_NUM="${{ inputs.pr_number }}" | |
| if [[ -z "$PR_NUM" ]]; then | |
| PR_NUM=$((9000 + ${{ github.run_number }})) | |
| fi | |
| BACKEND_BRANCH="${{ inputs.backend_branch }}" | |
| fi | |
| echo "pr_number=${PR_NUM}" >> $GITHUB_OUTPUT | |
| echo "backend_branch=${BACKEND_BRANCH}" >> $GITHUB_OUTPUT | |
| IMAGE_BASE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" | |
| IMAGE_TAG_PR="${IMAGE_BASE}:pr-${PR_NUM}" | |
| IMAGE_TAG_SHA="${IMAGE_BASE}:pr-${PR_NUM}-${{ github.sha }}" | |
| echo "image_tag_pr=${IMAGE_TAG_PR}" >> $GITHUB_OUTPUT | |
| echo "image_tag_sha=${IMAGE_TAG_SHA}" >> $GITHUB_OUTPUT | |
| echo "::notice::🚀 Building ARM64 PR #${PR_NUM} from branch '${BACKEND_BRANCH}'" | |
| echo "::notice::📦 Image: ${IMAGE_TAG_PR}" | |
| - name: Checkout Repository | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ steps.context.outputs.backend_branch }} | |
| - name: Setup Rust Cache | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "pr-preview-arm64" | |
| key: "arm64-${{ steps.context.outputs.backend_branch }}" | |
| cache-all-crates: true | |
| save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'pull_request' }} | |
| - name: Login to GitHub Container Registry | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| with: | |
| driver-opts: | | |
| image=moby/buildkit:latest | |
| network=host | |
| - name: Check for Existing Image | |
| id: check_image | |
| run: | | |
| if docker manifest inspect ${{ steps.context.outputs.image_tag_sha }} >/dev/null 2>&1; then | |
| echo "image_exists=true" >> $GITHUB_OUTPUT | |
| echo "::notice::📦 Image already exists for SHA ${{ github.sha }}" | |
| else | |
| echo "image_exists=false" >> $GITHUB_OUTPUT | |
| echo "::notice::🔨 Building new ARM64 image for SHA ${{ github.sha }}" | |
| fi | |
| - name: Build and Push ARM64 Backend Image | |
| id: build_push | |
| if: steps.check_image.outputs.image_exists != 'true' || inputs.force_rebuild == true | |
| uses: docker/build-push-action@v5 | |
| with: | |
| context: . | |
| file: ./Dockerfile | |
| platforms: linux/arm64 | |
| push: true | |
| tags: | | |
| ${{ steps.context.outputs.image_tag_pr }} | |
| ${{ steps.context.outputs.image_tag_sha }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| labels: | | |
| org.opencontainers.image.title=Refactor Platform Backend PR-${{ steps.context.outputs.pr_number }} | |
| org.opencontainers.image.description=PR preview for branch ${{ steps.context.outputs.backend_branch }} | |
| org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} | |
| org.opencontainers.image.revision=${{ github.sha }} | |
| org.opencontainers.image.created=${{ github.event.head_commit.timestamp }} | |
| pr.number=${{ steps.context.outputs.pr_number }} | |
| pr.branch=${{ steps.context.outputs.backend_branch }} | |
| build-args: | | |
| BUILDKIT_INLINE_CACHE=1 | |
| CARGO_INCREMENTAL=0 | |
| RUSTC_WRAPPER=sccache | |
| provenance: true | |
| sbom: false | |
| - name: Tag Existing Image | |
| if: steps.check_image.outputs.image_exists == 'true' && inputs.force_rebuild != true | |
| run: | | |
| docker buildx imagetools create \ | |
| --tag ${{ steps.context.outputs.image_tag_pr }} \ | |
| ${{ steps.context.outputs.image_tag_sha }} | |
| - name: Display sccache Statistics | |
| if: always() | |
| run: | | |
| echo "::group::sccache final stats" | |
| if command -v sccache >/dev/null 2>&1; then | |
| sccache --show-stats | |
| else | |
| echo "sccache not available" | |
| fi | |
| echo "::endgroup::" | |
| - name: Attest Build Provenance | |
| if: steps.build_push.conclusion == 'success' | |
| uses: actions/attest-build-provenance@v2 | |
| with: | |
| subject-name: ${{ steps.context.outputs.image_tag_pr }} | |
| subject-digest: ${{ steps.build_push.outputs.digest }} | |
| push-to-registry: true | |
| # =========================================================================== | |
| # JOB 4: Deploy to RPi5 via Tailscale VPN | |
| # =========================================================================== | |
| deploy-to-rpi5: | |
| name: Deploy to RPi5 via Tailscale | |
| runs-on: [self-hosted, Linux, ARM64, neo] | |
| needs: build-arm64-image | |
| environment: pr-preview | |
| steps: | |
| - name: Calculate Deployment Ports | |
| id: ports | |
| run: | | |
| PR_NUM="${{ needs.build-arm64-image.outputs.pr_number }}" | |
| BACKEND_CONTAINER_PORT=${{ vars.BACKEND_PORT_BASE }} | |
| BACKEND_EXTERNAL_PORT=$((${{ vars.BACKEND_PORT_BASE }} + PR_NUM)) | |
| POSTGRES_EXTERNAL_PORT=$((${{ vars.POSTGRES_PORT_BASE }} + PR_NUM)) | |
| FRONTEND_EXTERNAL_PORT=$((${{ vars.FRONTEND_PORT_BASE }} + PR_NUM)) | |
| echo "backend_container_port=${BACKEND_CONTAINER_PORT}" >> $GITHUB_OUTPUT | |
| echo "backend_port=${BACKEND_EXTERNAL_PORT}" >> $GITHUB_OUTPUT | |
| echo "postgres_port=${POSTGRES_EXTERNAL_PORT}" >> $GITHUB_OUTPUT | |
| echo "frontend_port=${FRONTEND_EXTERNAL_PORT}" >> $GITHUB_OUTPUT | |
| echo "project_name=pr-${PR_NUM}" >> $GITHUB_OUTPUT | |
| echo "::notice::🔌 Postgres: ${POSTGRES_EXTERNAL_PORT} | Backend: ${BACKEND_EXTERNAL_PORT} | Frontend: ${FRONTEND_EXTERNAL_PORT}" | |
| - name: Checkout Repository | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.build-arm64-image.outputs.backend_branch }} | |
| - name: Connect to Tailscale | |
| uses: tailscale/github-action@v3 | |
| with: | |
| oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID_PR_PREVIEW }} | |
| oauth-secret: ${{ secrets.TS_OAUTH_SECRET_PR_PREVIEW }} | |
| tags: tag:github-actions | |
| version: latest | |
| use-cache: true | |
| - name: Setup SSH Configuration | |
| run: | | |
| mkdir -p ~/.ssh | |
| chmod 700 ~/.ssh | |
| echo "${{ secrets.RPI5_SSH_KEY }}" > ~/.ssh/id_ed25519 | |
| chmod 600 ~/.ssh/id_ed25519 | |
| echo "${{ secrets.RPI5_HOST_KEY }}" >> ~/.ssh/known_hosts | |
| chmod 644 ~/.ssh/known_hosts | |
| - name: Test SSH Connection | |
| run: | | |
| echo "🔍 Testing SSH connection to ${{ secrets.RPI5_TAILSCALE_NAME }}..." | |
| if ! ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=10 \ | |
| -i ~/.ssh/id_ed25519 \ | |
| ${{ secrets.RPI5_USERNAME }}@${{ secrets.RPI5_TAILSCALE_NAME }} \ | |
| 'echo "SSH connection successful"'; then | |
| echo "::error::SSH connection failed to ${{ secrets.RPI5_TAILSCALE_NAME }}" | |
| exit 1 | |
| fi | |
| echo "::notice::✅ SSH connection verified" | |
| - name: Deploy to RPi5 via Tailscale SSH | |
| run: | | |
| PR_NUMBER="${{ needs.build-arm64-image.outputs.pr_number }}" | |
| BACKEND_IMAGE="${{ needs.build-arm64-image.outputs.image_tag_pr }}" | |
| PROJECT_NAME="${{ steps.ports.outputs.project_name }}" | |
| echo "📦 Transferring compose file to RPi5..." | |
| scp -o StrictHostKeyChecking=accept-new -i ~/.ssh/id_ed25519 \ | |
| docker-compose.pr-preview.yaml \ | |
| ${{ secrets.RPI5_USERNAME }}@${{ secrets.RPI5_TAILSCALE_NAME }}:/home/${{ secrets.RPI5_USERNAME }}/pr-${PR_NUMBER}-compose.yaml | |
| echo "🚀 Deploying PR preview environment..." | |
| ssh -o StrictHostKeyChecking=accept-new -i ~/.ssh/id_ed25519 \ | |
| ${{ secrets.RPI5_USERNAME }}@${{ secrets.RPI5_TAILSCALE_NAME }} \ | |
| /bin/bash << 'ENDSSH' | |
| set -e | |
| # Validate we're on the target server | |
| if [[ "$(hostname)" == *"runner"* ]] || [[ "$(pwd)" == *"runner"* ]]; then | |
| echo "::error::Script running on GitHub runner instead of target server!" | |
| exit 1 | |
| fi | |
| export PR_NUMBER="${{ needs.build-arm64-image.outputs.pr_number }}" | |
| export BACKEND_IMAGE="${{ needs.build-arm64-image.outputs.image_tag_pr }}" | |
| export PR_POSTGRES_PORT="${{ steps.ports.outputs.postgres_port }}" | |
| export PR_BACKEND_PORT="${{ steps.ports.outputs.backend_port }}" | |
| export PR_BACKEND_CONTAINER_PORT="${{ steps.ports.outputs.backend_container_port }}" | |
| export PR_FRONTEND_PORT="${{ steps.ports.outputs.frontend_port }}" | |
| export POSTGRES_USER="${{ secrets.PR_PREVIEW_POSTGRES_USER }}" | |
| export POSTGRES_PASSWORD="${{ secrets.PR_PREVIEW_POSTGRES_PASSWORD }}" | |
| export POSTGRES_DB="${{ secrets.PR_PREVIEW_POSTGRES_DB }}" | |
| export POSTGRES_SCHEMA="${{ secrets.PR_PREVIEW_POSTGRES_SCHEMA }}" | |
| export RUST_ENV="${{ vars.RUST_ENV }}" | |
| export BACKEND_INTERFACE="${{ vars.BACKEND_INTERFACE }}" | |
| export BACKEND_ALLOWED_ORIGINS="${{ vars.BACKEND_ALLOWED_ORIGINS }}" | |
| export BACKEND_LOG_FILTER_LEVEL="${{ vars.BACKEND_LOG_FILTER_LEVEL }}" | |
| export BACKEND_SESSION_EXPIRY_SECONDS="${{ vars.BACKEND_SESSION_EXPIRY_SECONDS }}" | |
| export TIPTAP_APP_ID="${{ secrets.PR_PREVIEW_TIPTAP_APP_ID }}" | |
| export TIPTAP_URL="${{ secrets.PR_PREVIEW_TIPTAP_URL }}" | |
| export TIPTAP_AUTH_KEY="${{ secrets.PR_PREVIEW_TIPAP_AUTH_KEY }}" | |
| export TIPTAP_JWT_SIGNING_KEY="${{ secrets.PR_PREVIEW_TIPJWT_SIGNING_KEY }}" | |
| export MAILERSEND_API_KEY="${{ secrets.PR_PREVIEW_MAILERSEND_API_KEY }}" | |
| export WELCOME_EMAIL_TEMPLATE_ID="${{ secrets.PR_PREVIEW_WELCOME_EMAIL_TEMPLATE_ID }}" | |
| cd /home/${{ secrets.RPI5_USERNAME }} | |
| echo "📦 Logging into GHCR..." | |
| echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin | |
| echo "📥 Pulling image: ${BACKEND_IMAGE}..." | |
| docker pull ${BACKEND_IMAGE} | |
| echo "🛑 Stopping existing PR-${PR_NUMBER} environment..." | |
| docker compose -p ${PROJECT_NAME} -f pr-${PR_NUMBER}-compose.yaml down 2>/dev/null || true | |
| echo "🚀 Starting PR preview environment..." | |
| docker compose -p ${PROJECT_NAME} -f pr-${PR_NUMBER}-compose.yaml up -d | |
| echo "⏳ Waiting ${{ vars.SERVICE_STARTUP_WAIT_SECONDS }} seconds for services..." | |
| sleep ${{ vars.SERVICE_STARTUP_WAIT_SECONDS }} | |
| echo "🩺 Deployment status:" | |
| docker compose -p ${PROJECT_NAME} ps | |
| echo "📜 Migration logs:" | |
| docker logs ${PROJECT_NAME}-migrator-1 2>&1 | tail -20 || echo "⚠️ Migrator exited" | |
| echo "📜 Backend logs:" | |
| docker logs ${PROJECT_NAME}-backend-1 2>&1 | tail -20 || echo "⚠️ Backend starting" | |
| echo "✅ Deployment complete!" | |
| ENDSSH | |
| - name: Comment on PR with Preview URLs | |
| if: github.event_name == 'pull_request' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const prNumber = ${{ needs.build-arm64-image.outputs.pr_number }}; | |
| const backendPort = ${{ steps.ports.outputs.backend_port }}; | |
| const postgresPort = ${{ steps.ports.outputs.postgres_port }}; | |
| const frontendPort = ${{ steps.ports.outputs.frontend_port }}; | |
| const backendBranch = '${{ needs.build-arm64-image.outputs.backend_branch }}'; | |
| const imageTag = '${{ needs.build-arm64-image.outputs.image_tag_pr }}'; | |
| const isNativeArm64 = '${{ needs.build-arm64-image.outputs.is_native_arm64 }}' === 'true'; | |
| const backendUrl = `http://${{ secrets.RPI5_TAILSCALE_NAME }}:${backendPort}`; | |
| const frontendUrl = `http://${{ secrets.RPI5_TAILSCALE_NAME }}:${frontendPort}`; | |
| const comment = `## 🚀 PR Preview Environment Deployed! | |
| ### 🔗 Access URLs | |
| | Service | URL | | |
| |---------|-----| | |
| | **Frontend** | [${frontendUrl}](${frontendUrl}) | | |
| | **Backend API** | [${backendUrl}](${backendUrl}) | | |
| | **Health Check** | [${backendUrl}/health](${backendUrl}/health) | | |
| ### 📊 Environment Details | |
| - **PR Number:** #${prNumber} | |
| - **Backend Branch:** \`${backendBranch}\` | |
| - **Commit:** \`${{ github.sha }}\` | |
| - **Image:** \`${imageTag}\` | |
| - **Ports:** Frontend: ${frontendPort} | Backend: ${backendPort} | Postgres: ${postgresPort} | |
| - **Build Type:** ${isNativeArm64 ? '🚀 Native ARM64' : '⚠️ ARM64 Emulation'} | |
| ### 🔐 Access Requirements | |
| 1. **Connect to Tailscale** (required) | |
| 2. Access frontend: ${frontendUrl} | |
| 3. Access backend: ${backendUrl} | |
| ### 🧪 Testing | |
| \`\`\`bash | |
| # Health check | |
| curl ${backendUrl}/health | |
| # API test | |
| curl ${backendUrl}/api/v1/... | |
| \`\`\` | |
| ### 🧹 Cleanup | |
| _Environment auto-cleaned when PR closes/merges_ | |
| --- | |
| *Deployed: ${new Date().toISOString()}* | |
| *Optimizations: Native ARM64 build on Neo + sccache + Rust cache + Docker BuildKit*`; | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| }); | |
| const botComment = comments.find(c => | |
| c.user.type === 'Bot' && c.body.includes('PR Preview Environment') | |
| ); | |
| if (botComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body: comment, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: comment, | |
| }); | |
| } | |
| - name: Display Deployment Summary | |
| if: github.event_name == 'workflow_dispatch' | |
| run: | | |
| echo "::notice::✅ Deployment complete!" | |
| echo "::notice::🌐 Frontend: http://${{ secrets.RPI5_TAILSCALE_NAME }}:${{ steps.ports.outputs.frontend_port }}" | |
| echo "::notice::🌐 Backend: http://${{ secrets.RPI5_TAILSCALE_NAME }}:${{ steps.ports.outputs.backend_port }}" | |
| echo "::notice::🗄️ Postgres: ${{ secrets.RPI5_TAILSCALE_NAME }}:${{ steps.ports.outputs.postgres_port }}" | |
| echo "::notice::📦 Image: ${{ needs.build-arm64-image.outputs.image_tag_pr }}" | |
| echo "::notice::🏗️ Build: Native ARM64 on Neo" |