feat: numbered releases (2.1.0) with git tags and GitHub releases

The app already knew exactly which build was running - a calendar version
of commit date plus short hash - but nothing gave a release a name users
could quote, and the repo had no tags at all.

Adds a VERSION file as the single source of truth for a SemVer release
number, read by app/version.py alongside the existing build string rather
than replacing it: the number is for people, the build is for pinning down
a deploy, and both ship in /api/version and the template context. The menu
shows the release first with the build underneath. #versionText still holds
the build string, because the remote-update poller compares it to detect
that the server came back on a new build.

Resolution order is unchanged (frozen file > git > fallback), and a frozen
file written before this change still yields a correct release number, so
an already-deployed server does not need re-freezing to stay sane. The
container has no git, hence COPY VERSION into the image - otherwise a plain
'docker compose build' reports 0.0.0.

scripts/release.sh cuts a release from main: it refuses a dirty tree, a
wrong branch, a malformed number or an existing tag, extracts the notes
from the matching whatsnew section, then tags, pushes and publishes via gh.

Numbering starts at 2.1.0 rather than 1.x: the v2 line has been in
production since March and 'v1' is the archived pre-migration branch, so
1.x would have been ambiguous. Sections in whatsnew before 2.1.0 keep
their date-only headings - they were never tagged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-26 19:20:57 +02:00
parent 95d96ecbd3
commit 4c6857a00f
10 changed files with 172 additions and 11 deletions
+4 -1
View File
@@ -29,7 +29,10 @@ RUN pip install --no-cache-dir -r requirements.txt \
&& rm -rf /var/lib/apt/lists/*
# Copy application code
# Note: Run 'python -m app.version freeze' before build to include version info
# Note: Run 'python -m app.version freeze' before build to include version info.
# VERSION holds the release number and is read at /app/VERSION when no frozen
# version file is present (e.g. a plain 'docker compose build' during dev).
COPY VERSION ./
COPY app/ ./app/
# Expose Flask port
+1 -1
View File
@@ -335,7 +335,7 @@ python3 -m app.version freeze
docker compose up -d --build
```
The `python3 -m app.version freeze` command captures the current Git version (date + commit hash) for display in the app menu.
The `python3 -m app.version freeze` command captures the current Git build (date + commit hash) for display in the app menu, underneath the release number from the `VERSION` file. Released versions are tagged `v<version>` and published at [Releases](https://github.com/MarekWo/mc-webui/releases); see [Versioning & Releases](docs/architecture.md#versioning--releases) for how a release is cut.
### Testing experimental features
+1
View File
@@ -0,0 +1 @@
2.1.0
+2 -1
View File
@@ -22,7 +22,7 @@ from app.log_handler import MemoryLogHandler
from app.observer import ObserverManager
from app.routes.views import views_bp
from app.routes.api import api_bp
from app.version import VERSION_STRING, GIT_BRANCH
from app.version import RELEASE_VERSION, VERSION_STRING, GIT_BRANCH
# Configure logging
logging.basicConfig(
@@ -230,6 +230,7 @@ def create_app():
@app.context_processor
def inject_globals():
return {
'release': RELEASE_VERSION,
'version': VERSION_STRING,
'git_branch': GIT_BRANCH,
'transport_type': config.transport_type,
+6 -1
View File
@@ -4962,14 +4962,19 @@ def get_version():
JSON with version info:
{
"success": true,
"release": "2.1.0",
"version": "2025.01.18+576c8ca9",
"docker_tag": "2025.01.18-576c8ca9",
"branch": "dev"
}
'release' is the human-facing release number; 'version' stays the exact
build and remains what the update poller compares.
"""
from app.version import VERSION_STRING, DOCKER_TAG, GIT_BRANCH
from app.version import RELEASE_VERSION, VERSION_STRING, DOCKER_TAG, GIT_BRANCH
return jsonify({
'success': True,
'release': RELEASE_VERSION,
'version': VERSION_STRING,
'docker_tag': DOCKER_TAG,
'branch': GIT_BRANCH
+4 -1
View File
@@ -96,8 +96,11 @@
<div class="px-3 pb-2 text-muted small border-bottom">
<div class="d-flex align-items-center justify-content-between">
<span id="versionDisplay">
<i class="bi bi-tag"></i> <span id="versionText">{{ version }}</span>
<i class="bi bi-tag"></i> <span id="releaseText" class="fw-semibold">{{ release }}</span>
<span class="badge bg-secondary ms-1" id="branchBadge">{{ git_branch }}</span>
<!-- Exact build: what pins a deployment down, and what the
update poller compares - keep it as its own element -->
<span id="versionText" class="d-block opacity-75" title="Exact build">{{ version }}</span>
</span>
<button id="checkUpdateBtn" class="btn btn-sm btn-outline-secondary py-0 px-1" title="Check for updates">
<i class="bi bi-arrow-repeat" id="checkUpdateIcon"></i>
+37 -5
View File
@@ -1,16 +1,35 @@
"""
Git-based version management for mc-webui.
Format: YYYY.MM.DD+<short_hash> (e.g., 2025.01.18+576c8ca9)
Version management for mc-webui.
Two identities, on purpose:
- RELEASE_VERSION - the human-facing release number (SemVer, e.g. 2.1.0),
read from the VERSION file at the repo root. This is what users quote.
- VERSION_STRING - the exact build, derived from git as
YYYY.MM.DD+<short_hash> (e.g. 2025.01.18+576c8ca9). This is what pins a
deployment down for debugging, and what the update checker compares.
"""
import subprocess
import shlex
import os
RELEASE_VERSION = "0.0.0"
VERSION_STRING = "0.0.0+unknown"
DOCKER_TAG = "0.0.0-unknown"
GIT_BRANCH = "unknown"
def get_release_version():
"""Read the release number from the VERSION file next to the app package."""
path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "VERSION"
)
try:
with open(path, encoding="utf8") as f:
return f.read().strip() or "0.0.0"
except OSError:
return "0.0.0"
def subprocess_run(args):
"""Execute subprocess and return stripped stdout."""
if not isinstance(args, (list, tuple)):
@@ -65,9 +84,16 @@ def get_git_version():
return git_version, docker_tag, git_branch
# Load version: frozen file takes priority, then git, then fallback
# Load version: frozen file takes priority, then git, then fallback.
# The release number comes from the VERSION file either way - a frozen file
# written before releases existed simply has no RELEASE_VERSION to override it.
RELEASE_VERSION = get_release_version()
try:
from app.version_frozen import VERSION_STRING, DOCKER_TAG, GIT_BRANCH
from app import version_frozen
VERSION_STRING = version_frozen.VERSION_STRING
DOCKER_TAG = version_frozen.DOCKER_TAG
GIT_BRANCH = version_frozen.GIT_BRANCH
RELEASE_VERSION = getattr(version_frozen, "RELEASE_VERSION", None) or RELEASE_VERSION
except ImportError:
try:
VERSION_STRING, DOCKER_TAG, GIT_BRANCH = get_git_version()
@@ -79,7 +105,9 @@ if __name__ == "__main__":
import sys
if len(sys.argv) >= 2 and sys.argv[1] == "freeze":
VERSION_STRING, DOCKER_TAG, GIT_BRANCH = get_git_version()
RELEASE_VERSION = get_release_version()
code = f'''"""Frozen version - auto-generated, do not edit."""
RELEASE_VERSION = "{RELEASE_VERSION}"
VERSION_STRING = "{VERSION_STRING}"
DOCKER_TAG = "{DOCKER_TAG}"
GIT_BRANCH = "{GIT_BRANCH}"
@@ -87,8 +115,12 @@ GIT_BRANCH = "{GIT_BRANCH}"
path = os.path.join(os.path.dirname(__file__), "version_frozen.py")
with open(path, "w", encoding="utf8") as f:
f.write(code)
print(f"Version frozen: {VERSION_STRING} ({GIT_BRANCH})")
print(f"Version frozen: {RELEASE_VERSION} ({VERSION_STRING}, {GIT_BRANCH})")
elif len(sys.argv) >= 2 and sys.argv[1] == "release":
# Plain release number, for scripts/release.sh and CI
print(RELEASE_VERSION)
else:
print(f'RELEASE_VERSION="{RELEASE_VERSION}"')
print(f'VERSION_STRING="{VERSION_STRING}"')
print(f'DOCKER_TAG="{DOCKER_TAG}"')
print(f'GIT_BRANCH="{GIT_BRANCH}"')
+25
View File
@@ -11,6 +11,7 @@ Technical documentation for mc-webui, covering system architecture, project stru
- [Database Architecture](#database-architecture)
- [API Reference](#api-reference)
- [WebSocket API](#websocket-api)
- [Versioning & Releases](#versioning--releases)
- [Offline Support](#offline-support)
---
@@ -447,6 +448,30 @@ The `MemoryLogHandler` filters werkzeug access-log records for `/socket.io/` and
---
## Versioning & Releases
Two identities, deliberately separate — `app/version.py` exposes both:
| | Source | Looks like | Used for |
|---|---|---|---|
| `RELEASE_VERSION` | `VERSION` file at the repo root | `2.1.0` | What users and testers quote; the git tag; the GitHub release |
| `VERSION_STRING` | git commit date + short hash | `2026.07.26+95d96ec` | Pinning down an exact deploy; what `/api/check-update` compares |
Resolution order is unchanged: `app/version_frozen.py` (written by `python -m app.version freeze`, which `scripts/update.sh` runs before every rebuild) wins over live git, which wins over the built-in fallbacks. `RELEASE_VERSION` is read from the `VERSION` file and only *overridden* by the frozen file, so a frozen file written before releases existed still yields a correct release number. The container has no git and no `.git`, hence `COPY VERSION ./` in the Dockerfile — without it a plain `docker compose build` would report `0.0.0`.
Both values ship in `GET /api/version` (`release` and `version`) and in the template context (`{{ release }}`, `{{ version }}`). The menu shows the release number first with the build underneath; `#versionText` deliberately still holds the *build* string, because the remote-update poller compares it to detect that the server came back on a new build.
**Cutting a release:**
1. On `dev`: bump `VERSION`, and title the pending `docs/whatsnew.md` section `## <version> — <date>` (open a fresh `## Unreleased` above it)
2. Merge `dev``main`
3. On `main`: `./scripts/release.sh` — validates the number, refuses a dirty tree, a non-`main` branch, or an existing tag, extracts the notes from that whatsnew section, then tags `v<version>`, pushes it, and publishes the GitHub release via `gh`. `--dry-run` prints the notes and changes nothing
4. Deploy as usual (`mcupdate`), which freezes the version into the image
Numbering is SemVer read through an operator's eyes: **MAJOR** when a deploy needs manual action (new env var, migration, breaking config), **MINOR** for new features, **PATCH** for fixes only. Releases start at 2.1.0 — the v2 line has been in production since 2026-03-28, and `v1` is the archived pre-migration branch, so 1.x would have been ambiguous. Everything before 2.1.0 is dated but untagged.
---
## Offline Support
The application works completely offline without internet connection. Vendor libraries (Bootstrap, Bootstrap Icons, Socket.IO, Emoji Picker) are bundled locally. A Service Worker provides hybrid caching to ensure functionality without connectivity.
+10 -1
View File
@@ -2,14 +2,23 @@
User-facing summary of changes since the last `main` release. Maintained on `dev` and finalized before each merge to `main`.
Releases are numbered `MAJOR.MINOR.PATCH` from **2.1.0** onward and tagged on GitHub — **MAJOR** when a deploy needs manual action from you, **MINOR** for new features, **PATCH** for fixes only. Sections before 2.1.0 are dated only; the project wasn't tagging releases yet. The exact build running on your server (`2026.07.26+95d96ec`) is still shown in the menu under the release number, for pinning down a specific deploy.
For deep technical notes, see [architecture.md](architecture.md). For the full git history, run `git log`.
---
## Unreleased (since c6d2367)
## Unreleased
_Nothing yet since 2.1.0._
---
## 2.1.0 — 2026-07-26
### New features
- **Releases now have version numbers.** This is the first numbered release: the menu shows **2.1.0** with the exact build (date and commit) underneath, so "which version are you on?" has a short answer, and the build is still there when a problem needs pinning down. Each release is tagged on GitHub with these notes attached, so you can see what changed without digging through the commit history.
- **The Path Analyzer map starts on the route you asked for.** The map used to plot every located repeater on top of your route, which buried the path you actually wanted to see. It now opens showing just that route, and two checkboxes in the map's top corner add the rest back when you want it: **All repeaters** brings back the purple dots of uninvolved repeaters, and **Alternative paths** draws the other copies of the same message your node overheard. Both start off, and switching them never moves or re-zooms the map, so you keep the view you panned to.
- **See where a message's routes actually differ.** With **Alternative paths** on, each of the message's other routes gets its own light colour, matched by a coloured dot next to it in the side list, so you can tell the lines apart — tap one to see its hops and SNR. Copies of one message usually travel most of the same way, so only the stretches where an alternative really diverges are drawn, plus a dot marking where it ends. Often that's the last hop alone, which used to be invisible under the main route.
- **The Path Analyzer remembers your filters.** Time range, hop and hash-size filters, the text searches, and the Routes segment length are kept between visits, so a working set like "Last 1 day + 2/3-byte" no longer has to be set up every single time. **Clear** resets and forgets them. Opening the analyzer from a chat route ignores your saved filters for that visit, so they can't hide the message you tapped through to. The settings are stored by your browser, not on the device — each browser or phone keeps its own.
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# Tag the current release and publish it on GitHub.
#
# Run on `main`, after merging `dev`. Reads the release number from VERSION
# and the release notes from the matching docs/whatsnew.md section, so there
# is exactly one place to edit each of them.
#
# ./scripts/release.sh # tag + GitHub release
# ./scripts/release.sh --dry-run # show what would happen, change nothing
set -e
DRY_RUN=false
[ "$1" = "--dry-run" ] && DRY_RUN=true
cd "$(dirname "$0")/.."
info() { echo -e "\033[0;34m[*]\033[0m $1"; }
success() { echo -e "\033[0;32m[+]\033[0m $1"; }
error() { echo -e "\033[0;31m[!]\033[0m $1" >&2; }
VERSION=$(tr -d ' \t\r\n' < VERSION)
TAG="v${VERSION}"
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
error "VERSION must be MAJOR.MINOR.PATCH, got '${VERSION}'"
exit 1
fi
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" != "main" ]; then
error "Releases are cut from main, but you are on '${BRANCH}'"
exit 1
fi
if [ -n "$(git status --porcelain)" ]; then
error "Working tree is dirty - commit or stash first"
exit 1
fi
if git rev-parse "$TAG" >/dev/null 2>&1; then
error "Tag ${TAG} already exists - bump VERSION before releasing"
exit 1
fi
# Release notes: the whatsnew.md section headed "## <version> - <date>",
# up to the next "## " heading
NOTES=$(awk -v ver="$VERSION" '
$0 ~ "^## " ver "( |$|—)" { found = 1; next }
found && /^## / { exit }
found { print }
' docs/whatsnew.md | sed -e 's/^---$//' | awk 'NF || printed { print; printed = 1 }')
if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
error "No release notes for ${VERSION} in docs/whatsnew.md"
error "Expected a section headed: ## ${VERSION} - YYYY-MM-DD"
exit 1
fi
info "Release ${TAG} on $(git rev-parse --short HEAD)"
echo "----------------------------------------"
echo "$NOTES"
echo "----------------------------------------"
if [ "$DRY_RUN" = true ]; then
info "Dry run - no tag created, nothing published"
exit 0
fi
git tag -a "$TAG" -m "mc-webui ${VERSION}"
success "Tagged ${TAG}"
git push origin "$TAG"
success "Pushed ${TAG}"
if command -v gh >/dev/null 2>&1; then
echo "$NOTES" | gh release create "$TAG" --title "mc-webui ${VERSION}" --notes-file -
success "Published GitHub release ${TAG}"
else
info "gh CLI not found - create the release manually at:"
info " https://github.com/MarekWo/mc-webui/releases/new?tag=${TAG}"
fi