Skip to content

190 add a staging environment for previewing and testing ahead of a new deployment #12

190 add a staging environment for previewing and testing ahead of a new deployment

190 add a staging environment for previewing and testing ahead of a new deployment #12

# =============================================================================
# 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"