8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 293,
|
||
"slug": "editorial-2019-11-mechanism-reproducible-builds",
|
||
"title": "Почему один commit даёт разные бандлы: механизм воспроизводимой сборки",
|
||
"excerpt": "Один и тот же commit не гарантирует одинаковый bundle. Разбираем границы входов сборки, канонический manifest и способ найти первый байт, который расходится между двумя чистыми прогонами.",
|
||
"contentHtml": "<p>Разработчик собрал релиз и получил <code>main.abc123.js</code>. Коллега взял тот же commit и получил <code>main.xyz789.js</code>. Приложение запускается в обоих случаях, поэтому проблему легко отложить. Цена ошибки проявится при rollback, проверке cache и расследовании инцидента: команда не знает, какой набор файлов проверяли, а один номер версии скрывает два разных результата.</p>\n<p>Разные имена bundle не доказывают ошибку webpack. Сначала нужно сравнить входы и найти первый файл, в котором расходятся байты. Тезис статьи простой: воспроизводимая сборка — свойство конкретной команды с явным контрактом входов. Если два чистых прогона используют один commit, одно дерево зависимостей, один набор инструментов, одну конфигурацию и одни 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 и те же флаги package manager. Runtime включает Node и системные особенности 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>Транзитивный пакет разрешён по другому диапазону.</td><td>SHA-256 lockfile, версия package manager и флаги установки.</td><td>Согласовать один lockfile и повторить чистую установку.</td></tr>\n<tr><th scope=\"row\">Runtime</th><td>Разные Node или системные библиотеки меняют инструменты и native-зависимости.</td><td><code>node --version</code>, <code>npm --version</code>, образ или ОС.</td><td>Задать поддерживаемый runtime и способ его получить.</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, разные версии npm или разные флаги установки, это разные входы. Обсуждать module ID до устранения этого отличия преждевременно.</p>\n<p><code>npm ci</code> полезен для диагностики: команде нужен существующий lockfile, а при несовпадении lockfile с <code>package.json</code> она завершается ошибкой вместо обновления lockfile. Установка также удаляет существующий <code>node_modules</code> и не записывает <code>package.json</code> или <code>package-lock.json</code>. Это делает install-шаг чистым, но не фиксирует саму версию Node, системные библиотеки, install flags и поведение lifecycle scripts. Удаление lockfile, переход на <code>npm install</code> или копирование чужого <code>node_modules</code> убирают симптом ценой потери эксперимента.</p>\n<h2>Почему contenthash не заменяет сравнение output</h2>\n<p>Webpack использует <code>[contenthash]</code> как отпечаток содержимого asset. Разные имена соответствующих файлов обычно означают разные bytes; теоретическую коллизию hash нельзя использовать как доказательство равенства. Но одинаковое имя одного файла не доказывает, что совпали HTML, CSS, source map и остальные chunks. Небольшое изменение графа модулей также может изменить runtime и несколько имён сразу.</p>\n<p>В конфигурации, где runtime и manifest попадают в entry chunk, повторная сборка может дать другой hash даже при неизменном исходном коде. Выделение runtime в отдельный chunk и стабильные module IDs уменьшают шум, но не исправляют дату в banner, разный mode или внешний список файлов. Сначала нужно установить причину различия. Затем можно менять стратегию chunks. Точный результат зависит от версии webpack и конфигурации проекта.</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 assert from 'node:assert/strict';\nimport { createHash } from 'node:crypto';\n\nfunction comparePath(left, right) {\n if (left.path === right.path) return 0;\n return left.path < right.path ? -1 : 1;\n}\n\nfunction manifestHash(entries) {\n const canonical = [...entries]\n .sort(comparePath)\n .map(({ path, size, sha256 }) => {\n if (path.includes('\\t') || path.includes('\\n')) {\n throw new Error('path contains a separator');\n }\n return `${path}\\t${size}\\t${sha256}`;\n })\n .join('\\n') + '\\n';\n\n return createHash('sha256').update(canonical, 'utf8').digest('hex');\n}\n\nconst baseline = [\n { path: 'main.js', size: 120, sha256: 'aaa' },\n { path: 'runtime.js', size: 40, sha256: 'bbb' },\n];\nconst changed = baseline.map((entry) =>\n entry.path === 'main.js' ? { ...entry, size: 121 } : entry,\n);\n\nassert.equal(manifestHash(baseline), manifestHash([...baseline].reverse()));\nassert.notEqual(manifestHash(baseline), manifestHash(changed));</code></pre>\n<p>Код отвечает на два ограниченных вопроса: порядок входных записей не меняет digest, а изменение размера или SHA-256 меняет digest. Сравнение путей использует операции <code><</code> и <code>></code>, поэтому пример не зависит от локали. Проверка разделителей нужна потому, что tab и newline входят в формат canonical manifest. Код не проверяет, что приложение работает, registry выдал ожидаемый tarball или release безопасен. Эти результаты относятся только к учебным массивам. Их нельзя выдавать за результат 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, package manager и историю его изменения.</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, package manager flags и команду сборки.</li>\n<li>Запустите <code>npm ci</code> в каждой копии с одинаковыми flags. Если команда остановилась из-за рассинхронизации 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 (CLI v8)</a> — чистая установка, проверка соответствия lockfile и manifest и отказ вместо переписывания lockfile.</li><li><a href=\"https://webpack.js.org/guides/caching/\" target=\"_blank\" rel=\"noopener\">webpack: Caching</a> — <code>contenthash</code>, runtime chunk и детерминированные module IDs; руководство отдельно предупреждает о зависимости поведения от версии webpack.</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>"
|
||
}
|