From 4aa8056f7cb8543267e70ec0d686a8507a48d666 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 23:04:54 +0300 Subject: [PATCH] editorial: polish reproducible builds article 294 --- editorial/agent-rewrites/294.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editorial/agent-rewrites/294.json b/editorial/agent-rewrites/294.json index 9a81fed..8e0b2c9 100644 --- a/editorial/agent-rewrites/294.json +++ b/editorial/agent-rewrites/294.json @@ -3,5 +3,5 @@ "slug": "editorial-2019-11-practice-reproducible-builds", "title": "Один commit — два dist: как доказать воспроизводимость сборки", "excerpt": "Одинаковый commit иногда даёт разные assets. Разбираем входы webpack-сборки, чистую установку npm и manifest SHA-256, который показывает место расхождения.", - "contentHtml": "

Один и тот же commit собирают два раза, а в dist появляются разные файлы. Меняются имя chunk, размер bundle или даже байты при одинаковом размере. Команда снова очищает cache и запускает build. Иногда это временно скрывает симптом. Причина остаётся. Цена ошибки растёт на релизе: review видит один результат, CI публикует другой, а откат нельзя связать с точным набором входов.

\n

Воспроизводимость не означает, что любой компьютер всегда выдаст одинаковый файл. Это проверяемое утверждение о конкретном маршруте: два чистых прогона с одинаковыми зафиксированными входами должны дать одинаковый набор deployable-байтов. Если результат различается, журнал должен показать, какой вход изменился, либо какой output содержит недетерминированное значение. Такой предел превращает спор о «странном webpack» в проверку.

\n

Тезис: сравнивать нужно входы и весь результат

\n

Удобно считать сборку функцией: artifact = build(source, dependencies, runtime, config, environment, generatedData). Git фиксирует source и часть config. Lockfile фиксирует выбранное дерево зависимостей. Node и npm задают runtime и поведение install-скриптов. Команда, mode, define-переменные и project .npmrc меняют конфигурацию. Banner с датой, случайный ID, абсолютный путь или порядок чтения каталога добавляют generated data.

\n

Одинаковый Git hash проверяет только один аргумент. Одинаковое имя main.[contenthash].js проверяет не весь output. Поэтому контроль состоит из двух частей: записать безопасный минимум входов и посчитать канонический manifest всех файлов, которые действительно попадают в поставку. Manifest содержит путь и SHA-256 байтов каждого файла. Сортировка по пути убирает шум файловой системы.

\n

Сначала закрыть дрейф зависимостей

\n

npm install может пересчитать дерево и изменить lockfile. Для обычной разработки это ожидаемо. Для сравнения двух сборок это лишнее изменение эксперимента. npm ci требует lockfile, проверяет его согласованность с package.json, удаляет существующий node_modules и устанавливает описанное дерево. Если команда остановилась из-за рассинхронизации, это полезный результат: проверяемого входа пока нет.

\n

Чистая установка не замораживает всё окружение. npm читает параметры из CLI, переменных среды, .npmrc и package.json. Запишите версию Node, версию npm, hash lockfile, безопасное имя registry, полную build-команду и hash конфигурации. Не копируйте в журнал весь env. Токен, пароль и приватный URL с учётными данными превращают диагностический лог в риск.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Разный набор или размер assetsДругой commit, незаписанный или generated filegit rev-parse HEAD, git status --shortСобрать из точного commit; generated source включить или исключить явно
Расходятся vendor или loader-файлыДругое дерево зависимостейHash package-lock.json, результат npm ciИсправить manifest и lockfile отдельным diff
Один прогон падает или меняет native outputДругой Node, npm, ОС или архитектураnode --version, npm --version, платформаЗадать поддерживаемую среду и повторить
Отличаются mode, public path или HTMLРазная команда или конфигурацияКоманда, config, .npmrc, build-переменныеСделать параметр явным и записать безопасное значение
Меняются дата, путь, порядок или IDНедетерминированные generated dataПервый diff и код, который его пишетЗафиксировать значение, убрать из output или задать исключение
Главный bundle совпал, релиз различаетсяПроверили не весь deployable outputManifest всех файловСравнить строки manifest, а не один размер
\n

Учебный пример: канонический manifest

\n

Следующий код — учебный пример для disposable-копии проекта. Он не запускает сборку, не подписывает release и не доказывает свойство production-артефакта. Его задача уже и проще: получить одинаковое представление одного набора файлов независимо от порядка чтения каталога. В реальном проекте передайте в функцию байты каждого файла из заранее определённой директории deployable output.

\n
import { createHash } from 'node:crypto';\n\nfunction sha256(bytes) {\n  return createHash('sha256').update(bytes).digest('hex');\n}\n\nfunction manifest(files) {\n  return files\n    .map(({ path, bytes }) => sha256(bytes) + '  ' + path)\n    .sort()\n    .join('\\\\n') + '\\\\n';\n}\n\nconst first = manifest([\n  { path: 'dist/main.js', bytes: Buffer.from('same\\\\n') },\n  { path: 'dist/index.html', bytes: Buffer.from('<script>main</script>\\\\n') },\n]);\n\nconst second = manifest([\n  { path: 'dist/index.html', bytes: Buffer.from('<script>main</script>\\\\n') },\n  { path: 'dist/main.js', bytes: Buffer.from('same\\\\n') },\n]);\n\nconsole.log(first === second); // true для этих учебных входов
\n

Важны три детали. Hash считается по байтам, а не по отображаемому размеру. В строку входит относительный путь, потому что исчезнувший или переименованный файл тоже меняет результат. Перед сравнением строки сортируются, потому что порядок обхода каталога не является контрактом. В настоящем отчёте сохраняйте сам manifest рядом с командами и версиями. Один итоговый digest удобен для статуса, но diff строк нужен для расследования.

\n

Как читать расхождение

\n

Если hash lockfile различается, остановитесь на зависимостях. Нет смысла сравнивать webpack, пока два процесса установили разные деревья. Если lockfile совпадает, сравните Node, npm, ОС, архитектуру и параметры установки. Native-пакет или lifecycle script может зависеть от платформы. Если входы совпадают, откройте первый различившийся path в manifest и найдите код, который его формирует.

\n

Дата в banner, абсолютный путь и случайное значение требуют отдельного решения. Не маскируйте их через удаление строк из manifest без правила. Если файл не поставляется пользователю, исключите его из явного allowlist. Если поставляется, зафиксируйте источник значения или уберите его из результата. Если разный output допустим по контракту, проверяйте не полное равенство, а заранее описанный инвариант. Молчаливое исключение не является воспроизводимостью.

\n

Webpack связывает [contenthash] с содержимым asset, но изменение порядка разрешения модулей может затронуть module IDs, vendor chunk и runtime. Поэтому одинаковый размер не доказывает одинаковые байты, а совпавший hash одного файла не доказывает совпадение HTML, CSS, source map и дополнительных chunks. Сначала определите, какие файлы потребляет deploy. Именно этот список сравнивайте.

\n
\"Схема
Повторяемость начинается с явного набора входов. Manifest нужен для сравнения всего deployable output, а не одного главного bundle.
\n

Порядок проверки

\n
  1. Выберите одну настоящую build-команду и точный deployable-каталог. Запишите commit, состояние дерева, Node, npm и hash lockfile.
  2. Создайте две свежие копии того же commit. Не используйте уже существующий node_modules и не переносите готовый dist во второй прогон.
  3. Выполните npm ci. При ошибке согласованности остановитесь и исправьте manifest или lockfile отдельным diff.
  4. Запустите одну и ту же build-команду с одинаковыми безопасно записанными параметрами.
  5. Постройте отсортированный manifest по всем файлам, которые входят в поставку. Проверьте число строк и сравните diff.
  6. При различии классифицируйте первый diff как source, dependency, runtime, config или generated data. Исправляйте одну причину за раз.
  7. Повторите два чистых прогона и сохраните входы, manifest и короткое объяснение результата без секретов.
\n

Что не сработает

\n

Очистка cache не исправляет другой lockfile, дату в banner или путь в source map. npm update перед вторым прогоном меняет объект сравнения. Жёсткая версия одной прямой зависимости не заменяет lockfile для транзитивного дерева. Сравнение размера main.js пропускает разные байты и остальные файлы. Замена имени output на timestamp делает результат менее воспроизводимым.

\n

Ограничения

\n

Два совпавших прогона не доказывают равенство на другой ОС, CPU, версии libc, registry или будущей версии toolchain. SHA-256 manifest не заменяет подпись, проверку происхождения, тесты и проверку поведения приложения. Если сборка использует внешний API, текущую дату, системный часовой пояс или случайность, их нужно включить в контракт или убрать из пути поставки. Если это невозможно, честный результат звучит так: «полное равенство не обещается; проверяется такой-то инвариант».

\n

Эта статья не содержит production-прогона и не выдаёт учебный hash за результат сайта. Реальный критерий зависит от выбранной команды и allowlist output. Нельзя закрыть проблему фразой «manifest однажды совпал».

\n

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

\n

Проверка готова, когда другой инженер получает точный commit, hash lockfile, версии runtime, build-команду, список deployable-файлов и два manifest. Он может повторить два чистых прогона без догадок. При совпадении записано узкое утверждение: «для этих входов два manifest совпали». При расхождении указан первый различившийся файл, причина или следующий проверяемый вход. Это проверяемый предел результата, а не обещание детерминизма всей системы.

\n

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

" + "contentHtml": "

Один и тот же commit собирают два раза, а в dist появляются разные файлы. Меняются имя chunk, размер bundle или даже байты при одинаковом размере. Команда снова очищает cache и запускает build. Иногда это временно скрывает симптом. Причина остаётся. Цена ошибки растёт на релизе: review видит один результат, CI публикует другой, а откат нельзя связать с точным набором входов.

\n

Воспроизводимость не означает, что любой компьютер всегда выдаст одинаковый файл. Это проверяемое утверждение о конкретном маршруте: два чистых прогона с одинаковыми зафиксированными входами должны дать одинаковый набор deployable-байтов. Если результат различается, журнал должен показать, какой вход изменился, либо какой output содержит недетерминированное значение. Такой предел превращает спор о «странном webpack» в проверку.

\n

Тезис: сравнивать нужно входы и весь результат

\n

Удобно считать сборку функцией: artifact = build(source, dependencies, runtime, config, environment, generatedData). Git фиксирует source и часть config. Lockfile фиксирует выбранное дерево зависимостей. Node и npm задают runtime и поведение install-скриптов. Команда, mode, define-переменные и project .npmrc меняют конфигурацию. Banner с датой, случайный ID, абсолютный путь или порядок чтения каталога добавляют generated data.

\n

Одинаковый Git hash проверяет только один аргумент. Одинаковое имя main.[contenthash].js проверяет не весь output. Поэтому контроль состоит из двух частей: записать безопасный минимум входов и посчитать канонический manifest всех файлов, которые действительно попадают в поставку. Manifest содержит путь и SHA-256 байтов каждого файла. Сортировка по пути убирает шум файловой системы.

\n

Сначала закрыть дрейф зависимостей

\n

npm install может пересчитать дерево и изменить lockfile. Для обычной разработки это ожидаемо. Для сравнения двух сборок это лишнее изменение эксперимента. npm ci требует lockfile, проверяет его согласованность с package.json, удаляет существующий node_modules и устанавливает описанное дерево. Если команда остановилась из-за рассинхронизации, это полезный результат: проверяемого входа пока нет.

\n

Чистая установка не замораживает всё окружение. npm читает параметры из CLI, переменных среды, .npmrc и package.json. Запишите версию Node, версию npm, hash lockfile, безопасное имя registry, полную build-команду и hash конфигурации. Не копируйте в журнал весь env. Токен, пароль и приватный URL с учётными данными превращают диагностический лог в риск.

\n

Для legacy-проекта 2019 года отдельно зафиксируйте совместимую пару Node/npm и версию webpack. Справка npm 6 ниже описывает исторический контракт npm ci; у другой версии CLI или сборщика могут быть дополнительные флаги и ограничения.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Разный набор или размер assetsДругой commit, незаписанный или generated filegit rev-parse HEAD, git status --shortСобрать из точного commit; generated source включить или исключить явно
Расходятся vendor или loader-файлыДругое дерево зависимостейHash package-lock.json, результат npm ciИсправить manifest и lockfile отдельным diff
Один прогон падает или меняет native outputДругой Node, npm, ОС или архитектураnode --version, npm --version, платформаЗадать поддерживаемую среду и повторить
Отличаются mode, public path или HTMLРазная команда или конфигурацияКоманда, config, .npmrc, build-переменныеСделать параметр явным и записать безопасное значение
Меняются дата, путь, порядок или IDНедетерминированные generated dataПервый diff и код, который его пишетЗафиксировать значение, убрать из output или задать исключение
Главный bundle совпал, релиз различаетсяПроверили не весь deployable outputManifest всех файловСравнить строки manifest, а не один размер
\n

Учебный пример: канонический manifest

\n

Следующий код — учебный пример для disposable-копии проекта. Он не запускает сборку, не подписывает release и не доказывает свойство production-артефакта. Его задача уже и проще: получить одинаковое представление одного набора файлов независимо от порядка чтения каталога. В реальном проекте передайте в функцию байты каждого файла из заранее определённой директории deployable output.

\n
import { createHash } from 'node:crypto';\n\nfunction sha256(bytes) {\n  return createHash('sha256').update(bytes).digest('hex');\n}\n\nfunction manifest(files) {\n  return files\n    .map(({ path, bytes }) => ({ path, digest: sha256(bytes) }))\n    .sort((a, b) => a.path.localeCompare(b.path))\n    .map(({ path, digest }) => digest + '  ' + path)\n    .join('\\\\n') + '\\\\n';\n}\n\nconst first = manifest([\n  { path: 'dist/main.js', bytes: Buffer.from('same\\\\n') },\n  { path: 'dist/index.html', bytes: Buffer.from('<script>main</script>\\\\n') },\n]);\n\nconst second = manifest([\n  { path: 'dist/index.html', bytes: Buffer.from('<script>main</script>\\\\n') },\n  { path: 'dist/main.js', bytes: Buffer.from('same\\\\n') },\n]);\n\nconsole.log(first === second); // true для этих учебных входов
\n

Важны три детали. Hash считается по байтам, а не по отображаемому размеру. В строку входит относительный путь, потому что исчезнувший или переименованный файл тоже меняет результат. Перед сериализацией записи сортируются по относительному пути, потому что порядок обхода каталога не является контрактом. В настоящем отчёте сохраняйте сам manifest рядом с командами и версиями. Один итоговый digest удобен для статуса, но diff строк нужен для расследования.

\n

Как читать расхождение

\n

Если hash lockfile различается, остановитесь на зависимостях. Нет смысла сравнивать webpack, пока два процесса установили разные деревья. Если lockfile совпадает, сравните Node, npm, ОС, архитектуру и параметры установки. Native-пакет или lifecycle script может зависеть от платформы. Если входы совпадают, откройте первый различившийся path в manifest и найдите код, который его формирует.

\n

Дата в banner, абсолютный путь и случайное значение требуют отдельного решения. Не маскируйте их через удаление строк из manifest без правила. Если файл не поставляется пользователю, исключите его из явного allowlist. Если поставляется, зафиксируйте источник значения или уберите его из результата. Если разный output допустим по контракту, проверяйте не полное равенство, а заранее описанный инвариант. Молчаливое исключение не является воспроизводимостью.

\n

Webpack связывает [contenthash] с содержимым asset, но изменение порядка разрешения модулей может затронуть module IDs, vendor chunk и runtime. Поэтому одинаковый размер не доказывает одинаковые байты, а совпавший hash одного файла не доказывает совпадение HTML, CSS, source map и дополнительных chunks. Сначала определите, какие файлы потребляет deploy. Именно этот список сравнивайте.

\n
\"Схема
Повторяемость начинается с явного набора входов. Manifest нужен для сравнения всего deployable output, а не одного главного bundle.
\n

Порядок проверки

\n
  1. Выберите одну настоящую build-команду и точный deployable-каталог. Запишите commit, состояние дерева, Node, npm и hash lockfile.
  2. Создайте две свежие копии того же commit. Не используйте уже существующий node_modules и не переносите готовый dist во второй прогон.
  3. Выполните npm ci. При ошибке согласованности остановитесь и исправьте manifest или lockfile отдельным diff.
  4. Запустите одну и ту же build-команду с одинаковыми безопасно записанными параметрами.
  5. Постройте отсортированный manifest по всем файлам, которые входят в поставку. Проверьте число строк и сравните diff.
  6. При различии классифицируйте первый diff как source, dependency, runtime, config или generated data. Исправляйте одну причину за раз.
  7. Повторите два чистых прогона и сохраните входы, manifest и короткое объяснение результата без секретов.
\n

Что не сработает

\n

Очистка cache не исправляет другой lockfile, дату в banner или путь в source map. npm update перед вторым прогоном меняет объект сравнения. Жёсткая версия одной прямой зависимости не заменяет lockfile для транзитивного дерева. Сравнение размера main.js пропускает разные байты и остальные файлы. Замена имени output на timestamp делает результат менее воспроизводимым.

\n

Ограничения

\n

Два совпавших прогона не доказывают равенство на другой ОС, CPU, версии libc, registry или будущей версии toolchain. SHA-256 manifest не заменяет подпись, проверку происхождения, тесты и проверку поведения приложения. Если сборка использует внешний API, текущую дату, системный часовой пояс или случайность, их нужно включить в контракт или убрать из пути поставки. Если это невозможно, честный результат звучит так: «полное равенство не обещается; проверяется такой-то инвариант».

\n

Код выше проверяет только каноническое представление набора файлов и не заменяет запуск сборки, тесты или проверку происхождения артефакта. Реальный критерий зависит от выбранной команды и allowlist output. Нельзя закрыть проблему фразой «manifest однажды совпал».

\n

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

\n

Проверка готова, когда другой инженер получает точный commit, hash lockfile, версии runtime, build-команду, список deployable-файлов и два manifest. Он может повторить два чистых прогона без догадок. При совпадении записано узкое утверждение: «для этих входов два manifest совпали». При расхождении указан первый различившийся файл, причина или следующий проверяемый вход. Это проверяемый предел результата, а не обещание детерминизма всей системы.

\n

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

" }