build-hub.sh v1.24.0: refuse a stale or dirty tree (R-1021)
gates / gates (push) Successful in 6m44s

Fetch, pull --ff-only, then refuse unless clean and HEAD == origin/main; the
commit label is read after the pull. Test with a stub docker: 6 cases; the
old script gets 5 wrong. Evidence for the catalog gate unclean-boot-images.
This commit is contained in:
2026-10-14 19:11:17 +02:00
parent 8e7dce3211
commit 621783b3b0
7 changed files with 409 additions and 12 deletions
@@ -0,0 +1,7 @@
ok dirty: an untracked file (rc=1)
ok dirty: a modified tracked file (rc=1)
ok behind origin and the pull blocked (diverged) (rc=1)
ok ahead of origin (an unpushed commit) (rc=1)
ok behind origin, clean pull: Commit is the NEW head (rc=0)
ok clean, at origin/main: builds (stub docker) (rc=0)
build-hub tree gate OK — 6 case(s)
@@ -0,0 +1,47 @@
XX dirty: an untracked file (rc=0, expected 1; wanted: the tree is not clean)
[INFO] ╔══════════════════════════════════════╗
[INFO] ║ Build complete ✓ ║
[INFO] ╚══════════════════════════════════════╝
[INFO] Image: gitea.dooplex.hu/admin/felhom-hub:9.9.9
[INFO] Size: STUB docker image inspect gitea.dooplex.hu/admin/felhom-hub:9.9.9 --format={{.Size}} bytes
[INFO] Image is local only. To push: ./build.sh 9.9.9 --push
XX dirty: a modified tracked file (rc=0, expected 1; wanted: the tree is not clean)
[INFO] ╔══════════════════════════════════════╗
[INFO] ║ Build complete ✓ ║
[INFO] ╚══════════════════════════════════════╝
[INFO] Image: gitea.dooplex.hu/admin/felhom-hub:9.9.9
[INFO] Size: STUB docker image inspect gitea.dooplex.hu/admin/felhom-hub:9.9.9 --format={{.Size}} bytes
[INFO] Image is local only. To push: ./build.sh 9.9.9 --push
XX behind origin and the pull blocked (diverged) (rc=0, expected 1; wanted: pull --ff-only failed)
[INFO] ╔══════════════════════════════════════╗
[INFO] ║ Build complete ✓ ║
[INFO] ╚══════════════════════════════════════╝
[INFO] Image: gitea.dooplex.hu/admin/felhom-hub:9.9.9
[INFO] Size: STUB docker image inspect gitea.dooplex.hu/admin/felhom-hub:9.9.9 --format={{.Size}} bytes
[INFO] Image is local only. To push: ./build.sh 9.9.9 --push
XX ahead of origin (an unpushed commit) (rc=0, expected 1; wanted: is not origin/main)
[INFO] ╔══════════════════════════════════════╗
[INFO] ║ Build complete ✓ ║
[INFO] ╚══════════════════════════════════════╝
[INFO] Image: gitea.dooplex.hu/admin/felhom-hub:9.9.9
[INFO] Size: STUB docker image inspect gitea.dooplex.hu/admin/felhom-hub:9.9.9 --format={{.Size}} bytes
[INFO] Image is local only. To push: ./build.sh 9.9.9 --push
XX behind origin, clean pull: Commit is the NEW head (rc=0, expected 0; wanted: Commit: ee2f2cb)
[INFO] ╔══════════════════════════════════════╗
[INFO] ║ Build complete ✓ ║
[INFO] ╚══════════════════════════════════════╝
[INFO] Image: gitea.dooplex.hu/admin/felhom-hub:9.9.9
[INFO] Size: STUB docker image inspect gitea.dooplex.hu/admin/felhom-hub:9.9.9 --format={{.Size}} bytes
[INFO] Image is local only. To push: ./build.sh 9.9.9 --push
ok clean, at origin/main: builds (stub docker) (rc=0)
build-hub tree gate: 5 case(s) WRONG
@@ -0,0 +1,224 @@
ok FACT: docmost-postgres major + 1 (read from the clone) rc=1 (expected 1)
ok FACT: docmost-postgres major + 1, alone rc=1 (expected 1)
ok FACT: kimai-db 11.6 -> 12.3 BUNDLED with the kimai app bump rc=1 (expected 1)
ok FACT: kimai-db mariadb:11.6 -> mariadb:lts (major unreadable) rc=2 (expected 2)
ok GENUINE: kimai-db mariadb:11.x -> 11.99 (within major) rc=0 (expected 0)
ok GENUINE: kimai-db mariadb:11.6 -> 12.3 ALONE (the R-469 lift) rc=0 (expected 0)
ok GENUINE: docmost-postgres major+1 ALONE with its proven two-venue MARKED entry rc=0 (expected 0)
ok DECOY: the entry is proven on both venues but carries NO conversion mark rc=1 (expected 1)
ok DECOY: the marked entry cites ONE venue only (no box_evidence) rc=1 (expected 1)
ok DECOY: the mark sits in a COMMENT, the entry has none rc=1 (expected 1)
ok DECOY: the marked entry, but the move is BUNDLED with the app's own bump rc=1 (expected 1)
ok FACT: adventurelog's postgis 16 -> 17 (the postgis family was never judged before) rc=1 (expected 1)
ok DECOY: major moves only in a comment + serverVersion env rc=0 (expected 0)
ok DECOY: the APP image crosses a major (kimai 2.57 -> 3.0) rc=0 (expected 0)
ok DECOY: 'mariadb:12.3' lands in README.md, not a template rc=0 (expected 0)
ok FACT: kimai image moves, catalog_since untouched rc=1 (expected 1)
ok FACT: kimai image moves, catalog_since set to a FUTURE year rc=1 (expected 1)
ok GENUINE: kimai image moves AND catalog_since = today rc=0 (expected 0)
ok DECOY: image moves; today's date lands in a COMMENT and README, the field stays rc=1 (expected 1)
ok DECOY: only a comment + env line change, images untouched, date untouched rc=0 (expected 0)
ok FACT: a Hungarian byte changed in a frozen string rc=1 (expected 1)
ok FACT: an em dash in a Hungarian string (R-932) rc=1 (expected 1)
ok FACT: a Hungarian first_step removed rc=1 (expected 1)
ok FACT: a NEW app is not in the freeze rc=1 (expected 1)
ok FACT: an unknown key inside the English block rc=1 (expected 1)
ok FACT: an English deploy field with no Hungarian twin rc=1 (expected 1)
ok FACT: an accented Hungarian letter left in the English rc=1 (expected 1)
ok FACT: ASCII-only Hungarian left in the English (no accent to find) rc=1 (expected 1)
ok FACT: the product begs ("please") rc=1 (expected 1)
ok FACT: an English retrieval promise the Hungarian never made rc=1 (expected 1)
ok GENUINE: a REGISTERED true retrieval promise passes rc=0 (expected 0)
ok FACT: registered for ANOTHER app - the promise still convicts, the entry is stale rc=1 (expected 1)
ok FACT: registered on the path, but the sentence was rewritten (match gone) rc=1 (expected 1)
ok FACT: a STALE entry - nothing in the English promises it any more rc=1 (expected 1)
ok FACT: a registered promise with a two-word reason rc=1 (expected 1)
ok FACT: a credential token rewritten in translation rc=1 (expected 1)
ok FACT: an i18n block for a language the controller does not render rc=1 (expected 1)
ok FACT: an English list with a different number of steps rc=1 (expected 1)
ok FACT: ratchet — fewer strings translated than the ceiling records rc=1 (expected 1)
ok FACT: ratchet — more translated than the ceiling records rc=1 (expected 1)
ok GENUINE: a correct English block on privatebin rc=0 (expected 0)
ok GENUINE: naming an app does not change the coverage count rc=0 (expected 0)
ok FACT(no-yaml): a Hungarian byte changed in a frozen string rc=1 (expected 1)
ok FACT(no-yaml): a frozen Hungarian line deleted rc=1 (expected 1)
ok FACT(no-yaml): a NEW app is not in the freeze rc=1 (expected 1)
ok GENUINE(no-yaml): the untouched tree passes, and SAYS what it did not check rc=0 (expected 0)
ok GENUINE(no-yaml): romm's escaped-quote help_texts are still found rc=0 (expected 0)
ok DECOY: Hungarian rewritten inside a YAML COMMENT rc=0 (expected 0)
ok DECOY: a frozen Hungarian sentence pasted into README.md rc=0 (expected 0)
ok DECOY: display_name changed - a NAME, never copy rc=0 (expected 0)
ok DECOY: docs_url changed - configuration, never copy rc=0 (expected 0)
ok DECOY: Hungarian text added to a docker-compose.yml rc=0 (expected 0)
-- probe-matches-compose: the facts (each MUST be refused)
ok FACT: tandoor's real R-618 port fault, re-introduced rc=1 (expected 1)
ok FACT: wger's real R-618 port fault, re-introduced rc=1 (expected 1)
ok FACT: zipline's real R-618 path fault, re-introduced rc=1 (expected 1)
ok GENUINE: the fixed tree passes rc=0 (expected 0)
ok FACT: a clean app given a wrong probe port rc=1 (expected 1)
ok FACT: api+expect probe given a path the app does not answer rc=1 (expected 1)
-- probe-matches-compose: the decoys (the label moves, the fact does not)
ok DECOY: the port moves in a COMMENT in .felhom.yml rc=0 (expected 0)
ok DECOY: traefik loadbalancer.server.port changed rc=0 (expected 0)
ok DECOY: a published `ports:` mapping changed rc=0 (expected 0)
ok DECOY: a NON-probed sidecar's healthcheck port changed rc=0 (expected 0)
ok DECOY: path mismatch on a probe that CANNOT fail on it -> WARN, not FAIL rc=0 (expected 0)
-- probe-matches-compose: the DEGRADED mode CI actually runs (no PyYAML)
ok DEGRADED: the fixed tree passes, and SAYS it is degraded rc=0 (expected 0)
ok DEGRADED FACT: tandoor's port fault still refused rc=1 (expected 1)
ok DEGRADED FACT: wger's port fault still refused rc=1 (expected 1)
ok DEGRADED FACT: zipline's path fault still refused rc=1 (expected 1)
ok DEGRADED DECOY: traefik label moves, the fact does not rc=0 (expected 0)
-- probe-matches-compose: the probe TARGET (R-630)
ok FACT: paperless-ngx without its explicit container is refused rc=1 (expected 1)
ok FACT: immich's FOUR prefix candidates are an ambiguity, not a pick rc=1 (expected 1)
ok FACT: an explicit container no service declares is refused rc=1 (expected 1)
ok GENUINE: both explicit containers resolve rc=0 (expected 0)
ok DECOY: the container name moves in a COMMENT, not the field rc=0 (expected 0)
ok DECOY: a UNIQUE prefix still resolves without the field rc=0 (expected 0)
ok DEGRADED: the explicit container is read without PyYAML too rc=0 (expected 0)
ok DEGRADED FACT: removing it still refuses rc=1 (expected 1)
-- test-record-move: an image move needs its own proven record
ok FACT: a bare image move, no entry rc=1 (expected 1)
ok GENUINE: a proven entry whose digest the registry serves rc=0 (expected 0)
ok FACT: the entry's verdict is failed rc=1 (expected 1)
ok FACT: the registry now serves another digest rc=1 (expected 1)
ok INCONCLUSIVE: the registry cannot be asked rc=2 (expected 2)
ok DECOY: the entry only in a COMMENT under update_ladder rc=1 (expected 1)
ok DECOY: the entry only in README.md rc=1 (expected 1)
ok FACT: a new move carrying a BACKFILLED entry rc=1 (expected 1)
ok FACT: memory_tight and the limit did not move rc=1 (expected 1)
ok GENUINE: memory_tight WITH the limit raised rc=0 (expected 0)
ok DECOY: a ref moves in a compose COMMENT only rc=0 (expected 0)
-- test-record (static): the newest step IS the compose
ok GENUINE: a ladder whose head is the compose rc=0 (expected 0)
ok FACT: the compose moved past the ladder's head rc=1 (expected 1)
ok FACT: a line that is not one JSON entry rc=1 (expected 1)
ok FACT: a gap between steps rc=1 (expected 1)
ok FACT: a failed verdict sits in the ladder rc=1 (expected 1)
-- test-record (static): every intermediate step carries its own definition
ok FACT: a two-step ladder with NO steps/ file for the first step rc=1 (expected 1)
ok GENUINE: a two-step ladder whose first step carries its definition rc=0 (expected 0)
ok DECOY: the steps/ file has the right NAME and names the head's image rc=1 (expected 1)
ok DECOY: the step's definition sits beside the template under another name rc=1 (expected 1)
ok FACT: the step has its compose but no .felhom.yml rc=1 (expected 1)
ok DECOY: the step's .felhom.yml exists by NAME and holds no healthcheck rc=1 (expected 1)
-- test-record: a same-tag re-test (decision 52)
ok GENUINE: head + a re-test from its digest, both venues rc=0 (expected 0)
ok FACT: a re-test with NO new digest rc=1 (expected 1)
ok FACT: a re-test without the box venue rc=1 (expected 1)
ok FACT: a re-test FROM a digest the previous entry never tested rc=1 (expected 1)
ok DECOY: a re-test that names digest_from only in its evidence text rc=1 (expected 1)
ok GENUINE: a re-test whose digest the registry serves now rc=0 (expected 0)
ok FACT: a re-test whose digest the registry no longer serves rc=1 (expected 1)
ok FACT: a re-test with no new digest reaches the move gate too rc=1 (expected 1)
ok probe-measured: genuine komga note passes rc=0 (expected 0)
ok probe-measured: note moved into the TAGLINE rc=1 (expected 1)
ok probe-measured: date but no before/after rc=1 (expected 1)
ok probe-measured: before/after but no date rc=1 (expected 1)
ok probe-measured: 'read upstream' is not a measurement rc=1 (expected 1)
ok image-pins: FACT: untagged rc=1 (expected 1)
ok image-pins: FACT: a registry PORT is not a tag rc=1 (expected 1)
ok image-pins: FACT: quoted :latest rc=1 (expected 1)
ok image-pins: FACT: :LATEST in capitals rc=1 (expected 1)
ok image-pins: FACT: a floating alias (:edge) rc=1 (expected 1)
ok image-pins: FACT: untagged with a comment saying pinned rc=1 (expected 1)
ok image-pins: FACT: a QUOTED key is the same field rc=1 (expected 1)
ok image-pins: FACT: an interpolated ref cannot be read rc=1 (expected 1)
ok image-pins: FACT: @sha256: with no digest behind it rc=1 (expected 1)
ok image-pins: INERT: a commented-out untagged image rc=0 (expected 0)
ok image-pins: INERT: an x- extension field (Compose ignores it) rc=0 (expected 0)
ok image-pins: INERT: an untagged image in README.md rc=0 (expected 0)
ok image-pins: GENUINE: a concrete tag rc=0 (expected 0)
ok image-pins: GENUINE: a registry port WITH a tag rc=0 (expected 0)
ok image-pins: GENUINE: a real digest rc=0 (expected 0)
ok image-pins: GENUINE: :latest pinned by its digest rc=0 (expected 0)
ok image-pins: GENUINE: the real catalog rc=0 (expected 0)
ok image-resolvable: GENUINE: every pin resolves rc=0 (expected 0)
ok image-resolvable: FACT: the registry says one pin is gone rc=1 (expected 1)
ok image-resolvable: LABEL: rc=0 carrying a throttle message rc=2 (expected 2)
ok image-resolvable: LABEL: rc=0 carrying any error text rc=2 (expected 2)
ok image-resolvable: CRY-WOLF: a throttle on rc=1 is not an accusation rc=2 (expected 2)
ok image-resolvable: CRY-WOLF: an unrecognised failure is not an accusation rc=2 (expected 2)
ok image-resolvable: LABEL: a docker that resolves EVERYTHING, the canary too rc=3 (expected 3)
ok image-resolvable: LABEL: no docker at all rc=2 (expected 2)
ok image-resolvable: LABEL: nothing to judge rc=3 (expected 3)
ok volume-persistence: injected-prober suite OK
ok volume-persistence: LABEL: a runtime that answers nothing rc=3 (expected 3)
ok volume-persistence: LABEL: no docker at all rc=3 (expected 3)
ok volume-persistence: LABEL: nothing to judge rc=3 (expected 3)
ok volume-persistence: LABEL: not a scratch host rc=3 (expected 3)
-- onboarding: the facts (each MUST be refused)
ok FACT: a new template with NO record rc=1 (expected 1)
ok FACT: a record missing id 1.4 rc=1 (expected 1)
ok FACT: 1.4 answered only inside an HTML comment rc=1 (expected 1)
ok FACT: done with a path that does not exist rc=1 (expected 1)
ok FACT: done with an EMPTY directory (the mkdir shape) rc=1 (expected 1)
ok FACT: done naming an absent file in the sibling repo rc=1 (expected 1)
ok FACT: n/a with an EMPTY reason rc=1 (expected 1)
ok FACT: n/a with a two-word reason rc=1 (expected 1)
ok FACT: an OPEN row rc=1 (expected 1)
ok FACT: opened: backdated before the checklist rc=1 (expected 1)
ok FACT: a checklist id the template a new app copies lacks rc=1 (expected 1)
ok FACT: an exempt app's record with a done that points nowhere rc=1 (expected 1)
-- onboarding: the genuine articles (each MUST pass)
ok GENUINE: a complete record (catalog + sibling evidence) rc=0 (expected 0)
ok GENUINE: an id added AFTER opened: does not bind rc=0 (expected 0)
ok GENUINE: an exempt app's record may say open rc=0 (expected 0)
ok STATED SKIP: sibling repo absent (the CI shape) - printed, not checked rc=0 (expected 0)
-- family-gate: genuine and decoys
ok GENUINE: a family app, literal exceptions, min 0.287.0, golden 0.287.0 rc=0 (expected 0)
ok DECOY: family_gate only in a COMMENT -> not gated, nothing owed rc=0 (expected 0)
ok DECOY: no sibling -> rule 3 stated NOT CHECKED rc=0 (expected 0)
-- family-gate: the facts (each MUST be refused)
ok FACT: an exception that is a regex rc=1 (expected 1)
ok FACT: the exception '/' (the whole app) rc=1 (expected 1)
ok FACT: an exception with '..' rc=1 (expected 1)
ok FACT: an exception list with no gate rc=1 (expected 1)
ok FACT: min_controller only in a comment rc=1 (expected 1)
ok FACT: min_controller below the release rc=1 (expected 1)
ok FACT: newest baked golden below the release rc=1 (expected 1)
ok FACT: a golden DIRECTORY 0.287.0 with no bake log (a name is not a bake) rc=1 (expected 1)
ok FACT: glance (ruled family-gated, decision 221) without family_gate rc=1 (expected 1)
ok FACT: glance with family_gate only in a COMMENT (the label without the fact) rc=1 (expected 1)
ok FACT: grimmory (ruled, decisions 63/64) with family_gate: false rc=1 (expected 1)
ok GENUINE: glance gated, no exceptions rc=0 (expected 0)
-- mem-limit-sum: genuine and decoys
ok GENUINE: 384+256 = 640M, the field says 640M rc=0 (expected 0)
ok GENUINE: 1G is 1024M (1G + 256M = 1280M) rc=0 (expected 0)
ok DECOY: a steps/ file with an old figure is not judged rc=0 (expected 0)
-- mem-limit-sum: the facts (each MUST be refused)
ok FACT: the field under the sum; the right figure only in comments rc=1 (expected 1)
ok FACT: reservations: memory makes nothing a limit (service w/o limit) rc=1 (expected 1)
ok FACT: mem_limit only OUTSIDE resources: (top level) is no declaration rc=1 (expected 1)
ok FACT: an unreadable size is INCONCLUSIVE, never a pass rc=2 (expected 2)
-- data-key: genuine and decoys
ok GENUINE: a flagged *_ENCRYPTION_KEY; a signing key labelled 'Encryption key' unflagged; bookstack registered rc=0 (expected 0)
-- data-key: the facts (each MUST be refused)
ok FACT: the flag only in a COMMENT rc=1 (expected 1)
ok FACT: the flag only inside the i18n: block rc=1 (expected 1)
ok FACT: a REGISTERED data key unflagged (bookstack APP_KEY) rc=1 (expected 1)
ok FACT: an unexplained flag (a signing key flagged) rc=1 (expected 1)
ok FACT: a stale REGISTRY entry (bookstack has no APP_KEY any more) rc=1 (expected 1)
ok FACT: a non-boolean flag is INCONCLUSIVE rc=2 (expected 2)
-- unclean-boot-images: genuine and decoys
ok GENUINE: the block lists the server's image rc=0 (expected 0)
ok GENUINE: bumped AND the list updated (flow list) rc=0 (expected 0)
ok GENUINE: bumped AND the list updated (block list) rc=0 (expected 0)
ok DECOY: the new image only in a compose COMMENT rc=0 (expected 0)
ok DECOY: ANOTHER service bumped (machine-learning) rc=0 (expected 0)
ok DECOY: the block COMMENTED OUT, server bumped (no block, nothing owed) rc=0 (expected 0)
ok DECOY: a template with no block rc=0 (expected 0)
-- unclean-boot-images: the facts (each MUST be refused)
ok FACT: the server bumped, the list unchanged rc=1 (expected 1)
ok FACT: the same in a steps/ file (the ladder) rc=1 (expected 1)
ok FACT: the block names a service the compose does not run rc=1 (expected 1)
ok FACT: the block lists no images rc=1 (expected 1)
catalog gate decoys OK — 198 case(s), every label judged on its fact (R-421)
@@ -0,0 +1,24 @@
=== MUTATION: the image-in-list rule removed
XX FACT: the server bumped, the list unchanged rc=0 (expected 1)
XX FACT: the same in a steps/ file (the ladder) rc=0 (expected 1)
rc=1
=== MUTATION: the steps/ loop removed
XX FACT: the same in a steps/ file (the ladder) rc=0 (expected 1)
rc=1
=== MUTATION: a commented-out block read as the block
XX DECOY: the block COMMENTED OUT, server bumped (no block, nothing owed) rc=1 (expected 0)
rc=1
=== MUTATION: any service's image read (the last one)
rc=0
=== MUTATION: an unreadable block passed
XX FACT: the block lists no images rc=0 (expected 1)
rc=1
=== MUTATION (redo of 4): the service name ignored — every service's image read, the last one wins
XX GENUINE: the block lists the server's image rc=1 (expected 0)
XX GENUINE: bumped AND the list updated (flow list) rc=1 (expected 0)
XX GENUINE: bumped AND the list updated (block list) rc=1 (expected 0)
XX DECOY: the new image only in a compose COMMENT rc=1 (expected 0)
XX DECOY: ANOTHER service bumped (machine-learning) rc=1 (expected 0)
XX FACT: the server bumped, the list unchanged rc=1 (expected 1)
XX FACT: the block names a service the compose does not run rc=1 (expected 1)
rc=1
+12
View File
@@ -1,3 +1,15 @@
## 2026-10-14 — build-hub.sh v1.24.0: refuses a stale or dirty tree (R-1021)
- Fetches, then REFUSES (exit 1, naming the leftovers' home and the own-clone route) unless the tree is clean, the
`git pull --ff-only` succeeds, and `HEAD == origin/main`. `GIT_COMMIT` is read AFTER the pull. Until now a failed
pull only warned and the commit was read before it — on 2026-10-14 the shared checkout was 44 commits behind and the
script would have built old hub code under the new version label.
- `FELHOM_HUB_REPO_DIR` builds from another clone (same rule). No `--allow-dirty`: no current runbook needs one.
- `test_build_hub_tree_gate.sh` (new): a scratch origin + clone, a STUB `docker` first on PATH (nothing is built or
pushed). Six cases — untracked file, modified file, diverged (pull blocked), unpushed commit → refused; behind with a
clean pull → passes and the Commit line names the NEW head; clean → passes. Red-proof: the old script (only
`REPO_DIR` made overridable) gets 5 of 6 wrong (`documentation/audits/immich-repair-2026-10-14/E/bh-old.txt`).
## 2026-10-14 — host install: the uninstall removes the new root helper felhom-vzdump-partial-clean (R-1006)
- `felhom-host-install.sh` step 4d removes `/usr/local/sbin/felhom-vzdump-partial-clean` (agent v0.156.1's bundle installs
+33 -12
View File
@@ -19,10 +19,12 @@
# =============================================================================
set -euo pipefail
SCRIPT_VERSION="1.23.0"
SCRIPT_VERSION="1.24.0"
# --- Configuration (edit these if your paths differ) ---
REPO_DIR="/mnt/5_hdd/felhom.eu/git/felhom.eu"
# FELHOM_HUB_REPO_DIR builds from another clone (a session's own clean clone; the script's own test) — the same
# clean-tree rule applies to it.
REPO_DIR="${FELHOM_HUB_REPO_DIR:-/mnt/5_hdd/felhom.eu/git/felhom.eu}"
HUB_SRC="${REPO_DIR}/hub"
REGISTRY="gitea.dooplex.hu/admin"
IMAGE="${REGISTRY}/felhom-hub"
@@ -54,8 +56,35 @@ if ! command -v docker &>/dev/null; then
exit 1
fi
# Get git commit for build metadata
GIT_COMMIT=$(cd "${REPO_DIR}" && git rev-parse --short HEAD 2>/dev/null || echo "unknown")
# =========================================================================
# Step 1: the clean-tree gate (R-1021) — fetch, pull, and REFUSE a stale or dirty tree
# =========================================================================
# Until v1.24.0 a failed pull only warned and the commit was read BEFORE it: on 2026-10-14 the shared checkout was
# 44 commits behind, its pull aborted on untracked leftovers, and this script would have shipped old hub code under a
# new version label. An unpushed or stale change does not exist (workspace CLAUDE.md, „Clean-tree gate"). There is
# no override: no current runbook builds the hub from a dirty tree. Test: scripts/test_build_hub_tree_gate.sh.
step "1/3 — Pulling latest code (the tree must be clean and at origin/main)..."
cd "${REPO_DIR}"
refuse() {
error "$1"
error "Refusing to build from ${REPO_DIR}: the hub image must be built from a clean tree at origin/main."
error "Leftover files in the shared checkout move to /mnt/5_hdd/felhom.eu/leftovers/<date>/ (never deleted; R-1021),"
error "or build from your own clean clone: FELHOM_HUB_REPO_DIR=<clone> ./build.sh <VER> --push"
exit 1
}
DIRTY=$(git status --porcelain 2>&1) || refuse "git status failed: ${DIRTY}"
[[ -z "${DIRTY}" ]] || refuse "the tree is not clean:
${DIRTY}"
git fetch -q origin 2>/dev/null || refuse "git fetch failed (offline?) — origin/main cannot be checked"
git pull -q --ff-only 2>/dev/null || refuse "git pull --ff-only failed (local commits, or files in the way)"
DIRTY=$(git status --porcelain 2>&1) || refuse "git status failed: ${DIRTY}"
[[ -z "${DIRTY}" ]] || refuse "the tree is not clean after the pull:
${DIRTY}"
HEAD_SHA=$(git rev-parse HEAD) ; MAIN_SHA=$(git rev-parse origin/main)
[[ "${HEAD_SHA}" == "${MAIN_SHA}" ]] || refuse "HEAD ${HEAD_SHA:0:10} is not origin/main ${MAIN_SHA:0:10} (unpushed commits?)"
# Read AFTER the pull: the label names the commit that is built.
GIT_COMMIT=$(git rev-parse --short HEAD)
echo ""
info "╔══════════════════════════════════════╗"
@@ -68,14 +97,6 @@ info "Build: ${BUILD_DIR}"
info "Image: ${IMAGE}:${VERSION}"
echo ""
# =========================================================================
# Step 1: Pull latest code
# =========================================================================
step "1/3 — Pulling latest code..."
cd "${REPO_DIR}"
git pull --ff-only 2>/dev/null || warn "git pull failed (offline or dirty tree?)"
# =========================================================================
# Step 2: Sync source to build workspace
# =========================================================================
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# test_build_hub_tree_gate.sh — build-hub.sh refuses a stale or dirty tree (R-1021).
#
# Builds a tiny scratch repo (a bare "origin" + a clone holding the hub's directory shape), copies build-hub.sh next
# to it (so its workspace/ is scratch too) and runs it with FELHOM_HUB_REPO_DIR pointing at the clone and a STUB
# `docker` first on PATH: the real Docker is never called, nothing is built or pushed. Each case asserts the exit code
# AND a line of output:
# dirty (an untracked file) -> refused, "not clean"
# dirty (a modified tracked file) -> refused, "not clean"
# behind origin, the pull blocked -> refused (local commit diverged: "pull --ff-only failed")
# ahead of origin (unpushed commit) -> refused, "is not origin/main"
# behind origin, the pull clean -> passes, and the Commit line names the NEW head (read after the pull)
# clean, at origin/main -> passes, the stub docker reached
# Run: bash scripts/test_build_hub_tree_gate.sh (exit 0 all cases right · 1 a case wrong)
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
WS="$(mktemp -d)"
trap 'rm -rf "${WS:?}"' EXIT
fails=0
mkdir -p "$WS/bin" "$WS/build"
printf '#!/bin/sh\necho "STUB docker $*"\n' > "$WS/bin/docker"; chmod +x "$WS/bin/docker"
cp "$HERE/build-hub.sh" "$WS/build/build.sh"
fresh() {
rm -rf "${WS:?}/origin.git" "${WS:?}/clone" "${WS:?}/other" "${WS:?}/build/workspace"
git init -q --bare -b main "$WS/origin.git"
git clone -q "$WS/origin.git" "$WS/clone" 2>/dev/null
(cd "$WS/clone" && mkdir -p hub/cmd/hub hub/internal/api hub/internal/store hub/internal/web \
&& printf 'module x\n\ngo 1.22\n' > hub/go.mod && echo a > hub/cmd/hub/a.txt \
&& git add -A && git -c user.name=t -c user.email=t@t commit -q -m init && git push -q origin main)
git clone -q "$WS/origin.git" "$WS/other" 2>/dev/null
}
upstream_commit() { # a new commit on origin, made from another clone
(cd "$WS/other" && git pull -q && echo "$1" > "hub/cmd/hub/$1.txt" && git add -A \
&& git -c user.name=t -c user.email=t@t commit -q -m "$1" && git push -q origin main)
}
run() { # name, expected rc, required output fragment
local out rc
out=$(cd "$WS/build" && PATH="$WS/bin:$PATH" FELHOM_HUB_REPO_DIR="$WS/clone" bash ./build.sh 9.9.9 2>&1); rc=$?
if [[ $rc -eq $2 && "$out" == *"$3"* ]]; then
echo " ok $1 (rc=$rc)"
else
echo " XX $1 (rc=$rc, expected $2; wanted: $3)"; echo "$out" | tail -8 | sed 's/^/ /'; fails=$((fails+1))
fi
}
fresh; touch "$WS/clone/leftover.txt"
run "dirty: an untracked file" 1 "the tree is not clean"
fresh; echo b >> "$WS/clone/hub/cmd/hub/a.txt"
run "dirty: a modified tracked file" 1 "the tree is not clean"
fresh; upstream_commit up1; (cd "$WS/clone" && echo l > hub/cmd/hub/local.txt && git add -A && git -c user.name=t -c user.email=t@t commit -q -m local)
run "behind origin and the pull blocked (diverged)" 1 "pull --ff-only failed"
fresh; (cd "$WS/clone" && echo l > hub/cmd/hub/local.txt && git add -A && git -c user.name=t -c user.email=t@t commit -q -m local)
run "ahead of origin (an unpushed commit)" 1 "is not origin/main"
fresh; upstream_commit up2; NEW=$(git -C "$WS/origin.git" rev-parse --short main)
run "behind origin, clean pull: Commit is the NEW head" 0 "Commit: ${NEW}"
fresh
run "clean, at origin/main: builds (stub docker)" 0 "STUB docker build"
if [[ $fails -gt 0 ]]; then echo "build-hub tree gate: $fails case(s) WRONG"; exit 1; fi
echo "build-hub tree gate OK — 6 case(s)"