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:", "")})}