feat(deploy): Webhook, Build-Skript, nginx, Traefik

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 08:22:46 +00:00
1 parent 30f4ddc0f4
commit 906bdf1033
7 files changed
+1084

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
# Deployment aircard-ios.org
Die Seite wird auf dem Webklar-Server aus dem Gitea-Repo `knso/AirCardIOS` gebaut und von nginx ausgeliefert. Traefik stellt TLS und Weiterleitungen. Dieser Ordner enthält alles, was dafür im Repo liegt; die Dateien auf dem Server sind Kopien davon.
## Bestandteile
| Datei im Repo | Ziel auf dem Server (`/home/webklar/apps/`) | Aufgabe |
|---|---|---|
| `deploy/deploy.sh` | `aircard-ios.org/deploy.sh` (ausführbar) | klont/aktualisiert das Repo, baut `web/` und kopiert `dist/` nach `out/` |
| `deploy/webhook-server.js` | `aircard-ios.org/webhook-server.js` | nimmt den Gitea-Webhook an, startet `deploy.sh` nacheinander, pollt alle 15 Minuten |
| `deploy/nginx/aircard-ios.org.conf` | `aircard-ios.org/nginx/aircard-ios.org.conf` | nginx-Konfiguration der Seite |
| `deploy/traefik/aircard.yml` | `traefik/dynamic/aircard.yml` | Route `/v1` mit Header `X-Appwrite-Project: aircard` an Appwrite |
| `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build` und `aircard` |
Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repos, Arbeitsordner des Build-Containers), `out/` (gebaute Seite, von nginx ausgeliefert), `dev/` (Arbeitskopie für die Entwicklung, nicht gemountet).
## Einrichten (einmalig)
```sh
APP=/home/webklar/apps
SRC=$APP/aircard-ios.org/dev/deploy
mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx
cp $SRC/deploy.sh $APP/aircard-ios.org/deploy.sh && chmod +x $APP/aircard-ios.org/deploy.sh
cp $SRC/webhook-server.js $APP/aircard-ios.org/webhook-server.js
cp $SRC/nginx/aircard-ios.org.conf $APP/aircard-ios.org/nginx/aircard-ios.org.conf
# Compose: Backup, dann den Inhalt von compose-snippet.yml unter `services:` direkt nach dem eship-Block einfügen
cp $APP/docker-compose.yml $APP/docker-compose.yml.bak-2026-10-04-aircard
cd $APP && docker compose config -q; echo $? # 0; einzige erlaubte Ausgabe: Warnung "attribute `version` is obsolete"
# Traefik-Route atomar ablegen (Datei-Provider lädt sie ohne Neustart)
cp $SRC/traefik/aircard.yml $APP/traefik/dynamic/aircard.yml.tmp && mv $APP/traefik/dynamic/aircard.yml.tmp $APP/traefik/dynamic/aircard.yml
```
Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten. Beide Werte gehören nie in Logs, Chats oder Commits.
## Starten
```sh
cd /home/webklar/apps && docker compose up -d aircard-build aircard
```
Nur diese beiden Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`).
Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen.
## Manuell deployen
```sh
docker exec webklar-aircard-build sh /deploy.sh
```
Ohne `DEPLOY_TRIGGER` baut das Skript immer neu, auch wenn sich der Commit nicht geändert hat.
## Gitea-Webhook
Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
| Feld | Wert |
|---|---|
| Ziel-URL | `https://aircard-ios.org/webhook/deploy` |
| HTTP-Methode | POST |
| Content-Type | `application/json` |
| Secret | Wert von `GITEA_DEPLOY_WEBHOOK_SECRET` aus `/home/webklar/apps/.env` (volle Länge, nur dort nachsehen) |
| Auslöser | nur Push-Ereignisse |
| Branch-Filter | `main` |
| Aktiv | ja |
„Test delivery“ muss `202` ergeben. `401` heißt: Secret stimmt nicht. `200` mit `{"ignored":true}` heißt: Push auf einen anderen Branch als `main`.
## Verhalten
- **Webhook** (`POST /webhook/deploy`): prüft die Signatur im Header `x-gitea-signature` (HMAC-SHA256 über den Rohbody). `401` bei falscher oder fehlender Signatur, `413` bei Body über 1 MB, `200` bei Push auf einen anderen Branch, `202` bei Push auf `main`; der Build läuft danach im Hintergrund. Alles andere antwortet `404`.
- **Warteschlange**: Builds laufen nacheinander, höchstens einer läuft und einer wartet. Weitere Pushes während ein Lauf wartet, werden mit `202` beantwortet und fügen keinen Lauf hinzu (der wartende Lauf holt ohnehin den neuesten Stand).
- **Trigger**: `webhook` und manuelle Aufrufe bauen immer. `initial` (Container-Start) und `poll` (alle 15 Minuten) bauen nur, wenn es einen neuen Commit gibt oder `out/index.html` fehlt.
- **Gemerkter Commit**: `build/.git/aircard-last-deployed-commit` enthält den zuletzt **versuchten** Commit, geschrieben vor dem Build. Ein kaputter Build wird deshalb nicht alle 15 Minuten wiederholt; die alte Seite bleibt online, bis ein neuer Commit oder ein Webhook-/manueller Lauf kommt.
- **Fehler**: Ein fehlgeschlagener Lauf wird mit `[webhook] … deployment failed` protokolliert und beendet den Server nicht. Fehlerzeilen des Skripts beginnen mit `ERROR:`.
- **Build-Variablen**: `VITE_APPWRITE_ENDPOINT` und `VITE_APPWRITE_PROJECT_ID` kommen aus der Container-Umgebung (compose); Vite liest sie direkt.
## Änderungen später
- `deploy.sh` ändern: Datei auf dem Server mit `cp` überschreiben (nicht `mv`, sonst zeigt der Datei-Mount auf die alte Datei). Gilt beim nächsten Lauf.
- `webhook-server.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-build`.
- nginx-Konfiguration ändern: mit `cp` ersetzen, dann `docker exec webklar-aircard nginx -t && docker exec webklar-aircard nginx -s reload`.
- Traefik-Route ändern: Datei atomar ersetzen wie oben; Löschen der Datei entfernt die Route wieder.
## www.aircard-ios.org
Solange `dig +short www.aircard-ios.org @8.8.8.8` nichts liefert, gibt es nur die Hauptdomain. Zeigt der A-Record auf `91.99.156.85`, werden die auskommentierten www-Labels am Dienst `aircard` in `docker-compose.yml` aktiviert (Backup vorher, dann `docker compose config -q` und `docker compose up -d aircard`). Sie leiten per 301 auf `https://aircard-ios.org` weiter und haben eigene Router, damit ein www-Fehler das Zertifikat der Hauptdomain nicht blockiert.
## Tests
```sh
node --test deploy/webhook-server.test.js
```
Die Tests brauchen nur Node und starten die Server auf Port 0.
+81
View File
@@ -0,0 +1,81 @@
# ==============================================
# AIRCARD-IOS.ORG - Automatisches Deployment aus Gitea
# URL: aircard-ios.org
# Repository: https://git.webklar.com/knso/AirCardIOS
# Einfügen unter `services:` direkt nach dem eship-Block (siehe deploy/README.md).
# Starten NUR mit: docker compose up -d aircard-build aircard
# (ein nacktes `up -d` würde andere Dienste wieder anlegen)
# Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml.
# ==============================================
aircard-build:
image: node:22-alpine
container_name: webklar-aircard-build
restart: unless-stopped
init: true
mem_limit: 768m
working_dir: /app
environment:
- GITEA_URL=https://git.webklar.com
- GITEA_USER=knso
- GITEA_REPO=AirCardIOS
- GITEA_TOKEN=${GITEA_DEPLOY_TOKEN}
- APP_SUBDIR=web
- DEPLOY_BRANCH=main
- AIRCARD_DEPLOY_BRANCH=main
- AIRCARD_WEBHOOK_TOKEN=${GITEA_DEPLOY_WEBHOOK_SECRET}
- AIRCARD_WEBHOOK_PORT=8087
- AIRCARD_DEPLOY_SCRIPT=/deploy.sh
- POLL_INTERVAL_MINUTES=15
- NODE_OPTIONS=--max-old-space-size=512
- VITE_APPWRITE_ENDPOINT=https://aircard-ios.org/v1
- VITE_APPWRITE_PROJECT_ID=aircard
volumes:
- ./aircard-ios.org/build:/app
- ./aircard-ios.org/out:/out
- ./aircard-ios.org/deploy.sh:/deploy.sh:ro
- ./aircard-ios.org/webhook-server.js:/webhook/server.js:ro
networks:
- webklar-network
command: /bin/sh -c "apk add --no-cache git && node /webhook/server.js"
labels:
- "traefik.enable=true"
- "traefik.http.routers.aircard-webhook.rule=Host(`aircard-ios.org`) && Path(`/webhook/deploy`)"
- "traefik.http.routers.aircard-webhook.entrypoints=websecure"
- "traefik.http.routers.aircard-webhook.tls.certresolver=letsencrypt"
- "traefik.http.routers.aircard-webhook.priority=200"
- "traefik.http.services.aircard-webhook.loadbalancer.server.port=8087"
aircard:
image: nginx:alpine
container_name: webklar-aircard
restart: unless-stopped
mem_limit: 64m
volumes:
- ./aircard-ios.org/out:/usr/share/nginx/html:ro
- ./aircard-ios.org/nginx/aircard-ios.org.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- webklar-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.aircard.rule=Host(`aircard-ios.org`)"
- "traefik.http.routers.aircard.entrypoints=websecure"
- "traefik.http.routers.aircard.tls.certresolver=letsencrypt"
- "traefik.http.services.aircard.loadbalancer.server.port=80"
- "traefik.http.middlewares.aircard-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.aircard-https.redirectscheme.permanent=true"
- "traefik.http.routers.aircard-http.rule=Host(`aircard-ios.org`)"
- "traefik.http.routers.aircard-http.entrypoints=web"
- "traefik.http.routers.aircard-http.middlewares=aircard-https"
# --- www (Step 10): erst einkommentieren, wenn www.aircard-ios.org per DNS auf 91.99.156.85 zeigt.
# Eigene Router, damit ein www-Fehler (Zertifikat) die Hauptdomain nicht blockiert.
# `$$` ist das Escape für ein einzelnes `$` in docker compose; `\\.` ergibt im YAML ein `\.`.
# - "traefik.http.routers.aircard-www.rule=Host(`www.aircard-ios.org`)"
# - "traefik.http.routers.aircard-www.entrypoints=websecure"
# - "traefik.http.routers.aircard-www.tls.certresolver=letsencrypt"
# - "traefik.http.routers.aircard-www.middlewares=aircard-www"
# - "traefik.http.routers.aircard-www-http.rule=Host(`www.aircard-ios.org`)"
# - "traefik.http.routers.aircard-www-http.entrypoints=web"
# - "traefik.http.routers.aircard-www-http.middlewares=aircard-www"
# - "traefik.http.middlewares.aircard-www.redirectregex.regex=^https?://www\\.aircard-ios\\.org(.*)"
# - "traefik.http.middlewares.aircard-www.redirectregex.replacement=https://aircard-ios.org$${1}"
# - "traefik.http.middlewares.aircard-www.redirectregex.permanent=true"
+133
View File
@@ -0,0 +1,133 @@
#!/bin/sh
# AirCard-Deployment (läuft im Container webklar-aircard-build, node:22-alpine, POSIX sh).
#
# Ablauf: Repo klonen bzw. aktualisieren -> (bei poll/initial) überspringen, wenn dieser Commit
# schon versucht wurde -> npm ci -> Build -> web/dist nach $OUT_DIR kopieren.
#
# Aufruf: durch webhook-server.js (DEPLOY_TRIGGER=initial|poll|webhook) oder von Hand:
# docker exec webklar-aircard-build sh /deploy.sh
#
# Variablen (Standardwerte in Klammern):
# REPO_DIR (/app) OUT_DIR (/out) GITEA_URL (https://git.webklar.com) GITEA_USER (knso)
# GITEA_REPO (AirCardIOS) GITEA_TOKEN (leer = anonym) APP_SUBDIR (web) DEPLOY_BRANCH (main)
# DEPLOY_TRIGGER (leer = manual)
#
# Fehlerzeilen beginnen mit "ERROR:"; der letzte Satz eines erfolgreichen Builds ist
# "Deployment abgeschlossen".
set -e
# Jeder Abbruch soll eine ERROR-Zeile hinterlassen, auch wenn set -e ihn auslöst.
ERROR_PRINTED=""
fail() {
echo "ERROR: $*"
ERROR_PRINTED=1
exit 1
}
trap 'status=$?; if [ "${status}" -ne 0 ] && [ -z "${ERROR_PRINTED}" ]; then echo "ERROR: Deployment unerwartet abgebrochen (Exit-Code ${status})"; fi' EXIT
REPO_DIR="${REPO_DIR:-/app}"
OUT_DIR="${OUT_DIR:-/out}"
GITEA_URL="${GITEA_URL:-https://git.webklar.com}"
GITEA_URL="${GITEA_URL%/}"
GITEA_USER="${GITEA_USER:-knso}"
GITEA_REPO="${GITEA_REPO:-AirCardIOS}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
APP_SUBDIR="${APP_SUBDIR:-web}"
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
# Clone-URL: mit Token nur, wenn GITEA_TOKEN gesetzt ist. Die URL mit Token wird nie ausgegeben.
CLEAN_URL="${GITEA_URL}/${GITEA_USER}/${GITEA_REPO}.git"
if [ -n "${GITEA_TOKEN}" ]; then
case "${GITEA_URL}" in
https://*) REPO_URL="https://${GITEA_TOKEN}@${GITEA_URL#https://}/${GITEA_USER}/${GITEA_REPO}.git" ;;
http://*) REPO_URL="http://${GITEA_TOKEN}@${GITEA_URL#http://}/${GITEA_USER}/${GITEA_REPO}.git" ;;
*) fail "GITEA_URL muss mit https:// oder http:// beginnen" ;;
esac
else
REPO_URL="${CLEAN_URL}"
fi
# git-Aufruf, dessen Ausgabe nie eine URL mit Zugangsdaten verrät (git zeigt sie in manchen Fehlermeldungen).
run_git() {
_git_log="$(mktemp)"
if "$@" >"${_git_log}" 2>&1; then
_git_rc=0
else
_git_rc=$?
fi
sed 's#//[^/@ ]*@#//***@#g' "${_git_log}"
rm -f "${_git_log}"
return "${_git_rc}"
}
echo "=========================================="
echo "AirCard Deployment (Trigger: ${DEPLOY_TRIGGER:-manual})"
echo "=========================================="
echo "Repository: ${GITEA_URL}/${GITEA_USER}/${GITEA_REPO} (Branch ${DEPLOY_BRANCH})"
echo ""
mkdir -p "${REPO_DIR}" "${OUT_DIR}" || fail "Verzeichnisse ${REPO_DIR} und ${OUT_DIR} nicht anlegbar"
if [ ! -d "${REPO_DIR}/.git" ]; then
echo "Klone Repository..."
# Reste eines abgebrochenen Klons entfernen (inkl. versteckter Dateien); das Verzeichnis selbst ist ein Mount.
rm -rf "${REPO_DIR}"/* "${REPO_DIR}"/.[!.]* "${REPO_DIR}"/..?*
run_git git clone --quiet --branch "${DEPLOY_BRANCH}" "${REPO_URL}" "${REPO_DIR}" ||
fail "Repository konnte nicht geklont werden"
fi
cd "${REPO_DIR}" || fail "Verzeichnis ${REPO_DIR} nicht erreichbar"
echo "Aktualisiere Repository..."
# Remote-URL bei jedem Lauf neu setzen, damit ein getauschtes Token greift.
git remote set-url origin "${REPO_URL}" || fail "git remote set-url fehlgeschlagen"
run_git git fetch --quiet origin "+refs/heads/${DEPLOY_BRANCH}:refs/remotes/origin/${DEPLOY_BRANCH}" ||
fail "git fetch für Branch ${DEPLOY_BRANCH} fehlgeschlagen"
# Token nur für clone/fetch: danach aus der Remote-URL entfernen, damit er nicht in .git/config liegen
# bleibt (dort könnten ihn die Build-Skripte lesen).
git remote set-url origin "${CLEAN_URL}" || fail "git remote set-url fehlgeschlagen"
git reset --hard "origin/${DEPLOY_BRANCH}" >/dev/null || fail "git reset auf origin/${DEPLOY_BRANCH} fehlgeschlagen"
git clean -fd >/dev/null || fail "git clean fehlgeschlagen"
CURRENT_COMMIT="$(git rev-parse HEAD)" || fail "git rev-parse HEAD fehlgeschlagen"
echo "Commit: ${CURRENT_COMMIT}"
# Beim Neustart (initial) und beim Polling nur bauen, wenn es einen neuen Commit gibt.
# Webhook-, manuelle und Aufrufe ohne Trigger bauen immer.
MARKER="${REPO_DIR}/.git/aircard-last-deployed-commit"
case "${DEPLOY_TRIGGER:-manual}" in
poll | initial)
if [ -f "${MARKER}" ] && [ "$(cat "${MARKER}")" = "${CURRENT_COMMIT}" ] && [ -f "${OUT_DIR}/index.html" ]; then
echo "Commit ${CURRENT_COMMIT} unverändert, Build übersprungen."
exit 0
fi
;;
esac
# Gemerkt wird der zuletzt VERSUCHTE Commit (vor dem Build): ein kaputter Build wird nicht alle
# 15 Minuten wiederholt, und die alte Seite bleibt online.
echo "${CURRENT_COMMIT}" >"${MARKER}" || fail "Marker ${MARKER} nicht schreibbar"
[ -d "${REPO_DIR}/${APP_SUBDIR}" ] || fail "Ordner ${APP_SUBDIR} fehlt im Repository"
cd "${REPO_DIR}/${APP_SUBDIR}"
# Zugangsdaten nicht an npm und die Build-Skripte weiterreichen.
unset GITEA_TOKEN REPO_URL
echo ""
echo "Installiere Abhängigkeiten..."
npm ci --no-audit --no-fund || fail "npm ci fehlgeschlagen"
echo ""
echo "Baue die Seite..."
# VITE_*-Variablen kommen direkt aus der Container-Umgebung (compose).
rm -rf dist
npm run build || fail "Build fehlgeschlagen"
[ -f dist/index.html ] || fail "dist/index.html fehlt nach dem Build"
echo ""
echo "Kopiere nach ${OUT_DIR}..."
rm -rf "${OUT_DIR}"/* "${OUT_DIR}"/.[!.]* "${OUT_DIR}"/..?*
cp -r dist/. "${OUT_DIR}/" || fail "Kopieren nach ${OUT_DIR} fehlgeschlagen"
echo "Deployment abgeschlossen"
+66
View File
@@ -0,0 +1,66 @@
# nginx für aircard-ios.org (Container webklar-aircard, nginx:alpine; wird nach
# /etc/nginx/conf.d/default.conf gemountet). Ausgeliefert wird die statische Seite aus
# /usr/share/nginx/html (= out/ des Build-Containers). TLS macht Traefik davor.
#
# Wichtig: In den location-Blöcken kein add_header verwenden - sonst entfallen die
# Sicherheits-Header vom server-Block. "expires" ist davon nicht betroffen.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Sicherheits-Header (alle Antworten, auch Fehler)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/manifest+json
application/xml
image/svg+xml;
# Gehashte Build-Dateien: ein Jahr, nie auf index.html zurückfallen.
location ^~ /assets/ {
try_files $uri =404;
expires 1y;
access_log off;
}
# Die Appwrite-API liegt nur über Traefik (Header X-Appwrite-Project) auf /v1.
# Ohne diesen Header darf nginx hier nie die App (index.html) ausliefern.
location ^~ /v1/ {
return 404;
}
location = /index.html {
expires -1;
}
# Versteckte Dateien sperren (außer /.well-known). Steht vor den Regex-Blöcken
# darunter, damit z. B. /.git/x.png nicht als Bild durchgeht.
location ~ /\.(?!well-known) {
deny all;
}
location ~* \.(?:png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$ {
expires 7d;
}
# Single-Page-App: alle anderen Pfade (/karten, /editor ...) liefern index.html.
location / {
try_files $uri $uri/ /index.html;
expires -1;
}
}
+22
View File
@@ -0,0 +1,22 @@
# Appwrite-Route für aircard-ios.org (Projekt "aircard" auf der bestehenden Appwrite-Instanz).
#
# Das Frontend ruft https://aircard-ios.org/v1/... auf und sendet den Header
# X-Appwrite-Project: aircard. Nur Anfragen MIT diesem Header gehen an Appwrite; alles andere
# unter /v1 (z. B. ein <img src> oder ein Aufruf ohne Header) landet beim nginx der Seite und
# bekommt dort 404.
#
# Priorität 150 liegt über dem Seiten-Router (Host-Regel, Label-Router `aircard`) und unter
# dem Webhook-Router (200). Der Service `appwrite@docker` stammt aus dem Appwrite-Container;
# an diesem Container wird nichts geändert.
#
# Wird per Datei-Provider geladen (Hot-Reload). Additiv + reversibel: Datei löschen entfernt die Route.
http:
routers:
aircard-api:
rule: "Host(`aircard-ios.org`) && PathPrefix(`/v1`) && Header(`X-Appwrite-Project`, `aircard`)"
entryPoints:
- websecure
service: appwrite@docker
priority: 150
tls:
certResolver: letsencrypt
+233
View File
@@ -0,0 +1,233 @@
'use strict';
/**
* Deploy-Webhook für aircard-ios.org (Node 22, nur Bordmittel).
*
* POST /webhook/deploy Gitea-Push-Webhook; Signatur im Header x-gitea-signature
* (HMAC-SHA256 über den Rohbody, Hex ohne Präfix).
* 401 Signatur fehlt oder stimmt nicht
* 413 Body größer als 1 MB
* 200 {"ignored":true} Push auf einen anderen Branch (oder ohne Branch)
* 202 {"queued":true} Push auf den Deploy-Branch; der Build läuft im Hintergrund
* alles andere 404
*
* Builds laufen nacheinander: höchstens ein laufender und ein wartender Lauf. Weitere
* Anfragen während ein Lauf wartet, werden mit 202 beantwortet, fügen aber keinen
* Lauf hinzu (der wartende Lauf holt ohnehin den neuesten Stand).
*
* Umgebung (nur beim Start als Programm): AIRCARD_WEBHOOK_TOKEN (Pflicht, sonst Exit 1),
* AIRCARD_WEBHOOK_PORT (8087), AIRCARD_DEPLOY_BRANCH (main), AIRCARD_DEPLOY_SCRIPT
* (/deploy.sh), POLL_INTERVAL_MINUTES (15; 0 schaltet das Polling ab).
*/
const http = require('node:http');
const crypto = require('node:crypto');
const { spawn } = require('node:child_process');
const MAX_BODY_BYTES = 1024 * 1024;
const WEBHOOK_PATH = '/webhook/deploy';
const BRANCH_PREFIX = 'refs/heads/';
/**
* Prüft die Gitea-Signatur: kleingeschriebenes Hex von HMAC-SHA256 über den Rohbody.
* false bei leerem Secret, fehlender oder unpassend langer Signatur.
*/
function verifySignature(raw, signatureHex, secret) {
if (typeof secret !== 'string' || secret === '') return false;
if (typeof signatureHex !== 'string' || signatureHex === '') return false;
if (!Buffer.isBuffer(raw)) return false;
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(raw).digest('hex'));
const given = Buffer.from(signatureHex);
// timingSafeEqual wirft bei ungleicher Länge, deshalb vorher prüfen.
if (given.length !== expected.length) return false;
return crypto.timingSafeEqual(given, expected);
}
/** `refs/heads/<x>` -> `<x>`, alles andere -> null. */
function branchFromPayload(body) {
if (body === null || typeof body !== 'object') return null;
const ref = body.ref;
if (typeof ref !== 'string' || !ref.startsWith(BRANCH_PREFIX)) return null;
const branch = ref.slice(BRANCH_PREFIX.length);
return branch === '' ? null : branch;
}
/**
* Baut den HTTP-Server (ohne ihn zu starten).
*
* @param {object} options
* @param {string} options.secret Webhook-Secret; leer -> jede Anfrage bekommt 401
* @param {string} [options.branch] Deploy-Branch (Standard: main)
* @param {(trigger: string) => Promise<void>} options.runDeploy führt einen Lauf aus
* @param {(line: string) => void} [options.log] bekommt fertige Zeilen, die mit [webhook] beginnen
* @returns {http.Server} mit zusätzlicher Methode `enqueueDeploy(trigger)` für initial/poll-Läufe
* über dieselbe Warteschlange
*/
function createServer({ secret, branch = 'main', runDeploy, log = console.log }) {
const say = (message) => log(`[webhook] ${new Date().toISOString()} ${message}`);
// Warteschlange: ein laufender Lauf + ein Platz für einen wartenden Lauf.
let running = false;
let pending = null; // Trigger des wartenden Laufs oder null
async function drain() {
if (running) return;
running = true;
try {
while (pending !== null) {
const trigger = pending;
pending = null;
say(`starting deployment (trigger=${trigger})`);
try {
await runDeploy(trigger);
say(`deployment finished (trigger=${trigger})`);
} catch (err) {
say(`deployment failed (trigger=${trigger}): ${err && err.message ? err.message : err}`);
}
}
} finally {
running = false;
}
}
/** @returns {boolean} true, wenn ein neuer Lauf vorgemerkt wurde; false, wenn schon einer wartet */
function enqueueDeploy(trigger) {
if (pending !== null) {
// Der Webhook baut immer, ein Poll darf überspringen: der wartende Lauf behält den stärkeren Trigger.
if (trigger === 'webhook') pending = 'webhook';
return false;
}
pending = trigger;
drain();
return true;
}
function sendJson(res, status, payload) {
const body = JSON.stringify(payload);
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
res.end(body);
}
function handleWebhook(req, res) {
const chunks = [];
let size = 0;
let tooLarge = false;
req.on('data', (chunk) => {
if (tooLarge) return; // Rest verwerfen, die Verbindung bleibt sauber
size += chunk.length;
if (size > MAX_BODY_BYTES) {
tooLarge = true;
chunks.length = 0;
say('rejected request: body too large');
sendJson(res, 413, { error: 'payload too large' });
return;
}
chunks.push(chunk);
});
req.on('error', (err) => say(`request error: ${err.message}`));
req.on('end', () => {
if (tooLarge) return;
const raw = Buffer.concat(chunks);
if (!verifySignature(raw, req.headers['x-gitea-signature'], secret)) {
say('rejected request: invalid signature');
sendJson(res, 401, { error: 'invalid signature' });
return;
}
let pushedBranch = null;
try {
pushedBranch = branchFromPayload(JSON.parse(raw.toString('utf8')));
} catch {
// kein JSON: wie ein Push ohne Branch behandeln
}
if (pushedBranch !== branch) {
say(`ignoring push to ${pushedBranch === null ? 'unknown ref' : pushedBranch} (only ${branch} deploys)`);
sendJson(res, 200, { ignored: true });
return;
}
// Sofort antworten: Gitea wartet nur wenige Sekunden, der Build dauert Minuten.
const queued = enqueueDeploy('webhook');
say(queued ? `push to ${branch} queued` : `push to ${branch} accepted, a deployment is already waiting`);
sendJson(res, 202, { queued: true });
});
}
const server = http.createServer((req, res) => {
const pathname = (req.url || '').split('?')[0];
if (req.method === 'POST' && pathname === WEBHOOK_PATH) {
handleWebhook(req, res);
return;
}
sendJson(res, 404, { error: 'not found' });
});
server.enqueueDeploy = enqueueDeploy;
return server;
}
// ---- Start als Programm -----------------------------------------------------
/**
* Startet `sh <script>` mit DEPLOY_TRIGGER; erfolgreich bei Exit-Code 0, sonst Ablehnung.
* Das Webhook-Secret bekommt das Skript nicht (npm und die Build-Skripte laufen darunter).
*/
function spawnDeploy(script, trigger) {
return new Promise((resolve, reject) => {
const env = { ...process.env, DEPLOY_TRIGGER: trigger };
delete env.AIRCARD_WEBHOOK_TOKEN;
const child = spawn('sh', [script], { stdio: 'inherit', env });
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else reject(new Error(`deploy exited with ${code === null ? `signal ${signal}` : `code ${code}`}`));
});
});
}
function integerFromEnv(name, fallback, { min }) {
const raw = process.env[name];
if (raw === undefined || raw.trim() === '') return fallback;
const value = Number.parseInt(raw, 10);
return Number.isInteger(value) && value >= min ? value : fallback;
}
function main() {
const secret = process.env.AIRCARD_WEBHOOK_TOKEN || '';
if (secret.trim() === '') {
console.error('[webhook] AIRCARD_WEBHOOK_TOKEN ist nicht gesetzt - Abbruch (ohne Secret wird kein Webhook angeboten)');
process.exit(1);
}
const port = integerFromEnv('AIRCARD_WEBHOOK_PORT', 8087, { min: 1 });
const branch = process.env.AIRCARD_DEPLOY_BRANCH || 'main';
const script = process.env.AIRCARD_DEPLOY_SCRIPT || '/deploy.sh';
const pollMinutes = integerFromEnv('POLL_INTERVAL_MINUTES', 15, { min: 0 });
const server = createServer({
secret,
branch,
runDeploy: (trigger) => spawnDeploy(script, trigger),
});
server.listen(port, () => {
console.log(`[webhook] ${new Date().toISOString()} listening on :${port} (branch ${branch})`);
server.enqueueDeploy('initial');
if (pollMinutes > 0) {
console.log(`[webhook] ${new Date().toISOString()} periodic check enabled: every ${pollMinutes} minutes`);
setInterval(() => server.enqueueDeploy('poll'), pollMinutes * 60 * 1000);
}
});
}
module.exports = { verifySignature, branchFromPayload, createServer };
if (require.main === module) {
main();
}
+451
View File
@@ -0,0 +1,451 @@
'use strict';
// Tests für deploy/webhook-server.js. Aufruf: node --test deploy/webhook-server.test.js
// Nur Node-Bordmittel; die Server laufen auf Port 0 und werden nach jedem Test geschlossen.
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const http = require('node:http');
const net = require('node:net');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { verifySignature, branchFromPayload, createServer } = require('./webhook-server.js');
const SECRET = 'test-secret-0123456789abcdef-test-secret-0123456789abcdef';
function sign(raw, secret = SECRET) {
return crypto.createHmac('sha256', secret).update(raw).digest('hex');
}
function pushBody(ref) {
return Buffer.from(JSON.stringify({ ref, after: '0123456789abcdef0123456789abcdef01234567' }));
}
async function waitFor(condition, what, timeoutMs = 3000) {
const deadline = Date.now() + timeoutMs;
while (!condition()) {
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`);
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ---- Aufräumen -------------------------------------------------------------
const servers = [];
const children = [];
const tmpDirs = [];
afterEach(async () => {
for (const child of children.splice(0)) {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
await new Promise((resolve) => child.once('exit', resolve));
}
}
for (const server of servers.splice(0)) {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
}
for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// ---- Hilfen für Server-Tests -----------------------------------------------
/** Startet createServer() auf Port 0. runDeploy-Aufrufe werden in `calls` gesammelt. */
async function startServer(overrides = {}) {
const calls = [];
const logs = [];
const options = {
secret: SECRET,
branch: 'main',
runDeploy: async (trigger) => {
calls.push(trigger);
},
log: (line) => logs.push(line),
...overrides,
};
const server = createServer(options);
servers.push(server);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
return { server, port: server.address().port, calls, logs };
}
/** Ein runDeploy, das so lange blockiert, bis der Test den Lauf freigibt. */
function blockedDeploy() {
const calls = [];
const releases = [];
const runDeploy = (trigger) => {
calls.push(trigger);
return new Promise((resolve) => releases.push(resolve));
};
return { calls, releases, runDeploy };
}
function request(port, { method = 'POST', path: urlPath = '/webhook/deploy', headers = {}, body } = {}) {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, method, path: urlPath, headers }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let json = null;
try {
json = JSON.parse(text);
} catch {
// kein JSON
}
resolve({ status: res.statusCode, headers: res.headers, text, json });
});
});
req.on('error', reject);
if (body) req.write(body);
req.end();
});
}
function signedPush(port, ref, secret = SECRET) {
const body = pushBody(ref);
return request(port, {
headers: { 'content-type': 'application/json', 'x-gitea-signature': sign(body, secret) },
body,
});
}
// ---- verifySignature --------------------------------------------------------
test('verifySignature: gültige Signatur wird akzeptiert', () => {
const raw = Buffer.from('{"ref":"refs/heads/main"}');
assert.equal(verifySignature(raw, sign(raw), SECRET), true);
});
test('verifySignature: falscher Rohbody oder falsches Secret wird abgelehnt', () => {
const raw = Buffer.from('{"ref":"refs/heads/main"}');
assert.equal(verifySignature(Buffer.from('{"ref":"refs/heads/dev"}'), sign(raw), SECRET), false);
assert.equal(verifySignature(raw, sign(raw, 'anderes-secret'), SECRET), false);
assert.equal(verifySignature(raw, sign(raw), 'anderes-secret'), false);
});
test('verifySignature: ungültige oder unpassend lange Signaturen liefern false ohne Exception', () => {
const raw = Buffer.from('{}');
const valid = sign(raw);
assert.equal(verifySignature(raw, 'deadbeef', SECRET), false);
assert.equal(verifySignature(raw, valid.slice(0, -1), SECRET), false);
assert.equal(verifySignature(raw, `${valid}00`, SECRET), false);
assert.equal(verifySignature(raw, 'z'.repeat(valid.length), SECRET), false);
assert.equal(verifySignature(raw, `sha256=${valid}`, SECRET), false);
});
test('verifySignature: leeres Secret ist immer false', () => {
const raw = Buffer.from('{}');
assert.equal(verifySignature(raw, sign(raw, ''), ''), false);
assert.equal(verifySignature(raw, sign(raw), ''), false);
assert.equal(verifySignature(raw, sign(raw), undefined), false);
});
test('verifySignature: fehlender Header ist false', () => {
const raw = Buffer.from('{}');
assert.equal(verifySignature(raw, undefined, SECRET), false);
assert.equal(verifySignature(raw, '', SECRET), false);
});
// ---- branchFromPayload ------------------------------------------------------
test('branchFromPayload: refs/heads/<x> ergibt <x>', () => {
assert.equal(branchFromPayload({ ref: 'refs/heads/main' }), 'main');
assert.equal(branchFromPayload({ ref: 'refs/heads/feature/neu' }), 'feature/neu');
});
test('branchFromPayload: alles andere ergibt null', () => {
assert.equal(branchFromPayload({}), null);
assert.equal(branchFromPayload(null), null);
assert.equal(branchFromPayload('refs/heads/main'), null);
assert.equal(branchFromPayload({ ref: 42 }), null);
assert.equal(branchFromPayload({ ref: 'refs/tags/v1' }), null);
assert.equal(branchFromPayload({ ref: 'refs/heads/' }), null);
});
// ---- Server -----------------------------------------------------------------
test('Server: unsignierter POST ergibt 401 und startet keinen Lauf', async () => {
const { port, calls } = await startServer();
const res = await request(port, {
headers: { 'content-type': 'application/json' },
body: pushBody('refs/heads/main'),
});
assert.equal(res.status, 401);
await pause(20);
assert.deepEqual(calls, []);
});
test('Server: POST mit falscher Signatur ergibt 401', async () => {
const { port, calls } = await startServer();
const res = await signedPush(port, 'refs/heads/main', 'falsches-secret');
assert.equal(res.status, 401);
assert.deepEqual(calls, []);
});
test('Server: Signatur nur im Header x-gitea-signature, keine Fallbacks', async () => {
const { port, calls } = await startServer();
const body = pushBody('refs/heads/main');
const res = await request(port, {
headers: {
'x-hub-signature-256': `sha256=${sign(body)}`,
'x-hub-signature': `sha256=${sign(body)}`,
'x-gitea-token': SECRET,
},
body,
});
assert.equal(res.status, 401);
assert.deepEqual(calls, []);
});
test('Server: leeres Secret lehnt jeden Push ab', async () => {
const { port, calls } = await startServer({ secret: '' });
const body = pushBody('refs/heads/main');
const res = await request(port, { headers: { 'x-gitea-signature': sign(body, '') }, body });
assert.equal(res.status, 401);
assert.deepEqual(calls, []);
});
test('Server: signierter Push auf main ergibt 202 und runDeploy("webhook")', async () => {
const { port, calls } = await startServer();
const res = await signedPush(port, 'refs/heads/main');
assert.equal(res.status, 202);
assert.deepEqual(res.json, { queued: true });
await waitFor(() => calls.length === 1, 'runDeploy');
assert.deepEqual(calls, ['webhook']);
});
test('Server: Push auf einen anderen Branch wird mit 200 ignoriert', async () => {
const { port, calls } = await startServer();
const res = await signedPush(port, 'refs/heads/dev');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { ignored: true });
await pause(20);
assert.deepEqual(calls, []);
});
test('Server: Tag-Push und Body ohne Branch werden mit 200 ignoriert', async () => {
const { port, calls } = await startServer();
assert.deepEqual((await signedPush(port, 'refs/tags/v1')).json, { ignored: true });
const notJson = Buffer.from('das ist kein JSON');
const res = await request(port, { headers: { 'x-gitea-signature': sign(notJson) }, body: notJson });
assert.equal(res.status, 200);
assert.deepEqual(res.json, { ignored: true });
assert.deepEqual(calls, []);
});
test('Server: alles andere ergibt 404', async () => {
const { port, calls } = await startServer();
assert.equal((await request(port, { method: 'GET', path: '/' })).status, 404);
assert.equal((await request(port, { method: 'GET', path: '/webhook/deploy' })).status, 404);
assert.equal((await request(port, { method: 'POST', path: '/' })).status, 404);
assert.equal((await request(port, { method: 'POST', path: '/webhook/deploy/x' })).status, 404);
assert.equal((await request(port, { method: 'GET', path: '/healthz' })).status, 404);
assert.deepEqual(calls, []);
});
test('Server: zu großer Body ergibt 413 und startet keinen Lauf', async () => {
const { port, calls } = await startServer();
const body = Buffer.alloc(1024 * 1024 + 1, 'a');
const res = await request(port, { headers: { 'x-gitea-signature': sign(body) }, body });
assert.equal(res.status, 413);
await pause(20);
assert.deepEqual(calls, []);
});
test('Server: drei schnelle Pushes bei blockiertem runDeploy -> alle 202, danach genau 2 Läufe', async () => {
const deploy = blockedDeploy();
const { port } = await startServer({ runDeploy: deploy.runDeploy });
const first = await signedPush(port, 'refs/heads/main');
const second = await signedPush(port, 'refs/heads/main');
const third = await signedPush(port, 'refs/heads/main');
assert.deepEqual([first.status, second.status, third.status], [202, 202, 202]);
assert.deepEqual([first.json, second.json, third.json], [{ queued: true }, { queued: true }, { queued: true }]);
// Solange der erste Lauf hängt, startet kein zweiter (nacheinander, nie parallel).
await pause(50);
assert.equal(deploy.calls.length, 1);
deploy.releases[0]();
await waitFor(() => deploy.calls.length === 2, 'zweiter Lauf');
deploy.releases[1]();
await pause(50);
assert.deepEqual(deploy.calls, ['webhook', 'webhook']);
assert.equal(deploy.releases.length, 2);
});
test('Server: ein Push nach Ende aller Läufe startet wieder einen Lauf', async () => {
const deploy = blockedDeploy();
const { port } = await startServer({ runDeploy: deploy.runDeploy });
await signedPush(port, 'refs/heads/main');
deploy.releases[0]();
await pause(30);
await signedPush(port, 'refs/heads/main');
await waitFor(() => deploy.calls.length === 2, 'zweiter Lauf');
deploy.releases[1]();
await pause(30);
assert.deepEqual(deploy.calls, ['webhook', 'webhook']);
});
test('Server: ein fehlgeschlagener Lauf wird protokolliert, der Server läuft weiter', async () => {
const calls = [];
const { port, logs } = await startServer({
runDeploy: async (trigger) => {
calls.push(trigger);
throw new Error('deploy exited with code 1');
},
});
assert.equal((await signedPush(port, 'refs/heads/main')).status, 202);
await waitFor(() => logs.some((line) => line.includes('deploy exited with code 1')), 'Fehlerzeile im Log');
assert.equal((await signedPush(port, 'refs/heads/main')).status, 202);
await waitFor(() => calls.length === 2, 'zweiter Lauf nach Fehler');
});
test('Server: synchron werfendes runDeploy bringt den Server nicht zum Absturz', async () => {
const calls = [];
const { port } = await startServer({
runDeploy: () => {
calls.push('x');
throw new Error('kaputt');
},
});
assert.equal((await signedPush(port, 'refs/heads/main')).status, 202);
await waitFor(() => calls.length === 1, 'Lauf');
assert.equal((await signedPush(port, 'refs/heads/main')).status, 202);
await waitFor(() => calls.length === 2, 'zweiter Lauf');
});
test('Server: enqueueDeploy teilt die Warteschlange; ein wartender poll wird vom Webhook überholt', async () => {
const deploy = blockedDeploy();
const { server, port } = await startServer({ runDeploy: deploy.runDeploy });
server.enqueueDeploy('initial');
server.enqueueDeploy('poll'); // wartet
const res = await signedPush(port, 'refs/heads/main');
assert.equal(res.status, 202);
await pause(30);
assert.deepEqual(deploy.calls, ['initial']);
deploy.releases[0]();
await waitFor(() => deploy.calls.length === 2, 'zweiter Lauf');
deploy.releases[1]();
await pause(30);
// Der Webhook baut immer; deshalb darf der wartende Lauf kein überspringbarer "poll" bleiben.
assert.deepEqual(deploy.calls, ['initial', 'webhook']);
});
test('Server: Logzeilen beginnen mit [webhook] und enthalten weder Secret noch Signatur', async () => {
const { port, logs } = await startServer();
const body = pushBody('refs/heads/main');
const signature = sign(body);
await request(port, { headers: { 'x-gitea-signature': signature }, body });
await request(port, { headers: { 'x-gitea-signature': 'deadbeef' }, body });
await signedPush(port, 'refs/heads/dev');
await pause(30);
assert.ok(logs.length >= 3, `zu wenige Logzeilen: ${logs.length}`);
for (const line of logs) {
assert.ok(line.startsWith('[webhook]'), `Zeile beginnt nicht mit [webhook]: ${line}`);
assert.ok(!line.includes(SECRET), 'Secret im Log');
assert.ok(!line.includes(signature), 'Signatur im Log');
}
});
// ---- Start als Programm (require.main === module) ---------------------------
const SERVER_JS = path.join(__dirname, 'webhook-server.js');
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
});
}
function makeTmpDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aircard-webhook-'));
tmpDirs.push(dir);
return dir;
}
function startProgram(env) {
const child = spawn(process.execPath, [SERVER_JS], {
env: { PATH: process.env.PATH, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
children.push(child);
const output = { text: '' };
child.stdout.on('data', (chunk) => (output.text += chunk));
child.stderr.on('data', (chunk) => (output.text += chunk));
return { child, output };
}
test('Start ohne AIRCARD_WEBHOOK_TOKEN: Fehlermeldung ohne Wert, Exit-Code 1', async () => {
for (const env of [{}, { AIRCARD_WEBHOOK_TOKEN: '' }]) {
const { child, output } = startProgram({ ...env, AIRCARD_WEBHOOK_PORT: String(await freePort()) });
const code = await new Promise((resolve) => child.once('exit', resolve));
assert.equal(code, 1);
assert.match(output.text, /AIRCARD_WEBHOOK_TOKEN/);
}
});
test('Start: initial-Lauf, Webhook-Lauf mit DEPLOY_TRIGGER und weiterlaufender Server nach Fehlschlag', async () => {
const dir = makeTmpDir();
const record = path.join(dir, 'triggers.txt');
const script = path.join(dir, 'deploy.sh');
const secretSeen = path.join(dir, 'secret-seen.txt');
// Protokolliert (ob das Webhook-Secret im Env ankommt, dann den Trigger); scheitert, solange "fail" existiert.
fs.writeFileSync(
script,
`#!/bin/sh\necho "\${AIRCARD_WEBHOOK_TOKEN:-leer}" >> "${secretSeen}"\necho "$DEPLOY_TRIGGER" >> "${record}"\n` +
`if [ -f "${path.join(dir, 'fail')}" ]; then exit 3; fi\n`,
);
fs.writeFileSync(path.join(dir, 'fail'), '');
const port = await freePort();
const { child, output } = startProgram({
AIRCARD_WEBHOOK_TOKEN: SECRET,
AIRCARD_WEBHOOK_PORT: String(port),
AIRCARD_DEPLOY_BRANCH: 'main',
AIRCARD_DEPLOY_SCRIPT: script,
POLL_INTERVAL_MINUTES: '0',
});
const triggers = () => (fs.existsSync(record) ? fs.readFileSync(record, 'utf8').trim().split('\n') : []);
await waitFor(() => triggers().length === 1, 'initial-Lauf', 8000);
assert.deepEqual(triggers(), ['initial']);
await waitFor(() => /deploy exited with code 3/.test(output.text), 'Fehlerzeile', 5000);
// Der Server lebt trotz fehlgeschlagenem Lauf und nimmt signierte Pushes an.
fs.rmSync(path.join(dir, 'fail'));
const res = await signedPush(port, 'refs/heads/main');
assert.equal(res.status, 202);
await waitFor(() => triggers().length === 2, 'webhook-Lauf', 8000);
assert.deepEqual(triggers(), ['initial', 'webhook']);
assert.equal(child.exitCode, null);
// Das Deploy-Skript (und damit npm) bekommt das Webhook-Secret nicht.
assert.deepEqual(fs.readFileSync(secretSeen, 'utf8').trim().split('\n'), ['leer', 'leer']);
for (const line of output.text.split('\n').filter(Boolean)) {
assert.ok(line.startsWith('[webhook]'), `Zeile beginnt nicht mit [webhook]: ${line}`);
assert.ok(!line.includes(SECRET), 'Secret im Log');
}
});