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>
This commit is contained in:
Ivo Oskamp 2026-06-01 14:02:03 +02:00
parent 3ce9df9bae
commit e474d2642a
4 changed files with 284 additions and 38 deletions

View File

@ -6,11 +6,24 @@ set -euo pipefail
# #
# Purpose: # Purpose:
# - Build & push Docker images for each service under ./containers/* # - 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: # - Two modes:
# t (test) = only push :dev # t (test) = build & push :dev. Switches to (or creates) the `dev`
# r (release) = push :<version>, :dev, :latest, then commit + tag and # branch from `main` if not already on it, commits any
# push a release/<version> branch + tag to origin. # pending changes, and pushes `dev` to origin so the dev
# Version is read from the top of changelog.md. # 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: # Usage:
# ./build-and-push.sh [mode] # ./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. # 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" SCRIPT_MASTER_PATH="/docker/develop/shared-integrations/tooling/docker-build-and-push/build-and-push.sh"
DOCKER_REGISTRY="gitea.oskamp.info" DOCKER_REGISTRY="gitea.oskamp.info"
@ -53,8 +66,16 @@ CHANGELOG_FILE="docs/changelog.md"
CONTAINERS_DIR="containers" CONTAINERS_DIR="containers"
# --- Self-update check ------------------------------------------------------- # --- 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() { self_update_check() {
if [[ "${SKIP_SELF_UPDATE:-0}" == "1" ]]; then
return 0
fi
local self_path="${BASH_SOURCE[0]}" local self_path="${BASH_SOURCE[0]}"
# Resolve to absolute path so a comparison against itself is detected. # Resolve to absolute path so a comparison against itself is detected.
local self_abs local self_abs
@ -67,23 +88,60 @@ self_update_check() {
return 0 # Master not reachable from this host; silently skip. return 0 # Master not reachable from this host; silently skip.
fi fi
local master_version local master_version reason=""
master_version="$(grep -m1 -E '^SCRIPT_VERSION=' "$SCRIPT_MASTER_PATH" | sed -E 's/.*"([^"]+)".*/\1/')" master_version="$(grep -m1 -E '^SCRIPT_VERSION=' "$SCRIPT_MASTER_PATH" | sed -E 's/.*"([^"]+)".*/\1/')"
if [[ -n "$master_version" && "$master_version" != "$SCRIPT_VERSION" ]]; then 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 " local : $SCRIPT_VERSION"
echo " master : $master_version ($SCRIPT_MASTER_PATH)" echo " master : $master_version ($SCRIPT_MASTER_PATH)"
echo " Update with: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\"" else
echo "" echo " Same SCRIPT_VERSION ($SCRIPT_VERSION) but file contents differ."
elif ! cmp -s "$self_abs" "$SCRIPT_MASTER_PATH"; then
echo "[WARN] Local build-and-push.sh differs from master (same SCRIPT_VERSION=$SCRIPT_VERSION)."
echo " master : $SCRIPT_MASTER_PATH" echo " master : $SCRIPT_MASTER_PATH"
echo " Diff or update with: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\""
echo ""
fi 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 ------------------------------------------------ # --- Input: prompt if missing ------------------------------------------------
MODE="${1:-}" MODE="${1:-}"
@ -193,19 +251,86 @@ else
echo "[INFO] Repo: $(pwd) (not a git checkout)" echo "[INFO] Repo: $(pwd) (not a git checkout)"
fi 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="" VERSION=""
DEV_MERGED=0
if [[ "$MODE" == "r" ]]; then 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)" 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 "$VERSION"
validate_tag "latest" 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. # 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 read -r -p "Proceed building & pushing as ${VERSION}? [y/N] " CONFIRM
CONFIRM="${CONFIRM:-N}" CONFIRM="${CONFIRM:-N}"
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then 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 exit 0
fi fi
else else
@ -280,32 +405,78 @@ done
echo "============================================================" echo "============================================================"
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 [[ "$MODE" == "r" ]]; then
if [[ ! -d ".git" ]]; then echo "[INFO] Finalising release: version=${VERSION}"
echo "[WARN] Not a git checkout — skipping git commit/tag/push."
exit 0
fi
RELEASE_BRANCH="release/${VERSION}" # Produce a clean Release commit at the tip. Preflight guarantees the working
echo "[INFO] Preparing git release: branch=${RELEASE_BRANCH}, tag=${VERSION}" # tree was clean at start; any post-build artefacts would be unexpected, so
# commit with --allow-empty to keep the release marker isolated.
# Stage everything and commit only if there's something to commit. if git diff --quiet HEAD -- && git diff --cached --quiet; then
git add -A git commit --allow-empty -m "Release ${VERSION}"
if git diff --cached --quiet; then
echo "[INFO] Working tree clean — no commit needed."
else else
echo "[WARN] Working tree changed during the build — staging and including in release commit."
git add -A
git commit -m "Release ${VERSION}" git commit -m "Release ${VERSION}"
fi 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}" 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: 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 fi
git push origin "HEAD:refs/heads/${RELEASE_BRANCH}" CURRENT_BRANCH="$(git symbolic-ref --short -q HEAD || echo)"
git push origin "refs/tags/${VERSION}"
echo "[INFO] Pushed ${RELEASE_BRANCH} and tag ${VERSION} to origin." # 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 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 "$@"

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")