Відтворювані збірки: зафіксуйте інструменти, зафіксуйте вихідний код, приберіть годинник
Замовник телефонує щодо firmware, яке ви поставили чотирнадцять місяців тому. Ви завантажуєте тег, збираєте, прошиваєте — і помилки немає. Помилку виправлено чи це інше firmware? Якщо ваша збірка не відтворювана, ви не можете цього сказати. Цього не може сказати ніхто, бо бінарний файл, який ви щойно зібрали, відрізняється від поставленого так, що ви не можете ні побачити, ні пояснити: інша версія компілятора, бібліотека, оновлена на ПК, шлях до теки, час доби.
Відтворювана збірка має просте визначення: ті самі вхідні дані дають ті самі байти. Ця стаття показує, які вхідні дані має збірка firmware, як платформа Embedbits їх фіксує (Artifacts Handler), і експеримент, який я провів: дві збірки того самого коду в двох теках у два різні моменти дають два різні бінарні файли. А потім, після трьох змін, вони дають той самий — до останнього біта.
Вхідні дані збірки
Збірка — це функція. Що в неї входить?
| Вхід | Звідки береться | Як зафіксувати |
|---|---|---|
| Вихідний код | Git | тег або коміт, підмодулі зафіксовано їхніми комітами |
| Інструменти (компілятор, лінкер, Ninja, Doxygen) | встановлені на ПК або завантажені | точні версії, з одного джерела |
| Опис збірки (прапорці, linker script) | файли CMake | у репозиторії, нічого не задається вручну |
| Скрипти платформи | підмодуль EmBi_Platform | коміт підмодуля, оновлюється Updater свідомо |
| Середовище | ПК | ніщо у виході не повинно від нього залежати: шлях, час, ім'я користувача |
Перше, третє й четверте лежать у Git, тож зафіксовані за дизайном. Інструменти та середовище — це місця, де збірка зазвичай ламається, і кожне має власну відповідь.
Інструменти: Artifacts Handler
«Встановіть GCC 13.2 і Ninja 1.12» у README — це речення, якого ніхто не виконує буквально. Платформа розв'язує це, роблячи інструменти частиною проєкту. Artifacts Handler — це скрипт CMake, який читає перелік інструментів та їхніх версій із файлу ArtifactsConfig.txt і готує їх. Його документація каже, що єдине програмне забезпечення, потрібне користувачеві, — це Git і CMake, а кожен інший компонент збірки постачає handler. По кроках:
- Клонувати кореневий репозиторій артефактів (без підмодулів, щоб заощадити трафік).
- Знайти в ньому артефакт (кожен інструмент — підмодуль, назви порівнюються без урахування регістру).
- Перейти на запитану версію (shallow clone,
--depth=1). Релізи позначені версією та операційною системою (Win,Unix,DarwinARM). - Встановити його: розпакувати архів у теку кешу.
- Ініціалізувати його: здебільшого він ставить теку інструмента на початок
PATH, тож інструмент проєкту перемагає будь-який інструмент, встановлений у системі.
Конфігурація має один рядок на інструмент, name;version;handler version, наприклад рядки з документації платформи:
ninja;1.12.1;1
gcc-arm-none-eabi;13.2.rel1;2
Перше число — версія самого інструмента (бінарного файлу), друге — версія скрипта handler цього артефакту (вона може змінюватися незалежно, наприклад, коли виправляють спосіб встановлення PATH). Кеш має одну теку на інструмент і на версію, тож кілька версій співіснують поруч, а проєкт, якому потрібна старіша, не заважає проєкту, якому потрібна новіша.
Чому не latest
Handler також приймає latest замість числа. Це зручно й руйнує всю ідею. Я запустив handler з рядком ninja;latest;latest, і він надрукував, у що це перетворилося 5 жовтня 2026 року:
-- Processing artifact ninja with Bin version latest and Core version latest.
-- The version 1.0.0 of artifacts Core part will be used.
-- The version 1.13.2 of artifacts Bin part will be used.
(Запуск зупинився на завантаженні релізу, бо моє середовище не може дістатися GitHub API, але визначення версії відбулося.) latest — це рухома ціль: за документацією, перелік версій завантажується раз на добу, тож той самий файл сьогодні дає 1.13.2, а після наступного релізу — щось інше. Збірка, правильна сьогодні, завтра може бути іншою збіркою, і ніщо у вашому репозиторії про це не скаже. Тому правило просте: latest — для спроби нової версії, а випущений проєкт фіксує кожне число.
Інші налаштування, що допомагають
- Офлайн-режим (
-DOFFLINE_MODE=true): перевіряється лише локальний кеш, нічого не завантажується. Це спосіб довести, що збірка не залежить від мережі, і спосіб збирати там, де мережі немає. - Розташування кешу (
ARTIFACTS_HANDLER_CACHE_PATH) і кореневого репозиторію (ARTIFACTS_HANDLER_ROOT_REPO_URL) можна задати у файлі конфігурації, в середовищі або в командному рядку. Компанія може тримати власне дзеркало артефактів, тож збірка не залежить і від публічного сервера. - Контрольна сума. Кожен реліз артефакту публікується з файлом SHA-256 поруч з архівом, тож пошкоджений або підмінений файл не приймається мовчки.
Інструменти — перша половина відповіді. Друга половина — те, що ви можете перевірити самі, наведеною нижче командою.
Середовище: експеримент
Я взяв приклад проєкту зі статті про CMake і додав файл, який більшість firmware мають у тій чи іншій формі: інформацію про збірку. Він зберігає час збірки та шлях до вихідного файлу, бо колись комусь захотілося бачити у відлагоднику «коли це зібрано»:
const char buildFile[] = __FILE__;
const char buildTime[] = __DATE__ " " __TIME__;
Потім я зібрав той самий код двічі: у двох різних теках (a і b), другу — на три секунди пізніше. Команда strings, застосована до двох бінарних файлів, показує, що всередині:
Oct 5 2026 15:46:02
/tmp/.../repro/a/Application/BuildInfo.c
---
Oct 5 2026 15:46:05
/tmp/.../repro/b/Application/BuildInfo.c
і SHA-256 двох файлів firmware.bin відрізняються:
f2366319462b6fee...
d634c24c3c431799...
Дві збірки одного коду, два різні бінарні файли. Час очевидний, але шлях — це те, що люди забувають: __FILE__ (а також assert() і налагоджувальна інформація) записує в програму повний шлях до файлу, а шлях різний на ПК кожного розробника і в кожному завданні CI. Бінарний файл, зібраний у /home/anna/project, і той самий, зібраний у /builds/job-4711/project, — це два різні файли.
Три зміни
1. Приберіть годинник. Макросам __DATE__ і __TIME__ немає місця у firmware, яке має бути відтворюваним. Компілятор може це забезпечити, щоб ніхто не додав їх назад випадково:
BuildInfo.c:5:26: error: macro "__DATE__" might prevent reproducible builds [-Werror=date-time]
BuildInfo.c:5:39: error: macro "__TIME__" might prevent reproducible builds [-Werror=date-time]
2. Версія — це вхід. Якщо ви хочете знати «що це за firmware», збірка отримує відповідь ззовні: версію, тег або хеш коміту, який система збірки передає як визначення. Той самий код має ту саму версію, отже, ті самі байти:
#ifndef BUILD_VERSION
#define BUILD_VERSION "unknown"
#endif
const char buildVersion[] = BUILD_VERSION;
3. Приберіть шляхи. Опція -ffile-prefix-map=OLD=NEW замінює префікс кожного шляху, який GCC записує у вивід (__FILE__, налагоджувальна інформація, assertions). Тека з вихідним кодом проєкту замінюється крапкою:
# The version of the firmware is an input of the build, not the time of the build.
set(FIRMWARE_VERSION "1.2.3" CACHE STRING "Version of the firmware")
add_compile_definitions(BUILD_VERSION="${FIRMWARE_VERSION}")
# No clock in the binary, and no path of this PC in it.
add_compile_options(${MCU_FLAGS} ${WARNING_FLAGS} ${BUILD_OPTIONS}
-Wdate-time -Werror=date-time
-ffile-prefix-map=${CMAKE_SOURCE_DIR}=.)
З цими трьома змінами та сама перевірка дає:
first : 1bedc9d2b1d78452a77fa3440851d5ed3d9f6568d96edd4c4a18be3b828dc313
second: 1bedc9d2b1d78452a77fa3440851d5ed3d9f6568d96edd4c4a18be3b828dc313
REPRODUCIBLE
.bin ідентичний, як і .elf, у release (-Os -g0) і також у debug-збірці (-Og -g3), чия налагоджувальна інформація повна шляхів. З -ffile-prefix-map strings бінарного файлу показує ./Application/BuildInfo.c і версію 1.2.3, і нічого про ПК, що його зібрав.
Тест, який не старіє
Недостатньо зробити збірку відтворюваною один раз, бо перший же __TIME__, який хтось додасть, її зруйнує. Тож зробіть із цього тест: зберіть код двічі й порівняйте. Це скрипт, яким я отримав числа вище; він копіює проєкт у дві теки, збирає обидві з паузою між ними та порівнює SHA-256:
#!/usr/bin/env bash
# Builds the same source twice, in two different folders, and compares the firmware.
set -euo pipefail
source_dir="$(cd "$1" && pwd)"
config="${2:-Release}"
work="$(mktemp -d)"
for copy in first second; do
cp -r "$source_dir" "$work/$copy"
rm -rf "$work/$copy/build"
cmake -S "$work/$copy" -B "$work/$copy/build" -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DCMAKE_BUILD_TYPE="$config" > /dev/null
cmake --build "$work/$copy/build" > /dev/null
sleep 2 # the second build is later and is in another folder
done
first="$(sha256sum "$work/first/build/firmware.bin" | cut -d' ' -f1)"
second="$(sha256sum "$work/second/build/firmware.bin" | cut -d' ' -f1)"
echo "first : $first"
echo "second: $second"
if [ "$first" = "$second" ]; then echo "REPRODUCIBLE"; else echo "NOT REPRODUCIBLE"; exit 1; fi
Запущений у CI при кожному злитті, він є вартовим. Я використав його й на зламаній версії інформації про збірку, щоб переконатися, що він може впасти, і він упав (NOT REPRODUCIBLE, код виходу 1, два різні хеші). Тест, який ніколи не падав, — це тест, якого ви не знаєте.
Що ще ламає відтворювану збірку
Я перевірив три речі вище. Далі йдуть інші добре відомі джерела проблем, про які варто пам'ятати (у прикладі вони мені не знадобилися, тож я їх лише називаю):
- Порядок файлів, що походить від
file(GLOB ...)або зі списку теки. Вихідні файли слід перелічувати явно, як у файлах CMake модулів платформи. - Позначка часу в архіві (статична бібліотека, створена
ar). Сучасні версії інструментів мають детермінований режим, але старий toolchain може його не мати. - Згенерований код. Моки й раннери unit-тестів або linker script, згенерований для MCU, мають генеруватися однаково з тих самих вхідних даних. Правила — сортування та жодних позначок часу у виході.
- Сам компілятор. Та сама версія того самого компілятора з двох джерел (пакет дистрибутива та архів вендора) може відрізнятися бібліотеками, які підключає. Це причина брати компілятор з одного місця, у чому й полягає вся суть артефактів.
- Збірка з різною кількістю потоків. Коректна система збірки дає той самий результат з
-j1і-j16, і тест на це — дешеве доповнення до наведеного вище.
Чекліст
- Кожен інструмент зафіксований на точній версії в
ArtifactsConfig.txt. Жодногоlatestу релізі. - Підмодулі (BSP, платформа) зафіксовано комітами, а оновлення — це видимий коміт.
- Ніщо у firmware не залежить від часу чи шляху:
-Werror=date-time,-ffile-prefix-map. - Версія — параметр збірки, виведений з тега, і зберігається у firmware.
- CI збирає двічі й порівнює. Також час від часу збирає офлайн, із кешу.
- Поставлений бінарний файл зберігається разом із його SHA-256 і тегом, тож на запитання «чи це той самий файл» є відповідь без перезбирання.
Нагорода — речення, яке ви скажете по телефону замовнику: «Я зібрав саме ваше firmware, і помилка в ньому (не) є».