diff --git a/editorial/agent-rewrites/292.json b/editorial/agent-rewrites/292.json index 3a74e5c..6431617 100644 --- a/editorial/agent-rewrites/292.json +++ b/editorial/agent-rewrites/292.json @@ -3,5 +3,5 @@ "slug": "editorial-2019-11-field-reproducible-builds", "title": "Два разных dist из одного commit: как найти первый разрыв сборки", "excerpt": "Один commit даёт разные release-файлы у двух разработчиков. Разбираем, как отделить dependency drift, конфигурацию и generated data, а затем подтвердить исправление двумя чистыми прогонами.", - "contentHtml": "

Разработчик собрал release и получил vendors.abc.js. Коллега взял тот же commit и получил vendors.xyz.js. Приложение запускается в обоих случаях, поэтому проблему легко отложить. Цена ошибки проявится позже: rollback не знает, какой набор файлов проверяли, CDN хранит два набора assets под одной версией, а расследование начинается с догадок о cache и webpack.

\n

Разные байты не доказывают ошибку сборщика. Сначала нужно сравнить входы и найти первый отличающийся файл. В этом материале используется учебный сценарий для legacy-проекта на npm 6 и webpack 4. Он не сообщает о production-запуске и не заменяет проверку конкретного проекта. Метод применим там, где команда может получить два чистых каталога, записать окружение и сравнить полный deployable output.

\n

Тезис: hash результата начинается с контракта входов

\n

Воспроизводимая сборка — это не одинаковое имя файла и не один совпавший hash. Это договор о том, какие bytes и настройки входят в функцию build. В минимальный договор входят commit, lockfile, версия Node и npm, команда, режим webpack, значимые переменные и исходные generated data. Если один вход не записан, одинаковый commit ещё не означает одинаковый запуск.

\n

package-lock.json фиксирует дерево npm, но не фиксирует операционную систему, дату в баннере, значение NODE_ENV или код webpack-конфигурации. npm ci помогает отделить drift зависимостей: он требует lockfile, проверяет соответствие package.json и удаляет существующий node_modules. После этого всё равно остаются runtime, config и generated output.

\n

Сначала фиксируем наблюдаемые факты

\n

Не начинайте с ручной очистки cache. Она может убрать старый файл, но не объяснит, почему два прогона разошлись. Возьмите две новые копии одного commit. Для каждой сохраните короткую карточку. Полный process.env в журнал не пишите: он может содержать token или auth-настройки.

\n\n\n\n\n\n\n\n\n\n\n
Карточка двух чистых прогонов
ПолеПрогон AПрогон BЧто показывает отличие
Commitgit rev-parse HEADgit rev-parse HEADРазный commit прекращает сравнение output.
LockfileSHA-256 package-lock.jsonSHA-256 package-lock.jsonРазный digest означает разное дерево зависимостей.
Runtimeверсия Node и npmверсия Node и npmРазная версия становится первой проверяемой гипотезой.
Командаnpm run build и modeта же командаРазный mode меняет конфигурацию и набор chunks.
Manifestpath и SHA-256 каждого файлата же формаПервый differing path показывает границу поиска.
\n

Таблица не делает окружения одинаковыми. Она показывает, на каком слое они уже различаются. Если lockfile digest разный, не обсуждайте module ID: сначала разберите dependency tree. Если все входы совпадают, а первым расходится index.html, откройте генератор HTML. Не обновляйте npm наугад.

\n

Механизм: четыре слоя, которые часто смешивают

\n

Первый слой — зависимости. Без lockfile или после npm install с изменением дерева допустимый диапазон версии может привести к другому транзитивному пакету. Копирование чужого node_modules скрывает drift и привязывает результат к непроверяемому каталогу. Действие простое: остановить сравнение, согласовать один lockfile и повторить чистую установку.

\n

Второй слой — runtime. Node и npm влияют на установку, скрипты и поведение инструментов. Зафиксируйте версии командами node --version и npm --version. Если они различаются, повторите тест на одной версии. Совпавший output после этого не доказывает, что старые среды эквивалентны; он только устраняет одну гипотезу.

\n

Третий слой — конфигурация. Один процесс получил NODE_ENV=production, другой не получил переменную. Или webpack config прочитал PUBLIC_PATH без явного значения по умолчанию. Тогда меняются source map, public URL, chunks или минификация. Требуемые переменные нужно проверять до сборки и выводить в журнал только по whitelist.

\n

Четвёртый слой — generated data. Представьте banner с текущей датой в главном bundle. Чистая установка не исправит это различие: dependency tree уже одинаков. Если дата нужна пользователю, она должна быть явным входом release и попасть в карточку. Если она нужна только для аудита, храните её рядом с доказательством сборки, а не в deployable asset. Исключить файл из сравнения без объяснения — не решение.

\n

Есть ещё один контрпример. В webpack runtime хранит связи между chunks и module IDs. Небольшое изменение графа модулей может сдвинуть hash нескольких chunks. Это не повод сразу менять optimization. Сначала сравните source graph и найдите самый ранний differing path. Имя файла с contenthash — сигнал о содержимом asset, но не доказательство равенства всего dist.

\n
\"Диагностическая
Порядок сравнения не обвиняет webpack заранее: он ведёт к первому фактическому расхождению.
\n

Пример: канонический manifest

\n

Сравнивайте не размер одного main.js, а список всех файлов, которые действительно уходят в deploy. Для каждого path вычислите SHA-256. Перед hash отсортируйте entries по path. Тогда порядок обхода каталога не создаст ложное различие.

\n
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) console.error('compare the first differing path');
\n

Код проверяет только представление manifest. Он не проверяет, что браузер открыл приложение, что registry отдал ожидаемый пакет или что release безопасен. Учебный fixture может проверить два свойства: перестановка одинаковых entries сохраняет hash, а изменение bytes одного sample bundle меняет hash. Это fixture-only результат, не hash настоящего проекта.

\n

Симптом → причина → проверка → действие

\n\n\n\n\n\n\n\n\n\n\n
Маршрут диагностики по первому наблюдаемому различию
СимптомВероятная причинаПроверкаДействие
Lockfile digest различается.Разные dependency trees.Сравнить package-lock.json и историю изменения.Выбрать один lockfile, затем снова выполнить npm ci.
Chunks и source maps имеют разный набор.Разный mode или runtime.Сверить Node/npm, команду и whitelisted variables.Сделать mode и обязательные переменные явными.
Первым расходится HTML с датой.Нестабильный generated data.Открыть bytes и найти источник timestamp.Передать дату явно или вынести её из deployable asset.
Многие chunks меняются после малого edit.Изменился graph, runtime или module IDs.Сравнить source diff и первый differing path.Проверить runtime/chunk strategy на малом эксперименте.
Разошёлся один файл при равных входах.Скрытый генератор или недописанный input contract.Повторить два чистых прогона и открыть генератор файла.Назначить владельца входа; не исключать файл молча.
\n

Порядок действий

\n
    \n
  1. Возьмите две новые копии одного commit. До установки запишите состояние дерева, SHA-256 lockfile, Node/npm и build-команду.
  2. \n
  3. Запустите npm ci в каждой копии. Если команда остановилась из-за lockfile, сначала исправьте рассинхронизацию.
  4. \n
  5. Выполните одну и ту же сборку. Сохраните mode и выбранные значения переменных без секретов.
  6. \n
  7. Составьте отсортированные manifest только для deployable-файлов. Для каждого path сохраните размер и SHA-256.
  8. \n
  9. Сравните manifest. Откройте первый differing path, его bytes и генератор.
  10. \n
  11. Отнесите отличие к dependency tree, runtime/config или generated data. Меняйте один слой за раз.
  12. \n
  13. Повторите оба чистых прогона после изменения. Сохраните карточки, manifest и короткий diff.
  14. \n
  15. Если output снова различается, не скрывайте файл фильтром. Вернитесь к новому первому различию.
  16. \n
\n

Что не сработает как объяснение

\n

Удалить cache вручную можно как санитарный шаг, но это не причина. Выполнить npm update перед повтором — значит изменить dependency tree и потерять исходный эксперимент. Добавить timestamp в имя asset — значит гарантировать разные paths. Сравнить только размер bundle — значит пропустить разные bytes, HTML, CSS и дополнительные chunks. Зафиксировать одну прямую зависимость недостаточно, если транзитивное дерево осталось свободным.

\n

Ограничения и критерий готовности

\n

Два совпавших manifest не доказывают корректность приложения. Они показывают, что выбранные bytes совпали при записанных входах. Метод также не обнаружит различие, если команда забыла включить файл в deployable manifest. Поэтому список output должен исходить из реального маршрута выкладки, а не из удобного glob.

\n

Работа готова, когда команда может ответить на четыре вопроса без устного контекста: какие входы записываются; где лежат два журнала; как строится полный manifest; какое действие следует из первого различия. Проверяемый критерий — два чистых прогона на одном commit с одинаковыми lockfile/runtime/config и одинаковым manifest всех deployable-файлов. Если результат не совпал, готовность не объявляется: карточка должна содержать новый first differing path и следующую проверку.

\n

Исправление должно оставаться обратимым. Для обязательного BUILD_VERSION задайте явную ошибку при отсутствии и безопасное значение для локальной разработки, если оно действительно допустимо. Не встраивайте скрытый fallback. Тогда следующий разбор начнётся с видимого входа, а не с вопроса, какая машина случайно собрала правильный release.

\n

Проверяемые источники

" + "contentHtml": "

Разработчик собрал release и получил vendors.abc.js. Коллега взял тот же commit и получил vendors.xyz.js. Приложение запускается в обоих случаях, поэтому проблему легко отложить. Цена ошибки проявится позже: rollback не знает, какой набор файлов проверяли, CDN хранит два набора assets под одной версией, а расследование начинается с догадок о cache и webpack.

\n

Разные байты не доказывают ошибку сборщика. Сначала нужно сравнить входы и найти первый отличающийся файл. В этом материале используется учебный сценарий для legacy-проекта на npm 6.14.x и webpack 4.x. Это историческая рамка: у новых npm менялся формат lockfile и поведение отдельных флагов, поэтому команду сверяйте с документацией именно установленной версии. Сценарий не сообщает о production-запуске и не заменяет проверку конкретного проекта. Метод применим там, где команда может получить два чистых каталога, записать окружение и сравнить полный deployable output.

\n

Тезис: hash результата начинается с контракта входов

\n

Воспроизводимая сборка — это не одинаковое имя файла и не один совпавший hash. Это договор о том, какие bytes и настройки входят в функцию build. В минимальный договор входят commit, lockfile, версия Node и npm, команда, режим webpack, значимые переменные и исходные generated data. Если один вход не записан, одинаковый commit ещё не означает одинаковый запуск.

\n

package-lock.json фиксирует дерево npm, но не фиксирует операционную систему, дату в баннере, значение NODE_ENV или код webpack-конфигурации. npm ci помогает отделить drift зависимостей: он требует lockfile, проверяет соответствие package.json и удаляет существующий node_modules. После этого всё равно остаются runtime, config и generated output.

\n

Сначала фиксируем наблюдаемые факты

\n

Не начинайте с ручной очистки cache. Она может убрать старый файл, но не объяснит, почему два прогона разошлись. Возьмите две новые копии одного commit. Для каждой сохраните короткую карточку. Полный process.env в журнал не пишите: он может содержать token или auth-настройки.

\n\n\n\n\n\n\n\n\n\n\n
Карточка двух чистых прогонов
ПолеПрогон AПрогон BЧто показывает отличие
Commitgit rev-parse HEADgit rev-parse HEADРазный commit прекращает сравнение output.
LockfileSHA-256 package-lock.jsonSHA-256 package-lock.jsonРазный digest означает разное дерево зависимостей.
Runtimeверсия Node и npmверсия Node и npmРазная версия становится первой проверяемой гипотезой.
Командаnpm run build и modeта же командаРазный mode меняет конфигурацию и набор chunks.
Manifestpath и SHA-256 каждого файлата же формаПервый differing path показывает границу поиска.
\n

Таблица не делает окружения одинаковыми. Она показывает, на каком слое они уже различаются. Если lockfile digest разный, не обсуждайте module ID: сначала разберите dependency tree. Если все входы совпадают, а первым расходится index.html, откройте генератор HTML. Не обновляйте npm наугад.

\n

Механизм: четыре слоя, которые часто смешивают

\n

Первый слой — зависимости. Без lockfile или после npm install с изменением дерева допустимый диапазон версии может привести к другому транзитивному пакету. Копирование чужого node_modules скрывает drift и привязывает результат к непроверяемому каталогу. Действие простое: остановить сравнение, согласовать один lockfile и повторить чистую установку.

\n

Второй слой — runtime. Node и npm влияют на установку, скрипты и поведение инструментов. Зафиксируйте версии командами node --version и npm --version. Если они различаются, повторите тест на одной версии. Совпавший output после этого не доказывает, что старые среды эквивалентны; он только устраняет одну гипотезу.

\n

Третий слой — конфигурация. Один процесс получил NODE_ENV=production, другой не получил переменную. Или webpack config прочитал PUBLIC_PATH без явного значения по умолчанию. Тогда меняются source map, public URL, chunks или минификация. Требуемые переменные нужно проверять до сборки и выводить в журнал только по whitelist.

\n

Четвёртый слой — generated data. Представьте banner с текущей датой в главном bundle. Чистая установка не исправит это различие: dependency tree уже одинаков. Если дата нужна пользователю, она должна быть явным входом release и попасть в карточку. Если она нужна только для аудита, храните её рядом с доказательством сборки, а не в deployable asset. Исключить файл из сравнения без объяснения — не решение.

\n

Есть ещё один контрпример. В webpack runtime хранит связи между chunks и module IDs. Небольшое изменение графа модулей может сдвинуть hash нескольких chunks. Это не повод сразу менять optimization. Сначала сравните source graph и найдите самый ранний differing path. Имя файла с contenthash — сигнал о содержимом asset, но не доказательство равенства всего dist.

\n
\"Диагностическая
Порядок сравнения не обвиняет webpack заранее: он ведёт к первому фактическому расхождению.
\n

Пример: канонический manifest

\n

Сравнивайте не размер одного main.js, а список всех файлов, которые действительно уходят в deploy. Для каждого path вычислите SHA-256 и сохраните path в UTF-8. Перед hash отсортируйте entries по байтам path, а не через localeCompare: locale и версия ICU могут изменить порядок. Тогда порядок обхода каталога не создаст ложное различие.

\n
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');
\n

Код проверяет только представление manifest. Сравнение UTF-8 bytes не зависит от locale, поэтому порядок не меняется из-за настроек языка или ICU. Функция также не проверяет, что браузер открыл приложение, registry отдал ожидаемый пакет или release безопасен. Учебный fixture может проверить два свойства: перестановка одинаковых entries сохраняет hash, а изменение bytes одного sample bundle меняет hash. Это fixture-only результат, не hash настоящего проекта.

\n

Симптом → причина → проверка → действие

\n\n\n\n\n\n\n\n\n\n\n
Маршрут диагностики по первому наблюдаемому различию
СимптомВероятная причинаПроверкаДействие
Lockfile digest различается.Разные dependency trees.Сравнить package-lock.json и историю изменения.Выбрать один lockfile, затем снова выполнить npm ci.
Chunks и source maps имеют разный набор.Разный mode или runtime.Сверить Node/npm, команду и whitelisted variables.Сделать mode и обязательные переменные явными.
Первым расходится HTML с датой.Нестабильный generated data.Открыть bytes и найти источник timestamp.Передать дату явно или вынести её из deployable asset.
Многие chunks меняются после малого edit.Изменился graph, runtime или module IDs.Сравнить source diff и первый differing path.Проверить runtime/chunk strategy на малом эксперименте.
Разошёлся один файл при равных входах.Скрытый генератор или недописанный input contract.Повторить два чистых прогона и открыть генератор файла.Назначить владельца входа; не исключать файл молча.
\n

Порядок действий

\n
    \n
  1. Возьмите две новые копии одного commit. До установки запишите состояние дерева, SHA-256 lockfile, Node/npm и build-команду.
  2. \n
  3. Запустите npm ci в каждой копии. Если команда остановилась из-за lockfile, сначала исправьте рассинхронизацию.
  4. \n
  5. Выполните одну и ту же сборку. Сохраните mode и выбранные значения переменных без секретов.
  6. \n
  7. Составьте отсортированные manifest только для deployable-файлов. Для каждого path сохраните размер и SHA-256.
  8. \n
  9. Сравните manifest. Откройте первый differing path, его bytes и генератор.
  10. \n
  11. Отнесите отличие к dependency tree, runtime/config или generated data. Меняйте один слой за раз.
  12. \n
  13. Повторите оба чистых прогона после изменения. Сохраните карточки, manifest и короткий diff.
  14. \n
  15. Если output снова различается, не скрывайте файл фильтром. Вернитесь к новому первому различию.
  16. \n
\n

Что не сработает как объяснение

\n

Удалить cache вручную можно как санитарный шаг, но это не причина. Выполнить npm update перед повтором — значит изменить dependency tree и потерять исходный эксперимент. Добавить timestamp в имя asset — значит гарантировать разные paths. Сравнить только размер bundle — значит пропустить разные bytes, HTML, CSS и дополнительные chunks. Зафиксировать одну прямую зависимость недостаточно, если транзитивное дерево осталось свободным.

\n

Ограничения и критерий готовности

\n

Два совпавших manifest не доказывают корректность приложения. Они показывают, что выбранные bytes совпали при записанных входах. Метод также не обнаружит различие, если команда забыла включить файл в deployable manifest. Поэтому список output должен исходить из реального маршрута выкладки, а не из удобного glob.

\n

Работа готова, когда команда может ответить на четыре вопроса без устного контекста: какие входы записываются; где лежат два журнала; как строится полный manifest; какое действие следует из первого различия. Проверяемый критерий — два чистых прогона на одном commit с одинаковыми lockfile/runtime/config и одинаковым manifest всех deployable-файлов. Если результат не совпал, готовность не объявляется: карточка должна содержать новый first differing path и следующую проверку.

\n

Исправление должно оставаться обратимым. Для обязательного BUILD_VERSION задайте явную ошибку при отсутствии и безопасное значение для локальной разработки, если оно действительно допустимо. Не встраивайте скрытый fallback. Тогда следующий разбор начнётся с видимого входа, а не с вопроса, какая машина случайно собрала правильный release.

\n

Проверяемые источники

" }