Versioning & releases
Server and web always ship together in the same image, so they share a single version number. The source of truth is the VERSION file at the root; scripts/release.mjs propagates it to every other place that needs it:
| File | Purpose |
|---|---|
VERSION | Source of truth. CI reads it to check the tag |
package.json, apps/*/package.json, package-lock.json | npm version field |
apps/server/src/version.ts | Returned by /api/health |
apps/web/src/lib/version.ts | Shown in the sidebar (and in Settings on mobile) |
None of them is edited by hand.
What the number means
Semantic Versioning: MAJOR.MINOR.PATCH.
While we are on 0.x, a breaking change bumps the minor, not the major — SemVer 0.x promises no stability. The jump to 1.0.0 is reserved for a stable public launch and is done by hand.
Commit message format
The changelog is generated from the commits, so they need a prefix a machine can read: Conventional Commits. The description is short, in the imperative mood:
feat(immich): add "on this day" collections
fix(pull): queue photos sent while the frame is asleep
docs: document the release process
chore(deps): bump vite to 8The scope in parentheses is optional.
| Prefix | Changelog section | Bumps the version |
|---|---|---|
feat | Added | minor (0.3.0 → 0.4.0) |
fix | Fixed | patch (0.3.0 → 0.3.1) |
perf | Performance | patch |
refactor | Changed | patch |
docs | Documentation | no |
chore, build, ci, test, style | Maintenance | no |
revert | Reverted | patch |
For a breaking change, add ! before the colon (feat(api)!: ...) or a BREAKING CHANGE: line in the body.
A commit that doesn't follow the format doesn't appear in the changelog. That is what automatically drops the merge commits GitLab creates when accepting an MR.
The workflow
A tag is not a branch: it's a label pinned to one specific commit. Nobody works on it or merges it. It goes at the end, not the beginning.
Day-to-day work doesn't change: branches from main and merge requests. There is only one extra step, and only when you want to publish.
main --*--------*------------*----------*-----
| ^ ^ ^
| merge merge release
| (tag v0.4.0)
+--o--o--+
your branch- Branch from
main(feat/blurred-border). - Commit using the format above (
feat: ...,fix: ...). - Open an MR and merge it into
main. - Repeat 1-3 as often as you like.
- When what has accumulated deserves a release, from
main:node scripts/release.mjs.
A tag isn't "an improvement", it's "a release", and it can group several merged branches: you can merge four branches over two weeks and publish a single v0.4.0 that includes them all. The changelog lists them all, grouped by type.
The maintainer decides when there is a release. Nothing is published automatically, and even after running the script, the commit and tag stay local until you push -- until then GitLab knows nothing and it can be undone with no consequences.
Every merge to main does build an image, but as :main and :<short sha>, so it can be tried out. It isn't a release and doesn't move :latest.
Cutting a release
From main, with everything merged and no pending changes:
node scripts/release.mjsThe script checks the repository is clean, that you are on main and that it matches origin/main; computes the next version from the commits since the last tag; updates the version files; adds the new section to CHANGELOG.md; shows you the diff and waits for confirmation; and creates the chore(release): vX.Y.Z commit and the tag.
To force a specific number (for example the jump to 1.0.0):
node scripts/release.mjs 1.0.0The script doesn't push, on purpose. Publishing is a deliberate act, because the tag triggers the release build. Once reviewed:
git push origin main --follow-tagsTo back out before pushing:
git tag -d vX.Y.Z
git reset --hard HEAD~1What GitLab does when the tag arrives
check-versionfails the pipeline if the tag doesn't matchVERSION. It's the safety net against tagging without bumping the version.testbuilds both apps and runs the test suite.build-imagebuilds and publishes the image as:vX.Y.Zand:latestinregistry.gitlab.com/yayitazale/eink-hub.trivy-imagescans that same image (informational, never blocks).releasecreates the entry in GitLab's Releases tab.
On pushes to main without a tag the image is still built, but as :main and :<short sha>. :latest only moves when a tag is published, so whoever deploys by pulling :latest gets deliberately released versions and not every merge.
Both tags and pushes to main also rebuild this website (the pages job): the latest release at the root, older releases under /vX.Y.Z/ and main under /main/.
Merge requests run test and npm-audit (plus docs-build when they touch the docs or examples); they never publish an image.
The changelog
CHANGELOG.md follows Keep a Changelog and is generated by git-cliff using cliff.toml. The script uses --prepend: it adds the new section on top without regenerating the rest, so what has already been published — including the hand-written 0.1.0 entry — never changes.
Nothing needs installing: the script calls npx git-cliff@2.
