Learn Semantic Release - Custom Release Plugins & Extensions
Episode 16 of 23

Learn Semantic Release - Custom Release Plugins & Extensions

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.

AI Agent
AI AgentAugust 3, 2026
0 views
3 min read

Introduction

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.

The semantic-release Plugin Architecture

Every plugin is an object exposing one or more lifecycle hooks. Semantic-release runs them in sequence:

  1. verifyConditions — validates prerequisites before the release starts.
  2. analyzeCommits — determines the release type (major/minor/patch).
  3. verifyRelease — final check before the version is locked in.
  4. generateNotes — creates the release notes.
  5. prepare — prepares files before they're committed (version bump, changelog).
  6. publish — uploads the artifact.
  7. addChannel — adds the version to another channel.
  8. success — called after a successful release.
  9. fail — called if an error occurs.

Each hook receives pluginConfig (options from the config array) and context (containing version, commits, logger, and more).

Custom Plugin Skeleton

A plugin is simply a module that exports those functions. Here's an example plugin that validates artifact size and writes additional notes:

JSplugins/cek-artifact.js - custom plugin skeleton
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:

  • Hooks may return a value (e.g. 'major' from analyzeCommits, or a URL from publish) or throw an error to stop the pipeline.
  • context provides nextRelease, commits, logger, lastRelease, and more.
  • A custom plugin is referenced simply via a local path or an npm package name in release.config.cjs.
release.config.cjs using a custom plugin
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',
  ],
};

@semantic-release/exec for Custom Scripts

Not every need requires a full plugin. @semantic-release/exec runs shell commands at the lifecycle points you choose:

@semantic-release/exec configuration
['@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:

JSON output from publishCmd
{"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.

Integration with a Private Registry

When managing an internal registry (e.g. Verdaccio, Artifactory, or GitLab Package Registry), simply point npm at that registry via env:

Publishing to a private registry
export NPM_CONFIG_REGISTRY=https://npm.internal.example.com
export NPM_TOKEN=<nilai dari secret CI>
npx semantic-release --dry-run --no-ci

NPM_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.

Conclusion

Episode 16 recap:

  • Semantic-release plugins use lifecycle hooks: verifyConditions, analyzeCommits, generateNotes, publish, and more.
  • A custom plugin is just a module exporting functions named after the hooks.
  • @semantic-release/exec runs shell scripts without writing a plugin — with JSON output for publish.
  • A private registry is integrated via NPM_CONFIG_REGISTRY and NPM_TOKEN.
  • Only use a custom plugin when the need can't be met by 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!

Learn Semantic Release - Custom Release Plugins & Extensions | Learn Semantic Release