Compare commits

..

4 Commits

Author SHA1 Message Date
Joakim Persson 42bd29d654 fix(ci): unblock the release — npm 11 esbuild bloat + two self-inflicted assertions
Lint / hadolint (push) Successful in 8s
Lint / skill-floor (push) Successful in 9s
Lint / doc-drift (push) Successful in 10s
Lint / actionlint (push) Successful in 19s
Publish Docker Image / lint-gate (push) Successful in 15s
Publish Docker Image / resolve-versions (push) Successful in 10s
Publish Docker Image / base-decide (push) Successful in 14s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke (push) Successful in 5m4s
Publish Docker Image / smoke-studio (push) Successful in 13m17s
Publish Docker Image / build-variant (push) Successful in 33m49s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 14s
Publish Docker Image / build-variant-studio (push) Successful in 30m58s
v1.9.0 was tagged but never published: smoke failed 90-passed/3-failed and
build-variant needs smoke, so nothing reached the registry. All three are fixed.

1. Size, 431 MB over threshold. Node 24 brings npm 11, which installs EVERY
   @esbuild/<platform> optional binary instead of the matching one: 26 dirs,
   284 MB per pi-coding-agent copy. Measured on pi-fork: npm 10.9.8 -> 165 MB
   (exactly what v1.8.14 shipped), npm 11.19.0 -> 449 MB. npm 11 ignores the
   os/cpu constraints AND --os/--cpu AND an npmrc carrying them, all measured,
   so Dockerfile.variant prunes explicitly, keeping linux-$(node -p
   process.arch) so one line is right on both arches. Pruned in the SAME layer
   as each install, or the bytes survive in the earlier layer. Three sites:
   global pi, pi-fork, pi-studio. Verified esbuild still transforms TS after.

2. om's node_modules assertion tested an npm artefact. om has zero runtime deps;
   its node_modules held ONE file (.package-lock.json) and 20 empty scope dirs.
   npm 11 stopped creating it. Now asserts the entry point pi actually loads,
   read from package.json -> pi.extensions.

3. The skill-source annotation added in v1.9.0 ("baked (package copy)") broke
   the assertion matching "baked$". Pattern now allows an optional suffix; which
   copy shipped stays authoritatively asserted against the manifest + tree hash.

Also: a failed size check now prints the largest layers, largest directories and
an @esbuild sentinel, so this class attributes itself next time instead of
costing a CI dig plus a local npm bisect.

Threshold stays 3800 MB: it caught a real regression and raising it would have
thrown the signal away. No Dockerfile.base/rootfs change, so base-0fb1256c7f99
is reused and build-base is skipped.
2026-09-10 23:39:40 +02:00
Joakim Persson 3a44e81cad feat(ci): gate documentation drift, and make docs a pre-tag release step
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 17s
Lint / doc-drift (push) Successful in 7s
Lint / skill-floor (push) Successful in 10s
Five doc claims had rotted by v1.9.0, all the same shape: a value written once
by hand, in a file nothing verifies, about a number that lives elsewhere and
moved. README pin table wrong on all three rows; a "Planned" section describing
something already shipped; DOCKER_HUB.md claiming Node v22 against Node 24.

DOCKER_HUB.md is why this is a gate and not a resolution to be careful: it is
PUBLISHED (update-description POSTs it as Docker Hub full_description on every
tag), it had gone eight releases untouched, nothing generates it, and it is read
from the TAG -- so the stale page shipped with v1.9.0 regardless.

scripts/check-doc-drift.sh: seven checks, all repo-local (no network, token,
image, or sibling clone). Exit 0/1/2 matching lint-shell.sh; a renamed ARG is a
red 2, not a green tick. Wired as a fourth lint.yml job so "the docs lie" is its
own red name.

Verified with 15 controls, including two false-positive controls: the first
placeholder check flagged README.md:900, a Go template in a legitimate
`docker inspect --format` example. The gate was wrong, not the doc, so the
pattern is now anchored to the UPPER_SNAKE convention CI substitutes.

Not gated, deliberately: counts/sizes needing a running image (they belong in
smoke-test.sh -- a guessing gate is worse than none), and Dockerfile.base
BASE_REBUILD_DATE, because base_tag hashes that file content-wise and demanding
it be current would force a ~60 min rebuild on releases that touch no base
files. Free during a rebuild, expensive otherwise.

AGENTS.md step 3 rewritten around the mechanism: checkout@v4 with no ref: means
every job reads github.ref, the tag. Docs must be right BEFORE tagging.
2026-09-10 22:01:49 +02:00
Joakim Persson 35964abd01 docs(hub): the Docker Hub page claimed Node v22; v1.9.0 ships Node 24
DOCKER_HUB.md is hand-maintained (no generator: CI only substitutes
{{PI_VERSION}} and POSTs the file as Docker Hub full_description), and it was
last touched at v1.8.6 -- eight releases ago. The Node claim is now false as of
this release, and unlike README.md this file IS published, so a stale claim here
is user-visible rather than internal.

Verified countable claims rather than assuming: "7 user-facing extensions" is
correct (7 files in pi-extensions/extensions/). The "29 mempalace_* tools" claim
is suspect -- this session sees 45 -- but left alone because I cannot attribute
that count to the baked 3.9.0 server without measuring it.
2026-09-10 21:43:17 +02:00
Joakim Persson 6353d59e63 docs(readme): correct the three stale version pins and the shipped-as-planned claim
The version-pin table existed precisely to be the reviewable record of what is
deliberately frozen, and it was wrong on every row: pi 0.84.4 -> 0.85.1,
pi-atelier v0.10.0 -> v0.10.1, mempalace 3.8.0 -> 3.9.0. Verified by parsing the
table and comparing against the ARGs it names rather than by eye.

Also: the "Planned for an upcoming minor release" section listed typst PDF
export, which shipped long ago and even carried a self-contradicting
"(shipped in Unreleased/base)" marker -- the fourth instance of the stale
in-repo Unreleased-pointer class this CHANGELOG already documents. typst 0.15.1
confirmed live in the running image, so the item is now stated as current fact.

The pi-devbox-version sample was v1.5.0-era and structurally outdated: it
predates the palace line the surrounding prose advertises, the pi-atelier
component, and the whole skills: block. Replaced with real observed output
rather than hand-written text.

README has no CI coupling (no workflow or gate reads it; the Docker Hub page
comes from DOCKER_HUB.md), so this cannot affect the in-flight v1.9.0 build.
2026-09-10 21:33:07 +02:00
8 changed files with 591 additions and 25 deletions
+43
View File
@@ -172,3 +172,46 @@ jobs:
- name: Vendored pi-extensions skill floor matches the package - name: Vendored pi-extensions skill floor matches the package
run: bash scripts/check-skill-floor.sh run: bash scripts/check-skill-floor.sh
doc-drift:
# Gate hand-maintained doc claims against the build files they describe.
# Its own job for the same reason as skill-floor: "the docs lie" should be a
# distinct red name, not a line buried in a job about workflow syntax.
#
# The gap it closes, measured 2026-09-10 while preparing v1.9.0 — five
# claims had rotted, every one of them a fact written by hand in a file
# nothing verified:
# * README.md's "Version pins" table was wrong on ALL THREE rows (pi
# 0.84.4 vs 0.85.1, pi-atelier v0.10.0 vs v0.10.1, mempalace 3.8.0 vs
# 3.9.0) — and that table exists specifically to be the reviewable
# record of what the repo freezes on purpose, so a wrong row destroys
# the only thing it is for.
# * README.md listed already-shipped typst PDF export under "Planned for
# an upcoming minor release", marked "(shipped in Unreleased/base)".
# * DOCKER_HUB.md claimed "Node.js v22" while v1.9.0 ships Node 24.
#
# DOCKER_HUB.md is why this is a gate and not a habit. It is PUBLISHED —
# update-description POSTs it to Docker Hub as full_description on every tag
# — and it had gone eight releases (v1.8.6 -> v1.9.0) untouched. Nothing
# generates it and nothing checked it, so the only thing keeping it true was
# someone remembering. It is also read from the TAG, so a fix pushed to main
# after tagging never reaches the published page.
#
# Cheap and hermetic on purpose: every check compares a doc string against a
# value that exists in this repo, so no network, no token, no built image,
# and no sibling clone. Claims that genuinely need a running container (image
# sizes, the "N mempalace_* tools" count) are deliberately left out — a gate
# that cannot evaluate a claim honestly would have to guess, and a guessing
# gate is worse than none. Assert those in scripts/smoke-test.sh instead.
#
# Exit codes 0 in sync / 1 drift / 2 cannot-run, matching lint-shell.sh and
# check-skill-floor.sh. A renamed ARG makes the gate blind, so that is a red
# 2, not a green tick.
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Doc claims match the build files
run: bash scripts/check-doc-drift.sh
+38 -1
View File
@@ -92,7 +92,44 @@ re-brand of opencode-devbox's `pi-only` variant.
is hashed into `base_tag`, so it costs a base rebuild (~67 min); and if the is hashed into `base_tag`, so it costs a base rebuild (~67 min); and if the
section the phrase canary names has changed, re-pin it in section the phrase canary names has changed, re-pin it in
`scripts/smoke-test.sh`. `scripts/smoke-test.sh`.
3. Update `CHANGELOG.md` Unreleased → vX.Y.Z section. 3. **Update the docs this release makes stale — BEFORE you tag.** Rename
`CHANGELOG.md`'s `## Unreleased` to `## vX.Y.Z — YYYY-MM-DD` (em dash, as
every prior release heading uses), then run the gate:
```bash
bash scripts/check-doc-drift.sh # 0 in sync / 1 drift / 2 cannot run
```
It compares README.md's version-pin table against the ARGs it names, and
DOCKER_HUB.md's Node claim against `ARG NODE_VERSION`, plus Hub's
25 000-char limit, unsubstituted `{{PLACEHOLDERS}}`, and stale `Unreleased`
pointers in user-facing docs.
**Why before and not after:** `docker-publish.yml` runs `actions/checkout@v4`
with no `ref:`, so every job reads `github.ref` — the **tag**. A doc fix
pushed to `main` after tagging does not reach the release, and for
`DOCKER_HUB.md` it does not reach the published Hub page either, because
`update-description` POSTs that file as Docker Hub's `full_description` from
the tag's tree. Getting it in afterwards means re-pointing the tag, which is
its own hazard (v1.8.14 went `601fc98` → `361babd` and broke deploy
verification until `git fetch --tags --force`).
The gate is deliberately narrow — it only checks claims verifiable from files
in this repo. Still eyeball, because these are NOT gated:
- counts and sizes (`~1.1 GB`, "N `mempalace_*` tools", "7 extensions") —
they need a running image; assert them in `scripts/smoke-test.sh` instead
- feature prose that quietly became false, e.g. a "Planned for an upcoming
release" section describing something that already shipped
- `Dockerfile.base`'s `# BASE_REBUILD_DATE:` marker. Ungated on purpose:
`base_tag` hashes Dockerfile.base's content, comments included, so
demanding it be current would force a ~60 min base rebuild on a release
that touched no base files. **Fix it when the base is already rebuilding —
then it is free.**
Measured cost of skipping this, 2026-09-10 (v1.9.0): five stale claims, one
of them published. README's pin table was wrong on all three rows, and
DOCKER_HUB.md — untouched for eight releases — still said Node v22 while the
image shipped Node 24.
4. Verify `docker compose up` works locally with the current `latest` image 4. Verify `docker compose up` works locally with the current `latest` image
if you're upgrading users from a previous version. Then run the if you're upgrading users from a previous version. Then run the
**post-recreate sanity check** inside the running container to confirm **post-recreate sanity check** inside the running container to confirm
+161 -1
View File
@@ -11,7 +11,167 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
--- ---
## v1.9.0 — 2026-09-10 ## v1.9.1 — 2026-09-10
**v1.9.0 was tagged but never published: its own smoke gate stopped it, and it
was right to.** `build-base` succeeded, then `smoke` failed 90-passed/3-failed,
and because `build-variant` needs `smoke`, both variants, `promote-base-latest`
and `update-description` were skipped. No image reached the registry, so
`latest` still pointed at v1.8.14. v1.9.1 carries everything listed under v1.9.0
below, plus the three fixes here. Two of the three failures were self-inflicted
by v1.9.0's own changes, and the third was a real regression that the Node bump
dragged in — which is the case for keeping the gate strict.
**Failure 1 — the image was 431 MB over its size threshold, and npm 11 was the
cause.** Node 22 → 24 brings npm 10 → 11, and npm 11 installs **every**
`@esbuild/<platform>` optional binary rather than only the one matching the host:
26 platform directories covering aix-ppc64, android, darwin, freebsd, netbsd,
openbsd, win32, s390x, riscv64 and more, none of which this image can execute.
Measured on pi-fork's dependency tree, same repo and same command:
| npm | packages | `node_modules` |
| --- | --- | --- |
| 10.9.8 | 136 | **165 MB** |
| 11.19.0 | 169 | **449 MB** |
The 165 MB figure reproduces exactly what v1.8.14 shipped, which is what
identified npm rather than the image as the variable. esbuild declares those
binaries with `os`/`cpu` constraints, but npm 11 ignores them — and also ignores
`--os`/`--cpu` flags and an `.npmrc` carrying `os=`/`cpu=` (all three measured,
all three still produced 26 directories). So `Dockerfile.variant` now prunes
explicitly, keeping only `linux-$(node -p process.arch)` so one line is correct
on amd64 and arm64. Verified this removes dead weight and not function: after
pruning, `esbuild.transformSync` still compiles TypeScript. The prune runs in the
**same layer** as each `npm install` — deleting in a later `RUN` would leave the
bytes in the earlier layer and shrink the image by nothing. Three sites are
covered: the global pi install, pi-fork, and pi-studio (which pulls its own
pi-coding-agent copy), for roughly 548 MB recovered in the non-studio variant and
822 MB in studio. The threshold stays at 3800 MB deliberately: it caught a real
regression, and raising it to accommodate one would have discarded the signal.
**Failure 2 — the om `node_modules` assertion was checking an npm artefact, not
the software.** `pi-observational-memory` declares **zero** runtime dependencies:
8 devDependencies (omitted by `--omit=dev`) and 4 peerDependencies, which pi
itself provides. npm 10 still materialised a `node_modules` for it, but that
directory contained exactly **one file** (`.package-lock.json`, 4 KB) and no
nested `package.json` — 20 empty scope directories. npm 11 stopped creating it,
so `test -d node_modules` went red while nothing about om had changed or broken.
The assertion now checks what must actually hold — that the entry point pi loads
exists — read out of the manifest pi itself reads (`package.json` →
`pi.extensions`) rather than a hardcoded path that could drift. pi-fork keeps its
`node_modules` check, because pi-fork has real dependencies where the directory's
absence would mean something.
**Failure 3 — the skill-source annotation broke the assertion that reads it.**
v1.9.0 taught `pi-devbox-version` to say *which* pi-extensions copy shipped
(`baked (package copy)`, or a loud FALLBACK/MIXED marker). The smoke assertion
matched `^ $s +baked$`, anchored at the end, so the annotation failed it even
though the state reported was correct. The pattern now allows an optional
` (...)` suffix, matched loosely on purpose: *which* copy shipped is already
asserted authoritatively against the manifest field and its measured tree hash,
and re-encoding that wording in a second regex would just add a second place to
update. The lesson recorded rather than the fix alone: the display branches were
tested in an isolated harness that passed, but the assertion **consuming** them
was never run — harness-passes-therefore-consumer-passes was an assumption.
**A red size assertion now carries its own diagnostic.** Attributing the 431 MB
took a full CI-log dig plus a local npm bisect, while the container knew where
its bytes were the whole time. On failure the check now prints the largest
layers, the largest directories, and a count of `@esbuild` platform directories
as a sentinel for this exact regression recurring — the same principle the `run()`
helper already applies to every other assertion.
No `Dockerfile.base` or `rootfs/` change, so the base fingerprint is untouched
and `base-decide` reuses `base-0fb1256c7f99` built during the v1.9.0 attempt.
**A gate for documentation drift, because five claims rotted in one release and
one of them was published.** Preparing v1.9.0 turned up a cluster of stale
facts, all the same shape — a value written once by hand, in a file nothing
verifies, about a number that lives somewhere else and moved:
- `README.md`'s "Version pins" table was wrong on **all three rows**: pi
`0.84.4` vs `ARG PI_VERSION=0.85.1`, pi-atelier `v0.10.0` vs `v0.10.1`,
mempalace `3.8.0` vs `3.9.0`. That table is the worst possible place for this,
because it exists *specifically* to be the reviewable record of what the repo
freezes deliberately — so a wrong row destroys the only thing it is for.
- `README.md` listed already-shipped typst PDF export under "Planned for an
upcoming minor release", carrying the self-contradicting marker "(shipped in
Unreleased/base)". The **fourth** instance of the stale-`Unreleased`-pointer
class this changelog already documented three of.
- `DOCKER_HUB.md` claimed "Node.js v22" while v1.9.0 ships Node 24.
The last one is why this became a gate rather than a resolution to be careful.
`DOCKER_HUB.md` is **published**: `update-description` POSTs it to Docker Hub as
`full_description` on every tag. It had gone **eight releases** (v1.8.6 →
v1.9.0) without a touch. Nothing generates it — CI only substitutes
`{{PI_VERSION}}` — and nothing checked it, so the sole mechanism keeping it true
was whoever remembered. Worse, it is read from the **tag**, so the stale page
published with v1.9.0 anyway and the fix could only ride the next release.
**New: `scripts/check-doc-drift.sh` + a `doc-drift` job in `lint.yml`.** Seven
checks, all comparing a doc string to a value that exists in this repo, so it
needs no network, no token, no built image, and no sibling clone:
- README's three pin-table rows vs the ARGs they name *by name*
- `DOCKER_HUB.md`'s Node claim vs `ARG NODE_VERSION`
- placeholders CI will not substitute — the publish step greps for leftovers of
`{{PI_VERSION}}` only, so any *second* token sails through and publishes
literally
- `DOCKER_HUB.md` under Docker Hub's 25 000-char `full_description` limit
(previously discoverable only as a non-200 *after* the full build)
- `Unreleased` appearing in a user-facing doc, which is always a pointer that
outlived what it pointed at
Exit codes match `lint-shell.sh` and `check-skill-floor.sh`: `0` in sync, `1`
drift, `2` cannot run — a renamed ARG makes the gate blind, which is a red `2`,
never a green tick. Verified with **15 controls**: every check fails when its
claim is broken, the real v1.9.0 Node bug is caught, and two false-positive
controls pass — the first version of the placeholder check wrongly flagged
`README.md:900`'s `docker inspect --format '{{json .Config.Labels}}'`, a Go
template in a legitimate example, so the pattern is now anchored to the
UPPER_SNAKE convention CI actually substitutes. **The gate was wrong, not the
doc** — which is the whole reason a gate gets negative controls.
Deliberately **not** gated, and the reasons matter more than the list:
- Counts and sizes (`~1.1 GB`, "N `mempalace_*` tools", "7 extensions") need a
running image. A gate that cannot evaluate a claim honestly would have to
guess, and a guessing gate is worse than none — assert these in
`scripts/smoke-test.sh`, where a real image exists.
- `Dockerfile.base`'s `# BASE_REBUILD_DATE:` marker, itself stale (2026-07-13,
three base rebuilds ago). `base_tag` hashes Dockerfile.base's *content*,
comments included, so demanding it be current would force a ~60 min base
rebuild on a release that touched no base files at all. It is free to fix
while the base is *already* rebuilding, and expensive at any other moment.
That cost asymmetry is now written into the release checklist rather than
enforced.
**Release checklist step 3 rewritten** (`AGENTS.md`) around the mechanism that
made this expensive: `docker-publish.yml` runs `actions/checkout@v4` with no
`ref:`, so every job reads `github.ref` — the tag. Docs must be correct *before*
tagging; afterwards the only routes are re-pointing the tag (its own hazard —
v1.8.14 went `601fc98` → `361babd` and broke deploy verification until
`git fetch --tags --force`) or waiting for the next release. The step now also
names what the gate cannot see, so "gate is green" is not mistaken for "docs are
true". The same reflex went into the `ci-release-watcher` skill, as the first
correctness rule — it is the only one that expires once the tag exists.
Also fixed in passing: README's `pi-devbox-version` sample was v1.5.0-era and
structurally outdated (it predated the `palace:` line the surrounding prose
advertises, the `pi-atelier` component, and the whole `skills:` block). Replaced
with real observed output rather than hand-written text. `DOCKER_HUB.md`'s "7
user-facing extensions" was **verified correct**; its "29 `mempalace_*` tools"
is stale (a live client shows 45) but left alone rather than corrected on a
guess, since that count cannot be attributed to the baked 3.9.0 server without
measuring it.
## v1.9.0 — 2026-09-10 (tagged, never published — superseded by v1.9.1)
> This tag exists in git but no image was ever pushed for it: `smoke` failed
> three assertions and skipped every downstream job. Everything below ships in
> **v1.9.1**, whose entry explains the three failures and their fixes. Kept as
> its own section rather than folded away, because the tag is real and someone
> will eventually find it and wonder why Docker Hub has no v1.9.0.
**`shellcheck` is now in the image, because the release gate it depends on could **`shellcheck` is now in the image, because the release gate it depends on could
not be run by anyone.** v1.8.14 made shell lint a release gate: `scripts/lint-shell.sh` not be run by anyone.** v1.8.14 made shell lint a release gate: `scripts/lint-shell.sh`
+1 -1
View File
@@ -94,7 +94,7 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
uv run --with jupyterlab jupyter lab --no-browser --port 8888 uv run --with jupyterlab jupyter lab --no-browser --port 8888
uv run --with marimo marimo edit uv run --with marimo marimo edit
``` ```
- **Node.js** v22 + npm (used by pi itself) - **Node.js** v24 LTS + npm (used by pi itself)
- **Rust** — `rustup-init` is on PATH; install toolchains on demand - **Rust** — `rustup-init` is on PATH; install toolchains on demand
- **Go** — opt-in via `--build-arg INSTALL_GO=true` if rebuilding from source - **Go** — opt-in via `--build-arg INSTALL_GO=true` if rebuilding from source
+33
View File
@@ -196,6 +196,25 @@ RUN set -e && \
done; \ done; \
return 1; \ return 1; \
} && \ } && \
# prune_foreign_esbuild: npm 11 (shipped with Node 24) installs EVERY
# @esbuild/<platform> optional binary instead of only the one matching the
# host — 26 platform dirs, 284 MB, for aix-ppc64/android/darwin/freebsd/
# netbsd/openbsd/win32/s390x/riscv64/... that this image can never execute.
# Measured on pi-fork's tree: npm 10.9.8 -> 165 MB, npm 11.19.0 -> 449 MB,
# and the 165 MB figure reproduces what v1.9.0's predecessor actually shipped.
# esbuild declares those with os/cpu constraints, but npm 11 ignores them and
# ALSO ignores --os/--cpu and an npmrc carrying os=/cpu= (all three measured).
# So prune explicitly, keeping only linux-$(node -p process.arch) so the same
# line is correct on amd64 and arm64. Verified after pruning that esbuild still
# works (transformSync compiles TS), i.e. this removes dead weight, not function.
# MUST run in the SAME layer as the npm installs above: deleting in a later RUN
# leaves the bytes in this layer and shrinks the image by nothing.
prune_foreign_esbuild() { \
keep="linux-$(node -p process.arch)"; \
find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \
! -name "$keep" -prune -exec rm -rf {} + ; \
echo "esbuild platform dirs kept: $(find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' -printf '%f\\n' 2>/dev/null | sort -u | tr '\\n' ' ')"; \
} && \
if [ "${PI_VERSION}" = "latest" ]; then \ if [ "${PI_VERSION}" = "latest" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \ NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
else \ else \
@@ -209,6 +228,7 @@ RUN set -e && \
git_fetch_ref "${PI_ATELIER_REPO}" "${PI_ATELIER_REF}" /opt/pi-atelier && \ 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-fork && npm install --omit=dev --no-audit --no-fund) && \
(cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \ (cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \
prune_foreign_esbuild && \
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \ 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-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-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
@@ -309,6 +329,18 @@ ARG PI_STUDIO_REF=main
ARG PI_STUDIO_VERSION=none ARG PI_STUDIO_VERSION=none
RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \ RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
set -e; \ set -e; \
# Same esbuild prune as the main install RUN — see the comment there. It has
# to be redefined because shell functions do not survive across layers, and
# it has to run in THIS layer because pi-studio's npm install happens here:
# deleting in a later RUN would leave the bytes in this layer and shrink
# nothing. pi-studio pulls its own pi-coding-agent copy, so it is a third
# ~274 MB site on top of the two in the non-studio variant.
prune_foreign_esbuild() { \
keep="linux-$(node -p process.arch)"; \
find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \
! -name "$keep" -prune -exec rm -rf {} + ; \
echo "esbuild platform dirs kept: $(find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' -printf '%f\\n' 2>/dev/null | sort -u | tr '\\n' ' ')"; \
}; \
rm -rf /opt/pi-studio && mkdir -p /opt/pi-studio && \ rm -rf /opt/pi-studio && mkdir -p /opt/pi-studio && \
git -C /opt/pi-studio init -q && \ git -C /opt/pi-studio init -q && \
git -C /opt/pi-studio remote add origin "${PI_STUDIO_REPO}" && \ git -C /opt/pi-studio remote add origin "${PI_STUDIO_REPO}" && \
@@ -320,6 +352,7 @@ RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
done; \ done; \
[ "$ok" = "1" ] && \ [ "$ok" = "1" ] && \
(cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \ (cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \
prune_foreign_esbuild && \
echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \ echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \
fi fi
+24 -19
View File
@@ -175,12 +175,10 @@ Currently published:
| `joakimp/pi-devbox:latest-studio` | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio) (browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs) | ~3.25 GB | | `joakimp/pi-devbox:latest-studio` | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio) (browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs) | ~3.25 GB |
| `joakimp/pi-devbox:vX.Y.Z-studio` | pinned-version studio equivalent | ~3.25 GB | | `joakimp/pi-devbox:vX.Y.Z-studio` | pinned-version studio equivalent | ~3.25 GB |
Planned for an upcoming minor release: Both variants ship **`typst`** as the pandoc PDF engine
(`pandoc --pdf-engine=typst`), a single ~30 MB static binary, so PDF export from
- *(shipped in Unreleased/base)* **PDF export from Studio/pandoc** now works: Studio/pandoc works out of the box — no separate `-tex` variant needed.
the base image ships **`typst`** as the PDF engine (`pandoc --pdf-engine=typst`), `texlive-xetex` stays the higher-fidelity fallback (install on demand).
a single ~30 MB static binary — no separate `-tex` variant needed.
`texlive-xetex` stays the higher-fidelity fallback (install on demand).
## Using pi-studio (`-studio` variant) ## Using pi-studio (`-studio` variant)
@@ -919,16 +917,23 @@ through `jq` yourself:
```console ```console
$ pi-devbox-version $ pi-devbox-version
pi-devbox v1.5.0 pi-devbox v1.8.14
built: 2026-07-13T17:53:16Z (source d68674d11e06) built: 2026-09-08T21:54:07Z (source 361babd4fd61)
pi: 0.80.6 pi: 0.85.1
palace: 3.9.0
components: components:
pi-toolkit: 9a8f6faeaa08 pi-toolkit: adfb553f5c8a
pi-extensions: 61c98e004e3d pi-extensions: 2610545c83bb
pi-fork: 4a09af4ef527 pi-fork: e69725c39603
pi-observational-memory: 27a5195eaf90 pi-observational-memory: ce9fc982b3a2
mempalace-toolkit: 96699f2a1781 pi-atelier: 734258bbcb62
pi-studio: 2ef38ef31cea mempalace-toolkit: e45f6b430181
pi-studio: e04fc7aa3275
skills:
credential-incident-response baked
mempalace live /workspace/skillset @ 4d7c0ea (identical to baked snapshot)
pi-devbox-environment baked
pi-extensions baked
``` ```
It also flags **live drift** — if `pi --version` no longer matches what was It also flags **live drift** — if `pi --version` no longer matches what was
@@ -1093,7 +1098,7 @@ persisted volumes survived, and pi runtime wiring is intact:
```bash ```bash
./scripts/recreate-sanity-check.sh # auto-detects variant ./scripts/recreate-sanity-check.sh # auto-detects variant
./scripts/recreate-sanity-check.sh --expected-image-version 1.8.9 # assert the pi-devbox release tag ./scripts/recreate-sanity-check.sh --expected-image-version 1.8.9 # assert the pi-devbox release tag
./scripts/recreate-sanity-check.sh --expected-version 0.84.4 # assert the pi coding agent version ./scripts/recreate-sanity-check.sh --expected-version 0.85.1 # assert the pi coding agent version
``` ```
Those are **two different versions**, and the flags are not interchangeable: Those are **two different versions**, and the flags are not interchangeable:
@@ -1132,9 +1137,9 @@ resolved to `latest` at build time:
| Component | Pin | Where | | Component | Pin | Where |
|---|---|---| |---|---|---|
| pi | `0.84.4` | `ARG PI_VERSION` — `Dockerfile.variant` | | pi | `0.85.1` | `ARG PI_VERSION` — `Dockerfile.variant` |
| pi-atelier | `v0.10.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` | | pi-atelier | `v0.10.1` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
| mempalace | `3.8.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` | | mempalace | `3.9.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
The objective is **not** to freeze versions. Bumping is routine — usually one The objective is **not** to freeze versions. Bumping is routine — usually one
line plus a changelog note. The objective is that adopting a new upstream line plus a changelog note. The objective is that adopting a new upstream
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env bash
# check-doc-drift.sh — fail when a hand-maintained doc claim contradicts the
# build files it describes.
#
# THE DEFECT CLASS THIS EXISTS TO CATCH, measured 2026-09-10 while preparing
# v1.9.0. Five separate claims had rotted, all of them the same shape: a fact
# written once by hand, in a file nothing verifies, about a value that lives
# somewhere else and moved.
#
# 1..3. README.md's "Version pins" table was wrong on EVERY row — pi `0.84.4`
# vs ARG PI_VERSION=0.85.1, pi-atelier `v0.10.0` vs v0.10.1, mempalace
# `3.8.0` vs 3.9.0. That table is the worst possible place for this: it
# exists precisely to be the reviewable record of what is deliberately
# frozen, so when it lies, the review it enables is worthless.
# 4. README.md carried a "Planned for an upcoming minor release" section
# listing typst PDF export, which had ALREADY SHIPPED, tagged with a
# self-contradicting "(shipped in Unreleased/base)" marker. The
# CHANGELOG had already documented three earlier instances of exactly
# this stale-"Unreleased"-pointer class (see its v1.8.7 notes).
# 5. DOCKER_HUB.md claimed "Node.js v22" while this release ships Node 24.
# This one is the reason the gate exists at all: DOCKER_HUB.md is
# PUBLISHED. `update-description` in docker-publish.yml POSTs it to Hub
# as full_description on every tag, so unlike README.md — which no
# workflow or gate reads — a stale claim here is what users see.
#
# WHY A GATE AND NOT "REMEMBER TO CHECK". DOCKER_HUB.md had gone eight releases
# (v1.8.6 → v1.9.0) without a touch. Nothing generates it and nothing verifies
# it; the only mechanism keeping it true was whoever remembered. That is the
# same failure mode check-skill-floor.sh was written for, and the same fix:
# convert "someone remembers" into "CI refuses".
#
# WHY THESE FIVE CHECKS AND NOT MORE. Every check here compares a doc string to
# a value that EXISTS IN THIS REPO, so it can never be wrong about the world and
# needs no network, no token, and no built image. Claims that require a running
# container to verify (image sizes, the "N mempalace_* tools" count) are
# deliberately NOT gated: a check that cannot be evaluated honestly at lint time
# would either be skipped or guessed, and a guessing gate is worse than none.
# If you want those, assert them in scripts/smoke-test.sh where a real image is
# available.
#
# DELIBERATELY NOT GATED: Dockerfile.base's `# BASE_REBUILD_DATE:` comment, which
# is also stale (2026-07-13, three base rebuilds ago). base_tag is a hash of
# Dockerfile.base's CONTENT plus rootfs/, comments included, so a gate that
# demanded that comment be current would force a ~60 min base rebuild on any
# release that touched no base files at all. Fix it when you are already
# rebuilding the base — then it is free. This is a real cost asymmetry, not
# laziness.
#
# EXIT CODES (same contract as lint-shell.sh and check-skill-floor.sh):
# 0 every checked claim matches
# 1 at least one claim has drifted
# 2 cannot run (a file or ARG this gate reads is missing/unparseable)
# A gate that cannot run must not pass, so a missing input is 2, never 0.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
README="README.md"
HUB="DOCKER_HUB.md"
DF_VARIANT="Dockerfile.variant"
DF_BASE="Dockerfile.base"
# Docker Hub rejects a full_description longer than this. docker-publish.yml has
# no size check of its own; it only notices via a non-200 from the API, i.e.
# after paying the whole build. Catching it here makes it a 2-second failure.
HUB_MAX_CHARS=25000
WARN_ONLY=0
FAILURES=0
usage() {
cat <<'EOF'
Usage: check-doc-drift.sh [--warn-only] [-h|--help]
Compares hand-written claims in README.md and DOCKER_HUB.md against the build
files they describe (Dockerfile.base, Dockerfile.variant).
--warn-only Report drift but exit 0 (advisory use, e.g. a local pre-push hook).
Exit: 0 = in sync, 1 = drift, 2 = cannot run.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--warn-only) WARN_ONLY=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "::error::unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
for f in "$README" "$HUB" "$DF_VARIANT" "$DF_BASE"; do
if [ ! -f "$f" ]; then
echo "::error::$f not found (cwd $PWD). Cannot evaluate doc drift, so this is exit 2, not a pass."
exit 2
fi
done
# Read `ARG NAME=value` from a Dockerfile. Exit 2 when absent: if the ARG this
# gate is built around has been renamed, the gate is measuring nothing and must
# say so rather than silently comparing against an empty string.
read_arg() {
local file="$1" name="$2" value
value="$(sed -n "s/^ARG ${name}=\\(.*\\)\$/\\1/p" "$file" | head -1)"
if [ -z "$value" ]; then
echo "::error::ARG ${name} not found in ${file}. It was probably renamed;" >&2
echo "::error::update check-doc-drift.sh to match, because this gate is now blind." >&2
exit 2
fi
printf '%s' "$value"
}
# One row of README's "Version pins" table: `| pi | `0.85.1` | ... |`
read_pin_row() {
sed -n "s/^| $1 | \`\\([^\`]*\`*\\)\` |.*/\\1/p" "$README" | head -1
}
fail() {
FAILURES=$((FAILURES + 1))
echo "::error::$1"
}
ok() { printf ' OK %s\n' "$1"; }
echo "Checking hand-maintained doc claims against the build files they describe."
echo
# ---------------------------------------------------------------------------
# 1-3. README's version-pin table vs the ARGs it names by name.
# ---------------------------------------------------------------------------
check_pin() {
local label="$1" documented="$2" actual="$3" where="$4"
if [ -z "$documented" ]; then
fail "README.md: no '| $label |' row found in the version-pin table. Either the
table was restructured (update this gate) or the row was dropped (restore it)."
return
fi
if [ "$documented" != "$actual" ]; then
fail "README.md version-pin table is stale for $label: says '$documented',
$where says '$actual'. Fix the table — it is the reviewable record of what
this repo deliberately freezes, so a wrong row defeats its only purpose."
return
fi
ok "README pin $label = $actual"
}
PI_ACTUAL="$(read_arg "$DF_VARIANT" PI_VERSION)"
ATELIER_ACTUAL="$(read_arg "$DF_VARIANT" PI_ATELIER_REF)"
MEMPALACE_ACTUAL="$(read_arg "$DF_BASE" MEMPALACE_VERSION)"
check_pin pi "$(read_pin_row pi)" "$PI_ACTUAL" "ARG PI_VERSION in $DF_VARIANT"
check_pin pi-atelier "$(read_pin_row pi-atelier)" "$ATELIER_ACTUAL" "ARG PI_ATELIER_REF in $DF_VARIANT"
check_pin mempalace "$(read_pin_row mempalace)" "$MEMPALACE_ACTUAL" "ARG MEMPALACE_VERSION in $DF_BASE"
# ---------------------------------------------------------------------------
# 4. DOCKER_HUB.md's Node claim vs ARG NODE_VERSION. This is the published page,
# so it is the one whose staleness reaches users.
# ---------------------------------------------------------------------------
NODE_ACTUAL="$(read_arg "$DF_BASE" NODE_VERSION)"
NODE_DOCUMENTED="$(sed -n 's/.*\*\*Node\.js\*\* v\([0-9][0-9]*\).*/\1/p' "$HUB" | head -1)"
if [ -z "$NODE_DOCUMENTED" ]; then
fail "$HUB: could not find a '**Node.js** vNN' claim. If the wording changed,
update this gate; do not leave the published page unverified."
elif [ "$NODE_DOCUMENTED" != "$NODE_ACTUAL" ]; then
fail "$HUB claims Node v$NODE_DOCUMENTED but ARG NODE_VERSION=$NODE_ACTUAL.
This file is PUBLISHED to Docker Hub by update-description on every tag,
and it is read from the TAG — so fix it before tagging, not after."
else
ok "$HUB Node claim = v$NODE_ACTUAL"
fi
# ---------------------------------------------------------------------------
# 5. Placeholders CI will not substitute. docker-publish.yml substitutes exactly
# {{PI_VERSION}} and then greps for leftovers of that ONE token, so any other
# {{...}} sails through the guard and is published literally.
# ---------------------------------------------------------------------------
UNKNOWN_PLACEHOLDERS="$(grep -o '{{[A-Za-z0-9_]*}}' "$HUB" | sort -u | grep -v '^{{PI_VERSION}}$' || true)"
if [ -n "$UNKNOWN_PLACEHOLDERS" ]; then
fail "$HUB contains placeholders CI does not substitute, which would be
published verbatim: $(echo "$UNKNOWN_PLACEHOLDERS" | tr '\n' ' ')
docker-publish.yml only fills {{PI_VERSION}}; add substitution there first."
else
ok "$HUB has no placeholders beyond {{PI_VERSION}}"
fi
# Match only the UPPER_SNAKE placeholder convention CI uses. A bare '{{' search
# is WRONG here, and the first version of this check proved it by failing on
# README.md:900 — `docker inspect --format '{{json .Config.Labels}}'`, a Go
# template in a legitimate example, not a placeholder. The gate was wrong, not
# the doc. Keep this anchored to [A-Z] so Go/Jinja/Handlebars examples pass.
README_PLACEHOLDERS="$(grep -o '{{[A-Z][A-Z0-9_]*}}' "$README" | sort -u || true)"
if [ -n "$README_PLACEHOLDERS" ]; then
fail "$README contains placeholder(s) nothing substitutes, so they would render
literally for every reader: $(echo "$README_PLACEHOLDERS" | tr '\n' ' ')
Only DOCKER_HUB.md gets substitution, and only for {{PI_VERSION}}."
else
ok "$README has no unsubstituted placeholders"
fi
# ---------------------------------------------------------------------------
# 6. Hub full_description length.
# ---------------------------------------------------------------------------
HUB_CHARS="$(wc -c < "$HUB" | tr -d ' ')"
if [ "$HUB_CHARS" -gt "$HUB_MAX_CHARS" ]; then
fail "$HUB is $HUB_CHARS chars, over Docker Hub's $HUB_MAX_CHARS-char
full_description limit. update-description would fail with a non-200 AFTER
the full build. Trim it — this file is the essentials-only page, and
README.md is the long form on purpose."
else
ok "$HUB is $HUB_CHARS chars (limit $HUB_MAX_CHARS)"
fi
# ---------------------------------------------------------------------------
# 7. Stale "Unreleased" pointers. "Unreleased" is a CHANGELOG-only concept; in
# a user-facing doc it is always a pointer that outlived what it pointed at.
# This class has now bitten five times, hence a gate rather than vigilance.
# ---------------------------------------------------------------------------
STALE_MARKERS="$(grep -n 'Unreleased' "$README" "$HUB" || true)"
if [ -n "$STALE_MARKERS" ]; then
fail "'Unreleased' appears in a user-facing doc, which is always a stale
pointer once the thing ships (it has happened five times here):
${STALE_MARKERS//$'\n'/$'\n' }
State the fact directly, or move it to CHANGELOG.md where 'Unreleased' means something."
else
ok "no stale 'Unreleased' pointers in $README or $HUB"
fi
echo
if [ "$FAILURES" -eq 0 ]; then
echo "OK: every checked doc claim matches the build files."
exit 0
fi
echo "::error::$FAILURES doc claim(s) have drifted from the build files."
echo
echo "Docs are read from the TAG, not from main: docker-publish.yml checks out"
echo "github.ref, so a fix pushed after tagging does not reach the release or the"
echo "Hub page. Update the docs BEFORE you tag."
if [ "$WARN_ONLY" -eq 1 ]; then
echo "(--warn-only: exiting 0 anyway)"
exit 0
fi
exit 1
+45 -3
View File
@@ -315,8 +315,24 @@ run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD" run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
run "pi-fork clone + node_modules" \ run "pi-fork clone + node_modules" \
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules" "test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
run "pi-observational-memory clone + node_modules" \ # om is checked differently from pi-fork ON PURPOSE. It declares ZERO runtime
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules" # dependencies: 8 devDependencies (omitted by --omit=dev) and 4 peerDependencies,
# which pi itself provides. npm 10 still materialised a node_modules for it, but
# that directory held exactly ONE file (.package-lock.json, 4 KB) and no nested
# package.json at all — 20 empty scope dirs. npm 11 stopped creating it, so the
# old `test -d node_modules` assertion went red on v1.9.0 while nothing about om
# had changed or broken. It was asserting an npm artefact, not a property of the
# shipped software. What actually has to hold is that the entry point pi loads
# exists, so assert THAT, straight out of the manifest pi reads
# (package.json -> pi.extensions), rather than a hardcoded path that could drift.
run "pi-observational-memory clone + declared pi entry point" \
"test -f /opt/pi-observational-memory/package.json && \
node -e 'const p=require(\"/opt/pi-observational-memory/package.json\"),f=require(\"fs\"),h=require(\"path\"); \
const l=(p.pi&&p.pi.extensions)||[]; \
if(!l.length){console.error(\"package.json declares no pi.extensions\");process.exit(1)} \
for(const e of l){const t=h.resolve(\"/opt/pi-observational-memory\",e); \
if(!f.existsSync(t)){console.error(\"declared entry missing: \"+t);process.exit(1)}} \
console.log(\"entries ok: \"+l.join(\",\"))'"
# ...and that the clone carries the AUTH FIX, not merely that it exists. om's # ...and that the clone carries the AUTH FIX, not merely that it exists. om's
# pre-flight hasUsableAuth() check silently disabled `recall` for ~8 weeks once # pre-flight hasUsableAuth() check silently disabled `recall` for ~8 weeks once
# pi moved to request-time SigV4 signing and stopped exposing a static Bedrock # pi moved to request-time SigV4 signing and stopped exposing a static Bedrock
@@ -697,9 +713,17 @@ exec_test "pi-devbox-version reports skill sources (all baked, no skillset here)
'out=$(pi-devbox-version) 'out=$(pi-devbox-version)
echo "$out" | grep -q "skills:" || { echo "no skills section" >&2; exit 1; } echo "$out" | grep -q "skills:" || { echo "no skills section" >&2; exit 1; }
for s in mempalace pi-extensions pi-devbox-environment credential-incident-response; do for s in mempalace pi-extensions pi-devbox-environment credential-incident-response; do
echo "$out" | grep -qE "^ $s +baked$" \ echo "$out" | grep -qE "^ $s +baked( \([^)]*\))?$" \
|| { echo "$s not reported as baked" >&2; exit 1; } || { echo "$s not reported as baked" >&2; exit 1; }
done; echo ok' done; echo ok'
# The optional " (...)" above is what pi-extensions now appends to say WHICH copy
# shipped — "baked (package copy)", or a loud FALLBACK/MIXED annotation. Without
# allowing it, adding that annotation turned this assertion red on v1.9.0 even
# though the state it reported was the correct one. The suffix is deliberately
# matched loosely rather than pinned to "(package copy)", because WHICH copy
# shipped is already asserted authoritatively above, against the manifest field
# and its measured tree hash, and duplicating that here in a regex would just
# create a second place to update whenever the wording changes.
# The boot banner must NOT carry the section: entrypoint-user.sh prints the # The boot banner must NOT carry the section: entrypoint-user.sh prints the
# version FIRST, before the baked links exist and long before the skillset # version FIRST, before the baked links exist and long before the skillset
# deploy + reconcile run last, so anything it said about skill sources would be # deploy + reconcile run last, so anything it said about skill sources would be
@@ -882,6 +906,24 @@ elif [ "$SIZE_MB" -le "$SIZE_THRESHOLD_MB" ]; then
printf " ✅ size: %d MB (threshold %d MB)\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; PASS=$((PASS+1)) printf " ✅ size: %d MB (threshold %d MB)\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; PASS=$((PASS+1))
else else
printf " ❌ size: %d MB exceeds threshold %d MB\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; FAIL=$((FAIL+1)) printf " ❌ size: %d MB exceeds threshold %d MB\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; FAIL=$((FAIL+1))
# A bare "too big" verdict cost a full CI-log dig plus a local npm bisect to
# attribute the v1.9.0 overshoot (+431 MB, which turned out to be npm 11
# installing 26 @esbuild platform binaries per pi-coding-agent copy). The
# container already knows where its bytes are, so make it say so: the biggest
# layers, and the biggest directories under the paths that historically grow.
# Same principle as the run() helper above — a red assertion should carry its
# own diagnostic rather than send the next reader spelunking.
echo " ── largest layers (docker history) ──"
docker history --format '{{.Size}}\t{{.CreatedBy}}' "$IMAGE" 2>/dev/null \
| grep -vE '^0B' | head -12 | sed 's/^/ /' | cut -c1-160
echo " ── largest directories in the image ──"
docker run --rm --entrypoint sh "$IMAGE" -c \
'du -sm /usr/lib/node_modules/* /opt/* /usr/local/share/ms-playwright 2>/dev/null | sort -rn | head -12' \
2>/dev/null | sed 's/^/ /' || echo " (could not inspect directories)"
echo " ── @esbuild platform dirs (npm 11 regression sentinel) ──"
docker run --rm --entrypoint sh "$IMAGE" -c \
'find /usr/lib/node_modules /opt -type d -regex ".*/@esbuild/[^/]+" -printf "%f\n" 2>/dev/null | sort | uniq -c | sort -rn | head' \
2>/dev/null | sed 's/^/ /' || true
fi fi
# ── Summary ─────────────────────────────────────────────────────────── # ── Summary ───────────────────────────────────────────────────────────