name: Push to SFTP on: workflow_dispatch: # push: # branches: # - main # Optional: Run automatically on pushes to the main branch # OPS-011 — what this workflow now guarantees, and what it still does not. # # Guaranteed: # - nothing is uploaded unless the test suite and the linters pass; # - only files on an explicit allowlist are uploaded, so a new file at the # repository root does not reach production by default. That is how # clear_db.py — a script that DROPs every table and recreates # admin/password — got there in the first place; # - after the upload, /health is polled until it answers healthy, and the # job fails loudly if it does not. Before, a half-uploaded tree was a # green deployment. # # NOT guaranteed — the switch is not atomic. Files are mirrored in place, so # for the length of the transfer production runs a mixture of two versions. # Closing that needs a release-directory layout, which has three # prerequisites, two of which cannot be done from here: # # 1. the Pterodactyl startup command must run the app from `current/` # rather than from the server root, and the server must be restarted on # switch — a panel change; # 2. `documents/`, `logs/` and `.env` must live beside the releases, not # inside one. DOCUMENTS_ROOT exists for this (app/storage.py); # 3. contract paths must be relative to that root, so the switch does not # strand them. Done: new rows are relative, old absolute ones still # resolve. # # docs/deployment.md carries the design and the rollback procedure. jobs: deploy-to-sftp: runs-on: ubuntu-latest steps: # Was @v7, which does not exist (latest major is v5): the workflow # failed on its very first step. - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install dependencies run: pip install -r requirements.txt -r requirements-dev.txt # The gate. This runner is not the GitHub one, so a green CI over # there proves nothing about what is about to be shipped from here: # the deploy is triggered by hand, on whatever the branch holds. - name: Refuse to deploy a broken tree run: | python -m pytest -q python -m ruff check . python -m ruff format --check . # An allowlist, not a list of exclusions. The previous form mirrored # the whole working tree minus nine globs, so every file added to the # repository shipped to production unless someone remembered to # exclude it. This inverts the default: a new top-level file has to be # named here to reach the server. - name: Assemble the release payload run: | set -euo pipefail mkdir -p payload cp -r app payload/ cp requirements.txt wsgi.py payload/ # Compiled catalogues are versioned deliberately: the deployment is # a file mirror with no build step (docs/translations.md). find payload -name '__pycache__' -type d -prune -exec rm -rf {} + find payload -name '*.pyc' -delete echo "Shipping $(find payload -type f | wc -l) files:" find payload -maxdepth 2 -type d | sort - name: Set up SSH Private Key env: # Binds the secret to a secure environment variable SSH_PRIVATE_KEY: ${{ secrets.SSH }} run: | mkdir -p ~/.ssh # Uses the environment variable, so the raw key is never printed in the execution log echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa - name: Push files via SFTP with progress run: | # --delete is deliberately NOT used. Uploaded contracts, logs and the # server's own .env live under the deployment root and are absent # from the repository; deleting anything not present locally would # destroy them. Stale files therefore still accumulate — that is the # other half of what the release-directory layout would fix. lftp -e "set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes -o PasswordAuthentication=no'; \ set sftp:auto-confirm yes; \ set net:max-retries 5; \ set net:timeout 30; \ set cmd:fail-exit yes; \ open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \ mirror -R --verbose --parallel=4 ./payload/ ./; \ quit" # Without this a deployment that left the site 500ing reported success, # and the first person to hear about it was a user. /health checks the # database connection and reports whether the Discord bot thread is # alive (OPS-012). # HEALTH_URL is carried as a secret rather than as a variable. It is # not secret — it is the public site — but `secrets` is the context # this runner is already known to support, and a smoke test that fails # to run because of an unsupported expression is worse than none. - name: Smoke test if: ${{ secrets.HEALTH_URL != '' }} env: HEALTH_URL: ${{ secrets.HEALTH_URL }} run: | set -euo pipefail # The app is restarted by the panel, not by this workflow, so the # first few probes are expected to fail or answer from the old # process. Two minutes, then give up. for attempt in $(seq 1 24); do body=$(curl -fsS --max-time 10 "$HEALTH_URL" 2>/dev/null) || body='' if echo "$body" | grep -q '"status": *"healthy"'; then echo "Healthy after ${attempt} attempt(s):" echo "$body" exit 0 fi echo "attempt ${attempt}: not healthy yet" sleep 5 done echo "::error::/health never reported healthy. The deployment is live and may be broken — see the rollback procedure in docs/deployment.md." exit 1 - name: Warn when no health check is configured if: ${{ secrets.HEALTH_URL == '' }} run: | echo "::warning::HEALTH_URL is not set, so this deployment was not verified. Set it to https:///health in the repository secrets."