# CI.md — офлайн-автотесты на self-hosted Gitea Actions Сервер Gitea изолирован (без постоянного интернета), поэтому здесь нет `actions/checkout`, нет Marketplace-экшенов и нет ни одного `uses:` — только `run:`. Прежде чем править `.gitea/workflows/*`, прочитай это целиком: почти все неожиданные падения объясняются структурой раздела «Нюансы окружения» ниже, а не багом в тесте. ## Два runner'а — зачем и как разделены Сигнальный тракт pluto-link целиком живёт на ARM (Cortex-A9 внутри Pluto), а сам Gitea-сервер — x86_64. Проверить можно только то, что не требует Pluto-железа: (1) чистую логику на libc, (2) факт компиляции под armhf, (3) статические grep-правила. Для этого заведено два независимых runner'а с разными метками — они физически разные Docker-контейнеры на одном сервере: | Runner | Контейнер | База образа | Метка | Что гоняет | |---|---|---|---|---| | основной | `gitea_runner` | Alpine `act_runner_tools:0.2.13` | `host` | 01-smoke, 02-guardrails | | cross-builder | `gitea_runner_armhf` | Debian bookworm, **нативный x86-64** | `armhf` | 03-gftest, 04-cross-build | У каждого `capacity: 1` в `config.yaml` — внутри одного runner'а job'ы идут строго последовательно (см. «Нюансы» ниже про параллелизм между runner'ами). Оба используют host-executor: шаги `run:` выполняются прямо в окружении процесса runner (то есть внутри контейнера), без docker-in-docker. Отсюда то же правило, что и в примерах для других проектов на этом сервере: **что лежит в образе runner'а — то и доступно job'у; что стоит на Debian-хосте сервера — не видно job'у вообще.** ## Что проверяет каждый workflow ### `.gitea/workflows/01-smoke.yml` — информационный Печатает `/etc/os-release` и `uname -a` (докажет, что это Alpine job-окружения, а не Debian-хост сервера), какие инструменты есть (`git`/`bash`/`cc`/`make`/ `arm-linux-gnueabihf-gcc`) и есть ли `$XARM/lib/*.a`. **Никогда не падает** — только помечает `[ЕСТЬ]`/`[НЕТ]`. Зелёный означает буквально одно: «runner жив и взял задачу», не более. `timeout-minutes: 5`. ### `.gitea/workflows/02-guardrails.yml` — статический страж Клонирует репозиторий (без `actions/checkout`, см. ниже) и гоняет `scripts/ci_guardrails.sh` — 17 grep/bash-ассертов на жёсткие правила CLAUDE.md: sample rate `1920000` и никогда `3840000`, `header_len=12` на TX **и** RX (техдолг #1), только `local:` в тракте, статические/ARM-флаги в makefile, отсутствие тяжёлых зависимостей, отсутствие Python в `src/`. Скрипт самодостаточен и гоняется точно так же локально: ```sh scripts/ci_guardrails.sh ``` Тулчейн не нужен — работает на базовом Alpine-образе. `timeout-minutes: 5`. ### `.gitea/workflows/03-gftest.yml` — юнит-тест group-FEC `make gftest` (см. `makefile`): нативная (не кросс-!) сборка `src/group_fec.c` с `-DGF_SELFTEST` через хостовый `cc` cross-builder'а и запуск самопроверки GF(256) — 774 400 кейсов восстановления группового FEC 16+2, честный pass/fail по коду возврата. Модуль тянет только `/ //`, поэтому компилируется любым gcc. Идёт на `runs-on: armhf`, потому что там гарантированно есть нативный `cc` (`build-essential`) — это не про ARM, просто удобно совместить с cross-builder'ом. `timeout-minutes: 5`. ### `.gitea/workflows/04-cross-build.yml` — приёмочный тест ARM-тулчейна `make check-env && make all` — реальная кросс-сборка `transmitter`, `receiver`, `ofdm_bench` под armhf с `-Wall -Wextra -static`. Ловит любую компиляционную регрессию в боевом коде до деплоя на плату. `timeout-minutes: 15`. ## Ключевой архитектурный момент: это НЕ эмуляция ARM Первая попытка поднять второй runner использовала `platform: linux/arm64` / `linux/arm/v7` в compose — и падала с `exec /sbin/tini: exec format error`: Docker пытался выполнить ARM-бинарь на x86-ядре сервера без qemu/binfmt-регистрации. Это была неверная модель. **Кросс-компиляция не требует ARM-машины.** `arm-linux-gnueabihf-gcc` — обычный **x86-бинарь**, который на входе берёт `.c` и на выходе даёт ARM-машинный код; выполняется на полной скорости x86-ядра, без единого такта эмуляции. Это ровно то же самое, что уже десятилетиями делает Raspberry Pi-хост в `scripts/build_deps.sh` — только хост там aarch64, а не x86_64, что для кросс-компиляции роли не играет. Рабочее решение: `gitea_runner_armhf` — **нативный x86-64** контейнер (`FROM debian:bookworm`, без `platform:` в compose), в который *вложен* ARM-кросс-тулчейн как обычный пакет (`gcc-arm-linux-gnueabihf`). Сам `act_runner` в этом контейнере тоже нативный `linux-amd64` — ARM в цепочке нет нигде, кроме результата компиляции. ## Нюансы окружения (то, что реально ломалось) **musl vs glibc — «файл есть» ≠ «работает».** На Alpine (`host`-runner) glibc-бинарь может лежать на диске и даже отвечать на `command -v`, но падать с обманчивым `not found` при запуске — потому что нет `ld-linux`. Поэтому все приёмочные шаги здесь **компилируют и запускают** пробную программу, а не проверяют `command -v`. В `04-cross-build.yml` это доведено до предела: после сборки пробника парсится байт `e_machine` прямо из ELF-заголовка (`od -An -tx1 -j18 -N2`, ждём `2800` = `EM_ARM` в little-endian) — «собралось без ошибки» тоже не значит «собралось под ту архитектуру». Тот же разбор повторяется для всех трёх собранных бинарников в `04`, а не только для пробника. **`$XARM` перенесён с RPi, не пересобирается в CI.** Пять статических armhf-библиотек (`libliquid.a`, `libfec.a`, `libfftw3f.a`, `libiio.a`, `libad9361.a`) — результат `scripts/build_deps.sh` (~15–20 мин, нужен интернет и sudo). Пересобирать их в образе cross-builder'а на каждый билд образа избыточно: они `.a`-файлы с ARM-машинным кодом, архитектура и ОС хоста, на котором их скопировали, роли не играет. Поэтому в `runner-image/Dockerfile` (лежит на сервере, `/opt/gitea-runner-armhf/`, вне этого git-репозитория) готовый `~/xarm` с RPi просто кладётся `COPY xarm /root/xarm` — путь совпадает с дефолтным `XARM ?= $(HOME)/xarm` из `makefile`, поэтому `make check-env`/`make all` в CI работают без дополнительных переменных окружения. **Циклические зависимости статических либ потребовали group-linking.** Прямой прогон `make all` на реальном cross-builder'е (не на бумаге) вскрыл, что однопроходного порядка `-lad9361 -liio -lliquid -lfec -lfftw3f` иногда недостаточно — компоновщик требует повторных проходов по кругу `ad9361↔iio`. Исправлено в `makefile` через `-Wl,--start-group ... -Wl,--end-group` вокруг всего списка библиотек (коммит `738fd4a`): линкер пересматривает группу, пока не разрешит все символы, вместо одного прохода слева направо. Это именно то, ради чего 04-cross-build существует — баг был невидим на бумаге и всплыл только на реальной кросс-сборке в CI. **Параллелизм: между runner'ами есть, внутри — нет.** `capacity: 1` у каждого runner'а сериализует job'ы **в пределах одного контейнера**, но `host` и `armhf` — независимые процессы с раздельной очередью, поэтому 01/02 (на `host`) и 03/04 (на `armhf`) в реальности выполняются параллельно друг другу; наблюдалось по чередованию task-id в логах push'а. Внутри `armhf` 03 и 04 всегда строго последовательны (04 ждёт освобождения runner'а после 03). **Клон репозитория — без `actions/checkout`.** Marketplace-экшены недоступны офлайн. Вместо этого каждый job сам клонирует себя через токен job'а: ```yaml - name: Получить код репозитория env: REPO_TOKEN: ${{ github.token }} run: | rm -rf repo git clone --quiet "http://x-access-token:${REPO_TOKEN}@localhost:3000/${GITHUB_REPOSITORY}.git" repo git -C repo checkout --quiet "$GITHUB_SHA" ``` `--quiet` обязателен: без него git печатает URL с токеном в progress-вывод, который попадает в открытые логи Actions. **Уборка рабочего каталога — `if: always()`.** Оба runner'а — общие контейнеры для всех job'ов; мусор от упавшего шага иначе копится в ФС контейнера до пересоздания. **Токен регистрации в логах — разовая гигиена.** `GITEA_RUNNER_REGISTRATION_TOKEN` из `docker-compose.runner-armhf.yml` виден в истории команд/логах настройки. Он однократный (используется только при первой регистрации — `data/.runner` уже существует), но по итогам стоит сбросить в Gitea (Site Administration → Actions → Runners → сброс токена регистрации), чтобы старое значение не годилось для привязки постороннего runner'а к инстансу. ## Как гонять локально, без runner'а ```sh scripts/ci_guardrails.sh # 02 — только bash/grep, где угодно make gftest # 03 — нужен любой hostовый cc make check-env && make all # 04 — нужен arm-linux-gnueabihf-gcc + $XARM # (например, прямо на RPi-хосте) ``` ## Что сознательно не автоматизировано - `scripts/test_file_link.sh` — e2e-передача файла через радио с md5-сверкой: нужны две живые Pluto-платы по SSH/RF. - `scripts/deploy.sh`, `make deploy` — заливка бинарников на платы. - On-board `ofdm_bench`, PER-свипы, соук-тесты — числа с x86 бессмысленны: бюджет CPU в CLAUDE.md посчитан для Cortex-A9 @ 667 МГц, а не для сервера. Правило #6 в CLAUDE.md: при изменении PHY-параметров замерять на плате и обновлять `docs/BENCHMARK.md` в том же коммите — CI это не проверяет. Зелёные 01–04 подтверждают компилируемость, статические инварианты и group-FEC математику. Они **не** подтверждают, что радиолиния действительно передаёт данные по эфиру — это по-прежнему ручной чек-лист выше. ## Файлы - `.gitea/workflows/{01-smoke,02-guardrails,03-gftest,04-cross-build}.yml` - `scripts/ci_guardrails.sh` — логика 02, гоняется и локально - `runner-image/Dockerfile`, `docker-compose.runner-armhf.yml`, `entrypoint.sh` cross-builder'а — **вне этого репозитория**, на сервере в `/opt/gitea-runner-armhf/`. Не версионируются здесь; если понадобится восстановить cross-builder с нуля, конфигурация существует только на сервере.