Упаковка и установка: пакеты, контейнеры, инсталляторы, подпись
Между «код работает у нас» и «код работает у клиента» лежит слой, который почти никто не проектирует специально. Он появляется сам: кто-то однажды написал скрипт, который кладёт бинарь в архив, потом к архиву прирос инсталлятор, потом к инсталлятору — сертификат, купленный в спешке за день до релиза. Через год этот слой стоит команде больше часов в месяц, чем половина продуктовых фич, и никто не может ответить на простой вопрос: сколько у нас на самом деле артефактов и кто отвечает за каждый.
Эта глава — про то, чтобы упаковка была решением, а не осадочной породой. Мы уже разобрали, в каких моделях артефакт вообще уезжает к клиенту (модели поставки), какие обязательства накладывает лицензия на то, что лежит рядом с бинарём (лицензии), и через какие каналы это доезжает (каналы, магазины). Здесь — форма. Что именно вы кладёте в файл, как чужая машина понимает, что с этим делать, и почему она вам верит.
Техническую базу — конвейер, реестры образов, герметичность сборки — мы не пересказываем: она в треках devops и platform-engineering, а криптография подписи и провенанс подробно разобраны в цепочке поставок. Наш угол — поставка: что покупатель получает в руки, во что это обходится вам и какие обязательства создаёт.
Пакет — это не архив, а обещание
Архив отвечает на один вопрос: какие файлы внутри. Пакет отвечает на семь:
- Как это называется и какой это версии — так, чтобы машина могла сравнить две версии между собой.
- От чего это зависит и с чем конфликтует.
- Куда лечь на диск и с какими правами.
- Что сделать до установки, после установки, до удаления и после удаления.
- Что считать конфигурацией, которую нельзя затирать при обновлении.
- Под какой лицензией это распространяется и что положено рядом.
- Кто это собрал и как в этом убедиться.
Первые шесть пунктов — метаданные, седьмой — подпись. Артефакт без метаданных и подписи не является поставкой: это файл, который кто-то откуда-то скачал. Разница видна ровно в тот момент, когда у клиента что-то ломается и надо ответить на вопрос «какая версия чего у вас стоит и откуда она взялась».
бинарь, бандл, слои"] --> B["Упаковка:
метаданные, зависимости, скрипты"] B --> C["Подпись:
ключ в HSM, метка времени"] C --> D["Публикация в канал:
репозиторий, реестр, магазин, сайт"] D --> E["Разрешение версии
на стороне клиента"] E --> F["Проверка подлинности
и целостности"] F -->|"сходится"| G["Установка:
файлы, права, служба"] F -->|"не сходится"| X["Отказ до записи на диск"] G --> H["Активация
и проверка живости"] H -->|"сбой"| R["Откат к прошлой версии"] H -->|"порядок"| Z["Работает"]
Обратите внимание на два узла, которые в самодельных схемах поставки обычно отсутствуют: «отказ до записи на диск» и «откат». Пока их нет, у вас не поставка, а надежда.
Метаданные: минимальный честный набор
Формат разный, содержание одинаковое. Вот 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работает на отдельном файле, но и там метаданные репозитория подписываются отдельно.
Скрипты сопровождения — главный источник инцидентов при установке. Они выполняются от 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 — самый частый способ потерять контроль над чужими сборками; подробности в цепочке поставок и управлении секретами.
- Имя пакета — это ещё и бренд. Занять его стоит раньше, чем понадобится.
Подпись: что именно она доказывает
Подпись не говорит «этот код хороший». Она говорит: «этот байт-в-байт артефакт выпущен владельцем вот этого ключа и не менялся с тех пор». Всё, что вы строите поверх, — про то, кому именно вы разрешаете быть этим владельцем.
ключ в аппаратном модуле participant TS as Служба меток времени participant CH as Канал: реестр,
репозиторий, магазин participant CL as Машина клиента CI->>CI: собрать артефакт, посчитать хеш CI->>KMS: запросить подпись хеша Note over KMS: закрытый ключ не покидает модуль,
наружу уходит только подпись KMS-->>CI: подпись CI->>TS: заверить момент подписи TS-->>CI: метка времени CI->>CH: опубликовать артефакт, подпись, SBOM CL->>CH: запросить версию CH-->>CL: артефакт и подпись CL->>CL: проверить цепочку до доверенного корня CL->>CL: проверить метку времени и статус отзыва alt всё сходится CL->>CL: установить else не сходится CL-->>CL: отказать до записи на диск end
Метка времени — не украшение. Без неё подпись перестаёт считаться валидной в день истечения сертификата, и все выпущенные ранее артефакты начинают ругаться. С меткой они остаются валидными: проверяющий видит, что подпись поставлена в период действия сертификата.
Ключ в файле — это утечка, которая ещё не случилась. Отраслевые правила для сертификатов подписи кода с 2023 года требуют хранения закрытого ключа в аппаратном модуле, и это заодно решило спор «а можно нам ключ в переменной окружения CI»: нельзя. Практические варианты: аппаратный токен у релиз-инженера (дёшево, но плохо автоматизируется и создаёт человека — единую точку отказа), облачный подписной сервис (автоматизируется, платится помесячно), собственный HSM (дорого, оправдано на масштабе). Для образов и открытых артефактов есть третий путь — подпись без долгоживущего ключа: удостоверение выдаётся на минуты под удостоверение конвейера, а факт подписи фиксируется в публичном журнале прозрачности. Тогда проверяющий сверяет не «есть ли подпись», а «подписал ли именно тот конвейер того репозитория».
Порядок величин расходов на середину 2026 года (проверяйте актуальные цены у поставщика, они меняются):
| Статья | Порядок | Что за этим стоит |
|---|---|---|
| Сертификат подписи кода для Windows | 200–600 USD в год плюс аппаратный носитель | Проверка организации удостоверяющим центром, обычно 1–3 недели на первую выдачу |
| Облачный подписной сервис | единицы–десятки USD в месяц | Ключ в чужом HSM, интеграция с конвейером |
| Программа разработчика Apple | 99 USD в год | Подпись Developer ID и нотаризация |
| Регистрация разработчика в магазине Android | около 25 USD единоразово | Право публиковать |
| Ключ GPG для репозитория Linux | 0 | Зато ваш процесс ротации и хранения |
Отдельная строка расходов, которую забывают: время до первой выдачи сертификата. Организационная проверка занимает недели. Если вы вспомнили о подписи за три дня до релиза, релиз сдвинется — и это не техническая проблема, а календарная.
Репутация — не то же самое, что подпись. На Windows свежий сертификат не отменяет предупреждение фильтра репутации: оно уходит по мере накопления статистики установок. Смена сертификата обнуляет накопленное. Это аргумент против «купим подешевле и будем менять поставщика каждый год».
Установка как продукт
Установка — первое, что видит покупатель, и единственный процесс вашего продукта, который выполняется на чужой машине с максимальными правами. Проектировать её надо как фичу.
ничего не тронуто unpacked --> configured: скрипт настройки отработал configured --> running: служба поднялась
и прошла проверку живости running --> upgrading: пришла новая версия upgrading --> configured: успех upgrading --> broken: скрипт упал
или проверка не прошла broken --> configured: ручное вмешательство broken --> unpacked: автоматический откат
на прошлую версию running --> removed: удаление removed --> configured: переустановка поверх конфигурации removed --> none: полная очистка данных
Предполётные проверки. До первой записи на диск проверьте: версию ОС и архитектуру, свободное место, права, занятость портов, наличие конфликтующей версии, доступность каталога данных, совместимость схемы данных. Ошибка на этом этапе — это сообщение и код возврата, а не половина установленного продукта. Правило: сначала всё проверить, потом всё изменить.
Атомарность и откат. 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} рублей в год")
Смысл счёта не в точности цифр, а в переводе разговора из плоскости «а давайте ещё соберём под…» в плоскость «это стоит столько-то в год, кто это оплачивает». Ответ «клиент, и вот пункт в договоре» — нормальный. Ответ «никто, просто хочется полноты» — источник той самой осадочной породы из первого абзаца.
Три правила, которые экономят больше всего:
- Один формат — один владелец. У каждой цели есть человек, который её тестирует на чистой машине перед релизом. Нет владельца — цель удаляется.
- Новая цель появляется только под подписанное обязательство. Просьба в чате обязательством не является.
- Матрица целей — часть релизного чек-листа, а не свойство машины сборки. Иначе она незаметно расползается вслед за тем, что кто-то когда-то добавил в конвейер.
Как это устроено на портале
Этот портал — статический сайт: «пакет» здесь тарбол сгенерированных файлов, а «установка» — синхронизация каталога на сервер. Формат простой, но обязательства ровно те же: версия зафиксирована коммитом, публикация идёт только из конвейера, откат — это выкладка предыдущей сборки.
Более интересный случай — наш инстанс it-tools под GPL-3.0, разобранный в главе про лицензии. С точки зрения упаковки его урок формулируется коротко: обязательство по исходникам закрывается в момент сборки артефакта, а не потом. Если сборка отдаёт пользователю объектный код, то рядом с этим артефактом — в подвале страницы, в каталоге пакета, в метаданных образа — обязан лежать указатель на соответствующий исходный код именно этой версии. Технически это одна строка в конвейере: класть в артефакт файл с лицензиями зависимостей и ссылкой на коммит. Организационно это то, что забывают, и потом полгода живут с наполовину выполненным обязательством.
Практический вывод для любой упаковки: сборка лицензионных уведомлений — шаг конвейера, а не задача перед релизом. Инструменты для этого есть под каждую экосистему, и запускать их надо на каждой сборке, а не раз в квартал.
Где заканчивается инженерия и начинается вопрос к юристу
Упаковка регулярно упирается в правовые вопросы. Их не надо решать самому — надо задавать точно:
- Экран с условиями в инсталляторе. Вопрос юристу: считается ли в нашей юрисдикции нажатие «Принимаю» в инсталляторе заключением договора и что должно быть в тексте, чтобы он работал. Живой пример формулировок — оферта этого портала.
- Чужие компоненты в пакете. Вопрос: какие уведомления и тексты лицензий мы обязаны положить в дистрибутив и достаточно ли ссылки. Инженерная часть ответа — в главе про лицензии, правовая — не наша.
- Криптография внутри инсталлятора. Вопрос: подпадает ли наш дистрибутив под ограничения на распространение шифрования в странах, куда мы поставляем. Тема разбирается в следующей главе, но формулировка вопроса — уже сейчас.
- Данные, остающиеся после удаления. Вопрос: обязаны ли мы удалять пользовательские данные при деинсталляции и как это отразить в документах.
- Телеметрия установщика. Вопрос: что именно мы имеем право отправлять при первом запуске и с какого момента нужно согласие.
Общее правило: приносите юристу не «посмотрите наш инсталлятор», а конкретный вопрос, список компонентов с лицензиями, перечень стран поставки и описание того, какие данные собираются. Так консультация занимает час, а не месяц.
Типичные ошибки
- Артефакт без метаданных. «Скачайте zip и распакуйте» — это не поставка: у клиента нет версии, зависимостей, удаления и способа проверить подлинность.
- Подпись в последний момент. Организационная проверка удостоверяющего центра занимает недели, нотаризация — часы. Оба срока обнаруживаются в день релиза.
- Ключ подписи в переменной окружения CI. Компрометация конвейера превращается в компрометацию всех клиентов, а отзыв сертификата ломает все выпущенные версии сразу.
- Подпись без метки времени. Всё выпущенное перестаёт проверяться в день истечения сертификата.
- Конфигурация не помечена конфигурацией. Одно обновление затирает настройки продакшна у всех клиентов сразу.
- Скрипт установки ходит в сеть. Продукт становится непоставляемым в закрытый контур — то есть недоступным для самых платящих клиентов.
- Установка не проверяет предусловия. Половина продукта на диске и невозможность ни запустить, ни удалить.
- Ссылка тегом вместо дайджеста. Клиент получает не ту версию, которую вы тестировали, а ту, что лежала под тегом в момент вытягивания.
- Матрица целей растёт без владельцев. Через год половина форматов не тестируется никем и ломается молча.
- Версия продукта равна версии пакета «по умолчанию». Правила сравнения версий у форматов разные; несовпадение вылезает у клиента при обновлении.
- Удаление не продумано. Остатки служб, задач в планировщике и записей в автозапуске портят репутацию сильнее, чем любая пропущенная фича.
Мини-итог
Упаковка — это точка, где ваш продукт превращается в объект, которому чужая машина либо доверяет, либо нет. Пакет отличается от архива семью ответами: имя и версия, зависимости, размещение, скрипты, конфигурация, лицензионные уведомления, подпись. Формат выбирается не по моде, а по цене владения: чем больше среды артефакт несёт с собой, тем дешевле установка и тем дороже реакция на чужие уязвимости.
Подпись доказывает происхождение, а не качество; она требует метки времени, аппаратного хранения ключа и календарного запаса на выдачу сертификата. Установка — полноценная функциональность продукта: предполётные проверки до первой записи на диск, тихий режим, журнал, коды возврата, проверка живости, работающее удаление и продуманный откат. Версии в каждом формате сравниваются по своим правилам, поэтому каноническая версия одна, а преобразования покрыты тестами.
И главное: матрица целей — это статья бюджета. Считайте её в часах и деньгах в год, назначайте владельца каждой цели и добавляйте новую только под обязательство, у которого есть плательщик.
Источники
- Debian Policy Manual, разделы про метаданные и скрипты сопровождения — https://www.debian.org/doc/debian-policy/
- Руководство по сборке пакетов RPM — https://rpm-packaging-guide.github.io/
- Спецификации формата образов OCI — https://github.com/opencontainers/image-spec
- Документация Windows Installer, правила компонентов и апгрейдов — https://learn.microsoft.com/windows/win32/msi/windows-installer-portal
- WiX Toolset — https://wixtoolset.org/docs/
- Notarizing macOS software before distribution — https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution
- Базовые требования к сертификатам подписи кода (CA/Browser Forum) — https://cabforum.org/working-groups/code-signing/documents/
- Sigstore и подпись без долгоживущих ключей — https://docs.sigstore.dev/
- Reproducible Builds — https://reproducible-builds.org/
- Документация Flatpak — https://docs.flatpak.org/, Snapcraft — https://snapcraft.io/docs, AppImage — https://docs.appimage.org/
- Доверенная публикация в PyPI — https://docs.pypi.org/trusted-publishers/
Что дальше
Мы разобрали, в какой форме артефакт доезжает до чужой машины и как эта машина решает, доверять ли ему. Осталась последняя рамка, которая может запретить поставку целиком независимо от того, насколько хорошо всё упаковано: право страны получателя, экспортные ограничения на криптографию, требования к обработке персональных данных и локализации. Об этом заключительная глава трека: Ограничения: юрисдикции, экспорт, персональные данные.