versionary 0.28.2 → 0.29.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/dist/cli/index.js CHANGED
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import { loadConfig } from "../config/load-config.js";
4
4
  import { prependChangelog, renderReleasePlanChangelog, } from "../release/changelog.js";
5
5
  import { createReleasePlan } from "../release/plan.js";
6
- import { closeStaleReviewRequestIfExists, consumeNextReleaseFile, isReleaseCommitMessage, openOrUpdateReviewRequest, prepareReleasePr, pushReleaseBranch, readNextReleaseHighlights, } from "../release/pr.js";
6
+ import { closeStaleReviewRequestIfExists, consumeNextReleaseFile, isReleaseCommitMessage, openOrUpdateReviewRequest, prepareReleasePr, pushReleaseBranch, resolveReleaseHighlights, } from "../release/pr.js";
7
7
  import { runRelease, runReleaseDetailed } from "../release/release.js";
8
8
  import { verifyProject } from "../release/verify-project.js";
9
9
  function printVerifyResult() {
@@ -196,16 +196,17 @@ async function main() {
196
196
  return 0;
197
197
  }
198
198
  const loaded = loadConfig();
199
- const highlightsRead = readNextReleaseHighlights(process.cwd(), loaded.config);
200
- const highlights = highlightsRead?.content ?? "";
201
- const section = renderReleasePlanChangelog(plan, { highlights });
199
+ const highlightsResult = resolveReleaseHighlights(process.cwd(), loaded.config, plan.changelogFile, plan.changelogFormat, logger);
200
+ const section = renderReleasePlanChangelog(plan, {
201
+ highlights: highlightsResult.highlights,
202
+ });
202
203
  if (!write) {
203
204
  console.log(section);
204
205
  return 0;
205
206
  }
206
207
  prependChangelog(process.cwd(), plan.changelogFile, section, plan.changelogFormat);
207
- if (highlightsRead) {
208
- consumeNextReleaseFile(process.cwd(), highlightsRead.filePath);
208
+ if (highlightsResult.source === "file" && highlightsResult.filePath) {
209
+ consumeNextReleaseFile(process.cwd(), highlightsResult.filePath);
209
210
  }
210
211
  console.log(`Updated ${plan.changelogFile}`);
211
212
  return 0;
@@ -25,6 +25,26 @@ export declare function renderReleasePlanChangelog(plan: ReleasePlan, options?:
25
25
  }): string;
26
26
  /** @deprecated Use renderReleasePlanChangelog. */
27
27
  export declare function renderSimpleChangelog(plan: SimplePlan): string;
28
+ /**
29
+ * Locate a manual-notes block at the top of an existing changelog and split it
30
+ * out. The notes block is the first release-level heading whose text is not a
31
+ * version (`##` for markdown, `#` for r-news), captured up to — but excluding —
32
+ * the first subsequent version heading at the same level.
33
+ *
34
+ * Returns the captured prose as `highlights` (the heading line itself dropped,
35
+ * trimmed) and the changelog with that block removed as `body`. When the first
36
+ * release-level heading is already a version, or there is none, nothing is
37
+ * stripped and `highlights` is empty.
38
+ *
39
+ * Only the topmost heading is ever eligible, and capture stops at the first
40
+ * version heading, so prior releases are never swallowed. For r-news this also
41
+ * subsumes the conventional `# pkg (development version)` header, whose body
42
+ * (if any) becomes the release notes.
43
+ */
44
+ export declare function extractUnreleasedNotes(existing: string, format?: VersionaryChangelogFormat): {
45
+ highlights: string;
46
+ body: string;
47
+ };
28
48
  export declare function prependChangelog(cwd: string, changelogFile: string, section: string, format?: VersionaryChangelogFormat): void;
29
49
  export declare function renderRNewsReleaseNotes(input: {
30
50
  packageName: string;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { applyRevertSuppression, inferReleaseTypeFromParsedCommit, parseConventionalCommitMessage, } from "../git/commits.js";
4
4
  import { resolveRepositoryWebBaseUrl } from "../git/repo-url.js";
5
5
  import { resolvePackageDependencies, } from "./plan.js";
6
+ import { isVersionHeading } from "./semver.js";
6
7
  const REVIEW_REQUEST_FOOTER = "---\n\nThis PR was generated by [Versionary](https://github.com/jolars/versionary).";
7
8
  function formatDate() {
8
9
  return new Date().toISOString().slice(0, 10);
@@ -201,15 +202,63 @@ export function renderReleasePlanChangelog(plan, options = {}) {
201
202
  export function renderSimpleChangelog(plan) {
202
203
  return renderReleasePlanChangelog(plan);
203
204
  }
205
+ /**
206
+ * Locate a manual-notes block at the top of an existing changelog and split it
207
+ * out. The notes block is the first release-level heading whose text is not a
208
+ * version (`##` for markdown, `#` for r-news), captured up to — but excluding —
209
+ * the first subsequent version heading at the same level.
210
+ *
211
+ * Returns the captured prose as `highlights` (the heading line itself dropped,
212
+ * trimmed) and the changelog with that block removed as `body`. When the first
213
+ * release-level heading is already a version, or there is none, nothing is
214
+ * stripped and `highlights` is empty.
215
+ *
216
+ * Only the topmost heading is ever eligible, and capture stops at the first
217
+ * version heading, so prior releases are never swallowed. For r-news this also
218
+ * subsumes the conventional `# pkg (development version)` header, whose body
219
+ * (if any) becomes the release notes.
220
+ */
221
+ export function extractUnreleasedNotes(existing, format = "markdown-changelog") {
222
+ const headingPattern = format === "r-news" ? /^#(?!#)\s+/u : /^##(?!#)\s+/u;
223
+ const lines = existing.split("\n");
224
+ let firstHeadingIdx = -1;
225
+ for (let index = 0; index < lines.length; index += 1) {
226
+ if (headingPattern.test(lines[index])) {
227
+ firstHeadingIdx = index;
228
+ break;
229
+ }
230
+ }
231
+ if (firstHeadingIdx === -1 || isVersionHeading(lines[firstHeadingIdx])) {
232
+ return { highlights: "", body: existing };
233
+ }
234
+ let nextVersionIdx = lines.length;
235
+ for (let index = firstHeadingIdx + 1; index < lines.length; index += 1) {
236
+ if (headingPattern.test(lines[index]) && isVersionHeading(lines[index])) {
237
+ nextVersionIdx = index;
238
+ break;
239
+ }
240
+ }
241
+ const highlights = lines
242
+ .slice(firstHeadingIdx + 1, nextVersionIdx)
243
+ .join("\n")
244
+ .trim();
245
+ const body = [
246
+ ...lines.slice(0, firstHeadingIdx),
247
+ ...lines.slice(nextVersionIdx),
248
+ ].join("\n");
249
+ return { highlights, body };
250
+ }
204
251
  export function prependChangelog(cwd, changelogFile, section, format = "markdown-changelog") {
205
252
  const changelogPath = path.join(cwd, changelogFile);
206
- const existing = fs.existsSync(changelogPath)
253
+ const existingRaw = fs.existsSync(changelogPath)
207
254
  ? fs.readFileSync(changelogPath, "utf8")
208
255
  : "";
256
+ // Strip any manual-notes block; its prose is folded into `section` upstream,
257
+ // so leaving it here would duplicate it.
258
+ const { body: existing } = extractUnreleasedNotes(existingRaw, format);
209
259
  if (format === "r-news") {
210
- const bodyWithoutDevHeader = existing.replace(/^#\s+.+\s+\(development version\)\s*(?:\r?\n)*/u, "");
211
- const separator = bodyWithoutDevHeader.length > 0 ? "\n\n" : "";
212
- const next = `${`${section}${separator}${bodyWithoutDevHeader}`.trimEnd()}\n`;
260
+ const separator = existing.length > 0 ? "\n\n" : "";
261
+ const next = `${`${section}${separator}${existing}`.trimEnd()}\n`;
213
262
  fs.writeFileSync(changelogPath, next, "utf8");
214
263
  return;
215
264
  }
@@ -1,5 +1,5 @@
1
1
  import type { ParsedCommit } from "../git/commits.js";
2
- import type { VersionaryConfig } from "../types/config.js";
2
+ import type { VersionaryChangelogFormat, VersionaryConfig } from "../types/config.js";
3
3
  import type { VersionaryPluginContext } from "../types/plugins.js";
4
4
  import { type ReleasePlan, type SimplePlan } from "./plan.js";
5
5
  export declare function getNextReleaseFile(config: VersionaryConfig): string;
@@ -10,6 +10,22 @@ export declare function readNextReleaseHighlights(cwd: string, config: Versionar
10
10
  export declare function consumeNextReleaseFile(cwd: string, filePath: string): {
11
11
  tracked: boolean;
12
12
  };
13
+ /**
14
+ * Read the manual-notes ("Unreleased") prose from the top of a changelog file.
15
+ * Returns an empty string when the file is absent or has no notes block.
16
+ */
17
+ export declare function readChangelogHighlights(cwd: string, changelogFile: string, format: VersionaryChangelogFormat): string;
18
+ export interface ResolvedReleaseHighlights {
19
+ highlights: string;
20
+ source: "changelog" | "file" | "none";
21
+ filePath?: string;
22
+ }
23
+ /**
24
+ * Resolve release highlights, preferring an editable "Unreleased" section at the
25
+ * top of the changelog. Falls back to the deprecated `NEXT_RELEASE.md` file
26
+ * (emitting a warning) so existing setups keep working.
27
+ */
28
+ export declare function resolveReleaseHighlights(cwd: string, config: VersionaryConfig, changelogFile: string, format: VersionaryChangelogFormat, logger?: VersionaryPluginContext["logger"]): ResolvedReleaseHighlights;
13
29
  export declare function splitSafeDirtyFiles(files: string[]): {
14
30
  ignored: string[];
15
31
  blocking: string[];
@@ -6,7 +6,7 @@ import { ensureGitIdentity } from "../git/identity.js";
6
6
  import { getScmClient } from "../scm/client.js";
7
7
  import { resolvePackageStrategyContext, resolveReleaseName, } from "../strategy/package-context.js";
8
8
  import { applyConfiguredArtifactRules } from "./artifact-rules.js";
9
- import { prependChangelog, renderReleaseNotesSection, renderReleasePlanChangelog, renderReviewRequestFooter, } from "./changelog.js";
9
+ import { extractUnreleasedNotes, prependChangelog, renderReleaseNotesSection, renderReleasePlanChangelog, renderReviewRequestFooter, } from "./changelog.js";
10
10
  import { createReleasePlan, getChangelogDefaults, resolvePackageDependencies, } from "./plan.js";
11
11
  import { getBaselineStatePath, writeBaselineSha, } from "./state.js";
12
12
  const SAFE_DIRTY_FILES = new Set([
@@ -49,6 +49,39 @@ export function consumeNextReleaseFile(cwd, filePath) {
49
49
  }
50
50
  return { tracked };
51
51
  }
52
+ /**
53
+ * Read the manual-notes ("Unreleased") prose from the top of a changelog file.
54
+ * Returns an empty string when the file is absent or has no notes block.
55
+ */
56
+ export function readChangelogHighlights(cwd, changelogFile, format) {
57
+ const changelogPath = path.join(cwd, changelogFile);
58
+ if (!fs.existsSync(changelogPath)) {
59
+ return "";
60
+ }
61
+ const existing = fs.readFileSync(changelogPath, "utf8");
62
+ return extractUnreleasedNotes(existing, format).highlights;
63
+ }
64
+ /**
65
+ * Resolve release highlights, preferring an editable "Unreleased" section at the
66
+ * top of the changelog. Falls back to the deprecated `NEXT_RELEASE.md` file
67
+ * (emitting a warning) so existing setups keep working.
68
+ */
69
+ export function resolveReleaseHighlights(cwd, config, changelogFile, format, logger) {
70
+ const fromChangelog = readChangelogHighlights(cwd, changelogFile, format);
71
+ if (fromChangelog.length > 0) {
72
+ return { highlights: fromChangelog, source: "changelog" };
73
+ }
74
+ const fromFile = readNextReleaseHighlights(cwd, config);
75
+ if (fromFile) {
76
+ logger?.warn(`${fromFile.filePath} is deprecated; add release notes under an "Unreleased" heading at the top of ${changelogFile} instead.`);
77
+ return {
78
+ highlights: fromFile.content,
79
+ source: "file",
80
+ filePath: fromFile.filePath,
81
+ };
82
+ }
83
+ return { highlights: "", source: "none" };
84
+ }
52
85
  function listTrackedDirtyFiles(cwd) {
53
86
  const status = execFileSync("git", ["status", "--porcelain", "--untracked-files=no"], {
54
87
  cwd,
@@ -264,16 +297,18 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
264
297
  }
265
298
  const updatedArtifactFiles = applyConfiguredArtifactRules(cwd, loaded.config, plan);
266
299
  const packageReleaseMetadata = buildPackageReleaseMetadata(cwd, plan, loaded.config);
267
- const nextReleaseRead = readNextReleaseHighlights(cwd, loaded.config);
268
- const highlights = nextReleaseRead?.content ?? "";
300
+ const highlightsResult = resolveReleaseHighlights(cwd, loaded.config, plan.changelogFile, plan.changelogFormat, options.logger);
301
+ const highlights = highlightsResult.highlights;
269
302
  const section = renderReleasePlanChangelog(plan, { highlights, cwd });
270
303
  prependChangelog(cwd, plan.changelogFile, section, plan.changelogFormat);
271
304
  const updatedChangelogFiles = [plan.changelogFile];
272
305
  let consumedHighlightsPath = null;
273
- if (nextReleaseRead) {
274
- const { tracked } = consumeNextReleaseFile(cwd, nextReleaseRead.filePath);
306
+ // The changelog "Unreleased" block is stripped in-place by prependChangelog;
307
+ // only the legacy side file needs explicit consumption and staging.
308
+ if (highlightsResult.source === "file" && highlightsResult.filePath) {
309
+ const { tracked } = consumeNextReleaseFile(cwd, highlightsResult.filePath);
275
310
  if (tracked) {
276
- consumedHighlightsPath = nextReleaseRead.filePath;
311
+ consumedHighlightsPath = highlightsResult.filePath;
277
312
  }
278
313
  }
279
314
  for (const packagePlan of plan.packages ?? []) {
@@ -292,6 +327,8 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
292
327
  if (!packageMetadata) {
293
328
  continue;
294
329
  }
330
+ const packageChangelogPath = path.posix.join(packagePlan.path, packageChangelogFile);
331
+ const packageHighlights = readChangelogHighlights(cwd, packageChangelogPath, "markdown-changelog");
295
332
  const packageSection = renderReleaseNotesSection({
296
333
  currentVersion: packagePlan.currentVersion,
297
334
  nextVersion: packagePlan.nextVersion,
@@ -299,9 +336,10 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
299
336
  tagPrefix: packageMetadata.tagPrefix,
300
337
  cwd,
301
338
  dependencies: resolvePackageDependencies(plan, packagePlan.path),
339
+ highlights: packageHighlights,
302
340
  });
303
- prependChangelog(cwd, path.posix.join(packagePlan.path, packageChangelogFile), packageSection, "markdown-changelog");
304
- updatedChangelogFiles.push(path.posix.join(packagePlan.path, packageChangelogFile));
341
+ prependChangelog(cwd, packageChangelogPath, packageSection, "markdown-changelog");
342
+ updatedChangelogFiles.push(packageChangelogPath);
305
343
  }
306
344
  const releaseTargets = buildReleaseTargets(cwd, plan, loaded.config);
307
345
  const branch = plan.releaseBranchPrefix;
@@ -12,5 +12,17 @@ export interface BumpVersionOptions {
12
12
  }
13
13
  export declare function parseVersion(version: string): ParsedVersion;
14
14
  export declare function isValidVersion(version: string): boolean;
15
+ /**
16
+ * Decide whether a changelog heading denotes a released version (e.g.
17
+ * `## [0.28.2](url) (2026-06-02)`, `## 1.2.3`, `## v1.2.3`, `## 0.28.2.9000`)
18
+ * rather than a manual-notes heading (e.g. `## Unreleased`, `## Upcoming`).
19
+ *
20
+ * Intentionally liberal: a heading counts as a version if it contains any
21
+ * version-like token that {@link isValidVersion} accepts. This errs toward
22
+ * "it's a version", so a real release heading is never mistaken for a notes
23
+ * block (and therefore never stripped). Dates such as `2026-06-02` use dashes,
24
+ * not dots, so they never match the three-component token.
25
+ */
26
+ export declare function isVersionHeading(heading: string): boolean;
15
27
  export declare function compareVersions(leftRaw: string, rightRaw: string): number;
16
28
  export declare function bumpVersion(current: string, releaseType: Exclude<ReleaseType, null>, options?: BumpVersionOptions): string;
@@ -38,6 +38,25 @@ export function parseVersion(version) {
38
38
  export function isValidVersion(version) {
39
39
  return SEMVER_PATTERN.test(normalizeVersionInput(version));
40
40
  }
41
+ const VERSION_TOKEN_PATTERN = /v?(\d+\.\d+\.\d+(?:\.\d+)?)/u;
42
+ /**
43
+ * Decide whether a changelog heading denotes a released version (e.g.
44
+ * `## [0.28.2](url) (2026-06-02)`, `## 1.2.3`, `## v1.2.3`, `## 0.28.2.9000`)
45
+ * rather than a manual-notes heading (e.g. `## Unreleased`, `## Upcoming`).
46
+ *
47
+ * Intentionally liberal: a heading counts as a version if it contains any
48
+ * version-like token that {@link isValidVersion} accepts. This errs toward
49
+ * "it's a version", so a real release heading is never mistaken for a notes
50
+ * block (and therefore never stripped). Dates such as `2026-06-02` use dashes,
51
+ * not dots, so they never match the three-component token.
52
+ */
53
+ export function isVersionHeading(heading) {
54
+ const match = heading.match(VERSION_TOKEN_PATTERN);
55
+ if (!match?.[1]) {
56
+ return false;
57
+ }
58
+ return isValidVersion(match[1]);
59
+ }
41
60
  function isNumericIdentifier(identifier) {
42
61
  return /^(0|[1-9]\d*)$/u.test(identifier);
43
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "versionary",
3
- "version": "0.28.2",
3
+ "version": "0.29.0",
4
4
  "description": "Automatic release framework based on conventional commits and semantic versioning",
5
5
  "keywords": [
6
6
  "releasing",