Projekt

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/fileshare/-/compare/vX.Y.Z...main
[X.Y.Z]:      https://gitlab.com/mankeldor/fileshare/-/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

Every place that names a version in prose. They go stale silently — nothing breaks, the sentence just starts lying. Listed by name so nobody has to go looking:

File What to change
docs/uebersicht.md the Stand: **X.Y.Z** line near the top
docs/installation/paket.md VERSION=vX.Y.Z in the manual download block
docs/aktualisieren.md the fileshare-vX.Y.Z.tar.gz examples

Deliberately only these. The READMEs and INSTALL.md say that the project is released and point at the documentation for the number; the setup script resolves the latest release on its own; and the site takes the version it puts into the structured data straight from CHANGELOG.md. Every place that would have to be touched per release is a place that will eventually be forgotten — so keep this table short, and add to it only when a new file really does spell out a number.

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/fileshare-vX.Y.Z.tar.gz.sha256
( cd /tmp/dr && sh -c 'NAME=fileshare-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.

Once, for the version that introduces fileshare-setup.sh: run it against the real registry from a machine with network access — sh fileshare-setup.sh /tmp/probe --dry-run. Neither CI nor a sandbox can do this for you; both are blocked from gitlab.com.

2d. Grep for example addresses

Every address that ships has to be one nobody can register. example.com, example.net, example.org (RFC 2606) and the TLDs .example, .invalid, .test, .localhost are reserved; everything else belongs to someone, and what that someone gets depends on where the address sits:

The address is in What its owner receives
APP_URL every link this installation writes into a mail — password reset, invitation, address confirmation. The recipient clicks it, because the mail is genuine
MAIL_FROM_ADDRESS the bounces and the replies; SPF and DKIM fail on top of it
a seeder or factory mail to a real, foreign address as soon as it runs somewhere reachable

It is enough that one installation ever ran with the example value.

grep -rInE '(https?://|@)[A-Za-z0-9.-]+\.[a-z]{2,}' \
     .env.example app config database resources/views routes INSTALL.md \
     --exclude=database.sqlite \
| grep -vE ':[0-9]+:.*(example\.(com|net|org)|\.(example|invalid|test|localhost)([^a-z0-9]|$))' \
| grep -vE ':[0-9]+:.*(polyformproject\.org|gitlab\.com|peuser\.dev|schema\.org|w3\.org|mozilla\.org|sensiolabs\.org|vivalacrowe\.com|amazonaws\.com)'

A handful of lines comes back, and every one is a decision. Extend the second filter with domains that really are ours; whatever is left is a find. Replace it with a reserved name and write a sentence next to the value saying why it looks like that — otherwise somebody later "improves" it back into something plausible.

The pattern above needs a scheme or an @ — it finds https://… and …@…, which is where the damaging values sit. It does not find a bare host name, and one of those shipped in 3.3.1 all the same: placeholder: 'mail.beispiel.de' in the SMTP settings. A bare host cannot be matched loosely without drowning the result in prose, so the second grep goes the other way and asks where a value sits rather than what it looks like — the configuration template, the seeders and factories, and the placeholder attributes:

{ grep -rInE '[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+\.[a-z]{2,}' .env.example database/seeders database/factories
  grep -rInE "placeholder: *'[^']*[A-Za-z0-9-]+\.[a-z]{2,}" resources/views
} \
| grep -vE ':[0-9]+:.*(example\.(com|net|org)|\.(example|invalid|test|localhost)([^a-z0-9]|$))' \
| grep -vE ':[0-9]+:.*(gitlab\.com|peuser\.dev)'

A comment in TargetUrl.php that discusses kunde.de is prose and harmless. A placeholder: or a value in .env.example is something somebody copies. Run both greps.

Two details that are load-bearing.

The paths are named instead of scanning . because the answer has to be short enough to read. Scanning everything buries a handful of decisions under hundreds of lines of log file, test fixture and licence header — and a check nobody reads is not a check. What is listed is what ships: the configuration template, the code, the seeders and factories, the templates, and the one document that goes into the archive.

The :[0-9]+: in front of both filters is not decoration. grep -rIn puts file:line: in front of every hit, so a plain grep -vE 'example\.(com|…)|\.(example|…)' matches the file name too — and the one file whose name contains .example is .env.example, which is exactly the file whose defaults ship in the archive and seed every new .env. That filter reported a clean run for 3.3.0, which carried beispiel.de in APP_URL and MAIL_FROM_ADDRESS; 3.3.1 is the correction. Anchoring behind the line number confines the filter to the content.

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 "Fileshare 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 "Fileshare 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.