|
|
|
|
@@ -20,6 +20,10 @@ on:
|
|
|
|
|
description: "Run Playwright E2E gate"
|
|
|
|
|
required: true
|
|
|
|
|
default: "true"
|
|
|
|
|
skip_tests:
|
|
|
|
|
description: "Skip the entire tests stage"
|
|
|
|
|
required: true
|
|
|
|
|
default: "false"
|
|
|
|
|
deploy:
|
|
|
|
|
description: "Run production deploy over SSH"
|
|
|
|
|
required: true
|
|
|
|
|
@@ -31,7 +35,7 @@ on:
|
|
|
|
|
require_manual_approval:
|
|
|
|
|
description: "Require approval token for deploy job"
|
|
|
|
|
required: true
|
|
|
|
|
default: "true"
|
|
|
|
|
default: "false"
|
|
|
|
|
approval_token:
|
|
|
|
|
description: "Approval token value when manual approval is required"
|
|
|
|
|
required: false
|
|
|
|
|
@@ -96,31 +100,36 @@ jobs:
|
|
|
|
|
timeout-minutes: 120
|
|
|
|
|
needs: prepare
|
|
|
|
|
steps:
|
|
|
|
|
- name: Skip tests stage
|
|
|
|
|
if: ${{ github.event.inputs.skip_tests == 'true' }}
|
|
|
|
|
run: echo "Skipping tests stage because skip_tests=true"
|
|
|
|
|
|
|
|
|
|
- name: Check out promoted ref
|
|
|
|
|
if: ${{ github.event.inputs.skip_tests != 'true' }}
|
|
|
|
|
uses: actions/checkout@v3
|
|
|
|
|
with:
|
|
|
|
|
ref: ${{ needs.prepare.outputs.target_ref }}
|
|
|
|
|
|
|
|
|
|
- name: Set up Python for backend tests
|
|
|
|
|
if: ${{ github.event.inputs.run_backend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false' }}
|
|
|
|
|
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_backend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
|
|
|
|
uses: actions/setup-python@v4
|
|
|
|
|
with:
|
|
|
|
|
python-version: "3.11"
|
|
|
|
|
|
|
|
|
|
- name: Install backend dependencies (runner python)
|
|
|
|
|
if: ${{ github.event.inputs.run_backend_tests != 'false' }}
|
|
|
|
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
|
|
|
|
|
run: |
|
|
|
|
|
python -m pip install --upgrade pip
|
|
|
|
|
pip install -r backend/requirements.txt
|
|
|
|
|
|
|
|
|
|
- name: Run backend unit tests
|
|
|
|
|
if: ${{ github.event.inputs.run_backend_tests != 'false' }}
|
|
|
|
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
|
|
|
|
|
run: |
|
|
|
|
|
cd backend
|
|
|
|
|
pytest -q
|
|
|
|
|
|
|
|
|
|
- name: Set up Node.js
|
|
|
|
|
if: ${{ github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false' }}
|
|
|
|
|
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
|
|
|
|
uses: actions/setup-node@v4
|
|
|
|
|
with:
|
|
|
|
|
node-version: "20.19.0"
|
|
|
|
|
@@ -128,29 +137,29 @@ jobs:
|
|
|
|
|
cache-dependency-path: frontend/package-lock.json
|
|
|
|
|
|
|
|
|
|
- name: Install frontend dependencies
|
|
|
|
|
if: ${{ github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false' }}
|
|
|
|
|
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
|
|
|
|
run: npm ci
|
|
|
|
|
working-directory: frontend
|
|
|
|
|
|
|
|
|
|
- name: Run frontend unit tests
|
|
|
|
|
if: ${{ github.event.inputs.run_frontend_tests != 'false' }}
|
|
|
|
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_frontend_tests != 'false' }}
|
|
|
|
|
run: npm run test:unit --if-present
|
|
|
|
|
working-directory: frontend
|
|
|
|
|
|
|
|
|
|
- name: Create backend venv for Playwright webServer
|
|
|
|
|
if: ${{ github.event.inputs.run_playwright_tests != 'false' }}
|
|
|
|
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
|
|
|
|
run: |
|
|
|
|
|
python -m venv backend/.venv
|
|
|
|
|
backend/.venv/bin/python -m pip install --upgrade pip
|
|
|
|
|
backend/.venv/bin/python -m pip install -r backend/requirements.txt
|
|
|
|
|
|
|
|
|
|
- name: Install Playwright browsers
|
|
|
|
|
if: ${{ github.event.inputs.run_playwright_tests != 'false' }}
|
|
|
|
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
|
|
|
|
run: npx playwright install --with-deps
|
|
|
|
|
working-directory: frontend
|
|
|
|
|
|
|
|
|
|
- name: Run Playwright tests
|
|
|
|
|
if: ${{ github.event.inputs.run_playwright_tests != 'false' }}
|
|
|
|
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
|
|
|
|
run: npx playwright test --reporter=line
|
|
|
|
|
working-directory: frontend
|
|
|
|
|
env:
|
|
|
|
|
@@ -192,13 +201,27 @@ jobs:
|
|
|
|
|
script: |
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
cd /opt
|
|
|
|
|
if [ -d "chore/.git" ]; then
|
|
|
|
|
cd chore
|
|
|
|
|
repo_dir="${{ secrets.DEPLOY_PROD_PATH }}"
|
|
|
|
|
if [ -z "$repo_dir" ]; then
|
|
|
|
|
repo_dir="$HOME/chore"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
repo_parent=$(dirname "$repo_dir")
|
|
|
|
|
if [ ! -d "$repo_parent" ]; then
|
|
|
|
|
mkdir -p "$repo_parent"
|
|
|
|
|
fi
|
|
|
|
|
if [ ! -w "$repo_parent" ]; then
|
|
|
|
|
echo "Deploy user $(whoami) cannot write to ${repo_parent}."
|
|
|
|
|
echo "Set DEPLOY_PROD_PATH to a writable location or grant write access before rerunning promotion."
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ -d "$repo_dir/.git" ]; then
|
|
|
|
|
cd "$repo_dir"
|
|
|
|
|
git fetch --all --tags
|
|
|
|
|
else
|
|
|
|
|
git clone --branch "${{ needs.prepare.outputs.target_ref }}" https://git.ryankegel.com/ryan/chore.git chore
|
|
|
|
|
cd chore
|
|
|
|
|
git clone --branch "${{ needs.prepare.outputs.target_ref }}" https://git.ryankegel.com/ryan/chore.git "$repo_dir"
|
|
|
|
|
cd "$repo_dir"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
git checkout "${{ needs.prepare.outputs.target_ref }}"
|
|
|
|
|
@@ -238,17 +261,20 @@ jobs:
|
|
|
|
|
PREV_DIGEST_TOKEN_SECRET=""
|
|
|
|
|
PREV_VAPID_PUBLIC_KEY=""
|
|
|
|
|
PREV_VAPID_PRIVATE_KEY=""
|
|
|
|
|
PREV_REFRESH_TOKEN_EXPIRY_DAYS=""
|
|
|
|
|
if [ -f .env ]; then
|
|
|
|
|
PREV_SECRET_KEY=$(grep '^SECRET_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
|
|
|
|
PREV_DIGEST_TOKEN_SECRET=$(grep '^DIGEST_TOKEN_SECRET=' .env | head -n1 | cut -d '=' -f2- || true)
|
|
|
|
|
PREV_VAPID_PUBLIC_KEY=$(grep '^VAPID_PUBLIC_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
|
|
|
|
PREV_VAPID_PRIVATE_KEY=$(grep '^VAPID_PRIVATE_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
|
|
|
|
PREV_REFRESH_TOKEN_EXPIRY_DAYS=$(grep '^REFRESH_TOKEN_EXPIRY_DAYS=' .env | head -n1 | cut -d '=' -f2- || true)
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
SECRET_KEY_VALUE="${{ secrets.PROD_SECRET_KEY }}"
|
|
|
|
|
DIGEST_TOKEN_SECRET_VALUE="${{ secrets.PROD_DIGEST_TOKEN_SECRET }}"
|
|
|
|
|
VAPID_PUBLIC_KEY_VALUE="${{ secrets.PROD_VAPID_PUBLIC_KEY }}"
|
|
|
|
|
VAPID_PRIVATE_KEY_VALUE="${{ secrets.PROD_VAPID_PRIVATE_KEY }}"
|
|
|
|
|
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}"
|
|
|
|
|
|
|
|
|
|
if [ -z "$SECRET_KEY_VALUE" ]; then
|
|
|
|
|
if [ -n "$PREV_SECRET_KEY" ]; then
|
|
|
|
|
@@ -278,10 +304,25 @@ jobs:
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ -z "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" ]; then
|
|
|
|
|
if [ -n "$PREV_REFRESH_TOKEN_EXPIRY_DAYS" ]; then
|
|
|
|
|
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="$PREV_REFRESH_TOKEN_EXPIRY_DAYS"
|
|
|
|
|
else
|
|
|
|
|
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="90"
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if ! printf '%s' "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" | grep -Eq '^[0-9]+$'; then
|
|
|
|
|
echo "REFRESH_TOKEN_EXPIRY_DAYS must be a positive integer, got: $REFRESH_TOKEN_EXPIRY_DAYS_VALUE"
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
cat > .env << EOF
|
|
|
|
|
FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}
|
|
|
|
|
BACKEND_HOST_PORT=${{ secrets.PROD_BACKEND_HOST_PORT }}
|
|
|
|
|
FRONTEND_HOST_PORT=${{ secrets.PROD_FRONTEND_HOST_PORT }}
|
|
|
|
|
SECRET_KEY=${SECRET_KEY_VALUE}
|
|
|
|
|
REFRESH_TOKEN_EXPIRY_DAYS=${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}
|
|
|
|
|
REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS_VALUE}
|
|
|
|
|
DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET_VALUE}
|
|
|
|
|
VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY_VALUE}
|
|
|
|
|
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_VALUE}
|
|
|
|
|
@@ -293,7 +334,52 @@ jobs:
|
|
|
|
|
|
|
|
|
|
chmod 600 .env .rollback-meta
|
|
|
|
|
|
|
|
|
|
backend_host_port=$(grep '^BACKEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
|
|
|
|
frontend_host_port=$(grep '^FRONTEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
|
|
|
|
if [ -z "$backend_host_port" ]; then
|
|
|
|
|
backend_host_port="5001"
|
|
|
|
|
fi
|
|
|
|
|
if [ -z "$frontend_host_port" ]; then
|
|
|
|
|
frontend_host_port="4601"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
docker-compose down
|
|
|
|
|
|
|
|
|
|
wait_for_container_removal() {
|
|
|
|
|
container_name="$1"
|
|
|
|
|
attempts=0
|
|
|
|
|
while docker ps -a --format '{{.Names}}' | grep -qx "$container_name"; do
|
|
|
|
|
attempts=$((attempts + 1))
|
|
|
|
|
if [ "$attempts" -ge 12 ]; then
|
|
|
|
|
echo "Container ${container_name} still exists after waiting for teardown."
|
|
|
|
|
docker ps -a --filter "name=^${container_name}$"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
echo "Waiting for ${container_name} to be removed..."
|
|
|
|
|
sleep 5
|
|
|
|
|
done
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wait_for_port_release() {
|
|
|
|
|
port="$1"
|
|
|
|
|
attempts=0
|
|
|
|
|
while docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" >/dev/null 2>&1; do
|
|
|
|
|
attempts=$((attempts + 1))
|
|
|
|
|
if [ "$attempts" -ge 12 ]; then
|
|
|
|
|
echo "Port ${port} is still allocated after teardown. Blocking container(s):"
|
|
|
|
|
docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" || true
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
echo "Waiting for port ${port} to be released..."
|
|
|
|
|
sleep 5
|
|
|
|
|
done
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wait_for_container_removal chores-app-frontend-prod
|
|
|
|
|
wait_for_container_removal chores-app-backend-prod
|
|
|
|
|
wait_for_port_release "$frontend_host_port"
|
|
|
|
|
wait_for_port_release "$backend_host_port"
|
|
|
|
|
|
|
|
|
|
docker-compose pull
|
|
|
|
|
docker-compose up -d
|
|
|
|
|
|
|
|
|
|
@@ -303,18 +389,22 @@ jobs:
|
|
|
|
|
frontend_running=$(docker inspect -f '{{.State.Running}}' chores-app-frontend-prod 2>/dev/null || echo false)
|
|
|
|
|
|
|
|
|
|
backend_ok=false
|
|
|
|
|
if curl -fsS http://localhost:5001/version >/dev/null 2>&1; then
|
|
|
|
|
if curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null 2>&1; then
|
|
|
|
|
backend_ok=true
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
frontend_ok=false
|
|
|
|
|
if curl -kfsS https://localhost/ >/dev/null 2>&1; then
|
|
|
|
|
if curl -kfsS "https://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
|
|
|
|
|
frontend_ok=true
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ "$backend_running" != "true" ] || [ "$frontend_running" != "true" ] || [ "$backend_ok" != "true" ] || [ "$frontend_ok" != "true" ]; then
|
|
|
|
|
echo "Post-deploy health check failed."
|
|
|
|
|
docker-compose ps
|
|
|
|
|
echo "--- Backend logs (last 200 lines) ---"
|
|
|
|
|
docker logs --tail 200 chores-app-backend-prod || true
|
|
|
|
|
echo "--- Frontend logs (last 120 lines) ---"
|
|
|
|
|
docker logs --tail 120 chores-app-frontend-prod || true
|
|
|
|
|
|
|
|
|
|
if [ "${{ github.event.inputs.rollback_on_failed_healthcheck }}" = "true" ]; then
|
|
|
|
|
echo "Attempting rollback using predeploy-backup image tags..."
|
|
|
|
|
@@ -322,7 +412,7 @@ jobs:
|
|
|
|
|
docker tag git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup git.ryankegel.com:3000/kegel/chores/frontend:latest || true
|
|
|
|
|
docker-compose up -d
|
|
|
|
|
sleep 20
|
|
|
|
|
curl -fsS http://localhost:5001/version >/dev/null
|
|
|
|
|
curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
exit 1
|
|
|
|
|
|