Sécuriser Gitea et alerter sur les échecs CI
This commit is contained in:
9
ops/gitea-token-rotation-apply.path
Normal file
9
ops/gitea-token-rotation-apply.path
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Surveillance de la validation manuelle du jeton Gitea Santinova
|
||||
|
||||
[Path]
|
||||
PathExists=/opt/manus-deploy/secrets/gitea-token-rotation/approval.request
|
||||
Unit=gitea-token-rotation-apply.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
11
ops/gitea-token-rotation-apply.service
Normal file
11
ops/gitea-token-rotation-apply.service
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Application manuelle validée du jeton Gitea Santinova
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh apply
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
12
ops/gitea-token-rotation.service
Normal file
12
ops/gitea-token-rotation.service
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Préparation contrôlée de la rotation du jeton Gitea Santinova
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh prepare
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
Nice=10
|
||||
148
ops/gitea-token-rotation.sh
Normal file
148
ops/gitea-token-rotation.sh
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly COMMAND="${1:-status}"
|
||||
readonly FORCE="${2:-}"
|
||||
readonly GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
|
||||
readonly GITEA_URL="${GITEA_URL:-https://git.recette.santinova-soft.org}"
|
||||
readonly GITEA_USER="${GITEA_USERNAME:-manus-admin}"
|
||||
readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
|
||||
readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}"
|
||||
readonly STATE_DIR="${GITEA_ROTATION_STATE_DIR:-/opt/manus-deploy/secrets/gitea-token-rotation}"
|
||||
readonly STATUS_FILE="${GITEA_ROTATION_STATUS_FILE:-/var/log/manus-deploy/gitea-token-rotation.json}"
|
||||
readonly PENDING_TOKEN_FILE="${STATE_DIR}/pending.token"
|
||||
readonly PENDING_NAME_FILE="${STATE_DIR}/pending.name"
|
||||
readonly CURRENT_NAME_FILE="${STATE_DIR}/current.name"
|
||||
readonly LAST_ROTATION_FILE="${STATE_DIR}/last-rotation"
|
||||
readonly LOCK_FILE="${STATE_DIR}/rotation.lock"
|
||||
readonly APPROVAL_FILE="${STATE_DIR}/approval.request"
|
||||
readonly TOKEN_PREFIX="santinova-recette"
|
||||
readonly MAX_AGE_DAYS="90"
|
||||
|
||||
install -d -m 0700 "${STATE_DIR}"
|
||||
install -d -m 0750 "$(dirname "${STATUS_FILE}")"
|
||||
exec 9>"${LOCK_FILE}"
|
||||
flock -n 9 || { echo "Une opération de rotation Gitea est déjà en cours." >&2; exit 1; }
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/[[:cntrl:]]/ /g'
|
||||
}
|
||||
|
||||
write_status() {
|
||||
local state="$1"
|
||||
local message="$2"
|
||||
local token_name="${3:-}"
|
||||
local temporary
|
||||
temporary="$(mktemp "${STATUS_FILE}.XXXXXX")"
|
||||
chmod 0640 "${temporary}"
|
||||
printf '{"state":"%s","message":"%s","tokenName":"%s","updatedAt":"%s"}\n' \
|
||||
"$(json_escape "${state}")" \
|
||||
"$(json_escape "${message}")" \
|
||||
"$(json_escape "${token_name}")" \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "${temporary}"
|
||||
mv "${temporary}" "${STATUS_FILE}"
|
||||
}
|
||||
|
||||
read_env_token() {
|
||||
sed -n -E 's/^GITEA_TOKEN=(.*)$/\1/p' "${DASHBOARD_ENV_FILE}" | tail -n 1
|
||||
}
|
||||
|
||||
validate_token() {
|
||||
local token="$1"
|
||||
local response
|
||||
response="$(mktemp /tmp/gitea-token-validation.XXXXXX)"
|
||||
trap 'rm -f "${response}"' RETURN
|
||||
local status
|
||||
status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/user")"
|
||||
[[ "${status}" == "200" ]] || return 1
|
||||
grep -q '"login":"manus-admin"' "${response}"
|
||||
status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/manus-dashboard")"
|
||||
[[ "${status}" == "200" ]] || return 1
|
||||
status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/referentiel-ia")"
|
||||
[[ "${status}" == "200" ]]
|
||||
}
|
||||
|
||||
rotation_due() {
|
||||
[[ "${FORCE}" == "--force" ]] && return 0
|
||||
[[ -f "${LAST_ROTATION_FILE}" ]] || return 0
|
||||
local last_epoch now_epoch max_age_seconds
|
||||
last_epoch="$(date -d "$(<"${LAST_ROTATION_FILE}")" +%s 2>/dev/null || echo 0)"
|
||||
now_epoch="$(date +%s)"
|
||||
max_age_seconds="$((MAX_AGE_DAYS * 86400))"
|
||||
(( now_epoch - last_epoch >= max_age_seconds ))
|
||||
}
|
||||
|
||||
prepare_rotation() {
|
||||
if [[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]]; then
|
||||
local existing_pending
|
||||
existing_pending="$(<"${PENDING_TOKEN_FILE}")"
|
||||
validate_token "${existing_pending}"
|
||||
write_status "pending_approval" "Un jeton préparé et validé attend déjà une approbation manuelle." "$(<"${PENDING_NAME_FILE}")"
|
||||
echo "GITEA_ROTATION_PENDING"
|
||||
return 0
|
||||
fi
|
||||
if ! rotation_due; then
|
||||
write_status "current" "La rotation trimestrielle n’est pas encore requise." "$(cat "${CURRENT_NAME_FILE}" 2>/dev/null || true)"
|
||||
echo "GITEA_ROTATION_NOT_DUE"
|
||||
return 0
|
||||
fi
|
||||
local current_token token_name pending_token
|
||||
current_token="$(read_env_token)"
|
||||
[[ -n "${current_token}" ]] || { write_status "failed" "Jeton actif absent du fichier .env serveur."; exit 1; }
|
||||
validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe pas les contrôles Gitea."; exit 1; }
|
||||
token_name="${TOKEN_PREFIX}-$(date -u +%Y%m%d-%H%M%S)"
|
||||
pending_token="$(docker exec --user git "${GITEA_CONTAINER}" gitea admin user generate-access-token \
|
||||
--username "${GITEA_USER}" --token-name "${token_name}" --scopes all --raw)"
|
||||
[[ -n "${pending_token}" ]] || { write_status "failed" "Gitea n’a pas généré de nouveau jeton." "${token_name}"; exit 1; }
|
||||
printf '%s' "${pending_token}" > "${PENDING_TOKEN_FILE}"
|
||||
printf '%s' "${token_name}" > "${PENDING_NAME_FILE}"
|
||||
chmod 0600 "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
if ! validate_token "${pending_token}"; then
|
||||
rm -f "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
write_status "failed" "Le nouveau jeton a été créé mais n’a pas passé les contrôles." "${token_name}"
|
||||
exit 1
|
||||
fi
|
||||
write_status "pending_approval" "Nouveau jeton créé et validé. Approbation manuelle requise avant basculement." "${token_name}"
|
||||
echo "GITEA_ROTATION_PREPARED"
|
||||
}
|
||||
|
||||
apply_rotation() {
|
||||
trap 'rm -f "${APPROVAL_FILE}"' EXIT
|
||||
[[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]] || { write_status "failed" "Aucun jeton préparé à appliquer."; exit 1; }
|
||||
local pending_token pending_name current_token backup
|
||||
pending_token="$(<"${PENDING_TOKEN_FILE}")"
|
||||
pending_name="$(<"${PENDING_NAME_FILE}")"
|
||||
current_token="$(read_env_token)"
|
||||
validate_token "${pending_token}" || { write_status "failed" "Le jeton en attente n’est plus valide." "${pending_name}"; exit 1; }
|
||||
validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe plus les contrôles préalables." "${pending_name}"; exit 1; }
|
||||
backup="$(mktemp "${STATE_DIR}/dashboard.env.backup.XXXXXX")"
|
||||
chmod 0600 "${backup}"
|
||||
cp "${DASHBOARD_ENV_FILE}" "${backup}"
|
||||
sed -i -E "s/^GITEA_TOKEN=.*/GITEA_TOKEN=${pending_token}/" "${DASHBOARD_ENV_FILE}"
|
||||
if ! grep -q '^GITEA_TOKEN=' "${DASHBOARD_ENV_FILE}"; then
|
||||
printf '\nGITEA_TOKEN=%s\n' "${pending_token}" >> "${DASHBOARD_ENV_FILE}"
|
||||
fi
|
||||
write_status "applying" "Basculement du dashboard vers le jeton validé." "${pending_name}"
|
||||
if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null && sleep 8 && validate_token "${pending_token}"); then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
(cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true
|
||||
rm -f "${backup}"
|
||||
write_status "rollback" "Le basculement a échoué ; l’ancien jeton a été restauré." "${pending_name}"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "${backup}" "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
printf '%s' "${pending_name}" > "${CURRENT_NAME_FILE}"
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "${LAST_ROTATION_FILE}"
|
||||
chmod 0600 "${CURRENT_NAME_FILE}" "${LAST_ROTATION_FILE}"
|
||||
write_status "active_revoke_previous" "Basculement validé. Révoquer manuellement l’ancien jeton dans Gitea après le contrôle final." "${pending_name}"
|
||||
echo "GITEA_ROTATION_APPLIED"
|
||||
}
|
||||
|
||||
case "${COMMAND}" in
|
||||
prepare) prepare_rotation ;;
|
||||
apply) apply_rotation ;;
|
||||
status)
|
||||
if [[ -f "${STATUS_FILE}" ]]; then cat "${STATUS_FILE}"; else write_status "not_configured" "Aucune rotation n’a encore été préparée."; cat "${STATUS_FILE}"; fi
|
||||
;;
|
||||
*) echo "Usage: $0 {prepare [--force]|apply|status}" >&2; exit 2 ;;
|
||||
esac
|
||||
11
ops/gitea-token-rotation.timer
Normal file
11
ops/gitea-token-rotation.timer
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Préparation trimestrielle du jeton Gitea Santinova
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-01,04,07,10-01 07:00:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=30m
|
||||
Unit=gitea-token-rotation.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
18
ops/install-gitea-token-rotation.sh
Normal file
18
ops/install-gitea-token-rotation.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
install -D -m 0700 "${SCRIPT_DIR}/gitea-token-rotation.sh" /opt/manus-deploy/scripts/gitea-token-rotation.sh
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.service" /etc/systemd/system/gitea-token-rotation.service
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.timer" /etc/systemd/system/gitea-token-rotation.timer
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.service" /etc/systemd/system/gitea-token-rotation-apply.service
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.path" /etc/systemd/system/gitea-token-rotation-apply.path
|
||||
install -d -m 0700 /opt/manus-deploy/secrets/gitea-token-rotation
|
||||
install -d -m 0750 /var/log/manus-deploy
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gitea-token-rotation.timer
|
||||
systemctl enable --now gitea-token-rotation-apply.path
|
||||
systemctl is-enabled gitea-token-rotation.timer
|
||||
systemctl is-enabled gitea-token-rotation-apply.path
|
||||
systemctl list-timers gitea-token-rotation.timer --no-pager
|
||||
48
ops/provision-dashboard-alert-token.sh
Normal file
48
ops/provision-dashboard-alert-token.sh
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
|
||||
readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}"
|
||||
readonly REFERENTIEL_CONTAINER="${REFERENTIEL_CONTAINER:-referentiel-ia}"
|
||||
readonly REFERENTIEL_URL="${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}"
|
||||
|
||||
backup="$(mktemp /tmp/manus-dashboard-env-backup.XXXXXX)"
|
||||
temporary="$(mktemp /tmp/manus-dashboard-env-next.XXXXXX)"
|
||||
chmod 0600 "${backup}" "${temporary}"
|
||||
cp "${DASHBOARD_ENV_FILE}" "${backup}"
|
||||
|
||||
cleanup() {
|
||||
rm -f "${backup}" "${temporary}"
|
||||
unset token result
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
result="$(docker exec "${REFERENTIEL_CONTAINER}" sh -lc 'cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts prepare')"
|
||||
token="${result%%$'\t'*}"
|
||||
token_id="${result##*$'\t'}"
|
||||
[[ -n "${token}" && "${token_id}" =~ ^[0-9]+$ ]] || { echo "Le Référentiel IA n’a pas retourné un jeton exploitable." >&2; exit 1; }
|
||||
|
||||
awk -v value="${token}" '
|
||||
BEGIN { replaced = 0 }
|
||||
/^IA_REFERENTIEL_TOKEN=/ { print "IA_REFERENTIEL_TOKEN=" value; replaced = 1; next }
|
||||
{ print }
|
||||
END { if (!replaced) print "IA_REFERENTIEL_TOKEN=" value }
|
||||
' "${DASHBOARD_ENV_FILE}" > "${temporary}"
|
||||
mv "${temporary}" "${DASHBOARD_ENV_FILE}"
|
||||
chmod 0600 "${DASHBOARD_ENV_FILE}"
|
||||
|
||||
if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null); then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 8
|
||||
health="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${token}" "${REFERENTIEL_URL}/api/integration/v1/alerts/ci/health")"
|
||||
if [[ "${health}" != "204" ]]; then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
(cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true
|
||||
echo "Le nouveau jeton d’alerte a échoué au contrôle HTTP (${health}); rollback effectué." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec "${REFERENTIEL_CONTAINER}" sh -lc "cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts finalize ${token_id}" >/dev/null
|
||||
echo "DASHBOARD_ALERT_TOKEN_PROVISIONED"
|
||||
116
ops/setup-gitea-webhooks.py
Normal file
116
ops/setup-gitea-webhooks.py
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://git.recette.santinova-soft.org").rstrip("/")
|
||||
GITEA_USER = os.environ.get("GITEA_USERNAME", "manus-admin")
|
||||
DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "https://dashboard.recette.santinova-soft.org").rstrip("/")
|
||||
DASHBOARD_APP_DIR = Path(os.environ.get("DASHBOARD_APP_DIR", "/opt/manus-deploy/apps/manus-dashboard"))
|
||||
ENV_FILE = DASHBOARD_APP_DIR / ".env"
|
||||
WEBHOOK_ENDPOINT = f"{DASHBOARD_URL}/api/webhook/gitea"
|
||||
|
||||
|
||||
def read_env() -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
if ENV_FILE.exists():
|
||||
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
||||
if line and not line.lstrip().startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def update_env(key: str, value: str) -> None:
|
||||
lines = ENV_FILE.read_text(encoding="utf-8").splitlines() if ENV_FILE.exists() else []
|
||||
updated: list[str] = []
|
||||
found = False
|
||||
for line in lines:
|
||||
if line.startswith(f"{key}="):
|
||||
updated.append(f"{key}={value}")
|
||||
found = True
|
||||
else:
|
||||
updated.append(line)
|
||||
if not found:
|
||||
updated.append(f"{key}={value}")
|
||||
temporary = ENV_FILE.with_suffix(".env.next")
|
||||
temporary.write_text("\n".join(updated).rstrip() + "\n", encoding="utf-8")
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(ENV_FILE)
|
||||
|
||||
|
||||
def api(token: str, method: str, path: str, payload: dict | None = None):
|
||||
data = json.dumps(payload).encode() if payload is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{GITEA_URL}/api/v1{path}",
|
||||
method=method,
|
||||
data=data,
|
||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
body = response.read()
|
||||
return json.loads(body) if body else None
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = error.read().decode(errors="replace")
|
||||
raise RuntimeError(f"Gitea {method} {path} : HTTP {error.code} {detail[:300]}") from error
|
||||
|
||||
|
||||
def ensure_dashboard_secret(values: dict[str, str]) -> tuple[dict[str, str], bool]:
|
||||
if values.get("WEBHOOK_SECRET"):
|
||||
return values, False
|
||||
update_env("WEBHOOK_SECRET", secrets.token_hex(32))
|
||||
return read_env(), True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
values, generated = ensure_dashboard_secret(read_env())
|
||||
token = values.get("GITEA_TOKEN", "")
|
||||
secret = values.get("WEBHOOK_SECRET", "")
|
||||
if not token or not secret:
|
||||
raise RuntimeError("GITEA_TOKEN et WEBHOOK_SECRET sont requis dans le .env serveur.")
|
||||
if generated:
|
||||
subprocess.run(["docker", "compose", "up", "-d", "--force-recreate", "dashboard"], cwd=DASHBOARD_APP_DIR, check=True)
|
||||
time.sleep(8)
|
||||
identity = api(token, "GET", "/user")
|
||||
if identity.get("login") != GITEA_USER:
|
||||
raise RuntimeError("Le jeton Gitea n’appartient pas au compte attendu.")
|
||||
repositories = api(token, "GET", "/user/repos?limit=100&page=1") or []
|
||||
configured = 0
|
||||
for repository in repositories:
|
||||
owner = repository.get("owner", {}).get("login")
|
||||
name = repository.get("name")
|
||||
if owner != GITEA_USER or not name:
|
||||
continue
|
||||
hooks = api(token, "GET", f"/repos/{owner}/{name}/hooks") or []
|
||||
existing = next((hook for hook in hooks if hook.get("config", {}).get("url") == WEBHOOK_ENDPOINT), None)
|
||||
payload = {
|
||||
"active": True,
|
||||
"branch_filter": "main",
|
||||
"config": {"url": WEBHOOK_ENDPOINT, "content_type": "json", "secret": secret},
|
||||
"events": ["push", "workflow_run"],
|
||||
}
|
||||
if existing:
|
||||
api(token, "PATCH", f"/repos/{owner}/{name}/hooks/{existing['id']}", payload)
|
||||
else:
|
||||
api(token, "POST", f"/repos/{owner}/{name}/hooks", {"type": "gitea", **payload})
|
||||
configured += 1
|
||||
print(f"WEBHOOK_OK {owner}/{name}")
|
||||
if configured == 0:
|
||||
raise RuntimeError("Aucun dépôt Gitea n’a été configuré.")
|
||||
print(f"GITEA_WEBHOOKS_CONFIGURED {configured}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc:
|
||||
print(f"GITEA_WEBHOOK_SETUP_FAILED: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Reference in New Issue
Block a user