versionary 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.
@@ -112,7 +112,9 @@ function main() {
112
112
  setOutput("release_created", "false");
113
113
  setOutput("tag_name", "");
114
114
  setOutput("tag_names", "[]");
115
+ setOutput("release_targets", "[]");
115
116
  setOutput("review_url", "");
117
+ setOutput("review_requests", "[]");
116
118
  setOutput("branch", "");
117
119
  setOutput("title", "");
118
120
  return;
@@ -148,7 +150,9 @@ function main() {
148
150
  setOutput("release_created", releaseCreated);
149
151
  setOutput("tag_name", firstTag);
150
152
  setOutput("tag_names", JSON.stringify(tagNames));
153
+ setOutput("release_targets", JSON.stringify(Array.isArray(payload.releaseTargets) ? payload.releaseTargets : []));
151
154
  setOutput("review_url", payload.reviewUrl ?? "");
155
+ setOutput("review_requests", JSON.stringify(Array.isArray(payload.reviewRequests) ? payload.reviewRequests : []));
152
156
  setOutput("branch", payload.branch ?? "");
153
157
  setOutput("title", payload.title ?? "");
154
158
  }
package/dist/cli/index.js CHANGED
@@ -1,10 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from "node:child_process";
3
+ import { loadConfig } from "../config/load-config.js";
3
4
  import { prependChangelog, renderReleasePlanChangelog, } from "../release/changelog.js";
4
5
  import { createReleasePlan } from "../release/plan.js";
5
- import { closeStaleReviewRequestIfExists, isReleaseCommitMessage, openOrUpdateReviewRequest, prepareReleasePr, pushReleaseBranch, resolveReleaseHighlights, } from "../release/pr.js";
6
+ import { closeStaleReviewRequestIfExists, isReleaseCommitMessage, openOrUpdateReviewRequest, preparePendingReleasePr, preparePendingSeparateReleasePrs, prepareReleasePr, prepareSeparateReleasePrs, pushReleaseBranch, reconcileSeparateReviewRequests, resolveReleaseHighlights, } from "../release/pr.js";
6
7
  import { runRelease, runReleaseDetailed } from "../release/release.js";
8
+ import { hasFullyUntaggedPendingRelease, hasReleaseStateChangeAtHead, readPendingReleaseTargets, } from "../release/state.js";
7
9
  import { verifyProject } from "../release/verify-project.js";
10
+ /**
11
+ * What a dry run would actually release. `plan.nextVersion` is the repository
12
+ * aggregate, which in a monorepo names no real release, so prefer the
13
+ * per-package versions whenever the plan has them.
14
+ */
15
+ function formatDryRunReleaseSubject(plan) {
16
+ const releasing = (plan.packages ?? []).filter((pkg) => pkg.nextVersion);
17
+ if (releasing.length === 0) {
18
+ return plan.nextVersion ?? "";
19
+ }
20
+ return releasing
21
+ .map((pkg) => `${pkg.path === "." ? plan.packageName : pkg.path} ${pkg.nextVersion}`)
22
+ .join(", ");
23
+ }
8
24
  function printVerifyResult() {
9
25
  const result = verifyProject();
10
26
  const categories = [
@@ -39,6 +55,140 @@ function parseFlags(args) {
39
55
  function emitJson(payload) {
40
56
  process.stdout.write(`${JSON.stringify(payload)}\n`);
41
57
  }
58
+ function reviewResultPayload(action, message, reviewRequests) {
59
+ const primary = reviewRequests[0];
60
+ return {
61
+ action,
62
+ message,
63
+ releaseCreated: false,
64
+ tagNames: [],
65
+ reviewUrl: primary?.reviewUrl,
66
+ branch: primary?.branch,
67
+ title: primary?.title,
68
+ reviewRequests,
69
+ targets: reviewRequests.flatMap((request) => request.targets.map((target) => ({
70
+ tag: target.tag,
71
+ version: target.version,
72
+ }))),
73
+ };
74
+ }
75
+ function printReviewResults(message, reviewRequests) {
76
+ console.log(message);
77
+ for (const request of reviewRequests) {
78
+ console.log(`${request.branch}: ${request.title}`);
79
+ if (request.reviewUrl) {
80
+ console.log(request.reviewUrl);
81
+ }
82
+ }
83
+ }
84
+ async function prepareCurrentSeparateReviewRequests(flags, logger) {
85
+ const cwd = process.cwd();
86
+ const prepared = prepareSeparateReleasePrs(cwd, {
87
+ logger,
88
+ "dry-run": flags["dry-run"],
89
+ });
90
+ const reviewRequests = await reconcileSeparateReviewRequests(cwd, prepared, {
91
+ logger,
92
+ "dry-run": flags["dry-run"],
93
+ });
94
+ return {
95
+ reviewRequests,
96
+ updated: reviewRequests.some((request) => request.status === "prepared"),
97
+ };
98
+ }
99
+ async function recoverPendingReleaseIfNeeded(flags, logger) {
100
+ const cwd = process.cwd();
101
+ if (!hasFullyUntaggedPendingRelease(cwd)) {
102
+ return false;
103
+ }
104
+ const pendingTargets = readPendingReleaseTargets(cwd);
105
+ const loaded = loadConfig(cwd);
106
+ const releaseBranch = loaded.config["release-branch"] ?? "versionary/release";
107
+ if (loaded.config["separate-release-prs"]) {
108
+ const prepared = preparePendingSeparateReleasePrs(cwd, {
109
+ logger,
110
+ "dry-run": flags["dry-run"],
111
+ });
112
+ const reviewRequests = await reconcileSeparateReviewRequests(cwd, prepared, {
113
+ logger,
114
+ "dry-run": flags["dry-run"],
115
+ recovered: true,
116
+ });
117
+ const action = flags["dry-run"]
118
+ ? "pr-dry-run"
119
+ : reviewRequests.some((request) => request.status === "recovered")
120
+ ? "pr-prepared"
121
+ : "pr-up-to-date";
122
+ const message = flags["dry-run"]
123
+ ? `Dry run: would recover ${prepared.length} pending package release PR${prepared.length === 1 ? "" : "s"}.`
124
+ : action === "pr-prepared"
125
+ ? `Prepared ${prepared.length} pending package release recovery PR${prepared.length === 1 ? "" : "s"}.`
126
+ : "Pending package release recovery PRs are already up to date.";
127
+ if (flags.json) {
128
+ emitJson(reviewResultPayload(action, message, reviewRequests));
129
+ return true;
130
+ }
131
+ printReviewResults(message, reviewRequests);
132
+ return true;
133
+ }
134
+ if (flags["dry-run"]) {
135
+ const message = `Dry run: would recover pending releases ${pendingTargets.map((target) => target.tag).join(", ")} on branch ${releaseBranch}`;
136
+ if (flags.json) {
137
+ emitJson({
138
+ action: "pr-dry-run",
139
+ message,
140
+ releaseCreated: false,
141
+ tagNames: [],
142
+ branch: releaseBranch,
143
+ targets: pendingTargets.map((target) => ({
144
+ tag: target.tag,
145
+ version: target.version,
146
+ })),
147
+ });
148
+ return true;
149
+ }
150
+ console.log(message);
151
+ return true;
152
+ }
153
+ const recovery = preparePendingReleasePr(cwd, { logger });
154
+ if (!recovery.updated) {
155
+ const message = `Pending release recovery branch ${recovery.branch} is already up to date.`;
156
+ if (flags.json) {
157
+ emitJson({
158
+ action: "pr-up-to-date",
159
+ message,
160
+ releaseCreated: false,
161
+ tagNames: [],
162
+ branch: recovery.branch,
163
+ title: recovery.title,
164
+ });
165
+ return true;
166
+ }
167
+ console.log(message);
168
+ console.log(`Title: ${recovery.title}`);
169
+ return true;
170
+ }
171
+ pushReleaseBranch(cwd, recovery.branch);
172
+ const primaryVersion = recovery.targets[0]?.version ?? "";
173
+ const reviewResult = await openOrUpdateReviewRequest(cwd, recovery.branch, recovery.title, primaryVersion, primaryVersion, [], null, { logger, body: recovery.body });
174
+ const message = `Prepared pending release recovery PR branch ${recovery.branch}`;
175
+ if (flags.json) {
176
+ emitJson({
177
+ action: "pr-prepared",
178
+ message,
179
+ releaseCreated: false,
180
+ tagNames: [],
181
+ reviewUrl: reviewResult,
182
+ branch: recovery.branch,
183
+ title: recovery.title,
184
+ });
185
+ return true;
186
+ }
187
+ console.log(message);
188
+ console.log(`Title: ${recovery.title}`);
189
+ console.log(reviewResult);
190
+ return true;
191
+ }
42
192
  async function main() {
43
193
  const [, , command, ...args] = process.argv;
44
194
  const flags = parseFlags(args);
@@ -48,51 +198,69 @@ async function main() {
48
198
  encoding: "utf8",
49
199
  stdio: ["ignore", "pipe", "ignore"],
50
200
  }).trim();
51
- if (isReleaseCommitMessage(commitMessage)) {
52
- if (flags["dry-run"] && !flags.json) {
53
- const release = await runReleaseDetailed(process.cwd(), {
54
- logger,
55
- "dry-run": true,
56
- });
57
- if (release.action === "release-dry-run") {
58
- console.log(release.message);
59
- return 0;
60
- }
61
- }
62
- if (flags.json) {
63
- const release = await runReleaseDetailed(process.cwd(), {
64
- logger,
65
- "dry-run": flags["dry-run"],
66
- });
67
- if (release.action === "release-skipped") {
201
+ if (isReleaseCommitMessage(commitMessage) ||
202
+ hasReleaseStateChangeAtHead(process.cwd())) {
203
+ const release = await runReleaseDetailed(process.cwd(), {
204
+ logger,
205
+ "dry-run": flags["dry-run"],
206
+ });
207
+ if (release.action === "release-skipped") {
208
+ if (flags.json) {
68
209
  emitJson({
69
210
  action: "release-skipped",
70
211
  message: release.reason,
71
212
  releaseCreated: false,
72
213
  tagNames: [],
73
214
  });
74
- return 0;
75
215
  }
76
- if (release.action === "release-dry-run") {
216
+ else {
217
+ console.log(release.reason);
218
+ }
219
+ return 0;
220
+ }
221
+ if (release.action === "release-dry-run") {
222
+ if (flags.json) {
77
223
  emitJson({
78
224
  action: "release-dry-run",
79
225
  message: release.message,
80
226
  releaseCreated: false,
81
227
  tagNames: release.targets.map((target) => target.tag),
228
+ releaseTargets: release.releaseTargets,
82
229
  targets: release.targets,
83
230
  });
84
- return 0;
85
231
  }
232
+ else {
233
+ console.log(release.message);
234
+ }
235
+ return 0;
236
+ }
237
+ let reviewRequests = [];
238
+ if (loadConfig(process.cwd()).config["separate-release-prs"]) {
239
+ reviewRequests = (await prepareCurrentSeparateReviewRequests({ ...flags, "dry-run": false }, logger)).reviewRequests;
240
+ }
241
+ if (flags.json) {
242
+ const primary = reviewRequests[0];
86
243
  emitJson({
87
244
  action: "release-published",
88
245
  message: release.message,
89
246
  releaseCreated: release.releases.length > 0,
90
247
  tagNames: release.releases.map((target) => target.tag),
248
+ releaseTargets: release.releaseTargets,
249
+ reviewUrl: primary?.reviewUrl,
250
+ branch: primary?.branch,
251
+ title: primary?.title,
252
+ reviewRequests,
91
253
  });
92
- return 0;
93
254
  }
94
- const message = await runRelease(process.cwd());
95
- console.log(message);
255
+ else {
256
+ console.log(release.message);
257
+ if (reviewRequests.length > 0) {
258
+ printReviewResults(`Reconciled ${reviewRequests.length} remaining package release PR${reviewRequests.length === 1 ? "" : "s"}.`, reviewRequests);
259
+ }
260
+ }
261
+ return 0;
262
+ }
263
+ if (await recoverPendingReleaseIfNeeded(flags, logger)) {
96
264
  return 0;
97
265
  }
98
266
  const plan = createReleasePlan();
@@ -113,8 +281,28 @@ async function main() {
113
281
  console.log(message);
114
282
  return 0;
115
283
  }
284
+ if (loadConfig(process.cwd()).config["separate-release-prs"]) {
285
+ const { reviewRequests, updated } = await prepareCurrentSeparateReviewRequests(flags, logger);
286
+ const action = flags["dry-run"]
287
+ ? "pr-dry-run"
288
+ : updated
289
+ ? "pr-prepared"
290
+ : "pr-up-to-date";
291
+ const message = flags["dry-run"]
292
+ ? `Dry run: would prepare ${reviewRequests.length} package release PR${reviewRequests.length === 1 ? "" : "s"}.`
293
+ : updated
294
+ ? `Prepared ${reviewRequests.length} package release PR${reviewRequests.length === 1 ? "" : "s"}.`
295
+ : "Package release PRs are already up to date.";
296
+ if (flags.json) {
297
+ emitJson(reviewResultPayload(action, message, reviewRequests));
298
+ }
299
+ else {
300
+ printReviewResults(message, reviewRequests);
301
+ }
302
+ return 0;
303
+ }
116
304
  if (flags["dry-run"]) {
117
- const dryRunMessage = `Dry run: would prepare release PR branch ${plan.releaseBranchPrefix} for ${plan.nextVersion}`;
305
+ const dryRunMessage = `Dry run: would prepare release PR branch ${plan.releaseBranchPrefix} for ${formatDryRunReleaseSubject(plan)}`;
118
306
  if (flags.json) {
119
307
  emitJson({
120
308
  action: "pr-dry-run",
@@ -207,6 +395,9 @@ async function main() {
207
395
  return 0;
208
396
  }
209
397
  if (command === "pr") {
398
+ if (await recoverPendingReleaseIfNeeded({ ...flags, json: false }, console)) {
399
+ return 0;
400
+ }
210
401
  const plan = createReleasePlan();
211
402
  if (!plan.nextVersion) {
212
403
  if (!flags["dry-run"]) {
@@ -217,8 +408,18 @@ async function main() {
217
408
  console.log("No releasable commits found. Nothing to do.");
218
409
  return 0;
219
410
  }
411
+ if (loadConfig(process.cwd()).config["separate-release-prs"]) {
412
+ const { reviewRequests, updated } = await prepareCurrentSeparateReviewRequests(flags, console);
413
+ const message = flags["dry-run"]
414
+ ? `Dry run: would prepare ${reviewRequests.length} package release PR${reviewRequests.length === 1 ? "" : "s"}.`
415
+ : updated
416
+ ? `Prepared ${reviewRequests.length} package release PR${reviewRequests.length === 1 ? "" : "s"}.`
417
+ : "Package release PRs are already up to date.";
418
+ printReviewResults(message, reviewRequests);
419
+ return 0;
420
+ }
220
421
  if (flags["dry-run"]) {
221
- console.log(`Dry run: would prepare release PR branch ${plan.releaseBranchPrefix} for ${plan.nextVersion}`);
422
+ console.log(`Dry run: would prepare release PR branch ${plan.releaseBranchPrefix} for ${formatDryRunReleaseSubject(plan)}`);
222
423
  return 0;
223
424
  }
224
425
  const pr = prepareReleasePr(process.cwd(), { logger: console });
@@ -19,6 +19,7 @@ export declare const configSchema: z.ZodObject<{
19
19
  strict: "strict";
20
20
  }>>;
21
21
  "release-branch": z.ZodOptional<z.ZodString>;
22
+ "separate-release-prs": z.ZodOptional<z.ZodBoolean>;
22
23
  "baseline-file": z.ZodOptional<z.ZodString>;
23
24
  "bootstrap-sha": z.ZodOptional<z.ZodString>;
24
25
  "monorepo-mode": z.ZodOptional<z.ZodEnum<{
@@ -52,6 +53,7 @@ export declare const configSchema: z.ZodObject<{
52
53
  "field-path": z.ZodOptional<z.ZodString>;
53
54
  pattern: z.ZodOptional<z.ZodString>;
54
55
  replacement: z.ZodOptional<z.ZodString>;
56
+ "expected-matches": z.ZodOptional<z.ZodNumber>;
55
57
  }, z.core.$strip>>>;
56
58
  follows: z.ZodOptional<z.ZodArray<z.ZodString>>;
57
59
  }, z.core.$strict>>>;
@@ -6,6 +6,7 @@ const artifactRuleSchema = z
6
6
  "field-path": z.string().optional(),
7
7
  pattern: z.string().optional(),
8
8
  replacement: z.string().optional(),
9
+ "expected-matches": z.number().int().positive().optional(),
9
10
  })
10
11
  .superRefine((value, ctx) => {
11
12
  const needsFieldPath = value.type === "json" ||
@@ -33,6 +34,13 @@ const artifactRuleSchema = z
33
34
  path: ["replacement"],
34
35
  });
35
36
  }
37
+ if (needsFieldPath && value["expected-matches"] !== undefined) {
38
+ ctx.addIssue({
39
+ code: z.ZodIssueCode.custom,
40
+ message: `${value.type} artifact rules do not support "expected-matches".`,
41
+ path: ["expected-matches"],
42
+ });
43
+ }
36
44
  if (value.type === "regex" && !value.pattern) {
37
45
  ctx.addIssue({
38
46
  code: z.ZodIssueCode.custom,
@@ -75,6 +83,7 @@ export const configSchema = z
75
83
  .enum(["off", "best-effort", "strict"])
76
84
  .optional(),
77
85
  "release-branch": z.string().optional(),
86
+ "separate-release-prs": z.boolean().optional(),
78
87
  "baseline-file": z.string().optional(),
79
88
  "bootstrap-sha": z.string().optional(),
80
89
  "monorepo-mode": z.enum(["independent", "fixed"]).optional(),
@@ -90,6 +99,29 @@ export const configSchema = z
90
99
  .strict()
91
100
  .superRefine((value, ctx) => {
92
101
  const packages = value.packages;
102
+ if (value["separate-release-prs"]) {
103
+ if (!packages || Object.keys(packages).length === 0) {
104
+ ctx.addIssue({
105
+ code: z.ZodIssueCode.custom,
106
+ message: '"separate-release-prs" requires a non-empty packages map.',
107
+ path: ["separate-release-prs"],
108
+ });
109
+ }
110
+ if (value["monorepo-mode"] === "fixed") {
111
+ ctx.addIssue({
112
+ code: z.ZodIssueCode.custom,
113
+ message: '"separate-release-prs" cannot be combined with monorepo-mode "fixed".',
114
+ path: ["separate-release-prs"],
115
+ });
116
+ }
117
+ if (value["review-mode"] === "direct") {
118
+ ctx.addIssue({
119
+ code: z.ZodIssueCode.custom,
120
+ message: '"separate-release-prs" cannot be combined with review-mode "direct".',
121
+ path: ["separate-release-prs"],
122
+ });
123
+ }
124
+ }
93
125
  if (!packages) {
94
126
  return;
95
127
  }
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { verifyProject } from "./release/verify-project.js";
3
3
  export { findPluginsByCapability, pluginHasCapability, } from "./scm/capabilities.js";
4
4
  export { getScmClient } from "./scm/client.js";
5
5
  export { createGitHubPlugin } from "./scm/github-plugin.js";
6
- export type { ScmClient, ScmClientContext, ScmProvider, ScmReleaseMetadataInput, ScmReleaseMetadataResult, ScmReleaseReferenceCommentsInput, ScmReleaseReferenceCommentsResult, ScmReviewRequestInput, ScmReviewRequestResult, } from "./scm/types.js";
6
+ export type { ScmClient, ScmClientContext, ScmListReviewRequestsInput, ScmProvider, ScmReleaseMetadataInput, ScmReleaseMetadataResult, ScmReleaseReferenceCommentsInput, ScmReleaseReferenceCommentsResult, ScmReviewRequestInput, ScmReviewRequestResult, ScmReviewRequestSummary, } from "./scm/types.js";
7
7
  export { resolveVersionStrategy } from "./strategy/resolve.js";
8
8
  export type { VersionaryArtifactRule, VersionaryConfig, VersionaryPackage, } from "./types/config.js";
9
- export type { VersionaryPluginCapability, VersionaryPluginContext, VersionaryPluginRuntime, VersionaryScmReleaseMetadataInput, VersionaryScmReleaseMetadataResult, VersionaryScmReleaseReferenceCommentsInput, VersionaryScmReleaseReferenceCommentsResult, VersionaryScmReviewRequestInput, VersionaryScmReviewRequestResult, } from "./types/plugins.js";
9
+ export type { VersionaryPluginCapability, VersionaryPluginContext, VersionaryPluginRuntime, VersionaryScmListReviewRequestsInput, VersionaryScmReleaseMetadataInput, VersionaryScmReleaseMetadataResult, VersionaryScmReleaseReferenceCommentsInput, VersionaryScmReleaseReferenceCommentsResult, VersionaryScmReviewRequestInput, VersionaryScmReviewRequestResult, VersionaryScmReviewRequestSummary, } from "./types/plugins.js";
@@ -183,40 +183,39 @@ function renderReplacementTemplate(template, version) {
183
183
  return value;
184
184
  });
185
185
  }
186
- function applyRegexRule(content, pattern, version, replacementTemplate) {
186
+ function applyRegexRule(content, pattern, version, expectedMatches, replacementTemplate) {
187
187
  const regex = parseRegexPattern(pattern);
188
188
  const matchFlags = regex.flags.includes("g")
189
189
  ? regex.flags
190
190
  : `${regex.flags}g`;
191
191
  const globalRegex = new RegExp(regex.source, matchFlags.includes("d") ? matchFlags : `${matchFlags}d`);
192
192
  const matches = [...content.matchAll(globalRegex)];
193
- if (matches.length !== 1) {
194
- throw new Error(`Regex pattern must match exactly one occurrence; matched ${matches.length}.`);
193
+ if (matches.length !== expectedMatches) {
194
+ throw new Error(`Regex pattern expected ${expectedMatches} ${expectedMatches === 1 ? "match" : "matches"}; matched ${matches.length}.`);
195
+ }
196
+ const renderedReplacement = replacementTemplate === undefined
197
+ ? undefined
198
+ : renderReplacementTemplate(replacementTemplate, version);
199
+ let updated = content;
200
+ for (let index = matches.length - 1; index >= 0; index -= 1) {
201
+ const match = matches[index];
202
+ if (!match || match.index === undefined) {
203
+ throw new Error("Regex match did not include an index.");
204
+ }
205
+ // Apply edits from right to left so every match keeps its original indices.
206
+ if (renderedReplacement !== undefined) {
207
+ updated = `${updated.slice(0, match.index)}${renderedReplacement}${updated.slice(match.index + match[0].length)}`;
208
+ continue;
209
+ }
210
+ // Splice by group indices so literal `$` sequences and repeated group content
211
+ // are handled correctly.
212
+ const [start, end] = match.indices?.[1] ?? [
213
+ match.index,
214
+ match.index + match[0].length,
215
+ ];
216
+ updated = `${updated.slice(0, start)}${version}${updated.slice(end)}`;
195
217
  }
196
- const match = matches[0];
197
- if (!match) {
198
- throw new Error("Regex match result missing.");
199
- }
200
- const start = match.index;
201
- if (start === undefined) {
202
- throw new Error("Regex match did not include an index.");
203
- }
204
- const full = match[0];
205
- // With a replacement template, render it and replace the entire match.
206
- if (replacementTemplate !== undefined) {
207
- const rendered = renderReplacementTemplate(replacementTemplate, version);
208
- return `${content.slice(0, start)}${rendered}${content.slice(start + full.length)}`;
209
- }
210
- // Without a template: substitute the full version into the first capture
211
- // group, leaving the rest of the match intact. Splice by group indices rather than
212
- // `String.replace` so literal `$` sequences and repeated group content are
213
- // handled correctly.
214
- const groupIndices = match.indices?.[1];
215
- if (!groupIndices) {
216
- return `${content.slice(0, start)}${version}${content.slice(start + full.length)}`;
217
- }
218
- const [groupStart, groupEnd] = groupIndices;
219
- return `${content.slice(0, groupStart)}${version}${content.slice(groupEnd)}`;
218
+ return updated;
220
219
  }
221
220
  function applyTomlRulePreservingFormatting(content, fieldPath, version) {
222
221
  const simplePath = fieldPath.match(/^\$\.([A-Za-z0-9_-]+)$/u);
@@ -403,7 +402,7 @@ function applyArtifactRuleToContent(content, rule, version) {
403
402
  if (!rule.pattern) {
404
403
  throw new Error('regex artifact rules require "pattern".');
405
404
  }
406
- return applyRegexRule(content, rule.pattern, version, rule.replacement);
405
+ return applyRegexRule(content, rule.pattern, version, rule["expected-matches"] ?? 1, rule.replacement);
407
406
  }
408
407
  if (rule.type === "json") {
409
408
  const parsed = JSON.parse(content);
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  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
- import { resolvePackageDependencies } from "./plan.js";
5
+ import { resolvePackageDependencies, resolveRootReleaseView, } from "./plan.js";
6
6
  import { isVersionHeading } from "./semver.js";
7
7
  const REVIEW_REQUEST_FOOTER = "---\n\nThis PR was generated by [Versionary](https://github.com/jolars/versionary).";
8
8
  function formatDate() {
@@ -192,24 +192,27 @@ export function renderReviewRequestFooter() {
192
192
  return REVIEW_REQUEST_FOOTER;
193
193
  }
194
194
  export function renderReleasePlanChangelog(plan, options = {}) {
195
- if (!plan.nextVersion) {
195
+ // Root's changelog describes root's own release, so it must be driven by the
196
+ // root package's version and commit set rather than the plan-level aggregate.
197
+ const root = resolveRootReleaseView(plan);
198
+ if (!root.nextVersion) {
196
199
  return "";
197
200
  }
198
201
  const dedupedCommits = [
199
- ...new Map(plan.commits.map((commit) => [commit.hash, commit])).values(),
202
+ ...new Map(root.commits.map((commit) => [commit.hash, commit])).values(),
200
203
  ];
201
204
  if (plan.changelogFormat === "r-news") {
202
205
  return renderRNewsReleaseNotes({
203
206
  packageName: plan.packageName,
204
- nextVersion: plan.nextVersion,
207
+ nextVersion: root.nextVersion,
205
208
  commits: dedupedCommits,
206
209
  cwd: process.cwd(),
207
210
  highlights: options.highlights,
208
211
  });
209
212
  }
210
213
  return renderReleaseNotesSection({
211
- currentVersion: plan.currentVersion,
212
- nextVersion: plan.nextVersion,
214
+ currentVersion: root.currentVersion,
215
+ nextVersion: root.nextVersion,
213
216
  commits: dedupedCommits,
214
217
  cwd: options.cwd ?? process.cwd(),
215
218
  dependencies: resolvePackageDependencies(plan, "."),
@@ -0,0 +1,6 @@
1
+ import type { ReleasePlan } from "./plan.js";
2
+ export type ReleaseCohort = string[];
3
+ export declare function buildInitialReleaseCohorts(plan: ReleasePlan): ReleaseCohort[];
4
+ export declare function stabilizeReleaseCohorts(initial: readonly ReleaseCohort[], changedFilesFor: (packagePaths: ReleaseCohort) => readonly string[]): ReleaseCohort[];
5
+ export declare function resolveSeparateReleaseBranchPrefix(prefix: string): string;
6
+ export declare function resolveSeparateReleaseBranch(prefix: string, releaseName: string, packagePath: string): string;