Теги и публикация релизов из CI
Запушенный тег — это триггер, превращающий коммит в опубликованный релиз. Гейти workflow на тег вида v*.*.*, ограничивай токен через блок permissions и публикуй с provenance — чтобы утёкший PAT или публикация на каждый пуш никогда не отгрузились.
В 16:00 загорается инцидент-канал: acme-sdk@2.4.0-rc.1 уже в npm — релиз-кандидат, который не проходил QA, теперь устанавливается у любого, кто запустил npm update. Никто не нажимал «опубликовать». Это сделал пайплайн. Кто-то написал on: push в начале release-workflow, поэтому каждый коммит в любую ветку запускал npm publish, и бамп версии в feature-ветке улетел прямиком в публичный реестр. Фикс — одна строка YAML, но команда ещё и хранила долгоживущий npm automation token обычным секретом, и этот токен, способный публиковать любой их пакет, только что отработал на коде, который никто не ревьюил.
Тег — это именованный неизменяемый указатель на один коммит
Прежде чем тянуться к npm publish в workflow — задай себе вопрос: что решает, когда происходит релиз? Ответом должен быть тег — осознанное, отревьюенное действие, а не «каждый пуш». Вот почему тег несёт всю нагрузку.
Git-тег — это метка, привязывающая человекочитаемое имя к одному конкретному коммиту. Тегов два вида, и разница важна для релизов. Lightweight-тег — это просто указатель: имя тега и хэш коммита, и больше ничего. Annotated-тег — это полноценный объект в базе Git: он несёт имя и email теггера, временную метку, сообщение и — главное — его можно подписать через GPG/SSH. Для релиза почти всегда нужен annotated: git tag -a v1.2.0 -m "Release 1.2.0". Он фиксирует, кто выпустил релиз и когда, он переживает git describe, и только его пробрасывает git push --follow-tags. Lightweight-теги — для черновых закладок, которые ты не собираешься публиковать.
Теги якорят релизы именно потому, что должны быть неизменяемыми: v1.2.0 обязан указывать ровно на те байты, что ты протестировал, навсегда. Как только ты позволяешь тегу плавать — пере-указываешь его на новый коммит или тегаешь движущийся кончик ветки — ты ломаешь обещание, что версия есть фиксированный артефакт. А GitHub Release затем навешивает на этот тег заметки и скачиваемые ассеты, давая потребителям человекочитаемый changelog и стабильное место для бинарников. Тегай конкретный отревьюенный зелёный коммит — никогда изменяемый ref.
Тег и есть триггер: on: push: tags
Вот механизм, связывающий тегирование с публикацией. GitHub Actions позволяет workflow подписаться на пуши тегов. Когда ты пушишь тег, попадающий под фильтр, workflow срабатывает — и только тогда. Именно это отделяет «каждый коммит собирается и тестируется» (твой обычный CI) от «релиз отгружается» (этот workflow). Глоб v*.*.* матчит v1.2.0, v2.0.1 и так далее; а branches: [] (или просто отсутствие branches) не даёт обычным коммитам его триггерить.
name: publish
on:
push:
tags:
- 'v*.*.*' # срабатывает только на запушенный тег версии, напр. v1.2.0
permissions: # least privilege: задание получает только то, что использует
contents: read # читать репозиторий для сборки
id-token: write # выпустить OIDC-токен для provenance
jobs:
publish-npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}Контраст с хуком — это весь урок. on: push (без фильтра) бежит на каждый пуш в любую ветку — так и сбежал релиз-кандидат. on: push: tags: ['v*.*.*'] бежит только когда мейнтейнер осознанно пушит тег версии, а это отревьюенное, намеренное действие. Тег — это решение человека; workflow — автоматизация, которая этому решению доверяет.
▸lesson.inset.warning
Тонкая ловушка: тег, созданный workflow через встроенный GITHUB_TOKEN, не триггерит событие push другого workflow — GitHub подавляет это, чтобы избежать бесконечных CI-циклов. Поэтому если инструмент вроде release-please или semantic-release нарезает твой тег внутри CI, твоё publish-задание на on: push: tags может молча так и не запуститься. Стандартные фиксы — триггерить на on: release: [published] либо пушить тег через PAT / GitHub App token вместо дефолтного GITHUB_TOKEN.
Аутентификация: токен наименьших привилегий, а не долгоживущий PAT
Второй провал хука — это credential. Release-workflow нужно чем-то аутентифицироваться в реестре, и выбор тянется от опасного до дисциплинированного.
Встроенный GITHUB_TOKEN — дефолт, к которому стоит тянуться: GitHub выпускает его заново на каждый запуск, он истекает с концом задания, и ты ограничиваешь его блоком permissions:. Правило — least privilege: дай только то, что задание использует. Для публикации образа контейнера в GitHub Container Registry (GHCR) это packages: write; для npm-provenance — id-token: write. Важно: GitHub Actions выставляет токену read-only contents, как только ты объявляешь любой блок permissions:, поэтому, написав блок, ты одновременно даёшь нужный скоуп и отзываешь всё остальное. Опусти блок — и токен может унаследовать широкий write-скоуп на весь репозиторий: ошибка «забыл permissions:», превращающая сборочный токен в обузу.
Для пуша в сторонний реестр (npm, Docker Hub, облачный провайдер) предпочитай OIDC (OpenID Connect — стандарт федеративной аутентификации на основе короткоживущих подписанных токенов): задание выпускает короткоживущий подписанный токен, trust-политика реестра проверяет, что он пришёл из твоего конкретного workflow, и ты обмениваешь его на эфемерные креды. Ничего долгоживущего не хранится. Худший вариант — собственно баг хука — это долгоживущий PAT или npm automation token, вставленный секретом: он не истекает, его надо ротировать, и один утёкший токен со скоупом «публиковать любой пакет» — это готовая supply-chain-брешь.
# Публикация образа контейнера в GHCR встроенным токеном
permissions:
contents: read
packages: write # единственный скоуп, нужный пушу в GHCR
jobs:
publish-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # эфемерный, на один запуск
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}Provenance: привяжи пакет к сборке, которая его сделала
Когда ты устанавливаешь пакет из npm, ты доверяешь, что байты в тарболе пришли из того репозитория, который он называет. Без provenance (подписанной аттестации сборки) украденного токена достаточно, чтобы опубликовать отравленный пакет, выглядящий легитимным. Вот как закрыть эту щель.
Капстоун — это provenance. Когда ты запускаешь npm publish --provenance на облачном раннере с id-token: write, npm использует OIDC-токен, чтобы сгенерировать подписанную аттестацию — через Sigstore, — связывающую опубликованный тарбол с конкретным исходным репозиторием, коммитом и инструкциями сборки, которые его произвели. Требования точны и их стоит запомнить: npm CLI 9.5.0+, право id-token: write и поддерживаемый облачный CI-раннер (например, ubuntu-latest в GitHub Actions). Аттестация публична и проверяема, поэтому потребитель может подтвердить, где и как пакет был собран, до установки — закрывая щель, через которую атакующий с украденным токеном публикует отравленную сборку, выглядящую легитимной.
| Измерение | Долгоживущий PAT / npm-токен секретом | GITHUB_TOKEN + permissions: / OIDC |
|---|---|---|
| Время жизни | Бессрочно, пока вручную не ротируешь | Выпуск на запуск, истекает с концом задания |
| Скоуп | Часто широкий — публиковать любой пакет | Ровно объявленный permissions: |
| Радиус поражения при утечке | Полный, пока не заметят и не отзовут | Один запуск; OIDC привязан к твоему workflow |
| Provenance | Нет — публиковать может любой с токеном | Подпись связывает тарбол с коммитом |
Релиз-кандидат опубликовался в npm из коммита feature-ветки, который никто не ревьюил. Workflow начинается с `on: push`. В чём корневая причина?
Твоё задание на теге запускает `npm publish --provenance`, но падает: 'provenance generation requires id-token: write'. Блока `permissions:` нет. Что происходит и каков фикс?
Твой CI публикует пакет в публичный реестр на релизе. Выбери, как publish-задание аутентифицируется.
- 01Почему `on: push: tags: ['v*.*.*']` делает публикацию безопасной там, где `on: push` опасен, и какая одна тег-триггерная ловушка может молча пропустить задание?
- 02Сравни долгоживущий npm-токен со встроенным GITHUB_TOKEN с блоком permissions: и OIDC-provenance, и объясни, что именно доказывает `--provenance`.
Git-тег привязывает человекочитаемое имя к одному коммиту; для релизов используй annotated, в идеале подписанный тег (git tag -a v1.2.0 -m ...), потому что он фиксирует, кто и когда выпустил релиз, и считай его неизменяемым — тегай конкретный отревьюенный зелёный коммит, никогда движущийся кончик ветки, и навесь на него GitHub Release с заметками и ассетами. Тег — это триггер: гейтинг release-workflow на on: push: tags: ['v*.*.*'] означает, что он срабатывает только когда мейнтейнер намеренно пушит тег версии, и это фикс классического провала, когда голый on: push отгружает релиз-кандидат из неотревьюенной ветки. Помни ловушку: тег, созданный внутри CI дефолтным GITHUB_TOKEN, не триггерит событие push другого workflow, поэтому тянись к on: release: [published] или PAT/App-токену, когда тег нарезает release-инструмент. Для аутентификации предпочитай эфемерный встроенный GITHUB_TOKEN, суженный блоком permissions: наименьших привилегий — packages: write для GHCR, id-token: write для provenance — долгоживущему PAT или npm-токену, чья единственная утечка это supply-chain-брешь; для сторонних реестров используй OIDC ради короткоживущих кредов. Наконец, npm publish --provenance с облачного раннера с id-token: write (npm 9.5.0+) подписывает Sigstore-аттестацию, связывающую пакет с точным коммитом и сборкой, чтобы потребители могли проверить, где и как он собран. Теперь, когда встретишь release-workflow на голом on: push — ты будешь знать, почему релиз-кандидат может улететь в публичный реестр раньше, чем кто-то его отревьюит.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.