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.
@@ -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
- return spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
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
- }).stdout.trim() === "true";
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 updated = content.replace(/("version"\s*:\s*")[^"]*(")/, `$1${version}$2`);
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
- data.version = version;
205
- if (data.packages?.[""] !== void 0) data.packages[""].version = version;
206
- const indent = /^\s+"/.exec(content)?.[0].length ?? 2;
207
- return `${JSON.stringify(data, null, indent)}\n`;
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 tokenEnv = config.tokenEnv ?? "GITLAB_TOKEN";
383
- const token = process.env[tokenEnv];
384
- const project = config.project ?? process.env.GITLAB_PROJECT;
385
- if (token === void 0 || token.length === 0) throw new ReleaseError("GITLAB_RELEASE_FAILED", `Set ${tokenEnv} to create a GitLab release.`);
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 ?? process.env.GITLAB_HOST ?? "https://gitlab.com",
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 plannedVersion = bumpVersion(currentVersion, releaseType, config.preid);
509
- const tag = render(config.git?.tagName ?? "v{{version}}", plannedVersion);
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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "genbumppush",
3
3
  "description": "Generate changelog, bump version, then push",
4
- "version": "0.0.2",
4
+ "version": "0.0.4",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "license": "MIT",