versionary 0.29.0 → 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 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
@@ -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>]>>;
@@ -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(),
@@ -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 parsedCommits = !hasPackages && pkg.path === "."
62
- ? getParsedCommitsSinceLastTag(cwd, baselineSha)
63
- : getParsedCommitsForPath(cwd, releaseTargetByPath.get(pkg.path)?.tag ?? baselineSha, pkg.path, pkg.config["exclude-paths"] ?? []);
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);
@@ -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 { readReleaseTargets } from "./state.js";
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 = readReleaseTargets(cwd);
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
- if (references.length > 0) {
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,
@@ -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;
@@ -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
- if (manifest[RELEASE_TARGETS_KEY] !== undefined &&
23
- !Array.isArray(manifest[RELEASE_TARGETS_KEY])) {
24
- throw new Error(`Invalid release manifest at ${filePath}: ${RELEASE_TARGETS_KEY} must be an array.`);
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
- if (Array.isArray(manifest[RELEASE_TARGETS_KEY])) {
27
- for (const target of manifest[RELEASE_TARGETS_KEY]) {
28
- if (!target || typeof target !== "object" || Array.isArray(target)) {
29
- throw new Error(`Invalid release manifest at ${filePath}: each release target must be an object.`);
30
- }
31
- const record = target;
32
- if (typeof record.path !== "string" ||
33
- typeof record.version !== "string" ||
34
- typeof record.tag !== "string") {
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 = "Released by [Versionary](https://github.com/jolars/versionary).";
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
- const body = renderReleaseReferenceCommentBody(releases);
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,
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "versionary",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Automatic release framework based on conventional commits and semantic versioning",
5
5
  "keywords": [
6
6
  "releasing",