Merge pull request #130 from LINUXexpert-org/calver-decouple-from-stalwart
Stop borrowing Stalwart's version number
This commit is contained in:
@@ -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.
|
||||
|
||||
+5
-4
@@ -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 \
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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
|
||||
* 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.<pr>`, 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.
|
||||
|
||||
@@ -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",
|
||||
"version": "2.16.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -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__;
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AboutSettings() {
|
||||
</tbody>
|
||||
</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">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>
|
||||
<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>)}
|
||||
|
||||
Reference in New Issue
Block a user