versionary 0.29.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 +16 -2
- package/dist/config/schema.d.ts +2 -0
- package/dist/config/schema.js +2 -0
- package/dist/release/plan.js +27 -6
- package/dist/release/release.js +4 -3
- package/dist/release/semver.d.ts +8 -6
- package/dist/release/semver.js +16 -9
- 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/strategy/julia.d.ts +2 -0
- package/dist/strategy/julia.js +115 -0
- package/dist/strategy/resolve.js +2 -0
- package/dist/types/config.d.ts +2 -0
- package/package.json +2 -1
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
|
|
@@ -190,6 +193,17 @@ For a quick trial, use:
|
|
|
190
193
|
combining `follows` with `monorepo-mode: "fixed"` are config errors.
|
|
191
194
|
`follows` is non-transitive: A follows B does not imply A follows what B
|
|
192
195
|
follows.
|
|
196
|
+
- per-package `exclude-paths` drops commits that only touch the listed paths
|
|
197
|
+
(relative to the package) from that package's bump and changelog. A
|
|
198
|
+
top-level `exclude-paths` applies to every package; the effective excludes
|
|
199
|
+
for a package are the union of the top-level list and the package's own
|
|
200
|
+
list. The top-level list also applies to a single-package (non-`packages`)
|
|
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.
|
|
193
207
|
|
|
194
208
|
```jsonc
|
|
195
209
|
// Editor extension that bundles the root CLI artifact
|
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>]>>;
|
|
@@ -39,6 +40,7 @@ export declare const configSchema: z.ZodObject<{
|
|
|
39
40
|
"markdown-changelog": "markdown-changelog";
|
|
40
41
|
"r-news": "r-news";
|
|
41
42
|
}>>;
|
|
43
|
+
"allow-stable-major": z.ZodOptional<z.ZodBoolean>;
|
|
42
44
|
"exclude-paths": z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
43
45
|
"extra-files": z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
44
46
|
type: z.ZodEnum<{
|
package/dist/config/schema.js
CHANGED
|
@@ -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(),
|
|
@@ -82,6 +83,7 @@ export const configSchema = z
|
|
|
82
83
|
"bump-minor-pre-major": z.boolean().optional(),
|
|
83
84
|
"allow-stable-major": z.boolean().optional(),
|
|
84
85
|
"include-commit-authors": z.boolean().optional(),
|
|
86
|
+
"exclude-paths": z.array(z.string()).optional(),
|
|
85
87
|
"release-type": z
|
|
86
88
|
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
|
87
89
|
.optional(),
|
package/dist/release/plan.js
CHANGED
|
@@ -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);
|
|
@@ -58,14 +60,29 @@ export function createReleasePlan(cwd = process.cwd()) {
|
|
|
58
60
|
throw new Error(`Versionary requires ${packageContext.versionFile} to exist for package "${pkg.path}".`);
|
|
59
61
|
}
|
|
60
62
|
const packageCurrentVersion = packageContext.strategy.readVersion(cwd, packageContext.config);
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
const excludePaths = [
|
|
64
|
+
...new Set([
|
|
65
|
+
...(loaded.config["exclude-paths"] ?? []),
|
|
66
|
+
...(pkg.config["exclude-paths"] ?? []),
|
|
67
|
+
]),
|
|
68
|
+
];
|
|
69
|
+
const isImplicitRoot = !hasPackages && pkg.path === ".";
|
|
70
|
+
let parsedCommits;
|
|
71
|
+
if (isImplicitRoot && excludePaths.length === 0) {
|
|
72
|
+
parsedCommits = getParsedCommitsSinceLastTag(cwd, baselineSha);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
parsedCommits = getParsedCommitsForPath(cwd, isImplicitRoot
|
|
76
|
+
? baselineSha
|
|
77
|
+
: (releaseTargetByPath.get(pkg.path)?.tag ?? baselineSha), pkg.path, excludePaths);
|
|
78
|
+
}
|
|
64
79
|
const effectiveCommits = applyRevertSuppression(parsedCommits);
|
|
65
80
|
const commits = effectiveCommits;
|
|
66
81
|
const releaseType = analyzeParsedCommits(parsedCommits);
|
|
67
82
|
const nextVersion = releaseType
|
|
68
|
-
? bumpVersion(packageCurrentVersion, releaseType, {
|
|
83
|
+
? bumpVersion(packageCurrentVersion, releaseType, {
|
|
84
|
+
allowStableMajor: allowStableMajorForPath(pkg.path),
|
|
85
|
+
})
|
|
69
86
|
: null;
|
|
70
87
|
return {
|
|
71
88
|
path: pkg.path,
|
|
@@ -185,7 +202,9 @@ export function createReleasePlan(cwd = process.cwd()) {
|
|
|
185
202
|
return {
|
|
186
203
|
...pkgPlan,
|
|
187
204
|
releaseType: "patch",
|
|
188
|
-
nextVersion: bumpVersion(current, "patch", {
|
|
205
|
+
nextVersion: bumpVersion(current, "patch", {
|
|
206
|
+
allowStableMajor: allowStableMajorForPath(pkgPlan.path),
|
|
207
|
+
}),
|
|
189
208
|
bumpReason: "dependency-propagation",
|
|
190
209
|
dependencySourcePaths,
|
|
191
210
|
};
|
|
@@ -221,7 +240,9 @@ export function createReleasePlan(cwd = process.cwd()) {
|
|
|
221
240
|
pkgPlan.bumpReason === undefined;
|
|
222
241
|
const baseVersion = packageCurrentVersionByPath[pkgPlan.path] ?? pkgPlan.currentVersion;
|
|
223
242
|
const nextVersion = combinedReleaseType
|
|
224
|
-
? bumpVersion(baseVersion, combinedReleaseType, {
|
|
243
|
+
? bumpVersion(baseVersion, combinedReleaseType, {
|
|
244
|
+
allowStableMajor: allowStableMajorForPath(pkgPlan.path),
|
|
245
|
+
})
|
|
225
246
|
: null;
|
|
226
247
|
return {
|
|
227
248
|
...pkgPlan,
|
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
|
@@ -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`,
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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;
|
package/dist/release/semver.js
CHANGED
|
@@ -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`,
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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 (
|
|
56
|
-
return
|
|
62
|
+
if (match?.[1] && isValidVersion(match[1])) {
|
|
63
|
+
return true;
|
|
57
64
|
}
|
|
58
|
-
return
|
|
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);
|
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,
|
|
@@ -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
|
+
};
|
package/dist/strategy/resolve.js
CHANGED
|
@@ -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,
|
package/dist/types/config.d.ts
CHANGED
|
@@ -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[];
|
|
@@ -33,6 +34,7 @@ export interface VersionaryConfig {
|
|
|
33
34
|
"bump-minor-pre-major"?: boolean;
|
|
34
35
|
"allow-stable-major"?: boolean;
|
|
35
36
|
"include-commit-authors"?: boolean;
|
|
37
|
+
"exclude-paths"?: string[];
|
|
36
38
|
"release-type"?: string | string[];
|
|
37
39
|
packages?: Record<string, VersionaryPackage>;
|
|
38
40
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "versionary",
|
|
3
|
-
"version": "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",
|