Files
team-tryouts/.gitea/workflows/git-to-ptero.yaml
T
GGThed 3882b6035f fix(ops): defauts surs a la copie, CDN epingles, actions epinglees
Quatre taches de la matrice du rapport, toutes sans dependance, qu aucune
liste de « ce qui reste » ne reprenait.

OPS-003 — app/.env.exemple disait « copiez ce fichier et remplissez les
valeurs pour la production », puis posait FLASK_DEBUG=true,
SESSION_COOKIE_SECURE=false et FORCE_HTTPS=false. Le debogueur Werkzeug
execute du code soumis par le navigateur : cette ligne transformait un
copier-coller en shell distant. Chaque valeur est desormais sure a la
copie, et le fichier refuse de demarrer tant que les deux secrets
obligatoires ne sont pas remplis plutot que de demarrer grand ouvert.

Renomme en .env.example : l orthographe francaise ne correspondait pas a
l exception !.env.example du .gitignore, donc le fichier n etait suivi que
par accident de l ordre des regles. Les deux points de la decision ouverte
du §8 tombent d un seul git mv.

OPS-002 — trusted_proxy='*' et HOST ne sont plus soudes dans wsgi.py. Les
defauts sont **inchanges**, deliberement : choisir sans connaitre la
topologie coupe la prod si nginx est ailleurs, ou casse la limitation de
debit pour tout le monde si on cesse de croire X-Forwarded-For alors que
c etait la seule source d adresses. Ce sont maintenant des variables, les
valeurs sures sont dans .env.example pour un nouveau deploiement, et
docs/deployment.md donne les quatre topologies avec la valeur de chacune.
wsgi.py avertit au demarrage tant que les deux defauts sont en place.

Le commentaire de HOST annoncait « bind to localhost by default » a cote
d un defaut a 0.0.0.0 : il decrivait l intention pendant que le code
faisait l inverse. Il dit maintenant ce qu il fait.

QUA-004 — Font Awesome et FullCalendar etaient charges sans empreinte,
depuis des hotes que la CSP autorise nommement. Qui controle ces CDN
controlait ce qui s execute sur chaque page. Empreintes posees, avec ce
que SRI promet et ce qu il ne promet pas ecrit a cote : ca fige le fichier,
ca ne prouve pas qu il etait honnete au moment du calcul.

**Le CSS de FullCalendar n existait pas.** La v6 embarque ses styles dans
le JS et ce fichier n est pas publie : le <link> repondait 404 a chaque
ouverture du calendrier depuis la montee de version. Une feuille de style
en echec est silencieuse dans le navigateur, c est ce qui l a fait durer.

CI-003 — actions epinglees sur un commit, version en commentaire, dans les
deux forges. Un tag est un pointeur mobile : deplacer v4 fait executer du
code arbitraire dans le job qui detient la cle SSH de production. Ce job
recoit aussi enfin un bloc permissions.

517 tests.
2026-08-11 14:42:23 -04:00

153 lines
6.9 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.
# Least privilege (CI-003). This job never writes back to the repository; it
# holds the SSH key to the production node, which makes it the most valuable
# job in either forge to compromise.
permissions:
contents: read
# Actions pinned to a commit, version in the comment. A tag is a moving
# pointer, and moving `v4` here means running arbitrary code in the job that
# holds that key. If the Gitea runner ever fails to resolve a commit ref, it
# fails on the checkout step — loudly, like the `@v7` that did not exist.
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@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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."