Files

8 lines
20 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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 &lt; right.path ? -1 : 1;\n}\n\nfunction manifestHash(entries) {\n const canonical = [...entries]\n .sort(comparePath)\n .map(({ path, size, sha256 }) =&gt; {\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) =&gt;\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>&lt;</code> и <code>&gt;</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>"
}