name: Promote Master to Production run-name: ${{ gitea.actor }} promoting ${{ github.event.inputs.target_ref || 'master' }} [${{ gitea.sha }}] on: workflow_dispatch: inputs: target_ref: description: "Git ref/branch to promote" required: true default: "master" run_backend_tests: description: "Run backend pytest gate" required: true default: "true" run_frontend_tests: description: "Run frontend unit test gate" required: true default: "true" run_playwright_tests: 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 default: "true" create_tag: description: "Create and push git release tag after successful deploy" required: true default: "true" require_manual_approval: description: "Require approval token for deploy job" required: true default: "false" approval_token: description: "Approval token value when manual approval is required" required: false default: "" rollback_on_failed_healthcheck: description: "Auto-rollback if post-deploy health checks fail" required: true default: "false" concurrency: group: promotion-production cancel-in-progress: false jobs: prepare: runs-on: ubuntu-latest timeout-minutes: 10 outputs: target_ref: ${{ steps.resolve.outputs.target_ref }} version: ${{ steps.version.outputs.version }} release_tag: ${{ steps.version.outputs.release_tag }} commit_sha: ${{ steps.commit.outputs.commit_sha }} steps: - name: Resolve target ref id: resolve run: | target_ref="${{ github.event.inputs.target_ref }}" if [ -z "$target_ref" ]; then target_ref="master" fi echo "target_ref=$target_ref" >> "$GITHUB_OUTPUT" - name: Check out repository code uses: actions/checkout@v3 with: ref: ${{ steps.resolve.outputs.target_ref }} fetch-depth: 0 - name: Resolve version and release tag id: version run: | version=$(python -c "import sys; sys.path.append('./backend'); from config.version import BASE_VERSION; print(BASE_VERSION)") echo "version=$version" >> "$GITHUB_OUTPUT" echo "release_tag=v$version" >> "$GITHUB_OUTPUT" - name: Resolve promoted commit SHA id: commit run: | echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Guard against duplicate release tags if: ${{ github.event.inputs.create_tag != 'false' }} run: | release_tag="${{ steps.version.outputs.release_tag }}" if git ls-remote --exit-code --tags origin "refs/tags/${release_tag}" >/dev/null 2>&1; then echo "Release tag ${release_tag} already exists on origin; aborting promotion." exit 1 fi tests: runs-on: ubuntu-latest 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.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.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.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }} run: | cd backend pytest -q - name: Set up Node.js 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" cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install frontend dependencies 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.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.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.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.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }} run: npx playwright test --reporter=line working-directory: frontend env: CI: "true" PLAYWRIGHT_BASE_URL: "https://localhost:5173" E2E_ACCESS_TOKEN_EXPIRY_MINUTES: "180" deploy: runs-on: ubuntu-latest timeout-minutes: 45 needs: - prepare - tests if: ${{ github.event.inputs.deploy != 'false' }} steps: - name: Validate manual approval token if: ${{ github.event.inputs.require_manual_approval != 'false' }} run: | if [ -z "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then echo "PROD_APPROVAL_TOKEN secret is required when manual approval is enabled." exit 1 fi if [ -z "${{ github.event.inputs.approval_token }}" ]; then echo "approval_token input is required when manual approval is enabled." exit 1 fi if [ "${{ github.event.inputs.approval_token }}" != "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then echo "Approval token does not match PROD_APPROVAL_TOKEN." exit 1 fi - name: Deploy to production homeserver uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.DEPLOY_PROD_HOST }} username: ${{ secrets.DEPLOY_PROD_USER }} key: ${{ secrets.PROD_SSH_PRIVATE_KEY }} port: 22 script: | set -euo pipefail 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 "$repo_dir" cd "$repo_dir" fi git checkout "${{ needs.prepare.outputs.target_ref }}" git reset --hard "origin/${{ needs.prepare.outputs.target_ref }}" timestamp=$(date +%Y%m%d-%H%M%S) mkdir -p backups # Capture currently running images for rollback. backend_prev_id="" frontend_prev_id="" if docker ps --format '{{.Names}}' | grep -q '^chores-app-backend-prod$'; then backend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-backend-prod) docker tag "$backend_prev_id" git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup || true fi if docker ps --format '{{.Names}}' | grep -q '^chores-app-frontend-prod$'; then frontend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-frontend-prod) docker tag "$frontend_prev_id" git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup || true fi cat > .rollback-meta << EOF BACKEND_PREV_ID=${backend_prev_id} FRONTEND_PREV_ID=${frontend_prev_id} CREATED_AT=${timestamp} PROMOTED_SHA=${{ needs.prepare.outputs.commit_sha }} RELEASE_TAG=${{ needs.prepare.outputs.release_tag }} EOF # Backup TinyDB volume before replacement. docker run --rm \ -v chores-app-backend-data:/data \ -v "$PWD/backups:/backup" \ alpine sh -c "tar czf /backup/chores-backend-data-${timestamp}.tar.gz -C /data ." || true # Keep long-lived cryptographic values stable; fall back to previous .env if secrets are absent. PREV_SECRET_KEY="" PREV_DIGEST_TOKEN_SECRET="" PREV_VAPID_PUBLIC_KEY="" PREV_VAPID_PRIVATE_KEY="" 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) 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 }}" if [ -z "$SECRET_KEY_VALUE" ]; then if [ -n "$PREV_SECRET_KEY" ]; then SECRET_KEY_VALUE="$PREV_SECRET_KEY" else SECRET_KEY_VALUE=$(openssl rand -hex 32) fi fi if [ -z "$DIGEST_TOKEN_SECRET_VALUE" ]; then if [ -n "$PREV_DIGEST_TOKEN_SECRET" ]; then DIGEST_TOKEN_SECRET_VALUE="$PREV_DIGEST_TOKEN_SECRET" else DIGEST_TOKEN_SECRET_VALUE=$(openssl rand -hex 32) fi fi if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] && [ -n "$PREV_VAPID_PUBLIC_KEY" ]; then VAPID_PUBLIC_KEY_VALUE="$PREV_VAPID_PUBLIC_KEY" fi if [ -z "$VAPID_PRIVATE_KEY_VALUE" ] && [ -n "$PREV_VAPID_PRIVATE_KEY" ]; then VAPID_PRIVATE_KEY_VALUE="$PREV_VAPID_PRIVATE_KEY" fi if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] || [ -z "$VAPID_PRIVATE_KEY_VALUE" ]; then echo "VAPID keys are required (set PROD_VAPID_PUBLIC_KEY/PROD_VAPID_PRIVATE_KEY or keep previous .env values)." exit 1 fi cat > .env << EOF FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }} SECRET_KEY=${SECRET_KEY_VALUE} REFRESH_TOKEN_EXPIRY_DAYS=${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }} DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET_VALUE} VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY_VALUE} VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_VALUE} ADMIN_EMAIL=${{ secrets.PROD_ADMIN_EMAIL }} ADMIN_PASSWORD=${{ secrets.PROD_ADMIN_PASSWORD }} ADMIN_FIRST_NAME=${{ secrets.PROD_ADMIN_FIRST_NAME }} ADMIN_LAST_NAME=${{ secrets.PROD_ADMIN_LAST_NAME }} EOF chmod 600 .env .rollback-meta 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 443 wait_for_port_release 5001 docker-compose pull docker-compose up -d sleep 20 backend_running=$(docker inspect -f '{{.State.Running}}' chores-app-backend-prod 2>/dev/null || echo false) 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 backend_ok=true fi frontend_ok=false if curl -kfsS https://localhost/ >/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 if [ "${{ github.event.inputs.rollback_on_failed_healthcheck }}" = "true" ]; then echo "Attempting rollback using predeploy-backup image tags..." docker tag git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup git.ryankegel.com:3000/kegel/chores/backend:latest || true 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 fi exit 1 fi docker-compose ps create-release-tag: runs-on: ubuntu-latest timeout-minutes: 10 needs: - prepare - tests - deploy if: ${{ github.event.inputs.create_tag != 'false' && github.event.inputs.deploy != 'false' }} steps: - name: Check out promoted ref uses: actions/checkout@v3 with: ref: ${{ needs.prepare.outputs.target_ref }} fetch-depth: 0 - name: Create and push annotated release tag run: | tag_name="${{ needs.prepare.outputs.release_tag }}" git config user.name "gitea-actions" git config user.email "gitea-actions@local" git tag -a "$tag_name" -m "Release $tag_name" git push origin "$tag_name" notify-on-failure: runs-on: ubuntu-latest needs: - prepare - tests - deploy - create-release-tag if: ${{ always() }} steps: - name: Send email when promotion fails if: ${{ needs.prepare.result == 'failure' || needs.tests.result == 'failure' || needs.deploy.result == 'failure' || needs.create-release-tag.result == 'failure' }} uses: dawidd6/action-send-mail@v3 with: server_address: smtp.gmail.com server_port: 465 username: ${{ secrets.MAIL_USER }} password: ${{ secrets.MAIL_PASSWORD }} secure: true to: ${{ secrets.MAIL_TO }} from: Gitea subject: Promotion failed - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}] convert_markdown: true html_body: | ### Production promotion failed - Repository: ${{ gitea.repository }} - Ref: ${{ needs.prepare.outputs.target_ref }} - Commit: ${{ needs.prepare.outputs.commit_sha }} - Intended tag: ${{ needs.prepare.outputs.release_tag }} - prepare: ${{ needs.prepare.result }} - tests: ${{ needs.tests.result }} - deploy: ${{ needs.deploy.result }} - tag: ${{ needs.create-release-tag.result }}