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.
@@ -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
+ }
@@ -34,6 +34,28 @@ export declare function getChangelogDefaults(config: {
34
34
  changelogFormat: VersionaryChangelogFormat;
35
35
  };
36
36
  export declare function createReleasePlan(cwd?: string): ReleasePlan;
37
+ /**
38
+ * The root package's own release, as distinct from the plan-level aggregate.
39
+ *
40
+ * `plan.releaseType`/`plan.nextVersion`/`plan.commits` describe the repository
41
+ * as a whole: the aggregate release type folds in every package's commits and
42
+ * is then applied to root's version number. That answers "is anything
43
+ * releasing, and how large is the biggest change anywhere" — not "what is root
44
+ * releasing". A sibling's `feat` therefore lifts the aggregate to a minor even
45
+ * when root's own `exclude-paths` drop that commit, so anything that names or
46
+ * describes root's release must go through here instead. Using the aggregate
47
+ * would report a version no version file carries and list commits root
48
+ * deliberately excluded.
49
+ *
50
+ * Falls back to the aggregate when the plan has no explicit root package: the
51
+ * top-level changelog is then a repository-wide summary with no package of its
52
+ * own to describe.
53
+ */
54
+ export declare function resolveRootReleaseView(plan: ReleasePlan): {
55
+ currentVersion: string;
56
+ nextVersion: string | null;
57
+ commits: ParsedCommit[];
58
+ };
37
59
  export declare function resolvePackageDependencies(plan: ReleasePlan, packagePath: string): Array<{
38
60
  name: string;
39
61
  version: string;
@@ -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,
@@ -480,6 +484,38 @@ export function createReleasePlan(cwd = process.cwd()) {
480
484
  packages: visiblePackages.map(({ implicitRoot: _implicitRoot, ...pkgPlan }) => pkgPlan),
481
485
  };
482
486
  }
487
+ /**
488
+ * The root package's own release, as distinct from the plan-level aggregate.
489
+ *
490
+ * `plan.releaseType`/`plan.nextVersion`/`plan.commits` describe the repository
491
+ * as a whole: the aggregate release type folds in every package's commits and
492
+ * is then applied to root's version number. That answers "is anything
493
+ * releasing, and how large is the biggest change anywhere" — not "what is root
494
+ * releasing". A sibling's `feat` therefore lifts the aggregate to a minor even
495
+ * when root's own `exclude-paths` drop that commit, so anything that names or
496
+ * describes root's release must go through here instead. Using the aggregate
497
+ * would report a version no version file carries and list commits root
498
+ * deliberately excluded.
499
+ *
500
+ * Falls back to the aggregate when the plan has no explicit root package: the
501
+ * top-level changelog is then a repository-wide summary with no package of its
502
+ * own to describe.
503
+ */
504
+ export function resolveRootReleaseView(plan) {
505
+ const rootPackage = plan.packages?.find((pkg) => pkg.path === ".");
506
+ if (!rootPackage) {
507
+ return {
508
+ currentVersion: plan.currentVersion,
509
+ nextVersion: plan.nextVersion,
510
+ commits: plan.commits,
511
+ };
512
+ }
513
+ return {
514
+ currentVersion: rootPackage.currentVersion,
515
+ nextVersion: rootPackage.nextVersion,
516
+ commits: rootPackage.commits,
517
+ };
518
+ }
483
519
  export function resolvePackageDependencies(plan, packagePath) {
484
520
  const target = plan.packages?.find((pkg) => pkg.path === packagePath);
485
521
  if (!target) {
@@ -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<{