Writing custom semantic-release plugins tailored to organizational needs, leveraging @semantic-release/exec to run external scripts, and integrating publishing with an artifact repository or private registry.

In episode 15 we managed multiple release branches. Semantic-release's built-in plugins handle commit analysis, changelog, npm, and GitHub — about 80 percent of the need. But organizations often have special needs: signing artifacts, uploading to an internal registry, or calling another service after a release.
This episode covers custom plugins & extensions: how semantic-release executes plugins via lifecycle hooks, how to write a simple plugin, using @semantic-release/exec for shell commands without writing code, and integration with a private registry.
Every plugin is an object exposing one or more lifecycle hooks. Semantic-release runs them in sequence:
verifyConditions — validates prerequisites before the release starts.analyzeCommits — determines the release type (major/minor/patch).verifyRelease — final check before the version is locked in.generateNotes — creates the release notes.prepare — prepares files before they're committed (version bump, changelog).publish — uploads the artifact.addChannel — adds the version to another channel.success — called after a successful release.fail — called if an error occurs.Each hook receives pluginConfig (options from the config array) and context (containing version, commits, logger, and more).
A plugin is simply a module that exports those functions. Here's an example plugin that validates artifact size and writes additional notes:
module.exports = {
async verifyConditions(pluginConfig, context) {
const { logger } = context;
const max = pluginConfig.maxAssetSizeMB || 100;
logger.log('Making sure the artifact size stays below %d MB', max);
},
async analyzeCommits(pluginConfig, context) {
const { commits } = context;
const breaking = commits.some((c) => c.message.includes('BREAKING CHANGE'));
return breaking ? 'major' : null;
},
async generateNotes(pluginConfig, context) {
const { nextRelease } = context;
return 'This release was signed by the internal pipeline.';
},
async publish(pluginConfig, context) {
const { nextRelease, logger } = context;
logger.log('Uploading artifact version %s', nextRelease.version);
return { name: nextRelease.version, url: 'https://artifact.internal.example.com' };
},
};Key points:
'major' from analyzeCommits, or a URL from publish) or throw an error to stop the pipeline.context provides nextRelease, commits, logger, lastRelease, and more.release.config.cjs.module.exports = {
branches: ['main'],
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
['./plugins/cek-artifact.js', { maxAssetSizeMB: 100 }],
'@semantic-release/npm',
'@semantic-release/github',
],
};Not every need requires a full plugin. @semantic-release/exec runs shell commands at the lifecycle points you choose:
['@semantic-release/exec', {
verifyConditionsCmd: 'node ./scripts/cek-license.js',
prepareCmd: 'node ./scripts/build-artifact.js',
publishCmd: 'npm publish --registry ${NPM_CONFIG_REGISTRY}',
successCmd: 'node ./scripts/notify.js ${nextRelease.version}',
}],The provided commands can use context variables — for example the version number nextRelease.version and the notes nextRelease.notes. For the publish hook, the script must print a JSON output to stdout — semantic-release uses it as the publish result:
{"name":"1.5.0","url":"https://artifact.internal.example.com/pkg/1.5.0"}Install with bun add -D @semantic-release/exec.
Warning
Shell commands in publishCmd run in the CI environment. Make sure all
sensitive values come in through environment variables, not hardcoded in
the configuration. Also, don't have publishCmd print free-form text —
the output must be valid JSON so other plugins don't fail when reading the
publish result.
When managing an internal registry (e.g. Verdaccio, Artifactory, or GitLab Package Registry), simply point npm at that registry via env:
export NPM_CONFIG_REGISTRY=https://npm.internal.example.com
export NPM_TOKEN=<nilai dari secret CI>
npx semantic-release --dry-run --no-ciNPM_CONFIG_REGISTRY and NPM_TOKEN are the two env vars most often used for a private registry. Make sure both go into GitHub Actions secrets and are added to the env of the release step. With a combination of custom plugins and exec, the release pipeline can point anywhere — as long as there's an endpoint that can accept the artifact.
Tip
Start with @semantic-release/exec for one-or-two command needs, and move
up to a custom plugin when the logic gets complex, needs unit tests, or is
reused across many repositories. A tested custom plugin is easier to
maintain than a long shell command.
Episode 16 recap:
@semantic-release/exec runs shell scripts without writing a plugin — with JSON output for publish.NPM_CONFIG_REGISTRY and NPM_TOKEN.exec.With programmable plugins, release automation can now adapt to your organization. In episode 17 we'll cover Migrating an Existing Repository to Conventional Commits — adopting the commit style, setting up commitlint and husky, and handling old history without conventional commits. See you there!