From a1c6b1a30ff8b61f80e2f1c1b666853943acaee5 Mon Sep 17 00:00:00 2001 From: Ezra Maccabee Date: Thu, 23 Apr 2026 10:02:57 +0300 Subject: [PATCH] ci: trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci: rerun ci: rerun ci: rerun3 ci: rerun4 ci: rerun5 ci: rerun6 Update ci.yml Update ci.yml Update ci.yml №1 ci.yml Update ci.yml --- .github/workflows/ci.yml | 108 ++++++++++++++++++++++++ bsp/generated/.clang-tidy | 3 - bsp/qspi_flash/src/qspi_flash.c | 70 ++++++++-------- firmware/test/CMakeLists.txt | 10 ++- firmware/test/src/main.c | 141 ++++++++++---------------------- just/ci.just | 21 ++--- just/ci_workflow.md | 85 +++++++++++++++++++ 7 files changed, 285 insertions(+), 153 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 just/ci_workflow.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..92d7d0c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: CI + +on: + push: + branches: + - dev + - main + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build in devcontainer + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build devcontainer image with cache + uses: docker/build-push-action@v7 + with: + context: . + file: .devcontainer/Dockerfile + tags: tft-devcontainer-ci:latest + load: true + cache-from: type=gha,scope=tft-devcontainer + cache-to: type=gha,mode=max,scope=tft-devcontainer + + - name: Show tool versions in container + run: | + docker run --rm --user root -v "$GITHUB_WORKSPACE":/workspace -w /workspace tft-devcontainer-ci:latest bash -lc 'just --version && cmake --version && ninja --version && arm-none-eabi-gcc --version | head -n 1 && uv --version' + + - name: Sync host Python tools inside container + run: | + docker run --rm --user root -v "$GITHUB_WORKSPACE":/workspace -w /workspace tft-devcontainer-ci:latest bash -lc 'cd tools/host && uv sync' + + - name: Run CI build inside container + run: | + docker run --rm --user root -v "$GITHUB_WORKSPACE":/workspace -w /workspace tft-devcontainer-ci:latest bash -lc 'just ci::build' + + - name: Upload build directory + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-tree + path: build/ + if-no-files-found: warn + retention-days: 7 + + test: + name: Host tests in devcontainer + runs-on: ubuntu-latest + timeout-minutes: 90 + needs: build + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build devcontainer image with cache + uses: docker/build-push-action@v7 + with: + context: . + file: .devcontainer/Dockerfile + tags: tft-devcontainer-ci:latest + load: true + cache-from: type=gha,scope=tft-devcontainer + cache-to: type=gha,mode=max,scope=tft-devcontainer + + - name: Download build directory artifact + uses: actions/download-artifact@v4 + with: + name: build-tree + path: build/ + + - name: Sync host Python tools inside container + run: | + docker run --rm --user root -v "$GITHUB_WORKSPACE":/workspace -w /workspace tft-devcontainer-ci:latest bash -lc 'cd tools/host && uv sync' + + - name: Run host tests inside container + run: | + docker run --rm --user root -v "$GITHUB_WORKSPACE":/workspace -w /workspace tft-devcontainer-ci:latest bash -lc 'just ci::test' + + - name: Upload test logs and build directory + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-artifacts + path: | + build/ + if-no-files-found: warn + retention-days: 7 \ No newline at end of file diff --git a/bsp/generated/.clang-tidy b/bsp/generated/.clang-tidy index 0275f28..be30322 100644 --- a/bsp/generated/.clang-tidy +++ b/bsp/generated/.clang-tidy @@ -1,4 +1 @@ ---- -# Vendored / сгенерированный код — не анализируем вообще -InheritParentConfig: false Checks: "-*" \ No newline at end of file diff --git a/bsp/qspi_flash/src/qspi_flash.c b/bsp/qspi_flash/src/qspi_flash.c index 38ff77d..3a0e359 100644 --- a/bsp/qspi_flash/src/qspi_flash.c +++ b/bsp/qspi_flash/src/qspi_flash.c @@ -186,6 +186,8 @@ kFLEXSPI_Command_READ_SDR, kFLEXSPI_4PAD, 0x04U), \ } +static const uint8_t BYTE_MASK = 0xFFU; + /** @brief LUT для W25Q64/128 — стандартные 3-byte opcodes. */ static const uint32_t K_LUT_3B[LUT_TOTAL_WORDS] = BUILD_LUT(0x20U, 0x52U, 0xD8U, 0x32U, 0x6BU, ADDR_BITS_24); @@ -204,11 +206,11 @@ static uint32_t g_s_flash_size = 0U; /**< Размер Flash в байтах. /* NOLINTNEXTLINE(cppcoreguidelines-macro-usage) */ AT_QUICKACCESS_SECTION_CODE(static uint32_t qspi_irq_lock(void)) { - const uint32_t primask = __get_PRIMASK(); + const uint32_t PRIMASK = __get_PRIMASK(); __disable_irq(); __DSB(); __ISB(); - return primask; + return PRIMASK; } /* NOLINTNEXTLINE(cppcoreguidelines-macro-usage) */ @@ -309,15 +311,15 @@ AT_QUICKACCESS_SECTION_CODE(static status_t qspi_read_chunk(uint8_t *p_dst, uint } while ((intr & (uint32_t) kFLEXSPI_IpRxFifoWatermarkAvailableFlag) == 0U); /* NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-constant-array-index) */ - for (uint32_t w = 0U; w < wm_words; w++) + for (uint32_t wm_word = 0U; wm_word < wm_words; wm_word++) { /* NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-constant-array-index) */ - const uint32_t WORD = QSPI_BASE->RFDR[w]; - const uint32_t BASE = w * QSPI_RFDR_WORD_BYTES; - p_dst[BASE + 0U] = (uint8_t) (WORD & 0xFFU); - p_dst[BASE + 1U] = (uint8_t) ((WORD >> 8U) & 0xFFU); - p_dst[BASE + 2U] = (uint8_t) ((WORD >> 16U) & 0xFFU); - p_dst[BASE + 3U] = (uint8_t) ((WORD >> 24U) & 0xFFU); + const uint32_t WORD = QSPI_BASE->RFDR[wm_word]; + const uint32_t BASE = wm_word * QSPI_RFDR_WORD_BYTES; + p_dst[BASE + 0U] = (uint8_t) (WORD & BYTE_MASK); + p_dst[BASE + 1U] = (uint8_t) ((WORD >> 8U) & BYTE_MASK); + p_dst[BASE + 2U] = (uint8_t) ((WORD >> 16U) & BYTE_MASK); + p_dst[BASE + 3U] = (uint8_t) ((WORD >> 24U) & BYTE_MASK); } QSPI_BASE->INTR = (uint32_t) kFLEXSPI_IpRxFifoWatermarkAvailableFlag; return kStatus_Success; @@ -353,16 +355,16 @@ AT_QUICKACCESS_SECTION_CODE(static status_t qspi_read_tail(uint8_t *p_dst, uint3 } uint32_t bytes_left = remain; - for (uint32_t w = 0U; w < WORDS_NEEDED; w++) + for (uint32_t word = 0U; word < WORDS_NEEDED; word++) { /* NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-constant-array-index) */ - const uint32_t WORD = QSPI_BASE->RFDR[w]; + const uint32_t WORD = QSPI_BASE->RFDR[word]; const uint32_t BYTES = (bytes_left < QSPI_RFDR_WORD_BYTES) ? bytes_left : QSPI_RFDR_WORD_BYTES; - const uint32_t BASE = w * QSPI_RFDR_WORD_BYTES; - for (uint32_t b = 0U; b < BYTES; b++) + const uint32_t BASE = word * QSPI_RFDR_WORD_BYTES; + for (uint32_t byte = 0U; byte < BYTES; byte++) { - p_dst[BASE + b] = (uint8_t) ((WORD >> (8U * b)) & 0xFFU); + p_dst[BASE + byte] = (uint8_t) ((WORD >> (8U * byte)) & BYTE_MASK); } bytes_left -= BYTES; } @@ -373,22 +375,22 @@ AT_QUICKACCESS_SECTION_CODE(static status_t qspi_read_tail(uint8_t *p_dst, uint3 /* NOLINTNEXTLINE(cppcoreguidelines-macro-usage) */ AT_QUICKACCESS_SECTION_CODE(static status_t qspi_read_fifo(uint8_t *p_buf, uint32_t len)) { - const uint32_t wm_units = + const uint32_t WM_UNITS = ((QSPI_BASE->IPRXFCR & FLEXSPI_IPRXFCR_RXWMRK_MASK) >> FLEXSPI_IPRXFCR_RXWMRK_SHIFT) + 1U; - const uint32_t wm_words = wm_units * QSPI_WM_UNIT_WORDS; - const uint32_t wm_bytes = wm_words * QSPI_RFDR_WORD_BYTES; + const uint32_t WM_WORDS = WM_UNITS * QSPI_WM_UNIT_WORDS; + const uint32_t WM_BYTES = WM_WORDS * QSPI_RFDR_WORD_BYTES; uint32_t offset = 0U; - while ((len - offset) >= wm_bytes) + while ((len - offset) >= WM_BYTES) { /* NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) */ - const status_t ERR = qspi_read_chunk(p_buf + offset, wm_words); + const status_t ERR = qspi_read_chunk(p_buf + offset, WM_WORDS); if (ERR != kStatus_Success) { return ERR; } - offset += wm_bytes; + offset += WM_BYTES; } if (offset < len) @@ -407,10 +409,10 @@ AT_QUICKACCESS_SECTION_CODE(static status_t qspi_write_fifo(const uint8_t *p_buf { const uint8_t *p = p_buf; uint32_t remain = len; - const uint32_t wm_units = + const uint32_t WM_UNITS = ((QSPI_BASE->IPTXFCR & FLEXSPI_IPTXFCR_TXWMRK_MASK) >> FLEXSPI_IPTXFCR_TXWMRK_SHIFT) + 1U; - const uint32_t wm_words = wm_units * QSPI_WM_UNIT_WORDS; - const uint32_t wm_bytes = wm_words * QSPI_RFDR_WORD_BYTES; + const uint32_t WM_WORDS = WM_UNITS * QSPI_WM_UNIT_WORDS; + const uint32_t WM_BYTES = WM_WORDS * QSPI_RFDR_WORD_BYTES; while (remain > 0U) { @@ -426,26 +428,26 @@ AT_QUICKACCESS_SECTION_CODE(static status_t qspi_write_fifo(const uint8_t *p_buf } } while ((intr & (uint32_t) kFLEXSPI_IpTxFifoWatermarkEmptyFlag) == 0U); - const uint32_t CHUNK = (remain > wm_bytes) ? wm_bytes : remain; + const uint32_t CHUNK = (remain > WM_BYTES) ? WM_BYTES : remain; const uint32_t WR_WORDS = (CHUNK + QSPI_RFDR_WORD_BYTES - 1U) / QSPI_RFDR_WORD_BYTES; - if (WR_WORDS > wm_words) + if (WR_WORDS > WM_WORDS) { return kStatus_FLEXSPI_IpCommandSequenceError; } - for (uint32_t w = 0U; w < WR_WORDS; w++) + for (uint32_t wr_word = 0U; wr_word < WR_WORDS; wr_word++) { - const uint32_t OFF = w * QSPI_RFDR_WORD_BYTES; + const uint32_t OFF = wr_word * QSPI_RFDR_WORD_BYTES; const uint32_t BYTES = ((CHUNK - OFF) < QSPI_RFDR_WORD_BYTES) ? (CHUNK - OFF) : QSPI_RFDR_WORD_BYTES; uint32_t word = 0U; - for (uint32_t b = 0U; b < BYTES; b++) + for (uint32_t byte = 0U; byte < BYTES; byte++) { - word |= ((uint32_t) *p) << (8U * b); + word |= ((uint32_t) *p) << (8U * byte); p++; } /* NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-constant-array-index) */ - QSPI_BASE->TFDR[w] = word; + QSPI_BASE->TFDR[wr_word] = word; } remain -= CHUNK; QSPI_BASE->INTR = (uint32_t) kFLEXSPI_IpTxFifoWatermarkEmptyFlag; @@ -662,7 +664,7 @@ AT_QUICKACCESS_SECTION_CODE(static bsp_status_t qspi_do_erase(uint32_t seq_idx, { return BSP_ERR_HW; } - const uint32_t primask = qspi_irq_lock(); + const uint32_t PRIMASK = qspi_irq_lock(); qspi_ahb_disable(); status_t result = qspi_write_enable(addr); @@ -677,7 +679,7 @@ AT_QUICKACCESS_SECTION_CODE(static bsp_status_t qspi_do_erase(uint32_t seq_idx, qspi_sw_reset(); qspi_ahb_enable(); - qspi_irq_unlock(primask); + qspi_irq_unlock(PRIMASK); return to_bsp(result); } @@ -748,7 +750,7 @@ AT_QUICKACCESS_SECTION_CODE(bsp_status_t bsp_qspi_read_jedec_id(bsp_qspi_jedec_t { return BSP_ERR_HW; } - const uint32_t primask = qspi_irq_lock(); + const uint32_t PRIMASK = qspi_irq_lock(); qspi_ahb_disable(); uint8_t buf[SR_READ_LEN] = { 0U }; @@ -756,7 +758,7 @@ AT_QUICKACCESS_SECTION_CODE(bsp_status_t bsp_qspi_read_jedec_id(bsp_qspi_jedec_t qspi_sw_reset(); qspi_ahb_enable(); - qspi_irq_unlock(primask); + qspi_irq_unlock(PRIMASK); if (RESULT != kStatus_Success) { diff --git a/firmware/test/CMakeLists.txt b/firmware/test/CMakeLists.txt index d87d125..f77a5c5 100644 --- a/firmware/test/CMakeLists.txt +++ b/firmware/test/CMakeLists.txt @@ -17,12 +17,14 @@ add_executable( target_include_directories(firmware_test PRIVATE src/) +# __STARTUP_INITIALIZE_RAMFUNCTION - очистка секции .ram_function и копирование +# туда данных из __ram_function_flash_start; __STARTUP_INITIALIZE_NONCACHEDATA - +# инициализация некешируемое секции нулями target_compile_definitions( ${TARGET_NAME} - PRIVATE BSP_UART_HOST_RX_BUFFER_SIZE=512 - BOARD_MPU_SDRAM=1 - __STARTUP_INITIALIZE_RAMFUNCTION - __STARTUP_CLEAR_BSS) + PRIVATE BSP_UART_HOST_RX_BUFFER_SIZE=512 BOARD_MPU_SDRAM=1 + __STARTUP_INITIALIZE_RAMFUNCTION __STARTUP_CLEAR_BSS + __STARTUP_INITIALIZE_NONCACHEDATA) # # ----------------------------------------------------------------------------- # Зависимости — только то что нужно для входного контроля bsp_board транзитивно diff --git a/firmware/test/src/main.c b/firmware/test/src/main.c index d9bae15..52503fb 100644 --- a/firmware/test/src/main.c +++ b/firmware/test/src/main.c @@ -3,120 +3,63 @@ * @file main.c * @brief firmware_test — точка входа. * - * Минимальный smoke-тест QSPI в XIP-конфигурации: - * 1) Инициализация QSPI. - * 2) Чтение JEDEC ID. - * 3) Erase одного сектора в конце Flash. - * 4) Запись одной страницы и чтение назад. - * 5) Сравнение буферов и LED-индикация PASS/FAIL. + * Bare-metal входной контроль платы. + * Единственный канал хост↔таргет: USB CDC ACM (J2). + * Протокол: JSON-lines через cli.c. + * + * Последовательность старта: + * 1. board_hw_init() — тактирование, MPU, кэш, пины + * 2. bsp_tick_init() — SysTick 1 мс + * 3. bsp_led_init() — оба LED выключены + * 4. bsp_usb_cdc_init() — PHY + стек + NVIC + * 5. Ожидание CDC ready — LED_HEARTBEAT мигает + * 6. cli_init() — сброс буфера + * 7. READY → хост — JSON сигнал готовности + * 8. Главный цикл — poll + cli_process */ #include "board.h" #include "bsp/led.h" -#include "bsp/qspi_flash.h" #include "bsp/tick.h" +#include "bsp/usb_cdc.h" +#include "cli.h" +#include "protocol.h" +#include "test_runner.h" #include #include -static volatile uint32_t g_qspi_test_step = 0U; -static volatile uint32_t g_qspi_test_addr = 0U; -static volatile uint32_t g_qspi_test_mismatch_index = 0xFFFFFFFFUL; -static volatile bsp_qspi_jedec_t g_qspi_test_jedec = { 0U, 0U }; - -static bool bytes_equal(const uint8_t *p_lhs, const uint8_t *p_rhs, size_t size) -{ - for (size_t i = 0U; i < size; i++) - { - if (p_lhs[i] != p_rhs[i]) - { - g_qspi_test_mismatch_index = (uint32_t) i; - return false; - } - } - return true; -} - int main(void) { - const uint32_t ERROR_BLINK_MS = 80U; - const uint32_t PASS_BLINK_MS = 350U; - + const uint32_t CONNECT_BLINK_MS = 200U; + const uint32_t ERROR_BLINK_MS = 250; board_hw_init(); + bsp_led_init(); bsp_tick_init(); - g_qspi_test_step = 1U; - if (bsp_qspi_init() != BSP_OK) - { - goto FAIL; - } - - g_qspi_test_step = 2U; - if (bsp_qspi_read_jedec_id((bsp_qspi_jedec_t *) &g_qspi_test_jedec) != BSP_OK) - { - goto FAIL; - } - - if (g_qspi_test_jedec.manufacturer_id != BSP_QSPI_MFR_WINBOND) - { - goto FAIL; - } - - g_qspi_test_step = 3U; - const uint32_t flash_size = bsp_qspi_flash_size(); - if (flash_size < BSP_QSPI_SECTOR_SIZE) - { - goto FAIL; - } - g_qspi_test_addr = flash_size - BSP_QSPI_SECTOR_SIZE; - - uint8_t tx[BSP_QSPI_PAGE_SIZE]; - uint8_t rx[BSP_QSPI_PAGE_SIZE]; - - for (uint32_t i = 0U; i < BSP_QSPI_PAGE_SIZE; i++) - { - tx[i] = (uint8_t) (0xA5U ^ i ^ g_qspi_test_jedec.manufacturer_id ^ - (uint8_t) g_qspi_test_jedec.device_id); - rx[i] = 0U; - } - - g_qspi_test_step = 4U; - if (bsp_qspi_erase_sector(g_qspi_test_addr) != BSP_OK) - { - goto FAIL; - } - - g_qspi_test_step = 5U; - if (bsp_qspi_write_page(g_qspi_test_addr, tx) != BSP_OK) - { - goto FAIL; - } - - g_qspi_test_step = 6U; - if (bsp_qspi_read(g_qspi_test_addr, rx, sizeof(rx)) != BSP_OK) - { - goto FAIL; - } - - g_qspi_test_step = 7U; - if (!bytes_equal(tx, rx, sizeof(rx))) - { - goto FAIL; - } - - g_qspi_test_step = 8U; - bsp_led_on(LED_APP); - while (1) - { - bsp_led_toggle(LED_HEARTBEAT); - bsp_delay(PASS_BLINK_MS); - } - -FAIL: - g_qspi_test_step |= 0x80000000UL; - while (1) + if (bsp_usb_cdc_init() != BSP_OK) { bsp_led_toggle(LED_HEARTBEAT); bsp_delay(ERROR_BLINK_MS); } -} + + /* Ожидать подключения хоста. LED_HEARTBEAT мигает — прошивка жива. */ + while (!bsp_usb_cdc_is_ready()) + { + bsp_usb_cdc_poll(); + bsp_led_toggle(LED_HEARTBEAT); + bsp_delay(CONNECT_BLINK_MS); + } + + bsp_led_on(LED_APP); + + cli_init(); + test_runner_init(); + protocol_send_session_start(); + while (1) + { + bsp_usb_cdc_poll(); + cli_process(); + test_runner_process(); + } +} \ No newline at end of file diff --git a/just/ci.just b/just/ci.just index 544081b..cfc7928 100644 --- a/just/ci.just +++ b/just/ci.just @@ -2,22 +2,20 @@ # ci.just — CI/CD пайплайны # Выполняется в CI-окружении (GitHub Actions, GitLab CI, Jenkins) # -# Предполагается что CI работает внутри devcontainer. +# Stage 1: только reproducible шаги без железа: +# - сборка +# - host unit-тесты +# lint/release будут включаться по мере стабилизации пайплайна. # ============================================================================= -# === Импорт общих переменных === BUILD_DIR := env('BUILD_DIR', justfile_directory() / 'build') set working-directory := '..' -# ============================================================================= -# ГРУППА: ci — полные пайплайны -# ============================================================================= - -[doc('Полный CI pipeline: сборка + тесты + линтеры')] +[doc('Минимальный CI pipeline: сборка + host-тесты')] [group('ci')] -pipeline: build test lint - @echo " ✅ CI pipeline complete" +pipeline: build test + @echo " ✅ Minimal CI pipeline complete" [doc('Собрать все проекты в Release')] [group('ci')] @@ -32,10 +30,7 @@ test: [doc('Запустить линтеры и статический анализ')] [group('ci')] lint: - echo "TODO" - -#just build::lint -#just build::check + @echo "TODO: enable just build::lint / just build::check after local stabilization" [doc('Собрать HAB Release-артефакты для выкладки')] [group('ci')] diff --git a/just/ci_workflow.md b/just/ci_workflow.md new file mode 100644 index 0000000..23cee4d --- /dev/null +++ b/just/ci_workflow.md @@ -0,0 +1,85 @@ +# Текущее состояние CI/CD workflow для `tft_manufacture_test` + +## Обзор + +В репозитории настроен рабочий GitHub Actions pipeline, который успешно запускается на событиях `push`, `pull_request` и при ручном запуске через `workflow_dispatch`.[cite:23][cite:99] Пайплайн специально привязан к реальному окружению разработки проекта: сборка и тесты выполняются внутри того же devcontainer-образа, который описан в `.devcontainer/Dockerfile`, а не в вручную собранной среде на `ubuntu-latest`.[cite:125] + +Такой подход уже устранил основные проблемы, которые проявились при первичном поднятии CI: отсутствие ARM toolchain, отсутствие `ninja`, установка неправильного `just` и несовместимость прав доступа при работе с bind-mounted workspace внутри Docker.[cite:125][cite:119][cite:127] + +## Текущая архитектура + +Workflow разделён на две job’ы: `build` и `test`.[cite:156] Такое разделение делает пайплайн проще для сопровождения, позволяет отдельно анализировать результаты стадии сборки и создаёт хороший фундамент для следующих этапов — `lint`, coverage, release packaging и аппаратных проверок.[cite:156][cite:147] + +Среда выполнения строится из Dockerfile devcontainer-а проекта, в котором уже определены ARM GCC toolchain в `/opt/arm-toolchain`, обновлённый `PATH`, а также установлены `cmake`, `ninja-build`, `clang-17`, `uv` и `just`.[cite:125] Поскольку все ключевые зависимости уже зафиксированы именно там, использование этого же образа в CI делает поведение раннера максимально близким к локальной разработке.[cite:125] + +## Как работает workflow + +Workflow реагирует на три типа событий: `push`, `pull_request` и `workflow_dispatch`.[cite:23][cite:99] Это даёт удобный баланс между автоматической проверкой обычных коммитов и возможностью вручную перезапускать pipeline для отладки инфраструктурных или нестабильных падений без обязательного нового изменения в коде.[cite:99][cite:106] + +Внутри job используется Docker Buildx и `docker/build-push-action`, а кэширование слоёв контейнера подключено через backend GitHub Actions cache с помощью `cache-from: type=gha` и `cache-to: type=gha`.[cite:142][cite:143][cite:146] За счёт этого повторные прогоны не пересобирают devcontainer с нуля, а переиспользуют уже собранные Docker-слои, что заметно ускоряет пайплайн после первого успешного заполнения кэша.[cite:142][cite:143] + +## Что делает job `build` + +Job `build` выполняет checkout репозитория, инициализирует Buildx, собирает devcontainer image с поддержкой кэша, проверяет версии инструментов внутри контейнера, синхронизирует Python tooling в `tools/host` через `uv sync`, а затем запускает `just ci::build` внутри контейнера.[cite:142][cite:143][cite:125] После успешной сборки workflow выгружает директорию `build/` как GitHub artifact, чтобы результаты можно было сохранить и использовать на следующих стадиях.[cite:156][cite:153] + +Важная техническая деталь — команды внутри контейнера запускаются с `--user root`.[cite:127][cite:135] Это требуется из-за того, что `GITHUB_WORKSPACE` подключается в контейнер как bind mount, а в GitHub Actions non-root пользователь внутри Docker часто не получает права на запись в такую директорию; ранее это как раз ломало создание `.venv` во время `uv sync`.[cite:127][cite:129][cite:135] + +## Что делает job `test` + +Job `test` зависит от `build`, скачивает artifact с директорией `build/`, заново поднимает тот же devcontainer image с использованием cached layers, синхронизирует `tools/host` и запускает `just ci::test` внутри контейнера.[cite:156][cite:142][cite:143] На практике это означает, что host unit-тесты работают в той же программной среде, что и стадия сборки, но при этом выделены в отдельный CI-этап.[cite:125][cite:156] + +Так как GitHub-hosted runner’ы эфемерны, сам Docker image не передаётся напрямую между job’ами.[cite:143] Поэтому обмен между `build` и `test` организован двумя способами: ускорение повторной сборки образа идёт через Docker layer cache, а результаты проекта передаются через GitHub artifacts.[cite:143][cite:153][cite:156] + +## Почему эта схема хорошо подходит проекту + +Этот репозиторий нельзя считать обычным desktop C-проектом: он завязан на фиксированное расположение embedded toolchain и на специально подготовленный devcontainer.[cite:125] Ранние попытки выполнять pipeline прямо на runner’е падали, потому что проект ожидал наличие `/opt/arm-toolchain`, установленный `Ninja` и современный бинарник `just`, который понимает атрибуты вроде `[doc(...)]`.[cite:125][cite:97][cite:119] + +Перенос CI внутрь devcontainer image устраняет этот класс расхождений и делает Dockerfile единым источником истины для окружения, версий и путей.[cite:125] Это упрощает дальнейшее сопровождение: при изменении инструментария достаточно обновить Dockerfile, и эти же изменения автоматически начнут действовать как локально, так и в CI.[cite:125] + +## Чего workflow пока не делает + +Текущий pipeline пока не включает обязательную стадию `lint` и статический анализ, потому что в `just/ci.just` для `lint` пока ещё оставлена заглушка, а не полноценный вызов `clang-format` и `clang-tidy`.[cite:1] Он также пока не формирует release/HAB artifacts в CI, хотя в репозитории уже есть соответствующие рецепты `just ci::release` и связанные сборочные шаги.[cite:1] + +Также pipeline пока не запускает HIL-сценарии.[cite:1] Это ожидаемо и правильно для текущего этапа: hardware-in-the-loop проверки требуют физического оборудования и в дальнейшем должны выполняться отдельно на self-hosted runner рядом с bench-стендом, а не на GitHub-hosted машинах.[cite:1] + +## Сильные стороны текущего решения + +У текущей реализации уже есть несколько сильных сторон: + +- Она воспроизводима, потому что сборка и тесты выполняются в том же образе, что и локальная разработка.[cite:125] +- Она ускоряется на повторных прогонах за счёт Docker layer caching через GitHub Actions cache backend.[cite:142][cite:143] +- Она модульна, потому что `build` и `test` вынесены в отдельные job’ы, связанные артефактами.[cite:156][cite:153] +- Она удобна для отладки, потому что build outputs сохраняются как artifacts, а workflow можно запускать вручную через `workflow_dispatch`.[cite:99][cite:156] +- Она хорошо вписана в структуру проекта, потому что использует уже существующие `just`-точки входа, а не дублирует build-логику в YAML.[cite:1] + +## Текущие ограничения + +Главное ограничение сейчас состоит в том, что pipeline проверяет собираемость и host unit-тесты, но ещё не закрывает style gate, static analysis, coverage и release packaging.[cite:1] Второе ограничение — Docker image пересобирается в каждой job, поэтому даже при наличии кэша остаётся неизбежный накладной расход по времени по сравнению с вариантом, где используется заранее опубликованный образ из registry.[cite:143] + +Есть и архитектурное ограничение GitHub-hosted runner’ов для аппаратной части.[cite:127][cite:1] Прошивка через USB, pyOCD-сценарии и управление стендом должны в будущем быть вынесены в отдельную hardware lane на self-hosted runner.[cite:1] + +## Рекомендуемые следующие шаги + +### Шаг 1 — добавить `lint` job + +Самое логичное следующее улучшение — реализовать полноценную стадию `lint` в `just/ci.just` и подключить отдельную job в workflow.[cite:1] В эту стадию стоит включить `clang-format --dry-run --Werror`, `clang-tidy` и необходимые исключения для generated-кода или vendor-зависимостей, чтобы избежать лишнего шума в CI.[cite:1] + +### Шаг 2 — добавить coverage + +После стабилизации `lint` полезно подключить экспорт coverage для host-тестов.[cite:1] В `ci.just` уже существует закрытый рецепт `_coverage`, и его можно развить до генерации XML-отчёта, выгрузки артефактов и последующей интеграции с внешним coverage-сервисом, если это будет нужно.[cite:1] + +### Шаг 3 — выделить release workflow + +Release packaging лучше оформлять отдельным workflow или отдельной gated job, запускаемой только по тегам, на `main` или вручную через `workflow_dispatch`.[cite:1] Это позволит не замедлять обычный PR-цикл, но при этом использовать `just ci::release` и публикацию HAB-артефактов тогда, когда это действительно нужно.[cite:1] + +### Шаг 4 — публиковать devcontainer image в GHCR + +Следующий сильный шаг по оптимизации — публиковать devcontainer image в GHCR и затем запускать CI уже на базе заранее собранного образа, а не пересобирать его в каждой job.[cite:142][cite:146] Это ещё сильнее сократит время старта pipeline и сделает масштабирование на `lint`, `coverage` и `release` заметно проще.[cite:142][cite:143] + +### Шаг 5 — добавить self-hosted HIL lane + +Финальное крупное направление развития — выделенный аппаратный workflow на self-hosted runner с доступом к MCU-Link, target board и M5StampPLC.[cite:1] Такую lane лучше запускать вручную, по расписанию или по label-триггеру, а не делать обязательной для каждого PR, поскольку аппаратные проверки медленнее, менее стабильны и по природе отличаются от быстрых software regression checks.[cite:1] + +## Целевое состояние + +Зрелая версия этого CI/CD контура, вероятно, будет состоять из четырёх независимых линий: быстрый PR-pipeline (`build`, `test`, `lint`), optional coverage reporting, отдельный release workflow и отдельный self-hosted HIL pipeline.[cite:1][cite:156] Такая структура сохранит короткий feedback loop для обычной разработки и одновременно покроет полный жизненный цикл embedded-проекта: от изменений в исходниках до production artifacts и аппаратной валидации на стенде.[cite:1]