Merge pull request #59 from LINUXexpert-org/version-numbers
Give builds a version number
This commit is contained in:
+15
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Vendored
+8
@@ -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;
|
||||
@@ -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__;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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)) },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user