ci: trigger

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
This commit is contained in:
Dmitry Akimov 2026-04-23 10:02:57 +03:00
parent ab8bdeeb2f
commit a1c6b1a30f
7 changed files with 285 additions and 153 deletions

108
.github/workflows/ci.yml vendored Normal file
View file

@ -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

View file

@ -1,4 +1 @@
---
# Vendored / сгенерированный код — не анализируем вообще
InheritParentConfig: false
Checks: "-*"

View file

@ -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)
{

View file

@ -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 транзитивно

View file

@ -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 <stdbool.h>
#include <stdint.h>
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();
}
}

View file

@ -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')]

85
just/ci_workflow.md Normal file
View file

@ -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]