From e474d2642a2113561d6aae904bb6e56dd7528959 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Mon, 1 Jun 2026 14:02:03 +0200 Subject: [PATCH] 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) --- build-and-push.sh | 247 ++++++++++++++++++++++++++----- build.sh | 28 ++++ scripts/bump-dev-build.py | 20 +++ scripts/check-release-version.py | 27 ++++ 4 files changed, 284 insertions(+), 38 deletions(-) create mode 100755 build.sh create mode 100755 scripts/bump-dev-build.py create mode 100755 scripts/check-release-version.py diff --git a/build-and-push.sh b/build-and-push.sh index c785c26..130102f 100755 --- a/build-and-push.sh +++ b/build-and-push.sh @@ -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/` snapshot branches. # - Two modes: -# t (test) = only push :dev -# r (release) = push :, :dev, :latest, then commit + tag and -# push a release/ 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 :, +# :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:-}." + 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}" + 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 - git push origin "HEAD:refs/heads/${RELEASE_BRANCH}" - git push origin "refs/tags/${VERSION}" - echo "[INFO] Pushed ${RELEASE_BRANCH} and tag ${VERSION} to origin." + 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 diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6060e88 --- /dev/null +++ b/build.sh @@ -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 "$@" diff --git a/scripts/bump-dev-build.py b/scripts/bump-dev-build.py new file mode 100755 index 0000000..a8ec01e --- /dev/null +++ b/scripts/bump-dev-build.py @@ -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}") diff --git a/scripts/check-release-version.py b/scripts/check-release-version.py new file mode 100755 index 0000000..d28f9fb --- /dev/null +++ b/scripts/check-release-version.py @@ -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")