release-with-ease 2.0.2 → 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.
- package/.claude/settings.local.json +10 -0
- package/README.md +14 -1
- package/bin/release-with-ease.js +132 -23
- package/package.json +1 -1
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
|
|
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
|
package/bin/release-with-ease.js
CHANGED
|
@@ -74,18 +74,79 @@ function parseCommits(raw) {
|
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
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
|
|
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
142
|
.map(c => {
|
|
86
143
|
const body = c.body ? c.body.trim() : '';
|
|
87
144
|
const truncatedBody = body.length > 500 ? body.slice(0, 500) + '…' : body;
|
|
88
|
-
|
|
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}`;
|
|
89
150
|
})
|
|
90
151
|
.join('\n');
|
|
91
152
|
|
|
@@ -177,6 +238,12 @@ function prompt(question) {
|
|
|
177
238
|
});
|
|
178
239
|
}
|
|
179
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
|
+
|
|
180
247
|
function parseArgs() {
|
|
181
248
|
const args = process.argv.slice(2);
|
|
182
249
|
const dryRun = args.includes('--dry-run');
|
|
@@ -203,10 +270,13 @@ function parseArgs() {
|
|
|
203
270
|
console.log('🔍 DRY RUN MODE - No changes will be made\n');
|
|
204
271
|
}
|
|
205
272
|
|
|
273
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
274
|
+
const isPublicPackage = !pkg.private;
|
|
275
|
+
|
|
206
276
|
fetchOriginTags();
|
|
207
277
|
const lastVersionTag = getLastVersionTag();
|
|
208
278
|
const raw = getCommitRange(lastVersionTag);
|
|
209
|
-
|
|
279
|
+
let commits = parseCommits(raw);
|
|
210
280
|
if (!commits.length) {
|
|
211
281
|
console.log('No commits found since last tag. Aborting.');
|
|
212
282
|
process.exit(1);
|
|
@@ -222,9 +292,13 @@ function parseArgs() {
|
|
|
222
292
|
console.log(` ${shortSha} ${commit.subject}`);
|
|
223
293
|
});
|
|
224
294
|
|
|
295
|
+
if (isPublicPackage) {
|
|
296
|
+
commits = fetchGitHubMeta(commits, lastVersionTag);
|
|
297
|
+
}
|
|
298
|
+
|
|
225
299
|
console.log('\nWaiting for Claude to analyze commits...');
|
|
226
300
|
|
|
227
|
-
const result = await askClaudeForRelease(commits);
|
|
301
|
+
const result = await askClaudeForRelease(commits, isPublicPackage);
|
|
228
302
|
|
|
229
303
|
const { bump, reasoning, notes } = result;
|
|
230
304
|
|
|
@@ -242,7 +316,6 @@ function parseArgs() {
|
|
|
242
316
|
process.exit(1);
|
|
243
317
|
}
|
|
244
318
|
|
|
245
|
-
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
246
319
|
const curVersion = pkg.version;
|
|
247
320
|
const newVersion = bumpVersionString(curVersion, finalBump);
|
|
248
321
|
|
|
@@ -284,32 +357,48 @@ function parseArgs() {
|
|
|
284
357
|
process.exit(1);
|
|
285
358
|
}
|
|
286
359
|
|
|
360
|
+
const useReadmeChangelog = hasReadmeChangelog();
|
|
361
|
+
|
|
287
362
|
if (dryRun) {
|
|
288
363
|
console.log(`\n🔍 DRY RUN - Would have done the following:`);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
}
|
|
296
380
|
console.log(`\n✅ Dry run complete. Use without --dry-run to execute.`);
|
|
297
381
|
fs.unlinkSync(tempEntryPath);
|
|
298
382
|
return;
|
|
299
383
|
}
|
|
300
384
|
|
|
301
|
-
// Read the edited entry
|
|
385
|
+
// Read the edited entry
|
|
302
386
|
const editedEntry = fs.readFileSync(tempEntryPath, 'utf8');
|
|
303
|
-
const readme = fs.readFileSync(readmePath, 'utf8');
|
|
304
|
-
const updatedReadme = insertChangelogEntry(
|
|
305
|
-
readme,
|
|
306
|
-
editedEntry.trim().split('\n'),
|
|
307
|
-
);
|
|
308
|
-
fs.writeFileSync(readmePath, updatedReadme);
|
|
309
|
-
fs.unlinkSync(tempEntryPath);
|
|
310
387
|
|
|
311
|
-
|
|
312
|
-
|
|
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);
|
|
313
402
|
|
|
314
403
|
// Use npm version keyword per user preference
|
|
315
404
|
run(`npm version ${finalBump} -m "%s"`);
|
|
@@ -317,6 +406,26 @@ function parseArgs() {
|
|
|
317
406
|
// Push commit and tags explicitly
|
|
318
407
|
run('git push origin main --tags');
|
|
319
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
|
+
|
|
320
429
|
console.log(`\nRelease ${newVersion} created and pushed with tags.`);
|
|
321
430
|
} catch (err) {
|
|
322
431
|
console.error(err?.stderr?.toString?.() || err?.message || err);
|