versionary 0.30.0 → 0.32.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
@@ -1,474 +1,136 @@
1
1
  # Versionary
2
2
 
3
3
  Versionary is a software-agnostic automated release tool focused on SemVer,
4
- conventional commits, release PR workflows, and extensibility.
4
+ Conventional Commits, release-PR workflows, and extensibility.
5
5
 
6
- ## Why this exists
6
+ 📖 **Documentation: <https://versionary.dev/>**
7
7
 
8
- Versionary is designed as a practical middle ground between `semantic-release`
9
- and `release-please`.
8
+ ## Why this exists
10
9
 
11
- - Like `semantic-release`, it supports direct release execution.
12
- - Like `release-please`, it supports a release PR workflow so maintainers can
13
- preview and review changes before publication.
10
+ Versionary is a practical middle ground between
11
+ [`semantic-release`](https://github.com/semantic-release/semantic-release) and
12
+ [`release-please`](https://github.com/googleapis/release-please):
14
13
 
15
- The core idea is to keep versioning, changelog generation, tagging, and SCM
16
- release metadata in one tool, while leaving package publication (npm, crates.io,
17
- etc.) to dedicated CI workflows triggered by tags or releases.
14
+ - like `semantic-release`, it supports **direct release execution**;
15
+ - like `release-please`, it supports a **release PR workflow** so maintainers
16
+ can preview and review changes before publication.
18
17
 
19
- ## Product direction
18
+ It keeps versioning, changelog generation, tagging, and SCM release metadata in
19
+ one tool, while leaving package publication (npm, crates.io, etc.) to dedicated
20
+ CI workflows triggered by tags or releases.
20
21
 
21
- Versionary is being built to:
22
+ It is built to:
22
23
 
23
24
  - support both direct releases and release-PR-gated releases
24
- - work across repository types (Node, Rust, docs/LaTeX, etc.)
25
- - stay SCM-agnostic at the core with built-in integration adapters
26
- (GitHub first; GitLab/Codeberg later)
27
- - keep a small, stable core with explicit extension points
25
+ - work across ecosystems (Node, Rust, Python, R, Julia, LaTeX, )
26
+ - stay SCM-agnostic at the core with built-in integration (GitHub first)
27
+ - keep a small, stable core with clear extension points
28
28
  - handle trunk-based development and monorepo workflows cleanly
29
29
 
30
- ## Scope and non-goals
30
+ > **Status:** early, alpha-stage development. Breaking changes are expected
31
+ > before `1.0.0`.
31
32
 
32
- In scope:
33
+ ## Quick start
33
34
 
34
- - semantic version planning from commits
35
- - changelog generation
36
- - release PR automation
37
- - tags + SCM release metadata (e.g. GitHub Releases)
35
+ Install:
38
36
 
39
- Out of scope (intentional):
37
+ ```bash
38
+ pnpm add -D versionary # or npm install --save-dev versionary
39
+ ```
40
40
 
41
- - publishing artifacts to language registries
42
- - replacing package-specific publish tooling
43
- - external/user-provided plugin loading
41
+ Add a `versionary.jsonc` at the repo root:
44
42
 
45
- Use your CI/CD platform for registry publishing, triggered from a created
46
- release/tag.
43
+ ```jsonc
44
+ {
45
+ "$schema": "https://raw.githubusercontent.com/jolars/versionary/main/schemas/config.json",
46
+ "version": 1,
47
+ "release-type": "node"
48
+ }
49
+ ```
47
50
 
48
- ## Current status vs roadmap
51
+ Check it and preview the next release:
49
52
 
50
- Current implementation focuses on:
53
+ ```bash
54
+ npx versionary verify
55
+ npx versionary plan
56
+ ```
51
57
 
52
- - strategy-based version updates (`simple`, `node`, `rust`, `r`, `latex`,
53
- `python`)
54
- - release planning and changelog generation
55
- - review-mode vs direct-mode release flow
56
- - a static internal SCM client (`github` provider today)
58
+ Then automate it in CI—see the
59
+ [Getting started](https://versionary.dev/guide/getting-started) and
60
+ [GitHub Actions](https://versionary.dev/guide/github-actions)
61
+ guides.
57
62
 
58
- Planned/harder areas include deeper monorepo ergonomics, broader SCM coverage,
59
- and stronger failure recovery around release steps.
63
+ ## Documentation
60
64
 
61
- ## Adding a new release strategy
65
+ The full documentation lives at <https://versionary.dev/>:
62
66
 
63
- Versionary is set up so new language strategies can be added internally without
64
- changing release orchestration. A new strategy should implement the
65
- `VersionStrategy` contract in `src/strategy/types.ts` and be wired in
66
- `src/strategy/resolve.ts`.
67
+ - [Getting started](https://versionary.dev/guide/getting-started)
68
+ - [GitHub Actions setup](https://versionary.dev/guide/github-actions)
69
+ (including tokens and permissions)
70
+ - [Release workflows](https://versionary.dev/guide/workflows)
71
+ - [Monorepos](https://versionary.dev/guide/monorepos)
72
+ - [Conventional Commits](https://versionary.dev/guide/conventional-commits)
73
+ and [Versioning](https://versionary.dev/guide/versioning)
74
+ - Reference:
75
+ [CLI](https://versionary.dev/reference/cli),
76
+ [Configuration](https://versionary.dev/reference/configuration),
77
+ [Strategies](https://versionary.dev/reference/strategies)
67
78
 
68
- Checklist for new strategies:
79
+ ## Scope and non-goals
69
80
 
70
- - define strategy `name`
71
- - define `getVersionFile(config)` defaults and config override behavior
72
- - implement `readVersion(cwd, config)` with explicit malformed-file errors
73
- - implement `writeVersion(cwd, config, version)` returning deterministic updated
74
- file paths
75
- - optionally implement `readPackageName(cwd, config)` so monorepo release tags
76
- can derive from language metadata (similar to Node/Rust/R)
77
- - optionally implement `propagateDependentPatchImpacts(cwd, packages)` if
78
- dependency updates in this ecosystem should trigger dependent package patch
79
- bumps
80
- - optionally implement `finalizeVersionWrites(cwd, writes, context)` for
81
- ecosystem post-processing after all target version files are written
82
- - add focused strategy tests for ecosystem-specific behavior and edge cases
83
- - add/extend strategy contract tests in `tests/strategy-contract.test.ts`
84
- - update schema/docs for new `release-type` behavior and defaults
81
+ In scope: semantic version planning from commits, changelog generation, release
82
+ PR automation, and tags + SCM release metadata (e.g. GitHub Releases).
85
83
 
86
- Current ecosystem policy defaults:
87
-
88
- - changelog source for publish:
89
- - root target uses root `changelog-file`
90
- - package target uses package changelog defaults:
91
- - `packages.<path>.changelog-file` when configured
92
- - otherwise `NEWS.md` for R targets or `CHANGELOG.md` for other targets at
93
- `<package-path>/<default-file>`
94
- - lockfiles:
95
- - Node strategy updates root `package-lock.json`/`npm-shrinkwrap.json` when
96
- present
97
- - Rust release PR prep refreshes all discovered `Cargo.lock` files
98
- - Python strategy refreshes any `poetry.lock`/`uv.lock`/`pdm.lock` at the
99
- package root by shelling out to the matching tool (`poetry lock
100
- --no-update`, `uv lock`, `pdm lock --update-reuse`); the corresponding
101
- binary must be on `PATH`
102
- - workspace/inheritance:
103
- - Rust supports `version.workspace = true` via `[workspace.package].version`
104
- - other strategies should document equivalent inheritance behavior explicitly
84
+ Out of scope (intentional): publishing artifacts to language registries,
85
+ replacing package-specific publish tooling, and external/user-provided plugin
86
+ loading. Use your CI/CD platform for registry publishing, triggered from a
87
+ created release/tag.
88
+
89
+ ## Contributing
90
+
91
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, commands, and
92
+ commit conventions.
105
93
 
106
94
  ## Architecture layout (canonical)
107
95
 
108
- Current runtime code uses a flat `src/` layout with clear module boundaries:
96
+ Runtime code uses a flat `src/` layout with clear module boundaries:
109
97
 
110
98
  - `src/cli/`: command router (`run`, `verify`, `plan`, `changelog`, `pr`, `release`)
111
99
  - `src/release/`: release orchestration (plan/changelog/PR/release/state/recovery)
112
100
  - `src/strategy/`: strategy contracts, resolver, and built-in implementations
113
- (`simple`, `node`, `rust`, `r`, `latex`, `python`)
101
+ (`simple`, `node`, `rust`, `r`, `latex`, `python`, `julia`)
114
102
  - `src/scm/`: SCM client contracts and provider implementation(s)
115
103
  - `src/config/`: config loading and schema validation
116
104
  - `src/git/`: git commit/range and repository URL helpers
117
- - `src/verify/`: repository/config verification
118
105
  - `src/types/`: shared config/plugin-facing types
119
106
 
120
107
  Configuration is loaded from `versionary.jsonc` by default (or
121
- `versionary.json`).
122
-
123
- Schema URL for editor support:
124
-
125
- - `https://raw.githubusercontent.com/jolars/versionary/main/schemas/config.json`
126
-
127
- ## Config (manifest style)
128
-
129
- For a quick trial, use:
130
-
131
- - `version-file` (default `version.txt`) as version source
132
- - `changelog-file` (default `CHANGELOG.md`) as release notes output
133
- - `release-type: "node"` uses `package.json` as version source and updates it
134
- during release PR prep
135
- - `release-type: "r"` uses `DESCRIPTION` as version source and updates the
136
- `Version:` field
137
- - `release-type: "rust"` uses Cargo manifests (`Cargo.toml`) as version source;
138
- `version-file` must point to a `Cargo.toml` (default: `Cargo.toml`)
139
- - `release-type: "latex"` uses `build.lua` as version source and updates LaTeX
140
- `\ProvidesPackage{...}[YYYY-MM-DD vX.Y.Z ...]` metadata in `src/**/*.dtx`
141
- using the release commit date
142
- - `release-type: "python"` uses `pyproject.toml` (default) and updates
143
- `[project].version` and/or `[tool.poetry].version`; point `version-file` at a
144
- Python source file (e.g. `src/<pkg>/__init__.py`) to update a `__version__`
145
- assignment instead. Refreshes `poetry.lock`/`uv.lock`/`pdm.lock` at the
146
- package root if present
147
- - `release-type` can also be an array of strategy names to compose them across
148
- manifests, e.g. `["python", "rust"]` for a PyO3/maturin project: the first
149
- entry is the *primary* (drives `readVersion`, `readPackageName`, and consumes
150
- any `version-file` override); each *secondary* writes its default manifest
151
- with the same target version. Common combinations:
152
- - `["python", "rust"]` — PyO3/maturin (`pyproject.toml` + `Cargo.toml` +
153
- `Cargo.lock`)
154
- - `["node", "rust"]` — napi-rs (`package.json` + `Cargo.toml` + `Cargo.lock`)
155
- - `["r", "rust"]` — R packages with embedded Rust crates (note: nested
156
- `src/rust/Cargo.toml` layouts are not supported by the array form yet —
157
- use a single strategy until per-strategy `version-file` overrides land)
158
- - simple/default strategy keeps `version.txt` as source of truth and does not
159
- update `package.json`
160
- - stable release branch (`release-branch`, default: `versionary/release`) so
161
- release PRs are updated in-place
162
- - `baseline-file` (default `.versionary-manifest.json`) tracks baseline SHA for
163
- deterministic commit ranges independent of tags
164
- - pre-1.0 policy defaults to conservative major handling: for `0.y.z`, breaking
165
- changes bump to `0.(y+1).0`; set `allow-stable-major: true` to allow explicit
166
- auto-transition to `1.0.0` on a breaking release
167
- - review mode (`review-mode`): `pr` (PR/MR style) or `direct` (no review
168
- request)
169
- - `release-draft` (default `false`) publishes GitHub releases as drafts when
170
- enabled
171
- - `release-reference-comments` controls release comments on linked issues/PRs:
172
- - `off` (default): do not post comments
173
- - `best-effort`: post comments and continue on API/permission failures
174
- - `strict`: fail release if comment posting fails
175
- - comments are authored by the account that owns the configured token; see
176
- [Comment and commit author identity](#comment-and-commit-author-identity)
177
- to post them under a bot identity
178
- - optional monorepo planning with `monorepo-mode` and `packages`:
179
- - `independent` computes package bumps per path
180
- - `fixed` computes one shared bump across configured package paths
181
- - per-package `package-name` can override release identity (labels + tag base)
182
- - per-package `changelog-file` writes package release notes to
183
- `<package-path>/<changelog-file>`
184
- - per-package `follows` declares an asymmetric version link to one or more
185
- source packages: when any source bumps, the follower releases too, with
186
- bump = `max(own bump, max(source bumps))`. The follower's changelog gets a
187
- `### Dependencies` section listing the followed sources. Use it when one
188
- package bundles another's artifact (e.g. an editor extension that ships
189
- the CLI binary). Cycles, self-references, unknown source paths, and
190
- combining `follows` with `monorepo-mode: "fixed"` are config errors.
191
- `follows` is non-transitive: A follows B does not imply A follows what B
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.
199
-
200
- ```jsonc
201
- // Editor extension that bundles the root CLI artifact
202
- {
203
- "version": 1,
204
- "release-type": "rust",
205
- "monorepo-mode": "independent",
206
- "packages": {
207
- ".": { "exclude-paths": ["editors"] },
208
- "editors/code": {
209
- "release-type": "node",
210
- "package-name": "panache-code",
211
- "follows": ["."]
212
- }
213
- }
214
- }
215
- ```
216
-
217
- Rust strategy examples:
218
-
219
- ```jsonc
220
- // Single crate
221
- {
222
- "release-type": "rust",
223
- "version-file": "Cargo.toml"
224
- }
225
- ```
226
-
227
- ```jsonc
228
- // Workspace root (virtual or root crate + members)
229
- {
230
- "release-type": "rust",
231
- "version-file": "Cargo.toml"
232
- }
233
- ```
234
-
235
- Current rust auto-update behavior (phase scope):
236
-
237
- - updates crate versions in each targeted crate `[package].version`
238
- - supports targeted crates using `version.workspace = true` by updating
239
- `[workspace.package].version` in the owning workspace manifest
240
- - updates internal workspace dependency versions when the dependency name
241
- matches another targeted crate name
242
- - refreshes `Cargo.lock` via `cargo generate-lockfile` when `Cargo.lock` exists
243
- in repo root
244
- - applies dependency version rewrites in:
245
- - `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]`
246
- - `[target.*.dependencies]`, `[target.*.dev-dependencies]`,
247
- `[target.*.build-dependencies]`
248
-
249
- Current rust non-goals/limits:
250
-
251
- - does not update external dependency versions
252
- - does not update `workspace.dependencies`
253
- - does not add missing `version = ...` fields to dependency inline tables
254
- - does not perform Cargo publish/release to crates.io
255
-
256
- If `Cargo.lock` exists, `cargo` must be available in PATH during PR preparation.
257
-
258
- ### Monorepo release names and tag naming
259
-
260
- For independent monorepo targets, Versionary derives release tags as:
261
-
262
- - root package (`"."`): `v<version>`
263
- - non-root package: `<release-name>-v<version>`
264
-
265
- `release-name` precedence is:
266
-
267
- 1. `packages.<path>.package-name` (explicit override)
268
- 2. strategy-native package name from version file:
269
- - Node: `package.json` `name`
270
- - Rust: `Cargo.toml` `[package].name`
271
- - R: `DESCRIPTION` `Package:`
272
- 3. package path fallback
273
-
274
- When multiple packages resolve to the same `<release-name>` and version, the run
275
- fails fast with a duplicate-tag error and suggests setting unique
276
- `package-name` values.
277
-
278
- ## Commit parsing and release analysis
279
-
280
- Release planning is based on Conventional Commit parsing semantics:
281
-
282
- - parses type/scope/description from commit headers
283
- - exposes structured parsed fields (`header`, `body`, `footer`, `type`, `scope`,
284
- `description`, `notes`, `references`, `mentions`, `revert`)
285
- - separates parser output from release policy mapping (`inferReleaseType*`)
286
- - recognizes breaking changes from `!` and `BREAKING CHANGE` / `BREAKING-CHANGE`
287
- footers
288
- - maps release impact as `feat => minor`, `fix|perf => patch`, breaking => major
289
- - treats `revert:` commits as patch-releasable by default (and major if marked
290
- breaking, e.g. `revert!:` or `BREAKING CHANGE`)
291
- - suppresses commits that are reverted within the analyzed release window so
292
- they do not affect bump/changelog output
293
- - emits parser diagnostics for malformed headers/footers/references and
294
- ambiguous revert messages
295
-
296
- Commands:
108
+ `versionary.json`). The config schema lives in `src/config/schema.ts`; the
109
+ editor-facing `schemas/config.json` is generated via `pnpm gen:schema`.
297
110
 
298
- - `pnpm verify`
299
- - `pnpm run` (default orchestration: no-op, create/update release PR, or publish
300
- release based on context)
301
- - `pnpm run -- --json` (machine-readable orchestration result)
302
- - `pnpm plan`
303
- - `pnpm changelog -- --write`
304
- - `pnpm pr`
305
- - `pnpm release`
306
-
307
- `pnpm pr` prepares release commit + branch and opens/updates a review request
308
- through the SCM client. `pnpm run` is the recommended CI entrypoint and
309
- auto-dispatches between PR/update and release publish.
310
-
311
- ### Moloch migration example (semantic-release -> versionary)
312
-
313
- For LaTeX projects like `moloch`, use `release-type: "latex"` so Versionary:
314
-
315
- - bumps `build.lua` version
316
- - updates `src/**/*.dtx` `\ProvidesPackage{...}[YYYY-MM-DD vX.Y.Z ...]` entries
317
- with the release commit date (`git show --format=%cs <sha>`)
318
-
319
- Example `versionary.jsonc` for `moloch`:
320
-
321
- ```jsonc
322
- {
323
- "version": 1,
324
- "review-mode": "pr",
325
- "release-type": "latex",
326
- "version-file": "build.lua",
327
- "changelog-file": "CHANGELOG.md",
328
- "release-branch": "versionary/release"
329
- }
330
- ```
331
-
332
- For first-run bootstrapping, set `bootstrap-sha` (similar to release-please).
333
- Subsequent runs use the baseline state file.
334
-
335
- ## Release retry and recovery behavior
336
-
337
- Release publish (`pnpm release` or the publish path in `pnpm run`) is idempotent
338
- by target tag:
339
-
340
- - if a tag already exists, Versionary reuses it rather than recreating it
341
- - if release metadata already exists for the tag (e.g., GitHub Release), it is
342
- reused
343
- - if a prior run created/pushed the tag but failed before metadata creation, a
344
- rerun creates the missing metadata and proceeds
345
-
346
- Versionary fails fast when recovery is unsafe (for example, local and remote
347
- tags with the same name point to different SHAs). In these cases, the error
348
- message includes remediation guidance so CI logs are actionable.
349
-
350
- ## SCM API model
351
-
352
- Versionary currently uses a static internal SCM client model:
353
-
354
- - `src/scm/types.ts` defines the `ScmClient` contract
355
- - `src/scm/client.ts` returns the active provider client
356
- - current provider is `github` via `src/scm/github-plugin.ts`
357
-
358
- There is no runtime discovery/loading of external SCM providers in the release
359
- flow. Adding another provider is an internal extension: implement `ScmClient`
360
- and wire provider selection in `src/scm/client.ts`.
361
-
362
- ### GitHub integration: env, permissions, and flow
363
-
364
- Required environment for the GitHub SCM provider:
365
-
366
- - `GITHUB_REPOSITORY` (format: `owner/repo`)
367
- - one token env var: `VERSIONARY_PR_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN`
368
-
369
- Token precedence is:
370
-
371
- - `VERSIONARY_PR_TOKEN` > `GH_TOKEN` > `GITHUB_TOKEN`
372
-
373
- Minimum GitHub token/repo permissions for Versionary-managed metadata:
374
-
375
- - release PR create/update flow: `contents: write`, `pull-requests: write`
376
- - release metadata flow (GitHub Release create/read): `contents: write`
377
-
378
- `review-mode` behavior:
379
-
380
- - `pr` (preferred; `review` is a backward-compatible alias): `pnpm run run`
381
- prepares/updates the release branch and creates or
382
- updates a release PR
383
- - `direct`: `pnpm run run` prepares/updates the release branch and skips review
384
- request creation
385
-
386
- Concise GitHub Actions examples:
387
-
388
- ```yaml
389
- # 1) Release PR / update flow (run on push to default branch)
390
- permissions:
391
- contents: write
392
- pull-requests: write
393
-
394
- steps:
395
- - uses: actions/checkout@v6
396
- with:
397
- fetch-depth: 0
398
- fetch-tags: true
399
- - id: versionary
400
- uses: jolars/versionary@v1
401
- with:
402
- token: ${{ secrets.RELEASE_TOKEN }}
403
- ```
404
-
405
- ```yaml
406
- # 2) Release publish flow after merge (release commit context)
407
- permissions:
408
- contents: write
409
-
410
- steps:
411
- - uses: actions/checkout@v6
412
- with:
413
- fetch-depth: 0
414
- fetch-tags: true
415
- - id: versionary
416
- uses: jolars/versionary@v1
417
- with:
418
- token: ${{ secrets.RELEASE_TOKEN }}
419
- - if: ${{ steps.versionary.outputs.release_created == 'true' }}
420
- run: echo "Released ${{ steps.versionary.outputs.tag_name }}"
421
- ```
422
-
423
- `token` is used for both GitHub API calls and git push authentication in
424
- the composite action. This means release-branch force-pushes are attributed to
425
- that token and can trigger downstream workflows when using a PAT/App token.
426
- (`github-token` remains as a deprecated alias for backward compatibility.)
427
-
428
- #### Comment and commit author identity
429
-
430
- Two distinct identities are at play; keep them apart.
431
-
432
- **Release-reference comments, the GitHub Release, and the tag/branch push** are
433
- attributed to the account that owns the token you provide. There is no GitHub
434
- API to set a custom author independent of the token, so this identity always
435
- follows the token's account:
436
-
437
- - the workflow's default `GITHUB_TOKEN` acts as `github-actions[bot]` — the
438
- common case, and what most `semantic-release` setups show
439
- - a **personal access token (PAT)** acts as your own user
440
- - a **dedicated bot user account** acts as that account (for example
441
- `semantic-release`'s own `@semantic-release-bot`): create a separate GitHub
442
- user, generate a PAT for it, and store it as the release token
443
- - a **GitHub App installation token** (e.g. minted with
444
- `actions/create-github-app-token`) acts as `<app-name>[bot]`
445
-
446
- **The release commit's committer** comes from git's `user.name`/`user.email`,
447
- not the token. When neither is configured (e.g. a bare CI runner), Versionary
448
- defaults it to `github-actions[bot]`, so no `git config` step is needed in your
449
- workflow; an existing identity (local, global, or the one the GitHub Action
450
- wrapper sets) is left untouched.
451
-
452
- The release-reference comment body itself is signed by Versionary regardless of
453
- which account posts it.
454
-
455
- Action outputs:
111
+ ## Adding a new release strategy
456
112
 
457
- - `action`: `noop`, `pr-prepared`, `release-published`, `release-skipped`
458
- - `message`: human-readable summary
459
- - `release_created`: `"true"` when at least one release was published
460
- - `tag_name`: first published tag (for single-target flows)
461
- - `tag_names`: JSON array of published tags
462
- - `review_url`: review request URL when PR flow runs
113
+ New language strategies can be added internally without changing release
114
+ orchestration. A new strategy should implement the `VersionStrategy` contract in
115
+ `src/strategy/types.ts` and be wired in `src/strategy/resolve.ts`.
463
116
 
464
- For GitHub Action consumers, publish immutable tags (for example `v1.2.3`) and
465
- maintain a moving major tag (`v1`, `v2`, ...). A small release-triggered
466
- workflow should update `v<major>` to the latest release tag so `uses:
467
- jolars/versionary@v1` stays current without breaking major compatibility.
117
+ Checklist for new strategies:
468
118
 
469
- Package publication is intentionally out of scope in the current release flow.
470
- Use separate CI workflows for publishing after Versionary has prepared/tagged
471
- the release.
119
+ - define strategy `name`
120
+ - define `getVersionFile(config)` defaults and config override behavior
121
+ - implement `readVersion(cwd, config)` with explicit malformed-file errors
122
+ - implement `writeVersion(cwd, config, version)` returning deterministic updated
123
+ file paths
124
+ - optionally implement `readPackageName(cwd, config)` so monorepo release tags
125
+ can derive from language metadata (similar to Node/Rust/R)
126
+ - optionally implement `propagateDependentPatchImpacts(cwd, packages)` if
127
+ dependency updates in this ecosystem should trigger dependent package patch
128
+ bumps
129
+ - optionally implement `finalizeVersionWrites(cwd, writes, context)` for
130
+ ecosystem post-processing after all target version files are written
131
+ - add focused strategy tests for ecosystem-specific behavior and edge cases
132
+ - add/extend strategy contract tests in `tests/strategy-contract.test.ts`
133
+ - update schema/docs for new `release-type` behavior and defaults
472
134
 
473
135
  ## Install from GitHub
474
136
 
@@ -483,4 +145,8 @@ You can install directly from a git ref:
483
145
  ```
484
146
 
485
147
  The package runs a `prepare` build during git installation so the `versionary`
486
- CLI binary is available after `pnpm install`.
148
+ CLI binary is available after install.
149
+
150
+ ## License
151
+
152
+ [MIT](LICENSE)
@@ -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<{
@@ -53,6 +54,7 @@ export declare const configSchema: z.ZodObject<{
53
54
  "field-path": z.ZodOptional<z.ZodString>;
54
55
  jsonpath: z.ZodOptional<z.ZodString>;
55
56
  pattern: z.ZodOptional<z.ZodString>;
57
+ replacement: z.ZodOptional<z.ZodString>;
56
58
  }, z.core.$strip>>>;
57
59
  follows: z.ZodOptional<z.ZodArray<z.ZodString>>;
58
60
  }, z.core.$strict>>>;
@@ -6,6 +6,7 @@ const artifactRuleSchema = z
6
6
  "field-path": z.string().optional(),
7
7
  jsonpath: z.string().optional(),
8
8
  pattern: z.string().optional(),
9
+ replacement: z.string().optional(),
9
10
  })
10
11
  .superRefine((value, ctx) => {
11
12
  const needsJsonPath = value.type === "json" ||
@@ -27,6 +28,13 @@ const artifactRuleSchema = z
27
28
  path: ["pattern"],
28
29
  });
29
30
  }
31
+ if (needsJsonPath && value.replacement) {
32
+ ctx.addIssue({
33
+ code: z.ZodIssueCode.custom,
34
+ message: `${value.type} artifact rules do not support "replacement".`,
35
+ path: ["replacement"],
36
+ });
37
+ }
30
38
  if (value["field-path"] && value.jsonpath) {
31
39
  ctx.addIssue({
32
40
  code: z.ZodIssueCode.custom,
@@ -57,6 +65,7 @@ const packageSchema = z
57
65
  "package-name": z.string().optional(),
58
66
  "changelog-file": z.string().optional(),
59
67
  "changelog-format": z.enum(["markdown-changelog", "r-news"]).optional(),
68
+ "allow-stable-major": z.boolean().optional(),
60
69
  "exclude-paths": z.array(z.string()).optional(),
61
70
  "extra-files": z.array(artifactRuleSchema).optional(),
62
71
  follows: z.array(z.string().min(1)).optional(),
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
4
4
  import YAML from "yaml";
5
+ import { parseVersion } from "./semver.js";
5
6
  const WILDCARD = Symbol("wildcard");
6
7
  function isRecord(value) {
7
8
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -160,12 +161,34 @@ function parseRegexPattern(pattern) {
160
161
  }
161
162
  return new RegExp(pattern, "m");
162
163
  }
163
- function applyRegexRule(content, pattern, version) {
164
+ const REPLACEMENT_TOKEN_PATTERN = /\{\{\s*([A-Za-z]+)\s*\}\}/gu;
165
+ function renderReplacementTemplate(template, version) {
166
+ const parsed = parseVersion(version);
167
+ const tokens = {
168
+ version,
169
+ major: String(parsed.major),
170
+ minor: String(parsed.minor),
171
+ patch: String(parsed.patch),
172
+ prerelease: parsed.prerelease.join("."),
173
+ build: parsed.build.join("."),
174
+ };
175
+ return template.replace(REPLACEMENT_TOKEN_PATTERN, (_whole, name) => {
176
+ const key = name.toLowerCase();
177
+ const value = tokens[key];
178
+ if (value === undefined) {
179
+ throw new Error(`Unknown replacement token "{{${name}}}". Supported tokens: ${Object.keys(tokens)
180
+ .map((token) => `{{${token}}}`)
181
+ .join(", ")}.`);
182
+ }
183
+ return value;
184
+ });
185
+ }
186
+ function applyRegexRule(content, pattern, version, replacementTemplate) {
164
187
  const regex = parseRegexPattern(pattern);
165
188
  const matchFlags = regex.flags.includes("g")
166
189
  ? regex.flags
167
190
  : `${regex.flags}g`;
168
- const globalRegex = new RegExp(regex.source, matchFlags);
191
+ const globalRegex = new RegExp(regex.source, matchFlags.includes("d") ? matchFlags : `${matchFlags}d`);
169
192
  const matches = [...content.matchAll(globalRegex)];
170
193
  if (matches.length !== 1) {
171
194
  throw new Error(`Regex pattern must match exactly one occurrence; matched ${matches.length}.`);
@@ -179,9 +202,21 @@ function applyRegexRule(content, pattern, version) {
179
202
  throw new Error("Regex match did not include an index.");
180
203
  }
181
204
  const full = match[0];
182
- const groupOne = match[1];
183
- const replacement = typeof groupOne === "string" ? full.replace(groupOne, version) : version;
184
- return `${content.slice(0, start)}${replacement}${content.slice(start + full.length)}`;
205
+ // With a replacement template, render it and replace the entire match.
206
+ if (replacementTemplate !== undefined) {
207
+ const rendered = renderReplacementTemplate(replacementTemplate, version);
208
+ return `${content.slice(0, start)}${rendered}${content.slice(start + full.length)}`;
209
+ }
210
+ // Legacy behavior: substitute the full version into the first capture group,
211
+ // leaving the rest of the match intact. Splice by group indices rather than
212
+ // `String.replace` so literal `$` sequences and repeated group content are
213
+ // handled correctly.
214
+ const groupIndices = match.indices?.[1];
215
+ if (!groupIndices) {
216
+ return `${content.slice(0, start)}${version}${content.slice(start + full.length)}`;
217
+ }
218
+ const [groupStart, groupEnd] = groupIndices;
219
+ return `${content.slice(0, groupStart)}${version}${content.slice(groupEnd)}`;
185
220
  }
186
221
  function applyTomlRulePreservingFormatting(content, fieldPath, version) {
187
222
  const simplePath = fieldPath.match(/^\$\.([A-Za-z0-9_-]+)$/u);
@@ -368,7 +403,7 @@ function applyArtifactRuleToContent(content, rule, version) {
368
403
  if (!rule.pattern) {
369
404
  throw new Error('regex artifact rules require "pattern".');
370
405
  }
371
- return applyRegexRule(content, rule.pattern, version);
406
+ return applyRegexRule(content, rule.pattern, version, rule.replacement);
372
407
  }
373
408
  if (rule.type === "json") {
374
409
  const parsed = JSON.parse(content);
@@ -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,
@@ -435,6 +435,15 @@ export function renderSimpleReviewRequestBody(version, previousVersion, commits,
435
435
  }
436
436
  return `${bodySections}\n\n${renderReviewRequestFooter()}`;
437
437
  }
438
+ // Honor the plan's changelog format (e.g. r-news) so manual notes, which are
439
+ // authored/extracted relative to that format's heading depth, render at the
440
+ // right level. Falling through to renderReleaseNotesSection would always use
441
+ // the markdown-changelog convention and leave r-news highlights one level too
442
+ // high relative to the auto-generated sections.
443
+ if (plan && plan.changelogFormat === "r-news") {
444
+ const notes = renderReleasePlanChangelog(plan, { cwd, highlights });
445
+ return `${notes}\n\n${renderReviewRequestFooter()}`;
446
+ }
438
447
  return renderReleaseNotesSection({
439
448
  currentVersion: previousVersion,
440
449
  nextVersion: version,
@@ -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,
@@ -7,12 +7,14 @@ export interface VersionaryArtifactRule {
7
7
  "field-path"?: string;
8
8
  jsonpath?: string;
9
9
  pattern?: string;
10
+ replacement?: string;
10
11
  }
11
12
  export interface VersionaryPackage {
12
13
  "release-type"?: string | string[];
13
14
  "package-name"?: string;
14
15
  "changelog-file"?: string;
15
16
  "changelog-format"?: VersionaryChangelogFormat;
17
+ "allow-stable-major"?: boolean;
16
18
  "exclude-paths"?: string[];
17
19
  "extra-files"?: VersionaryArtifactRule[];
18
20
  follows?: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "versionary",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Automatic release framework based on conventional commits and semantic versioning",
5
5
  "keywords": [
6
6
  "releasing",
@@ -27,27 +27,33 @@
27
27
  "main": "dist/index.js",
28
28
  "types": "dist/index.d.ts",
29
29
  "dependencies": {
30
- "smol-toml": "^1.4.2",
31
30
  "@octokit/rest": "^22.0.0",
32
31
  "jsonc-parser": "^3.3.1",
32
+ "smol-toml": "^1.4.2",
33
33
  "yaml": "^2.8.3",
34
34
  "zod": "^4.1.12"
35
35
  },
36
36
  "devDependencies": {
37
- "@types/node": "^25.6.0",
37
+ "@types/node": "^26.0.1",
38
38
  "tsx": "^4.20.6",
39
39
  "typescript": "^6.0.3",
40
+ "vite": "^8.0.0",
41
+ "vitepress": "^1.6.4",
40
42
  "vitest": "^4.1.4"
41
43
  },
42
44
  "scripts": {
43
45
  "build": "tsc -p tsconfig.json",
44
46
  "typecheck": "tsc -p tsconfig.json --noEmit",
47
+ "gen:schema": "tsx scripts/generate-schema.ts && biome format --write schemas/config.json",
45
48
  "test": "vitest run",
46
49
  "verify": "tsx src/cli/index.ts verify",
47
50
  "run": "tsx src/cli/index.ts run",
48
51
  "plan": "tsx src/cli/index.ts plan",
49
52
  "changelog": "tsx src/cli/index.ts changelog",
50
53
  "pr": "tsx src/cli/index.ts pr",
51
- "release": "tsx src/cli/index.ts release"
54
+ "release": "tsx src/cli/index.ts release",
55
+ "docs:dev": "vitepress dev docs",
56
+ "docs:build": "vitepress build docs",
57
+ "docs:preview": "vitepress preview docs"
52
58
  }
53
59
  }