devops-курс Все курсы

✻ Урок 3.5 · Тема 3: Git и CI

Релизы: semver, теги и GitHub Releases

⏱ 3 ч

Зачем это нужно

В чате пишут: «на проде версия с четверга». «Прод» (продакшен, production) это боевой сервер, на котором приложение работает для настоящих пользователей. С какого коммита (снимка всех файлов проекта с описанием, что изменилось, урок 3.1)? Что в ней было? Как вернуться к предыдущей? Без релизов на эти вопросы отвечает только память автора. Релиз (release) превращает состояние кода в именованную неизменяемую версию. В него входят три вещи. Тег (tag) в git: неподвижная метка на коммите, как закладка в книге, которую не двигают. Архив с контрольной суммой: один файл .tar.gz (папка проекта, сжатая в один файл, как zip), а рядом короткая строка-отпечаток этого файла, по которой проверяют, что он не испорчен и не подменён (подробно разберём ниже). Список изменений: что нового по сравнению с прошлой версией. Так работают и открытые проекты, и внутренние сервисы.

На работе ты будешь каждый день слышать «выкатываем 1.4.2» (выкатить значит установить новую версию на боевой сервер), «откатились на 1.4.1» (вернули прежнюю), «в 2.0 ломается совместимость» (то, что работало со старой версией, может перестать работать). За этими словами стоит договорённость о том, что значат числа в версии, к какому коду привязано имя и как получить ровно тот файл, который проверили. Всё это спрашивают и на собеседованиях: что значит semver (semantic versioning, договорённость о смысле трёх чисел в номере версии вроде 1.4.2, разберём в теории), как откатить релиз, что делать, если тег поставили не туда.

Шаг проекта: в «Заметках» появляется .github/workflows/release.yml, который по тегу v* собирает notes-<версия>.tar.gz с контрольной суммой и публикует GitHub Release (страницу на GitHub с описанием версии и прикреплёнными скачиваемыми файлами). Первый такой релиз: v0.2.0. Код app.py (версия v3) не меняется.

Что нужно знать

  • Урок 3.1: коммиты, история и ветки: что такое коммит и его хеш (уникальный отпечаток коммита из 40 букв и цифр), ветка (подвижное имя для цепочки коммитов), git log, .gitignore. Ниже мы это коротко напомним, потому что тег привязан именно к коммиту.
  • Урок 3.2: remotes, rebase и Pull Request: git push, защищённая main, слияние через Pull Request со squash, первый тег v0.1.0.
  • Урок 3.3: CI в GitHub Actions: что такое workflow (файл с описанием автоматических действий), job (задача внутри него, выполняется на своей чистой машине), шаг (одна команда или готовое действие внутри job) и триггер (событие, по которому workflow запускается) on:, файл ci.yml.
  • Урок 3.4: качество и безопасность в CI: блок permissions: (права токена) и почему нельзя подставлять данные из GitHub прямо в run:. Здесь мы разберём это на живом примере с тегом.
  • Урок 1.2: текст и пайпы: grep (фильтр строк), head (показать первые строки) и | (передать вывод одной команды на вход другой), они встречаются в командах ниже.
  • Урок 1.6: bash и Make: код выхода команды (0 значит успех) и запись A && B || C.

Картина целиком

Представь издательство. Редакция всё время работает над рукописью: правки, новые главы (это main и коммиты). Раз в несколько месяцев редактор решает: «вот это состояние отправляем в печать». Дальше происходит четыре вещи:

  • на состояние рукописи ставится номер издания, например «2-е издание» (это тег и версия; тег мы уже определили выше, а версия это сам номер вроде 0.2.0);
  • типография печатает тираж, одинаковый для всех читателей (это артефакт: любой файл, который получился в результате работы, у нас архив notes-0.2.0.tar.gz с кодом проекта);
  • на тираже ставят контрольный знак, по которому можно проверить, что экземпляр не подделан и не бракованный (это контрольная сумма: короткое число, которое считается из всего содержимого файла по алгоритму SHA-256; изменится хоть один байт, и число станет совсем другим, отсюда способность ловить подделку);
  • в каталог издательства вносят карточку книги с описанием, что нового (это GitHub Release).

Рукопись продолжает меняться, а второе издание остаётся таким, каким вышло. Если в нём нашли опечатку, выпускают 2.1, а не переписывают тираж, который уже купили.

Вот путь от коммита до скачиваемого файла в нашем проекте:

flowchart TD
    L["ты, локально<br>коммиты в main, c1e8673<br>тег v0.2.0"] -->|"git push"| R["GitHub: репозиторий notes"]
    L -->|"git tag -a, git push origin v0.2.0"| T["тег v0.2.0 указывает на коммит c1e8673"]
    T --> E["событие: пришёл тег v*"]
    E --> W["workflow release.yml<br>1. git archive, .tar.gz<br>2. sha256sum, .sha256<br>3. gh release create"]
    W --> REL["GitHub Release v0.2.0<br>файлы: .tar.gz и .sha256"]
    REL -->|"gh release download"| U["потребитель: файл и сумма<br>sha256sum -c: OK"]

За урок ты разберёшь каждый блок этой схемы: что такое коммит и тег, что значат числа в версии, как получить список изменений между релизами, почему архив собирают через git archive (команда git, которая упаковывает в архив файлы ровно из указанного коммита, а не то, что лежит у тебя на диске), что проверяет контрольная сумма, как workflow (автоматический сценарий GitHub Actions) узнаёт про тег и как исправить ошибку в уже выпущенной версии.

Теория

Зачем нужны релизы: коммитов много, а «версий» должно быть мало

Каждый Pull Request добавляет в main коммит. За неделю их бывает десять, за год тысячи. Но пользователю, тестировщику и дежурному инженеру не нужны тысячи состояний. Им нужно несколько именованных точек: «вот эта версия проверена, вот эту мы выкатили на прод, к вот этой откатились».

Без таких точек начинаются проблемы:

  • «На проде что-то из main»: непонятно, что именно, а значит непонятно, чинить ли баг у себя или он уже исправлен.
  • «Откатимся на прошлую версию»: какую? Прошлый коммит? Прошлый вечер?
  • Клиенту сказали «исправлено», а какая версия его сервера содержит исправление, никто не знает.

Релиз решает это тремя вещами: имя версии (v0.2.0), привязка имени к точному коммиту и готовый файл, который можно скачать, проверить и запустить. Аналогия с изданием книги хорошо работает, но с одной оговоркой: у книги тираж печатают один раз физически, а у нас архив можно собрать заново в любой момент из того же коммита, и он получится тем же. Именно поэтому важно собирать его из коммита, а не из «того, что лежит у меня на диске» (ниже разберём, чем это плохо).

flowchart LR
    c1((коммит)) --- c2((коммит)) --- c3((коммит)) --- c4((коммит)) --- c5((коммит)) --- c6((коммит)) --- c7((коммит))
    c2 -.- v1["v0.1.0"]
    c4 -.- v2["v0.2.0"]
    c7 -.- v3["v0.3.0"]

Коммитов много, и они появляются каждый день. Релизы это редкие именованные точки: v0.1.0, v0.2.0, v0.3.0.

Прикинь сам: в main за неделю 40 коммитов. Сколько из них стоит называть релизами?

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

Главное: релиз даёт версии имя, привязку к точному коммиту и готовый файл, который можно скачать и проверить.

Релиз привязан к коммиту. Что именно помечает тег? Разберём коммит и хеш.

Коммит и хеш: что именно помечает тег

Вспомним из урока 3.1: коммит (commit) это снимок всех файлов проекта в некоторый момент плюс подпись автора, время, сообщение и ссылка на предыдущий коммит. У каждого коммита есть хеш (hash): 40 символов из цифр и букв a-f, например c1e8673491aa03fea6f9f489dd926435ba178f13. Хеш вычисляется из содержимого коммита, поэтому изменить коммит, не поменяв хеш, невозможно. Полный хеш неудобно писать, поэтому обычно показывают первые 7 символов: c1e8673.

Так выглядит настоящий коммит изнутри (команда git cat-file -p печатает объект без оформления):

$ git cat-file -p HEAD
tree f9ce60e9593d3fbc0e9f592f8e02e143701d6e58
parent 6139850098dc2273ef539585e6d3352c43b9f831
author Student <ubuntu@example.com> 1790770251 +0000
committer Student <ubuntu@example.com> 1790770251 +0000

Add release workflow (#5)

Разбор строк. tree это ссылка на снимок всех файлов (тоже по хешу). parent это хеш предыдущего коммита: по этим ссылкам и складывается история, цепочка от нового к старому. author и committer это кто и когда, время записано числом секунд с 1 января 1970 года. Дальше пустая строка и сообщение. HEAD это имя «текущего коммита», того, на котором ты стоишь.

Ветка (branch), например main, это просто имя, которое двигается: когда ты делаешь новый коммит, имя main переезжает на него. Внутри git ветка это маленький файл с хешем. Тег (tag) тоже имя для коммита, но оно не двигается. В этом вся разница.

flowchart LR
    c1((c1)) --> c2((c2)) --> c3((c3)) --> c4((c4))
    M["main"] -.->|"сегодня"| c4
    M2["main, позавчера"] -.-> c3
    T["v0.1.0"] -.->|"тег стоит на c3 всегда"| c3

Что путают: тег не «содержит» файлы и не является копией проекта. Это ярлык на коммите. Файлы остаются в коммите, а ярлык говорит: «версия v0.1.0 это вот этот коммит».

Прикинь сам: ты изменил один символ в файле и сделал коммит. Совпадёт ли хеш с прежним?

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

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

Проверь понимание: ты сделал два новых коммита. Куда указывают теперь main и тег v0.1.0, который стоял на предыдущем коммите?

Ответ

main переехала на второй новый коммит, а v0.1.0 остался на старом месте. Поэтому по тегу всегда можно получить ровно тот код, который был в момент выпуска.

Хеш ясен. Как дать ему человеческое имя? Тегом.

Тег: лёгкий и аннотированный

В git есть два вида тегов.

Лёгкий (lightweight) тег: git tag v0.2.0. Это только имя, которое указывает на коммит. Больше в нём ничего нет.

Аннотированный (annotated) тег: git tag -a v0.2.0 -m "текст". Флаг -a (annotate, снабдить пояснением) создаёт отдельный объект git: он хранит имя того, кто поставил тег (tagger), время и сообщение, а уже сам указывает на коммит. Флаг -m (message) задаёт сообщение сразу в командной строке, иначе git откроет редактор.

Разница видна, если спросить git, какого типа объект стоит за именем (git cat-file -t, t от type):

$ git tag light-demo
$ git tag -a ann-demo -m "annotated demo"
$ git cat-file -t light-demo
commit
$ git cat-file -t ann-demo
tag
$ git cat-file -p ann-demo
object c1e8673491aa03fea6f9f489dd926435ba178f13
type commit
tag ann-demo
tagger Student <ubuntu@example.com> 1790770279 +0000

annotated demo

Лёгкий тег оказался «просто коммитом»: имя сразу ведёт на коммит. Аннотированный оказался отдельным объектом типа tag: внутри есть object (на какой коммит он указывает), tagger (кто поставил), время и сообщение. Для релизов берут аннотированные: видно, кто и когда выпустил и зачем. Лёгкие годятся как личная закладка «сюда вернусь».

Из-за этого у аннотированного тега два хеша: свой собственный (хеш объекта tag) и хеш коммита, на который он указывает. git ls-remote показывает оба, второй помечен ^{}:

$ git ls-remote --tags origin
ec8cb9704c3e89dc21abb3853fe0b47e139544e0	refs/tags/v0.1.0
f08981627566268f522decc95e5396b2b0431280	refs/tags/v0.1.0^{}
53fea5540399ed8c60ad4ab9cd32ad02a3c78208	refs/tags/v0.2.0
c1e8673491aa03fea6f9f489dd926435ba178f13	refs/tags/v0.2.0^{}

Строка без ^{} это хеш самого объекта тега, строка с ^{} это коммит, на который тег «указывает после раскрытия». Если тебе нужен именно коммит, пиши git rev-parse 'v0.2.0^{commit}': кавычки нужны, чтобы оболочка не приняла фигурные скобки за свои.

Теги не уезжают сами. Обычный git push отправляет только ветки. Проверим: создали тег, сделали git push, спросили сервер про теги.

$ git push origin
Everything up-to-date
$ git ls-remote --tags origin | grep -c demo
0
$ git push origin ann-demo light-demo
To /home/ubuntu/origin.git
 * [new tag]         ann-demo -> ann-demo
 * [new tag]         light-demo -> light-demo

Тег на сервере появился только после явной команды git push origin <имя-тега>. Есть и сокращение: git push --follow-tags вместе с ветками отправляет аннотированные теги, которые указывают на отправляемые коммиты. Лёгкие эта команда пропускает (мы проверили: ann-x уехал, light-x остался у нас). Поэтому для релизов берут аннотированные теги и отправляют явно.

Прикинь сам: в git ls-remote --tags у v0.1.0 две строки, у light-demo одна. Чем они отличаются?

У аннотированного тега есть собственный объект с автором, датой и сообщением, поэтому две строки (объект и коммит с ^{}). Лёгкий тег это просто имя коммита.

Главное: лёгкий тег это имя коммита, аннотированный хранит автора, дату и сообщение, и для релизов берут его.

Проверь понимание: ты поставил лёгкий тег v0.5.0 и выполнил git push --follow-tags. Появится ли тег на GitHub?

Ответ

Нет. --follow-tags отправляет только аннотированные теги. Лёгкий нужно отправить явно: git push origin v0.5.0. Ещё одна причина ставить релизы аннотированными.

Тег создан. Что будет, если его переставить после публикации?

Опубликованный тег не двигают

Главное правило релизов: если тег уже виден другим людям, его не переписывают. Представь, что ты выпустил v0.2.0, кто-то скачал его и развернул, а ты перекинул тег на другой коммит. Теперь у двух людей «версия 0.2.0» означает разный код. Ошибку в баг-репорте «на версии 0.2.0 падает» уже нельзя воспроизвести. Это как перепечатать страницы книги в уже проданных экземплярах.

Git сам защищает от случайной перезаписи, но эту защиту можно обойти. Вот что происходит на самом деле:

$ git tag -a mv-1 -m a
$ git tag -a mv-1 -m b HEAD~1
fatal: tag 'mv-1' already exists
$ git tag -f -a mv-1 -m b HEAD~1            # -f (force) переставляет тег насильно
Updated tag 'mv-1' (was 65bccd7)
$ git push origin mv-1                       # на сервер тег уехал (первый раз)
$ git tag -f -a mv-1 -m c HEAD               # переставили ещё раз
Updated tag 'mv-1' (was cf4e834)
$ git push origin mv-1
 ! [rejected]        mv-1 -> mv-1 (already exists)
error: failed to push some refs to '/home/ubuntu/origin.git'
hint: Updates were rejected because the tag already exists in the remote.

Локально -f тег переставляет, а вот сервер отказывается принимать тег с тем же именем на другой коммит. Обойти это можно, если удалить тег на сервере и отправить заново (или git push --force), но это ровно то, чего делать нельзя, кроме исключительных случаев. Правильное исправление ошибочного релиза: новая версия. Вместо перестановки v0.2.0 выпускают v0.2.1.

Прикинь сам: ты переставил тег -f и отправил на сервер. Что ответит сервер?

Отклонит: already exists. Тег, уже ушедший на сервер, не двигают, а выпускают следующую версию.

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

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

Проверь понимание: релиз v0.2.0 собрался, но в нём ошибка. Можно ли удалить тег и поставить v0.2.0 заново на исправленный коммит?

Ответ

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

Теги не двигаем. Как выбирать их номера? Semver.

Semver: что значат три числа

Название версии должно нести смысл. Семантическое версионирование (semantic versioning, semver) это договорённость, как выбирать номер. Формат: MAJOR.MINOR.PATCH, три числа через точки, например 0.2.0. Каждое число растёт по своему правилу:

  • PATCH (заплатка) растёт при исправлении ошибки без изменения поведения: 0.2.0 -> 0.2.1;
  • MINOR (второстепенная) растёт при добавлении возможности, совместимой со старым: 0.2.1 -> 0.3.0;
  • MAJOR (главная) растёт при несовместимом изменении: убрали эндпоинт, поменяли формат ответа.

Аналогия: инструкция к бытовой технике. Исправили опечатку: это PATCH, читателю ничего перечитывать не надо. Добавили новую главу про новую функцию, а прежние главы верны: MINOR. Переписали всё так, что закладки читателей в старой версии больше не ведут туда, куда вели: MAJOR. Аналогия перестаёт работать в одном: у инструкции «несовместимость» это неудобство, а у программы это сломанный чужой код, который её вызывает.

Правило вложенности: при росте старшего числа младшие обнуляются. После 2.7.3 с новой возможностью будет 2.8.0 (не 2.8.3), а после несовместимого изменения 3.0.0.

Главный вопрос выбора числа: «сломается ли у того, кто пользуется нами, то, что работало?» Версия описывает договор с потребителем, а не объём вашей работы. Три недели рефакторинга внутри без изменения поведения это может быть PATCH. Однострочное удаление эндпоинта это MAJOR.

Примеры для «Заметок» (у сервиса есть GET /notes, ответ вида {"id": 1, "text": "..."}):

Что изменили Влияет ли на тех, кто вызывает API Какая часть растёт
Исправили падение на пустой заметке нет, поведение стало правильным PATCH: 0.2.0 -> 0.2.1
Добавили эндпоинт /headers нет, старое работает MINOR: 0.2.1 -> 0.3.0
Добавили в ответ поле created_at обычно нет (лишнее поле игнорируют) MINOR
Переименовали поле id в note_id да, клиенты читают id MAJOR
Убрали /leak да, скрипты обращались к нему MAJOR

Версия 0.x. Пока MAJOR равен нулю, проект считается нестабильным: совместимость не обещается, ломать можно и при росте MINOR. Наши «Заметки» живут в 0.x весь курс. Принято поднимать MINOR при несовместимых изменениях и явно писать об этом в списке изменений.

Пре-релиз. Проверочную версию перед настоящей помечают суффиксом через дефис: v0.3.0-rc.1 (rc это release candidate, кандидат в релиз). По правилам semver она старше отсутствия версии, но младше v0.3.0: сначала выходят -rc.1, -rc.2, потом настоящая 0.3.0. GitHub умеет помечать такие релизы значком pre-release, и наш workflow ниже сделает это сам по дефису в имени тега.

Приставка v. Тег в git называют v0.2.0, а «чистая» версия без буквы это 0.2.0. Она попадёт в имя файла notes-0.2.0.tar.gz, в APP_VERSION и позже в тег образа ghcr.io/<user>/notes:0.2.0 (образ это готовая упаковка приложения со всем окружением для запуска в контейнере; что это, разберём в уроке 4.1, а про теги образов в уроке 4.7). В workflow ты увидишь, как v отрезается.

Прикинь сам: в v0.9.0 и v0.10.0 какая версия новее и что покажет обычный sort?

Новее v0.10.0, а обычный sort поставит её перед v0.2.0 как строку. Версии сортируют sort -V или git tag --sort=v:refname, с учётом -rc.

Тут часто путают так. Числа сравнивают как числа, а не как текст. Версия 0.10.0 новее 0.9.0, хотя по алфавиту «0.10» идёт раньше «0.9». Обычная сортировка sort ошибается, sort -V (V от version) считает правильно, но пре-релиз ставит не туда:

$ printf 'v0.10.0\nv0.2.0\nv0.9.0\nv0.3.0-rc.1\nv0.3.0\nv0.1.0\n' | sort
v0.1.0
v0.10.0            <- ошибка: 0.10 стоит между 0.1 и 0.2
v0.2.0
v0.3.0
v0.3.0-rc.1
v0.9.0

$ printf 'v0.10.0\nv0.2.0\nv0.9.0\nv0.3.0-rc.1\nv0.3.0\nv0.1.0\n' | sort -V
v0.1.0
v0.2.0
v0.3.0
v0.3.0-rc.1        <- ошибка: пре-релиз должен идти раньше v0.3.0
v0.9.0
v0.10.0

Для списка тегов git есть свой порядок: git tag --sort=v:refname (v: значит «сортируй как версии»). Пре-релизы он тоже ставит после, но это лечится настройкой суффиксов:

$ git tag --sort=v:refname -l 'xv*'
xv0.3.0
xv0.3.0-rc.1
xv0.9.0
xv0.10.0
$ git -c versionsort.suffix=-rc tag --sort=v:refname -l 'xv*'
xv0.3.0-rc.1
xv0.3.0
xv0.9.0
xv0.10.0

Вывод: «последней версией» по алфавиту или простой сортировке доверять нельзя.

Главное: число версии растёт по правилам (MAJOR.MINOR.PATCH), а сортировать версии нужно как версии, а не как строки.

Проверь понимание: в API был ответ {"id": 1}, ты добавил поле и получилось {"id": 1, "text": "..."}. Какая часть версии растёт? А если поле id переименовали в note_id?

Ответ

Добавление поля обычно совместимо: растёт MINOR. Переименование ломает клиентов, которые читают id: растёт MAJOR (в 0.x поднимают MINOR и явно пишут в changelog, что совместимости нет).

Версия выбрана. Как узнать, что вошло в релиз? Диапазон коммитов.

Диапазон коммитов и changelog: что вошло в релиз

Changelog (список изменений) отвечает на вопрос «что нового в этой версии по сравнению с прошлой». Для нас он уже есть в git: это коммиты между двумя тегами. Нужен способ сказать git «покажи коммиты, которые есть в новом, но которых нет в старом».

Для этого служит запись с двумя точками: A..B означает «всё, что достижимо из B, но недостижимо из A». Простыми словами: коммиты, которые появились после A и дошли до B. Пример на схеме:

flowchart LR
    a["f089816<br>тег v0.1.0"] --> b["9392b08"] --> c["6debb7c"] --> d["6139850<br>main, HEAD"]

git log v0.1.0..HEAD покажет 9392b08, 6debb7c, 6139850: три коммита после тега. git log HEAD..v0.1.0 пуст, потому что в теге нет ничего, чего нет в HEAD.

Порядок важен: v0.1.0..HEAD даёт новые коммиты, а HEAD..v0.1.0 даёт пустоту. Если в диапазоне используется несуществующий тег, git отвечает:

$ git log --oneline v9.9.9..HEAD
fatal: ambiguous argument 'v9.9.9..HEAD': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'

Три полезные команды над диапазоном: git log --oneline v0.1.0..HEAD (по строке на коммит), git rev-list --count v0.1.0..HEAD (просто число коммитов) и git diff --stat v0.1.0..HEAD (какие файлы поменялись и сколько строк).

Почему сообщения коммитов важны. Из истории можно сделать changelog только если коммиты описаны осмысленно. Заголовок «fix» или «правки» ничего не скажет читателю релиза, а «fix: пустая заметка не роняет сервис» скажет. Поэтому в уроке 3.1 мы договорились о стиле Conventional Commits (feat:, fix:, docs:, ci:): по префиксу сразу видно, что за изменение и как выбирать число semver (fix: подсказывает PATCH, feat: подсказывает MINOR).

GitHub умеет собирать такой список сам: при создании релиза флаг «сгенерировать заметки» (в gh это --generate-notes) берёт заголовки слитых Pull Request с момента прошлого релиза. Отсюда вывод: чем яснее заголовки PR, тем полезнее релиз.

Прикинь сам: что покажет git log v0.1.0..HEAD?

Коммиты, которые достижимы из HEAD, но не из тега: то есть всё, что появилось после v0.1.0. Это основа changelog.

Главное: запись A..B даёт всё, что есть в B, но нет в A, и из этого собирают список изменений релиза.

Список изменений готов. Теперь файл, который скачает потребитель: артефакт.

Артефакт релиза: что именно скачивает потребитель

Артефакт (artifact) это то, что потребитель забирает и запускает: архив с кодом, бинарный файл, образ контейнера (упакованное приложение, которое запускается одинаково на любой машине с Docker, урок 4.1). Разработчик может собрать его руками, но у хорошего артефакта три свойства:

  1. Он собран из тега, то есть из точного коммита, а не из «того, что лежит у меня на диске».
  2. Он собирается одинаково, сколько раз ни запусти: одно и то же содержимое каждый раз.
  3. Рядом лежит контрольная сумма, чтобы проверить целостность (то есть что файл дошёл до тебя ровно таким, каким его выпустили: не оборвался при скачивании и не подменён).

Нашим артефактом будет архив notes-0.2.0.tar.gz. Что это за формат: tar (от tape archive) склеивает много файлов и папок в один файл, а gzip сжимает его. Отсюда двойное расширение .tar.gz. Такие архивы стандарт в мире Linux.

Как собрать архив? Есть два способа, и различие принципиально.

Способ «из папки»: tar czf notes.tar.gz . упаковывает всё, что лежит на диске в текущей папке. Флаги: c (create, создать), z (сжать gzip), f (file, имя файла архива следует дальше), точка это «текущая папка».

Способ «из коммита»: git archive берёт файлы из объекта коммита, а не с диска. Всё, что не добавлено в git, в архив не попадёт.

Чем это отличается на практике? Пусть в папке проекта лежит недокоммиченный scratch.txt, секретный .env (он есть в .gitignore) и тяжёлая папка .venv с установленными пакетами. Из папки они все упакуются: сборка получилась бы «из грязного дерева», в публичный релиз могли бы утечь пароли. Из коммита они не попадут ни при каких обстоятельствах, потому что в коммите их нет. В нашем стенде (реальный запуск, дальше ты повторишь это сам):

$ tar czf /tmp/notes-0.9.3.tar.gz --exclude=.git .
$ ls -l /tmp/notes-0.9.3.tar.gz
-rw-r--r-- 1 ubuntu ubuntu 207343 Sep 30 12:11 /tmp/notes-0.9.3.tar.gz     <- 200 КБ: внутри лишнее
$ tar tzf /tmp/notes-0.9.3.tar.gz | grep -E '\.env|\.venv' | head
./.venv/
./.venv/bin/
./.venv/bin/big
./.env
$ git archive --format=tar.gz --prefix=notes-0.9.3/ -o /tmp/good.tar.gz HEAD
$ ls -l /tmp/good.tar.gz
-rw-r--r-- 1 ubuntu ubuntu 6870 Sep 30 12:11 /tmp/good.tar.gz              <- 7 КБ: только коммит

Обрати внимание: git status при этом не показал ни .env, ни .venv, потому что они в .gitignore. Git их «не видит», а tar по папке видит. Поэтому «.gitignore защищает от утечки» верно только для команд git, но не для tar.

Разберём git archive --format=tar.gz --prefix=notes-0.2.0/ -o файл HEAD:

  • --format=tar.gz формат результата (git сам сожмёт);
  • --prefix=notes-0.2.0/ добавить перед именем каждого файла папку notes-0.2.0/. Тот, кто распакует архив, получит одну аккуратную папку, а не 25 файлов, рассыпанных в текущем каталоге. Слэш в конце обязателен: это папка;
  • -o файл (output, вывод) куда записать результат;
  • HEAD (или имя тега) из какого коммита взять файлы.

Ещё одно полезное свойство: git archive записывает в архив хеш коммита, из которого он сделан, и его можно достать обратно:

$ gzip -dc /tmp/notes-demo.tar.gz | git get-tar-commit-id
6139850098dc2273ef539585e6d3352c43b9f831

gzip -dc распаковывает (d, decompress) и выводит в стандартный вывод (c), а git get-tar-commit-id читает этот поток и печатает хеш коммита. По архиву из скачанного релиза можно доказать, из какого коммита он собран.

Воспроизводимость значит: собрал дважды из одного коммита, получил одинаковые байты. Проверим (реальный вывод, один и тот же коммит, вторая сборка через две секунды):

$ git archive --format=tar --prefix=notes-demo/ HEAD | sha256sum
908b6436c17f29d6caf9a8e0ff8281d37f89f1a62e6d4cb50b7fee74b4504c3a  -
$ git archive --format=tar --prefix=notes-demo/ HEAD | sha256sum
908b6436c17f29d6caf9a8e0ff8281d37f89f1a62e6d4cb50b7fee74b4504c3a  -

Суммы равны, потому что git archive записывает в архив время коммита, а не текущее время. Сжатый .tar.gz git тоже собирает без текущего времени, и на одной машине суммы совпали даже через паузу. Но результат сжатия зависит от версии программы gzip, поэтому между разными машинами надёжнее сравнивать несжатый tar. С обычным tar czf из папки так не получится: там попадают время изменения файлов, порядок, лишние файлы.

Прикинь сам: в архиве, собранном tar из папки, оказались .env и .venv. Как собрать правильно?

git archive: он упаковывает только файлы выбранного коммита, без лишнего на диске. Размер падает с 200 КБ до 7 КБ.

Главное: артефакт собирают из коммита по тегу, а не из папки, чтобы в него не попало лишнее.

Проверь понимание: попадёт ли в архив git archive файл scratch.txt, который ты создал, но не добавил в git? А в архив tar czf?

Ответ

В git archive не попадёт: он читает только объекты коммита. В tar czf попадёт, потому что tar берёт файлы с диска. Поэтому релизный архив собирают через git archive.

Архив собран. Как потребителю убедиться, что он не испорчен? Контрольная сумма.

Контрольная сумма SHA-256: как проверить, что файл не изменился

Скачанный файл мог повредиться по дороге, скачаться не до конца или быть подменённым. Как проверить? Сравнивать файл по байтам не с чем: оригинала у тебя нет. Помогает контрольная сумма (checksum): короткая «отпечаток пальца» файла. Её считают функцией, которая из любого набора байт делает число фиксированной длины.

У функции SHA-256 три свойства:

  1. Один и тот же файл всегда даёт одну и ту же сумму.
  2. Изменился хотя бы один байт: сумма меняется полностью и непредсказуемо.
  3. По сумме файл восстановить нельзя, а подобрать другой файл с той же суммой практически невозможно.

Сумма SHA-256 это 256 бит, записанные как 64 шестнадцатеричные цифры (0-9 и a-f). Настоящие суммы для трёх маленьких входов:

$ printf 'hello' | sha256sum
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824  -
$ printf 'hellp' | sha256sum
fdd7585e08c4e2afd71dcabdb4636c89d557a3f42db9e2040c8bbd1708aa4ce7  -
$ printf '' | sha256sum
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  -

Слова hello и hellp отличаются одной буквой, а суммы не похожи вообще. Пустой ввод тоже имеет свою сумму (полезно знать, если увидишь e3b0c442...: это сумма «ничего»). Дефис в конце строки значит «данные пришли со стандартного ввода, а не из файла». printf 'hello' печатает слово без перевода строки, echo добавил бы перевод строки и сумма была бы другой.

Как это работает в релизе. Публикующий считает сумму архива и кладёт рядом файл .sha256: одна строка «сумма, два пробела, имя файла». Скачавший считает сумму у себя и сравнивает. Команда sha256sum -c файл.sha256 (-c от check, проверить) делает это сама: читает из файла суммы и имена, считает суммы заново и печатает OK или FAILED.

$ sha256sum notes-demo.tar.gz > notes-demo.tar.gz.sha256
$ cat notes-demo.tar.gz.sha256
ae6c48301267e97c0f894b1dc5515cca7f9c58655b6f4811d404fbec6630a64b  notes-demo.tar.gz
$ sha256sum -c notes-demo.tar.gz.sha256
notes-demo.tar.gz: OK
$ printf 'x' >> notes-demo.tar.gz              # «испортим» файл: дописали один байт
$ sha256sum -c notes-demo.tar.gz.sha256
sha256sum: WARNING: 1 computed checksum did NOT match
notes-demo.tar.gz: FAILED

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

Прикинь сам: ты дописал в архив один байт. Что покажет sha256sum -c?

FAILED: даже один байт меняет сумму полностью. Совпадение суммы говорит, что файл тот же, но не что ему можно доверять.

Осторожно: Сумма не шифрует и не скрывает содержимое, это только «отпечаток». И сумма не доказывает, что файл хороший: она доказывает только, что он тот же, что был у публикующего.

Главное: SHA-256 меняется от любой правки файла, поэтому сумма рядом с файлом ловит повреждение и подмену.

Проверь понимание: ты запускаешь sha256sum -c notes-0.2.0.tar.gz.sha256 из домашней папки, а архив лежит в ~/dl. Что случится и почему?

Ответ

Ошибка sha256sum: notes-0.2.0.tar.gz: No such file or directory и FAILED open or read. В .sha256 записано имя файла без пути, и sha256sum ищет его рядом, в текущей папке. Нужно зайти в ~/dl (cd ~/dl) и повторить.

Файл и сумма есть. Осталось автоматизировать выпуск: GitHub Release по тегу.

GitHub Release и workflow по тегу

GitHub Release это страница на GitHub, привязанная к тегу: заголовок, описание («что изменилось»), прикреплённые файлы (assets) и отметка pre-release. Создать её можно руками в веб-интерфейсе, командой gh release create (gh это консольная программа GitHub, её мы ставили в уроке 3.2) или автоматически. Руками ошибиться легко: забыл файл, собрал не из того коммита. Поэтому релиз создаёт workflow, и делается это по тегу: пока ты не поставил и не отправил тег, ничего не публикуется.

Вспомним из урока 3.3: workflow это файл в .github/workflows/, описывающий автоматический запуск; триггер (on:) говорит, по какому событию он запускается; job это группа шагов на одной виртуальной машине, раннер (runner) это сама виртуальная машина, которую GitHub выдаёт на время запуска.

Как workflow узнаёт про тег. Триггер выглядит так:

on:
  push:
    tags: ['v*']

Читается: «при событии push, если отправлен тег, имя которого подходит под шаблон v*». Шаблон v* это glob: звёздочка значит «любое количество любых символов». Под него подходит v0.2.0, v1, даже version, а 0.2.0 (без буквы v) нет: шаблон требует начальную v. Проверим тем же механизмом, что использует оболочка (case из урока 1.6):

v0.2.0: совпало
0.2.0: нет
v1: совпало
version: совпало
release-1: нет

Обычный git push ветки такой workflow не запускает: условие «отправлен тег», а не ветка.

Из какого коммита берётся файл workflow. Это важный и неочевидный момент. Когда приходит тег, GitHub берёт файл release.yml из того коммита, на который указывает тег. Если на этом коммите файла ещё не было (тег поставили раньше, чем влили workflow), запуска не будет: событие пришло, но описания, что делать, в этом коммите нет. Поэтому порядок такой: сначала workflow вливается в main, потом ставится тег на свежую main. Тот же коммит раннер и скачивает: действие actions/checkout в запуске по тегу кладёт на раннер ровно код тегнутого коммита. Мы проверили это: клон тега v0.2.0 даёт «отсоединённый HEAD» (detached HEAD, состояние «стоим не на ветке, а прямо на коммите»), и git archive HEAD собирает нужный код.

flowchart LR
    T["тег v0.2.0"] -->|указывает| C["коммит c1e8673"]
    C -->|содержит| F[".github/workflows/release.yml"]
    P["push тега"] -.->|"GitHub читает файл именно из этого коммита"| F

Права токена. Каждому запуску GitHub выдаёт временный токен (пароль на время запуска), по которому workflow обращается к API репозитория. Как в уроке 3.4, выдаём минимум: на весь workflow только чтение (contents: read), а запись contents: write (нужна, чтобы создать Release) только job, который его создаёт. Так, даже если в другой job что-то пойдёт не так, права на запись у него не будет.

Как имя тега попадает в скрипт. Здесь ловушка, о которой предупреждал урок 3.4. Имя тега придумывает тот, кто создал тег, а git разрешает в нём почти любые символы. Проверим, какие имена git считает допустимыми (git check-ref-format проверяет правильность имени):

ok:   v1$(id)
ok:   v1;id
BAD:  v1 x
BAD:  v1..2
BAD:  v1~2
ok:   v0.2.0
ok:   v0.3.0-rc.1

Имя v1$(id) (с долларом, скобками, точкой с запятой) допустимо. В оболочке $(команда) значит «выполни команду и подставь её вывод». Теперь смотри, что будет, если подставить имя тега прямо в текст скрипта. Actions перед запуском заменяет выражения вида github.ref_name на значение, то есть сначала склеивает текст скрипта, потом отдаёт его bash. Повторим это руками с тегом v1$(whoami):

$ TAG='v1$(whoami)'
$ printf '%s\n' "echo \"Собираю релиз ${TAG}\"" > /tmp/inline.sh     # значение вставили в текст скрипта
$ cat /tmp/inline.sh
echo "Собираю релиз v1$(whoami)"
$ bash /tmp/inline.sh
Собираю релиз v1ubuntu                                             # $(whoami) ВЫПОЛНИЛОСЬ

$ printf '%s\n' 'echo "Собираю релиз ${TAG}"' > /tmp/env.sh          # текст скрипта не зависит от значения
$ TAG="$TAG" bash /tmp/env.sh                                       # значение передано как переменная окружения
Собираю релиз v1$(whoami)                                          # осталось просто текстом

В первом случае $(whoami) превратилось в результат команды, то есть чужой текст выполнился как код на раннере, где есть токен с правом записи. Во втором скрипт всегда один и тот же, а значение лежит в переменной окружения (environment variable, именованное значение, доступное программе), и bash подставляет его как данные. Поэтому в workflow значение из GitHub всегда передаётся через env:, а в скрипте используется "$TAG". actionlint (проверка workflow, см. задание 3) такую подстановку в run: не подсвечивает: мы проверили, он пропускает и небезопасный вариант с именем тега. Придётся следить самому.

Флаг --verify-tag. Команда gh release create умеет создать тег сама, если его нет. Нам это не нужно: тег должен быть поставлен человеком осознанно. Флаг --verify-tag прерывает создание релиза, если тега на сервере нет.

Pre-release по имени. Если в имени тега есть дефис (v0.3.0-rc.1), workflow добавляет к gh release create флаг --prerelease, и GitHub помечает релиз как предварительный. Логику проверили на разных именах: v0.2.0 не получает флага, v0.3.0-rc.1 и v0.3.0-beta получают.

Один тег, много артефактов. Позже, в уроке 4.7, появится второй workflow image.yml, который по тому же тегу соберёт контейнерный образ. Правило курса: тег в git v0.2.0, версия в APP_VERSION и тег образа 0.2.0 совпадают (без буквы v), у каждого артефакта свой workflow.

Прикинь сам: тег release-1 не подходит под шаблон v*. Запустится ли workflow?

Нет: событие push по тегам срабатывает только на совпавшие шаблоны, release-1 не совпал.

Главное: workflow по тегу собирает артефакт и сумму и публикует их в GitHub Release, а значение тега нужно проверять до использования.

Проверь понимание: запустится ли release.yml при обычном git push в ветку? При пуше тега v0.2.0? При пуше тега 0.2.0?

Ответ

Push в ветку не запустит: триггер только на теги. Тег v0.2.0 запустит. Тег 0.2.0 не запустит: шаблон v* требует начальную v.

Понятий много: тег, версия, релиз, артефакт. Как они связаны?

Тег, версия, релиз, артефакт: как понятия урока связаны

Слова «версия», «тег», «релиз», «артефакт» в разговорах часто мешают в кучу: «выкатили релиз», «скачай тег», «какая версия на проде». Если не разделять их, непонятно, что именно можно исправить, а что нельзя, и где искать причину, когда что-то не совпало.

Снова издательство. Рукопись на определённый день это коммит. «Второе издание» это имя, закреплённое за этим состоянием (тег). Число «2» в нём это версия. Напечатанный тираж это артефакт. Запись в каталоге «вышло второе издание, вот что нового, вот где скачать» это релиз. Аналогия ломается тем, что в каталоге книгу можно переписать, а у нас релиз привязан к тегу, и если тег не двигать, то и содержимое релиза остаётся тем же.

Понятия друг на друга нанизаны, и у каждого своя «степень изменяемости»:

flowchart TD
    C["коммит c1e8673<br>снимок файлов, неизменяем: хеш зависит от содержимого"]
    T["тег v0.2.0<br>имя для коммита, по договорённости не двигается<br>версия = число внутри имени: 0.2.0 (без буквы v)"]
    W["workflow release.yml<br>по событию «пришёл тег» собирает"]
    A["артефакт notes-0.2.0.tar.gz<br>файл, собранный из коммита по тегу"]
    S["notes-0.2.0.tar.gz.sha256<br>контрольная сумма этого файла"]
    R["GitHub Release v0.2.0<br>страница: описание и прикреплённые файлы"]
    T -->|указывает на| C
    W --> A
    W --> S
    A --> R
    S --> R
    T -.->|запускает| W
Понятие Что это Где живёт Можно ли менять
Коммит снимок файлов в истории git нет: новый коммит это новый хеш
Тег имя на коммите в репозитории технически да, по договорённости нет
Версия число вида 0.2.0 в имени тега, файла, образа выбираешь один раз при выпуске
Артефакт собранный файл во вложениях релиза пересобирают только из того же тега, и сумма должна совпасть
Релиз страница с описанием и файлами на GitHub описание править можно, файлы и тег нет

Дежурный получил сообщение: «на проде версия 0.2.0, и она падает». Что он делает? По версии находит тег v0.2.0 (имя версии с буквой v). Тег указывает на коммит c1e8673: значит, исходный код этой версии известен точно. Из релиза скачивает notes-0.2.0.tar.gz и сверяет сумму с файлом .sha256: если совпала, на сервере лежит именно тот артефакт, который выпустили. Из списка изменений видно, что вошло в версию. Все четыре шага работают только потому, что понятия связаны жёстко: версия ведёт к тегу, тег к коммиту, коммит к точному коду. Если бы тег двигали, первая же связь рассыпалась.

Прикинь сам: тег v0.2.0 и версия 0.2.0: это одно и то же?

Нет: версия это число внутри имени (без буквы v), а тег это имя на коммите.

Тут часто путают так. «Релиз это то же самое, что тег». Нет: тег это метка в git, её можно поставить и без релиза (и тогда на GitHub не будет ни файлов, ни описания). Релиз это надстройка над тегом, которую создаёт человек или workflow. Второе: «версия это номер, который я придумал для красоты». Номер обязан что-то обещать: по нему потребитель решает, безопасно ли обновляться.

Главное: цепочка такая: коммит, тег на нём, версия из тега, артефакт из коммита, Release как страница с файлами.

Проверь понимание: в git есть тег v0.3.0, но на странице GitHub Releases его нет. Какие из понятий урока уже существуют, а каких ещё нет?

Ответ

Есть коммит, тег и версия (она записана в имени тега). Нет релиза (страницы с описанием) и артефакта с контрольной суммой: их создаёт workflow или человек отдельно. Если workflow должен был сработать по тегу, смотри вкладку Actions: запускался ли он и чем закончился.

Понятия связаны. Как на сервере узнать, какая версия сейчас запущена?

Как узнать, какая версия сейчас запущена: git describe

Зачем это нужно. Релиз выпущен, но через месяц кто-то запускает сервис из свежей копии репозитория и пишет: «у меня 0.2.0, и всё сломано». Проверить, правда ли это 0.2.0, можно по коммиту. Если тегов на нужном коммите нет, человеку остаётся гадать, какая это версия. Команда git describe --tags отвечает на вопрос «на какой версии ближе всего стоит этот коммит».

Аналогия: километровые столбы на дороге. Ты стоишь не точно у столба, а в трёхстах метрах после столба «42». Говоришь: «42 км и ещё немного». Оговорка: у дороги столбы стоят по порядку, а у git «ближайший» тег ищется по истории коммитов назад от твоего места, а не по дате создания тега.

Как устроено. Git идёт от текущего коммита назад по цепочке parent (из теории про коммит) и ищет первый тег. Если тег стоит прямо на этом коммите, печатает только имя тега. Если между тегом и тобой есть коммиты, печатает три части через дефис: имя тега, число коммитов после него, буква g (от git) и короткий хеш текущего коммита.

Разобранный пример на стенде. На коммите с тегом v0.2.0 и после одного лишнего коммита:

$ git describe --tags
v0.2.0
$ git commit --allow-empty -q -m "wip: проверка describe"
$ git describe --tags
v0.2.0-1-g6876473

Расшифровка второй строки: v0.2.0 ближайший тег, 1 один коммит сверху, g6876473 буква g и хеш коммита, на котором ты стоишь. Флаг --allow-empty разрешает коммит без изменений файлов (нужен только для демонстрации), а -q (quiet) убирает лишний вывод.

Итог: если git describe печатает просто v0.2.0, ты на релизе. Если в конце -N-gХЕШ, это уже не релиз, а промежуточное состояние. Такую строку удобно записывать в лог при старте сервиса, чтобы по логу было видно, какой именно код работал. Позже, в теме про контейнеры, ты увидишь тот же приём: версия передаётся в образ при сборке.

Что путают. Думают, что git describe без --tags тоже найдёт лёгкие теги. Не найдёт: по умолчанию он смотрит только на аннотированные. Ещё одна ловушка: в свежем клоне без тегов (например, git clone --depth 1 в CI) команда завершится ошибкой fatal: No names found, cannot describe anything. Тогда сначала нужно скачать теги: git fetch --tags.

Прикинь сам: git describe --tags показал v0.2.0-1-g6876473. Что это значит?

От тега v0.2.0 ушёл один коммит, и текущий коммит 6876473. Значит, это не чистый релиз.

Главное: git describe называет текущий коммит по ближайшему тегу, а суффикс показывает, сколько коммитов добавлено после него.

Проверь понимание: git describe --tags напечатал v0.2.0-3-g1a2b3c4. Это релиз v0.2.0? Сколько коммитов после него?

Ответ

Нет, это не сам релиз: после тега v0.2.0 есть три коммита, ты стоишь на коммите 1a2b3c4. Если нужен именно релиз, переключись на тег: git switch --detach v0.2.0.

Версия видна. Что делать, если релиз оказался плохим? Откат.

Откат релиза и исправление ошибки

Откатить релиз значит вернуть работающее состояние, а не стереть историю. Есть два действия, и их делают в таком порядке.

Быстрое. Сначала возвращают сервис к предыдущей версии: разворачивают v1.0.0, пока разбираются. Это не правка кода, а выбор другого артефакта, поэтому занимает минуты. Артефакты и теги старых версий именно для этого и не удаляют.

Правильное. Потом убирают причину. Команда git revert <коммит> создаёт новый коммит, который отменяет изменения указанного коммита. История не переписывается: старый коммит на месте, поверх него лежит «анти-коммит». Затем выпускают новую версию с исправлением. Проверим на настоящем примере: v1.1.0 сломал вход, main откатили и выпустили v1.1.1.

$ git revert --no-edit HEAD
[main 5e5bfe7] Revert "feat: новая форма входа"
$ git tag -a v1.1.1 -m "Release 1.1.1: откат новой формы входа (v1.1.0 отозван)"
$ git push --follow-tags origin main
   02e61cb..5e5bfe7  main -> main
 * [new tag]         v1.1.1 -> v1.1.1
$ git log --oneline --decorate
5e5bfe7 (HEAD -> main, tag: v1.1.1, origin/main) Revert "feat: новая форма входа"
02e61cb (tag: v1.1.0) feat: новая форма входа
3c6e70b (tag: v1.0.0) feat: первая версия

Тег v1.1.0 остался на месте, а версия 1.1.1 содержит откат. В описании релиза 1.1.0 пишут, что он отозван. --no-edit значит «не открывать редактор для сообщения, оставить стандартное».

Почему не git reset --hard и git push --force? Они переписывают историю общей ветки, у коллег ломаются их копии, а main в GitHub защищена от этого правилами (урок 3.2).

Hotfix для старой версии. Бывает, что main уехала далеко вперёд (уже 1.2), а клиенту на 1.0 нужно срочное исправление без новых функций. Тогда создают ветку от тега: git switch -c release/1.0 v1.0.0, переносят туда нужный коммит командой git cherry-pick <хеш> («сорвать вишенку»: скопировать один коммит из другой ветки, не сливая остальное) и ставят тег v1.0.1.

$ git switch -c release/1.0 v1.0.0
$ git cherry-pick 6708f8c
[release/1.0 04d541c] fix: пустая заметка не роняет сервис
$ git tag -a v1.0.1 -m "Release 1.0.1: исправление пустой заметки"
$ git push -u origin release/1.0 v1.0.1
$ git log --oneline --decorate --graph --all
* 618ea9d (tag: v1.2.0, origin/main, main) feat: поиск по заметкам
* 6708f8c fix: пустая заметка не роняет сервис
* 5e5bfe7 (tag: v1.1.1) Revert "feat: новая форма входа"
* 02e61cb (tag: v1.1.0) feat: новая форма входа
| * 04d541c (HEAD -> release/1.0, tag: v1.0.1, origin/release/1.0) fix: пустая заметка не роняет сервис
|/
* 3c6e70b (tag: v1.0.0) feat: первая версия

Обрати внимание на хеши: 6708f8c в main и 04d541c в release/1.0 это два разных коммита с одинаковым содержимым правки: cherry-pick создаёт копию. Держать долгоживущие релизные ветки стоит, только если ты действительно поддерживаешь старые версии: каждая такая ветка это работа по сопровождению.

flowchart LR
    a["3c6e70b<br>v1.0.0"] --> b["02e61cb<br>v1.1.0"] --> c["5e5bfe7<br>v1.1.1"] --> d["6708f8c"] --> e["618ea9d<br>v1.2.0 main"]
    a --> h["04d541c<br>копия 6708f8c<br>v1.0.1 release/1.0"]

Прикинь сам: версия v1.1.0 плохая и уже опубликована. Можно ли удалить тег и выпустить заново?

Нет: тег не двигают. Делают git revert и выпускают v1.1.1, а для старой ветки отдельную правку.

Осторожно: «Откат» и «revert» не одно и то же: откат это возврат сервиса к прежней версии (действие в эксплуатации), git revert это способ убрать изменение из кода. Первое делают сразу, второе после.

Главное: ошибку в опубликованной версии исправляют новой версией, а не правкой старой.

Откат ясен. Остался вопрос: кому доверять выпуск релизов?

Кто может выпускать релизы

Тег, отправленный в репозиторий, запускает публикацию. Значит, тот, кто может запушить тег v*, может выпустить релиз. Ограничить это можно на GitHub правилами репозитория (rulesets): для тегов по шаблону v* разрешают создание только выбранным ролям и запрещают удаление и перемещение. Для деплоя добавляют environment (окружение с защитой): job, привязанный к нему, ждёт ручного подтверждения ответственного человека. Ещё у GitHub есть настройка неизменяемых релизов (immutable releases): после публикации нельзя менять файлы и переносить тег. Названия и пункты меню GitHub со временем меняются, поэтому актуальные ищи в документации; в курсе мы эти настройки не включаем.

Три вопроса перед тем как ставить тег. Релиз нельзя отменить незаметно, поэтому перед git tag полезно задать себе три вопроса. Первый: «я стою на актуальной main?» Проверка: git pull --ff-only, затем git log --oneline -1, и хеш должен совпасть с тем, что показывает GitHub. Второй: «зелёный ли CI на этом коммите?» Тегировать красный коммит значит выпустить заведомо сломанную версию. Третий: «что войдёт в релиз?» Ответ даёт git log --oneline <прошлый-тег>..HEAD: если в списке есть коммит, которого ты не ожидал, разберись до тега, а не после. Эти три проверки занимают минуту, а исправление ошибочного релиза занимает часы и оставляет след в истории навсегда.

Есть и четвёртый вопрос, про людей: «знает ли команда, что сейчас будет релиз?» Тег запускает публикацию сразу, а скачать файл могут уже через минуту. Поэтому в реальных командах релиз объявляют в рабочем чате заранее и не выпускают в пятницу вечером, когда некому будет откатывать. Это не техническое правило, а привычка, но именно она отличает спокойные релизы от ночных дежурств.

Прикинь сам: кому нужно разрешить создание тегов v*?

Выбранным ролям: правила репозитория (rulesets) разрешают создание тегов v* только им и запрещают удаление и перемещение.

Главное: тот, кто может запушить тег v*, может выпустить релиз, поэтому право на такие теги ограничивают правилами репозитория.

Теории хватит. Дальше практика: выбираем версию и собираем релиз.

Практика

Все задания выполняются в ~/notes. Репозиторий уже на GitHub, тег v0.1.0 стоит (урок 3.2), main защищена, изменения идут через Pull Request. Для gh (командная строка GitHub, ты её использовал в 3.2 и 3.3) нужна авторизация: gh auth status. Если gh не установлен, ставь по инструкции GitHub CLI (версия не закреплена, проверь актуальную на странице проекта).

Хеши, время, номера Pull Request и размеры файлов в примерах вывода у тебя будут другими: важна форма.

Задание 1. Выбрать версию и собрать changelog

Цель: по истории между релизами решить, какой будет следующая версия, и получить список изменений.

Предскажи: сколько коммитов между v0.1.0 и HEAD, если после первого тега ты сделал только CI-файлы из уроков 3.3 и 3.4? Какую часть версии поднимешь, если приложение не менялось?

Ответ

Коммитов столько, сколько попало в main после тега: по одному на Pull Request при squash-merge. В уроках 3.3 и 3.4 это три PR: CI, security-jobs и dependabot. Код приложения не менялся, добавились CI-проверки, поэтому по строгому semver для потребителя ничего не поменялось, это в лучшем случае PATCH. Мы поднимаем MINOR до 0.2.0, потому что в проекте появился новый процесс (релизы), а в 0.x это нормальная практика. Главное, чтобы решение было объяснено.

Шаги:

  1. Убедись, что локальная main актуальна и тег есть. Команды: git switch main переключает на ветку, git pull --ff-only забирает новое с GitHub (--ff-only, fast-forward only, значит «только простое перемотывание вперёд, без слияния»; если история разошлась, команда остановится с ошибкой, а не создаст лишний коммит), git tag -l 'v*' печатает теги по шаблону (-l это list; кавычки нужны, чтобы оболочка не раскрыла * в имена файлов).
cd ~/notes
git switch main
git pull --ff-only
git tag -l 'v*'
  1. Посмотри коммиты после релиза. --oneline это по одной строке на коммит (7 символов хеша и заголовок), v0.1.0..HEAD это диапазон из теории:
git log --oneline v0.1.0..HEAD
  1. Посчитай их и посмотри, какие файлы менялись. git rev-list --count печатает только число коммитов, git diff --stat показывает по файлам, сколько строк добавлено (знаки +) и удалено (знаки -):
git rev-list --count v0.1.0..HEAD
git diff --stat v0.1.0..HEAD
  1. Проверь, менялся ли app.py. git diff --quiet ничего не печатает, а сообщает результат кодом выхода: 0 если различий нет, 1 если есть. Часть -- app.py (два дефиса и имя) ограничивает сравнение одним файлом. Запись A && B || C значит: если A успешна, выполнить B, иначе выполнить C (урок 1.6):
git diff --quiet v0.1.0..HEAD -- app.py && echo "app.py без изменений" || echo "app.py менялся"

Что должно получиться (прогон на стенде с локальным голым репозиторием вместо GitHub):

Already on 'main'
Your branch is up to date with 'origin/main'.
Already up to date.
v0.1.0
6139850 ci: dependabot для actions и pip (#4)
6debb7c ci: secrets, trivy fs, минимальные permissions (#3)
9392b08 Добавить CI: линтер и тесты на Python 3.13 и 3.14 (#2)
3
 .github/dependabot.yml   | 12 ++++++++++++
 .github/workflows/ci.yml | 88 ++++++++++++++++++++++++++++++++++++++++++++
 Makefile                 | 11 ++++++++---
 requirements-dev.txt     |  2 ++
 ruff.toml                |  7 +++++++
 5 files changed, 117 insertions(+), 3 deletions(-)
app.py без изменений

Как читать вывод: первые три строки от git switch и git pull: ты уже на main и нового нет (если на GitHub появились коммиты, pull покажет их скачивание). v0.1.0 это единственный тег. Дальше три коммита после тега, самый новый сверху, в конце заголовка номер Pull Request. 3 это их число. Таблица --stat: слева файл, справа количество изменённых строк и полоска (+ добавленные строки, - удалённые; если изменений много, git сокращает полоску, чтобы она влезла в экран, поэтому считай по числу, а не по знакам); последняя строка суммирует: 5 файлов, 117 добавленных строк, 3 удалённые. Удалённые строки это старые строки цели lint в Makefile, которые заменил новый вариант. Все файлы служебные (CI, зависимости для разработки, настройки линтера, цели make), ни одного файла с кодом сервиса, поэтому и app.py без изменений.

Объясни себе:

  • Что означают две точки в v0.1.0..HEAD?
  • Почему changelog по git log полезен, только если сообщения коммитов осмысленные?

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

  • fatal: ambiguous argument 'v0.1.0..HEAD': unknown revision or path not in the working tree.: тега нет локально (тот же текст git выдаёт для любого неизвестного имени, мы проверили на v9.9.9). Выполни git fetch --tags (скачать теги с GitHub) и проверь git tag -l.
  • Пустой вывод git log: в main после тега ничего нет, релизить нечего.
  • fatal: Not possible to fast-forward, aborting. от git pull --ff-only: у тебя есть локальные коммиты, которых нет на GitHub, или история разошлась. Не решай это принудительно: посмотри git log --oneline --graph --all и разберись по уроку 3.2.

Если нейросеть предложила номер версии или changelog, не публикуй их вслепую: сверь с реальным выводом git log v...HEAD и разделом про semver.

Задание 2. Аннотированный тег и архив с контрольной суммой

Цель: руками собрать то, что потом сделает workflow, и научиться проверять артефакт.

Предскажи: попадёт ли в git archive файл scratch.txt, который ты создал в каталоге, но не добавил в git? А в tar czf?

Ответ

В git archive не попадёт: он читает только объекты коммита. В tar czf попадёт, потому что tar берёт файлы с диска. Поэтому релизный архив собирают через git archive.

Шаги:

  1. Создай мусорный файл и собери архив двумя способами (тег пока не ставим, берём HEAD). Разбор команд: echo "лишнее" > scratch.txt создаёт файл; git archive и tar czf разобраны в теории (у tar добавлено --exclude=.git, чтобы не упаковывать саму папку git); tar tzf файл печатает список файлов в архиве (t list, z gzip, f file); grep -c scratch.txt печатает количество строк со словом; || true нужен потому, что grep -c при нуле совпадений возвращает код выхода 1, а || true («иначе выполни true», команду, которая всегда успешна) прячет это, чтобы ноль не выглядел ошибкой:
cd ~/notes
echo "лишнее" > scratch.txt
git archive --format=tar.gz --prefix=notes-demo/ -o /tmp/notes-demo.tar.gz HEAD
tar tzf /tmp/notes-demo.tar.gz | grep -c scratch.txt || true
tar czf /tmp/notes-dirty.tar.gz --exclude=.git .
tar tzf /tmp/notes-dirty.tar.gz | grep scratch.txt
  1. Посмотри, что внутри «чистого» архива, и достань из него хеш коммита:
tar tzf /tmp/notes-demo.tar.gz | head -8
gzip -dc /tmp/notes-demo.tar.gz | git get-tar-commit-id
git rev-parse HEAD
  1. Проверь, что два git archive одного коммита дают идентичный результат. Здесь | sha256sum считает сумму того, что вывел git archive (без флага -o он пишет архив в стандартный вывод):
git archive --format=tar --prefix=notes-demo/ HEAD | sha256sum
git archive --format=tar --prefix=notes-demo/ HEAD | sha256sum
  1. Посчитай и проверь контрольную сумму. Запись > файл сохраняет вывод в файл. Сначала переходим в /tmp, потому что в файле .sha256 имя записано без пути:
cd /tmp
sha256sum notes-demo.tar.gz > notes-demo.tar.gz.sha256
cat notes-demo.tar.gz.sha256
sha256sum -c notes-demo.tar.gz.sha256
  1. Испорти архив и проверь ещё раз. printf 'x' >> файл дописывает в конец файла один символ (>> это добавление, а не перезапись):
printf 'x' >> notes-demo.tar.gz
sha256sum -c notes-demo.tar.gz.sha256
  1. Убери за собой:
rm -f ~/notes/scratch.txt /tmp/notes-demo.tar.gz* /tmp/notes-dirty.tar.gz

Что должно получиться:

0
./scratch.txt
notes-demo/
notes-demo/.github/
notes-demo/.github/dependabot.yml
notes-demo/.github/workflows/
notes-demo/.github/workflows/ci.yml
notes-demo/.gitignore
notes-demo/CONTRIBUTING.md
notes-demo/Makefile
6139850098dc2273ef539585e6d3352c43b9f831
6139850098dc2273ef539585e6d3352c43b9f831
908b6436c17f29d6caf9a8e0ff8281d37f89f1a62e6d4cb50b7fee74b4504c3a  -
908b6436c17f29d6caf9a8e0ff8281d37f89f1a62e6d4cb50b7fee74b4504c3a  -
ae6c48301267e97c0f894b1dc5515cca7f9c58655b6f4811d404fbec6630a64b  notes-demo.tar.gz
notes-demo.tar.gz: OK
sha256sum: WARNING: 1 computed checksum did NOT match
notes-demo.tar.gz: FAILED

Как читать вывод: первая строка 0: в чистом архиве нет scratch.txt; вторая ./scratch.txt: в «грязном» есть (точка со слэшем значит «от текущей папки»). Дальше первые 8 записей архива: папка notes-demo/ (это --prefix), потом файлы в алфавитном порядке, служебная папка .github первой. Две строки с одинаковым хешем: первая это хеш коммита, зашитый в архив, вторая это HEAD: они совпали, значит архив собран из того коммита, который мы имели в виду. Две равные суммы 908b64... подтверждают воспроизводимость. Строка с ae6c48... это содержимое .sha256: сумма, два пробела, имя. OK значит всё сошлось. После дописанного байта: FAILED и предупреждение, что 1 сумма не совпала, а код выхода команды 1 (его увидит CI, если такая проверка стоит в шаге). Когда ты проделаешь это у себя, суммы будут другими: у тебя другой набор файлов и коммитов, важно только, что две первых равны.

Объясни себе:

  • Что проверяет sha256sum -c и от чего это защищает, а от чего нет?
  • Зачем --prefix=notes-demo/?

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

  • fatal: not a valid object name: HEAD: пустой репозиторий без коммитов (git archive HEAD нечего брать) или ты не в каталоге репозитория (тогда перед этим будет fatal: not a git repository (or any of the parent directories): .git).
  • sha256sum: notes-demo.tar.gz: No such file or directory, дальше notes-demo.tar.gz: FAILED open or read: запускаешь -c не из каталога, где лежит архив; имя файла в .sha256 относительное.
  • tar (child): notes-demo.tar.gz: Cannot open: No such file or directory: неверный путь или архив уже удалён шагом 6.

Задание 3. Шаг проекта: release.yml и релиз v0.2.0

Цель: по тегу v0.2.0 GitHub сам собирает notes-0.2.0.tar.gz, сумму и создаёт Release.

Предскажи: запустится ли release.yml при обычном git push в ветку? При пуше тега v0.2.0? При пуше тега 0.2.0?

Ответ

Push в ветку не запустит (триггер только теги). Тег v0.2.0 запустит. Тег 0.2.0 не запустит: шаблон v* требует начальную v.

Шаги:

  1. Создай ветку и каталог для workflow. git switch -c создаёт ветку и сразу переходит на неё, mkdir -p создаёт каталог и не ругается, если он уже есть:
cd ~/notes
git switch main && git pull --ff-only
git switch -c release-workflow
mkdir -p .github/workflows
  1. Запиши .github/workflows/release.yml. Файл разобран построчно в комментариях, а после блока идёт общий разбор:
name: release

# Запуск только по тегам вида v0.2.0
on:
  push:
    tags: ['v*']

# По умолчанию токен только читает
permissions:
  contents: read

jobs:
  release:
    runs-on: ubuntu-24.04
    # Запись нужна только этому job: создать Release
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v7.0.1

      - name: Собрать архив и контрольную сумму
        env:
          TAG: ${{ github.ref_name }}
        run: |
          set -euo pipefail
          VERSION="${TAG#v}"
          # Только файлы из коммита, без мусора рабочего каталога
          git archive --format=tar.gz --prefix="notes-${VERSION}/" \
            -o "notes-${VERSION}.tar.gz" HEAD
          sha256sum "notes-${VERSION}.tar.gz" > "notes-${VERSION}.tar.gz.sha256"
          ls -l notes-*

      - name: Создать GitHub Release
        env:
          TAG: ${{ github.ref_name }}
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          # Суффикс после дефиса (rc, beta) означает пре-релиз
          FLAGS=""
          case "$TAG" in *-*) FLAGS="--prerelease" ;; esac
          gh release create "$TAG" notes-*.tar.gz notes-*.tar.gz.sha256 \
            --title "notes ${TAG}" --generate-notes --verify-tag $FLAGS

Разбор файла.

  • name: release название, которое увидишь во вкладке Actions.
  • on: push: tags: ['v*'] триггер из теории. Кавычки вокруг v* нужны, потому что в YAML звёздочка может означать особую конструкцию, а в кавычках это просто текст. Квадратные скобки это список: можно перечислить несколько шаблонов.
  • Верхний permissions: contents: read права по умолчанию для всех job, а внутри job permissions: contents: write поднимает их только для него.
  • runs-on: ubuntu-24.04 тип раннера, той виртуальной машины.
  • actions/checkout@v7.0.1 готовый шаг, который скачивает код репозитория (по тегу это код тегнутого коммита). Версия закреплена, как в уроке 3.3.
  • env: задаёт переменные окружения шага. Строка с двойными фигурными скобками и github.ref_name это выражение GitHub: «имя ветки или тега, который вызвал запуск». Для тега v0.2.0 в TAG окажется v0.2.0. github.token это временный токен запуска, а GH_TOKEN то имя переменной, в которой программа gh ищет токен.
  • run: | начинает многострочный скрипт (вертикальная черта значит «дальше идёт текст как есть, построчно»).
  • set -euo pipefail три защиты bash: -e остановиться при первой ошибке, -u считать ошибкой обращение к несуществующей переменной, -o pipefail считать конвейер a | b неудачным, если упала любая его часть.
  • VERSION="${TAG#v}" берёт значение TAG и отрезает начальную v: из v0.2.0 получится 0.2.0.
  • \ в конце строки значит «команда продолжается на следующей». ${VERSION} в фигурных скобках позволяет вплотную приписать текст (notes-${VERSION}.tar.gz).
  • sha256sum ... > ....sha256 пишет сумму в файл, ls -l notes-* печатает получившиеся файлы в лог, чтобы их можно было посмотреть глазами.
  • case "$TAG" in *-*) проверяет, есть ли в имени тега дефис (шаблон *-* значит «что-то, дефис, что-то»); если есть, в FLAGS кладётся --prerelease. Переменная $FLAGS без кавычек написана намеренно: если она пустая, gh не получит лишнего аргумента.
  • gh release create "$TAG" файл1 файл2 ... создаёт релиз для тега и прикрепляет файлы. notes-*.tar.gz оболочка раскроет в имя архива, notes-*.tar.gz.sha256 в имя суммы. --title заголовок, --generate-notes описание из заголовков слитых Pull Request, --verify-tag отказ, если тега нет.
  1. Проверь синтаксис файла локально. Установи actionlint (статический проверяющий для workflow-файлов) по странице проекта и запусти в корне репозитория. Если не хочешь ставить, пропусти шаг: GitHub всё равно покажет ошибку YAML на вкладке Actions. Без замечаний команда ничего не печатает и возвращает 0:
actionlint

Что он умеет ловить, показали три намеренно испорченные копии нашего файла:

bad1.yml:4:5: unexpected key "tag" for "push" section. expected one of "branches", "branches-ignore", "paths", "paths-ignore", "tags", "tags-ignore", "types", "workflows" [syntax-check]
bad2.yml:9:14: label "ubuntu-24.4" is unknown. available labels are "windows-latest", ... "ubuntu-24.04", ... [runner-label]
bad2.yml:11:17: "writ" is invalid as permission of scope "contents". available values are "read", "write", "none" [permissions]

Первая строка: вместо tags написали tag; вторая: опечатка в имени раннера; третья: неверное значение права. В скобках в конце тип проверки. Формат везде «файл:строка:столбец: сообщение».

Отправь через Pull Request, как в уроке 3.2. git add, git commit -m и git push -u origin ты знаешь. gh pr create --fill создаёт PR, заполнив заголовок из коммита. gh pr checks --watch ждёт зелёных проверок. gh pr merge --squash --delete-branch вливает со squash и удаляет ветку:

git add .github/workflows/release.yml
git commit -m "Add release workflow"
git push -u origin release-workflow
gh pr create --fill
gh pr checks --watch
gh pr merge --squash --delete-branch
  1. После слияния поставь аннотированный тег на актуальную main и отправь его. Порядок именно такой: workflow уже в main, поэтому он будет и в тегнутом коммите:
git switch main && git pull --ff-only
git tag -a v0.2.0 -m "Release 0.2.0: CI, security checks, release workflow"
git push origin v0.2.0
  1. Дождись workflow и проверь релиз. gh run list показывает последние запуски, gh run watch <номер> следит за выбранным до конца (номер берётся из первого столбца), gh release view показывает релиз. С --json мы просим только нужные поля, а --jq печатает их построчно: имя тега, признак пре-релиза и имена прикреплённых файлов:
gh run list --workflow release.yml --limit 1
RUN_ID=$(gh run list --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId')
gh run watch "$RUN_ID"
gh release view v0.2.0 --json tagName,isPrerelease,assets --jq '.tagName, .isPrerelease, (.assets[].name)'
  1. Скачай артефакт и проверь сумму. gh release download v0.2.0 скачивает все файлы релиза в текущую папку, -R владелец/репозиторий указывает репозиторий (нужен, потому что мы вне папки проекта), а подстановка $(gh repo view --json nameWithOwner -q .nameWithOwner) узнаёт это имя у текущего репозитория. head -5 показывает первые пять строк:
mkdir -p ~/dl && cd ~/dl
gh release download v0.2.0 -R "$(gh repo view --json nameWithOwner -q .nameWithOwner)"
sha256sum -c notes-0.2.0.tar.gz.sha256
tar tzf notes-0.2.0.tar.gz | head -5
cd ~ && rm -rf ~/dl

Что должно получиться. Сама GitHub-часть (запуск workflow, gh release view, gh release download) в подготовке урока не прогонялась: стенда с GitHub не было. Ниже вывод разделён на то, что проверено, и то, что взято из документации.

Не проверялось на GitHub, ожидаемая форма:

v0.2.0
false
notes-0.2.0.tar.gz
notes-0.2.0.tar.gz.sha256

Проверено на стенде: те же команды сборки, что в шаге workflow, на клоне тега v0.2.0 (клон сделан с локального голого репозитория, который играл роль GitHub). Это то, что напечатает ls -l notes-* в логе шага и что ты увидишь на своей машине после скачивания:

-rw-r--r-- 1 ubuntu ubuntu 6870 Sep 30 12:11 notes-0.2.0.tar.gz
-rw-r--r-- 1 ubuntu ubuntu   85 Sep 30 12:11 notes-0.2.0.tar.gz.sha256
notes-0.2.0.tar.gz: OK
notes-0.2.0/
notes-0.2.0/.github/
notes-0.2.0/.github/dependabot.yml
notes-0.2.0/.github/workflows/
notes-0.2.0/.github/workflows/ci.yml

Как читать вывод: первые четыре строки: v0.2.0 тег, false значит «не пре-релиз», дальше два прикреплённых файла. Размер .sha256 85 байт: 64 знака суммы, два пробела, 19 знаков имени и перевод строки. OK подтверждает, что скачанный архив совпадает с суммой. Список из архива показывает, что внутри одна папка notes-0.2.0/ и в ней только файлы репозитория (в архиве всего 26 записей, у тебя число будет своё: оно зависит от файлов проекта). Пять строк это только начало списка.

Состояние проекта после урока: в main есть release.yml, на GitHub есть Release v0.2.0 с двумя файлами, app.py остался версией v3. Деплой пока делается руками, это закроется в теме 6.

Объясни себе:

  • Почему contents: write стоит на job, а не на всём workflow?
  • Зачем нужен флаг --verify-tag и что защищает env: вместо прямой подстановки тега в run:?
  • Кто может запушить тег v0.2.0 и почему это стоит ограничить (Settings, Rules, тег-правила)?

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

  • remote: error: GH013: Repository rule violations found при git push origin v0.2.0: на теги действует правило репозитория; проверь Settings, Rules.
  • HTTP 403: Resource not accessible by integration (https://api.github.com/repos/.../releases): у job нет contents: write. Добавь permissions: на job.
  • HTTP 422: Validation Failed ... already_exists: релиз для этого тега уже создан. Удалять тег не нужно, выпусти следующую версию.
  • ! [rejected] v0.2.0 -> v0.2.0 (already exists) при повторном git push origin v0.2.0: такой тег уже на сервере. Не двигай его, выпусти v0.2.1.
  • Workflow не запустился: тег не отправлен (git ls-remote --tags origin) или workflow-файла нет в коммите, на который указывает тег (см. сценарий 2 ниже).
  • gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable в логе шага: забыт GH_TOKEN в env: (по документации gh, в этой формулировке не проверялось).

Перед отправкой тега v0.2.0 в публичный репозиторий проверь release.yml сам: тег запускает публикацию сразу, и отменить её незаметно нельзя.

Задание 4. Песочница: откат и hotfix без GitHub

Цель: отработать «откат» и «hotfix от тега» на маленьком репозитории, где ошибки ничего не стоят. GitHub для этого не нужен: роль сервера сыграет голый репозиторий (он хранит только историю, без рабочих файлов, как в уроке 3.2).

Предскажи: после git revert версии v1.1.0 изменится ли тег v1.1.0? Сколько будет тегов и коммитов после отката?

Ответ

Тег v1.1.0 не изменится: он по-прежнему указывает на «плохой» коммит. Появится новый коммит-откат и новый тег v1.1.1. Тегов станет три (v1.0.0, v1.1.0, v1.1.1), коммитов тоже три.

Шаги:

  1. Создай «сервер» и клон, выпусти v1.0.0 и «сломанный» v1.1.0. git init --bare -b main создаёт голый репозиторий с веткой main, git clone делает рабочую копию, git commit -am коммитит изменения отслеживаемых файлов:
mkdir -p ~/sandbox && cd ~/sandbox
git init -q --bare -b main rel-origin.git
git clone -q rel-origin.git rel && cd rel
printf 'login: работает\nсписок заметок: работает\n' > status.txt
git add status.txt && git commit -q -m "feat: первая версия"
git tag -a v1.0.0 -m "Release 1.0.0"
git push -q --follow-tags origin main
printf 'login: СЛОМАН\nсписок заметок: работает\n' > status.txt
git commit -q -am "feat: новая форма входа"
git tag -a v1.1.0 -m "Release 1.1.0"
git push -q --follow-tags origin main
  1. Откати: git revert --no-edit HEAD отменяет последний коммит новым, затем тег v1.1.1:
git revert --no-edit HEAD
git tag -a v1.1.1 -m "Release 1.1.1: откат новой формы входа (v1.1.0 отозван)"
git push --follow-tags origin main
git log --oneline --decorate
cat status.txt
  1. main уехала вперёд, а клиенту на v1.0.0 нужно только исправление. Сначала сделай в main исправление и новую функцию. $(git rev-parse --short HEAD) запоминает короткий хеш исправления в переменную FIX:
echo 'исправление: пустая заметка не роняет сервис' > fix.txt
git add fix.txt && git commit -q -m "fix: пустая заметка не роняет сервис"
FIX=$(git rev-parse --short HEAD)
echo 'поиск: работает' >> status.txt
git commit -q -am "feat: поиск по заметкам"
git tag -a v1.2.0 -m "Release 1.2.0"
git push -q --follow-tags origin main
  1. Сделай hotfix от старого тега: ветка release/1.0 от v1.0.0, перенос исправления, тег v1.0.1:
git switch -c release/1.0 v1.0.0
git cherry-pick "$FIX"
git tag -a v1.0.1 -m "Release 1.0.1: исправление пустой заметки"
git push -u origin release/1.0 v1.0.1
git log --oneline --decorate --graph --all
git log --oneline v1.0.0..v1.0.1

Что должно получиться:

[main 5e5bfe7] Revert "feat: новая форма входа"
 Date: Wed Sep 30 12:11:46 2026 +0000
 1 file changed, 1 insertion(+), 1 deletion(-)
To /home/ubuntu/sandbox/rel-origin.git
   02e61cb..5e5bfe7  main -> main
 * [new tag]         v1.1.1 -> v1.1.1
5e5bfe7 (HEAD -> main, tag: v1.1.1, origin/main) Revert "feat: новая форма входа"
02e61cb (tag: v1.1.0) feat: новая форма входа
3c6e70b (tag: v1.0.0) feat: первая версия
login: работает
список заметок: работает
Switched to a new branch 'release/1.0'
[release/1.0 04d541c] fix: пустая заметка не роняет сервис
 Date: Wed Sep 30 12:11:46 2026 +0000
 1 file changed, 1 insertion(+)
 create mode 100644 fix.txt
To /home/ubuntu/sandbox/rel-origin.git
 * [new branch]      release/1.0 -> release/1.0
 * [new tag]         v1.0.1 -> v1.0.1
branch 'release/1.0' set up to track 'origin/release/1.0'.
* 618ea9d (tag: v1.2.0, origin/main, main) feat: поиск по заметкам
* 6708f8c fix: пустая заметка не роняет сервис
* 5e5bfe7 (tag: v1.1.1) Revert "feat: новая форма входа"
* 02e61cb (tag: v1.1.0) feat: новая форма входа
| * 04d541c (HEAD -> release/1.0, tag: v1.0.1, origin/release/1.0) fix: пустая заметка не роняет сервис
|/
* 3c6e70b (tag: v1.0.0) feat: первая версия
04d541c fix: пустая заметка не роняет сервис

Как читать вывод: блок [main 5e5bfe7] Revert ... это новый коммит-откат, 1 insertion(+), 1 deletion(-) показывает, что он вернул одну строку. Путь To /home/ubuntu/... это твой «сервер», у тебя путь будет своим. В журнале после отката видны все три версии, v1.1.0 осталась на сломанном коммите, а status.txt снова с работающим входом. На графе слева от звёздочек линии показывают ветвление: главная линия это main, а отдельный коммит справа 04d541c это release/1.0, отросток от v1.0.0. Хеши 6708f8c и 04d541c разные, хотя правка одна и та же. Последняя команда git log v1.0.0..v1.0.1 это changelog hotfix-релиза: один коммит.

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

  • error: could not apply 6708f8c... fix: ... при cherry-pick: конфликт, правка затрагивает те же строки, что и старая версия. Открой помеченные файлы, поправь, затем git add и git cherry-pick --continue.
  • fatal: 'v1.0.0' is not a commit and a branch 'release/1.0' cannot be created from it: тег не аннотированный и не указывает на коммит, проверь git tag -l (у нас такого не будет: тег поставлен на коммит).
  • error: pathspec 'v1.0.0' did not match any file(s) known to git: тега нет в этом клоне: git fetch --tags.

Убери песочницу: rm -rf ~/sandbox.

Сломай и почини

Три сценария на GitHub-репозитории notes (разбор ниже, сначала попробуй сам). Все делаются в отдельной ветке или временными тегами v0.9.x, чтобы не трогать v0.2.0. Готового скрипта нет, поломки ты воспроизводишь сам: они связаны с GitHub, а не с системой, поэтому скрипт на твоей машине их не смог бы сделать. После упражнения удали тестовые релизы и теги: gh release delete v0.9.0 --cleanup-tag --yes (флаг --cleanup-tag удаляет и тег, --yes пропускает вопрос).

Симптом

Выбери один сценарий, воспроизведи и опиши симптом своими словами:

  1. Релиз v0.9.0 собрался, но в архиве нет свежего исправления, которое лежит в main.
  2. Ты запушил тег v0.9.1, а во вкладке Actions пусто.
  3. Архив релиза весит в десять раз больше ожидаемого и внутри лежат .env и .venv.

Как воспроизвести: (1) поставь тег на старый коммит: git tag -a v0.9.0 -m x HEAD~2 и отправь его; (2) замени в release.yml ['v*'] на ['release-*'], влей в main через PR и запушь тег v0.9.1; (3) создай .env и папку .venv с любыми файлами (они в .gitignore), замени в workflow git archive на tar czf "notes-${VERSION}.tar.gz" . (.env и .venv лежат на диске раннера только если их создал шаг, поэтому для честной проверки собери такой архив локально) и запушь тег v0.9.2.

Гипотезы

  • Сценарий 1: тег указывает не на тот коммит (поставили до слияния исправления или не сделали git pull); в CI собрана не та ревизия; кэш.
  • Сценарий 2: шаблон не совпадает с именем тега; workflow отсутствует в коммите тега; Actions отключены; тег не отправлен.
  • Сценарий 3: сборка идёт из рабочего каталога, а не из коммита; .gitignore не влияет на tar.

Проверки

# 1: на какой коммит указывает тег и что в main
git rev-parse v0.9.0^{commit}
git rev-parse main
git log --oneline v0.9.0..main

# 2: дошёл ли тег до GitHub и какой шаблон в файле коммита тега
git ls-remote --tags origin
git show v0.9.1:.github/workflows/release.yml | sed -n 1,8p

# 3: что реально в архиве
tar tzf notes-0.9.2.tar.gz | grep -E '\.env|\.venv' | head

Что покажут проверки (прогонялось на стенде с локальным голым репозиторием, вместо GitHub):

# сценарий 1
6debb7c3610483377ba9d251a4e6370802e6a7d0
c1e8673491aa03fea6f9f489dd926435ba178f13
c1e8673 Add release workflow (#5)
6139850 ci: dependabot для actions и pip (#4)

# сценарий 2 (для тега на коммите с испорченным шаблоном)
name: release
on:
  push:
    tags: ['release-*']
permissions:
  contents: read

# сценарий 3, архив собран из папки
./.venv/
./.venv/bin/
./.venv/bin/big
./.env

Как читать: в сценарии 1 первая строка это коммит, на который указывает тег, вторая коммит main. Они разные, а git log v0.9.0..main перечисляет пропущенные коммиты: два коммита не вошли в релиз. В сценарии 2 выводится начало файла из коммита тега: видно, что шаблон release-*, а тег называется v0.9.1, значит не совпадёт. В сценарии 3 в списке архива есть .env и .venv: значит архив собирали из папки. Проверь и размер: ls -l notes-0.9.2.tar.gz. На стенде «грязный» архив был 207 343 байта против 6 870 у собранного через git archive.

Исправление

Разбор трёх сценариев

1. Тег на неверном коммите. git log v0.9.0..main показывает пропущенные коммиты. Тег опубликован, поэтому не двигаем его, а выпускаем v0.9.1 на правильный коммит: git tag -a v0.9.1 -m "fix" main && git push origin v0.9.1. Старый релиз помечаем в описании как отозванный (gh release edit v0.9.0 --notes "Отозван, используй v0.9.1"; --notes задаёт новый текст описания). Профилактика: тегировать только после git pull --ff-only на main и смотреть git log -1 перед git tag. Про кэш из гипотез: git archive берёт коммит, а не кэш, поэтому в нашем workflow кэш причиной быть не может.

2. Workflow не запустился. Триггер по тегу берёт файл workflow из коммита, на который указывает тег. Если шаблон release-* не совпал с v0.9.1, событий нет. Исправь шаблон на ['v*'], влей в main, а тег для новой попытки создай заново с новой версией v0.9.2 (тег v0.9.1 не двигаем). Ещё проверь Settings, Actions, что Actions включены. Чтобы прогнать workflow без нового тега, добавляют триггер workflow_dispatch (ручной запуск из интерфейса), но тогда имя тега нужно передавать входом.

3. Релиз из грязного дерева. tar czf . упаковывает всё, что лежит на диске раннера или машины: .env, .venv, __pycache__. В публичном репозитории с секретом это уже утечка: секрет считаем скомпрометированным, ротируем (заменяем на новый, порядок из урока 3.1), релиз удаляем (gh release delete v0.9.2 --cleanup-tag) и выпускаем исправленный. Возвращаем git archive, который берёт только содержимое коммита.

ИИ в помощь

Нейросеть быстро набросает changelog и объяснит схему версий, но твоей истории коммитов не видит: вывод git log ты вставляешь сам, без секретов. Общие правила: ИИ-помощник.

Задача: собрать черновик changelog по списку коммитов.

Вот вывод git log --oneline v0.1.0..HEAD:
<вставь список коммитов>
Сгруппируй изменения по типам (новое, исправления, прочее) и напиши черновик описания релиза для людей, которые не читали коммиты.
Не добавляй ничего, чего нет в списке.

Проверь ответ: сверь каждую строку changelog со списком коммитов. Типичная ошибка нейросетей: добавить красивую, но выдуманную строку («улучшена производительность») или потерять коммит, который ломает совместимость.

Задача: выбрать следующий номер версии по списку изменений.

Текущая версия 1.9.3. Изменения с прошлого релиза:
<вставь список изменений>
Скажи, какой должна быть следующая версия по semver (MAJOR.MINOR.PATCH), и объясни, какое именно изменение определило выбор.
Отдельно перечисли изменения, которые могут ломать совместимость.

Проверь ответ: проверь вывод по разделу про semver: ломающее изменение даёт MAJOR, новая совместимая возможность MINOR, исправление PATCH. Типичная ошибка: нейросеть повышает только PATCH, не заметив удалённое поле или изменённое поведение.

Задача: проверить release.yml на ошибки.

Вот мой workflow выпуска релиза:
<вставь release.yml>
Проверь: событие по тегу, права токена, как значение тега попадает в скрипт (нет ли подстановки в run), сборка архива через git archive, контрольная сумма.
Для каждой проблемы покажи исправленную строку.

Проверь ответ: прогони файл через actionlint и проверь каждое замечание по соответствующему разделу. Типичная ошибка: нейросеть советует собирать архив через tar из папки или пропускает подстановку значения тега прямо в команду.

Словарик урока

Термин Простыми словами
Релиз (release) Именованная, неизменяемая версия проекта: тег, файлы и описание изменений
Коммит и хеш Снимок проекта и его 40-значный идентификатор (обычно показывают первые 7 знаков)
Ветка (branch) Имя-указатель на коммит, двигается вместе с новыми коммитами
Тег (tag) Имя-указатель на коммит, которое не двигается
Лёгкий тег Только имя на коммит, без автора, даты и сообщения
Аннотированный тег Отдельный объект git с автором, временем и сообщением; его используют для релизов
HEAD Текущий коммит, на котором ты стоишь
Прод (production) Боевой сервер, на котором приложение работает для настоящих пользователей
Выкатить / откатить Установить новую версию на боевой сервер / вернуть предыдущую
gh Консольная программа GitHub (GitHub CLI): создаёт релизы и PR из терминала
Semver Договорённость называть версии MAJOR.MINOR.PATCH по смыслу изменений
MAJOR / MINOR / PATCH Несовместимое изменение / новая совместимая возможность / исправление ошибки
Пре-релиз (-rc.1) Предварительная версия перед настоящей, суффикс через дефис
Диапазон A..B Коммиты, которые есть в B, но которых нет в A
Changelog Список изменений между версиями
Артефакт (artifact) Готовый файл, который скачивают и запускают: архив, бинарник, образ
.tar.gz Много файлов, склеенных tar в один и сжатых gzip
git archive Команда, собирающая архив из коммита, а не из папки на диске
Воспроизводимая сборка Из одного и того же коммита каждый раз получается один и тот же результат
Контрольная сумма (checksum), SHA-256 «Отпечаток пальца» файла: 64 шестнадцатеричных знака, меняются при любом изменении файла
GitHub Release Страница на GitHub с описанием, тегом и прикреплёнными файлами
Workflow, job, runner Файл автоматизации / группа шагов / виртуальная машина, на которой они выполняются
Триггер (on:) Событие, по которому запускается workflow
Glob (v*) Шаблон имён, где * значит «любые символы»
Токен и permissions: Временный пароль запуска и список того, что он разрешает
env: и переменная окружения Именованное значение, доступное программе; безопасный способ передать данные в скрипт
Script injection Атака, когда чужой текст (например, имя тега) выполняется как команда
git revert Создаёт новый коммит, отменяющий чужое изменение, не переписывая историю
cherry-pick Копирует один коммит в другую ветку
Hotfix Срочное исправление для уже выпущенной версии
Rulesets, environment Правила репозитория (кто может создавать теги) / защищённое окружение с ручным подтверждением

Вопросы с собеседований

Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.

1. [junior] [часто] [на скорость] Чем тег в git отличается от ветки?

Ответ

Ветка - подвижный указатель: с каждым новым коммитом она сдвигается. Тег - имя на конкретный коммит, оно остаётся на месте, поэтому подходит для релизов. Бывают лёгкие теги и аннотированные: git tag -a v1.2.0 -m "Релиз 1.2.0" хранит автора, дату и сообщение. Для релизов беру аннотированные. На сервер теги не уходят с обычным push, отправляю явно: git push origin v1.2.0.

Что хотят услышать: ветка двигается, тег фиксирован, аннотированный и лёгкий тег, отдельная отправка тега, тег как точка релиза.

Красный флаг: «Тег это такая же ветка»; переставляет уже опубликованный тег на другой коммит.

2. [junior] [часто] Ты выпустил релиз v1.4.0, через час выяснилось, что он ломает логин. Что делаешь?

Ответ

Сначала возвращаю работающее: раскатываю предыдущую версию v1.3.2, это самое быстрое. Потом делаю git revert ломающего коммита и выпускаю v1.4.1. Тег v1.4.0 не удаляю и не двигаю, в описании релиза помечаю, что он отозван. После этого разбор причины и тест, который поймал бы ошибку.

Что хотят услышать: порядок «восстановить сервис, потом чинить причину»; новый тег вместо переписывания старого; revert вместо reset --hard и force-push; постмортем (разбор причин после инцидента).

Красный флаг: «удалю тег и поставлю заново» или «сделаю force-push в main».

3. [junior] [часто] [на скорость] Что означает версия 2.7.3 и когда какое число растёт?

Ответ

2 совместимость: растёт при несовместимых изменениях API. 7 растёт при новых совместимых возможностях. 3 растёт при исправлениях без изменения поведения. При росте старшего числа младшие обнуляются: после 2.7.3 с новой фичей будет 2.8.0.

Что хотят услышать: обнуление младших, пре-релизы (-rc.1), нестабильность 0.x, что semver это договор с потребителем, а не объём работы.

Красный флаг: «мажор это когда много работы».

4. [junior] Тег запушили, а релиз не создался, во вкладке Actions пусто. Куда смотришь?

Ответ

Проверяю, что тег дошёл на GitHub: git ls-remote --tags origin. Смотрю шаблон on: push: tags: в файле workflow в том коммите, на который указывает тег: git show <тег>:.github/workflows/release.yml. Проверяю, что Actions включены и что workflow не отключён. Если тег ставили через веб или API, смотрю, не сработало ли правило репозитория.

Что хотят услышать: workflow берётся из коммита тега; шаблон v* не совпадает с 0.2.0; тег и ветка это разные события.

Красный флаг: «перезапущу пайплайн» без проверки того, запускался ли он вообще.

5. [middle] Тег v2.0.0 поставили на неверный коммит, но релиз уже скачали. Как исправляешь?

Ответ

Тег не двигаю: у скачавших версия уже означает конкретный код. Ставлю правильный тег v2.0.1 на нужный коммит, релиз v2.0.0 помечаю отозванным и пишу почему. Если релиз никто не мог использовать и команда договорилась, допустимо удалить и пересоздать, но это исключение, и о нём нужно объявить.

Что хотят услышать: неизменяемость опубликованных версий, сообщение потребителям, разница между «исправить локально» и «исправить у всех» (сервер вообще отклонит перенос тега, пока его не удалить).

Красный флаг: git push --force origin v2.0.0 как рутинное решение.

6. [middle] Как связать версию на проде с коммитом и с тем, что менялось?

Ответ

По тегу: версия v1.4.1 указывает на коммит, APP_VERSION и тег образа (образ, это упакованное приложение для контейнера, тема 4) совпадают с тегом без v. Образ можно закрепить по digest (неизменяемый идентификатор содержимого образа). Список изменений получаю через git log v1.4.0..v1.4.1. В приложении версия видна на эндпоинте или в метриках, так проще сопоставить с логами. Если версию не записали, git describe --tags на коммите покажет ближайший тег и число коммитов после него.

Что хотят услышать: единая версия в git, образе и приложении; неизменяемый артефакт; latest не используется.

Красный флаг: «на проде что-то из main».

7. [middle] В артефакте релиза оказался .env с паролем. Действия?

Ответ

Считаю пароль скомпрометированным: ротирую (заменяю на новый) немедленно, до чистки. Удаляю релиз и тег, проверяю логи доступа, кто скачивал. Причину исправляю: сборка из коммита через git archive, а не из рабочего каталога, плюс сканер секретов в CI. Выпускаю новую версию.

Что хотят услышать: ротация важнее удаления; артефакт публичен, значит утечка состоялась; профилактика сканером.

Красный флаг: «удалю файл из релиза, и достаточно».

8. [middle] Как ограничить, кто может выпускать релизы?

Ответ

Правила репозитория (rulesets) для тегов v*: создавать могут только выбранные роли. Для деплоя добавляю environment (окружение с защитой) с обязательным подтверждением. Токену в workflow даю contents: write только в job релиза. main защищена, тегируют только её актуальное состояние.

Что хотят услышать: защита тегов, environments и подтверждения, минимальные права токена, аудит (кто и когда выпустил).

Красный флаг: «у всех разработчиков права admin, так проще».

9. [middle] Нужен hotfix для версии 1.3, а main уже уехала к 1.5. Как поступаешь?

Ответ

Создаю ветку от тега v1.3.2, переношу исправление (cherry-pick коммита из main, копирование одного коммита в другую ветку), выпускаю v1.3.3 с этой ветки. Затем убеждаюсь, что исправление есть и в main. Держать долгоживущие релизные ветки стоит, только если реально поддерживаешь старые версии.

Что хотят услышать: ветка от тега, cherry-pick, версия патча, нет переписывания истории, цена поддержки старых веток.

Красный флаг: «выпущу 1.5 со всеми фичами, пусть обновляются».

10. [middle] Релиз собирается на CI из тега, но сумма архива меняется от запуска к запуску. Почему это плохо и что смотришь?

Ответ

Без воспроизводимости нельзя проверить, что артефакт соответствует коду. Причины: время в метаданных tar/gzip, порядок файлов, сборка из рабочего каталога, зависимости с плавающими версиями. Собираю через git archive из тега (он пишет время коммита, а не текущее), закрепляю версии зависимостей, публикую SHA-256 рядом. Если суммы всё же отличаются между машинами, сравниваю несжатый tar: результат сжатия зависит от версии gzip.

Что хотят услышать: воспроизводимая сборка, закреплённые версии, контрольные суммы, привязка к коммиту.

Красный флаг: «сумма неважна, архив ведь открывается».

11. [junior] Зачем в workflow релиза передавать имя тега через env, а не писать его прямо в run?

Ответ

Имя тега контролирует тот, кто его создал, а git разрешает в нём $, скобки и точку с запятой. Если подставить его в run: напрямую, оно превратится в часть shell-команды (v1$(whoami) выполнит whoami), и специально названный тег выполнит чужой код на раннере с токеном. Через переменную окружения значение остаётся данными.

Что хотят услышать: script injection, env:, минимальные права токена, закрепление действий по версии или SHA (хешу коммита действия).

Красный флаг: «тег это просто строка, ничего страшного».

12. [junior] [на скорость] Чем lightweight-тег отличается от аннотированного и какой использовать для релиза?

Ответ

Lightweight-тег (git tag v1.0.0) это просто имя, указывающее на коммит. Аннотированный (git tag -a v1.0.0 -m "Релиз 1.0.0") отдельный объект: хранит автора, дату и сообщение, его можно подписать (-s). Для релизов беру аннотированный, потому что видно, кто и когда выпустил. Теги обычным git push не отправляются: нужен git push origin v1.0.0 или git push --tags. git describe по умолчанию тоже опирается на аннотированные теги.

Что хотят услышать: два вида тегов, -a, тег отправляется отдельно.

Красный флаг: Думать, что git push сам отправит теги.

13. [middle] Что такое Conventional Commits и чем они помогают в релизах?

Ответ

Это договорённость о формате сообщений: feat: добавить экспорт, fix: поправить таймаут, feat!: сменить формат API или строка BREAKING CHANGE: в теле. По префиксам инструмент может сам вычислить следующую версию: fix даёт patch, feat даёт minor, ломающее изменение даёт major. Из тех же коммитов собирается changelog. Такое делают, например, release-please или semantic-release. Минус: нужна дисциплина, её проверяют линтером сообщений в CI.

Что хотят услышать: формат префиксов, связь с semver, автоматический changelog, проверка в CI.

Красный флаг: Сообщения «update», «fix», «ещё правки» и ручной подсчёт версии на глаз.

Проверено на версиях

  • Git 2.43.0 (Ubuntu 24.04) и 2.53.0 (Ubuntu 26.04): tag, archive, get-tar-commit-id, cat-file, revert, cherry-pick, диапазоны A..B, ls-remote, push --follow-tags; всё на локальном голом репозитории вместо GitHub
  • sha256sum из GNU coreutils 9.4 (24.04) и uutils coreutils 0.8.0 (26.04): вывод OK/FAILED совпал; GNU tar 1.35, gzip 1.12
  • Имитация шагов workflow на клоне тега v0.2.0 (сборка архива, сумма, tar tzf, проверка -c, выбор --prerelease по имени тега): выполнена локально
  • actionlint 1.7.12 (образ rhysd/actionlint, внутри shellcheck 0.11.0): release.yml без замечаний; ошибки из примеров воспроизведены на испорченных копиях
  • GitHub CLI (gh) 2.101.0: проверены только флаги по --help (--verify-tag, --generate-notes, --prerelease, --cleanup-tag, --json); команды против GitHub не запускались
  • Не прогонялось: запуск release.yml на GitHub, gh release view/download, gh run watch, rulesets и immutable releases; actions/checkout@v7.0.1 взят из урока 3.3
  • Раннер ubuntu-24.04, Python приложения «Заметок» 3.13 по проекту, app.py версии v3 (в этом уроке не запускался)

Итог урока: ты умеешь

  • умею выбрать MAJOR, MINOR или PATCH и объяснить выбор
  • умею поставить аннотированный тег, отправить его и не двигать после публикации
  • умею собрать артефакт из коммита через git archive и проверить SHA-256
  • умею написать workflow по тегу с минимальными правами и безопасной передачей имени тега
  • умею получить changelog командой git log <тег>..HEAD
  • умею откатить релиз через git revert и новый тег
  • умею выпустить hotfix для старой версии из ветки от тега
  • умею диагностировать, почему workflow не запустился по тегу

Дальше: Урок 3.6: GitLab CI и Jenkins

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 3 урок 3.5 3 ч курс 0/0 ← → уроки