versionary 1.0.1 → 1.2.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.
@@ -48,6 +48,24 @@ function getRemoteRefSha(cwd, ref) {
48
48
  return null;
49
49
  }
50
50
  }
51
+ function hasVersionaryReleaseMarker(cwd, sha) {
52
+ try {
53
+ const message = runGit(cwd, ["show", "-s", "--format=%B", sha]);
54
+ return /^Versionary-Release:\s*true$/imu.test(message);
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ function isAncestor(cwd, ancestor, descendant) {
61
+ try {
62
+ runGit(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]);
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
51
69
  function setOutput(name, value) {
52
70
  const outputPath = process.env.GITHUB_OUTPUT;
53
71
  if (!outputPath) {
@@ -98,24 +116,31 @@ function main() {
98
116
  hasOriginRemote(cwd)) {
99
117
  const remoteSha = getRemoteRefSha(cwd, ref);
100
118
  if (remoteSha && remoteSha !== sha) {
101
- const staleMessage = `Skipping stale push run for ${sha.slice(0, 7)}; ` +
102
- `${ref} now points to ${remoteSha.slice(0, 7)}.`;
103
- const stalePayload = {
104
- action: "stale-run-skipped",
105
- message: staleMessage,
106
- releaseCreated: false,
107
- tagNames: [],
108
- };
109
- process.stdout.write(`${JSON.stringify(stalePayload)}\n`);
110
- setOutput("action", stalePayload.action);
111
- setOutput("message", stalePayload.message);
112
- setOutput("release_created", "false");
113
- setOutput("tag_name", "");
114
- setOutput("tag_names", "[]");
115
- setOutput("review_url", "");
116
- setOutput("branch", "");
117
- setOutput("title", "");
118
- return;
119
+ // Descendant pushes must not turn an otherwise valid release into a
120
+ // recovery cycle.
121
+ const releaseCanPublish = hasVersionaryReleaseMarker(cwd, sha) && isAncestor(cwd, sha, remoteSha);
122
+ if (!releaseCanPublish) {
123
+ const staleMessage = `Skipping stale push run for ${sha.slice(0, 7)}; ` +
124
+ `${ref} now points to ${remoteSha.slice(0, 7)}.`;
125
+ const stalePayload = {
126
+ action: "stale-run-skipped",
127
+ message: staleMessage,
128
+ releaseCreated: false,
129
+ tagNames: [],
130
+ };
131
+ process.stdout.write(`${JSON.stringify(stalePayload)}\n`);
132
+ setOutput("action", stalePayload.action);
133
+ setOutput("message", stalePayload.message);
134
+ setOutput("release_created", "false");
135
+ setOutput("tag_name", "");
136
+ setOutput("tag_names", "[]");
137
+ setOutput("release_targets", "[]");
138
+ setOutput("review_url", "");
139
+ setOutput("review_requests", "[]");
140
+ setOutput("branch", "");
141
+ setOutput("title", "");
142
+ return;
143
+ }
119
144
  }
120
145
  }
121
146
  const raw = execFileSync("npx", ["--yes", `versionary@${versionaryVersion}`, "run", "--json"], {
@@ -148,7 +173,9 @@ function main() {
148
173
  setOutput("release_created", releaseCreated);
149
174
  setOutput("tag_name", firstTag);
150
175
  setOutput("tag_names", JSON.stringify(tagNames));
176
+ setOutput("release_targets", JSON.stringify(Array.isArray(payload.releaseTargets) ? payload.releaseTargets : []));
151
177
  setOutput("review_url", payload.reviewUrl ?? "");
178
+ setOutput("review_requests", JSON.stringify(Array.isArray(payload.reviewRequests) ? payload.reviewRequests : []));
152
179
  setOutput("branch", payload.branch ?? "");
153
180
  setOutput("title", payload.title ?? "");
154
181
  }
package/dist/cli/index.js CHANGED
@@ -1,9 +1,11 @@
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";
8
10
  /**
9
11
  * What a dry run would actually release. `plan.nextVersion` is the repository
@@ -53,6 +55,140 @@ function parseFlags(args) {
53
55
  function emitJson(payload) {
54
56
  process.stdout.write(`${JSON.stringify(payload)}\n`);
55
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
+ }
56
192
  async function main() {
57
193
  const [, , command, ...args] = process.argv;
58
194
  const flags = parseFlags(args);
@@ -62,51 +198,69 @@ async function main() {
62
198
  encoding: "utf8",
63
199
  stdio: ["ignore", "pipe", "ignore"],
64
200
  }).trim();
65
- if (isReleaseCommitMessage(commitMessage)) {
66
- if (flags["dry-run"] && !flags.json) {
67
- const release = await runReleaseDetailed(process.cwd(), {
68
- logger,
69
- "dry-run": true,
70
- });
71
- if (release.action === "release-dry-run") {
72
- console.log(release.message);
73
- return 0;
74
- }
75
- }
76
- if (flags.json) {
77
- const release = await runReleaseDetailed(process.cwd(), {
78
- logger,
79
- "dry-run": flags["dry-run"],
80
- });
81
- 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) {
82
209
  emitJson({
83
210
  action: "release-skipped",
84
211
  message: release.reason,
85
212
  releaseCreated: false,
86
213
  tagNames: [],
87
214
  });
88
- return 0;
89
215
  }
90
- 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) {
91
223
  emitJson({
92
224
  action: "release-dry-run",
93
225
  message: release.message,
94
226
  releaseCreated: false,
95
227
  tagNames: release.targets.map((target) => target.tag),
228
+ releaseTargets: release.releaseTargets,
96
229
  targets: release.targets,
97
230
  });
98
- return 0;
99
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];
100
243
  emitJson({
101
244
  action: "release-published",
102
245
  message: release.message,
103
246
  releaseCreated: release.releases.length > 0,
104
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,
105
253
  });
106
- return 0;
107
254
  }
108
- const message = await runRelease(process.cwd());
109
- 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)) {
110
264
  return 0;
111
265
  }
112
266
  const plan = createReleasePlan();
@@ -127,6 +281,26 @@ async function main() {
127
281
  console.log(message);
128
282
  return 0;
129
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
+ }
130
304
  if (flags["dry-run"]) {
131
305
  const dryRunMessage = `Dry run: would prepare release PR branch ${plan.releaseBranchPrefix} for ${formatDryRunReleaseSubject(plan)}`;
132
306
  if (flags.json) {
@@ -221,6 +395,9 @@ async function main() {
221
395
  return 0;
222
396
  }
223
397
  if (command === "pr") {
398
+ if (await recoverPendingReleaseIfNeeded({ ...flags, json: false }, console)) {
399
+ return 0;
400
+ }
224
401
  const plan = createReleasePlan();
225
402
  if (!plan.nextVersion) {
226
403
  if (!flags["dry-run"]) {
@@ -231,6 +408,16 @@ async function main() {
231
408
  console.log("No releasable commits found. Nothing to do.");
232
409
  return 0;
233
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
+ }
234
421
  if (flags["dry-run"]) {
235
422
  console.log(`Dry run: would prepare release PR branch ${plan.releaseBranchPrefix} for ${formatDryRunReleaseSubject(plan)}`);
236
423
  return 0;
@@ -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);
@@ -291,7 +291,6 @@ export function prependChangelog(cwd, changelogFile, section, format = "markdown
291
291
  export function renderRNewsReleaseNotes(input) {
292
292
  const repoUrl = resolveRepositoryWebBaseUrl(input.cwd ?? process.cwd());
293
293
  const grouped = groupCommitLines(input.commits, repoUrl);
294
- const normalizedVersion = input.nextVersion.replace(/\.\d+$/u, "");
295
294
  const sections = [];
296
295
  const trimmedHighlights = input.highlights?.trim() ?? "";
297
296
  if (trimmedHighlights.length > 0) {
@@ -315,7 +314,7 @@ export function renderRNewsReleaseNotes(input) {
315
314
  if (needsOtherChangesFallback(grouped, trimmedHighlights.length > 0, 0)) {
316
315
  sections.push("## Other changes", "", ...grouped.other, "");
317
316
  }
318
- return [`# ${input.packageName} ${normalizedVersion}`, "", ...sections]
317
+ return [`# ${input.packageName} ${input.nextVersion}`, "", ...sections]
319
318
  .join("\n")
320
319
  .trimEnd();
321
320
  }
@@ -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;