From 2f55b1e3e135d24f97956bb28eed1055d9623974 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 30 Aug 2026 14:38:07 -0700 Subject: [PATCH] Stop borrowing Stalwart's version number The middle field was the Stalwart generation a build targeted -- 16 for 0.16 -- which leaves nowhere to go when Stalwart reaches 1.0. There is no honest value for it: 2.1 sorts below the 2.16 already deployed, so every image and About screen would have read as a downgrade. Tying our numbering to somebody else's was the mistake, and which Stalwart a build needs is said properly in the README badge and KNOWN-ISSUES, where it can be precise rather than one digit. The version is now the date of the commit it was built from, and the pull request moves after the + as build metadata. It is provenance rather than a rank: at the rate they merge here it climbs without bound and says nothing about how new a build is. Everything after the + is ignored when versions are compared, which reads correctly -- two builds from the same day differ in where they came from, not in age -- and nothing depends on that comparison anyway, since images are pruned by creation time and a rollback names a git ref. The date is the commit's own, so rebuilding an old commit gives the version it had the first time. package.json is no longer the source of anything and sits at 0.0.0, which is what an unversioned build reports and is meant to look wrong. The formatting is a pure function now, so the rules have tests. They had none while the version was the thing naming every image we ship. --- README.md | 38 ++++++--- deploy.example.sh | 9 +- package.json | 2 +- scripts/version.d.mts | 3 +- scripts/version.mjs | 101 ++++++++++++++--------- server/src/version.test.ts | 63 ++++++++++++++ web/package.json | 2 +- web/src/lib/version.ts | 8 +- web/src/views/settings/AboutSettings.tsx | 2 +- 9 files changed, 169 insertions(+), 59 deletions(-) create mode 100644 server/src/version.test.ts diff --git a/README.md b/README.md index d7b0d3f..13a504e 100644 --- a/README.md +++ b/README.md @@ -161,21 +161,39 @@ the sign-in refusal can be tested. ### Version numbers -`ihasmail v2.16.84` — `2` is ihasmail's own major, `16` the Stalwart generation -this build targets, `84` the pull request the commit came from. The first two -live in the root `package.json`; the third comes from git at build time, since -it does not exist until the PR has merged. A commit that did not arrive through -a PR carries the last number plus its short SHA — `2.16.84+g1fa6578`. +`ihasmail v2026.8.30+pr129` — the date of the commit this was built from, and +the pull request that commit arrived through. A commit that did not arrive +through one carries its short SHA instead: `2026.8.30+g1fa6578`. It all comes +from git at build time; nothing writes a version into the tree, and +`package.json` sits at `0.0.0` because it is no longer the source of anything. + +The date is the commit's own rather than today's, so rebuilding an old commit +gives the version it had the first time. ```bash node scripts/version.mjs # the version for the current checkout -docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 . +docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2026.8.30 . ``` `.dockerignore` excludes `.git` deliberately, so an image build cannot work this -out for itself — pass it in. Left out, the build falls back to the base version -from `package.json`, so a version with no PR number means whoever built the -image did not pass one. +out for itself — pass it in. Left out, the build reports `0.0.0`, which is meant +to look wrong: a version with no `+pr` or `+g` means whoever built the image did +not pass one. + +The version says nothing about Stalwart, deliberately. It used to: `2.16.x` had +`16` for the 0.16 generation it targeted, which left nowhere to go when Stalwart +reached 1.0 — `2.1` sorts *below* the `2.16` already deployed, so every image and +About screen would have read as a downgrade. Which Stalwart a build needs is +stated where it can be precise, in the badge at the top of this file and in +[KNOWN-ISSUES.md](KNOWN-ISSUES.md), rather than compressed into one digit. + +The pull request lives after the `+`, as build metadata, because it is +provenance rather than a rank: at the rate they merge here it climbs without +bound and says nothing about how new a build is. Everything after the `+` is +ignored when versions are compared, which is the right reading — two builds from +the same day differ in where they came from, not in age. Nothing here depends on +that comparison: images are pruned oldest-first by creation time, and a rollback +names a git ref. ### Deploying @@ -188,7 +206,7 @@ container, waits for healthy, then prunes all but the newest ```bash ./deploy.sh # origin/main, asks before shipping new commits ./deploy.sh --dry-run # run the guards and stop -./deploy.sh v2.16.84 --yes # a named ref, no prompt (there is no tty over ssh) +./deploy.sh v2026.8.30 --yes # a named ref, no prompt (there is no tty over ssh) ``` `--yes` does not override a hold; clearing one means deleting its line. diff --git a/deploy.example.sh b/deploy.example.sh index fe1dd6b..57bf54d 100755 --- a/deploy.example.sh +++ b/deploy.example.sh @@ -208,10 +208,11 @@ prune_old_images() { } VERSION="$(node scripts/version.mjs)" -# A Docker tag may not contain "+", which a version for a commit that did not -# come through a pull request does: 2.16.57+g1fa6578. The image is tagged with -# the "+" turned into "-"; what the build is *told* it is keeps the real form, -# so About and /api/health still report it correctly. +# A Docker tag may not contain "+", and every version has one now: +# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull +# request. The image is tagged with the "+" turned into "-"; what the build is +# *told* it is keeps the real form, so About and /api/health still report it +# correctly. TAG="${VERSION//+/-}" echo "==> building $(git log --oneline -1) as v$VERSION" docker build \ diff --git a/package.json b/package.json index b14e0c8..cc1ef60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ihasmail", - "version": "2.16.0", + "version": "0.0.0", "private": true, "description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server", "license": "AGPL-3.0-or-later", diff --git a/scripts/version.d.mts b/scripts/version.d.mts index 5bbaf18..4331277 100644 --- a/scripts/version.d.mts +++ b/scripts/version.d.mts @@ -1,4 +1,5 @@ /** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */ -export function baseVersion(): string; +export const UNVERSIONED: string; +export function formatVersion(commit: { date: string; subject?: string; sha: string }): string; export function versionFromGit(): string | null; export function resolveVersion(): string; diff --git a/scripts/version.mjs b/scripts/version.mjs index 5382aa3..842d184 100644 --- a/scripts/version.mjs +++ b/scripts/version.mjs @@ -1,38 +1,51 @@ /** - * Work out this build's version: `2.16.57`. + * Work out this build's version: `2026.8.30+pr129`. * - * 2 ihasmail's own major - * 16 the Stalwart major this build targets — 0.16, the oldest it supports - * 57 the pull request the checked-out commit came from + * 2026.8.30 the date of the commit this was built from + * +pr129 the pull request it arrived through * - * The first two are the `version` in the root package.json, so there is one - * place to bump them; the third is read from git, because it does not exist - * until the pull request has actually merged. Nothing writes a version back - * into the tree: a committed one would always be describing a merge that had - * not happened yet, and every branch would collide on the same line. + * The date leads because ihasmail's version used to be `2.16.`, where `16` + * was the Stalwart generation it targeted -- and Stalwart 1.0 leaves that with + * nowhere to go. `2.1` would have sorted *below* the `2.16` already deployed, + * so every image and About screen would have read as a downgrade. Tying our + * numbering to somebody else's was the mistake; which Stalwart a build needs is + * said properly in the README badge and KNOWN-ISSUES, where it can be precise + * ("0.16 or newer; tested against 0.16.19") rather than one digit. * - * A commit that did not arrive through a pull request has no number of its - * own, so it carries the last one plus its own short SHA — `2.16.57+g1fa6578` - * — which is honest about being past that PR rather than silently claiming to - * be it. + * The pull request moved into build metadata, after the `+`, because it is + * provenance rather than a position in a sequence: at a hundred merges a week + * it climbs without bound and says nothing about how new a build is. SemVer + * ignores everything after the `+` when comparing versions, which is the right + * reading -- two builds from the same day differ in where they came from, not + * in rank. Nothing here relies on that comparison anyway: images are pruned + * oldest-first by creation time and rollbacks name a git ref. + * + * A commit that did not arrive through a pull request carries its short SHA + * instead -- `2026.8.30+g1fa6578` -- which is honest about being some commit on + * that day rather than claiming a pull request it was only built after. + * + * The date is the commit's own, not today's, so rebuilding an old commit gives + * the same answer it gave the first time. It comes from the commit object, + * timezone included, so two machines agree. + * + * Nothing writes a version back into the tree: a committed one would always be + * describing a merge that had not happened yet, and every branch would collide + * on the same line. `package.json` no longer carries it either -- npm wants the + * field, so it stays at `0.0.0`, which is what an unversioned build reports and + * is meant to look wrong. * * `.dockerignore` excludes `.git`, so an image build cannot run any of this. * It takes the answer through `--build-arg IHASMAIL_VERSION=...` instead, and - * whoever builds is responsible for computing it — see ihasmail-deploy.sh. + * whoever builds is responsible for computing it -- see ihasmail-deploy.sh. */ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -/** "2.16" — ihasmail major and the Stalwart major this build is built for. */ -export function baseVersion() { - const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); - const [major, minor] = String(pkg.version).split("."); - return `${major}.${minor}`; -} +/** What a build with nothing to go on reports, and it should look wrong. */ +export const UNVERSIONED = "0.0.0"; function git(...args) { return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); @@ -40,41 +53,53 @@ function git(...args) { const PR_SUBJECT = /^Merge pull request #(\d+)\b/; +/** + * The version for a commit, from the three things about it that decide one. + * Pure, so the rules can be exercised without a repository staged to produce + * them: `{ date: "2026-08-30", subject: "Merge pull request #129 from ...", + * sha: "1fa6578" }` gives `2026.8.30+pr129`. + * + * Leading zeros are stripped because a version field may not carry them, so + * September is `9` rather than `09`. + */ +export function formatVersion({ date, subject = "", sha }) { + const [y, m, d] = date.split("-"); + const calendar = `${Number(y)}.${Number(m)}.${Number(d)}`; + const pr = PR_SUBJECT.exec(subject)?.[1]; + return pr ? `${calendar}+pr${pr}` : `${calendar}+g${sha}`; +} + /** * The version for the commit checked out here, or null when there is no git to - * ask — an unpacked tarball, or the Docker build context. + * ask -- an unpacked tarball, or the Docker build context. */ export function versionFromGit() { let head; + let date; try { head = git("rev-parse", "--short", "HEAD"); + // %cs is the committer date in the commit's own timezone, which is stored + // in the commit -- so this does not depend on the clock or zone of whoever + // is building. + date = git("show", "-s", "--format=%cs", "HEAD"); } catch { return null; } - const base = baseVersion(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; + let subject = ""; try { - // Walk back over first parents: a merge commit's subject names its PR, and - // anything after the newest one is work that has not been through one. - const log = git("log", "--first-parent", "--format=%H%x00%s", "-n", "200"); - const commits = log ? log.split("\n").map((l) => l.split("\0")) : []; - for (const [sha, subject = ""] of commits) { - const pr = PR_SUBJECT.exec(subject)?.[1]; - if (!pr) continue; - // The PR's own merge commit is the version; anything above it is past it. - const exact = sha.startsWith(git("rev-parse", "HEAD")); - return exact ? `${base}.${pr}` : `${base}.${pr}+g${head}`; - } + subject = git("show", "-s", "--format=%s", "HEAD"); } catch { - /* a shallow clone, or no history to read */ + /* no subject to read; fall through to the SHA */ } - return `${base}.0+g${head}`; + return formatVersion({ date, subject, sha: head }); } -/** Whatever the environment was told, else git, else just the base. */ +/** Whatever the environment was told, else git, else an answer that looks wrong. */ export function resolveVersion() { const fromEnv = process.env.IHASMAIL_VERSION?.trim(); if (fromEnv) return fromEnv; - return versionFromGit() ?? `${baseVersion()}.0`; + return versionFromGit() ?? UNVERSIONED; } // `node scripts/version.mjs` prints it, for shell scripts and CI. diff --git a/server/src/version.test.ts b/server/src/version.test.ts new file mode 100644 index 0000000..d029bc5 --- /dev/null +++ b/server/src/version.test.ts @@ -0,0 +1,63 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { formatVersion, resolveVersion, UNVERSIONED, versionFromGit } from "../../scripts/version.mjs"; + +/** + * The version is this build's public identity: it names the image, and it is + * what About and /api/health report. It had no tests while it was + * `2.16.`; it has them now that the rules moved. + */ + +test("a pull request merge is named by its number", () => { + assert.equal( + formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from LINUXexpert-org/link-project-site-v2", sha: "1fa6578" }), + "2026.8.30+pr129", + ); +}); + +test("a commit that did not come through a pull request carries its SHA", () => { + // Claiming the last PR would say it *is* that PR rather than something after it. + assert.equal(formatVersion({ date: "2026-08-30", subject: "Fix a thing directly on main", sha: "1fa6578" }), "2026.8.30+g1fa6578"); +}); + +test("leading zeros are stripped, since a version field may not carry them", () => { + assert.equal(formatVersion({ date: "2026-09-05", subject: "Merge pull request #7 from x/y", sha: "abc1234" }), "2026.9.5+pr7"); + assert.equal(formatVersion({ date: "2027-01-01", subject: "", sha: "abc1234" }), "2027.1.1+gabc1234"); +}); + +test("it sorts forward from the versions it replaces", () => { + // 2.16.129 was deployed. 2.1.x would have read as a downgrade, which is the + // whole reason the Stalwart generation left the version. + const [older, newer] = ["2.16.129", "2026.8.30"].map((v) => v.split(".").map(Number)); + assert.ok(newer![0]! > older![0]!, "the leading field has to increase"); +}); + +test("two builds from the same day differ, even though they rank the same", () => { + const a = formatVersion({ date: "2026-08-30", subject: "Merge pull request #128 from x/y", sha: "aaaaaaa" }); + const b = formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "bbbbbbb" }); + assert.notEqual(a, b); + assert.equal(a.split("+")[0], b.split("+")[0]); +}); + +test("the same commit always resolves to the same version", () => { + // Built from the commit's own date, not today's, so an old commit rebuilt + // now reports what it reported then. + const commit = { date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "1fa6578" }; + assert.equal(formatVersion(commit), formatVersion(commit)); +}); + +test("an explicit IHASMAIL_VERSION wins, because the Docker build has no git", () => { + const before = process.env.IHASMAIL_VERSION; + process.env.IHASMAIL_VERSION = "2026.8.30+pr129"; + try { + assert.equal(resolveVersion(), "2026.8.30+pr129"); + } finally { + if (before === undefined) delete process.env.IHASMAIL_VERSION; + else process.env.IHASMAIL_VERSION = before; + } +}); + +test("a checkout with git resolves to a real version, and an unversioned build looks wrong", () => { + assert.match(versionFromGit() ?? "", /^\d{4}\.\d{1,2}\.\d{1,2}\+(pr\d+|g[0-9a-f]+)$/); + assert.equal(UNVERSIONED, "0.0.0"); +}); diff --git a/web/package.json b/web/package.json index 1152a58..6245554 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@ihasmail/web", - "version": "2.16.0", + "version": "0.0.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/web/src/lib/version.ts b/web/src/lib/version.ts index d068309..7bfb016 100644 --- a/web/src/lib/version.ts +++ b/web/src/lib/version.ts @@ -1,6 +1,8 @@ /** - * What this build calls itself: `2.16.57`, or `2.16.57+g1fa6578` for a commit - * that did not come through a pull request. Baked in by Vite; see - * `scripts/version.mjs` for where the parts come from. + * What this build calls itself: `2026.8.30+pr129` -- the date of the commit it + * was built from, and the pull request that commit arrived through. A commit + * that did not come through one carries its short SHA instead, + * `2026.8.30+g1fa6578`. Baked in by Vite; see `scripts/version.mjs` for why the + * parts are what they are. */ export const APP_VERSION = __IHASMAIL_VERSION__; diff --git a/web/src/views/settings/AboutSettings.tsx b/web/src/views/settings/AboutSettings.tsx index 8bd7122..183db75 100644 --- a/web/src/views/settings/AboutSettings.tsx +++ b/web/src/views/settings/AboutSettings.tsx @@ -30,7 +30,7 @@ export function AboutSettings() {

Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.

-

The middle number of ihasmail's own version is the Stalwart generation it is built for: v2.16.x targets Stalwart 0.16. The last is the pull request it was built from, and a trailing +g and short commit means the build is past that pull request rather than exactly it.

+

ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: v2026.8.30+pr129 was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — +g1fa6578. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.

Server capabilities

{caps.map((c) => {c.replace("urn:ietf:params:jmap:", "")})}