Skip to main content

better-semantic-release

A drop-in fork of python-semantic-release with built-in release-safety guards.

CI status CD status Coverage of src/semantic_release/bsr Latest release on PyPI Supported Python versions Apache-2.0 licence

better-semantic-release is a drop-in fork of python-semantic-release (MIT licensed). This fork is distributed under Apache-2.0; the upstream MIT terms are preserved in LICENSE-MIT. It keeps the same [tool.semantic_release] configuration schema, the same semantic-release CLI, and the same GitHub Action interface – switching over only requires pointing the uses: line at the fork:

uses: n24q02m/better-semantic-release@v<major>

On top of that, the fork adds release-safety guards that run automatically before any commit, tag, or push is made. Guards can be opted out of per-repository under the [tool.semantic_release.bsr] table in pyproject.toml. Cross-ecosystem starting points (Python, Node, Rust, generic repos) live in docs/recipes/.

Design principles

better-semantic-release is evolving from a safety-guarded fork into a release control plane built around three verbs:

plan – compute and render the release decision (what would be released, for which components, and every blocker) as structured data, read-only. verify – run the safety/policy checks and report pass/fail per check. publish – execute the release with the same fail-closed guards, reporting structured results.

Non-negotiables for every change to this codebase:

  • No Release-PR machinery. Reviewability comes from the plan surface (CLI, JSON, job summaries), never from bot branches, PR labels, or PR state machines.

  • Drop-in compatibility is the default path. The [tool.semantic_release] schema, the existing CLI commands, and the GitHub Action interface must keep working unchanged; new behavior is opt-in under [tool.semantic_release.bsr] or behind new subcommands.

  • Universal support via adapter contracts, not if ecosystem == ... branches scattered through the core. New version sources, targets, and publishers plug in as adapters; core logic stays ecosystem-agnostic.

  • Fail closed. When safety is uncertain (unreachable registry, unknown registry, unverifiable publish state), the tool blocks the release instead of guessing.

  • New logic lives under src/semantic_release/bsr/* where possible, keeping the upstream delta auditable and the future upstream-sync cost low.

Compatibility contract

The following surfaces are locked; the right-hand column lists the only allowed extension direction.

Surface

Must keep working (locked)

Allowed extension

semantic-release version stdout

Bare next-version string on stdout; no added prefixes, suffixes, or JSON

--format json (already opt-in); new subcommands only

[tool.semantic_release] schema

Every existing key parses and behaves identically

New keys under [tool.semantic_release.bsr] only

GitHub Action interface

Inputs and outputs (version, tag, released, commit_sha, …) keep their names and semantics

Additional outputs/artifacts may be added; existing ones never renamed

Dry-run / --noop behavior

Makes no change, keeps current exit-code semantics

May run additional read-only checks (plan/verify surfaces)

How it differs from upstream

Behavior

Upstream (python-semantic-release)

better-semantic-release

Orphaned release-tag detection (a rebase or force-push silently freezes releases on a tag nobody notices)

None

Built-in, fails loud

Registry-collision detection (re-publishing a version that already exists on the target registry)

None

Built-in, fails closed

Monorepo commit path filtering (commits outside a component’s configured path(s) count toward its version bump / changelog)

None

Opt-in, off by default (drop-in)

Why a run did (or did not) release – the real reason, not the misattributed “already released” message

Only at INFO log level, never surfaced

Opt-in, off by default (explain)

Recurring cryptic failures (bad config key, unknown commit parser, missing git remote, prerelease-bump mismatch)

Raw str(exc), or an uncaught traceback

Opt-in “what / why / fix” messages (actionable_errors)

Per-component “what would this release do” plan for a monorepo

None

Opt-in report-only table (summary)

Stable release notes after a prerelease line (prerelease tags “consume” the commits, leaving the stable section empty)

Won’t fix upstream

Opt-in aggregation (stable_notes_aggregate)

Machine-readable output for the release decision (a CI job or agent reading the result has to parse English prose)

None – prose on stderr, one bare version string on stdout

Opt-in --format json on version and publish

Config / CLI / GitHub Action interface

–

Identical (drop-in)

Configuration reference

Every fork addition lives under [tool.semantic_release.bsr] in the same config file upstream already reads (pyproject.toml, setup.cfg, releaserc.toml, …). The two guards are on by default; everything else is off by default, so an untouched config behaves exactly like upstream.

Key

Default

What it does

guard_orphan_tag

true

Fail loud when the version recomputes to an already-released tag that is no longer reachable from HEAD (a rebase / force-push dropped the release commit).

guard_registry_collision

true

Fail closed when the computed version already exists on the target registry.

registry

auto

"pypi", "npm" or "none". Auto-targets PyPI when [project].name is declared.

path_filter / paths

false / []

Count only commits touching the configured path prefixes toward this component’s bump and changelog.

explain

false

Report the real reason a run did or did not release.

actionable_errors

false

Replace recurring cryptic failures with “what / why / fix” messages.

summary / components

false / []

Print a report-only, per-component release plan for a monorepo.

stable_notes_aggregate / stable_notes_scope

false / "line"

Fold the notes of intervening prereleases into the stable release they finalize.

Every diagnostic below writes to stderr, so semantic-release version keeps printing only the version number on stdout and stays safe to capture in a shell substitution.

Release diagnostics (explain)

When next_version() recomputes a version that already exists, upstream always prints the same line – No release will be made, X has already been released! – no matter what actually happened. The usual real cause is that no commit since the last release qualified for a bump, a fact upstream only logs at INFO and never surfaces.

[tool.semantic_release.bsr]
schema_version = 1
explain = true

With explain on, that line is replaced by the classified cause:

  • NO_QUALIFYING_COMMITS – commits were scanned, none were releasable (no feat / fix / breaking-change commits). This is the case upstream misattributes.

  • ALREADY_RELEASED_NOOP – the current tip is already tagged; a genuine re-dispatch with nothing new to release.

  • ORPHAN – the version recomputes to an already-released but unreachable tag, which is the silent-release-freeze the orphan-tag guard exists for.

On a run that does release, it also prints a “why this bump” line – the bump level, the per-commit-type breakdown behind it, the base version, and how many commits were scanned:

better-semantic-release explain: minor bump from 2 feat, 3 fix commit(s) since 1.4.0 (5 commit(s) scanned).

Actionable error messages (actionable_errors)

Upstream surfaces its most-cited failures as a bare str(exc) with no framing, and leaves two of them uncaught entirely (raw traceback).

[tool.semantic_release.bsr]
schema_version = 1
actionable_errors = true

With the flag on, these categories are rewritten as “what happened / why / how to fix”:

  • PRERELEASE BUMP MISMATCH – a prerelease-level bump was requested but the base version is not already a prerelease.

  • INVALID CONFIGURATION – each failing key is listed as [tool.semantic_release.<key>]: <reason> instead of a raw pydantic dump.

  • PARSER LOAD FAILED – lists the valid parser names (angular, conventional, conventional-monorepo, emoji, scipy) and the module:ClassName form.

  • GIT REMOTE NOT FOUND – shows both fixes (add the remote, or point [tool.semantic_release.remote] at an existing one).

  • TAG_FORMAT MISMATCH – a note emitted when the repository has git tags but none match the configured tag_format, which otherwise makes upstream silently treat the repository as having no prior releases and start over from the initial version.

Monorepo path filtering

Upstream’s directory: input only selects where config (pyproject.toml, tag_format, …) is read from – it does not filter which commits are analyzed, so in a monorepo a commit touching an unrelated component still bumps this component’s version and shows up in its changelog. This is opt-in and off by default (drop-in): enable it under [tool.semantic_release.bsr].

[tool.semantic_release.bsr]
schema_version = 1
path_filter = true
paths = ["apps/api", "libs/shared"]
  • path_filter (default false) – master switch. When false the fork behaves identically to upstream: every commit since the last release counts, regardless of which paths it touched.

  • paths (default [], repository-root-relative) – one or more path prefixes; a commit only counts toward this component if it changed a file under one of them. Multiple entries are OR’d together, which supports a component that also depends on shared code (e.g. ["apps/api", "libs/shared"]).

  • When path_filter is true and paths is left empty, it defaults to the run directory (the GitHub Action’s directory: input) made relative to the repository root.

Monorepo release-plan summary (summary)

Neither upstream’s conventional-monorepo parser nor the path filter above tells you what a release run would do across a monorepo’s components before it does it.

[tool.semantic_release.bsr]
schema_version = 1
summary = true
components = [
    { name = "api", paths = ["apps/api", "libs/shared"] },
    { name = "web", paths = ["apps/web"] },
]

Each row is computed with the same next_version() upstream uses for the real release, scoped to that component’s paths:

better-semantic-release summary: monorepo release plan
component  would-release  level       commits  sample paths                            version
---------  -------------  ----------  -------  --------------------------------------  -------
api        yes            MINOR       4        apps/api/main.go, apps/api/handlers.go  1.4.0
web        no             NO_RELEASE  0        -                                       1.2.3
  • The report is read-only – nothing is committed, tagged, pushed or persisted, and it renders before any persistence step, so it appears whether or not the run itself ends up releasing.

  • components is optional. Left empty, you get a single row built from bsr.paths and named after [project].name – so a non-monorepo project still gets a plan instead of an empty report.

  • A component with no paths means “the whole repository” (the filter is a passthrough), not “nothing”.

Stable release notes aggregation (stable_notes_aggregate)

Upstream buckets every commit under the nearest tag walking back from HEAD. Once a line has rc/beta tags, those prereleases have already “consumed” the commits, so finalizing the stable version with no brand-new commits of its own produces a release section that is empty – or fragmented across the prerelease tags instead of one grouped vX.Y.Z section. This is the most-cited changelog complaint upstream (issues #555, #817, #1377, #1440) and is won’t-fix there.

[tool.semantic_release.bsr]
schema_version = 1
stable_notes_aggregate = true
stable_notes_scope = "line"  # or "since_stable"

With this on, the notes of every intervening prerelease are merged into the stable release that finalizes them, de-duplicated by commit sha, covering both the written changelog and the VCS release notes. It only ever runs for a genuine stable finalize; a prerelease run is untouched.

  • scope = "line" (default) – fold in prereleases sharing the stable version’s major.minor.patch.

  • scope = "since_stable" – walk back from the new version and fold in every intervening prerelease regardless of line, stopping at (and excluding) the previous stable tag. This differs from "line" when a prerelease track was abandoned mid-line: if a forced bump moved 0.2.0-beta.1 to 1.0.0-beta.1, "line" picks up only 1.0.0-beta.1 while "since_stable" also folds in the abandoned 0.2.0-beta.1.

Machine-readable output (--format json)

Everything above is reported as English prose. That is the right default for a human reading a CI log, and the wrong one for the CI job itself: to answer “did this release, and why not”, a script has to either match on message text or re-derive the answer from git. --format json is the exit for the data the tool already computed.

It is a CLI flag, not a [tool.semantic_release.bsr] key – it describes how one invocation reports, not how the repository releases, so it does not belong in a committed config that every other run would inherit.

$ semantic-release version --print --format json
{
  "schema_version": 1,
  "released": false,
  "version": "1.3.0",
  "tag": "v1.3.0",
  "is_prerelease": false,
  "previous_version": "1.2.3",
  "reason": null,
  "commit_count": 7,
  "level_bump": "minor",
  "type_counts": { "features": 4, "bug fixes": 3 },
  "components": []
}

Three guarantees, with no exceptions to memorize:

  • stdout carries exactly one JSON document and nothing else – for every way the command can end, including --print, --print-last-released, a run that makes no release, and the failure exits. A caller can run json.loads over the whole stream without special-casing any path. (--print-last-released has no bare line to print in this mode; its datum travels in previous_version.)

  • Human output is unchanged without the flag – byte-for-byte, asserted directly by the test suite. Anything parsing today’s --print line is unaffected.

  • The decision data does not depend on the diagnostics being on. explain controls what is narrated; the document reports reason, level_bump and type_counts whether or not it is set. (components is the one exception: it mirrors the summary report, so it is [] unless summary is configured.)

The full field reference for both documents is in docs/api/commands.rst, under the --format option of each command. schema_version is 1; it exists so a consumer can pin behavior if fields are ever added or renamed.


Python Semantic Release

Automating Releases via SemVer and Commit Message Conventions


The official documentation for Python Semantic Release can be found at python-semantic-release.readthedocs.io.

GitHub Action

When using the Python Semantic Release GitHub Action, it executes the command semantic-release version using python-semantic-release.

The usage information and examples for this GitHub Action is available under the GitHub Actions section of python-semantic-release.readthedocs.io.

Release files for better-semantic-release 1.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for better-semantic-release 1.7.0
File Size Uploaded
better_semantic_release-1.7.0.tar.gz 809.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for better-semantic-release 1.7.0
File Interpreter ABI Platform
better_semantic_release-1.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.1 MB

Release files / better_semantic_release-1.7.0.tar.gz

Download URL better_semantic_release-1.7.0.tar.gz
Size 809.5 kB
Tags Source
SHA-256 checksum
How to use checksums
8f280e50fab144f73792f0993729e1a9d8735e8559b93001bab7e072e5f2bdb3
BLAKE2b-256 checksum
How to use checksums
19f065ac85f5dce90028867077d16ecd11bc4bb55c64975a3b0dca9d9a1ff3e7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 18, 2026.

Transparency log

Release files / better_semantic_release-1.7.0-py3-none-any.whl

Download URL better_semantic_release-1.7.0-py3-none-any.whl
Size 266.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6683f3eaf49908fd4516b61995aa1caa28bd27fdeb7210a98007ddd8fc075cdf
BLAKE2b-256 checksum
How to use checksums
90730e160da2216780715a0f45bfc0bd05f0d09de5fa5fb29ccad55e41a3e576
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 18, 2026.

Transparency log

Release history Release notifications | RSS feed

1.7.3

2 release files

1.7.2

2 release files

1.7.1

2 release files

This release

1.7.0 This release

2 release files

1.6.10

2 release files

1.6.9

2 release files

1.6.8

2 release files

1.6.7

2 release files

1.6.6

2 release files

1.6.5

2 release files

1.6.4

2 release files

1.6.3

2 release files

1.6.2

2 release files

1.6.1

2 release files

1.6.0

2 release files

1.5.0

2 release files

1.4.0

2 release files

1.3.0

2 release files

1.2.3

2 release files

1.2.2

2 release files

1.2.1

2 release files

1.2.0

2 release files

1.1.0

2 release files

1.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page