CI pipeline: WAM-triggered CI with auto-revert on failure Revised architecture: post-receive hook creates a WAM ci-trigger ticket, astra's ci-watcher polls for triggers and runs CI. Avoids Tailscale SSH ACL issues between Hetzner and astra. - ci-on-push.sh: pulls latest, runs full test suite, auto-reverts and pushes revert if tests fail (enforces no-regressions rule) - ci-watcher.sh: polls WAM every 30s for ci-trigger tickets, claims and runs CI, closes trigger on completion - post-receive hook: creates WAM ticket on main push (replaces SSH) - cicd.md: updated roadmap with TUI monitor and staging steps
- Co-Authored-By
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-25 20:42 UTC
Commit:
0d2944286fa74ae12c2b9d6097a72f316bacf601Parent:
3 files changed,
+171 insertions,
-60 deletions
# CI wrapper for push-triggered builds on astra.# Called by the post-receive hook on Hetzner via SSH.## Pulls latest code, runs the CI suite, and reports results to WAM.# Pulls latest code, runs the CI suite, reports results to WAM.# If tests fail on main, auto-reverts the offending commit(s) and pushes# the revert — enforcing the no-regressions rule from operations.md.## Location on astra: /home/max/staging/ci-on-push.sh# Location on astra: /home/max/mnw-ci/server/deploy/ci-on-push.sh# Usage: ci-on-push.sh [branch_name]set -uo pipefailexport PATH="$HOME/.cargo/bin:$PATH"BRANCH="${1:-main}"STAGING_DIR="$HOME/staging"SERVER_DIR="$STAGING_DIR/server"REPO_DIR="$HOME/mnw-ci"SERVER_DIR="$REPO_DIR/server"WAM_URL="${WAM_URL:-http://100.120.174.96:7890}"START_TIME=$(date +%s)LOG_FILE="$STAGING_DIR/ci-latest.log"LOG_FILE="$REPO_DIR/ci-latest.log"echo "=== CI triggered for branch: $BRANCH ==="echo "Started: $(date -u +%Y-%m-%dT%H:%M:%SZ)"# Pull latest codecd "$STAGING_DIR" || exit 1cd "$REPO_DIR" || exit 1echo "[pull] Fetching latest..."git fetch origin "$BRANCH" 2>&1if [ -d ".git" ]; then echo "[pull] Fetching latest..." git fetch origin "$BRANCH" 2>&1 git reset --hard "origin/$BRANCH" 2>&1else echo "[pull] No git repo in staging dir, skipping pull"fi# Record what we're about to testOLD_HEAD=$(git rev-parse HEAD 2>/dev/null || echo "none")git reset --hard "origin/$BRANCH" 2>&1NEW_HEAD=$(git rev-parse HEAD)COMMIT_MSG=$(git log --oneline -1)# Run CI (capture output for WAM ticket)cd "$SERVER_DIR" 2>/dev/null || cd "$STAGING_DIR" || exit 1echo "[pull] $OLD_HEAD -> $NEW_HEAD"echo "[pull] $COMMIT_MSG"# Run CI from the server directorycd "$SERVER_DIR" || exit 1export SQLX_OFFLINE=trueexport TEST_DATABASE_URL="${TEST_DATABASE_URL:-postgres:///postgres}"export RUST_TEST_THREADS="${RUST_TEST_THREADS:-8}"export CARGO_INCREMENTAL=0export RUST_BACKTRACE=1echo "[ci] Running CI suite..."CI_OUTPUT=$("$STAGING_DIR/run-ci.sh" 2>&1) || trueCI_OUTPUT=$("$REPO_DIR/server/deploy/run-ci.sh" 2>&1) || trueCI_EXIT=$?END_TIME=$(date +%s)DURATION=$(( END_TIME - START_TIME ))# Extract summary from CI outputSUMMARY=$(echo "$CI_OUTPUT" | grep -A 50 "CI Summary" | tail -n +2)PASS_COUNT=$(echo "$CI_OUTPUT" | grep -c "^ PASS" || true)FAIL_COUNT=$(echo "$CI_OUTPUT" | grep -c "^ FAIL" || true)# Save full logecho "$CI_OUTPUT" > "$LOG_FILE"# Determine ticket priority and titleif [ $CI_EXIT -eq 0 ]; then PRIORITY="low" TITLE="CI passed: $BRANCH ($PASS_COUNT steps, ${DURATION}s)" STATUS_LINE="All steps passed."else PRIORITY="high" TITLE="CI failed: $BRANCH ($FAIL_COUNT step(s) failed, ${DURATION}s)" # Extract failed step names FAILED_STEPS=$(echo "$CI_OUTPUT" | grep "^ FAIL" | sed 's/^ FAIL / - /') STATUS_LINE="Failed steps:\n$FAILED_STEPS"fi# --- Handle results ---# Build ticket body (truncate to avoid huge payloads)BODY=$(cat <<TICKETBranch: $BRANCHwam_ticket() { local title="$1" body="$2" priority="$3" source_ref="$4" local escaped_title escaped_body escaped_title=$(echo "$title" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$title\"") escaped_body=$(echo "$body" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || echo '""') curl -sf -X POST "$WAM_URL/tickets" \ -H "Content-Type: application/json" \ -d "{\"title\": $escaped_title, \"body\": $escaped_body, \"priority\": \"$priority\", \"source\": \"ci\", \"source_ref\": \"$source_ref\"}" \ >/dev/null 2>&1 || echo "[warn] Failed to create WAM ticket"}if [ $CI_EXIT -eq 0 ]; then # --- CI PASSED --- TITLE="CI passed: $BRANCH ($PASS_COUNT steps, ${DURATION}s)" BODY="Commit: $COMMIT_MSGDuration: ${DURATION}sSteps passed: $PASS_COUNTAll steps passed." wam_ticket "$TITLE" "$BODY" "low" "$NEW_HEAD" echo "" echo "=== CI PASSED ($PASS_COUNT steps, ${DURATION}s) ==="else # --- CI FAILED — AUTO-REVERT --- FAILED_STEPS=$(echo "$CI_OUTPUT" | grep "^ FAIL" | sed 's/^ FAIL / - /') echo "" echo "=== CI FAILED — REVERTING ===" echo "" cd "$REPO_DIR" || exit 1 # Count how many new commits since old head if [ "$OLD_HEAD" = "none" ] || [ "$OLD_HEAD" = "$NEW_HEAD" ]; then # Can't determine what to revert (first run or no change) REVERT_STATUS="could not determine commits to revert" else NEW_COMMITS=$(git rev-list "$OLD_HEAD..$NEW_HEAD" --count 2>/dev/null || echo "0") if [ "$NEW_COMMITS" -eq 1 ]; then # Single commit — revert it git revert --no-edit HEAD 2>&1 REVERT_STATUS="reverted 1 commit ($NEW_HEAD)" elif [ "$NEW_COMMITS" -gt 1 ]; then # Multiple commits — revert the range git revert --no-edit "$OLD_HEAD..$NEW_HEAD" 2>&1 REVERT_STATUS="reverted $NEW_COMMITS commits ($OLD_HEAD..$NEW_HEAD)" else REVERT_STATUS="no new commits to revert" fi # Push the revert back to origin if git push origin "$BRANCH" 2>&1; then REVERT_STATUS="$REVERT_STATUS — pushed to origin" else REVERT_STATUS="$REVERT_STATUS — PUSH FAILED (manual intervention needed)" fi fi TITLE="CI FAILED + REVERTED: $BRANCH ($FAIL_COUNT step(s) failed)" BODY="Commit: $COMMIT_MSGDuration: ${DURATION}sSteps passed: $PASS_COUNTSteps failed: $FAIL_COUNT$STATUS_LINEFailed steps:$FAILED_STEPSRevert: $REVERT_STATUSNo-regressions rule enforced automatically.Fix the issue and re-push.Last 30 lines of output:$(echo "$CI_OUTPUT" | tail -30)TICKET)$(echo "$CI_OUTPUT" | tail -30)"# Create WAM ticketESCAPED_BODY=$(echo "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || echo '""') wam_ticket "$TITLE" "$BODY" "critical" "$NEW_HEAD" echo "Revert: $REVERT_STATUS" echo ""ficurl -sf -X POST "$WAM_URL/tickets" \ -H "Content-Type: application/json" \ -d "{ \"title\": $(echo "$TITLE" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'), \"body\": $ESCAPED_BODY, \"priority\": \"$PRIORITY\", \"source\": \"ci\", \"source_ref\": \"$BRANCH\" }" >/dev/null 2>&1 || echo "[warn] Failed to create WAM ticket"echo ""echo "$SUMMARY"echo ""echo "Duration: ${DURATION}s"echo "WAM ticket: $PRIORITY"exit $CI_EXIT## Install: copy to /opt/git/max/makenotwork.git/hooks/post-receiveASTRA_HOST="max@100.106.221.39"ASTRA_CI_SCRIPT="/home/max/staging/ci-on-push.sh"BUILD_TOKEN="a11d2b9ff121a70e7391b36d00e812da6bba1dd91ed17d4b5e0f0dbb7f7f66cc"WAM_URL="http://127.0.0.1:7890"while read oldrev newrev refname; do case "$refname" in >/dev/null 2>&1 & ;; refs/heads/main) # CI trigger: SSH to astra in background # Uses the git user's SSH key (set up during CI provisioning) ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ "$ASTRA_HOST" "$ASTRA_CI_SCRIPT main" \ </dev/null >/dev/null 2>&1 & # CI trigger: create a WAM ticket that astra's CI watcher picks up. # Avoids SSH ACL issues — both machines can reach WAM on localhost/tailnet. curl -sf -X POST "$WAM_URL/tickets" \ -H "Content-Type: application/json" \ -d "{\"title\": \"CI trigger: main pushed ($newrev)\", \"priority\": \"medium\", \"source\": \"ci-trigger\", \"source_ref\": \"$newrev\"}" \ >/dev/null 2>&1 & ;; esacdone#!/bin/bash# CI watcher for astra — polls WAM for ci-trigger tickets and runs CI.## Runs as a background service on astra. Checks WAM every 30 seconds for# open tickets with source=ci-trigger. When found, claims the ticket# (marks it in-progress), runs CI, and updates the ticket with results.## Location on astra: /home/max/mnw-ci/server/deploy/ci-watcher.sh# Systemd unit: ci-watcher.service## Usage: ci-watcher.shset -uo pipefailexport PATH="$HOME/.cargo/bin:$PATH"WAM_URL="${WAM_URL:-http://100.120.174.96:7890}"REPO_DIR="$HOME/mnw-ci"POLL_INTERVAL=30CI_SCRIPT="$REPO_DIR/server/deploy/ci-on-push.sh"echo "CI watcher started (polling WAM every ${POLL_INTERVAL}s)"while true; do sleep "$POLL_INTERVAL" # Check for open ci-trigger tickets RESPONSE=$(curl -sf "$WAM_URL/tickets?source=ci-trigger&status=open" 2>/dev/null) || continue COUNT=$(echo "$RESPONSE" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("count",0))' 2>/dev/null || echo "0") if [ "$COUNT" = "0" ] || [ -z "$COUNT" ]; then continue fi # Get the first trigger ticket TICKET_ID=$(echo "$RESPONSE" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["data"][0]["id"])' 2>/dev/null) || continue COMMIT=$(echo "$RESPONSE" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["data"][0].get("source_ref","unknown"))' 2>/dev/null || echo "unknown") echo "" echo "=== CI trigger found: $COMMIT ===" # Claim the ticket (mark in-progress so we don't pick it up again) curl -sf -X PATCH "$WAM_URL/tickets/$TICKET_ID" \ -H "Content-Type: application/json" \ -d '{"status": "in-progress"}' >/dev/null 2>&1 # Run CI if "$CI_SCRIPT" main 2>&1; then # CI passed — close the trigger ticket curl -sf -X PATCH "$WAM_URL/tickets/$TICKET_ID" \ -H "Content-Type: application/json" \ -d '{"status": "closed"}' >/dev/null 2>&1 else # CI failed — ci-on-push.sh already created a failure ticket and reverted. # Close the trigger ticket. curl -sf -X PATCH "$WAM_URL/tickets/$TICKET_ID" \ -H "Content-Type: application/json" \ -d '{"status": "closed"}' >/dev/null 2>&1 fi echo "=== CI run complete ==="done