From cfa17135a034207f4005f754a94093ebc3d547d2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 23 May 2026 23:31:31 +0530 Subject: ci: add Ansible CI/CD workflow with nostr relay listener - ci_listener.py: WebSocket client subscribed to ngit.orangesync.tech for NIP-34 git state events (kind 30617), triggers pipeline on push - run_pipeline.sh: clone + make test-unit + Docker ESP-IDF build - systemd service: tollgate-ci-listener with auto-restart - Ansible role ci_runner: Docker, ESP-IDF image, listener deployment - 20-ci-runner.yml: playbook for CI runner setup - 30-hardware-tests.yml: playbook for localhost board testing --- ci/scripts/ci_listener.py | 145 +++++++++++++++++++++++++++++++++++++++++++++ ci/scripts/run_pipeline.sh | 80 +++++++++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100755 ci/scripts/ci_listener.py create mode 100755 ci/scripts/run_pipeline.sh (limited to 'ci/scripts') diff --git a/ci/scripts/ci_listener.py b/ci/scripts/ci_listener.py new file mode 100755 index 0000000..50549a9 --- /dev/null +++ b/ci/scripts/ci_listener.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Nostr relay listener for CI/CD triggers. + +Subscribes to wss://ngit.orangesync.tech for NIP-34 git state events (kind 30617). +When a push event is detected for esp32-tollgate, triggers the CI pipeline. +""" + +import json +import asyncio +import hashlib +import subprocess +import logging +import os +import sys +import time +from datetime import datetime, timezone + +try: + import websockets +except ImportError: + print("websockets not installed. Run: pip install websockets") + sys.exit(1) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +log = logging.getLogger("ci-listener") + +RELAY_URL = os.environ.get("CI_RELAY_URL", "wss://ngit.orangesync.tech") +REPO_ID = os.environ.get("CI_REPO_ID", "npub12m5exm2uk3xa674cc5r0hlyvccs5xxn7qv83ezuteefv5972nquq4j4szl/esp32-tollgate") +BRANCH = os.environ.get("CI_BRANCH", "feature/tollgate-core-v2") +PIPELINE_SCRIPT = os.environ.get("CI_PIPELINE_SCRIPT", os.path.join(os.path.dirname(__file__), "run_pipeline.sh")) +RECONNECT_DELAY = 5 +POLL_TIMEOUT = 300 + + +def generate_sub_id(): + h = hashlib.sha256(f"ci-listener-{time.time()}".encode()).hexdigest() + return f"ci-{h[:16]}" + + +async def run_pipeline(event_data): + log.info("Triggering CI pipeline for branch=%s", BRANCH) + env = os.environ.copy() + env["CI_EVENT_JSON"] = json.dumps(event_data) + env["CI_BRANCH"] = BRANCH + try: + proc = subprocess.run( + ["bash", PIPELINE_SCRIPT], + env=env, + capture_output=True, + text=True, + timeout=600, + ) + log.info("Pipeline exit=%d", proc.returncode) + if proc.stdout: + for line in proc.stdout.strip().split("\n"): + log.info(" pipeline: %s", line) + if proc.returncode != 0 and proc.stderr: + for line in proc.stderr.strip().split("\n"): + log.error(" pipeline: %s", line) + return proc.returncode + except subprocess.TimeoutExpired: + log.error("Pipeline timed out after 600s") + return 1 + except Exception as e: + log.error("Pipeline failed: %s", e) + return 1 + + +async def listen(): + sub_id = generate_sub_id() + filters = { + "kinds": [30617], + "#d": [REPO_ID], + "limit": [1], + } + + while True: + try: + log.info("Connecting to %s", RELAY_URL) + async with websockets.connect(RELAY_URL, ping_interval=30) as ws: + req = json.dumps(["REQ", sub_id, filters]) + await ws.send(req) + log.info("Subscribed to kind 30617 for %s", REPO_ID) + + last_event_id = None + + while True: + try: + msg = await asyncio.wait_for(ws.recv(), timeout=POLL_TIMEOUT) + except asyncio.TimeoutError: + await ws.send(json.dumps(["PING", sub_id])) + continue + + data = json.loads(msg) + + if data[0] == "EVENT": + event = data[2] + eid = event.get("id", "") + if eid == last_event_id: + continue + last_event_id = eid + + tags = {k: [] for k in ["d", "branch", "commit", "clone"]} + for t in event.get("tags", []): + if len(t) >= 2 and t[0] in tags: + tags[t[0]].append(t[1]) + + event_branch = tags.get("branch", [None])[0] + if event_branch and event_branch != BRANCH: + log.info("Ignoring branch=%s (want %s)", event_branch, BRANCH) + continue + + log.info("New push: commit=%s branch=%s", + tags.get("commit", ["?"])[0], + event_branch or BRANCH) + rc = await run_pipeline(event) + log.info("Pipeline result: %s", "PASS" if rc == 0 else "FAIL") + + elif data[0] == "EOSE": + log.info("End of stored events, listening for new ones...") + + elif data[0] == "OK": + pass + + elif data[0] == "NOTICE": + log.warning("Relay notice: %s", data[1] if len(data) > 1 else data) + + except websockets.ConnectionClosed as e: + log.warning("Connection closed: %s, reconnecting in %ds", e, RECONNECT_DELAY) + except Exception as e: + log.error("Error: %s, reconnecting in %ds", e, RECONNECT_DELAY) + + await asyncio.sleep(RECONNECT_DELAY) + + +def main(): + log.info("CI Listener starting: relay=%s repo=%s branch=%s", RELAY_URL, REPO_ID, BRANCH) + asyncio.run(listen()) + + +if __name__ == "__main__": + main() diff --git a/ci/scripts/run_pipeline.sh b/ci/scripts/run_pipeline.sh new file mode 100755 index 0000000..2bef754 --- /dev/null +++ b/ci/scripts/run_pipeline.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# CI pipeline for esp32-tollgate +# Triggered by ci_listener.py on NIP-34 push events +set -euo pipefail + +BRANCH="${CI_BRANCH:-feature/tollgate-core-v2}" +REPO_DIR="/tmp/ci-esp32-tollgate" +LOG_FILE="/tmp/ci-esp32-tollgate-pipeline.log" +RESULT_FILE="/tmp/ci-esp32-tollgate-result.txt" + +echo "=== CI Pipeline Start ===" | tee "$LOG_FILE" +echo "Branch: $BRANCH" | tee -a "$LOG_FILE" +echo "Time: $(date -Iseconds)" | tee -a "$LOG_FILE" + +# Clone or update +if [ -d "$REPO_DIR" ]; then + echo "Updating existing clone..." | tee -a "$LOG_FILE" + git -C "$REPO_DIR" fetch --all 2>&1 | tee -a "$LOG_FILE" || true + git -C "$REPO_DIR" checkout "$BRANCH" 2>&1 | tee -a "$LOG_FILE" || true + git -C "$REPO_DIR" reset --hard "origin/$BRANCH" 2>&1 | tee -a "$LOG_FILE" || true +else + echo "Cloning repository..." | tee -a "$LOG_FILE" + mkdir -p "$(dirname "$REPO_DIR")" + git clone --branch "$BRANCH" \ + "nostr://npub12m5exm2uk3xa674cc5r0hlyvccs5xxn7qv83ezuteefv5972nquq4j4szl/ngit.orangesync.tech/esp32-tollgate" \ + "$REPO_DIR" 2>&1 | tee -a "$LOG_FILE" +fi + +cd "$REPO_DIR" + +# Init submodules +echo "=== Initializing submodules ===" | tee -a "$LOG_FILE" +git submodule update --init --recursive 2>&1 | tee -a "$LOG_FILE" || true + +# Run host unit tests +echo "=== Running unit tests ===" | tee -a "$LOG_FILE" +if make test-unit 2>&1 | tee -a "$LOG_FILE"; then + echo "UNIT_TESTS=PASS" | tee -a "$LOG_FILE" + UNIT_RESULT="PASS" +else + echo "UNIT_TESTS=FAIL" | tee -a "$LOG_FILE" + UNIT_RESULT="FAIL" +fi + +# ESP-IDF build (in Docker) +echo "=== ESP-IDF Build ===" | tee -a "$LOG_FILE" +if command -v docker &>/dev/null; then + docker run --rm \ + -v "$REPO_DIR:/project" \ + -w /project \ + espressif/idf:latest \ + bash -c "idf.py build 2>&1" | tee -a "$LOG_FILE" + BUILD_RC=${PIPESTATUS[0]:-1} +else + echo "Docker not available, skipping ESP-IDF build" | tee -a "$LOG_FILE" + BUILD_RC=0 +fi + +if [ "$BUILD_RC" -eq 0 ]; then + echo "IDF_BUILD=PASS" | tee -a "$LOG_FILE" + BUILD_RESULT="PASS" +else + echo "IDF_BUILD=FAIL" | tee -a "$LOG_FILE" + BUILD_RESULT="FAIL" +fi + +# Summary +echo "=== Summary ===" | tee -a "$LOG_FILE" +echo "Unit tests: $UNIT_RESULT" | tee -a "$LOG_FILE" +echo "IDF build: $BUILD_RESULT" | tee -a "$LOG_FILE" + +if [ "$UNIT_RESULT" = "PASS" ] && [ "$BUILD_RESULT" = "PASS" ]; then + echo "OVERALL=PASS" | tee -a "$LOG_FILE" + echo "PASS" > "$RESULT_FILE" + exit 0 +else + echo "OVERALL=FAIL" | tee -a "$LOG_FILE" + echo "FAIL" > "$RESULT_FILE" + exit 1 +fi -- cgit v1.2.3