versionary 0.32.0 → 1.0.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.
@@ -57,9 +57,9 @@ function setOutput(name, value) {
57
57
  appendFileSync(outputPath, `${name}<<${delimiter}\n${value}\n${delimiter}\n`, "utf8");
58
58
  }
59
59
  function main() {
60
- const token = getInput("token") || getInput("github-token");
60
+ const token = getInput("token");
61
61
  if (!token) {
62
- throw new Error("Input required and not supplied: token (or deprecated github-token).");
62
+ throw new Error("Input required and not supplied: token.");
63
63
  }
64
64
  const versionaryVersion = getInput("versionary-version") || "0.7.0";
65
65
  const cwd = getInput("working-directory") || ".";
package/dist/cli/index.js CHANGED
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from "node:child_process";
3
- import { loadConfig } from "../config/load-config.js";
4
3
  import { prependChangelog, renderReleasePlanChangelog, } from "../release/changelog.js";
5
4
  import { createReleasePlan } from "../release/plan.js";
6
- import { closeStaleReviewRequestIfExists, consumeNextReleaseFile, isReleaseCommitMessage, openOrUpdateReviewRequest, prepareReleasePr, pushReleaseBranch, resolveReleaseHighlights, } from "../release/pr.js";
5
+ import { closeStaleReviewRequestIfExists, isReleaseCommitMessage, openOrUpdateReviewRequest, prepareReleasePr, pushReleaseBranch, resolveReleaseHighlights, } from "../release/pr.js";
7
6
  import { runRelease, runReleaseDetailed } from "../release/release.js";
8
7
  import { verifyProject } from "../release/verify-project.js";
9
8
  function printVerifyResult() {
@@ -195,8 +194,7 @@ async function main() {
195
194
  console.log("No releasable commits found.");
196
195
  return 0;
197
196
  }
198
- const loaded = loadConfig();
199
- const highlightsResult = resolveReleaseHighlights(process.cwd(), loaded.config, plan.changelogFile, plan.changelogFormat, logger);
197
+ const highlightsResult = resolveReleaseHighlights(process.cwd(), plan.changelogFile, plan.changelogFormat);
200
198
  const section = renderReleasePlanChangelog(plan, {
201
199
  highlights: highlightsResult.highlights,
202
200
  });
@@ -205,9 +203,6 @@ async function main() {
205
203
  return 0;
206
204
  }
207
205
  prependChangelog(process.cwd(), plan.changelogFile, section, plan.changelogFormat);
208
- if (highlightsResult.source === "file" && highlightsResult.filePath) {
209
- consumeNextReleaseFile(process.cwd(), highlightsResult.filePath);
210
- }
211
206
  console.log(`Updated ${plan.changelogFile}`);
212
207
  return 0;
213
208
  }
@@ -5,7 +5,6 @@ export declare const configSchema: z.ZodObject<{
5
5
  "review-mode": z.ZodOptional<z.ZodEnum<{
6
6
  direct: "direct";
7
7
  pr: "pr";
8
- review: "review";
9
8
  }>>;
10
9
  "version-file": z.ZodOptional<z.ZodString>;
11
10
  "changelog-file": z.ZodOptional<z.ZodString>;
@@ -15,17 +14,16 @@ export declare const configSchema: z.ZodObject<{
15
14
  }>>;
16
15
  "release-draft": z.ZodOptional<z.ZodBoolean>;
17
16
  "release-reference-comments": z.ZodOptional<z.ZodEnum<{
18
- off: "off";
19
17
  "best-effort": "best-effort";
18
+ off: "off";
20
19
  strict: "strict";
21
20
  }>>;
22
21
  "release-branch": z.ZodOptional<z.ZodString>;
23
22
  "baseline-file": z.ZodOptional<z.ZodString>;
24
- "next-release-file": z.ZodOptional<z.ZodString>;
25
23
  "bootstrap-sha": z.ZodOptional<z.ZodString>;
26
24
  "monorepo-mode": z.ZodOptional<z.ZodEnum<{
27
- independent: "independent";
28
25
  fixed: "fixed";
26
+ independent: "independent";
29
27
  }>>;
30
28
  "bump-minor-pre-major": z.ZodOptional<z.ZodBoolean>;
31
29
  "allow-stable-major": z.ZodOptional<z.ZodBoolean>;
@@ -45,14 +43,13 @@ export declare const configSchema: z.ZodObject<{
45
43
  "extra-files": z.ZodOptional<z.ZodArray<z.ZodObject<{
46
44
  type: z.ZodEnum<{
47
45
  json: "json";
48
- toml: "toml";
49
- yaml: "yaml";
50
46
  nix: "nix";
51
47
  regex: "regex";
48
+ toml: "toml";
49
+ yaml: "yaml";
52
50
  }>;
53
51
  path: z.ZodString;
54
52
  "field-path": z.ZodOptional<z.ZodString>;
55
- jsonpath: z.ZodOptional<z.ZodString>;
56
53
  pattern: z.ZodOptional<z.ZodString>;
57
54
  replacement: z.ZodOptional<z.ZodString>;
58
55
  }, z.core.$strip>>>;
@@ -4,44 +4,35 @@ const artifactRuleSchema = z
4
4
  type: z.enum(["json", "toml", "yaml", "nix", "regex"]),
5
5
  path: z.string().min(1),
6
6
  "field-path": z.string().optional(),
7
- jsonpath: z.string().optional(),
8
7
  pattern: z.string().optional(),
9
8
  replacement: z.string().optional(),
10
9
  })
11
10
  .superRefine((value, ctx) => {
12
- const needsJsonPath = value.type === "json" ||
11
+ const needsFieldPath = value.type === "json" ||
13
12
  value.type === "toml" ||
14
13
  value.type === "yaml" ||
15
14
  value.type === "nix";
16
- const hasFieldPath = Boolean(value["field-path"] ?? value.jsonpath);
17
- if (needsJsonPath && !hasFieldPath) {
15
+ if (needsFieldPath && !value["field-path"]) {
18
16
  ctx.addIssue({
19
17
  code: z.ZodIssueCode.custom,
20
- message: `${value.type} artifact rules require "field-path" (or deprecated "jsonpath").`,
18
+ message: `${value.type} artifact rules require "field-path".`,
21
19
  path: ["field-path"],
22
20
  });
23
21
  }
24
- if (needsJsonPath && value.pattern) {
22
+ if (needsFieldPath && value.pattern) {
25
23
  ctx.addIssue({
26
24
  code: z.ZodIssueCode.custom,
27
25
  message: `${value.type} artifact rules do not support "pattern".`,
28
26
  path: ["pattern"],
29
27
  });
30
28
  }
31
- if (needsJsonPath && value.replacement) {
29
+ if (needsFieldPath && value.replacement) {
32
30
  ctx.addIssue({
33
31
  code: z.ZodIssueCode.custom,
34
32
  message: `${value.type} artifact rules do not support "replacement".`,
35
33
  path: ["replacement"],
36
34
  });
37
35
  }
38
- if (value["field-path"] && value.jsonpath) {
39
- ctx.addIssue({
40
- code: z.ZodIssueCode.custom,
41
- message: 'Specify only one of "field-path" or deprecated "jsonpath".',
42
- path: ["field-path"],
43
- });
44
- }
45
36
  if (value.type === "regex" && !value.pattern) {
46
37
  ctx.addIssue({
47
38
  code: z.ZodIssueCode.custom,
@@ -49,10 +40,10 @@ const artifactRuleSchema = z
49
40
  path: ["pattern"],
50
41
  });
51
42
  }
52
- if (value.type === "regex" && (value["field-path"] || value.jsonpath)) {
43
+ if (value.type === "regex" && value["field-path"]) {
53
44
  ctx.addIssue({
54
45
  code: z.ZodIssueCode.custom,
55
- message: 'regex artifact rules do not support "field-path" or deprecated "jsonpath".',
46
+ message: 'regex artifact rules do not support "field-path".',
56
47
  path: ["field-path"],
57
48
  });
58
49
  }
@@ -75,7 +66,7 @@ export const configSchema = z
75
66
  .object({
76
67
  $schema: z.string().optional(),
77
68
  version: z.literal(1),
78
- "review-mode": z.enum(["direct", "pr", "review"]).optional(),
69
+ "review-mode": z.enum(["direct", "pr"]).optional(),
79
70
  "version-file": z.string().optional(),
80
71
  "changelog-file": z.string().optional(),
81
72
  "changelog-format": z.enum(["markdown-changelog", "r-news"]).optional(),
@@ -85,7 +76,6 @@ export const configSchema = z
85
76
  .optional(),
86
77
  "release-branch": z.string().optional(),
87
78
  "baseline-file": z.string().optional(),
88
- "next-release-file": z.string().optional(),
89
79
  "bootstrap-sha": z.string().optional(),
90
80
  "monorepo-mode": z.enum(["independent", "fixed"]).optional(),
91
81
  "bump-minor-pre-major": z.boolean().optional(),
@@ -1,3 +1,3 @@
1
1
  import type { VersionaryConfig } from "../types/config.js";
2
- import type { SimplePlan } from "./plan.js";
3
- export declare function applyConfiguredArtifactRules(cwd: string, config: VersionaryConfig, plan: SimplePlan): string[];
2
+ import type { ReleasePlan } from "./plan.js";
3
+ export declare function applyConfiguredArtifactRules(cwd: string, config: VersionaryConfig, plan: ReleasePlan): string[];
@@ -143,9 +143,9 @@ function setVersionAtJsonPath(document, fieldPath, version) {
143
143
  }
144
144
  }
145
145
  function resolveFieldPath(rule) {
146
- const fieldPath = rule["field-path"] ?? rule.jsonpath;
146
+ const fieldPath = rule["field-path"];
147
147
  if (!fieldPath) {
148
- throw new Error(`${rule.type} artifact rules require "field-path" (or deprecated "jsonpath").`);
148
+ throw new Error(`${rule.type} artifact rules require "field-path".`);
149
149
  }
150
150
  return fieldPath;
151
151
  }
@@ -207,8 +207,8 @@ function applyRegexRule(content, pattern, version, replacementTemplate) {
207
207
  const rendered = renderReplacementTemplate(replacementTemplate, version);
208
208
  return `${content.slice(0, start)}${rendered}${content.slice(start + full.length)}`;
209
209
  }
210
- // Legacy behavior: substitute the full version into the first capture group,
211
- // leaving the rest of the match intact. Splice by group indices rather than
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
212
  // `String.replace` so literal `$` sequences and repeated group content are
213
213
  // handled correctly.
214
214
  const groupIndices = match.indices?.[1];
@@ -1,6 +1,6 @@
1
1
  import { type ParsedCommit } from "../git/commits.js";
2
2
  import type { VersionaryChangelogFormat } from "../types/config.js";
3
- import { type ReleasePlan, type SimplePlan } from "./plan.js";
3
+ import { type ReleasePlan } from "./plan.js";
4
4
  export declare function renderReleaseNotesSection(input: {
5
5
  currentVersion: string;
6
6
  nextVersion: string;
@@ -23,8 +23,6 @@ export declare function renderReleasePlanChangelog(plan: ReleasePlan, options?:
23
23
  cwd?: string;
24
24
  highlights?: string;
25
25
  }): string;
26
- /** @deprecated Use renderReleasePlanChangelog. */
27
- export declare function renderSimpleChangelog(plan: SimplePlan): string;
28
26
  /**
29
27
  * Locate a manual-notes block at the top of an existing changelog and split it
30
28
  * out. The notes block is the first release-level heading whose text is not a
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { applyRevertSuppression, inferReleaseTypeFromParsedCommit, parseConventionalCommitMessage, } from "../git/commits.js";
4
4
  import { resolveRepositoryWebBaseUrl } from "../git/repo-url.js";
5
- import { resolvePackageDependencies, } from "./plan.js";
5
+ import { resolvePackageDependencies } from "./plan.js";
6
6
  import { isVersionHeading } from "./semver.js";
7
7
  const REVIEW_REQUEST_FOOTER = "---\n\nThis PR was generated by [Versionary](https://github.com/jolars/versionary).";
8
8
  function formatDate() {
@@ -68,6 +68,7 @@ function groupCommitLines(commits, repoUrl) {
68
68
  const fixes = [];
69
69
  const performance = [];
70
70
  const reverts = [];
71
+ const other = [];
71
72
  const effectiveCommits = applyRevertSuppression(commits);
72
73
  const getRevertedSubject = (commit) => {
73
74
  const normalizedDescription = (commit.description ?? "")
@@ -93,9 +94,6 @@ function groupCommitLines(commits, repoUrl) {
93
94
  };
94
95
  for (const commit of effectiveCommits) {
95
96
  const type = inferReleaseTypeFromParsedCommit(commit);
96
- if (!type) {
97
- continue;
98
- }
99
97
  const { label, message } = formatCommitMessage(commit.subject);
100
98
  const short = commit.hash.slice(0, 7);
101
99
  const hashLabel = repoUrl
@@ -106,6 +104,10 @@ function groupCommitLines(commits, repoUrl) {
106
104
  const line = `- ${label}${message} (${hashLabel})${referencesSuffix}`;
107
105
  const commitType = (commit.type ?? "").toLowerCase();
108
106
  const isBreaking = type === "major";
107
+ if (!type) {
108
+ other.push(line);
109
+ continue;
110
+ }
109
111
  if (commit.isRevert) {
110
112
  if (!shouldIncludeRevert(commit)) {
111
113
  continue;
@@ -128,7 +130,23 @@ function groupCommitLines(commits, repoUrl) {
128
130
  fixes.push(line);
129
131
  }
130
132
  }
131
- return { breaking, features, fixes, performance, reverts };
133
+ return { breaking, features, fixes, performance, reverts, other };
134
+ }
135
+ /**
136
+ * A forced bump — a stale dependency, or a propagated requirement rewrite —
137
+ * can carry no release-worthy commits at all. Rather than publish a bare
138
+ * version heading, such a release falls back to listing whatever else is
139
+ * shipping under it.
140
+ */
141
+ function needsOtherChangesFallback(grouped, hasHighlights, dependencyCount) {
142
+ if (hasHighlights || dependencyCount > 0 || grouped.other.length === 0) {
143
+ return false;
144
+ }
145
+ return (grouped.breaking.length === 0 &&
146
+ grouped.features.length === 0 &&
147
+ grouped.fixes.length === 0 &&
148
+ grouped.performance.length === 0 &&
149
+ grouped.reverts.length === 0);
132
150
  }
133
151
  export function renderReleaseNotesSection(input, options = {}) {
134
152
  const repoUrl = resolveRepositoryWebBaseUrl(input.cwd ?? process.cwd());
@@ -161,6 +179,9 @@ export function renderReleaseNotesSection(input, options = {}) {
161
179
  if (input.dependencies && input.dependencies.length > 0) {
162
180
  sections.push("### Dependencies", ...input.dependencies.map((dependency) => `- updated ${dependency.name} to v${dependency.version}`), "");
163
181
  }
182
+ if (needsOtherChangesFallback(grouped, trimmedHighlights.length > 0, input.dependencies?.length ?? 0)) {
183
+ sections.push("### Other changes", ...grouped.other, "");
184
+ }
164
185
  const body = [header, "", ...sections].join("\n").trimEnd();
165
186
  if (options.includeFooter) {
166
187
  return `${body}\n\n${renderReviewRequestFooter()}`;
@@ -198,10 +219,6 @@ export function renderReleasePlanChangelog(plan, options = {}) {
198
219
  includeFooter: options.includeFooter,
199
220
  });
200
221
  }
201
- /** @deprecated Use renderReleasePlanChangelog. */
202
- export function renderSimpleChangelog(plan) {
203
- return renderReleasePlanChangelog(plan);
204
- }
205
222
  /**
206
223
  * Locate a manual-notes block at the top of an existing changelog and split it
207
224
  * out. The notes block is the first release-level heading whose text is not a
@@ -292,6 +309,9 @@ export function renderRNewsReleaseNotes(input) {
292
309
  if (grouped.reverts.length > 0) {
293
310
  sections.push("## Reverts", "", ...grouped.reverts, "");
294
311
  }
312
+ if (needsOtherChangesFallback(grouped, trimmedHighlights.length > 0, 0)) {
313
+ sections.push("## Other changes", "", ...grouped.other, "");
314
+ }
295
315
  return [`# ${input.packageName} ${normalizedVersion}`, "", ...sections]
296
316
  .join("\n")
297
317
  .trimEnd();
@@ -1,6 +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 ReleaseType } from "./semver.js";
4
+ type BumpReason = "direct" | "dependency-propagation" | "stale-dependency" | "follows" | "release-as";
4
5
  export interface ReleasePlan {
5
6
  mode: "simple";
6
7
  releaseType: ReleaseType;
@@ -18,13 +19,11 @@ export interface ReleasePlan {
18
19
  releaseType: ReleaseType;
19
20
  currentVersion: string;
20
21
  nextVersion: string | null;
21
- bumpReason?: "direct" | "dependency-propagation" | "follows";
22
+ bumpReason?: BumpReason;
22
23
  dependencySourcePaths?: string[];
23
24
  commits: ParsedCommit[];
24
25
  }>;
25
26
  }
26
- /** @deprecated Use ReleasePlan. */
27
- export type SimplePlan = ReleasePlan;
28
27
  export declare function getChangelogDefaults(config: {
29
28
  "release-type"?: VersionaryConfig["release-type"];
30
29
  "changelog-file"?: VersionaryConfig["changelog-file"];
@@ -35,9 +34,8 @@ export declare function getChangelogDefaults(config: {
35
34
  changelogFormat: VersionaryChangelogFormat;
36
35
  };
37
36
  export declare function createReleasePlan(cwd?: string): ReleasePlan;
38
- /** @deprecated Use createReleasePlan. */
39
- export declare function createSimplePlan(cwd?: string): ReleasePlan;
40
37
  export declare function resolvePackageDependencies(plan: ReleasePlan, packagePath: string): Array<{
41
38
  name: string;
42
39
  version: string;
43
40
  }>;
41
+ export {};
@@ -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,10 +480,6 @@ 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);
326
- }
327
483
  export function resolvePackageDependencies(plan, packagePath) {
328
484
  const target = plan.packages?.find((pkg) => pkg.path === packagePath);
329
485
  if (!target) {
@@ -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,20 +255,11 @@ 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
261
  prependChangelog(cwd, plan.changelogFile, section, plan.changelogFormat);
304
262
  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
- }
313
- }
314
263
  for (const packagePlan of plan.packages ?? []) {
315
264
  if (!packagePlan.nextVersion || packagePlan.path === ".") {
316
265
  continue;
@@ -357,7 +306,6 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
357
306
  ...updatedVersionFiles,
358
307
  ...updatedArtifactFiles,
359
308
  ...updatedChangelogFiles,
360
- ...(consumedHighlightsPath ? [consumedHighlightsPath] : []),
361
309
  ]),
362
310
  ];
363
311
  execFileSync("git", ["add", ...filesToAdd], {
@@ -497,14 +445,6 @@ export async function closeStaleReviewRequestIfExists(cwd = process.cwd(), optio
497
445
  }
498
446
  return result;
499
447
  }
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
448
  export function pushReleaseBranch(cwd, branch) {
509
449
  execFileSync("git", ["push", "--force-with-lease", "origin", branch], {
510
450
  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;
@@ -0,0 +1,70 @@
1
+ import { compareVersions, isValidVersion, parseVersion, } from "./semver.js";
2
+ /**
3
+ * Footer token (case-insensitive) that requests an explicit next version, e.g.
4
+ *
5
+ * chore: graduate to stable release
6
+ *
7
+ * Release-As: 1.0.0
8
+ *
9
+ * The override forces a release with the requested version even when the
10
+ * conventional-commit analysis alone would produce no bump.
11
+ */
12
+ export const RELEASE_AS_FOOTER_TOKEN = "release-as";
13
+ function normalizeToken(token) {
14
+ return token.trim().toLowerCase();
15
+ }
16
+ /**
17
+ * Scan a release window for a `Release-As:` footer override.
18
+ *
19
+ * Returns the explicit target version, or `null` when no footer is present.
20
+ * Throws on an invalid version, a downgrade or no-op relative to
21
+ * `currentVersion`, or conflicting override versions within the window.
22
+ */
23
+ export function resolveReleaseAsOverride(commits, currentVersion) {
24
+ const overrides = [];
25
+ for (const commit of commits) {
26
+ for (const footer of commit.footers) {
27
+ if (normalizeToken(footer.token) !== RELEASE_AS_FOOTER_TOKEN) {
28
+ continue;
29
+ }
30
+ const raw = footer.value.trim().replace(/^v/u, "");
31
+ if (!isValidVersion(raw)) {
32
+ throw new Error(`Invalid \`Release-As\` footer in commit ${commit.hash}: ` +
33
+ `"${footer.value}" is not a valid SemVer version.`);
34
+ }
35
+ overrides.push({ version: raw, sourceHash: commit.hash });
36
+ }
37
+ }
38
+ if (overrides.length === 0) {
39
+ return null;
40
+ }
41
+ const distinct = [...new Set(overrides.map((override) => override.version))];
42
+ if (distinct.length > 1) {
43
+ throw new Error(`Conflicting \`Release-As\` footers in release window: ${distinct
44
+ .map((version) => `"${version}"`)
45
+ .join(", ")}. Only one target version may be requested.`);
46
+ }
47
+ // All requested versions are identical here; the last footer wins.
48
+ const override = overrides[overrides.length - 1];
49
+ if (compareVersions(override.version, currentVersion) <= 0) {
50
+ throw new Error(`\`Release-As\` footer requests ${override.version}, which is not ` +
51
+ `greater than the current version ${currentVersion}. Downgrades and ` +
52
+ `no-op releases are not allowed.`);
53
+ }
54
+ return override;
55
+ }
56
+ /** Derive the semantic release level implied by moving `from` -> `to`. */
57
+ export function releaseTypeBetween(from, to) {
58
+ const before = parseVersion(from);
59
+ const after = parseVersion(to);
60
+ if (after.major !== before.major) {
61
+ return "major";
62
+ }
63
+ if (after.minor !== before.minor) {
64
+ return "minor";
65
+ }
66
+ if (after.patch !== before.patch) {
67
+ return "patch";
68
+ }
69
+ return null;
70
+ }
@@ -30,11 +30,3 @@ export interface RunReleaseOptions {
30
30
  "dry-run"?: boolean;
31
31
  }
32
32
  export declare function runReleaseDetailed(cwd?: string, options?: RunReleaseOptions): Promise<RunReleaseResult>;
33
- /** @deprecated Use RunReleaseResult. */
34
- export type SimpleRunReleaseResult = RunReleaseResult;
35
- /** @deprecated Use RunReleaseOptions. */
36
- export type RunSimpleReleaseOptions = RunReleaseOptions;
37
- /** @deprecated Use runRelease. */
38
- export declare function runSimpleRelease(cwd?: string): Promise<string>;
39
- /** @deprecated Use runReleaseDetailed. */
40
- export declare function runSimpleReleaseDetailed(cwd?: string, options?: RunSimpleReleaseOptions): Promise<SimpleRunReleaseResult>;
@@ -224,11 +224,3 @@ export async function runReleaseDetailed(cwd = process.cwd(), options = {}) {
224
224
  message: `Published releases ${published.join(", ")}`,
225
225
  };
226
226
  }
227
- /** @deprecated Use runRelease. */
228
- export async function runSimpleRelease(cwd = process.cwd()) {
229
- return runRelease(cwd);
230
- }
231
- /** @deprecated Use runReleaseDetailed. */
232
- export async function runSimpleReleaseDetailed(cwd = process.cwd(), options = {}) {
233
- return runReleaseDetailed(cwd, options);
234
- }
@@ -49,15 +49,7 @@ export function getBaselineStatePath(cwd) {
49
49
  if (configured) {
50
50
  return path.join(cwd, configured);
51
51
  }
52
- const preferred = path.join(cwd, ".versionary-manifest.json");
53
- if (fs.existsSync(preferred)) {
54
- return preferred;
55
- }
56
- const legacy = path.join(cwd, "versionary.versions.json");
57
- if (fs.existsSync(legacy)) {
58
- return legacy;
59
- }
60
- return preferred;
52
+ return path.join(cwd, ".versionary-manifest.json");
61
53
  }
62
54
  export function readBaselineSha(cwd = process.cwd()) {
63
55
  const filePath = getBaselineStatePath(cwd);
@@ -80,15 +72,14 @@ export function readReleaseTargets(cwd = process.cwd()) {
80
72
  }
81
73
  // The publish set introduced by the current release PR. `release` consumes this
82
74
  // so it only publishes/announces what this PR bumped, not every package in the
83
- // accumulated baseline. Falls back to the accumulated targets for manifests
84
- // written before this key existed (legacy compatibility).
75
+ // accumulated baseline.
85
76
  export function readPendingReleaseTargets(cwd = process.cwd()) {
86
77
  const filePath = getBaselineStatePath(cwd);
87
78
  if (!fs.existsSync(filePath)) {
88
79
  return [];
89
80
  }
90
81
  const parsed = parseStateFile(fs.readFileSync(filePath, "utf8"), filePath);
91
- return (parsed[PENDING_RELEASE_TARGETS_KEY] ?? parsed[RELEASE_TARGETS_KEY] ?? []);
82
+ return parsed[PENDING_RELEASE_TARGETS_KEY] ?? [];
92
83
  }
93
84
  export function writeBaselineSha(cwd = process.cwd(), sha, releaseTargets) {
94
85
  const baselineShaValue = sha ??
@@ -71,6 +71,17 @@ export function compositeVersionStrategy(strategies) {
71
71
  return primary.readPackageName?.(cwd, config) ?? null;
72
72
  };
73
73
  }
74
+ if (primary.isPublishable || secondaries.some((s) => s.isPublishable)) {
75
+ composite.isPublishable = (cwd, pkg) => {
76
+ // Only strategies that recognize this package's version file have an
77
+ // opinion; among those, one publishing facet is enough to expose the
78
+ // package on a registry.
79
+ const opinions = [primary, ...secondaries]
80
+ .map((strategy) => strategy.isPublishable?.(cwd, pkg))
81
+ .filter((opinion) => typeof opinion === "boolean");
82
+ return opinions.length === 0 ? undefined : opinions.some(Boolean);
83
+ };
84
+ }
74
85
  if (primary.propagateDependentPatchImpacts ||
75
86
  secondaries.some((strategy) => strategy.propagateDependentPatchImpacts)) {
76
87
  composite.propagateDependentPatchImpacts = (cwd, packages) => {
@@ -71,4 +71,19 @@ export const nodeVersionStrategy = {
71
71
  }
72
72
  return name.trim();
73
73
  },
74
+ isPublishable(cwd, pkg) {
75
+ if (path.basename(pkg.versionFile) !== "package.json") {
76
+ return undefined;
77
+ }
78
+ const versionPath = path.join(cwd, pkg.versionFile);
79
+ if (!fs.existsSync(versionPath)) {
80
+ return true;
81
+ }
82
+ try {
83
+ return readJsonFile(versionPath).private !== true;
84
+ }
85
+ catch {
86
+ return true;
87
+ }
88
+ },
74
89
  };
@@ -253,13 +253,53 @@ function collectRustTargetManifests(cwd, versionFile, includeWorkspaceMembers) {
253
253
  : `"${versionFile}" has neither [package] nor [workspace].`;
254
254
  throw new Error(`Configured rust target "${versionFile}" is not a Rust crate manifest. ${detail} Either remove the "packages" config so the workspace is auto-discovered, or point a package at a member crate path (e.g. "packages": { "crates/foo": {} }).`);
255
255
  }
256
- function isWorkspaceInheritedVersion(rawVersion) {
256
+ function isWorkspaceInheritedValue(rawVersion) {
257
257
  if (!rawVersion || typeof rawVersion !== "object") {
258
258
  return false;
259
259
  }
260
260
  const versionRecord = rawVersion;
261
261
  return versionRecord.workspace === true;
262
262
  }
263
+ function isPublishValueUnpublishable(rawPublish) {
264
+ // Cargo treats both `publish = false` and an empty registry list as
265
+ // "never publish"; a non-empty list still reaches a registry.
266
+ return (rawPublish === false ||
267
+ (Array.isArray(rawPublish) && rawPublish.length === 0));
268
+ }
269
+ function readManifestPublishable(cwd, manifest) {
270
+ const manifestPath = path.join(cwd, manifest);
271
+ if (!fs.existsSync(manifestPath)) {
272
+ return true;
273
+ }
274
+ const cargoTomlRaw = fs.readFileSync(manifestPath, "utf8");
275
+ let packageTable;
276
+ try {
277
+ ({ packageTable } = parseCargoManifest(manifest, cargoTomlRaw));
278
+ }
279
+ catch {
280
+ return true;
281
+ }
282
+ if (!packageTable) {
283
+ return true;
284
+ }
285
+ const rawPublish = packageTable.publish;
286
+ if (!isWorkspaceInheritedValue(rawPublish)) {
287
+ return !isPublishValueUnpublishable(rawPublish);
288
+ }
289
+ let workspaceManifest;
290
+ try {
291
+ workspaceManifest = findWorkspaceManifestForMember(cwd, manifest);
292
+ }
293
+ catch {
294
+ return true;
295
+ }
296
+ const workspaceRaw = fs.readFileSync(path.join(cwd, workspaceManifest), "utf8");
297
+ const { workspaceTable } = parseCargoManifest(workspaceManifest, workspaceRaw);
298
+ const workspacePackage = workspaceTable?.package && typeof workspaceTable.package === "object"
299
+ ? workspaceTable.package
300
+ : null;
301
+ return !isPublishValueUnpublishable(workspacePackage?.publish);
302
+ }
263
303
  function readWorkspacePackageVersion(cargoTomlRaw, versionFile) {
264
304
  const { workspaceTable } = parseCargoManifest(versionFile, cargoTomlRaw);
265
305
  if (!workspaceTable || typeof workspaceTable !== "object") {
@@ -314,7 +354,7 @@ function readResolvedCargoVersion(cwd, manifest, cargoTomlRaw) {
314
354
  if (typeof rawVersion === "string" && rawVersion.trim().length > 0) {
315
355
  return rawVersion.trim();
316
356
  }
317
- if (!isWorkspaceInheritedVersion(rawVersion)) {
357
+ if (!isWorkspaceInheritedValue(rawVersion)) {
318
358
  throw new Error(`${manifest} has invalid [package].version. Expected a non-empty SemVer string or version.workspace = true.`);
319
359
  }
320
360
  const workspaceManifest = findWorkspaceManifestForMember(cwd, manifest);
@@ -425,7 +465,7 @@ function usesWorkspaceInheritedVersion(cargoTomlRaw, versionFile) {
425
465
  return false;
426
466
  }
427
467
  const rawVersion = packageTable.version;
428
- return isWorkspaceInheritedVersion(rawVersion);
468
+ return isWorkspaceInheritedValue(rawVersion);
429
469
  }
430
470
  function isDependencySection(section) {
431
471
  if (ROOT_DEPENDENCY_SECTIONS.has(section)) {
@@ -733,6 +773,13 @@ export const rustVersionStrategy = {
733
773
  }
734
774
  return readCargoPackageName(cargoTomlRaw, selectedManifest);
735
775
  },
776
+ isPublishable(cwd, pkg) {
777
+ const manifest = normalizeSlashPath(pkg.versionFile);
778
+ if (path.posix.basename(manifest) !== "Cargo.toml") {
779
+ return undefined;
780
+ }
781
+ return readManifestPublishable(cwd, manifest);
782
+ },
736
783
  propagateDependentPatchImpacts(cwd, packages) {
737
784
  const manifestToVersion = {};
738
785
  const candidateManifests = [];
@@ -22,6 +22,17 @@ export interface VersionStrategy {
22
22
  writeVersion(cwd: string, config: VersionaryConfig, version: string): string[];
23
23
  validateProject?(cwd: string, config: VersionaryConfig): string | null;
24
24
  readPackageName?(cwd: string, config: VersionaryConfig): string | null;
25
+ /**
26
+ * Whether this package is published to a registry. A package that never
27
+ * reaches a registry cannot leave a stale published copy behind, so release
28
+ * planning exempts it from published-dependency freshness enforcement.
29
+ *
30
+ * Returns `undefined` to abstain, which a strategy must do for a version
31
+ * file it does not recognize — otherwise, composed with another strategy, it
32
+ * would outvote the one that actually owns the manifest. Strategies that
33
+ * omit this hook entirely are assumed to publish everything.
34
+ */
35
+ isPublishable?(cwd: string, pkg: StrategyPackagePlanContext): boolean | undefined;
25
36
  propagateDependentPatchImpacts?(cwd: string, packages: StrategyPackagePlanContext[]): string[];
26
37
  finalizeVersionWrites?(cwd: string, writes: StrategyVersionWriteContext[], context: StrategyFinalizeContext): string[];
27
38
  }
@@ -5,7 +5,6 @@ export interface VersionaryArtifactRule {
5
5
  type: "json" | "toml" | "yaml" | "nix" | "regex";
6
6
  path: string;
7
7
  "field-path"?: string;
8
- jsonpath?: string;
9
8
  pattern?: string;
10
9
  replacement?: string;
11
10
  }
@@ -21,7 +20,7 @@ export interface VersionaryPackage {
21
20
  }
22
21
  export interface VersionaryConfig {
23
22
  version: 1;
24
- "review-mode"?: "direct" | "pr" | "review";
23
+ "review-mode"?: "direct" | "pr";
25
24
  "version-file"?: string;
26
25
  "changelog-file"?: string;
27
26
  "changelog-format"?: VersionaryChangelogFormat;
@@ -29,7 +28,6 @@ export interface VersionaryConfig {
29
28
  "release-reference-comments"?: ReleaseReferenceCommentsMode;
30
29
  "release-branch"?: string;
31
30
  "baseline-file"?: string;
32
- "next-release-file"?: string;
33
31
  "bootstrap-sha"?: string;
34
32
  "monorepo-mode"?: "independent" | "fixed";
35
33
  "bump-minor-pre-major"?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "versionary",
3
- "version": "0.32.0",
3
+ "version": "1.0.0",
4
4
  "description": "Automatic release framework based on conventional commits and semantic versioning",
5
5
  "keywords": [
6
6
  "releasing",
@@ -36,7 +36,7 @@
36
36
  "devDependencies": {
37
37
  "@types/node": "^26.0.1",
38
38
  "tsx": "^4.20.6",
39
- "typescript": "^6.0.3",
39
+ "typescript": "^7.0.2",
40
40
  "vite": "^8.0.0",
41
41
  "vitepress": "^1.6.4",
42
42
  "vitest": "^4.1.4"