release-with-ease 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(git add:*)",
5
+ "Bash(git commit -m ':*)",
6
+ "Bash(git push:*)",
7
+ "Bash(gh pr:*)"
8
+ ]
9
+ }
10
+ }
package/README.md CHANGED
@@ -25,10 +25,23 @@ The script requires these environment variables to be set:
25
25
 
26
26
  You can get a key from https://console.anthropic.com/settings/keys.
27
27
 
28
- The script also assumes that your README.md file has a `# Changelog` section.
28
+ The script also requires the `gh` CLI to be installed and authenticated (used to
29
+ create GitHub releases).
30
+
31
+ If your `README.md` has a `# Changelog` section, the script will automatically
32
+ insert the release notes there. Otherwise it skips that step and relies solely on
33
+ the GitHub release.
29
34
 
30
35
  # Changelog
31
36
 
37
+ ## 2.1.0
38
+
39
+ - Support npm publish and GitHub releases for public npm packages [by @trotzig in #5]
40
+ - Include PR number and author attribution in release notes for public packages [by @trotzig]
41
+ - Auto-detect README.md changelog section; skip insertion if absent for better compatibility
42
+ - Run `npm publish` automatically for packages without `private: true` in package.json
43
+ - Create GitHub releases automatically via `gh release create` after every push (for public packages)
44
+
32
45
  ## 1.0.1
33
46
 
34
47
  - Fix path to README and package.json
@@ -74,15 +74,80 @@ function parseCommits(raw) {
74
74
  });
75
75
  }
76
76
 
77
- async function askClaudeForRelease(commits) {
77
+ function extractPrNumber(subject, body) {
78
+ // "(#123)" suffix — squash-merge style
79
+ const m = subject.match(/\(#(\d+)\)\s*$/);
80
+ if (m) return parseInt(m[1], 10);
81
+ // "Merge pull request #123" — merge commit style
82
+ const mm = subject.match(/Merge pull request #(\d+)/);
83
+ if (mm) return parseInt(mm[1], 10);
84
+ // Same patterns in body
85
+ const bm = (body || '').match(/\(#(\d+)\)\s*$/m);
86
+ if (bm) return parseInt(bm[1], 10);
87
+ return null;
88
+ }
89
+
90
+ function fetchGitHubMeta(commits, lastTag) {
91
+ const repoRes = safeRun('gh repo view --json nameWithOwner -q .nameWithOwner');
92
+ if (!repoRes.ok) return commits;
93
+ const [owner, repo] = repoRes.out.trim().split('/');
94
+
95
+ // SHA → GitHub login via compare API (best-effort)
96
+ const shaToLogin = {};
97
+ if (lastTag) {
98
+ const cmpRes = safeRun(
99
+ `gh api "repos/${owner}/${repo}/compare/${lastTag}...HEAD" --jq '.commits[] | [.sha, (.author.login // "")] | @tsv'`,
100
+ );
101
+ if (cmpRes.ok) {
102
+ for (const line of cmpRes.out.trim().split('\n').filter(Boolean)) {
103
+ const [sha, login] = line.split('\t');
104
+ if (sha && login) shaToLogin[sha] = login;
105
+ }
106
+ }
107
+ }
108
+
109
+ // Merge commit SHA → PR number via pr list (best-effort)
110
+ const shaToPr = {};
111
+ const prRes = safeRun(
112
+ `gh pr list --state merged --limit 100 --json number,mergeCommit --jq '.[] | select(.mergeCommit != null) | [.mergeCommit.oid, (.number | tostring)] | @tsv'`,
113
+ );
114
+ if (prRes.ok) {
115
+ for (const line of prRes.out.trim().split('\n').filter(Boolean)) {
116
+ const [sha, num] = line.split('\t');
117
+ if (sha && num) shaToPr[sha] = parseInt(num, 10);
118
+ }
119
+ }
120
+
121
+ return commits.map(c => ({
122
+ ...c,
123
+ githubLogin: shaToLogin[c.hash] || null,
124
+ prNumber: shaToPr[c.hash] ?? extractPrNumber(c.subject, c.body),
125
+ }));
126
+ }
127
+
128
+ async function askClaudeForRelease(commits, isPublicPackage = false) {
78
129
  const apiKey = process.env.ANTHROPIC_API_KEY;
79
130
  if (!apiKey) return null;
80
131
 
81
- const systemPrompt =
132
+ const basePrompt =
82
133
  'You are a release assistant. Given recent git commits, decide one of: major, minor, or patch following semver. Consider conventional commits, breaking changes, and scope. Also generate concise release notes for a public changelog. Respond with JSON containing "bump" (major/minor/patch), "reasoning" (brief explanation for version bump), and "notes" (array of 3-8 short bullet points of the most important user-facing changes). Use present tense for release notes (e.g. "Add script" not "Added script" or "Adds script"). Do not wrap the JSON in ```json or anything else.';
83
134
 
135
+ const publicExtra = isPublicPackage
136
+ ? ' Each commit may carry metadata in brackets like [by @login in #123]. When present, append that attribution verbatim at the end of the corresponding bullet point.'
137
+ : '';
138
+
139
+ const systemPrompt = basePrompt + publicExtra;
140
+
84
141
  const userContent = commits
85
- .map(c => `- ${c.subject}\n${c.body ? c.body.trim() : ''}`)
142
+ .map(c => {
143
+ const body = c.body ? c.body.trim() : '';
144
+ const truncatedBody = body.length > 500 ? body.slice(0, 500) + '…' : body;
145
+ const meta = [];
146
+ if (c.githubLogin) meta.push(`by @${c.githubLogin}`);
147
+ if (c.prNumber) meta.push(`in #${c.prNumber}`);
148
+ const metaStr = meta.length ? ` [${meta.join(' ')}]` : '';
149
+ return `- ${c.subject}${metaStr}\n${truncatedBody}`;
150
+ })
86
151
  .join('\n');
87
152
 
88
153
  const res = await fetch('https://api.anthropic.com/v1/messages', {
@@ -173,6 +238,12 @@ function prompt(question) {
173
238
  });
174
239
  }
175
240
 
241
+ function hasReadmeChangelog() {
242
+ if (!fs.existsSync(readmePath)) return false;
243
+ const content = fs.readFileSync(readmePath, 'utf8');
244
+ return /^#\s*Changelog\s*$/im.test(content);
245
+ }
246
+
176
247
  function parseArgs() {
177
248
  const args = process.argv.slice(2);
178
249
  const dryRun = args.includes('--dry-run');
@@ -199,10 +270,13 @@ function parseArgs() {
199
270
  console.log('🔍 DRY RUN MODE - No changes will be made\n');
200
271
  }
201
272
 
273
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
274
+ const isPublicPackage = !pkg.private;
275
+
202
276
  fetchOriginTags();
203
277
  const lastVersionTag = getLastVersionTag();
204
278
  const raw = getCommitRange(lastVersionTag);
205
- const commits = parseCommits(raw);
279
+ let commits = parseCommits(raw);
206
280
  if (!commits.length) {
207
281
  console.log('No commits found since last tag. Aborting.');
208
282
  process.exit(1);
@@ -218,9 +292,13 @@ function parseArgs() {
218
292
  console.log(` ${shortSha} ${commit.subject}`);
219
293
  });
220
294
 
295
+ if (isPublicPackage) {
296
+ commits = fetchGitHubMeta(commits, lastVersionTag);
297
+ }
298
+
221
299
  console.log('\nWaiting for Claude to analyze commits...');
222
300
 
223
- const result = await askClaudeForRelease(commits);
301
+ const result = await askClaudeForRelease(commits, isPublicPackage);
224
302
 
225
303
  const { bump, reasoning, notes } = result;
226
304
 
@@ -238,7 +316,6 @@ function parseArgs() {
238
316
  process.exit(1);
239
317
  }
240
318
 
241
- const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
242
319
  const curVersion = pkg.version;
243
320
  const newVersion = bumpVersionString(curVersion, finalBump);
244
321
 
@@ -280,32 +357,48 @@ function parseArgs() {
280
357
  process.exit(1);
281
358
  }
282
359
 
360
+ const useReadmeChangelog = hasReadmeChangelog();
361
+
283
362
  if (dryRun) {
284
363
  console.log(`\n🔍 DRY RUN - Would have done the following:`);
285
- console.log(
286
- ` 1. Insert changelog entry for ${newVersion} into README.md`,
287
- );
288
- console.log(` 2. git add README.md`);
289
- console.log(` 3. git commit -m "Update changelog for ${newVersion}"`);
290
- console.log(` 4. npm version ${finalBump} -m "%s"`);
291
- console.log(` 5. git push origin main --tags`);
364
+ let step = 1;
365
+ if (useReadmeChangelog) {
366
+ console.log(
367
+ ` ${step++}. Insert changelog entry for ${newVersion} into README.md`,
368
+ );
369
+ console.log(` ${step++}. git add README.md`);
370
+ console.log(
371
+ ` ${step++}. git commit -m "Update changelog for ${newVersion}"`,
372
+ );
373
+ }
374
+ console.log(` ${step++}. npm version ${finalBump} -m "%s"`);
375
+ console.log(` ${step++}. git push origin main --tags`);
376
+ console.log(` ${step++}. gh release create v${newVersion} --title "v${newVersion}" --notes-file <entry>`);
377
+ if (isPublicPackage) {
378
+ console.log(` ${step++}. npm publish`);
379
+ }
292
380
  console.log(`\n✅ Dry run complete. Use without --dry-run to execute.`);
293
381
  fs.unlinkSync(tempEntryPath);
294
382
  return;
295
383
  }
296
384
 
297
- // Read the edited entry and insert it into README
385
+ // Read the edited entry
298
386
  const editedEntry = fs.readFileSync(tempEntryPath, 'utf8');
299
- const readme = fs.readFileSync(readmePath, 'utf8');
300
- const updatedReadme = insertChangelogEntry(
301
- readme,
302
- editedEntry.trim().split('\n'),
303
- );
304
- fs.writeFileSync(readmePath, updatedReadme);
305
- fs.unlinkSync(tempEntryPath);
306
387
 
307
- run('git add README.md');
308
- run(`git commit -m "Update changelog for ${newVersion}"`);
388
+ if (useReadmeChangelog) {
389
+ // Insert changelog entry into README.md
390
+ const readme = fs.readFileSync(readmePath, 'utf8');
391
+ const updatedReadme = insertChangelogEntry(
392
+ readme,
393
+ editedEntry.trim().split('\n'),
394
+ );
395
+ fs.writeFileSync(readmePath, updatedReadme);
396
+
397
+ run('git add README.md');
398
+ run(`git commit -m "Update changelog for ${newVersion}"`);
399
+ }
400
+
401
+ fs.unlinkSync(tempEntryPath);
309
402
 
310
403
  // Use npm version keyword per user preference
311
404
  run(`npm version ${finalBump} -m "%s"`);
@@ -313,6 +406,26 @@ function parseArgs() {
313
406
  // Push commit and tags explicitly
314
407
  run('git push origin main --tags');
315
408
 
409
+ // Create GitHub release
410
+ const ghNotesFile = path.join(
411
+ os.tmpdir(),
412
+ `release-notes-${crypto.randomBytes(8).toString('hex')}.md`,
413
+ );
414
+ fs.writeFileSync(ghNotesFile, editedEntry.trim());
415
+ try {
416
+ const releaseUrl = run(
417
+ `gh release create v${newVersion} --title "v${newVersion}" --notes-file "${ghNotesFile}"`,
418
+ ).trim();
419
+ console.log(`\n🎉 GitHub release created: ${releaseUrl}`);
420
+ } finally {
421
+ fs.unlinkSync(ghNotesFile);
422
+ }
423
+
424
+ if (isPublicPackage) {
425
+ run('npm publish');
426
+ console.log(`\n📦 Published ${pkg.name}@${newVersion} to npm.`);
427
+ }
428
+
316
429
  console.log(`\nRelease ${newVersion} created and pushed with tags.`);
317
430
  } catch (err) {
318
431
  console.error(err?.stderr?.toString?.() || err?.message || err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "release-with-ease",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "A script to bump the version of an npm library and update release notes. Uses Claude to analyze commits.",
5
5
  "main": "index.js",
6
6
  "bin": {