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.
153 lines
6.9 KiB
YAML
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."
|