diff options
| author | Your Name <you@example.com> | 2026-05-23 23:31:31 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-23 23:31:31 +0530 |
| commit | cfa17135a034207f4005f754a94093ebc3d547d2 (patch) | |
| tree | 1c4a3d93b27d273b58be636cdafabf202b50bfa7 /ci | |
| parent | 85ef20a2004c600dd04428520a79a0e47a54ed2d (diff) | |
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
Diffstat (limited to 'ci')
| -rw-r--r-- | ci/ansible/20-ci-runner.yml | 9 | ||||
| -rw-r--r-- | ci/ansible/30-hardware-tests.yml | 69 | ||||
| -rw-r--r-- | ci/ansible/roles/ci_runner/tasks/main.yml | 72 | ||||
| -rwxr-xr-x | ci/scripts/ci_listener.py | 145 | ||||
| -rwxr-xr-x | ci/scripts/run_pipeline.sh | 80 | ||||
| -rw-r--r-- | ci/systemd/tollgate-ci-listener.service | 18 |
6 files changed, 393 insertions, 0 deletions
diff --git a/ci/ansible/20-ci-runner.yml b/ci/ansible/20-ci-runner.yml new file mode 100644 index 0000000..62ff12a --- /dev/null +++ b/ci/ansible/20-ci-runner.yml | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | --- | ||
| 2 | # Deploy CI runner for esp32-tollgate | ||
| 3 | # Usage: ansible-playbook 20-ci-runner.yml | ||
| 4 | |||
| 5 | - name: Deploy CI runner | ||
| 6 | hosts: localhost | ||
| 7 | connection: local | ||
| 8 | roles: | ||
| 9 | - role: ci_runner | ||
diff --git a/ci/ansible/30-hardware-tests.yml b/ci/ansible/30-hardware-tests.yml new file mode 100644 index 0000000..93a3a10 --- /dev/null +++ b/ci/ansible/30-hardware-tests.yml | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | --- | ||
| 2 | # Run hardware integration tests on localhost | ||
| 3 | # Usage: ansible-playbook 30-hardware-tests.yml [-e "board=a"] [-e "skip_flash=false"] | ||
| 4 | |||
| 5 | - name: Hardware integration tests | ||
| 6 | hosts: localhost | ||
| 7 | connection: local | ||
| 8 | vars: | ||
| 9 | board: "a" | ||
| 10 | skip_flash: false | ||
| 11 | esp32_tollgate_dir: "/home/c03rad0r/esp32-tollgate" | ||
| 12 | sudo_pw: "c03rad0r123" | ||
| 13 | board_config: | ||
| 14 | a: | ||
| 15 | port: "/dev/ttyACM0" | ||
| 16 | ip: "10.185.47.1" | ||
| 17 | ssid: "TollGate-B96D80" | ||
| 18 | b: | ||
| 19 | port: "/dev/ttyACM1" | ||
| 20 | ip: "10.192.45.1" | ||
| 21 | ssid: "TollGate-C0E9CA" | ||
| 22 | |||
| 23 | tasks: | ||
| 24 | - name: Set board facts | ||
| 25 | set_fact: | ||
| 26 | board_port: "{{ board_config[board].port }}" | ||
| 27 | board_ip: "{{ board_config[board].ip }}" | ||
| 28 | board_ssid: "{{ board_config[board].ssid }}" | ||
| 29 | |||
| 30 | - name: Verify board connected | ||
| 31 | command: "esptool.py --port {{ board_port }} chip_id" | ||
| 32 | changed_when: false | ||
| 33 | |||
| 34 | - name: Flash firmware | ||
| 35 | when: not skip_flash | bool | ||
| 36 | command: "make flash-{{ board }}" | ||
| 37 | args: | ||
| 38 | chdir: "{{ esp32_tollgate_dir }}" | ||
| 39 | |||
| 40 | - name: Wait for board boot | ||
| 41 | pause: | ||
| 42 | seconds: 15 | ||
| 43 | |||
| 44 | - name: Run smoke tests | ||
| 45 | command: "make test-smoke" | ||
| 46 | args: | ||
| 47 | chdir: "{{ esp32_tollgate_dir }}" | ||
| 48 | environment: | ||
| 49 | TOLLGATE_IP: "{{ board_ip }}" | ||
| 50 | BOARD: "{{ board }}" | ||
| 51 | register: smoke_result | ||
| 52 | ignore_errors: true | ||
| 53 | |||
| 54 | - name: Run integration tests | ||
| 55 | command: "make test-integration" | ||
| 56 | args: | ||
| 57 | chdir: "{{ esp32_tollgate_dir }}" | ||
| 58 | environment: | ||
| 59 | TOLLGATE_IP: "{{ board_ip }}" | ||
| 60 | TOLLGATE_SSID: "{{ board_ssid }}" | ||
| 61 | register: integration_result | ||
| 62 | ignore_errors: true | ||
| 63 | |||
| 64 | - name: Report results | ||
| 65 | debug: | ||
| 66 | msg: | | ||
| 67 | Board: {{ board }} ({{ board_ip }}) | ||
| 68 | Smoke: {{ 'PASS' if smoke_result is success else 'FAIL' }} | ||
| 69 | Integration: {{ 'PASS' if integration_result is success else 'FAIL' }} | ||
diff --git a/ci/ansible/roles/ci_runner/tasks/main.yml b/ci/ansible/roles/ci_runner/tasks/main.yml new file mode 100644 index 0000000..b29a237 --- /dev/null +++ b/ci/ansible/roles/ci_runner/tasks/main.yml | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | --- | ||
| 2 | # CI Runner role: Docker + ESP-IDF + nostr listener for CI/CD | ||
| 3 | |||
| 4 | - name: Install Docker | ||
| 5 | become: true | ||
| 6 | block: | ||
| 7 | - name: Check if Docker is installed | ||
| 8 | command: docker --version | ||
| 9 | changed_when: false | ||
| 10 | ignore_errors: true | ||
| 11 | register: docker_check | ||
| 12 | |||
| 13 | - name: Install Docker | ||
| 14 | when: docker_check.failed | ||
| 15 | block: | ||
| 16 | - name: Install Docker packages | ||
| 17 | apt: | ||
| 18 | name: | ||
| 19 | - docker.io | ||
| 20 | - docker-compose-v2 | ||
| 21 | state: present | ||
| 22 | update_cache: true | ||
| 23 | |||
| 24 | - name: Add user to docker group | ||
| 25 | user: | ||
| 26 | name: "{{ ansible_user_id }}" | ||
| 27 | groups: docker | ||
| 28 | append: true | ||
| 29 | |||
| 30 | - name: Pull ESP-IDF Docker image | ||
| 31 | become: true | ||
| 32 | community.docker.docker_image: | ||
| 33 | name: espressif/idf | ||
| 34 | tag: latest | ||
| 35 | source: pull | ||
| 36 | |||
| 37 | - name: Install Python dependencies for CI listener | ||
| 38 | pip: | ||
| 39 | name: | ||
| 40 | - websockets | ||
| 41 | state: present | ||
| 42 | extra_args: --user | ||
| 43 | |||
| 44 | - name: Install CI listener systemd service | ||
| 45 | become: true | ||
| 46 | block: | ||
| 47 | - name: Copy service file | ||
| 48 | copy: | ||
| 49 | src: ../../systemd/tollgate-ci-listener.service | ||
| 50 | dest: /etc/systemd/system/tollgate-ci-listener.service | ||
| 51 | mode: "0644" | ||
| 52 | |||
| 53 | - name: Reload systemd | ||
| 54 | systemd: | ||
| 55 | daemon_reload: true | ||
| 56 | |||
| 57 | - name: Enable and start CI listener | ||
| 58 | systemd: | ||
| 59 | name: tollgate-ci-listener | ||
| 60 | enabled: true | ||
| 61 | state: started | ||
| 62 | |||
| 63 | - name: Verify CI listener is running | ||
| 64 | block: | ||
| 65 | - name: Check service status | ||
| 66 | command: systemctl is-active tollgate-ci-listener | ||
| 67 | changed_when: false | ||
| 68 | register: listener_status | ||
| 69 | |||
| 70 | - name: Report status | ||
| 71 | debug: | ||
| 72 | msg: "CI listener status: {{ listener_status.stdout }}" | ||
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 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Nostr relay listener for CI/CD triggers. | ||
| 3 | |||
| 4 | Subscribes to wss://ngit.orangesync.tech for NIP-34 git state events (kind 30617). | ||
| 5 | When a push event is detected for esp32-tollgate, triggers the CI pipeline. | ||
| 6 | """ | ||
| 7 | |||
| 8 | import json | ||
| 9 | import asyncio | ||
| 10 | import hashlib | ||
| 11 | import subprocess | ||
| 12 | import logging | ||
| 13 | import os | ||
| 14 | import sys | ||
| 15 | import time | ||
| 16 | from datetime import datetime, timezone | ||
| 17 | |||
| 18 | try: | ||
| 19 | import websockets | ||
| 20 | except ImportError: | ||
| 21 | print("websockets not installed. Run: pip install websockets") | ||
| 22 | sys.exit(1) | ||
| 23 | |||
| 24 | logging.basicConfig( | ||
| 25 | level=logging.INFO, | ||
| 26 | format="%(asctime)s [%(levelname)s] %(message)s", | ||
| 27 | ) | ||
| 28 | log = logging.getLogger("ci-listener") | ||
| 29 | |||
| 30 | RELAY_URL = os.environ.get("CI_RELAY_URL", "wss://ngit.orangesync.tech") | ||
| 31 | REPO_ID = os.environ.get("CI_REPO_ID", "npub12m5exm2uk3xa674cc5r0hlyvccs5xxn7qv83ezuteefv5972nquq4j4szl/esp32-tollgate") | ||
| 32 | BRANCH = os.environ.get("CI_BRANCH", "feature/tollgate-core-v2") | ||
| 33 | PIPELINE_SCRIPT = os.environ.get("CI_PIPELINE_SCRIPT", os.path.join(os.path.dirname(__file__), "run_pipeline.sh")) | ||
| 34 | RECONNECT_DELAY = 5 | ||
| 35 | POLL_TIMEOUT = 300 | ||
| 36 | |||
| 37 | |||
| 38 | def generate_sub_id(): | ||
| 39 | h = hashlib.sha256(f"ci-listener-{time.time()}".encode()).hexdigest() | ||
| 40 | return f"ci-{h[:16]}" | ||
| 41 | |||
| 42 | |||
| 43 | async def run_pipeline(event_data): | ||
| 44 | log.info("Triggering CI pipeline for branch=%s", BRANCH) | ||
| 45 | env = os.environ.copy() | ||
| 46 | env["CI_EVENT_JSON"] = json.dumps(event_data) | ||
| 47 | env["CI_BRANCH"] = BRANCH | ||
| 48 | try: | ||
| 49 | proc = subprocess.run( | ||
| 50 | ["bash", PIPELINE_SCRIPT], | ||
| 51 | env=env, | ||
| 52 | capture_output=True, | ||
| 53 | text=True, | ||
| 54 | timeout=600, | ||
| 55 | ) | ||
| 56 | log.info("Pipeline exit=%d", proc.returncode) | ||
| 57 | if proc.stdout: | ||
| 58 | for line in proc.stdout.strip().split("\n"): | ||
| 59 | log.info(" pipeline: %s", line) | ||
| 60 | if proc.returncode != 0 and proc.stderr: | ||
| 61 | for line in proc.stderr.strip().split("\n"): | ||
| 62 | log.error(" pipeline: %s", line) | ||
| 63 | return proc.returncode | ||
| 64 | except subprocess.TimeoutExpired: | ||
| 65 | log.error("Pipeline timed out after 600s") | ||
| 66 | return 1 | ||
| 67 | except Exception as e: | ||
| 68 | log.error("Pipeline failed: %s", e) | ||
| 69 | return 1 | ||
| 70 | |||
| 71 | |||
| 72 | async def listen(): | ||
| 73 | sub_id = generate_sub_id() | ||
| 74 | filters = { | ||
| 75 | "kinds": [30617], | ||
| 76 | "#d": [REPO_ID], | ||
| 77 | "limit": [1], | ||
| 78 | } | ||
| 79 | |||
| 80 | while True: | ||
| 81 | try: | ||
| 82 | log.info("Connecting to %s", RELAY_URL) | ||
| 83 | async with websockets.connect(RELAY_URL, ping_interval=30) as ws: | ||
| 84 | req = json.dumps(["REQ", sub_id, filters]) | ||
| 85 | await ws.send(req) | ||
| 86 | log.info("Subscribed to kind 30617 for %s", REPO_ID) | ||
| 87 | |||
| 88 | last_event_id = None | ||
| 89 | |||
| 90 | while True: | ||
| 91 | try: | ||
| 92 | msg = await asyncio.wait_for(ws.recv(), timeout=POLL_TIMEOUT) | ||
| 93 | except asyncio.TimeoutError: | ||
| 94 | await ws.send(json.dumps(["PING", sub_id])) | ||
| 95 | continue | ||
| 96 | |||
| 97 | data = json.loads(msg) | ||
| 98 | |||
| 99 | if data[0] == "EVENT": | ||
| 100 | event = data[2] | ||
| 101 | eid = event.get("id", "") | ||
| 102 | if eid == last_event_id: | ||
| 103 | continue | ||
| 104 | last_event_id = eid | ||
| 105 | |||
| 106 | tags = {k: [] for k in ["d", "branch", "commit", "clone"]} | ||
| 107 | for t in event.get("tags", []): | ||
| 108 | if len(t) >= 2 and t[0] in tags: | ||
| 109 | tags[t[0]].append(t[1]) | ||
| 110 | |||
| 111 | event_branch = tags.get("branch", [None])[0] | ||
| 112 | if event_branch and event_branch != BRANCH: | ||
| 113 | log.info("Ignoring branch=%s (want %s)", event_branch, BRANCH) | ||
| 114 | continue | ||
| 115 | |||
| 116 | log.info("New push: commit=%s branch=%s", | ||
| 117 | tags.get("commit", ["?"])[0], | ||
| 118 | event_branch or BRANCH) | ||
| 119 | rc = await run_pipeline(event) | ||
| 120 | log.info("Pipeline result: %s", "PASS" if rc == 0 else "FAIL") | ||
| 121 | |||
| 122 | elif data[0] == "EOSE": | ||
| 123 | log.info("End of stored events, listening for new ones...") | ||
| 124 | |||
| 125 | elif data[0] == "OK": | ||
| 126 | pass | ||
| 127 | |||
| 128 | elif data[0] == "NOTICE": | ||
| 129 | log.warning("Relay notice: %s", data[1] if len(data) > 1 else data) | ||
| 130 | |||
| 131 | except websockets.ConnectionClosed as e: | ||
| 132 | log.warning("Connection closed: %s, reconnecting in %ds", e, RECONNECT_DELAY) | ||
| 133 | except Exception as e: | ||
| 134 | log.error("Error: %s, reconnecting in %ds", e, RECONNECT_DELAY) | ||
| 135 | |||
| 136 | await asyncio.sleep(RECONNECT_DELAY) | ||
| 137 | |||
| 138 | |||
| 139 | def main(): | ||
| 140 | log.info("CI Listener starting: relay=%s repo=%s branch=%s", RELAY_URL, REPO_ID, BRANCH) | ||
| 141 | asyncio.run(listen()) | ||
| 142 | |||
| 143 | |||
| 144 | if __name__ == "__main__": | ||
| 145 | 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 @@ | |||
| 1 | #!/bin/bash | ||
| 2 | # CI pipeline for esp32-tollgate | ||
| 3 | # Triggered by ci_listener.py on NIP-34 push events | ||
| 4 | set -euo pipefail | ||
| 5 | |||
| 6 | BRANCH="${CI_BRANCH:-feature/tollgate-core-v2}" | ||
| 7 | REPO_DIR="/tmp/ci-esp32-tollgate" | ||
| 8 | LOG_FILE="/tmp/ci-esp32-tollgate-pipeline.log" | ||
| 9 | RESULT_FILE="/tmp/ci-esp32-tollgate-result.txt" | ||
| 10 | |||
| 11 | echo "=== CI Pipeline Start ===" | tee "$LOG_FILE" | ||
| 12 | echo "Branch: $BRANCH" | tee -a "$LOG_FILE" | ||
| 13 | echo "Time: $(date -Iseconds)" | tee -a "$LOG_FILE" | ||
| 14 | |||
| 15 | # Clone or update | ||
| 16 | if [ -d "$REPO_DIR" ]; then | ||
| 17 | echo "Updating existing clone..." | tee -a "$LOG_FILE" | ||
| 18 | git -C "$REPO_DIR" fetch --all 2>&1 | tee -a "$LOG_FILE" || true | ||
| 19 | git -C "$REPO_DIR" checkout "$BRANCH" 2>&1 | tee -a "$LOG_FILE" || true | ||
| 20 | git -C "$REPO_DIR" reset --hard "origin/$BRANCH" 2>&1 | tee -a "$LOG_FILE" || true | ||
| 21 | else | ||
| 22 | echo "Cloning repository..." | tee -a "$LOG_FILE" | ||
| 23 | mkdir -p "$(dirname "$REPO_DIR")" | ||
| 24 | git clone --branch "$BRANCH" \ | ||
| 25 | "nostr://npub12m5exm2uk3xa674cc5r0hlyvccs5xxn7qv83ezuteefv5972nquq4j4szl/ngit.orangesync.tech/esp32-tollgate" \ | ||
| 26 | "$REPO_DIR" 2>&1 | tee -a "$LOG_FILE" | ||
| 27 | fi | ||
| 28 | |||
| 29 | cd "$REPO_DIR" | ||
| 30 | |||
| 31 | # Init submodules | ||
| 32 | echo "=== Initializing submodules ===" | tee -a "$LOG_FILE" | ||
| 33 | git submodule update --init --recursive 2>&1 | tee -a "$LOG_FILE" || true | ||
| 34 | |||
| 35 | # Run host unit tests | ||
| 36 | echo "=== Running unit tests ===" | tee -a "$LOG_FILE" | ||
| 37 | if make test-unit 2>&1 | tee -a "$LOG_FILE"; then | ||
| 38 | echo "UNIT_TESTS=PASS" | tee -a "$LOG_FILE" | ||
| 39 | UNIT_RESULT="PASS" | ||
| 40 | else | ||
| 41 | echo "UNIT_TESTS=FAIL" | tee -a "$LOG_FILE" | ||
| 42 | UNIT_RESULT="FAIL" | ||
| 43 | fi | ||
| 44 | |||
| 45 | # ESP-IDF build (in Docker) | ||
| 46 | echo "=== ESP-IDF Build ===" | tee -a "$LOG_FILE" | ||
| 47 | if command -v docker &>/dev/null; then | ||
| 48 | docker run --rm \ | ||
| 49 | -v "$REPO_DIR:/project" \ | ||
| 50 | -w /project \ | ||
| 51 | espressif/idf:latest \ | ||
| 52 | bash -c "idf.py build 2>&1" | tee -a "$LOG_FILE" | ||
| 53 | BUILD_RC=${PIPESTATUS[0]:-1} | ||
| 54 | else | ||
| 55 | echo "Docker not available, skipping ESP-IDF build" | tee -a "$LOG_FILE" | ||
| 56 | BUILD_RC=0 | ||
| 57 | fi | ||
| 58 | |||
| 59 | if [ "$BUILD_RC" -eq 0 ]; then | ||
| 60 | echo "IDF_BUILD=PASS" | tee -a "$LOG_FILE" | ||
| 61 | BUILD_RESULT="PASS" | ||
| 62 | else | ||
| 63 | echo "IDF_BUILD=FAIL" | tee -a "$LOG_FILE" | ||
| 64 | BUILD_RESULT="FAIL" | ||
| 65 | fi | ||
| 66 | |||
| 67 | # Summary | ||
| 68 | echo "=== Summary ===" | tee -a "$LOG_FILE" | ||
| 69 | echo "Unit tests: $UNIT_RESULT" | tee -a "$LOG_FILE" | ||
| 70 | echo "IDF build: $BUILD_RESULT" | tee -a "$LOG_FILE" | ||
| 71 | |||
| 72 | if [ "$UNIT_RESULT" = "PASS" ] && [ "$BUILD_RESULT" = "PASS" ]; then | ||
| 73 | echo "OVERALL=PASS" | tee -a "$LOG_FILE" | ||
| 74 | echo "PASS" > "$RESULT_FILE" | ||
| 75 | exit 0 | ||
| 76 | else | ||
| 77 | echo "OVERALL=FAIL" | tee -a "$LOG_FILE" | ||
| 78 | echo "FAIL" > "$RESULT_FILE" | ||
| 79 | exit 1 | ||
| 80 | fi | ||
diff --git a/ci/systemd/tollgate-ci-listener.service b/ci/systemd/tollgate-ci-listener.service new file mode 100644 index 0000000..287f474 --- /dev/null +++ b/ci/systemd/tollgate-ci-listener.service | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=ESP32-TollGate CI Nostr Listener | ||
| 3 | After=network-online.target | ||
| 4 | Wants=network-online.target | ||
| 5 | |||
| 6 | [Service] | ||
| 7 | Type=simple | ||
| 8 | User=c03rad0r | ||
| 9 | WorkingDirectory=/home/c03rad0r/esp32-tollgate/ci/scripts | ||
| 10 | ExecStart=/usr/bin/python3 /home/c03rad0r/esp32-tollgate/ci/scripts/ci_listener.py | ||
| 11 | Restart=always | ||
| 12 | RestartSec=10 | ||
| 13 | Environment=CI_RELAY_URL=wss://ngit.orangesync.tech | ||
| 14 | Environment=CI_BRANCH=feature/tollgate-core-v2 | ||
| 15 | Environment=CI_REPO_ID=npub12m5exm2uk3xa674cc5r0hlyvccs5xxn7qv83ezuteefv5972nquq4j4szl/esp32-tollgate | ||
| 16 | |||
| 17 | [Install] | ||
| 18 | WantedBy=multi-user.target | ||