diff --git a/documentation/audits/immich-repair-2026-10-14/E/bh-new.txt b/documentation/audits/immich-repair-2026-10-14/E/bh-new.txt new file mode 100644 index 00000000..22c3837e --- /dev/null +++ b/documentation/audits/immich-repair-2026-10-14/E/bh-new.txt @@ -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) diff --git a/documentation/audits/immich-repair-2026-10-14/E/bh-old.txt b/documentation/audits/immich-repair-2026-10-14/E/bh-old.txt new file mode 100644 index 00000000..d3cd154f --- /dev/null +++ b/documentation/audits/immich-repair-2026-10-14/E/bh-old.txt @@ -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 diff --git a/documentation/audits/immich-repair-2026-10-14/E/catalog-decoys.txt b/documentation/audits/immich-repair-2026-10-14/E/catalog-decoys.txt new file mode 100644 index 00000000..39ff5162 --- /dev/null +++ b/documentation/audits/immich-repair-2026-10-14/E/catalog-decoys.txt @@ -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) diff --git a/documentation/audits/immich-repair-2026-10-14/E/redproof-unclean-boot.txt b/documentation/audits/immich-repair-2026-10-14/E/redproof-unclean-boot.txt new file mode 100644 index 00000000..6907b77e --- /dev/null +++ b/documentation/audits/immich-repair-2026-10-14/E/redproof-unclean-boot.txt @@ -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 diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index 0e51cd00..089a84db 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -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 diff --git a/scripts/build-hub.sh b/scripts/build-hub.sh index 3c0e10f2..5f17fdbf 100755 --- a/scripts/build-hub.sh +++ b/scripts/build-hub.sh @@ -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// (never deleted; R-1021)," + error "or build from your own clean clone: FELHOM_HUB_REPO_DIR= ./build.sh --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 # ========================================================================= diff --git a/scripts/test_build_hub_tree_gate.sh b/scripts/test_build_hub_tree_gate.sh new file mode 100755 index 00000000..79739bc7 --- /dev/null +++ b/scripts/test_build_hub_tree_gate.sh @@ -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)"