bunset 1.0.10 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.11
4
+
5
+ ### Features
6
+
7
+ - add --release option to create GitHub releases with changelog notes (#2)
8
+ - update workspace root package.json version when scope is all (#1)
9
+
3
10
  ## 1.0.10
4
11
 
5
12
  ### Features
package/README.md CHANGED
@@ -33,6 +33,8 @@ bunx bunset [options]
33
33
  | `--dry-run` | Preview changes without writing files, committing, or tagging |
34
34
  | `--debug` | Show detailed inclusion/exclusion reasoning (implies `--dry-run`) |
35
35
  | `--no-filter-by-package` | Include all commits in every package changelog (monorepo) |
36
+ | `--push` | Push the release commit and tags to the remote |
37
+ | `--release` | Create a GitHub release per tag with the changelog entry as the release notes (requires `--push` and the `gh` CLI) |
36
38
 
37
39
  When bump type or scope flags are omitted, interactive prompts will ask.
38
40
 
@@ -107,6 +109,8 @@ tag = true # create git tags (default: true)
107
109
  per-package-tags = false # pkg@version tags (monorepo)
108
110
  tag-prefix = "v" # tag prefix (default: auto-detect)
109
111
  sections = "all" # changelog sections and order ("all" or array)
112
+ push = false # push after tagging (default: false)
113
+ release = false # create GitHub release per tag (default: false)
110
114
  dry-run = false # preview without writing
111
115
  debug = false # detailed reasoning (implies dry-run)
112
116
  filter-by-package = true # per-package filtering (monorepo)
@@ -124,6 +128,8 @@ filter-by-package = true # per-package filtering (monorepo)
124
128
  | `dry-run` | `boolean` | `false` | Preview all changes without writing files, committing, or tagging. |
125
129
  | `debug` | `boolean` | `false` | Show detailed inclusion/exclusion reasoning. Implies `dry-run`. |
126
130
  | `filter-by-package` | `boolean` | `true` | In a monorepo, only include commits that touched files within each package. Disable with `false` to include all commits in every changelog. |
131
+ | `push` | `boolean` | `false` | Push the release commit and tags to the remote after tagging. |
132
+ | `release` | `boolean` | `false` | Create a GitHub release for each tag with the changelog entry as the release notes. Requires `push = true` and the [`gh` CLI](https://cli.github.com/). When multiple packages share a tag, their entries are combined under per-package headings. |
127
133
 
128
134
  ## Testing
129
135
 
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "bunset",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "bunset": "src/index.ts"
8
8
  },
9
9
  "devDependencies": {
10
- "@types/bun": "latest"
10
+ "@types/bun": "1.3.13"
11
11
  },
12
12
  "peerDependencies": {
13
- "typescript": "^5"
13
+ "typescript": "^6.0.3"
14
14
  },
15
15
  "engines": {
16
16
  "bun": "^1.3"
@@ -1,5 +1,5 @@
1
1
  import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2
- import { buildChangelogEntry, writeChangelog } from "./changelog.ts";
2
+ import { buildChangelogEntry, buildReleaseNotes, writeChangelog } from "./changelog.ts";
3
3
  import { COMMIT_TYPES } from "./commits.ts";
4
4
  import type { GroupedCommits } from "./types.ts";
5
5
  import { mkdtemp, rm } from "node:fs/promises";
@@ -202,3 +202,34 @@ describe("writeChangelog", () => {
202
202
  expect(idx1).toBeLessThan(idx2);
203
203
  });
204
204
  });
205
+
206
+ describe("buildReleaseNotes", () => {
207
+ test("returns empty string for no entries", () => {
208
+ expect(buildReleaseNotes([])).toBe("");
209
+ });
210
+
211
+ test("strips the version heading from a single entry", () => {
212
+ const entry = "## 1.2.3\n\n### Features\n\n- Add login\n";
213
+ expect(buildReleaseNotes([{ pkgName: "pkg-a", entry }])).toBe(
214
+ "### Features\n\n- Add login\n",
215
+ );
216
+ });
217
+
218
+ test("combines multiple entries under per-package headings", () => {
219
+ const result = buildReleaseNotes([
220
+ { pkgName: "pkg-a", entry: "## 1.2.3\n\n### Features\n\n- A\n" },
221
+ { pkgName: "pkg-b", entry: "## 1.2.3\n\n### Bug Fixes\n\n- B\n" },
222
+ ]);
223
+ expect(result).toContain("## pkg-a");
224
+ expect(result).toContain("## pkg-b");
225
+ expect(result).toContain("### Features");
226
+ expect(result).toContain("### Bug Fixes");
227
+ expect(result).not.toMatch(/^## 1\.2\.3/m);
228
+ expect(result.indexOf("## pkg-a")).toBeLessThan(result.indexOf("## pkg-b"));
229
+ });
230
+
231
+ test("handles entries without a leading version heading", () => {
232
+ const entry = "### Features\n\n- Plain entry\n";
233
+ expect(buildReleaseNotes([{ pkgName: "pkg-a", entry }])).toBe(entry);
234
+ });
235
+ });
package/src/changelog.ts CHANGED
@@ -84,6 +84,17 @@ export function buildChangelogEntry(
84
84
  return lines.join("\n");
85
85
  }
86
86
 
87
+ export function buildReleaseNotes(
88
+ entries: { pkgName: string; entry: string }[],
89
+ ): string {
90
+ const stripVersion = (e: string) => e.replace(/^## [^\n]*\n+/, "");
91
+ if (entries.length === 0) return "";
92
+ if (entries.length === 1) return stripVersion(entries[0]!.entry);
93
+ return entries
94
+ .map(({ pkgName, entry }) => `## ${pkgName}\n\n${stripVersion(entry)}`)
95
+ .join("\n\n");
96
+ }
97
+
87
98
  export async function writeChangelog(
88
99
  dir: string,
89
100
  entry: string,
package/src/cli.ts CHANGED
@@ -19,6 +19,8 @@ Options:
19
19
  --tag-prefix <str> Tag prefix (auto-detected from last tag, or "v" if no tags)
20
20
  --sections <list> Comma-separated changelog sections, or "all" (default: all)
21
21
  --push Push commit and tags to remote after tagging
22
+ --release Create a GitHub release per tag with the changelog entry
23
+ as the release notes (requires --push and the gh CLI)
22
24
  --dry-run Preview changes without writing files, committing, or tagging
23
25
  --debug Show detailed inclusion/exclusion reasoning (implies --dry-run)
24
26
  --no-filter-by-package
@@ -75,6 +77,7 @@ Config file (.bunset.toml):
75
77
  tag-prefix = "v" # tag prefix (default: auto-detect)
76
78
  sections = "all" # changelog sections and order ("all" or array)
77
79
  push = false # push after tagging (default: false)
80
+ release = false # create GitHub release (default: false)
78
81
  dry-run = false # preview without writing
79
82
  debug = false # detailed reasoning (implies dry-run)
80
83
  filter-by-package = true # per-package filtering (monorepo)`);
@@ -99,6 +102,7 @@ export function resolveOptions(
99
102
  "per-package-tags": { type: "boolean", default: false },
100
103
  sections: { type: "string" },
101
104
  push: { type: "boolean", default: false },
105
+ release: { type: "boolean", default: false },
102
106
  "dry-run": { type: "boolean", default: false },
103
107
  "filter-by-package": { type: "boolean", default: true },
104
108
  "tag-prefix": { type: "string" },
@@ -138,6 +142,7 @@ export function resolveOptions(
138
142
  ?? DEFAULT_SECTIONS;
139
143
 
140
144
  const push = values.push ? true : (config.push ?? false);
145
+ const release = values.release ? true : (config.release ?? false);
141
146
  const debug = values.debug ? true : (config.debug ?? false);
142
147
  const dryRun = debug || values["dry-run"] ? true : (config.dryRun ?? false);
143
148
 
@@ -150,11 +155,11 @@ export function resolveOptions(
150
155
  ?? null;
151
156
 
152
157
  if (bump && scope && commit !== null && tag !== null) {
153
- return { scope, bump, commit, tag, perPackageTags, sections, dryRun, filterByPackage, tagPrefix, push, debug };
158
+ return { scope, bump, commit, tag, perPackageTags, sections, dryRun, filterByPackage, tagPrefix, push, release, debug };
154
159
  }
155
160
 
156
161
  return promptForMissing(
157
- { commit, tag, perPackageTags, sections, dryRun, filterByPackage, tagPrefix, push, debug },
162
+ { commit, tag, perPackageTags, sections, dryRun, filterByPackage, tagPrefix, push, release, debug },
158
163
  bump,
159
164
  scope,
160
165
  isWs,
@@ -198,6 +203,7 @@ interface MergedDefaults {
198
203
  filterByPackage: boolean;
199
204
  tagPrefix: string | null;
200
205
  push: boolean;
206
+ release: boolean;
201
207
  debug: boolean;
202
208
  }
203
209
 
package/src/config.ts CHANGED
@@ -53,6 +53,10 @@ export async function loadConfig(
53
53
  config.push = raw.push;
54
54
  }
55
55
 
56
+ if (typeof raw.release === "boolean") {
57
+ config.release = raw.release;
58
+ }
59
+
56
60
  if (typeof raw.debug === "boolean") {
57
61
  config.debug = raw.debug;
58
62
  }
package/src/git.ts CHANGED
@@ -89,3 +89,20 @@ export async function gitPush(
89
89
  await $`git -C ${cwd} push --tags`.quiet();
90
90
  }
91
91
  }
92
+
93
+ export async function createGithubRelease(
94
+ cwd: string,
95
+ tag: string,
96
+ notes: string,
97
+ ): Promise<void> {
98
+ const proc = Bun.spawn(
99
+ ["gh", "release", "create", tag, "--title", tag, "--notes-file", "-"],
100
+ { cwd, stdin: "pipe", stdout: "inherit", stderr: "inherit" },
101
+ );
102
+ proc.stdin.write(notes);
103
+ await proc.stdin.end();
104
+ const exitCode = await proc.exited;
105
+ if (exitCode !== 0) {
106
+ throw new Error(`gh release create exited with code ${exitCode}`);
107
+ }
108
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { $ } from "bun";
4
+ import { join } from "node:path";
4
5
  import { resolveOptions } from "./cli.ts";
5
6
  import { loadConfig } from "./config.ts";
6
7
  import {
@@ -8,13 +9,14 @@ import {
8
9
  groupCommits,
9
10
  filterCommitsForPackage,
10
11
  } from "./commits.ts";
11
- import { buildChangelogEntry, writeChangelog } from "./changelog.ts";
12
+ import { buildChangelogEntry, buildReleaseNotes, writeChangelog } from "./changelog.ts";
12
13
  import {
13
14
  getLastTag,
14
15
  getCommitsSince,
15
16
  getCommitFiles,
16
17
  commitAndTag,
17
18
  gitPush,
19
+ createGithubRelease,
18
20
  } from "./git.ts";
19
21
  import { getUpdatedDependencies } from "./deps.ts";
20
22
  import {
@@ -71,6 +73,21 @@ if (rawCommits.length === 0) {
71
73
  process.exit(1);
72
74
  }
73
75
 
76
+ if (options.release && !options.push) {
77
+ console.error("--release requires --push (the tag must be on the remote).");
78
+ process.exit(1);
79
+ }
80
+
81
+ if (options.release && !options.tag) {
82
+ console.error("--release requires tagging to be enabled.");
83
+ process.exit(1);
84
+ }
85
+
86
+ if (options.release && !options.commit) {
87
+ console.error("--release requires --commit (tag/push/release run inside the commit step).");
88
+ process.exit(1);
89
+ }
90
+
74
91
  const parsed = rawCommits.map((c) => parseCommit(c.hash, c.message, c.body));
75
92
 
76
93
  if (dbg) {
@@ -141,11 +158,23 @@ function packageHasChanges(groups: GroupedCommits): boolean {
141
158
  return options.sections.some((type) => groups[type].length > 0);
142
159
  }
143
160
 
144
- // When using shared tags, sync all packages to the same target version
161
+ // When scope is "all" in a workspace, also update the workspace root's package.json
162
+ const rootPackageJsonPath = join(cwd, "package.json");
163
+ const updateRoot =
164
+ isWs &&
165
+ options.scope === "all" &&
166
+ !packages.some((p) => p.packageJsonPath === rootPackageJsonPath);
167
+ const rootCurrentVersion = updateRoot
168
+ ? ((await Bun.file(rootPackageJsonPath).json()).version ?? "0.0.0")
169
+ : null;
170
+ debug(`update root package.json: ${updateRoot}${updateRoot ? ` (current: ${rootCurrentVersion})` : ""}`);
171
+
172
+ // When using shared tags, sync all packages (and root, if updating) to the same target version
145
173
  let targetVersion: string | null = null;
146
- if (!options.perPackageTags && packages.length > 1) {
147
- const maxVersion = packages.reduce((max, pkg) => {
148
- const v = pkg.version ?? "0.0.0";
174
+ if (!options.perPackageTags && (packages.length > 1 || updateRoot)) {
175
+ const candidateVersions = packages.map((p) => p.version ?? "0.0.0");
176
+ if (updateRoot && rootCurrentVersion) candidateVersions.push(rootCurrentVersion);
177
+ const maxVersion = candidateVersions.reduce((max, v) => {
149
178
  const [mj1, mn1, p1] = parseSemver(max);
150
179
  const [mj2, mn2, p2] = parseSemver(v);
151
180
  if (mj2 > mj1) return v;
@@ -162,6 +191,8 @@ if (options.dryRun) {
162
191
 
163
192
  const tags: string[] = [];
164
193
  const filesToCommit: string[] = [];
194
+ const tagEntries = new Map<string, { pkgName: string; entry: string }[]>();
195
+ let anyPackageUpdated = false;
165
196
 
166
197
  for (const pkg of packages) {
167
198
  const groups = getPackageGroups(pkg, parsed);
@@ -194,6 +225,7 @@ if (options.dryRun) {
194
225
  const newVersion = targetVersion ?? bumpVersion(oldVersion, options.bump);
195
226
  console.log(`${pkg.name}: ${oldVersion} → ${newVersion}`);
196
227
  filesToCommit.push(pkg.packageJsonPath, `${pkg.path}/CHANGELOG.md`);
228
+ anyPackageUpdated = true;
197
229
 
198
230
  const updatedDeps = await getUpdatedDependencies(
199
231
  cwd,
@@ -211,14 +243,22 @@ if (options.dryRun) {
211
243
  console.log(entry);
212
244
 
213
245
  if (options.tag) {
214
- if (options.perPackageTags) {
215
- tags.push(`${pkg.name}@${newVersion}`);
216
- } else {
217
- tags.push(`${tagPrefix}${newVersion}`);
218
- }
246
+ const tag = options.perPackageTags
247
+ ? `${pkg.name}@${newVersion}`
248
+ : `${tagPrefix}${newVersion}`;
249
+ tags.push(tag);
250
+ const list = tagEntries.get(tag) ?? [];
251
+ list.push({ pkgName: pkg.name, entry });
252
+ tagEntries.set(tag, list);
219
253
  }
220
254
  }
221
255
 
256
+ if (updateRoot && rootCurrentVersion && anyPackageUpdated) {
257
+ const newRootVersion = targetVersion ?? bumpVersion(rootCurrentVersion, options.bump);
258
+ console.log(`(workspace root): ${rootCurrentVersion} → ${newRootVersion}`);
259
+ filesToCommit.push(rootPackageJsonPath);
260
+ }
261
+
222
262
  const uniqueTags = [...new Set(tags)];
223
263
 
224
264
  filesToCommit.push(`${cwd}/bun.lock`);
@@ -244,6 +284,14 @@ if (options.dryRun) {
244
284
  console.log("Would push commit and tags to remote.");
245
285
  }
246
286
 
287
+ if (options.release) {
288
+ for (const tag of uniqueTags) {
289
+ const entries = tagEntries.get(tag) ?? [];
290
+ console.log(`\nWould create GitHub release ${tag} with notes:`);
291
+ console.log(buildReleaseNotes(entries));
292
+ }
293
+ }
294
+
247
295
  console.log(`\nFiles that would be modified (${filesToCommit.length}):`);
248
296
  for (const f of filesToCommit) {
249
297
  console.log(` ${f}`);
@@ -253,6 +301,8 @@ if (options.dryRun) {
253
301
 
254
302
  const tags: string[] = [];
255
303
  const changedFiles: string[] = [];
304
+ const tagEntries = new Map<string, { pkgName: string; entry: string }[]>();
305
+ let anyPackageUpdated = false;
256
306
 
257
307
  for (const pkg of packages) {
258
308
  const groups = getPackageGroups(pkg, parsed);
@@ -269,6 +319,7 @@ for (const pkg of packages) {
269
319
  : await updatePackageVersion(pkg.packageJsonPath, options.bump);
270
320
  changedFiles.push(pkg.packageJsonPath);
271
321
  console.log(`${pkg.name}: ${oldVersion} → ${newVersion}`);
322
+ anyPackageUpdated = true;
272
323
 
273
324
  const updatedDeps = await getUpdatedDependencies(
274
325
  cwd,
@@ -285,14 +336,24 @@ for (const pkg of packages) {
285
336
  changedFiles.push(`${pkg.path}/CHANGELOG.md`);
286
337
 
287
338
  if (options.tag) {
288
- if (options.perPackageTags) {
289
- tags.push(`${pkg.name}@${newVersion}`);
290
- } else {
291
- tags.push(`${tagPrefix}${newVersion}`);
292
- }
339
+ const tag = options.perPackageTags
340
+ ? `${pkg.name}@${newVersion}`
341
+ : `${tagPrefix}${newVersion}`;
342
+ tags.push(tag);
343
+ const list = tagEntries.get(tag) ?? [];
344
+ list.push({ pkgName: pkg.name, entry });
345
+ tagEntries.set(tag, list);
293
346
  }
294
347
  }
295
348
 
349
+ if (updateRoot && anyPackageUpdated) {
350
+ const { oldVersion, newVersion } = targetVersion
351
+ ? await setPackageVersion(rootPackageJsonPath, targetVersion)
352
+ : await updatePackageVersion(rootPackageJsonPath, options.bump);
353
+ changedFiles.push(rootPackageJsonPath);
354
+ console.log(`(workspace root): ${oldVersion} → ${newVersion}`);
355
+ }
356
+
296
357
  const uniqueTags = [...new Set(tags)];
297
358
 
298
359
  if (changedFiles.length === 0) {
@@ -320,6 +381,21 @@ if (options.commit) {
320
381
  await gitPush(cwd, uniqueTags);
321
382
  console.log("Pushed to remote.");
322
383
  }
384
+
385
+ if (options.release) {
386
+ for (const tag of uniqueTags) {
387
+ const entries = tagEntries.get(tag) ?? [];
388
+ const notes = buildReleaseNotes(entries);
389
+ try {
390
+ await createGithubRelease(cwd, tag, notes);
391
+ console.log(`Created GitHub release: ${tag}`);
392
+ } catch (err) {
393
+ console.warn(
394
+ `⚠ Failed to create GitHub release ${tag}: ${(err as Error).message}`,
395
+ );
396
+ }
397
+ }
398
+ }
323
399
  }
324
400
 
325
401
  console.log("Done.");
package/src/types.ts CHANGED
@@ -24,6 +24,7 @@ export interface CliOptions {
24
24
  filterByPackage: boolean;
25
25
  tagPrefix: string | null;
26
26
  push: boolean;
27
+ release: boolean;
27
28
  debug: boolean;
28
29
  }
29
30