34cf1e3810
Lint / hadolint (push) Successful in 14s
Lint / actionlint (push) Successful in 17s
Publish Docker Image / resolve-versions (push) Successful in 10s
Publish Docker Image / base-decide (push) Successful in 19s
Publish Docker Image / build-base (push) Successful in 1h3m31s
Publish Docker Image / smoke (push) Successful in 4m47s
Publish Docker Image / smoke-studio (push) Successful in 8m8s
Publish Docker Image / build-variant-studio (push) Successful in 20m4s
Publish Docker Image / build-variant (push) Successful in 26m9s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / update-description (push) Successful in 14s
Freezes the v1.8.8 section and clears the two non-code checklist items
pi@emb-7kj4vr4g handed over (evt_20260826T134919_a614ecfc2d4f), plus the two
carried nits from its round-2 verification (evt_20260826T133356_d56792791a49).
Every claim below was re-measured here rather than taken from the handoff.
THE STALENESS NOTICE ASSERTED A DIRECTION IT NEVER TESTED — Blocker 1's shape,
one layer down, in the message I added to replace the message that named the
wrong cause. The notice fired on "recorded != HEAD" and then announced HEAD as
the newer side without testing ancestry, so a clone that was merely BEHIND got
"has moved to 82a8d3c; the snapshot describes the older 5fd0d5c" when 82a8d3c is
5fd0d5c's ANCESTOR. Found by EMB against the real state of its own host, not a
fabrication. The verdict was never wrong (rc 0, nothing mis-verified) but the
remedy it implies is a ~67-minute base rebuild when the actual fix is `git pull`
— the only one of the two carried nits with a price tag, which is why it went
first. Now tests ancestry with the merge-base --is-ancestor primitive the refresh
path 60 lines below already used, and reports three verdicts: stale (refresh),
clone behind (pull, do NOT refresh), diverged (reconcile). All three verified by
execution; only the first was correct before. --help no longer errors on the one
script whose argument order was itself a landmine. The `-s "$VENDORED"` guard is
now commented as load-bearing: it makes the empty-stdin collision unreachable by
construction, which also means no test below exercises it any more, so deleting
it as "redundant with the probes" would silently restore the false OK.
CHANGELOG: retitled, and three stale spots fixed in what becomes the permanent
record. It cited the skill at 82a8d3c (twice superseded); it RE-ASSERTED the
retracted mailbox measurement as live evidence 200 lines after withdrawing it,
which is the exact non-contradiction failure this release exists to fix; and its
warning block still described the pre-e8ddeaf world ("still records c04cd15",
"now exits 1") while quoting as exemplary the very notice whose direction was
unverified. Per EMB's steer the conclusion was kept and only the evidence
replaced: the status filter does drop broadcast noise, it just never computed
owed-ness. Honest replacement, measured on both machines: raw filter returns 2
here and 1 there, EVERY ONE already answered, derivation returns 0 for both.
SNAPSHOT RESYNCED AGAIN, 5fd0d5c -> 6eb20af, because skillset 6eb20af adds the
limit of my own seq ordering test: seq is REPLICA-LOCAL, equal to origin_seq only
because one replica authors for all four machines, so use hlc once mesh_peers
reports a peer. Recorded as reasoning not measurement — a second replica cannot
be stood up here. The durable half is the asymmetry: seq skew makes an ANSWERED
item resurface (noise, visible, self-correcting) while created_at SUPPRESSES AN
UNANSWERED ask forever (silent, permanent), so the skill now says outright that
"fixing" a resurfacing item with a timestamp trades the safe failure for the
dangerous one. The resync was free: rootfs/ was already changing, so the base
rebuild was forced regardless — the ordering warning about accidental staleness
does not apply to a deliberate refresh before the tag.
--check is a clean OK at 6eb20af with no notice, canary re-verified bidirectionally
(present 3, withdrawn 0), baked snapshot 0644, tree hash recomputed at build time
and re-verified in-container. bash -n clean; shellcheck/hadolint/actionlint remain
absent locally, so CI is still the only evidence for those.
423 lines
25 KiB
Docker
423 lines
25 KiB
Docker
# pi-devbox — variant image
|
|
#
|
|
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
|
|
# the variant-specific tools — currently just the pi install. Kept as a
|
|
# separate file (rather than collapsed into Dockerfile.base) so future
|
|
# variants (e.g. studio, studio-tex) can FROM the variant or extend
|
|
# this Dockerfile with additional build args without rebuilding the
|
|
# base on every pi version bump.
|
|
#
|
|
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
|
|
# CI computes the base hash from Dockerfile.base + rootfs/ +
|
|
# entrypoint*.sh and feeds it in.
|
|
#
|
|
# IMPORTANT: the base image sets NPM_CONFIG_PREFIX to
|
|
# /home/developer/.pi/npm-global so runtime `pi install npm:...` and
|
|
# `npm install -g` by the developer user lands on the named volume.
|
|
# At BUILD time we want the baked binaries on /usr so they survive the
|
|
# volume mount. Each `npm install -g` below therefore prefixes the
|
|
# command with `NPM_CONFIG_PREFIX=/usr`.
|
|
|
|
ARG BASE_IMAGE
|
|
FROM ${BASE_IMAGE}
|
|
|
|
ARG TARGETARCH
|
|
ARG USER_NAME=developer
|
|
|
|
# ── pi coding-agent + companions ─────────────────────────────────────
|
|
# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh
|
|
# runs each repo's install.sh on container start so symlinks land under
|
|
# ~/.pi/agent/ on the named volume.
|
|
#
|
|
# ── pi version pin: an AUDITED CHECKPOINT, not a freeze ──────────────
|
|
# PI_VERSION is pinned to a version whose upstream CHANGELOG has been read
|
|
# against this image's integration surface: the theme/TUI API that pi-atelier
|
|
# couples to, the session `.jsonl` format that `pi-session-repair` parses, the
|
|
# extension/package loader, and the Node engine floor. CI reads THIS LINE as
|
|
# the single source of truth (see the `resolve-versions` job) and no longer
|
|
# follows npm `latest` — following it meant every release silently adopted
|
|
# whatever pi shipped that morning, unaudited, in the very build that then got
|
|
# tagged and published.
|
|
#
|
|
# BUMPING IS ROUTINE AND EXPECTED — the pin exists to force a look, not to
|
|
# hold a version forever:
|
|
# 1. Read the upstream CHANGELOG for every version between old and new.
|
|
# 2. Re-check the companions that couple to pi's private TUI/renderer
|
|
# internals — pi-atelier above all (see PI_ATELIER_REF below for the
|
|
# 0.6.0-under-pi-0.84 startup-hang precedent).
|
|
# 3. Bump this line, record the audit in CHANGELOG.md, then tag.
|
|
# CI fails the build if this pin is not a published npm version, and warns —
|
|
# without adopting it — when npm `latest` has moved ahead. That warning is the
|
|
# prompt to do step 1; it is not something to silence.
|
|
#
|
|
# A concrete version here ALSO defeats the registry-buildcache cache-hit
|
|
# footgun that `latest` carried: a byte-identical build-arg string produced an
|
|
# identical layer hash, so the cache reused the layer from whatever pi was
|
|
# current when it was first populated (shipped the same bytes for pi-devbox
|
|
# v0.74.0..v0.75.5; discovered + fixed in v0.75.5b, 2026-05-23). The `latest`
|
|
# branch below is kept only for a deliberate local `docker build` override.
|
|
#
|
|
# AUDITED AT 0.84.3 (2026-08-25, was 0.84.2): upstream's notes carry a
|
|
# "Breaking Changes" heading — `GoogleThinkingLevel` renamed to
|
|
# `GoogleApiThinkingLevel`. INERT FOR THIS IMAGE: all four vendored companions
|
|
# (/opt/pi-fork, /opt/pi-observational-memory, /opt/pi-atelier, /opt/pi-studio)
|
|
# were grepped for that symbol and reference it ZERO times, so nothing here
|
|
# couples to the renamed type. Recorded because the heading will look alarming
|
|
# to the next reader doing step 1 above — the audit is done, don't redo it.
|
|
# Adopted for two fixes that land squarely on this repo's own vendored-skill
|
|
# wiring (see devbox-skill-reconcile, v1.8.5): nested Markdown skills inside
|
|
# `.agents/skills/<group>/` directories were not discovered, and root Markdown
|
|
# files such as README.md / AGENTS.md inside a skill dir were reported as
|
|
# broken skills unless they declared valid skill frontmatter.
|
|
# pi-atelier needs no companion bump: v0.8.2 clears the >=0.7.1 floor that
|
|
# pi >= 0.84 requires (see PI_ATELIER_REF below).
|
|
ARG PI_VERSION=0.84.3
|
|
ARG PI_TOOLKIT_REF=main
|
|
ARG PI_EXTENSIONS_REF=main
|
|
# Repo URLs default to the canonical gitea origin but are overridable so a
|
|
# relocated/forked build can clone from a mirror or a different host
|
|
# without editing this Dockerfile — same pattern as PI_FORK_REPO /
|
|
# PI_OBSMEM_REPO / PI_STUDIO_REPO below.
|
|
ARG PI_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/pi-toolkit.git
|
|
ARG PI_EXTENSIONS_REPO=https://gitea.jordbo.se/joakimp/pi-extensions.git
|
|
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
|
|
# under elpapi42. CI resolves these to commit SHAs to defeat the same
|
|
# cache-hit footgun that affects PI_VERSION.
|
|
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
|
ARG PI_FORK_REF=master
|
|
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
|
ARG PI_OBSMEM_REF=master
|
|
# pi-atelier (TUI sidebar: ordered panels, split-pane, themes) is PINNED TO A
|
|
# TAG, which CI resolves to that tag's commit SHA — same treatment as
|
|
# pi-studio, for reproducibility plus cache-busting.
|
|
#
|
|
# This floor is hard-earned. pi-atelier 0.6.0/0.7.0 wrapped pi's PRIVATE TUI
|
|
# renderer, and under pi 0.84 that wrapper recursed: pi hung at startup with
|
|
# sustained CPU. Upstream fixed the recursion in 0.7.1 and restored the
|
|
# non-overlapping split in 0.7.2 — "avoiding the recursive render path that
|
|
# caused startup hangs and sustained CPU usage". Its own peerDependencies
|
|
# still say `>=0.80.7`, which does NOT encode that floor, so nothing would
|
|
# have warned us: NEVER pair pi-atelier < 0.7.1 with pi >= 0.84. Bump this
|
|
# pin and PI_VERSION together, checking atelier's CHANGELOG for the pi
|
|
# version it claims to track.
|
|
#
|
|
# No `npm install` step, unlike pi-fork/pi-observational-memory/pi-studio:
|
|
# pi-atelier declares ZERO runtime dependencies (only peerDeps, satisfied by
|
|
# the baked pi) and has no build step — pi loads its TypeScript directly from
|
|
# the /opt checkout. Adding an install here would be a no-op that only costs
|
|
# build time.
|
|
ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
|
|
ARG PI_ATELIER_REF=v0.8.2
|
|
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
|
|
ARG PI_ATELIER_VERSION=v0.8.2
|
|
|
|
RUN set -e && \
|
|
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
|
|
# OR a commit SHA as $ref. Uses `git fetch <ref> + checkout FETCH_HEAD`
|
|
# which (a) works with both name and SHA forms uniformly, and (b) defeats
|
|
# the registry-buildcache footgun when CI passes a resolved SHA. The
|
|
# earlier helper `git_clone_retry` (using `git clone --branch`) only
|
|
# worked with branch names — a SHA-resolved build-arg made `git clone
|
|
# --branch <40-char-SHA>` fail with "Remote branch not found". Surfaced
|
|
# in pi-devbox v1.0.0-rerun (run 374) 2026-06-10 and fixed by switching
|
|
# all four clones to git_fetch_ref. Both Gitea and GitHub allow fetching
|
|
# arbitrary commits by default (uploadpack.allowReachableSHA1InWant).
|
|
git_fetch_ref() { \
|
|
url="$1"; ref="$2"; dest="$3"; \
|
|
rm -rf "$dest"; mkdir -p "$dest"; \
|
|
git -C "$dest" init -q && git -C "$dest" remote add origin "$url" && \
|
|
for i in 1 2 3 4 5; do \
|
|
if git -C "$dest" fetch --depth 1 origin "$ref" && git -C "$dest" checkout -q FETCH_HEAD; then return 0; fi; \
|
|
echo "git fetch $url@$ref failed (attempt $i/5), retrying in $((i*5))s..."; \
|
|
sleep $((i*5)); \
|
|
done; \
|
|
return 1; \
|
|
} && \
|
|
if [ "${PI_VERSION}" = "latest" ]; then \
|
|
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
|
else \
|
|
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
|
fi && \
|
|
pi --version && \
|
|
git_fetch_ref "${PI_TOOLKIT_REPO}" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
|
git_fetch_ref "${PI_EXTENSIONS_REPO}" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
|
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
|
|
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
|
|
git_fetch_ref "${PI_ATELIER_REPO}" "${PI_ATELIER_REF}" /opt/pi-atelier && \
|
|
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
|
|
(cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \
|
|
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
|
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" && \
|
|
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
|
|
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)" && \
|
|
echo "pi-atelier at $(cd /opt/pi-atelier && git rev-parse --short HEAD) (${PI_ATELIER_VERSION})"
|
|
|
|
# ── Image-baked skill refresh: pi-extensions (Option 1 over Option 2) ──
|
|
# rootfs ships a VENDORED snapshot of the pi-extensions skill at
|
|
# /usr/local/share/pi-devbox/skills/pi-extensions/ (the "floor" — guarantees the
|
|
# skill is always in the image). The pi-extensions PACKAGE repo now co-locates
|
|
# the canonical skill under skill/, so here — after the pinned clone — we copy
|
|
# that over the snapshot. Result: a normal build ships the fresh, package-owned
|
|
# copy (pinned + recorded in the manifest via PI_EXTENSIONS_REF); a build whose
|
|
# ref predates the skill, or a fork pointing at a mirror without it, still ships
|
|
# the committed snapshot. The skill calls ./evaluate-extension-usage.py, so it
|
|
# is copied alongside. Idempotent and cache-safe (depends only on the clone).
|
|
RUN if [ -f /opt/pi-extensions/skill/SKILL.md ]; then \
|
|
cp /opt/pi-extensions/skill/SKILL.md \
|
|
/usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md && \
|
|
if [ -f /opt/pi-extensions/skill/evaluate-extension-usage.py ]; then \
|
|
cp /opt/pi-extensions/skill/evaluate-extension-usage.py \
|
|
/usr/local/share/pi-devbox/skills/pi-extensions/evaluate-extension-usage.py ; \
|
|
fi && \
|
|
echo "refreshed pi-extensions skill from package @ $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
|
|
else \
|
|
echo "pi-extensions package has no skill/ at this ref — keeping vendored snapshot" ; \
|
|
fi
|
|
|
|
# ── pi-devbox awareness: append our pointer to the global AGENTS.md ──
|
|
# pi loads a SINGLE global instruction file (~/.pi/agent/AGENTS.md), which
|
|
# pi-toolkit's install.sh re-symlinks to /opt/pi-toolkit/pi-global-AGENTS.md on
|
|
# every container start. There is no second global slot, and that file is
|
|
# root-owned (not writable by the runtime user), so we compose at BUILD time:
|
|
# append the pi-devbox managed block to pi-toolkit's file here, after the clone.
|
|
# Idempotent via a marker grep so a rebuilt layer never double-appends. This
|
|
# makes every container proactively aware of the pi-devbox-environment skill;
|
|
# the snippet itself is gated (only fires when /usr/local/lib/pi-devbox exists).
|
|
RUN if [ -f /opt/pi-toolkit/pi-global-AGENTS.md ] && \
|
|
! grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md; then \
|
|
printf '\n' >> /opt/pi-toolkit/pi-global-AGENTS.md && \
|
|
cat /usr/local/share/pi-devbox/pi-global-AGENTS.append.md >> /opt/pi-toolkit/pi-global-AGENTS.md && \
|
|
echo "appended pi-devbox block to pi-global-AGENTS.md" ; \
|
|
else \
|
|
echo "pi-devbox block already present or pi-global-AGENTS.md missing (skipped)" ; \
|
|
fi
|
|
|
|
# ── Optional: pi-studio (:latest-studio variant) ─────────────────────
|
|
# pi-studio (omaclaren/pi-studio) is a pi-package + theme providing a
|
|
# two-pane browser workspace: prompt/response editor, KaTeX/Mermaid live
|
|
# preview, and tmux-backed literate REPLs. Off by default; the studio
|
|
# variant sets INSTALL_STUDIO=true.
|
|
#
|
|
# Vendored to /opt/pi-studio and registered at container start by
|
|
# entrypoint-user.sh via `pi install /opt/pi-studio` — the SAME pattern
|
|
# as pi-fork / pi-observational-memory above. We deliberately do NOT run
|
|
# `pi install <git-url>` at build time: that writes into ~/.pi/agent,
|
|
# which is a named volume, so a build-time install collides with / is
|
|
# shadowed by the volume on first run. Vendoring to /opt (an image layer)
|
|
# + a runtime local-path install keeps it on the image and idempotent.
|
|
#
|
|
# No build step is needed: pi-studio ships its browser bundle prebuilt in
|
|
# git (client/studio-client.js) and pi loads index.ts directly; its
|
|
# package.json scripts are only test/typecheck. So we just fetch + install
|
|
# the 3 prod deps (@earendil-works/pi-ai, @sinclair/typebox, ws).
|
|
#
|
|
# PI_STUDIO_REF is CI-resolved to a commit SHA to defeat the registry-
|
|
# buildcache cache-hit footgun (see the PI_VERSION note above).
|
|
ARG INSTALL_STUDIO=false
|
|
ARG PI_STUDIO_REPO=https://github.com/omaclaren/pi-studio.git
|
|
ARG PI_STUDIO_REF=main
|
|
# PI_STUDIO_VERSION is the human-readable tag (e.g. v0.9.36) that PI_STUDIO_REF
|
|
# was resolved from; recorded as a label below for at-a-glance identification.
|
|
# Only meaningful for the studio variant (default `none` otherwise).
|
|
ARG PI_STUDIO_VERSION=none
|
|
RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
|
|
set -e; \
|
|
rm -rf /opt/pi-studio && mkdir -p /opt/pi-studio && \
|
|
git -C /opt/pi-studio init -q && \
|
|
git -C /opt/pi-studio remote add origin "${PI_STUDIO_REPO}" && \
|
|
ok=0; for i in 1 2 3 4 5; do \
|
|
if git -C /opt/pi-studio fetch --depth 1 origin "${PI_STUDIO_REF}" && \
|
|
git -C /opt/pi-studio checkout -q FETCH_HEAD; then ok=1; break; fi; \
|
|
echo "git fetch pi-studio@${PI_STUDIO_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
|
|
sleep $((i*5)); \
|
|
done; \
|
|
[ "$ok" = "1" ] && \
|
|
(cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \
|
|
echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \
|
|
fi
|
|
|
|
# STUDIO_PORT: advisory default consumed by docker-compose port publishing
|
|
# and the recommended `/studio --no-browser --port "$STUDIO_PORT"` launch.
|
|
# Harmless in the non-studio variant. NOTE: pi-studio hard-binds the server
|
|
# to 127.0.0.1 inside the container (index.ts: .listen(port,"127.0.0.1")),
|
|
# so reaching it from a browser needs a loopback bridge or host networking —
|
|
# see the "Using pi-studio" section in README.md.
|
|
ENV STUDIO_PORT=8765
|
|
|
|
# ── Optional: Go toolchain ───────────────────────────────────────────
|
|
# Off by default; opt in for users who run Go tools inside the devbox.
|
|
ARG INSTALL_GO=false
|
|
ARG GO_VERSION=latest
|
|
RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
|
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
|
V="${GO_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \
|
|
awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \
|
|
fi && \
|
|
[ -n "$V" ] && \
|
|
echo "Installing Go ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
|
|
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
|
|
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
|
|
fi
|
|
|
|
# ── Build provenance: OCI labels + on-disk build manifest ────────────
|
|
# Records exactly which pi version and companion-repo commits were baked
|
|
# into THIS image, so a published tag is self-describing and reproducible
|
|
# after the fact (CI logs rotate; a released image must not depend on
|
|
# them). Previously the resolved SHAs only ever reached the CI build log.
|
|
#
|
|
# These ARGs are declared LAST, immediately before the layer that uses
|
|
# them, so a changing BUILD_DATE / RELEASE_TAG / SOURCE_REVISION never
|
|
# invalidates the expensive pi-install / clone layers above.
|
|
ARG RELEASE_TAG=dev
|
|
ARG BUILD_DATE=
|
|
ARG SOURCE_REVISION=
|
|
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
|
|
# only so its intended ref lands in the label set alongside the others.
|
|
ARG MEMPALACE_TOOLKIT_REF=main
|
|
# ── Vendored skill provenance ─────────────────────────────────────────
|
|
# The vendored mempalace SKILL.md is the ONLY baked artefact with no /opt
|
|
# clone behind it: its upstream (the skillset repo) is PRIVATE, so the
|
|
# image cannot clone it and CI cannot resolve its HEAD (see VENDORED.md).
|
|
# Consequence through v1.8.7: the snapshot was ANONYMOUS — nothing in the
|
|
# image or the repo recorded which skillset commit it was taken from, so
|
|
# the only staleness check available was a hand-maintained phrase canary in
|
|
# scripts/smoke-test.sh, which by construction can only detect "older than
|
|
# what I remembered to pin", never "older than skillset main".
|
|
#
|
|
# Recording the ref costs nothing and makes the question answerable. It is
|
|
# deliberately a plain ARG DEFAULT rather than a CI-resolved output:
|
|
# * the value is a fact about the committed snapshot, so it belongs in
|
|
# the tree next to it — not in a workflow that a local `docker build`
|
|
# never runs (same reasoning as MEMPALACE_VERSION living in
|
|
# Dockerfile.base rather than being duplicated in docker-publish.yml);
|
|
# * CI therefore needs NO new build-arg at any of its four
|
|
# Dockerfile.variant call sites (smoke, smoke-studio, build-variant,
|
|
# build-variant-studio) — a plumbing change that is easy to
|
|
# under-apply to only two of them;
|
|
# * and it needs no credential for a private repo.
|
|
# Bump it with scripts/vendor-mempalace-skill.sh, which refreshes the file
|
|
# and rewrites this line together, so the pair cannot drift apart by hand.
|
|
# This ARG lives in Dockerfile.variant ON PURPOSE: Dockerfile.base and
|
|
# rootfs/ are both hashed into base_tag, so recording provenance here costs
|
|
# no ~67-minute base rebuild. (scripts/check-base-hash.sh scans only
|
|
# Dockerfile.base, so no folding into the base hash is required — nor would
|
|
# it be correct, since this ARG changes nothing about the base's contents.)
|
|
ARG SKILLSET_SNAPSHOT_REF=6eb20af181f0147cb8c1377f6e36a6a47a68e8e5
|
|
|
|
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
|
|
# and every variant INHERITS it, so both published images used to advertise
|
|
# themselves on Docker Hub as the base image. A LABEL cannot branch on
|
|
# INSTALL_STUDIO, so the description arrives as a build-arg: CI passes the
|
|
# variant-specific string (see docker-publish.yml), and the default below keeps
|
|
# a plain `docker build -f Dockerfile.variant` honest rather than misleading.
|
|
ARG IMAGE_TITLE="pi-devbox"
|
|
ARG IMAGE_DESCRIPTION="pi-devbox — development container for the pi coding agent"
|
|
|
|
LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
|
|
org.opencontainers.image.revision="${SOURCE_REVISION}" \
|
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
|
org.opencontainers.image.title="${IMAGE_TITLE}" \
|
|
org.opencontainers.image.description="${IMAGE_DESCRIPTION}" \
|
|
description="${IMAGE_DESCRIPTION}" \
|
|
se.jordbo.pi-devbox.pi-version="${PI_VERSION}" \
|
|
se.jordbo.pi-devbox.pi-toolkit-ref="${PI_TOOLKIT_REF}" \
|
|
se.jordbo.pi-devbox.pi-extensions-ref="${PI_EXTENSIONS_REF}" \
|
|
se.jordbo.pi-devbox.pi-fork-ref="${PI_FORK_REF}" \
|
|
se.jordbo.pi-devbox.pi-obsmem-ref="${PI_OBSMEM_REF}" \
|
|
se.jordbo.pi-devbox.pi-atelier-ref="${PI_ATELIER_REF}" \
|
|
se.jordbo.pi-devbox.pi-atelier-version="${PI_ATELIER_VERSION}" \
|
|
se.jordbo.pi-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
|
|
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}" \
|
|
se.jordbo.pi-devbox.pi-studio-version="${PI_STUDIO_VERSION}" \
|
|
se.jordbo.pi-devbox.skillset-snapshot-ref="${SKILLSET_SNAPSHOT_REF}"
|
|
|
|
# The manifest is written from GROUND TRUTH — the actual checked-out HEAD
|
|
# of each /opt clone and the live `pi --version` — not merely the intended
|
|
# build-args. That way it also exposes a clone that silently resolved to
|
|
# something other than the requested ref. pi-studio is present only in the
|
|
# studio variant (JSON null otherwise).
|
|
RUN set -e; \
|
|
mkdir -p /etc/pi-devbox; \
|
|
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
|
|
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
|
|
# mempalace CORE (the PyPI package behind the MCP tools) is installed in
|
|
# Dockerfile.base via `uv tool install`, so no /opt clone reveals it and
|
|
# until v1.8.6 the manifest could not answer "which palace shipped here?" —
|
|
# a palace bug could not be correlated to an image, which is precisely the
|
|
# correlation this file exists to provide. Read from the INSTALLED BINARY,
|
|
# not from ARG MEMPALACE_VERSION, per the ground-truth rule above: that is
|
|
# what catches an install which resolved to something other than the pin.
|
|
# `mempalace --version` prints "MemPalace 3.7.1" — NAME-PREFIXED, unlike
|
|
# pi's bare "0.84.2" — hence the $NF pick rather than a straight read. The
|
|
# leading-digit test then rejects usage/error text (a renamed flag prints a
|
|
# usage block) and degrades to JSON null, so this can never fail the build.
|
|
MP_V="$(mempalace --version 2>/dev/null | head -n1 | tr -d '\r' | awk '{print $NF}')"; \
|
|
case "$MP_V" in [0-9]*) MP_CORE="\"${MP_V}\"" ;; *) MP_CORE='null' ;; esac; \
|
|
STUDIO_REV='null'; \
|
|
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
|
# The vendored skill snapshot's fingerprint is MEASURED here, not passed
|
|
# in as a build-arg, per the ground-truth rule above: SKILLSET_SNAPSHOT_REF
|
|
# is a CLAIM about which skillset commit the file came from, while this
|
|
# hash is what the image actually ships. Recorded together they let any
|
|
# reader with the skillset checked out — which on this fleet is every
|
|
# host, since all four compose stacks mount it — verify the claim at
|
|
# RUNTIME, without CI ever needing access to the private repo. Degrades
|
|
# to JSON null rather than failing the build if the directory is absent;
|
|
# the smoke assertion is what turns that into a loud failure.
|
|
#
|
|
# Hashes the whole DIRECTORY, not just SKILL.md: a single-file hash
|
|
# answers "did this one file change", not "is the live copy the same
|
|
# skill" — a live checkout that added or edited a SIBLING file (a
|
|
# reference/ doc, a helper script) would still report "identical to
|
|
# baked snapshot" against a file-only hash. pi-extensions already ships
|
|
# two files for exactly this reason (SKILL.md + evaluate-extension-usage.py),
|
|
# so this is not a hypothetical. Deterministic over `find | sort`, never
|
|
# readdir order: relative paths + per-file sha256, folded into one hash.
|
|
# pi-devbox-version mirrors this exact pipeline over the live directory so
|
|
# the two sides are comparable — if you change this, change that too.
|
|
tree_sha256() { \
|
|
( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) 2>/dev/null | sha256sum | cut -d' ' -f1; \
|
|
}; \
|
|
SKILL_SNAP='null'; \
|
|
_snap_dir=/usr/local/share/pi-devbox/skills/mempalace; \
|
|
if [ -d "$_snap_dir" ] && [ -n "$(find "$_snap_dir" -type f -print -quit)" ]; then \
|
|
SKILL_SNAP="\"$(tree_sha256 "$_snap_dir")\""; \
|
|
fi; \
|
|
{ \
|
|
echo '{'; \
|
|
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
|
|
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
|
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
|
echo " \"pi_version\": \"${PI_V}\","; \
|
|
# Sibling of pi_version, NOT a member of components{}: that map holds git
|
|
# SHAs and `pi-devbox-version` renders it with .value[0:12], which would
|
|
# silently truncate a longer version string.
|
|
echo " \"mempalace_version\": ${MP_CORE},"; \
|
|
# Siblings, NOT members of components{}, for two independent reasons:
|
|
# that map means "HEAD of a clone present in this image" and the
|
|
# skillset is not cloned here (calling it a component would be a
|
|
# lie a future reader would act on), and `pi-devbox-version` renders
|
|
# every components{} value with .value[0:12] — which would truncate
|
|
# a 64-hex sha256 into something that looks like a short commit.
|
|
# Named `_tree_sha256`, not `_sha256`: it measures every file under the
|
|
# vendored skill directory, not one file — see tree_sha256() above.
|
|
echo " \"skillset_snapshot_ref\": \"${SKILLSET_SNAPSHOT_REF}\","; \
|
|
echo " \"skillset_snapshot_tree_sha256\": ${SKILL_SNAP},"; \
|
|
echo " \"components\": {"; \
|
|
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
|
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
|
echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \
|
|
echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \
|
|
echo " \"pi-atelier\": \"$(rev /opt/pi-atelier)\","; \
|
|
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \
|
|
echo " \"pi-studio\": ${STUDIO_REV}"; \
|
|
echo " }"; \
|
|
echo '}'; \
|
|
} > /etc/pi-devbox/build-manifest.json; \
|
|
echo "── build manifest ──"; cat /etc/pi-devbox/build-manifest.json
|
|
|
|
# WORKDIR / ENTRYPOINT / CMD inherited from base.
|