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
maindeploys to production; pushing a tag builds and publishes a release. The two never happen in the same pipeline —deploy_productionchecks$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:
- Rename
## [Unreleased]to## [X.Y.Z] — YYYY-MM-DD. - Add a fresh, empty
## [Unreleased]above it. - 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 |
release → package_release |
checks the CHANGELOG section, builds the archive, verifies its contents, writes VERSION, uploads archive and SHA256 to the package registry |
release → release |
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_releasewrites the tag into it, so the archive knows what it is.deploy_productionwrites 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.