@doryski/release 1.0.0 → 1.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/README.md CHANGED
@@ -37,6 +37,7 @@ pnpm release
37
37
  | `-r, --release-version <ver>` | Exact release version (`0.2.0` or `v0.2.0`). Highest precedence. |
38
38
  | `--bump <major\|minor\|patch>` | Force the bump level. Overrides the auto-detected level; ignored when `--release-version` is set. |
39
39
  | `-y, --yes` | Skip the confirmation prompt (required for non-interactive runs). |
40
+ | `--no-changelog` | Skip cutting a `CHANGELOG.md` section for this release. |
40
41
  | `-n, --dry-run` | Preview actions without modifying files, committing, tagging, or pushing. |
41
42
  | `-h, --help` | Show help. |
42
43
 
@@ -48,6 +49,30 @@ The auto-detected bump follows conventional commits since the latest tag:
48
49
  - any `feat:` → **minor**
49
50
  - everything else (`fix`, `chore`, `docs`, …) → **patch**
50
51
 
52
+ ## Changelog
53
+
54
+ If the repo has a `CHANGELOG.md`, the CLI cuts a dated section for the release
55
+ and includes it in the `release: vX.Y.Z` commit, so the file can never drift
56
+ behind the tags. Repos without the file are unaffected; `--no-changelog` opts
57
+ out.
58
+
59
+ The format follows [Keep a Changelog](https://keepachangelog.com/): the new
60
+ section goes directly below `## [Unreleased]`, which is left in place, empty and
61
+ ready for the next cycle.
62
+
63
+ Section content comes from one of two places:
64
+
65
+ - **Hand-written notes win.** Anything under `## [Unreleased]` is promoted
66
+ verbatim into the new version section. Write prose there as you go and the
67
+ release just files it under the right version.
68
+ - **Otherwise it is generated** from the conventional commits since the last
69
+ tag: `feat` → `### Added`, `fix` → `### Fixed`, `perf`/`refactor`/`revert` →
70
+ `### Changed`, and anything marked `!` or carrying a `BREAKING CHANGE:` footer
71
+ → `### Breaking Changes`. Scopes render as a bold prefix. Non-user-facing
72
+ types (`chore`, `docs`, `test`, `ci`, `build`, `style`, `release`) are dropped.
73
+
74
+ When neither source yields anything, `CHANGELOG.md` is left untouched.
75
+
51
76
  ## Programmatic use
52
77
 
53
78
  ```ts
@@ -1,5 +1,146 @@
1
+ // src/changelog.ts
2
+ import { readFile, writeFile } from "fs/promises";
3
+
4
+ // src/semver.ts
5
+ var parseSemver = (tag) => {
6
+ const match = /^v(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(tag);
7
+ if (!match) return null;
8
+ const [, major, minor, patch, prerelease] = match;
9
+ return {
10
+ major: Number(major),
11
+ minor: Number(minor),
12
+ patch: Number(patch),
13
+ prerelease: prerelease ?? null
14
+ };
15
+ };
16
+ var applyBump = (v, type) => {
17
+ if (type === "major") return `v${v.major + 1}.0.0`;
18
+ if (type === "minor") return `v${v.major}.${v.minor + 1}.0`;
19
+ return `v${v.major}.${v.minor}.${v.patch + 1}`;
20
+ };
21
+ var determineBump = (commits) => {
22
+ let bump = "patch";
23
+ const subjectRe = /^(\w+)(?:\([^)]*\))?(!)?:/;
24
+ for (const msg of commits) {
25
+ const subject = msg.split("\n")[0];
26
+ const match = subjectRe.exec(subject);
27
+ const breaking = match?.[2] === "!" || /^BREAKING CHANGE:/m.test(msg);
28
+ if (breaking) return "major";
29
+ if (match?.[1] === "feat") bump = "minor";
30
+ }
31
+ return bump;
32
+ };
33
+ var stripV = (tag) => tag.startsWith("v") ? tag.slice(1) : tag;
34
+
35
+ // src/changelog.ts
36
+ var SECTION_BY_TYPE = {
37
+ feat: "Added",
38
+ fix: "Fixed",
39
+ perf: "Changed",
40
+ refactor: "Changed",
41
+ revert: "Changed"
42
+ };
43
+ var BREAKING_SECTION = "Breaking Changes";
44
+ var SECTION_ORDER = [
45
+ BREAKING_SECTION,
46
+ "Added",
47
+ "Changed",
48
+ "Fixed"
49
+ ];
50
+ var SUBJECT_RE = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/;
51
+ var isKnownType = (type) => type in SECTION_BY_TYPE;
52
+ var parseCommit = (message) => {
53
+ const [subject = "", ...rest] = message.split("\n");
54
+ const match = SUBJECT_RE.exec(subject.trim());
55
+ if (!match) return null;
56
+ const [, type, scope, bang, description] = match;
57
+ const breaking = bang === "!" || /^BREAKING CHANGE:/m.test(rest.join("\n"));
58
+ if (breaking) return { section: BREAKING_SECTION, scope: scope ?? null, description };
59
+ if (!isKnownType(type)) return null;
60
+ return { section: SECTION_BY_TYPE[type], scope: scope ?? null, description };
61
+ };
62
+ var renderEntry = ({ scope, description }) => scope ? `- **${scope}**: ${description}` : `- ${description}`;
63
+ var renderEntries = (commits) => {
64
+ const entries = commits.map(parseCommit).filter((entry) => entry !== null);
65
+ return SECTION_ORDER.flatMap((section) => {
66
+ const forSection = entries.filter((entry) => entry.section === section);
67
+ if (!forSection.length) return [];
68
+ return [`### ${section}`, "", ...forSection.map(renderEntry), ""];
69
+ }).join("\n").trimEnd();
70
+ };
71
+ var VERSION_HEADING_RE = /^## \[/;
72
+ var UNRELEASED_HEADING_RE = /^## \[unreleased\]/i;
73
+ var splitDocument = (content) => {
74
+ const lines = content.split("\n");
75
+ const headings = lines.flatMap(
76
+ (line, index) => VERSION_HEADING_RE.test(line) ? [index] : []
77
+ );
78
+ const unreleasedAt = headings.find(
79
+ (index) => UNRELEASED_HEADING_RE.test(lines[index])
80
+ );
81
+ if (unreleasedAt === void 0) {
82
+ const at = headings[0] ?? lines.length;
83
+ return {
84
+ head: lines.slice(0, at).join("\n"),
85
+ unreleasedHeading: null,
86
+ unreleasedBody: "",
87
+ tail: lines.slice(at).join("\n")
88
+ };
89
+ }
90
+ const tailAt = headings.find(
91
+ (index) => index > unreleasedAt && !UNRELEASED_HEADING_RE.test(lines[index])
92
+ ) ?? lines.length;
93
+ return {
94
+ head: lines.slice(0, unreleasedAt).join("\n"),
95
+ unreleasedHeading: lines[unreleasedAt],
96
+ unreleasedBody: lines.slice(unreleasedAt + 1, tailAt).join("\n").trim(),
97
+ tail: lines.slice(tailAt).join("\n")
98
+ };
99
+ };
100
+ var LINK_REF_RE = /^\[(\d+\.\d+\.\d+)\]:\s*(\S*\/tag\/)v\d+\.\d+\.\d+\s*$/;
101
+ var withLinkRef = (tail, version) => {
102
+ const lines = tail.split("\n");
103
+ const at = lines.findIndex((line) => LINK_REF_RE.test(line));
104
+ if (at < 0) return tail;
105
+ const [, , base] = LINK_REF_RE.exec(lines[at]) ?? [];
106
+ const ref = `[${version}]: ${base}v${version}`;
107
+ if (lines.some((line) => line.startsWith(`[${version}]:`))) return tail;
108
+ return [...lines.slice(0, at), ref, ...lines.slice(at)].join("\n");
109
+ };
110
+ var buildChangelog = ({
111
+ content,
112
+ version,
113
+ date,
114
+ commits
115
+ }) => {
116
+ const { head, unreleasedHeading, unreleasedBody, tail } = splitDocument(content);
117
+ const body = unreleasedBody || renderEntries(commits);
118
+ if (!body) return null;
119
+ const released = stripV(version);
120
+ const blocks = [
121
+ head.trimEnd(),
122
+ unreleasedHeading,
123
+ `## [${released}] - ${date}
124
+
125
+ ${body}`,
126
+ withLinkRef(tail.trim(), released)
127
+ ].filter((block) => Boolean(block));
128
+ return `${blocks.join("\n\n")}
129
+ `;
130
+ };
131
+ var today = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
132
+ var updateChangelogFile = async (file, version, commits) => {
133
+ const content = await readFile(file, "utf8").catch(() => null);
134
+ if (content === null) return false;
135
+ const next = buildChangelog({ content, version, date: today(), commits });
136
+ if (!next || next === content) return false;
137
+ await writeFile(file, next, "utf8");
138
+ return true;
139
+ };
140
+
1
141
  // src/release.ts
2
142
  import { spawnSync } from "child_process";
143
+ import { existsSync } from "fs";
3
144
  import path from "path";
4
145
  import { createInterface } from "readline/promises";
5
146
  import { stdin, stdout } from "process";
@@ -61,10 +202,10 @@ var getRemoteActionsUrl = () => {
61
202
  };
62
203
 
63
204
  // src/pkg.ts
64
- import { readFile, writeFile } from "fs/promises";
65
- var readJson = async (file) => JSON.parse(await readFile(file, "utf8"));
205
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
206
+ var readJson = async (file) => JSON.parse(await readFile2(file, "utf8"));
66
207
  var writeJson = async (file, data) => {
67
- await writeFile(file, `${JSON.stringify(data, null, 2)}
208
+ await writeFile2(file, `${JSON.stringify(data, null, 2)}
68
209
  `, "utf8");
69
210
  };
70
211
  var updateVersionFile = async (file, newVersion) => {
@@ -75,39 +216,9 @@ var updateVersionFile = async (file, newVersion) => {
75
216
  return true;
76
217
  };
77
218
 
78
- // src/semver.ts
79
- var parseSemver = (tag) => {
80
- const match = /^v(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(tag);
81
- if (!match) return null;
82
- const [, major, minor, patch, prerelease] = match;
83
- return {
84
- major: Number(major),
85
- minor: Number(minor),
86
- patch: Number(patch),
87
- prerelease: prerelease ?? null
88
- };
89
- };
90
- var applyBump = (v, type) => {
91
- if (type === "major") return `v${v.major + 1}.0.0`;
92
- if (type === "minor") return `v${v.major}.${v.minor + 1}.0`;
93
- return `v${v.major}.${v.minor}.${v.patch + 1}`;
94
- };
95
- var determineBump = (commits) => {
96
- let bump = "patch";
97
- const subjectRe = /^(\w+)(?:\([^)]*\))?(!)?:/;
98
- for (const msg of commits) {
99
- const subject = msg.split("\n")[0];
100
- const match = subjectRe.exec(subject);
101
- const breaking = match?.[2] === "!" || /^BREAKING CHANGE:/m.test(msg);
102
- if (breaking) return "major";
103
- if (match?.[1] === "feat") bump = "minor";
104
- }
105
- return bump;
106
- };
107
- var stripV = (tag) => tag.startsWith("v") ? tag.slice(1) : tag;
108
-
109
219
  // src/release.ts
110
220
  var PACKAGE_JSON = path.join(process.cwd(), "package.json");
221
+ var CHANGELOG_MD = path.join(process.cwd(), "CHANGELOG.md");
111
222
  var makeRunStep = (dryRun) => (label, cmd, args) => {
112
223
  if (dryRun) {
113
224
  console.log(`\u2192 [dry-run] ${label}`);
@@ -122,7 +233,8 @@ var release = async (options = {}) => {
122
233
  releaseVersion: options.releaseVersion,
123
234
  bump: options.bump,
124
235
  yes: options.yes ?? false,
125
- dryRun: options.dryRun ?? false
236
+ dryRun: options.dryRun ?? false,
237
+ changelog: options.changelog ?? true
126
238
  };
127
239
  ensureCleanTree();
128
240
  const branch = ensureOnDefaultBranch();
@@ -187,13 +299,21 @@ var release = async (options = {}) => {
187
299
  rl?.close();
188
300
  process.exit(1);
189
301
  }
302
+ const writesChangelog = flags.changelog && existsSync(CHANGELOG_MD);
190
303
  console.log(`
191
304
  This will:`);
192
305
  console.log(` 1. Bump package.json ${pkg.version} \u2192 ${bareVersion}`);
193
- console.log(` 2. Commit on ${branch}`);
194
- console.log(` 3. Tag ${normalized} and push ${branch} + tag`);
195
- console.log(` 4. GitHub Actions will build, test, and publish to npm
196
- `);
306
+ if (writesChangelog) {
307
+ console.log(` 2. Cut CHANGELOG.md section [${bareVersion}]`);
308
+ }
309
+ console.log(` ${writesChangelog ? 3 : 2}. Commit on ${branch}`);
310
+ console.log(
311
+ ` ${writesChangelog ? 4 : 3}. Tag ${normalized} and push ${branch} + tag`
312
+ );
313
+ console.log(
314
+ ` ${writesChangelog ? 5 : 4}. GitHub Actions will build, test, and publish to npm
315
+ `
316
+ );
197
317
  if (flags.dryRun) console.log("Dry-run mode: no changes will be made.\n");
198
318
  const confirmed = await askConfirm();
199
319
  rl?.close();
@@ -204,12 +324,22 @@ This will:`);
204
324
  const runStep = makeRunStep(flags.dryRun);
205
325
  if (flags.dryRun) {
206
326
  console.log(`\u2192 [dry-run] would bump package.json to ${bareVersion}`);
327
+ if (writesChangelog) {
328
+ console.log(`\u2192 [dry-run] would cut CHANGELOG.md section [${bareVersion}]`);
329
+ }
207
330
  } else {
208
331
  const pkgChanged = await updateVersionFile(PACKAGE_JSON, bareVersion);
209
- if (!pkgChanged) {
210
- console.warn("\n\u26A0 package.json already at target version.");
211
- } else {
212
- runStep(`git add package.json`, "git", ["add", "package.json"]);
332
+ if (!pkgChanged) console.warn("\n\u26A0 package.json already at target version.");
333
+ const changelogChanged = writesChangelog && await updateChangelogFile(CHANGELOG_MD, normalized, commitsSinceTag);
334
+ if (writesChangelog && !changelogChanged) {
335
+ console.warn("\u26A0 No changelog entries to cut; leaving CHANGELOG.md alone.");
336
+ }
337
+ const staged = [
338
+ pkgChanged ? "package.json" : null,
339
+ changelogChanged ? "CHANGELOG.md" : null
340
+ ].filter((file) => file !== null);
341
+ if (staged.length) {
342
+ runStep(`git add ${staged.join(" ")}`, "git", ["add", ...staged]);
213
343
  runStep(`git commit -m "release: ${normalized}"`, "git", [
214
344
  "commit",
215
345
  "-m",
@@ -251,5 +381,8 @@ This will:`);
251
381
  };
252
382
 
253
383
  export {
384
+ parseCommit,
385
+ renderEntries,
386
+ buildChangelog,
254
387
  release
255
388
  };
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  release
4
- } from "./chunk-67J7X3FS.js";
4
+ } from "./chunk-IE5POH6X.js";
5
5
 
6
6
  // src/cli.ts
7
7
  import { Command, InvalidArgumentError } from "commander";
@@ -20,6 +20,9 @@ var program = new Command().name("doryski-release").description("Bump package.js
20
20
  "force the bump level (overrides the auto-detected level; ignored when --release-version is set)",
21
21
  parseBump
22
22
  ).option("-y, --yes", "skip the confirmation prompt", false).option(
23
+ "--no-changelog",
24
+ "skip cutting a CHANGELOG.md section for this release"
25
+ ).option(
23
26
  "-n, --dry-run",
24
27
  "preview the actions without modifying files, committing, tagging, or pushing",
25
28
  false
@@ -30,7 +33,8 @@ release({
30
33
  releaseVersion: flags.releaseVersion,
31
34
  bump: flags.bump,
32
35
  yes: flags.yes,
33
- dryRun: flags.dryRun
36
+ dryRun: flags.dryRun,
37
+ changelog: flags.changelog
34
38
  }).catch((err) => {
35
39
  console.error(err);
36
40
  process.exit(1);
package/dist/index.d.ts CHANGED
@@ -11,7 +11,25 @@ type ReleaseOptions = {
11
11
  bump?: BumpType;
12
12
  yes?: boolean;
13
13
  dryRun?: boolean;
14
+ changelog?: boolean;
14
15
  };
15
16
  declare const release: (options?: ReleaseOptions) => Promise<void>;
16
17
 
17
- export { type BumpType, type ParsedSemver, type ReleaseOptions, release };
18
+ declare const SECTION_ORDER: readonly ["Breaking Changes", "Added", "Changed", "Fixed"];
19
+ type ChangelogSection = (typeof SECTION_ORDER)[number];
20
+ type ChangelogEntry = {
21
+ section: ChangelogSection;
22
+ scope: string | null;
23
+ description: string;
24
+ };
25
+ declare const parseCommit: (message: string) => ChangelogEntry | null;
26
+ declare const renderEntries: (commits: string[]) => string;
27
+ type BuildChangelogInput = {
28
+ content: string;
29
+ version: string;
30
+ date: string;
31
+ commits: string[];
32
+ };
33
+ declare const buildChangelog: ({ content, version, date, commits, }: BuildChangelogInput) => string | null;
34
+
35
+ export { type BuildChangelogInput, type BumpType, type ChangelogEntry, type ChangelogSection, type ParsedSemver, type ReleaseOptions, buildChangelog, parseCommit, release, renderEntries };
package/dist/index.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import {
2
- release
3
- } from "./chunk-67J7X3FS.js";
2
+ buildChangelog,
3
+ parseCommit,
4
+ release,
5
+ renderEntries
6
+ } from "./chunk-IE5POH6X.js";
4
7
  export {
5
- release
8
+ buildChangelog,
9
+ parseCommit,
10
+ release,
11
+ renderEntries
6
12
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doryski/release",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A conventional-commit release CLI and reusable GitHub Actions workflow for npm packages",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",