@modulify/conventional-release 0.1.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 ADDED
@@ -0,0 +1,380 @@
1
+ # @modulify/conventional-release
2
+
3
+ Release orchestration package for conventional workflows.
4
+
5
+ This workspace combines:
6
+ - semantic version recommendation from `@modulify/conventional-bump`,
7
+ - changelog rendering and writing from `@modulify/conventional-changelog`,
8
+ - package manifest updates,
9
+ - release finalization with commit and tag creation.
10
+
11
+ The package is library-first. It exposes:
12
+ - `createScope()` to inspect what would be released,
13
+ - `run()` to apply the release flow,
14
+ - `conventional-release` as a config-driven CLI binary.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ yarn add -D @modulify/conventional-release
20
+ ```
21
+
22
+ Other package managers:
23
+
24
+ ```bash
25
+ npm install -D @modulify/conventional-release
26
+ pnpm add -D @modulify/conventional-release
27
+ bun add -d @modulify/conventional-release
28
+ ```
29
+
30
+ ## Mental model
31
+
32
+ The package works in two stages:
33
+
34
+ 1. `createScope(options)` discovers the release scope for the repository.
35
+ It resolves packages, filters workspaces, detects affected packages, and produces ordered release slices.
36
+ 2. `run(options)` resolves the same scope and applies side effects.
37
+ It updates manifests, writes the changelog, creates a commit, and creates tags.
38
+
39
+ `Scope` is the declarative view of a release.
40
+ `Slice` is one execution unit inside that scope.
41
+
42
+ In `sync` mode there is usually one slice for the whole repository.
43
+ In `async` mode each affected package gets its own slice.
44
+ In `hybrid` mode packages can be split into named `partitions`.
45
+
46
+ ## Quick start
47
+
48
+ ```ts
49
+ import { run } from '@modulify/conventional-release'
50
+
51
+ const result = await run()
52
+
53
+ if (!result.changed) {
54
+ console.log('No changes since last release')
55
+ } else {
56
+ for (const slice of result.slices) {
57
+ if (!slice.changed) continue
58
+
59
+ console.log(slice.id, slice.nextVersion, slice.tag)
60
+ }
61
+ }
62
+ ```
63
+
64
+ ## CLI
65
+
66
+ The package ships a `conventional-release` binary.
67
+
68
+ Typical usage:
69
+
70
+ ```bash
71
+ conventional-release
72
+ conventional-release --dry
73
+ conventional-release --dry --verbose --tags
74
+ ```
75
+
76
+ From a project script:
77
+
78
+ ```json
79
+ {
80
+ "scripts": {
81
+ "release": "conventional-release",
82
+ "release:dry": "conventional-release --dry"
83
+ }
84
+ }
85
+ ```
86
+
87
+ Without adding a local script:
88
+
89
+ ```bash
90
+ npx @modulify/conventional-release --dry
91
+ npm exec conventional-release -- --dry
92
+ yarn dlx @modulify/conventional-release --dry
93
+ pnpm dlx @modulify/conventional-release --dry
94
+ bunx @modulify/conventional-release --dry
95
+ ```
96
+
97
+ Useful flags:
98
+
99
+ - `--dry`: compute versions, files, and tags without write-side effects
100
+ - `--verbose`: show detailed per-slice progress output
101
+ - `--tags`: print generated tags in the final summary
102
+ - `--release-as <type>`: force `major`, `minor`, or `patch`
103
+ - `--prerelease <channel>`: use `alpha`, `beta`, or `rc`
104
+
105
+ The CLI reads the same repository configuration as the library API and wires a lifecycle reporter into `run()`.
106
+
107
+ ## Inspect before running
108
+
109
+ Use `createScope()` when you want a dry, deterministic view of the release shape:
110
+
111
+ ```ts
112
+ import { createScope } from '@modulify/conventional-release'
113
+
114
+ const scope = await createScope({
115
+ mode: 'hybrid',
116
+ })
117
+
118
+ console.log(scope.mode)
119
+ console.log(scope.packages.map((pkg) => pkg.path))
120
+ console.log(scope.slices.map((slice) => slice.id))
121
+ ```
122
+
123
+ This is useful for:
124
+ - the built-in package CLI,
125
+ - custom CLIs,
126
+ - dashboards,
127
+ - approval flows,
128
+ - tests around release planning.
129
+
130
+ ## Running a release
131
+
132
+ `run()` applies the release flow and returns per-slice results:
133
+
134
+ ```ts
135
+ import { run } from '@modulify/conventional-release'
136
+
137
+ const result = await run({
138
+ mode: 'sync',
139
+ dry: true,
140
+ })
141
+
142
+ console.log(result.changed)
143
+ console.log(result.files)
144
+ console.log(result.slices)
145
+ ```
146
+
147
+ When `dry: true` is used, the package still resolves versions, tags, and touched files, but skips write-side effects.
148
+
149
+ ## Configuration sources
150
+
151
+ Configuration is resolved in this order:
152
+
153
+ 1. `package.json` field `release`
154
+ 2. `release.config.ts`, `release.config.mjs`, or `release.config.js`
155
+ 3. inline options passed to `run()` or `createScope()`
156
+
157
+ Inline options always win.
158
+
159
+ Example `package.json`:
160
+
161
+ ```json
162
+ {
163
+ "name": "example-repo",
164
+ "version": "1.0.0",
165
+ "release": {
166
+ "mode": "sync",
167
+ "tagPrefix": "v"
168
+ }
169
+ }
170
+ ```
171
+
172
+ Example `release.config.ts`:
173
+
174
+ ```ts
175
+ import type { Options } from '@modulify/conventional-release'
176
+
177
+ const config: Options = {
178
+ mode: 'hybrid',
179
+ partitions: {
180
+ core: {
181
+ mode: 'sync',
182
+ workspaces: ['@scope/core-*'],
183
+ },
184
+ plugins: {
185
+ mode: 'async',
186
+ workspaces: ['packages/plugins/*'],
187
+ tagPrefix: 'plugin-',
188
+ },
189
+ },
190
+ }
191
+
192
+ export default config
193
+ ```
194
+
195
+ ## Common options
196
+
197
+ The most important public options are:
198
+
199
+ - `mode`: release strategy, one of `sync`, `async`, or `hybrid`
200
+ - `releaseAs`: explicit semver bump override such as `major`, `minor`, or `patch`
201
+ - `prerelease`: prerelease channel, one of `alpha`, `beta`, or `rc`
202
+ - `fromTag`: explicit lower bound tag for advisory commit analysis
203
+ - `tagPrefix`: tag matcher used during advisory commit analysis
204
+ - `workspaces`: include and exclude filters for workspace discovery
205
+ - `partitions`: named hybrid slices for mixed release strategies
206
+ - `dependencyPolicy`: how internal dependency ranges are updated, one of `preserve`, `caret`, or `exact`
207
+ - `install`: whether install should run after manifest updates
208
+ - `tagName`, `tagMessage`, `commitMessage`: custom formatters for release output
209
+ - `changelogFile`: changelog output path relative to the repository root
210
+
211
+ Important:
212
+ - `tagPrefix` affects release discovery and commit analysis boundaries.
213
+ - `tagPrefix` does not format the new tag name by itself.
214
+ - To change produced tag names, use `tagName`.
215
+
216
+ ## Single-package repository
217
+
218
+ ```ts
219
+ import { run } from '@modulify/conventional-release'
220
+
221
+ await run({
222
+ mode: 'sync',
223
+ fromTag: 'v1.0.0',
224
+ })
225
+ ```
226
+
227
+ This is the simplest setup and usually produces one slice:
228
+ - one next version,
229
+ - one commit,
230
+ - one tag.
231
+
232
+ By default, a changed `sync` slice produces a tag like `v1.2.3`.
233
+
234
+ ## Monorepo with independent packages
235
+
236
+ ```ts
237
+ import { run } from '@modulify/conventional-release'
238
+
239
+ await run({
240
+ mode: 'async',
241
+ workspaces: {
242
+ include: ['packages/*'],
243
+ },
244
+ })
245
+ ```
246
+
247
+ This creates one slice per affected package.
248
+
249
+ By default, each changed async slice produces a tag like `package-name@1.2.3`.
250
+
251
+ ## Monorepo with grouped release behavior
252
+
253
+ ```ts
254
+ import { run } from '@modulify/conventional-release'
255
+
256
+ await run({
257
+ mode: 'hybrid',
258
+ partitions: {
259
+ app: {
260
+ mode: 'sync',
261
+ workspaces: ['@scope/app', '@scope/web'],
262
+ },
263
+ plugins: {
264
+ mode: 'async',
265
+ workspaces: ['packages/plugins/*'],
266
+ },
267
+ },
268
+ })
269
+ ```
270
+
271
+ This is useful when some packages must move in lockstep, while others can release independently.
272
+
273
+ By default, partition slices use tags like `partition-name@1.2.3`.
274
+
275
+ ## Result shape
276
+
277
+ `run()` returns:
278
+
279
+ - `changed`: whether at least one slice changed version
280
+ - `files`: all files touched by changed slices
281
+ - `packages`: all packages in resolved scope
282
+ - `affected`: packages affected by the current working tree
283
+ - `slices`: ordered slice results with:
284
+ - `id`
285
+ - `kind`
286
+ - `mode`
287
+ - `packages`
288
+ - `currentVersion`
289
+ - `nextVersion`
290
+ - `releaseType`
291
+ - `tag`
292
+ - `commitMessage`
293
+ - `tagMessage`
294
+
295
+ Example:
296
+
297
+ ```ts
298
+ const result = await run({ dry: true })
299
+
300
+ for (const slice of result.slices) {
301
+ console.log({
302
+ id: slice.id,
303
+ changed: slice.changed,
304
+ nextVersion: slice.nextVersion,
305
+ tag: slice.tag,
306
+ })
307
+ }
308
+ ```
309
+
310
+ ## Install behavior
311
+
312
+ After manifest updates the package can run the repository package manager install command.
313
+
314
+ `install` supports three forms:
315
+
316
+ - `false`: skip install entirely
317
+ - `true` or omitted: run install with default extra arguments for the detected package manager
318
+ - `string[]`: run install and append these extra arguments after the `install` subcommand
319
+
320
+ Example:
321
+
322
+ ```ts
323
+ await run({
324
+ install: ['--mode=skip-build'],
325
+ })
326
+ ```
327
+
328
+ That becomes conceptually:
329
+
330
+ ```bash
331
+ <package-manager> install --mode=skip-build
332
+ ```
333
+
334
+ ## Package manager detection
335
+
336
+ The package detects the package manager in this order:
337
+
338
+ 1. `package.json#packageManager`
339
+ 2. lockfiles in the repository root
340
+ 3. fallback to `npm`
341
+
342
+ Recognized lockfiles:
343
+
344
+ - `yarn.lock`
345
+ - `pnpm-lock.yaml`
346
+ - `package-lock.json`
347
+ - `bun.lock`
348
+ - `bun.lockb`
349
+
350
+ Default install extras:
351
+
352
+ - `yarn`: `--no-immutable`
353
+ - `npm`: no extra args
354
+ - `pnpm`: no extra args
355
+ - `bun`: no extra args
356
+
357
+ ## Tag and message customization
358
+
359
+ Custom formatters receive a `TagContext` object:
360
+
361
+ ```ts
362
+ import { run } from '@modulify/conventional-release'
363
+
364
+ await run({
365
+ tagName: ({ version, partition, packages }) => {
366
+ const name = partition ?? packages[0]?.name ?? 'release'
367
+
368
+ return `${name}@${version}`
369
+ },
370
+ commitMessage: ({ tag }) => `chore(release): ${tag}`,
371
+ tagMessage: ({ tag }) => `chore(release): ${tag}`,
372
+ })
373
+ ```
374
+
375
+ ## Notes
376
+
377
+ - The package detects the package manager from `package.json#packageManager` or lockfiles.
378
+ - The default fallback package manager is `npm`.
379
+ - The package does not perform `git push`.
380
+ - CLI-style push hints belong in the CLI layer, not in the library result.
package/bin/cli.cjs ADDED
@@ -0,0 +1,308 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const gitToolkit = require("@modulify/git-toolkit");
4
+ const shell = require("@modulify/git-toolkit/shell");
5
+ const index = require("../dist/index.cjs");
6
+ const yargs = require("yargs");
7
+ const helpers = require("yargs/helpers");
8
+ const chalk = require("chalk");
9
+ const figures = require("figures");
10
+ const util = require("node:util");
11
+ function _interopNamespaceDefault(e) {
12
+ const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
13
+ if (e) {
14
+ for (const k in e) {
15
+ if (k !== "default") {
16
+ const d = Object.getOwnPropertyDescriptor(e, k);
17
+ Object.defineProperty(n, k, d.get ? d : {
18
+ enumerable: true,
19
+ get: () => e[k]
20
+ });
21
+ }
22
+ }
23
+ }
24
+ n.default = e;
25
+ return Object.freeze(n);
26
+ }
27
+ const util__namespace = /* @__PURE__ */ _interopNamespaceDefault(util);
28
+ const DEFAULTS = {
29
+ dry: false,
30
+ verbose: false,
31
+ tags: false
32
+ };
33
+ class CliParseError extends Error {
34
+ help;
35
+ }
36
+ async function parseArgv(argv = process.argv) {
37
+ const parser = yargs(helpers.hideBin(argv)).locale("en").scriptName("conventional-release").usage("Usage: $0 [options]").option("release-as", {
38
+ alias: "r",
39
+ describe: "Specify the release type (major|minor|patch)",
40
+ requiresArg: true,
41
+ string: true
42
+ }).option("prerelease", {
43
+ alias: "p",
44
+ describe: "Specify the prerelease type (alpha|beta|rc)",
45
+ requiresArg: true,
46
+ string: true
47
+ }).option("dry", {
48
+ type: "boolean",
49
+ default: DEFAULTS.dry,
50
+ describe: "See the commands that running release would run"
51
+ }).option("verbose", {
52
+ type: "boolean",
53
+ default: DEFAULTS.verbose,
54
+ describe: "Show detailed per-slice progress output"
55
+ }).option("tags", {
56
+ type: "boolean",
57
+ default: DEFAULTS.tags,
58
+ describe: "Show generated tags in the final output"
59
+ }).exitProcess(false).check((options) => {
60
+ if (!["alpha", "beta", "rc", void 0].includes(options.prerelease)) {
61
+ throw new Error("prerelease should be one of alpha, beta, rc or undefined");
62
+ }
63
+ return true;
64
+ }).showHelpOnFail(false).fail((message) => {
65
+ throw new Error(message);
66
+ }).alias("version", "v").alias("help", "h").example("$0", "Update changelog and tag release").example("$0 --dry --verbose", "Show a detailed dry-run release preview").pkgConf("release").wrap(97);
67
+ let parsed;
68
+ try {
69
+ parsed = await parser.parseAsync();
70
+ } catch (error) {
71
+ const failure = new CliParseError(error.message);
72
+ const help = await parser.getHelp();
73
+ failure.help = [help].flat().join("\n");
74
+ throw failure;
75
+ }
76
+ return {
77
+ releaseAs: parsed.releaseAs,
78
+ prerelease: parsed.prerelease,
79
+ dry: parsed.dry,
80
+ verbose: parsed.verbose,
81
+ tags: parsed.tags
82
+ };
83
+ }
84
+ function createReporter({
85
+ output,
86
+ git,
87
+ showTags = false,
88
+ verbosity = "summary"
89
+ }) {
90
+ if (verbosity === "detailed") {
91
+ return new DetailedReporter({
92
+ output,
93
+ git,
94
+ showTags
95
+ });
96
+ }
97
+ return new SummaryReporter({
98
+ output,
99
+ git,
100
+ showTags
101
+ });
102
+ }
103
+ class SummaryReporter {
104
+ output;
105
+ git;
106
+ showTags;
107
+ scope = null;
108
+ position = /* @__PURE__ */ new Map();
109
+ constructor({
110
+ output,
111
+ git,
112
+ showTags
113
+ }) {
114
+ this.output = output;
115
+ this.git = git;
116
+ this.showTags = showTags;
117
+ }
118
+ async onStart(context) {
119
+ this.output.info(
120
+ context.dry ? "Starting dry release" : "Starting release"
121
+ );
122
+ }
123
+ async onScope(scope, context) {
124
+ this.scope = scope;
125
+ this.position = new Map(
126
+ scope.slices.map((slice, index2) => [slice.id, index2 + 1])
127
+ );
128
+ }
129
+ async onSliceStart(slice) {
130
+ this.output.info("Running slice %s", [this.describeProgress(slice)]);
131
+ }
132
+ async onSuccess(result) {
133
+ if (!result.changed) {
134
+ this.output.success("No changes since last release");
135
+ return;
136
+ }
137
+ const changed = result.slices.filter((slice) => slice.changed);
138
+ const primary = changed[0];
139
+ const packages = collectPackages(changed);
140
+ this.output.success("Release slices: %s", [String(changed.length)]);
141
+ this.output.success("Updated packages: %s", [String(packages.length)]);
142
+ if (primary) {
143
+ this.output.success("Next version: %s", [primary.nextVersion]);
144
+ }
145
+ if (result.dry) {
146
+ this.output.info("No committing or tagging since this was a dry run");
147
+ return;
148
+ }
149
+ this.output.success("Committed %s staged files", [String(result.files.length)]);
150
+ if (this.showTags) {
151
+ const tags = collectTags(changed);
152
+ if (tags.length) {
153
+ this.output.success("Tags: %s", [tags.join(", ")]);
154
+ }
155
+ }
156
+ this.output.info("Run `%s` to publish", [
157
+ `git push --follow-tags origin ${await this.resolveBranch()}`
158
+ ]);
159
+ }
160
+ async onError(error) {
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ this.output.error(message);
163
+ }
164
+ describeProgress(slice) {
165
+ const position = this.position.get(slice.id);
166
+ const total = this.scope?.slices.length;
167
+ const label = describeSlice(slice);
168
+ return position && total ? `${position}/${total}: ${label}` : label;
169
+ }
170
+ async resolveBranch() {
171
+ try {
172
+ return await this.git.revParse("HEAD", { abbrevRef: true });
173
+ } catch {
174
+ return "%branch%";
175
+ }
176
+ }
177
+ }
178
+ class DetailedReporter extends SummaryReporter {
179
+ async onScope(scope, context) {
180
+ await super.onScope(scope, context);
181
+ this.output.info("%s scope: %s packages, %s affected, %s slices", [
182
+ scope.mode,
183
+ String(scope.packages.length),
184
+ String(scope.affected.length),
185
+ String(scope.slices.length)
186
+ ]);
187
+ }
188
+ async onSliceSuccess(slice) {
189
+ if (!slice.changed) {
190
+ this.output.warn("Completed slice %s without version changes (%s)", [
191
+ describeSlice(slice),
192
+ slice.currentVersion
193
+ ]);
194
+ return;
195
+ }
196
+ this.output.success("Completed slice %s: %s -> %s (%s)", [
197
+ describeSlice(slice),
198
+ slice.currentVersion,
199
+ slice.nextVersion,
200
+ slice.releaseType
201
+ ]);
202
+ if (this.showTags && slice.tag) {
203
+ this.output.info("Tag: %s", [slice.tag]);
204
+ }
205
+ }
206
+ async onSuccess(result) {
207
+ await super.onSuccess(result);
208
+ if (!result.changed) {
209
+ return;
210
+ }
211
+ const packages = collectPackages(result.slices.filter((slice) => slice.changed));
212
+ this.output.info("Updated packages: %s", [describePackages(packages)]);
213
+ }
214
+ }
215
+ function describeSlice(slice) {
216
+ if (slice.partition) {
217
+ return `${slice.partition} [${describePackages(slice.packages)}]`;
218
+ }
219
+ return describePackages(slice.packages);
220
+ }
221
+ function describePackages(packages) {
222
+ return packages.map((pkg) => pkg.name ?? pkg.path).join(", ");
223
+ }
224
+ function collectTags(slices) {
225
+ return slices.map((slice) => slice.tag).filter((tag) => !!tag);
226
+ }
227
+ function collectPackages(slices) {
228
+ const packages = [];
229
+ const seen = /* @__PURE__ */ new Set();
230
+ for (const slice of slices) {
231
+ for (const pkg of slice.packages) {
232
+ const identity = pkg.name ?? pkg.path;
233
+ if (seen.has(identity)) {
234
+ continue;
235
+ }
236
+ seen.add(identity);
237
+ packages.push(pkg);
238
+ }
239
+ }
240
+ return packages;
241
+ }
242
+ class ConsoleOutput {
243
+ write(message) {
244
+ console.info(message);
245
+ }
246
+ writeError(message) {
247
+ console.error(message);
248
+ }
249
+ }
250
+ class Output {
251
+ output;
252
+ theme;
253
+ constructor({
254
+ dry,
255
+ output = new ConsoleOutput(),
256
+ theme = createDefaultTheme(dry)
257
+ }) {
258
+ this.output = output;
259
+ this.theme = theme;
260
+ }
261
+ info(template, context = [], figure = this.theme.info) {
262
+ this.output.write(format(template, context, figure));
263
+ }
264
+ success(template, context = [], figure = this.theme.success) {
265
+ this.output.write(format(template, context, figure));
266
+ }
267
+ warn(template, context = [], figure = this.theme.warning) {
268
+ this.output.write(format(template, context, figure));
269
+ }
270
+ error(template, context = [], figure = this.theme.error) {
271
+ this.output.writeError(format(template, context, figure));
272
+ }
273
+ }
274
+ function createDefaultTheme(dry) {
275
+ return {
276
+ success: dry ? chalk.yellow(figures.tick) : chalk.green(figures.tick),
277
+ warning: chalk.yellow(figures.warning),
278
+ error: chalk.red(figures.cross),
279
+ info: chalk.blue(figures.info)
280
+ };
281
+ }
282
+ function format(template, context, figure) {
283
+ const bold = (arg) => chalk.bold(arg);
284
+ const message = util__namespace.format(template, ...context.map(bold));
285
+ return `${figure} ${message}`;
286
+ }
287
+ async function main(argv = process.argv) {
288
+ const cwd = process.cwd();
289
+ const options = await parseArgv(argv);
290
+ const output = new Output({
291
+ dry: options.dry,
292
+ output: new ConsoleOutput()
293
+ });
294
+ const git = new gitToolkit.GitCommander({ sh: new shell.Runner(cwd) });
295
+ await index.run({
296
+ cwd,
297
+ dry: options.dry,
298
+ releaseAs: options.releaseAs,
299
+ prerelease: options.prerelease,
300
+ reporter: createReporter({
301
+ output,
302
+ git,
303
+ showTags: options.tags,
304
+ verbosity: options.verbose ? "detailed" : "summary"
305
+ })
306
+ });
307
+ }
308
+ exports.main = main;