spectrum-cli 3.0.1 → 3.1.0
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 +20 -0
- package/README.md +84 -17
- package/index.js +15 -2
- package/package.json +1 -1
- package/src/git.js +5 -5
- package/src/hotfix-changelog.js +88 -0
- package/src/hotfix.js +219 -0
- package/src/open-release.js +80 -0
- package/src/preflight.js +46 -50
- package/src/release.js +19 -9
- package/src/stable-tags.js +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ 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.0] - 2026-09-24
|
|
9
|
+
|
|
10
|
+
### 🆕 Added
|
|
11
|
+
|
|
12
|
+
- Добавлен цикл хотфикса: подготовка changelog без merge, публикация stable-тега и перенос исправления в dev.
|
|
13
|
+
|
|
14
|
+
### 🛠 Changed
|
|
15
|
+
|
|
16
|
+
- Релизы используют теги release/X.Y.Z, хотфиксы — hotfix/X.Y.Z; старые vX.Y.Z продолжают учитываться.
|
|
17
|
+
|
|
18
|
+
### 🪲 Fixed
|
|
19
|
+
|
|
20
|
+
- Повторный release start дополняет открытый релиз, сохраняя версию и удаляя использованные fragments.
|
|
21
|
+
|
|
22
|
+
## 🚀 [3.0.2] - 2026-09-15
|
|
23
|
+
|
|
24
|
+
### 🆕 Added
|
|
25
|
+
|
|
26
|
+
- `spectrum release start` проверяет, что текущая dev-ветка содержит `origin/main` или `origin/master`.
|
|
27
|
+
|
|
8
28
|
## 🚀 [3.0.1] - 2026-08-31
|
|
9
29
|
|
|
10
30
|
### 🪲 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 # Создать стабильный тег
|
|
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/*
|
|
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` | Создать стабильный тег
|
|
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-ветка,
|
|
129
|
-
- `spectrum release deploy`: чистая и актуальная main/master, стабильная версия `X.Y.Z` из верхнего заголовка `CHANGELOG.md`, отсутствие
|
|
130
|
-
- `spectrum release close`: чистая и актуальная main/master, версия из верхнего заголовка `CHANGELOG.md` и remote-тег
|
|
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. Проверяет
|
|
145
|
-
2. После `git fetch origin --prune --tags` находит максимальный
|
|
146
|
-
3.
|
|
147
|
-
4.
|
|
148
|
-
5.
|
|
149
|
-
|
|
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` и проверяет, что
|
|
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,71 @@ spectrum-cli/
|
|
|
160
168
|
### `spectrum release deploy`
|
|
161
169
|
|
|
162
170
|
1. Проверяет актуальную main/master и читает версию `X.Y.Z` из верхнего заголовка `CHANGELOG.md`.
|
|
163
|
-
2. Создает единственный release-тег `
|
|
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` требует task-backed hotfix-ветку, `deploy` и
|
|
186
|
+
`close` — актуальный production commit на `main/master` после merge MR.
|
|
187
|
+
|
|
188
|
+
### Изолированный цикл хотфикса
|
|
189
|
+
|
|
190
|
+
Создайте `hotfix/<TASK>` или `hotfix/<TASK>-<slug>` от актуального `origin/main`
|
|
191
|
+
или `origin/master`. Включайте только срочное исправление и его patch fragments
|
|
192
|
+
(`fixed`, `security`, `support` либо другой совместимый patch-тип).
|
|
193
|
+
`added` и `breaking` требуют обычного релиза. Все команды запускаются из корня
|
|
194
|
+
репозитория; для `start` нужен Prettier, как для обычной сборки changelog.
|
|
195
|
+
|
|
196
|
+
1. **`spectrum hotfix start`** обновляет сведения об origin и выбирает опубликованный
|
|
197
|
+
stable-тег, достижимый из production. Цель — этот stable плюс один patch.
|
|
198
|
+
Команда формирует верхний датированный раздел `CHANGELOG.md` и удаляет только
|
|
199
|
+
собранные fragments. Она не делает commit, staging, push, merge или tag,
|
|
200
|
+
не меняет файлы версий и сохраняет посторонние локальные изменения.
|
|
201
|
+
2. Повторный `start` дополняет тот же неопубликованный раздел, сохраняя дату и
|
|
202
|
+
существующие записи без дублей. Без новых fragments подготовленный раздел
|
|
203
|
+
остаётся без изменений. Например, от `v6.35.0` первый и повторные запуски
|
|
204
|
+
готовят `6.35.1`; только после публикации `hotfix/6.35.1` следующий хотфикс получит
|
|
205
|
+
`6.35.2`. Унаследованные от production fragments не собираются и не меняются.
|
|
206
|
+
3. Проверьте diff, закоммитьте и отправьте исправление по правилам проекта.
|
|
207
|
+
**В MR из hotfix в production уже должны быть верхний patch-раздел и удаления
|
|
208
|
+
использованных fragments.** Merge выполняется отдельно после ревью. Если
|
|
209
|
+
другой хотфикс уже в production, сначала перенесите его изменения в свою
|
|
210
|
+
ветку и повторите подготовку: пока stable-тега нет, раздел и версия общие.
|
|
211
|
+
4. После merge MR и проверки RC выполните **`spectrum hotfix deploy`** на чистом
|
|
212
|
+
`main/master`, точно совпадающем с origin. Команда проверяет следующий patch,
|
|
213
|
+
неизменность опубликованной истории и отсутствие несобранных hotfix fragments,
|
|
214
|
+
создаёт `hotfix/X.Y.Z` на проверенном SHA и отправляет **только этот тег**.
|
|
215
|
+
Повтор после ошибки push допускает существующий локальный тег лишь на том же
|
|
216
|
+
SHA; опубликованный тег не перемещается. Ветки не сливаются.
|
|
217
|
+
5. После успешного stable pipeline выполните **`spectrum hotfix close`** на том же
|
|
218
|
+
чистом production commit с тегом `hotfix/X.Y.Z`. Команда обновит `dev/develop` только
|
|
219
|
+
fast-forward, смержит production в integration и отправит integration.
|
|
220
|
+
Незавершённая работа и fragments в dev сохраняются. Локальные неопубликованные
|
|
221
|
+
commits блокируют автоматическую отправку; конфликт остаётся для ручного
|
|
222
|
+
разрешения, без push. При неуспешном push готового merge проверьте локальный
|
|
223
|
+
diff и согласуйте ветку вручную — повторный `close` не отправляет локальные
|
|
224
|
+
commits без проверки.
|
|
225
|
+
|
|
226
|
+
CLI не проверяет статус CI и не управляет кластером: проверка RC, успешного stable
|
|
227
|
+
pipeline и фактического выката выполняется отдельно в системе доставки. Обычный
|
|
228
|
+
`release start` не используется в этом цикле: он отправляет integration в production.
|
|
229
|
+
`changelog check` проверяет исходные fragments до сборки; после сборки сам `hotfix start`
|
|
230
|
+
проверяет подготовленный раздел. CI проекта должен принимать собранные заметки в MR,
|
|
231
|
+
не требуя повторного fragment для уже включённой записи.
|
|
232
|
+
|
|
166
233
|
### Цикл версий и CI
|
|
167
234
|
|
|
168
|
-
Источник
|
|
235
|
+
Источник версии — Git-теги `release/X.Y.Z`, `hotfix/X.Y.Z` и legacy `vX.Y.Z`. Файлы версий (`package.json`, `Makefile`) не читаются ни CLI, ни CI:
|
|
169
236
|
|
|
170
237
|
| Событие | Источник версии | Артефакт |
|
|
171
238
|
| --- | --- | --- |
|
|
@@ -173,11 +240,11 @@ spectrum-cli/
|
|
|
173
240
|
| коммиты в dev | тег `v1.0.0` + бамп из `.changelog/` | `1.1.0-alpha.SHORTSHA` |
|
|
174
241
|
| `release start` на dev | fragments схлопнуты в `## 🚀 [1.1.0]` | release commit в `dev` и `main/master` |
|
|
175
242
|
| merge в main/master | заголовок CHANGELOG `1.1.0` | `1.1.0-rc.1`, затем `rc.2`, ... |
|
|
176
|
-
| stable-тег `
|
|
177
|
-
| merge stable в dev (`release close`) | тег `
|
|
243
|
+
| stable-тег `release/1.1.0` | git-тег | проверенный RC продвигается в `1.1.0` |
|
|
244
|
+
| merge stable в dev (`release close`) | тег `release/1.1.0` + бамп fragments | `X.Y.Z-alpha.SHORTSHA` |
|
|
178
245
|
|
|
179
246
|
Номер RC хранится в registry и переиспользуется при retry того же commit SHA.
|
|
180
|
-
|
|
247
|
+
Новые stable-теги — `release/X.Y.Z` и `hotfix/X.Y.Z`; старые `vX.Y.Z` сохраняются. Stable pipeline проверяет OCI
|
|
181
248
|
revision обязательных образов и копирует точные RC digest без пересборки. Эту
|
|
182
249
|
CI-часть реализует подключенный release component, а не Spectrum CLI.
|
|
183
250
|
|
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("Схлопнуть
|
|
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("Создать и отправить только стабильный тег
|
|
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/* для MR, без 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
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(`
|
|
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
|
|
104
|
-
logSuccess('🔖', 'Создан тег
|
|
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
|
|
112
|
-
logSuccess('🚀', 'Тег
|
|
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,88 @@
|
|
|
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 seen = new Set();
|
|
8
|
+
const releases = headings.map((heading, index) => {
|
|
9
|
+
const match = heading[0].match(/^## (?:🚀 )?\[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})$/);
|
|
10
|
+
if (!match || seen.has(match[1])) {
|
|
11
|
+
throw new Error('CHANGELOG.md содержит некорректный или повторный раздел релиза.');
|
|
12
|
+
}
|
|
13
|
+
seen.add(match[1]);
|
|
14
|
+
return {
|
|
15
|
+
version: match[1], date: match[2],
|
|
16
|
+
text: text.slice(heading.index, headings[index + 1]?.index ?? text.length),
|
|
17
|
+
body: text.slice(heading.index + heading[0].length, headings[index + 1]?.index ?? text.length),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
return { intro: text.slice(0, headings[0]?.index ?? text.length), releases };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function releaseEntries(release, patchOnly = false) {
|
|
24
|
+
const entries = [];
|
|
25
|
+
let type;
|
|
26
|
+
for (const raw of release.body.split(/\r?\n/)) {
|
|
27
|
+
const line = raw.trim();
|
|
28
|
+
if (!line) continue;
|
|
29
|
+
if (line.startsWith('### ')) {
|
|
30
|
+
type = SECTION_TO_TYPE[line];
|
|
31
|
+
if (!type || (patchOnly && FRAGMENT_TYPES[type].bump !== 'patch')) {
|
|
32
|
+
throw new Error(patchOnly ? 'Хотфикс допускает только patch-разделы CHANGELOG.md.' : 'Неизвестный раздел CHANGELOG.md.');
|
|
33
|
+
}
|
|
34
|
+
} else if (type && line.startsWith('- ')) {
|
|
35
|
+
entries.push({ type, section: FRAGMENT_TYPES[type].section, entries: [line] });
|
|
36
|
+
} else if (/^\s+\S/.test(raw) && entries.at(-1)?.type === type) {
|
|
37
|
+
// Formatters may wrap a bullet or its author link onto indented lines.
|
|
38
|
+
entries.at(-1).entries[0] += ` ${line}`;
|
|
39
|
+
} else {
|
|
40
|
+
throw new Error('Не удалось безопасно дополнить раздел релиза: неизвестный формат записи.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!entries.length) throw new Error('Раздел релиза пуст.');
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function history(document) {
|
|
48
|
+
return document.releases.map((release) => release.text.trim()).join('\n\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function pendingDocument(text, stableText, target) {
|
|
52
|
+
const document = parseChangelog(text);
|
|
53
|
+
const stable = parseChangelog(stableText);
|
|
54
|
+
const pending = document.releases[0]?.version === target ? document.releases.shift() : null;
|
|
55
|
+
if (history(document) !== history(stable) || document.intro.trim() !== stable.intro.trim()) {
|
|
56
|
+
throw new Error('История CHANGELOG.md отличается от stable: допустим только верхний раздел следующего patch.');
|
|
57
|
+
}
|
|
58
|
+
return { document, pending, entries: pending ? releaseEntries(pending, true) : [] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function prepareChangelog({ text, stableText, productionText, target, fragments, date }) {
|
|
62
|
+
const current = pendingDocument(text, stableText, target);
|
|
63
|
+
const production = pendingDocument(productionText, stableText, target);
|
|
64
|
+
const entryKey = (entry) => `${entry.type}:${entry.entries[0]}`;
|
|
65
|
+
const currentKeys = new Set(current.entries.map(entryKey));
|
|
66
|
+
if (production.entries.some((entry) => !currentKeys.has(entryKey(entry)))) {
|
|
67
|
+
throw new Error('Сначала перенесите в hotfix актуальный раздел из production; его записи нельзя терять.');
|
|
68
|
+
}
|
|
69
|
+
if (!fragments.length) {
|
|
70
|
+
if (!current.pending) throw new Error('Нет новых hotfix fragments или подготовленного раздела.');
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
const merged = [...current.entries];
|
|
74
|
+
for (const fragment of fragments) {
|
|
75
|
+
if (fragment.bump !== 'patch') throw new Error('Хотфикс не может включать minor или major fragments.');
|
|
76
|
+
for (const line of fragment.entries) {
|
|
77
|
+
const entry = { ...fragment, entries: [line] };
|
|
78
|
+
if (!currentKeys.has(entryKey(entry))) merged.push(entry);
|
|
79
|
+
currentKeys.add(entryKey(entry));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const block = renderReleaseBlock(target, merged, current.pending?.date || date);
|
|
83
|
+
// Preserve the published tail byte-for-byte, including its spacing.
|
|
84
|
+
const tail = current.document.releases.map((release) => release.text).join('');
|
|
85
|
+
return `${current.document.intro.trimEnd()}\n\n${block.trim()}\n${tail ? `\n${tail}` : ''}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { parseChangelog, releaseEntries, pendingDocument, prepareChangelog };
|
package/src/hotfix.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const { execFileSync } = require('child_process');
|
|
3
|
+
const preflight = require('./preflight');
|
|
4
|
+
const { execCommand, logSuccess, logError } = require('./utils');
|
|
5
|
+
const { upVersion, compareVersions } = require('./version');
|
|
6
|
+
const { STABLE_TAG_PATTERNS, parseStableTag, stableTagNames } = require('./stable-tags');
|
|
7
|
+
const { CHANGELOG_FILE, CHANGELOG_DIR, FRAGMENT_TYPES, getFragmentType } = require('./changelog-config');
|
|
8
|
+
const { parseChangelog, pendingDocument, prepareChangelog } = require('./hotfix-changelog');
|
|
9
|
+
|
|
10
|
+
function git(...args) {
|
|
11
|
+
try {
|
|
12
|
+
return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error(`Git не выполнил «${args[0]}». Проверьте доступ к origin, состояние веток и конфликты.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function exists(ref) {
|
|
19
|
+
try {
|
|
20
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', ref], { stdio: 'ignore' });
|
|
21
|
+
return true;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error.status === 1) return false;
|
|
24
|
+
throw new Error(`Не удалось проверить ref ${ref}.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ancestor(base, head) {
|
|
29
|
+
try {
|
|
30
|
+
execFileSync('git', ['merge-base', '--is-ancestor', base, head], { stdio: 'ignore' });
|
|
31
|
+
return true;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.status === 1) return false;
|
|
34
|
+
throw new Error('Не удалось проверить историю Git.');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readAt(ref, file = CHANGELOG_FILE) {
|
|
39
|
+
return git('show', `${ref}:${file}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function context(mode) {
|
|
43
|
+
const root = git('rev-parse', '--show-toplevel').trim();
|
|
44
|
+
if (fs.realpathSync(root) !== fs.realpathSync(process.cwd())) {
|
|
45
|
+
throw new Error('Запустите hotfix из корня репозитория.');
|
|
46
|
+
}
|
|
47
|
+
const branch = git('branch', '--show-current').trim();
|
|
48
|
+
if (!['main', 'master'].includes(branch) && !/^hotfix\/.+/.test(branch)) {
|
|
49
|
+
throw new Error('Hotfix-команды разрешены только на hotfix/*, main или master (не detached HEAD).');
|
|
50
|
+
}
|
|
51
|
+
if (mode !== 'start' && !['main', 'master'].includes(branch)) {
|
|
52
|
+
throw new Error('hotfix deploy/close выполняются на main/master после merge хотфикса.');
|
|
53
|
+
}
|
|
54
|
+
if (mode === 'start' && !/^hotfix\/[A-Z]+-[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(branch)) {
|
|
55
|
+
throw new Error('hotfix start выполняется только на hotfix/<TASK>[-slug].');
|
|
56
|
+
}
|
|
57
|
+
if (mode !== 'start' && git('status', '--porcelain').trim()) {
|
|
58
|
+
throw new Error('Для deploy/close требуется чистое рабочее дерево.');
|
|
59
|
+
}
|
|
60
|
+
if (git('ls-files', '--unmerged').trim()) throw new Error('Сначала разрешите конфликты Git.');
|
|
61
|
+
git('fetch', 'origin', '--prune', '--tags');
|
|
62
|
+
const mainBranch = ['master', 'main'].find((name) => exists(`refs/remotes/origin/${name}`));
|
|
63
|
+
if (!mainBranch) throw new Error('Не найдена production-ветка origin/main или origin/master.');
|
|
64
|
+
const mainRef = `refs/remotes/origin/${mainBranch}`;
|
|
65
|
+
const mainSha = git('rev-parse', mainRef).trim();
|
|
66
|
+
const head = git('rev-parse', 'HEAD').trim();
|
|
67
|
+
if (mode === 'start') {
|
|
68
|
+
if (!ancestor(mainSha, head)) throw new Error('Сначала обновите hotfix из актуальной production-ветки.');
|
|
69
|
+
} else if (branch !== mainBranch || head !== mainSha) {
|
|
70
|
+
throw new Error(`Нужна ветка ${mainBranch}, точно совпадающая с origin/${mainBranch}.`);
|
|
71
|
+
}
|
|
72
|
+
// Query origin explicitly: a local-only tag must never change the baseline.
|
|
73
|
+
const remoteTags = new Map(git('ls-remote', '--refs', '--tags', 'origin', ...STABLE_TAG_PATTERNS.map((tag) => `refs/tags/${tag}`))
|
|
74
|
+
.trim().split('\n').filter(Boolean).map((line) => {
|
|
75
|
+
const [sha, ref] = line.split(/\s+/);
|
|
76
|
+
return [ref.slice('refs/tags/'.length), sha];
|
|
77
|
+
}));
|
|
78
|
+
const versions = git('tag', '--merged', mainSha, '--list', ...STABLE_TAG_PATTERNS).trim().split('\n')
|
|
79
|
+
.filter((tag) => parseStableTag(tag) && remoteTags.has(tag))
|
|
80
|
+
.map((tag) => ({ tag, ...parseStableTag(tag) })).sort((a, b) => compareVersions(a.version, b.version));
|
|
81
|
+
const latest = versions.at(-1);
|
|
82
|
+
const stable = latest?.version;
|
|
83
|
+
if (!stable) throw new Error('Для хотфикса необходим опубликованный stable-тег production.');
|
|
84
|
+
const stableTag = latest.tag;
|
|
85
|
+
const stableSha = git('rev-parse', `refs/tags/${stableTag}^{commit}`).trim();
|
|
86
|
+
for (const tag of stableTagNames(stable).filter((name) => remoteTags.has(name))) {
|
|
87
|
+
if (git('rev-parse', `refs/tags/${tag}^{commit}`).trim() !== stableSha) {
|
|
88
|
+
throw new Error(`Конфликт stable-тегов версии ${stable}: разные commits.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const stableText = readAt(`refs/tags/${stableTag}`);
|
|
92
|
+
const productionText = readAt(mainSha);
|
|
93
|
+
const target = upVersion(stable, 'patch');
|
|
94
|
+
return { mainBranch, mainSha, head, remoteTags, stable, stableTag, stableText, productionText, target };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function assertTagAvailable(ctx, allowMatchingLocal = false) {
|
|
98
|
+
for (const tag of stableTagNames(ctx.target)) {
|
|
99
|
+
if (ctx.remoteTags.has(tag)) throw new Error(`Тег ${tag} уже опубликован; эту версию нельзя выпустить повторно.`);
|
|
100
|
+
if (exists(`refs/tags/${tag}`) &&
|
|
101
|
+
(!allowMatchingLocal || tag !== `hotfix/${ctx.target}` || git('rev-parse', `refs/tags/${tag}^{commit}`).trim() !== ctx.head)) {
|
|
102
|
+
throw new Error(`Локальный тег ${tag} уже существует. Проверьте его перед продолжением.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function assertOriginUnchanged(ctx) {
|
|
108
|
+
const line = git('ls-remote', '--heads', 'origin', `refs/heads/${ctx.mainBranch}`).trim();
|
|
109
|
+
if (line.split(/\s+/)[0] !== ctx.mainSha) {
|
|
110
|
+
throw new Error('Production изменилась во время проверки. Обновите ветку и повторите команду.');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function newFragments(baseRef) {
|
|
115
|
+
const inherited = git('ls-tree', '-r', '-z', '--name-only', baseRef, '--', `${CHANGELOG_DIR}/`)
|
|
116
|
+
.split('\0').filter(Boolean);
|
|
117
|
+
for (const file of inherited) {
|
|
118
|
+
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== readAt(baseRef, file)) {
|
|
119
|
+
throw new Error(`Нельзя изменять унаследованный fragment ${file} в хотфиксе.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!fs.existsSync(CHANGELOG_DIR)) return [];
|
|
123
|
+
return fs.readdirSync(CHANGELOG_DIR).sort().filter((name) => !name.startsWith('.'))
|
|
124
|
+
.map((name) => `${CHANGELOG_DIR}/${name}`).filter((file) => !inherited.includes(file))
|
|
125
|
+
.map((filePath) => {
|
|
126
|
+
const type = getFragmentType(filePath);
|
|
127
|
+
if (!type || !fs.lstatSync(filePath).isFile()) throw new Error(`Некорректный fragment ${filePath}.`);
|
|
128
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
129
|
+
const entries = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
130
|
+
if (!entries.length || entries.some((line) => !line.startsWith('- ') || line.length > 120)) {
|
|
131
|
+
throw new Error(`Пустой или неверно оформленный fragment ${filePath}: записи «- …», до 120 символов.`);
|
|
132
|
+
}
|
|
133
|
+
if (FRAGMENT_TYPES[type].bump !== 'patch') throw new Error(`Хотфикс не допускает minor/major: ${filePath}.`);
|
|
134
|
+
return { filePath, content, type, ...FRAGMENT_TYPES[type], entries };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatChangelog() {
|
|
139
|
+
const runner = preflight.getPrettierRunner();
|
|
140
|
+
if (!runner || !execCommand(`${runner} --write CHANGELOG.md`) || !execCommand(`${runner} --check CHANGELOG.md`)) {
|
|
141
|
+
throw new Error('Не удалось отформатировать CHANGELOG.md; hotfix fragments сохранены.');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function run(action) {
|
|
146
|
+
try {
|
|
147
|
+
action();
|
|
148
|
+
return true;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
logError('❌', error.message);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function hotfixStart() {
|
|
156
|
+
return run(() => {
|
|
157
|
+
const ctx = context('start');
|
|
158
|
+
assertTagAvailable(ctx);
|
|
159
|
+
const fragments = newFragments(ctx.mainSha);
|
|
160
|
+
const original = fs.readFileSync(CHANGELOG_FILE, 'utf8');
|
|
161
|
+
const updated = prepareChangelog({ text: original, ...ctx, fragments });
|
|
162
|
+
assertOriginUnchanged(ctx);
|
|
163
|
+
if (updated !== original || fragments.length) {
|
|
164
|
+
try {
|
|
165
|
+
fs.writeFileSync(CHANGELOG_FILE, updated);
|
|
166
|
+
formatChangelog();
|
|
167
|
+
// Formatting must not rewrite published history or lose merged notes.
|
|
168
|
+
prepareChangelog({ text: fs.readFileSync(CHANGELOG_FILE, 'utf8'), ...ctx, fragments: [] });
|
|
169
|
+
for (const fragment of fragments) fs.unlinkSync(fragment.filePath);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
fs.writeFileSync(CHANGELOG_FILE, original);
|
|
172
|
+
for (const fragment of fragments) fs.writeFileSync(fragment.filePath, fragment.content);
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
logSuccess('✅', `Хотфикс ${ctx.target} подготовлен локально. Проверьте diff и включите CHANGELOG.md и удаления fragments в MR в ${ctx.mainBranch}.`);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function hotfixDeploy() {
|
|
181
|
+
return run(() => {
|
|
182
|
+
const ctx = context('deploy');
|
|
183
|
+
const prepared = pendingDocument(ctx.productionText, ctx.stableText, ctx.target);
|
|
184
|
+
if (!prepared.pending) throw new Error(`В CHANGELOG.md нужен верхний подготовленный раздел ${ctx.target}.`);
|
|
185
|
+
if (newFragments(`refs/tags/${ctx.stableTag}`).length) throw new Error('В production остались несобранные hotfix fragments.');
|
|
186
|
+
assertTagAvailable(ctx, true);
|
|
187
|
+
assertOriginUnchanged(ctx);
|
|
188
|
+
const tag = `hotfix/${ctx.target}`;
|
|
189
|
+
if (!exists(`refs/tags/${tag}`)) git('tag', tag, ctx.head);
|
|
190
|
+
git('push', 'origin', `refs/tags/${tag}:refs/tags/${tag}`);
|
|
191
|
+
logSuccess('✅', `Опубликован ${tag} на ${ctx.head}. Дождитесь успешного stable pipeline перед hotfix close.`);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function hotfixClose() {
|
|
196
|
+
return run(() => {
|
|
197
|
+
const ctx = context('close');
|
|
198
|
+
const top = parseChangelog(ctx.productionText).releases[0]?.version;
|
|
199
|
+
const tag = `hotfix/${ctx.stable}`;
|
|
200
|
+
if (top !== ctx.stable || !ctx.remoteTags.has(tag) || git('rev-parse', `refs/tags/${tag}^{commit}`).trim() !== ctx.head) {
|
|
201
|
+
throw new Error('Сначала опубликуйте hotfix/X.Y.Z на текущем production commit.');
|
|
202
|
+
}
|
|
203
|
+
const dev = ['develop', 'dev'].find((name) => exists(`refs/remotes/origin/${name}`));
|
|
204
|
+
if (!dev) throw new Error('Не найдена integration-ветка origin/dev или origin/develop.');
|
|
205
|
+
const devRef = `refs/remotes/origin/${dev}`;
|
|
206
|
+
if (exists(`refs/heads/${dev}`) && !ancestor(`refs/heads/${dev}`, devRef)) {
|
|
207
|
+
throw new Error(`В ${dev} есть локальные непубликуемые commits. Сначала согласуйте их с origin/${dev}.`);
|
|
208
|
+
}
|
|
209
|
+
assertOriginUnchanged(ctx);
|
|
210
|
+
if (exists(`refs/heads/${dev}`)) git('switch', dev);
|
|
211
|
+
else git('switch', '--track', '-c', dev, devRef);
|
|
212
|
+
git('merge', '--ff-only', devRef);
|
|
213
|
+
git('merge', '--no-edit', ctx.head);
|
|
214
|
+
git('push', 'origin', `refs/heads/${dev}:refs/heads/${dev}`);
|
|
215
|
+
logSuccess('✅', `Хотфикс ${ctx.stable} сведён в ${dev}; production и файлы версий не изменены.`);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
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 } = 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) => 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);
|
|
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} найдены незакрытые релизы новее
|
|
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\/(
|
|
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
|
|
263
|
-
if (
|
|
264
|
-
versions.add(
|
|
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} не найден достижимый
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const remoteTag = execSilent(`git ls-remote --tags origin "refs/tags/${
|
|
322
|
-
if (remoteTag
|
|
323
|
-
|
|
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
|
|
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(`Релизный тег
|
|
344
|
+
return fail(`Релизный тег версии ${version} уже существует локально.`);
|
|
350
345
|
}
|
|
351
346
|
|
|
352
|
-
const remoteTag = execSilent(`git ls-remote --tags origin "refs/tags
|
|
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(`Не удалось проверить тег
|
|
349
|
+
return fail(`Не удалось проверить тег версии ${version} в origin.`);
|
|
355
350
|
}
|
|
356
351
|
if (remoteTag && remoteTag.trim()) {
|
|
357
|
-
return fail(`Релизный тег
|
|
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
|
-
|
|
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: '
|
|
168
|
+
{ name: 'release-state', run: requireReleaseState },
|
|
161
169
|
{ name: 'changelog-prettier-check', run: requireChangelogFormatted },
|
|
162
|
-
{ name: 'changelog-fragments', run:
|
|
170
|
+
{ name: 'changelog-fragments', run: requireReleaseFragments },
|
|
163
171
|
{
|
|
164
172
|
name: 'detect-bump-type',
|
|
165
173
|
run: (ctx) => {
|
|
166
|
-
const resolved =
|
|
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 };
|