versionary 0.28.2 → 0.30.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.
- package/README.md +6 -0
- package/dist/cli/index.js +7 -6
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.js +1 -0
- package/dist/release/changelog.d.ts +20 -0
- package/dist/release/changelog.js +53 -4
- package/dist/release/plan.js +16 -3
- package/dist/release/pr.d.ts +17 -1
- package/dist/release/pr.js +46 -8
- package/dist/release/release.js +4 -3
- package/dist/release/semver.d.ts +12 -0
- package/dist/release/semver.js +19 -0
- package/dist/release/state.d.ts +1 -0
- package/dist/release/state.js +42 -15
- package/dist/scm/github-plugin.js +35 -2
- package/dist/types/config.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -190,6 +190,12 @@ For a quick trial, use:
|
|
|
190
190
|
combining `follows` with `monorepo-mode: "fixed"` are config errors.
|
|
191
191
|
`follows` is non-transitive: A follows B does not imply A follows what B
|
|
192
192
|
follows.
|
|
193
|
+
- per-package `exclude-paths` drops commits that only touch the listed paths
|
|
194
|
+
(relative to the package) from that package's bump and changelog. A
|
|
195
|
+
top-level `exclude-paths` applies to every package; the effective excludes
|
|
196
|
+
for a package are the union of the top-level list and the package's own
|
|
197
|
+
list. The top-level list also applies to a single-package (non-`packages`)
|
|
198
|
+
repository.
|
|
193
199
|
|
|
194
200
|
```jsonc
|
|
195
201
|
// Editor extension that bundles the root CLI artifact
|
package/dist/cli/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
|
|
|
3
3
|
import { loadConfig } from "../config/load-config.js";
|
|
4
4
|
import { prependChangelog, renderReleasePlanChangelog, } from "../release/changelog.js";
|
|
5
5
|
import { createReleasePlan } from "../release/plan.js";
|
|
6
|
-
import { closeStaleReviewRequestIfExists, consumeNextReleaseFile, isReleaseCommitMessage, openOrUpdateReviewRequest, prepareReleasePr, pushReleaseBranch,
|
|
6
|
+
import { closeStaleReviewRequestIfExists, consumeNextReleaseFile, isReleaseCommitMessage, openOrUpdateReviewRequest, prepareReleasePr, pushReleaseBranch, resolveReleaseHighlights, } from "../release/pr.js";
|
|
7
7
|
import { runRelease, runReleaseDetailed } from "../release/release.js";
|
|
8
8
|
import { verifyProject } from "../release/verify-project.js";
|
|
9
9
|
function printVerifyResult() {
|
|
@@ -196,16 +196,17 @@ async function main() {
|
|
|
196
196
|
return 0;
|
|
197
197
|
}
|
|
198
198
|
const loaded = loadConfig();
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
|
|
199
|
+
const highlightsResult = resolveReleaseHighlights(process.cwd(), loaded.config, plan.changelogFile, plan.changelogFormat, logger);
|
|
200
|
+
const section = renderReleasePlanChangelog(plan, {
|
|
201
|
+
highlights: highlightsResult.highlights,
|
|
202
|
+
});
|
|
202
203
|
if (!write) {
|
|
203
204
|
console.log(section);
|
|
204
205
|
return 0;
|
|
205
206
|
}
|
|
206
207
|
prependChangelog(process.cwd(), plan.changelogFile, section, plan.changelogFormat);
|
|
207
|
-
if (
|
|
208
|
-
consumeNextReleaseFile(process.cwd(),
|
|
208
|
+
if (highlightsResult.source === "file" && highlightsResult.filePath) {
|
|
209
|
+
consumeNextReleaseFile(process.cwd(), highlightsResult.filePath);
|
|
209
210
|
}
|
|
210
211
|
console.log(`Updated ${plan.changelogFile}`);
|
|
211
212
|
return 0;
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export declare const configSchema: z.ZodObject<{
|
|
|
30
30
|
"bump-minor-pre-major": z.ZodOptional<z.ZodBoolean>;
|
|
31
31
|
"allow-stable-major": z.ZodOptional<z.ZodBoolean>;
|
|
32
32
|
"include-commit-authors": z.ZodOptional<z.ZodBoolean>;
|
|
33
|
+
"exclude-paths": z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
33
34
|
"release-type": z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
34
35
|
packages: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
35
36
|
"release-type": z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
package/dist/config/schema.js
CHANGED
|
@@ -82,6 +82,7 @@ export const configSchema = z
|
|
|
82
82
|
"bump-minor-pre-major": z.boolean().optional(),
|
|
83
83
|
"allow-stable-major": z.boolean().optional(),
|
|
84
84
|
"include-commit-authors": z.boolean().optional(),
|
|
85
|
+
"exclude-paths": z.array(z.string()).optional(),
|
|
85
86
|
"release-type": z
|
|
86
87
|
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
|
87
88
|
.optional(),
|
|
@@ -25,6 +25,26 @@ export declare function renderReleasePlanChangelog(plan: ReleasePlan, options?:
|
|
|
25
25
|
}): string;
|
|
26
26
|
/** @deprecated Use renderReleasePlanChangelog. */
|
|
27
27
|
export declare function renderSimpleChangelog(plan: SimplePlan): string;
|
|
28
|
+
/**
|
|
29
|
+
* Locate a manual-notes block at the top of an existing changelog and split it
|
|
30
|
+
* out. The notes block is the first release-level heading whose text is not a
|
|
31
|
+
* version (`##` for markdown, `#` for r-news), captured up to — but excluding —
|
|
32
|
+
* the first subsequent version heading at the same level.
|
|
33
|
+
*
|
|
34
|
+
* Returns the captured prose as `highlights` (the heading line itself dropped,
|
|
35
|
+
* trimmed) and the changelog with that block removed as `body`. When the first
|
|
36
|
+
* release-level heading is already a version, or there is none, nothing is
|
|
37
|
+
* stripped and `highlights` is empty.
|
|
38
|
+
*
|
|
39
|
+
* Only the topmost heading is ever eligible, and capture stops at the first
|
|
40
|
+
* version heading, so prior releases are never swallowed. For r-news this also
|
|
41
|
+
* subsumes the conventional `# pkg (development version)` header, whose body
|
|
42
|
+
* (if any) becomes the release notes.
|
|
43
|
+
*/
|
|
44
|
+
export declare function extractUnreleasedNotes(existing: string, format?: VersionaryChangelogFormat): {
|
|
45
|
+
highlights: string;
|
|
46
|
+
body: string;
|
|
47
|
+
};
|
|
28
48
|
export declare function prependChangelog(cwd: string, changelogFile: string, section: string, format?: VersionaryChangelogFormat): void;
|
|
29
49
|
export declare function renderRNewsReleaseNotes(input: {
|
|
30
50
|
packageName: string;
|
|
@@ -3,6 +3,7 @@ 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
5
|
import { resolvePackageDependencies, } from "./plan.js";
|
|
6
|
+
import { isVersionHeading } from "./semver.js";
|
|
6
7
|
const REVIEW_REQUEST_FOOTER = "---\n\nThis PR was generated by [Versionary](https://github.com/jolars/versionary).";
|
|
7
8
|
function formatDate() {
|
|
8
9
|
return new Date().toISOString().slice(0, 10);
|
|
@@ -201,15 +202,63 @@ export function renderReleasePlanChangelog(plan, options = {}) {
|
|
|
201
202
|
export function renderSimpleChangelog(plan) {
|
|
202
203
|
return renderReleasePlanChangelog(plan);
|
|
203
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Locate a manual-notes block at the top of an existing changelog and split it
|
|
207
|
+
* out. The notes block is the first release-level heading whose text is not a
|
|
208
|
+
* version (`##` for markdown, `#` for r-news), captured up to — but excluding —
|
|
209
|
+
* the first subsequent version heading at the same level.
|
|
210
|
+
*
|
|
211
|
+
* Returns the captured prose as `highlights` (the heading line itself dropped,
|
|
212
|
+
* trimmed) and the changelog with that block removed as `body`. When the first
|
|
213
|
+
* release-level heading is already a version, or there is none, nothing is
|
|
214
|
+
* stripped and `highlights` is empty.
|
|
215
|
+
*
|
|
216
|
+
* Only the topmost heading is ever eligible, and capture stops at the first
|
|
217
|
+
* version heading, so prior releases are never swallowed. For r-news this also
|
|
218
|
+
* subsumes the conventional `# pkg (development version)` header, whose body
|
|
219
|
+
* (if any) becomes the release notes.
|
|
220
|
+
*/
|
|
221
|
+
export function extractUnreleasedNotes(existing, format = "markdown-changelog") {
|
|
222
|
+
const headingPattern = format === "r-news" ? /^#(?!#)\s+/u : /^##(?!#)\s+/u;
|
|
223
|
+
const lines = existing.split("\n");
|
|
224
|
+
let firstHeadingIdx = -1;
|
|
225
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
226
|
+
if (headingPattern.test(lines[index])) {
|
|
227
|
+
firstHeadingIdx = index;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (firstHeadingIdx === -1 || isVersionHeading(lines[firstHeadingIdx])) {
|
|
232
|
+
return { highlights: "", body: existing };
|
|
233
|
+
}
|
|
234
|
+
let nextVersionIdx = lines.length;
|
|
235
|
+
for (let index = firstHeadingIdx + 1; index < lines.length; index += 1) {
|
|
236
|
+
if (headingPattern.test(lines[index]) && isVersionHeading(lines[index])) {
|
|
237
|
+
nextVersionIdx = index;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const highlights = lines
|
|
242
|
+
.slice(firstHeadingIdx + 1, nextVersionIdx)
|
|
243
|
+
.join("\n")
|
|
244
|
+
.trim();
|
|
245
|
+
const body = [
|
|
246
|
+
...lines.slice(0, firstHeadingIdx),
|
|
247
|
+
...lines.slice(nextVersionIdx),
|
|
248
|
+
].join("\n");
|
|
249
|
+
return { highlights, body };
|
|
250
|
+
}
|
|
204
251
|
export function prependChangelog(cwd, changelogFile, section, format = "markdown-changelog") {
|
|
205
252
|
const changelogPath = path.join(cwd, changelogFile);
|
|
206
|
-
const
|
|
253
|
+
const existingRaw = fs.existsSync(changelogPath)
|
|
207
254
|
? fs.readFileSync(changelogPath, "utf8")
|
|
208
255
|
: "";
|
|
256
|
+
// Strip any manual-notes block; its prose is folded into `section` upstream,
|
|
257
|
+
// so leaving it here would duplicate it.
|
|
258
|
+
const { body: existing } = extractUnreleasedNotes(existingRaw, format);
|
|
209
259
|
if (format === "r-news") {
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
const next = `${`${section}${separator}${bodyWithoutDevHeader}`.trimEnd()}\n`;
|
|
260
|
+
const separator = existing.length > 0 ? "\n\n" : "";
|
|
261
|
+
const next = `${`${section}${separator}${existing}`.trimEnd()}\n`;
|
|
213
262
|
fs.writeFileSync(changelogPath, next, "utf8");
|
|
214
263
|
return;
|
|
215
264
|
}
|
package/dist/release/plan.js
CHANGED
|
@@ -58,9 +58,22 @@ export function createReleasePlan(cwd = process.cwd()) {
|
|
|
58
58
|
throw new Error(`Versionary requires ${packageContext.versionFile} to exist for package "${pkg.path}".`);
|
|
59
59
|
}
|
|
60
60
|
const packageCurrentVersion = packageContext.strategy.readVersion(cwd, packageContext.config);
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
const excludePaths = [
|
|
62
|
+
...new Set([
|
|
63
|
+
...(loaded.config["exclude-paths"] ?? []),
|
|
64
|
+
...(pkg.config["exclude-paths"] ?? []),
|
|
65
|
+
]),
|
|
66
|
+
];
|
|
67
|
+
const isImplicitRoot = !hasPackages && pkg.path === ".";
|
|
68
|
+
let parsedCommits;
|
|
69
|
+
if (isImplicitRoot && excludePaths.length === 0) {
|
|
70
|
+
parsedCommits = getParsedCommitsSinceLastTag(cwd, baselineSha);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
parsedCommits = getParsedCommitsForPath(cwd, isImplicitRoot
|
|
74
|
+
? baselineSha
|
|
75
|
+
: (releaseTargetByPath.get(pkg.path)?.tag ?? baselineSha), pkg.path, excludePaths);
|
|
76
|
+
}
|
|
64
77
|
const effectiveCommits = applyRevertSuppression(parsedCommits);
|
|
65
78
|
const commits = effectiveCommits;
|
|
66
79
|
const releaseType = analyzeParsedCommits(parsedCommits);
|
package/dist/release/pr.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ParsedCommit } from "../git/commits.js";
|
|
2
|
-
import type { VersionaryConfig } from "../types/config.js";
|
|
2
|
+
import type { VersionaryChangelogFormat, VersionaryConfig } from "../types/config.js";
|
|
3
3
|
import type { VersionaryPluginContext } from "../types/plugins.js";
|
|
4
4
|
import { type ReleasePlan, type SimplePlan } from "./plan.js";
|
|
5
5
|
export declare function getNextReleaseFile(config: VersionaryConfig): string;
|
|
@@ -10,6 +10,22 @@ export declare function readNextReleaseHighlights(cwd: string, config: Versionar
|
|
|
10
10
|
export declare function consumeNextReleaseFile(cwd: string, filePath: string): {
|
|
11
11
|
tracked: boolean;
|
|
12
12
|
};
|
|
13
|
+
/**
|
|
14
|
+
* Read the manual-notes ("Unreleased") prose from the top of a changelog file.
|
|
15
|
+
* Returns an empty string when the file is absent or has no notes block.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readChangelogHighlights(cwd: string, changelogFile: string, format: VersionaryChangelogFormat): string;
|
|
18
|
+
export interface ResolvedReleaseHighlights {
|
|
19
|
+
highlights: string;
|
|
20
|
+
source: "changelog" | "file" | "none";
|
|
21
|
+
filePath?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
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.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveReleaseHighlights(cwd: string, config: VersionaryConfig, changelogFile: string, format: VersionaryChangelogFormat, logger?: VersionaryPluginContext["logger"]): ResolvedReleaseHighlights;
|
|
13
29
|
export declare function splitSafeDirtyFiles(files: string[]): {
|
|
14
30
|
ignored: string[];
|
|
15
31
|
blocking: string[];
|
package/dist/release/pr.js
CHANGED
|
@@ -6,7 +6,7 @@ import { ensureGitIdentity } from "../git/identity.js";
|
|
|
6
6
|
import { getScmClient } from "../scm/client.js";
|
|
7
7
|
import { resolvePackageStrategyContext, resolveReleaseName, } from "../strategy/package-context.js";
|
|
8
8
|
import { applyConfiguredArtifactRules } from "./artifact-rules.js";
|
|
9
|
-
import { prependChangelog, renderReleaseNotesSection, renderReleasePlanChangelog, renderReviewRequestFooter, } from "./changelog.js";
|
|
9
|
+
import { extractUnreleasedNotes, prependChangelog, renderReleaseNotesSection, renderReleasePlanChangelog, renderReviewRequestFooter, } from "./changelog.js";
|
|
10
10
|
import { createReleasePlan, getChangelogDefaults, resolvePackageDependencies, } from "./plan.js";
|
|
11
11
|
import { getBaselineStatePath, writeBaselineSha, } from "./state.js";
|
|
12
12
|
const SAFE_DIRTY_FILES = new Set([
|
|
@@ -49,6 +49,39 @@ export function consumeNextReleaseFile(cwd, filePath) {
|
|
|
49
49
|
}
|
|
50
50
|
return { tracked };
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Read the manual-notes ("Unreleased") prose from the top of a changelog file.
|
|
54
|
+
* Returns an empty string when the file is absent or has no notes block.
|
|
55
|
+
*/
|
|
56
|
+
export function readChangelogHighlights(cwd, changelogFile, format) {
|
|
57
|
+
const changelogPath = path.join(cwd, changelogFile);
|
|
58
|
+
if (!fs.existsSync(changelogPath)) {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
const existing = fs.readFileSync(changelogPath, "utf8");
|
|
62
|
+
return extractUnreleasedNotes(existing, format).highlights;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
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.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveReleaseHighlights(cwd, config, changelogFile, format, logger) {
|
|
70
|
+
const fromChangelog = readChangelogHighlights(cwd, changelogFile, format);
|
|
71
|
+
if (fromChangelog.length > 0) {
|
|
72
|
+
return { highlights: fromChangelog, source: "changelog" };
|
|
73
|
+
}
|
|
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
|
+
return { highlights: "", source: "none" };
|
|
84
|
+
}
|
|
52
85
|
function listTrackedDirtyFiles(cwd) {
|
|
53
86
|
const status = execFileSync("git", ["status", "--porcelain", "--untracked-files=no"], {
|
|
54
87
|
cwd,
|
|
@@ -264,16 +297,18 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
|
|
|
264
297
|
}
|
|
265
298
|
const updatedArtifactFiles = applyConfiguredArtifactRules(cwd, loaded.config, plan);
|
|
266
299
|
const packageReleaseMetadata = buildPackageReleaseMetadata(cwd, plan, loaded.config);
|
|
267
|
-
const
|
|
268
|
-
const highlights =
|
|
300
|
+
const highlightsResult = resolveReleaseHighlights(cwd, loaded.config, plan.changelogFile, plan.changelogFormat, options.logger);
|
|
301
|
+
const highlights = highlightsResult.highlights;
|
|
269
302
|
const section = renderReleasePlanChangelog(plan, { highlights, cwd });
|
|
270
303
|
prependChangelog(cwd, plan.changelogFile, section, plan.changelogFormat);
|
|
271
304
|
const updatedChangelogFiles = [plan.changelogFile];
|
|
272
305
|
let consumedHighlightsPath = null;
|
|
273
|
-
|
|
274
|
-
|
|
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);
|
|
275
310
|
if (tracked) {
|
|
276
|
-
consumedHighlightsPath =
|
|
311
|
+
consumedHighlightsPath = highlightsResult.filePath;
|
|
277
312
|
}
|
|
278
313
|
}
|
|
279
314
|
for (const packagePlan of plan.packages ?? []) {
|
|
@@ -292,6 +327,8 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
|
|
|
292
327
|
if (!packageMetadata) {
|
|
293
328
|
continue;
|
|
294
329
|
}
|
|
330
|
+
const packageChangelogPath = path.posix.join(packagePlan.path, packageChangelogFile);
|
|
331
|
+
const packageHighlights = readChangelogHighlights(cwd, packageChangelogPath, "markdown-changelog");
|
|
295
332
|
const packageSection = renderReleaseNotesSection({
|
|
296
333
|
currentVersion: packagePlan.currentVersion,
|
|
297
334
|
nextVersion: packagePlan.nextVersion,
|
|
@@ -299,9 +336,10 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
|
|
|
299
336
|
tagPrefix: packageMetadata.tagPrefix,
|
|
300
337
|
cwd,
|
|
301
338
|
dependencies: resolvePackageDependencies(plan, packagePlan.path),
|
|
339
|
+
highlights: packageHighlights,
|
|
302
340
|
});
|
|
303
|
-
prependChangelog(cwd,
|
|
304
|
-
updatedChangelogFiles.push(
|
|
341
|
+
prependChangelog(cwd, packageChangelogPath, packageSection, "markdown-changelog");
|
|
342
|
+
updatedChangelogFiles.push(packageChangelogPath);
|
|
305
343
|
}
|
|
306
344
|
const releaseTargets = buildReleaseTargets(cwd, plan, loaded.config);
|
|
307
345
|
const branch = plan.releaseBranchPrefix;
|
package/dist/release/release.js
CHANGED
|
@@ -8,7 +8,7 @@ import { resolveVersionStrategy } from "../strategy/resolve.js";
|
|
|
8
8
|
import { getChangelogDefaults } from "./plan.js";
|
|
9
9
|
import { isReleaseCommitMessage } from "./pr.js";
|
|
10
10
|
import { executeIdempotentReleaseTarget } from "./recovery.js";
|
|
11
|
-
import {
|
|
11
|
+
import { readPendingReleaseTargets } from "./state.js";
|
|
12
12
|
function escapeRegExp(input) {
|
|
13
13
|
return input.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
14
14
|
}
|
|
@@ -147,7 +147,7 @@ export async function runReleaseDetailed(cwd = process.cwd(), options = {}) {
|
|
|
147
147
|
const referenceCommentMode = loaded.config["release-reference-comments"] ?? "off";
|
|
148
148
|
const version = strategy.readVersion(cwd, loaded.config);
|
|
149
149
|
const defaultTag = `v${version}`;
|
|
150
|
-
const releaseTargets =
|
|
150
|
+
const releaseTargets = readPendingReleaseTargets(cwd);
|
|
151
151
|
const targets = releaseTargets.length > 0
|
|
152
152
|
? releaseTargets
|
|
153
153
|
: [
|
|
@@ -195,7 +195,8 @@ export async function runReleaseDetailed(cwd = process.cwd(), options = {}) {
|
|
|
195
195
|
tagStatus: outcome.tagStatus,
|
|
196
196
|
metadataStatus: outcome.metadataStatus,
|
|
197
197
|
});
|
|
198
|
-
|
|
198
|
+
const releaseWasCreated = outcome.tagStatus === "created" || outcome.metadataStatus === "created";
|
|
199
|
+
if (references.length > 0 && releaseWasCreated) {
|
|
199
200
|
referenceReleases.push({
|
|
200
201
|
name: resolveTargetPackageName(cwd, loaded.config, target.path),
|
|
201
202
|
tag: outcome.tag,
|
package/dist/release/semver.d.ts
CHANGED
|
@@ -12,5 +12,17 @@ export interface BumpVersionOptions {
|
|
|
12
12
|
}
|
|
13
13
|
export declare function parseVersion(version: string): ParsedVersion;
|
|
14
14
|
export declare function isValidVersion(version: string): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Decide whether a changelog heading denotes a released version (e.g.
|
|
17
|
+
* `## [0.28.2](url) (2026-06-02)`, `## 1.2.3`, `## v1.2.3`, `## 0.28.2.9000`)
|
|
18
|
+
* rather than a manual-notes heading (e.g. `## Unreleased`, `## Upcoming`).
|
|
19
|
+
*
|
|
20
|
+
* Intentionally liberal: a heading counts as a version if it contains any
|
|
21
|
+
* version-like token that {@link isValidVersion} accepts. This errs toward
|
|
22
|
+
* "it's a version", so a real release heading is never mistaken for a notes
|
|
23
|
+
* block (and therefore never stripped). Dates such as `2026-06-02` use dashes,
|
|
24
|
+
* not dots, so they never match the three-component token.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isVersionHeading(heading: string): boolean;
|
|
15
27
|
export declare function compareVersions(leftRaw: string, rightRaw: string): number;
|
|
16
28
|
export declare function bumpVersion(current: string, releaseType: Exclude<ReleaseType, null>, options?: BumpVersionOptions): string;
|
package/dist/release/semver.js
CHANGED
|
@@ -38,6 +38,25 @@ export function parseVersion(version) {
|
|
|
38
38
|
export function isValidVersion(version) {
|
|
39
39
|
return SEMVER_PATTERN.test(normalizeVersionInput(version));
|
|
40
40
|
}
|
|
41
|
+
const VERSION_TOKEN_PATTERN = /v?(\d+\.\d+\.\d+(?:\.\d+)?)/u;
|
|
42
|
+
/**
|
|
43
|
+
* Decide whether a changelog heading denotes a released version (e.g.
|
|
44
|
+
* `## [0.28.2](url) (2026-06-02)`, `## 1.2.3`, `## v1.2.3`, `## 0.28.2.9000`)
|
|
45
|
+
* rather than a manual-notes heading (e.g. `## Unreleased`, `## Upcoming`).
|
|
46
|
+
*
|
|
47
|
+
* Intentionally liberal: a heading counts as a version if it contains any
|
|
48
|
+
* version-like token that {@link isValidVersion} accepts. This errs toward
|
|
49
|
+
* "it's a version", so a real release heading is never mistaken for a notes
|
|
50
|
+
* block (and therefore never stripped). Dates such as `2026-06-02` use dashes,
|
|
51
|
+
* not dots, so they never match the three-component token.
|
|
52
|
+
*/
|
|
53
|
+
export function isVersionHeading(heading) {
|
|
54
|
+
const match = heading.match(VERSION_TOKEN_PATTERN);
|
|
55
|
+
if (!match?.[1]) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return isValidVersion(match[1]);
|
|
59
|
+
}
|
|
41
60
|
function isNumericIdentifier(identifier) {
|
|
42
61
|
return /^(0|[1-9]\d*)$/u.test(identifier);
|
|
43
62
|
}
|
package/dist/release/state.d.ts
CHANGED
|
@@ -6,4 +6,5 @@ export interface ReleaseTargetState {
|
|
|
6
6
|
export declare function getBaselineStatePath(cwd: string): string;
|
|
7
7
|
export declare function readBaselineSha(cwd?: string): string | null;
|
|
8
8
|
export declare function readReleaseTargets(cwd?: string): ReleaseTargetState[];
|
|
9
|
+
export declare function readPendingReleaseTargets(cwd?: string): ReleaseTargetState[];
|
|
9
10
|
export declare function writeBaselineSha(cwd?: string, sha?: string, releaseTargets?: ReleaseTargetState[]): void;
|
package/dist/release/state.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadConfig } from "../config/load-config.js";
|
|
|
5
5
|
const MANIFEST_VERSION_KEY = "manifest-version";
|
|
6
6
|
const BASELINE_SHA_KEY = "baseline-sha";
|
|
7
7
|
const RELEASE_TARGETS_KEY = "release-targets";
|
|
8
|
+
const PENDING_RELEASE_TARGETS_KEY = "pending-release-targets";
|
|
8
9
|
function parseStateFile(raw, filePath) {
|
|
9
10
|
const parsed = JSON.parse(raw);
|
|
10
11
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
@@ -19,24 +20,28 @@ function parseStateFile(raw, filePath) {
|
|
|
19
20
|
typeof manifest[BASELINE_SHA_KEY] !== "string") {
|
|
20
21
|
throw new Error(`Invalid release manifest at ${filePath}: ${BASELINE_SHA_KEY} must be a string.`);
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
validateReleaseTargets(manifest[RELEASE_TARGETS_KEY], RELEASE_TARGETS_KEY, filePath);
|
|
24
|
+
validateReleaseTargets(manifest[PENDING_RELEASE_TARGETS_KEY], PENDING_RELEASE_TARGETS_KEY, filePath);
|
|
25
|
+
return manifest;
|
|
26
|
+
}
|
|
27
|
+
function validateReleaseTargets(value, key, filePath) {
|
|
28
|
+
if (value === undefined) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!Array.isArray(value)) {
|
|
32
|
+
throw new Error(`Invalid release manifest at ${filePath}: ${key} must be an array.`);
|
|
25
33
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
throw new Error(`Invalid release manifest at ${filePath}: ${RELEASE_TARGETS_KEY} must contain string path, version, and tag.`);
|
|
36
|
-
}
|
|
34
|
+
for (const target of value) {
|
|
35
|
+
if (!target || typeof target !== "object" || Array.isArray(target)) {
|
|
36
|
+
throw new Error(`Invalid release manifest at ${filePath}: each release target must be an object.`);
|
|
37
|
+
}
|
|
38
|
+
const record = target;
|
|
39
|
+
if (typeof record.path !== "string" ||
|
|
40
|
+
typeof record.version !== "string" ||
|
|
41
|
+
typeof record.tag !== "string") {
|
|
42
|
+
throw new Error(`Invalid release manifest at ${filePath}: ${key} must contain string path, version, and tag.`);
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
|
-
return manifest;
|
|
40
45
|
}
|
|
41
46
|
export function getBaselineStatePath(cwd) {
|
|
42
47
|
const loaded = loadConfig(cwd);
|
|
@@ -62,6 +67,9 @@ export function readBaselineSha(cwd = process.cwd()) {
|
|
|
62
67
|
const parsed = parseStateFile(fs.readFileSync(filePath, "utf8"), filePath);
|
|
63
68
|
return parsed[BASELINE_SHA_KEY] ?? null;
|
|
64
69
|
}
|
|
70
|
+
// Accumulated per-package baseline: the latest released tag for every package
|
|
71
|
+
// ever released. `plan` uses these tags as the commit-range floor per package,
|
|
72
|
+
// so this list must persist entries across releases rather than be replaced.
|
|
65
73
|
export function readReleaseTargets(cwd = process.cwd()) {
|
|
66
74
|
const filePath = getBaselineStatePath(cwd);
|
|
67
75
|
if (!fs.existsSync(filePath)) {
|
|
@@ -70,6 +78,18 @@ export function readReleaseTargets(cwd = process.cwd()) {
|
|
|
70
78
|
const parsed = parseStateFile(fs.readFileSync(filePath, "utf8"), filePath);
|
|
71
79
|
return parsed[RELEASE_TARGETS_KEY] ?? [];
|
|
72
80
|
}
|
|
81
|
+
// The publish set introduced by the current release PR. `release` consumes this
|
|
82
|
+
// 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).
|
|
85
|
+
export function readPendingReleaseTargets(cwd = process.cwd()) {
|
|
86
|
+
const filePath = getBaselineStatePath(cwd);
|
|
87
|
+
if (!fs.existsSync(filePath)) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const parsed = parseStateFile(fs.readFileSync(filePath, "utf8"), filePath);
|
|
91
|
+
return (parsed[PENDING_RELEASE_TARGETS_KEY] ?? parsed[RELEASE_TARGETS_KEY] ?? []);
|
|
92
|
+
}
|
|
73
93
|
export function writeBaselineSha(cwd = process.cwd(), sha, releaseTargets) {
|
|
74
94
|
const baselineShaValue = sha ??
|
|
75
95
|
execFileSync("git", ["rev-parse", "HEAD"], {
|
|
@@ -82,6 +102,9 @@ export function writeBaselineSha(cwd = process.cwd(), sha, releaseTargets) {
|
|
|
82
102
|
? parseStateFile(fs.readFileSync(filePath, "utf8"), filePath)
|
|
83
103
|
: {};
|
|
84
104
|
const existingTargets = existing[RELEASE_TARGETS_KEY] ?? [];
|
|
105
|
+
// Merge the current targets into the accumulated baseline (latest tag per
|
|
106
|
+
// path wins, since `releaseTargets` is appended last), but record the current
|
|
107
|
+
// targets verbatim as the pending publish set.
|
|
85
108
|
const nextTargets = releaseTargets === undefined
|
|
86
109
|
? existingTargets
|
|
87
110
|
: [
|
|
@@ -90,10 +113,14 @@ export function writeBaselineSha(cwd = process.cwd(), sha, releaseTargets) {
|
|
|
90
113
|
target,
|
|
91
114
|
])).values(),
|
|
92
115
|
].sort((a, b) => a.path.localeCompare(b.path));
|
|
116
|
+
const nextPending = releaseTargets === undefined
|
|
117
|
+
? (existing[PENDING_RELEASE_TARGETS_KEY] ?? [])
|
|
118
|
+
: [...releaseTargets].sort((a, b) => a.path.localeCompare(b.path));
|
|
93
119
|
const next = {
|
|
94
120
|
[MANIFEST_VERSION_KEY]: 1,
|
|
95
121
|
[BASELINE_SHA_KEY]: baselineShaValue,
|
|
96
122
|
[RELEASE_TARGETS_KEY]: nextTargets,
|
|
123
|
+
[PENDING_RELEASE_TARGETS_KEY]: nextPending,
|
|
97
124
|
};
|
|
98
125
|
fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
99
126
|
}
|
|
@@ -99,14 +99,30 @@ function groupReleasesByIssue(releases) {
|
|
|
99
99
|
}
|
|
100
100
|
return new Map([...byIssue.entries()].sort(([a], [b]) => a - b));
|
|
101
101
|
}
|
|
102
|
+
const RELEASE_REFERENCE_COMMENT_FOOTER = "Released by [Versionary](https://github.com/jolars/versionary).";
|
|
102
103
|
function renderReleaseLink(release) {
|
|
103
104
|
if (release.name) {
|
|
104
105
|
return `[\`${release.name}\` v${release.version}](${release.releaseUrl})`;
|
|
105
106
|
}
|
|
106
107
|
return `[version ${release.version}](${release.releaseUrl})`;
|
|
107
108
|
}
|
|
109
|
+
// Reads bodies of prior Versionary release-reference comments on an issue so we
|
|
110
|
+
// can avoid re-announcing a release that has already been posted (e.g. when an
|
|
111
|
+
// already-published target is re-processed on a later release run).
|
|
112
|
+
async function readExistingReferenceComments(octokit, repo, issueNumber) {
|
|
113
|
+
const response = await octokit.issues.listComments({
|
|
114
|
+
owner: repo.owner,
|
|
115
|
+
repo: repo.repo,
|
|
116
|
+
issue_number: issueNumber,
|
|
117
|
+
per_page: 100,
|
|
118
|
+
});
|
|
119
|
+
return response.data
|
|
120
|
+
.map((comment) => comment.body ?? "")
|
|
121
|
+
.filter((body) => body.includes(RELEASE_REFERENCE_COMMENT_FOOTER))
|
|
122
|
+
.join("\n");
|
|
123
|
+
}
|
|
108
124
|
function renderReleaseReferenceCommentBody(releases) {
|
|
109
|
-
const footer =
|
|
125
|
+
const footer = RELEASE_REFERENCE_COMMENT_FOOTER;
|
|
110
126
|
if (releases.length === 1) {
|
|
111
127
|
const release = releases[0];
|
|
112
128
|
if (!release) {
|
|
@@ -312,7 +328,24 @@ export function createGitHubPlugin() {
|
|
|
312
328
|
const releasesByIssue = groupReleasesByIssue(input.releases);
|
|
313
329
|
const commented = [];
|
|
314
330
|
for (const [reference, releases] of releasesByIssue) {
|
|
315
|
-
|
|
331
|
+
let announcedText;
|
|
332
|
+
try {
|
|
333
|
+
announcedText = await readExistingReferenceComments(octokit, repo, reference);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
const { message } = parseGitHubError(error);
|
|
337
|
+
if (mode === "strict") {
|
|
338
|
+
throw new Error(`Failed listing existing comments on #${reference}: [${repoRef(repo)}] ${message}`);
|
|
339
|
+
}
|
|
340
|
+
context.logger?.warn(`Could not check existing comments on #${reference}, posting anyway: [${repoRef(repo)}] ${message}`);
|
|
341
|
+
announcedText = "";
|
|
342
|
+
}
|
|
343
|
+
const pendingReleases = releases.filter((release) => !announcedText.includes(release.releaseUrl));
|
|
344
|
+
if (pendingReleases.length === 0) {
|
|
345
|
+
context.logger?.info(`Skipping release reference comment on #${reference}: already announced.`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const body = renderReleaseReferenceCommentBody(pendingReleases);
|
|
316
349
|
try {
|
|
317
350
|
await octokit.issues.createComment({
|
|
318
351
|
owner: repo.owner,
|
package/dist/types/config.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export interface VersionaryConfig {
|
|
|
33
33
|
"bump-minor-pre-major"?: boolean;
|
|
34
34
|
"allow-stable-major"?: boolean;
|
|
35
35
|
"include-commit-authors"?: boolean;
|
|
36
|
+
"exclude-paths"?: string[];
|
|
36
37
|
"release-type"?: string | string[];
|
|
37
38
|
packages?: Record<string, VersionaryPackage>;
|
|
38
39
|
}
|