Общеизвестно, что каждый программный продукт в конечном итоге обретает номер поставляемой версии. Изначально это может быть цифра в README файле, на борде в JIRA либо просто в голове у тимлида или ПМа. Но в какой-то момент становится понятно, что нужно формализовать процесс назначения версии релизу, отобразить номер в приложении, интегрировать версионность в CI, аналитику и другие места. 

С появлением технологии PWA, версионность в вебе обрела еще больший смысл, ведь теперь самая последняя версия приложения доступна пользователю не в момент загрузки страницы, а только через определенное время после обновления файлов в фоновом режиме. Поэтому важно следить за номерами версий у пользователей, чтобы знать в какой из них возникла проблема и сколько обновилось до последней.

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

Git и версионность

Для наглядности, давайте взглянем на диаграмму одного из самых популярных подходов Git Flow: 

Демонстрация Git Flow
Демонстрация Git Flow

Как видим, нумерация версий происходит в ветке, код которого попадает на прод (разумеется, можно добавлять версии для любых окружений), при этом формат версии обычно задается X.Y.Z (как правило, согласно спецификации семантической версионности, где X - мажорная версия, Y - минорная, Z - патч).  

Расшифровка версии semver
Расшифровка версии semver

Очевидно, что версии в первую очередь должны быть привязаны к коммитам (после которых и собираются релизные сборки), чтобы хранилась наглядная история релизов и легко было откатываться до предыдущего при необходимости. Удобнее всего это реализовать с помощью тегирования коммитов (git tag). Давайте рассмотрим npm пакеты, помогающие решить эту задачу.

Готовые npm-решения

Основная проблема в том, что все существующие npm пакеты предназначены больше для версионности и публикации вашего проекта в npm непосредственно, хотя никто не мешает воспользоваться функционалом инкрементирования версии сборки и пуша тегов в гит (будь то ручной запуск команды, либо из CI-скрипта).

Примеры:

npm np

npm np CLI
npm np CLI

После установки пакета, выполняем команду np, выбираем какую цифру релиза нужно увеличить. Правда, придется выполнять команду добавив флаги --no-publish --no-tests --no-release-draft

Плюсы:

  • Обновление версии с помощью одной команды

Минусы:

  • Нет возможности контролировать, куда записывается номер версии (только в package.json)

  • Нет возможности добавить постфикс версиям (например, 1.3.1-dev, 0.1.1-alpha)

  • Не все git сервисы разрешают пушить тег с новой версией и измененный package.json в репозиторий прямо из CI-скрипта после окончания сборки.

npm release-it 

Пакет популярнее предыдущего, больше конфигураций, интерактивный режим настройки. Принцип выполнения команды такой же: 

release-it minor --no-npm.publish

Для режима CI нужно добавить флаг --ci

Плюсы:

Минусы:

  • Чтобы хранить номер версии в своем файле нужен отдельный плагин (тем не менее, там нет поддержки .js расширения, поэтому проще использовать пакет replace-in-file) - для отображения версии сборки в самом приложении

  • Нет возможности добавить постфикс к версии (по крайней мере я не нашел)

npm semantic-release

Наиболее популярный из представленных примеров. Полностью автоматизирует процесс версионности, убирая человеческий фактор по инкременту версий. Однако, для этого необходимо следовать Commit Message Conventions (хороший повод начать именовать коммиты более организованно). 

Инкремент версий происходит по следующей логике: если в названии коммита находится слово fix - это считается как Patch Release (обновляется третья цифра); если в названии коммита присутствует feat (feature) - Minor Release (вторая цифра); perf (performance) или breaking change - Major Release (первая цифра). 

Вдобавок, на основе коммитов генерируется changelog.

Минусы:

  • Не обнаружил возможность хранить версию в отдельном .js файле для последующего отображения в приложении

  • Возможность контролировать процесс обновления мажорных и минорных версий вручную.

Как видим, готовых решений достаточно, однако, давайте все же попробуем написать свое, адаптировав его под свои нужды.

Что нам нужно?

  1. Код из git-веток попадающий на окружение (dev, staging, prod) должен быть пронумерован и хранить тип окружения (к примеру, 1.0.1-dev)

  2. Каждый пуш в ветку (master, integration, release) увеличивает патч-версию

  3. Обновление мажорной и минорной версии происходит вручную после каждого релиза / спринта. Какую версию менять решаем сами на основе запланированных задач и потенциальных изменений

  4. Версия сборки доступна в JS, для того чтобы была возможность ее отображать в самом приложении, использовать для аналитики, передавать в системы репорта ошибок и т.п.

  5. package.json не должен меняться во время CI (т.е версию приложения не храним в этом файле) во избежание потенциальных мерж конфликтов (к примеру, когда одновременно вмерживается несколько реквестов и сборки собираются одновременно, в нашей команде такое случается достаточно часто).

  6. При релизе патч-версия (z) начинается с 1, оставляя только номер релиза (x.y). Например: версии на деве 1.4.1-dev, 1.4.2-dev, 1.4.3-dev, а в релиз пойдет 1.4.1. Если же подливаем hotfix в тот же релиз, то версия будет 1.4.2.

Данные пункты являются субъективными и легко могут быть изменены под ваши требования. Ниже рассмотрим JS-реализацию данной логики.

Реализация своей системы версионности

Предварительно создадим 2 файла, первый version.txt (в корне проекта) для хранения мажорной и минорной версии релиза (которые мы вручную меняем, как указано выше). В файле будет хранится только 2 числа версии, разделенные точкой вида: 2.13

Создадим второй файл app-version.js (путь - src/environment, т.к. там лежат подобные файлы в Angular проекте, вы же можете выбрать любой удобный путь), который будет меняться CI-скриптом перед сборкой, при этом в самом репозитории файл всегда статичен. Содержимое выглядит так:

exports.APP_VERSION = '{VERSION}'; // DO NOT TOUCH

Это позволит получить доступ к версии прямо во время выполнения javascript / typescript кода приложения и использовать по назначению:

import { APP_VERSION } from 'src/environments/app-version';

Логика определения и назначения версии будет следующая:

  1. получить текущую мажорную и минорную версии (x.y) из файла version.txt

  2. вывести список всех git-тегов данного релиза x.y.*

  3. обнаружить патч версию (z) последнего тега релиза

  4. добавить новый тег вида x.y.(z+1)

  5. при необходимости добавить постфикс окружения (x.y.z-dev)

Приступим к скрипту. Нам нужен пакет npm shelljs - для того, чтобы мы могли работать с системными командами (git, файловая система) прямо из js-файла без нужды писать shell-скрипты. Создадим update-version.js:

const shell = require('shelljs');

if (!shell.which('git')) {
  shell.echo('This script requires git');
  shell.exit(1);
}

const postfixArg = process.argv[2]; // переданные аргументы во время запуска скрипта начинаются со второго индекса, ссылка: https://nodejs.org/docs/latest/api/process.html#process_process_argv
const tagPostfix = postfixArg ? `-${postfixArg}` : ''; // пример: x.y.z-dev, если нет аргумента - x.y.z

// ПОИСК ПОСЛЕДНЕГО ТЕГА ДЛЯ ТЕКУЩЕГО РЕЛИЗА

const releaseNum = shell.head('version.txt'); // читаем содержимое файла чтобы получить версию (x.y) текущего релиза

const tagsTemplateToSearch = `${releaseNum}.*${tagPostfix}`; // 'x.y.*-postfix' шаблон для поиска предыдущих тегов для текущего релиза
const releasedTags = shell
  .exec(`git tag -l ${tagsTemplateToSearch} --sort=-v:refname`)
  .split('\n')
  .filter(Boolean);

const lastReleaseTag = releasedTags.length > 0 ? releasedTags[0] : '';

shell.echo(`The last tag for ${releaseNum}: ${lastReleaseTag || '-'}`);

// ПОЛУЧЕНИЕ НОВОЙ ВЕРСИИ

let patchVersion = 1;
if (lastReleaseTag) {
  // если для данного релиза уже были теги, получить версию последнего патча и увеличить
  const lastReleaseVersion = lastReleaseTag.split('-')[0]; // получаем 'x.y.z' из 'x.y.z-postfix'
  patchVersion = +lastReleaseVersion.split('.').pop() + 1; // получаем патч версию z из x.y.z и увеличиваем на 1
}

const newVersionTag = `${releaseNum}.${patchVersion}${tagPostfix}`;

shell.echo(`New version tag: ${newVersionTag}`);

// СОХРАНЯЕМ ВЕРСИЮ СБОРКИ В app-version.js

shell.cd('src/environments');
shell.sed('-i', '{VERSION}', newVersionTag, 'app-version.js');
shell.cd('../../');

// КОММИТИМ НОВЫЙ ТЕГ

shell.exec(`git tag ${newVersionTag}`);

Скрипт генерации новой версии приложения на основе предыдущих версий из git готов. Далее можно запускать сборку проекта, зная, что app-version.js с новой версией попадет в проект и будет доступен в JS.

Остался лишь последний шаг - git push нового тега после успешной сборки в git-репозиторий.

Создаем еще один файл push-new-version-tag.js:

const shell = require('shelljs');
const version = require('./src/environments/app-version');

if (!shell.which('git')) {
  shell.echo('The script requires git');
  shell.exit(1);
}

shell.echo(`Tag to push: ${version.APP_VERSION}`);

// shell.exec(`git push --force origin ${version.APP_VERSION} -o ci.skip`); GitLab CI не позволяет пуш в репозиторий, поэтому делаем POST запрос с флагом --silent и приватным ключом переданным как аргумент из скрипта

const branchName = process.argv[2];
const gitlabToken = process.argv[3];
shell.exec(
  `curl -X POST --silent --show-error --fail "https://gitlab.com/api/v4/projects/gitlab_project_id/repository/tags?tag_name=${version.APP_VERSION}&ref=${branchName}&private_token=${gitlabToken}"`
);

Готово. Остается добавить запуск этих команд в ваш CI скрипт.

А вот как выглядит наш для GitLab (проект на Angular):

stages:
  - build
  - deploy

build_dev:
  image: 'node:latest'
  stage: build
  script:
    - npm install
    - node ./update-version.js dev
    - npm run build -- --configuration=dev
    - node ./push-new-version-tag.js integration $GITLAB_TOKEN_CI
  artifacts:
    paths:
      - dist/uxcel
  only:
    - integration

build_prod:
  image: 'node:latest'
  stage: build
  script:
    - npm install
    - node ./update-version.js
    - npm run build -- --configuration=production
    - node ./push-new-version-tag.js master $GITLAB_TOKEN_CI
  artifacts:
    paths:
      - dist/uxcel
  only:
    - master

Таким образом, мы добавили версионность в веб-приложение для разных окружений (дев-версии сборок именуются вида x.y.z-dev, а прод-версии без постфикса - x.y.z). Как видите, реализация получилась несложной и адаптируемой. К примеру, если вы уже используете готовое решение, но у вас нет возможности получить номер версии в JS, можно добавить скрипт, который будет вычитывать последний тег и записывать его в js-файл. К слову, для меня был большим открытием npm-пакет shelljs, заметно упрощающий написание логики вместо shell-скриптов.

Как мы используем номер версии в Uxcel?

Наше приложение является PWA, поэтому нам важно следить за номерами версий наших пользователей: версии отправляются в google analytics, в систему мониторинга ошибок sentry, в API запросы (которые могут обрабатываться по-разному в зависимости от версии) и, само собой, версия отображается в самом приложении. Помимо этого, номер версии может использоваться для отображения Release Notes или для показа обучающего окна нового функционала сразу после авто-обновления приложения.

Uxcel - сервис интерактивного обучения UI/UX. Наша версия отображается в меню.
Uxcel - сервис интерактивного обучения UI/UX. Наша версия отображается в меню.

Спасибо за внимание, надеюсь, статья была полезной для вас! Буду рад услышать ваше мнение, делитесь своими способами версионности веб-приложений.