8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"index": 292,
|
||
"slug": "editorial-2019-11-field-reproducible-builds",
|
||
"title": "Два разных dist из одного commit: как найти первый разрыв сборки",
|
||
"excerpt": "Один commit даёт разные release-файлы у двух разработчиков. Разбираем, как отделить dependency drift, конфигурацию и generated data, а затем подтвердить исправление двумя чистыми прогонами.",
|
||
"contentHtml": "<p>Разработчик собрал release и получил <code>vendors.abc.js</code>. Коллега взял тот же commit и получил <code>vendors.xyz.js</code>. Приложение запускается в обоих случаях, поэтому проблему легко отложить. Цена ошибки проявится позже: rollback не знает, какой набор файлов проверяли, CDN хранит два набора assets под одной версией, а расследование начинается с догадок о cache и webpack.</p>\n<p>Разные байты не доказывают ошибку сборщика. Сначала нужно сравнить входы и найти первый отличающийся файл. В этом материале используется учебный сценарий для legacy-проекта на npm 6.14.x и webpack 4.x. Это историческая рамка: у новых npm менялся формат lockfile и поведение отдельных флагов, поэтому команду сверяйте с документацией именно установленной версии. Сценарий не сообщает о production-запуске и не заменяет проверку конкретного проекта. Метод применим там, где команда может получить два чистых каталога, записать окружение и сравнить полный deployable output.</p>\n<h2>Тезис: hash результата начинается с контракта входов</h2>\n<p>Воспроизводимая сборка — это не одинаковое имя файла и не один совпавший hash. Это договор о том, какие bytes и настройки входят в функцию build. В минимальный договор входят commit, lockfile, версия Node и npm, команда, режим webpack, значимые переменные и исходные generated data. Если один вход не записан, одинаковый commit ещё не означает одинаковый запуск.</p>\n<p><code>package-lock.json</code> фиксирует дерево npm, но не фиксирует операционную систему, дату в баннере, значение <code>NODE_ENV</code> или код webpack-конфигурации. <code>npm ci</code> помогает отделить drift зависимостей: он требует lockfile, проверяет соответствие <code>package.json</code> и удаляет существующий <code>node_modules</code>. После этого всё равно остаются runtime, config и generated output.</p>\n<h2>Сначала фиксируем наблюдаемые факты</h2>\n<p>Не начинайте с ручной очистки cache. Она может убрать старый файл, но не объяснит, почему два прогона разошлись. Возьмите две новые копии одного commit. Для каждой сохраните короткую карточку. Полный <code>process.env</code> в журнал не пишите: он может содержать token или auth-настройки.</p>\n<table>\n<caption>Карточка двух чистых прогонов</caption>\n<thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Прогон A</th><th scope=\"col\">Прогон B</th><th scope=\"col\">Что показывает отличие</th></tr></thead>\n<tbody>\n<tr><th scope=\"row\">Commit</th><td><code>git rev-parse HEAD</code></td><td><code>git rev-parse HEAD</code></td><td>Разный commit прекращает сравнение output.</td></tr>\n<tr><th scope=\"row\">Lockfile</th><td>SHA-256 <code>package-lock.json</code></td><td>SHA-256 <code>package-lock.json</code></td><td>Разный digest означает разное дерево зависимостей.</td></tr>\n<tr><th scope=\"row\">Runtime</th><td>версия Node и npm</td><td>версия Node и npm</td><td>Разная версия становится первой проверяемой гипотезой.</td></tr>\n<tr><th scope=\"row\">Команда</th><td><code>npm run build</code> и mode</td><td>та же команда</td><td>Разный mode меняет конфигурацию и набор chunks.</td></tr>\n<tr><th scope=\"row\">Manifest</th><td>path и SHA-256 каждого файла</td><td>та же форма</td><td>Первый differing path показывает границу поиска.</td></tr>\n</tbody>\n</table>\n<p>Таблица не делает окружения одинаковыми. Она показывает, на каком слое они уже различаются. Если lockfile digest разный, не обсуждайте module ID: сначала разберите dependency tree. Если все входы совпадают, а первым расходится <code>index.html</code>, откройте генератор HTML. Не обновляйте npm наугад.</p>\n<h2>Механизм: четыре слоя, которые часто смешивают</h2>\n<p>Первый слой — зависимости. Без lockfile или после <code>npm install</code> с изменением дерева допустимый диапазон версии может привести к другому транзитивному пакету. Копирование чужого <code>node_modules</code> скрывает drift и привязывает результат к непроверяемому каталогу. Действие простое: остановить сравнение, согласовать один lockfile и повторить чистую установку.</p>\n<p>Второй слой — runtime. Node и npm влияют на установку, скрипты и поведение инструментов. Зафиксируйте версии командами <code>node --version</code> и <code>npm --version</code>. Если они различаются, повторите тест на одной версии. Совпавший output после этого не доказывает, что старые среды эквивалентны; он только устраняет одну гипотезу.</p>\n<p>Третий слой — конфигурация. Один процесс получил <code>NODE_ENV=production</code>, другой не получил переменную. Или webpack config прочитал <code>PUBLIC_PATH</code> без явного значения по умолчанию. Тогда меняются source map, public URL, chunks или минификация. Требуемые переменные нужно проверять до сборки и выводить в журнал только по whitelist.</p>\n<p>Четвёртый слой — generated data. Представьте banner с текущей датой в главном bundle. Чистая установка не исправит это различие: dependency tree уже одинаков. Если дата нужна пользователю, она должна быть явным входом release и попасть в карточку. Если она нужна только для аудита, храните её рядом с доказательством сборки, а не в deployable asset. Исключить файл из сравнения без объяснения — не решение.</p>\n<p>Есть ещё один контрпример. В webpack runtime хранит связи между chunks и module IDs. Небольшое изменение графа модулей может сдвинуть hash нескольких chunks. Это не повод сразу менять optimization. Сначала сравните source graph и найдите самый ранний differing path. Имя файла с <code>contenthash</code> — сигнал о содержимом asset, но не доказательство равенства всего <code>dist</code>.</p>\n<figure><img src=\"/assets/editorial/2019/reproducible-build-diagnosis-2019.svg\" alt=\"Диагностическая схема: два чистых прогона сравнивают commit, lockfile, runtime и команду, затем manifest ведёт к dependency, config или generated output\"><figcaption>Порядок сравнения не обвиняет webpack заранее: он ведёт к первому фактическому расхождению.</figcaption></figure>\n<h2>Пример: канонический manifest</h2>\n<p>Сравнивайте не размер одного <code>main.js</code>, а список всех файлов, которые действительно уходят в deploy. Для каждого path вычислите SHA-256 и сохраните path в UTF-8. Перед hash отсортируйте entries по байтам path, а не через <code>localeCompare</code>: locale и версия ICU могут изменить порядок. Тогда порядок обхода каталога не создаст ложное различие.</p>\n<pre><code>import { createHash } from 'node:crypto';\n\nfunction manifestHash(entries) {\n const canonical = [...entries]\n .sort((a, b) => Buffer.compare(Buffer.from(a.path, 'utf8'), Buffer.from(b.path, 'utf8')))\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) console.error('compare the first differing path');</code></pre>\n<p>Код проверяет только представление manifest. Сравнение UTF-8 bytes не зависит от locale, поэтому порядок не меняется из-за настроек языка или ICU. Функция также не проверяет, что браузер открыл приложение, registry отдал ожидаемый пакет или release безопасен. Учебный fixture может проверить два свойства: перестановка одинаковых entries сохраняет hash, а изменение bytes одного sample bundle меняет hash. Это fixture-only результат, не hash настоящего проекта.</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>Lockfile digest различается.</td><td>Разные dependency trees.</td><td>Сравнить <code>package-lock.json</code> и историю изменения.</td><td>Выбрать один lockfile, затем снова выполнить <code>npm ci</code>.</td></tr>\n<tr><td>Chunks и source maps имеют разный набор.</td><td>Разный mode или runtime.</td><td>Сверить Node/npm, команду и whitelisted variables.</td><td>Сделать mode и обязательные переменные явными.</td></tr>\n<tr><td>Первым расходится HTML с датой.</td><td>Нестабильный generated data.</td><td>Открыть bytes и найти источник timestamp.</td><td>Передать дату явно или вынести её из deployable asset.</td></tr>\n<tr><td>Многие chunks меняются после малого edit.</td><td>Изменился graph, runtime или module IDs.</td><td>Сравнить source diff и первый differing path.</td><td>Проверить runtime/chunk strategy на малом эксперименте.</td></tr>\n<tr><td>Разошёлся один файл при равных входах.</td><td>Скрытый генератор или недописанный input contract.</td><td>Повторить два чистых прогона и открыть генератор файла.</td><td>Назначить владельца входа; не исключать файл молча.</td></tr>\n</tbody>\n</table>\n<h2>Порядок действий</h2>\n<ol>\n<li>Возьмите две новые копии одного commit. До установки запишите состояние дерева, SHA-256 lockfile, Node/npm и build-команду.</li>\n<li>Запустите <code>npm ci</code> в каждой копии. Если команда остановилась из-за lockfile, сначала исправьте рассинхронизацию.</li>\n<li>Выполните одну и ту же сборку. Сохраните mode и выбранные значения переменных без секретов.</li>\n<li>Составьте отсортированные manifest только для deployable-файлов. Для каждого path сохраните размер и SHA-256.</li>\n<li>Сравните manifest. Откройте первый differing path, его bytes и генератор.</li>\n<li>Отнесите отличие к dependency tree, runtime/config или generated data. Меняйте один слой за раз.</li>\n<li>Повторите оба чистых прогона после изменения. Сохраните карточки, manifest и короткий diff.</li>\n<li>Если output снова различается, не скрывайте файл фильтром. Вернитесь к новому первому различию.</li>\n</ol>\n<h2>Что не сработает как объяснение</h2>\n<p>Удалить cache вручную можно как санитарный шаг, но это не причина. Выполнить <code>npm update</code> перед повтором — значит изменить dependency tree и потерять исходный эксперимент. Добавить timestamp в имя asset — значит гарантировать разные paths. Сравнить только размер bundle — значит пропустить разные bytes, HTML, CSS и дополнительные chunks. Зафиксировать одну прямую зависимость недостаточно, если транзитивное дерево осталось свободным.</p>\n<h2>Ограничения и критерий готовности</h2>\n<p>Два совпавших manifest не доказывают корректность приложения. Они показывают, что выбранные bytes совпали при записанных входах. Метод также не обнаружит различие, если команда забыла включить файл в deployable manifest. Поэтому список output должен исходить из реального маршрута выкладки, а не из удобного glob.</p>\n<p>Работа готова, когда команда может ответить на четыре вопроса без устного контекста: какие входы записываются; где лежат два журнала; как строится полный manifest; какое действие следует из первого различия. Проверяемый критерий — два чистых прогона на одном commit с одинаковыми lockfile/runtime/config и одинаковым manifest всех deployable-файлов. Если результат не совпал, готовность не объявляется: карточка должна содержать новый first differing path и следующую проверку.</p>\n<p>Исправление должно оставаться обратимым. Для обязательного <code>BUILD_VERSION</code> задайте явную ошибку при отсутствии и безопасное значение для локальной разработки, если оно действительно допустимо. Не встраивайте скрытый fallback. Тогда следующий разбор начнётся с видимого входа, а не с вопроса, какая машина случайно собрала правильный release.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://docs.npmjs.com/cli/v6/commands/npm-ci/\" target=\"_blank\" rel=\"noopener\">npm 6 Docs: npm ci</a> — чистая установка, отказ при несовпадении lockfile с package.json и удаление существующего <code>node_modules</code>.</li><li><a href=\"https://docs.npmjs.com/cli/v6/configuring-npm/package-lock-json/\" target=\"_blank\" rel=\"noopener\">npm 6 Docs: package-lock.json</a> — назначение lockfile и описание зафиксированного дерева зависимостей.</li><li><a href=\"https://webpack.js.org/guides/caching/\" target=\"_blank\" rel=\"noopener\">webpack: Caching</a> — content hash, runtime и стабилизация module IDs.</li><li><a href=\"https://nodejs.org/api/crypto.html\" target=\"_blank\" rel=\"noopener\">Node.js Crypto</a> — API для SHA-256 digest.</li><li><a href=\"https://nodejs.org/api/buffer.html\" target=\"_blank\" rel=\"noopener\">Node.js Buffer</a> — преобразование строк в UTF-8 и сравнение байтовых буферов.</li></ul>"
|
||
}
|