genbumppush 0.0.2 → 0.0.4
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/README.md +116 -52
- package/dist/bin.mjs +25 -3
- package/dist/index.d.mts +449 -1
- package/dist/index.mjs +1 -1
- package/dist/{release-cb3ZULZM.mjs → release-gwyqzR0C.mjs} +528 -18
- package/package.json +1 -1
|
@@ -1,12 +1,43 @@
|
|
|
1
|
-
import { createDefineConfig, loadConfig } from "c12";
|
|
2
|
-
import { determineSemverChange, generateMarkDown, getGitDiff, loadChangelogConfig, parseCommits } from "changelogen";
|
|
1
|
+
import { createDefineConfig, loadConfig, setupDotenv } from "c12";
|
|
2
|
+
import { determineSemverChange, generateMarkDown, getGitDiff, loadChangelogConfig, parseCommits, resolveRepoConfig } from "changelogen";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { readFile, readdir, realpath, unlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
7
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
8
8
|
//#region src/config.ts
|
|
9
|
+
/**
|
|
10
|
+
* Identity helper that types a release config for your editor.
|
|
11
|
+
*
|
|
12
|
+
* It does not change the object at runtime — it only enables autocomplete
|
|
13
|
+
* and catches typos inside `defineConfig({ … })`.
|
|
14
|
+
*
|
|
15
|
+
* @typeParam Config - Config object shape; defaults to {@link GenBumpPushConfig}.
|
|
16
|
+
* @returns The same object you passed in.
|
|
17
|
+
*
|
|
18
|
+
* @example `genbumppush.config.ts`
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { defineConfig } from 'genbumppush';
|
|
21
|
+
*
|
|
22
|
+
* export default defineConfig({
|
|
23
|
+
* preid: 'beta',
|
|
24
|
+
* files: ['package.json'],
|
|
25
|
+
* git: {
|
|
26
|
+
* commitMessage: 'chore(release): v{{version}}',
|
|
27
|
+
* },
|
|
28
|
+
* hooks: {
|
|
29
|
+
* before: ['npm run check', 'npm test'],
|
|
30
|
+
* },
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
9
34
|
const defineConfig = createDefineConfig();
|
|
35
|
+
/**
|
|
36
|
+
* Built-in defaults used when neither the CLI, a config file, nor
|
|
37
|
+
* `package.json` sets a field.
|
|
38
|
+
*
|
|
39
|
+
* Useful if you want to document or assert what an empty config resolves to.
|
|
40
|
+
*/
|
|
10
41
|
const defaults = {
|
|
11
42
|
changelog: "CHANGELOG.md",
|
|
12
43
|
excludeDependencyCommits: true,
|
|
@@ -22,7 +53,45 @@ const defaults = {
|
|
|
22
53
|
tagMessage: "v{{version}}"
|
|
23
54
|
}
|
|
24
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* Load the effective release config for a repository.
|
|
58
|
+
*
|
|
59
|
+
* Resolution order (later wins):
|
|
60
|
+
* 1. {@link defaults}
|
|
61
|
+
* 2. `"genbumppush"` key in that directory’s `package.json`
|
|
62
|
+
* 3. C12 config file (`genbumppush.config.ts` / `.js`, or `configFile` when given)
|
|
63
|
+
* 4. `overrides` you pass here (CLI flags in the binary go through this)
|
|
64
|
+
*
|
|
65
|
+
* Also loads `.env` from `cwd` into `process.env` without overwriting
|
|
66
|
+
* variables that are already set. Secrets still belong in the environment —
|
|
67
|
+
* not in config files.
|
|
68
|
+
*
|
|
69
|
+
* @param cwd - Repository root to load config from.
|
|
70
|
+
* @param configFile - Optional explicit path to a C12 config file.
|
|
71
|
+
* @param overrides - Highest-priority values (for example CLI flags).
|
|
72
|
+
* @returns The fully merged config object.
|
|
73
|
+
* @throws May reject if the config file throws or cannot be loaded.
|
|
74
|
+
*
|
|
75
|
+
* @example Read what a repo already configured
|
|
76
|
+
* ```ts
|
|
77
|
+
* import { loadReleaseConfig } from 'genbumppush';
|
|
78
|
+
*
|
|
79
|
+
* const config = await loadReleaseConfig(process.cwd());
|
|
80
|
+
* console.log(config.git?.remote ?? 'origin');
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* @example Force a dry-run-style push disable from a script
|
|
84
|
+
* ```ts
|
|
85
|
+
* import { loadReleaseConfig } from 'genbumppush';
|
|
86
|
+
*
|
|
87
|
+
* const config = await loadReleaseConfig(process.cwd(), undefined, {
|
|
88
|
+
* git: { push: false },
|
|
89
|
+
* });
|
|
90
|
+
* // config.git.push === false even if the file enabled push
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
25
93
|
async function loadReleaseConfig(cwd, configFile, overrides) {
|
|
94
|
+
await setupDotenv({ cwd });
|
|
26
95
|
return (await loadConfig({
|
|
27
96
|
name: "genbumppush",
|
|
28
97
|
cwd,
|
|
@@ -36,8 +105,42 @@ async function loadReleaseConfig(cwd, configFile, overrides) {
|
|
|
36
105
|
}
|
|
37
106
|
//#endregion
|
|
38
107
|
//#region src/error.ts
|
|
108
|
+
/**
|
|
109
|
+
* Error thrown by genbumppush for expected release failures.
|
|
110
|
+
*
|
|
111
|
+
* Unlike a generic `Error`, every {@link ReleaseError} carries a stable
|
|
112
|
+
* {@link ReleaseError.code} so scripts and the CLI can branch on the reason
|
|
113
|
+
* (`DIRTY_WORKTREE`, `TAG_EXISTS`, `CANCELLED`, …) without parsing messages.
|
|
114
|
+
*
|
|
115
|
+
* @example Branch on the failure reason
|
|
116
|
+
* ```ts
|
|
117
|
+
* import { runRelease, ReleaseError } from 'genbumppush';
|
|
118
|
+
*
|
|
119
|
+
* try {
|
|
120
|
+
* await runRelease({ cwd: process.cwd(), dryRun: false, yes: true, help: false });
|
|
121
|
+
* } catch (error) {
|
|
122
|
+
* if (error instanceof ReleaseError && error.code === 'CANCELLED') {
|
|
123
|
+
* process.exit(0);
|
|
124
|
+
* }
|
|
125
|
+
* throw error;
|
|
126
|
+
* }
|
|
127
|
+
* ```
|
|
128
|
+
*
|
|
129
|
+
* @example Print code and message the same way the CLI does
|
|
130
|
+
* ```ts
|
|
131
|
+
* if (error instanceof ReleaseError) {
|
|
132
|
+
* console.error(`[${error.code}] ${error.message}`);
|
|
133
|
+
* }
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
39
136
|
var ReleaseError = class extends Error {
|
|
137
|
+
/** Machine-readable reason, for example `'DIRTY_WORKTREE'` or `'TAG_EXISTS'`. */
|
|
40
138
|
code;
|
|
139
|
+
/**
|
|
140
|
+
* @param code - Stable machine-readable reason.
|
|
141
|
+
* @param message - Human-readable explanation shown to the user.
|
|
142
|
+
* @param options - Optional `cause` when wrapping an underlying error.
|
|
143
|
+
*/
|
|
41
144
|
constructor(code, message, options) {
|
|
42
145
|
super(message, options);
|
|
43
146
|
this.name = "ReleaseError";
|
|
@@ -73,7 +176,7 @@ function tagExists(cwd, tag) {
|
|
|
73
176
|
}).status === 0;
|
|
74
177
|
}
|
|
75
178
|
function isGitRepository(cwd) {
|
|
76
|
-
|
|
179
|
+
const result = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
77
180
|
cwd,
|
|
78
181
|
encoding: "utf8",
|
|
79
182
|
stdio: [
|
|
@@ -81,7 +184,9 @@ function isGitRepository(cwd) {
|
|
|
81
184
|
"pipe",
|
|
82
185
|
"ignore"
|
|
83
186
|
]
|
|
84
|
-
})
|
|
187
|
+
});
|
|
188
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== "string") return false;
|
|
189
|
+
return result.stdout.trim() === "true";
|
|
85
190
|
}
|
|
86
191
|
function remoteTagExists(cwd, remote, tag) {
|
|
87
192
|
return git([
|
|
@@ -99,7 +204,139 @@ function runHook(command, cwd) {
|
|
|
99
204
|
}).status !== 0) throw new ReleaseError("HOOK_FAILED", `Hook failed: ${command}`);
|
|
100
205
|
}
|
|
101
206
|
//#endregion
|
|
207
|
+
//#region src/env.ts
|
|
208
|
+
const ENV = {
|
|
209
|
+
GITHUB_TOKEN: "GENBUMPPUSH_GITHUB_TOKEN",
|
|
210
|
+
GITHUB_HOST: "GENBUMPPUSH_GITHUB_HOST",
|
|
211
|
+
GITHUB_REPOSITORY: "GENBUMPPUSH_GITHUB_REPOSITORY",
|
|
212
|
+
GITLAB_TOKEN: "GENBUMPPUSH_GITLAB_TOKEN",
|
|
213
|
+
GITLAB_HOST: "GENBUMPPUSH_GITLAB_HOST",
|
|
214
|
+
GITLAB_PROJECT: "GENBUMPPUSH_GITLAB_PROJECT"
|
|
215
|
+
};
|
|
216
|
+
function readEnv(name) {
|
|
217
|
+
const value = process.env[name];
|
|
218
|
+
return value !== void 0 && value.length > 0 ? value : void 0;
|
|
219
|
+
}
|
|
220
|
+
function readEnvFirst(...names) {
|
|
221
|
+
for (const name of names) {
|
|
222
|
+
const value = readEnv(name);
|
|
223
|
+
if (value !== void 0) return value;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/github.ts
|
|
228
|
+
function normalizeHost(host) {
|
|
229
|
+
return host.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
230
|
+
}
|
|
231
|
+
function apiBase(host) {
|
|
232
|
+
const normalized = normalizeHost(host);
|
|
233
|
+
if (normalized === "github.com" || normalized === "api.github.com") return "https://api.github.com";
|
|
234
|
+
return `https://${normalized}/api/v3`;
|
|
235
|
+
}
|
|
236
|
+
function encodeRepoPath(repo) {
|
|
237
|
+
return repo.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
238
|
+
}
|
|
239
|
+
const GITHUB_TOKEN_FALLBACKS = [
|
|
240
|
+
ENV.GITHUB_TOKEN,
|
|
241
|
+
"GITHUB_TOKEN",
|
|
242
|
+
"GH_TOKEN",
|
|
243
|
+
"CHANGELOGEN_TOKENS_GITHUB"
|
|
244
|
+
];
|
|
245
|
+
function githubTokenEnvLabel(tokenEnv) {
|
|
246
|
+
return tokenEnv ?? `${ENV.GITHUB_TOKEN} (or GITHUB_TOKEN, GH_TOKEN, CHANGELOGEN_TOKENS_GITHUB)`;
|
|
247
|
+
}
|
|
248
|
+
function resolveGitHubToken(tokenEnv) {
|
|
249
|
+
if (tokenEnv !== void 0) return readEnv(tokenEnv);
|
|
250
|
+
return readEnvFirst(...GITHUB_TOKEN_FALLBACKS);
|
|
251
|
+
}
|
|
252
|
+
async function resolveGitHubRepo(cwd, source) {
|
|
253
|
+
const host = normalizeHost(source.host ?? readEnv(ENV.GITHUB_HOST) ?? readEnv("GITHUB_API_URL") ?? "github.com");
|
|
254
|
+
const explicit = source.repo ?? readEnvFirst(ENV.GITHUB_REPOSITORY, "GITHUB_REPOSITORY");
|
|
255
|
+
if (explicit !== void 0 && explicit.length > 0) return {
|
|
256
|
+
host,
|
|
257
|
+
repo: explicit
|
|
258
|
+
};
|
|
259
|
+
const resolved = await resolveRepoConfig(cwd);
|
|
260
|
+
if (resolved?.provider === "github" && resolved.repo) return {
|
|
261
|
+
host: normalizeHost(resolved.domain ?? host),
|
|
262
|
+
repo: resolved.repo
|
|
263
|
+
};
|
|
264
|
+
throw new ReleaseError("GITHUB_RELEASE_FAILED", `Set github.repo or ${ENV.GITHUB_REPOSITORY} (or GITHUB_REPOSITORY) to create a GitHub release.`);
|
|
265
|
+
}
|
|
266
|
+
async function githubFetch(options, path, init) {
|
|
267
|
+
const url = `${apiBase(options.host)}/repos/${encodeRepoPath(options.repo)}${path}`;
|
|
268
|
+
const headers = new Headers({
|
|
269
|
+
Accept: "application/vnd.github+json",
|
|
270
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
271
|
+
Authorization: `Bearer ${options.token}`,
|
|
272
|
+
"Content-Type": "application/json"
|
|
273
|
+
});
|
|
274
|
+
if (init?.headers) new Headers(init.headers).forEach((value, key) => {
|
|
275
|
+
headers.set(key, value);
|
|
276
|
+
});
|
|
277
|
+
let response;
|
|
278
|
+
try {
|
|
279
|
+
response = await fetch(url, {
|
|
280
|
+
method: init?.method,
|
|
281
|
+
body: init?.body,
|
|
282
|
+
headers,
|
|
283
|
+
signal: init?.signal ?? AbortSignal.timeout(3e4)
|
|
284
|
+
});
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw new ReleaseError("GITHUB_RELEASE_FAILED", "Could not send the GitHub release request.", { cause: error });
|
|
287
|
+
}
|
|
288
|
+
return response;
|
|
289
|
+
}
|
|
290
|
+
function parseReleaseId(body) {
|
|
291
|
+
if (typeof body !== "object" || body === null || !("id" in body)) return void 0;
|
|
292
|
+
const id = body.id;
|
|
293
|
+
return typeof id === "number" ? id : void 0;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Create or update a GitHub (or GHES) release for an existing tag.
|
|
297
|
+
* Uses the exact tag string so custom genbumppush tag templates stay valid.
|
|
298
|
+
*/
|
|
299
|
+
async function createGitHubRelease(options) {
|
|
300
|
+
const existing = await githubFetch(options, `/releases/tags/${encodeURIComponent(options.tag)}`, { method: "GET" });
|
|
301
|
+
let existingId;
|
|
302
|
+
if (existing.ok) existingId = parseReleaseId(await existing.json().catch(() => ({})));
|
|
303
|
+
else if (existing.status !== 404) {
|
|
304
|
+
const text = await existing.text().catch(() => "");
|
|
305
|
+
throw new ReleaseError("GITHUB_RELEASE_FAILED", `GitHub release lookup failed with ${existing.status}: ${text || existing.statusText}`);
|
|
306
|
+
}
|
|
307
|
+
const payload = JSON.stringify({
|
|
308
|
+
tag_name: options.tag,
|
|
309
|
+
name: options.name,
|
|
310
|
+
body: options.description
|
|
311
|
+
});
|
|
312
|
+
const response = existingId === void 0 ? await githubFetch(options, "/releases", {
|
|
313
|
+
method: "POST",
|
|
314
|
+
body: payload
|
|
315
|
+
}) : await githubFetch(options, `/releases/${existingId}`, {
|
|
316
|
+
method: "PATCH",
|
|
317
|
+
body: payload
|
|
318
|
+
});
|
|
319
|
+
if (!response.ok) {
|
|
320
|
+
const text = await response.text().catch(() => "");
|
|
321
|
+
throw new ReleaseError("GITHUB_RELEASE_FAILED", `GitHub release creation failed with ${response.status}: ${text || response.statusText}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
//#endregion
|
|
102
325
|
//#region src/gitlab.ts
|
|
326
|
+
const GITLAB_TOKEN_FALLBACKS = [ENV.GITLAB_TOKEN, "GITLAB_TOKEN"];
|
|
327
|
+
function gitLabTokenEnvLabel(tokenEnv) {
|
|
328
|
+
return tokenEnv ?? `${ENV.GITLAB_TOKEN} (or GITLAB_TOKEN)`;
|
|
329
|
+
}
|
|
330
|
+
function resolveGitLabToken(tokenEnv) {
|
|
331
|
+
if (tokenEnv !== void 0) return readEnv(tokenEnv);
|
|
332
|
+
return readEnvFirst(...GITLAB_TOKEN_FALLBACKS);
|
|
333
|
+
}
|
|
334
|
+
function resolveGitLabHost(host) {
|
|
335
|
+
return host ?? readEnv(ENV.GITLAB_HOST) ?? readEnv("GITLAB_HOST") ?? "https://gitlab.com";
|
|
336
|
+
}
|
|
337
|
+
function resolveGitLabProject(project) {
|
|
338
|
+
return project ?? readEnvFirst(ENV.GITLAB_PROJECT, "GITLAB_PROJECT");
|
|
339
|
+
}
|
|
103
340
|
function releaseNotes(changelog, tag) {
|
|
104
341
|
const lines = changelog.split("\n");
|
|
105
342
|
const heading = `## ${tag}`;
|
|
@@ -112,12 +349,14 @@ function releaseNotes(changelog, tag) {
|
|
|
112
349
|
}
|
|
113
350
|
return notes.join("\n").trim() || "See CHANGELOG.md for release notes.";
|
|
114
351
|
}
|
|
352
|
+
const PROVIDER_FETCH_TIMEOUT_MS = 3e4;
|
|
115
353
|
async function createGitLabRelease(options) {
|
|
116
354
|
const url = `${options.host.replace(/\/$/, "")}/api/v4/projects/${encodeURIComponent(options.project)}/releases`;
|
|
117
355
|
let response;
|
|
118
356
|
try {
|
|
119
357
|
response = await fetch(url, {
|
|
120
358
|
method: "POST",
|
|
359
|
+
signal: AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS),
|
|
121
360
|
headers: {
|
|
122
361
|
Authorization: `Bearer ${options.token}`,
|
|
123
362
|
"Content-Type": "application/json"
|
|
@@ -184,11 +423,139 @@ async function findManifests(directory) {
|
|
|
184
423
|
function assertCurrent(path, actual, expected) {
|
|
185
424
|
if (actual !== expected) throw new ReleaseError("VERSION_MISMATCH", `${path} has version ${actual}; expected ${expected}.`);
|
|
186
425
|
}
|
|
426
|
+
function parseJsonString(raw) {
|
|
427
|
+
try {
|
|
428
|
+
const parsed = JSON.parse(raw);
|
|
429
|
+
return isString$1(parsed) ? parsed : void 0;
|
|
430
|
+
} catch {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function readJsonString(content, start) {
|
|
435
|
+
let index = start + 1;
|
|
436
|
+
let escape = false;
|
|
437
|
+
while (index < content.length) {
|
|
438
|
+
const char = content[index];
|
|
439
|
+
if (char === void 0) break;
|
|
440
|
+
if (escape) escape = false;
|
|
441
|
+
else if (char === "\\") escape = true;
|
|
442
|
+
else if (char === "\"") {
|
|
443
|
+
index += 1;
|
|
444
|
+
return {
|
|
445
|
+
end: index,
|
|
446
|
+
raw: content.slice(start, index)
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
index += 1;
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
end: content.length,
|
|
453
|
+
raw: content.slice(start)
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function objectKeyPath(stack, propertyKey) {
|
|
457
|
+
const path = [];
|
|
458
|
+
for (const frame of stack) if (frame.kind === "object" && frame.introKey !== void 0) path.push(frame.introKey);
|
|
459
|
+
path.push(propertyKey);
|
|
460
|
+
return path;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Locate a JSON string property by object-key path without reformatting the file.
|
|
464
|
+
* Nested keys with the same name are never selected.
|
|
465
|
+
*/
|
|
466
|
+
function findJsonStringProperty(content, keyPath) {
|
|
467
|
+
if (keyPath.length === 0) return void 0;
|
|
468
|
+
const stack = [];
|
|
469
|
+
let index = 0;
|
|
470
|
+
let pendingKey;
|
|
471
|
+
let afterColon = false;
|
|
472
|
+
const skipWhitespace = () => {
|
|
473
|
+
while (index < content.length && /\s/.test(content[index] ?? "")) index += 1;
|
|
474
|
+
};
|
|
475
|
+
while (index < content.length) {
|
|
476
|
+
skipWhitespace();
|
|
477
|
+
const char = content[index];
|
|
478
|
+
if (char === void 0) break;
|
|
479
|
+
if (char === "{") {
|
|
480
|
+
stack.push({
|
|
481
|
+
kind: "object",
|
|
482
|
+
introKey: pendingKey
|
|
483
|
+
});
|
|
484
|
+
pendingKey = void 0;
|
|
485
|
+
afterColon = false;
|
|
486
|
+
index += 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (char === "}") {
|
|
490
|
+
stack.pop();
|
|
491
|
+
pendingKey = void 0;
|
|
492
|
+
afterColon = false;
|
|
493
|
+
index += 1;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (char === "[") {
|
|
497
|
+
stack.push({ kind: "array" });
|
|
498
|
+
pendingKey = void 0;
|
|
499
|
+
afterColon = false;
|
|
500
|
+
index += 1;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (char === "]") {
|
|
504
|
+
stack.pop();
|
|
505
|
+
pendingKey = void 0;
|
|
506
|
+
afterColon = false;
|
|
507
|
+
index += 1;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (char === ",") {
|
|
511
|
+
pendingKey = void 0;
|
|
512
|
+
afterColon = false;
|
|
513
|
+
index += 1;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (char === ":") {
|
|
517
|
+
afterColon = true;
|
|
518
|
+
index += 1;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (char === "\"") {
|
|
522
|
+
const { end, raw } = readJsonString(content, index);
|
|
523
|
+
index = end;
|
|
524
|
+
const decoded = parseJsonString(raw);
|
|
525
|
+
if (decoded === void 0) continue;
|
|
526
|
+
if (stack.at(-1)?.kind === "object" && !afterColon) {
|
|
527
|
+
pendingKey = decoded;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (afterColon && pendingKey !== void 0) {
|
|
531
|
+
const path = objectKeyPath(stack, pendingKey);
|
|
532
|
+
if (path.length === keyPath.length && path.every((segment, i) => segment === keyPath[i])) return {
|
|
533
|
+
value: decoded,
|
|
534
|
+
valueStart: end - raw.length + 1,
|
|
535
|
+
valueEnd: end - 1
|
|
536
|
+
};
|
|
537
|
+
pendingKey = void 0;
|
|
538
|
+
afterColon = false;
|
|
539
|
+
}
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (afterColon) {
|
|
543
|
+
pendingKey = void 0;
|
|
544
|
+
afterColon = false;
|
|
545
|
+
}
|
|
546
|
+
index += 1;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function replaceJsonStringProperty(content, span, value) {
|
|
550
|
+
return `${content.slice(0, span.valueStart)}${JSON.stringify(value).slice(1, -1)}${content.slice(span.valueEnd)}`;
|
|
551
|
+
}
|
|
187
552
|
function replaceJsonVersion(content, path, current, version) {
|
|
188
553
|
const parsed = JSON.parse(content);
|
|
189
554
|
if (!isObject$1(parsed) || !("version" in parsed) || !isString$1(parsed.version)) throw new ReleaseError("MISSING_VERSION", `${path} has no top-level version string.`);
|
|
190
555
|
assertCurrent(path, parsed.version, current);
|
|
191
|
-
const
|
|
556
|
+
const span = findJsonStringProperty(content, ["version"]);
|
|
557
|
+
if (span === void 0) throw new ReleaseError("MISSING_VERSION", `${path} has no top-level version string.`);
|
|
558
|
+
const updated = replaceJsonStringProperty(content, span, version);
|
|
192
559
|
if (updated === content) throw new ReleaseError("VERSION_UNCHANGED", `${path} was not updated.`);
|
|
193
560
|
return updated;
|
|
194
561
|
}
|
|
@@ -201,10 +568,20 @@ function replacePackageLockVersion(content, path, current, version) {
|
|
|
201
568
|
const rootVersion = data.packages?.[""]?.version;
|
|
202
569
|
if (rootVersion !== void 0 && !isString$1(rootVersion)) throw new ReleaseError("INVALID_LOCKFILE", `${path} has an invalid root package version.`);
|
|
203
570
|
if (isString$1(rootVersion)) assertCurrent(path, rootVersion, current);
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
571
|
+
const rootSpan = findJsonStringProperty(content, ["version"]);
|
|
572
|
+
if (rootSpan === void 0) throw new ReleaseError("MISSING_VERSION", `${path} has no root version.`);
|
|
573
|
+
let updated = replaceJsonStringProperty(content, rootSpan, version);
|
|
574
|
+
if (isString$1(rootVersion)) {
|
|
575
|
+
const packagesSpan = findJsonStringProperty(updated, [
|
|
576
|
+
"packages",
|
|
577
|
+
"",
|
|
578
|
+
"version"
|
|
579
|
+
]);
|
|
580
|
+
if (packagesSpan === void 0) throw new ReleaseError("MISSING_VERSION", `${path} has no packages[""].version string to update.`);
|
|
581
|
+
updated = replaceJsonStringProperty(updated, packagesSpan, version);
|
|
582
|
+
}
|
|
583
|
+
if (updated === content) throw new ReleaseError("VERSION_UNCHANGED", `${path} was not updated.`);
|
|
584
|
+
return updated;
|
|
208
585
|
}
|
|
209
586
|
function cargoSection(content) {
|
|
210
587
|
const start = content.search(/^\[package\]\s*$/m);
|
|
@@ -379,19 +756,35 @@ function isString(value) {
|
|
|
379
756
|
}
|
|
380
757
|
function gitLabContext(config) {
|
|
381
758
|
if (config?.enabled !== true) throw new ReleaseError("GITLAB_RELEASE_FAILED", "Enable gitlab before creating a release.");
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
if (
|
|
386
|
-
if (project === void 0 || project.length === 0) throw new ReleaseError("GITLAB_RELEASE_FAILED", "Set gitlab.project or GITLAB_PROJECT to create a GitLab release.");
|
|
759
|
+
const token = resolveGitLabToken(config.tokenEnv);
|
|
760
|
+
const project = resolveGitLabProject(config.project);
|
|
761
|
+
if (token === void 0) throw new ReleaseError("GITLAB_RELEASE_FAILED", `Set ${gitLabTokenEnvLabel(config.tokenEnv)} to create a GitLab release.`);
|
|
762
|
+
if (project === void 0) throw new ReleaseError("GITLAB_RELEASE_FAILED", "Set gitlab.project or GENBUMPPUSH_GITLAB_PROJECT (or GITLAB_PROJECT) to create a GitLab release.");
|
|
387
763
|
const context = {
|
|
388
|
-
host: config.host
|
|
764
|
+
host: resolveGitLabHost(config.host),
|
|
389
765
|
project,
|
|
390
766
|
token
|
|
391
767
|
};
|
|
392
768
|
if (config.releaseName !== void 0) context.releaseName = config.releaseName;
|
|
393
769
|
return context;
|
|
394
770
|
}
|
|
771
|
+
function resolveGitHubContext(config, cwd) {
|
|
772
|
+
if (config?.enabled !== true) throw new ReleaseError("GITHUB_RELEASE_FAILED", "Enable github before creating a release.");
|
|
773
|
+
const token = resolveGitHubToken(config.tokenEnv);
|
|
774
|
+
if (token === void 0) throw new ReleaseError("GITHUB_RELEASE_FAILED", `Set ${githubTokenEnvLabel(config.tokenEnv)} to create a GitHub release.`);
|
|
775
|
+
return resolveGitHubRepo(cwd, {
|
|
776
|
+
host: config.host,
|
|
777
|
+
repo: config.repo
|
|
778
|
+
}).then(({ host, repo }) => {
|
|
779
|
+
const context = {
|
|
780
|
+
host,
|
|
781
|
+
repo,
|
|
782
|
+
token
|
|
783
|
+
};
|
|
784
|
+
if (config.releaseName !== void 0) context.releaseName = config.releaseName;
|
|
785
|
+
return context;
|
|
786
|
+
});
|
|
787
|
+
}
|
|
395
788
|
async function changelogText(cwd, config) {
|
|
396
789
|
if (config.changelog === false) return "";
|
|
397
790
|
const path = await resolveRepositoryPath(cwd, config.changelog === true ? "CHANGELOG.md" : config.changelog ?? "CHANGELOG.md");
|
|
@@ -407,6 +800,16 @@ async function publishGitLab(context, cwd, config, tag, version) {
|
|
|
407
800
|
description: releaseNotes(await changelogText(cwd, config), tag)
|
|
408
801
|
});
|
|
409
802
|
}
|
|
803
|
+
async function publishGitHub(context, cwd, config, tag, version) {
|
|
804
|
+
await createGitHubRelease({
|
|
805
|
+
host: context.host,
|
|
806
|
+
repo: context.repo,
|
|
807
|
+
token: context.token,
|
|
808
|
+
tag,
|
|
809
|
+
name: context.releaseName?.replaceAll("{{version}}", version) ?? tag,
|
|
810
|
+
description: releaseNotes(await changelogText(cwd, config), tag)
|
|
811
|
+
});
|
|
812
|
+
}
|
|
410
813
|
function filter(commits, config, exclude) {
|
|
411
814
|
return commits.filter((commit) => {
|
|
412
815
|
const type = config.types[commit.type.toLowerCase()];
|
|
@@ -446,6 +849,82 @@ function packageVersion(cwd) {
|
|
|
446
849
|
if (!isObject(data) || !("version" in data) || !isString(data.version)) throw new ReleaseError("INVALID_PACKAGE", "package.json must contain a version string.");
|
|
447
850
|
return data.version;
|
|
448
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* Run a full release (or a dry run / provider retry) for one repository.
|
|
854
|
+
*
|
|
855
|
+
* Typical flow:
|
|
856
|
+
* 1. Load config and `.env`
|
|
857
|
+
* 2. Verify the worktree is a clean Git repo on a branch
|
|
858
|
+
* 3. Detect (or force) a release type from Conventional Commits
|
|
859
|
+
* 4. Confirm, run `before` hooks, update version files and the changelog
|
|
860
|
+
* 5. Commit, tag, and atomically push branch + tag
|
|
861
|
+
* 6. Optionally create a GitHub/GitLab release, then run `after` hooks
|
|
862
|
+
*
|
|
863
|
+
* Version-file or commit failures restore files and the index. A failed tag
|
|
864
|
+
* or network push can leave the release commit locally so you can inspect
|
|
865
|
+
* and retry — see the returned {@link ReleaseResult} and any thrown
|
|
866
|
+
* {@link ReleaseError}.
|
|
867
|
+
*
|
|
868
|
+
* Prefer the `genbumppush` CLI for day-to-day use. Call this directly when
|
|
869
|
+
* embedding releases in a Node script or CI job that already builds
|
|
870
|
+
* {@link CliOptions}.
|
|
871
|
+
*
|
|
872
|
+
* @param options - Parsed CLI options. `cwd`, `dryRun`, `yes`, and `help` are required;
|
|
873
|
+
* the rest override config when set.
|
|
874
|
+
* @returns A summary of what happened. Never mutates when `dryRun` is `true`.
|
|
875
|
+
* @throws {@link ReleaseError} for expected failures (dirty worktree, existing tag,
|
|
876
|
+
* cancelled confirmation, Git/provider errors). Unexpected errors may also throw.
|
|
877
|
+
*
|
|
878
|
+
* @example Dry run in the current directory
|
|
879
|
+
* ```ts
|
|
880
|
+
* import { runRelease } from 'genbumppush';
|
|
881
|
+
*
|
|
882
|
+
* const result = await runRelease({
|
|
883
|
+
* cwd: process.cwd(),
|
|
884
|
+
* dryRun: true,
|
|
885
|
+
* yes: true,
|
|
886
|
+
* help: false,
|
|
887
|
+
* });
|
|
888
|
+
*
|
|
889
|
+
* console.log(result);
|
|
890
|
+
* // {
|
|
891
|
+
* // currentVersion: '1.2.3',
|
|
892
|
+
* // newVersion: '1.3.0',
|
|
893
|
+
* // releaseType: 'minor',
|
|
894
|
+
* // tag: 'v1.3.0',
|
|
895
|
+
* // pushed: false,
|
|
896
|
+
* // dryRun: true,
|
|
897
|
+
* // commitCount: 4
|
|
898
|
+
* // }
|
|
899
|
+
* ```
|
|
900
|
+
*
|
|
901
|
+
* @example Non-interactive patch release that stays local
|
|
902
|
+
* ```ts
|
|
903
|
+
* import { runRelease } from 'genbumppush';
|
|
904
|
+
*
|
|
905
|
+
* await runRelease({
|
|
906
|
+
* cwd: process.cwd(),
|
|
907
|
+
* dryRun: false,
|
|
908
|
+
* yes: true,
|
|
909
|
+
* help: false,
|
|
910
|
+
* release: 'patch',
|
|
911
|
+
* push: false,
|
|
912
|
+
* });
|
|
913
|
+
* ```
|
|
914
|
+
*
|
|
915
|
+
* @example Retry only the GitLab release after Git already succeeded
|
|
916
|
+
* ```ts
|
|
917
|
+
* import { runRelease } from 'genbumppush';
|
|
918
|
+
*
|
|
919
|
+
* await runRelease({
|
|
920
|
+
* cwd: process.cwd(),
|
|
921
|
+
* dryRun: false,
|
|
922
|
+
* yes: true,
|
|
923
|
+
* help: false,
|
|
924
|
+
* gitlabRetryTag: 'v1.3.0',
|
|
925
|
+
* });
|
|
926
|
+
* ```
|
|
927
|
+
*/
|
|
449
928
|
async function runRelease(options) {
|
|
450
929
|
const overrides = {};
|
|
451
930
|
if (options.release !== void 0) overrides.release = options.release;
|
|
@@ -469,6 +948,20 @@ async function runRelease(options) {
|
|
|
469
948
|
gitlabReleaseCreated: true
|
|
470
949
|
};
|
|
471
950
|
}
|
|
951
|
+
if (options.githubRetryTag !== void 0) {
|
|
952
|
+
const context = await resolveGitHubContext(config.github, cwd);
|
|
953
|
+
const remote = config.git?.remote ?? "origin";
|
|
954
|
+
if (!remoteTagExists(cwd, remote, options.githubRetryTag)) throw new ReleaseError("GITHUB_RELEASE_FAILED", `Tag ${options.githubRetryTag} does not exist on ${remote}.`);
|
|
955
|
+
await publishGitHub(context, cwd, config, options.githubRetryTag, currentVersion);
|
|
956
|
+
return {
|
|
957
|
+
currentVersion,
|
|
958
|
+
tag: options.githubRetryTag,
|
|
959
|
+
pushed: true,
|
|
960
|
+
dryRun: false,
|
|
961
|
+
commitCount: 0,
|
|
962
|
+
githubReleaseCreated: true
|
|
963
|
+
};
|
|
964
|
+
}
|
|
472
965
|
if (config.git?.requireClean !== false && git(["status", "--porcelain"], cwd) !== "") throw new ReleaseError("DIRTY_WORKTREE", "Commit or stash all changes before releasing.");
|
|
473
966
|
const branch = git(["branch", "--show-current"], cwd);
|
|
474
967
|
if (branch === "") throw new ReleaseError("DETACHED_HEAD", "Releases require a checked-out branch.");
|
|
@@ -489,11 +982,16 @@ async function runRelease(options) {
|
|
|
489
982
|
};
|
|
490
983
|
const releaseType = detected;
|
|
491
984
|
console.info(`Release: v${currentVersion} → ${releaseType} (${commits.length} commits)`);
|
|
985
|
+
const plannedVersion = bumpVersion(currentVersion, releaseType, config.preid);
|
|
986
|
+
const plannedTag = render(config.git?.tagName ?? "v{{version}}", plannedVersion);
|
|
492
987
|
if (options.dryRun) {
|
|
988
|
+
console.info(`Dry run: v${currentVersion} → v${plannedVersion} (${plannedTag})`);
|
|
493
989
|
console.info(await generateMarkDown(commits, changelog));
|
|
494
990
|
return {
|
|
495
991
|
currentVersion,
|
|
992
|
+
newVersion: plannedVersion,
|
|
496
993
|
releaseType,
|
|
994
|
+
tag: plannedTag,
|
|
497
995
|
pushed: false,
|
|
498
996
|
dryRun: true,
|
|
499
997
|
commitCount: commits.length
|
|
@@ -504,13 +1002,17 @@ async function runRelease(options) {
|
|
|
504
1002
|
if (!push) throw new ReleaseError("GITLAB_RELEASE_FAILED", "GitLab release creation requires git.push to be enabled.");
|
|
505
1003
|
gitlab = gitLabContext(config.gitlab);
|
|
506
1004
|
}
|
|
1005
|
+
let github;
|
|
1006
|
+
if (config.github?.enabled === true) {
|
|
1007
|
+
if (!push) throw new ReleaseError("GITHUB_RELEASE_FAILED", "GitHub release creation requires git.push to be enabled.");
|
|
1008
|
+
github = await resolveGitHubContext(config.github, cwd);
|
|
1009
|
+
}
|
|
507
1010
|
if (!options.yes && !await confirm(`Create a ${releaseType} release?`)) throw new ReleaseError("CANCELLED", "Release cancelled.");
|
|
508
|
-
const
|
|
509
|
-
const tag =
|
|
1011
|
+
const version = plannedVersion;
|
|
1012
|
+
const tag = plannedTag;
|
|
510
1013
|
const remote = config.git?.remote ?? "origin";
|
|
511
1014
|
if (tagExists(cwd, tag) || push && remoteTagExists(cwd, remote, tag)) throw new ReleaseError("TAG_EXISTS", `Tag ${tag} already exists.`);
|
|
512
1015
|
for (const command of list(config.hooks?.before)) runHook(command, cwd);
|
|
513
|
-
const version = plannedVersion;
|
|
514
1016
|
const changes = await planVersionChanges(cwd, currentVersion, version, config);
|
|
515
1017
|
const changed = changes.map((change) => change.path);
|
|
516
1018
|
let changelogSnapshot;
|
|
@@ -565,6 +1067,13 @@ async function runRelease(options) {
|
|
|
565
1067
|
} catch (error) {
|
|
566
1068
|
throw new ReleaseError("RELEASE_PUBLISHED_GITLAB_FAILED", `Git release ${tag} was pushed, but GitLab release creation failed. Retry with: genbumppush --retry-gitlab ${tag}`, { cause: error });
|
|
567
1069
|
}
|
|
1070
|
+
let githubReleaseCreated = false;
|
|
1071
|
+
if (github !== void 0) try {
|
|
1072
|
+
await publishGitHub(github, cwd, config, tag, version);
|
|
1073
|
+
githubReleaseCreated = true;
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
throw new ReleaseError("RELEASE_PUBLISHED_GITHUB_FAILED", `Git release ${tag} was pushed, but GitHub release creation failed. Retry with: genbumppush --retry-github ${tag}`, { cause: error });
|
|
1076
|
+
}
|
|
568
1077
|
for (const command of list(config.hooks?.after)) runHook(command, cwd);
|
|
569
1078
|
const result = {
|
|
570
1079
|
currentVersion,
|
|
@@ -576,6 +1085,7 @@ async function runRelease(options) {
|
|
|
576
1085
|
commitCount: commits.length
|
|
577
1086
|
};
|
|
578
1087
|
if (gitlabReleaseCreated) result.gitlabReleaseCreated = true;
|
|
1088
|
+
if (githubReleaseCreated) result.githubReleaseCreated = true;
|
|
579
1089
|
return result;
|
|
580
1090
|
}
|
|
581
1091
|
//#endregion
|