Why I rewrote semantic-release in Go
semantic-release does one job: read commits, decide the next version, write a changelog, cut a release. That's it. That's the whole job. And somehow the canonical implementation of it needs @semantic-release/commit-analyzer, @semantic-release/release-notes-generator, @semantic-release/changelog, @semantic-release/git, @semantic-release/github, a .releaserc to wire them together in the right order, and a node_modules folder that's bigger than most of the projects it's releasing.
I've set this up more times than I can count, across projects that share nothing except wanting the same four things to happen on push to main. Every single time: which combination of plugins do I need this time, in what order, with what config, for what my package.json looks like today. I've copy-pasted the same .releaserc between repos so many times it stopped feeling like configuration and started feeling like incantation. You don't understand it, you just know that if you leave a step out the release silently doesn't do the one thing you needed.
the actual complaint
It's not that semantic-release is bad software. It's that a plugin architecture is the wrong shape for a job with four fixed steps and no real variation between projects. Every plugin is its own package, its own version, its own changelog to check when something breaks. Every plugin needs to agree with every other plugin about what "the next version" is, and when they don't, you get to debug a pipeline made of five packages you didn't write, none of which are the thing that's actually broken.
And underneath all five plugins: Node. A whole runtime and a node_modules install, in CI, for every single project, including the ones written in Go or Rust or whatever, that otherwise have nothing to do with JavaScript. I've watched a Go project's release job take longer to npm ci than to build the actual binary.
None of this is optional complexity. It's not there because the problem is hard. Conventional Commits already tell you the next version. The four steps are read commits, bump version, write changelog, tag and release. That's a weekend project, not a plugin ecosystem.
I looked at GoReleaser first
Before writing my own, I went looking, because writing your own anything is usually a sign you didn't look hard enough. GoReleaser is the obvious name that comes up, it's mature, it's got a real ecosystem, companies like Google and GitHub are listed as users. So I gave it a real look.
It's not the same tool solving the same problem. GoReleaser's job is building and packaging: cross-compiling binaries for a dozen platforms, producing Docker images, pushing to Homebrew, Scoop, Winget, AUR, Nix, generating SBOMs, signing and notarizing artifacts. That's a real problem and GoReleaser is clearly good at it. What it doesn't do is decide your next version from your commit history, you still tag the repo yourself, GoReleaser runs after that tag exists. I didn't need a packaging pipeline for a dozen platforms and half a dozen package managers, I needed the boring part before any of that: read the commits, work out the version, write the changelog, cut the release. Bolting GoReleaser on top would've meant configuring a tool built for a much bigger job just to get the one step it doesn't actually do.
So, wrong tool. Not a bad one, just aimed at a different problem than the one sitting on my plate.
Releaser
So I wrote it as a weekend project. One static Go binary, zero dependencies, zero config. Point it at a repo that uses Conventional Commits and on push to main it reads the commits since the last tag, picks the next version, writes CHANGELOG.md, bumps package.json if there is one, commits, tags, and creates the GitHub release. No .releaserc, no plugin list, no deciding which five packages to trust today.
- uses: orochibraru/releaser@v1That's the config. There is no more config.
what it actually looks like
The bare minimum, no options at all:
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: orochibraru/releaser@v1Push a fix: or feat: commit to main, it tags, releases, done. No merge commit shenanigans, no separate release branch.
Attach a build artifact to the release, which is the other thing I actually needed:
- uses: orochibraru/releaser@v1
with:
artifacts: dist/app.zip=app-${version}.zip${version} gets substituted with whatever it just decided the version is, so the asset name matches the tag without a separate step to rename the file.
Build and push a Docker image in the same run:
- uses: orochibraru/releaser@v1
with:
docker: true
docker-image: ghcr.io/orochibraru/homerun
docker-platforms: linux/amd64,linux/arm64Both modes together, because half of what I release is a binary and the other half needs to run somewhere:
- uses: orochibraru/releaser@v1
with:
artifacts: dist/app.zip=app-${version}.zip
docker: true
docker-platforms: linux/amd64,linux/arm64And when you actually want to know what happened, the outputs are there so you don't have to go parse a log:
- id: release
uses: orochibraru/releaser@v1
- if: steps.release.outputs.released == 'true'
run: echo "shipped ${{ steps.release.outputs.tag }}"released is false and everything else is empty when nothing shipped, so that if: is the whole check, no scraping stdout for a version number.
That's the entire surface. No .releaserc, no thinking about plugin order, no second dependency tree for the Docker part. If you need something more exotic, rules lets you override which commit types bump what (breaking=patch if you're feeling reckless), and prepare runs a shell command before the release commit if you need to touch a file releaser doesn't know about. Everything else is a sane default you'll probably never look at.
I'm not claiming this replaces semantic-release for everyone, and it's definitely not trying to replace GoReleaser either, if you need cross-platform packaging and a dozen package manager integrations, go use GoReleaser, that's its actual job. But if what you actually needed was "bump the version and write the changelog like every other project I have," you didn't need a plugin ecosystem or a packaging suite. You needed four steps and a binary that does them.
Docs and examples are in the repo if you want to see what "zero config" looks like when it's actually zero.