DarkRiDDeR12 мин

Один commit — два dist: как собрать доказательство, а не спор

JavaScriptnpmWebpackСборка

Симптом неприятный и дорогой: один commit собирают два разработчика, а в dist разные имена файлов или разные байты. На первом шаге это часто называют «кэшем webpack» и запускают сборку ещё раз. Цена такого лечения выше лишних минут: в review нельзя понять, что именно уйдёт в релиз, а исправление на одной машине не становится проверяемым для другой. Проблема не в самом хеше. Хеш честно сообщает, что в цепочку вошли разные данные или разный порядок работы. Задача — превратить это сообщение в короткое расследование.

Для проекта на npm и webpack 2019 года не нужно строить абстрактную платформу. Надо договориться о входах: исходный commit, package-lock.json, версия Node и npm, команда, важные параметры окружения и конфигурация сборщика. Затем два раза собрать в отдельной копии дерева и сравнить не названия «на глаз», а канонический список файлов с SHA-256. Такой список не доказывает, что release безопасен. Он отвечает на более узкий вопрос: одинаковый ли output у записанных входов.

Сначала отделяем сборку от окружения разработчика

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

Чистая установка не означает «запустить на домашней машине без записи контекста». npm берёт настройки из флагов, переменных среды, .npmrc, пользовательского и глобального npmrc. Версия runtime влияет на транспиляцию, native-пакеты и скрипты жизненного цикла. Поэтому журнал хранит не секреты и не полный дамп окружения, а минимум, который позволяет повторить путь: hash commit, версию Node/npm, выбранный registry без токена, hash lockfile, команду build и SHA-256 output-manifest. Если в логе есть пароль, его нельзя считать доказательством: он уже инцидент.

Минимальные входы контрольной сборки
ВходПочему может менять результатЧто записатьЧего не делать
Исходный кодДругой commit, незакоммиченный файл или generated source меняет bundlegit rev-parse HEAD и состояние дереваНе писать «актуальный master» вместо точного hash
Дерево пакетовSemver-диапазон и транзитивный пакет могут разрешиться иначеSHA-256 package-lock.json и факт npm ciНе перегенерировать lockfile во время сравнения
Node, npm и npmrcRuntime и параметры установки участвуют в сборочных скриптахТолько версии и безопасные значения registry/configНе копировать токены, proxy-пароли или весь env
Команда и configДругой mode, public path или define-переменная меняют bytesПолную команду и hash конфигурацииНе подменять production-команду похожей локальной
OutputИмя файла не доказывает равенство всех файловОтсортированный manifest SHA-256Не сравнивать только один главный bundle

Фикстура: журнал, lockfile и manifest

Ниже не «скрипт для CI» и не обещание, что он уже работал в production. Это маленькая фикстура для disposable-копии репозитория. Она сначала сохраняет наблюдаемые входы, потом выполняет ровно ту сборочную команду, которую проект использует, и только после неё создаёт manifest. Команду npm ci нельзя подменять npm install: во втором случае можно одновременно искать ошибку и незаметно менять объект расследования. Если проект не поддерживает npm 6 или применяет Yarn, нужно выбрать эквивалент чистой установки из документации своего менеджера.

# Запускать в отдельной копии рабочего дерева, не в папке с незаписанной работой.
mkdir -p build-proof
{
  git rev-parse HEAD
  node --version
  npm --version
  npm config get registry
  shasum -a 256 package-lock.json
  npm ci
  npm run build
} > build-proof/first.log 2>&1

node tools/proof-manifest.mjs dist > build-proof/first.manifest
shasum -a 256 build-proof/first.manifest > build-proof/first.manifest.sha256

Скрипт tools/proof-manifest.mjs ниже читает байты каждого файла, сортирует относительные пути и печатает digest вместе с путём. Сортировка важна: файловая система не обязана возвращать каталог в одном и том же порядке. Manifest превращает набор из десятков assets в один diff, который можно показать коллеге. При расхождении не начинайте с поиска «правильного» хеша. Сначала сравните сами manifest: если отличается один runtime-файл, путь расследования иной, чем когда changed half of node_modules или HTML содержит момент времени.

// tools/proof-manifest.mjs
// Запускать в отдельной копии репозитория после сборки.
import { createHash } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';

async function filesUnder(directory, prefix = '') {
  const entries = await readdir(directory, { withFileTypes: true });
  const nested = await Promise.all(entries.map(async (entry) => {
    const relative = prefix ? prefix + '/' + entry.name : entry.name;
    const absolute = join(directory, entry.name);
    return entry.isDirectory() ? filesUnder(absolute, relative) : [relative];
  }));
  return nested.flat();
}

function digest(bytes) {
  return createHash('sha256').update(bytes).digest('hex');
}

const outputDirectory = process.argv[2] || 'dist';
const files = (await filesUnder(outputDirectory)).sort();
for (const relative of files) {
  const bytes = await readFile(join(outputDirectory, relative));
  process.stdout.write(digest(bytes) + '  ' + relative + '\n');
}

Два прогона должны различаться только именем журнала

Сделайте второй прогон с тем же commit и тем же lockfile, но в свежей рабочей копии. Он должен получить собственные second.log и second.manifest. Нельзя брать уже построенный dist как «второй результат»: тогда мы проверим сохранность каталога, а не повторяемость сборки. Перед сравнением полезно проверить, что оба manifest содержат одинаковое число строк. Это не заменяет hash, но быстро ловит случай, когда в одном прогоне вообще не возник CSS, source map или chunk.

# Во второй чистой копии с тем же commit и package-lock.json:
mkdir -p build-proof
npm ci > build-proof/second.log 2>&1
npm run build >> build-proof/second.log 2>&1
node tools/proof-manifest.mjs dist > build-proof/second.manifest

diff -u build-proof/first.manifest build-proof/second.manifest
shasum -a 256 build-proof/first.manifest build-proof/second.manifest

Если manifest совпал, фиксируем результат узко: «в этих двух чистых прогонах с записанными входами SHA-256 manifest совпал». Не надо расширять вывод до «сборка всегда воспроизводима»: не проверены другая ОС, другой CPU, другой registry и будущие версии инструментов. Если manifest различается, журнал позволяет сначала сравнить версии и lockfile, потом команды и config, и только после этого идти в webpack output. Простой порядок избавляет от бесконечного удаления cache.

Схема контрольной сборки: commit, package-lock, версии Node и npm, безопасная конфигурация и команда поступают в чистую установку и webpack; результатом является отсортированный manifest SHA-256
Повторяемость начинается не с хеша bundle, а с явного списка входов. Hash manifest нужен, чтобы сравнить весь output одним проверяемым артефактом.

Как читать расхождение без гадания

Первый тип расхождения — разный lockfile hash или разные версии npm. Здесь нет смысла обсуждать webpack: установка получила разные входы. Второй — lockfile одинаков, а в журнале другая команда, NODE_ENV, --mode или project npmrc. Это граница конфигурации. Третий — входы записаны одинаково, но расходятся assets. Тогда смотрим на самый ранний различающийся файл и на его содержимое. В webpack 4 имя с [contenthash] связано с content asset, но runtime, manifest и идентификаторы модулей могут сделать отличие шире, чем изменение одного исходника. Не делайте вывод по одному filename.

Особый класс — время, путь, locale и случайность. Banner-плагин может вставить дату, генератор документации — абсолютный путь, а код — случайный идентификатор. Такой output не лечится новой фиксацией npm-пакета: надо найти генератор и решить, должен ли он получить фиксированное значение, исключаться из сравнения по явному правилу или жить вне deployable artifact. Исключение нельзя молча сделать через grep -v. Его нужно назвать в контракте и объяснить, почему файл не влияет на поставку.

Маршрут внедрения в старый webpack-проект

  1. Выбрать одну реальную команду сборки и отдельную копию дерева. Записать commit, runtime и hash lockfile до установки.
  2. Заменить для контрольного прогона обычный install на npm ci; если команда падает на рассинхронизации, исправить manifest/lockfile отдельным изменением.
  3. Добавить manifest по файлам deployable-каталога. Сначала хранить его рядом с журналом, не в public assets.
  4. Повторить сборку в новой копии и сравнить manifest. При различии классифицировать вход: dependency, config, environment или generated output.
  5. Внести минимальное исправление одной причины. Повторить два прогона и оставить рядом diff/лог без секретов.
  6. Только после стабильного fixture решать, где этот контроль будет жить дальше: локальная release-инструкция, отдельный job или проверка перед выкладкой.

Что считается готовым, а что нет

Готовый первый шаг — не красивый badge и не обещание «у нас детерминированный webpack». Это папка доказательства, из которой другой разработчик понимает exact commit, lockfile hash, runtime, команду и отличие либо совпадение manifest. Для наследуемого проекта этого достаточно, чтобы следующая ошибка не начиналась с памяти о том, как однажды помогла очистка cache. Следующий шаг определяется причиной: зафиксировать версию инструмента, убрать timestamp, оформить project npmrc или разделить runtime chunk. Один symptom не требует сразу переделывать всю цепочку доставки.

Ограничение результата

Эта практика не выполняла npm ci и webpack для данного архива и не выдаёт sample hash за hash сайта. В модуле пакета есть автономная hash-фикстура: она доказывает только свойство канонического manifest — одинаковые sample files в разном порядке дают один SHA-256, изменённый байт даёт другой. Реальный проект должен получить собственные журнал и manifest на своей версии Node/npm. Именно запись этих артефактов отделяет проверку от убедительного, но пустого рассказа.

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

  • npm 6: package-lockspackage-lock.json описывает зафиксированное дерево зависимостей; semver-диапазон и транзитивные пакеты без него способны дать другой node_modules
  • npm 6: package-lock.json — описаны поля version, resolved и integrity и назначение lockfile в корне репозитория
  • npm 6: npm cinpm ci требует существующий lockfile, прекращает работу при расхождении с package.json, удаляет старый node_modules и не меняет manifest или lockfile
  • npm 6: npm config — параметры npm могут прийти из CLI, окружения, npmrc или package.json; источник конфигурации надо записывать рядом с прогоном
  • webpack: Caching[contenthash] связан с содержимым asset; guide отдельно разбирает влияние runtime и module IDs на имена файлов
  • Node.js: CryptocreateHash() создаёт hash-объект; в фикстуре он применяется к lockfile и каноническому списку файлов, а не выдаётся за подпись release