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)) }, },