mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-09 02:03:12 +02:00
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:
+4
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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}"')
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Executable
+82
@@ -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
|
||||
Reference in New Issue
Block a user