spectrum-cli 5.0.1 → 5.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.
@@ -4,9 +4,12 @@ const hotfix = require('../hotfix');
4
4
  const changelog = require('../changelog');
5
5
  const chart = require('../chart');
6
6
  const token = require('../token');
7
+ const nextjs = require('../nextjs');
8
+ const sbom = require('../sbom');
9
+ const security = require('../security');
7
10
  const { upgrade } = require('./update-check');
8
11
  const { version: pkgVersion } = require('../../package.json');
9
- const { runAction } = require('./run-action');
12
+ const { runAction, runAutomationAction } = require('./run-action');
10
13
 
11
14
  function registerCommands(program) {
12
15
  program
@@ -21,6 +24,81 @@ program
21
24
  .option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
22
25
  .action((command) => runAction(upgrade, command));
23
26
 
27
+ // Команды Next.js
28
+ const nextjsCmd = program
29
+ .command("nextjs")
30
+ .description("Обработка Next.js build artifacts");
31
+
32
+ nextjsCmd
33
+ .command("licenses")
34
+ .description("Сохранить LICENSE встроенных пакетов в standalone")
35
+ .allowExcessArguments(false)
36
+ .option("-d, --dry", "Запустить только предпроверки")
37
+ .option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
38
+ .action((command) => runAutomationAction(nextjs.licenses, command));
39
+
40
+ // Команды SBOM
41
+ const sbomCmd = program
42
+ .command("sbom")
43
+ .description("Работа с SBOM");
44
+
45
+ const catalogCmd = sbomCmd
46
+ .command("catalog")
47
+ .description("Каталоги встроенных зависимостей Next.js");
48
+
49
+ catalogCmd
50
+ .command("resolve")
51
+ .description("Найти проверенные OCI-каталоги для образа приложения")
52
+ .allowExcessArguments(false)
53
+ .option("-d, --dry", "Запустить только предпроверки")
54
+ .option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
55
+ .action((command) => runAutomationAction(sbom.resolve, command));
56
+
57
+ catalogCmd
58
+ .command("populate")
59
+ .description("Построить и опубликовать каталог из официальных исходников")
60
+ .allowExcessArguments(false)
61
+ .option("-d, --dry", "Запустить только предпроверки")
62
+ .option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
63
+ .action((command) => runAutomationAction(sbom.populate, command));
64
+
65
+ // Команды security
66
+ const securityCmd = program
67
+ .command("security")
68
+ .description("Ночная проверка и исправление HIGH/CRITICAL");
69
+
70
+ securityCmd
71
+ .command("scan")
72
+ .description("Проверить production npm-зависимости и runtime-образ")
73
+ .allowExcessArguments(false)
74
+ .option("-d, --dry", "Запустить только предпроверки")
75
+ .option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
76
+ .action((command) => runAutomationAction(security.scan, command));
77
+
78
+ securityCmd
79
+ .command("track")
80
+ .description("Создать или найти YouTrack-задачи в центральном pipeline")
81
+ .allowExcessArguments(false)
82
+ .option("-d, --dry", "Запустить только предпроверки")
83
+ .option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
84
+ .action((command) => runAutomationAction(security.track, command));
85
+
86
+ securityCmd
87
+ .command("reconcile")
88
+ .description("Дождаться центрального tracker и получить номера задач")
89
+ .allowExcessArguments(false)
90
+ .option("-d, --dry", "Запустить только предпроверки")
91
+ .option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
92
+ .action((command) => runAutomationAction(security.reconcile, command));
93
+
94
+ securityCmd
95
+ .command("repair")
96
+ .description("Исправить, проверить и создать отдельный MR на проектную задачу")
97
+ .allowExcessArguments(false)
98
+ .option("-d, --dry", "Запустить только предпроверки")
99
+ .option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
100
+ .action((command) => runAutomationAction(security.repair, command));
101
+
24
102
  // Команды релиза
25
103
  const releaseCmd = program
26
104
  .command("release")
@@ -29,6 +107,7 @@ const releaseCmd = program
29
107
  releaseCmd
30
108
  .command("start")
31
109
  .description("Схлопнуть fragments и атомарно отправить release commit в dev и main/master")
110
+ .option("--stable", "Явно перейти с 0.x на первый стабильный релиз 1.0.0")
32
111
  .option("-d, --dry", "Запустить только предпроверки")
33
112
  .option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
34
113
  .action((command) => runAction(release.releaseStart, command));
@@ -1,5 +1,5 @@
1
1
  const { checkForUpdates } = require('./update-check');
2
- const { withCommandOptions } = require('./command-executor');
2
+ const { withCommandOptions, reportDryRun } = require('./command-executor');
3
3
 
4
4
  function commandOptions(command) {
5
5
  return command && typeof command.opts === "function" ? command.opts() : command || {};
@@ -25,4 +25,20 @@ async function runAction(action, command) {
25
25
  }
26
26
  }
27
27
 
28
- module.exports = { runAction };
28
+ // Automation handlers return numeric exit codes, including 0 (success)
29
+ // and 2 (partial result). They run without the interactive update check.
30
+ async function runAutomationAction(action, command) {
31
+ const options = commandOptions(command);
32
+ try {
33
+ process.exitCode = await withCommandOptions(options, async () => {
34
+ const code = await action(options);
35
+ if (options.dry && code === 0) reportDryRun();
36
+ return code;
37
+ });
38
+ } catch (error) {
39
+ console.error('ERROR: ' + error.message);
40
+ process.exitCode = 1;
41
+ }
42
+ }
43
+
44
+ module.exports = { runAction, runAutomationAction };
@@ -0,0 +1,184 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const { isDryRun, reportPreflight } = require("../cli/command-executor");
4
+ function licenses({
5
+ cwd = process.cwd(),
6
+ reportOnly = false,
7
+ dry = isDryRun(),
8
+ } = {}) {
9
+ const workspace = fs.realpathSync(cwd);
10
+ const standalone = path.join(workspace, "dist/standalone");
11
+ const result = { copied: 0, existing: 0, withoutLicense: 0, warnings: [] };
12
+ if (dry) result.planned = 0;
13
+
14
+ function warn(file, error) {
15
+ result.warnings.push({
16
+ path: path.relative(workspace, file),
17
+ reason: error.message,
18
+ });
19
+ }
20
+
21
+ // Reject symlinks at every level; never read or write outside this checkout.
22
+ function checked(file, kind) {
23
+ const relative = path.relative(workspace, file);
24
+ if (
25
+ !relative ||
26
+ relative === ".." ||
27
+ relative.startsWith(".." + path.sep) ||
28
+ path.isAbsolute(relative)
29
+ ) {
30
+ throw new Error("path outside checkout");
31
+ }
32
+ let current = workspace;
33
+ const parts = relative.split(path.sep);
34
+ for (let i = 0; i < parts.length; i++) {
35
+ current = path.join(current, parts[i]);
36
+ const stat = fs.lstatSync(current);
37
+ if (
38
+ stat.isSymbolicLink() ||
39
+ (i < parts.length - 1 && !stat.isDirectory())
40
+ ) {
41
+ throw new Error("unsafe symlink or parent path");
42
+ }
43
+ if (
44
+ i === parts.length - 1 &&
45
+ !(kind === "directory" ? stat.isDirectory() : stat.isFile())
46
+ ) {
47
+ throw new Error("unexpected file type");
48
+ }
49
+ }
50
+ return file;
51
+ }
52
+
53
+ function read(file) {
54
+ return fs.readFileSync(checked(file, "file"));
55
+ }
56
+
57
+ function identical(left, right) {
58
+ if (!read(left).equals(read(right)))
59
+ throw new Error("source and standalone files differ");
60
+ }
61
+
62
+ function files(directory) {
63
+ checked(directory, "directory");
64
+ const found = [];
65
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
66
+ const file = path.join(directory, entry.name);
67
+ if (entry.isDirectory()) found.push(...files(file));
68
+ else if (entry.isFile()) found.push(checked(file, "file"));
69
+ else throw new Error("non-regular compiled package entry");
70
+ }
71
+ return found;
72
+ }
73
+
74
+ function preserve(nextRoot) {
75
+ const sourceRoot = path.join(
76
+ workspace,
77
+ path.relative(standalone, nextRoot),
78
+ );
79
+ const owner = JSON.parse(read(path.join(nextRoot, "package.json")));
80
+ if (
81
+ owner.name !== "next" ||
82
+ typeof owner.version !== "string" ||
83
+ !owner.version
84
+ ) {
85
+ throw new Error("invalid standalone Next identity");
86
+ }
87
+ identical(
88
+ path.join(nextRoot, "package.json"),
89
+ path.join(sourceRoot, "package.json"),
90
+ );
91
+ const compiled = path.join(nextRoot, "dist/compiled");
92
+ const manifests = files(compiled).filter(
93
+ (file) => path.basename(file) === "package.json",
94
+ );
95
+ for (const manifest of manifests) {
96
+ try {
97
+ const target = path.dirname(manifest);
98
+ const source = path.join(sourceRoot, path.relative(nextRoot, target));
99
+ const pkg = JSON.parse(read(manifest));
100
+ if (typeof pkg.name !== "string" || !pkg.name)
101
+ throw new Error("invalid compiled package identity");
102
+ identical(manifest, path.join(source, "package.json"));
103
+ const license = path.join(source, "LICENSE");
104
+ // Some published compiled packages legitimately have no LICENSE file.
105
+ // Validate the parent first so ENOENT cannot hide an unsafe source path.
106
+ checked(source, "directory");
107
+ try {
108
+ checked(license, "file");
109
+ } catch (error) {
110
+ if (error.code !== "ENOENT") throw error;
111
+ result.withoutLicense++;
112
+ continue;
113
+ }
114
+ // No repair of modified code, assets or existing license files.
115
+ for (const file of files(target)) {
116
+ identical(file, path.join(source, path.relative(target, file)));
117
+ }
118
+ const destination = path.join(target, "LICENSE");
119
+ try {
120
+ checked(destination, "file");
121
+ identical(destination, license);
122
+ result.existing++;
123
+ continue;
124
+ } catch (error) {
125
+ if (error.code !== "ENOENT") throw error;
126
+ }
127
+ checked(target, "directory");
128
+ if (dry) result.planned++;
129
+ else {
130
+ fs.copyFileSync(license, destination, fs.constants.COPYFILE_EXCL);
131
+ result.copied++;
132
+ }
133
+ } catch (error) {
134
+ warn(manifest, error);
135
+ }
136
+ }
137
+ }
138
+
139
+ function discover(directory) {
140
+ checked(directory, "directory");
141
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
142
+ const file = path.join(directory, entry.name);
143
+ if (entry.isSymbolicLink()) {
144
+ // Standalone .bin links are not package roots and are never followed.
145
+ if (entry.name === "next" || entry.name === "node_modules")
146
+ warn(file, new Error("symlinked package root"));
147
+ continue;
148
+ }
149
+ if (!entry.isDirectory()) continue;
150
+ if (
151
+ entry.name === "next" &&
152
+ path.basename(directory) === "node_modules"
153
+ ) {
154
+ try {
155
+ preserve(file);
156
+ } catch (error) {
157
+ warn(file, error);
158
+ }
159
+ }
160
+ discover(file);
161
+ }
162
+ }
163
+
164
+ try {
165
+ discover(standalone);
166
+ } catch (error) {
167
+ warn(standalone, error);
168
+ }
169
+ if (dry) {
170
+ if (result.warnings.length)
171
+ console.error(
172
+ "Standalone LICENSE preflight: " + JSON.stringify(result.warnings),
173
+ );
174
+ else
175
+ reportPreflight(
176
+ "nextjs licenses",
177
+ "standalone-metadata",
178
+ "проверены исходники, standalone и LICENSE",
179
+ );
180
+ } else
181
+ console.log("Standalone LICENSE preservation: " + JSON.stringify(result));
182
+ return reportOnly ? result : result.warnings.length ? 2 : 0;
183
+ }
184
+ module.exports = { licenses };
@@ -0,0 +1,291 @@
1
+ // Noninteractive preflights. Remote checks are read-only and never log credentials.
2
+ const { fs, path, ensure, request } = require("../automation");
3
+ const { API, projectPath } = require("../security/runtime");
4
+ const { encoded } = require("../sbom/schema");
5
+ const { reportPreflight } = require("../cli/command-executor");
6
+ class PreflightError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "PreflightError";
10
+ }
11
+ }
12
+ function check(condition, message) {
13
+ if (!condition) throw new PreflightError(message);
14
+ }
15
+ function required(env, names) {
16
+ const missing = names.filter(
17
+ (name) => typeof env[name] !== "string" || !env[name].trim(),
18
+ );
19
+ check(!missing.length, "Missing required variables: " + missing.join(", "));
20
+ return env;
21
+ }
22
+ function credentialPair(env, user, password) {
23
+ check(
24
+ Boolean(env[user]) === Boolean(env[password]),
25
+ `Configure both ${user} and ${password}`,
26
+ );
27
+ }
28
+ function httpsEndpoint(value, name) {
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(value);
32
+ } catch {
33
+ throw new PreflightError(name + " must be an explicit HTTPS URL");
34
+ }
35
+ check(
36
+ typeof value === "string" &&
37
+ !/[\x00-\x20]/.test(value) &&
38
+ parsed.protocol === "https:" &&
39
+ !parsed.username &&
40
+ !parsed.password &&
41
+ !parsed.search &&
42
+ !parsed.hash,
43
+ name + " must be an HTTPS URL without credentials, query or fragment",
44
+ );
45
+ return value.replace(/\/+$/, "");
46
+ }
47
+ async function get(api, location, options, label) {
48
+ try {
49
+ return await api.get(location, options);
50
+ } catch (error) {
51
+ const status = Number.isInteger(error.status)
52
+ ? ` (HTTP ${error.status})`
53
+ : "";
54
+ throw new PreflightError(
55
+ `${label}: connection, authentication or access check failed${status}`,
56
+ );
57
+ }
58
+ }
59
+ async function checkGitlabAccess(
60
+ env,
61
+ projects,
62
+ { write = false, purpose = "repository", api } = {},
63
+ ) {
64
+ required(env, ["CI_API_V4_URL", "GLAB_BOT_PAT"]);
65
+ const base = httpsEndpoint(env.CI_API_V4_URL, "CI_API_V4_URL");
66
+ if (env.CI_SERVER_URL)
67
+ check(
68
+ base === httpsEndpoint(env.CI_SERVER_URL, "CI_SERVER_URL") + "/api/v4",
69
+ "GitLab API URL does not match CI_SERVER_URL",
70
+ );
71
+ check(base.endsWith("/api/v4"), "CI_API_V4_URL must end with /api/v4");
72
+ const client = api || new API(base, { "PRIVATE-TOKEN": env.GLAB_BOT_PAT });
73
+ const user = await get(client, "user", undefined, "GitLab identity");
74
+ check(
75
+ user?.id && user.state === "active",
76
+ "GitLab token does not belong to an active user",
77
+ );
78
+ const token = await get(
79
+ client,
80
+ "personal_access_tokens/self",
81
+ undefined,
82
+ "GitLab token scope",
83
+ );
84
+ check(
85
+ token?.active === true &&
86
+ token.revoked !== true &&
87
+ Array.isArray(token.scopes),
88
+ "GitLab token is inactive, revoked or cannot be inspected",
89
+ );
90
+ check(
91
+ token.scopes.includes("api") ||
92
+ (!write && token.scopes.includes("read_api")),
93
+ write
94
+ ? "GitLab token requires the api scope for pipeline/repository writes"
95
+ : "GitLab token requires read_api or api scope",
96
+ );
97
+ for (const project of [...new Set(projects)]) {
98
+ check(
99
+ typeof project === "string" && project.trim() && !/[\r\n]/.test(project),
100
+ "GitLab project must be configured explicitly",
101
+ );
102
+ const data = await get(
103
+ client,
104
+ projectPath(project),
105
+ undefined,
106
+ "GitLab project access",
107
+ );
108
+ const level = user.is_admin
109
+ ? 50
110
+ : Math.max(
111
+ data.permissions?.project_access?.access_level || 0,
112
+ data.permissions?.group_access?.access_level || 0,
113
+ );
114
+ check(
115
+ level >= (write ? 30 : 20),
116
+ write
117
+ ? "GitLab project requires Developer access or higher"
118
+ : "GitLab project requires Reporter access or higher",
119
+ );
120
+ if (write) {
121
+ check(data.archived !== true, "GitLab project is archived");
122
+ if (purpose === "pipeline")
123
+ check(
124
+ data.builds_access_level !== "disabled",
125
+ "GitLab pipeline access is disabled",
126
+ );
127
+ else
128
+ check(
129
+ data.repository_access_level !== "disabled" &&
130
+ data.merge_requests_access_level !== "disabled" &&
131
+ data.can_create_merge_request_in !== false,
132
+ "GitLab repository/MR access is disabled",
133
+ );
134
+ }
135
+ }
136
+ reportPreflight(
137
+ "automation",
138
+ "gitlab-access",
139
+ "проверены GitLab token, scope и роль в проектах",
140
+ );
141
+ return {
142
+ identity: "verified",
143
+ token_scope: "verified",
144
+ project_role: "verified",
145
+ protected_branch_policies: "checked_by_operations",
146
+ };
147
+ }
148
+ async function checkYouTrackAccess(env, cfg, api) {
149
+ required(env, ["YOUTRACK_URL", "YOUTRACK_PRIVATE_TOKEN"]);
150
+ const base = httpsEndpoint(env.YOUTRACK_URL, "YOUTRACK_URL");
151
+ check(
152
+ /^[A-Za-z0-9_-]+$/.test(cfg.youtrack_project || ""),
153
+ "YouTrack project key is required",
154
+ );
155
+ const client =
156
+ api ||
157
+ new API(base + "/api", {
158
+ Authorization: "Bearer " + env.YOUTRACK_PRIVATE_TOKEN,
159
+ });
160
+ const user = await get(
161
+ client,
162
+ "users/me",
163
+ { query: { fields: "id,login,guest,banned" } },
164
+ "YouTrack identity",
165
+ );
166
+ check(
167
+ user?.id && user.login && user.guest !== true && user.banned !== true,
168
+ "YouTrack token must belong to an active, non-guest user",
169
+ );
170
+ const parentProject = env.NIGHTLY_CVE_PROJECT || "CVE";
171
+ check(
172
+ /^[A-Za-z0-9_-]+$/.test(parentProject),
173
+ "Invalid parent YouTrack project",
174
+ );
175
+ for (const project of [...new Set([parentProject, cfg.youtrack_project])]) {
176
+ const projects = await get(
177
+ client,
178
+ "admin/projects",
179
+ { query: { query: project, fields: "id,shortName,archived", $top: 100 } },
180
+ "YouTrack project access",
181
+ );
182
+ check(Array.isArray(projects), "Invalid YouTrack project response");
183
+ const matches = projects.filter((item) => item.shortName === project);
184
+ check(
185
+ matches.length === 1 && matches[0].archived !== true,
186
+ "YouTrack project is missing, inaccessible or archived",
187
+ );
188
+ const fields = await get(
189
+ client,
190
+ "admin/projects/" + encoded(matches[0].id) + "/customFields",
191
+ { query: { fields: "field(name)", $top: 100 } },
192
+ "YouTrack custom fields",
193
+ );
194
+ check(Array.isArray(fields), "Invalid YouTrack custom field response");
195
+ const names = new Set(fields.map((field) => field.field?.name));
196
+ const expected =
197
+ project === parentProject
198
+ ? ["CVE ID", "Severity", "Priority", "Type"]
199
+ : [
200
+ "Tester",
201
+ "Статья учета времени",
202
+ ...(cfg.subsystem ? ["Subsystem"] : []),
203
+ ];
204
+ const missing = expected.filter((name) => !names.has(name));
205
+ check(
206
+ !missing.length,
207
+ "YouTrack project is missing required fields: " + missing.join(", "),
208
+ );
209
+ await get(
210
+ client,
211
+ "issues",
212
+ { query: { query: `project: {${project}}`, fields: "id", $top: 1 } },
213
+ "YouTrack issue read access",
214
+ );
215
+ }
216
+ // GET access cannot prove create/update/link permission or workflow acceptance.
217
+ reportPreflight(
218
+ "security track",
219
+ "youtrack-access",
220
+ "проверены вход в YouTrack, проекты, поля и чтение",
221
+ );
222
+ return {
223
+ identity: "verified",
224
+ projects: "read_verified",
225
+ required_fields: "verified",
226
+ write_permissions: "checked_by_operations",
227
+ };
228
+ }
229
+ async function checkRegistryAccess(
230
+ namespace,
231
+ { transport = request, probe } = {},
232
+ ) {
233
+ const host = namespace.split("/")[0];
234
+ const base = httpsEndpoint("https://" + host, "Registry");
235
+ let response;
236
+ try {
237
+ response = await transport(base + "/v2/", {
238
+ timeout: 15000,
239
+ limit: 1024 * 1024,
240
+ });
241
+ } catch {
242
+ throw new PreflightError(
243
+ "Registry is unavailable or TLS validation failed",
244
+ );
245
+ }
246
+ check(
247
+ response.status === 200 ||
248
+ (response.status === 401 && !!response.headers?.get("www-authenticate")),
249
+ "Registry v2 endpoint is unavailable",
250
+ );
251
+ // ORAS handles registry authentication; an exact target read distinguishes
252
+ // auth failures from an empty/missing catalogue before upstream work begins.
253
+ const existing = await probe();
254
+ reportPreflight(
255
+ "sbom catalog populate",
256
+ "registry-access",
257
+ "проверены доступность registry и чтение namespace",
258
+ );
259
+ return {
260
+ endpoint: "reachable",
261
+ authentication: "oras_login_completed",
262
+ namespace_read: existing ? "verified" : "missing_or_hidden",
263
+ push_permissions: "checked_by_publish",
264
+ };
265
+ }
266
+ function checkExecutables(names, env = process.env) {
267
+ for (const name of names) {
268
+ const found = (env.PATH || "").split(path.delimiter).some((directory) => {
269
+ const file = path.join(directory, name);
270
+ try {
271
+ return (
272
+ fs.statSync(file).isFile() &&
273
+ (fs.accessSync(file, fs.constants.X_OK), true)
274
+ );
275
+ } catch {
276
+ return false;
277
+ }
278
+ });
279
+ check(found, "Required executable is unavailable: " + name);
280
+ }
281
+ }
282
+ module.exports = {
283
+ checkExecutables,
284
+ PreflightError,
285
+ required,
286
+ credentialPair,
287
+ httpsEndpoint,
288
+ checkGitlabAccess,
289
+ checkYouTrackAccess,
290
+ checkRegistryAccess,
291
+ };
@@ -5,7 +5,7 @@ const { requireGitRepo, requireCleanWorkingTree, requireCurrentBranchUpToDateWit
5
5
  const { CHANGELOG_FILE } = require('../changelog/config');
6
6
  const { detectBumpType, resolveReleaseVersion } = require('./version');
7
7
  const { releaseFormatChangelog, releaseCheckChangelogLint, releaseCommit, releasePush } = require('./steps');
8
- function releaseStart() {
8
+ function releaseStart(options = {}) {
9
9
  return runCommand({
10
10
  name: 'release start',
11
11
  checks: [
@@ -24,9 +24,13 @@ function releaseStart() {
24
24
  name: 'detect-bump-type',
25
25
  requires: ['changelogFragments', 'stableVersion'],
26
26
  run: (ctx) => {
27
+ if (options.stable && (!ctx.stableVersion.startsWith('0.') ||
28
+ (ctx.openReleaseVersion && ctx.openReleaseVersion !== '1.0.0'))) {
29
+ return { ok: false, reason: '--stable разрешён только для перехода с 0.x на 1.0.0 без другого открытого релиза.' };
30
+ }
27
31
  const resolved = ctx.openReleaseVersion
28
32
  ? { bumpType: detectBumpType(ctx.changelogFragments), newVersion: ctx.openReleaseVersion }
29
- : resolveReleaseVersion(ctx.stableVersion, ctx.changelogFragments);
33
+ : resolveReleaseVersion(ctx.stableVersion, ctx.changelogFragments, options);
30
34
  if (!resolved) {
31
35
  return { ok: false, reason: 'Не удалось вычислить release-версию от последнего стабильного тега.' };
32
36
  }
@@ -1,4 +1,4 @@
1
- const { upVersion } = require('../common/version');
1
+ const { upVersion, parseStableVersion } = require('../common/version');
2
2
  function detectBumpType(fragments) {
3
3
  const bumpTypes = new Set((fragments || []).map((fragment) => fragment.bump));
4
4
  if (bumpTypes.has('major')) {
@@ -10,8 +10,12 @@ function detectBumpType(fragments) {
10
10
  return 'patch';
11
11
  }
12
12
 
13
- function resolveReleaseVersion(stableVersion, fragments) {
14
- const bumpType = detectBumpType(fragments);
13
+ function resolveReleaseVersion(stableVersion, fragments, { stable = false } = {}) {
14
+ const baseline = parseStableVersion(stableVersion);
15
+ if (!baseline || (stable && baseline[0] !== 0)) return null;
16
+ const impact = detectBumpType(fragments);
17
+ // Initial development stays in 0.x until explicitly promoted to 1.0.0.
18
+ const bumpType = stable ? 'major' : baseline[0] === 0 && impact === 'major' ? 'minor' : impact;
15
19
  const newVersion = upVersion(stableVersion, bumpType);
16
20
  if (!newVersion) return null;
17
21
  return { bumpType, newVersion };
@@ -0,0 +1,4 @@
1
+ const { resolve } = require('./resolve');
2
+ const { populate } = require('./populate');
3
+
4
+ module.exports = { resolve, populate };