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.
- package/.claude/settings.local.json +10 -0
- package/README.md +14 -1
- package/bin/release-with-ease.js +136 -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,15 +74,80 @@ 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
|
-
.map(c =>
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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
|
-
|
|
308
|
-
|
|
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);
|