spectrum-cli 3.1.0 → 3.2.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 +37 -10
- package/index.js +17 -1
- package/package.json +1 -1
- package/src/changelog.js +65 -3
- package/src/hotfix-changelog.js +21 -20
- package/src/hotfix.js +21 -32
- package/src/open-release.js +3 -3
- package/src/update-check.js +45 -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.2.0] - 2026-10-01
|
|
9
|
+
|
|
10
|
+
### 🆕 Added
|
|
11
|
+
|
|
12
|
+
- Добавлена команда `spectrum changelog write`: выравнивает заголовки релизов и хотфиксов и форматирует `CHANGELOG.md` через Prettier.
|
|
13
|
+
- Добавлена команда `spectrum upgrade` для глобального обновления CLI через npm.
|
|
14
|
+
- Перед выполнением команд CLI проверяет наличие новой версии в npm с таймаутом 200 мс и выводит версии и команду обновления; ошибка проверки не прерывает команду.
|
|
15
|
+
|
|
16
|
+
### 🪲 Fixed
|
|
17
|
+
|
|
18
|
+
- `spectrum hotfix start` собирает все текущие фрагменты из `.changelog/` независимо от истории Git.
|
|
19
|
+
|
|
20
|
+
## 🚀 [3.1.1] - 2026-09-24
|
|
21
|
+
|
|
22
|
+
### 🪲 Fixed
|
|
23
|
+
|
|
24
|
+
- Хотфиксы создают раздел с 🩹 и поддерживают запуск подготовки из main/master.
|
|
25
|
+
- Дата раздела changelog необязательна; опубликованная история в прежнем формате сохраняется.
|
|
26
|
+
- Исправлены проверка корня репозитория и сравнение CRLF/LF в release/hotfix на Windows.
|
|
27
|
+
|
|
8
28
|
## 🚀 [3.1.0] - 2026-09-24
|
|
9
29
|
|
|
10
30
|
### 🆕 Added
|
package/README.md
CHANGED
|
@@ -14,6 +14,12 @@ npm install -g spectrum-cli
|
|
|
14
14
|
spectrum --help
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
Перед выполнением каждой команды CLI проверяет опубликованную версию `spectrum-cli`
|
|
18
|
+
в npm Registry. Если доступна новая версия, показывает текущую и новую версии и
|
|
19
|
+
команду `spectrum upgrade`. Она запускает `npm install -g spectrum-cli`.
|
|
20
|
+
Проверка ограничена 200 мс; недоступность Registry не влияет на выполнение
|
|
21
|
+
команды и не выводит ошибку.
|
|
22
|
+
|
|
17
23
|
### 🔧 Из исходников
|
|
18
24
|
|
|
19
25
|
```bash
|
|
@@ -42,7 +48,7 @@ spectrum release deploy # Создать стабильный те
|
|
|
42
48
|
spectrum release close # Свести stable main/master в dev
|
|
43
49
|
|
|
44
50
|
# Срочный patch без переноса незавершённого dev в production
|
|
45
|
-
spectrum hotfix start #
|
|
51
|
+
spectrum hotfix start # Локальная подготовка 🩹 CHANGELOG на hotfix/* или main/master
|
|
46
52
|
spectrum hotfix deploy # После merge MR: stable-тег на актуальном main/master
|
|
47
53
|
spectrum hotfix close # После stable pipeline: main/master → dev
|
|
48
54
|
|
|
@@ -57,6 +63,7 @@ spectrum chart verify ~/repo # Сравнить ingress paths AS IS vs TO BE д
|
|
|
57
63
|
# 📝 Управление changelog
|
|
58
64
|
spectrum changelog append "Сообщение" # Создать fragment в .changelog/
|
|
59
65
|
spectrum changelog check # Проверить CHANGELOG.md и все fragments
|
|
66
|
+
spectrum changelog write # Выравнять заголовки CHANGELOG.md и запустить Prettier
|
|
60
67
|
|
|
61
68
|
# 🔑 GitLab токены
|
|
62
69
|
spectrum token rotate # Пролить GITLAB_PRIVATE_TOKEN в CI variables
|
|
@@ -182,35 +189,46 @@ spectrum-cli/
|
|
|
182
189
|
|
|
183
190
|
Все hotfix-команды сначала проверяют ветку: разрешены только `hotfix/*`, `main`
|
|
184
191
|
или `master`. `dev`, `develop`, `feature/*` и detached HEAD отклоняются до fetch.
|
|
185
|
-
|
|
186
|
-
|
|
192
|
+
Для `start` подходят `hotfix/<TASK>[-slug]` и production-ветка `main/master`,
|
|
193
|
+
содержащая актуальный origin. Локальные исправления и commits допустимы.
|
|
194
|
+
`deploy` и `close` требуют чистый production commit, совпадающий с origin.
|
|
187
195
|
|
|
188
196
|
### Изолированный цикл хотфикса
|
|
189
197
|
|
|
190
198
|
Создайте `hotfix/<TASK>` или `hotfix/<TASK>-<slug>` от актуального `origin/main`
|
|
191
|
-
или `origin/master
|
|
199
|
+
или `origin/master`, либо работайте прямо в актуальной production-ветке
|
|
200
|
+
`main/master`. Включайте только срочное исправление и его patch fragments
|
|
192
201
|
(`fixed`, `security`, `support` либо другой совместимый patch-тип).
|
|
193
202
|
`added` и `breaking` требуют обычного релиза. Все команды запускаются из корня
|
|
194
203
|
репозитория; для `start` нужен Prettier, как для обычной сборки changelog.
|
|
195
204
|
|
|
196
205
|
1. **`spectrum hotfix start`** обновляет сведения об origin и выбирает опубликованный
|
|
197
206
|
stable-тег, достижимый из production. Цель — этот stable плюс один patch.
|
|
198
|
-
|
|
207
|
+
Как и `release start`, команда читает все файлы текущей `.changelog/` независимо
|
|
208
|
+
от того, когда они попали в Git. Проверяет их общими правилами changelog и
|
|
209
|
+
допускает только patch-типы; файлы `added` и `breaking` требуют обычного релиза.
|
|
210
|
+
Формат CHANGELOG.md проверяется только в текущей ветке. История может отличаться
|
|
211
|
+
от stable-тега: её исправления не блокируют хотфикс. Снимок production используется
|
|
212
|
+
для сохранения записей уже подготовленного хотфикса, без строгой проверки старых заголовков.
|
|
213
|
+
Команда формирует верхний раздел `## 🩹 [X.Y.Z] - YYYY-MM-DD` и удаляет только
|
|
199
214
|
собранные fragments. Она не делает commit, staging, push, merge или tag,
|
|
200
215
|
не меняет файлы версий и сохраняет посторонние локальные изменения.
|
|
201
216
|
2. Повторный `start` дополняет тот же неопубликованный раздел, сохраняя дату и
|
|
202
|
-
существующие записи без дублей.
|
|
217
|
+
существующие записи без дублей. Дата необязательна: раздел без даты останется
|
|
218
|
+
без даты. Старые заголовки с `🚀`, `🩹`, без значка и с датами через точки
|
|
219
|
+
читаются без переписывания опубликованной истории. Без новых fragments подготовленный раздел
|
|
203
220
|
остаётся без изменений. Например, от `v6.35.0` первый и повторные запуски
|
|
204
221
|
готовят `6.35.1`; только после публикации `hotfix/6.35.1` следующий хотфикс получит
|
|
205
|
-
`6.35.2`.
|
|
222
|
+
`6.35.2`.
|
|
206
223
|
3. Проверьте diff, закоммитьте и отправьте исправление по правилам проекта.
|
|
207
224
|
**В MR из hotfix в production уже должны быть верхний patch-раздел и удаления
|
|
208
225
|
использованных fragments.** Merge выполняется отдельно после ревью. Если
|
|
209
226
|
другой хотфикс уже в production, сначала перенесите его изменения в свою
|
|
210
227
|
ветку и повторите подготовку: пока stable-тега нет, раздел и версия общие.
|
|
211
|
-
4. После
|
|
228
|
+
4. После доставки подготовленного исправления в production через MR или
|
|
229
|
+
разрешённый проектом push и проверки RC выполните **`spectrum hotfix deploy`** на чистом
|
|
212
230
|
`main/master`, точно совпадающем с origin. Команда проверяет следующий patch,
|
|
213
|
-
|
|
231
|
+
отсутствие несобранных hotfix fragments,
|
|
214
232
|
создаёт `hotfix/X.Y.Z` на проверенном SHA и отправляет **только этот тег**.
|
|
215
233
|
Повтор после ошибки push допускает существующий локальный тег лишь на том же
|
|
216
234
|
SHA; опубликованный тег не перемещается. Ветки не сливаются.
|
|
@@ -362,9 +380,18 @@ Fragment должен содержать только непустые Markdown-
|
|
|
362
380
|
> Совет: Приведите файл к нужному формату командой:
|
|
363
381
|
>
|
|
364
382
|
> ```bash
|
|
365
|
-
>
|
|
383
|
+
> spectrum changelog write
|
|
366
384
|
> ```
|
|
367
385
|
|
|
386
|
+
### `spectrum changelog write`
|
|
387
|
+
|
|
388
|
+
Команда приводит все релизные заголовки `CHANGELOG.md` к виду `## 🚀 [X.Y.Z]`,
|
|
389
|
+
`## 🩹 [X.Y.Z]` или `## [X.Y.Z]` с необязательной датой, затем запускает Prettier.
|
|
390
|
+
Старые даты через точки и текст записей сохраняются. Неверный заголовок, включая
|
|
391
|
+
`Unreleased`, останавливает команду без удаления записей. При ошибке Prettier
|
|
392
|
+
исходный `CHANGELOG.md` восстанавливается. Файлы `.changelog/` команда не меняет;
|
|
393
|
+
commit и push не выполняет.
|
|
394
|
+
|
|
368
395
|
## 🎯 Философия
|
|
369
396
|
|
|
370
397
|
- **Простота** - минимум команд, максимум функциональности
|
package/index.js
CHANGED
|
@@ -8,9 +8,15 @@ const hotfix = require("./src/hotfix");
|
|
|
8
8
|
const changelog = require("./src/changelog");
|
|
9
9
|
const chart = require("./src/chart");
|
|
10
10
|
const token = require("./src/token");
|
|
11
|
+
const { checkForUpdates, upgrade } = require("./src/update-check");
|
|
11
12
|
const { version: pkgVersion } = require("./package.json");
|
|
12
13
|
|
|
13
14
|
async function runAction(action, ...args) {
|
|
15
|
+
try {
|
|
16
|
+
await checkForUpdates();
|
|
17
|
+
} catch {
|
|
18
|
+
// A failed update check must not affect the command.
|
|
19
|
+
}
|
|
14
20
|
try {
|
|
15
21
|
const result = await action(...args);
|
|
16
22
|
if (!result) {
|
|
@@ -27,6 +33,11 @@ program
|
|
|
27
33
|
.description("🚀 Spectrum CLI для процесса разработки")
|
|
28
34
|
.version(pkgVersion, "-v, --version");
|
|
29
35
|
|
|
36
|
+
program
|
|
37
|
+
.command("upgrade")
|
|
38
|
+
.description("Обновить Spectrum CLI через npm install -g spectrum-cli")
|
|
39
|
+
.action(() => runAction(upgrade));
|
|
40
|
+
|
|
30
41
|
// Команды релиза
|
|
31
42
|
const releaseCmd = program
|
|
32
43
|
.command("release")
|
|
@@ -50,7 +61,7 @@ releaseCmd
|
|
|
50
61
|
const hotfixCmd = program.command("hotfix").description("Изолированный цикл срочного patch-релиза");
|
|
51
62
|
|
|
52
63
|
hotfixCmd.command("start")
|
|
53
|
-
.description("Подготовить CHANGELOG на hotfix/*
|
|
64
|
+
.description("Подготовить 🩹 CHANGELOG на hotfix/* или main/master, без commit, push и merge")
|
|
54
65
|
.action(() => runAction(hotfix.hotfixStart));
|
|
55
66
|
hotfixCmd.command("deploy")
|
|
56
67
|
.description("После merge хотфикса отправить stable-тег с main/master")
|
|
@@ -74,6 +85,11 @@ changelogCmd
|
|
|
74
85
|
.description("Проверить CHANGELOG.md и changelog fragments")
|
|
75
86
|
.action(() => runAction(changelog.changelogCheck));
|
|
76
87
|
|
|
88
|
+
changelogCmd
|
|
89
|
+
.command("write")
|
|
90
|
+
.description("Привести заголовки CHANGELOG.md к формату релиза и запустить Prettier")
|
|
91
|
+
.action(() => runAction(changelog.changelogWrite));
|
|
92
|
+
|
|
77
93
|
// Команды chart
|
|
78
94
|
const chartCmd = program
|
|
79
95
|
.command("chart")
|
package/package.json
CHANGED
package/src/changelog.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const readline = require('readline');
|
|
5
|
-
const { logSuccess, logError, execSilent, getCurrentBranch, colors } = require('./utils');
|
|
5
|
+
const { logSuccess, logError, execSilent, execCommand, getCurrentBranch, colors } = require('./utils');
|
|
6
6
|
const { runCommand } = require('./command-executor');
|
|
7
7
|
const {
|
|
8
8
|
requireGitRepo,
|
|
9
9
|
requireFileExists,
|
|
10
|
+
requirePrettierAvailable,
|
|
10
11
|
requireChangelogFormatted,
|
|
11
12
|
requireChangelogFragments
|
|
12
13
|
} = require('./preflight');
|
|
@@ -260,6 +261,65 @@ function changelogCheck() {
|
|
|
260
261
|
});
|
|
261
262
|
}
|
|
262
263
|
|
|
264
|
+
function normalizeChangelogHeadings(text) {
|
|
265
|
+
const version = '(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)';
|
|
266
|
+
const date = '(\\d{4}-\\d{2}-\\d{2}|\\d{4}\\.\\d{2}\\.\\d{2}|\\d{2}\\.\\d{2}\\.\\d{4})';
|
|
267
|
+
const releaseHeading = new RegExp(`^##[ \\t]*(?:(🚀|🩹)[ \\t]*)?\\[${version}\\](?:[ \\t]+-[ \\t]+${date})?[ \\t]*\\r?$`);
|
|
268
|
+
return text.replace(/^##(?!#)[^\n]*$/gm, (heading) => {
|
|
269
|
+
const match = releaseHeading.exec(heading);
|
|
270
|
+
if (!match) throw new Error(`Некорректный заголовок ${CHANGELOG_FILE}: ${heading.trimEnd()}`);
|
|
271
|
+
const marker = match[1] ? `${match[1]} ` : '';
|
|
272
|
+
const releaseVersion = `${match[2]}.${match[3]}.${match[4]}`;
|
|
273
|
+
const releaseDate = match[5] ? ` - ${match[5]}` : '';
|
|
274
|
+
return `## ${marker}[${releaseVersion}]${releaseDate}${heading.endsWith('\r') ? '\r' : ''}`;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function writeCurrentChangelog(context) {
|
|
279
|
+
let original;
|
|
280
|
+
let mayHaveChanged = false;
|
|
281
|
+
try {
|
|
282
|
+
original = fs.readFileSync(CHANGELOG_FILE, 'utf8');
|
|
283
|
+
const normalized = normalizeChangelogHeadings(original);
|
|
284
|
+
if (normalized !== original) {
|
|
285
|
+
mayHaveChanged = true;
|
|
286
|
+
fs.writeFileSync(CHANGELOG_FILE, normalized);
|
|
287
|
+
}
|
|
288
|
+
mayHaveChanged = true;
|
|
289
|
+
if (!execCommand(`${context.prettierRunner} --write ${CHANGELOG_FILE}`) ||
|
|
290
|
+
!execCommand(`${context.prettierRunner} --check ${CHANGELOG_FILE}`)) {
|
|
291
|
+
throw new Error('Prettier не смог отформатировать CHANGELOG.md.');
|
|
292
|
+
}
|
|
293
|
+
const formatted = fs.readFileSync(CHANGELOG_FILE, 'utf8');
|
|
294
|
+
if (normalizeChangelogHeadings(formatted) !== formatted) {
|
|
295
|
+
throw new Error('После Prettier заголовки CHANGELOG.md не соответствуют формату релизов.');
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (mayHaveChanged && original !== undefined) {
|
|
300
|
+
try {
|
|
301
|
+
fs.writeFileSync(CHANGELOG_FILE, original);
|
|
302
|
+
} catch (restoreError) {
|
|
303
|
+
logError('❌', 'Не удалось восстановить %s: %s', CHANGELOG_FILE, restoreError.message);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
logError('❌', 'Не удалось отформатировать %s: %s', CHANGELOG_FILE, error.message);
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function changelogWrite() {
|
|
312
|
+
return runCommand({
|
|
313
|
+
name: 'changelog write',
|
|
314
|
+
checks: [
|
|
315
|
+
{ name: 'git-repo', run: requireGitRepo },
|
|
316
|
+
{ name: 'changelog-exists', run: () => requireFileExists(CHANGELOG_FILE) },
|
|
317
|
+
{ name: 'prettier-available', run: requirePrettierAvailable }
|
|
318
|
+
],
|
|
319
|
+
steps: [{ name: 'write-changelog', run: writeCurrentChangelog }]
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
263
323
|
function stripLegacyUnreleasedBlock(changelog) {
|
|
264
324
|
const unreleasedMatch = /^## \[Unreleased\]\s*$/m.exec(changelog);
|
|
265
325
|
if (!unreleasedMatch) return changelog;
|
|
@@ -270,7 +330,7 @@ function stripLegacyUnreleasedBlock(changelog) {
|
|
|
270
330
|
return `${changelog.slice(0, unreleasedMatch.index).trimEnd()}\n\n${changelog.slice(end).trimStart()}`;
|
|
271
331
|
}
|
|
272
332
|
|
|
273
|
-
function renderReleaseBlock(version, fragments, date = new Date().toISOString().slice(0, 10)) {
|
|
333
|
+
function renderReleaseBlock(version, fragments, date = new Date().toISOString().slice(0, 10), marker = '🚀') {
|
|
274
334
|
const fragmentsByType = new Map();
|
|
275
335
|
for (const fragment of fragments) {
|
|
276
336
|
const entries = fragmentsByType.get(fragment.type) || [];
|
|
@@ -278,7 +338,7 @@ function renderReleaseBlock(version, fragments, date = new Date().toISOString().
|
|
|
278
338
|
fragmentsByType.set(fragment.type, entries);
|
|
279
339
|
}
|
|
280
340
|
|
|
281
|
-
const lines = [`##
|
|
341
|
+
const lines = [`## ${marker ? `${marker} ` : ''}[${version}]${date ? ` - ${date}` : ''}`];
|
|
282
342
|
for (const [type, config] of Object.entries(FRAGMENT_TYPES)) {
|
|
283
343
|
const entries = fragmentsByType.get(type);
|
|
284
344
|
if (!entries || entries.length === 0) continue;
|
|
@@ -339,6 +399,8 @@ function changelogRemoveFragments(context) {
|
|
|
339
399
|
module.exports = {
|
|
340
400
|
changelogAppend,
|
|
341
401
|
changelogCheck,
|
|
402
|
+
changelogWrite,
|
|
403
|
+
normalizeChangelogHeadings,
|
|
342
404
|
extractTaskFromBranch,
|
|
343
405
|
getGitUser,
|
|
344
406
|
formatMessage,
|
package/src/hotfix-changelog.js
CHANGED
|
@@ -2,21 +2,23 @@ const { FRAGMENT_TYPES, SECTION_TO_TYPE } = require('./changelog-config');
|
|
|
2
2
|
const { renderReleaseBlock } = require('./changelog');
|
|
3
3
|
|
|
4
4
|
// Parse release boundaries without rewriting any published text.
|
|
5
|
-
function parseChangelog(text) {
|
|
5
|
+
function parseChangelog(text, { validate = true } = {}) {
|
|
6
6
|
const headings = [...text.matchAll(/^## .+$/gm)];
|
|
7
|
-
const seen = new Set();
|
|
8
7
|
const releases = headings.map((heading, index) => {
|
|
9
|
-
const match = heading[0].match(/^## (
|
|
10
|
-
if (!match
|
|
11
|
-
|
|
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
|
+
// Production snapshots supply pending notes, not files to validate. Skip
|
|
11
|
+
// historical non-release sections (including their bodies).
|
|
12
|
+
if (!validate) return null;
|
|
13
|
+
throw new Error(`Некорректный заголовок CHANGELOG.md: ${heading[0]}. Ожидается ## [X.Y.Z], дата необязательна.`);
|
|
12
14
|
}
|
|
13
|
-
|
|
15
|
+
// Historical duplicates are preserved; callers validate the pending version.
|
|
14
16
|
return {
|
|
15
|
-
version: match[
|
|
17
|
+
version: match[2], date: match[3] || null, marker: match[1] || '',
|
|
16
18
|
text: text.slice(heading.index, headings[index + 1]?.index ?? text.length),
|
|
17
19
|
body: text.slice(heading.index + heading[0].length, headings[index + 1]?.index ?? text.length),
|
|
18
20
|
};
|
|
19
|
-
});
|
|
21
|
+
}).filter(Boolean);
|
|
20
22
|
return { intro: text.slice(0, headings[0]?.index ?? text.length), releases };
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -44,23 +46,22 @@ function releaseEntries(release, patchOnly = false) {
|
|
|
44
46
|
return entries;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
function
|
|
48
|
-
return
|
|
49
|
+
function normalizeLineEndings(text) {
|
|
50
|
+
return text.replace(/\r\n/g, '\n');
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
function pendingDocument(text,
|
|
52
|
-
const document = parseChangelog(text);
|
|
53
|
-
const stable = parseChangelog(stableText);
|
|
53
|
+
function pendingDocument(text, target, { validate = true } = {}) {
|
|
54
|
+
const document = parseChangelog(text, { validate });
|
|
54
55
|
const pending = document.releases[0]?.version === target ? document.releases.shift() : null;
|
|
55
|
-
if (
|
|
56
|
-
throw new Error('
|
|
56
|
+
if (document.releases.some((release) => release.version === target)) {
|
|
57
|
+
throw new Error('Новый patch-раздел должен быть единственным и находиться в начале CHANGELOG.md.');
|
|
57
58
|
}
|
|
58
59
|
return { document, pending, entries: pending ? releaseEntries(pending, true) : [] };
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
function prepareChangelog({ text,
|
|
62
|
-
const current = pendingDocument(text,
|
|
63
|
-
const production = pendingDocument(productionText,
|
|
62
|
+
function prepareChangelog({ text, productionText, target, fragments, date }) {
|
|
63
|
+
const current = pendingDocument(text, target);
|
|
64
|
+
const production = pendingDocument(productionText, target, { validate: false });
|
|
64
65
|
const entryKey = (entry) => `${entry.type}:${entry.entries[0]}`;
|
|
65
66
|
const currentKeys = new Set(current.entries.map(entryKey));
|
|
66
67
|
if (production.entries.some((entry) => !currentKeys.has(entryKey(entry)))) {
|
|
@@ -79,10 +80,10 @@ function prepareChangelog({ text, stableText, productionText, target, fragments,
|
|
|
79
80
|
currentKeys.add(entryKey(entry));
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
|
-
const block = renderReleaseBlock(target, merged, current.pending
|
|
83
|
+
const block = renderReleaseBlock(target, merged, current.pending ? current.pending.date : date, '🩹');
|
|
83
84
|
// Preserve the published tail byte-for-byte, including its spacing.
|
|
84
85
|
const tail = current.document.releases.map((release) => release.text).join('');
|
|
85
86
|
return `${current.document.intro.trimEnd()}\n\n${block.trim()}\n${tail ? `\n${tail}` : ''}`;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
module.exports = { parseChangelog, releaseEntries, pendingDocument, prepareChangelog };
|
|
89
|
+
module.exports = { parseChangelog, releaseEntries, pendingDocument, prepareChangelog, normalizeLineEndings };
|
package/src/hotfix.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
2
3
|
const { execFileSync } = require('child_process');
|
|
3
4
|
const preflight = require('./preflight');
|
|
4
5
|
const { execCommand, logSuccess, logError } = require('./utils');
|
|
5
6
|
const { upVersion, compareVersions } = require('./version');
|
|
6
7
|
const { STABLE_TAG_PATTERNS, parseStableTag, stableTagNames } = require('./stable-tags');
|
|
7
|
-
const { CHANGELOG_FILE
|
|
8
|
+
const { CHANGELOG_FILE } = require('./changelog-config');
|
|
8
9
|
const { parseChangelog, pendingDocument, prepareChangelog } = require('./hotfix-changelog');
|
|
9
10
|
|
|
10
11
|
function git(...args) {
|
|
@@ -41,7 +42,7 @@ function readAt(ref, file = CHANGELOG_FILE) {
|
|
|
41
42
|
|
|
42
43
|
function context(mode) {
|
|
43
44
|
const root = git('rev-parse', '--show-toplevel').trim();
|
|
44
|
-
if (fs.realpathSync(root)
|
|
45
|
+
if (path.relative(fs.realpathSync(root), fs.realpathSync(process.cwd())) !== '') {
|
|
45
46
|
throw new Error('Запустите hotfix из корня репозитория.');
|
|
46
47
|
}
|
|
47
48
|
const branch = git('branch', '--show-current').trim();
|
|
@@ -51,8 +52,8 @@ function context(mode) {
|
|
|
51
52
|
if (mode !== 'start' && !['main', 'master'].includes(branch)) {
|
|
52
53
|
throw new Error('hotfix deploy/close выполняются на main/master после merge хотфикса.');
|
|
53
54
|
}
|
|
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 выполняется
|
|
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.');
|
|
56
57
|
}
|
|
57
58
|
if (mode !== 'start' && git('status', '--porcelain').trim()) {
|
|
58
59
|
throw new Error('Для deploy/close требуется чистое рабочее дерево.');
|
|
@@ -64,6 +65,9 @@ function context(mode) {
|
|
|
64
65
|
const mainRef = `refs/remotes/origin/${mainBranch}`;
|
|
65
66
|
const mainSha = git('rev-parse', mainRef).trim();
|
|
66
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
|
+
}
|
|
67
71
|
if (mode === 'start') {
|
|
68
72
|
if (!ancestor(mainSha, head)) throw new Error('Сначала обновите hotfix из актуальной production-ветки.');
|
|
69
73
|
} else if (branch !== mainBranch || head !== mainSha) {
|
|
@@ -88,10 +92,9 @@ function context(mode) {
|
|
|
88
92
|
throw new Error(`Конфликт stable-тегов версии ${stable}: разные commits.`);
|
|
89
93
|
}
|
|
90
94
|
}
|
|
91
|
-
const stableText = readAt(`refs/tags/${stableTag}`);
|
|
92
95
|
const productionText = readAt(mainSha);
|
|
93
96
|
const target = upVersion(stable, 'patch');
|
|
94
|
-
return { mainBranch, mainSha, head, remoteTags, stable, stableTag,
|
|
97
|
+
return { mainBranch, mainSha, head, remoteTags, stable, stableTag, productionText, target };
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
function assertTagAvailable(ctx, allowMatchingLocal = false) {
|
|
@@ -111,28 +114,14 @@ function assertOriginUnchanged(ctx) {
|
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
});
|
|
117
|
+
function currentFragments() {
|
|
118
|
+
if (!preflight.findChangelogFragmentFiles().length) return [];
|
|
119
|
+
const result = preflight.requireChangelogFragments();
|
|
120
|
+
if (!result.ok) throw new Error(result.reason);
|
|
121
|
+
return result.data.changelogFragments.map((fragment) => {
|
|
122
|
+
if (fragment.bump !== 'patch') throw new Error(`Хотфикс не допускает minor/major: ${fragment.filePath}.`);
|
|
123
|
+
return { ...fragment, content: fs.readFileSync(fragment.filePath, 'utf8') };
|
|
124
|
+
});
|
|
136
125
|
}
|
|
137
126
|
|
|
138
127
|
function formatChangelog() {
|
|
@@ -156,7 +145,7 @@ function hotfixStart() {
|
|
|
156
145
|
return run(() => {
|
|
157
146
|
const ctx = context('start');
|
|
158
147
|
assertTagAvailable(ctx);
|
|
159
|
-
const fragments =
|
|
148
|
+
const fragments = currentFragments();
|
|
160
149
|
const original = fs.readFileSync(CHANGELOG_FILE, 'utf8');
|
|
161
150
|
const updated = prepareChangelog({ text: original, ...ctx, fragments });
|
|
162
151
|
assertOriginUnchanged(ctx);
|
|
@@ -164,7 +153,7 @@ function hotfixStart() {
|
|
|
164
153
|
try {
|
|
165
154
|
fs.writeFileSync(CHANGELOG_FILE, updated);
|
|
166
155
|
formatChangelog();
|
|
167
|
-
//
|
|
156
|
+
// Validate the formatted current file and keep all merged hotfix notes.
|
|
168
157
|
prepareChangelog({ text: fs.readFileSync(CHANGELOG_FILE, 'utf8'), ...ctx, fragments: [] });
|
|
169
158
|
for (const fragment of fragments) fs.unlinkSync(fragment.filePath);
|
|
170
159
|
} catch (error) {
|
|
@@ -180,9 +169,9 @@ function hotfixStart() {
|
|
|
180
169
|
function hotfixDeploy() {
|
|
181
170
|
return run(() => {
|
|
182
171
|
const ctx = context('deploy');
|
|
183
|
-
const prepared = pendingDocument(ctx.productionText, ctx.
|
|
172
|
+
const prepared = pendingDocument(ctx.productionText, ctx.target);
|
|
184
173
|
if (!prepared.pending) throw new Error(`В CHANGELOG.md нужен верхний подготовленный раздел ${ctx.target}.`);
|
|
185
|
-
if (
|
|
174
|
+
if (currentFragments().length) throw new Error('В production остались несобранные hotfix fragments.');
|
|
186
175
|
assertTagAvailable(ctx, true);
|
|
187
176
|
assertOriginUnchanged(ctx);
|
|
188
177
|
const tag = `hotfix/${ctx.target}`;
|
package/src/open-release.js
CHANGED
|
@@ -2,7 +2,7 @@ const fs = require('fs');
|
|
|
2
2
|
const { execSilent, logError, logSuccess } = require('./utils');
|
|
3
3
|
const { compareVersions } = require('./version');
|
|
4
4
|
const { CHANGELOG_FILE, CHANGELOG_DIR } = require('./changelog-config');
|
|
5
|
-
const { parseChangelog, releaseEntries } = require('./hotfix-changelog');
|
|
5
|
+
const { parseChangelog, releaseEntries, normalizeLineEndings } = require('./hotfix-changelog');
|
|
6
6
|
const { renderReleaseBlock, stripLegacyUnreleasedBlock } = require('./changelog');
|
|
7
7
|
const { requireChangelogFragments } = require('./preflight');
|
|
8
8
|
|
|
@@ -23,7 +23,7 @@ function resolveReleaseState(localText, productionText, stableVersion) {
|
|
|
23
23
|
if (local.open && production.open && local.open.version !== production.open.version) {
|
|
24
24
|
throw new Error('Открытые версии в dev и production различаются. Сначала согласуйте CHANGELOG.md.');
|
|
25
25
|
}
|
|
26
|
-
const history = ({ document }) => document.releases.map((release) => release.text.trim()).join('\n\n');
|
|
26
|
+
const history = ({ document }) => document.releases.map((release) => normalizeLineEndings(release.text).trim()).join('\n\n');
|
|
27
27
|
if (history(local) !== history(production)) {
|
|
28
28
|
throw new Error('Опубликованная история CHANGELOG.md изменена относительно production.');
|
|
29
29
|
}
|
|
@@ -65,7 +65,7 @@ function appendOpenRelease(context) {
|
|
|
65
65
|
keys.add(key);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
const block = renderReleaseBlock(open.version, merged, open.date);
|
|
68
|
+
const block = renderReleaseBlock(open.version, merged, open.date, open.marker);
|
|
69
69
|
const tail = local.document.releases.map((release) => release.text).join('');
|
|
70
70
|
const updated = `${local.document.intro.trimEnd()}\n\n${block.trim()}\n${tail ? `\n${tail}` : ''}`;
|
|
71
71
|
fs.writeFileSync(CHANGELOG_FILE, updated);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const childProcess = require('child_process');
|
|
2
|
+
const { name, version: currentVersion } = require('../package.json');
|
|
3
|
+
const { compareVersions } = require('./version');
|
|
4
|
+
|
|
5
|
+
const UPDATE_CHECK_TIMEOUT_MS = 200;
|
|
6
|
+
|
|
7
|
+
async function checkForUpdates() {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
let timer;
|
|
10
|
+
try {
|
|
11
|
+
const timeout = new Promise((resolve) => {
|
|
12
|
+
timer = setTimeout(() => {
|
|
13
|
+
controller.abort();
|
|
14
|
+
resolve(null);
|
|
15
|
+
}, UPDATE_CHECK_TIMEOUT_MS);
|
|
16
|
+
});
|
|
17
|
+
const request = (async () => {
|
|
18
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, {
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
headers: { accept: 'application/json' }
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) return null;
|
|
23
|
+
return (await response.json()).version;
|
|
24
|
+
})();
|
|
25
|
+
const latestVersion = await Promise.race([request, timeout]);
|
|
26
|
+
if (compareVersions(latestVersion, currentVersion) > 0) {
|
|
27
|
+
console.log(`⚠️ Доступна новая версия Spectrum CLI: сейчас ${currentVersion}, доступна ${latestVersion}.\nОбновить: spectrum upgrade`);
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Network and registry errors must never block a command.
|
|
31
|
+
} finally {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function upgrade() {
|
|
37
|
+
const result = childProcess.spawnSync('npm', ['install', '-g', name], {
|
|
38
|
+
stdio: 'inherit',
|
|
39
|
+
shell: process.platform === 'win32'
|
|
40
|
+
});
|
|
41
|
+
if (result.error) throw result.error;
|
|
42
|
+
return result.status === 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { checkForUpdates, upgrade, UPDATE_CHECK_TIMEOUT_MS };
|