OPS-011, en partie. Ce que le workflow garantit maintenant : - rien ne part d un arbre casse. La suite, ruff check et ruff format tournent sur le runner de deploiement avant tout envoi. Une CI verte sur GitHub ne prouve rien ici : le deploiement se declenche a la main, sur ce que la branche contient a cet instant ; - seuls les fichiers nommes partent. La charge est une liste blanche — app/, wsgi.py, requirements.txt — et non l arbre de travail moins neuf exclusions. C est par cette porte que clear_db.py, la suite de tests et les definitions de CI se sont retrouves sur le noeud de production ; - le deploiement est verifie. /health est interroge pendant deux minutes apres l envoi et le job echoue s il ne repond jamais « healthy ». Avant, un arbre a moitie televerse etait un deploiement vert. Ce qui n est pas garanti, et c est ecrit dans le fichier : la bascule n est pas atomique. Le miroir se fait sur place, donc pendant le transfert la production execute un melange de deux versions. En cherchant a fermer ce point, un defaut a part entiere est apparu. Les contrats etaient ranges a os.getcwd()/documents et leur chemin absolu ecrit en base. La racine de stockage suivait donc le repertoire depuis lequel le processus avait ete lance : redemarrer le serveur ailleurs envoie les nouveaux contrats dans un nouvel arbre et rend les anciens illisibles — la base continuant d affirmer qu ils sont la, la panne se manifeste par un 500 au telechargement, pas par quelque chose d actionnable. app/storage.py fixe la racine et DOCUMENTS_ROOT la deplace. Les nouvelles lignes gardent un chemin relatif, les anciennes gardent leur chemin absolu et continuent de resoudre : aucune migration de donnees n est necessaire, donc ce changement n attend pas Alembic. C etait aussi le troisieme pre-requis de la bascule par repertoires de version. Les deux autres sont hors d atteinte d ici — la commande de demarrage Pterodactyl doit pointer sur current/, et les repertoires partages doivent etre installes sur le noeud. Les deux sont decrits dans docs/deployment.md, avec la procedure de retour arriere qui manquait. 511 tests.
142 lines
6.3 KiB
YAML
142 lines
6.3 KiB
YAML
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://<host>/health in the repository secrets."
|