Автоматическое версионирование через GitHub Actions
Разбираем reusable workflow setup-version.yml: как он считает версию из package.json, добавляет branch/build suffix, передает full_version дальше и подключается в деплой-пайплайнах 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 в 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. Он должен сказать:
- Какую версию поднять:
major,minor,patchилиnone. - Нужно ли добавить имя ветки.
- Нужно ли добавить номер сборки.
А 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 }}
Источник: 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 }}
Источник: 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 --tagsTags нужны не для красоты. По ним 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
Для 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 }}
Источник: jenesei-kit-react/.github/workflows/deploy-node.yml.
А вот так это выглядит в реальном GitHub Actions run:

Источник: deploy-node #21 в jenesei-kit-react. На публичной странице GitHub показывает структуру job и шаги, а подробные логи может просить открыть после входа.
На скриншоте видно, что внутри call-publisher отдельными job идут:
setup-version
setup-build
deployТо есть проект запускает один workflow, а общий pipeline сам раскладывает работу на расчет версии, сборку и публикацию.
Почему reusable workflow удобнее копипаста
Если держать такой YAML прямо в каждом проекте, быстро появляются расхождения:
- В одном проекте Node.js уже
22.x, в другом остался старый runner. - Где-то забыли
git fetch --tags, и build number начал считаться неправильно. - Где-то поменяли формат tag.
- Где-то версия записывается в
VITE_APP_VERSION, а где-то в другую env-переменную. - Где-то забыли обновить деплой на 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.
Ссылки
Основные репозитории и файлы:
CyrilStrone/blog- блог, где лежит эта статья.jenesei-software/.github- общий репозиторий с reusable workflow.setup-version.yml- workflow, который считаетfull_version.deploy-node.ymlв общем.github- workflow, который вызываетsetup-version, собирает проект и деплоит результат.jenesei-template-project-react- пример React-проекта, который подключает общийdeploy-node.yml.deploy-node.ymlвjenesei-template-project-react- проектовый workflow сworkflow_dispatch.jenesei-kit-react- пример UI kit, который деплоит Storybook через тот же pipeline.deploy-node #21в GitHub Actions - реальный run, где видно jobsetup-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 живет в одном месте, а каждая сборка получает имя, по которому ее можно найти и повторить.