Glab Release Notes
Automatically add the changes from each merged merge request to human-readable release notes in a file.
This is a GitLab-oriented take on latest-changes: a small, dependency-free CLI that runs in your GitLab CI and keeps a release-notes.md up to date as merge requests get merged.
Unlike tools that scrape git log or conventional-commit messages, it reads the data from the merge request itself:
- a label decides which section the entry lands in,
- a one-line quote (
> …) in the MR description is the entry text, - a ticket key in the source-branch name (e.g.
FMT-1110) becomes an optional Jira link.
So the person writing the MR writes the one sentence that matters, and CI takes care of the formatting, links, author, and ordering.
How it works
A release notes file is plain Markdown with an ## Latest Changes block at the top and versioned blocks below it:
# Release notes
## Latest Changes
### Features
### Fixes
## 0.8.1
### Fixes {#fix_0.8.1}
* 🐛 Fixed a crash on empty input ([#35](https://gitlab.example/your/project/-/merge_requests/35)) by [@alice](https://gitlab.example/alice)
After a merge request is merged, the append command finds the ## Latest Changes block and adds a new list item to the section chosen by the MR's label:
Latest Changes
Features
The entry is built from:
- the label → section and emoji (see Using labels),
- the last non-empty
> …quote in the MR description → the entry text, - the ticket key parsed from the source branch name → a Jira link (only when a
--jira-baseis given), - the MR author and URL → trailing
by [@author](…).
The entry is prepended to the top of the section so the newest change reads first. Because the notes are just a file in your repo, you can later tweak, reorder, or extend any entry by hand.
Using labels
A merge request is routed to a section by its labels. The built-in default mapping is:
| Label | Section | Emoji |
|---|---|---|
feature |
Features |
✨ |
bug |
Fixes |
🐛 |
docs |
Documentation |
📝 |
ci |
CI |
👷 |
Labels that are never recorded into the notes:
release,refactor,test,chore,style
Rules:
- A merge request must carry either a section label or a skip label.
- A skip label wins if both a section label and a skip label are present.
- With several section labels, the first one in the configured order wins.
- A section label requires a
> …quote in the description; a skip label does not.
Features and Fixes always exist (even when empty). The other sections appear only once they have at least one entry.
Getting started
-
Add the notes file (if you don't have one), e.g.
docs/release-notes.md:# Release notes ## Latest Changes ### Features ### Fixes
-
Add a merge-request template that asks authors to set a label and write a one-line quote. A minimal
Default.md:/label ~feature ## Release notes One line in a quote. No emojis and no links — CI adds those after the merge. > -
Add the CI jobs (see CI wiring).
That's enough for validate and append to work. promote is what your release job calls.
Commands
glab-release-notes [--config PATH] [--file PATH] <command>
validate— a gate for the MR pipeline. Fails the pipeline when the MR has neither a section label nor a skip label, or has a section label but no> …quote.append— after a merge, appends the entry to the## Latest Changesblock and pushes achore(notes): add entry for MR !<iid>commit. It retries on a push race and is idempotent: an already-recorded MR is skipped.promote— moves the non-empty sections of## Latest Changesunder a new## <version>heading with anchors, then empties the block back to the always-kept sections.--versionis required;--no-pushrewrites the file only so your release process can own the commit.
CI wiring
The tool talks to the GitLab API using CI variables, so it is bound to whichever project's pipeline runs it:
CI_API_V4_URL,CI_PROJECT_ID,GITLAB_API_TOKEN— API endpoint and auth;CI_MERGE_REQUEST_IID— used byvalidate;CI_COMMIT_SHA— used byappendto find the MR of the current commit.
A typical .gitlab-ci.yml:
validate-release-notes:
stage: linter
script:
- uv run glab-release-notes validate
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
append-release-notes:
stage: linter
variables:
GIT_DEPTH: 0
before_script:
- !reference [.base_git_pre_settings, script] # git credentials for the push
script:
- uv run glab-release-notes append --jira-base https://jira.example/browse
rules:
- if: $CI_COMMIT_TAG || $CI_PIPELINE_SOURCE == "merge_request_event"
when: never
- if: '$CI_COMMIT_MESSAGE =~ /^chore\(notes\)/'
when: never
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
The chore(notes) rule prevents the job from re-running on its own commits, and GIT_DEPTH: 0 gives append a full history. The before_script must configure git with credentials able to push to the target branch (see Pushing to a protected branch).
Promoting on release
Call promote in your release job, right before (and, with --no-push, folded into) the release commit:
- uv run glab-release-notes promote --version "${version}" --no-push
Existing MRs — running manually
append resolves the MR from CI_COMMIT_SHA by default. To record a specific merge request by hand — for example one that was merged before you added this — run append on the target branch with the MR_IID variable set:
MR_IID=123 uv run glab-release-notes append --jira-base https://jira.example/browse
This is also the escape hatch for any MR the automatic run skipped.
Configuration
The wording of the release notes — the file, the Latest Changes header, the sections, the labels, and the skip labels — is configurable, in the same spirit as latest-changes. Put a release-notes.toml in the repo root and it is picked up automatically; pass --config PATH to point at a different file. Without a config file, the built-in defaults from Using labels apply.
Precedence: CLI flags (--file) > config file > built-in defaults.
# release-notes.toml
file = "docs/release-notes.md" # the notes file (CLI --file wins)
unreleased_header = "## Latest Changes"
section_prefix = "### "
skip_labels = ["release", "refactor", "test", "chore", "style"]
always_keep = ["Features", "Fixes"]
[[section]]
label = "feature"
header = "Features"
emoji = "✨"
anchor_prefix = "feat"
[[section]]
label = "bug"
header = "Fixes"
emoji = "🐛"
anchor_prefix = "fix"
[[section]]
label = "docs"
header = "Documentation"
emoji = "📝"
anchor_prefix = "docs"
[[section]]
label = "ci"
header = "CI"
emoji = "👷"
anchor_prefix = "ci"
Keys:
file— the notes file to update. Defaults todocs/release-notes.md.unreleased_header— the header of the accumulating block. Defaults to## Latest Changes.section_prefix— the prefix of the section headers inside the block. Defaults to###.skip_labels— labels that are never recorded. A skip label also wins over a section label.always_keep— section headers that are always rendered, even when empty. Defaults to["Features", "Fixes"]; use[]to render a section only once it has an entry (the latest-changes behavior).[[section]]— one table per section:label(required) — the GitLab label that routes an MR to this section.header(required) — the section header text.emoji(optional) — the emoji prefixed to entries in this section. Omit it for a plain entry.anchor_prefix(optional) — the anchor prefix used when promoting, e.g.{#feat_1.0.0}. Defaults to the label.
The order of [[section]] tables matters in both directions: it is the display order of the sections, and the first matching label wins when an MR carries several section labels.
The entry line format itself is fixed: * <emoji> <text> (links) by <author>. It is not a template.
Pushing to a protected branch
append and promote (without --no-push) git commit and git push the updated notes. If your target branch is protected against direct pushes, the CI job must be running with credentials that can push to it.
In GitLab that usually means one of:
- the job runs as a user/role allowed to push to the branch, or
- you pass a token to the git setup (the
!reference [.base_git_pre_settings, script]pattern above) that can push.
The commits are attributed to the CI identity, not to any individual author.
Development
uv sync
uv run ruff check .
uv run ruff format --check .
uv run mypy glab_release_notes tests
uv run pytest
Releases are managed with commitizen: uv run cz bump updates the version and CHANGELOG.md and tags the commit. A GitHub Release on that tag publishes the package to PyPI via trusted publishing (.github/workflows/publish.yaml).
License
This project is licensed under the terms of the MIT license.
Metadata
Release files for glab-release-notes 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| glab_release_notes-0.1.1.tar.gz | 16.4 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| glab_release_notes-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 37.8 kB
Release files / glab_release_notes-0.1.1.tar.gz
| Download URL | glab_release_notes-0.1.1.tar.gz |
|---|---|
| Size | 16.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1231d4072c38c2d4e2ccd8d976eb615401bef136fa81b36351f72d4897cfbd40
|
|
BLAKE2b-256 checksum How to use checksums |
b2aab3a6c75315aa52fb0ff8785f79c349b6de339b85267f39aa4d4a40d93317
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.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 Oct 23, 2026.
Transparency logRelease files / glab_release_notes-0.1.1-py3-none-any.whl
| Download URL | glab_release_notes-0.1.1-py3-none-any.whl |
|---|---|
| Size | 21.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0378134452e1a5bbe3aaee8290fcc0228ad7a7110887fc46041c8d6735b3f64a
|
|
BLAKE2b-256 checksum How to use checksums |
792399680c91274b79531129e03c13f706450ad695518e5729e6971fd57f710a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.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 Oct 23, 2026.
Transparency logSecurity for glab-release-notes 0.1.1
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.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for glab_release_notes-0.1.1.tar.gz
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 Oct 23, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.