open atlas
↑ К треку
CI/CD-пайплайны CICD · 04 · 02

Теги и публикация релизов из CI

Запушенный тег — это триггер, превращающий коммит в опубликованный релиз. Гейти workflow на тег вида v*.*.*, ограничивай токен через блок permissions и публикуй с provenance — чтобы утёкший PAT или публикация на каждый пуш никогда не отгрузились.

CICD Middle ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

В 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-задание аутентифицируется.

Вспомните перед уходом
  1. 01
    Почему `on: push: tags: ['v*.*.*']` делает публикацию безопасной там, где `on: push` опасен, и какая одна тег-триггерная ловушка может молча пропустить задание?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.