8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 293,
|
||
"slug": "editorial-2019-11-mechanism-reproducible-builds",
|
||
"title": "Почему один commit даёт разные bundle: механизм воспроизводимой сборки",
|
||
"excerpt": "Одинаковый commit не гарантирует одинаковый bundle. Разбираем границы входов сборки, канонический manifest и способ найти первый байт, который расходится между двумя чистыми прогонами.",
|
||
"contentHtml": "<p>Разработчик собрал release и получил <code>main.abc.js</code>. Коллега взял тот же commit и получил <code>main.xyz.js</code>. Приложение запускается в обоих случаях, поэтому проблему легко отложить. Цена ошибки проявится при rollback, проверке cache и расследовании инцидента: команда не знает, какой набор файлов проверяли, а один номер версии скрывает два разных результата.</p>\n<p>Разные bundle не доказывают ошибку webpack. Сначала нужно сравнить входы и найти первый файл, в котором расходятся байты. Тезис статьи простой: воспроизводимая сборка — это свойство конкретной команды с явным контрактом входов. Если два чистых прогона получают один commit, одно дерево зависимостей, один runtime, одну конфигурацию и одну generated data, их deployable output должен совпасть. Если вход не зафиксирован, одинаковый Git hash ничего не доказывает.</p>\n<h2>Сборка — функция с внешними аргументами</h2>\n<p>Полезная модель выглядит так: <code>artifact = build(source, dependencies, runtime, config, environment, generatedData)</code>. Git фиксирует source и часть config. Lockfile фиксирует разрешённое дерево пакетов, если сборка действительно использует этот lockfile. Runtime включает Node, npm и системные особенности native-зависимостей. Config включает webpack mode, public path, entry и настройки plugins. Environment включает locale, timezone и разрешённые переменные. Generated data включает дату, список файлов, случайный идентификатор или ответ внешнего сервиса.</p>\n<p>Эта модель не требует заморозить всю машину. Она задаёт вопрос для каждого отличившегося байта: какой аргумент его породил? Ответ должен вести к проверке или к явному исключению из контракта. Молчаливое исключение не делает сборку воспроизводимой. Оно только прячет часть результата.</p>\n<table>\n<caption>Граница входов: симптом, след и владелец действия</caption>\n<thead><tr><th scope=\"col\">Вход</th><th scope=\"col\">Пример дрейфа</th><th scope=\"col\">Проверяемый след</th><th scope=\"col\">Действие</th></tr></thead>\n<tbody>\n<tr><th scope=\"row\">Source</th><td>Другой commit или незаписанный generated file.</td><td><code>git rev-parse HEAD</code>, <code>git status --short</code>.</td><td>Зафиксировать файл или исключить его по правилу репозитория.</td></tr>\n<tr><th scope=\"row\">Dependencies</th><td>Транзитивный пакет попал под semver-диапазон.</td><td>SHA-256 lockfile и журнал чистой установки.</td><td>Согласовать один lockfile и повторить установку.</td></tr>\n<tr><th scope=\"row\">Runtime</th><td>Разные Node или npm меняют установку и инструменты.</td><td><code>node --version</code>, <code>npm --version</code>.</td><td>Задать поддерживаемую версию и способ её получить.</td></tr>\n<tr><th scope=\"row\">Config</th><td>Разный mode, public path или значение <code>DefinePlugin</code>.</td><td>Команда и whitelist build-переменных.</td><td>Сделать режим и обязательные параметры явными.</td></tr>\n<tr><th scope=\"row\">Generated data</th><td>Дата, абсолютный путь, порядок чтения или случайный ID.</td><td>Diff файла и его генератора.</td><td>Передать значение явно или документировать исключение.</td></tr>\n</tbody>\n</table>\n<p>Полный <code>process.env</code> в журнал не нужен. Он может содержать token и auth-настройки. Сохраняйте только белый список: версии инструментов, команду, режим, hash lockfile, registry host без учётных данных и значения, которые реально влияют на output. Если после этого manifest расходится, сравнивайте байты и раскрывайте следующий вход, а не печатайте все секреты.</p>\n<h2>Что фиксирует lockfile, а что оставляет открытым</h2>\n<p><code>package.json</code> описывает желаемые диапазоны версий. <code>package-lock.json</code> описывает выбранное дерево, resolved location и integrity. Поэтому одна прямая зависимость в manifest не гарантирует неизменность транзитивных пакетов. Если два прогона используют разные lockfile, это уже разные входы. Не следует обсуждать module ID, пока это отличие не устранено.</p>\n<p><code>npm ci</code> полезен для диагностики тем, что не пытается подправить lockfile под manifest. Он устанавливает дерево из lockfile и останавливается при несовпадении. Это правильный отрицательный результат: проверяемого входа пока нет. Удаление lockfile, переход на <code>npm install</code> или копирование чужого <code>node_modules</code> убирают симптом ценой потери эксперимента.</p>\n<h2>Почему contenthash не заменяет сравнение output</h2>\n<p>Webpack использует <code>[contenthash]</code> как отпечаток содержимого asset. Разные имена bundle показывают, что соответствующие bytes изменились. Но одинаковое имя одного файла не доказывает, что совпали HTML, CSS, source map и остальные chunks. И наоборот, небольшое изменение графа модулей может изменить runtime и несколько имён сразу.</p>\n<p>В legacy-конфигурации runtime и manifest могут попасть в entry chunk. Тогда повторная сборка способна дать другой hash даже при одинаковом исходном коде. Выделение runtime в отдельный chunk и стабильные module IDs уменьшают шум, но не исправляют дату в banner, разный mode или внешний список файлов. Сначала нужно установить причину различия. Затем можно менять стратегию chunks.</p>\n<figure><img src=\"/assets/editorial/2019/reproducible-build-hash-boundary-2019.svg\" alt=\"Схема границы воспроизводимой сборки: commit и lockfile фиксируют часть входов, runtime, конфигурация и generated data проходят отдельную проверку, затем сравнивается manifest deployable output\"><figcaption>Lockfile необходим, но недостаточен: после сборки сравнивают канонический manifest всех файлов, которые действительно уходят в deploy.</figcaption></figure>\n<h2>Пример: канонический manifest</h2>\n<p>Сравнивайте не размер <code>main.js</code>, а список всех файлов, которые потребляет выкладка или браузер. Для каждого path вычислите SHA-256. Затем отсортируйте строки по path. Сортировка убирает ложное различие, которое создаёт разный порядок обхода каталога.</p>\n<pre><code>import { createHash } from 'node:crypto';\n\nfunction manifestHash(entries) {\n const canonical = [...entries]\n .sort((a, b) => a.path.localeCompare(b.path))\n .map(({ path, sha256 }) => `${path}\\t${sha256}`)\n .join('\\n');\n\n return createHash('sha256').update(canonical).digest('hex');\n}\n\nconst first = manifestHash(firstEntries);\nconst second = manifestHash(secondEntries);\nif (first !== second) {\n console.error('compare the first differing path');\n}</code></pre>\n<p>Код отвечает на один вопрос: одинаково ли представление выбранного набора файлов. Он не проверяет, что приложение работает, что registry выдал ожидаемый tarball или что release безопасен. Учебный пример можно проверить на двух массивах: перестановка одинаковых entries сохраняет digest, а изменение одного sample byte меняет digest. Эти результаты относятся только к примеру. Их нельзя выдавать за результат npm, webpack или production-системы.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table>\n<caption>Маршрут от первого симптома к следующей проверке</caption>\n<thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead>\n<tbody>\n<tr><td>Разный hash lockfile.</td><td>Разные dependency trees.</td><td>Сравнить lockfile и историю его изменения.</td><td>Выбрать один lockfile, затем выполнить <code>npm ci</code>.</td></tr>\n<tr><td>Разный набор chunks.</td><td>Разный mode, runtime или entry.</td><td>Сверить Node/npm, команду и whitelist переменных.</td><td>Сделать mode и обязательные параметры явными.</td></tr>\n<tr><td>Первым расходится HTML.</td><td>Timestamp, public path или другой generated data.</td><td>Открыть bytes и найти генератор поля.</td><td>Передать значение явно или убрать его из deployable asset.</td></tr>\n<tr><td>Многие chunks меняются после малого edit.</td><td>Изменился граф модулей или runtime.</td><td>Сравнить source diff и первый differing path.</td><td>Проверить module IDs и runtime на малом изменении.</td></tr>\n<tr><td>Один файл расходится при равных входах.</td><td>Скрытый генератор или неполный контракт.</td><td>Повторить чистые прогоны и открыть генератор файла.</td><td>Назначить вход и владельца; не фильтровать файл молча.</td></tr>\n</tbody>\n</table>\n<h2>Порядок действий</h2>\n<ol>\n<li>Создайте две новые копии одного commit. До установки запишите состояние дерева, hash lockfile, версии Node/npm и команду сборки.</li>\n<li>Запустите <code>npm ci</code> в каждой копии. Если команда остановилась из-за рассинхронизации manifest и lockfile, сначала исправьте её отдельным изменением.</li>\n<li>Выполните одну и ту же команду сборки. Сохраните mode и разрешённые значения переменных без секретов.</li>\n<li>Составьте manifest всех deployable-файлов. Для каждого path сохраните размер и SHA-256, затем отсортируйте записи.</li>\n<li>Сравните список путей. Если файл появился или исчез, проверьте entry, mode, plugin и условие генерации.</li>\n<li>Для общего path сравните SHA-256, затем сам файл. Для text asset используйте diff; для binary зафиксируйте размер и источник.</li>\n<li>Отнесите первое различие к dependencies, runtime, config или generated data. Меняйте один слой за раз.</li>\n<li>После исправления повторите оба чистых прогона и сохраните карточки, manifest и короткий diff.</li>\n<li>Если output снова различается, не добавляйте фильтр. Вернитесь к новому первому differing path.</li>\n</ol>\n<h2>Отрицательный путь и ограничения</h2>\n<p>Очистка cache может быть полезной санитарной операцией, но не объясняет расхождение. <code>npm update</code> перед повтором меняет dependency tree и разрушает исходное сравнение. Timestamp в имени asset гарантирует разные paths. Сравнение размеров пропускает разные bytes. Фиксация только webpack не устраняет разный Node, mode или данные plugin.</p>\n<p>Два совпавших manifest не доказывают корректность приложения и не распространяют вывод на все операционные системы. Они показывают, что выбранные bytes совпали при записанных входах. Метод также не обнаружит файл, который ошибочно не включили в manifest. Поэтому список output должен исходить из реального маршрута выкладки, а не из удобного glob.</p>\n<h2>Критерий готовности</h2>\n<p>Проверка готова, когда другой разработчик без устного контекста может найти четыре артефакта: карточки двух прогонов, hash lockfile, whitelist значимых входов и полный manifest deployable output. Критерий результата — два чистых прогона на одном commit с одинаковыми lockfile, runtime, config и manifest. Если manifest не совпал, готовность не объявляется: запись должна содержать первый differing path и следующую проверку.</p>\n<p>Если обязательный вход отсутствует, сборка должна завершиться видимой ошибкой. Скрытый fallback возвращает проблему в следующий release. Безопасное значение для локальной разработки допустимо только там, где оно не попадает в deployable output и явно отмечено как локальное. В production-like проверке лучше остановиться, чем собрать убедительно неправильный artifact.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://docs.npmjs.com/cli/v8/commands/npm-ci\" target=\"_blank\" rel=\"noopener\">npm Docs: npm ci</a> — правила чистой установки и остановка при несовпадении lockfile с package.json.</li><li><a href=\"https://webpack.js.org/guides/caching/\" target=\"_blank\" rel=\"noopener\">webpack: Caching</a> — <code>contenthash</code>, runtime chunk и стабильные module IDs.</li><li><a href=\"https://nodejs.org/api/crypto.html\" target=\"_blank\" rel=\"noopener\">Node.js Crypto</a> — официальный API <code>createHash</code> для digest.</li></ul>"
|
||
}
|