Skip to content

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:

FilePurpose
VERSIONSource of truth. CI reads it to check the tag
package.json, apps/*/package.json, package-lock.jsonnpm version field
apps/server/src/version.tsReturned by /api/health
apps/web/src/lib/version.tsShown 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 8

The scope in parentheses is optional.

PrefixChangelog sectionBumps the version
featAddedminor (0.3.0 → 0.4.0)
fixFixedpatch (0.3.0 → 0.3.1)
perfPerformancepatch
refactorChangedpatch
docsDocumentationno
chore, build, ci, test, styleMaintenanceno
revertRevertedpatch

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
  1. Branch from main (feat/blurred-border).
  2. Commit using the format above (feat: ..., fix: ...).
  3. Open an MR and merge it into main.
  4. Repeat 1-3 as often as you like.
  5. 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:

bash
node scripts/release.mjs

The 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):

bash
node scripts/release.mjs 1.0.0

The script doesn't push, on purpose. Publishing is a deliberate act, because the tag triggers the release build. Once reviewed:

bash
git push origin main --follow-tags

To back out before pushing:

bash
git tag -d vX.Y.Z
git reset --hard HEAD~1

What GitLab does when the tag arrives ​

  1. check-version fails the pipeline if the tag doesn't match VERSION. It's the safety net against tagging without bumping the version.
  2. test builds both apps and runs the test suite.
  3. build-image builds and publishes the image as :vX.Y.Z and:latest in registry.gitlab.com/yayitazale/eink-hub.
  4. trivy-image scans that same image (informational, never blocks).
  5. release creates 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.