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 ----
|
# ---- build stage ----
|
||||||
FROM node:22-alpine AS build
|
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
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
COPY server/package.json server/
|
COPY server/package.json server/
|
||||||
@@ -10,14 +17,21 @@ RUN npm run build
|
|||||||
|
|
||||||
# ---- runtime stage ----
|
# ---- runtime stage ----
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
|
# Re-declared: an ARG does not cross stages.
|
||||||
|
ARG IHASMAIL_VERSION=""
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=8080 \
|
PORT=8080 \
|
||||||
STATIC_DIR=/app/web/dist \
|
STATIC_DIR=/app/web/dist \
|
||||||
SESSION_FILE=/data/sessions.json
|
SESSION_FILE=/data/sessions.json \
|
||||||
|
IHASMAIL_VERSION=$IHASMAIL_VERSION
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY server/package.json server/
|
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/node_modules ./node_modules
|
||||||
COPY --from=build /app/server/dist ./server/dist
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
COPY --from=build /app/web/dist ./web/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).
|
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
|
### The mock
|
||||||
|
|
||||||
`npm run mock` is an in-memory fake Stalwart 0.16 — enough of JMAP to develop
|
`npm run mock` is an in-memory fake Stalwart 0.16 — enough of JMAP to develop
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ihasmail",
|
"name": "ihasmail",
|
||||||
"version": "2.0.0",
|
"version": "2.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
|
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
|
||||||
"license": "AGPL-3.0-or-later",
|
"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",
|
"name": "@ihasmail/server",
|
||||||
"version": "2.0.0",
|
"version": "2.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+1
-1
@@ -143,7 +143,7 @@ export function createApp(): Hono<Env> {
|
|||||||
const api = new Hono<Env>();
|
const api = new Hono<Env>();
|
||||||
api.use("*", csrfGuard);
|
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) =>
|
api.get("/config", (c) =>
|
||||||
c.json({
|
c.json({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveVersion } from "../../scripts/version.mjs";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
@@ -60,6 +61,13 @@ const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+
|
|||||||
export const config = {
|
export const config = {
|
||||||
isProd,
|
isProd,
|
||||||
appName: env("APP_NAME", "ihasmail"),
|
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.
|
* Where this instance's source can be had, shown to everyone who reaches it.
|
||||||
*
|
*
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ihasmail/web",
|
"name": "@ihasmail/web",
|
||||||
"version": "2.0.0",
|
"version": "2.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"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 { useSession } from "@/store/session";
|
||||||
import { ApiError } from "@/jmap/client";
|
import { ApiError } from "@/jmap/client";
|
||||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||||
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const login = useSession((s) => s.login);
|
const login = useSession((s) => s.login);
|
||||||
@@ -94,7 +95,13 @@ export function LoginPage() {
|
|||||||
{busy ? "Signing in…" : "Sign in"}
|
{busy ? "Signing in…" : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
<p className="foot">
|
<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>
|
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">AGPL-3.0 source</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||||
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
|
||||||
export function AboutSettings() {
|
export function AboutSettings() {
|
||||||
const session = useSession((s) => s.session);
|
const session = useSession((s) => s.session);
|
||||||
@@ -14,7 +15,7 @@ export function AboutSettings() {
|
|||||||
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
||||||
<img src="/img/logo.png" alt="ihasmail" width={96} />
|
<img src="/img/logo.png" alt="ihasmail" width={96} />
|
||||||
<div>
|
<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 className="hint">AGPL-3.0-or-later · <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,6 +30,7 @@ export function AboutSettings() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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" 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>
|
<h2>Server capabilities</h2>
|
||||||
<div className="row wrap gap-4">
|
<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>)}
|
{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 { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { fileURLToPath, URL } from "node:url";
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
define: { __IHASMAIL_VERSION__: JSON.stringify(version) },
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user