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
|
### 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