From dd29f98f82b8ac8b3a62f17bbc0ba97d3370026f Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 16:38:19 +0100 Subject: [PATCH] build: refuse to package from a branch that is behind main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everyone branches off main and builds from their own branch. A branch that is behind main still builds and installs perfectly — it just packages a binary missing whatever landed on main meanwhile, and `rpm -U --force` overwrites the good one with it. Nothing reports an error; the release succeeds and the feature simply vanishes from the installed CLI. That is what happened today: a build from a stale checkout replaced /usr/bin/mcpctl with one that has no `statusline` command at all, months after the status line landed on main (`mcpctl statusline` -> "unknown command"). `check-main-sync.sh` fetches main, compares, and fails before any work happens, listing the commits the branch is missing and the merge that fixes it. Sourced by build-rpm.sh and build-deb.sh — both are run standalone, so neither can rely on the other having checked. A hard failure rather than a warning: a warning scrolls past in a build log, and the whole point is to stop before the artifact exists. MCPCTL_ALLOW_BEHIND_MAIN=1 is the escape hatch for a deliberate old-tree build; MCPCTL_BASE_BRANCH retargets the comparison. Offline it degrades to the last fetched origin/main, then a local main, saying which it used; outside a git checkout it skips. Verified: passes on this branch (up to date with main); against a synthetic ref one commit ahead, build-rpm.sh aborts with exit 1 before ensure_build_deps and before any compilation; the escape hatch bypasses it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k --- docs/project-summary.md | 18 +++++++ scripts/build-deb.sh | 5 ++ scripts/build-rpm.sh | 5 ++ scripts/check-main-sync.sh | 99 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100755 scripts/check-main-sync.sh diff --git a/docs/project-summary.md b/docs/project-summary.md index 95552bd..9ec9bd7 100644 --- a/docs/project-summary.md +++ b/docs/project-summary.md @@ -884,6 +884,24 @@ All pushed to `mysources.co.uk/michal/` registry. source .env && bash scripts/release.sh ``` +**The build refuses to run from a branch that is behind `main`.** Everyone +branches off main, so a stale branch still builds and installs cleanly — it just +ships a binary missing whatever landed on main meanwhile, and `rpm -U --force` +overwrites the good one with it. That happened on 2026-08-10: a build from a +stale checkout replaced `/usr/bin/mcpctl` with one that had no `statusline` +command, months after the status line landed. `scripts/check-main-sync.sh` +(sourced by `build-rpm.sh` and `build-deb.sh`) fetches `main`, compares, and +fails before any work happens, listing the commits you are missing. + +```bash +git merge main # the fix +MCPCTL_ALLOW_BEHIND_MAIN=1 bash scripts/release.sh # deliberate old-tree build +MCPCTL_BASE_BRANCH=release-2.x bash scripts/build-rpm.sh # compare to another branch +``` + +Offline it falls back to the last fetched `origin/main`, then to a local `main`, +and says which it used; outside a git checkout it skips entirely. + Installs via nfpm: - `/usr/bin/mcpctl` — CLI binary (bun compiled) - `/usr/bin/mcpctl-local` — Local proxy binary (bun compiled) diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index e8b1d07..5d0013d 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh" resolve_arch "${MCPCTL_TARGET_ARCH:-}" # Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX +# Same guard as build-rpm.sh: this script is also run on its own, so it cannot +# rely on that one having checked. +source "$SCRIPT_DIR/check-main-sync.sh" +check_main_sync + # Check and install missing build dependencies source "$SCRIPT_DIR/ensure-deps.sh" ensure_build_deps diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh index f2db0f1..2f45a98 100755 --- a/scripts/build-rpm.sh +++ b/scripts/build-rpm.sh @@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh" resolve_arch "${MCPCTL_TARGET_ARCH:-}" # Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX +# Before anything expensive: a branch behind main packages a binary missing +# whatever landed there, and installing it silently downgrades the machine. +source "$SCRIPT_DIR/check-main-sync.sh" +check_main_sync + # Check and install missing build dependencies source "$SCRIPT_DIR/ensure-deps.sh" ensure_build_deps diff --git a/scripts/check-main-sync.sh b/scripts/check-main-sync.sh new file mode 100755 index 0000000..246ada2 --- /dev/null +++ b/scripts/check-main-sync.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Refuse to build a package from a branch that main has already moved past. +# +# WHY +# +# Everyone branches off main and builds from their own branch. A branch that is +# behind main still builds and installs perfectly — it just quietly ships a +# binary missing whatever landed on main in the meantime, and `rpm -U --force` +# overwrites the good one with it. +# +# That is not hypothetical: on 2026-08-10 a build from a stale checkout replaced +# /usr/bin/mcpctl with one that had no `statusline` command at all, months after +# the status line landed on main. Nothing reported an error — the release +# succeeded, the feature just vanished from the installed CLI. +# +# So this is a hard failure rather than a warning. A warning scrolls past in a +# build log; the whole point is to stop before the artifact exists. +# +# ESCAPE HATCH +# +# MCPCTL_ALLOW_BEHIND_MAIN=1 build anyway (deliberate build of an old tree) +# MCPCTL_BASE_BRANCH= compare against something other than main +# +# Skips itself entirely outside a git checkout, so tarball builds still work. + +# Resolve the ref to compare against, echoing it on stdout. Prefers a fresh +# fetch; falls back to whatever is already on disk so an offline build is +# degraded rather than blocked. Returns 1 when there is nothing to compare to. +_main_sync_ref() { + local base="$1" remote="$2" + + if [ -n "$remote" ] && git fetch --quiet "$remote" "$base" 2>/dev/null; then + # FETCH_HEAD rather than refs/remotes//: it is what this fetch + # just wrote, so it cannot be a stale opportunistic update. + echo "FETCH_HEAD" + return 0 + fi + + if [ -n "$remote" ] && git rev-parse --verify --quiet "refs/remotes/$remote/$base" >/dev/null; then + echo " (could not reach $remote — comparing against the last fetched $remote/$base)" >&2 + echo "refs/remotes/$remote/$base" + return 0 + fi + + if git rev-parse --verify --quiet "refs/heads/$base" >/dev/null; then + echo " (no reachable remote — comparing against local $base)" >&2 + echo "refs/heads/$base" + return 0 + fi + + return 1 +} + +check_main_sync() { + local base="${MCPCTL_BASE_BRANCH:-main}" + + if ! git rev-parse --git-dir >/dev/null 2>&1; then + return 0 # not a checkout; nothing to be behind + fi + + if [ "${MCPCTL_ALLOW_BEHIND_MAIN:-}" = "1" ]; then + echo "==> Skipping the '$base' sync check (MCPCTL_ALLOW_BEHIND_MAIN=1)" + return 0 + fi + + echo "==> Checking this branch is not behind '$base'..." + + local remote ref + remote="$(git remote | head -1)" + if ! ref="$(_main_sync_ref "$base" "$remote")"; then + echo " (no '$base' branch found anywhere — skipping)" + return 0 + fi + + local behind + behind="$(git rev-list --count "HEAD..$ref" 2>/dev/null || echo 0)" + if [ "$behind" -eq 0 ]; then + echo " up to date with $base" + return 0 + fi + + local branch + branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)" + echo "" >&2 + echo "ERROR: '$branch' is $behind commit(s) behind $base — refusing to build." >&2 + echo "" >&2 + echo " Building now would package a binary without these, and installing it" >&2 + echo " would overwrite a good one with a version missing them:" >&2 + echo "" >&2 + git log --oneline --no-decorate "HEAD..$ref" | head -15 | sed 's/^/ /' >&2 + if [ "$behind" -gt 15 ]; then + echo " … and $((behind - 15)) more" >&2 + fi + echo "" >&2 + echo " Fix it: git merge $base # or: git rebase $base" >&2 + echo " Anyway: MCPCTL_ALLOW_BEHIND_MAIN=1 $0" >&2 + echo "" >&2 + return 1 +}