versionary 0.32.0 → 1.0.1

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.
@@ -4,6 +4,7 @@ import { loadConfig } from "../config/load-config.js";
4
4
  import { analyzeParsedCommits, applyRevertSuppression, getParsedCommitsForPath, getParsedCommitsSinceLastTag, } from "../git/commits.js";
5
5
  import { resolvePackageStrategyContext } from "../strategy/package-context.js";
6
6
  import { resolveVersionStrategy } from "../strategy/resolve.js";
7
+ import { releaseTypeBetween, resolveReleaseAsOverride } from "./release-as.js";
7
8
  import { bumpVersion, maxReleaseType } from "./semver.js";
8
9
  import { readBaselineSha, readReleaseTargets } from "./state.js";
9
10
  function getMode(configMode) {
@@ -78,19 +79,34 @@ export function createReleasePlan(cwd = process.cwd()) {
78
79
  }
79
80
  const effectiveCommits = applyRevertSuppression(parsedCommits);
80
81
  const commits = effectiveCommits;
81
- const releaseType = analyzeParsedCommits(parsedCommits);
82
- const nextVersion = releaseType
83
- ? bumpVersion(packageCurrentVersion, releaseType, {
84
- allowStableMajor: allowStableMajorForPath(pkg.path),
85
- })
86
- : null;
82
+ const analyzedType = analyzeParsedCommits(parsedCommits);
83
+ const override = resolveReleaseAsOverride(commits, packageCurrentVersion);
84
+ let releaseType;
85
+ let nextVersion;
86
+ let bumpReason;
87
+ if (override) {
88
+ // An explicit `Release-As:` footer forces a release with the requested
89
+ // version, even when the conventional-commit analysis produces no bump.
90
+ nextVersion = override.version;
91
+ releaseType = releaseTypeBetween(packageCurrentVersion, override.version);
92
+ bumpReason = "release-as";
93
+ }
94
+ else {
95
+ releaseType = analyzedType;
96
+ nextVersion = releaseType
97
+ ? bumpVersion(packageCurrentVersion, releaseType, {
98
+ allowStableMajor: allowStableMajorForPath(pkg.path),
99
+ })
100
+ : null;
101
+ bumpReason = nextVersion ? "direct" : undefined;
102
+ }
87
103
  return {
88
104
  path: pkg.path,
89
105
  implicitRoot: pkg.implicitRoot,
90
106
  releaseType,
91
107
  currentVersion: packageCurrentVersion,
92
108
  nextVersion,
93
- bumpReason: nextVersion ? "direct" : undefined,
109
+ bumpReason,
94
110
  commits,
95
111
  parsedCommits,
96
112
  resolvedVersionFile: packageContext.versionFile,
@@ -119,39 +135,179 @@ export function createReleasePlan(cwd = process.cwd()) {
119
135
  ...(implicitRootPlan ? [implicitRootPlan] : []),
120
136
  ].sort((a, b) => a.path.localeCompare(b.path));
121
137
  const packageCurrentVersionByPath = {};
122
- const packageNextVersionByPath = {};
123
138
  const strategyPackagesByName = new Map();
139
+ // Strategy contexts are shared by reference with their group so that forcing
140
+ // a bump during the fixpoint below is immediately visible to the next
141
+ // `propagateDependentPatchImpacts` query.
142
+ const strategyContextByPath = new Map();
143
+ const strategyByPath = new Map();
124
144
  for (const packagePlan of packagePlans) {
125
145
  const packageConfig = loaded.config.packages?.[packagePlan.path] ?? {};
126
146
  const packageContext = resolvePackageStrategyContext(loaded.config, packagePlan.path, packageConfig);
147
+ const strategyContext = {
148
+ packagePath: packagePlan.path,
149
+ versionFile: packagePlan.resolvedVersionFile,
150
+ currentVersion: packagePlan.currentVersion,
151
+ nextVersion: packagePlan.nextVersion,
152
+ };
153
+ strategyContextByPath.set(packagePlan.path, strategyContext);
154
+ strategyByPath.set(packagePlan.path, packageContext.strategy);
127
155
  const existingGroup = strategyPackagesByName.get(packageContext.strategy.name);
128
156
  if (existingGroup) {
129
- existingGroup.packages.push({
130
- packagePath: packagePlan.path,
131
- versionFile: packagePlan.resolvedVersionFile,
132
- currentVersion: packagePlan.currentVersion,
133
- nextVersion: packagePlan.nextVersion,
134
- });
157
+ existingGroup.packages.push(strategyContext);
135
158
  }
136
159
  else {
137
160
  strategyPackagesByName.set(packageContext.strategy.name, {
138
161
  strategy: packageContext.strategy,
139
- packages: [
140
- {
141
- packagePath: packagePlan.path,
142
- versionFile: packagePlan.resolvedVersionFile,
143
- currentVersion: packagePlan.currentVersion,
144
- nextVersion: packagePlan.nextVersion,
145
- },
146
- ],
162
+ packages: [strategyContext],
147
163
  });
148
164
  }
149
165
  packageCurrentVersionByPath[packagePlan.path] = packagePlan.currentVersion;
150
- if (packagePlan.nextVersion) {
151
- packageNextVersionByPath[packagePlan.path] = packagePlan.nextVersion;
166
+ }
167
+ const workingPlans = packagePlans.map((pkgPlan) => ({ ...pkgPlan }));
168
+ const workingPlanByPath = new Map(workingPlans.map((pkgPlan) => [pkgPlan.path, pkgPlan]));
169
+ const forcePatchBump = (target, reason) => {
170
+ const current = packageCurrentVersionByPath[target.path] ?? target.currentVersion;
171
+ target.releaseType = "patch";
172
+ target.nextVersion = bumpVersion(current, "patch", {
173
+ allowStableMajor: allowStableMajorForPath(target.path),
174
+ });
175
+ target.bumpReason = reason;
176
+ const strategyContext = strategyContextByPath.get(target.path);
177
+ if (strategyContext) {
178
+ strategyContext.nextVersion = target.nextVersion;
179
+ }
180
+ };
181
+ /**
182
+ * Packages that record a version requirement on `sourcePath`, found by
183
+ * asking the strategy who would need a requirement rewrite if `sourcePath`
184
+ * alone released. Works whether or not `sourcePath` is currently bumping,
185
+ * so it doubles as a reverse-edge lookup for a package that has not entered
186
+ * the plan yet.
187
+ */
188
+ const findDependents = (sourcePath) => {
189
+ const strategyContext = strategyContextByPath.get(sourcePath);
190
+ // The implicit root can name a version file that does not exist. Nothing
191
+ // can record a requirement on a package that has no manifest, and asking
192
+ // the strategy would make it read one.
193
+ if (!strategyContext ||
194
+ !fs.existsSync(path.join(cwd, strategyContext.versionFile))) {
195
+ return [];
196
+ }
197
+ const strategy = strategyByPath.get(sourcePath);
198
+ const strategyGroup = strategy
199
+ ? strategyPackagesByName.get(strategy.name)
200
+ : undefined;
201
+ if (!strategyGroup?.strategy.propagateDependentPatchImpacts) {
202
+ return [];
203
+ }
204
+ const hypotheticalVersion = strategyContext.nextVersion ??
205
+ bumpVersion(packageCurrentVersionByPath[sourcePath] ??
206
+ strategyContext.currentVersion, "patch", { allowStableMajor: allowStableMajorForPath(sourcePath) });
207
+ return strategyGroup.strategy.propagateDependentPatchImpacts(cwd, strategyGroup.packages.map((pkg) => ({
208
+ ...pkg,
209
+ nextVersion: pkg.packagePath === sourcePath ? hypotheticalVersion : null,
210
+ })));
211
+ };
212
+ const isPublishable = (packagePath) => {
213
+ const strategy = strategyByPath.get(packagePath);
214
+ const strategyContext = strategyContextByPath.get(packagePath);
215
+ if (!strategy?.isPublishable || !strategyContext) {
216
+ return true;
217
+ }
218
+ // Abstaining counts as publishable: the enforcement below should only be
219
+ // skipped on a positive signal that nothing reaches a registry.
220
+ return strategy.isPublishable(cwd, strategyContext) !== false;
221
+ };
222
+ // Forward dependency edges, inverted from the reverse-edge lookup above.
223
+ // Whether one package records a requirement on another is a property of the
224
+ // manifests, not of the versions in flight, so this is computed once.
225
+ const dependenciesByPath = new Map();
226
+ for (const pkgPlan of workingPlans) {
227
+ for (const dependentPath of findDependents(pkgPlan.path)) {
228
+ const existing = dependenciesByPath.get(dependentPath);
229
+ if (existing) {
230
+ existing.add(pkgPlan.path);
231
+ continue;
232
+ }
233
+ dependenciesByPath.set(dependentPath, new Set([pkgPlan.path]));
234
+ }
235
+ }
236
+ /**
237
+ * Every package reachable by following dependency edges down from a
238
+ * publishable package that is currently releasing. Staleness matters
239
+ * transitively: a release that pulls in a stale grandparent resolves against
240
+ * the stale published copy just as readily as a direct dependency does.
241
+ */
242
+ const collectReleasingDependencyClosure = () => {
243
+ const reachable = new Set();
244
+ const queue = workingPlans
245
+ .filter((pkgPlan) => pkgPlan.nextVersion && isPublishable(pkgPlan.path))
246
+ .map((pkgPlan) => pkgPlan.path);
247
+ while (queue.length > 0) {
248
+ const currentPath = queue.shift();
249
+ if (currentPath === undefined) {
250
+ continue;
251
+ }
252
+ for (const dependencyPath of dependenciesByPath.get(currentPath) ?? []) {
253
+ if (reachable.has(dependencyPath)) {
254
+ continue;
255
+ }
256
+ reachable.add(dependencyPath);
257
+ queue.push(dependencyPath);
258
+ }
259
+ }
260
+ return reachable;
261
+ };
262
+ // Both rules below can enable each other: forcing a bump creates a dependent
263
+ // whose requirement must be rewritten, and rewriting a dependent can in turn
264
+ // expose a stale dependency a further level up. Iterating to a fixpoint
265
+ // avoids having to order them, and terminates because a package can only
266
+ // ever move from not-releasing to releasing.
267
+ for (let iteration = 0; iteration <= workingPlans.length; iteration += 1) {
268
+ let changed = false;
269
+ // Rule 1: a package whose recorded requirement on a releasing sibling
270
+ // would change must release too, or the rewritten requirement ships in the
271
+ // release commit without a version to publish it under.
272
+ for (const strategyGroup of strategyPackagesByName.values()) {
273
+ const impacted = strategyGroup.strategy.propagateDependentPatchImpacts?.(cwd, strategyGroup.packages) ?? [];
274
+ for (const impactedPath of impacted) {
275
+ const target = workingPlanByPath.get(impactedPath);
276
+ if (!target || target.nextVersion) {
277
+ continue;
278
+ }
279
+ forcePatchBump(target, "dependency-propagation");
280
+ changed = true;
281
+ }
282
+ }
283
+ // Rule 2: a package that changed since its own last release but produced
284
+ // no bump would leave a releasing dependent pointing at a stale published
285
+ // copy. Commit type is deliberately not consulted — the exposure comes
286
+ // from the change existing, not from how it was labeled.
287
+ const reachableDependencies = collectReleasingDependencyClosure();
288
+ for (const candidate of workingPlans) {
289
+ if (candidate.nextVersion || candidate.commits.length === 0) {
290
+ continue;
291
+ }
292
+ if (!reachableDependencies.has(candidate.path)) {
293
+ continue;
294
+ }
295
+ if (!isPublishable(candidate.path)) {
296
+ continue;
297
+ }
298
+ forcePatchBump(candidate, "stale-dependency");
299
+ changed = true;
300
+ }
301
+ if (!changed) {
302
+ break;
303
+ }
304
+ }
305
+ const packageNextVersionByPath = {};
306
+ for (const pkgPlan of workingPlans) {
307
+ if (pkgPlan.nextVersion) {
308
+ packageNextVersionByPath[pkgPlan.path] = pkgPlan.nextVersion;
152
309
  }
153
310
  }
154
- const impactedPaths = new Set();
155
311
  const dependencySourcePathsByPackage = new Map();
156
312
  const addDependencySourcePath = (targetPath, sourcePath) => {
157
313
  if (targetPath === sourcePath) {
@@ -164,48 +320,30 @@ export function createReleasePlan(cwd = process.cwd()) {
164
320
  }
165
321
  dependencySourcePathsByPackage.set(targetPath, new Set([sourcePath]));
166
322
  };
323
+ // Attribute each dependent's requirement rewrite to the specific sources
324
+ // driving it, using the settled version set so chained bumps are credited.
167
325
  for (const strategyGroup of strategyPackagesByName.values()) {
168
- const impactedByAll = strategyGroup.strategy.propagateDependentPatchImpacts?.(cwd, strategyGroup.packages) ?? [];
169
- for (const pkgPath of impactedByAll) {
170
- impactedPaths.add(pkgPath);
171
- }
172
- const sourcePackages = strategyGroup.packages.filter((pkg) => Boolean(pkg.nextVersion));
173
- for (const sourcePackage of sourcePackages) {
174
- const scopedImpacts = strategyGroup.strategy.propagateDependentPatchImpacts?.(cwd, strategyGroup.packages.map((pkg) => ({
175
- ...pkg,
176
- nextVersion: pkg.packagePath === sourcePackage.packagePath
177
- ? sourcePackage.nextVersion
178
- : null,
179
- }))) ?? [];
180
- for (const impactedPath of scopedImpacts) {
326
+ for (const sourcePackage of strategyGroup.packages) {
327
+ if (!sourcePackage.nextVersion) {
328
+ continue;
329
+ }
330
+ for (const impactedPath of findDependents(sourcePackage.packagePath)) {
181
331
  addDependencySourcePath(impactedPath, sourcePackage.packagePath);
182
332
  }
183
333
  }
184
334
  }
185
- const propagatedPackages = packagePlans.map((pkgPlan) => {
335
+ const propagatedPackages = workingPlans.map((pkgPlan) => {
186
336
  const dependencySourcePaths = [
187
337
  ...(dependencySourcePathsByPackage.get(pkgPlan.path) ??
188
338
  new Set()),
189
339
  ]
190
340
  .filter((sourcePath) => Boolean(packageNextVersionByPath[sourcePath]))
191
341
  .sort((a, b) => a.localeCompare(b));
192
- if (pkgPlan.nextVersion || !impactedPaths.has(pkgPlan.path)) {
193
- if (dependencySourcePaths.length === 0) {
194
- return pkgPlan;
195
- }
196
- return {
197
- ...pkgPlan,
198
- dependencySourcePaths,
199
- };
342
+ if (dependencySourcePaths.length === 0) {
343
+ return pkgPlan;
200
344
  }
201
- const current = packageCurrentVersionByPath[pkgPlan.path] ?? pkgPlan.currentVersion;
202
345
  return {
203
346
  ...pkgPlan,
204
- releaseType: "patch",
205
- nextVersion: bumpVersion(current, "patch", {
206
- allowStableMajor: allowStableMajorForPath(pkgPlan.path),
207
- }),
208
- bumpReason: "dependency-propagation",
209
347
  dependencySourcePaths,
210
348
  };
211
349
  });
@@ -217,6 +355,11 @@ export function createReleasePlan(cwd = process.cwd()) {
217
355
  }
218
356
  }
219
357
  const adjustedPackages = propagatedPackages.map((pkgPlan) => {
358
+ if (pkgPlan.bumpReason === "release-as") {
359
+ // An explicit override pins this package's version; do not let a
360
+ // followed source recompute it out from under the requested version.
361
+ return pkgPlan;
362
+ }
220
363
  const followsSources = followsByPath.get(pkgPlan.path) ?? [];
221
364
  const bumpingSources = followsSources
222
365
  .map((sourcePath) => propagatedPackages.find((pkg) => pkg.path === sourcePath))
@@ -237,6 +380,7 @@ export function createReleasePlan(cwd = process.cwd()) {
237
380
  ].sort((a, b) => a.localeCompare(b));
238
381
  const sourceDrove = combinedReleaseType !== ownReleaseType ||
239
382
  pkgPlan.bumpReason === "dependency-propagation" ||
383
+ pkgPlan.bumpReason === "stale-dependency" ||
240
384
  pkgPlan.bumpReason === undefined;
241
385
  const baseVersion = packageCurrentVersionByPath[pkgPlan.path] ?? pkgPlan.currentVersion;
242
386
  const nextVersion = combinedReleaseType
@@ -272,12 +416,19 @@ export function createReleasePlan(cwd = process.cwd()) {
272
416
  commits: rootPackagePlan.commits,
273
417
  };
274
418
  }
419
+ const rootOverridden = rootPackagePlan.bumpReason === "release-as";
275
420
  if (monorepoMode === "fixed") {
276
- const fixedType = analyzeParsedCommits(adjustedPackages.flatMap((pkgPlan) => pkgPlan.parsedCommits));
421
+ const analyzedFixedType = analyzeParsedCommits(adjustedPackages.flatMap((pkgPlan) => pkgPlan.parsedCommits));
277
422
  const fixedBaseVersion = rootPackagePlan.currentVersion;
278
- const fixedNextVersion = fixedType
279
- ? bumpVersion(fixedBaseVersion, fixedType, { allowStableMajor })
280
- : null;
423
+ // A `Release-As:` footer on the shared (root) version pins every package.
424
+ const fixedType = rootOverridden
425
+ ? rootPackagePlan.releaseType
426
+ : analyzedFixedType;
427
+ const fixedNextVersion = rootOverridden
428
+ ? rootPackagePlan.nextVersion
429
+ : analyzedFixedType
430
+ ? bumpVersion(fixedBaseVersion, analyzedFixedType, { allowStableMajor })
431
+ : null;
281
432
  const adjusted = adjustedPackages.map((pkgPlan) => ({
282
433
  ...pkgPlan,
283
434
  releaseType: fixedType,
@@ -300,11 +451,20 @@ export function createReleasePlan(cwd = process.cwd()) {
300
451
  .map(({ implicitRoot: _implicitRoot, ...pkgPlan }) => pkgPlan),
301
452
  };
302
453
  }
303
- const overallType = analyzeParsedCommits(adjustedPackages.flatMap((pkgPlan) => pkgPlan.parsedCommits));
454
+ const analyzedOverallType = analyzeParsedCommits(adjustedPackages.flatMap((pkgPlan) => pkgPlan.parsedCommits));
304
455
  const overallBaseVersion = rootPackagePlan.currentVersion;
305
- const overallNextVersion = overallType
306
- ? bumpVersion(overallBaseVersion, overallType, { allowStableMajor })
307
- : null;
456
+ // A root-level `Release-As:` footer drives the aggregate version too, keeping
457
+ // the top-level plan consistent with the pinned root package.
458
+ const overallType = rootOverridden
459
+ ? rootPackagePlan.releaseType
460
+ : analyzedOverallType;
461
+ const overallNextVersion = rootOverridden
462
+ ? rootPackagePlan.nextVersion
463
+ : analyzedOverallType
464
+ ? bumpVersion(overallBaseVersion, analyzedOverallType, {
465
+ allowStableMajor,
466
+ })
467
+ : null;
308
468
  return {
309
469
  mode: "simple",
310
470
  releaseType: overallType,
@@ -320,9 +480,37 @@ export function createReleasePlan(cwd = process.cwd()) {
320
480
  packages: visiblePackages.map(({ implicitRoot: _implicitRoot, ...pkgPlan }) => pkgPlan),
321
481
  };
322
482
  }
323
- /** @deprecated Use createReleasePlan. */
324
- export function createSimplePlan(cwd = process.cwd()) {
325
- return createReleasePlan(cwd);
483
+ /**
484
+ * The root package's own release, as distinct from the plan-level aggregate.
485
+ *
486
+ * `plan.releaseType`/`plan.nextVersion`/`plan.commits` describe the repository
487
+ * as a whole: the aggregate release type folds in every package's commits and
488
+ * is then applied to root's version number. That answers "is anything
489
+ * releasing, and how large is the biggest change anywhere" — not "what is root
490
+ * releasing". A sibling's `feat` therefore lifts the aggregate to a minor even
491
+ * when root's own `exclude-paths` drop that commit, so anything that names or
492
+ * describes root's release must go through here instead. Using the aggregate
493
+ * would report a version no version file carries and list commits root
494
+ * deliberately excluded.
495
+ *
496
+ * Falls back to the aggregate when the plan has no explicit root package: the
497
+ * top-level changelog is then a repository-wide summary with no package of its
498
+ * own to describe.
499
+ */
500
+ export function resolveRootReleaseView(plan) {
501
+ const rootPackage = plan.packages?.find((pkg) => pkg.path === ".");
502
+ if (!rootPackage) {
503
+ return {
504
+ currentVersion: plan.currentVersion,
505
+ nextVersion: plan.nextVersion,
506
+ commits: plan.commits,
507
+ };
508
+ }
509
+ return {
510
+ currentVersion: rootPackage.currentVersion,
511
+ nextVersion: rootPackage.nextVersion,
512
+ commits: rootPackage.commits,
513
+ };
326
514
  }
327
515
  export function resolvePackageDependencies(plan, packagePath) {
328
516
  const target = plan.packages?.find((pkg) => pkg.path === packagePath);
@@ -1,15 +1,7 @@
1
1
  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
- import { type ReleasePlan, type SimplePlan } from "./plan.js";
5
- export declare function getNextReleaseFile(config: VersionaryConfig): string;
6
- export declare function readNextReleaseHighlights(cwd: string, config: VersionaryConfig): {
7
- content: string;
8
- filePath: string;
9
- } | null;
10
- export declare function consumeNextReleaseFile(cwd: string, filePath: string): {
11
- tracked: boolean;
12
- };
4
+ import { type ReleasePlan } from "./plan.js";
13
5
  /**
14
6
  * Read the manual-notes ("Unreleased") prose from the top of a changelog file.
15
7
  * Returns an empty string when the file is absent or has no notes block.
@@ -17,15 +9,13 @@ export declare function consumeNextReleaseFile(cwd: string, filePath: string): {
17
9
  export declare function readChangelogHighlights(cwd: string, changelogFile: string, format: VersionaryChangelogFormat): string;
18
10
  export interface ResolvedReleaseHighlights {
19
11
  highlights: string;
20
- source: "changelog" | "file" | "none";
21
- filePath?: string;
12
+ source: "changelog" | "none";
22
13
  }
23
14
  /**
24
- * Resolve release highlights, preferring an editable "Unreleased" section at the
25
- * top of the changelog. Falls back to the deprecated `NEXT_RELEASE.md` file
26
- * (emitting a warning) so existing setups keep working.
15
+ * Resolve release highlights from the editable "Unreleased" section at the top
16
+ * of the changelog.
27
17
  */
28
- export declare function resolveReleaseHighlights(cwd: string, config: VersionaryConfig, changelogFile: string, format: VersionaryChangelogFormat, logger?: VersionaryPluginContext["logger"]): ResolvedReleaseHighlights;
18
+ export declare function resolveReleaseHighlights(cwd: string, changelogFile: string, format: VersionaryChangelogFormat): ResolvedReleaseHighlights;
29
19
  export declare function splitSafeDirtyFiles(files: string[]): {
30
20
  ignored: string[];
31
21
  blocking: string[];
@@ -42,8 +32,8 @@ export declare function prepareReleasePr(cwd?: string, options?: {
42
32
  updated: boolean;
43
33
  highlights: string;
44
34
  };
45
- export declare function renderSimpleReviewRequestBody(version: string, previousVersion: string, commits: ParsedCommit[], plan?: SimplePlan | null, cwd?: string, highlights?: string, loadedConfig?: VersionaryConfig): string;
46
- export declare function openOrUpdateReviewRequest(cwd: string, branch: string, title: string, version: string, previousVersion: string, commits: ParsedCommit[], plan?: SimplePlan | null, options?: {
35
+ export declare function renderSimpleReviewRequestBody(version: string, previousVersion: string, commits: ParsedCommit[], plan?: ReleasePlan | null, cwd?: string, highlights?: string, loadedConfig?: VersionaryConfig): string;
36
+ export declare function openOrUpdateReviewRequest(cwd: string, branch: string, title: string, version: string, previousVersion: string, commits: ParsedCommit[], plan?: ReleasePlan | null, options?: {
47
37
  logger?: VersionaryPluginContext["logger"];
48
38
  highlights?: string;
49
39
  }): Promise<string>;
@@ -54,14 +44,5 @@ export declare function closeStaleReviewRequestIfExists(cwd?: string, options?:
54
44
  url?: string;
55
45
  number?: number;
56
46
  }>;
57
- /** @deprecated Use prepareReleasePr. */
58
- export declare function prepareSimpleReleasePr(cwd?: string, options?: {
59
- logger?: VersionaryPluginContext["logger"];
60
- }): ReturnType<typeof prepareReleasePr>;
61
- /** @deprecated Use openOrUpdateReviewRequest. */
62
- export declare function openOrUpdateSimpleReviewRequest(cwd: string, branch: string, title: string, version: string, previousVersion: string, commits: ParsedCommit[], plan?: SimplePlan | null, options?: {
63
- logger?: VersionaryPluginContext["logger"];
64
- highlights?: string;
65
- }): Promise<string>;
66
47
  export declare function pushReleaseBranch(cwd: string, branch: string): void;
67
48
  export declare function isReleaseCommitMessage(commitMessage: string): boolean;
@@ -17,38 +17,6 @@ const SAFE_DIRTY_FILES = new Set([
17
17
  "npm-shrinkwrap.json",
18
18
  ]);
19
19
  const VERSIONARY_RELEASE_TRAILER = "Versionary-Release: true";
20
- export function getNextReleaseFile(config) {
21
- return config["next-release-file"] ?? "NEXT_RELEASE.md";
22
- }
23
- export function readNextReleaseHighlights(cwd, config) {
24
- const filePath = getNextReleaseFile(config);
25
- const fullPath = path.join(cwd, filePath);
26
- if (!fs.existsSync(fullPath)) {
27
- return null;
28
- }
29
- const content = fs.readFileSync(fullPath, "utf8").trim();
30
- return { content, filePath };
31
- }
32
- function isTrackedFile(cwd, filePath) {
33
- try {
34
- execFileSync("git", ["ls-files", "--error-unmatch", "--", filePath], {
35
- cwd,
36
- stdio: ["ignore", "pipe", "ignore"],
37
- });
38
- return true;
39
- }
40
- catch {
41
- return false;
42
- }
43
- }
44
- export function consumeNextReleaseFile(cwd, filePath) {
45
- const tracked = isTrackedFile(cwd, filePath);
46
- const fullPath = path.join(cwd, filePath);
47
- if (fs.existsSync(fullPath)) {
48
- fs.rmSync(fullPath);
49
- }
50
- return { tracked };
51
- }
52
20
  /**
53
21
  * Read the manual-notes ("Unreleased") prose from the top of a changelog file.
54
22
  * Returns an empty string when the file is absent or has no notes block.
@@ -62,24 +30,14 @@ export function readChangelogHighlights(cwd, changelogFile, format) {
62
30
  return extractUnreleasedNotes(existing, format).highlights;
63
31
  }
64
32
  /**
65
- * Resolve release highlights, preferring an editable "Unreleased" section at the
66
- * top of the changelog. Falls back to the deprecated `NEXT_RELEASE.md` file
67
- * (emitting a warning) so existing setups keep working.
33
+ * Resolve release highlights from the editable "Unreleased" section at the top
34
+ * of the changelog.
68
35
  */
69
- export function resolveReleaseHighlights(cwd, config, changelogFile, format, logger) {
36
+ export function resolveReleaseHighlights(cwd, changelogFile, format) {
70
37
  const fromChangelog = readChangelogHighlights(cwd, changelogFile, format);
71
38
  if (fromChangelog.length > 0) {
72
39
  return { highlights: fromChangelog, source: "changelog" };
73
40
  }
74
- const fromFile = readNextReleaseHighlights(cwd, config);
75
- if (fromFile) {
76
- logger?.warn(`${fromFile.filePath} is deprecated; add release notes under an "Unreleased" heading at the top of ${changelogFile} instead.`);
77
- return {
78
- highlights: fromFile.content,
79
- source: "file",
80
- filePath: fromFile.filePath,
81
- };
82
- }
83
41
  return { highlights: "", source: "none" };
84
42
  }
85
43
  function listTrackedDirtyFiles(cwd) {
@@ -297,19 +255,16 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
297
255
  }
298
256
  const updatedArtifactFiles = applyConfiguredArtifactRules(cwd, loaded.config, plan);
299
257
  const packageReleaseMetadata = buildPackageReleaseMetadata(cwd, plan, loaded.config);
300
- const highlightsResult = resolveReleaseHighlights(cwd, loaded.config, plan.changelogFile, plan.changelogFormat, options.logger);
258
+ const highlightsResult = resolveReleaseHighlights(cwd, plan.changelogFile, plan.changelogFormat);
301
259
  const highlights = highlightsResult.highlights;
302
260
  const section = renderReleasePlanChangelog(plan, { highlights, cwd });
303
- prependChangelog(cwd, plan.changelogFile, section, plan.changelogFormat);
304
- const updatedChangelogFiles = [plan.changelogFile];
305
- let consumedHighlightsPath = null;
306
- // The changelog "Unreleased" block is stripped in-place by prependChangelog;
307
- // only the legacy side file needs explicit consumption and staging.
308
- if (highlightsResult.source === "file" && highlightsResult.filePath) {
309
- const { tracked } = consumeNextReleaseFile(cwd, highlightsResult.filePath);
310
- if (tracked) {
311
- consumedHighlightsPath = highlightsResult.filePath;
312
- }
261
+ const updatedChangelogFiles = [];
262
+ // An empty section means root itself is not releasing, only siblings are.
263
+ // Writing then would leave a bare heading behind and, worse, strip the
264
+ // manual-notes block that nothing has folded in yet.
265
+ if (section.length > 0) {
266
+ prependChangelog(cwd, plan.changelogFile, section, plan.changelogFormat);
267
+ updatedChangelogFiles.push(plan.changelogFile);
313
268
  }
314
269
  for (const packagePlan of plan.packages ?? []) {
315
270
  if (!packagePlan.nextVersion || packagePlan.path === ".") {
@@ -357,7 +312,6 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
357
312
  ...updatedVersionFiles,
358
313
  ...updatedArtifactFiles,
359
314
  ...updatedChangelogFiles,
360
- ...(consumedHighlightsPath ? [consumedHighlightsPath] : []),
361
315
  ]),
362
316
  ];
363
317
  execFileSync("git", ["add", ...filesToAdd], {
@@ -497,14 +451,6 @@ export async function closeStaleReviewRequestIfExists(cwd = process.cwd(), optio
497
451
  }
498
452
  return result;
499
453
  }
500
- /** @deprecated Use prepareReleasePr. */
501
- export function prepareSimpleReleasePr(cwd = process.cwd(), options = {}) {
502
- return prepareReleasePr(cwd, options);
503
- }
504
- /** @deprecated Use openOrUpdateReviewRequest. */
505
- export async function openOrUpdateSimpleReviewRequest(cwd, branch, title, version, previousVersion, commits, plan = null, options = {}) {
506
- return openOrUpdateReviewRequest(cwd, branch, title, version, previousVersion, commits, plan, options);
507
- }
508
454
  export function pushReleaseBranch(cwd, branch) {
509
455
  execFileSync("git", ["push", "--force-with-lease", "origin", branch], {
510
456
  cwd,
@@ -0,0 +1,29 @@
1
+ import type { ParsedCommit } from "../git/commits.js";
2
+ import { type ReleaseType } from "./semver.js";
3
+ /**
4
+ * Footer token (case-insensitive) that requests an explicit next version, e.g.
5
+ *
6
+ * chore: graduate to stable release
7
+ *
8
+ * Release-As: 1.0.0
9
+ *
10
+ * The override forces a release with the requested version even when the
11
+ * conventional-commit analysis alone would produce no bump.
12
+ */
13
+ export declare const RELEASE_AS_FOOTER_TOKEN = "release-as";
14
+ export interface ReleaseAsOverride {
15
+ /** The requested target version (leading `v` stripped, validated SemVer). */
16
+ version: string;
17
+ /** Hash of the commit carrying the winning footer. */
18
+ sourceHash: string;
19
+ }
20
+ /**
21
+ * Scan a release window for a `Release-As:` footer override.
22
+ *
23
+ * Returns the explicit target version, or `null` when no footer is present.
24
+ * Throws on an invalid version, a downgrade or no-op relative to
25
+ * `currentVersion`, or conflicting override versions within the window.
26
+ */
27
+ export declare function resolveReleaseAsOverride(commits: ParsedCommit[], currentVersion: string): ReleaseAsOverride | null;
28
+ /** Derive the semantic release level implied by moving `from` -> `to`. */
29
+ export declare function releaseTypeBetween(from: string, to: string): ReleaseType;