На 31 июля 2026 года строгий аудит проходит 64 из 358 созданных материалов. Остальные 294 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 67 из 358 созданных материалов. Остальные 291 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
| Утверждение или решение | Первичный источник | Проверенная граница |
| --- | --- | --- |
| Lockfile фиксирует конкретное дерево, включая транзитивные зависимости | [npm 6: package-locks](https://docs.npmjs.com/cli/v6/configuring-npm/package-locks/) и [package-lock.json](https://docs.npmjs.com/cli/v6/configuring-npm/package-lock-json/) | Текст не утверждает, что lockfile фиксирует OS, время, webpack config или внешний генератор. Он фиксирует разрешение пакетов, когда используется в контролируемом install. |
| <code>npm ci</code> — чистая установка с отказом при рассинхронизации lockfile и package.json | [npm 6: npm ci](https://docs.npmjs.com/cli/v6/commands/npm-ci/) | Статьи не подменяют им рабочий <code>npm install</code> и не обещают, что npm ci устраняет drift плагина или даты в asset. |
| npm config может прийти из CLI, окружения, npmrc и package.json | [npm 6: npm config](https://docs.npmjs.com/cli/v6/commands/npm-config/) | В журнал предлагается whitelist безопасных значений, а не полный <code>env</code>, токены или auth headers. |
| <code>[contenthash]</code> связан с содержимым asset, но runtime и module IDs способны расширить diff chunks | [webpack: Caching](https://webpack.js.org/guides/caching/) | Имя одного файла названо сигналом, а не доказательством равенства всего dist. Проверяется manifest всех deployable files. |
| SHA-256 нужен для сравнения выбранных bytes | [Node.js: Crypto](https://nodejs.org/api/crypto.html) | Hash не объявлен подписью релиза, доказательством работы приложения или результатом production-сборки. |
Автономная fixture не скачивает зависимости и не запускает webpack. Она
проверяет только алгоритм сравнения: перестановка одинаковых entries даёт тот
же hash manifest, изменение одного bytes даёт другой. Фактический вывод:
| Ревизия | Симптом и стоимость в начале | Главный технический вопрос | Проверяемый результат и ограничение |
| --- | --- | --- | --- |
| Практика | Один commit создаёт два dist; review и rollback не знают, какой набор assets был проверен | Как получить два чистых прогона, не смешав установку зависимостей и проверку output | Lockfile/log/manifest route, Node hash script и список входов; нет заявления о запуске этого проекта |
| Механизм | Одинаковый Git hash скрывает разные runtime, config или generated data | Где проходит граница функции build и что доказывает manifest SHA-256 | Карта входов, таблица владельцев, контрпримеры contenthash; hash не выдан за подпись или тест приложения |
| Полевой разбор | Коллеги получили разные release-файлы и хотят начать с очистки cache | Как по первому differing file отличить dependency, config и generated output | Карточка двух прогонов, дерево диагностики и обратимый путь исправления; CI и production не заявлены |
<descid="desc">Вертикальная схема начинает с двух чистых запусков, сравнивает их входы, затем разветвляется к регистрации различия либо к сравнению output manifest и первому отличающемуся файлу.</desc>
<titleid="title">Граница lockfile и остальных входов сборки</title>
<descid="desc">Вертикальная схема показывает, что commit и lockfile фиксируются в репозитории, а runtime и config нужно назвать отдельно до вычисления manifest и диагностики расхождения.</desc>
<textx="360"y="80"text-anchor="middle"fill="#ffffff"font-family="Arial, sans-serif"font-size="28"font-weight="700">Lockfile не закрывает все входы</text>
<textx="360"y="116"text-anchor="middle"fill="#d8e7f3"font-family="Arial, sans-serif"font-size="21">Фиксируем репозиторий и называем внешнюю среду.</text>
<textx="360"y="406"text-anchor="middle"fill="#7c2d12"font-family="Arial, sans-serif"font-size="27"font-weight="700">Нужно назвать отдельно</text>
<textx="360"y="444"text-anchor="middle"fill="#9a4c16"font-family="Arial, sans-serif"font-size="21">runtime, config, env и время</text>
<textx="360"y="474"text-anchor="middle"fill="#9a4c16"font-family="Arial, sans-serif"font-size="20">иначе одинаковый lockfile ничего не доказывает</text>
<textx="360"y="630"text-anchor="middle"fill="#4d3b78"font-family="Arial, sans-serif"font-size="21">webpack и plugins читают только названные входы</text>
<textx="360"y="658"text-anchor="middle"fill="#4d3b78"font-family="Arial, sans-serif"font-size="20">неназванный вход остаётся риском</text>
<textx="360"y="968"text-anchor="middle"fill="#a12f2f"font-family="Arial, sans-serif"font-size="21">сначала открыть первый differing file</text>
<textx="360"y="996"text-anchor="middle"fill="#a12f2f"font-family="Arial, sans-serif"font-size="20">не начинать расследование с очистки cache</text>
<textx="360"y="1102"text-anchor="middle"fill="#425d73"font-family="Arial, sans-serif"font-size="21">Хеш фиксирует факт, причина живёт во входах.</text>
<descid="desc">Вертикальная схема ведёт от зафиксированных исходников, зависимостей и среды к чистой установке, сборке и сортированному manifest с хешами файлов.</desc>
<textx="360"y="80"text-anchor="middle"fill="#ffffff"font-family="Arial, sans-serif"font-size="28"font-weight="700">Сначала входы, затем output</text>
<textx="360"y="116"text-anchor="middle"fill="#d8e7f3"font-family="Arial, sans-serif"font-size="21">Одинаковая команда не отменяет контракт среды.</text>
note:'<code>package-lock.json</code> описывает зафиксированное дерево зависимостей; semver-диапазон и транзитивные пакеты без него способны дать другой <code>node_modules</code>',
note:'<code>npm ci</code> требует существующий lockfile, прекращает работу при расхождении с <code>package.json</code>, удаляет старый <code>node_modules</code> и не меняет manifest или lockfile',
note:'параметры npm могут прийти из CLI, окружения, npmrc или package.json; источник конфигурации надо записывать рядом с прогоном',
};
constwebpackCaching={
title:'webpack: Caching',
url:'https://webpack.js.org/guides/caching/',
note:'<code>[contenthash]</code> связан с содержимым asset; guide отдельно разбирает влияние runtime и module IDs на имена файлов',
};
constnodeCrypto={
title:'Node.js: Crypto',
url:'https://nodejs.org/api/crypto.html',
note:'<code>createHash()</code> создаёт hash-объект; в фикстуре он применяется к lockfile и каноническому списку файлов, а не выдаётся за подпись release',
excerpt:'Одинаковый commit выпустил разные файлы. Фиксируем lockfile, runtime, конфигурацию и список SHA-256, чтобы найти меняющийся вход, а не очищать cache наугад.',
readingMinutes:12,
},
[
paragraph('Симптом неприятный и дорогой: один commit собирают два разработчика, а в <code>dist</code> разные имена файлов или разные байты. На первом шаге это часто называют «кэшем webpack» и запускают сборку ещё раз. Цена такого лечения выше лишних минут: в review нельзя понять, что именно уйдёт в релиз, а исправление на одной машине не становится проверяемым для другой. Проблема не в самом хеше. Хеш честно сообщает, что в цепочку вошли разные данные или разный порядок работы. Задача — превратить это сообщение в короткое расследование.'),
paragraph('Для проекта на npm и webpack 2019 года не нужно строить абстрактную платформу. Надо договориться о входах: исходный commit, <code>package-lock.json</code>, версия Node и npm, команда, важные параметры окружения и конфигурация сборщика. Затем два раза собрать в отдельной копии дерева и сравнить не названия «на глаз», а канонический список файлов с SHA-256. Такой список не доказывает, что release безопасен. Он отвечает на более узкий вопрос: одинаковый ли output у записанных входов.'),
heading('Сначала отделяем сборку от окружения разработчика'),
paragraph('У обычного <code>npm install</code> есть законное право обновить дерево зависимостей и записать lockfile. В рабочем цикле это удобно, но при проверке повторяемости смешивает два действия: разрешение версий и установку уже разрешённого дерева. Для контрольного прогона нужен другой контракт. npm 6 описывает <code>npm ci</code> как чистую установку: команда требует lockfile, прекращает работу при расхождении с <code>package.json</code>, удаляет прежний <code>node_modules</code> и не пишет manifest или lockfile. Это не делает все файлы мира одинаковыми, но закрывает случайную переустановку транзитивной зависимости.'),
paragraph('Чистая установка не означает «запустить на домашней машине без записи контекста». npm берёт настройки из флагов, переменных среды, <code>.npmrc</code>, пользовательского и глобального npmrc. Версия runtime влияет на транспиляцию, native-пакеты и скрипты жизненного цикла. Поэтому журнал хранит не секреты и не полный дамп окружения, а минимум, который позволяет повторить путь: hash commit, версию Node/npm, выбранный registry без токена, hash lockfile, команду build и SHA-256 output-manifest. Если в логе есть пароль, его нельзя считать доказательством: он уже инцидент.'),
dataTable(
'Минимальные входы контрольной сборки',
['Вход','Почему может менять результат','Что записать','Чего не делать'],
[
['Исходный код','Другой commit, незакоммиченный файл или generated source меняет bundle','<code>git rev-parse HEAD</code> и состояние дерева','Не писать «актуальный master» вместо точного hash'],
['Дерево пакетов','Semver-диапазон и транзитивный пакет могут разрешиться иначе','SHA-256 <code>package-lock.json</code> и факт <code>npm ci</code>','Не перегенерировать lockfile во время сравнения'],
['Node, npm и npmrc','Runtime и параметры установки участвуют в сборочных скриптах','Только версии и безопасные значения registry/config','Не копировать токены, proxy-пароли или весь <code>env</code>'],
['Команда и config','Другой mode, public path или define-переменная меняют bytes','Полную команду и hash конфигурации','Не подменять production-команду похожей локальной'],
['Output','Имя файла не доказывает равенство всех файлов','Отсортированный manifest SHA-256','Не сравнивать только один главный bundle'],
],
),
heading('Фикстура: журнал, lockfile и manifest'),
paragraph('Ниже не «скрипт для CI» и не обещание, что он уже работал в production. Это маленькая фикстура для disposable-копии репозитория. Она сначала сохраняет наблюдаемые входы, потом выполняет ровно ту сборочную команду, которую проект использует, и только после неё создаёт manifest. Команду <code>npm ci</code> нельзя подменять <code>npm install</code>: во втором случае можно одновременно искать ошибку и незаметно менять объект расследования. Если проект не поддерживает npm 6 или применяет Yarn, нужно выбрать эквивалент чистой установки из документации своего менеджера.'),
codeBlock([
'# Запускать в отдельной копии рабочего дерева, не в папке с незаписанной работой.',
'shasum -a 256 build-proof/first.manifest > build-proof/first.manifest.sha256',
]),
paragraph('Скрипт <code>tools/proof-manifest.mjs</code> ниже читает байты каждого файла, сортирует относительные пути и печатает digest вместе с путём. Сортировка важна: файловая система не обязана возвращать каталог в одном и том же порядке. Manifest превращает набор из десятков assets в один diff, который можно показать коллеге. При расхождении не начинайте с поиска «правильного» хеша. Сначала сравните сами manifest: если отличается один <code>runtime</code>-файл, путь расследования иной, чем когда changed half of <code>node_modules</code> или HTML содержит момент времени.'),
codeBlock(proofManifestScript),
heading('Два прогона должны различаться только именем журнала'),
paragraph('Сделайте второй прогон с тем же commit и тем же lockfile, но в свежей рабочей копии. Он должен получить собственные <code>second.log</code> и <code>second.manifest</code>. Нельзя брать уже построенный <code>dist</code> как «второй результат»: тогда мы проверим сохранность каталога, а не повторяемость сборки. Перед сравнением полезно проверить, что оба manifest содержат одинаковое число строк. Это не заменяет hash, но быстро ловит случай, когда в одном прогоне вообще не возник CSS, source map или chunk.'),
codeBlock([
'# Во второй чистой копии с тем же commit и package-lock.json:',
'shasum -a 256 build-proof/first.manifest build-proof/second.manifest',
]),
paragraph('Если manifest совпал, фиксируем результат узко: «в этих двух чистых прогонах с записанными входами SHA-256 manifest совпал». Не надо расширять вывод до «сборка всегда воспроизводима»: не проверены другая ОС, другой CPU, другой registry и будущие версии инструментов. Если manifest различается, журнал позволяет сначала сравнить версии и lockfile, потом команды и config, и только после этого идти в webpack output. Простой порядок избавляет от бесконечного удаления cache.'),
figure('/assets/editorial/2019/reproducible-build-input-contract-2019.svg','Схема контрольной сборки: commit, package-lock, версии Node и npm, безопасная конфигурация и команда поступают в чистую установку и webpack; результатом является отсортированный manifest SHA-256','Повторяемость начинается не с хеша bundle, а с явного списка входов. Hash manifest нужен, чтобы сравнить весь output одним проверяемым артефактом.'),
heading('Как читать расхождение без гадания'),
paragraph('Первый тип расхождения — разный lockfile hash или разные версии npm. Здесь нет смысла обсуждать webpack: установка получила разные входы. Второй — lockfile одинаков, а в журнале другая команда, <code>NODE_ENV</code>, <code>--mode</code> или project npmrc. Это граница конфигурации. Третий — входы записаны одинаково, но расходятся assets. Тогда смотрим на самый ранний различающийся файл и на его содержимое. В webpack 4 имя с <code>[contenthash]</code> связано с content asset, но runtime, manifest и идентификаторы модулей могут сделать отличие шире, чем изменение одного исходника. Не делайте вывод по одному filename.'),
paragraph('Особый класс — время, путь, locale и случайность. Banner-плагин может вставить дату, генератор документации — абсолютный путь, а код — случайный идентификатор. Такой output не лечится новой фиксацией npm-пакета: надо найти генератор и решить, должен ли он получить фиксированное значение, исключаться из сравнения по явному правилу или жить вне deployable artifact. Исключение нельзя молча сделать через <code>grep -v</code>. Его нужно назвать в контракте и объяснить, почему файл не влияет на поставку.'),
heading('Маршрут внедрения в старый webpack-проект'),
orderedList([
'Выбрать одну реальную команду сборки и отдельную копию дерева. Записать commit, runtime и hash lockfile до установки.',
'Заменить для контрольного прогона обычный install на <code>npm ci</code>; если команда падает на рассинхронизации, исправить manifest/lockfile отдельным изменением.',
'Добавить manifest по файлам deployable-каталога. Сначала хранить его рядом с журналом, не в public assets.',
'Повторить сборку в новой копии и сравнить manifest. При различии классифицировать вход: dependency, config, environment или generated output.',
'Внести минимальное исправление одной причины. Повторить два прогона и оставить рядом diff/лог без секретов.',
'Только после стабильного fixture решать, где этот контроль будет жить дальше: локальная release-инструкция, отдельный job или проверка перед выкладкой.',
]),
heading('Что считается готовым, а что нет'),
paragraph('Готовый первый шаг — не красивый badge и не обещание «у нас детерминированный webpack». Это папка доказательства, из которой другой разработчик понимает exact commit, lockfile hash, runtime, команду и отличие либо совпадение manifest. Для наследуемого проекта этого достаточно, чтобы следующая ошибка не начиналась с памяти о том, как однажды помогла очистка cache. Следующий шаг определяется причиной: зафиксировать версию инструмента, убрать timestamp, оформить project npmrc или разделить runtime chunk. Один symptom не требует сразу переделывать всю цепочку доставки.'),
heading('Ограничение результата'),
paragraph('Эта практика не выполняла <code>npm ci</code> и webpack для данного архива и не выдаёт sample hash за hash сайта. В модуле пакета есть автономная hash-фикстура: она доказывает только свойство канонического manifest — одинаковые sample files в разном порядке дают один SHA-256, изменённый байт даёт другой. Реальный проект должен получить собственные журнал и manifest на своей версии Node/npm. Именно запись этих артефактов отделяет проверку от убедительного, но пустого рассказа.'),
excerpt:'Lockfile фиксирует дерево пакетов, но не время, конфигурацию и поведение plugins. Разбираем, какие входы принадлежат сборке и что именно доказывает SHA-256 manifest.',
readingMinutes:13,
},
[
paragraph('Симптом выглядит нелогично: исходники не менялись, commit тот же, а webpack выдаёт другой <code>main.*.js</code>. Цена ошибки — не только cache miss. Когда команда не знает границы входов, она спорит о случайности вместо того, чтобы показать изменение в байтах и найти владельца. Проблема начинается с слишком короткой модели: «webpack собирает source». На деле bundle зависит от разрешённого дерева пакетов, runtime, конфигурации, командной строки и данных, которые plugins читают во время работы. Одинаковый Git hash не делает эти входы одинаковыми автоматически.'),
paragraph('Воспроизводимая сборка в этой заметке — проверяемое свойство конкретной команды: два чистых прогона с зафиксированным набором входов дают одинаковый набор байтов, либо различие явно объяснено. Это не сертификат для всех машин и не замена тестам. Такая формулировка полезна, потому что разрешает действовать: сначала назвать входы, потом сохранить их, потом сравнить output. Если один вход не фиксируется, он становится не мистикой, а точкой контракта.'),
heading('Сборка как функция с внешними аргументами'),
paragraph('Удобная модель для практики: <code>artifact = build(source, dependencyTree, runtime, config, environment, generatedData)</code>. Git хранит в основном source и часть config. Lockfile хранит dependencyTree, но только если он действительно используется. Runtime — это версия Node, npm и иногда платформенные библиотеки для native dependency. Environment — режим сборки, locale, timezone, path, registry, системные переменные. Generated data — дата в banner, случайный ID, список файлов каталога, ответ внешнего API. Модель не требует заморозить вселенную. Она заставляет для каждого отличившегося байта спросить: какой аргумент его породил?'),
dataTable(
'Граница входов и наблюдаемый след',
['Компонент функции','Пример дрейфа','Как увидеть','Чей следующий шаг'],
[
['<code>source</code>','Незаписанный generated file или другой commit','Hash HEAD и <code>git status --short</code>','Разработчик фиксирует или исключает generated source по правилам репозитория'],
['<code>dependencyTree</code>','Новый транзитивный пакет попал под semver-диапазон','Hash package-lock и log чистой установки','Владелец зависимостей обновляет lockfile отдельным diff'],
['<code>runtime</code>','Другой Node меняет синтаксическое преобразование или native module','<code>node --version</code>, <code>npm --version</code>','Проект задаёт поддерживаемую версию и способ её получить'],
['<code>config</code>','Другой mode, publicPath или define-значение','Команда, project config и безопасный diff параметров','Владелец build config делает параметр явным'],
['<code>generatedData</code>','Дата, абсолютный путь, порядок чтения каталога, случайный ID','Diff конкретного asset и его генератор','Владелец генератора фиксирует значение или документирует исключение'],
],
),
paragraph('Таблица не предлагает печатать всё окружение в лог. В конфигурации почти всегда есть секреты, а полный env делает сравнение шумным. Нужен белый список: версии tools, выбранная команда, hash lockfile, registry host без токена, mode и явно поддерживаемые build-переменные. Если после такого списка результат расходится, это не повод перейти к дампу секретов. Это повод сравнить фактический output и раскрыть следующий неназванный вход.'),
heading('Lockfile фиксирует разрешение, а не весь процесс'),
paragraph('npm отделяет <code>package.json</code> от lockfile не случайно. Manifest говорит, какой диапазон или пакет хочет проект; lockfile описывает конкретное дерево, включая resolved location и integrity. Наличие точной верхней версии в manifest не всегда закрывает транзитивные зависимости. Поэтому для сравнения «два прогона одной сборки» важно не только прочесть <code>dependencies</code>, а проверить, что оба прогона используют один и тот же <code>package-lock.json</code>. npm рекомендует коммитить этот файл именно для того, чтобы команда и системы установки получали одинаковое дерево.'),
paragraph('<code>npm ci</code> полезен здесь не скоростью, а отказом от творчества. Он не пытается аккуратно совместить локальный <code>node_modules</code> с новым manifest и не чинит lockfile по ходу проверки. Если <code>package.json</code> и lockfile расходятся, остановка — правильный результат: проверяемого входа нет. Нельзя обходить её удалением lockfile или переходом на <code>npm install</code>. Сначала нужно решить, какое дерево зависимостей проект вообще выбирает, закоммитить этот выбор отдельным diff, а потом вернуться к проверке output.'),
heading('Почему filename с contenthash — не весь доказательный набор'),
paragraph('Webpack использует <code>[contenthash]</code> как hash содержимого asset, поэтому различное имя bundle — полезная лампа: bytes изменились. Но обратное заключение слишком сильное. Один файл может не сменить имя, а другой asset, HTML, source map или CSS уже расходятся. И наоборот, runtime или идентификаторы модулей способны менять несколько chunk names после малого изменения графа модулей. В guide webpack это разобрано на примере runtime и module IDs: без устойчивых идентификаторов изменение порядка разрешения может сдвинуть hash vendor chunk. Для диагноста это значит: filename — вход в расследование, manifest всех deployable files — доказательство его результата.'),
paragraph('Не надо автоматически включать в сравнение всё, что лежит рядом с <code>dist</code>. Source map иногда содержит абсолютные пути. Статистика webpack может быть журналом, а не артефактом. Правильный вопрос: «какие файлы потребляет выкладка или браузер?» Составьте allowlist или директорию deployable output и храните её в build contract. Если source map тоже доставляется пользователю, он входит в manifest. Если нет — исключение должно быть явным, с причиной и владельцем. Молчаливое исключение создаёт ложное совпадение.'),
figure('/assets/editorial/2019/reproducible-build-hash-boundary-2019.svg','Схема границы: commit и lockfile фиксируют исходники и зависимости, runtime/config/generated data остаются отдельными входами; после build сравнивается hash канонического manifest, а не один filename','Lockfile необходим, но не достаточен. Все данные, которые build читает, надо либо фиксировать, либо честно обозначить как источник различия.'),
heading('Hash отвечает на точный вопрос'),
paragraph('SHA-256 в этом процессе — отпечаток набора уже выбранных байтов. Он не объясняет различие, не заменяет подпись пакета и не проверяет, что bundle работает. Его сила в сравнении: два одинаковых manifest дают одинаковый digest, а один изменённый файл меняет manifest. Чтобы это свойство не испортил порядок каталога, manifest строится из строк <code>sha256 + path</code>, отсортированных по пути. Сначала можно читать diff строк, затем сравнить итоговый hash для короткого отчёта. Если хранить только один digest, вы узнаете, что проблема есть, но потеряете место, где она возникла.'),
'console.log(digest(first) === digest(second)); // true: порядок массива не влияет',
]),
paragraph('В пакете есть автономный <code>--run-fixture</code> с этим правилом. Он использует sample lockfile и sample bytes, поэтому его выход нельзя назвать результатом npm, webpack или production. Он проверяет только алгоритм: перестановка одинаковых entries даёт тот же digest, изменение байта меняет digest. Для реальной сборки потребуется внешний журнал и manifest после фактической команды. Это ограничение важно: хорошая фикстура делает ровно одно утверждение и не прячется за знакомые слова.'),
heading('Как локализовать первую разницу'),
orderedList([
'Сравнить commit, lockfile digest, версии Node/npm и команду. Если здесь есть отличие, остановиться: output ещё не предметный.',
'Сравнить список путей manifest. Появившийся или пропавший файл указывает на ветку config, plugin или entry.',
'Для общего пути сравнить SHA-256, затем сам файл. Для text asset достаточно diff; для binary нужен путь к генератору и размер.',
'Если первым расходится HTML или runtime, проверить mode, public path, runtime chunk, define-переменные и данные banner/plugins.',
'Если расходится зависимый bundle, проверить lockfile и лог установки до удаления cache. Новый package version может изменить graph.',
'После одной правки повторить оба чистых прогона. Не складывать несколько гипотез в один commit: тогда manifest совпадёт или разойдётся без объяснения.',
]),
heading('Типовые ложные исправления'),
paragraph('Первое ложное исправление — закрепить версию webpack и объявить задачу закрытой. Версия сборщика важна, но date banner или разный mode продолжат менять output. Второе — добавить <code>[contenthash]</code> и сравнивать только имена. Это хорошо для cache policy, но не для полного доказательства. Третье — добавить timestamp в имя журнала и потом случайно включить журнал в manifest. Четвёртое — отключить source map, чтобы hash совпал, хотя map должен поставляться клиенту. В каждом случае результат выглядит спокойнее, но граница продукта изменилась молча.'),
heading('Практический предел модели'),
paragraph('Две одинаковые локальные сборки не утверждают, что Linux и macOS дадут одинаковые binary dependencies, что registry всегда отдаст те же tarball или что внешняя генерация никогда не изменится. Эти вопросы нужно добавлять по мере реальной стоимости ошибки. Для автора 2019 года полезнее сначала получить один воспроизводимый путь на поддерживаемой среде, чем написать манифест о supply chain, не умея сравнить два <code>dist</code>. Следующий разумный артефакт — короткий runbook с входами и последним результатом, а не ещё один набор флагов webpack.'),
excerpt:'Полевой разбор двух разных dist из одного commit: как не спутать lockfile, режим сборки, runtime и timestamp plugin, а затем оставить короткий след для следующей выкладки.',
readingMinutes:13,
},
[
paragraph('Симптом из поля: разработчик собрал release, коллега повторил тот же commit и получил другой <code>vendors.*.js</code>. Приложение открывается в обоих случаях, поэтому проблему предлагают отложить: «на сервере всё равно соберётся ещё раз». Цена такого решения появляется при rollback и расследовании: неясно, какой именно каталог проверяли, а cache получает два набора assets для одного номера версии. Здесь нельзя лечить исходя из названия файла. Нужно построить цепочку: что было входом, где байты впервые разошлись и какое изменение делает правило явным.'),
paragraph('Кейс намеренно небольшой. В нём есть npm 6, webpack 4, legacy plugin и один deployable каталог. Нет реального production-лога, не заявляется запуск CI и не приводится hash настоящего сайта. Вместо этого есть метод, который можно применить в отдельной копии проекта: два чистых прогона, два журнала, два manifest и один минимальный diff. Такой масштаб важен: если сразу обвинить registry, minifier и операционную систему, команда получит много версий истории, но ни одного проверяемого факта.'),
heading('Собираем карточку инцидента до исправления'),
paragraph('Первая запись должна быть короче issue. Для обоих прогонов нужны: commit, состояние дерева, SHA-256 lockfile, версия Node/npm, команда сборки, ключевые build variables без секретов и manifest. Самый частый пропуск — чистота каталога. Если второй прогон выполняется после первого в том же <code>node_modules</code> и <code>dist</code>, непонятно, какой слой взял данные из прошлого результата. Поэтому берём два worktree или две disposable-копии. Это не бюрократия: <code>npm ci</code> сам удалит <code>node_modules</code>, а независимый каталог защищает от старых generated files.'),
dataTable(
'Карточка двух прогонов',
['Поле','Прогон A','Прогон B','Смысл различия'],
[
['Commit','<code>git rev-parse HEAD</code>','<code>git rev-parse HEAD</code>','Любое отличие прекращает сравнение output'],
['Lockfile','SHA-256 package-lock.json','SHA-256 package-lock.json','Разный digest означает разные зависимые входы'],
['Runtime','Node/npm version','Node/npm version','Разная версия — гипотеза до чтения webpack diff'],
['Команда','<code>npm run build</code> с mode','Та же строка команды','Mode и env должны быть записаны, а не remembered'],
['Manifest','SHA-256 каждого deployable file','То же представление','Diff показывает первый фактический разрыв'],
],
),
paragraph('Таблица не делает два окружения одинаковыми. Она делает отличие наблюдаемым. Например, если lockfile hash различается, нельзя продолжать спор о module id: изменилось dependencyTree. Если все поля равны, но differ только <code>index.html</code>, не нужно обновлять packages: нужно открыть HTML и найти переменную, timestamp или public path. Форма записи экономит время потому, что запрещает перескакивать через предыдущий слой без доказательства.'),
heading('Отделяем разные зависимости от разного output'),
paragraph('В первом сценарии два <code>package-lock.json</code> отличаются. Причина часто не в том, что разработчик «не сделал npm install». npm lockfile существует именно для фиксации дерева; без него следующий install может выбрать более свежий пакет в допустимом диапазоне, включая транзитивный. Действие: остановить сборочные прогоны, рассмотреть diff manifest и lockfile как отдельное изменение, затем выбрать один tree и снова запустить чистую установку. Нельзя копировать чей-то <code>node_modules</code> в архив: это маскирует проблему, не делает её ревизируемой.'),
paragraph('Во втором сценарии lockfile и runtime совпадают, но config нет. Один запуск получил <code>NODE_ENV=production</code>, другой — development, либо webpack config читает переменную без default. Признак — разный набор chunks, source maps или public URL. Действие: сохранить точную команду в package script или release runbook, а требуемые переменные проверять до запуска. Выводить весь <code>process.env</code> не нужно: он может раскрыть секрет. Лучше перечислить одну переменную и её допустимые значения, если именно она меняет output.'),
heading('Третий сценарий: lockfile и команда равны, bytes нет'),
paragraph('Здесь появляется настоящая диагностическая работа. Сравнение manifest показывает самый ранний отличающийся файл. Допустим, это banner в главном bundle с текущей датой. Чистая установка ничего не изменит: dependencyTree уже одинаков. Нужно решить контракт generated data. Если дата служит только журналу, хранить её в отдельном файле доказательства, а не в deployable asset. Если дата нужна пользователю, считать её частью входа: передавать явно, записывать значение и ожидать разный output для разного release. Плохой выход — «не сравнивать этот файл», не объясняя, почему он участвует в выкладке.'),
paragraph('Другой частый след — движение hash всех chunks после малого изменения graph. В webpack 4 runtime может содержать связь chunk/module IDs, а порядок разрешения влияет на эти IDs. Это не доказательство bug в webpack и не повод сразу править optimisation наугад. Сначала на маленьком diff проверить, менялся ли source graph и какой chunk расходится первым. Затем оценить, нужна ли стабилизация module IDs и выделение runtime. Цель не в том, чтобы любой локальный edit сохранял hash vendor файла, а в том, чтобы причина изменения была известна и проверяема.'),
figure('/assets/editorial/2019/reproducible-build-diagnosis-2019.svg','Диагностическая схема двух чистых прогонов: сравниваются commit, lockfile, runtime и команда; затем сравнивается manifest. Ветка ведёт к dependency, config или generated output, а не к очистке cache.','Порядок проверки не сокращает техническую проблему. Он не даёт начать с webpack, пока разные входы ещё не исключены.'),
heading('Минимальный fixture без обещаний о CI'),
paragraph('Вместо скриншота terminal полезнее маленький fixture. Модуль P21 умеет запустить <code>--run-fixture</code>: он канонически сортирует sample paths, считает SHA-256 и проверяет два свойства. Перестановка входных записей не меняет manifest hash; изменение одного sample bundle меняет его. Fixture не загружает пакет из registry, не выполняет webpack и не создаёт production artifact. Его роль скромная, но честная: правило сравнения можно проверить отдельно от сети и конкретной машины.'),
'# Ожидаемая форма результата, не hash настоящего проекта:',
'{',
' "fixtureOnly": true,',
' "checks": {',
' "sameEntriesDifferentOrder": true,',
' "changedFileChangesManifest": true',
' }',
'}',
]),
paragraph('Реальный fixture строится поверх тех же правил, но после реальной команды build. Для двух копий проекта сохраняем <code>first.log</code>, <code>second.log</code>, <code>first.manifest</code> и <code>second.manifest</code>. Логи полезны только вместе с контекстом. Фраза «npm ci завершился успешно» без версии Node, lockfile hash и команды не позволяет повторить даже успешный сценарий. В то же время log не должен хранить auth header, token или полный URL private registry. Проверка сборки не отменяет базовую гигиену секретов.'),
heading('Маршрут исправления одной причины'),
orderedList([
'Взять два новых каталога на одном commit. До установки записать hash lockfile, Node/npm и выбранную build-команду.',
'Запустить <code>npm ci</code> в каждом каталоге. Расхождение manifest/package-lock считается отдельной задачей, а не поводом продолжить.',
'Собрать и создать отсортированный SHA-256 manifest только для deployable output.',
'Сравнить manifest; для первого отличающегося path открыть bytes и его генератор.',
'Отнести отличие к одному владельцу: dependency tree, runtime/config или generated data. Зафиксировать только эту причину отдельным diff.',
'Повторить два чистых прогона. При совпадении сохранить короткий результат; при расхождении не скрывать файл, а продолжить от следующего earliest diff.',
'Определить, где живёт проверка дальше. Она может остаться release checklist, пока нет доказательства, что её нужно автоматизировать.',
]),
heading('Что не помогло бы в этом кейсе'),
paragraph('Удалить cache вручную — полезный санитарный приём, но это не объяснение. Поменять output filename на timestamp — наоборот, гарантированно создаст разные paths. Выполнить <code>npm update</code> перед повторной проверкой — изменит dependencyTree и разрушит исходный эксперимент. Закрепить абсолютную версию одной прямой зависимости — не обязательно остановит транзитивный дрейф без lockfile. Наконец, сравнить только размер <code>main.js</code> — недостаточно: одинаковый размер допускает разные bytes, а HTML, CSS и дополнительные chunks останутся без проверки.'),
heading('Критерий закрытия и обратимый шаг'),
paragraph('Кейс закрыт не когда manifest «однажды совпал», а когда в repository или release-note есть воспроизводимый маршрут: какие inputs записать, как сделать две чистые установки, где лежит manifest и какое различие считать нормальным. Исправление должно быть обратимо. Например, если project config начинает требовать <code>BUILD_VERSION</code>, добавьте явную ошибку при его отсутствии и document default для локальной разработки, а не встраивайте переменную без следа. Если это решение ломает legacy deploy, можно убрать обязательность одним малым изменением и сохранить fixture как диагноз.'),
paragraph('В этом пакете не было реального production прогона и не появилось нового CI job. Есть три подробные статьи, собственные SVG и автономная hash-фикстура. Они дают следующий разговор с проектом: «вот выбранные inputs, вот manifest, вот первый файл различия». Для команды 2019 года это уже сильнее общего совета «закрепите зависимости», потому что совет превратился в действие, журнал и проверяемый предел вывода.'),
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.