From c24d4742b526b90081f82f83d108af7e2a29c3fe Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 23:05:28 +0300 Subject: [PATCH] editorial: refine reproducible builds article 293 --- editorial/agent-rewrites/293.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/editorial/agent-rewrites/293.json b/editorial/agent-rewrites/293.json index 88065d8..8b784f3 100644 --- a/editorial/agent-rewrites/293.json +++ b/editorial/agent-rewrites/293.json @@ -1,7 +1,7 @@ { "index": 293, "slug": "editorial-2019-11-mechanism-reproducible-builds", - "title": "Почему один commit даёт разные bundle: механизм воспроизводимой сборки", - "excerpt": "Одинаковый commit не гарантирует одинаковый bundle. Разбираем границы входов сборки, канонический manifest и способ найти первый байт, который расходится между двумя чистыми прогонами.", - "contentHtml": "

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

\n

Разные bundle не доказывают ошибку webpack. Сначала нужно сравнить входы и найти первый файл, в котором расходятся байты. Тезис статьи простой: воспроизводимая сборка — это свойство конкретной команды с явным контрактом входов. Если два чистых прогона получают один commit, одно дерево зависимостей, один runtime, одну конфигурацию и одну generated data, их deployable output должен совпасть. Если вход не зафиксирован, одинаковый Git hash ничего не доказывает.

\n

Сборка — функция с внешними аргументами

\n

Полезная модель выглядит так: artifact = build(source, dependencies, runtime, config, environment, generatedData). Git фиксирует source и часть config. Lockfile фиксирует разрешённое дерево пакетов, если сборка действительно использует этот lockfile. Runtime включает Node, npm и системные особенности native-зависимостей. Config включает webpack mode, public path, entry и настройки plugins. Environment включает locale, timezone и разрешённые переменные. Generated data включает дату, список файлов, случайный идентификатор или ответ внешнего сервиса.

\n

Эта модель не требует заморозить всю машину. Она задаёт вопрос для каждого отличившегося байта: какой аргумент его породил? Ответ должен вести к проверке или к явному исключению из контракта. Молчаливое исключение не делает сборку воспроизводимой. Оно только прячет часть результата.

\n\n\n\n\n\n\n\n\n\n\n
Граница входов: симптом, след и владелец действия
ВходПример дрейфаПроверяемый следДействие
SourceДругой commit или незаписанный generated file.git rev-parse HEAD, git status --short.Зафиксировать файл или исключить его по правилу репозитория.
DependenciesТранзитивный пакет попал под semver-диапазон.SHA-256 lockfile и журнал чистой установки.Согласовать один lockfile и повторить установку.
RuntimeРазные Node или npm меняют установку и инструменты.node --version, npm --version.Задать поддерживаемую версию и способ её получить.
ConfigРазный mode, public path или значение DefinePlugin.Команда и whitelist build-переменных.Сделать режим и обязательные параметры явными.
Generated dataДата, абсолютный путь, порядок чтения или случайный ID.Diff файла и его генератора.Передать значение явно или документировать исключение.
\n

Полный process.env в журнал не нужен. Он может содержать token и auth-настройки. Сохраняйте только белый список: версии инструментов, команду, режим, hash lockfile, registry host без учётных данных и значения, которые реально влияют на output. Если после этого manifest расходится, сравнивайте байты и раскрывайте следующий вход, а не печатайте все секреты.

\n

Что фиксирует lockfile, а что оставляет открытым

\n

package.json описывает желаемые диапазоны версий. package-lock.json описывает выбранное дерево, resolved location и integrity. Поэтому одна прямая зависимость в manifest не гарантирует неизменность транзитивных пакетов. Если два прогона используют разные lockfile, это уже разные входы. Не следует обсуждать module ID, пока это отличие не устранено.

\n

npm ci полезен для диагностики тем, что не пытается подправить lockfile под manifest. Он устанавливает дерево из lockfile и останавливается при несовпадении. Это правильный отрицательный результат: проверяемого входа пока нет. Удаление lockfile, переход на npm install или копирование чужого node_modules убирают симптом ценой потери эксперимента.

\n

Почему contenthash не заменяет сравнение output

\n

Webpack использует [contenthash] как отпечаток содержимого asset. Разные имена bundle показывают, что соответствующие bytes изменились. Но одинаковое имя одного файла не доказывает, что совпали HTML, CSS, source map и остальные chunks. И наоборот, небольшое изменение графа модулей может изменить runtime и несколько имён сразу.

\n

В legacy-конфигурации runtime и manifest могут попасть в entry chunk. Тогда повторная сборка способна дать другой hash даже при одинаковом исходном коде. Выделение runtime в отдельный chunk и стабильные module IDs уменьшают шум, но не исправляют дату в banner, разный mode или внешний список файлов. Сначала нужно установить причину различия. Затем можно менять стратегию chunks.

\n
\"Схема
Lockfile необходим, но недостаточен: после сборки сравнивают канонический manifest всех файлов, которые действительно уходят в deploy.
\n

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

\n

Сравнивайте не размер main.js, а список всех файлов, которые потребляет выкладка или браузер. Для каждого path вычислите SHA-256. Затем отсортируйте строки по 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) {\n  console.error('compare the first differing path');\n}
\n

Код отвечает на один вопрос: одинаково ли представление выбранного набора файлов. Он не проверяет, что приложение работает, что registry выдал ожидаемый tarball или что release безопасен. Учебный пример можно проверить на двух массивах: перестановка одинаковых entries сохраняет digest, а изменение одного sample byte меняет digest. Эти результаты относятся только к примеру. Их нельзя выдавать за результат npm, webpack или production-системы.

\n

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

\n\n\n\n\n\n\n\n\n\n\n
Маршрут от первого симптома к следующей проверке
СимптомПричинаПроверкаДействие
Разный hash lockfile.Разные dependency trees.Сравнить lockfile и историю его изменения.Выбрать один lockfile, затем выполнить npm ci.
Разный набор chunks.Разный mode, runtime или entry.Сверить Node/npm, команду и whitelist переменных.Сделать mode и обязательные параметры явными.
Первым расходится HTML.Timestamp, public path или другой generated data.Открыть bytes и найти генератор поля.Передать значение явно или убрать его из deployable asset.
Многие chunks меняются после малого edit.Изменился граф модулей или runtime.Сравнить source diff и первый differing path.Проверить module IDs и runtime на малом изменении.
Один файл расходится при равных входах.Скрытый генератор или неполный контракт.Повторить чистые прогоны и открыть генератор файла.Назначить вход и владельца; не фильтровать файл молча.
\n

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

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

Отрицательный путь и ограничения

\n

Очистка cache может быть полезной санитарной операцией, но не объясняет расхождение. npm update перед повтором меняет dependency tree и разрушает исходное сравнение. Timestamp в имени asset гарантирует разные paths. Сравнение размеров пропускает разные bytes. Фиксация только webpack не устраняет разный Node, mode или данные plugin.

\n

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

\n

Критерий готовности

\n

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

\n

Если обязательный вход отсутствует, сборка должна завершиться видимой ошибкой. Скрытый fallback возвращает проблему в следующий release. Безопасное значение для локальной разработки допустимо только там, где оно не попадает в deployable output и явно отмечено как локальное. В production-like проверке лучше остановиться, чем собрать убедительно неправильный artifact.

\n

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

" + "title": "Почему один commit даёт разные бандлы: механизм воспроизводимой сборки", + "excerpt": "Один и тот же commit не гарантирует одинаковый bundle. Разбираем границы входов сборки, канонический manifest и способ найти первый байт, который расходится между двумя чистыми прогонами.", + "contentHtml": "

Разработчик собрал релиз и получил main.abc123.js. Коллега взял тот же commit и получил main.xyz789.js. Приложение запускается в обоих случаях, поэтому проблему легко отложить. Цена ошибки проявится при rollback, проверке cache и расследовании инцидента: команда не знает, какой набор файлов проверяли, а один номер версии скрывает два разных результата.

\n

Разные имена bundle не доказывают ошибку webpack. Сначала нужно сравнить входы и найти первый файл, в котором расходятся байты. Тезис статьи простой: воспроизводимая сборка — свойство конкретной команды с явным контрактом входов. Если два чистых прогона используют один commit, одно дерево зависимостей, один набор инструментов, одну конфигурацию и одни generated data, их deployable output должен совпасть. Если вход не зафиксирован или находится за пределами контракта, одинаковый Git hash ничего не доказывает.

\n

Сборка — функция с внешними аргументами

\n

Полезная модель выглядит так: artifact = build(source, dependencies, runtime, config, environment, generatedData). Git фиксирует source и часть config. Lockfile фиксирует выбранное дерево пакетов, если установка действительно использует этот lockfile и те же флаги package manager. Runtime включает Node и системные особенности native-зависимостей. Config включает webpack mode, public path, entry и настройки plugins. Environment включает locale, timezone и разрешённые переменные. Generated data включает дату, список файлов, случайный идентификатор или ответ внешнего сервиса.

\n

Эта модель не требует заморозить всю машину. Она задаёт вопрос для каждого отличившегося байта: какой аргумент его породил? Ответ должен вести к проверке или к явному исключению из контракта. Молчаливое исключение не делает сборку воспроизводимой. Оно только прячет часть результата.

\n\n\n\n\n\n\n\n\n\n\n
Граница входов: симптом, проверяемый след и действие
ВходПример дрейфаПроверяемый следДействие
SourceДругой commit или незаписанный generated file.git rev-parse HEAD, git status --short.Зафиксировать файл или исключить его по правилу репозитория.
DependenciesТранзитивный пакет разрешён по другому диапазону.SHA-256 lockfile, версия package manager и флаги установки.Согласовать один lockfile и повторить чистую установку.
RuntimeРазные Node или системные библиотеки меняют инструменты и native-зависимости.node --version, npm --version, образ или ОС.Задать поддерживаемый runtime и способ его получить.
ConfigРазный mode, public path или значение DefinePlugin.Команда и whitelist build-переменных.Сделать режим и обязательные параметры явными.
Generated dataДата, абсолютный путь, порядок чтения или случайный ID.Diff файла и его генератора.Передать значение явно или документировать исключение.
\n

Полный process.env в журнал не нужен: он может содержать token и auth-настройки. Сохраняйте только белый список: версии инструментов, команду, режим, hash lockfile, registry host без учётных данных и значения, которые реально влияют на output. Если после этого manifest расходится, сравнивайте байты и раскрывайте следующий вход, а не печатайте все секреты.

\n

Что фиксирует lockfile, а что оставляет открытым

\n

package.json описывает желаемые диапазоны версий, а package-lock.json описывает выбранное дерево, resolved location и integrity. Поэтому одна прямая зависимость в manifest не гарантирует неизменность транзитивных пакетов. Если два прогона используют разные lockfile, разные версии npm или разные флаги установки, это разные входы. Обсуждать module ID до устранения этого отличия преждевременно.

\n

npm ci полезен для диагностики: команде нужен существующий lockfile, а при несовпадении lockfile с package.json она завершается ошибкой вместо обновления lockfile. Установка также удаляет существующий node_modules и не записывает package.json или package-lock.json. Это делает install-шаг чистым, но не фиксирует саму версию Node, системные библиотеки, install flags и поведение lifecycle scripts. Удаление lockfile, переход на npm install или копирование чужого node_modules убирают симптом ценой потери эксперимента.

\n

Почему contenthash не заменяет сравнение output

\n

Webpack использует [contenthash] как отпечаток содержимого asset. Разные имена соответствующих файлов обычно означают разные bytes; теоретическую коллизию hash нельзя использовать как доказательство равенства. Но одинаковое имя одного файла не доказывает, что совпали HTML, CSS, source map и остальные chunks. Небольшое изменение графа модулей также может изменить runtime и несколько имён сразу.

\n

В конфигурации, где runtime и manifest попадают в entry chunk, повторная сборка может дать другой hash даже при неизменном исходном коде. Выделение runtime в отдельный chunk и стабильные module IDs уменьшают шум, но не исправляют дату в banner, разный mode или внешний список файлов. Сначала нужно установить причину различия. Затем можно менять стратегию chunks. Точный результат зависит от версии webpack и конфигурации проекта.

\n
\"Схема
Lockfile необходим, но недостаточен: после сборки сравнивают канонический manifest всех файлов, которые действительно уходят в deploy.
\n

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

\n

Сравнивайте не размер main.js, а список всех файлов, которые потребляет выкладка или браузер. Для каждого path вычислите SHA-256 и сохраните размер. Затем отсортируйте строки по path детерминированным сравнением, не зависящим от локали. Сортировка убирает ложное различие, которое создаёт разный порядок обхода каталога.

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

Код отвечает на два ограниченных вопроса: порядок входных записей не меняет digest, а изменение размера или SHA-256 меняет digest. Сравнение путей использует операции < и >, поэтому пример не зависит от локали. Проверка разделителей нужна потому, что tab и newline входят в формат canonical manifest. Код не проверяет, что приложение работает, registry выдал ожидаемый tarball или release безопасен. Эти результаты относятся только к учебным массивам. Их нельзя выдавать за результат npm, webpack или production-системы.

\n

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

\n\n\n\n\n\n\n\n\n\n\n
Маршрут от первого симптома к следующей проверке
СимптомПричинаПроверкаДействие
Разный hash lockfile.Разные dependency trees.Сравнить lockfile, package manager и историю его изменения.Выбрать один lockfile, затем выполнить npm ci.
Разный набор chunks.Разный mode, runtime или entry.Сверить Node/npm, образ, команду и whitelist переменных.Сделать mode и обязательные параметры явными.
Первым расходится HTML.Timestamp, public path или другой generated data.Открыть bytes и найти генератор поля.Передать значение явно или убрать его из deployable asset.
Многие chunks меняются после малого edit.Изменился граф модулей или runtime.Сравнить source diff и первый differing path.Проверить module IDs и runtime на малом изменении.
Один файл расходится при равных входах.Скрытый генератор или неполный контракт.Повторить чистые прогоны и открыть генератор файла.Назначить вход и владельца; не фильтровать файл молча.
\n

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

\n
    \n
  1. Создайте две новые копии одного commit. До установки запишите состояние дерева, hash lockfile, версии Node/npm, package manager flags и команду сборки.
  2. \n
  3. Запустите npm ci в каждой копии с одинаковыми flags. Если команда остановилась из-за рассинхронизации manifest и lockfile, сначала исправьте её отдельным изменением.
  4. \n
  5. Выполните одну и ту же команду сборки. Сохраните mode и разрешённые значения переменных без секретов.
  6. \n
  7. Составьте manifest всех deployable-файлов. Для каждого path сохраните размер и SHA-256, затем отсортируйте записи.
  8. \n
  9. Сравните список путей. Если файл появился или исчез, проверьте entry, mode, plugin и условие генерации.
  10. \n
  11. Для общего path сравните SHA-256, затем сам файл. Для text asset используйте diff; для binary зафиксируйте размер и источник.
  12. \n
  13. Отнесите первое различие к dependencies, runtime, config или generated data. Меняйте один слой за раз.
  14. \n
  15. После исправления повторите оба чистых прогона и сохраните карточки, manifest и короткий diff.
  16. \n
  17. Если output снова различается, не добавляйте фильтр. Вернитесь к новому первому differing path.
  18. \n
\n

Отрицательный путь и ограничения

\n

Очистка cache может быть полезной санитарной операцией, но не объясняет расхождение. npm update перед повтором меняет dependency tree и разрушает исходное сравнение. Timestamp в имени asset гарантирует разные paths. Сравнение размеров пропускает разные bytes. Фиксация только webpack не устраняет разный Node, системные библиотеки, mode или данные plugin.

\n

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

\n

Критерий готовности

\n

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

\n

Если обязательный вход отсутствует, сборка должна завершиться видимой ошибкой. Скрытый fallback возвращает проблему в следующий release. Безопасное значение для локальной разработки допустимо только там, где оно не попадает в deployable output и явно отмечено как локальное. В production-like проверке лучше остановиться, чем собрать убедительно неправильный artifact.

\n

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

" }