A rotted action pin skipped the publish job rather than failing it, so edge stopped publishing and the phone sat on an old build for a working day with nothing saying why. The runner journal logs task pickup and never outcome, which is what was being read. ci-status.sh asks Gitea instead, drilling run -> job -> log tail on a failure. claude-hook-push-ci.sh runs it after every git push so the answer arrives without anyone choosing to look. TASK-42 has the three-layer plan; this is layer 1.
122 lines
4.6 KiB
Shell
Executable file
122 lines
4.6 KiB
Shell
Executable file
#!/usr/bin/env bash
|
|
# What did CI actually do? Ask Gitea, not the runner journal.
|
|
#
|
|
# ci-status.sh # infer the repo from the cwd's git remote
|
|
# ci-status.sh souveraine # name it
|
|
# ci-status.sh souveraine 10 # last 10 runs instead of 3
|
|
# ci-status.sh --all # newest run for every repo we own
|
|
#
|
|
# Exit 0 when the newest run is green or still going, 1 when it failed, so this
|
|
# is usable as a gate as well as a report.
|
|
#
|
|
# WHY THIS EXISTS. On 2026-07-28 a pinned action SHA rotted, `rust-test` died
|
|
# before compiling anything, and `aarch64-artifact` was therefore SKIPPED rather
|
|
# than failed — so the run looked unremarkable while the phone quietly kept
|
|
# installing a build from days earlier. It went unnoticed for a working day.
|
|
# The whole answer was in this API the entire time. What was being read instead
|
|
# was `journalctl -u gitea-runner`, which logs task PICKUP and never OUTCOME,
|
|
# and which produced three confident wrong diagnoses in a row.
|
|
#
|
|
# THE RUNNER JOURNAL IS NOT A STATUS SOURCE. This is.
|
|
#
|
|
# See docs/tasks/42-build-outcomes-are-invisible.md.
|
|
set -uo pipefail
|
|
|
|
GITEA="${GITEA_URL:-http://10.10.20.120:4455}"
|
|
OWNER="${GITEA_OWNER:-Fimeg}"
|
|
|
|
# Token, in order of preference: the environment, then the credential store
|
|
# git already uses for this host. No third copy of a secret in the tree.
|
|
if [ -z "${GITEA_TOKEN:-}" ] && [ -r "$HOME/.git-credentials" ]; then
|
|
GITEA_TOKEN=$(sed -n 's|^http://\([0-9a-f]\{40\}\)@10\.10\.20\.120.*|\1|p' \
|
|
"$HOME/.git-credentials" | head -1)
|
|
fi
|
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
|
GITEA_TOKEN=$(git -C "$HOME/Projects/Pixel3Arch" remote -v 2>/dev/null \
|
|
| sed -n 's|.*http://\([0-9a-f]\{40\}\)@.*|\1|p' | head -1)
|
|
fi
|
|
[ -n "${GITEA_TOKEN:-}" ] || { echo "ci-status: no token (set GITEA_TOKEN)" >&2; exit 2; }
|
|
|
|
api() { curl -sS --max-time 20 -H "Authorization: token $GITEA_TOKEN" "$GITEA/api/v1$1"; }
|
|
|
|
# Gitea ignores ?limit on this endpoint and returns the full history, so the
|
|
# slice happens here. Found the hard way: a "last 3 runs" call printed 120.
|
|
summarize() {
|
|
python3 -c '
|
|
import json, sys
|
|
limit = int(sys.argv[1])
|
|
try:
|
|
d = json.load(sys.stdin)
|
|
except Exception:
|
|
print("ERR"); raise SystemExit
|
|
runs = d.get("workflow_runs", d) if isinstance(d, dict) else d
|
|
if not runs:
|
|
print("NONE"); raise SystemExit
|
|
for r in runs[:limit]:
|
|
c = r.get("conclusion")
|
|
mark = "ok" if c == "success" else ("FAILED" if c == "failure" else (c or "running"))
|
|
print("DETAIL", r.get("id"), r.get("head_branch"), (r.get("head_sha") or "")[:8], mark)
|
|
n = runs[0]
|
|
print("TOP", n.get("id") if n.get("conclusion") == "failure" else "-")
|
|
' "$1"
|
|
}
|
|
|
|
check_repo() {
|
|
local repo="$1" limit="${2:-3}" out failed_run jobs failed_id
|
|
|
|
out=$(api "/repos/$OWNER/$repo/actions/runs" 2>/dev/null | summarize "$limit")
|
|
case "$out" in
|
|
ERR|NONE|"") printf 'ci-status: %-20s no Actions\n' "$repo"; return 0 ;;
|
|
esac
|
|
|
|
printf '%s\n' "$out" | awk -v r="$repo" '$1=="DETAIL"{
|
|
printf "ci-status: %-20s run %-5s [%-8s] %s -> %s\n", r, $2, $3, $4, $5 }'
|
|
|
|
failed_run=$(printf '%s\n' "$out" | awk '$1=="TOP" && $2!="-"{print $2}')
|
|
[ -n "$failed_run" ] || return 0
|
|
|
|
echo "ci-status: --- run $failed_run: which job ---"
|
|
jobs=$(api "/repos/$OWNER/$repo/actions/runs/$failed_run/jobs")
|
|
printf '%s' "$jobs" | python3 -c '
|
|
import json, sys
|
|
d = json.load(sys.stdin)
|
|
for j in d.get("jobs", d):
|
|
print(" %-22s %s" % (j.get("name"), j.get("conclusion")))
|
|
' 2>/dev/null
|
|
|
|
failed_id=$(printf '%s' "$jobs" | python3 -c '
|
|
import json, sys
|
|
d = json.load(sys.stdin)
|
|
for j in d.get("jobs", d):
|
|
if j.get("conclusion") == "failure":
|
|
print(j.get("id")); break
|
|
' 2>/dev/null)
|
|
|
|
if [ -n "$failed_id" ]; then
|
|
echo "ci-status: --- why ---"
|
|
api "/repos/$OWNER/$repo/actions/jobs/$failed_id/logs" 2>/dev/null \
|
|
| grep -viE "docker (pull|create|run|volume)|Writing entry|Extracting|cloning|Cleaning up|Created container|Start(ed|ing) container|Removed container|ENV ==>|Image exists" \
|
|
| tail -12 | sed 's/^/ /'
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# Listed, not discovered: a new repo should be a deliberate addition here, and
|
|
# a repo that disappears from the list should be noticed rather than silently
|
|
# stop being checked.
|
|
ALL_REPOS="souveraine Pixel3Arch culver souveraine-viewtop souveraine-player hexagonrpc souveraine-updater"
|
|
|
|
if [ "${1:-}" = "--all" ]; then
|
|
worst=0
|
|
for r in $ALL_REPOS; do check_repo "$r" "${2:-1}" || worst=1; done
|
|
exit $worst
|
|
fi
|
|
|
|
repo="${1:-}"
|
|
if [ -z "$repo" ]; then
|
|
url=$(git remote -v 2>/dev/null | awk 'NR==1{print $2}')
|
|
repo=$(basename "${url%.git}" 2>/dev/null)
|
|
fi
|
|
[ -n "$repo" ] || { echo "ci-status: no repo (pass a name)" >&2; exit 2; }
|
|
|
|
check_repo "$repo" "${2:-3}"
|