genbumppush 0.0.2 → 0.0.3

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 CHANGED
@@ -2,23 +2,15 @@
2
2
 
3
3
  [![CI](https://img.shields.io/github/actions/workflow/status/xcvzmoon/genbumppush/ci.yml?branch=main&color=black)](https://github.com/xcvzmoon/genbumppush/actions/workflows/ci.yml)
4
4
  [![Release](https://img.shields.io/github/actions/workflow/status/xcvzmoon/genbumppush/release.yml?color=black)](https://github.com/xcvzmoon/genbumppush/actions/workflows/release.yml)
5
- [![npm version](https://img.shields.io/npm/v/%40xcvzmoon%2Fgenbumppush?color=black)](https://www.npmjs.com/package/@xcvzmoon/genbumppush)
6
- [![npm downloads](https://img.shields.io/npm/dm/%40xcvzmoon%2Fgenbumppush?color=black)](https://www.npmjs.com/package/@xcvzmoon/genbumppush)
5
+ [![npm version](https://img.shields.io/npm/v/genbumppush?color=black)](https://www.npmjs.com/package/genbumppush)
6
+ [![npm downloads](https://img.shields.io/npm/dm/genbumppush?color=black)](https://www.npmjs.com/package/genbumppush)
7
7
 
8
8
  `genbumppush` handles the repetitive parts of releasing a Conventional Commit repository. It picks the next semantic version, updates the files you choose, writes the changelog, commits, tags, and can push the branch and tag together.
9
9
 
10
- It supports Node packages, Nuxt applications, fixed-version monorepos, Tauri applications, and tag-driven publication/deployment. It does not publish packages or create provider releases itself; GitHub Actions or GitLab CI should handle those after the tag is pushed.
11
-
12
- ## Requirements and installation
13
-
14
- - Node.js 20.19+
15
- - Git
16
- - Conventional Commits
17
- - An upstream branch when pushing (unless `requireUpstream: false`)
10
+ It supports Node packages, web applications (React, Vue, Solid, Svelte, Astro, Next, Nuxt, etc.), fixed-version monorepos, Tauri applications, and tag-driven publication/deployment. It does not publish packages or create provider releases itself; GitHub Actions or GitLab CI should handle those after the tag is pushed.
18
11
 
19
12
  ```bash
20
- pnpm add -D genbumppush
21
- # or: npm install --save-dev genbumppush
13
+ npm install --save-dev genbumppush
22
14
  ```
23
15
 
24
16
  ```json
@@ -50,15 +42,15 @@ genbumppush [release] [options]
50
42
  | `prerelease` | `1.2.4-beta.0` |
51
43
 
52
44
  ```bash
53
- pnpm release # detect from Conventional Commits
54
- pnpm release patch # force a release type
55
- pnpm release preminor --preid beta # start a prerelease channel
56
- pnpm release prerelease --preid beta
57
- pnpm release --dry-run # preview without mutation
58
- pnpm release patch --no-push # local commit and tag only
59
- pnpm release patch --yes # non-interactive
45
+ npm run release # detect from Conventional Commits
46
+ npm run release patch # force a release type
47
+ npm run release preminor --preid beta # start a prerelease channel
48
+ npm run release prerelease --preid beta
49
+ npm run release --dry-run # preview without mutation
50
+ npm run release patch --no-push # local commit and tag only
51
+ npm run release patch --yes # non-interactive
60
52
  genbumppush --cwd ../app --config release.config.ts patch
61
- genbumppush --retry-gitlab v1.2.4 # retry provider release after a successful Git push
53
+ genbumppush --retry-gitlab v1.2.4 # retry provider release after a successful Git push
62
54
  ```
63
55
 
64
56
  | Option | Meaning |
@@ -68,6 +60,7 @@ genbumppush --retry-gitlab v1.2.4 # retry provider release after a successful G
68
60
  | `--config <path>` | Explicit C12 config file |
69
61
  | `--preid <id>` | Identifier containing letters, numbers, and hyphens |
70
62
  | `--retry-gitlab <tag>` | Retry GitLab release creation for an existing remote tag |
63
+ | `--retry-github <tag>` | Retry GitHub release creation for an existing remote tag |
71
64
  | `--dry-run` | Preview without changing files, Git, or remotes |
72
65
  | `--no-push` | Keep commit and tag local |
73
66
  | `--yes`, `-y` | Skip confirmation |
@@ -140,6 +133,12 @@ The same object can live under `"genbumppush"` in `package.json`. JavaScript and
140
133
  | `git.tagMessage` | string | `v{{version}}` | Annotated tag template |
141
134
  | `hooks.before` | string or string[] | unset | Commands before file changes |
142
135
  | `hooks.after` | string or string[] | unset | Commands after tag/push |
136
+ | `github.enabled` | boolean | `false` | Create a GitHub release after push |
137
+ | `github.host` | string | `github.com` | github.com or GHES host |
138
+ | `github.repo` | string | `GITHUB_REPOSITORY` / remote | `owner/name` |
139
+ | `github.tokenEnv` | string | `GITHUB_TOKEN` | Token environment variable |
140
+ | `github.releaseName` | string | tag | Release title template |
141
+ | `gitlab.enabled` | boolean | `false` | Create a GitLab release after push |
143
142
 
144
143
  `{{version}}` is replaced in commit and tag templates. Hooks run through the shell in the repository directory; only use trusted configuration.
145
144
 
@@ -205,13 +204,28 @@ A release commit message alone does not trigger tag workflows. The tag must exis
205
204
  git push origin main v0.0.1
206
205
  ```
207
206
 
208
- For npm trusted publishing, configure the npm package trusted publisher to match the repository/workflow, keep `id-token: write`, and use a current npm. The included workflow disables provenance for private GitHub source repositories because npm rejects private-source provenance bundles. Public repositories can enable provenance after trusted publishing is configured.
207
+ For npm trusted publishing, configure the npm package trusted publisher to match the repository/workflow, keep `id-token: write`, and use a current npm. The included workflow enables provenance for public repositories. Private source repositories should set `NPM_CONFIG_PROVENANCE=false` because npm rejects private-source provenance bundles.
208
+
209
+ ## GitHub and GitLab releases
210
+
211
+ GitHub Releases are supported as an opt-in provider after the Git branch and tag are pushed atomically. genbumppush uses the exact `git.tagName` string (so custom templates work) and creates or updates the release via the GitHub API (including GHES).
212
+
213
+ ```ts
214
+ export default defineConfig({
215
+ git: { push: true },
216
+ github: {
217
+ enabled: true,
218
+ // host: 'github.com', // or a GHES host
219
+ // repo: 'group/project', // or GITHUB_REPOSITORY / package.json repository
220
+ tokenEnv: 'GITHUB_TOKEN', // also honors GH_TOKEN and CHANGELOGEN_TOKENS_GITHUB
221
+ releaseName: 'v{{version}}',
222
+ },
223
+ });
224
+ ```
209
225
 
210
- ## GitLab CI
226
+ If GitHub release creation fails after the Git push succeeds, fix credentials and run `genbumppush --retry-github <tag>`.
211
227
 
212
- GitLab release creation is supported as an opt-in provider. The Git branch and tag are
213
- still pushed atomically first; only then does genbumppush call the GitLab Releases API.
214
- Configure a project path and provide an API token through the environment:
228
+ GitLab release creation remains supported the same way. Configure a project path and provide an API token through the environment:
215
229
 
216
230
  ```ts
217
231
  export default defineConfig({
@@ -251,7 +265,7 @@ Keep artifact publication and GitLab release creation in protected, tag-triggere
251
265
 
252
266
  | Scenario | Recommended setup |
253
267
  | ---------------------- | -------------------------------------------------- |
254
- | Local release | `pnpm release`, confirm interactively |
268
+ | Local release | `npm run release`, confirm interactively |
255
269
  | CI release | `genbumppush --yes` with protected Git credentials |
256
270
  | Preview only | `genbumppush --dry-run --yes` |
257
271
  | Local commit/tag only | `genbumppush patch --no-push --yes` |
@@ -284,7 +298,7 @@ Run `git status --short` and inspect an existing tag with `git show <tag>`. Comm
284
298
 
285
299
  ### Private-repository provenance failure
286
300
 
287
- npm rejects provenance bundles identifying private GitHub source repositories. Use the included workflow’s `NPM_CONFIG_PROVENANCE=false` behavior, or make the source public and enable provenance after trusted publishing setup.
301
+ npm rejects provenance bundles identifying private GitHub source repositories. Set `NPM_CONFIG_PROVENANCE=false` in the publish workflow, or make the source public and keep provenance enabled.
288
302
 
289
303
  ### Workflow did not run
290
304
 
package/dist/bin.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as ReleaseError, t as runRelease } from "./release-cb3ZULZM.mjs";
2
+ import { n as ReleaseError, t as runRelease } from "./release-CVQ5WSKG.mjs";
3
3
  import { resolve } from "node:path";
4
4
  //#region src/types.ts
5
5
  const RELEASE_TYPES = [
@@ -13,7 +13,7 @@ const RELEASE_TYPES = [
13
13
  ];
14
14
  //#endregion
15
15
  //#region src/cli.ts
16
- const HELP_TEXT = `Usage: genbumppush [release] [options]\n\nGenerate a changelog, bump versions, create an annotated tag, and push atomically.\n\nArguments:\n release ${RELEASE_TYPES.join(" | ")}\n\nOptions:\n --cwd <path> Repository directory\n --config <path> Explicit C12 config file\n --preid <id> Prerelease identifier\n --retry-gitlab <tag> Retry GitLab release creation for a pushed tag\n --dry-run Preview without changes\n --no-push Keep commit and tag local\n --yes, -y Skip confirmation\n --help, -h Show help\n`;
16
+ const HELP_TEXT = `Usage: genbumppush [release] [options]\n\nGenerate a changelog, bump versions, create an annotated tag, and push atomically.\n\nArguments:\n release ${RELEASE_TYPES.join(" | ")}\n\nOptions:\n --cwd <path> Repository directory\n --config <path> Explicit C12 config file\n --preid <id> Prerelease identifier\n --retry-gitlab <tag> Retry GitLab release creation for a pushed tag\n --retry-github <tag> Retry GitHub release creation for a pushed tag\n --dry-run Preview without changes\n --no-push Keep commit and tag local\n --yes, -y Skip confirmation\n --help, -h Show help\n`;
17
17
  function isType(value) {
18
18
  return RELEASE_TYPES.some((item) => item === value);
19
19
  }
@@ -71,6 +71,10 @@ function parseCliOptions(args) {
71
71
  result.gitlabRetryTag = inlineValue ?? next(args, index, "--retry-gitlab");
72
72
  if (inlineValue === void 0) index += 1;
73
73
  break;
74
+ case "--retry-github":
75
+ result.githubRetryTag = inlineValue ?? next(args, index, "--retry-github");
76
+ if (inlineValue === void 0) index += 1;
77
+ break;
74
78
  default:
75
79
  if (isType(arg) && result.release === void 0) {
76
80
  result.release = arg;
@@ -80,6 +84,8 @@ function parseCliOptions(args) {
80
84
  }
81
85
  }
82
86
  if (result.gitlabRetryTag !== void 0 && result.release !== void 0) throw new ReleaseError("CONFLICTING_ARGUMENTS", "--retry-gitlab cannot be combined with a release type.");
87
+ if (result.githubRetryTag !== void 0 && result.release !== void 0) throw new ReleaseError("CONFLICTING_ARGUMENTS", "--retry-github cannot be combined with a release type.");
88
+ if (result.gitlabRetryTag !== void 0 && result.githubRetryTag !== void 0) throw new ReleaseError("CONFLICTING_ARGUMENTS", "--retry-gitlab and --retry-github cannot be combined.");
83
89
  return result;
84
90
  }
85
91
  //#endregion
@@ -89,8 +95,10 @@ try {
89
95
  if (options.help) console.info(HELP_TEXT);
90
96
  else {
91
97
  const result = await runRelease(options);
92
- if (result.gitlabReleaseCreated) console.info(`GitLab release ${result.tag ?? ""} created.`);
98
+ if (result.githubReleaseCreated) console.info(`GitHub release ${result.tag ?? ""} created.`);
99
+ else if (result.gitlabReleaseCreated) console.info(`GitLab release ${result.tag ?? ""} created.`);
93
100
  else if (result.releaseType === void 0) console.info("No releasable commits found.");
101
+ else if (result.dryRun) console.info(`Dry run: would create ${result.tag ?? result.releaseType} from ${result.currentVersion}${result.newVersion !== void 0 ? ` to v${result.newVersion}` : ""}.`);
94
102
  else if (!result.dryRun) console.info(`${result.tag ?? result.releaseType} created${result.pushed ? " and pushed" : ""}.`);
95
103
  }
96
104
  } catch (error) {
package/dist/index.d.mts CHANGED
@@ -22,6 +22,13 @@ type GitLabOptions = {
22
22
  tokenEnv?: string;
23
23
  releaseName?: string;
24
24
  };
25
+ type GitHubOptions = {
26
+ enabled?: boolean;
27
+ host?: string;
28
+ repo?: string;
29
+ tokenEnv?: string;
30
+ releaseName?: string;
31
+ };
25
32
  type GenBumpPushConfig = {
26
33
  release?: ReleaseType;
27
34
  preid?: string;
@@ -31,12 +38,14 @@ type GenBumpPushConfig = {
31
38
  excludeDependencyCommits?: boolean;
32
39
  git?: GitOptions;
33
40
  gitlab?: GitLabOptions;
41
+ github?: GitHubOptions;
34
42
  hooks?: HookOptions;
35
43
  };
36
44
  type CliOptions = {
37
45
  cwd: string;
38
46
  configFile?: string;
39
47
  gitlabRetryTag?: string;
48
+ githubRetryTag?: string;
40
49
  release?: ReleaseType;
41
50
  preid?: string;
42
51
  dryRun: boolean;
@@ -53,6 +62,7 @@ type ReleaseResult = {
53
62
  dryRun: boolean;
54
63
  commitCount: number;
55
64
  gitlabReleaseCreated?: boolean;
65
+ githubReleaseCreated?: boolean;
56
66
  };
57
67
  //#endregion
58
68
  //#region src/config.d.ts
@@ -68,4 +78,4 @@ export declare class ReleaseError extends Error {
68
78
  //#region src/release.d.ts
69
79
  export declare function runRelease(options: CliOptions): Promise<ReleaseResult>;
70
80
  //#endregion
71
- export type { CliOptions, GenBumpPushConfig, GitLabOptions, GitOptions, HookOptions, ReleaseResult, ReleaseType };
81
+ export type { CliOptions, GenBumpPushConfig, GitHubOptions, GitLabOptions, GitOptions, HookOptions, ReleaseResult, ReleaseType };
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { i as loadReleaseConfig, n as ReleaseError, r as defineConfig, t as runRelease } from "./release-cb3ZULZM.mjs";
1
+ import { i as loadReleaseConfig, n as ReleaseError, r as defineConfig, t as runRelease } from "./release-CVQ5WSKG.mjs";
2
2
  export { ReleaseError, defineConfig, loadReleaseConfig, runRelease };
@@ -1,5 +1,5 @@
1
1
  import { createDefineConfig, loadConfig } from "c12";
2
- import { determineSemverChange, generateMarkDown, getGitDiff, loadChangelogConfig, parseCommits } from "changelogen";
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";
@@ -73,7 +73,7 @@ function tagExists(cwd, tag) {
73
73
  }).status === 0;
74
74
  }
75
75
  function isGitRepository(cwd) {
76
- return spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
76
+ const result = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
77
77
  cwd,
78
78
  encoding: "utf8",
79
79
  stdio: [
@@ -81,7 +81,9 @@ function isGitRepository(cwd) {
81
81
  "pipe",
82
82
  "ignore"
83
83
  ]
84
- }).stdout.trim() === "true";
84
+ });
85
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string") return false;
86
+ return result.stdout.trim() === "true";
85
87
  }
86
88
  function remoteTagExists(cwd, remote, tag) {
87
89
  return git([
@@ -99,6 +101,105 @@ function runHook(command, cwd) {
99
101
  }).status !== 0) throw new ReleaseError("HOOK_FAILED", `Hook failed: ${command}`);
100
102
  }
101
103
  //#endregion
104
+ //#region src/github.ts
105
+ function normalizeHost(host) {
106
+ return host.replace(/^https?:\/\//, "").replace(/\/$/, "");
107
+ }
108
+ function apiBase(host) {
109
+ const normalized = normalizeHost(host);
110
+ if (normalized === "github.com" || normalized === "api.github.com") return "https://api.github.com";
111
+ return `https://${normalized}/api/v3`;
112
+ }
113
+ function encodeRepoPath(repo) {
114
+ return repo.split("/").map((segment) => encodeURIComponent(segment)).join("/");
115
+ }
116
+ function resolveGitHubToken(tokenEnv) {
117
+ if (tokenEnv !== void 0) {
118
+ const token = process.env[tokenEnv];
119
+ return token !== void 0 && token.length > 0 ? token : void 0;
120
+ }
121
+ for (const name of [
122
+ "GITHUB_TOKEN",
123
+ "GH_TOKEN",
124
+ "CHANGELOGEN_TOKENS_GITHUB"
125
+ ]) {
126
+ const token = process.env[name];
127
+ if (token !== void 0 && token.length > 0) return token;
128
+ }
129
+ }
130
+ async function resolveGitHubRepo(cwd, source) {
131
+ const host = normalizeHost(source.host ?? process.env.GITHUB_API_URL ?? "github.com");
132
+ const explicit = source.repo ?? process.env.GITHUB_REPOSITORY;
133
+ if (explicit !== void 0 && explicit.length > 0) return {
134
+ host,
135
+ repo: explicit
136
+ };
137
+ const resolved = await resolveRepoConfig(cwd);
138
+ if (resolved?.provider === "github" && resolved.repo) return {
139
+ host: normalizeHost(resolved.domain ?? host),
140
+ repo: resolved.repo
141
+ };
142
+ throw new ReleaseError("GITHUB_RELEASE_FAILED", "Set github.repo or GITHUB_REPOSITORY to create a GitHub release.");
143
+ }
144
+ async function githubFetch(options, path, init) {
145
+ const url = `${apiBase(options.host)}/repos/${encodeRepoPath(options.repo)}${path}`;
146
+ const headers = new Headers({
147
+ Accept: "application/vnd.github+json",
148
+ "X-GitHub-Api-Version": "2022-11-28",
149
+ Authorization: `Bearer ${options.token}`,
150
+ "Content-Type": "application/json"
151
+ });
152
+ if (init?.headers) new Headers(init.headers).forEach((value, key) => {
153
+ headers.set(key, value);
154
+ });
155
+ let response;
156
+ try {
157
+ response = await fetch(url, {
158
+ method: init?.method,
159
+ body: init?.body,
160
+ headers,
161
+ signal: init?.signal ?? AbortSignal.timeout(3e4)
162
+ });
163
+ } catch (error) {
164
+ throw new ReleaseError("GITHUB_RELEASE_FAILED", "Could not send the GitHub release request.", { cause: error });
165
+ }
166
+ return response;
167
+ }
168
+ function parseReleaseId(body) {
169
+ if (typeof body !== "object" || body === null || !("id" in body)) return void 0;
170
+ const id = body.id;
171
+ return typeof id === "number" ? id : void 0;
172
+ }
173
+ /**
174
+ * Create or update a GitHub (or GHES) release for an existing tag.
175
+ * Uses the exact tag string so custom genbumppush tag templates stay valid.
176
+ */
177
+ async function createGitHubRelease(options) {
178
+ const existing = await githubFetch(options, `/releases/tags/${encodeURIComponent(options.tag)}`, { method: "GET" });
179
+ let existingId;
180
+ if (existing.ok) existingId = parseReleaseId(await existing.json().catch(() => ({})));
181
+ else if (existing.status !== 404) {
182
+ const text = await existing.text().catch(() => "");
183
+ throw new ReleaseError("GITHUB_RELEASE_FAILED", `GitHub release lookup failed with ${existing.status}: ${text || existing.statusText}`);
184
+ }
185
+ const payload = JSON.stringify({
186
+ tag_name: options.tag,
187
+ name: options.name,
188
+ body: options.description
189
+ });
190
+ const response = existingId === void 0 ? await githubFetch(options, "/releases", {
191
+ method: "POST",
192
+ body: payload
193
+ }) : await githubFetch(options, `/releases/${existingId}`, {
194
+ method: "PATCH",
195
+ body: payload
196
+ });
197
+ if (!response.ok) {
198
+ const text = await response.text().catch(() => "");
199
+ throw new ReleaseError("GITHUB_RELEASE_FAILED", `GitHub release creation failed with ${response.status}: ${text || response.statusText}`);
200
+ }
201
+ }
202
+ //#endregion
102
203
  //#region src/gitlab.ts
103
204
  function releaseNotes(changelog, tag) {
104
205
  const lines = changelog.split("\n");
@@ -112,12 +213,14 @@ function releaseNotes(changelog, tag) {
112
213
  }
113
214
  return notes.join("\n").trim() || "See CHANGELOG.md for release notes.";
114
215
  }
216
+ const PROVIDER_FETCH_TIMEOUT_MS = 3e4;
115
217
  async function createGitLabRelease(options) {
116
218
  const url = `${options.host.replace(/\/$/, "")}/api/v4/projects/${encodeURIComponent(options.project)}/releases`;
117
219
  let response;
118
220
  try {
119
221
  response = await fetch(url, {
120
222
  method: "POST",
223
+ signal: AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS),
121
224
  headers: {
122
225
  Authorization: `Bearer ${options.token}`,
123
226
  "Content-Type": "application/json"
@@ -184,11 +287,139 @@ async function findManifests(directory) {
184
287
  function assertCurrent(path, actual, expected) {
185
288
  if (actual !== expected) throw new ReleaseError("VERSION_MISMATCH", `${path} has version ${actual}; expected ${expected}.`);
186
289
  }
290
+ function parseJsonString(raw) {
291
+ try {
292
+ const parsed = JSON.parse(raw);
293
+ return isString$1(parsed) ? parsed : void 0;
294
+ } catch {
295
+ return;
296
+ }
297
+ }
298
+ function readJsonString(content, start) {
299
+ let index = start + 1;
300
+ let escape = false;
301
+ while (index < content.length) {
302
+ const char = content[index];
303
+ if (char === void 0) break;
304
+ if (escape) escape = false;
305
+ else if (char === "\\") escape = true;
306
+ else if (char === "\"") {
307
+ index += 1;
308
+ return {
309
+ end: index,
310
+ raw: content.slice(start, index)
311
+ };
312
+ }
313
+ index += 1;
314
+ }
315
+ return {
316
+ end: content.length,
317
+ raw: content.slice(start)
318
+ };
319
+ }
320
+ function objectKeyPath(stack, propertyKey) {
321
+ const path = [];
322
+ for (const frame of stack) if (frame.kind === "object" && frame.introKey !== void 0) path.push(frame.introKey);
323
+ path.push(propertyKey);
324
+ return path;
325
+ }
326
+ /**
327
+ * Locate a JSON string property by object-key path without reformatting the file.
328
+ * Nested keys with the same name are never selected.
329
+ */
330
+ function findJsonStringProperty(content, keyPath) {
331
+ if (keyPath.length === 0) return void 0;
332
+ const stack = [];
333
+ let index = 0;
334
+ let pendingKey;
335
+ let afterColon = false;
336
+ const skipWhitespace = () => {
337
+ while (index < content.length && /\s/.test(content[index] ?? "")) index += 1;
338
+ };
339
+ while (index < content.length) {
340
+ skipWhitespace();
341
+ const char = content[index];
342
+ if (char === void 0) break;
343
+ if (char === "{") {
344
+ stack.push({
345
+ kind: "object",
346
+ introKey: pendingKey
347
+ });
348
+ pendingKey = void 0;
349
+ afterColon = false;
350
+ index += 1;
351
+ continue;
352
+ }
353
+ if (char === "}") {
354
+ stack.pop();
355
+ pendingKey = void 0;
356
+ afterColon = false;
357
+ index += 1;
358
+ continue;
359
+ }
360
+ if (char === "[") {
361
+ stack.push({ kind: "array" });
362
+ pendingKey = void 0;
363
+ afterColon = false;
364
+ index += 1;
365
+ continue;
366
+ }
367
+ if (char === "]") {
368
+ stack.pop();
369
+ pendingKey = void 0;
370
+ afterColon = false;
371
+ index += 1;
372
+ continue;
373
+ }
374
+ if (char === ",") {
375
+ pendingKey = void 0;
376
+ afterColon = false;
377
+ index += 1;
378
+ continue;
379
+ }
380
+ if (char === ":") {
381
+ afterColon = true;
382
+ index += 1;
383
+ continue;
384
+ }
385
+ if (char === "\"") {
386
+ const { end, raw } = readJsonString(content, index);
387
+ index = end;
388
+ const decoded = parseJsonString(raw);
389
+ if (decoded === void 0) continue;
390
+ if (stack.at(-1)?.kind === "object" && !afterColon) {
391
+ pendingKey = decoded;
392
+ continue;
393
+ }
394
+ if (afterColon && pendingKey !== void 0) {
395
+ const path = objectKeyPath(stack, pendingKey);
396
+ if (path.length === keyPath.length && path.every((segment, i) => segment === keyPath[i])) return {
397
+ value: decoded,
398
+ valueStart: end - raw.length + 1,
399
+ valueEnd: end - 1
400
+ };
401
+ pendingKey = void 0;
402
+ afterColon = false;
403
+ }
404
+ continue;
405
+ }
406
+ if (afterColon) {
407
+ pendingKey = void 0;
408
+ afterColon = false;
409
+ }
410
+ index += 1;
411
+ }
412
+ }
413
+ function replaceJsonStringProperty(content, span, value) {
414
+ return `${content.slice(0, span.valueStart)}${JSON.stringify(value).slice(1, -1)}${content.slice(span.valueEnd)}`;
415
+ }
187
416
  function replaceJsonVersion(content, path, current, version) {
188
417
  const parsed = JSON.parse(content);
189
418
  if (!isObject$1(parsed) || !("version" in parsed) || !isString$1(parsed.version)) throw new ReleaseError("MISSING_VERSION", `${path} has no top-level version string.`);
190
419
  assertCurrent(path, parsed.version, current);
191
- const updated = content.replace(/("version"\s*:\s*")[^"]*(")/, `$1${version}$2`);
420
+ const span = findJsonStringProperty(content, ["version"]);
421
+ if (span === void 0) throw new ReleaseError("MISSING_VERSION", `${path} has no top-level version string.`);
422
+ const updated = replaceJsonStringProperty(content, span, version);
192
423
  if (updated === content) throw new ReleaseError("VERSION_UNCHANGED", `${path} was not updated.`);
193
424
  return updated;
194
425
  }
@@ -201,10 +432,20 @@ function replacePackageLockVersion(content, path, current, version) {
201
432
  const rootVersion = data.packages?.[""]?.version;
202
433
  if (rootVersion !== void 0 && !isString$1(rootVersion)) throw new ReleaseError("INVALID_LOCKFILE", `${path} has an invalid root package version.`);
203
434
  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`;
435
+ const rootSpan = findJsonStringProperty(content, ["version"]);
436
+ if (rootSpan === void 0) throw new ReleaseError("MISSING_VERSION", `${path} has no root version.`);
437
+ let updated = replaceJsonStringProperty(content, rootSpan, version);
438
+ if (isString$1(rootVersion)) {
439
+ const packagesSpan = findJsonStringProperty(updated, [
440
+ "packages",
441
+ "",
442
+ "version"
443
+ ]);
444
+ if (packagesSpan === void 0) throw new ReleaseError("MISSING_VERSION", `${path} has no packages[""].version string to update.`);
445
+ updated = replaceJsonStringProperty(updated, packagesSpan, version);
446
+ }
447
+ if (updated === content) throw new ReleaseError("VERSION_UNCHANGED", `${path} was not updated.`);
448
+ return updated;
208
449
  }
209
450
  function cargoSection(content) {
210
451
  const start = content.search(/^\[package\]\s*$/m);
@@ -392,6 +633,23 @@ function gitLabContext(config) {
392
633
  if (config.releaseName !== void 0) context.releaseName = config.releaseName;
393
634
  return context;
394
635
  }
636
+ function resolveGitHubContext(config, cwd) {
637
+ if (config?.enabled !== true) throw new ReleaseError("GITHUB_RELEASE_FAILED", "Enable github before creating a release.");
638
+ const token = resolveGitHubToken(config.tokenEnv);
639
+ if (token === void 0) throw new ReleaseError("GITHUB_RELEASE_FAILED", `Set ${config.tokenEnv ?? "GITHUB_TOKEN"} to create a GitHub release.`);
640
+ return resolveGitHubRepo(cwd, {
641
+ host: config.host,
642
+ repo: config.repo
643
+ }).then(({ host, repo }) => {
644
+ const context = {
645
+ host,
646
+ repo,
647
+ token
648
+ };
649
+ if (config.releaseName !== void 0) context.releaseName = config.releaseName;
650
+ return context;
651
+ });
652
+ }
395
653
  async function changelogText(cwd, config) {
396
654
  if (config.changelog === false) return "";
397
655
  const path = await resolveRepositoryPath(cwd, config.changelog === true ? "CHANGELOG.md" : config.changelog ?? "CHANGELOG.md");
@@ -407,6 +665,16 @@ async function publishGitLab(context, cwd, config, tag, version) {
407
665
  description: releaseNotes(await changelogText(cwd, config), tag)
408
666
  });
409
667
  }
668
+ async function publishGitHub(context, cwd, config, tag, version) {
669
+ await createGitHubRelease({
670
+ host: context.host,
671
+ repo: context.repo,
672
+ token: context.token,
673
+ tag,
674
+ name: context.releaseName?.replaceAll("{{version}}", version) ?? tag,
675
+ description: releaseNotes(await changelogText(cwd, config), tag)
676
+ });
677
+ }
410
678
  function filter(commits, config, exclude) {
411
679
  return commits.filter((commit) => {
412
680
  const type = config.types[commit.type.toLowerCase()];
@@ -469,6 +737,20 @@ async function runRelease(options) {
469
737
  gitlabReleaseCreated: true
470
738
  };
471
739
  }
740
+ if (options.githubRetryTag !== void 0) {
741
+ const context = await resolveGitHubContext(config.github, cwd);
742
+ const remote = config.git?.remote ?? "origin";
743
+ if (!remoteTagExists(cwd, remote, options.githubRetryTag)) throw new ReleaseError("GITHUB_RELEASE_FAILED", `Tag ${options.githubRetryTag} does not exist on ${remote}.`);
744
+ await publishGitHub(context, cwd, config, options.githubRetryTag, currentVersion);
745
+ return {
746
+ currentVersion,
747
+ tag: options.githubRetryTag,
748
+ pushed: true,
749
+ dryRun: false,
750
+ commitCount: 0,
751
+ githubReleaseCreated: true
752
+ };
753
+ }
472
754
  if (config.git?.requireClean !== false && git(["status", "--porcelain"], cwd) !== "") throw new ReleaseError("DIRTY_WORKTREE", "Commit or stash all changes before releasing.");
473
755
  const branch = git(["branch", "--show-current"], cwd);
474
756
  if (branch === "") throw new ReleaseError("DETACHED_HEAD", "Releases require a checked-out branch.");
@@ -489,11 +771,16 @@ async function runRelease(options) {
489
771
  };
490
772
  const releaseType = detected;
491
773
  console.info(`Release: v${currentVersion} → ${releaseType} (${commits.length} commits)`);
774
+ const plannedVersion = bumpVersion(currentVersion, releaseType, config.preid);
775
+ const plannedTag = render(config.git?.tagName ?? "v{{version}}", plannedVersion);
492
776
  if (options.dryRun) {
777
+ console.info(`Dry run: v${currentVersion} → v${plannedVersion} (${plannedTag})`);
493
778
  console.info(await generateMarkDown(commits, changelog));
494
779
  return {
495
780
  currentVersion,
781
+ newVersion: plannedVersion,
496
782
  releaseType,
783
+ tag: plannedTag,
497
784
  pushed: false,
498
785
  dryRun: true,
499
786
  commitCount: commits.length
@@ -504,13 +791,17 @@ async function runRelease(options) {
504
791
  if (!push) throw new ReleaseError("GITLAB_RELEASE_FAILED", "GitLab release creation requires git.push to be enabled.");
505
792
  gitlab = gitLabContext(config.gitlab);
506
793
  }
794
+ let github;
795
+ if (config.github?.enabled === true) {
796
+ if (!push) throw new ReleaseError("GITHUB_RELEASE_FAILED", "GitHub release creation requires git.push to be enabled.");
797
+ github = await resolveGitHubContext(config.github, cwd);
798
+ }
507
799
  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);
800
+ const version = plannedVersion;
801
+ const tag = plannedTag;
510
802
  const remote = config.git?.remote ?? "origin";
511
803
  if (tagExists(cwd, tag) || push && remoteTagExists(cwd, remote, tag)) throw new ReleaseError("TAG_EXISTS", `Tag ${tag} already exists.`);
512
804
  for (const command of list(config.hooks?.before)) runHook(command, cwd);
513
- const version = plannedVersion;
514
805
  const changes = await planVersionChanges(cwd, currentVersion, version, config);
515
806
  const changed = changes.map((change) => change.path);
516
807
  let changelogSnapshot;
@@ -565,6 +856,13 @@ async function runRelease(options) {
565
856
  } catch (error) {
566
857
  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
858
  }
859
+ let githubReleaseCreated = false;
860
+ if (github !== void 0) try {
861
+ await publishGitHub(github, cwd, config, tag, version);
862
+ githubReleaseCreated = true;
863
+ } catch (error) {
864
+ 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 });
865
+ }
568
866
  for (const command of list(config.hooks?.after)) runHook(command, cwd);
569
867
  const result = {
570
868
  currentVersion,
@@ -576,6 +874,7 @@ async function runRelease(options) {
576
874
  commitCount: commits.length
577
875
  };
578
876
  if (gitlabReleaseCreated) result.gitlabReleaseCreated = true;
877
+ if (githubReleaseCreated) result.githubReleaseCreated = true;
579
878
  return result;
580
879
  }
581
880
  //#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.3",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "license": "MIT",