Give builds a version number

ihasmail called itself "2.0" on the About page and "2.0.0" from
/api/health, both hardcoded, in four places that had drifted from each
other and from anything meaningful. A build now says what it is:

  ihasmail v2.16.57
             |  |  |
             |  |  the pull request the commit came from
             |  the Stalwart generation this build targets -- 0.16
             ihasmail's own major

The first two are the version in the root package.json, so there is a
single place to bump them, and 16 becomes 17 when ihasmail moves to
Stalwart 0.17. Dropping 0.15 is what makes that middle number honest:
while two generations were supported it could not have been either.

The pull request number comes from git at build time and is never
written back into the tree. It cannot be: it does not exist until the
pull request has merged, so a committed version would always describe a
merge that had not happened yet, and every open branch would collide on
the same line. A commit that did not come through a pull request carries
the last number plus its own short SHA -- 2.16.57+g1fa6578 -- which says
it is past that pull request rather than quietly claiming to be it.

.dockerignore excludes .git on purpose, so an image build cannot work
any of this out. It takes --build-arg IHASMAIL_VERSION instead, which
the build stage bakes into the bundle and the runtime stage keeps as an
environment variable for the server. Left out, it falls back to the base
version from package.json rather than failing -- so a version with no PR
number on it means whoever built the image did not pass one.

scripts/ is copied into the runtime image because the server resolves
its version through it. There is no git in there to ask, which is the
fallback's whole purpose.

Verified: 2.16.57 in the bundle and from /api/health on a dev checkout;
the same after a real docker build --build-arg, from inside the
container; and 2.16.0 rather than a crash when the arg is left off.

Note for deploying: ihasmail-deploy.sh on the host builds without the
argument and will produce 2.16.0 until it passes
--build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)".
This commit is contained in:
2026-08-26 10:00:29 -07:00
parent 2a741f6407
commit bf70ba9df0
13 changed files with 171 additions and 6 deletions
+4
View File
@@ -0,0 +1,4 @@
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
export function baseVersion(): string;
export function versionFromGit(): string | null;
export function resolveVersion(): string;
+83
View File
@@ -0,0 +1,83 @@
/**
* Work out this build's version: `2.16.57`.
*
* 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
*
* 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.
*
* 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.
*
* `.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.
*/
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}`;
}
function git(...args) {
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
}
const PR_SUBJECT = /^Merge pull request #(\d+)\b/;
/**
* 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.
*/
export function versionFromGit() {
let head;
try {
head = git("rev-parse", "--short", "HEAD");
} catch {
return null;
}
const base = baseVersion();
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}`;
}
} catch {
/* a shallow clone, or no history to read */
}
return `${base}.0+g${head}`;
}
/** Whatever the environment was told, else git, else just the base. */
export function resolveVersion() {
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
if (fromEnv) return fromEnv;
return versionFromGit() ?? `${baseVersion()}.0`;
}
// `node scripts/version.mjs` prints it, for shell scripts and CI.
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.stdout.write(resolveVersion() + "\n");
}