Release-core package for conventional workflows.
This workspace combines:
- semantic version recommendation from
@modulify/conventional-bump, - changelog rendering and writing from
@modulify/conventional-changelog, - package manifest updates,
- release finalization with commit and tag creation.
The package is library-first. It exposes:
createScope()to inspect what would be released,run()to apply the release flow,conventional-releaseas a config-driven CLI binary.
This package is intentionally focused on release-core responsibilities inside the repository:
- discover the release scope
- compute versions from commit history
- update manifests and changelog files
- finalize the release with a commit and tags
It does not try to be an all-in-one delivery tool.
In particular, npm publish, GitHub Releases, GitLab Releases, registry credentials, and deployment-specific CI steps are out of scope for this package.
The intended layering is:
@modulify/conventional-releasehandles planning and repository-local release finalization- higher-level tools can add publishing, hosting, or CI-specific orchestration on top
yarn add -D @modulify/conventional-releaseOther package managers:
npm install -D @modulify/conventional-release
pnpm add -D @modulify/conventional-release
bun add -d @modulify/conventional-releaseThe package works in two stages:
createScope(options)discovers the release scope for the repository. It resolves packages, filters workspaces, detects affected packages, and produces ordered release slices.run(options)resolves the same scope and applies side effects. It updates manifests, writes the changelog, creates a commit, and creates tags.
That is the end of this package's responsibility boundary. Delivery steps outside the repository, such as package publication or hosted release creation, should be implemented above this layer.
Scope is the declarative view of a release.
Slice is one execution unit inside that scope.
In sync mode there is usually one slice for the whole repository.
In async mode each affected package gets its own slice.
In hybrid mode packages can be split into named partitions.
import { run } from '@modulify/conventional-release'
const result = await run()
if (!result.changed) {
console.log('No changes since last release')
} else {
for (const slice of result.slices) {
if (!slice.changed) continue
console.log(slice.id, slice.nextVersion, slice.tag)
}
}The package ships a conventional-release binary.
Typical usage:
conventional-release
conventional-release --dry
conventional-release --dry --verbose --tagsFrom a project script:
{
"scripts": {
"release": "conventional-release",
"release:dry": "conventional-release --dry"
}
}Without adding a local script:
npx @modulify/conventional-release --dry
npm exec conventional-release -- --dry
yarn dlx @modulify/conventional-release --dry
pnpm dlx @modulify/conventional-release --dry
bunx @modulify/conventional-release --dryUseful flags:
--dry: compute versions, files, and tags without write-side effects--verbose: show detailed per-slice progress output--tags: print generated tags in the final summary--release-as <type>: forcemajor,minor, orpatch--prerelease <channel>: usealpha,beta, orrc
The CLI reads the same repository configuration as the library API and wires a lifecycle reporter into run().
It stops after repository-local release finalization and does not publish artifacts.
Use createScope() when you want a dry, deterministic view of the release shape:
import { createScope } from '@modulify/conventional-release'
const scope = await createScope({
mode: 'hybrid',
})
console.log(scope.mode)
console.log(scope.packages.map((pkg) => pkg.path))
console.log(scope.slices.map((slice) => slice.id))This is useful for:
- the built-in package CLI,
- custom CLIs,
- dashboards,
- approval flows,
- tests around release planning.
run() applies the release flow and returns per-slice results:
import { run } from '@modulify/conventional-release'
const result = await run({
mode: 'sync',
dry: true,
})
console.log(result.changed)
console.log(result.files)
console.log(result.slices)When dry: true is used, the package still resolves versions, tags, and touched files, but skips write-side effects.
Configuration is resolved in this order:
package.jsonfieldreleaserelease.config.ts,release.config.mjs, orrelease.config.js- inline options passed to
run()orcreateScope()
Inline options always win.
Example package.json:
{
"name": "example-repo",
"version": "1.0.0",
"release": {
"mode": "sync",
"tagPrefix": "v"
}
}Example release.config.ts:
import type { Options } from '@modulify/conventional-release'
const config: Options = {
mode: 'hybrid',
partitions: {
core: {
mode: 'sync',
workspaces: ['@scope/core-*'],
},
plugins: {
mode: 'async',
workspaces: ['packages/plugins/*'],
tagPrefix: 'plugin-',
},
},
}
export default configThe most important public options are:
mode: release strategy, one ofsync,async, orhybridreleaseAs: explicit semver bump override such asmajor,minor, orpatchprerelease: prerelease channel, one ofalpha,beta, orrcfromTag: explicit lower bound tag for advisory commit analysistagPrefix: tag matcher used during advisory commit analysisworkspaces: include and exclude filters for workspace discoverypartitions: named hybrid slices for mixed release strategiesdependencyPolicy: how internal dependency ranges are updated, one ofpreserve,caret, orexactinstall: whether install should run after manifest updatestagName,tagMessage,commitMessage: custom formatters for release outputchangelogFile: changelog output path relative to the repository root
Important:
tagPrefixaffects release discovery and commit analysis boundaries.tagPrefixdoes not format the new tag name by itself.- To change produced tag names, use
tagName.
import { run } from '@modulify/conventional-release'
await run({
mode: 'sync',
fromTag: 'v1.0.0',
})This is the simplest setup and usually produces one slice:
- one next version,
- one commit,
- one tag.
By default, a changed sync slice produces a tag like v1.2.3.
import { run } from '@modulify/conventional-release'
await run({
mode: 'async',
workspaces: {
include: ['packages/*'],
},
})This creates one slice per affected package.
By default, each changed async slice produces a tag like package-name@1.2.3.
import { run } from '@modulify/conventional-release'
await run({
mode: 'hybrid',
partitions: {
app: {
mode: 'sync',
workspaces: ['@scope/app', '@scope/web'],
},
plugins: {
mode: 'async',
workspaces: ['packages/plugins/*'],
},
},
})This is useful when some packages must move in lockstep, while others can release independently.
By default, partition slices use tags like partition-name@1.2.3.
run() returns:
changed: whether at least one slice changed versionfiles: all files touched by changed slicespackages: all packages in resolved scopeaffected: packages affected by the current working treeslices: ordered slice results with:idkindmodepackagescurrentVersionnextVersionreleaseTypetagcommitMessagetagMessage
Example:
const result = await run({ dry: true })
for (const slice of result.slices) {
console.log({
id: slice.id,
changed: slice.changed,
nextVersion: slice.nextVersion,
tag: slice.tag,
})
}After manifest updates the package can run the repository package manager install command.
install supports three forms:
false: skip install entirelytrueor omitted: run install with default extra arguments for the detected package managerstring[]: run install and append these extra arguments after theinstallsubcommand
Example:
await run({
install: ['--mode=skip-build'],
})That becomes conceptually:
<package-manager> install --mode=skip-buildThe package detects the package manager in this order:
package.json#packageManager- lockfiles in the repository root
- fallback to
npm
Recognized lockfiles:
yarn.lockpnpm-lock.yamlpackage-lock.jsonbun.lockbun.lockb
Default install extras:
yarn:--no-immutablenpm: no extra argspnpm: no extra argsbun: no extra args
Custom formatters receive a TagContext object:
import { run } from '@modulify/conventional-release'
await run({
tagName: ({ version, partition, packages }) => {
const name = partition ?? packages[0]?.name ?? 'release'
return `${name}@${version}`
},
commitMessage: ({ tag }) => `chore(release): ${tag}`,
tagMessage: ({ tag }) => `chore(release): ${tag}`,
})- The package detects the package manager from
package.json#packageManageror lockfiles. - The default fallback package manager is
npm. - The package does not perform
git push. - CLI-style push hints belong in the CLI layer, not in the library result.