Files
pi-devbox/rootfs/usr/local/bin/pi-devbox-version
T
Joakim Persson ff6fd1492a feat(manifest): record WHICH pi-extensions skill copy shipped
Closes the half deliberately left open by cac5e00's skill-floor gate, and the
more important half: "the floor is currently fresh" is a fact with a shelf
life, whereas "the image says which copy it got" keeps working.

The refresh in Dockerfile.variant is guarded by
`[ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build whose clone predates the
co-located skill keeps the vendored floor and still succeeds GREEN, with nothing
in the manifest, labels or logs separating that from a normal build. Afterwards
the two are indistinguishable by inspection -- same path, same filenames, same
permissions -- which is exactly how the floor went unnoticed from 2026-07-30 to
2026-09-10.

build-manifest.json gains pi_extensions_skill_source and
pi_extensions_skill_tree_sha256, MEASURED rather than passed as build-args, per
the ground-truth rule the surrounding block already follows -- and necessarily
so, since the outcome depends on the clone's contents and no ARG could express
it. Three values, because two would force a lie: package (served bytes equal
the clone's skill/), vendored-floor (clone had no skill/ at this ref), and
divergent (both exist but differ -- e.g. the clone ships SKILL.md but not
evaluate-extension-usage.py, so the served directory is a genuine MIX). No OCI
label mirrors these deliberately: LABEL cannot take a RUN-computed value, and a
label fed from an ARG would be the claim-not-measurement being removed here.

Two smoke assertions make the record a gate: the source must be named and be
`package` -- vendored-floor FAILS rather than warns, since these images track
main where the package has shipped skill/ since fa04d20, so a fallback means
the clone did not resolve as intended -- and the tree hash is recomputed over
the served directory, because a recorded hash never recompared is a claim.

pi-devbox-version annotates the line too: "baked (package copy)" normally, or a
yellow "(FALLBACK: vendored floor)". Its existing section reports which copy is
READ at runtime; this is the one fact decided at BUILD time and unrecoverable
later. Old images degrade cleanly -- field absent, jq // empty yields nothing,
line prints plain "baked" as before (verified against this v1.8.14 manifest).

Tested by running the exact logic against this container's real layout, with
the expected value written down before each: package (served == clone),
vendored-floor (clone path absent), divergent (clone lacking the .py while the
served dir has it), and null (empty served dir) -- all four as predicted. The
five pi-devbox-version render branches likewise, including the absent-field
case. Emitted JSON validated with jq for both the populated and null forms.

Gates green: lint-shell.sh (15 files), hadolint 2.15.1, actionlint 1.7.12,
check-base-hash.sh, check-skill-floor.sh, vendor-mempalace-skill.sh --check.
2026-09-10 20:30:16 +02:00

261 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# pi-devbox-version — show which pi-devbox image build is running.
#
# WHY THIS EXISTS
# The image bakes ground-truth build info into /etc/pi-devbox/build-manifest.json
# at `docker build` time (see Dockerfile.variant): the release tag, build date,
# source commit, live `pi --version` at build time, and the actual checked-out
# commit of every /opt component clone. That answers "what image am I running?"
# — but only if you know to go look for the file. This wraps it into one
# command, prints it human-first at container start (see entrypoint-user.sh),
# and stays available on demand for the rest of the session.
#
# USAGE
# pi-devbox-version human-readable summary (default)
# pi-devbox-version --json raw manifest JSON (for scripting)
# pi-devbox-version --quiet one-line "release_tag (source_revision)" form
# pi-devbox-version --no-skills skip the skill-source section (used at
# container start, where it would be premature)
#
# EXIT STATUS
# 0 on success. 1 if the manifest is missing (e.g. an image built before
# this file existed, or a non-pi-devbox base) — prints a short notice
# to stderr rather than failing silently.
set -euo pipefail
MANIFEST=/etc/pi-devbox/build-manifest.json
MODE="human"
SHOW_SKILLS="yes"
# A `case "${1:-}"` here only ever looked at the FIRST argument, so
# `--no-skills --json` matched --no-skills, silently dropped --json, and
# printed human text to a caller expecting JSON (a real failure: a jq
# consumer piping that output gets a parse error, not a wrong-but-parseable
# answer). Loop over every argument instead, and reject anything unknown
# rather than silently ignoring it the same way.
for _arg in "$@"; do
case "$_arg" in
--json) MODE="json" ;;
--quiet|-q) MODE="quiet" ;;
--no-skills) SHOW_SKILLS="no" ;;
--help|-h)
# Print the leading `#`-comment block verbatim, stopping at the first
# non-comment line, rather than a hardcoded line range: `sed -n
# '2,22p'` was silently truncating --help because this file has grown
# usage lines since that range was written, and a fixed range will
# drift again the next time a comment is added above it.
awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"
exit 0
;;
*)
echo "pi-devbox-version: unknown option: $_arg" >&2
echo " try --help" >&2
exit 2
;;
esac
done
if [ ! -f "$MANIFEST" ]; then
echo "pi-devbox-version: no build manifest at $MANIFEST" >&2
echo " (image predates the manifest, or this isn't a pi-devbox image)" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "pi-devbox-version: jq not found; dumping raw manifest instead" >&2
cat "$MANIFEST"
exit 0
fi
if [ "$MODE" = "json" ]; then
cat "$MANIFEST"
exit 0
fi
release_tag=$(jq -r '.release_tag' "$MANIFEST")
build_date=$(jq -r '.build_date' "$MANIFEST")
source_rev=$(jq -r '.source_revision' "$MANIFEST")
pi_version_baked=$(jq -r '.pi_version' "$MANIFEST")
# `// empty` matters: images built before v1.8.6 have no such field, and
# `jq -r` renders a JSON null as the 4-char string "null" — which would
# print as a bogus version rather than being treated as absent.
mp_version_baked=$(jq -r '.mempalace_version // empty' "$MANIFEST")
if [ "$MODE" = "quiet" ]; then
printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}"
exit 0
fi
# Live drift check: has `pi` been upgraded since this container was built?
# (image is immutable, but a volume-persisted ~/.pi could in theory shadow
# the baked binary — this stays honest rather than trusting the manifest
# blindly, same "ground truth over intent" spirit as how the manifest
# itself is generated in Dockerfile.variant.)
pi_version_live=""
if command -v pi >/dev/null 2>&1; then
pi_version_live=$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')
fi
# Same check for the palace, which matters more than it looks: mempalace is
# the one component that is BOTH client (here) and server (synlig runs this
# same image), so a skew between the two is a real failure mode rather than
# cosmetic. `mempalace --version` prints "MemPalace 3.8.0" — name-prefixed,
# unlike pi's bare "0.84.3" — hence $NF rather than reading the whole line.
mp_version_live=""
if command -v mempalace >/dev/null 2>&1; then
mp_version_live=$(mempalace --version 2>/dev/null | head -n1 | awk '{print $NF}' | tr -d '\r\n')
fi
printf 'pi-devbox %s\n' "$release_tag"
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
if [ -n "$pi_version_live" ] && [ "$pi_version_live" != "$pi_version_baked" ]; then
printf ' pi: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$pi_version_live" "$pi_version_baked"
else
printf ' pi: %s\n' "${pi_version_live:-$pi_version_baked}"
fi
# Printed only when known, so this degrades quietly on pre-v1.8.6 images
# instead of showing an empty or "null" palace line.
if [ -n "$mp_version_live" ] || [ -n "$mp_version_baked" ]; then
if [ -n "$mp_version_live" ] && [ -n "$mp_version_baked" ] && [ "$mp_version_live" != "$mp_version_baked" ]; then
printf ' palace: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$mp_version_live" "$mp_version_baked"
else
printf ' palace: %s\n' "${mp_version_live:-$mp_version_baked}"
fi
fi
printf ' components:\n'
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
# ── Which copy of each vendored skill is actually being read? ─────────
# The image bakes fallback skills under /usr/local/share/pi-devbox/skills/,
# but for skills the skillset repo OWNS (skillset-owned.txt) a mounted live
# clone takes over at container start via devbox-skill-reconcile. Nothing
# reported which copy won, so a stale baked snapshot and a current live clone
# looked identical from inside — and on this fleet the baked mempalace copy is
# read by NOBODY (all four compose stacks mount a workspace containing the
# skillset), which is exactly the sort of fact that should be visible rather
# than reasoned about. Same "drift detected" shape as the pi/palace lines
# above: what is live, annotated with what was baked, when they disagree.
#
# Skipped with --no-skills at container start (entrypoint-user.sh calls this
# FIRST, before the baked links exist and long before the skillset deploy and
# reconcile run last), because a section that is accurate only after boot
# finishes is worse than no section at all.
BAKED_SKILLS=/usr/local/share/pi-devbox/skills
SKILLS_DIR="${HOME:-/home/developer}/.agents/skills"
if [ "$SHOW_SKILLS" = "yes" ] && [ -d "$BAKED_SKILLS" ] && [ -d "$SKILLS_DIR" ]; then
# Recorded provenance of the vendored mempalace snapshot (absent on images
# built before this existed — `// empty` so a JSON null never prints as the
# 4-char string "null", the same trap noted for mempalace_version above).
# `_tree_sha256`, not `_sha256`: it is a hash over every file in the
# vendored skill DIRECTORY (see tree_sha256() below), not one file, because
# a single-file hash reports "identical" against a live checkout that added
# or edited a sibling file — pi-extensions already ships two files, so this
# is not hypothetical.
snap_ref=$(jq -r '.skillset_snapshot_ref // empty' "$MANIFEST")
snap_sha=$(jq -r '.skillset_snapshot_tree_sha256 // empty' "$MANIFEST")
# Which pi-extensions copy the BUILD baked. Distinct from everything else in
# this section, which reports which copy is being READ at runtime: for
# pi-extensions the baked tree is itself one of two possible copies, and that
# choice was made at build time and is not recoverable by inspection.
px_src=$(jq -r '.pi_extensions_skill_source // empty' "$MANIFEST")
# Same pipeline Dockerfile.variant uses to measure the baked directory at
# build time: relative paths in `find | sort` order, each hashed, the whole
# listing folded into one sha256. Keep the two definitions identical — they
# run in different processes (image build vs. this container) and are
# meaningless to compare unless they agree byte-for-byte on the algorithm.
tree_sha256() {
( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) 2>/dev/null | sha256sum | cut -d' ' -f1
}
# Iterate the baked tree rather than a hardcoded name list, so vendoring a
# fourth skill needs no edit here. The header prints only if the tree is
# non-empty, so this can never emit a dangling "skills:" label.
_printed_header="no"
for _dir in "$BAKED_SKILLS"/*/; do
[ -d "$_dir" ] || continue
if [ "$_printed_header" = "no" ]; then
printf ' skills:\n'
_printed_header="yes"
fi
_name=$(basename "$_dir")
_link="$SKILLS_DIR/$_name"
if [ ! -e "$_link" ]; then
printf ' %-22s not linked\n' "$_name"
continue
fi
_target=$(readlink -f "$_link" 2>/dev/null || echo "$_link")
case "$_target" in
"$BAKED_SKILLS"/*|"$BAKED_SKILLS")
# "baked" alone used to be the whole story. For pi-extensions it is not:
# the baked tree holds EITHER the package copy that Dockerfile.variant
# lays over the snapshot, OR the vendored floor, when the clone had no
# skill/ at that ref. The two are indistinguishable by inspection — same
# path, same filenames, same permissions — so the build records which one
# it used and this reports it. Without this line a six-week-stale
# fallback skill looks exactly like a current one, which is precisely how
# the floor went unnoticed from 2026-07-30 to 2026-09-10.
if [ "$_name" = "pi-extensions" ] && [ -n "$px_src" ]; then
case "$px_src" in
package)
printf ' %-22s baked (package copy)\n' "$_name" ;;
vendored-floor)
printf ' %-22s baked \033[33m(FALLBACK: vendored floor — clone had no skill/)\033[0m\n' "$_name" ;;
divergent)
printf ' %-22s baked \033[33m(MIXED: part package, part floor)\033[0m\n' "$_name" ;;
*)
printf ' %-22s baked\n' "$_name" ;;
esac
else
printf ' %-22s baked\n' "$_name"
fi
continue
;;
esac
# Outside the baked tree: a mounted skillset clone, or a user override.
# The link target is <repo>/skills/<name>, so the repo root is two up.
# Everything here is guarded: this script runs on the container-start path
# and must never fail, and `set -e` is in force.
_root=$(cd "$_target/../.." 2>/dev/null && pwd) || _root=""
_head=""
if [ -n "$_root" ]; then
_head=$(git -C "$_root" rev-parse HEAD 2>/dev/null || echo "")
fi
_where="live ${_root:-$_target}"
[ -n "$_head" ] && _where="$_where @ ${_head:0:7}"
# For the one skill whose baked fingerprint we recorded, say plainly
# whether the live copy differs from what shipped. This is the check CI
# cannot perform (the skillset is private) and the container can, free.
# Hash the whole live DIRECTORY with the same tree_sha256() used to
# measure the baked one in Dockerfile.variant — a SKILL.md-only compare
# would silently ignore a changed or added sibling file.
_live_sha=""
if [ -n "$snap_sha" ] && [ "$_name" = "mempalace" ] && [ -d "$_target" ]; then
_live_sha=$(tree_sha256 "$_target")
fi
if [ -z "$_live_sha" ]; then
printf ' %-22s %s\n' "$_name" "$_where"
elif [ "$_live_sha" = "$snap_sha" ]; then
printf ' %-22s %s (identical to baked snapshot)\n' "$_name" "$_where"
elif [ -n "$_head" ] && [ "$_head" = "$snap_ref" ]; then
# Same commit, different bytes — i.e. uncommitted edits in the live
# checkout. Distinguished from plain drift because otherwise the line
# reads as a self-contradiction ("@ c04cd15 ... baked snapshot c04cd15
# — live copy differs") and a reader would suspect the tool, not the
# working tree.
printf ' %-22s %s \033[33m(baked snapshot %s + uncommitted edits)\033[0m\n' \
"$_name" "$_where" "${snap_ref:0:7}"
else
printf ' %-22s %s \033[33m(baked snapshot %s — live copy differs)\033[0m\n' \
"$_name" "$_where" "${snap_ref:0:7}"
fi
done
fi