В ISPsystem на текущий момент три front-end команды разрабатывают три крупных проекта: ISPmanager для управления веб-серверами, VMmanager для работы с виртуализацией и BILLmanager для автоматизации бизнеса хостеров. Команды работают одновременно, в режиме сжатых сроков, поэтому без оптимизации не обойтись. Чтобы сэкономить время, мы применяем единые решения и выносим общие компоненты в отдельные проекты. Такие проекты имеют собственные репозитории, которые поддерживают участники всех команд. Об устройстве этих репозиториев, а также работе с ними и будет эта статья.

Мы используем собственный сервер с GitLab для хранения удаленных репозиториев. Для нас было важно сохранить привычное рабочее окружение и иметь возможность работать с общими модулями в процессе их разработки. Поэтому мы отказались от публикации в приватных репозиториях npmjs.com. Благо модули Node.js можно устанавливать не только с NPM, но и из других источников, включая git-репозитории.
Мы пишем на TypeScript, который впоследствии компилируется в JavaScript для последующего использования. Но в наше время разве что ленивый фронтендер не компилирует свой JavaScript. А потому нужны разные хранилища для исходного кода и скомпилированного проекта.
Пройдя через тернии долгих обсуждений, мы выработали следующую концепцию. Должно быть два отдельных репозитория для исходников и для скомпилированной версии модуля. Причем второй репозиторий должен являться зеркалом первого.
Это значит, что при разработке какая-либо фича должна публиковаться до момента релиза в ветке с точно таким же именем, что и у ветки, в которой ведется разработка. Таким образом у нас есть возможность использовать экспериментальную версию модуля, устанавливая его из определенной ветки. Той самой, в которой мы ведем разработку — это очень удобно ��ля проверки его в действии.
Плюс ко всему на каждую публикацию мы создаем метку, сохраняющую состояние проекта. Имя метки соответствует версии, прописанной в package.json. При установке из git-репозитория метка указывается после решетки, например:
Таким образом мы можем зафиксировать используемую версию модуля и не волноваться, что кто-то что-то поменяет.
Для нестабильных версий также создаются метки, однако к ним добавляется сокращенный хэш коммита в репозитории исходников, из которого и произведена публикация. Вот пример такой метки:
Такой подход позволяет добиться уникальности меток, а также связать их с репозиторием исходников.
Раз мы заговорили о стабильных и нестабильных версиях модуля, то вот как мы их различаем: если публикация выполняется из ветки master или develop, версия стабильная, в противном случае — нет.
Все наши договоренности не имели бы смысла, если бы мы не могли их автоматизировать. В частности, автоматизировать процесс публикации. Ниже я покажу, как организована работа с одним из общих модулей — утилитой для тестирования пользовательских сценариев.
Эта утилита с помощью библиотеки puppeteer подготавливает браузер Chromium к использованию в докер-контейнерах и запускает тесты посредством Mocha. Участники всех команд могут модифицировать утилиту, не боясь при этом что-то сломать друг у друга.
В файле package.json утилиты для тестирования прописана следующая команда:
Она запускает рядом лежащий скрипт:
В свою очередь этот код через модуль Node.js child_process выполняет все необходимые команды.
1. Проверка на наличие незакоммиченных изменений
Здесь мы проверяем результат команды git diff. Нехорошо, если в публикацию попадут изменения, которые отсутствуют в исходниках. К тому же это нарушит связь нестабильных версий с коммитами.
2. Сборка утилиты
В константу build попадает результат выполнения сборки. Если все прошло хорошо, параметр status будет равен 0. В противном случае ничего опубликовано не будет.
3. Разворачивание репозитория скомпилированных версий
Весь процесс публикации — это ни что иное, как отправка изменений в определенный репозиторий. Поэтому скрипт создает в нашем проекте временную директорию, в которой инициализирует git-репозиторий и связывает его с удаленным репозиторием сборок.
Это стандартный процесс, использующий git init и git remote.
4. Генерация имени метки
Для начала мы выясняем имя ветки, из которой выполняем публикацию, при помощи команды git symbolic-ref. И задаем имя ветки, в которую будут залиты изменения (в репозитории сборок отсутствует ветка develop).
Используя команду git rev-parse, получаем сокращенный хеш последнего коммита в ветке, в которой находимся. Он может понадобиться для генерации имени метки нестабильной версии.
Ну и собственно составляем имя метки.
5. Проверка отсутствия точно такой же метки в удаленном репозитории
Если подобная метка была создана ранее, результат команды git ls-remote не будет пустым. Одна и та же версия должна быть опубликована лишь единожды.
6. Создание соответствующей ветки в репозитории сборок
Как я и говорил ранее, репозиторий скомпилированных версий утилиты представляет из себя зеркало репозитория с исходниками. А потому, если публикация происходит не из ветки master или develop, мы должны создать соответствующую ветку в репозитории сборок. Ну или по крайней мере убедиться в ее существовании
Если ветка отсутствовала ранее, мы производим инициализацию пустым коммитом при помощи флага --allow-empty.
7. Подготовка файлов
Для начала необходимо удалить все, что могло оказаться в развернутом репозитории. Ведь, если мы используем ранее существовавшую ветку, она содержит в себе предыдущую версию утилиты.
Далее переносим обновленные файлы, необходимые для публикации, и добавляем их в индекс репозитория.
После подобной манипуляции git хорошо распознает произведенные изменения по строкам файлов. Таким образом мы получаем консистентную историю изменений даже в репозитории скомпилированных версий.
8. Коммит и отправка изменений
В качестве сообщения коммита в репозитории сборок мы используем имя метки для стабильных версий. А для нестабильных — сообщение коммита из репозитория исходников. Поддерживая таким образом нашу идею хранилища-зеркала.
9. Удаление временной директории
Одним из важнейших процессов после внесения изменений в общие проекты становится ревью. Несмотря на то, что выработанная технология позволяет создавать абсолютно изолированные версии модулей, никому не хочется иметь десятки разных версий одной и той же утилиты. А потому каждый из общих проектов должен следовать единому пути развития. Об этом стоит договариваться между командами.
Ревью обновлений в общих проектах проводится членами всех команд по мере возможности. Это сложный процесс, так как каждая команда живет по собственному спринту и имеет разную загруженность. Иногда переход на новую версию может затянуться.
Тут лишь можно порекомендовать не пренебрегать и не затягивать с данным процессом.

Как устроены репозитории общих проектов
Мы используем собственный сервер с GitLab для хранения удаленных репозиториев. Для нас было важно сохранить привычное рабочее окружение и иметь возможность работать с общими модулями в процессе их разработки. Поэтому мы отказались от публикации в приватных репозиториях npmjs.com. Благо модули Node.js можно устанавливать не только с NPM, но и из других источников, включая git-репозитории.
Мы пишем на TypeScript, который впоследствии компилируется в JavaScript для последующего использования. Но в наше время разве что ленивый фронтендер не компилирует свой JavaScript. А потому нужны разные хранилища для исходного кода и скомпилированного проекта.
Пройдя через тернии долгих обсуждений, мы выработали следующую концепцию. Должно быть два отдельных репозитория для исходников и для скомпилированной версии модуля. Причем второй репозиторий должен являться зеркалом первого.
Это значит, что при разработке какая-либо фича должна публиковаться до момента релиза в ветке с точно таким же именем, что и у ветки, в которой ведется разработка. Таким образом у нас есть возможность использовать экспериментальную версию модуля, устанавливая его из определенной ветки. Той самой, в которой мы ведем разработку — это очень удобно ��ля проверки его в действии.
Плюс ко всему на каждую публикацию мы создаем метку, сохраняющую состояние проекта. Имя метки соответствует версии, прописанной в package.json. При установке из git-репозитория метка указывается после решетки, например:
npm install git+ssh://[url репозитория]#1.0.0
Таким образом мы можем зафиксировать используемую версию модуля и не волноваться, что кто-то что-то поменяет.
Для нестабильных версий также создаются метки, однако к ним добавляется сокращенный хэш коммита в репозитории исходников, из которого и произведена публикация. Вот пример такой метки:
1.0.0_e5541dc1
Такой подход позволяет добиться уникальности меток, а также связать их с репозиторием исходников.
Раз мы заговорили о стабильных и нестабильных версиях модуля, то вот как мы их различаем: если публикация выполняется из ветки master или develop, версия стабильная, в противном случае — нет.
Как организована работа с общими проектами
Все наши договоренности не имели бы смысла, если бы мы не могли их автоматизировать. В частности, автоматизировать процесс публикации. Ниже я покажу, как организована работа с одним из общих модулей — утилитой для тестирования пользовательских сценариев.
Эта утилита с помощью библиотеки puppeteer подготавливает браузер Chromium к использованию в докер-контейнерах и запускает тесты посредством Mocha. Участники всех команд могут модифицировать утилиту, не боясь при этом что-то сломать друг у друга.
В файле package.json утилиты для тестирования прописана следующая команда:
"publish:git": "ts-node ./scripts/publish.ts"
Она запускает рядом лежащий скрипт:
Полный код скрипта публикации
import { spawnSync } from 'child_process'; import { mkdirSync, existsSync } from 'fs'; import { join } from 'path'; import chalk from 'chalk'; /** * Скрипт публикации данного модуля */ /** * Генерация параметров для запускаемых подпроцессов * @param cwd - директория запуска подпроцесса * @param stdio - настройка ввода/вывода */ const getSpawnOptions = (cwd = process.cwd(), stdio = 'inherit') => ({ cwd, shell: true, stdio, }); /* корневая директория модуля */ const rootDir = join(__dirname, '../'); /* проверка наличия незакоммиченных изменений */ const isDiff = !!spawnSync('git', ['diff'], getSpawnOptions(rootDir, 'pipe')).stdout.toString().trim(); if (isDiff) { console.log(chalk.red('There are uncommitted changes')); } else { /* сборка проекта */ const build = spawnSync('npm', ['run', 'build'], getSpawnOptions(rootDir)); /* проверка статуса выполнения сборки */ if (build.status === 0) { /* временная директория для разворачивания репозитория сборок */ const tempDir = join(rootDir, 'temp'); if (existsSync(tempDir)) { spawnSync('rm', ['-rf', 'temp'], getSpawnOptions(rootDir)); } mkdirSync(tempDir); /* получение параметров из package.json */ const { name, version, repository } = require(join(rootDir, 'package.json')); const originUrl = repository.url.replace(`${name}-source`, name); spawnSync('git', ['init'], getSpawnOptions(tempDir)); spawnSync('git', ['remote', 'add', 'origin', originUrl], getSpawnOptions(tempDir)); /* имя текущей ветки из репозитория исходников модуля */ const branch = spawnSync( 'git', ['symbolic-ref', '--short', 'HEAD'], getSpawnOptions(rootDir, 'pipe') ).stdout.toString().trim(); /* имя ветки в репозитории сборок */ const buildBranch = branch === 'develop' ? 'master' : branch; /* сокращенный хеш последнего коммита в репозитории исходников, используемый при формировании метки нестабильной версии */ const shortSHA = spawnSync( 'git', ['rev-parse', '--short', 'HEAD'], getSpawnOptions(rootDir, 'pipe') ).stdout.toString().trim(); /* метка */ const tag = buildBranch === 'master' ? version : `${version}_${shortSHA}`; /* проверка существования сформированной метки в репозитории сборок */ const isTagExists = !!spawnSync( 'git', ['ls-remote', 'origin', `refs/tags/${tag}`], getSpawnOptions(tempDir, 'pipe') ).stdout.toString().trim(); if (isTagExists) { console.log(chalk.red(`Tag ${tag} already exists`)); } else { /* проверка существования ветки в репозитории сборок */ const isBranchExits = !!spawnSync( 'git', ['ls-remote', '--exit-code', 'origin', buildBranch], getSpawnOptions(tempDir, 'pipe') ).stdout.toString().trim(); if (isBranchExits) { /* переход в целевую ветку */ spawnSync('git', ['fetch', 'origin', buildBranch], getSpawnOptions(tempDir)); spawnSync('git', ['checkout', buildBranch], getSpawnOptions(tempDir)); } else { /* переход в ветку master */ spawnSync('git', ['fetch', 'origin', 'master'], getSpawnOptions(tempDir)); spawnSync('git', ['checkout', 'master'], getSpawnOptions(tempDir)); /* создание целевой ветки */ spawnSync('git', ['checkout', '-b', buildBranch], getSpawnOptions(tempDir)); /* создание начального коммита */ spawnSync('git', ['commit', '--allow-empty', '-m', '"Initial commit"'], getSpawnOptions(tempDir)); } /* удаление старых файлов сборки */ spawnSync( 'rm', ['-rf', 'lib', 'package.json', 'package-lock.json', 'README.md'], getSpawnOptions(tempDir) ); /* копирование файлов сборки */ spawnSync('cp', ['-r', 'lib', 'temp/lib'], getSpawnOptions(rootDir)); spawnSync('cp', ['package.json', 'temp/package.json'], getSpawnOptions(rootDir)); spawnSync('cp', ['package-lock.json', 'temp/package-lock.json'], getSpawnOptions(rootDir)); spawnSync('cp', ['README.md', 'temp/README.md'], getSpawnOptions(rootDir)); /* индексация файлов сборки */ spawnSync('git', ['add', '--all'], getSpawnOptions(tempDir)); /* сообщение последнего коммита в репозитории исходников */ const lastCommitMessage = spawnSync( 'git', ['log', '--oneline', '-1'], getSpawnOptions(rootDir, 'pipe') ).stdout.toString().trim(); /* с��общение коммита в репозитории сборок */ const message = buildBranch === 'master' ? version : lastCommitMessage; /* создание коммита в репозитории сборок */ spawnSync('git', ['commit', '-m', `"${message}"`], getSpawnOptions(tempDir)); /* создание метки в репозитории сборок */ spawnSync('git', ['tag', tag], getSpawnOptions(tempDir)); /* отправка изменений в удаленный репозиторий */ spawnSync('git', ['push', 'origin', buildBranch], getSpawnOptions(tempDir)); spawnSync('git', ['push', '--tags'], getSpawnOptions(tempDir)); console.log(chalk.green('Published successfully!')); } /* удаление временной директории */ spawnSync('rm', ['-rf', 'temp'], getSpawnOptions(rootDir)); } else { console.log(chalk.red(`Build was exited exited with code ${build.status}`)); } } console.log(''); // space
В свою очередь этот код через модуль Node.js child_process выполняет все необходимые команды.
Вот основные этапы его работы:
1. Проверка на наличие незакоммиченных изменений
const isDiff = !!spawnSync('git', ['diff'], getSpawnOptions(rootDir, 'pipe')).stdout.toString().trim();
Здесь мы проверяем результат команды git diff. Нехорошо, если в публикацию попадут изменения, которые отсутствуют в исходниках. К тому же это нарушит связь нестабильных версий с коммитами.
2. Сборка утилиты
const build = spawnSync('npm', ['run', 'build'], getSpawnOptions(rootDir));
В константу build попадает результат выполнения сборки. Если все прошло хорошо, параметр status будет равен 0. В противном случае ничего опубликовано не будет.
3. Разворачивание репозитория скомпилированных версий
Весь процесс публикации — это ни что иное, как отправка изменений в определенный репозиторий. Поэтому скрипт создает в нашем проекте временную директорию, в которой инициализирует git-репозиторий и связывает его с удаленным репозиторием сборок.
/* временная директория для разворачивания репозитория сборок */ const tempDir = join(rootDir, 'temp'); if (existsSync(tempDir)) { spawnSync('rm', ['-rf', 'temp'], getSpawnOptions(rootDir)); } mkdirSync(tempDir); /* получение параметров из package.json */ const { name, version, repository } = require(join(rootDir, 'package.json')); const originUrl = repository.url.replace(`${name}-source`, name); spawnSync('git', ['init'], getSpawnOptions(tempDir)); spawnSync('git', ['remote', 'add', 'origin', originUrl], getSpawnOptions(tempDir));
Это стандартный процесс, использующий git init и git remote.
4. Генерация имени метки
Для начала мы выясняем имя ветки, из которой выполняем публикацию, при помощи команды git symbolic-ref. И задаем имя ветки, в которую будут залиты изменения (в репозитории сборок отсутствует ветка develop).
/* имя текущей ветки из репозитория исходников модуля */ const branch = spawnSync( 'git', ['symbolic-ref', '--short', 'HEAD'], getSpawnOptions(rootDir, 'pipe') ).stdout.toString().trim(); /* имя ветки в репозитории сборок */ const buildBranch = branch === 'develop' ? 'master' : branch;
Используя команду git rev-parse, получаем сокращенный хеш последнего коммита в ветке, в которой находимся. Он может понадобиться для генерации имени метки нестабильной версии.
<source lang="typescript">/* сокращенный хеш последнего коммита в репозитории исходников, используемый при формировании метки нестабильной версии */ const shortSHA = spawnSync( 'git', ['rev-parse', '--short', 'HEAD'], getSpawnOptions(rootDir, 'pipe') ).stdout.toString().trim();
Ну и собственно составляем имя метки.
/* метка */ const tag = buildBranch === 'master' ? version : `${version}_${shortSHA}`;
5. Проверка отсутствия точно такой же метки в удаленном репозитории
/* проверка существования сформированной метки в репозитории сборок */ const isTagExists = !!spawnSync( 'git', ['ls-remote', 'origin', `refs/tags/${tag}`], getSpawnOptions(tempDir, 'pipe') ).stdout.toString().trim();
Если подобная метка была создана ранее, результат команды git ls-remote не будет пустым. Одна и та же версия должна быть опубликована лишь единожды.
6. Создание соответствующей ветки в репозитории сборок
Как я и говорил ранее, репозиторий скомпилированных версий утилиты представляет из себя зеркало репозитория с исходниками. А потому, если публикация происходит не из ветки master или develop, мы должны создать соответствующую ветку в репозитории сборок. Ну или по крайней мере убедиться в ее существовании
/* проверка существования ветки в репозитории сборок */ const isBranchExits = !!spawnSync( 'git', ['ls-remote', '--exit-code', 'origin', buildBranch], getSpawnOptions(tempDir, 'pipe') ).stdout.toString().trim(); if (isBranchExits) { /* переход в целевую ветку */ spawnSync('git', ['fetch', 'origin', buildBranch], getSpawnOptions(tempDir)); spawnSync('git', ['checkout', buildBranch], getSpawnOptions(tempDir)); } else { /* переход в ветку master */ spawnSync('git', ['fetch', 'origin', 'master'], getSpawnOptions(tempDir)); spawnSync('git', ['checkout', 'master'], getSpawnOptions(tempDir)); /* создание целевой ветки */ spawnSync('git', ['checkout', '-b', buildBranch], getSpawnOptions(tempDir)); /* создание начального коммита */ spawnSync('git', ['commit', '--allow-empty', '-m', '"Initial commit"'], getSpawnOptions(tempDir)); }
Если ветка отсутствовала ранее, мы производим инициализацию пустым коммитом при помощи флага --allow-empty.
7. Подготовка файлов
Для начала необходимо удалить все, что могло оказаться в развернутом репозитории. Ведь, если мы используем ранее существовавшую ветку, она содержит в себе предыдущую версию утилиты.
/* удаление старых файлов сборки */ spawnSync( 'rm', ['-rf', 'lib', 'package.json', 'package-lock.json', 'README.md'], getSpawnOptions(tempDir) );
Далее переносим обновленные файлы, необходимые для публикации, и добавляем их в индекс репозитория.
/* копирование файлов сборки */ spawnSync('cp', ['-r', 'lib', 'temp/lib'], getSpawnOptions(rootDir)); spawnSync('cp', ['package.json', 'temp/package.json'], getSpawnOptions(rootDir)); spawnSync('cp', ['package-lock.json', 'temp/package-lock.json'], getSpawnOptions(rootDir)); spawnSync('cp', ['README.md', 'temp/README.md'], getSpawnOptions(rootDir)); /* индексация файлов сборки */ spawnSync('git', ['add', '--all'], getSpawnOptions(tempDir));
После подобной манипуляции git хорошо распознает произведенные изменения по строкам файлов. Таким образом мы получаем консистентную историю изменений даже в репозитории скомпилированных версий.
8. Коммит и отправка изменений
В качестве сообщения коммита в репозитории сборок мы используем имя метки для стабильных версий. А для нестабильных — сообщение коммита из репозитория исходников. Поддерживая таким образом нашу идею хранилища-зеркала.
/* сообщение последнего коммита в репозитории исходников */ const lastCommitMessage = spawnSync( 'git', ['log', '--oneline', '-1'], getSpawnOptions(rootDir, 'pipe') ).stdout.toString().trim(); /* сообщение коммита в репозитории сборок */ const message = buildBranch === 'master' ? version : lastCommitMessage; /* создание коммита в репозитории сборок */ spawnSync('git', ['commit', '-m', `"${message}"`], getSpawnOptions(tempDir)); /* создание метки в репозитории сборок */ spawnSync('git', ['tag', tag], getSpawnOptions(tempDir)); /* отправка изменений в удаленный репозиторий */ spawnSync('git', ['push', 'origin', buildBranch], getSpawnOptions(tempDir)); spawnSync('git', ['push', '--tags'], getSpawnOptions(tempDir));
9. Удаление временной директории
spawnSync('rm', ['-rf', 'temp'], getSpawnOptions(rootDir));
Ревью обновлений в общих проектах
Одним из важнейших процессов после внесения изменений в общие проекты становится ревью. Несмотря на то, что выработанная технология позволяет создавать абсолютно изолированные версии модулей, никому не хочется иметь десятки разных версий одной и той же утилиты. А потому каждый из общих проектов должен следовать единому пути развития. Об этом стоит договариваться между командами.
Ревью обновлений в общих проектах проводится членами всех команд по мере возможности. Это сложный процесс, так как каждая команда живет по собственному спринту и имеет разную загруженность. Иногда переход на новую версию может затянуться.
Тут лишь можно порекомендовать не пренебрегать и не затягивать с данным процессом.
