Merge pull request #59 from LINUXexpert-org/version-numbers

Give builds a version number
This commit is contained in:
LINUXexpert.org
2026-08-26 10:22:28 -07:00
committed by GitHub
14 changed files with 179 additions and 7 deletions
+15 -1
View File
@@ -1,5 +1,12 @@
# ---- build stage ----
FROM node:22-alpine AS build
# What this build calls itself: 2.16.<PR>, 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
+34
View File
@@ -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`, shown on the sign-in page, 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
+1 -1
View File
@@ -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",
+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");
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -143,7 +143,7 @@ export function createApp(): Hono<Env> {
const api = new Hono<Env>();
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({
+8
View File
@@ -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.
*
+1 -1
View File
@@ -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",
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
/**
* 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;
+6
View File
@@ -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__;
+8 -1
View File
@@ -3,6 +3,7 @@ import { Eye, EyeOff, LogIn, ShieldCheck } from "lucide-react";
import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
export function LoginPage() {
const login = useSession((s) => s.login);
@@ -94,7 +95,13 @@ export function LoginPage() {
{busy ? "Signing in…" : "Sign in"}
</button>
<p className="foot">
ihasmail by <a href="https://linuxexpert.org" target="_blank" rel="noopener noreferrer">linuxexpert.org</a>
{/*
The version sits next to the source link on purpose: the AGPL's
offer is for the source of *this* build, and a version makes that
offer something a person can actually act on. It also means a bug
report names the build without anyone having to sign in to find it.
*/}
ihasmail v{APP_VERSION} by <a href="https://linuxexpert.org" target="_blank" rel="noopener noreferrer">linuxexpert.org</a>
{" · "}
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">AGPL-3.0 source</a>
</p>
+3 -1
View File
@@ -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() {
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
<img src="/img/logo.png" alt="ihasmail" width={96} />
<div>
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail 2.0</div>
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail v{APP_VERSION}</div>
<div className="hint">AGPL-3.0-or-later · <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a></div>
</div>
</div>
@@ -29,6 +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>
<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>)}
+6
View File
@@ -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)) },
},