Поставка софта Упаковка и установка: пакеты, контейнеры, инсталляторы, подпись
0%

Упаковка и установка: пакеты, контейнеры, инсталляторы, подпись

Упаковка и установка: пакеты, контейнеры, инсталляторы, подпись

Между «код работает у нас» и «код работает у клиента» лежит слой, который почти никто не проектирует специально. Он появляется сам: кто-то однажды написал скрипт, который кладёт бинарь в архив, потом к архиву прирос инсталлятор, потом к инсталлятору — сертификат, купленный в спешке за день до релиза. Через год этот слой стоит команде больше часов в месяц, чем половина продуктовых фич, и никто не может ответить на простой вопрос: сколько у нас на самом деле артефактов и кто отвечает за каждый.

Эта глава — про то, чтобы упаковка была решением, а не осадочной породой. Мы уже разобрали, в каких моделях артефакт вообще уезжает к клиенту (модели поставки), какие обязательства накладывает лицензия на то, что лежит рядом с бинарём (лицензии), и через какие каналы это доезжает (каналы, магазины). Здесь — форма. Что именно вы кладёте в файл, как чужая машина понимает, что с этим делать, и почему она вам верит.

Техническую базу — конвейер, реестры образов, герметичность сборки — мы не пересказываем: она в треках devops и platform-engineering, а криптография подписи и провенанс подробно разобраны в цепочке поставок. Наш угол — поставка: что покупатель получает в руки, во что это обходится вам и какие обязательства создаёт.

Пакет — это не архив, а обещание

Архив отвечает на один вопрос: какие файлы внутри. Пакет отвечает на семь:

  1. Как это называется и какой это версии — так, чтобы машина могла сравнить две версии между собой.
  2. От чего это зависит и с чем конфликтует.
  3. Куда лечь на диск и с какими правами.
  4. Что сделать до установки, после установки, до удаления и после удаления.
  5. Что считать конфигурацией, которую нельзя затирать при обновлении.
  6. Под какой лицензией это распространяется и что положено рядом.
  7. Кто это собрал и как в этом убедиться.

Первые шесть пунктов — метаданные, седьмой — подпись. Артефакт без метаданных и подписи не является поставкой: это файл, который кто-то откуда-то скачал. Разница видна ровно в тот момент, когда у клиента что-то ломается и надо ответить на вопрос «какая версия чего у вас стоит и откуда она взялась».

Обратите внимание на два узла, которые в самодельных схемах поставки обычно отсутствуют: «отказ до записи на диск» и «откат». Пока их нет, у вас не поставка, а надежда.

Метаданные: минимальный честный набор

Формат разный, содержание одинаковое. Вот control из deb — самый читаемый пример того, что вообще бывает в метаданных:

Package: myapp
Version: 2:2.4.1-1
Architecture: amd64
Depends: libc6 (>= 2.31), ca-certificates
Recommends: myapp-doc
Conflicts: myapp-legacy
Replaces: myapp-legacy (<< 2.0)
Maintainer: Example LLC <packages@example.com>
Homepage: https://example.com/myapp
Description: Сервис учёта заявок
 Демон и консольная утилита. Конфигурация в /etc/myapp/,
 данные в /var/lib/myapp/.

Что здесь стоит понимать не как формальность:

  • Depends — это контракт со средой, а не пожелание. Если вы пишете libc6 (>= 2.31), вы утверждаете, что проверяли. Не проверяли — не пишите: пакет установится и упадёт при первом запуске, а диагностировать будет клиент.
  • Conflicts и Replaces — это план миграции. Они описывают, что делать с прошлым поколением вашего же продукта. Отсутствие этих полей означает «две версии встанут рядом и подерутся за порт».
  • Version с эпохой (2:). Эпоха — аварийный люк: она позволяет объявить версию старше, чем требует лексикографика, если вы однажды выпустили 20240301 и хотите вернуться к 2.4.1. Эпоху нельзя убрать обратно. Это односторонняя дверь, и открывать её надо осознанно.
  • Конфигурация. В deb это conffiles, в rpm — %config(noreplace). Пометьте файл конфигурацией — и обновление не затрёт правки администратора. Забудьте — и вы однажды сотрёте клиенту продакшн-настройки очередным патчем. Это самая дешёвая по цене исправления и самая дорогая по последствиям ошибка в упаковке.

Форматы: за что вы платите в каждом

Форматы удобно раскладывать по двум осям: насколько пакет зависит от среды, в которую едет, и во что обходится поставщику его поддержка.

Формат Что получает покупатель Во что обходится поставщику Какое обязательство создаёт
deb, rpm Родная установка, зависимости из системы, обновления вместе с ОС Репозиторий, ключ, матрица дистрибутивов и версий Держать репозиторий живым годами; чужой пакетный менеджер станет вашим каналом обновлений
AppImage Один файл, запуск без прав администратора Сборка на самой старой поддерживаемой системе Свой механизм обновлений; никакой изоляции по умолчанию
Flatpak, Snap Изоляция, единый магазин, автообновления Поддержка версии рантайма, портальные разрешения Мигрировать вслед за рантаймом; в случае Snap — зависеть от одного магазина
Образ OCI Воспроизводимый запуск, привычные инструменты Реестр, трафик, обновление базового слоя ради уязвимостей Отвечать за всё, что внутри образа, включая чужую системную библиотеку
MSI и инсталляторы Тихая установка, корпоративная раскатка, аккуратное удаление Сертификат подписи, знание правил апгрейда Совместимость апгрейдов на годы вперёд; неверный GUID компонента ломает удаление
Бандл macOS Перетащил и работает Членство в программе разработчика, нотаризация Перевыпускать подпись при смене требований платформы
Статический бинарь Скачал и запустил Почти ничего сверх кросс-компиляции Полностью свой канал обновлений и своя ответственность за уязвимости

Главный вывод таблицы неочевиден: чем больше среды пакет несёт с собой, тем дешевле он в момент установки и тем дороже в момент уязвимости. Системный пакет получает исправление libssl от дистрибутива бесплатно. Образ и AppImage — только когда вы пересоберётесь и выпустите новую версию. Это не аргумент против контейнеров, это статья расходов, которую надо внести в план: «пересборка ради чужих CVE» — регулярная работа, а не аврал.

Нативные пакеты Linux: репозиторий как обязательство

Собрать .deb просто. Дорого — то, что начинается после.

# Дерево будущего пакета: слева путь в пакете, справа то, что там окажется
#   pkgroot/DEBIAN/control, postinst, prerm, conffiles
#   pkgroot/usr/bin/myapp, pkgroot/etc/myapp/config.yaml, pkgroot/lib/systemd/system/myapp.service

# Сборка: --root-owner-group важен, иначе в пакет уедут uid и gid сборочной машины
dpkg-deb --build --root-owner-group ./pkgroot dist/myapp_2.4.1-1_amd64.deb

# Проверка до публикации: линтер ловит больше, чем ревью глазами
lintian --fail-on error,warning dist/myapp_2.4.1-1_amd64.deb

# Индекс репозитория и подписанный файл релиза
apt-ftparchive packages dist > repo/dists/stable/main/binary-amd64/Packages
gzip -kf repo/dists/stable/main/binary-amd64/Packages
apt-ftparchive release repo/dists/stable > repo/dists/stable/Release
gpg --default-key release@example.com --clearsign \
    --output repo/dists/stable/InRelease repo/dists/stable/Release

Клиент подключает это так — и вот здесь появляется первое обязательство:

sudo install -m 0644 example-archive.gpg /etc/apt/keyrings/example-archive.gpg
echo "deb [signed-by=/etc/apt/keyrings/example-archive.gpg] https://apt.example.com stable main" \
  | sudo tee /etc/apt/sources.list.d/example.list
sudo apt update && sudo apt install myapp

Что вы только что пообещали клиенту:

  • Домен репозитория живёт столько же, сколько продукт. Не переезжает, не отдаёт 404 на старых путях, не ломает TLS. У клиента apt update в кроне: сломанный репозиторий превращается в ежедневную ошибку на сотнях машин.
  • Ключ надо когда-то ротировать, а старый — не отзывать резко. Стандартный сценарий: год публикуете новым и старым ключом, потом выкатываете пакет example-archive-keyring, который сам подкладывает новый ключ, и только потом прекращаете подписывать старым. Ротация без переходного периода означает, что все машины, не обновлявшиеся в окно, отваливаются молча.
  • Инструкция «скачайте deb с сайта» — это поставка без проверки подлинности. Подпись в мире deb живёт не в пакете, а в индексе репозитория; одиночный файл проверять по большому счёту нечем. В rpm иначе: подпись встроена в заголовок пакета, rpm --checksig работает на отдельном файле, но и там метаданные репозитория подписываются отдельно.

Анатомия deb, MSI, бандла macOS и образа OCI: где физически лежит подпись

Скрипты сопровождения — главный источник инцидентов при установке. Они выполняются от root на чужой машине, в среде, которую вы не видели. Правила, выстраданные сообществом дистрибутивов:

  • Скрипт должен быть идемпотентен: он выполнится повторно при переустановке и при неудачном обновлении.
  • Скрипт обязан различать установку и обновление. В rpm это аргумент $1 (единица — первая установка, двойка — обновление), в deb — аргументы configure, upgrade, remove, purge.
  • Порядок при обновлении в rpm противоречит интуиции: %post новой версии выполняется до %preun старой. Скрипт удаления старого пакета, который бездумно сносит службу, снесёт уже настроенную новую.
  • Не запускайте службу молча. Уважайте policy-rc.d, используйте deb-systemd-invoke и systemd-пресеты — иначе вы стартуете демон на машине, которую администратор готовил как золотой образ.
  • Никогда не ходите в сеть из postinstall. Установка обязана работать в закрытом контуре: это прямое требование корпоративных клиентов из главы про self-hosted.

Универсальные форматы Linux: чем платят за «работает везде»

  • AppImage — один исполняемый файл, монтирующий свой образ. Ноль установки, ноль прав администратора, ноль интеграции: ярлыков нет, обновлений нет, песочницы нет. Ключевое инженерное ограничение — собирать надо на самой старой поддерживаемой системе: glibc совместим вперёд, но не назад. Обновления придётся делать самим (zsync-дельты или свой апдейтер).
  • Flatpak — изоляция плюс общие рантаймы (org.freedesktop.Platform и производные). Дельта-обновления по объектному хранилищу, разрешения через порталы. Цена: рантайм имеет версию и срок жизни, за ним надо мигрировать; всё, что программе нужно за пределами песочницы, придётся явно запрашивать и объяснять пользователю.
  • Snap — squashfs плюс демон, строгая изоляция, принудительные автообновления. Ровно одна работающая площадка, ей и принадлежит канал. Если для вас важна независимость канала поставки (см. каналы), это существенный минус, а не деталь.

Практическое правило для небольшой команды: один родной формат на семейство плюс образ OCI. Всё остальное — по запросу платящего клиента и с записью в договоре, кто это сопровождает.

Контейнер как формат поставки

Образ OCI — это манифест, конфигурация и слои, адресуемые по дайджесту. Для доставки важны три следствия.

Тег — это ссылка, дайджест — это факт. Тег можно переписать, дайджест — нет. Всё, что касается воспроизводимости поставки, обязано опираться на @sha256:..., а тег остаётся человекочитаемым указателем.

# Многоплатформенная сборка и публикация с провенансом
docker buildx build --platform linux/amd64,linux/arm64 \
  --provenance=true --sbom=true \
  --tag ghcr.io/example/myapp:2.4.1 --push .

# Зафиксировать дайджест — именно он попадёт в манифесты клиента
DIGEST=$(crane digest ghcr.io/example/myapp:2.4.1)

# Подпись без долгоживущего ключа: удостоверение выдаётся под identity конвейера
cosign sign --yes "ghcr.io/example/myapp@${DIGEST}"

# Проверка на стороне клиента: доверяем не «кому-то с ключом», а конкретному репозиторию и издателю
cosign verify "ghcr.io/example/myapp@${DIGEST}" \
  --certificate-identity-regexp '^https://github\.com/example/myapp/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Трафик — это счёт, и платит его чаще всего поставщик. Прикиньте порядок: образ 400 МБ, у клиента 200 узлов, полный пул раз в сутки при выкатке — это 80 ГБ в день только за один кластер. При цене исходящего трафика порядка 0,05–0,09 USD за гигабайт (типичный диапазон публичных облаков на середину 2026 года) один такой клиент стоит вам несколько тысяч рублей в месяц просто за то, что образ большой. Отсюда прикладной вывод: работа над размером образа — это не эстетика, а прямое сокращение переменных затрат, и она окупается тем быстрее, чем крупнее клиенты.

Внутри образа лежит чужой код, за который отвечаете вы. Базовый слой с уязвимой системной библиотекой — это ваша уязвимость в глазах клиента и его сканера, даже если ваш код безупречен. Отсюда обязательство: регулярная пересборка и выпуск патч-версий без изменений в вашем коде. Ставьте это в календарь так же, как дежурства.

Механику слоёв, кэша и реестров подробно разбирает трек devops (контейнеры и реестры); изоляцию на уровне ядра — трек операционных систем (виртуализация и контейнеры).

Windows: MSI, тихая установка и корпоративный контур

Windows — платформа, где упаковка сильнее всего влияет на то, купит ли вас крупный клиент. Причина простая: корпоративная раскатка идёт через средства управления, а они умеют работать с MSI и MSIX предсказуемо, а с самописным EXE — как повезёт.

# То, что администратор должен уметь сделать без единого клика
msiexec /i myapp.msi /qn /norestart ALLUSERS=1 `
  INSTALLDIR="C:\Program Files\MyApp" LICENSEKEY=XXXX-YYYY /l*v install.log

# Коды возврата, на которые обязана реагировать автоматизация:
#   0    — установлено
#   1641 — установлено, машина перезагружается
#   3010 — установлено, требуется перезагрузка
#   1603 — фатальная ошибка, смотреть журнал
#   1618 — параллельно идёт другая установка

Что надо знать про апгрейды, потому что здесь ломается чаще всего:

  • ProductVersion сравнивается только по первым трём числам. Четвёртое поле игнорируется при определении мажорного обновления. Схема версий вида 2.4.1.1 и 2.4.1.2 означает, что второе обновление просто не установится поверх первого.
  • UpgradeCode остаётся неизменным всю жизнь продукта, ProductCode меняется с каждым мажорным апгрейдом. Перепутали — получите две записи в списке программ и невозможность корректно удалить.
  • GUID компонента привязан к конкретному пути установки. Правила компонентов Windows Installer выглядят бюрократией ровно до первого клиента, у которого удаление старой версии унесло файлы новой.
  • Тихая установка обязана быть параметризуемой. Лицензионный ключ, адрес сервера, каталог данных — всё через свойства MSI или файл ответов. Инсталлятор, который умеет только мастер с кнопками, отсекает вас от рынка, где машин больше десяти.

Отдельно: удаление — часть продукта. Корректная запись в списке установленных программ, честный размер, работающая деинсталляция, осознанное решение о судьбе пользовательских данных. Если данные остаются, скажите об этом в интерфейсе удаления: молчаливое сохранение чужих данных на диске — это вопрос из области персональных данных, которому посвящена следующая глава.

macOS: подпись, нотаризация и Gatekeeper

На macOS упаковка и подпись слиты в один процесс: система не станет запускать неподписанное и не заверенное приложение из интернета.

# 1. Подпись бандла: hardened runtime обязателен для нотаризации
codesign --force --options runtime --timestamp \
  --sign "Developer ID Application: Example LLC (TEAMID)" MyApp.app

# 2. Отправка на нотаризацию: Apple проверяет артефакт автоматикой
ditto -c -k --keepParent MyApp.app MyApp.zip
xcrun notarytool submit MyApp.zip --keychain-profile "notary" --wait

# 3. Прикрепление билета к артефакту, чтобы проверка работала без сети
xcrun stapler staple MyApp.app

# 4. Проверка ровно тем механизмом, которым будет проверять пользователь
spctl --assess --type execute -vv MyApp.app

Три вещи, которые стоят потерянных релизов:

  • Подпись покрывает каждый файл бандла. Любая правка ресурса после подписи ломает её. Значит, порядок в конвейере жёсткий: собрать, положить всё на места, подписать, только потом упаковывать в .dmg или .pkg (и подписать контейнер отдельно).
  • Нотаризация — это очередь. Обычно минуты, иногда часы. Планировать релиз в предположении «займёт пять минут» нельзя; закладывайте худший случай, как и для ревью в магазинах.
  • Билет надо прикреплять. Без stapler первая проверка идёт в сеть — и пользователь без интернета или за строгим прокси видит отказ запуска.

Членство в программе разработчика Apple — 99 USD в год на момент середины 2026 года; без него нет ни подписи Developer ID, ни нотаризации. Это не «расход на подписку», а лицензия на право поставлять софт под платформу: сравнивать надо не с нулём, а со стоимостью не поставлять вовсе.

Мобильные платформы: коротко и по делу

Мобильная упаковка — это .aab/.apk и .ipa, где подпись неотделима от магазина: платформа не пускает установку без валидной подписи, а магазин — без своей. Ключевое для поставки: ключ подписи Android — актив уровня домена и базы данных. Потеря ключа исторически означала невозможность обновлять приложение; схемы вроде подписи приложений на стороне магазина смягчают риск, но переносят его в другую точку — теперь ключом владеет площадка. Решение принимается один раз и на всю жизнь продукта.

Подробный релизный конвейер под магазины разобран в треке мобильной разработки (релизы и магазины), а правила площадок и цена комиссии — в главе про магазины.

Реестры языков: публикация как односторонняя дверь

npm, PyPI, crates.io, Maven Central, NuGet, Go-модули — это тоже упаковка и тоже канал, просто для другой аудитории: ваш пакет ставят не пользователи, а инженеры в свои сборки. Отличие от всех предыдущих форматов принципиальное: версия, однажды опубликованная, неизменна.

  • Нельзя перезалить 1.2.3 с исправлением. Можно только выпустить 1.2.4. Реестры отклоняют повторную публикацию того же номера намеренно: иначе рушится вся модель фиксации зависимостей.
  • Удаление опубликованного ограничено окном и правилами (в одних реестрах — часы, в других — только пометка «отозвано»). Планируйте так, будто удалить нельзя.
  • Секрет, случайно попавший в опубликованный пакет, считайте скомпрометированным немедленно, а не после удаления пакета.
  • Публикуйте из конвейера по короткоживущему удостоверению (доверенная публикация через OIDC), а не по долгоживущему токену в переменной окружения. Токен в CI — самый частый способ потерять контроль над чужими сборками; подробности в цепочке поставок и управлении секретами.
  • Имя пакета — это ещё и бренд. Занять его стоит раньше, чем понадобится.

Подпись: что именно она доказывает

Подпись не говорит «этот код хороший». Она говорит: «этот байт-в-байт артефакт выпущен владельцем вот этого ключа и не менялся с тех пор». Всё, что вы строите поверх, — про то, кому именно вы разрешаете быть этим владельцем.

Метка времени — не украшение. Без неё подпись перестаёт считаться валидной в день истечения сертификата, и все выпущенные ранее артефакты начинают ругаться. С меткой они остаются валидными: проверяющий видит, что подпись поставлена в период действия сертификата.

Ключ в файле — это утечка, которая ещё не случилась. Отраслевые правила для сертификатов подписи кода с 2023 года требуют хранения закрытого ключа в аппаратном модуле, и это заодно решило спор «а можно нам ключ в переменной окружения CI»: нельзя. Практические варианты: аппаратный токен у релиз-инженера (дёшево, но плохо автоматизируется и создаёт человека — единую точку отказа), облачный подписной сервис (автоматизируется, платится помесячно), собственный HSM (дорого, оправдано на масштабе). Для образов и открытых артефактов есть третий путь — подпись без долгоживущего ключа: удостоверение выдаётся на минуты под удостоверение конвейера, а факт подписи фиксируется в публичном журнале прозрачности. Тогда проверяющий сверяет не «есть ли подпись», а «подписал ли именно тот конвейер того репозитория».

Порядок величин расходов на середину 2026 года (проверяйте актуальные цены у поставщика, они меняются):

Статья Порядок Что за этим стоит
Сертификат подписи кода для Windows 200–600 USD в год плюс аппаратный носитель Проверка организации удостоверяющим центром, обычно 1–3 недели на первую выдачу
Облачный подписной сервис единицы–десятки USD в месяц Ключ в чужом HSM, интеграция с конвейером
Программа разработчика Apple 99 USD в год Подпись Developer ID и нотаризация
Регистрация разработчика в магазине Android около 25 USD единоразово Право публиковать
Ключ GPG для репозитория Linux 0 Зато ваш процесс ротации и хранения

Отдельная строка расходов, которую забывают: время до первой выдачи сертификата. Организационная проверка занимает недели. Если вы вспомнили о подписи за три дня до релиза, релиз сдвинется — и это не техническая проблема, а календарная.

Репутация — не то же самое, что подпись. На Windows свежий сертификат не отменяет предупреждение фильтра репутации: оно уходит по мере накопления статистики установок. Смена сертификата обнуляет накопленное. Это аргумент против «купим подешевле и будем менять поставщика каждый год».

Установка как продукт

Установка — первое, что видит покупатель, и единственный процесс вашего продукта, который выполняется на чужой машине с максимальными правами. Проектировать её надо как фичу.

Предполётные проверки. До первой записи на диск проверьте: версию ОС и архитектуру, свободное место, права, занятость портов, наличие конфликтующей версии, доступность каталога данных, совместимость схемы данных. Ошибка на этом этапе — это сообщение и код возврата, а не половина установленного продукта. Правило: сначала всё проверить, потом всё изменить.

Атомарность и откат. MSI умеет транзакционный откат из коробки, deb и rpm — нет: там откат пишете вы. Рабочая схема для сервисов: разложить новую версию рядом, переключить симлинк, проверить живость, при неудаче переключить обратно. Миграции данных при этом отдельная история: их откатить в общем случае нельзя, поэтому схему меняют совместимо, а не «в момент установки». Про безопасные релизы со стороны надёжности — релизная безопасность в SRE, про каналы и откаты версий — обновления и версии.

Тихий режим и наблюдаемость. Установщик обязан уметь: работать без интерактива, принимать конфигурацию файлом или параметрами, писать подробный журнал в предсказуемое место, возвращать осмысленные коды. Всё это нужно не «энтерпрайзу», а любому клиенту, у которого больше пяти машин.

Проверка после установки. Установка не считается успешной, пока продукт не ответил, что живой. Команда myapp doctor, которая печатает версию, пути, права, доступность зависимостей и статус лицензии, окупается на первом же обращении в поддержку — она превращает диалог «у меня не работает» в один вывод команды.

Версии пакетов: правила сравнения, которые не совпадают

Одна из самых коварных областей: у каждого формата свой порядок сравнения версий, и они не согласованы.

Система Как выглядит Правило, которое ломает интуицию
SemVer 1.0.0-rc.1 Предрелиз меньше релиза: 1.0.0-rc.1 < 1.0.0
deb 2:1.0~rc1-1 Тильда сортируется раньше пустоты: 1.0~rc1 < 1.0 < 1.0+b1; эпоха перебивает всё
rpm 1.0-0.1.rc1 Предрелиз кодируют в поле Release; современные версии понимают тильду
MSI 2.4.1.7 Четвёртое число игнорируется при сравнении
Android versionCode=241 Целое число, обязано монотонно расти; человекочитаемая версия отдельно
macOS CFBundleVersion Обязана расти между сборками, отдельно от версии для пользователя
OCI :2.4.1 Тег вообще не версия: его можно перезаписать

Отсюда практическое правило: держите одну каноническую версию продукта и функцию преобразования её в версию каждого формата — не наоборот. Преобразование должно быть в коде и покрыто тестами, потому что ошибка проявится только в поле, у клиента, при обновлении.

# Каноническая версия продукта -> версия в формате конкретного канала.
# Тесты на эту функцию дешевле одного разбирательства «почему не встало обновление».
import re

_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-(rc|beta)\.(\d+))?$")

def _parse(version: str) -> tuple[str, str, str, str | None, str | None]:
    m = _RE.match(version)
    if not m:
        raise ValueError(f"неканоническая версия: {version}")
    return m.groups()  # major, minor, patch, вид предрелиза, его номер

def to_deb(version: str, revision: int = 1) -> str:
    """1.4.0-rc.2 -> 1.4.0~rc2-1: тильда сортируется раньше пустоты."""
    major, minor, patch, pre, num = _parse(version)
    base = f"{major}.{minor}.{patch}"
    return f"{base}~{pre}{num}-{revision}" if pre else f"{base}-{revision}"

def to_rpm(version: str) -> tuple[str, str]:
    """(Version, Release): предрелиз прячется в Release, иначе он окажется старше релиза."""
    major, minor, patch, pre, num = _parse(version)
    base = f"{major}.{minor}.{patch}"
    return (base, f"0.{num}.{pre}{num}") if pre else (base, "1")

def to_android_code(version: str) -> int:
    """Монотонное целое: 1.4.0 -> 1040000, предрелизы строго ниже релиза."""
    major, minor, patch, pre, num = _parse(version)
    base = (int(major) * 10_000 + int(minor) * 100 + int(patch)) * 100
    return base - 50 + int(num) if pre else base

assert to_deb("1.4.0-rc.2") == "1.4.0~rc2-1"
assert to_rpm("1.4.0-rc.2") == ("1.4.0", "0.2.rc2")
assert to_android_code("1.4.0-rc.2") < to_android_code("1.4.0")

Сложность вычисления тривиальна — O(1) по времени и памяти на вызов; ценность не в алгоритме, а в том, что правило записано один раз и проверяется машиной, а не памятью релиз-инженера.

Воспроизводимость: почему две сборки должны совпадать байт в байт

Если из одного коммита дважды получаются разные артефакты, вы не можете доказать, что опубликованный файл собран из опубликованного кода. Для открытых проектов это прямо влияет на доверие, для закрытых — на расследование инцидентов. Минимальный набор мер: фиксировать время сборки через SOURCE_DATE_EPOCH, убирать пути сборочной машины (-trimpath в Go, --remap-path-prefix в Rust), сортировать содержимое архивов, обнулять временные метки, фиксировать версии инструментов по дайджесту. Глубоко тема разобрана в цепочке поставок; здесь важно понимать её как свойство поставки: воспроизводимость — это способность ответить на вопрос клиента «из чего собран вот этот файл» не словами, а командой.

Цена владения матрицей целей

Каждая цель сборки — это отдельный артефакт, отдельный тест установки на чистой машине, отдельная подпись и отдельный канал публикации. Количество целей растёт мультипликативно:

$$ T = \sum_{p \in P} a_p \cdot f_p $$

Здесь T — общее число целей, P — множество платформ, a_p — число архитектур на платформе, f_p — число форматов на этой же платформе. Годовая стоимость сопровождения:

$$ C = T \cdot \left( h_{0} + 12 \cdot h_{m} \right) \cdot r + F $$

Здесь h_0 — часы на создание одной цели, h_m — часы в месяц на её сопровождение, r — стоимость инженерного часа, F — фиксированные сборы за год (сертификаты, членства в программах разработчика, хранение и трафик реестра).

from dataclasses import dataclass

@dataclass(frozen=True)
class Target:
    """Одна цель сборки: платформа, архитектура, формат."""
    platform: str
    arch: str
    fmt: str
    setup_hours: float      # разовые часы на то, чтобы цель появилась
    monthly_hours: float    # часы в месяц: правки, тест установки на чистой машине, жалобы

TARGETS = [
    Target("linux", "amd64", "deb",       16, 1.5),
    Target("linux", "arm64", "deb",        6, 1.0),
    Target("linux", "amd64", "rpm",       14, 1.5),
    Target("linux", "amd64", "oci",        8, 1.0),
    Target("linux", "arm64", "oci",        2, 0.5),
    Target("windows", "amd64", "msi",     40, 2.5),
    Target("macos", "universal", "dmg",   24, 2.0),
]

FIXED_YEARLY_RUB = 45_000 + 9_000 + 30_000   # сертификат Windows, программа Apple, реестр и трафик
HOUR_RATE_RUB = 4_000

def yearly_cost(targets: list[Target], rate: int = HOUR_RATE_RUB) -> float:
    hours = sum(t.setup_hours + 12 * t.monthly_hours for t in targets)
    return hours * rate + FIXED_YEARLY_RUB

def cost_of_adding(target: Target, rate: int = HOUR_RATE_RUB) -> float:
    """Сколько стоит сказать «да» на просьбу одного клиента про новый формат."""
    return (target.setup_hours + 12 * target.monthly_hours) * rate

print(f"Полная матрица: {yearly_cost(TARGETS):,.0f} рублей в год")
print(f"Только Linux и контейнер: {yearly_cost(TARGETS[:5]):,.0f} рублей в год")
print(f"Добавить один rpm под arm64: "
      f"{cost_of_adding(Target('linux', 'arm64', 'rpm', 6, 1.0)):,.0f} рублей в год")

Смысл счёта не в точности цифр, а в переводе разговора из плоскости «а давайте ещё соберём под…» в плоскость «это стоит столько-то в год, кто это оплачивает». Ответ «клиент, и вот пункт в договоре» — нормальный. Ответ «никто, просто хочется полноты» — источник той самой осадочной породы из первого абзаца.

Три правила, которые экономят больше всего:

  1. Один формат — один владелец. У каждой цели есть человек, который её тестирует на чистой машине перед релизом. Нет владельца — цель удаляется.
  2. Новая цель появляется только под подписанное обязательство. Просьба в чате обязательством не является.
  3. Матрица целей — часть релизного чек-листа, а не свойство машины сборки. Иначе она незаметно расползается вслед за тем, что кто-то когда-то добавил в конвейер.

Как это устроено на портале

Этот портал — статический сайт: «пакет» здесь тарбол сгенерированных файлов, а «установка» — синхронизация каталога на сервер. Формат простой, но обязательства ровно те же: версия зафиксирована коммитом, публикация идёт только из конвейера, откат — это выкладка предыдущей сборки.

Более интересный случай — наш инстанс it-tools под GPL-3.0, разобранный в главе про лицензии. С точки зрения упаковки его урок формулируется коротко: обязательство по исходникам закрывается в момент сборки артефакта, а не потом. Если сборка отдаёт пользователю объектный код, то рядом с этим артефактом — в подвале страницы, в каталоге пакета, в метаданных образа — обязан лежать указатель на соответствующий исходный код именно этой версии. Технически это одна строка в конвейере: класть в артефакт файл с лицензиями зависимостей и ссылкой на коммит. Организационно это то, что забывают, и потом полгода живут с наполовину выполненным обязательством.

Практический вывод для любой упаковки: сборка лицензионных уведомлений — шаг конвейера, а не задача перед релизом. Инструменты для этого есть под каждую экосистему, и запускать их надо на каждой сборке, а не раз в квартал.

Где заканчивается инженерия и начинается вопрос к юристу

Упаковка регулярно упирается в правовые вопросы. Их не надо решать самому — надо задавать точно:

  • Экран с условиями в инсталляторе. Вопрос юристу: считается ли в нашей юрисдикции нажатие «Принимаю» в инсталляторе заключением договора и что должно быть в тексте, чтобы он работал. Живой пример формулировок — оферта этого портала.
  • Чужие компоненты в пакете. Вопрос: какие уведомления и тексты лицензий мы обязаны положить в дистрибутив и достаточно ли ссылки. Инженерная часть ответа — в главе про лицензии, правовая — не наша.
  • Криптография внутри инсталлятора. Вопрос: подпадает ли наш дистрибутив под ограничения на распространение шифрования в странах, куда мы поставляем. Тема разбирается в следующей главе, но формулировка вопроса — уже сейчас.
  • Данные, остающиеся после удаления. Вопрос: обязаны ли мы удалять пользовательские данные при деинсталляции и как это отразить в документах.
  • Телеметрия установщика. Вопрос: что именно мы имеем право отправлять при первом запуске и с какого момента нужно согласие.

Общее правило: приносите юристу не «посмотрите наш инсталлятор», а конкретный вопрос, список компонентов с лицензиями, перечень стран поставки и описание того, какие данные собираются. Так консультация занимает час, а не месяц.

Типичные ошибки

  • Артефакт без метаданных. «Скачайте zip и распакуйте» — это не поставка: у клиента нет версии, зависимостей, удаления и способа проверить подлинность.
  • Подпись в последний момент. Организационная проверка удостоверяющего центра занимает недели, нотаризация — часы. Оба срока обнаруживаются в день релиза.
  • Ключ подписи в переменной окружения CI. Компрометация конвейера превращается в компрометацию всех клиентов, а отзыв сертификата ломает все выпущенные версии сразу.
  • Подпись без метки времени. Всё выпущенное перестаёт проверяться в день истечения сертификата.
  • Конфигурация не помечена конфигурацией. Одно обновление затирает настройки продакшна у всех клиентов сразу.
  • Скрипт установки ходит в сеть. Продукт становится непоставляемым в закрытый контур — то есть недоступным для самых платящих клиентов.
  • Установка не проверяет предусловия. Половина продукта на диске и невозможность ни запустить, ни удалить.
  • Ссылка тегом вместо дайджеста. Клиент получает не ту версию, которую вы тестировали, а ту, что лежала под тегом в момент вытягивания.
  • Матрица целей растёт без владельцев. Через год половина форматов не тестируется никем и ломается молча.
  • Версия продукта равна версии пакета «по умолчанию». Правила сравнения версий у форматов разные; несовпадение вылезает у клиента при обновлении.
  • Удаление не продумано. Остатки служб, задач в планировщике и записей в автозапуске портят репутацию сильнее, чем любая пропущенная фича.

Мини-итог

Упаковка — это точка, где ваш продукт превращается в объект, которому чужая машина либо доверяет, либо нет. Пакет отличается от архива семью ответами: имя и версия, зависимости, размещение, скрипты, конфигурация, лицензионные уведомления, подпись. Формат выбирается не по моде, а по цене владения: чем больше среды артефакт несёт с собой, тем дешевле установка и тем дороже реакция на чужие уязвимости.

Подпись доказывает происхождение, а не качество; она требует метки времени, аппаратного хранения ключа и календарного запаса на выдачу сертификата. Установка — полноценная функциональность продукта: предполётные проверки до первой записи на диск, тихий режим, журнал, коды возврата, проверка живости, работающее удаление и продуманный откат. Версии в каждом формате сравниваются по своим правилам, поэтому каноническая версия одна, а преобразования покрыты тестами.

И главное: матрица целей — это статья бюджета. Считайте её в часах и деньгах в год, назначайте владельца каждой цели и добавляйте новую только под обязательство, у которого есть плательщик.

Источники

Что дальше

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

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов