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.
This commit is contained in:
@@ -161,21 +161,39 @@ the sign-in refusal can be tested.
|
|||||||
|
|
||||||
### Version numbers
|
### Version numbers
|
||||||
|
|
||||||
`ihasmail v2.16.84` — `2` is ihasmail's own major, `16` the Stalwart generation
|
`ihasmail v2026.8.30+pr129` — the date of the commit this was built from, and
|
||||||
this build targets, `84` the pull request the commit came from. The first two
|
the pull request that commit arrived through. A commit that did not arrive
|
||||||
live in the root `package.json`; the third comes from git at build time, since
|
through one carries its short SHA instead: `2026.8.30+g1fa6578`. It all comes
|
||||||
it does not exist until the PR has merged. A commit that did not arrive through
|
from git at build time; nothing writes a version into the tree, and
|
||||||
a PR carries the last number plus its short SHA — `2.16.84+g1fa6578`.
|
`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
|
```bash
|
||||||
node scripts/version.mjs # the version for the current checkout
|
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
|
`.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
|
out for itself — pass it in. Left out, the build reports `0.0.0`, which is meant
|
||||||
from `package.json`, so a version with no PR number means whoever built the
|
to look wrong: a version with no `+pr` or `+g` means whoever built the image did
|
||||||
image did not pass one.
|
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
|
### Deploying
|
||||||
|
|
||||||
@@ -188,7 +206,7 @@ container, waits for healthy, then prunes all but the newest
|
|||||||
```bash
|
```bash
|
||||||
./deploy.sh # origin/main, asks before shipping new commits
|
./deploy.sh # origin/main, asks before shipping new commits
|
||||||
./deploy.sh --dry-run # run the guards and stop
|
./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.
|
`--yes` does not override a hold; clearing one means deleting its line.
|
||||||
|
|||||||
+5
-4
@@ -208,10 +208,11 @@ prune_old_images() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VERSION="$(node scripts/version.mjs)"
|
VERSION="$(node scripts/version.mjs)"
|
||||||
# A Docker tag may not contain "+", which a version for a commit that did not
|
# A Docker tag may not contain "+", and every version has one now:
|
||||||
# come through a pull request does: 2.16.57+g1fa6578. The image is tagged with
|
# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull
|
||||||
# the "+" turned into "-"; what the build is *told* it is keeps the real form,
|
# request. The image is tagged with the "+" turned into "-"; what the build is
|
||||||
# so About and /api/health still report it correctly.
|
# *told* it is keeps the real form, so About and /api/health still report it
|
||||||
|
# correctly.
|
||||||
TAG="${VERSION//+/-}"
|
TAG="${VERSION//+/-}"
|
||||||
echo "==> building $(git log --oneline -1) as v$VERSION"
|
echo "==> building $(git log --oneline -1) as v$VERSION"
|
||||||
docker build \
|
docker build \
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ihasmail",
|
"name": "ihasmail",
|
||||||
"version": "2.16.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
|
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
|
/** 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 versionFromGit(): string | null;
|
||||||
export function resolveVersion(): string;
|
export function resolveVersion(): string;
|
||||||
|
|||||||
+63
-38
@@ -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
|
* 2026.8.30 the date of the commit this was built from
|
||||||
* 16 the Stalwart major this build targets — 0.16, the oldest it supports
|
* +pr129 the pull request it arrived through
|
||||||
* 57 the pull request the checked-out commit came from
|
|
||||||
*
|
*
|
||||||
* The first two are the `version` in the root package.json, so there is one
|
* The date leads because ihasmail's version used to be `2.16.<pr>`, where `16`
|
||||||
* place to bump them; the third is read from git, because it does not exist
|
* was the Stalwart generation it targeted -- and Stalwart 1.0 leaves that with
|
||||||
* until the pull request has actually merged. Nothing writes a version back
|
* nowhere to go. `2.1` would have sorted *below* the `2.16` already deployed,
|
||||||
* into the tree: a committed one would always be describing a merge that had
|
* so every image and About screen would have read as a downgrade. Tying our
|
||||||
* not happened yet, and every branch would collide on the same line.
|
* 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
|
* The pull request moved into build metadata, after the `+`, because it is
|
||||||
* own, so it carries the last one plus its own short SHA — `2.16.57+g1fa6578`
|
* provenance rather than a position in a sequence: at a hundred merges a week
|
||||||
* — which is honest about being past that PR rather than silently claiming to
|
* it climbs without bound and says nothing about how new a build is. SemVer
|
||||||
* be it.
|
* 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.
|
* `.dockerignore` excludes `.git`, so an image build cannot run any of this.
|
||||||
* It takes the answer through `--build-arg IHASMAIL_VERSION=...` instead, and
|
* 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 { execFileSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
/** "2.16" — ihasmail major and the Stalwart major this build is built for. */
|
/** What a build with nothing to go on reports, and it should look wrong. */
|
||||||
export function baseVersion() {
|
export const UNVERSIONED = "0.0.0";
|
||||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
||||||
const [major, minor] = String(pkg.version).split(".");
|
|
||||||
return `${major}.${minor}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function git(...args) {
|
function git(...args) {
|
||||||
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
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/;
|
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
|
* 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() {
|
export function versionFromGit() {
|
||||||
let head;
|
let head;
|
||||||
|
let date;
|
||||||
try {
|
try {
|
||||||
head = git("rev-parse", "--short", "HEAD");
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const base = baseVersion();
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||||
|
let subject = "";
|
||||||
try {
|
try {
|
||||||
// Walk back over first parents: a merge commit's subject names its PR, and
|
subject = git("show", "-s", "--format=%s", "HEAD");
|
||||||
// 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}`;
|
|
||||||
}
|
|
||||||
} catch {
|
} 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() {
|
export function resolveVersion() {
|
||||||
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
|
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
|
||||||
if (fromEnv) return fromEnv;
|
if (fromEnv) return fromEnv;
|
||||||
return versionFromGit() ?? `${baseVersion()}.0`;
|
return versionFromGit() ?? UNVERSIONED;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `node scripts/version.mjs` prints it, for shell scripts and CI.
|
// `node scripts/version.mjs` prints it, for shell scripts and CI.
|
||||||
|
|||||||
@@ -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.<pr>`; 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");
|
||||||
|
});
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ihasmail/web",
|
"name": "@ihasmail/web",
|
||||||
"version": "2.16.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* What this build calls itself: `2.16.57`, or `2.16.57+g1fa6578` for a commit
|
* What this build calls itself: `2026.8.30+pr129` -- the date of the commit it
|
||||||
* that did not come through a pull request. Baked in by Vite; see
|
* was built from, and the pull request that commit arrived through. A commit
|
||||||
* `scripts/version.mjs` for where the parts come from.
|
* 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__;
|
export const APP_VERSION = __IHASMAIL_VERSION__;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function AboutSettings() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p className="hint" style={{ marginTop: 6 }}>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.</p>
|
<p className="hint" style={{ marginTop: 6 }}>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.</p>
|
||||||
<p className="hint">The middle number of ihasmail's own version is the Stalwart generation it is built for: <strong>v2.16.x</strong> targets Stalwart 0.16. The last is the pull request it was built from, and a trailing <code>+g</code> and short commit means the build is past that pull request rather than exactly it.</p>
|
<p className="hint">ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: <strong>v2026.8.30+pr129</strong> 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 — <code>+g1fa6578</code>. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.</p>
|
||||||
<h2>Server capabilities</h2>
|
<h2>Server capabilities</h2>
|
||||||
<div className="row wrap gap-4">
|
<div className="row wrap gap-4">
|
||||||
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
||||||
|
|||||||
Reference in New Issue
Block a user