Автоматическое версионирование через GitHub Actions

Разбираем reusable workflow setup-version.yml: как он считает версию из package.json, добавляет branch/build suffix, передает full_version дальше и подключается в деплой-пайплайнах GitHub Actions.

Автоматическое версионирование через GitHub Actions

Версия приложения часто кажется мелочью, пока она не начинает мешать деплою.

Вручную поменять package.json, не забыть tag, прокинуть версию в .env, собрать проект, отправить артефакт в GitHub Pages - все это нормально работает один раз. На пятом проекте уже хочется вынести версионирование в одно место и перестать копировать один и тот же YAML.

В этой статье разберем reusable workflow:

jenesei-software/.github/.github/workflows/setup-version.yml

Он живет в общем репозитории jenesei-software/.github и используется другими workflow как маленький сервис: на вход получает тип bump-а, имя ветки и пару флагов, а на выход отдает готовую строку версии.

setup-version.yml в общем репозитории
setup-version.yml в общем репозитории

Источник: setup-version.yml в GitHub.

Полный код setup-version.yml

Ниже полный workflow, который отвечает за расчет версии. Дальше в статье разберем его по частям.

name: setup-version

on:
  workflow_call:
    inputs:
      # Version type to bump: major, minor, patch, or none (empty)
      version_type:
        required: false
        type: string
      # Branch name, defaults to github.ref_name if not provided
      branch_name:
        required: false
        type: string
      # Whether to add branch name to the version string (default: true)
      add_branch_name:
        required: false
        type: boolean
        default: false
      # Whether to add build number to the version string (default: true)
      add_build_number:
        required: false
        type: boolean
        default: false
    secrets:
      # GitHub access token required for git commands
      ACCESS_GITHUB_TOKEN:
        required: true
    outputs:
      # Full version string including build and branch or "tag not changed"
      full_version:
        description: "Full version including build or 'tag not changed'"
        value: ${{ jobs.setup-version.outputs.full_version }}
      # Flag indicating if the tag was changed (true/false)
      tag_changed:
        description: "Whether the tag was changed or not"
        value: ${{ jobs.setup-version.outputs.tag_changed }}

jobs:
  setup-version:
    runs-on: ubuntu-latest
    outputs:
      # Pass outputs from the 'define' step
      full_version: ${{ steps.define.outputs.full_version }}
      tag_changed: ${{ steps.define.outputs.tag_changed }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        # Checkout repository to access files and git history

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22.x"
        # Setup Node.js environment to run node commands

      - name: Fetch all tags
        run: git fetch --tags
        # Fetch all git tags from remote to check existing versions

      - name: Define version type and bump version
        id: define
        run: |
          # Log input parameters for debugging
          echo "Version type input: ${{ inputs.version_type }}"
          echo "Add branch name: ${{ inputs.add_branch_name }}"
          echo "Add build number: ${{ inputs.add_build_number }}"

          # Determine version bump type (major, minor, patch or none)
          if [[ -n "${{ inputs.version_type }}" ]]; then
            VERSION_TYPE="${{ inputs.version_type }}"
          else
            VERSION_TYPE="none"
          fi
          echo "VERSION_TYPE=$VERSION_TYPE" >> $GITHUB_ENV

          # Get branch name either from input or from github context
          BRANCH_NAME="${{ inputs.branch_name || github.ref_name }}"
          ADD_BRANCH_NAME="${{ inputs.add_branch_name }}"
          ADD_BUILD_NUMBER="${{ inputs.add_build_number }}"

          # Read current version from package.json and remove any pre-release suffix (e.g., -beta)
          PACKAGE_VERSION_RAW=$(node -p "require('./package.json').version")
          BASE_VERSION=$(echo "$PACKAGE_VERSION_RAW" | sed 's/-.*//')

          VERSION="$BASE_VERSION"
          BUILD=""

          # If bump type is specified, calculate new version accordingly
          if [ "$VERSION_TYPE" != "none" ]; then
            IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION"
            if [ "$VERSION_TYPE" = "major" ]; then
              MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0
            elif [ "$VERSION_TYPE" = "minor" ]; then
              MINOR=$((MINOR + 1)); PATCH=0
            elif [ "$VERSION_TYPE" = "patch" ]; then
              PATCH=$((PATCH + 1))
            fi
            VERSION="${MAJOR}.${MINOR}.${PATCH}"
            BUILD=1
          else
            # If no version specified, default to 0.0.0
            if [ -z "$VERSION" ]; then VERSION="0.0.0"; fi

            # If build number is enabled, count existing tags for this version and branch, then increment
            if [ "$ADD_BUILD_NUMBER" = "true" ]; then
              BUILD=$(git tag | grep "^v$VERSION-$BRANCH_NAME\." | wc -l)
              BUILD=$((BUILD + 1))
            fi
          fi

          # Construct the full version string depending on flags
          FULL_VERSION="$VERSION"
          if [ "$ADD_BRANCH_NAME" = "true" ]; then
            FULL_VERSION="${FULL_VERSION}-$BRANCH_NAME"
          fi
          if [ "$ADD_BUILD_NUMBER" = "true" ]; then
            # Default build to 1 if empty
            if [ -z "$BUILD" ]; then BUILD=1; fi
            FULL_VERSION="${FULL_VERSION}.$BUILD"
          fi

          # Check if tag with this version already exists (with 'v' prefix)
          TAG_EXISTS=$(git tag -l "v$FULL_VERSION")

          # Determine if tag changed:
          # If no version bump (version_type=none), no branch or build number added,
          # and tag already exists, then tag did NOT change
          if [ "$VERSION_TYPE" = "none" ] && [ "$ADD_BUILD_NUMBER" = "false" ] && [ "$ADD_BRANCH_NAME" = "false" ] && [ -n "$TAG_EXISTS" ]; then
            # Output old tag and false for tag_changed
            echo "full_version=$FULL_VERSION" >> $GITHUB_OUTPUT
            echo "tag_changed=false" >> $GITHUB_OUTPUT
          else
            # Otherwise, new or changed tag
            echo "full_version=$FULL_VERSION" >> $GITHUB_OUTPUT
            echo "tag_changed=true" >> $GITHUB_OUTPUT
          fi

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

Главная идея простая: проект не должен сам знать, как именно формируется release version. Он должен сказать:

  1. Какую версию поднять: major, minor, patch или none.
  2. Нужно ли добавить имя ветки.
  3. Нужно ли добавить номер сборки.

А reusable workflow уже решает детали:

package.json version: 1.4.0
branch: main
version_type: none
add_branch_name: true
add_build_number: true

full_version: 1.4.0-main.7
tag: v1.4.0-main.7

Такую версию удобно показывать внутри приложения, записывать в .env, класть в package.json, использовать в Git tag и быстро понимать, откуда приехала конкретная сборка.

Общая схема

В этом сетапе есть три уровня.

project workflow
        |
        v
jenesei-software/.github/deploy-node.yml
        |
        v
jenesei-software/.github/setup-version.yml

Проектовый workflow обычно очень тонкий. Например, в jenesei-template-project-react файл deploy-node.yml только показывает inputs в GitHub UI и вызывает общий deploy-node.yml:

jobs:
  call-publisher:
    uses: jenesei-software/.github/.github/workflows/deploy-node.yml@main
    with:
      env_property: ${{ inputs.env_property }}
      build_folder: ${{ inputs.build_folder }}
      version_type: ${{ inputs.version_type }}
      package_manager: ${{ inputs.package_manager }}
      add_branch_name: ${{ inputs.add_branch_name }}
      add_build_number: ${{ inputs.add_build_number }}
    secrets:
      ACCESS_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Подключение общего deploy-node.yml в template project
Подключение общего deploy-node.yml в template project

Источник: jenesei-template-project-react/.github/workflows/deploy-node.yml.

Дальше общий deploy-node.yml вызывает setup-version.yml:

jobs:
  setup-version:
    uses: jenesei-software/.github/.github/workflows/setup-version.yml@main
    with:
      version_type: ${{ inputs.version_type }}
      branch_name: ${{ github.ref_name }}
      add_branch_name: ${{ inputs.add_branch_name }}
      add_build_number: ${{ inputs.add_build_number }}
    secrets:
      ACCESS_GITHUB_TOKEN: ${{ secrets.ACCESS_GITHUB_TOKEN }}
Общий deploy-node.yml вызывает setup-version.yml
Общий deploy-node.yml вызывает setup-version.yml

Источник: jenesei-software/.github/.github/workflows/deploy-node.yml.

Контракт setup-version.yml

setup-version.yml объявлен через workflow_call, поэтому его можно вызывать из других workflow:

on:
  workflow_call:
    inputs:
      version_type:
        required: false
        type: string
      branch_name:
        required: false
        type: string
      add_branch_name:
        required: false
        type: boolean
        default: false
      add_build_number:
        required: false
        type: boolean
        default: false
    outputs:
      full_version:
        value: ${{ jobs.setup-version.outputs.full_version }}
      tag_changed:
        value: ${{ jobs.setup-version.outputs.tag_changed }}

На выходе есть два значения.

full_version - итоговая строка версии. Например, 1.4.0, 1.4.1, 1.4.0-main.7 или 2.0.0-stage.1.

tag_changed - флаг, который показывает, считается ли tag новым. Сейчас в deploy-node.yml основной сценарий всегда создает tag через FULL_VERSION, но этот output полезен, если позже захочется пропускать часть job, когда версия не менялась.

Как считается версия

Workflow делает несколько шагов.

Сначала checkout, Node.js и tags:

- name: Checkout repository
  uses: actions/checkout@v4

- name: Setup Node.js
  uses: actions/setup-node@v4
  with:
    node-version: "22.x"

- name: Fetch all tags
  run: git fetch --tags

Tags нужны не для красоты. По ним workflow понимает, какой следующий build number поставить для текущей base version и ветки.

Дальше версия берется из package.json:

PACKAGE_VERSION_RAW=$(node -p "require('./package.json').version")
BASE_VERSION=$(echo "$PACKAGE_VERSION_RAW" | sed 's/-.*//')

Если в package.json лежит 1.4.0-beta.2, базовой версией станет 1.4.0. Это удобно: pre-release suffix из package не тащится в production tag.

Потом применяется version_type:

if [ "$VERSION_TYPE" = "major" ]; then
  MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0
elif [ "$VERSION_TYPE" = "minor" ]; then
  MINOR=$((MINOR + 1)); PATCH=0
elif [ "$VERSION_TYPE" = "patch" ]; then
  PATCH=$((PATCH + 1))
fi

Правила стандартные:

1.4.0 + major = 2.0.0
1.4.0 + minor = 1.5.0
1.4.0 + patch = 1.4.1
1.4.0 + none  = 1.4.0

Если version_type не none, build number начинается с 1. То есть patch на ветке main с включенными suffix-ами даст:

1.4.1-main.1

Если version_type: none, workflow не трогает semver и может только добавить ветку и build number. Build number считается по существующим tags:

BUILD=$(git tag | grep "^v$VERSION-$BRANCH_NAME\." | wc -l)
BUILD=$((BUILD + 1))

Например, если уже есть:

v1.4.0-main.1
v1.4.0-main.2
v1.4.0-main.3

то следующая сборка на main станет:

1.4.0-main.4

Финальная строка собирается из трех частей:

FULL_VERSION="$VERSION"

if [ "$ADD_BRANCH_NAME" = "true" ]; then
  FULL_VERSION="${FULL_VERSION}-$BRANCH_NAME"
fi

if [ "$ADD_BUILD_NUMBER" = "true" ]; then
  if [ -z "$BUILD" ]; then BUILD=1; fi
  FULL_VERSION="${FULL_VERSION}.$BUILD"
fi

Примеры результатов

Вот как будут выглядеть версии при разных настройках.

package.json: 1.4.0
branch: main
version_type add_branch_name add_build_number full_version
none false false 1.4.0
none true false 1.4.0-main
none true true 1.4.0-main.1
patch true true 1.4.1-main.1
minor true true 1.5.0-main.1
major true true 2.0.0-main.1

Для frontend-приложений мне нравится режим version_type: none, add_branch_name: true, add_build_number: true. Он не меняет базовую product version без явного решения, но каждая сборка получает уникальный tag:

1.4.0-main.1
1.4.0-main.2
1.4.0-main.3

А когда нужен настоящий release bump, можно руками выбрать patch, minor или major при запуске workflow.

Как версия идет дальше в deploy-node.yml

setup-version.yml только считает версию. Он не коммитит package.json и не пушит tag. Это важное разделение ответственности.

В общем deploy-node.yml результат используется в двух местах.

Сначала версия передается в build workflow:

setup-build:
  needs: [setup-version]
  uses: jenesei-software/.github/.github/workflows/setup-build.yml@main
  with:
    full_version: ${{ needs.setup-version.outputs.full_version }}
    package_manager: ${{ inputs.package_manager }}
    env_property: ${{ inputs.env_property }}
    build_folder: ${{ inputs.build_folder }}

Потом deploy job записывает ее в package.json и .env:

env:
  FULL_VERSION: ${{ needs.setup-version.outputs.full_version }}
  BRANCH_NAME: ${{ github.ref_name }}
  ENV_PROPERTY: ${{ inputs.env_property }}
node -e "let p=require('./package.json'); p.version='${{ env.FULL_VERSION }}'; require('fs').writeFileSync('./package.json', JSON.stringify(p, null, 2));"

touch .env
if grep -q '^${{ env.ENV_PROPERTY }}=' .env; then
  sed -i "s/^${{ env.ENV_PROPERTY }}=.*/${{ env.ENV_PROPERTY }}=${{ env.FULL_VERSION }}/" .env
else
  echo "${{ env.ENV_PROPERTY }}=${{ env.FULL_VERSION }}" >> .env
fi

Для Vite-приложения это обычно означает:

VITE_APP_VERSION=1.4.0-main.7

После этого workflow создает commit и tag:

git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add -u

if ! git diff --cached --quiet; then
  git commit -m "version: ${{ env.FULL_VERSION }}"
fi

git tag -a "v${{ env.FULL_VERSION }}" -m "version: ${{ env.FULL_VERSION }}"
git push origin HEAD:${{ env.BRANCH_NAME }}
git push origin "v${{ env.FULL_VERSION }}"

И уже после этого деплоит build folder в GitHub Pages branch:

- name: Deploy to GitHub Pages
  uses: peaceiris/actions-gh-pages@v4
  with:
    github_token: ${{ secrets.ACCESS_GITHUB_TOKEN }}
    publish_branch: build_${{ env.BRANCH_NAME }}
    publish_dir: ${{ inputs.build_folder }}
    force_orphan: true
    keep_files: false

Пример из jenesei-kit-react

В jenesei-kit-react используется тот же принцип, но дефолтный build folder другой:

build_folder:
  description: "Build output folder"
  required: false
  default: "build-storybook"
  type: string

Это логично для UI kit: деплоится не обычное приложение, а Storybook.

Сам вызов общего workflow такой же:

jobs:
  call-publisher:
    uses: jenesei-software/.github/.github/workflows/deploy-node.yml@main
    with:
      env_property: ${{ inputs.env_property }}
      build_folder: ${{ inputs.build_folder }}
      version_type: ${{ inputs.version_type }}
      package_manager: ${{ inputs.package_manager }}
      add_branch_name: ${{ inputs.add_branch_name }}
      add_build_number: ${{ inputs.add_build_number }}
    secrets:
      ACCESS_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Подключение общего deploy-node.yml в jenesei-kit-react
Подключение общего deploy-node.yml в jenesei-kit-react

Источник: jenesei-kit-react/.github/workflows/deploy-node.yml.

А вот так это выглядит в реальном GitHub Actions run:

GitHub Actions job с setup-version
GitHub Actions job с setup-version

Источник: deploy-node #21 в jenesei-kit-react. На публичной странице GitHub показывает структуру job и шаги, а подробные логи может просить открыть после входа.

На скриншоте видно, что внутри call-publisher отдельными job идут:

setup-version
setup-build
deploy

То есть проект запускает один workflow, а общий pipeline сам раскладывает работу на расчет версии, сборку и публикацию.

Почему reusable workflow удобнее копипаста

Если держать такой YAML прямо в каждом проекте, быстро появляются расхождения:

  1. В одном проекте Node.js уже 22.x, в другом остался старый runner.
  2. Где-то забыли git fetch --tags, и build number начал считаться неправильно.
  3. Где-то поменяли формат tag.
  4. Где-то версия записывается в VITE_APP_VERSION, а где-то в другую env-переменную.
  5. Где-то забыли обновить деплой на GitHub Pages.

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

Для проекта это превращается в маленький интерфейс:

workflow_dispatch:
  inputs:
    build_folder:
      default: "build"
    env_property:
      default: "VITE_APP_VERSION"
    version_type:
      default: "none"
    package_manager:
      default: "yarn"
    add_branch_name:
      default: true
    add_build_number:
      default: true

А вся внутренняя кухня остается в jenesei-software/.github.

На что обратить внимание

Tag должен быть уникальным.

Если включены add_branch_name и add_build_number, workflow сам подберет следующий номер по существующим tags. Если оба флага выключены и version_type: none, повторный запуск для той же версии может упереться в уже существующий tag.

package.json должен существовать.

setup-version.yml читает версию командой:

node -p "require('./package.json').version"

Для Node/Vite/React-проектов это нормально. Для другого типа проекта этот workflow придется адаптировать или передавать base version отдельным input-ом.

Имя ветки попадает в версию как есть.

Для main все красиво:

1.4.0-main.1

Но если запускать workflow на ветке feature/auth-form, в версии появится slash:

1.4.0-feature/auth-form.1

Для npm semver и Docker tags это может быть неудобно. Если такие ветки тоже должны деплоиться, лучше добавить sanitizing branch name: заменить / и пробелы на -.

ACCESS_GITHUB_TOKEN должен иметь права на push.

Workflow не только считает версию, но и дальше в deploy-node.yml пушит commit/tag и публикует build branch. Поэтому token должен иметь достаточные permissions для repository contents.

Версия приложения и версия пакета - не всегда одно и то же.

Для приложения формат 1.4.0-main.7 очень удобен: он отвечает на вопрос "какая сборка сейчас открыта". Для npm-пакета такой формат не всегда подходит, потому что npm semver для pre-release имеет свои правила. Для библиотек лучше отдельно решить, какие версии идут в registry, а какие только в CI/deploy.

Ссылки

Основные репозитории и файлы:

  1. CyrilStrone/blog - блог, где лежит эта статья.
  2. jenesei-software/.github - общий репозиторий с reusable workflow.
  3. setup-version.yml - workflow, который считает full_version.
  4. deploy-node.yml в общем .github - workflow, который вызывает setup-version, собирает проект и деплоит результат.
  5. jenesei-template-project-react - пример React-проекта, который подключает общий deploy-node.yml.
  6. deploy-node.yml в jenesei-template-project-react - проектовый workflow с workflow_dispatch.
  7. jenesei-kit-react - пример UI kit, который деплоит Storybook через тот же pipeline.
  8. deploy-node #21 в GitHub Actions - реальный run, где видно job setup-version.

Итог

setup-version.yml делает одну маленькую, но важную вещь: превращает состояние репозитория в понятную строку версии.

Он берет base version из package.json, применяет major/minor/patch/none, при необходимости добавляет ветку и build number, а затем отдает full_version другим jobs. Уже deploy-node.yml использует эту версию для .env, package.json, commit, tag и GitHub Pages deploy.

Получается аккуратная схема: проекты остаются тонкими, общий CI/CD живет в одном месте, а каждая сборка получает имя, по которому ее можно найти и повторить.