If you've ever managed releases across multiple Git branches, you know the pain: checkout develop, merge to main, tag the release, push both branches, create the GitHub release, update the changelog. Miss a step and you're debugging deployment issues at 5 PM on a Friday.
The good news? You can automate this entire workflow with release-it, a powerful release automation tool. The even better news? Its plugin architecture lets you customize the release process to match your exact branching strategy, including Git Flow.
In this post, we'll build a custom release-it plugin that handles a Git Flow workflow automatically: committing version bumps, merging branches, pushing to remotes, and creating GitHub releases with proper release notes.
Why Automate Your Release Workflow?
Manual releases are error-prone. Even experienced developers occasionally forget to push a branch, skip a merge, or create a tag with the wrong format. These small mistakes compound into bigger problems: broken deployments, version mismatches, and hours spent untangling Git history.
Automated releases solve this by enforcing a consistent process every time. With release-it, you run a single command and the tool handles versioning, Git operations, changelog generation, and publishing. But out of the box, release-it assumes a simpler workflow: bump version, commit, tag, push to one branch.
Git Flow requires more. You might need to:
- Commit the version bump to
main - Fast-forward merge
maininto aprodbranch - Push multiple branches simultaneously
- Create GitHub releases with auto-generated notes
That's where custom plugins come in.
Understanding release-it's Plugin Architecture
Since version 11, release-it has supported a flexible plugin system. Internally, it uses this same architecture for Git, GitHub, GitLab, and npm operations. You can tap into this system to add custom behavior at any point in the release cycle.
Lifecycle Methods
Plugins can implement several lifecycle methods that run at specific points during a release:
- init(): Validate prerequisites and gather package details
- beforeBump(): Prepare information like changelogs before version bump
- bump(version): Update version numbers in manifest files
- beforeRelease(): Run tasks after bump but before release (staging changes, for example)
- release(): Execute the main release flow
- afterRelease(): Clean up and provide success details
All methods can be async, and they run in a predictable order. Custom plugin methods execute before internal plugins, which means you can override default behavior when needed.
Helper Methods
release-it provides several helper methods within plugins:
- this.exec(command): Execute shell commands with template variable substitution
- this.log(message): Output to the console (includes
.verbose,.warn,.errorvariants) - this.setContext() and this.getContext(): Store and retrieve plugin-specific data
- this.options: Access plugin configuration and the current version
Building the Plugin: git-flow
Let's build a plugin that automates a Git Flow variant. Our workflow commits version bumps to main, fast-forward merges to prod, and pushes both branches before creating a GitHub release.
Project Setup
Create a new directory for your plugin and initialize it:
mkdir git-flow-release-it-plugin
cd git-flow-release-it-plugin
npm init -yInstall release-it as both a peer dependency and dev dependency:
npm install --save-dev release-it
npm install --save-peer release-itThe Configuration (package.json)
Here's a complete package.json that demonstrates the release-it configuration alongside the plugin setup:
{
"name": "git-flow-release-it-plugin",
"version": "2026.1.0",
"description": "A release-it plugin for git-flow workflows",
"main": "index.js",
"type": "module",
"scripts": {
"release": "release-it"
},
"peerDependencies": {
"release-it": "^19.0.0"
},
"devDependencies": {
"@csmith/release-it-calver-plugin": "^2022.12.15",
"release-it": "^19.0.0"
},
"release-it": {
"hooks": {
"before:git:release": [
"echo '[git-flow] Committing version bump to main'",
"git add package.json package-lock.json && git commit -m '[RELEASE] ${version}'",
"echo '[git-flow] Fast-forward merging main to prod'",
"git checkout prod && git merge main --ff-only",
"echo '[git-flow] Pushing main and prod branches'",
"git push origin main prod"
],
"after:release": "echo 'Release complete!'"
},
"git": {
"requireBranch": ["main", "prod"],
"push": false,
"commit": false,
"requireCommits": false,
"requireCleanWorkingDir": false,
"tagName": "releases/${version}"
},
"github": {
"release": true,
"releaseName": "Release ${version}",
"releaseNotes": "git log --pretty=format:\"%B\" $(git describe --tags --abbrev=0)..HEAD | grep -v \"Co-authored-by:\""
},
"npm": {
"publish": false
},
"plugins": {
"@csmith/release-it-calver-plugin": {
"format": "yyyy.mm.major",
"increment": "calendar.major"
}
}
}
}Let's break down the key configuration sections:
Hooks: The before:git:release hook runs after version bumping but before release-it's internal Git operations. We use this to commit, merge, and push our branches manually.
Git settings: We disable release-it's default Git behaviors (push: false, commit: false) because we're handling these ourselves in the hooks. The requireBranch array ensures we only run releases from main or prod.
GitHub: release-it creates a GitHub release automatically. The releaseNotes command generates notes from commit messages since the last tag.
CalVer plugin: Instead of semantic versioning, we're using Calendar Versioning with the @csmith/release-it-calver-plugin. This creates versions like 2025.11.0 based on the current year and month.
Plugin Implementation (index.js)
For more complex workflows, you might want a full plugin class instead of just hooks. Here's an implementation that provides better logging and error handling:
import { Plugin } from "release-it";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
class CuttleFlowPlugin extends Plugin {
constructor(...args) {
super(...args);
}
static isEnabled() {
return true;
}
// Step 1: Commit version bump to main
async beforeCommit() {
const version = this.options.version;
try {
this.log.info("[git-flow] Committing package.json to main");
await execAsync(
`git add package.json package-lock.json && git commit -m "[RELEASE] ${version}"`
);
} catch (error) {
this.log.error(`Error during commit: ${error.message}`);
throw error;
}
}
// Step 2: Fast-forward merge to prod
async afterBump() {
try {
this.log.info("[git-flow] Checking out prod and fast-forward merging main");
await execAsync("git checkout prod && git merge main --ff-only");
} catch (error) {
this.log.error(`Error during merge: ${error.message}`);
throw error;
}
}
// Step 3: Push both branches
async beforeRelease() {
try {
this.log.info("[git-flow] Pushing main and prod branches");
await execAsync("git push origin main && git push origin prod");
} catch (error) {
this.log.error(`Error during push: ${error.message}`);
throw error;
}
}
}
export default CuttleFlowPlugin;To use this plugin locally, reference it in your release-it configuration:
{
"plugins": {
"./index.js": {}
}
}How It Works: A Complete Release Cycle
When you run npm run release, here's what happens:
- Version bump: release-it (with the CalVer plugin) calculates the next version number
- Prompt: You're asked to confirm the version bump
- Update files:
package.jsonis updated with the new version - Commit: Your hook commits the version change to
main - Merge:
mainis fast-forward merged intoprod - Push: Both branches are pushed to origin
- Tag: A Git tag is created (e.g.,
releases/2026.1.0) - GitHub release: A release is created with auto-generated notes
For CI environments, add --ci to run non-interactively:
npm run release -- --ciCustomization Options
CalVer vs SemVer
We used Calendar Versioning in this example, but release-it supports semantic versioning out of the box. To switch, simply remove the CalVer plugin and release-it will use SemVer with options like --increment=patch, --increment=minor, or --increment=major.
CalVer works well for applications deployed continuously, where "version 2026.1.5" tells you more than "version 3.2.1." Libraries and APIs often benefit from SemVer's clear breaking-change signals.
Adapting for Your Branch Strategy
Not everyone uses main and prod. You might have:
develop→main(classic Git Flow)main→staging→production(multi-environment)- Release branches like
release/2026.1
Adjust the hooks and requireBranch setting to match your strategy. You can also use environment variables or release-it's --config flag to switch between configurations for different projects.
Gotchas and Troubleshooting
Fast-forward merge fails: This happens when prod has commits that aren't in main. You'll need to rebase or handle merge commits differently.
Hook failures don't stop the release: By default, if a hook command fails, release-it continues. Use set -e in bash commands or throw errors in plugin methods to halt the process.
GitHub authentication: Make sure your GITHUB_TOKEN environment variable is set with permissions to create releases.
Dry run first: Always test with npm run release -- --dry-run before running a real release. This shows exactly what commands will execute without making any changes.
npm run release
Automated releases remove human error from one of the most critical parts of software delivery. With release-it's plugin architecture, you're not stuck with a one-size-fits-all approach. You can build exactly the workflow your team needs, whether that's Git Flow, trunk-based development, or something entirely custom.
The plugin we built handles the common Git Flow pattern: version bump, branch merge, multi-branch push, and GitHub release creation. Start with the hook-based approach for simplicity, then graduate to a full plugin class when you need more control.
For more on using Git effectively in your development workflow, check out our post on using Git to manage software documentation.
Need help automating your release pipeline or building custom developer tooling? Let's talk about how Cuttlesoft can streamline your development workflow.


