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.
@@ -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
+ }
@@ -259,6 +259,50 @@ export function createReleasePlan(cwd = process.cwd()) {
259
259
  }
260
260
  return reachable;
261
261
  };
262
+ const withReleasingDependencies = (plans) => {
263
+ const releasingPaths = new Set(plans
264
+ .filter((pkgPlan) => pkgPlan.nextVersion)
265
+ .map((pkgPlan) => pkgPlan.path));
266
+ for (const pkgPlan of plans) {
267
+ const strategyContext = strategyContextByPath.get(pkgPlan.path);
268
+ if (strategyContext) {
269
+ strategyContext.nextVersion = pkgPlan.nextVersion;
270
+ }
271
+ }
272
+ const dependencySourcePathsByPackage = new Map();
273
+ for (const sourcePackage of plans) {
274
+ if (!sourcePackage.nextVersion) {
275
+ continue;
276
+ }
277
+ for (const impactedPath of findDependents(sourcePackage.path)) {
278
+ if (impactedPath === sourcePackage.path) {
279
+ continue;
280
+ }
281
+ const existing = dependencySourcePathsByPackage.get(impactedPath);
282
+ if (existing) {
283
+ existing.add(sourcePackage.path);
284
+ }
285
+ else {
286
+ dependencySourcePathsByPackage.set(impactedPath, new Set([sourcePackage.path]));
287
+ }
288
+ }
289
+ }
290
+ return plans.map((pkgPlan) => {
291
+ const dependencySourcePaths = [
292
+ ...(dependencySourcePathsByPackage.get(pkgPlan.path) ??
293
+ new Set()),
294
+ ]
295
+ .filter((sourcePath) => releasingPaths.has(sourcePath))
296
+ .sort((a, b) => a.localeCompare(b));
297
+ if (dependencySourcePaths.length === 0) {
298
+ return pkgPlan;
299
+ }
300
+ return {
301
+ ...pkgPlan,
302
+ dependencySourcePaths,
303
+ };
304
+ });
305
+ };
262
306
  // Both rules below can enable each other: forcing a bump creates a dependent
263
307
  // whose requirement must be rewritten, and rewriting a dependent can in turn
264
308
  // expose a stale dependency a further level up. Iterating to a fixpoint
@@ -302,51 +346,9 @@ export function createReleasePlan(cwd = process.cwd()) {
302
346
  break;
303
347
  }
304
348
  }
305
- const packageNextVersionByPath = {};
306
- for (const pkgPlan of workingPlans) {
307
- if (pkgPlan.nextVersion) {
308
- packageNextVersionByPath[pkgPlan.path] = pkgPlan.nextVersion;
309
- }
310
- }
311
- const dependencySourcePathsByPackage = new Map();
312
- const addDependencySourcePath = (targetPath, sourcePath) => {
313
- if (targetPath === sourcePath) {
314
- return;
315
- }
316
- const existing = dependencySourcePathsByPackage.get(targetPath);
317
- if (existing) {
318
- existing.add(sourcePath);
319
- return;
320
- }
321
- dependencySourcePathsByPackage.set(targetPath, new Set([sourcePath]));
322
- };
323
349
  // Attribute each dependent's requirement rewrite to the specific sources
324
350
  // driving it, using the settled version set so chained bumps are credited.
325
- for (const strategyGroup of strategyPackagesByName.values()) {
326
- for (const sourcePackage of strategyGroup.packages) {
327
- if (!sourcePackage.nextVersion) {
328
- continue;
329
- }
330
- for (const impactedPath of findDependents(sourcePackage.packagePath)) {
331
- addDependencySourcePath(impactedPath, sourcePackage.packagePath);
332
- }
333
- }
334
- }
335
- const propagatedPackages = workingPlans.map((pkgPlan) => {
336
- const dependencySourcePaths = [
337
- ...(dependencySourcePathsByPackage.get(pkgPlan.path) ??
338
- new Set()),
339
- ]
340
- .filter((sourcePath) => Boolean(packageNextVersionByPath[sourcePath]))
341
- .sort((a, b) => a.localeCompare(b));
342
- if (dependencySourcePaths.length === 0) {
343
- return pkgPlan;
344
- }
345
- return {
346
- ...pkgPlan,
347
- dependencySourcePaths,
348
- };
349
- });
351
+ const propagatedPackages = withReleasingDependencies(workingPlans);
350
352
  const followsByPath = new Map();
351
353
  for (const [packagePath, packageConfig] of Object.entries(loaded.config.packages ?? {})) {
352
354
  const follows = packageConfig.follows ?? [];
@@ -429,11 +431,13 @@ export function createReleasePlan(cwd = process.cwd()) {
429
431
  : analyzedFixedType
430
432
  ? bumpVersion(fixedBaseVersion, analyzedFixedType, { allowStableMajor })
431
433
  : null;
432
- const adjusted = adjustedPackages.map((pkgPlan) => ({
434
+ // Fixed mode can promote unchanged dependencies into the release, so the
435
+ // final shared version set must drive dependency attribution.
436
+ const adjusted = withReleasingDependencies(adjustedPackages.map((pkgPlan) => ({
433
437
  ...pkgPlan,
434
438
  releaseType: fixedType,
435
439
  nextVersion: fixedNextVersion,
436
- }));
440
+ })));
437
441
  return {
438
442
  mode: "simple",
439
443
  releaseType: fixedType,
@@ -2,6 +2,7 @@ import type { ParsedCommit } from "../git/commits.js";
2
2
  import type { VersionaryChangelogFormat, VersionaryConfig } from "../types/config.js";
3
3
  import type { VersionaryPluginContext } from "../types/plugins.js";
4
4
  import { type ReleasePlan } from "./plan.js";
5
+ import { type ReleaseTargetState } from "./state.js";
5
6
  /**
6
7
  * Read the manual-notes ("Unreleased") prose from the top of a changelog file.
7
8
  * Returns an empty string when the file is absent or has no notes block.
@@ -20,6 +21,31 @@ export declare function splitSafeDirtyFiles(files: string[]): {
20
21
  ignored: string[];
21
22
  blocking: string[];
22
23
  };
24
+ export interface PendingReleasePrResult {
25
+ branch: string;
26
+ title: string;
27
+ updated: boolean;
28
+ targets: ReleaseTargetState[];
29
+ body: string;
30
+ }
31
+ export declare function renderPendingReleaseReviewRequestBody(targets: ReleaseTargetState[]): string;
32
+ /**
33
+ * Recreate the release marker on the corrected base without advancing an
34
+ * untagged version. The empty commit is intentional: the version and
35
+ * changelog changes were already reviewed in the original release PR.
36
+ */
37
+ export declare function preparePendingReleasePr(cwd?: string, options?: {
38
+ logger?: VersionaryPluginContext["logger"];
39
+ }): PendingReleasePrResult;
40
+ /**
41
+ * Recreate each unpublished package cohort on its own corrected-base branch.
42
+ * A temporary worktree keeps the caller on the trunk commit that triggered
43
+ * recovery while still leaving local branch refs available for pushing.
44
+ */
45
+ export declare function preparePendingSeparateReleasePrs(cwd?: string, options?: {
46
+ logger?: VersionaryPluginContext["logger"];
47
+ "dry-run"?: boolean;
48
+ }): SeparateReviewCandidate[];
23
49
  export declare function prepareReleasePr(cwd?: string, options?: {
24
50
  logger?: VersionaryPluginContext["logger"];
25
51
  }): {
@@ -32,11 +58,54 @@ export declare function prepareReleasePr(cwd?: string, options?: {
32
58
  updated: boolean;
33
59
  highlights: string;
34
60
  };
61
+ export interface PreparedSeparateReleasePr {
62
+ branch: string;
63
+ title: string;
64
+ version: string;
65
+ previousVersion: string;
66
+ commits: ParsedCommit[];
67
+ plan: ReleasePlan;
68
+ updated: boolean;
69
+ highlights: string;
70
+ packagePaths: string[];
71
+ targets: ReleaseTargetState[];
72
+ body: string;
73
+ }
74
+ export interface SeparateReviewCandidate {
75
+ branch: string;
76
+ title: string;
77
+ updated: boolean;
78
+ packagePaths: string[];
79
+ targets: ReleaseTargetState[];
80
+ body: string;
81
+ }
82
+ /**
83
+ * Build every independently mergeable release branch from the same immutable
84
+ * base without moving or modifying the caller's worktree.
85
+ */
86
+ export declare function prepareSeparateReleasePrs(cwd?: string, options?: {
87
+ logger?: VersionaryPluginContext["logger"];
88
+ "dry-run"?: boolean;
89
+ }): PreparedSeparateReleasePr[];
35
90
  export declare function renderSimpleReviewRequestBody(version: string, previousVersion: string, commits: ParsedCommit[], plan?: ReleasePlan | null, cwd?: string, highlights?: string, loadedConfig?: VersionaryConfig): string;
36
91
  export declare function openOrUpdateReviewRequest(cwd: string, branch: string, title: string, version: string, previousVersion: string, commits: ParsedCommit[], plan?: ReleasePlan | null, options?: {
37
92
  logger?: VersionaryPluginContext["logger"];
38
93
  highlights?: string;
94
+ body?: string;
39
95
  }): Promise<string>;
96
+ export interface ReviewRequestRunResult {
97
+ packagePaths: string[];
98
+ branch: string;
99
+ title: string;
100
+ reviewUrl?: string;
101
+ status: "prepared" | "up-to-date" | "dry-run" | "recovered";
102
+ targets: ReleaseTargetState[];
103
+ }
104
+ export declare function reconcileSeparateReviewRequests(cwd: string, prepared: SeparateReviewCandidate[], options?: {
105
+ logger?: VersionaryPluginContext["logger"];
106
+ "dry-run"?: boolean;
107
+ recovered?: boolean;
108
+ }): Promise<ReviewRequestRunResult[]>;
40
109
  export declare function closeStaleReviewRequestIfExists(cwd?: string, options?: {
41
110
  logger?: VersionaryPluginContext["logger"];
42
111
  }): Promise<{