feat(ci): gate the vendored pi-extensions skill floor, and bake python3-yaml

Follows ecfd2fc, which refreshed the stale floor by hand. A one-off refresh
fixes the symptom; this makes the drift impossible to reintroduce silently.

scripts/check-skill-floor.sh compares the repo floor
(rootfs/usr/local/share/pi-devbox/skills/pi-extensions/) against the package
repo it is a snapshot of, wired in as a new `skill-floor` job in lint.yml.

DIRECTORY hash, not `sha256sum SKILL.md`, using the same tree_sha256 pipeline
Dockerfile.variant uses for skillset_snapshot_tree_sha256 and for the reason
already documented there: a file-only compare answers "did this one file
change", not "is this the same skill". Verified by NEGATIVE CONTROL rather
than asserted -- with SKILL.md left byte-identical and only
evaluate-extension-usage.py edited, the directory check fails (rc=1) where a
file-only compare would have passed. Seven behaviour tests, each with its
expected rc written down before running: in-sync via local dir (0), in-sync
via anonymous remote clone (0), missing --package-dir (2), bad argument (2),
content drift (1), the sibling-file case (1), and --warn-only over drift (0).

Exit codes 0 in sync / 1 drift / 2 cannot-run, matching scripts/lint-shell.sh:
a gate that cannot run must not pass, so an unreachable package repo is a red
2 and never a green tick. A ref with no skill/ is NOT drift -- that is the
documented fallback -- but it emits ::warning:: because it is precisely the
condition under which the floor ships.

Gating on another repo is normally a smell. It is proportionate here because
the check can only fire when skill/ itself changed, which is exactly when the
floor has gone stale; pi-extensions commits that leave skill/ alone cannot
turn this red. It also needs no secret: pi-extensions is anonymously clonable
(verified with `git ls-remote` and no credentials), so it cannot start failing
when a token expires.

Also bakes python3-yaml (552 KB, zero extra deps) into Dockerfile.base. This
is the shellcheck story repeating exactly: scripts/check-workflow-shell.sh --
the guard against the Gitea sh/dash footgun that broke resolve-versions
(ed49b8d) and promote-base-latest (b7197e8) -- hard-exits with "python3 yaml
module missing", so a gate this repo already owns could not be run locally by
anyone. lint.yml installing it explicitly in CI was the evidence. Found while
wiring the job above: the guard could not be run before pushing.

CHANGELOG Unreleased updated for both this and ecfd2fc, including an explicit
note on what is NOT fixed -- the silent-fallback half still has no manifest
flag recording which copy was served.

Verified locally with every gate this repo owns, all green: lint-shell.sh (15
files clean), check-workflow-shell.sh, check-base-hash.sh, actionlint 1.7.7
(pinned, same version as CI), hadolint 2.14.0, and the new check itself.
This commit is contained in:
Joakim Persson
2026-09-10 19:14:01 +02:00
parent ecfd2fc2e5
commit cac5e00a31
4 changed files with 275 additions and 0 deletions
+35
View File
@@ -137,3 +137,38 @@ jobs:
- name: Run hadolint - name: Run hadolint
run: hadolint Dockerfile.base Dockerfile.variant run: hadolint Dockerfile.base Dockerfile.variant
skill-floor:
# Gate the VENDORED pi-extensions skill snapshot in rootfs/ against the
# package repo it is a snapshot of. Its own job rather than a step in
# `actionlint`, so "the floor is stale" is a distinct red name in the runs
# list instead of being buried in a lint job that is about something else.
#
# The gap it closes, measured 2026-09-10: the floor sat at 34284 B, untouched
# since fa04d20 (2026-07-30), while the package copy was 38973 B.
# Dockerfile.variant copies the fresh package copy over the SERVED path but
# never writes back to the floor, so nothing in the repo ever noticed. That
# matters because the floor is a FALLBACK: the copy is guarded by
# `if [ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build whose clone
# yields no skill/ ships the vendored snapshot and still goes green, with no
# manifest flag or label saying which copy was served.
#
# Gating on another repo is normally a smell; it is proportionate here
# because the check compares the skill DIRECTORY hash, so it can only fire
# when that directory actually changed — which is exactly when the floor has
# gone stale. pi-extensions commits that leave skill/ alone cannot turn this
# red. No secret is needed either: the repo is anonymously clonable (verified
# 2026-09-10 with `git ls-remote` and no credentials), so this cannot start
# failing when a token expires.
#
# Exit codes are 0 in sync / 1 drift / 2 cannot-run, matching
# scripts/lint-shell.sh: a gate that cannot run must not pass, so an
# unreachable package repo is a red 2 rather than a green tick.
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Vendored pi-extensions skill floor matches the package
run: bash scripts/check-skill-floor.sh
+60
View File
@@ -92,6 +92,66 @@ derivation's `mine` query at the newest end (`order: "desc"`); with the previous
default `asc` + `limit: 100`, a device passing 100 authored events would have its default `asc` + `limit: 100`, a device passing 100 authored events would have its
recent replies fall out of the join window and see answered asks resurface. recent replies fall out of the join window and see answered asks resurface.
**Four small packages, each chosen from a gap that was measured rather than
imagined.** All four were picked by looking back at a real session — the
`gitea.egl.lan`/FreeIPA debugging of 2026-09-09..10 — and asking which absences
actually cost time, not which tools sound useful. `bind9-dnsutils` (~6.1 MB, 10
packages): `dig`, `host` **and** `nslookup` were all absent, so the container
could resolve names but had no way to interrogate a *specific* nameserver —
`getent hosts` only follows the resolver's default path, so diagnosing "gateway
`172.16.88.1` NXDOMAINs the `egl.lan` zone while `10.20.253.1` is authoritative
for it" had to be hand-rolled in `python3`. Note the package name: plain
`dnsutils` is transitional in trixie. `ldap-utils` (1244 KB, **zero** extra deps):
the fleet authenticates against FreeIPA, yet every LDAP probe had to be run by
SSHing to an already-enrolled host; this gives simple binds only, since GSSAPI
would additionally need `krb5-user` + `libsasl2-modules-gssapi-mit`, which is a
Kerberos-client decision rather than a tool. `xxd` (198 KB) is frank convenience
— `od -c` already does the job. `python3-yaml` (552 KB, zero extra deps) is the
shellcheck story repeating exactly: `scripts/check-workflow-shell.sh`, the guard
against the Gitea `sh`/dash footgun that broke `resolve-versions` (`ed49b8d`) and
`promote-base-latest` (`b7197e8`), hard-exits with "python3 yaml module missing"
without it — and `lint.yml` installing it explicitly in CI was the evidence the
image lacked it. **`netcat-openbsd` was proposed and deliberately rejected**:
measured redundant, because `socat` is already baked and bash's `/dev/tcp` does
reachability checks with zero packages. The reason is recorded in
`Dockerfile.base` so the omission reads as a decision rather than an oversight.
**The vendored `pi-extensions` skill floor was 41 days stale, and is now gated so
it cannot silently rot again.** `rootfs/usr/local/share/pi-devbox/skills/pi-extensions/`
sat at 34284 B, untouched since `fa04d20` (2026-07-30), while the package copy
was 38973 B — four copies of one skill existed across the fleet with three
different sizes. `Dockerfile.variant` copies the freshly-cloned package copy over
the **served** path but never writes back to the repo floor, so nothing in the
repo ever noticed. That is worse than ordinary staleness because the floor is a
**fallback**: the copy is guarded by `if [ -f /opt/pi-extensions/skill/SKILL.md ]`,
so a build whose clone yields no `skill/` keeps the vendored snapshot and still
goes **green**, with no manifest flag and no label recording which copy was
served — the image would ship a July skill and nothing would say so. The floor is
refreshed here from `pi-extensions@c64c122`, and the new `skill-floor` job in
`lint.yml` runs `scripts/check-skill-floor.sh` to keep it that way.
The check compares the **directory** hash, using the same `tree_sha256` pipeline
`Dockerfile.variant` uses for `skillset_snapshot_tree_sha256` and for the same
documented reason: a `sha256sum SKILL.md` answers "did this one file change", not
"is this the same skill", and `pi-extensions` ships two files. That is not
hypothetical — it was **verified by negative control**: with `SKILL.md` left
byte-identical and only `evaluate-extension-usage.py` edited, the directory check
correctly fails while a file-only compare would have passed. Exit codes are `0`
in sync / `1` drift / `2` cannot-run, matching `scripts/lint-shell.sh`, so an
unreachable package repo is a red `2` rather than a green tick. Gating on another
repo is normally a smell; it is proportionate here because the check can only
fire when `skill/` itself changed — which is exactly when the floor has gone
stale — and it needs no secret, since `pi-extensions` is anonymously clonable
(verified with `git ls-remote` and no credentials).
> **What this does *not* fix, stated so nobody reads more into it than is there.**
> The floor is now fresh and guarded, but the *silent-fallback* half remains:
> if the build-time copy is ever absent, the build still succeeds with no
> manifest flag or OCI label recording that the vendored snapshot was served
> instead of the package copy. The durable fix for that is a manifest field
> alongside the existing `skillset_snapshot_tree_sha256`, which this change does
> not add.
--- ---
## v1.8.14 — 2026-09-08 ## v1.8.14 — 2026-09-08
+14
View File
@@ -162,6 +162,19 @@ ENV DEBIAN_FRONTEND=noninteractive
# /dev/tcp does reachability checks with zero packages # /dev/tcp does reachability checks with zero packages
# (verified against gitea.egl.lan:3000). Recorded here so the # (verified against gitea.egl.lan:3000). Recorded here so the
# omission reads as a decision rather than an oversight. # omission reads as a decision rather than an oversight.
# python3-yaml — PyYAML. Added 2026-09-10 for precisely the same reason as
# shellcheck above: a gate this repo ALREADY OWNS could not be
# run locally by anyone. scripts/check-workflow-shell.sh — the
# guard that catches the "bash-only syntax under Gitea's default
# sh/dash shell" footgun that broke resolve-versions (ed49b8d)
# and promote-base-latest (b7197e8) — hard-exits with "ERROR:
# python3 yaml module missing" without it. lint.yml installs it
# explicitly in CI (`shellcheck python3-yaml`), which is itself
# the evidence that the image lacked it. Measured 2026-09-10
# while wiring the skill-floor job: the guard could not be run
# before pushing — the same write → push → wait-for-CI loop that
# shellcheck was baked to shorten. 552 KB, and pulls ZERO extra
# packages under --no-install-recommends.
RUN apt-get update && \ RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \ apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
@@ -206,6 +219,7 @@ RUN apt-get update && \
bind9-dnsutils \ bind9-dnsutils \
ldap-utils \ ldap-utils \
xxd \ xxd \
python3-yaml \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \ && ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+166
View File
@@ -0,0 +1,166 @@
#!/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 <ref>` 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 <pi-extensions>/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