relnote docs · CHANGELOG.md · Next version · Compare · gh relnote

Release notes in GitLab CI, Jenkins or any CI

relnote ships as one file: a Python zipapp attached to every GitHub Release. Download it in a job, run it, and hand the notes to whatever creates your release. No pip, no clone, no Node. It needs Python 3.11+ and git, which most CI images already have.

One file, no install

curl -fsSL -o relnote https://github.com/loki-inu/relnote/releases/latest/download/relnote
chmod +x relnote
./relnote --version          # relnote 0.3.0
./relnote --no-bots --output notes.md --quiet

Each release has three assets:

AssetWhat it is
relnoteExecutable zipapp with a #!/usr/bin/env python3 shebang. Only the relnote package and the licence are inside; it does not read anything from a source tree.
relnote.pyzThe same bytes. Use python relnote.pyz where the shebang is not honoured (Windows).
SHA256SUMSChecksums of both files, in sha256sum -c format.

In CI, pin a version instead of latest and check the download:

base=https://github.com/loki-inu/relnote/releases/download/v0.3.0
curl -fsSL -o relnote "$base/relnote"
curl -fsSL -o SHA256SUMS "$base/SHA256SUMS"
grep ' relnote$' SHA256SUMS | sha256sum -c -
chmod +x relnote

Full history, or the notes are wrong

relnote finds the previous tag with git describe and lists the commits after it. A shallow clone usually does not contain that tag, so relnote falls back to "everything it can see": the notes cover only the fetched commits, the footer reads Range: (start) → HEAD, and --next-version prints v0.1.0. Before running it, make sure the clone is complete and has tags:

if [ "$(git rev-parse --is-shallow-repository)" = true ]; then git fetch --unshallow; fi
git fetch --tags --force

GitLab CI: release on tag push

Two jobs. The first runs in any image with Python 3.11+ and git (python:3.12 has both; the -slim and -alpine variants do not include git) and writes notes.md as an artifact. The second runs in GitLab's CLI image and uses the release keyword, whose description may be a path to a file relative to $CI_PROJECT_DIR.

stages: [notes, release]

release_notes:
  stage: notes
  image: python:3.12
  variables:
    GIT_DEPTH: 0                      # full history: relnote needs the previous tag
  rules:
    - if: $CI_COMMIT_TAG
  script:
    - curl -fsSL -o relnote https://github.com/loki-inu/relnote/releases/download/v0.3.0/relnote
    - chmod +x relnote
    - ./relnote --version
    - ./relnote --no-bots --output notes.md --quiet
  artifacts:
    paths: [notes.md]

release:
  stage: release
  image: registry.gitlab.com/gitlab-org/cli:latest
  needs:
    - job: release_notes
      artifacts: true
  rules:
    - if: $CI_COMMIT_TAG
  script:
    - echo "Releasing $CI_COMMIT_TAG"   # the release keyword requires a script
  release:
    tag_name: $CI_COMMIT_TAG
    name: $CI_COMMIT_TAG
    description: notes.md             # path relative to $CI_PROJECT_DIR

Because HEAD is the tag being released, relnote starts the range at the tag before it, so the notes describe exactly this release. Prefer a command? The same image has glab, and the job token works once GLAB_ENABLE_CI_AUTOLOGIN is set:

release:
  stage: release
  image: registry.gitlab.com/gitlab-org/cli:latest
  needs: [release_notes]
  rules:
    - if: $CI_COMMIT_TAG
  variables:
    GLAB_ENABLE_CI_AUTOLOGIN: "true"
  script:
    - glab release create "$CI_COMMIT_TAG" --name "$CI_COMMIT_TAG" --notes-file notes.md

release-cli (registry.gitlab.com/gitlab-org/release-cli) is deprecated since GitLab 18.0; the release keyword now runs glab release create and falls back to release-cli only where glab is missing. On older GitLab, the first example works unchanged with image: registry.gitlab.com/gitlab-org/release-cli:latest.

GitLab CI: let relnote pick the next tag

For a manual (or scheduled) release from the default branch, --next-version decides the tag: a breaking change bumps major, a feat bumps minor, anything else bumps patch (details). GitLab does not expand variables exported in script inside the same job's release block, so the tag goes through a dotenv artifact to a second job. With ref set, release:tag_name creates the tag that does not exist yet.

stages: [prepare, release]

prepare_release:
  stage: prepare
  image: python:3.12
  variables:
    GIT_DEPTH: 0
  rules:
    - if: $CI_COMMIT_TAG
      when: never                                   # not in tag pipelines
    - if: $CI_PIPELINE_SOURCE == "schedule"         # e.g. a weekly release
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual                                  # or a button on main
      allow_failure: false                          # pipeline waits for it
  script:
    - curl -fsSL -o relnote https://github.com/loki-inu/relnote/releases/download/v0.3.0/relnote
    - chmod +x relnote
    - echo "TAG=$(./relnote --no-bots --next-version)" >> variables.env
    - ./relnote --no-bots --output notes.md --quiet
    - cat variables.env
  artifacts:
    paths: [notes.md]
    reports:
      dotenv: variables.env

release:
  stage: release
  image: registry.gitlab.com/gitlab-org/cli:latest
  needs:
    - job: prepare_release
      artifacts: true
  rules:
    - if: $CI_COMMIT_TAG
      when: never
    - if: $CI_PIPELINE_SOURCE == "schedule"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - echo "Releasing $TAG"
  release:
    tag_name: $TAG                    # e.g. v1.4.0, from --next-version
    ref: $CI_COMMIT_SHA               # create the tag at this commit
    name: $TAG
    description: notes.md

Pick one of the two setups. Creating the tag starts a tag pipeline, and a second release job there would fail because the release already exists. If there is nothing to release since the last tag, relnote exits 1 in prepare_release and the pipeline stops before anything is tagged.

Monorepos

--path keeps only commits touching a directory and --tag-prefix makes tags like api/v1.2.0 the ones that count. One job per package, matched on the tag prefix:

api_notes:
  stage: notes
  image: python:3.12
  variables:
    GIT_DEPTH: 0
  rules:
    - if: $CI_COMMIT_TAG =~ /^api\/v/
  script:
    - curl -fsSL -o relnote https://github.com/loki-inu/relnote/releases/download/v0.3.0/relnote
    - chmod +x relnote
    - ./relnote --path packages/api --tag-prefix api/v --no-bots --output notes.md --quiet
  artifacts:
    paths: [notes.md]

With --next-version, the prefix is kept: ./relnote --path packages/api --tag-prefix api/v --next-version prints api/v1.3.0.

Jenkins, Buildkite, CircleCI, or a plain shell

The same four lines work in any step that has sh, curl, git, and python3 (3.11+). Put them in a Jenkinsfile sh block, a Buildkite command step, or a CircleCI run:

set -eu
if [ "$(git rev-parse --is-shallow-repository)" = true ]; then git fetch --unshallow; fi
git fetch --tags --force

curl -fsSL -o relnote https://github.com/loki-inu/relnote/releases/download/v0.3.0/relnote
chmod +x relnote
./relnote --no-bots --output notes.md --quiet

# then hand notes.md to whatever publishes the release, for example:
#   gh release create "$TAG" --notes-file notes.md
#   glab release create "$TAG" --notes-file notes.md
#   relnote --changelog CHANGELOG.md --changelog-title "$TAG" -q

Pass --since v1.2.0 --until v1.3.0 for an explicit range, or --repo /path if the checkout is not the working directory. On GitHub Actions use the action instead; it is the same code.

Flags you will want in CI

FlagWhy
--output notes.md --quietWrite the file and keep the job log clean. Exit 1 if the parent directory is missing.
--no-botsDrop dependabot, renovate, and [bot] authors.
--next-versionPrint the next tag instead of notes. Exit 1 if there are no commits or the previous tag is not x.y.z.
--path DIR, --tag-prefix PREFIXScope to one package in a monorepo.
--changelog CHANGELOG.md --changelog-title TAGPrepend the same notes to a Keep a Changelog file; a repeated title is refused, so re-runs cannot duplicate a section.

Everything relnote reads is local git history. No token, no network call, no config file.