diff --git a/.gitea/workflows/promote-master.yaml b/.gitea/workflows/promote-master.yaml new file mode 100644 index 0000000..04f23ef --- /dev/null +++ b/.gitea/workflows/promote-master.yaml @@ -0,0 +1,386 @@ +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" + 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: "true" + 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: Check out promoted ref + 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' }} + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install backend dependencies (runner python) + if: ${{ 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' }} + 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' }} + 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.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' }} + 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' }} + 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' }} + run: npx playwright install --with-deps + working-directory: frontend + + - name: Run Playwright tests + if: ${{ github.event.inputs.run_playwright_tests != 'false' }} + run: npx playwright test --reporter=line + working-directory: frontend + env: + CI: "true" + + 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 + + cd /opt + if [ -d "chore/.git" ]; then + cd chore + git fetch --all --tags + else + git clone --branch "${{ needs.prepare.outputs.target_ref }}" https://git.ryankegel.com/ryan/chore.git chore + cd chore + 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 + 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 }}