Cutting a release

Maintainers only. Everything here happens on main; the pipeline does the rest.

The only thing that triggers a release is a tag. Pushing to main deploys to production; pushing a tag builds and publishes a release. The two never happen in the same pipeline — deploy_production checks $CI_COMMIT_BRANCH, which is empty in a tag pipeline.


Before you start

Working tree clean, on main, up to date
Last pipeline on main green
Packages registry enabled (Settings → General → Visibility → Packages)

1. Pick the version

Semantic versioning, read from the operator's point of view — the question is always what does this force them to do?

When Example
Major The update needs a manual step, or something they relied on is gone a removed .env key, a migration that rewrites data, a changed default that acts on existing records
Minor New behaviour, update is update.sh and nothing else a new settings area, a new command
Patch Fixes and wording, no new behaviour a broken release note, a wrong label

2. Close the CHANGELOG section

This is the step that is easy to forget and the one the pipeline refuses to let you skip (see below). In CHANGELOG.md:

  1. Rename ## [Unreleased] to ## [X.Y.Z] — YYYY-MM-DD.
  2. Add a fresh, empty ## [Unreleased] above it.
  3. Update the link definitions at the bottom:
[Unreleased]: https://gitlab.com/mankeldor/shortlink/-/compare/vX.Y.Z...main
[X.Y.Z]:      https://gitlab.com/mankeldor/shortlink/-/compare/vPREVIOUS...vX.Y.Z

The release notes are generated from exactly this section, so it is what the world reads. Write it for someone who runs the software, not for someone who wrote it.

2b. Update the version markers in the docs

Two files name a version in prose, and both go stale silently — nothing breaks, they just start lying:

File What to change
docs/ADMIN-HANDBUCH.md the Stand: **X.Y.Z** line near the top
INSTALL.md VERSION=vX.Y.Z in the manual download block

Deliberately only these two. The READMEs say that the project is released and point at this file for the number, so they need no edit — and the setup script resolves the latest release on its own, so it names no version either. Every place that would have to be touched per release is a place that will eventually be forgotten.

2c. Dry-run before tagging

Two minutes, and the last moment at which a mistake is free.

Generate the release note — it is what the world reads:

awk '/^      \{$/{f=1} f{print} /^      \} > dist\/release-notes.md$/{exit}' \
    .gitlab-ci.yml | sed 's/^      //' > /tmp/blk.sh
mkdir -p /tmp/dr/dist && cp CHANGELOG.md /tmp/dr/
echo "0  x" > /tmp/dr/dist/shortlink-vX.Y.Z.tar.gz.sha256
( cd /tmp/dr && sh -c 'NAME=shortlink-vX.Y.Z; CI_API_V4_URL=https://gitlab.com/api/v4;
  CI_PROJECT_ID=123; CI_COMMIT_TAG=vX.Y.Z; . /tmp/blk.sh' )

# The block above ENDS at `} > dist/release-notes.md`. In the job the CHANGELOG section
# is appended after it, with `>>` — so append it here as well, or the dry run shows a
# note without the one part you came to read.
( cd /tmp/dr && { echo "## Changes"; echo; awk -v v=X.Y.Z '
    $0 ~ "^## \\[" v "\\]" { inside = 1; next }
    inside && /^## / { exit }
    inside { print }
  ' CHANGELOG.md; } >> dist/release-notes.md )

The cp CHANGELOG.md is not decoration: the generated block and the extraction both read that file, and they run in /tmp/dr.

Then read it. Does the "Updating" section mention the thing this version introduces? Are the backticks still there — or did a shell swallow them? Is the ## Changes section actually there, and does it start at the right version?

Pull the package self-check forward — the same two loops the job runs, but now:

rsync -a --dry-run --exclude-from=.rsync-exclude --exclude='/dist/' \
      --out-format='%n' ./ /tmp/pkg/ > /tmp/liste.txt
# then check the "must" and "must not" lists from .gitlab-ci.yml against /tmp/liste.txt

And check the extraction for the PREVIOUS version too. An edit to CHANGELOG.md can shift it; that the new section extracts cleanly does not mean the old one still does.

3. Commit, push, wait

git add -A
git commit -m "Release X.Y.Z"
git push origin main

Wait for the pipeline on main to go green. It runs the tests, builds the frontend and deploys to production — so this push is already the moment the change goes live. The tag afterwards only packages what is by then already running.

4. Tag and push

git tag -a vX.Y.Z -m "Shortlink X.Y.Z"
git push origin vX.Y.Z

The v prefix is expected: the archive is named after the tag, and the version check strips it when comparing.

5. What the pipeline does now

Stage
test test suite
build frontend, public/build/ as an artifact
releasepackage_release checks the CHANGELOG section, builds the archive, verifies its contents, writes VERSION, uploads archive and SHA256 to the package registry
releaserelease creates the GitLab release with the generated notes and links both assets

deploy_production does not run.

6. Check afterwards

  • The release page lists the changes — not just "See CHANGELOG.md in the archive". If it does say that, step 2 was skipped or the version does not match the heading.
  • Both asset links resolve.
  • Optional but cheap:
curl -sL <archive-url> -o /tmp/pkg.tar.gz
curl -sL <sha256-url> | sha256sum -c -

The VERSION file

VERSION sits in .gitignore and must not be committed. It is a build artefact:

  • package_release writes the tag into it, so the archive knows what it is.
  • deploy_production writes the tag or, on a branch deployment, main@<short-sha>.

update.sh and Settings → System read it to report "2.0.1 → 2.0.2". Committed, it would go stale and tell every git clone a version that is not true — and a wrong answer here is worse than none, because the "unknown" case is handled everywhere.

If you ever created one locally to look at the interface, leave it. Git ignores it, the package overwrites it.


When something goes wrong

The pipeline stops with "CHANGELOG.md has no section". Nothing is published yet — that check runs before anything is built. Take the tag back, fix, tag again:

git tag -d vX.Y.Z
git push --delete origin vX.Y.Z
# fix CHANGELOG.md, commit, push
git tag -a vX.Y.Z -m "Shortlink X.Y.Z" && git push origin vX.Y.Z

The tag already exists. GitLab refuses to create a second release for it. Either delete release and tag (Deployments → Releases, then the commands above), or go one patch version up. Going up is cleaner — a tag that already left your machine may already be somewhere else.

The package upload fails with 403. The package registry is switched off for the project: Settings → General → Visibility → Packages.

The release notes are missing words that were written in backticks. That was fixed in 2.0.1 and should not come back: GitLab passes release:description through a shell, where a backtick is command substitution. The notes are therefore generated as a file and referenced by path. If someone ever puts literal text back into description:, this returns.


Publishing nothing but a fix (patch release)

Same steps, in fewer words: [Unreleased][X.Y.Z+1], commit, push, wait for green, tag, push. The archive and the update path are identical — there is no separate "small update" package, and that is deliberate: two formats would mean two ways for an update to go wrong.

Quelle dieser Seite: RELEASE.md im Repository. Sie wird beim Bauen daraus erzeugt — Änderungen gehören in die Markdown-Datei, nicht in diese Seite.