#!/usr/bin/env bash # check-skill-floor.sh — fail when the vendored pi-extensions skill snapshot in # rootfs/ ("the floor") has drifted from the package repo it is a snapshot of. # # THE DEFECT THIS EXISTS TO CATCH, measured 2026-09-10. # rootfs/usr/local/share/pi-devbox/skills/pi-extensions/ ships a vendored copy # of the pi-extensions skill so the skill is ALWAYS present in the image. # Dockerfile.variant then copies the freshly-cloned package copy OVER the served # path at /usr/local/share/... — but it never writes back to the repo floor. So # the floor only silently rots, and it had: 34284 B, untouched since fa04d20 # (2026-07-30), while the package copy was 38973 B. Four copies existed with # three different sizes. # # Why that is worse than ordinary staleness: the floor is a FALLBACK. The copy # step is guarded by `if [ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build # where the package clone yields no skill/ keeps the vendored snapshot and still # succeeds — green, with no manifest flag and no label saying which copy was # served. The image would ship a July skill and nothing would say so. Keeping # the floor fresh means that fallback is harmless instead of a silent regression. # # WHY A DIRECTORY HASH AND NOT `sha256sum SKILL.md`. # The same pipeline Dockerfile.variant uses for skillset_snapshot_tree_sha256, # and for the same documented reason: a file-only compare answers "did this one # file change", not "is this the same skill". pi-extensions ships TWO files # (SKILL.md + evaluate-extension-usage.py), so a sibling-file edit would pass a # file-only check. If you change the pipeline here, change it there too. # # WHY GATING ON ANOTHER REPO IS PROPORTIONATE HERE, since that is normally a # smell: this fires only when the package's skill/ DIRECTORY HASH changes, which # is exactly and only when the floor has genuinely gone stale. pi-extensions # commits that do not touch skill/ leave the hash alone and cannot turn this red. # The repo is also anonymously clonable (verified 2026-09-10 with `git ls-remote` # and no credentials), so this needs no secret and cannot break on token expiry. # # Exit codes — deliberately three, matching scripts/lint-shell.sh's philosophy # that a gate which cannot run must not pass: # 0 in sync (or the package legitimately has no skill/ at this ref) # 1 DRIFT — the floor differs from the package # 2 cannot run — no package copy could be obtained set -euo pipefail REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) FLOOR_DIR="${REPO_ROOT}/rootfs/usr/local/share/pi-devbox/skills/pi-extensions" # Defaults mirror Dockerfile.variant's ARGs so this checks what the build builds. PI_EXTENSIONS_REPO="${PI_EXTENSIONS_REPO:-https://gitea.jordbo.se/joakimp/pi-extensions.git}" PI_EXTENSIONS_REF="${PI_EXTENSIONS_REF:-main}" PACKAGE_DIR="" WARN_ONLY=0 TMPDIR_CLONE="" usage() { cat <<'EOF' Usage: scripts/check-skill-floor.sh [options] --package-dir DIR Compare against an existing skill directory instead of cloning. In a devbox container use /opt/pi-extensions/skill for a fully offline run. --warn-only Report drift but exit 0 (advisory use, e.g. a local hook). -h, --help This text. Environment: PI_EXTENSIONS_REPO, PI_EXTENSIONS_REF (default main) — both mirror the Dockerfile.variant ARGs of the same name. EOF } while [ $# -gt 0 ]; do case "$1" in --package-dir) PACKAGE_DIR="${2:-}"; shift 2 ;; --warn-only) WARN_ONLY=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "::error::unknown argument: $1" >&2; usage >&2; exit 2 ;; esac done cleanup() { if [ -n "$TMPDIR_CLONE" ]; then rm -rf "$TMPDIR_CLONE"; fi } trap cleanup EXIT # Identical to Dockerfile.variant's tree_sha256(): relative paths + per-file # sha256 over a sorted `find`, folded into one digest. Deterministic, never # readdir order. tree_sha256() { ( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) \ 2>/dev/null | sha256sum | cut -d' ' -f1 } if [ ! -d "$FLOOR_DIR" ]; then echo "::error::floor directory is missing: ${FLOOR_DIR}" echo "::error::rootfs/ is supposed to guarantee the skill is always in the image." exit 2 fi SOURCE_DESC="" if [ -n "$PACKAGE_DIR" ]; then if [ ! -d "$PACKAGE_DIR" ]; then echo "::error::--package-dir does not exist: ${PACKAGE_DIR}" exit 2 fi SOURCE_DESC="local directory ${PACKAGE_DIR}" else command -v git >/dev/null 2>&1 || { echo "::error::git not found; cannot obtain the package copy."; exit 2; } TMPDIR_CLONE=$(mktemp -d) # Fetch the single ref shallowly. `git fetch ` accepts a branch, a tag # and (on Gitea) a reachable commit, which is why this is not `clone --branch` # — CI resolves PI_EXTENSIONS_REF to a 40-hex SHA before the build. if ! ( cd "$TMPDIR_CLONE" \ && git init -q . \ && git remote add origin "$PI_EXTENSIONS_REPO" \ && git fetch -q --depth 1 origin "$PI_EXTENSIONS_REF" \ && git checkout -q FETCH_HEAD ) 2>/dev/null; then echo "::error::could not fetch ${PI_EXTENSIONS_REF} from ${PI_EXTENSIONS_REPO}" echo "::error::Cannot determine whether the floor is stale, so this is exit 2, not a pass." echo "::error::For an offline run, pass --package-dir /opt/pi-extensions/skill" exit 2 fi PACKAGE_SHA=$( cd "$TMPDIR_CLONE" && git rev-parse --short HEAD ) PACKAGE_DIR="${TMPDIR_CLONE}/skill" SOURCE_DESC="${PI_EXTENSIONS_REPO} @ ${PI_EXTENSIONS_REF} (${PACKAGE_SHA})" fi # A ref with no skill/ is the documented fallback case: Dockerfile.variant keeps # the vendored snapshot and the build succeeds. Nothing to compare, so this is # not drift — but it IS the exact condition under which the floor ships, so say # so loudly rather than printing a silent green tick. if [ ! -d "$PACKAGE_DIR" ]; then echo "::warning::package has no skill/ at this ref — the vendored floor is what will ship." echo " source : ${SOURCE_DESC}" echo " floor : $(tree_sha256 "$FLOOR_DIR")" exit 0 fi FLOOR_HASH=$(tree_sha256 "$FLOOR_DIR") PKG_HASH=$(tree_sha256 "$PACKAGE_DIR") if [ "$FLOOR_HASH" = "$PKG_HASH" ]; then echo "OK: vendored pi-extensions floor matches the package." echo " source : ${SOURCE_DESC}" echo " tree_sha256: ${FLOOR_HASH}" exit 0 fi # `set -e` interacts badly with `[ … ] && x` as a bare statement, so both of # these are explicit if-blocks rather than AND-lists. LEVEL="error" if [ "$WARN_ONLY" -eq 1 ]; then LEVEL="warning"; fi echo "::${LEVEL}::vendored pi-extensions skill floor has DRIFTED from the package." echo " source : ${SOURCE_DESC}" echo " floor tree_sha256 : ${FLOOR_HASH}" echo " pkg tree_sha256 : ${PKG_HASH}" echo "" echo " per-file differences:" diff -rq "$FLOOR_DIR" "$PACKAGE_DIR" 2>&1 | sed 's/^/ /' || true echo "" echo " Remedy — re-sync the floor and commit it:" echo " cp -a /skill/. ${FLOOR_DIR}/" echo " git add ${FLOOR_DIR#"${REPO_ROOT}/"} && git commit" echo "" echo " NOTE this forces one full base rebuild: base_tag hashes Dockerfile.base" echo " + rootfs/, and that rebuild is what re-bakes the refreshed floor." if [ "$WARN_ONLY" -eq 1 ]; then exit 0; fi exit 1