versionary 1.0.1 → 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,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);
@@ -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;
@@ -0,0 +1,125 @@
1
+ import { createHash } from "node:crypto";
2
+ function normalizedCohorts(cohorts) {
3
+ return cohorts
4
+ .map((cohort) => [...new Set(cohort)].sort((a, b) => a.localeCompare(b)))
5
+ .filter((cohort) => cohort.length > 0)
6
+ .sort((a, b) => (a[0] ?? "").localeCompare(b[0] ?? ""));
7
+ }
8
+ export function buildInitialReleaseCohorts(plan) {
9
+ const releasing = (plan.packages ?? [])
10
+ .filter((pkg) => pkg.nextVersion)
11
+ .map((pkg) => pkg.path)
12
+ .sort((a, b) => a.localeCompare(b));
13
+ const releasingSet = new Set(releasing);
14
+ const parent = new Map(releasing.map((packagePath) => [packagePath, packagePath]));
15
+ const find = (packagePath) => {
16
+ const current = parent.get(packagePath) ?? packagePath;
17
+ if (current === packagePath) {
18
+ return current;
19
+ }
20
+ const root = find(current);
21
+ parent.set(packagePath, root);
22
+ return root;
23
+ };
24
+ const union = (left, right) => {
25
+ const leftRoot = find(left);
26
+ const rightRoot = find(right);
27
+ if (leftRoot === rightRoot) {
28
+ return;
29
+ }
30
+ const [first, second] = [leftRoot, rightRoot].sort((a, b) => a.localeCompare(b));
31
+ if (first && second) {
32
+ parent.set(second, first);
33
+ }
34
+ };
35
+ for (const pkg of plan.packages ?? []) {
36
+ if (!pkg.nextVersion || !releasingSet.has(pkg.path)) {
37
+ continue;
38
+ }
39
+ for (const sourcePath of pkg.dependencySourcePaths ?? []) {
40
+ if (releasingSet.has(sourcePath)) {
41
+ union(pkg.path, sourcePath);
42
+ }
43
+ }
44
+ }
45
+ const grouped = new Map();
46
+ for (const packagePath of releasing) {
47
+ const root = find(packagePath);
48
+ grouped.set(root, [...(grouped.get(root) ?? []), packagePath]);
49
+ }
50
+ return normalizedCohorts([...grouped.values()]);
51
+ }
52
+ function overlaps(left, right) {
53
+ for (const value of left) {
54
+ if (right.has(value)) {
55
+ return true;
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+ export function stabilizeReleaseCohorts(initial, changedFilesFor) {
61
+ let cohorts = normalizedCohorts(initial);
62
+ while (cohorts.length > 1) {
63
+ const footprints = cohorts.map((cohort) => new Set(changedFilesFor(cohort).map((file) => file.replaceAll("\\", "/"))));
64
+ const parent = cohorts.map((_, index) => index);
65
+ const find = (index) => {
66
+ const current = parent[index] ?? index;
67
+ if (current === index) {
68
+ return current;
69
+ }
70
+ const root = find(current);
71
+ parent[index] = root;
72
+ return root;
73
+ };
74
+ const union = (left, right) => {
75
+ const leftRoot = find(left);
76
+ const rightRoot = find(right);
77
+ if (leftRoot !== rightRoot) {
78
+ parent[Math.max(leftRoot, rightRoot)] = Math.min(leftRoot, rightRoot);
79
+ }
80
+ };
81
+ for (let left = 0; left < cohorts.length; left += 1) {
82
+ for (let right = left + 1; right < cohorts.length; right += 1) {
83
+ if (overlaps(footprints[left] ?? new Set(), footprints[right] ?? new Set())) {
84
+ union(left, right);
85
+ }
86
+ }
87
+ }
88
+ const merged = new Map();
89
+ for (let index = 0; index < cohorts.length; index += 1) {
90
+ const root = find(index);
91
+ merged.set(root, [
92
+ ...(merged.get(root) ?? []),
93
+ ...(cohorts[index] ?? []),
94
+ ]);
95
+ }
96
+ const next = normalizedCohorts([...merged.values()]);
97
+ if (next.length === cohorts.length) {
98
+ return cohorts;
99
+ }
100
+ cohorts = next;
101
+ }
102
+ return cohorts;
103
+ }
104
+ // The prefix shared by every separate release branch. It is the configured
105
+ // release branch itself, so listing on it also matches the legacy single
106
+ // release branch left behind by repos migrating from the combined PR flow.
107
+ export function resolveSeparateReleaseBranchPrefix(prefix) {
108
+ return prefix.replace(/\/+$/gu, "");
109
+ }
110
+ export function resolveSeparateReleaseBranch(prefix, releaseName, packagePath) {
111
+ const normalizedPrefix = resolveSeparateReleaseBranchPrefix(prefix);
112
+ const slug = releaseName
113
+ .trim()
114
+ .replace(/^@/u, "")
115
+ .replace(/[^A-Za-z0-9._-]+/gu, "-")
116
+ .replace(/^-+|-+$/gu, "") || "package";
117
+ const digest = createHash("sha256")
118
+ .update(packagePath)
119
+ .digest("hex")
120
+ .slice(0, 12);
121
+ // A sibling of the legacy release branch, not a child of it: git cannot hold
122
+ // both `refs/heads/<prefix>` and `refs/heads/<prefix>/<name>`, and repos
123
+ // migrating from the combined PR flow still have the former on the remote.
124
+ return `${normalizedPrefix}-${slug}-${digest}`;
125
+ }