spectrum-cli 3.0.1 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 🚀 [3.1.1] - 2026-09-24
9
+
10
+ ### 🪲 Fixed
11
+
12
+ - Хотфиксы создают раздел с 🩹 и поддерживают запуск подготовки из main/master.
13
+ - Дата раздела changelog необязательна; опубликованная история в прежнем формате сохраняется.
14
+ - Исправлены проверка корня репозитория и сравнение CRLF/LF в release/hotfix на Windows.
15
+
16
+ ## 🚀 [3.1.0] - 2026-09-24
17
+
18
+ ### 🆕 Added
19
+
20
+ - Добавлен цикл хотфикса: подготовка changelog без merge, публикация stable-тега и перенос исправления в dev.
21
+
22
+ ### 🛠 Changed
23
+
24
+ - Релизы используют теги release/X.Y.Z, хотфиксы — hotfix/X.Y.Z; старые vX.Y.Z продолжают учитываться.
25
+
26
+ ### 🪲 Fixed
27
+
28
+ - Повторный release start дополняет открытый релиз, сохраняя версию и удаляя использованные fragments.
29
+
30
+ ## 🚀 [3.0.2] - 2026-09-15
31
+
32
+ ### 🆕 Added
33
+
34
+ - `spectrum release start` проверяет, что текущая dev-ветка содержит `origin/main` или `origin/master`.
35
+
8
36
  ## 🚀 [3.0.1] - 2026-08-31
9
37
 
10
38
  ### 🪲 Fixed
package/README.md CHANGED
@@ -38,9 +38,14 @@ spectrum -v | --version
38
38
 
39
39
  # 🚀 Управление релизами
40
40
  spectrum release start # Схлопнуть fragments и атомарно отправить release commit в dev и main/master
41
- spectrum release deploy # Создать стабильный тег vX.Y.Z из заголовка CHANGELOG
41
+ spectrum release deploy # Создать стабильный тег release/X.Y.Z из заголовка CHANGELOG
42
42
  spectrum release close # Свести stable main/master в dev
43
43
 
44
+ # Срочный patch без переноса незавершённого dev в production
45
+ spectrum hotfix start # Локальная подготовка 🩹 CHANGELOG на hotfix/* или main/master
46
+ spectrum hotfix deploy # После merge MR: stable-тег на актуальном main/master
47
+ spectrum hotfix close # После stable pipeline: main/master → dev
48
+
44
49
  # 📈 Теги chart
45
50
  spectrum chart create 1.2.3 # Создать и запушить chart-$name-$version (требует запись в CHANGELOG.md чарта)
46
51
  spectrum chart create 1.2.3 --wait # После пуша тега дождаться публикации версии в Helm-registry
@@ -114,7 +119,7 @@ spectrum-cli/
114
119
  | Команда | Описание |
115
120
  | --------------------------- | ----------------------------------- |
116
121
  | `spectrum release start` | Схлопнуть и опубликовать fragments |
117
- | `spectrum release deploy` | Создать стабильный тег vX.Y.Z |
122
+ | `spectrum release deploy` | Создать стабильный тег release/X.Y.Z |
118
123
  | `spectrum release close` | Закрыть релиз |
119
124
  | `spectrum changelog append` | Создать changelog fragment |
120
125
  | `spectrum changelog check` | Проверить changelog и fragments |
@@ -125,9 +130,9 @@ spectrum-cli/
125
130
 
126
131
  ### 🛡️ Preflight-проверки по командам
127
132
 
128
- - `spectrum release start`: чистая и актуальная dev-ветка, стабильный тег, достижимый из `origin/main` или `origin/master`, отсутствие в `CHANGELOG.md` незакрытых версий новее этого тега, валидные fragments и отсутствие целевых hotfix-веток и тега. Версия вычисляется только из тегов и fragments — `package.json` не читается.
129
- - `spectrum release deploy`: чистая и актуальная main/master, стабильная версия `X.Y.Z` из верхнего заголовка `CHANGELOG.md`, отсутствие локального и remote-тега `vX.Y.Z`. Команда не создает RC-теги.
130
- - `spectrum release close`: чистая и актуальная main/master, версия из верхнего заголовка `CHANGELOG.md` и remote-тег `vX.Y.Z`, указывающий на текущий commit.
133
+ - `spectrum release start`: чистая и актуальная dev-ветка, `dev` содержит `origin/main` или `origin/master`, стабильный тег, достижимый из production-ветки, согласованная опубликованная история `CHANGELOG.md` и не более одного открытого релиза. При первом запуске нужны валидные fragments и свободная версия; повторный дополняет открытый раздел без повышения версии. `package.json` не читается.
134
+ - `spectrum release deploy`: чистая и актуальная main/master, стабильная версия `X.Y.Z` из верхнего заголовка `CHANGELOG.md`, отсутствие этой версии среди локальных и remote-тегов всех трёх форм. Команда не создает RC-теги.
135
+ - `spectrum release close`: чистая и актуальная main/master, версия из верхнего заголовка `CHANGELOG.md` и remote stable-тег поддерживаемой формы, указывающий на текущий commit.
131
136
  - `spectrum changelog append <message>`: `git-repo`, `changelog-exists`, валидные ID задачи, git identity и тип fragment. Команда не изменяет общий `CHANGELOG.md`.
132
137
  - `spectrum changelog check`: `git-repo`, `changelog-exists`, `changelog-prettier-check`, наличие и формат всех changelog fragments.
133
138
  - `spectrum chart create <version>`: `git-repo`, `clean-working-tree`, `on-main-branch`, `valid-semver` (переданный `<version>` — semver), `single-chart` (ровно один `charts/<chart-name>/Chart.yaml`), `tag-missing` (тега `chart-<name>-<version>` нет локально и на `origin`).
@@ -141,18 +146,21 @@ spectrum-cli/
141
146
 
142
147
  Выполняется на `dev`:
143
148
 
144
- 1. Проверяет `CHANGELOG.md`, отсутствие релизов новее последнего стабильного тега и все файлы `.changelog/<name>.<type>.md`. Новый релиз нельзя начать, пока предыдущий не получил stable-тег.
145
- 2. После `git fetch origin --prune --tags` находит максимальный стабильный тег `vX.Y.Z`, достижимый из stable-ветки, и применяет к нему максимальное повышение: `breaking` → major, `added` → minor, остальные типы → patch.
146
- 3. Собирает новый релизный блок `## 🚀 [X.Y.Z]` в `CHANGELOG.md` из fragments в стабильном порядке разделов.
147
- 4. Удаляет использованные fragments.
148
- 5. Повторно проверяет формат `CHANGELOG.md`.
149
- 6. Коммитит схлопнутый changelog одним коммитом в `dev` и атомарно пушит этот commit напрямую в `origin/dev` и `origin/main` или `origin/master`, без Merge Request.
149
+ 1. Проверяет чистую актуальную dev-ветку, включающую `origin/main` или `origin/master`, changelog и fragments.
150
+ 2. После `git fetch origin --prune --tags` находит максимальный stable-тег среди `release/X.Y.Z`, `hotfix/X.Y.Z` и `vX.Y.Z`, достижимый из production-ветки.
151
+ 3. Если открытого релиза нет, вычисляет версию по fragments: `breaking` → major, `added` → minor, остальные → patch. Если раздел уже открыт в main/master, сохраняет его версию и дату, объединяет прежние записи с новыми по разделам без дублей. Тип новых fragments не повышает уже выбранную версию.
152
+ 4. Форматирует и проверяет `CHANGELOG.md`, затем удаляет использованные fragments.
153
+ 5. Коммитит изменения и атомарно пушит dev в `origin/dev` и `origin/main` или `origin/master`, без Merge Request. Если новых записей нет, пустой коммит не создаётся; можно повторить отправку после её сбоя.
154
+
155
+ Например, повторный `release start` дополняет открытый `1.4.0`; после публикации
156
+ `release/1.4.0` новые fragments уже формируют следующий релиз. Изменение
157
+ опубликованной истории и конфликтующие открытые версии блокируют команду.
150
158
 
151
159
  Команда не изменяет `package.json`, lock-файлы и не создает `release/*`-веток. Прямой push в stable-ветку должен быть разрешен правилами защиты репозитория; non-fast-forward обновление не выполняется.
152
160
 
153
161
  ### `spectrum release close`
154
162
 
155
- 1. Читает версию из верхнего заголовка `CHANGELOG.md` и проверяет, что стабильный `vX.Y.Z` уже указывает на текущий commit main/master.
163
+ 1. Читает версию из верхнего заголовка `CHANGELOG.md` и проверяет, что remote stable-теги этой версии указывают на текущий commit main/master.
156
164
  2. Обновляет main/master и dev.
157
165
  3. Мержит main/master в dev.
158
166
  4. Пушит синхронизированный `dev`.
@@ -160,12 +168,76 @@ spectrum-cli/
160
168
  ### `spectrum release deploy`
161
169
 
162
170
  1. Проверяет актуальную main/master и читает версию `X.Y.Z` из верхнего заголовка `CHANGELOG.md`.
163
- 2. Создает единственный release-тег `vX.Y.Z`.
171
+ 2. Создает единственный release-тег `release/X.Y.Z`.
164
172
  3. Пушит тег в `origin`. RC-теги CLI не создает.
165
173
 
174
+ ### Формат стабильных тегов
175
+
176
+ Новые обычные релизы получают `release/X.Y.Z`, хотфиксы — `hotfix/X.Y.Z`.
177
+ Старый `vX.Y.Z` эквивалентен `release/X.Y.Z`: он участвует в поиске последней
178
+ версии и проверке занятости номера. Старые теги не нужно переименовывать.
179
+ Нумерация общая: после `release/1.2.3` и `hotfix/1.2.4` следующий patch — `1.2.5`.
180
+ Выпустить занятый номер под другим префиксом нельзя. В changelog и версиях
181
+ образов остаётся `X.Y.Z`, без `release/` и `hotfix/`. Это имена **тегов**, не веток.
182
+
183
+ Все hotfix-команды сначала проверяют ветку: разрешены только `hotfix/*`, `main`
184
+ или `master`. `dev`, `develop`, `feature/*` и detached HEAD отклоняются до fetch.
185
+ Для `start` подходят `hotfix/<TASK>[-slug]` и production-ветка `main/master`,
186
+ содержащая актуальный origin. Локальные исправления и commits допустимы.
187
+ `deploy` и `close` требуют чистый production commit, совпадающий с origin.
188
+
189
+ ### Изолированный цикл хотфикса
190
+
191
+ Создайте `hotfix/<TASK>` или `hotfix/<TASK>-<slug>` от актуального `origin/main`
192
+ или `origin/master`, либо работайте прямо в актуальной production-ветке
193
+ `main/master`. Включайте только срочное исправление и его patch fragments
194
+ (`fixed`, `security`, `support` либо другой совместимый patch-тип).
195
+ `added` и `breaking` требуют обычного релиза. Все команды запускаются из корня
196
+ репозитория; для `start` нужен Prettier, как для обычной сборки changelog.
197
+
198
+ 1. **`spectrum hotfix start`** обновляет сведения об origin и выбирает опубликованный
199
+ stable-тег, достижимый из production. Цель — этот stable плюс один patch.
200
+ Команда формирует верхний раздел `## 🩹 [X.Y.Z] - YYYY-MM-DD` и удаляет только
201
+ собранные fragments. Она не делает commit, staging, push, merge или tag,
202
+ не меняет файлы версий и сохраняет посторонние локальные изменения.
203
+ 2. Повторный `start` дополняет тот же неопубликованный раздел, сохраняя дату и
204
+ существующие записи без дублей. Дата необязательна: раздел без даты останется
205
+ без даты. Старые заголовки с `🚀`, `🩹`, без значка и с датами через точки
206
+ читаются без переписывания опубликованной истории. Без новых fragments подготовленный раздел
207
+ остаётся без изменений. Например, от `v6.35.0` первый и повторные запуски
208
+ готовят `6.35.1`; только после публикации `hotfix/6.35.1` следующий хотфикс получит
209
+ `6.35.2`. Унаследованные от production fragments не собираются и не меняются.
210
+ 3. Проверьте diff, закоммитьте и отправьте исправление по правилам проекта.
211
+ **В MR из hotfix в production уже должны быть верхний patch-раздел и удаления
212
+ использованных fragments.** Merge выполняется отдельно после ревью. Если
213
+ другой хотфикс уже в production, сначала перенесите его изменения в свою
214
+ ветку и повторите подготовку: пока stable-тега нет, раздел и версия общие.
215
+ 4. После доставки подготовленного исправления в production через MR или
216
+ разрешённый проектом push и проверки RC выполните **`spectrum hotfix deploy`** на чистом
217
+ `main/master`, точно совпадающем с origin. Команда проверяет следующий patch,
218
+ неизменность опубликованной истории и отсутствие несобранных hotfix fragments,
219
+ создаёт `hotfix/X.Y.Z` на проверенном SHA и отправляет **только этот тег**.
220
+ Повтор после ошибки push допускает существующий локальный тег лишь на том же
221
+ SHA; опубликованный тег не перемещается. Ветки не сливаются.
222
+ 5. После успешного stable pipeline выполните **`spectrum hotfix close`** на том же
223
+ чистом production commit с тегом `hotfix/X.Y.Z`. Команда обновит `dev/develop` только
224
+ fast-forward, смержит production в integration и отправит integration.
225
+ Незавершённая работа и fragments в dev сохраняются. Локальные неопубликованные
226
+ commits блокируют автоматическую отправку; конфликт остаётся для ручного
227
+ разрешения, без push. При неуспешном push готового merge проверьте локальный
228
+ diff и согласуйте ветку вручную — повторный `close` не отправляет локальные
229
+ commits без проверки.
230
+
231
+ CLI не проверяет статус CI и не управляет кластером: проверка RC, успешного stable
232
+ pipeline и фактического выката выполняется отдельно в системе доставки. Обычный
233
+ `release start` не используется в этом цикле: он отправляет integration в production.
234
+ `changelog check` проверяет исходные fragments до сборки; после сборки сам `hotfix start`
235
+ проверяет подготовленный раздел. CI проекта должен принимать собранные заметки в MR,
236
+ не требуя повторного fragment для уже включённой записи.
237
+
166
238
  ### Цикл версий и CI
167
239
 
168
- Источник правды о версии — git-тег `vX.Y.Z`. Файлы версий (`package.json`, `Makefile`) не читаются ни CLI, ни CI:
240
+ Источник версии — Git-теги `release/X.Y.Z`, `hotfix/X.Y.Z` и legacy `vX.Y.Z`. Файлы версий (`package.json`, `Makefile`) не читаются ни CLI, ни CI:
169
241
 
170
242
  | Событие | Источник версии | Артефакт |
171
243
  | --- | --- | --- |
@@ -173,11 +245,11 @@ spectrum-cli/
173
245
  | коммиты в dev | тег `v1.0.0` + бамп из `.changelog/` | `1.1.0-alpha.SHORTSHA` |
174
246
  | `release start` на dev | fragments схлопнуты в `## 🚀 [1.1.0]` | release commit в `dev` и `main/master` |
175
247
  | merge в main/master | заголовок CHANGELOG `1.1.0` | `1.1.0-rc.1`, затем `rc.2`, ... |
176
- | stable-тег `v1.1.0` | git-тег | проверенный RC продвигается в `1.1.0` |
177
- | merge stable в dev (`release close`) | тег `v1.1.0` + бамп fragments | `X.Y.Z-alpha.SHORTSHA` |
248
+ | stable-тег `release/1.1.0` | git-тег | проверенный RC продвигается в `1.1.0` |
249
+ | merge stable в dev (`release close`) | тег `release/1.1.0` + бамп fragments | `X.Y.Z-alpha.SHORTSHA` |
178
250
 
179
251
  Номер RC хранится в registry и переиспользуется при retry того же commit SHA.
180
- Git содержит только стабильные теги `vX.Y.Z`. Stable pipeline проверяет OCI
252
+ Новые stable-теги — `release/X.Y.Z` и `hotfix/X.Y.Z`; старые `vX.Y.Z` сохраняются. Stable pipeline проверяет OCI
181
253
  revision обязательных образов и копирует точные RC digest без пересборки. Эту
182
254
  CI-часть реализует подключенный release component, а не Spectrum CLI.
183
255
 
package/index.js CHANGED
@@ -4,6 +4,7 @@ const program = new Command();
4
4
 
5
5
  const git = require("./src/git");
6
6
  const release = require("./src/release");
7
+ const hotfix = require("./src/hotfix");
7
8
  const changelog = require("./src/changelog");
8
9
  const chart = require("./src/chart");
9
10
  const token = require("./src/token");
@@ -33,7 +34,7 @@ const releaseCmd = program
33
34
 
34
35
  releaseCmd
35
36
  .command("start")
36
- .description("Схлопнуть changelog fragments в dev и создать MR в main/master")
37
+ .description("Схлопнуть fragments и атомарно отправить release commit в dev и main/master")
37
38
  .action(() => runAction(release.releaseStart));
38
39
 
39
40
  releaseCmd
@@ -43,9 +44,21 @@ releaseCmd
43
44
 
44
45
  releaseCmd
45
46
  .command("deploy")
46
- .description("Создать и отправить только стабильный тег vX.Y.Z")
47
+ .description("Создать и отправить только стабильный тег release/X.Y.Z")
47
48
  .action(() => runAction(git.gitCreateTagAndPush));
48
49
 
50
+ const hotfixCmd = program.command("hotfix").description("Изолированный цикл срочного patch-релиза");
51
+
52
+ hotfixCmd.command("start")
53
+ .description("Подготовить 🩹 CHANGELOG на hotfix/* или main/master, без commit, push и merge")
54
+ .action(() => runAction(hotfix.hotfixStart));
55
+ hotfixCmd.command("deploy")
56
+ .description("После merge хотфикса отправить stable-тег с main/master")
57
+ .action(() => runAction(hotfix.hotfixDeploy));
58
+ hotfixCmd.command("close")
59
+ .description("После stable pipeline свести production в dev")
60
+ .action(() => runAction(hotfix.hotfixClose));
61
+
49
62
  // Команды changelog
50
63
  const changelogCmd = program
51
64
  .command("changelog")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectrum-cli",
3
- "version": "3.0.1",
3
+ "version": "3.1.1",
4
4
  "description": "🚀 Modern CLI for development workflow automation - release management, versioning, and changelog generation",
5
5
  "main": "index.js",
6
6
  "bin": {
package/src/changelog.js CHANGED
@@ -270,7 +270,7 @@ function stripLegacyUnreleasedBlock(changelog) {
270
270
  return `${changelog.slice(0, unreleasedMatch.index).trimEnd()}\n\n${changelog.slice(end).trimStart()}`;
271
271
  }
272
272
 
273
- function renderReleaseBlock(version, fragments, date = new Date().toISOString().slice(0, 10)) {
273
+ function renderReleaseBlock(version, fragments, date = new Date().toISOString().slice(0, 10), marker = '🚀') {
274
274
  const fragmentsByType = new Map();
275
275
  for (const fragment of fragments) {
276
276
  const entries = fragmentsByType.get(fragment.type) || [];
@@ -278,7 +278,7 @@ function renderReleaseBlock(version, fragments, date = new Date().toISOString().
278
278
  fragmentsByType.set(fragment.type, entries);
279
279
  }
280
280
 
281
- const lines = [`## 🚀 [${version}] - ${date}`];
281
+ const lines = [`## ${marker ? `${marker} ` : ''}[${version}]${date ? ` - ${date}` : ''}`];
282
282
  for (const [type, config] of Object.entries(FRAGMENT_TYPES)) {
283
283
  const entries = fragmentsByType.get(type);
284
284
  if (!entries || entries.length === 0) continue;
package/src/git.js CHANGED
@@ -89,7 +89,7 @@ function gitCreateTagAndPush() {
89
89
  { name: 'changelog-release-version', run: requireChangelogReleaseVersion },
90
90
  {
91
91
  name: 'tag-missing',
92
- run: (ctx) => requireTagMissing(`v${ctx.version}`)
92
+ run: (ctx) => requireTagMissing(`release/${ctx.version}`)
93
93
  }
94
94
  ],
95
95
  steps: [
@@ -100,16 +100,16 @@ function gitCreateTagAndPush() {
100
100
  {
101
101
  name: 'create-tag',
102
102
  run: (ctx) => {
103
- if (!execCommand(`git tag v${ctx.version}`)) return false;
104
- logSuccess('🔖', 'Создан тег v%s.', ctx.version);
103
+ if (!execCommand(`git tag release/${ctx.version}`)) return false;
104
+ logSuccess('🔖', 'Создан тег release/%s.', ctx.version);
105
105
  return true;
106
106
  }
107
107
  },
108
108
  {
109
109
  name: 'push-tag',
110
110
  run: (ctx) => {
111
- if (!execCommand(`git push origin v${ctx.version}`)) return false;
112
- logSuccess('🚀', 'Тег v%s отправлен.', ctx.version);
111
+ if (!execCommand(`git push origin refs/tags/release/${ctx.version}:refs/tags/release/${ctx.version}`)) return false;
112
+ logSuccess('🚀', 'Тег release/%s отправлен.', ctx.version);
113
113
  return true;
114
114
  }
115
115
  }
@@ -0,0 +1,94 @@
1
+ const { FRAGMENT_TYPES, SECTION_TO_TYPE } = require('./changelog-config');
2
+ const { renderReleaseBlock } = require('./changelog');
3
+
4
+ // Parse release boundaries without rewriting any published text.
5
+ function parseChangelog(text) {
6
+ const headings = [...text.matchAll(/^## .+$/gm)];
7
+ const releases = headings.map((heading, index) => {
8
+ const match = heading[0].match(/^##[ \t]+(?:(🚀|🩹)[ \t]*)?\[(\d+\.\d+\.\d+)\](?:[ \t]+-[ \t]+(\d{4}-\d{2}-\d{2}|\d{4}\.\d{2}\.\d{2}|\d{2}\.\d{2}\.\d{4}))?[ \t]*\r?$/);
9
+ if (!match) {
10
+ throw new Error(`Некорректный заголовок CHANGELOG.md: ${heading[0]}. Ожидается ## [X.Y.Z], дата необязательна.`);
11
+ }
12
+ // Historical duplicates are preserved; callers validate the pending version and immutable history.
13
+ return {
14
+ version: match[2], date: match[3] || null, marker: match[1] || '',
15
+ text: text.slice(heading.index, headings[index + 1]?.index ?? text.length),
16
+ body: text.slice(heading.index + heading[0].length, headings[index + 1]?.index ?? text.length),
17
+ };
18
+ });
19
+ return { intro: text.slice(0, headings[0]?.index ?? text.length), releases };
20
+ }
21
+
22
+ function releaseEntries(release, patchOnly = false) {
23
+ const entries = [];
24
+ let type;
25
+ for (const raw of release.body.split(/\r?\n/)) {
26
+ const line = raw.trim();
27
+ if (!line) continue;
28
+ if (line.startsWith('### ')) {
29
+ type = SECTION_TO_TYPE[line];
30
+ if (!type || (patchOnly && FRAGMENT_TYPES[type].bump !== 'patch')) {
31
+ throw new Error(patchOnly ? 'Хотфикс допускает только patch-разделы CHANGELOG.md.' : 'Неизвестный раздел CHANGELOG.md.');
32
+ }
33
+ } else if (type && line.startsWith('- ')) {
34
+ entries.push({ type, section: FRAGMENT_TYPES[type].section, entries: [line] });
35
+ } else if (/^\s+\S/.test(raw) && entries.at(-1)?.type === type) {
36
+ // Formatters may wrap a bullet or its author link onto indented lines.
37
+ entries.at(-1).entries[0] += ` ${line}`;
38
+ } else {
39
+ throw new Error('Не удалось безопасно дополнить раздел релиза: неизвестный формат записи.');
40
+ }
41
+ }
42
+ if (!entries.length) throw new Error('Раздел релиза пуст.');
43
+ return entries;
44
+ }
45
+
46
+ function normalizeLineEndings(text) {
47
+ return text.replace(/\r\n/g, '\n');
48
+ }
49
+
50
+ function history(document) {
51
+ return document.releases.map((release) => normalizeLineEndings(release.text).trim()).join('\n\n');
52
+ }
53
+
54
+ function pendingDocument(text, stableText, target) {
55
+ const document = parseChangelog(text);
56
+ const stable = parseChangelog(stableText);
57
+ const pending = document.releases[0]?.version === target ? document.releases.shift() : null;
58
+ if (document.releases.some((release) => release.version === target)) {
59
+ throw new Error('Новый patch-раздел должен быть единственным и находиться в начале CHANGELOG.md.');
60
+ }
61
+ if (history(document) !== history(stable) || normalizeLineEndings(document.intro).trim() !== normalizeLineEndings(stable.intro).trim()) {
62
+ throw new Error('История CHANGELOG.md отличается от stable: допустим только верхний раздел следующего patch.');
63
+ }
64
+ return { document, pending, entries: pending ? releaseEntries(pending, true) : [] };
65
+ }
66
+
67
+ function prepareChangelog({ text, stableText, productionText, target, fragments, date }) {
68
+ const current = pendingDocument(text, stableText, target);
69
+ const production = pendingDocument(productionText, stableText, target);
70
+ const entryKey = (entry) => `${entry.type}:${entry.entries[0]}`;
71
+ const currentKeys = new Set(current.entries.map(entryKey));
72
+ if (production.entries.some((entry) => !currentKeys.has(entryKey(entry)))) {
73
+ throw new Error('Сначала перенесите в hotfix актуальный раздел из production; его записи нельзя терять.');
74
+ }
75
+ if (!fragments.length) {
76
+ if (!current.pending) throw new Error('Нет новых hotfix fragments или подготовленного раздела.');
77
+ return text;
78
+ }
79
+ const merged = [...current.entries];
80
+ for (const fragment of fragments) {
81
+ if (fragment.bump !== 'patch') throw new Error('Хотфикс не может включать minor или major fragments.');
82
+ for (const line of fragment.entries) {
83
+ const entry = { ...fragment, entries: [line] };
84
+ if (!currentKeys.has(entryKey(entry))) merged.push(entry);
85
+ currentKeys.add(entryKey(entry));
86
+ }
87
+ }
88
+ const block = renderReleaseBlock(target, merged, current.pending ? current.pending.date : date, '🩹');
89
+ // Preserve the published tail byte-for-byte, including its spacing.
90
+ const tail = current.document.releases.map((release) => release.text).join('');
91
+ return `${current.document.intro.trimEnd()}\n\n${block.trim()}\n${tail ? `\n${tail}` : ''}`;
92
+ }
93
+
94
+ module.exports = { parseChangelog, releaseEntries, pendingDocument, prepareChangelog, normalizeLineEndings };
package/src/hotfix.js ADDED
@@ -0,0 +1,223 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execFileSync } = require('child_process');
4
+ const preflight = require('./preflight');
5
+ const { execCommand, logSuccess, logError } = require('./utils');
6
+ const { upVersion, compareVersions } = require('./version');
7
+ const { STABLE_TAG_PATTERNS, parseStableTag, stableTagNames } = require('./stable-tags');
8
+ const { CHANGELOG_FILE, CHANGELOG_DIR, FRAGMENT_TYPES, getFragmentType } = require('./changelog-config');
9
+ const { parseChangelog, pendingDocument, prepareChangelog, normalizeLineEndings } = require('./hotfix-changelog');
10
+
11
+ function git(...args) {
12
+ try {
13
+ return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
14
+ } catch {
15
+ throw new Error(`Git не выполнил «${args[0]}». Проверьте доступ к origin, состояние веток и конфликты.`);
16
+ }
17
+ }
18
+
19
+ function exists(ref) {
20
+ try {
21
+ execFileSync('git', ['show-ref', '--verify', '--quiet', ref], { stdio: 'ignore' });
22
+ return true;
23
+ } catch (error) {
24
+ if (error.status === 1) return false;
25
+ throw new Error(`Не удалось проверить ref ${ref}.`);
26
+ }
27
+ }
28
+
29
+ function ancestor(base, head) {
30
+ try {
31
+ execFileSync('git', ['merge-base', '--is-ancestor', base, head], { stdio: 'ignore' });
32
+ return true;
33
+ } catch (error) {
34
+ if (error.status === 1) return false;
35
+ throw new Error('Не удалось проверить историю Git.');
36
+ }
37
+ }
38
+
39
+ function readAt(ref, file = CHANGELOG_FILE) {
40
+ return git('show', `${ref}:${file}`);
41
+ }
42
+
43
+ function context(mode) {
44
+ const root = git('rev-parse', '--show-toplevel').trim();
45
+ if (path.relative(fs.realpathSync(root), fs.realpathSync(process.cwd())) !== '') {
46
+ throw new Error('Запустите hotfix из корня репозитория.');
47
+ }
48
+ const branch = git('branch', '--show-current').trim();
49
+ if (!['main', 'master'].includes(branch) && !/^hotfix\/.+/.test(branch)) {
50
+ throw new Error('Hotfix-команды разрешены только на hotfix/*, main или master (не detached HEAD).');
51
+ }
52
+ if (mode !== 'start' && !['main', 'master'].includes(branch)) {
53
+ throw new Error('hotfix deploy/close выполняются на main/master после merge хотфикса.');
54
+ }
55
+ if (mode === 'start' && !['main', 'master'].includes(branch) && !/^hotfix\/[A-Z]+-[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(branch)) {
56
+ throw new Error('hotfix start выполняется на hotfix/<TASK>[-slug], main или master.');
57
+ }
58
+ if (mode !== 'start' && git('status', '--porcelain').trim()) {
59
+ throw new Error('Для deploy/close требуется чистое рабочее дерево.');
60
+ }
61
+ if (git('ls-files', '--unmerged').trim()) throw new Error('Сначала разрешите конфликты Git.');
62
+ git('fetch', 'origin', '--prune', '--tags');
63
+ const mainBranch = ['master', 'main'].find((name) => exists(`refs/remotes/origin/${name}`));
64
+ if (!mainBranch) throw new Error('Не найдена production-ветка origin/main или origin/master.');
65
+ const mainRef = `refs/remotes/origin/${mainBranch}`;
66
+ const mainSha = git('rev-parse', mainRef).trim();
67
+ const head = git('rev-parse', 'HEAD').trim();
68
+ if (['main', 'master'].includes(branch) && branch !== mainBranch) {
69
+ throw new Error(`Production-ветка origin — ${mainBranch}. Переключитесь на неё или на hotfix/<TASK>[-slug].`);
70
+ }
71
+ if (mode === 'start') {
72
+ if (!ancestor(mainSha, head)) throw new Error('Сначала обновите hotfix из актуальной production-ветки.');
73
+ } else if (branch !== mainBranch || head !== mainSha) {
74
+ throw new Error(`Нужна ветка ${mainBranch}, точно совпадающая с origin/${mainBranch}.`);
75
+ }
76
+ // Query origin explicitly: a local-only tag must never change the baseline.
77
+ const remoteTags = new Map(git('ls-remote', '--refs', '--tags', 'origin', ...STABLE_TAG_PATTERNS.map((tag) => `refs/tags/${tag}`))
78
+ .trim().split('\n').filter(Boolean).map((line) => {
79
+ const [sha, ref] = line.split(/\s+/);
80
+ return [ref.slice('refs/tags/'.length), sha];
81
+ }));
82
+ const versions = git('tag', '--merged', mainSha, '--list', ...STABLE_TAG_PATTERNS).trim().split('\n')
83
+ .filter((tag) => parseStableTag(tag) && remoteTags.has(tag))
84
+ .map((tag) => ({ tag, ...parseStableTag(tag) })).sort((a, b) => compareVersions(a.version, b.version));
85
+ const latest = versions.at(-1);
86
+ const stable = latest?.version;
87
+ if (!stable) throw new Error('Для хотфикса необходим опубликованный stable-тег production.');
88
+ const stableTag = latest.tag;
89
+ const stableSha = git('rev-parse', `refs/tags/${stableTag}^{commit}`).trim();
90
+ for (const tag of stableTagNames(stable).filter((name) => remoteTags.has(name))) {
91
+ if (git('rev-parse', `refs/tags/${tag}^{commit}`).trim() !== stableSha) {
92
+ throw new Error(`Конфликт stable-тегов версии ${stable}: разные commits.`);
93
+ }
94
+ }
95
+ const stableText = readAt(`refs/tags/${stableTag}`);
96
+ const productionText = readAt(mainSha);
97
+ const target = upVersion(stable, 'patch');
98
+ return { mainBranch, mainSha, head, remoteTags, stable, stableTag, stableText, productionText, target };
99
+ }
100
+
101
+ function assertTagAvailable(ctx, allowMatchingLocal = false) {
102
+ for (const tag of stableTagNames(ctx.target)) {
103
+ if (ctx.remoteTags.has(tag)) throw new Error(`Тег ${tag} уже опубликован; эту версию нельзя выпустить повторно.`);
104
+ if (exists(`refs/tags/${tag}`) &&
105
+ (!allowMatchingLocal || tag !== `hotfix/${ctx.target}` || git('rev-parse', `refs/tags/${tag}^{commit}`).trim() !== ctx.head)) {
106
+ throw new Error(`Локальный тег ${tag} уже существует. Проверьте его перед продолжением.`);
107
+ }
108
+ }
109
+ }
110
+
111
+ function assertOriginUnchanged(ctx) {
112
+ const line = git('ls-remote', '--heads', 'origin', `refs/heads/${ctx.mainBranch}`).trim();
113
+ if (line.split(/\s+/)[0] !== ctx.mainSha) {
114
+ throw new Error('Production изменилась во время проверки. Обновите ветку и повторите команду.');
115
+ }
116
+ }
117
+
118
+ function newFragments(baseRef) {
119
+ const inherited = git('ls-tree', '-r', '-z', '--name-only', baseRef, '--', `${CHANGELOG_DIR}/`)
120
+ .split('\0').filter(Boolean);
121
+ for (const file of inherited) {
122
+ if (!fs.existsSync(file) || normalizeLineEndings(fs.readFileSync(file, 'utf8')) !== normalizeLineEndings(readAt(baseRef, file))) {
123
+ throw new Error(`Нельзя изменять унаследованный fragment ${file} в хотфиксе.`);
124
+ }
125
+ }
126
+ if (!fs.existsSync(CHANGELOG_DIR)) return [];
127
+ return fs.readdirSync(CHANGELOG_DIR).sort().filter((name) => !name.startsWith('.'))
128
+ .map((name) => `${CHANGELOG_DIR}/${name}`).filter((file) => !inherited.includes(file))
129
+ .map((filePath) => {
130
+ const type = getFragmentType(filePath);
131
+ if (!type || !fs.lstatSync(filePath).isFile()) throw new Error(`Некорректный fragment ${filePath}.`);
132
+ const content = fs.readFileSync(filePath, 'utf8');
133
+ const entries = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
134
+ if (!entries.length || entries.some((line) => !line.startsWith('- ') || line.length > 120)) {
135
+ throw new Error(`Пустой или неверно оформленный fragment ${filePath}: записи «- …», до 120 символов.`);
136
+ }
137
+ if (FRAGMENT_TYPES[type].bump !== 'patch') throw new Error(`Хотфикс не допускает minor/major: ${filePath}.`);
138
+ return { filePath, content, type, ...FRAGMENT_TYPES[type], entries };
139
+ });
140
+ }
141
+
142
+ function formatChangelog() {
143
+ const runner = preflight.getPrettierRunner();
144
+ if (!runner || !execCommand(`${runner} --write CHANGELOG.md`) || !execCommand(`${runner} --check CHANGELOG.md`)) {
145
+ throw new Error('Не удалось отформатировать CHANGELOG.md; hotfix fragments сохранены.');
146
+ }
147
+ }
148
+
149
+ function run(action) {
150
+ try {
151
+ action();
152
+ return true;
153
+ } catch (error) {
154
+ logError('❌', error.message);
155
+ return false;
156
+ }
157
+ }
158
+
159
+ function hotfixStart() {
160
+ return run(() => {
161
+ const ctx = context('start');
162
+ assertTagAvailable(ctx);
163
+ const fragments = newFragments(ctx.mainSha);
164
+ const original = fs.readFileSync(CHANGELOG_FILE, 'utf8');
165
+ const updated = prepareChangelog({ text: original, ...ctx, fragments });
166
+ assertOriginUnchanged(ctx);
167
+ if (updated !== original || fragments.length) {
168
+ try {
169
+ fs.writeFileSync(CHANGELOG_FILE, updated);
170
+ formatChangelog();
171
+ // Formatting must not rewrite published history or lose merged notes.
172
+ prepareChangelog({ text: fs.readFileSync(CHANGELOG_FILE, 'utf8'), ...ctx, fragments: [] });
173
+ for (const fragment of fragments) fs.unlinkSync(fragment.filePath);
174
+ } catch (error) {
175
+ fs.writeFileSync(CHANGELOG_FILE, original);
176
+ for (const fragment of fragments) fs.writeFileSync(fragment.filePath, fragment.content);
177
+ throw error;
178
+ }
179
+ }
180
+ logSuccess('✅', `Хотфикс ${ctx.target} подготовлен локально. Проверьте diff и включите CHANGELOG.md и удаления fragments в MR в ${ctx.mainBranch}.`);
181
+ });
182
+ }
183
+
184
+ function hotfixDeploy() {
185
+ return run(() => {
186
+ const ctx = context('deploy');
187
+ const prepared = pendingDocument(ctx.productionText, ctx.stableText, ctx.target);
188
+ if (!prepared.pending) throw new Error(`В CHANGELOG.md нужен верхний подготовленный раздел ${ctx.target}.`);
189
+ if (newFragments(`refs/tags/${ctx.stableTag}`).length) throw new Error('В production остались несобранные hotfix fragments.');
190
+ assertTagAvailable(ctx, true);
191
+ assertOriginUnchanged(ctx);
192
+ const tag = `hotfix/${ctx.target}`;
193
+ if (!exists(`refs/tags/${tag}`)) git('tag', tag, ctx.head);
194
+ git('push', 'origin', `refs/tags/${tag}:refs/tags/${tag}`);
195
+ logSuccess('✅', `Опубликован ${tag} на ${ctx.head}. Дождитесь успешного stable pipeline перед hotfix close.`);
196
+ });
197
+ }
198
+
199
+ function hotfixClose() {
200
+ return run(() => {
201
+ const ctx = context('close');
202
+ const top = parseChangelog(ctx.productionText).releases[0]?.version;
203
+ const tag = `hotfix/${ctx.stable}`;
204
+ if (top !== ctx.stable || !ctx.remoteTags.has(tag) || git('rev-parse', `refs/tags/${tag}^{commit}`).trim() !== ctx.head) {
205
+ throw new Error('Сначала опубликуйте hotfix/X.Y.Z на текущем production commit.');
206
+ }
207
+ const dev = ['develop', 'dev'].find((name) => exists(`refs/remotes/origin/${name}`));
208
+ if (!dev) throw new Error('Не найдена integration-ветка origin/dev или origin/develop.');
209
+ const devRef = `refs/remotes/origin/${dev}`;
210
+ if (exists(`refs/heads/${dev}`) && !ancestor(`refs/heads/${dev}`, devRef)) {
211
+ throw new Error(`В ${dev} есть локальные непубликуемые commits. Сначала согласуйте их с origin/${dev}.`);
212
+ }
213
+ assertOriginUnchanged(ctx);
214
+ if (exists(`refs/heads/${dev}`)) git('switch', dev);
215
+ else git('switch', '--track', '-c', dev, devRef);
216
+ git('merge', '--ff-only', devRef);
217
+ git('merge', '--no-edit', ctx.head);
218
+ git('push', 'origin', `refs/heads/${dev}:refs/heads/${dev}`);
219
+ logSuccess('✅', `Хотфикс ${ctx.stable} сведён в ${dev}; production и файлы версий не изменены.`);
220
+ });
221
+ }
222
+
223
+ module.exports = { hotfixStart, hotfixDeploy, hotfixClose };
@@ -0,0 +1,80 @@
1
+ const fs = require('fs');
2
+ const { execSilent, logError, logSuccess } = require('./utils');
3
+ const { compareVersions } = require('./version');
4
+ const { CHANGELOG_FILE, CHANGELOG_DIR } = require('./changelog-config');
5
+ const { parseChangelog, releaseEntries, normalizeLineEndings } = require('./hotfix-changelog');
6
+ const { renderReleaseBlock, stripLegacyUnreleasedBlock } = require('./changelog');
7
+ const { requireChangelogFragments } = require('./preflight');
8
+
9
+ function splitOpenRelease(text, stableVersion) {
10
+ const document = parseChangelog(stripLegacyUnreleasedBlock(text));
11
+ const pending = document.releases.filter((release) => compareVersions(release.version, stableVersion) > 0);
12
+ if (pending.length > 1 || (pending.length && document.releases[0] !== pending[0])) {
13
+ throw new Error('Допустим только один открытый релиз в верхнем разделе CHANGELOG.md.');
14
+ }
15
+ const open = pending[0] || null;
16
+ if (open) document.releases.shift();
17
+ return { document, open, entries: open ? releaseEntries(open) : [] };
18
+ }
19
+
20
+ function resolveReleaseState(localText, productionText, stableVersion) {
21
+ const local = splitOpenRelease(localText, stableVersion);
22
+ const production = splitOpenRelease(productionText, stableVersion);
23
+ if (local.open && production.open && local.open.version !== production.open.version) {
24
+ throw new Error('Открытые версии в dev и production различаются. Сначала согласуйте CHANGELOG.md.');
25
+ }
26
+ const history = ({ document }) => document.releases.map((release) => normalizeLineEndings(release.text).trim()).join('\n\n');
27
+ if (history(local) !== history(production)) {
28
+ throw new Error('Опубликованная история CHANGELOG.md изменена относительно production.');
29
+ }
30
+ return { openReleaseVersion: production.open?.version || local.open?.version || null, local, production };
31
+ }
32
+
33
+ function requireReleaseState(context) {
34
+ try {
35
+ const productionText = execSilent(`git show refs/remotes/origin/${context.mainBranch}:${CHANGELOG_FILE}`);
36
+ if (productionText === null) throw new Error('Не удалось прочитать CHANGELOG.md из production.');
37
+ const state = resolveReleaseState(fs.readFileSync(CHANGELOG_FILE, 'utf8'), productionText, context.stableVersion);
38
+ return { ok: true, data: { openReleaseVersion: state.openReleaseVersion, releaseChangelogState: state } };
39
+ } catch (error) {
40
+ return { ok: false, reason: error.message };
41
+ }
42
+ }
43
+
44
+ function requireReleaseFragments(context) {
45
+ try {
46
+ // Empty is valid only for an already prepared release, including retry after a failed push.
47
+ const entries = fs.existsSync(CHANGELOG_DIR) ? fs.readdirSync(CHANGELOG_DIR).filter((name) => !name.startsWith('.')) : [];
48
+ if (context.openReleaseVersion && !entries.length) return { ok: true, data: { changelogFragments: [] } };
49
+ return requireChangelogFragments();
50
+ } catch (error) {
51
+ return { ok: false, reason: error.message };
52
+ }
53
+ }
54
+
55
+ function appendOpenRelease(context) {
56
+ try {
57
+ const { local, production } = context.releaseChangelogState;
58
+ const open = production.open || local.open;
59
+ const keys = new Set();
60
+ const merged = [];
61
+ for (const fragment of [...production.entries, ...local.entries, ...context.changelogFragments]) {
62
+ for (const entry of fragment.entries) {
63
+ const key = `${fragment.type}:${entry}`;
64
+ if (!keys.has(key)) merged.push({ ...fragment, entries: [entry] });
65
+ keys.add(key);
66
+ }
67
+ }
68
+ const block = renderReleaseBlock(open.version, merged, open.date, open.marker);
69
+ const tail = local.document.releases.map((release) => release.text).join('');
70
+ const updated = `${local.document.intro.trimEnd()}\n\n${block.trim()}\n${tail ? `\n${tail}` : ''}`;
71
+ fs.writeFileSync(CHANGELOG_FILE, updated);
72
+ logSuccess('📋', 'Дополнен открытый релиз %s без повышения версии.', open.version);
73
+ return true;
74
+ } catch (error) {
75
+ logError('❌', 'Не удалось дополнить открытый релиз: %s', error.message);
76
+ return false;
77
+ }
78
+ }
79
+
80
+ module.exports = { resolveReleaseState, requireReleaseState, requireReleaseFragments, appendOpenRelease };
package/src/preflight.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  const fs = require('fs');
3
+ const { parseStableTag, stableTagNames } = require('./stable-tags');
3
4
  const path = require('path');
4
5
  const { execSilent, execCommand, getCurrentBranch, getMainBranch, getDevelopBranch } = require('./utils');
5
6
  const {
@@ -154,6 +155,15 @@ function requireCurrentBranchUpToDateWithRemote() {
154
155
  return ok({ currentBranch, upstreamBranch: upstreamBranch.trim() });
155
156
  }
156
157
 
158
+ function requireDevContainsRemoteMain() {
159
+ const mainBranch = getMainBranch();
160
+ const remoteMain = `origin/${mainBranch}`;
161
+ if (!execCommand(`git merge-base --is-ancestor ${remoteMain} HEAD`)) {
162
+ return fail(`Текущая ветка не содержит ${remoteMain}. Смержите ${remoteMain} в dev и повторите попытку.`);
163
+ }
164
+ return ok({ remoteMain });
165
+ }
166
+
157
167
  function requireFileExists(filePath) {
158
168
  if (!fs.existsSync(filePath)) {
159
169
  return fail(`Обязательный файл "${filePath}" не существует.`);
@@ -234,7 +244,7 @@ function requireNoPendingRelease(stableVersion) {
234
244
 
235
245
  if (pendingVersions.length > 0) {
236
246
  return fail(
237
- `В ${CHANGELOG_FILE} найдены незакрытые релизы новее стабильного тега v${stableVersion}: ${pendingVersions.join(', ')}. ` +
247
+ `В ${CHANGELOG_FILE} найдены незакрытые релизы новее stable-версии ${stableVersion}: ${pendingVersions.join(', ')}. ` +
238
248
  'Сначала выполните "spectrum release deploy", дождитесь успешного stable pipeline и выполните "spectrum release close".'
239
249
  );
240
250
  }
@@ -244,29 +254,29 @@ function requireNoPendingRelease(stableVersion) {
244
254
 
245
255
  function requireLatestStableVersion() {
246
256
  const mainBranch = getMainBranch();
247
- const tagNames = execSilent(`git tag --merged origin/${mainBranch} --list "v*"`);
248
- const remoteTagRefs = execSilent('git ls-remote --refs --tags origin "refs/tags/v*"');
257
+ const tagNames = execSilent(`git tag --merged origin/${mainBranch} --list "release/*" "hotfix/*" "v*"`);
258
+ const remoteTagRefs = execSilent('git ls-remote --refs --tags origin "refs/tags/release/*" "refs/tags/hotfix/*" "refs/tags/v*"');
249
259
  if (tagNames === null || remoteTagRefs === null) {
250
260
  return fail(`Не удалось проверить стабильные теги, достижимые из origin/${mainBranch}.`);
251
261
  }
252
262
 
253
263
  const remoteTagNames = new Set();
254
264
  for (const line of remoteTagRefs.split('\n')) {
255
- const match = line.trim().match(/^[0-9a-f]+\s+refs\/tags\/(v\d+\.\d+\.\d+)$/i);
256
- if (match) remoteTagNames.add(match[1]);
265
+ const match = line.trim().match(/^[0-9a-f]+\s+refs\/tags\/(.+)$/i);
266
+ if (match && parseStableTag(match[1])) remoteTagNames.add(match[1]);
257
267
  }
258
268
 
259
269
  const versions = new Set();
260
270
  for (const line of tagNames.split('\n')) {
261
271
  const tagName = line.trim();
262
- const match = tagName.match(/^v(\d+\.\d+\.\d+)$/);
263
- if (match && remoteTagNames.has(tagName) && STABLE_SEMVER_PATTERN.test(match[1])) {
264
- versions.add(match[1]);
272
+ const parsed = parseStableTag(tagName);
273
+ if (parsed && remoteTagNames.has(tagName)) {
274
+ versions.add(parsed.version);
265
275
  }
266
276
  }
267
277
 
268
278
  if (versions.size === 0) {
269
- return fail(`В origin/${mainBranch} не найден достижимый стабильный тег vX.Y.Z.`);
279
+ return fail(`В origin/${mainBranch} не найден достижимый stable-тег release/X.Y.Z, hotfix/X.Y.Z или vX.Y.Z.`);
270
280
  }
271
281
 
272
282
  const stableVersion = [...versions].sort(compareStableVersions).at(-1);
@@ -274,34 +284,19 @@ function requireLatestStableVersion() {
274
284
  }
275
285
 
276
286
  function requireStableTagAtHead(version) {
277
- if (!STABLE_SEMVER_PATTERN.test(String(version || ''))) {
278
- return fail('Для закрытия релиза требуется стабильная версия X.Y.Z.');
279
- }
280
-
287
+ if (!STABLE_SEMVER_PATTERN.test(String(version || ''))) return fail('Требуется stable-версия X.Y.Z.');
281
288
  const head = execSilent('git rev-parse HEAD');
282
- const tagRefs = execSilent(`git ls-remote --tags origin "refs/tags/v${version}" "refs/tags/v${version}^{}"`);
283
- if (!head || tagRefs === null) {
284
- return fail(`Не удалось проверить стабильный тег "v${version}".`);
285
- }
286
-
287
- let tagCommit = null;
288
- for (const line of tagRefs.split('\n')) {
289
+ const names = stableTagNames(version);
290
+ const patterns = names.flatMap((tag) => [`"refs/tags/${tag}"`, `"refs/tags/${tag}^{}"`]).join(' ');
291
+ const tagRefs = execSilent(`git ls-remote --tags origin ${patterns}`);
292
+ if (!head || tagRefs === null) return fail(`Не удалось проверить stable-теги версии ${version}.`);
293
+ const refs = new Map(tagRefs.trim().split('\n').filter(Boolean).map((line) => {
289
294
  const [sha, ref] = line.trim().split(/\s+/);
290
- if (ref === `refs/tags/v${version}^{}`) {
291
- tagCommit = sha;
292
- break;
293
- }
294
- if (ref === `refs/tags/v${version}`) {
295
- tagCommit = sha;
296
- }
297
- }
298
-
299
- if (!tagCommit) {
300
- return fail(`Стабильный тег "v${version}" отсутствует в origin.`);
301
- }
302
- if (tagCommit !== head) {
303
- return fail(`Стабильный тег "v${version}" указывает не на текущий commit main/master.`);
304
- }
295
+ return [ref, sha];
296
+ }));
297
+ const commits = names.map((tag) => refs.get(`refs/tags/${tag}^{}`) || refs.get(`refs/tags/${tag}`)).filter(Boolean);
298
+ if (!commits.length) return fail(`Stable-тег версии ${version} отсутствует в origin.`);
299
+ if (commits.some((commit) => commit !== head)) return fail(`Stable-тег ${version} не совпадает с HEAD или конфликтует с alias.`);
305
300
  return ok({ stableVersion: version });
306
301
  }
307
302
 
@@ -313,16 +308,14 @@ function requireYouTrackTask(task) {
313
308
  }
314
309
 
315
310
  function requireTagMissing(tagName) {
316
- const localTag = execSilent(`git tag -l "${tagName}"`);
317
- if (localTag && localTag.trim()) {
318
- return fail(`Тег "${tagName}" уже существует локально.`);
319
- }
320
-
321
- const remoteTag = execSilent(`git ls-remote --tags origin "refs/tags/${tagName}"`);
322
- if (remoteTag && remoteTag.trim()) {
323
- return fail(`Тег "${tagName}" уже существует на origin.`);
324
- }
325
-
311
+ const parsed = parseStableTag(tagName);
312
+ const names = parsed ? stableTagNames(parsed.version) : [tagName];
313
+ const localTag = execSilent(`git tag -l ${names.map((tag) => `"${tag}"`).join(' ')}`);
314
+ if (localTag === null) return fail('Не удалось проверить локальные теги.');
315
+ if (localTag.trim()) return fail(`Версия тега ${tagName} уже существует локально: ${localTag}.`);
316
+ const remoteTag = execSilent(`git ls-remote --tags origin ${names.map((tag) => `"refs/tags/${tag}"`).join(' ')}`);
317
+ if (remoteTag === null) return fail('Не удалось проверить теги origin.');
318
+ if (remoteTag.trim()) return fail(`Версия тега ${tagName} уже существует на origin.`);
326
319
  return ok();
327
320
  }
328
321
 
@@ -344,17 +337,19 @@ function requireReleaseVersionAvailable(version) {
344
337
  return fail(`Версия "${version}" уже используется hotfix-веткой в origin.`);
345
338
  }
346
339
 
347
- const localTag = execSilent(`git tag --list "v${version}"`);
340
+ const names = stableTagNames(version);
341
+ const localTag = execSilent(`git tag --list ${names.map((tag) => `"${tag}"`).join(' ')}`);
342
+ if (localTag === null) return fail('Не удалось проверить локальные stable-теги.');
348
343
  if (localTag && localTag.trim()) {
349
- return fail(`Релизный тег "v${version}" уже существует локально.`);
344
+ return fail(`Релизный тег версии ${version} уже существует локально.`);
350
345
  }
351
346
 
352
- const remoteTag = execSilent(`git ls-remote --tags origin "refs/tags/v${version}" "refs/tags/v${version}^{}"`);
347
+ const remoteTag = execSilent(`git ls-remote --tags origin ${names.flatMap((tag) => [`"refs/tags/${tag}"`, `"refs/tags/${tag}^{}"`]).join(' ')}`);
353
348
  if (remoteTag === null) {
354
- return fail(`Не удалось проверить тег "v${version}" в origin.`);
349
+ return fail(`Не удалось проверить тег версии ${version} в origin.`);
355
350
  }
356
351
  if (remoteTag && remoteTag.trim()) {
357
- return fail(`Релизный тег "v${version}" уже существует в origin.`);
352
+ return fail(`Релизный тег версии ${version} уже существует в origin.`);
358
353
  }
359
354
  return ok();
360
355
  }
@@ -772,6 +767,7 @@ module.exports = {
772
767
  requireRemoteOrigin,
773
768
  requireRemoteReachable,
774
769
  requireCurrentBranchUpToDateWithRemote,
770
+ requireDevContainsRemoteMain,
775
771
  requireFileExists,
776
772
  getChangelogReleaseVersions,
777
773
  getChangelogReleaseVersion,
package/src/release.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  const { goToDevBranch, goToMainBranch, updateCurrentBranch } = require('./git');
3
3
  const { changelogBuildRelease, changelogRemoveFragments } = require('./changelog');
4
- const { logSuccess, logError, execCommand, getCurrentBranch, getMainBranch } = require('./utils');
4
+ const { logSuccess, logError, execCommand, execSilent, getCurrentBranch, getMainBranch } = require('./utils');
5
+ const { requireReleaseState, requireReleaseFragments, appendOpenRelease } = require('./open-release');
5
6
  const { runCommand } = require('./command-executor');
6
7
  const { upVersion } = require('./version');
7
8
  const {
@@ -10,15 +11,14 @@ const {
10
11
  requireCurrentBranchUpToDateWithRemote,
11
12
  requireMainAndDevBranches,
12
13
  requireOnDevBranch,
14
+ requireDevContainsRemoteMain,
13
15
  requireOnMainBranch,
14
16
  requireReleaseVersionAvailable,
15
17
  requireChangelogReleaseVersion,
16
- requireNoPendingRelease,
17
18
  requireLatestStableVersion,
18
19
  requireStableTagAtHead,
19
20
  requireFileExists,
20
21
  requireChangelogFormatted,
21
- requireChangelogFragments,
22
22
  getPrettierRunner
23
23
  } = require('./preflight');
24
24
  const { CHANGELOG_FILE, CHANGELOG_DIR } = require('./changelog-config');
@@ -65,7 +65,14 @@ function resolveReleaseVersion(stableVersion, fragments) {
65
65
  }
66
66
 
67
67
  function releaseCommit(context) {
68
- if (!execCommand(`git add --all -- ${CHANGELOG_FILE} ${CHANGELOG_DIR}`)) return false;
68
+ const paths = context.changelogFragments?.length ? `${CHANGELOG_FILE} ${CHANGELOG_DIR}` : CHANGELOG_FILE;
69
+ if (!execCommand(`git add --all -- ${paths}`)) return false;
70
+ const staged = execSilent('git diff --cached --name-only');
71
+ if (staged === null) return false;
72
+ if (!staged.trim()) {
73
+ logSuccess('📋', 'Новых changelog-записей нет; пустой коммит не создаётся.');
74
+ return true;
75
+ }
69
76
  if (!execCommand(`git commit --message "📝 Подготовить релиз ${context.newVersion}." --no-verify`)) return false;
70
77
 
71
78
  logSuccess('📝', 'Коммит со схлопнутыми changelog fragments создан.');
@@ -155,15 +162,18 @@ function releaseStart() {
155
162
  { name: 'branch-up-to-date', run: requireCurrentBranchUpToDateWithRemote },
156
163
  { name: 'main-and-dev-branches', run: requireMainAndDevBranches },
157
164
  { name: 'on-dev-branch', run: requireOnDevBranch },
165
+ { name: 'dev-contains-main', run: requireDevContainsRemoteMain },
158
166
  { name: 'stable-version', run: requireLatestStableVersion },
159
167
  { name: 'changelog-exists', run: () => requireFileExists(CHANGELOG_FILE) },
160
- { name: 'no-pending-release', run: (ctx) => requireNoPendingRelease(ctx.stableVersion) },
168
+ { name: 'release-state', run: requireReleaseState },
161
169
  { name: 'changelog-prettier-check', run: requireChangelogFormatted },
162
- { name: 'changelog-fragments', run: requireChangelogFragments },
170
+ { name: 'changelog-fragments', run: requireReleaseFragments },
163
171
  {
164
172
  name: 'detect-bump-type',
165
173
  run: (ctx) => {
166
- const resolved = resolveReleaseVersion(ctx.stableVersion, ctx.changelogFragments);
174
+ const resolved = ctx.openReleaseVersion
175
+ ? { bumpType: detectBumpType(ctx.changelogFragments), newVersion: ctx.openReleaseVersion }
176
+ : resolveReleaseVersion(ctx.stableVersion, ctx.changelogFragments);
167
177
  if (!resolved) {
168
178
  return { ok: false, reason: 'Не удалось вычислить release-версию от последнего стабильного тега.' };
169
179
  }
@@ -175,10 +185,10 @@ function releaseStart() {
175
185
  }
176
186
  ],
177
187
  steps: [
178
- { name: 'build-release-changelog', run: changelogBuildRelease },
179
- { name: 'remove-changelog-fragments', run: changelogRemoveFragments },
188
+ { name: 'build-release-changelog', run: (ctx) => ctx.openReleaseVersion ? appendOpenRelease(ctx) : changelogBuildRelease(ctx) },
180
189
  { name: 'format-changelog', run: releaseFormatChangelog },
181
190
  { name: 'lint-changelog', run: releaseCheckChangelogLint },
191
+ { name: 'remove-changelog-fragments', run: (ctx) => !ctx.changelogFragments.length || changelogRemoveFragments(ctx) },
182
192
  { name: 'commit-release', run: releaseCommit },
183
193
  { name: 'push-dev-and-main', run: releasePush }
184
194
  ]
@@ -0,0 +1,15 @@
1
+ const { STABLE_VERSION_PATTERN } = require('./version');
2
+ const STABLE_TAG_PATTERNS = ['release/*', 'hotfix/*', 'v*'];
3
+
4
+ function parseStableTag(tag) {
5
+ const match = /^(release\/|hotfix\/|v)(.*)$/.exec(String(tag || ''));
6
+ if (!match || !STABLE_VERSION_PATTERN.test(match[2])) return null;
7
+ return { version: match[2], kind: match[1] === 'hotfix/' ? 'hotfix' : 'release' };
8
+ }
9
+
10
+ function stableTagNames(version) {
11
+ if (!STABLE_VERSION_PATTERN.test(String(version || ''))) throw new Error('Ожидается stable-версия X.Y.Z.');
12
+ return [`release/${version}`, `hotfix/${version}`, `v${version}`];
13
+ }
14
+
15
+ module.exports = { STABLE_TAG_PATTERNS, parseStableTag, stableTagNames };