> semantic-release does one job and buries it under a plugin registry, a .releaserc, and a node_modules folder heavier than the project it's releasing. I looked at GoReleaser too, wrong tool for a different job. Releaser is the same four steps and a single Go binary.
> Source: https://orochibraru.com/blog/why-i-rewrote-semantic-release-in-go · Site index: https://orochibraru.com/llms.txt

# Why I rewrote semantic-release in Go

*21 September 2026*

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](https://goreleaser.com/) 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](https://github.com/orochibraru/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.

```yaml
- uses: orochibraru/releaser@v1
```

That's the config. There is no more config.

## what it actually looks like

The bare minimum, no options at all:

```yaml
on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v7
      - uses: orochibraru/releaser@v1
```

Push 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:

```yaml
- 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:

```yaml
- uses: orochibraru/releaser@v1
  with:
    docker: true
    docker-image: ghcr.io/orochibraru/homerun
    docker-platforms: linux/amd64,linux/arm64
```

Both modes together, because half of what I release is a binary and the other half needs to run somewhere:

```yaml
- uses: orochibraru/releaser@v1
  with:
    artifacts: dist/app.zip=app-${version}.zip
    docker: true
    docker-platforms: linux/amd64,linux/arm64
```

And when you actually want to know what happened, the outputs are there so you don't have to go parse a log:

```yaml
- 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](https://github.com/orochibraru/releaser) if you want to see what "zero config" looks like when it's actually zero.
