Compare commits

...

3 Commits

Author SHA1 Message Date
e474d2642a Build tooling: dev-build bump and release-version validation
Add build.sh wrapper that bumps the explicit dev/test build segment
(scripts/bump-dev-build.py) for test builds and validates release version
state (scripts/check-release-version.py) for releases. Update build-and-push.sh
accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:03 +02:00
3ce9df9bae Editor: Find & Replace scope option
Add a "Current chapter only" checkbox to the Find & Replace modal. When
checked, search/replace runs against the open chapter instead of every chapter
in the book. Default unchecked, preserving the existing all-chapters behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:03 +02:00
347f959d80 Sidebar: show running build version
Display the running build version at the bottom of the sidebar (e.g. v0.2.11
for releases, v0.2.11.3 for dev builds), linking to the changelog. New
version.py exposes display_version(); shared_templates.py registers it as the
app_version Jinja global; main.py adds /api/version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:03 +02:00
11 changed files with 358 additions and 45 deletions

View File

@ -6,11 +6,24 @@ set -euo pipefail
#
# Purpose:
# - Build & push Docker images for each service under ./containers/*
# - Branch model: `main` is permanent and tracked by the prod stack
# (image `:latest`). `dev` is a short-lived branch tracked by the dev
# stack (image `:dev`); it is recreated from `main` at the start of each
# cycle and deleted after each release. Tags `vX.Y.Z` cover rollback —
# no `release/<version>` snapshot branches.
# - Two modes:
# t (test) = only push :dev
# r (release) = push :<version>, :dev, :latest, then commit + tag and
# push a release/<version> branch + tag to origin.
# Version is read from the top of changelog.md.
# t (test) = build & push :dev. Switches to (or creates) the `dev`
# branch from `main` if not already on it, commits any
# pending changes, and pushes `dev` to origin so the dev
# stack picks up the matching compose/config alongside
# the :dev image.
# r (release) = MUST be run from `main`. Build & push :<version>,
# :dev, :latest. Fast-forwards `main` against
# `origin/main`, merges `dev` into `main` if it exists
# (local or remote), creates a `Release vX.Y.Z` commit,
# tags it, pushes `main` and the tag, and deletes the
# `dev` branch locally and on origin. Version is read
# from the top of changelog.md.
#
# Usage:
# ./build-and-push.sh [mode]
@ -43,7 +56,7 @@ set -euo pipefail
# the local copy BEFORE running, then proceed. Mention the update to the user.
# ============================================================================
SCRIPT_VERSION="1.2.0"
SCRIPT_VERSION="1.7.0"
SCRIPT_MASTER_PATH="/docker/develop/shared-integrations/tooling/docker-build-and-push/build-and-push.sh"
DOCKER_REGISTRY="gitea.oskamp.info"
@ -53,8 +66,16 @@ CHANGELOG_FILE="docs/changelog.md"
CONTAINERS_DIR="containers"
# --- Self-update check -------------------------------------------------------
# Compare this script to the canonical master copy and warn (don't overwrite).
# Compare this script to the canonical master copy. If it differs, offer to
# copy master over the local copy and re-exec with the same arguments so the
# build runs against the up-to-date script.
#
# Skip with: SKIP_SELF_UPDATE=1 ./build-and-push.sh ...
self_update_check() {
if [[ "${SKIP_SELF_UPDATE:-0}" == "1" ]]; then
return 0
fi
local self_path="${BASH_SOURCE[0]}"
# Resolve to absolute path so a comparison against itself is detected.
local self_abs
@ -67,23 +88,60 @@ self_update_check() {
return 0 # Master not reachable from this host; silently skip.
fi
local master_version
local master_version reason=""
master_version="$(grep -m1 -E '^SCRIPT_VERSION=' "$SCRIPT_MASTER_PATH" | sed -E 's/.*"([^"]+)".*/\1/')"
if [[ -n "$master_version" && "$master_version" != "$SCRIPT_VERSION" ]]; then
echo "[WARN] A newer build-and-push.sh is available."
reason="version"
elif ! cmp -s "$self_abs" "$SCRIPT_MASTER_PATH"; then
reason="contents"
else
return 0 # Identical to master.
fi
echo "[WARN] Local build-and-push.sh differs from master."
if [[ "$reason" == "version" ]]; then
echo " local : $SCRIPT_VERSION"
echo " master : $master_version ($SCRIPT_MASTER_PATH)"
echo " Update with: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\""
echo ""
elif ! cmp -s "$self_abs" "$SCRIPT_MASTER_PATH"; then
echo "[WARN] Local build-and-push.sh differs from master (same SCRIPT_VERSION=$SCRIPT_VERSION)."
else
echo " Same SCRIPT_VERSION ($SCRIPT_VERSION) but file contents differ."
echo " master : $SCRIPT_MASTER_PATH"
echo " Diff or update with: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\""
echo ""
fi
# Prompt only when stdin is a TTY; in non-interactive runs, abort safely so
# an unattended release never silently runs against a stale script.
if [[ ! -t 0 ]]; then
echo "[ERROR] Non-interactive shell — refusing to auto-update."
echo " Re-run interactively, or set SKIP_SELF_UPDATE=1 to bypass,"
echo " or update manually: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\""
exit 1
fi
local reply
read -r -p "Update local script from master and re-run? [Y/n] " reply
reply="${reply:-Y}"
if [[ ! "$reply" =~ ^[Yy]$ ]]; then
echo "[INFO] Continuing with local version $SCRIPT_VERSION (not updated)."
echo ""
return 0
fi
if ! cp "$SCRIPT_MASTER_PATH" "$self_abs"; then
echo "[ERROR] Failed to copy master to $self_abs (read-only filesystem?)."
echo " Continuing with local version $SCRIPT_VERSION."
echo ""
return 0
fi
chmod +x "$self_abs" 2>/dev/null || true
echo "[INFO] Updated $self_abs from master. Re-executing..."
echo ""
# Re-exec with original arguments. SKIP_SELF_UPDATE=1 prevents an
# update loop if cp somehow didn't take.
export SKIP_SELF_UPDATE=1
exec "$self_abs" "$@"
}
self_update_check
self_update_check "$@"
# --- Input: prompt if missing ------------------------------------------------
MODE="${1:-}"
@ -193,19 +251,86 @@ else
echo "[INFO] Repo: $(pwd) (not a git checkout)"
fi
# --- Determine version (release only) ----------------------------------------
# --- Release preflight (BEFORE any docker work) ------------------------------
# All git-side validation for a release happens here so a wrong-branch / dirty
# tree / stale main / conflicting dev / pre-existing tag aborts the run before
# anything is built or pushed to the registry. dev is merged into main now so
# the version we read from changelog.md reflects the merged state, not main's
# pre-merge state.
VERSION=""
DEV_MERGED=0
if [[ "$MODE" == "r" ]]; then
if [[ ! -d ".git" ]]; then
echo "[ERROR] Release mode requires a git checkout."
exit 1
fi
CURRENT_BRANCH="$(git symbolic-ref --short -q HEAD || echo)"
if [[ "$CURRENT_BRANCH" != "main" ]]; then
echo "[ERROR] Release build must run from 'main' branch. Current: ${CURRENT_BRANCH:-<detached>}."
echo " Switch with: git checkout main"
exit 1
fi
if ! git diff --quiet HEAD -- || ! git diff --cached --quiet; then
echo "[ERROR] Working tree has uncommitted changes. Commit or stash them on the appropriate branch before releasing."
git status --short
exit 1
fi
echo "[INFO] Fetching origin..."
git fetch origin main
if git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
git fetch origin dev
fi
if ! git merge --ff-only origin/main 2>/dev/null; then
echo "[ERROR] Local main has diverged from origin/main. Resolve manually before releasing."
exit 1
fi
# Merge dev into main BEFORE reading the version, so changelog.md reflects
# the bumped state that dev brings in.
if git show-ref --verify --quiet refs/heads/dev; then
echo "[INFO] Merging local dev into main..."
if ! git merge --no-ff dev -m "Release (merge dev)"; then
echo "[ERROR] Merge of dev into main failed (conflict). Resolve manually and re-run."
exit 1
fi
DEV_MERGED=1
elif git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
echo "[INFO] Fetching and merging origin/dev into main..."
git fetch origin dev:dev
if ! git merge --no-ff dev -m "Release (merge dev)"; then
echo "[ERROR] Merge of dev into main failed (conflict). Resolve manually and re-run."
exit 1
fi
DEV_MERGED=1
else
echo "[INFO] No dev branch found — releasing main as-is."
fi
VERSION="$(read_version_from_changelog)"
echo "[INFO] Release version (from $CHANGELOG_FILE): $VERSION"
echo "[INFO] Release version (from $CHANGELOG_FILE, post-merge): $VERSION"
validate_tag "$VERSION"
validate_tag "latest"
# Tag collision = abort. A re-release of an existing version with different
# content would silently move what consumers think v0.X.Y points to.
if git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null; then
echo "[ERROR] Tag ${VERSION} already exists locally. Bump $CHANGELOG_FILE to a new version before releasing."
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/${VERSION}" >/dev/null 2>&1; then
echo "[ERROR] Tag ${VERSION} already exists on origin. Bump $CHANGELOG_FILE to a new version before releasing."
exit 1
fi
# Ask for confirmation so you never accidentally re-push an old version or a wrong one.
read -r -p "Proceed building & pushing as ${VERSION}? [y/N] " CONFIRM
CONFIRM="${CONFIRM:-N}"
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "[INFO] Aborted by user."
echo "[INFO] Aborted by user. Note: dev has been merged into local main; reset with 'git reset --hard origin/main' if you want to undo."
exit 0
fi
else
@ -280,32 +405,78 @@ done
echo "============================================================"
echo ""
# --- Git: commit + release branch + tag (release mode only) -----------------
# --- Git: release commit + tag + push (release mode only) -------------------
# Preflight (branch, clean tree, ff origin/main, dev merge, tag collision,
# version parse) already ran BEFORE the build. dev is already merged into
# local main. We only need to land the Release commit, tag, and push.
if [[ "$MODE" == "r" ]]; then
if [[ ! -d ".git" ]]; then
echo "[WARN] Not a git checkout — skipping git commit/tag/push."
exit 0
fi
echo "[INFO] Finalising release: version=${VERSION}"
RELEASE_BRANCH="release/${VERSION}"
echo "[INFO] Preparing git release: branch=${RELEASE_BRANCH}, tag=${VERSION}"
# Stage everything and commit only if there's something to commit.
git add -A
if git diff --cached --quiet; then
echo "[INFO] Working tree clean — no commit needed."
# Produce a clean Release commit at the tip. Preflight guarantees the working
# tree was clean at start; any post-build artefacts would be unexpected, so
# commit with --allow-empty to keep the release marker isolated.
if git diff --quiet HEAD -- && git diff --cached --quiet; then
git commit --allow-empty -m "Release ${VERSION}"
else
echo "[WARN] Working tree changed during the build — staging and including in release commit."
git add -A
git commit -m "Release ${VERSION}"
fi
# Create or move the annotated tag to current HEAD.
if git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null; then
echo "[WARN] Tag ${VERSION} already exists locally — leaving it as-is."
else
git tag -a "${VERSION}" -m "Release ${VERSION}"
# Push main first (triggers prod webhook), then the tag.
git push origin main
git push origin "refs/tags/${VERSION}"
echo "[INFO] Pushed main and tag ${VERSION} to origin."
# Clean up dev branch — local and remote.
if [[ "$DEV_MERGED" == "1" ]]; then
if git show-ref --verify --quiet refs/heads/dev; then
git branch -D dev
echo "[INFO] Deleted local dev branch."
fi
if git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
git push origin --delete dev
echo "[INFO] Deleted remote dev branch."
fi
fi
fi
git push origin "HEAD:refs/heads/${RELEASE_BRANCH}"
git push origin "refs/tags/${VERSION}"
echo "[INFO] Pushed ${RELEASE_BRANCH} and tag ${VERSION} to origin."
# --- Git: dev branch commit + push (test mode only) -------------------------
if [[ "$MODE" == "t" ]]; then
if [[ ! -d ".git" ]]; then
echo "[WARN] Not a git checkout — skipping dev branch commit/push."
exit 0
fi
CURRENT_BRANCH="$(git symbolic-ref --short -q HEAD || echo)"
# Ensure we are on the dev branch. Create it if needed.
if [[ "$CURRENT_BRANCH" != "dev" ]]; then
if git show-ref --verify --quiet refs/heads/dev; then
echo "[INFO] Switching to existing local dev branch."
git checkout dev
elif git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
echo "[INFO] Checking out remote dev branch."
git fetch origin dev
git checkout -b dev origin/dev
else
echo "[INFO] Creating new dev branch from main."
git fetch origin main
git checkout -b dev origin/main
fi
fi
# Stage and commit if there are changes.
git add -A
if git diff --cached --quiet; then
echo "[INFO] Working tree clean — pushing current HEAD to dev."
else
git commit -m "Dev build $(date '+%Y-%m-%d %H:%M')"
fi
# Non-force push. Diverged origin/dev fails hard — resolve manually.
git push -u origin dev
echo "[INFO] Pushed dev to origin."
fi

28
build.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
# Novela build wrapper. Keeps project-specific version handling out of the
# shared build-and-push.sh script.
#
# Usage:
# ./build.sh t # increment explicit dev/test build segment, then push :dev
# ./build.sh r # validate release version state, then run release build
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$repo_root"
mode="${1:-}"
case "$mode" in
t)
./scripts/bump-dev-build.py
;;
r)
./scripts/check-release-version.py
;;
*)
echo "usage: ./build.sh {t|r}" >&2
exit 2
;;
esac
exec ./build-and-push.sh "$@"

View File

@ -53,6 +53,12 @@ app.include_router(changelog_router)
app.include_router(search_router)
@app.get("/api/version")
async def version():
from version import display_version
return JSONResponse({"version": display_version()})
@app.get("/health")
async def health():
try:

View File

@ -1,6 +1,7 @@
from fastapi.templating import Jinja2Templates
from db import get_db_conn
from version import display_version
def _develop_mode() -> bool:
@ -16,3 +17,4 @@ def _develop_mode() -> bool:
templates = Jinja2Templates(directory="templates")
templates.env.globals["develop_mode"] = _develop_mode
templates.env.globals["app_version"] = display_version

View File

@ -596,6 +596,7 @@ async function replaceInAllChapters() {
const replaceVal = document.getElementById('rp-replace').value;
const useRegex = document.getElementById('rp-regex').checked;
const caseSens = document.getElementById('rp-case').checked;
const currentOnly = document.getElementById('rp-current').checked;
const runBtn = document.getElementById('rp-run');
const prog = document.getElementById('rp-progress');
@ -620,10 +621,24 @@ async function replaceInAllChapters() {
const curCh = currentCh();
if (curCh) pendingContent.set(curCh._id, editor.getValue());
for (let i = 0; i < chapters.length; i++) {
const ch = chapters[i];
// Determine which chapters to process
let targets;
if (currentOnly) {
if (!curCh) {
prog.className = 'modal-progress error';
prog.textContent = 'No chapter open.';
runBtn.disabled = false;
return;
}
targets = [curCh];
} else {
targets = chapters;
}
for (let i = 0; i < targets.length; i++) {
const ch = targets[i];
prog.className = 'modal-progress';
prog.textContent = `Checking chapter ${i + 1} / ${chapters.length}`;
prog.textContent = `Checking chapter ${i + 1} / ${targets.length}`;
let original;
if (pendingContent.has(ch._id)) {
@ -665,9 +680,13 @@ async function replaceInAllChapters() {
updateSaveAll();
prog.className = totalOccurrences > 0 ? 'modal-progress ok' : 'modal-progress';
prog.textContent = totalOccurrences > 0
? `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in ${chaptersChanged} chapter${chaptersChanged !== 1 ? 's' : ''} — not saved yet.`
: 'No matches found.';
if (totalOccurrences === 0) {
prog.textContent = 'No matches found.';
} else if (currentOnly) {
prog.textContent = `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in current chapter — not saved yet.`;
} else {
prog.textContent = `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in ${chaptersChanged} chapter${chaptersChanged !== 1 ? 's' : ''} — not saved yet.`;
}
runBtn.disabled = false;
}

View File

@ -118,6 +118,18 @@ html {
.sidebar-bottom { margin-top: auto; }
.sidebar-version {
display: block;
margin-top: 0.5rem;
text-align: center;
font-family: var(--mono);
font-size: 0.68rem;
color: var(--text-dim);
text-decoration: none;
opacity: 0.75;
}
.sidebar-version:hover { opacity: 1; }
.disk-warning {
display: flex;
align-items: center;

View File

@ -289,6 +289,7 @@
</svg>
<span id="rescan-label">Rescan library</span>
</button>
<a href="/changelog" class="sidebar-version" title="Running Novela build">{{ app_version() }}</a>
</div>
</aside>

View File

@ -82,7 +82,7 @@
<!-- Find & Replace modal -->
<div class="modal-backdrop" id="replace-modal">
<div class="modal">
<div class="modal-title">Find &amp; Replace — all chapters</div>
<div class="modal-title">Find &amp; Replace</div>
<div class="modal-field">
<label class="modal-label">Search</label>
<input class="modal-input" id="rp-search" type="text" placeholder="Search…" autocomplete="off"/>
@ -94,6 +94,7 @@
<div class="modal-options">
<label class="modal-opt"><input type="checkbox" id="rp-regex"/> Regex</label>
<label class="modal-opt"><input type="checkbox" id="rp-case"/> Case sensitive</label>
<label class="modal-opt"><input type="checkbox" id="rp-current"/> Current chapter only</label>
</div>
<div class="modal-progress" id="rp-progress"></div>
<div class="modal-actions">

View File

@ -0,0 +1,26 @@
"""Novela version metadata.
The release version is the single source maintained in ``changelog.py``
(``CHANGELOG[0]["version"]``). Dev/test builds append an explicit ``BUILD``
segment that is incremented by ``scripts/bump-dev-build.py`` on every test
build, so operators can see exactly which image build is running in the
sidebar. ``BUILD`` is reset to 0 for releases.
"""
from __future__ import annotations
from changelog import CHANGELOG
BUILD = 2
def _release_version() -> str:
"""Return the semantic release version (e.g. v0.2.11)."""
return CHANGELOG[0]["version"] if CHANGELOG else "v0.0.0"
def display_version() -> str:
"""Return the user-visible Novela version (e.g. v0.2.11 or v0.2.11.3)."""
version = _release_version()
if BUILD > 0:
return f"{version}.{BUILD}"
return version

20
scripts/bump-dev-build.py Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Increment Novela's explicit dev/test build number."""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VERSION_FILE = ROOT / "containers" / "novela" / "version.py"
text = VERSION_FILE.read_text()
match = re.search(r"^BUILD = (\d+)\s*$", text, flags=re.MULTILINE)
if not match:
raise SystemExit(f"BUILD assignment not found in {VERSION_FILE}")
next_build = int(match.group(1)) + 1
text = text[: match.start(1)] + str(next_build) + text[match.end(1) :]
VERSION_FILE.write_text(text)
print(f"[bump-dev-build] BUILD = {next_build}")

View File

@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Validate Novela release version state before a release build.
Releases must ship with BUILD = 0 so the sidebar shows a clean semantic
version (e.g. v0.2.11) instead of a dev build (e.g. v0.2.11.3).
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VERSION_FILE = ROOT / "containers" / "novela" / "version.py"
text = VERSION_FILE.read_text()
match = re.search(r"^BUILD = (\d+)\s*$", text, flags=re.MULTILINE)
if not match:
raise SystemExit(f"BUILD assignment not found in {VERSION_FILE}")
build = int(match.group(1))
if build != 0:
raise SystemExit(
f"Release builds require BUILD = 0 in {VERSION_FILE}; found BUILD = {build}. "
f"Reset it before releasing."
)
print("[check-release-version] BUILD = 0")