From bf70ba9df0ba49ebf01e60af8b996fe9129bf66f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 26 Aug 2026 10:00:29 -0700 Subject: [PATCH] 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)". --- Dockerfile | 16 ++++- README.md | 34 ++++++++++ package.json | 2 +- scripts/version.d.mts | 4 ++ scripts/version.mjs | 83 ++++++++++++++++++++++++ server/package.json | 2 +- server/src/app.ts | 2 +- server/src/config.ts | 8 +++ web/package.json | 2 +- web/src/env.d.ts | 8 +++ web/src/lib/version.ts | 6 ++ web/src/views/settings/AboutSettings.tsx | 4 +- web/vite.config.ts | 6 ++ 13 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 scripts/version.d.mts create mode 100644 scripts/version.mjs create mode 100644 web/src/env.d.ts create mode 100644 web/src/lib/version.ts diff --git a/Dockerfile b/Dockerfile index c5b86eb..b9cc1a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,12 @@ # ---- build stage ---- FROM node:22-alpine AS build +# What this build calls itself: 2.16., worked out by whoever runs the +# build. It cannot be worked out in here -- .dockerignore keeps .git out of the +# context on purpose, and git is not installed either. `node scripts/version.mjs` +# in a checkout prints the right answer; ihasmail-deploy.sh passes it through. +# Left empty, the build falls back to the base version from package.json. +ARG IHASMAIL_VERSION="" +ENV IHASMAIL_VERSION=$IHASMAIL_VERSION WORKDIR /app COPY package.json package-lock.json* ./ COPY server/package.json server/ @@ -10,14 +17,21 @@ RUN npm run build # ---- runtime stage ---- FROM node:22-alpine AS runtime +# Re-declared: an ARG does not cross stages. +ARG IHASMAIL_VERSION="" ENV NODE_ENV=production \ HOST=0.0.0.0 \ PORT=8080 \ STATIC_DIR=/app/web/dist \ - SESSION_FILE=/data/sessions.json + SESSION_FILE=/data/sessions.json \ + IHASMAIL_VERSION=$IHASMAIL_VERSION WORKDIR /app COPY package.json ./ COPY server/package.json server/ +# config.ts reads the version through this at startup. With IHASMAIL_VERSION +# set it never looks further; without it, it falls back to package.json rather +# than failing, since there is no git in here to ask. +COPY scripts/ ./scripts/ COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/server/dist ./server/dist COPY --from=build /app/web/dist ./web/dist diff --git a/README.md b/README.md index b7656c3..ad0a89a 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,40 @@ npm start # serve the production build Open http://localhost:5173 in dev (or http://localhost:8080 for the production build). +### Version numbers + +`ihasmail v2.16.57`, as shown in Settings › About and by `/api/health`: + +| | | +| --- | --- | +| `2` | ihasmail's own major | +| `16` | the **Stalwart** generation this build targets — 0.16, the oldest it supports | +| `57` | the pull request the commit came from | + +The first two live in the root `package.json`, so there is one place to bump +them; `16` becomes `17` when ihasmail moves to Stalwart 0.17. The third comes +from git at build time, because it does not exist until the pull request has +merged — a version committed to the tree would always be describing a merge +that had not happened yet, and every open branch would collide on the same +line. Nothing writes one back. + +A commit that did not arrive through a pull request carries the last number +plus its own short SHA — `2.16.57+g1fa6578` — which says plainly that the build +is *past* that pull request rather than being it. + +`node scripts/version.mjs` prints the version for the current checkout. + +`.dockerignore` excludes `.git` deliberately, so an image build cannot work any +of this out for itself. Pass it in: + +```bash +docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 . +``` + +Left out, the build falls back to the base version from `package.json` +(`2.16.0`) rather than failing — so a version with no PR number on it means +whoever built the image did not pass one. + ### The mock `npm run mock` is an in-memory fake Stalwart 0.16 — enough of JMAP to develop diff --git a/package.json b/package.json index f1f2cfb..b14e0c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ihasmail", - "version": "2.0.0", + "version": "2.16.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 new file mode 100644 index 0000000..5bbaf18 --- /dev/null +++ b/scripts/version.d.mts @@ -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; diff --git a/scripts/version.mjs b/scripts/version.mjs new file mode 100644 index 0000000..5382aa3 --- /dev/null +++ b/scripts/version.mjs @@ -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"); +} diff --git a/server/package.json b/server/package.json index 30a4b37..7c4aa34 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@ihasmail/server", - "version": "2.0.0", + "version": "2.16.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/server/src/app.ts b/server/src/app.ts index a2dc660..23ca031 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -143,7 +143,7 @@ export function createApp(): Hono { const api = new Hono(); api.use("*", csrfGuard); - api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: "2.0.0" })); + api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version })); api.get("/config", (c) => c.json({ diff --git a/server/src/config.ts b/server/src/config.ts index 40e414d..ff5a14a 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,3 +1,4 @@ +import { resolveVersion } from "../../scripts/version.mjs"; import { randomBytes } from "node:crypto"; import { fileURLToPath } from "node:url"; import { existsSync, readFileSync } from "node:fs"; @@ -60,6 +61,13 @@ const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+ export const config = { isProd, appName: env("APP_NAME", "ihasmail"), + /** + * What this build calls itself: `2.16.57`. Set by the image build from + * `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of + * the build context and nothing in there could work it out. A dev checkout + * has git, so it falls back to asking; see `scripts/version.mjs`. + */ + version: resolveVersion(), /** * Where this instance's source can be had, shown to everyone who reaches it. * diff --git a/web/package.json b/web/package.json index a751a82..1152a58 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@ihasmail/web", - "version": "2.0.0", + "version": "2.16.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..e49a916 --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,8 @@ +/// + +/** + * The build's version string, substituted by Vite at build time — there is no + * git to ask from inside a browser, or inside the Docker build. See + * `scripts/version.mjs`. + */ +declare const __IHASMAIL_VERSION__: string; diff --git a/web/src/lib/version.ts b/web/src/lib/version.ts new file mode 100644 index 0000000..d068309 --- /dev/null +++ b/web/src/lib/version.ts @@ -0,0 +1,6 @@ +/** + * 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. + */ +export const APP_VERSION = __IHASMAIL_VERSION__; diff --git a/web/src/views/settings/AboutSettings.tsx b/web/src/views/settings/AboutSettings.tsx index 09ee7a3..8bd7122 100644 --- a/web/src/views/settings/AboutSettings.tsx +++ b/web/src/views/settings/AboutSettings.tsx @@ -1,6 +1,7 @@ import { useSession } from "@/store/session"; import { client } from "@/jmap/client"; import { DEFAULT_SOURCE_URL } from "@/lib/source"; +import { APP_VERSION } from "@/lib/version"; export function AboutSettings() { const session = useSession((s) => s.session); @@ -14,7 +15,7 @@ export function AboutSettings() {
ihasmail
-
ihasmail 2.0
+
ihasmail v{APP_VERSION}
@@ -29,6 +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.

Server capabilities

{caps.map((c) => {c.replace("urn:ietf:params:jmap:", "")})} diff --git a/web/vite.config.ts b/web/vite.config.ts index d42ff48..9ec3dfc 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,9 +1,15 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import { fileURLToPath, URL } from "node:url"; +import { resolveVersion } from "../scripts/version.mjs"; + +// Resolved here, at build time: the browser has no git to ask, and neither does +// the Docker build, which is handed the answer as IHASMAIL_VERSION instead. +const version = resolveVersion(); export default defineConfig({ plugins: [react()], + define: { __IHASMAIL_VERSION__: JSON.stringify(version) }, resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, },