43cd6e22f2
Publish Docker Image / resolve-versions (push) Successful in 9s
Lint / actionlint (push) Successful in 15s
Lint / hadolint (push) Successful in 13s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / build-base (push) Successful in 41m22s
Publish Docker Image / smoke-studio (push) Successful in 5m16s
Publish Docker Image / smoke (push) Successful in 7m31s
Publish Docker Image / build-variant-studio (push) Successful in 18m31s
Publish Docker Image / build-variant (push) Successful in 27m18s
Publish Docker Image / promote-base-latest (push) Successful in 11s
Publish Docker Image / update-description (push) Successful in 12s
Two changes that belong together, because the first is what makes the second dangerous to get wrong. pi-atelier (TUI sidebar + status rail) is now vendored to /opt/pi-atelier at PI_ATELIER_REF=v0.8.0 and registered by entrypoint-user.sh — the pi-fork / pi-observational-memory / pi-studio pattern, deliberately NOT `pi install npm:pi-atelier`, which writes into ~/.pi/npm-global on the config volume where it shadows the image and pins nothing. Unlike its siblings it gets no `npm install`: atelier declares zero runtime deps (peerDeps only, satisfied by the baked pi) and has no build step, so pi loads its TypeScript straight from the checkout via package.json `pi.extensions`. pi is no longer resolved to npm `latest` at build time. The pin lives in Dockerfile.variant and CI reads it from there, so a local `docker build` and a CI release ship the same versions by construction. The pin is a CHECKPOINT, NOT A FREEZE: bumping stays a one-line change; what stops is *unreviewed* adoption of whatever shipped that morning, in the same build that then gets tagged and published. CI fails when a pin is not concrete or not actually published on npm, and warns — never adopts — when npm latest moves ahead, naming what to re-check. Why this pairing needed care: pi-atelier 0.6.0/0.7.0 wrap pi's PRIVATE TUI renderer, and under pi 0.84 that wrapper recurses — pi hangs at startup burning CPU with no error. Upstream fixed the recursion in 0.7.1 and restored the non-overlapping split in 0.7.2; 0.8.0 is additive on top. atelier's own peerDependencies still say >=0.80.7, which does not express that floor, so nothing in npm metadata could have warned us. The floor is therefore encoded as an executable rule — pi >= 0.84 => pi-atelier >= 0.7.1 — asserted in both smoke-test.sh (build time) and recreate-sanity-check.sh (after a real recreate), verified against a 4x4 version matrix. Existing volumes needed migration, not just vendoring: a hand-installed `npm:pi-atelier` entry is counted as already-registered by the entrypoint guard, so every existing volume would have kept its unpinned npm copy — and a 0.6.x copy next to pi 0.84 is exactly the startup hang. The entrypoint now drops that one exact string (settings.json.bak.atelier.<ts> backup, distinct prefix so it cannot clobber the template merge's backup in the same second) and lets the pinned /opt copy register. Tested against a real settings.json: only that entry removed, other packages and all keys intact, idempotent, and unparseable JSON leaves the file untouched. DEVBOX_ATELIER=0 opts out entirely — in the entrypoint rather than via `pi uninstall`, because this component's failure mode is "pi will not start", which cannot be repaired from inside pi. 0.84.1 was audited for this release, not merely adopted: theme/TUI changes are additive, the session format is unchanged (CURRENT_SESSION_VERSION = 3 in both 0.83.0 and 0.84.1 with an identical migrateV1ToV2/migrateV2ToV3 ladder, so existing transcripts are neither migrated nor at risk and pi-session-repair stays valid), and the Node engine floor is unmoved at >=22.19.0. CI resolves the atelier tag to its PEELED commit SHA — atelier uses annotated tags, so the unpeeled ref is a tag object, not a commit; pi-studio's lightweight tags never exposed that distinction. Also: docs for overriding the read-only ~/.ssh/config from the container — container-only keys in ~/.ssh-local, hardened authorized_keys, the fact that `from=` must allow the HOST's addresses because container egress is NAT'd through it, and the macOS-only-keyword trap (`UseKeychain` is fatal to Linux OpenSSH and takes out dssh/pi --ssh while the host keeps working). Corrects two claims in "Naming LAN peers": ssh-lan.conf is not ProxyJump-only, and first-time creation does need one restart because the Include is emitted only when the file already exists at start.
372 lines
13 KiB
Bash
Executable File
372 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Runtime post-recreate verification for pi-devbox.
|
|
#
|
|
# Verifies that after `docker compose up -d --force-recreate`:
|
|
# - The new image is actually live (pi version matches, when an expected
|
|
# version is supplied — see the version note below)
|
|
# - Persisted named volumes survived (~/.pi config, shell history, zoxide,
|
|
# nvim data, uv cache, ssh-local)
|
|
# - pi runtime wiring is intact: keybindings symlink, AGENTS.md symlink,
|
|
# ≥4 extensions, the mempalace.ts bridge, settings.json, and the pi-fork /
|
|
# pi-observational-memory / (studio variant) pi-studio package
|
|
# registrations in settings.json packages[]
|
|
# - Shell defaults re-seeded from /etc/skel-devbox
|
|
# - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir)
|
|
# - /opt toolkits intact
|
|
# - Known expected-absences don't regress
|
|
#
|
|
# This is repo/maintainer tooling — the runtime peer of smoke-test.sh.
|
|
# smoke-test.sh runs at BUILD time with `--entrypoint=""`, so it can never see
|
|
# a recreated container's persisted volumes or the entrypoint's runtime
|
|
# deploy. This script is its runtime counterpart: it inspects what is actually
|
|
# live in the container you are sitting in after a recreate.
|
|
#
|
|
# It is NOT baked into the published Docker Hub image; run it from a checkout of
|
|
# the pi-devbox repo (which a maintainer already has for CI builds). A plain
|
|
# `docker pull` consumer is not the audience and will not have this file.
|
|
#
|
|
# Version note: pi's version is resolved from `latest` at CI build time and is
|
|
# NOT pinned to a concrete value in Dockerfile.variant (ARG PI_VERSION=latest).
|
|
# So unlike opencode-devbox, this script cannot self-derive an expected version
|
|
# from the Dockerfile. Pass --expected-version to assert a match; without it the
|
|
# live pi version is reported as an informational WARN, not a failure.
|
|
#
|
|
# Usage: ./scripts/recreate-sanity-check.sh [--expected-version X.Y.Z] [--variant studio|plain]
|
|
#
|
|
# Exit codes:
|
|
# 0 all checks passed
|
|
# 1 one or more checks failed
|
|
# 2 usage error
|
|
|
|
set -euo pipefail
|
|
|
|
EXPECTED_VERSION=""
|
|
VARIANT=""
|
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
# Parse arguments
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--expected-version)
|
|
EXPECTED_VERSION="$2"
|
|
shift 2
|
|
;;
|
|
--variant)
|
|
VARIANT="$2"
|
|
shift 2
|
|
;;
|
|
*)
|
|
echo "usage: $0 [--expected-version X.Y.Z] [--variant studio|plain]" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
FAILED=0
|
|
pass() { echo " ✓ $1"; }
|
|
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
|
warn() { echo " ⚠ $1" >&2; }
|
|
|
|
# Auto-detect variant if not provided. The studio variant vendors pi-studio to
|
|
# /opt/pi-studio; the plain variant does not.
|
|
if [ -z "$VARIANT" ]; then
|
|
if [ -d /opt/pi-studio ]; then
|
|
VARIANT="studio"
|
|
else
|
|
VARIANT="plain"
|
|
fi
|
|
fi
|
|
|
|
# Print header with git context
|
|
echo "=== Recreate sanity check (variant: $VARIANT) ==="
|
|
if GIT_TAG=$(git -C "$REPO_DIR" describe --tags 2>/dev/null); then
|
|
echo " Repo HEAD: $GIT_TAG (version-match only meaningful when image tag matches)"
|
|
else
|
|
echo " Repo HEAD: (not a git repo or no tags)"
|
|
fi
|
|
echo
|
|
|
|
echo "-- pi version --"
|
|
if ACTUAL_VERSION=$(pi --version 2>&1 | head -1); then
|
|
if [ -n "$EXPECTED_VERSION" ]; then
|
|
if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then
|
|
pass "pi version $ACTUAL_VERSION"
|
|
else
|
|
fail "pi version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
|
|
fi
|
|
else
|
|
warn "pi version $ACTUAL_VERSION (no --expected-version given; pi is built from 'latest', cannot self-derive — informational only)"
|
|
fi
|
|
else
|
|
fail "pi --version failed"
|
|
fi
|
|
|
|
echo
|
|
echo "-- Persisted named volumes (must survive --force-recreate) --"
|
|
|
|
# ~/.pi config volume (devbox-pi-config) — holds agent settings, extensions,
|
|
# keybindings symlink. Must exist and be non-empty after recreate.
|
|
if [ -d "$HOME/.pi/agent" ] && [ -n "$(ls -A "$HOME/.pi/agent" 2>/dev/null)" ]; then
|
|
pass "~/.pi/agent exists and is non-empty"
|
|
else
|
|
fail "~/.pi/agent missing or empty"
|
|
fi
|
|
|
|
# shell history volume (devbox-shell-history). An empty .bash_history right
|
|
# after recreate is NORMAL — only the mount point must exist.
|
|
if [ -d "$HOME/.cache/bash" ]; then
|
|
pass "~/.cache/bash exists as directory"
|
|
else
|
|
fail "~/.cache/bash missing or not a directory"
|
|
fi
|
|
|
|
# remaining persisted volumes — mount points must exist
|
|
for vol_path in \
|
|
"$HOME/.local/share/zoxide" \
|
|
"$HOME/.local/share/nvim" \
|
|
"$HOME/.local/share/uv" \
|
|
"$HOME/.ssh-local"; do
|
|
if [ -d "$vol_path" ]; then
|
|
pass "$vol_path exists"
|
|
else
|
|
fail "$vol_path missing or not a directory"
|
|
fi
|
|
done
|
|
|
|
# mempalace palace — CONDITIONAL. In this repo's docker-compose.yml the
|
|
# devbox-palace named volume is commented out; the palace is reached via the
|
|
# shared /workspace (virtiofs) path instead. So absence of a local palace dir
|
|
# is NOT a recreate regression here.
|
|
if [ -f "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
|
|
SIZE=$(du -h "$HOME/.mempalace/palace/chroma.sqlite3" | cut -f1)
|
|
if [ -s "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
|
|
pass "~/.mempalace/palace/chroma.sqlite3 exists ($SIZE)"
|
|
else
|
|
fail "~/.mempalace/palace/chroma.sqlite3 exists but is empty"
|
|
fi
|
|
else
|
|
warn "~/.mempalace/palace/chroma.sqlite3 absent — expected unless devbox-palace volume is enabled (palace is shared via /workspace by default)"
|
|
fi
|
|
|
|
echo
|
|
echo "-- pi runtime wiring (deployed by entrypoint-user.sh) --"
|
|
|
|
# keybindings symlink (pi-toolkit)
|
|
if [ -L "$HOME/.pi/agent/keybindings.json" ]; then
|
|
pass "~/.pi/agent/keybindings.json symlink (pi-toolkit)"
|
|
else
|
|
fail "~/.pi/agent/keybindings.json missing or not a symlink"
|
|
fi
|
|
|
|
# global AGENTS.md symlink (pi-toolkit) — global instructions loaded by pi at
|
|
# every start (directs the agent to read the pi-extensions skill at session start)
|
|
if [ -L "$HOME/.pi/agent/AGENTS.md" ]; then
|
|
pass "~/.pi/agent/AGENTS.md symlink (pi-toolkit)"
|
|
else
|
|
fail "~/.pi/agent/AGENTS.md missing or not a symlink"
|
|
fi
|
|
|
|
# extensions deployed (pi-extensions) — expect ≥4 *.ts
|
|
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
|
|
if [ "$EXT_COUNT" -ge 4 ]; then
|
|
pass "$EXT_COUNT extensions deployed (≥4, pi-extensions)"
|
|
else
|
|
fail "only $EXT_COUNT extensions deployed (expected ≥4)"
|
|
fi
|
|
|
|
# mempalace.ts bridge symlink
|
|
if [ -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
|
pass "~/.pi/agent/extensions/mempalace.ts bridge symlink"
|
|
else
|
|
fail "~/.pi/agent/extensions/mempalace.ts missing or not a symlink"
|
|
fi
|
|
|
|
# settings.json bootstrapped
|
|
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
|
pass "~/.pi/agent/settings.json bootstrapped"
|
|
else
|
|
fail "~/.pi/agent/settings.json missing"
|
|
fi
|
|
|
|
# settings.json merge: the entrypoint deep-merges new template keys into a
|
|
# preserved settings.json on every start, so config added in an image upgrade
|
|
# (e.g. the observational-memory / pi-fork blocks) reaches existing volumes.
|
|
# Assert those blocks are present and that the file is still valid JSON.
|
|
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.pi/agent/settings.json" ]; then
|
|
if jq -e 'has("observational-memory") and has("pi-fork")' "$HOME/.pi/agent/settings.json" >/dev/null 2>&1; then
|
|
pass "settings.json has observational-memory + pi-fork blocks (template merge)"
|
|
else
|
|
fail "settings.json missing observational-memory and/or pi-fork blocks (template merge did not land)"
|
|
fi
|
|
fi
|
|
|
|
# pi package registrations (pi install <local-path> → recorded in settings.json).
|
|
# Check the `packages` ARRAY, not the whole file: the settings template ships a
|
|
# top-level "pi-fork" CONFIG block (asserted just above), so `grep -q pi-fork
|
|
# settings.json` is a guaranteed false green — which is how an un-registered
|
|
# fork tool went unnoticed from v1.0.0 through v1.6.3. Same array check the
|
|
# fixed entrypoint-user.sh guard uses.
|
|
_pkg_registered() {
|
|
_s="$HOME/.pi/agent/settings.json"
|
|
[ -f "$_s" ] || return 1
|
|
if command -v jq >/dev/null 2>&1; then
|
|
jq -e --arg n "$1" \
|
|
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
|
|
"$_s" >/dev/null 2>&1
|
|
else
|
|
grep -q "opt/$1\"" "$_s"
|
|
fi
|
|
}
|
|
|
|
# True when a literal `npm:pi-atelier` entry is still present — the
|
|
# volume-resident registration the entrypoint migrates away from.
|
|
_npm_atelier_present() {
|
|
_s="$HOME/.pi/agent/settings.json"
|
|
[ -f "$_s" ] || return 1
|
|
command -v jq >/dev/null 2>&1 || return 1
|
|
jq -e '(.packages // []) | any(. == "npm:pi-atelier")' "$_s" >/dev/null 2>&1
|
|
}
|
|
|
|
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
|
for pkg in pi-fork pi-observational-memory; do
|
|
if _pkg_registered "$pkg"; then
|
|
pass "$pkg registered in settings.json packages[]"
|
|
else
|
|
fail "$pkg NOT in settings.json packages[] (tool will not load)"
|
|
fi
|
|
done
|
|
|
|
if [ "$VARIANT" = "studio" ]; then
|
|
if _pkg_registered pi-studio; then
|
|
pass "pi-studio registered in settings.json packages[]"
|
|
else
|
|
fail "pi-studio NOT in settings.json packages[] (studio variant)"
|
|
fi
|
|
fi
|
|
|
|
# pi-atelier — vendored from v1.7.0 on. Absent on older images, and
|
|
# deliberately unregistered when DEVBOX_ATELIER=0; neither is a failure.
|
|
if [ -d /opt/pi-atelier ]; then
|
|
if [ "${DEVBOX_ATELIER:-1}" = "0" ]; then
|
|
if _pkg_registered pi-atelier; then
|
|
fail "pi-atelier still in packages[] despite DEVBOX_ATELIER=0"
|
|
else
|
|
pass "pi-atelier unregistered (DEVBOX_ATELIER=0, as requested)"
|
|
fi
|
|
elif _pkg_registered pi-atelier; then
|
|
pass "pi-atelier registered in settings.json packages[]"
|
|
else
|
|
fail "pi-atelier NOT in settings.json packages[] (sidebar will not load)"
|
|
fi
|
|
if _npm_atelier_present; then
|
|
fail "stale npm:pi-atelier still in packages[] — it resolves through the ~/.pi/npm-global VOLUME and shadows the pinned /opt copy (entrypoint migration did not run)"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ── pi <-> pi-atelier compatibility floor ─────────────────────────────
|
|
# atelier < 0.7.1 wraps pi's private TUI renderer in a way that recurses under
|
|
# pi >= 0.84: pi hangs at startup burning CPU, with no error message. atelier's
|
|
# own peerDependencies (>=0.80.7) do not encode this. Assert it here too, not
|
|
# just in the build-time smoke test: this script runs after a real
|
|
# `--force-recreate` on a live box, where a volume-resident old copy is exactly
|
|
# what could bite.
|
|
if [ -d /opt/pi-atelier ] && command -v jq >/dev/null 2>&1; then
|
|
_ge() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ]; }
|
|
_av=$(jq -r '.version // empty' /opt/pi-atelier/package.json 2>/dev/null || true)
|
|
_pv=$(pi --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)
|
|
if [ -n "$_av" ] && [ -n "$_pv" ]; then
|
|
if _ge "$_pv" 0.84.0 && ! _ge "$_av" 0.7.1; then
|
|
fail "pi $_pv with pi-atelier $_av — atelier < 0.7.1 hangs pi >= 0.84 at startup (bump PI_ATELIER_REF in Dockerfile.variant)"
|
|
else
|
|
pass "pi $_pv + pi-atelier $_av (compatibility floor OK)"
|
|
fi
|
|
else
|
|
warn "could not compare pi/pi-atelier versions (pi='$_pv' atelier='$_av')"
|
|
fi
|
|
fi
|
|
|
|
echo
|
|
echo "-- ssh ControlMaster dir --"
|
|
if [ -d /tmp/sshcm ] && [ "$(stat -c %a /tmp/sshcm 2>/dev/null)" = "700" ]; then
|
|
pass "/tmp/sshcm exists with mode 700"
|
|
else
|
|
fail "/tmp/sshcm missing or not mode 700"
|
|
fi
|
|
|
|
echo
|
|
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
|
|
if [ -f "$HOME/.bash_aliases" ]; then
|
|
pass "~/.bash_aliases exists"
|
|
else
|
|
fail "~/.bash_aliases missing"
|
|
fi
|
|
|
|
# History flush must survive shell nesting. The DEVBOX_HIST_SET guard must NOT
|
|
# be exported: if it leaks into child processes, nested shells (esp. tmux
|
|
# panes) skip installing `history -a` and lose in-memory history on abrupt
|
|
# termination. Assert a child login shell still wires up the per-prompt flush.
|
|
if bash -lic 'bash -lic "case \"\$PROMPT_COMMAND\" in *\"history -a\"*) exit 0;; *) exit 1;; esac"' </dev/null >/dev/null 2>&1; then
|
|
pass "nested shell installs 'history -a' (DEVBOX_HIST_SET not exported)"
|
|
else
|
|
fail "nested shell missing 'history -a' — DEVBOX_HIST_SET leaking to children?"
|
|
fi
|
|
|
|
if [ -f "$HOME/.inputrc" ]; then
|
|
pass "~/.inputrc exists"
|
|
else
|
|
fail "~/.inputrc missing"
|
|
fi
|
|
|
|
echo
|
|
echo "-- cli_utils bind-mount --"
|
|
if [ -d /workspace/cli_utils ] && [ -d /workspace/cli_utils/.git ]; then
|
|
pass "/workspace/cli_utils exists with .git subdir"
|
|
else
|
|
warn "/workspace/cli_utils missing or .git subdir absent — expected only if cli_utils is bind-mounted"
|
|
fi
|
|
|
|
echo
|
|
echo "-- Baked /opt toolkits --"
|
|
for opt_path in /opt/pi-toolkit /opt/pi-extensions /opt/pi-fork /opt/pi-observational-memory /opt/mempalace-toolkit; do
|
|
if [ -d "$opt_path" ]; then
|
|
pass "$opt_path exists"
|
|
else
|
|
fail "$opt_path missing"
|
|
fi
|
|
done
|
|
|
|
if [ "$VARIANT" = "studio" ]; then
|
|
if [ -d /opt/pi-studio ] && [ -f /opt/pi-studio/client/studio-client.js ]; then
|
|
pass "/opt/pi-studio exists with prebuilt client bundle"
|
|
else
|
|
fail "/opt/pi-studio missing or prebuilt client bundle absent (studio variant)"
|
|
fi
|
|
fi
|
|
|
|
# mempalace MCP entrypoint on PATH
|
|
if command -v mempalace-mcp >/dev/null 2>&1; then
|
|
pass "mempalace-mcp on PATH"
|
|
else
|
|
fail "mempalace-mcp not on PATH"
|
|
fi
|
|
|
|
echo
|
|
echo "-- Known expected-absences (regressions vs by-design) --"
|
|
if ! command -v go >/dev/null 2>&1; then
|
|
warn "go absent — expected unless image built with INSTALL_GO=true"
|
|
else
|
|
pass "go is on PATH"
|
|
fi
|
|
|
|
if [ "$VARIANT" = "plain" ] && [ ! -d /opt/pi-studio ]; then
|
|
warn "/opt/pi-studio absent — expected on the plain (non-studio) variant"
|
|
fi
|
|
|
|
echo
|
|
if [ "$FAILED" -gt 0 ]; then
|
|
echo "=== FAILED: $FAILED check(s) ===" >&2
|
|
exit 1
|
|
fi
|
|
echo "=== PASSED ==="
|