versionary 0.30.0 → 0.31.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
@@ -50,7 +50,7 @@ release/tag.
50
50
  Current implementation focuses on:
51
51
 
52
52
  - strategy-based version updates (`simple`, `node`, `rust`, `r`, `latex`,
53
- `python`)
53
+ `python`, `julia`)
54
54
  - release planning and changelog generation
55
55
  - review-mode vs direct-mode release flow
56
56
  - a static internal SCM client (`github` provider today)
@@ -110,7 +110,7 @@ Current runtime code uses a flat `src/` layout with clear module boundaries:
110
110
  - `src/cli/`: command router (`run`, `verify`, `plan`, `changelog`, `pr`, `release`)
111
111
  - `src/release/`: release orchestration (plan/changelog/PR/release/state/recovery)
112
112
  - `src/strategy/`: strategy contracts, resolver, and built-in implementations
113
- (`simple`, `node`, `rust`, `r`, `latex`, `python`)
113
+ (`simple`, `node`, `rust`, `r`, `latex`, `python`, `julia`)
114
114
  - `src/scm/`: SCM client contracts and provider implementation(s)
115
115
  - `src/config/`: config loading and schema validation
116
116
  - `src/git/`: git commit/range and repository URL helpers
@@ -144,6 +144,9 @@ For a quick trial, use:
144
144
  Python source file (e.g. `src/<pkg>/__init__.py`) to update a `__version__`
145
145
  assignment instead. Refreshes `poetry.lock`/`uv.lock`/`pdm.lock` at the
146
146
  package root if present
147
+ - `release-type: "julia"` uses `Project.toml` (default) as version source and
148
+ updates the top-level `version` field (Julia keeps `version`/`name` as root
149
+ keys, not under a section)
147
150
  - `release-type` can also be an array of strategy names to compose them across
148
151
  manifests, e.g. `["python", "rust"]` for a PyO3/maturin project: the first
149
152
  entry is the *primary* (drives `readVersion`, `readPackageName`, and consumes
@@ -196,6 +199,11 @@ For a quick trial, use:
196
199
  for a package are the union of the top-level list and the package's own
197
200
  list. The top-level list also applies to a single-package (non-`packages`)
198
201
  repository.
202
+ - per-package `allow-stable-major` overrides the top-level setting for that
203
+ package's own bump (including dependency-propagation and `follows`-driven
204
+ bumps), so a `0.y.z` package can transition to `1.0.0` on a breaking release
205
+ independently of its siblings. In `fixed` mode the single shared version is
206
+ governed by the top-level `allow-stable-major` only.
199
207
 
200
208
  ```jsonc
201
209
  // Editor extension that bundles the root CLI artifact
@@ -40,6 +40,7 @@ export declare const configSchema: z.ZodObject<{
40
40
  "markdown-changelog": "markdown-changelog";
41
41
  "r-news": "r-news";
42
42
  }>>;
43
+ "allow-stable-major": z.ZodOptional<z.ZodBoolean>;
43
44
  "exclude-paths": z.ZodOptional<z.ZodArray<z.ZodString>>;
44
45
  "extra-files": z.ZodOptional<z.ZodArray<z.ZodObject<{
45
46
  type: z.ZodEnum<{
@@ -57,6 +57,7 @@ const packageSchema = z
57
57
  "package-name": z.string().optional(),
58
58
  "changelog-file": z.string().optional(),
59
59
  "changelog-format": z.enum(["markdown-changelog", "r-news"]).optional(),
60
+ "allow-stable-major": z.boolean().optional(),
60
61
  "exclude-paths": z.array(z.string()).optional(),
61
62
  "extra-files": z.array(artifactRuleSchema).optional(),
62
63
  follows: z.array(z.string().min(1)).optional(),
@@ -50,6 +50,8 @@ export function createReleasePlan(cwd = process.cwd()) {
50
50
  const baselineSha = readBaselineSha(cwd) ?? loaded.config["bootstrap-sha"] ?? null;
51
51
  const releaseTargetByPath = new Map(readReleaseTargets(cwd).map((target) => [target.path, target]));
52
52
  const allowStableMajor = loaded.config["allow-stable-major"] ?? false;
53
+ const allowStableMajorForPath = (packagePath) => loaded.config.packages?.[packagePath]?.["allow-stable-major"] ??
54
+ allowStableMajor;
53
55
  const monorepoMode = getMode(loaded.config["monorepo-mode"]);
54
56
  const buildPackagePlan = (pkg) => {
55
57
  const packageContext = resolvePackageStrategyContext(loaded.config, pkg.path, pkg.config);
@@ -78,7 +80,9 @@ export function createReleasePlan(cwd = process.cwd()) {
78
80
  const commits = effectiveCommits;
79
81
  const releaseType = analyzeParsedCommits(parsedCommits);
80
82
  const nextVersion = releaseType
81
- ? bumpVersion(packageCurrentVersion, releaseType, { allowStableMajor })
83
+ ? bumpVersion(packageCurrentVersion, releaseType, {
84
+ allowStableMajor: allowStableMajorForPath(pkg.path),
85
+ })
82
86
  : null;
83
87
  return {
84
88
  path: pkg.path,
@@ -198,7 +202,9 @@ export function createReleasePlan(cwd = process.cwd()) {
198
202
  return {
199
203
  ...pkgPlan,
200
204
  releaseType: "patch",
201
- nextVersion: bumpVersion(current, "patch", { allowStableMajor }),
205
+ nextVersion: bumpVersion(current, "patch", {
206
+ allowStableMajor: allowStableMajorForPath(pkgPlan.path),
207
+ }),
202
208
  bumpReason: "dependency-propagation",
203
209
  dependencySourcePaths,
204
210
  };
@@ -234,7 +240,9 @@ export function createReleasePlan(cwd = process.cwd()) {
234
240
  pkgPlan.bumpReason === undefined;
235
241
  const baseVersion = packageCurrentVersionByPath[pkgPlan.path] ?? pkgPlan.currentVersion;
236
242
  const nextVersion = combinedReleaseType
237
- ? bumpVersion(baseVersion, combinedReleaseType, { allowStableMajor })
243
+ ? bumpVersion(baseVersion, combinedReleaseType, {
244
+ allowStableMajor: allowStableMajorForPath(pkgPlan.path),
245
+ })
238
246
  : null;
239
247
  return {
240
248
  ...pkgPlan,
@@ -14,14 +14,16 @@ export declare function parseVersion(version: string): ParsedVersion;
14
14
  export declare function isValidVersion(version: string): boolean;
15
15
  /**
16
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`).
17
+ * `## [0.28.2](url) (2026-06-02)`, `## 1.2.3`, `## v1.2.3`, `## 0.28.2.9000`,
18
+ * `# pkg 8.0`) rather than a manual-notes heading (e.g. `## Unreleased`,
19
+ * `## Upcoming`).
19
20
  *
20
21
  * 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.
22
+ * version-like token that {@link isValidVersion} accepts, or ends with a bare
23
+ * `major.minor` token (the R `NEWS.md` convention). This errs toward "it's a
24
+ * version", so a real release heading is never mistaken for a notes block (and
25
+ * therefore never stripped). Dates such as `2026-06-02` use dashes, not dots,
26
+ * so they never match either token.
25
27
  */
26
28
  export declare function isVersionHeading(heading: string): boolean;
27
29
  export declare function compareVersions(leftRaw: string, rightRaw: string): number;
@@ -39,23 +39,30 @@ export function isValidVersion(version) {
39
39
  return SEMVER_PATTERN.test(normalizeVersionInput(version));
40
40
  }
41
41
  const VERSION_TOKEN_PATTERN = /v?(\d+\.\d+\.\d+(?:\.\d+)?)/u;
42
+ // R `NEWS.md` headings conventionally abbreviate to `major.minor` (e.g.
43
+ // `# pkg 8.0`). Accept a bare two-component token only when it is the trailing
44
+ // token of the heading, so genuine R release headings register as versions
45
+ // while prose like `## Notes for 2.0 milestone` does not.
46
+ const TRAILING_MAJOR_MINOR_PATTERN = /\bv?\d+\.\d+\s*$/u;
42
47
  /**
43
48
  * 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`).
49
+ * `## [0.28.2](url) (2026-06-02)`, `## 1.2.3`, `## v1.2.3`, `## 0.28.2.9000`,
50
+ * `# pkg 8.0`) rather than a manual-notes heading (e.g. `## Unreleased`,
51
+ * `## Upcoming`).
46
52
  *
47
53
  * 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.
54
+ * version-like token that {@link isValidVersion} accepts, or ends with a bare
55
+ * `major.minor` token (the R `NEWS.md` convention). This errs toward "it's a
56
+ * version", so a real release heading is never mistaken for a notes block (and
57
+ * therefore never stripped). Dates such as `2026-06-02` use dashes, not dots,
58
+ * so they never match either token.
52
59
  */
53
60
  export function isVersionHeading(heading) {
54
61
  const match = heading.match(VERSION_TOKEN_PATTERN);
55
- if (!match?.[1]) {
56
- return false;
62
+ if (match?.[1] && isValidVersion(match[1])) {
63
+ return true;
57
64
  }
58
- return isValidVersion(match[1]);
65
+ return TRAILING_MAJOR_MINOR_PATTERN.test(heading);
59
66
  }
60
67
  function isNumericIdentifier(identifier) {
61
68
  return /^(0|[1-9]\d*)$/u.test(identifier);
@@ -0,0 +1,2 @@
1
+ import type { VersionStrategy } from "./types.js";
2
+ export declare const juliaVersionStrategy: VersionStrategy;
@@ -0,0 +1,115 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parse as parseToml } from "smol-toml";
4
+ function parseProjectToml(content, versionFile) {
5
+ let parsed;
6
+ try {
7
+ parsed = parseToml(content);
8
+ }
9
+ catch (error) {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ throw new Error(`Failed to parse ${versionFile}: ${message}`);
12
+ }
13
+ if (parsed && typeof parsed === "object") {
14
+ return parsed;
15
+ }
16
+ throw new Error(`Failed to parse ${versionFile}: not a TOML table.`);
17
+ }
18
+ function readProjectVersion(content, versionFile) {
19
+ const parsed = parseProjectToml(content, versionFile);
20
+ const version = parsed.version;
21
+ if (typeof version === "string" && version.trim().length > 0) {
22
+ return version.trim();
23
+ }
24
+ throw new Error(`${versionFile} is missing a valid root "version" field required by release-type "julia".`);
25
+ }
26
+ function writeProjectVersion(rawContent, versionFile, version) {
27
+ const lineEnding = rawContent.includes("\r\n") ? "\r\n" : "\n";
28
+ const hasFinalLineEnding = rawContent.endsWith("\n") || rawContent.endsWith("\r\n");
29
+ const lines = rawContent.split(/\r?\n/u);
30
+ let activeTable = null;
31
+ let replaced = false;
32
+ for (let index = 0; index < lines.length; index += 1) {
33
+ const line = lines[index] ?? "";
34
+ const sectionMatch = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/u);
35
+ if (sectionMatch) {
36
+ activeTable = sectionMatch[1]?.trim() ?? null;
37
+ continue;
38
+ }
39
+ // The Julia version is a root key: only match before the first table header.
40
+ if (activeTable !== null) {
41
+ continue;
42
+ }
43
+ const versionMatch = line.match(/^(\s*version\s*=\s*)(["'])([^"']*)(\2)(\s*(?:#.*)?)?$/u);
44
+ if (!versionMatch) {
45
+ continue;
46
+ }
47
+ const [, prefix = "", quote = '"', , , suffix = ""] = versionMatch;
48
+ lines[index] = `${prefix}${quote}${version}${quote}${suffix}`;
49
+ replaced = true;
50
+ break;
51
+ }
52
+ if (!replaced) {
53
+ throw new Error(`${versionFile} is missing a valid root "version" field required by release-type "julia".`);
54
+ }
55
+ let updated = lines.join(lineEnding);
56
+ if (hasFinalLineEnding && !updated.endsWith(lineEnding)) {
57
+ updated += lineEnding;
58
+ }
59
+ if (!hasFinalLineEnding && updated.endsWith(lineEnding)) {
60
+ updated = updated.slice(0, -lineEnding.length);
61
+ }
62
+ return updated;
63
+ }
64
+ export const juliaVersionStrategy = {
65
+ name: "julia",
66
+ getVersionFile(config) {
67
+ return config["version-file"] ?? "Project.toml";
68
+ },
69
+ validateProject(cwd, config) {
70
+ const versionFile = this.getVersionFile(config);
71
+ const versionPath = path.join(cwd, versionFile);
72
+ if (!fs.existsSync(versionPath)) {
73
+ return null;
74
+ }
75
+ try {
76
+ readProjectVersion(fs.readFileSync(versionPath, "utf8"), versionFile);
77
+ return null;
78
+ }
79
+ catch (error) {
80
+ return error instanceof Error ? error.message : String(error);
81
+ }
82
+ },
83
+ readVersion(cwd, config) {
84
+ const versionFile = this.getVersionFile(config);
85
+ const versionPath = path.join(cwd, versionFile);
86
+ if (!fs.existsSync(versionPath)) {
87
+ throw new Error(`Versionary requires ${versionFile} to exist.`);
88
+ }
89
+ return readProjectVersion(fs.readFileSync(versionPath, "utf8"), versionFile);
90
+ },
91
+ writeVersion(cwd, config, version) {
92
+ const versionFile = this.getVersionFile(config);
93
+ const versionPath = path.join(cwd, versionFile);
94
+ if (!fs.existsSync(versionPath)) {
95
+ throw new Error(`Versionary requires ${versionFile} to exist.`);
96
+ }
97
+ const existing = fs.readFileSync(versionPath, "utf8");
98
+ const updated = writeProjectVersion(existing, versionFile, version);
99
+ fs.writeFileSync(versionPath, updated, "utf8");
100
+ return [versionFile];
101
+ },
102
+ readPackageName(cwd, config) {
103
+ const versionFile = this.getVersionFile(config);
104
+ const versionPath = path.join(cwd, versionFile);
105
+ if (!fs.existsSync(versionPath)) {
106
+ throw new Error(`Versionary requires ${versionFile} to exist.`);
107
+ }
108
+ const parsed = parseProjectToml(fs.readFileSync(versionPath, "utf8"), versionFile);
109
+ const name = parsed.name;
110
+ if (typeof name === "string" && name.trim().length > 0) {
111
+ return name.trim();
112
+ }
113
+ return null;
114
+ },
115
+ };
@@ -1,4 +1,5 @@
1
1
  import { compositeVersionStrategy } from "./composite.js";
2
+ import { juliaVersionStrategy } from "./julia.js";
2
3
  import { latexVersionStrategy } from "./latex.js";
3
4
  import { nodeVersionStrategy } from "./node.js";
4
5
  import { pythonVersionStrategy } from "./python.js";
@@ -6,6 +7,7 @@ import { rVersionStrategy } from "./r.js";
6
7
  import { rustVersionStrategy } from "./rust.js";
7
8
  import { simpleVersionStrategy } from "./simple.js";
8
9
  const strategyRegistry = {
10
+ julia: juliaVersionStrategy,
9
11
  latex: latexVersionStrategy,
10
12
  simple: simpleVersionStrategy,
11
13
  node: nodeVersionStrategy,
@@ -13,6 +13,7 @@ export interface VersionaryPackage {
13
13
  "package-name"?: string;
14
14
  "changelog-file"?: string;
15
15
  "changelog-format"?: VersionaryChangelogFormat;
16
+ "allow-stable-major"?: boolean;
16
17
  "exclude-paths"?: string[];
17
18
  "extra-files"?: VersionaryArtifactRule[];
18
19
  follows?: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "versionary",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Automatic release framework based on conventional commits and semantic versioning",
5
5
  "keywords": [
6
6
  "releasing",
@@ -42,6 +42,7 @@
42
42
  "scripts": {
43
43
  "build": "tsc -p tsconfig.json",
44
44
  "typecheck": "tsc -p tsconfig.json --noEmit",
45
+ "gen:schema": "tsx scripts/generate-schema.ts && biome format --write schemas/config.json",
45
46
  "test": "vitest run",
46
47
  "verify": "tsx src/cli/index.ts verify",
47
48
  "run": "tsx src/cli/index.ts run",