feat(deploy): webhook auto-deploy Forgejo → VPS Paris (TD-04)
Some checks are pending
CI / quality (push) Waiting to run
Some checks are pending
CI / quality (push) Waiting to run
This commit is contained in:
parent
85c760abee
commit
0ae2db3d8c
6 changed files with 333 additions and 37 deletions
98
deploy/deploy.sh
Normal file
98
deploy/deploy.sh
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
#!/usr/bin/env bash
|
||||
# Expria auto-deploy script (VPS Paris).
|
||||
#
|
||||
# Runs as the non-root `deploy` user, invoked by deploy/webhook-listener.mjs on
|
||||
# a verified push to origin/main. Fast-forwards the checkout, installs, builds,
|
||||
# and restarts expria-backend.service via ONE restricted sudo rule:
|
||||
# deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart expria-backend.service
|
||||
#
|
||||
# On a build or health-check failure it auto-rolls back to the previous commit.
|
||||
# All output goes to stdout -> journald (journalctl -u expria-deploy).
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="/opt/expria/expria-backend"
|
||||
BRANCH="main"
|
||||
SERVICE="expria-backend.service"
|
||||
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:4000/}"
|
||||
LOCK_FILE="/tmp/expria-deploy.lock"
|
||||
|
||||
log() { echo "$(date --iso-8601=seconds) [deploy] $*"; }
|
||||
|
||||
# Serialize concurrent deploys: a second webhook waits here, or bails out.
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
log "another deploy is already in progress — aborting this run"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
PREV="$(git rev-parse HEAD)"
|
||||
log "start (current=${PREV:0:8})"
|
||||
|
||||
restart_backend() {
|
||||
sudo /usr/bin/systemctl restart "$SERVICE"
|
||||
}
|
||||
|
||||
# Poll the liveness endpoint (GET / always returns 200 when the process is up).
|
||||
health_check() {
|
||||
for _ in $(seq 1 10); do
|
||||
if curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
rollback() {
|
||||
log "ROLLBACK to ${PREV:0:8}"
|
||||
if git reset --hard "$PREV" && npm ci && npm run build; then
|
||||
if restart_backend && health_check; then
|
||||
log "ROLLBACK ok — service healthy on ${PREV:0:8}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
log "ROLLBACK FAILED — manual intervention required (was on ${PREV:0:8})"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 1. Fetch + fast-forward only (refuses a diverged history, no destructive pull).
|
||||
git fetch --prune origin "$BRANCH"
|
||||
if ! git merge --ff-only "origin/$BRANCH"; then
|
||||
log "fast-forward failed (diverged history) — aborting, no changes applied"
|
||||
exit 1
|
||||
fi
|
||||
NEW="$(git rev-parse HEAD)"
|
||||
log "pulled ${PREV:0:8} -> ${NEW:0:8}"
|
||||
|
||||
if [ "$PREV" = "$NEW" ]; then
|
||||
log "already up to date — nothing to deploy"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2. Install + build (rollback on failure).
|
||||
if ! npm ci; then
|
||||
log "npm ci FAILED"
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if ! npm run build; then
|
||||
log "npm run build FAILED"
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Restart + health check (rollback on failure).
|
||||
if ! restart_backend; then
|
||||
log "systemctl restart FAILED"
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if health_check; then
|
||||
log "SUCCESS — deployed ${NEW:0:8}"
|
||||
else
|
||||
log "health check FAILED after restart"
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
34
deploy/expria-deploy.service
Normal file
34
deploy/expria-deploy.service
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Expria auto-deploy webhook listener — systemd unit (VPS Paris).
|
||||
#
|
||||
# DISTINCT from expria-backend.service: this runs the webhook listener, not the
|
||||
# API. Keeping them separate is required — a deploy restarts expria-backend, so
|
||||
# the listener must NOT be a child of it or the deploy would kill itself.
|
||||
#
|
||||
# Install (ops step, after CP1 validation):
|
||||
# sudo cp deploy/expria-deploy.service /etc/systemd/system/expria-deploy.service
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now expria-deploy
|
||||
# Logs:
|
||||
# journalctl -u expria-deploy -f
|
||||
|
||||
[Unit]
|
||||
Description=Expria auto-deploy webhook listener (Forgejo push -> deploy)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=deploy
|
||||
Group=deploy
|
||||
WorkingDirectory=/opt/expria/expria-backend/deploy
|
||||
EnvironmentFile=/etc/expria/webhook.env
|
||||
ExecStart=/usr/bin/node /opt/expria/expria-backend/deploy/webhook-listener.mjs
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# NOTE: NoNewPrivileges MUST stay false (default) — deploy.sh relies on sudo for
|
||||
# the single restricted rule: systemctl restart expria-backend.service.
|
||||
# ProtectSystem=strict is intentionally NOT set: deploy.sh writes the checkout
|
||||
# under /opt/expria/expria-backend (git pull, npm ci, build).
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
140
deploy/webhook-listener.mjs
Normal file
140
deploy/webhook-listener.mjs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
#!/usr/bin/env node
|
||||
// Expria auto-deploy webhook listener (VPS Paris).
|
||||
//
|
||||
// Zero external dependencies: Node stdlib only. Runs as the non-root `deploy`
|
||||
// user under systemd (deploy/expria-deploy.service), bound to 127.0.0.1 only.
|
||||
// Caddy (deploy.expria.app) terminates TLS + filters source IP, then proxies
|
||||
// here. Forgejo "push" events are authenticated by HMAC-SHA256 over the raw
|
||||
// body (header X-Gitea-Signature) using a DEDICATED secret (never the git token).
|
||||
//
|
||||
// On a verified push to refs/heads/main, it spawns deploy/deploy.sh. Concurrent
|
||||
// deploys are serialized by flock inside deploy.sh.
|
||||
|
||||
import http from "node:http";
|
||||
import crypto from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const HOST = "127.0.0.1";
|
||||
const PORT = Number(process.env.WEBHOOK_PORT) || 9000;
|
||||
const HOOK_PATH = process.env.WEBHOOK_PATH || "/hooks/deploy";
|
||||
const TARGET_REF = "refs/heads/main";
|
||||
const MAX_BODY = 5 * 1024 * 1024; // 5 MB — generous for a push payload
|
||||
|
||||
const SECRET = process.env.WEBHOOK_SECRET;
|
||||
if (!SECRET || SECRET.length < 16) {
|
||||
console.error(
|
||||
"[webhook] WEBHOOK_SECRET missing or too short (<16 chars) — refusing to start",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DEPLOY_SCRIPT = path.join(__dirname, "deploy.sh");
|
||||
|
||||
let deployRunning = false;
|
||||
|
||||
function log(...args) {
|
||||
console.log(new Date().toISOString(), "[webhook]", ...args);
|
||||
}
|
||||
|
||||
// Constant-time HMAC comparison. Never logs the secret or the signature.
|
||||
function verifySignature(rawBody, headerSig) {
|
||||
if (!headerSig || typeof headerSig !== "string") return false;
|
||||
const expected = crypto
|
||||
.createHmac("sha256", SECRET)
|
||||
.update(rawBody)
|
||||
.digest("hex");
|
||||
let a;
|
||||
let b;
|
||||
try {
|
||||
a = Buffer.from(expected, "hex");
|
||||
b = Buffer.from(headerSig, "hex");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function runDeploy(triggerSha) {
|
||||
deployRunning = true;
|
||||
log("deploy launch", triggerSha ? `after=${triggerSha.slice(0, 8)}` : "");
|
||||
const child = spawn("/bin/bash", [DEPLOY_SCRIPT], {
|
||||
cwd: __dirname,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "inherit", "inherit"], // deploy.sh logs go to journald
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
deployRunning = false;
|
||||
log("deploy finished exit=" + code);
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
deployRunning = false;
|
||||
log("deploy spawn error:", err.message);
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== "POST" || req.url !== HOOK_PATH) {
|
||||
res.writeHead(404).end("not found\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
let aborted = false;
|
||||
|
||||
req.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY) {
|
||||
aborted = true;
|
||||
res.writeHead(413).end("payload too large\n");
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
if (aborted) return;
|
||||
const rawBody = Buffer.concat(chunks);
|
||||
const sig =
|
||||
req.headers["x-gitea-signature"] || req.headers["x-forgejo-signature"];
|
||||
|
||||
if (!verifySignature(rawBody, sig)) {
|
||||
log("rejected: invalid signature from", req.socket.remoteAddress);
|
||||
res.writeHead(401).end("unauthorized\n");
|
||||
return;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(rawBody.toString("utf8"));
|
||||
} catch {
|
||||
res.writeHead(400).end("bad json\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.ref !== TARGET_REF) {
|
||||
log("ignored ref", payload.ref);
|
||||
res.writeHead(200).end("ignored (not main)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (deployRunning) {
|
||||
log("deploy already running — new run will queue on flock");
|
||||
}
|
||||
res.writeHead(202).end("accepted\n");
|
||||
runDeploy(payload.after);
|
||||
});
|
||||
|
||||
req.on("error", () => {
|
||||
if (!res.headersSent) res.writeHead(400).end();
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
log(`listening on ${HOST}:${PORT}${HOOK_PATH}`);
|
||||
});
|
||||
|
|
@ -24,9 +24,9 @@ Utilisateur (navigateur)
|
|||
┌─────────────▼───────────────┐
|
||||
│ BACKEND │
|
||||
│ Hono.js (Node.js) │
|
||||
│ Render (Frankfurt) │
|
||||
│ VPS Paris (systemd + Caddy)│
|
||||
│ — toutes les routes API │
|
||||
│ — WebSocket proxy T2 EO │
|
||||
│ — WebSocket proxy T1/T2 EO │
|
||||
└──────┬──────────────┬───────┘
|
||||
│ │
|
||||
┌──────▼──────┐ ┌────▼────────────────┐
|
||||
|
|
@ -433,17 +433,17 @@ NODE_ENV=production
|
|||
|
||||
## 9. Déploiement
|
||||
|
||||
### Hébergement Git — GitHub
|
||||
### Hébergement Git — Forgejo (auto-hébergé)
|
||||
|
||||
- Plateforme : github.com
|
||||
- Dépôt frontend : `https://github.com/germannoff/expria-frontend`
|
||||
- Dépôt backend : `https://github.com/germannoff/expria-backend`
|
||||
- Note : compte GitHub réactivé le 17 avril 2026 après restriction OFAC levée
|
||||
- Auto-deploy : disponible via Render (connecté à GitHub)
|
||||
- Plateforme : Forgejo (fork Gitea) auto-hébergé sur le VPS Moscou (`157.22.190.91`), exposé via `git.expria.app` (proxifié par un Caddy Moscou).
|
||||
- Dépôt frontend : `https://git.expria.app/hermann/expria-frontend`
|
||||
- Dépôt backend : `https://git.expria.app/hermann/expria-backend`
|
||||
- Push : HTTPS + token Forgejo (branche `main`). Un miroir SSH historique (`ssh://157.22.190.91:2222`) peut exister mais n'est pas la voie de référence.
|
||||
- Note : migration depuis GitHub actée pour s'affranchir des restrictions OFAC et reprendre la main sur l'hébergement Git.
|
||||
|
||||
### Frontend — Cloudflare Pages
|
||||
|
||||
- Source : dépôt GitHub `expria-frontend`
|
||||
- Source : dépôt Forgejo `expria-frontend`
|
||||
- Build command : `npm run build`
|
||||
- Output directory : `dist`
|
||||
- Domaine : `expria.app` (DNS pointé depuis Vercel vers Cloudflare Pages)
|
||||
|
|
@ -455,17 +455,14 @@ npm run build
|
|||
npx wrangler pages deploy dist --project-name=expria
|
||||
```
|
||||
|
||||
### Backend — Render
|
||||
### Backend — VPS Paris (HostVDS)
|
||||
|
||||
- Source : dépôt GitHub `expria-backend`
|
||||
- Type : Web Service (Node.js)
|
||||
- Région : Frankfurt (EU) — proximité utilisateurs Afrique du Nord
|
||||
- Build command : `npm run build`
|
||||
- Start command : `npm start`
|
||||
- Domaine : `api.expria.app` (certificat SSL actif)
|
||||
- URL Render : `https://expria-backend.onrender.com` (alias)
|
||||
- WebSocket : activé nativement sur Render
|
||||
- Déploiement : **automatique à chaque push sur main (GitHub → Render)**
|
||||
- Hébergement : VPS **HostVDS Paris**, IP `95.182.84.3`.
|
||||
- Source : dépôt Forgejo `expria-backend`, checkout dans `/opt/expria/expria-backend`.
|
||||
- Runtime : Node.js (v20.x) géré par **systemd** — unité `expria-backend.service` (`ExecStart=/usr/bin/node dist/index.js`, `Restart=always`). Le process écoute sur `localhost:4000`.
|
||||
- Reverse proxy : **Caddy** (v2) sert `api.expria.app` en TLS (Let's Encrypt forcé, `acme_ca` production) et proxifie vers `localhost:4000`. WebSocket (T1/T2 Live) supporté nativement par Caddy.
|
||||
- Proxy Gemini : **Cloudflare WARP** tourne en mode SOCKS5 (`socks5://127.0.0.1:40000`) sur le VPS ; **seul** le trafic WebSocket Gemini Live y est routé (via `GEMINI_PROXY_URL`, cf. §8) car l'IP du datacenter est bloquée par Google. Supabase, DeepSeek, Stripe et les clients restent en connexion directe.
|
||||
- **Auto-deploy (webhook Forgejo)** : un push sur `main` déclenche un webhook Forgejo vers `deploy.expria.app` (sous-domaine dédié, proxifié par Caddy vers un listener local `127.0.0.1:9000`). Le listener (`deploy/webhook-listener.mjs`, Node stdlib) vérifie la signature **HMAC-SHA256** (`X-Gitea-Signature`) et l'IP source (allowlist `157.22.190.91`), puis lance `deploy/deploy.sh` (git fast-forward → `npm ci` → `npm run build` → `systemctl restart expria-backend`, avec **rollback automatique** si le build ou le health-check échoue). Listener isolé sous l'utilisateur non-root `deploy` (unité `expria-deploy.service`).
|
||||
|
||||
### Base de données — Supabase
|
||||
|
||||
|
|
@ -478,8 +475,9 @@ npx wrangler pages deploy dist --project-name=expria
|
|||
```
|
||||
1. Tester localement (npm run test — tous les tests verts)
|
||||
2. Rejouer le Golden Dataset
|
||||
3. Commit + push sur GitHub (branche main)
|
||||
4. Backend : auto-deploy Render déclenché automatiquement
|
||||
3. Commit + push sur Forgejo (branche main)
|
||||
4. Backend : auto-deploy déclenché par le webhook Forgejo → VPS Paris
|
||||
(git pull → npm ci → build → restart systemd, rollback auto si échec)
|
||||
5. Déployer le frontend : npm run build && npx wrangler pages deploy dist
|
||||
6. Vérifier les URLs de production (expria.app + api.expria.app)
|
||||
7. Rejouer le Smoke Test (Groupe Z du Golden Dataset)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,22 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/).
|
|||
|
||||
---
|
||||
|
||||
## [Unreleased] — 2026-07-01 — Auto-deploy par webhook Forgejo (VPS Paris)
|
||||
|
||||
### Added
|
||||
|
||||
- `deploy/webhook-listener.mjs` — listener HTTP **Node stdlib pur** (aucune dépendance externe), bindé sur `127.0.0.1:9000`, path `POST /hooks/deploy`. Vérifie la signature **HMAC-SHA256** du corps brut (header `X-Gitea-Signature`, fallback `X-Forgejo-Signature`) via `crypto.timingSafeEqual`, refuse de démarrer sans `WEBHOOK_SECRET`, ne traite que `ref === refs/heads/main`, cape le body à 5 Mo, ne logge jamais le secret ni la signature. Lance `deploy.sh` sans bloquer la réponse.
|
||||
- `deploy/deploy.sh` — script de déploiement (`set -euo pipefail`) : verrou `flock` (deploys sérialisés), `git fetch` + `git merge --ff-only origin/main` (refuse un historique divergé), `npm ci`, `npm run build`, `sudo systemctl restart expria-backend.service` (unique commande privilégiée), health-check `GET http://127.0.0.1:4000/`. **Rollback automatique** (`git reset --hard` vers le commit précédent + rebuild + restart) si le build ou le health-check échoue.
|
||||
- `deploy/expria-deploy.service` — unité systemd du listener, **distincte** de `expria-backend.service`, sous l'utilisateur non-root `deploy`, secret injecté via `EnvironmentFile=/etc/expria/webhook.env`.
|
||||
|
||||
### Notes
|
||||
|
||||
- Exposition : sous-domaine dédié `deploy.expria.app` proxifié par Caddy vers `127.0.0.1:9000`, avec **allowlist IP** sur l'egress Forgejo Moscou (`157.22.190.91`) + signature HMAC = double garde.
|
||||
- Sécurité : le listener tourne sous `deploy` (non-root) avec une règle sudoers restreinte à la seule ligne `systemctl restart expria-backend.service`. Secret dédié (≠ token git), hors dépôt, jamais loggé.
|
||||
- Résout **TD-04** (déploiement manuel). Étapes ops VPS (création user `deploy`, sudoers, DNS `deploy.expria.app`, bloc Caddy, secret, `systemctl enable --now expria-deploy`, config webhook Forgejo) documentées et exécutées hors code.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] — 2026-06-30 — Proxy SOCKS5 (Cloudflare WARP) pour les WS Gemini Live
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -44,13 +44,22 @@
|
|||
|
||||
## 2. Décisions pragmatiques — à revisiter
|
||||
|
||||
### TD-04 — Déploiement manuel (frontend + backend)
|
||||
### TD-04 — Déploiement manuel (backend)
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Ouvert — accepté jusqu'aux premiers revenus
|
||||
**Description :** Cloudflare Pages et Render ne supportent pas l'auto-deploy depuis Codeberg. Le déploiement est manuel (CLI + dashboard).
|
||||
**À faire :** Migrer vers VPS Hetzner + Coolify pour restaurer l'auto-deploy. Voir ARCHITECTURE.md §9 Phase 2.
|
||||
**Condition de résolution :** Quand Expria génère ses premiers revenus réguliers.
|
||||
**Statut :** Résolu — Auto-deploy webhook Forgejo → VPS Paris (2026-07-01)
|
||||
**Description :** Le déploiement backend était manuel (git pull + build + restart à la main sur le VPS). Résolu par un **webhook auto-deploy custom** : un push sur `main` du dépôt Forgejo (`git.expria.app`) déclenche un webhook vers `deploy.expria.app`, reçu par un listener **Node stdlib** (`deploy/webhook-listener.mjs`) qui vérifie la signature HMAC-SHA256 + l'IP source, puis exécute `deploy/deploy.sh` (fast-forward → `npm ci` → `npm run build` → `systemctl restart expria-backend`, avec rollback automatique sur échec). Le listener tourne sous l'utilisateur non-root `deploy` via `expria-deploy.service`. Solution retenue plutôt que la cible historique « VPS Hetzner + Coolify » (jamais implémentée) : le backend est déjà sur VPS Paris (HostVDS) avec systemd + Caddy, un webhook léger suffit.
|
||||
**Note frontend :** le déploiement frontend (Cloudflare Pages CLI) reste manuel — hors périmètre de cette résolution.
|
||||
|
||||
---
|
||||
|
||||
### TD-26 — `expria-backend.service` tourne en root
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Ouvert — introduit/constaté au Sprint auto-deploy (2026-07-01)
|
||||
**Description :** L'unité systemd `expria-backend.service` sur le VPS Paris définit `User=root`. Le process API (Hono, WebSocket) s'exécute donc avec les pleins privilèges, ce qui n'est pas nécessaire et augmente la surface d'impact en cas de compromission du runtime.
|
||||
**À faire :** Migrer le service vers un utilisateur dédié non-root (ex. `expria`), ajuster les permissions du checkout `/opt/expria/expria-backend`, du port (< 1024 non requis, le service écoute sur 4000) et du `EnvironmentFile`. Le listener auto-deploy tourne déjà sous `deploy` (non-root) — le backend applicatif doit suivre.
|
||||
**Hors scope :** ce durcissement est indépendant de la mise en place du webhook et sera planifié ultérieurement.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -324,7 +333,7 @@ live** apparaîtra (ex. T3 Live).
|
|||
## 5. Historique des résolutions
|
||||
|
||||
| ID | Description | Résolu le | Comment |
|
||||
| ----- | ------------------------------------------- | ---------- | --------------------------- |
|
||||
| ----- | ------------------------------------------- | ---------- | ----------------------------- |
|
||||
| TD-02 | planController.ts complété | 2026-04-16 | Session Stripe |
|
||||
| TD-03 | stripe.ts complété | 2026-04-16 | Session Stripe |
|
||||
| TD-14 | Erreurs TS2835 + TS18046 + TS7053 corrigées | 2026-04-17 | Session build Render |
|
||||
|
|
@ -334,3 +343,4 @@ live** apparaîtra (ex. T3 Live).
|
|||
| TD-17 | Limite audio in-memory caduque | 2026-04-25 | Sprint 4b |
|
||||
| TD-18 | RLS Storage caduque | 2026-04-25 | Sprint 4b |
|
||||
| TD-13 | Webhook Stripe idempotent | 2026-04-26 | Sprint 5a |
|
||||
| TD-04 | Auto-deploy webhook Forgejo → VPS Paris | 2026-07-01 | Node stdlib + systemd + Caddy |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue