#!/usr/bin/env bash # Ampel-Wächter (22.07.2026, Wasserdicht-Runde): meldet ROTE CI-Läufe aller # Gitea-Repos per Telegram. Läuft als no-agent-Cron (stdout leer = still, # klassisches Watchdog-Muster). Nur NEUE Fehlschläge werden gemeldet — # Status-Merkliste unter ~/.hermes/state/ampel-waechter-gesehen.txt. set -uo pipefail STATE="$HOME/.hermes/state/ampel-waechter-gesehen.txt" mkdir -p "$(dirname "$STATE")"; touch "$STATE" TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null | sed -E 's#https://[^:]+:([^@]+)@.*#\1#') [ -n "$TOKEN" ] || exit 0 python3 - "$TOKEN" "$STATE" <<'PY' import json, sys, urllib.request token, state_pfad = sys.argv[1], sys.argv[2] # API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026); # WEB bleibt die Domain — Telegram-Links muessen auch von unterwegs klickbar sein. BASE = "http://192.168.178.153:3000/api/v1" WEB = "https://git.tobisniceshomelab.ddnsfree.com" def api(pfad): req = urllib.request.Request(BASE + pfad, headers={"Authorization": "token " + token}) with urllib.request.urlopen(req, timeout=20) as r: return json.load(r) try: gesehen = set(open(state_pfad).read().split()) except OSError: gesehen = set() neu = [] try: # /repos/search statt /user/repos: das Token hat nur write:repository-Scope, # /user/repos verlangt read:user -> 403. Der stille exit 0 hat diesen Defekt # seit Inbetriebnahme versteckt (Fund 24.07.2026, Merkliste blieb leer). repos = api("/repos/search?limit=50&private=true").get("data", []) except Exception as e: print(f"ampel-waechter: Repo-Liste fehlgeschlagen: {e}", file=sys.stderr) sys.exit(0) for repo in repos: full = repo.get("full_name", "") if not full or repo.get("archived"): continue try: d = api(f"/repos/{full}/actions/tasks?limit=1") except Exception: continue rows = d.get("workflow_runs", d if isinstance(d, list) else d.get("entries", [])) for t in rows[:1]: kennung = f"{full}#{t.get('id')}" status = t.get("status") if status == "failure" and kennung not in gesehen: # Status-Lügen-Wache (Fund 22.07.): alter Runner meldete Abschluss nicht, # Gitea zombie-markierte GRÜNE Jobs als failure. Das Job-LOG ist die # Wahrheit — steht dort „Job succeeded", ist es KEIN Alarm. try: req = urllib.request.Request( f"{BASE}/repos/{full}/actions/jobs/{t.get('id')}/logs", headers={"Authorization": "token " + token}) with urllib.request.urlopen(req, timeout=20) as r: log_ende = r.read().decode(errors="replace")[-4000:] wirklich_rot = "Job succeeded" not in log_ende except Exception: wirklich_rot = True if wirklich_rot: neu.append((full, t.get("head_sha", "")[:7], t.get("name", ""))) # Nur ABGESCHLOSSENE Läufe merken — ein 'running', das später rot wird, # darf nicht schon als gesehen gelten. if status in ("failure", "success", "cancelled", "skipped"): gesehen.add(kennung) if neu: print("🔴 CI-AMPEL ROT:") for full, sha, name in neu: print(f"• {full} @ {sha} ({name})") print(f" {WEB}/{full}/actions") print("Nichts aus diesen Ständen ist ‚fertig', solange die Ampel rot ist.") with open(state_pfad, "w") as f: f.write("\n".join(sorted(gesehen))) PY