f7c34091b1
Mirrors the pi-devbox v0.75.5b fix (2026-05-23) onto the four-variant pipeline here. The with-pi, omos, and omos-with-pi variants install upstream npm packages whose *_VERSION build-args defaulted to 'latest'. When the build-arg string is byte-identical across builds, the layer hash is identical and the registry buildcache silently reuses the layer from whatever upstream version was current when the cache was first populated — same mechanism that shipped pi-devbox v0.74.0..v0.75.5 with identical image bytes. Currently masked here because OPENCODE_VERSION is a hard-coded ARG that bumps every release; parent-chain cache invalidation flushes the downstream pi/omos layers. Masking would fail on any vN.N.Nb opencode- version-unchanged release that only bumps pi or omos. Filed last night as parked followup; fixing preventatively now that #5 (AWS SSO inside tor-ms22 container) cleared. CHANGES .gitea/workflows/docker-publish-split.yml — new resolve-versions job running 'npm view @earendil-works/pi-coding-agent version' and 'npm view oh-my-opencode-slim version', exposing concrete strings as job outputs. All six affected jobs (smoke-omos, smoke-with-pi, smoke-omos-with-pi, build-variant-omos, build-variant-with-pi, build-variant-omos-with-pi) now consume them as PI_VERSION / OMOS_VERSION build-args. smoke-base / build-variant-base unaffected. scripts/smoke-test.sh — new run_expect helper asserting an expected substring in command output. The pi check uses EXPECTED_PI_VERSION; the omos check uses EXPECTED_OMOS_VERSION against npm ls -g. Both env vars are wired from resolve-versions outputs in the smoke jobs. Catches this regression class on the next release, not four releases later. Dockerfile.variant — comment blocks above OPENCODE_VERSION (source- pinned, not subject to the bug), PI_VERSION (CI-resolved), and OMOS_VERSION (CI-resolved) explaining the cache-hit footgun. AGENTS.md — new convention bullet under 'Critical conventions' naming the resolve-versions job + EXPECTED_*_VERSION wiring as the contract to keep in lockstep when modifying variant build-args. .gitea/README.md — Step 1 expanded to cover the parallel resolve- versions job alongside base-decide; pipeline diagram updated. CHANGELOG.md — Unreleased entry describing the fix, masking mechanism, and audit footprint. No image-content change expected on the next release vs what 'latest' would have resolved to anyway. Purely makes the cache invalidate correctly going forward.
346 lines
14 KiB
Bash
Executable File
346 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Smoke-test a freshly-built opencode-devbox image.
|
|
#
|
|
# Verifies:
|
|
# - Core binaries are on PATH and runnable
|
|
# - opencode itself starts and prints a version
|
|
# - Entrypoint runs cleanly as non-root after UID adjustment
|
|
# - Generated opencode.json has the expected shape
|
|
# - MCP wrapper works (when mempalace is installed)
|
|
#
|
|
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos|with-pi|omos-with-pi]
|
|
#
|
|
# Exit codes:
|
|
# 0 all checks passed
|
|
# 1 one or more checks failed
|
|
|
|
set -euo pipefail
|
|
|
|
IMAGE="${1:-}"
|
|
VARIANT="base"
|
|
if [ "${2:-}" = "--variant" ]; then
|
|
VARIANT="${3:-base}"
|
|
fi
|
|
|
|
if [ -z "$IMAGE" ]; then
|
|
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi]" >&2
|
|
exit 2
|
|
fi
|
|
|
|
FAILED=0
|
|
pass() { echo " ✓ $1"; }
|
|
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
|
|
|
run() {
|
|
# Run a command inside the image and capture its output.
|
|
# First arg is a label, rest is the shell command.
|
|
local label="$1"; shift
|
|
local out
|
|
if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$*" 2>&1); then
|
|
pass "$label ($(echo "$out" | head -1))"
|
|
else
|
|
fail "$label: $out"
|
|
fi
|
|
}
|
|
|
|
# Stricter version of `run` that also asserts an expected substring in
|
|
# the command's stdout. Used to catch the "image bytes silently identical
|
|
# to previous release" class of regression — Docker layer-cache hit on
|
|
# a bare `npm install -g <pkg>` (or @latest) because the build-arg
|
|
# string is identical across builds, even when 'latest' would have
|
|
# resolved differently. Discovered in pi-devbox 2026-05-23 (every
|
|
# release v0.74.0..v0.75.5 shipped the same image bytes); preventatively
|
|
# applied here for PI_VERSION + OMOS_VERSION.
|
|
run_expect() {
|
|
local label="$1"; local cmd="$2"; local expect="$3"
|
|
local out
|
|
out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1) || true
|
|
if echo "$out" | grep -Fq "$expect"; then
|
|
pass "$label (got $expect)"
|
|
else
|
|
fail "$label — expected substring '$expect', got: $out"
|
|
fi
|
|
}
|
|
|
|
echo "=== Smoke test: $IMAGE (variant: $VARIANT) ==="
|
|
echo
|
|
echo "-- Resolved component versions --"
|
|
# Prints the actual version of every floating component so CI logs
|
|
# always record what got baked into this image, even when Dockerfile
|
|
# ARGs default to "latest".
|
|
docker run --rm --entrypoint="" "$IMAGE" sh -c '
|
|
if command -v opencode >/dev/null 2>&1; then
|
|
printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)"
|
|
fi
|
|
if command -v pi >/dev/null 2>&1; then
|
|
printf " %-15s %s\n" "pi" "$(pi --version 2>&1 | head -1)"
|
|
fi
|
|
printf " %-15s %s\n" "node" "$(node --version)"
|
|
printf " %-15s %s\n" "npm" "$(npm --version)"
|
|
printf " %-15s %s\n" "nvim" "$(nvim --version | head -1)"
|
|
printf " %-15s %s\n" "bat" "$(bat --version)"
|
|
printf " %-15s %s\n" "eza" "$(eza --version | head -2 | tail -1)"
|
|
printf " %-15s %s\n" "zoxide" "$(zoxide --version)"
|
|
printf " %-15s %s\n" "uv" "$(uv --version)"
|
|
printf " %-15s %s\n" "fzf" "$(fzf --version)"
|
|
printf " %-15s %s\n" "fd" "$(fd --version)"
|
|
printf " %-15s %s\n" "rg" "$(rg --version | head -1)"
|
|
printf " %-15s %s\n" "gosu" "$(gosu --version)"
|
|
printf " %-15s %s\n" "git-lfs" "$(git-lfs --version)"
|
|
printf " %-15s %s\n" "gitea-mcp" "$(gitea-mcp --version 2>&1 | head -1)"
|
|
printf " %-15s %s\n" "aws" "$(aws --version 2>&1)"
|
|
if command -v bun >/dev/null 2>&1; then
|
|
printf " %-15s %s\n" "bun" "$(bun --version)"
|
|
fi
|
|
if command -v mempalace >/dev/null 2>&1; then
|
|
printf " %-15s %s\n" "mempalace" "$(mempalace --version 2>&1 | head -1 || echo installed)"
|
|
fi
|
|
if command -v mempalace-session >/dev/null 2>&1 && [ -d /opt/mempalace-toolkit ]; then
|
|
printf " %-15s %s\n" "toolkit" "$(git -C /opt/mempalace-toolkit rev-parse --short HEAD 2>/dev/null || echo installed)"
|
|
fi
|
|
'
|
|
echo
|
|
echo "-- Core binaries --"
|
|
# opencode is gated on INSTALL_OPENCODE=true (default). When absent, the
|
|
# image is a pi-only build (or a pure base — no harness at all).
|
|
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v opencode" >/dev/null 2>&1; then
|
|
run "opencode" "opencode --version"
|
|
else
|
|
echo " - opencode not installed (INSTALL_OPENCODE=false)"
|
|
fi
|
|
run "node" "node --version"
|
|
run "npm" "npm --version"
|
|
run "git" "git --version"
|
|
run "nvim" "nvim --version | head -1"
|
|
run "bat" "bat --version"
|
|
run "eza" "eza --version | head -1"
|
|
run "zoxide" "zoxide --version"
|
|
run "uv" "uv --version"
|
|
run "uvx" "uvx --version"
|
|
run "rustup-init" "rustup-init --version"
|
|
run "fzf" "fzf --version"
|
|
run "fd" "fd --version"
|
|
run "rg" "rg --version | head -1"
|
|
run "jq" "jq --version"
|
|
run "aws" "aws --version"
|
|
run "gitea-mcp" "gitea-mcp --version"
|
|
run "gosu" "gosu --version"
|
|
run "tmux" "tmux -V"
|
|
|
|
echo
|
|
echo "-- Optional / variant-gated --"
|
|
# mempalace: present unless built with INSTALL_MEMPALACE=false
|
|
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then
|
|
run "mempalace" "mempalace --help | head -1"
|
|
run "mempalace-mcp" "test -x /usr/local/bin/mempalace-mcp && readlink /usr/local/bin/mempalace-mcp"
|
|
else
|
|
echo " - mempalace not installed (INSTALL_MEMPALACE=false)"
|
|
fi
|
|
|
|
# mempalace-toolkit wrappers: present unless built with INSTALL_MEMPALACE_TOOLKIT=false
|
|
# Gated on mempalace presence — wrappers are useless without the CLI.
|
|
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace && command -v mempalace-session" >/dev/null 2>&1; then
|
|
run "mempalace-session (toolkit)" "mempalace-session --help | head -1"
|
|
run "mempalace-docs (toolkit)" "mempalace-docs --help | head -1"
|
|
run "toolkit symlink target" "test -L /usr/local/bin/mempalace-session && readlink /usr/local/bin/mempalace-session"
|
|
elif docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then
|
|
echo " - mempalace-toolkit not installed (INSTALL_MEMPALACE_TOOLKIT=false)"
|
|
fi
|
|
|
|
# pi: present when built with INSTALL_PI=true. Verifies pi itself plus
|
|
# the runtime-deployed pi-toolkit + pi-extensions + mempalace bridge
|
|
# symlinks under ~/.pi/agent/. Note: extension symlinks are created by
|
|
# entrypoint-user.sh on first start, so we test by running the entry
|
|
# point chain (not just `docker run --entrypoint=""`).
|
|
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&1; then
|
|
if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
|
|
run_expect "pi version matches build-arg" "pi --version" "$EXPECTED_PI_VERSION"
|
|
else
|
|
run "pi" "pi --version"
|
|
fi
|
|
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
|
|
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
|
|
|
|
# Run the full entrypoint as developer to verify install.sh deployment.
|
|
# Spin up a long-running container so we can `docker exec` into it from
|
|
# the host — the `run` helper above invokes commands INSIDE the image
|
|
# and has no docker CLI to nest with.
|
|
CID=$(docker run -d --rm "$IMAGE" tail -f /dev/null)
|
|
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
|
|
|
|
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions.
|
|
# Marker: keybindings.json symlink lands once pi-toolkit/install.sh has run.
|
|
# Up to 30s — omos-with-pi has more setup work than base+pi.
|
|
for _ in $(seq 1 30); do
|
|
if docker exec "$CID" test -L /home/developer/.pi/agent/keybindings.json 2>/dev/null; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
exec_test() {
|
|
local label="$1"; shift
|
|
local out
|
|
if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then
|
|
pass "$label ($(echo "$out" | head -1))"
|
|
else
|
|
fail "$label: $out"
|
|
fi
|
|
}
|
|
|
|
exec_test "~/.pi/agent/keybindings.json (pi-toolkit)" \
|
|
'test -L $HOME/.pi/agent/keybindings.json && echo ok'
|
|
exec_test "~/.pi/agent/extensions/*.ts ≥ 4 (pi-extensions)" \
|
|
'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
|
|
exec_test "~/.pi/agent/extensions/mempalace.ts (bridge)" \
|
|
'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
|
|
exec_test "~/.pi/agent/settings.json (template bootstrap)" \
|
|
'test -f $HOME/.pi/agent/settings.json && echo ok'
|
|
|
|
docker rm -f "$CID" >/dev/null 2>&1 || true
|
|
trap - EXIT
|
|
else
|
|
echo " - pi not installed (INSTALL_PI=false)"
|
|
fi
|
|
|
|
# bun: only in the omos and omos-with-pi variants
|
|
if [ "$VARIANT" = "omos" ] || [ "$VARIANT" = "omos-with-pi" ]; then
|
|
run "bun (omos)" "bun --version"
|
|
run "bunx symlink (omos)" "test -L /usr/local/bin/bunx && readlink /usr/local/bin/bunx"
|
|
# oh-my-opencode-slim is npm-installed globally (not a bun install);
|
|
# verify it shows up in the global module list. We must explicitly point
|
|
# npm at the system prefix (/usr) here: the image's NPM_CONFIG_PREFIX env
|
|
# is set to /home/developer/.pi/npm-global so user-installed packages
|
|
# land on the persistent volume — which means a default `npm ls -g`
|
|
# queries the user prefix and would miss the baked binaries even though
|
|
# they're correctly on PATH at /usr/bin.
|
|
run "oh-my-opencode-slim" "NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim"
|
|
if [ -n "${EXPECTED_OMOS_VERSION:-}" ]; then
|
|
run_expect "omos version matches build-arg" \
|
|
"NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim" \
|
|
"$EXPECTED_OMOS_VERSION"
|
|
fi
|
|
else
|
|
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v bun" >/dev/null 2>&1; then
|
|
fail "bun should NOT be in base image but was found"
|
|
else
|
|
pass "bun correctly absent from base image"
|
|
fi
|
|
fi
|
|
|
|
echo
|
|
echo "-- Entrypoint behaviour --"
|
|
|
|
# Generate-config script exists and has valid syntax.
|
|
run "generate-config.py exists" \
|
|
"test -x /usr/local/lib/opencode-devbox/generate-config.py && python3 -m py_compile /usr/local/lib/opencode-devbox/generate-config.py && echo ok"
|
|
|
|
# Entrypoint drops to developer user and runs a trivial command.
|
|
# Writes the result to a file inside the container so we don't have to
|
|
# disentangle entrypoint log output from command stdout on the host.
|
|
label="entrypoint drops to developer"
|
|
tmpout=$(mktemp)
|
|
if docker run --rm -e OPENCODE_PROVIDER= "$IMAGE" \
|
|
sh -c 'whoami > /tmp/who && cat /tmp/who' > "$tmpout" 2>/dev/null; then
|
|
# The last line of stdout is the whoami output. Entrypoint log lines
|
|
# (MemPalace init, "Adjusted developer UID", etc.) go to stderr or
|
|
# get printed before our sh command runs.
|
|
actual=$(tail -1 "$tmpout" | tr -d '[:space:]')
|
|
if [ "$actual" = "developer" ]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label: expected 'developer', got '$actual' (full output: $(cat "$tmpout"))"
|
|
fi
|
|
else
|
|
fail "$label: container failed"
|
|
fi
|
|
rm -f "$tmpout"
|
|
|
|
# Config generation with anthropic provider writes valid JSONC with the
|
|
# expected shape. The script's log message goes to stderr (line 1 of
|
|
# generate-config.py uses file=sys.stderr) so capturing only stdout
|
|
# gives us clean JSONC. We strip // comments before validating JSON.
|
|
label="generate-config produces valid opencode.jsonc"
|
|
tmp=$(mktemp -d)
|
|
if docker run --rm \
|
|
-e OPENCODE_PROVIDER=anthropic \
|
|
-e HOME=/tmp/home \
|
|
--entrypoint="" \
|
|
"$IMAGE" sh -c '
|
|
mkdir -p /tmp/home
|
|
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
|
|
cat /tmp/home/.config/opencode/opencode.jsonc
|
|
' > "$tmp/out.jsonc" 2>/dev/null; then
|
|
# Strip single-line // comments for JSON validation (respecting strings)
|
|
if python3 -c "
|
|
import re, json, sys
|
|
text = open('$tmp/out.jsonc').read()
|
|
# Match either a string literal or a // comment; keep strings, drop comments
|
|
pattern = r'\"(?:\\\\.|[^\"\\\\])*\"|//[^\n]*'
|
|
stripped = re.sub(pattern, lambda m: m.group(0) if m.group(0).startswith('\"') else '', text)
|
|
c = json.loads(stripped)
|
|
assert c['model'].startswith('anthropic/'), c
|
|
assert c['autoupdate'] is False
|
|
assert c['share'] == 'disabled'
|
|
assert 'context7' in c.get('mcp', {}), 'context7 MCP not registered'
|
|
" 2>&1; then
|
|
pass "$label"
|
|
else
|
|
fail "$label: output doesn't match expected shape: $(cat "$tmp/out.jsonc")"
|
|
fi
|
|
else
|
|
fail "$label: container failed: $(cat "$tmp/out.jsonc")"
|
|
fi
|
|
|
|
# Config generation is idempotent — running twice must not overwrite.
|
|
# Tests both legacy .json and new .jsonc detection.
|
|
label="generate-config never overwrites existing config"
|
|
if docker run --rm \
|
|
-e OPENCODE_PROVIDER=anthropic \
|
|
-e HOME=/tmp/home \
|
|
--entrypoint="" \
|
|
"$IMAGE" sh -c '
|
|
mkdir -p /tmp/home/.config/opencode
|
|
echo "{\"sentinel\": \"user-config\"}" > /tmp/home/.config/opencode/opencode.json
|
|
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
|
|
cat /tmp/home/.config/opencode/opencode.json
|
|
' 2>/dev/null | grep -q '"sentinel": "user-config"'; then
|
|
pass "$label"
|
|
else
|
|
fail "$label: existing config was modified!"
|
|
fi
|
|
rm -rf "$tmp"
|
|
|
|
echo
|
|
echo "-- Image size --"
|
|
SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE")
|
|
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
|
|
echo " Uncompressed size: ${SIZE_MB} MB"
|
|
|
|
# Thresholds (uncompressed): base 2500 MB, omos 3300 MB, with-pi adds ~150 MB.
|
|
# omos bumped 3000→3200 on v1.14.31c — mempalace-toolkit bake-in pushed the
|
|
# baseline; bumped 3200→3300 on v1.15.0 — opencode 1.15.0 came in at
|
|
# 3206 MB, leaving zero headroom for routine apt-get upgrade drift.
|
|
# omos-with-pi bumped 3400→3500 on v1.15.0 alongside the omos bump.
|
|
# omos-with-pi bumped 3500→3700 on v1.15.4b — omos+pi compounded as both
|
|
# upstream packages grew (opencode 1.15.0→1.15.4, pi 0.74.0→0.75.3) and
|
|
# the variant landed just over 3500 in v1.15.4's smoke.
|
|
# omos variant to ~3.1 GB. Functional smoke checks all pass; this is a
|
|
# guardrail, not a performance limit.
|
|
THRESHOLD=2500
|
|
[ "$VARIANT" = "omos" ] && THRESHOLD=3300
|
|
[ "$VARIANT" = "with-pi" ] && THRESHOLD=2700
|
|
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3700
|
|
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
|
|
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
|
|
else
|
|
pass "image size ${SIZE_MB} MB within threshold ${THRESHOLD} MB"
|
|
fi
|
|
|
|
echo
|
|
if [ "$FAILED" -gt 0 ]; then
|
|
echo "=== FAILED: $FAILED check(s) ===" >&2
|
|
exit 1
|
|
fi
|
|
echo "=== PASSED ==="
|