Compare commits
6 Commits
3a44e81cad
...
v1.9.2
| Author | SHA1 | Date | |
|---|---|---|---|
| f5c53b8693 | |||
| 735565b9be | |||
| 9aaff26e3a | |||
| 852f900b53 | |||
| 1baba79c96 | |||
| 42bd29d654 |
@@ -280,8 +280,10 @@ shipped the same image bytes); preventatively fixed for `PI_VERSION` +
|
||||
image. Verifies binaries, repo clones, runtime deployment (waits for
|
||||
keybindings + mempalace bridge + ≥4 extensions before sampling — fixes
|
||||
the parallel-build-load race documented in opencode-devbox c6f9d11
|
||||
2026-06-08), and image size threshold (3500 MB; revisit after a few
|
||||
releases as actuals settle).
|
||||
2026-06-08), build-time leftovers (see below), and image size threshold
|
||||
(3800 MB in `SIZE_THRESHOLD_MB`; revisit after a few releases as actuals
|
||||
settle — this doc said 3500 until 2026-09-11, after the bar had already
|
||||
moved twice).
|
||||
|
||||
If smoke fails on size threshold but build is otherwise fine: bump
|
||||
`SIZE_THRESHOLD_MB` in scripts/smoke-test.sh in a follow-up commit and
|
||||
@@ -289,6 +291,27 @@ re-run. The threshold exists to catch *runaway* growth (an accidental
|
||||
texlive bake-in, a forgotten chrome dependency), not to block ordinary
|
||||
upstream bumps.
|
||||
|
||||
**The size gate is not a substitute for naming the residue.** It carries
|
||||
~225 MB of deliberate margin, so v1.9.1 shipped +131 MB of pure build
|
||||
residue — 110 MB of it npm's own download cache under `/root/.npm`, the
|
||||
rest foreign platform packages — and stayed green. Four named assertions
|
||||
now cover that ground: no foreign npm-11 platform packages beyond the
|
||||
host arch (`@esbuild/*`, `@mariozechner/clipboard-*`), no `/root/.npm` in
|
||||
the image, and — because the prune's real risk is *removing something
|
||||
needed*, not size — esbuild must compile TS and clipboard must load its
|
||||
native binding at **every** install site.
|
||||
|
||||
Two failure shapes to copy from those, both of which bit here:
|
||||
- `test ! -d /root/.npm` on mode-700 `/root` passes for a **permission**
|
||||
error, so the cache assertion refuses to run as non-root. Watch for
|
||||
this in any assertion about a path you may not be allowed to read.
|
||||
- `node -e 'require("esbuild")'` resolves by walking up from the CURRENT
|
||||
DIRECTORY, so it fails with `MODULE_NOT_FOUND` from `/workspace` on a
|
||||
perfectly healthy image (esbuild is nested inside the pi trees;
|
||||
`NODE_PATH` is unset). Always path-qualify: `require("<abs>/esbuild")`.
|
||||
A runbook shipped the bare form with "if this fails, revert the
|
||||
release" attached, and it duly went red for the wrong reason.
|
||||
|
||||
## Build pipeline notes
|
||||
|
||||
- **Two-phase**: base + variant. Base is rebuilt only when
|
||||
|
||||
+334
-2
@@ -11,7 +11,319 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
## v1.9.2 — 2026-09-14
|
||||
|
||||
**The v1.9.1 residual is attributed and fixed: it was mostly npm's own download
|
||||
cache, not the platform binaries it looked like.** v1.9.1 pruned the 26 foreign
|
||||
`@esbuild/<platform>` directories that npm 11 installs, which fixed the 431 MB
|
||||
size-gate failure — but the image still shipped **+131 MB compressed over
|
||||
v1.8.14**, nearly all of it in the single pi/extensions install layer (87 MB →
|
||||
206 MB). v1.9.1's notes recorded the leftover as an open item with an explicit
|
||||
hypothesis (the `@mariozechner/clipboard-*` family, same npm 11 behaviour, a
|
||||
different package) and an explicit warning that the hypothesis was **not** a
|
||||
measured cause. It was measured on 2026-09-11, after recreating onto v1.9.1, and
|
||||
the hypothesis accounted for only a sixth of it:
|
||||
|
||||
| item | v1.8.14 | v1.9.1 | delta |
|
||||
|---|---|---|---|
|
||||
| `/root/.npm/_cacache` — the build's npm download cache | 35.2 MB | 145.3 MB | **+110 MB** |
|
||||
| `@mariozechner/clipboard-*` foreign platform packages (2 sites) | 0 MB | 21.1 MB | **+21 MB** |
|
||||
| variant install layer, uncompressed total | 270.8 MB | 401.9 MB | +131 MB |
|
||||
|
||||
That is the whole delta with no unexplained remainder. Both items are now
|
||||
deleted in the **same layer** that creates them, in both the main install RUN and
|
||||
the studio RUN:
|
||||
|
||||
- **`purge_build_caches`** — `npm cache clean --force` plus `rm -rf /root/.npm`.
|
||||
npm 11 caches every platform tarball it downloads, including the ones the
|
||||
prune then deletes, so the cache grew far faster than the installed tree.
|
||||
Nothing at runtime reads it: the build runs as root, the container runs as
|
||||
`developer` with its own cache under `$HOME`.
|
||||
- **`prune_foreign_esbuild` → `prune_foreign_natives`** — now covers both
|
||||
measured families. For clipboard the keep-set is `clipboard-linux-$arch-gnu`
|
||||
**and** `-musl`, because its napi-rs loader chooses between them at runtime
|
||||
from its own `isMusl()` probe; the musl package is a 420-byte stub, so keeping
|
||||
it is free insurance. The bare `@mariozechner/clipboard` wrapper has no
|
||||
hyphen suffix and cannot match the pattern.
|
||||
|
||||
**Verified on arm64 before writing the patch, which is why the order was
|
||||
update-then-patch:** a widened `rm -rf` glob is the worst possible change to
|
||||
write against a tree you cannot inspect, and there is no docker CLI inside the
|
||||
container — but after a recreate the container *is* the image. The prune was
|
||||
exercised against a copy of the real trees with foreign directories fabricated
|
||||
back in (aix-ppc64, android-arm64, darwin-arm64, win32-x64, linux-x64): all
|
||||
removed, host `linux-arm64` kept at both sites, 21 MB freed, and
|
||||
`require('@mariozechner/clipboard')` still loads and exports all 18 functions.
|
||||
`esbuild.transformSync` still compiles TS at both sites. v1.9.1's own arm64
|
||||
validation of the esbuild prune also passed here — CI could only smoke-test
|
||||
amd64.
|
||||
|
||||
**Two sentinel assertions in `smoke-test.sh`, because the size gate did not
|
||||
catch this.** The gate has ~225 MB of deliberate margin, so 131 MB of pure
|
||||
build residue stayed green. There are now named PASS/FAIL checks for *foreign
|
||||
platform packages beyond the host arch* and for *`/root/.npm` being shipped* —
|
||||
the latter deliberately refuses to run as non-root, because `test ! -d
|
||||
/root/.npm` on mode-700 `/root` would otherwise pass for the wrong reason. The
|
||||
size-failure diagnostics now also list cache paths: they previously enumerated
|
||||
only `node_modules` and `/opt`, where these bytes were not.
|
||||
|
||||
**Also fixed: the prune's own progress line was mangled.** `-printf '%f\\n'`
|
||||
reaches the shell with both backslashes (confirmed from the published image's
|
||||
recorded `created_by`), so `find` emitted a literal backslash and `tr` then ate
|
||||
the `n` out of the name — v1.9.1 printed `esbuild platform dirs kept: li
|
||||
ux-arm64`. Single backslash now.
|
||||
|
||||
Deliberately **not** changed: `/tmp/node-compile-cache` (1.3 MB). The manifest
|
||||
RUN at the end of `Dockerfile.variant` calls `pi --version` again, so deleting
|
||||
it earlier only relocates those bytes into that layer — today's manifest layer
|
||||
is 128 kB precisely because it finds the cache warm.
|
||||
|
||||
**Two functional assertions as well, after the runbook command left for the
|
||||
next machine failed for the wrong reason.** v1.9.1's open item prescribed
|
||||
`node -e 'require("esbuild").transformSync(...)'` as the post-boot check, with
|
||||
"if this fails, the prune removed something needed → revert to v1.8.14". Run
|
||||
from `/workspace` it fails with `MODULE_NOT_FOUND` on a perfectly good image:
|
||||
`require` resolves by walking up from the current directory, esbuild lives
|
||||
nested inside the two pi trees, and global installs are not on node's require
|
||||
path (`NODE_PATH` is unset). The check that verified the prune last time only
|
||||
passed because the shell happened to be inside the tree. Smoke now does it
|
||||
properly and CI owns it: for every install site found in the image (so the
|
||||
studio variant's third site is covered automatically), esbuild must compile TS
|
||||
and `@mariozechner/clipboard` must load with its native binding attached — the
|
||||
latter is the real proof for the clipboard prune, since napi-rs resolves the
|
||||
platform package at `require()` time. Both were verified as a four-way matrix:
|
||||
green on the real image *from `/workspace`*, and red against copies of the same
|
||||
packages with the host platform binary removed (`The package
|
||||
"@esbuild/linux-arm64" could not be found`).
|
||||
|
||||
Touches `Dockerfile.variant` and `scripts/smoke-test.sh` only: `Dockerfile.base`
|
||||
is unchanged, so this needs no base rebuild and should **ride the next release**
|
||||
rather than burn a cycle of its own.
|
||||
|
||||
> **Superseded by the entry below:** that entry refreshes the vendored mempalace
|
||||
> skill snapshot, which **is** hashed into `base_tag`. The release as a whole now
|
||||
> costs a base rebuild (~67 min). The *size* work above still needs none of its
|
||||
> own; the two simply travel together now.
|
||||
|
||||
**A behaviour change reached the fleet without any release naming it, and a
|
||||
paragraph in these notes kept saying it had not.** v1.9.1 bakes
|
||||
`mempalace-toolkit` **`e68ee20`**, which contains **`e2b060a`** — requester-side
|
||||
ask withdrawal (`isWithdrawn`, RFC 003 §3.3 clause 4). So the behaviour has been
|
||||
live on every v1.9.1 device since 2026-09-10, while the v1.9.0 section of this
|
||||
file still read "not yet pinned … this image still pins `e45f6b4`" and the
|
||||
mempalace skill still told every agent, at session start, that a withdrawal is
|
||||
impossible: *"there is nothing anyone can do about it from the other end."*
|
||||
|
||||
The mechanism is the point, because it will do this again. `Dockerfile.variant`
|
||||
carries `ARG MEMPALACE_TOOLKIT_REF=main` and `docker-publish.yml` resolves it to
|
||||
a commit SHA at build time (`gitea_sha mempalace-toolkit`). A release therefore
|
||||
absorbs *whatever toolkit `main` holds at that moment*, and "what behaviour did
|
||||
this image gain?" is a question **nobody is structurally forced to answer**. This
|
||||
fleet already has the rule — a floating ref that pulls a behaviour change into
|
||||
the image must be named in the CHANGELOG *before* tagging. It was honoured for
|
||||
the feed-tick fix, which v1.9.1 names explicitly (`309980b`, `e68ee20`), and
|
||||
missed for the commit sitting in the same range.
|
||||
|
||||
Measured before being written, two independent routes, expectation recorded
|
||||
first ("label should read ≥ `e68ee20`, since the build at 22:00Z postdates that
|
||||
commit's 18:58Z"):
|
||||
|
||||
| route | result |
|
||||
|---|---|
|
||||
| Docker Hub config-blob label, `:v1.9.1-studio` and `:latest-studio` (same digest) | `se.jordbo.pi-devbox.mempalace-toolkit-ref = e68ee2071ca3ad39…` |
|
||||
| `git merge-base --is-ancestor e2b060a e68ee20` | ancestor — the fix is inside the baked ref |
|
||||
| baked `extensions/pi/mempalace.ts` sha256 vs v1.8.14's | `7c16fe14…` vs `dfca71e9…` — different bytes, so not the pre-fix file |
|
||||
| `grep -c isWithdrawn` on this v1.8.14 container's baked copy | `0` — confirms the split, and that tor-ms22 cannot exercise it |
|
||||
|
||||
The first attempt at that label read **empty**, and the empty result was a claim
|
||||
about the request, not the image: Docker Hub redirects blob fetches to a CDN and
|
||||
`curl` without `-L` returns 0 bytes with exit 0. A registry audit that reports
|
||||
"no labels" should be assumed to be missing `-L` until proven otherwise.
|
||||
|
||||
So the skill is updated rather than deferred (skillset **`e9e45f7`**, vendored
|
||||
here with `scripts/vendor-mempalace-skill.sh`, `44472 → 46045 B`). Two things
|
||||
were deliberate:
|
||||
|
||||
- **The "silence is not an answer" rule keeps its teeth.** An ask still stays
|
||||
owed until the *recipient's* terminal event; what is new is a release by the
|
||||
**asker**, explicitly marked. Stated that way round on purpose — the wrong
|
||||
reading of this change is "withdrawals happen, so I need not reply".
|
||||
- **The precondition ships with the rule**, because this skill is read on images
|
||||
that lack the behaviour (tor-ms22, right now):
|
||||
`grep -c isWithdrawn /opt/mempalace-toolkit/extensions/pi/mempalace.ts`, where
|
||||
`0` means the withdrawal will not reach the recipient's mailbox. Same shape as
|
||||
the provenance bullet's live-bridge check.
|
||||
|
||||
**The snapshot canary is re-pinned, and this time it fails on the old bytes
|
||||
instead of merely failing to notice them.** The retired pair (`"Diaries
|
||||
self-heal…"` present / `"Agent diaries live in"` absent) was still green against
|
||||
the new snapshot, so it was blind to this refresh exactly as the pre-v1.8.13 pair
|
||||
was blind to that one. The replacement is stronger than any predecessor here
|
||||
because **both witnesses come from the same upstream commit**: `e9e45f7` added
|
||||
`"Withdrawing an ask you sent"` and deleted `"nothing anyone can do about it from
|
||||
the other end"`, the sentence the new bullet contradicts. Directions were
|
||||
measured against both files rather than read off the diff (`new=1/old=0` and
|
||||
`new=0/old=1`), then the canary body was **executed** against each: new → `rc=0
|
||||
ok`, old → `rc=1` empty.
|
||||
|
||||
**`isWithdrawn` is no longer deployed-and-unproven — and the suite that pins it
|
||||
had been dark since the node 24 bump.** The rule was exercised on the released
|
||||
image against the live logstream on 2026-09-14, from a container recreated onto
|
||||
v1.9.1 (born 13:21:41Z, confirmed by entrypoint-written mtimes and docker-written
|
||||
`/etc` files agreeing to the second; `/proc/uptime` and `ps -o lstart=` were not
|
||||
used, per their retraction). Baked toolkit `e68ee20`, `grep -c isWithdrawn` = 3,
|
||||
mempalace.ts sha256 `7c16fe14…`, and the file pi actually loads verified to be
|
||||
that same path and hash rather than a stale copy.
|
||||
|
||||
The instrument matters as much as the result: the shipped extractor was lifted
|
||||
out of `scripts/test-owed-withdrawal.sh` and used to cut `TERMINAL_STATUS`,
|
||||
`isStrictlyAfter`, `isAnswered` and `isWithdrawn` out of the **baked**
|
||||
mempalace.ts by brace matching, then `deriveOwed`'s three queries were replayed
|
||||
with their exact shipped parameters over the same `/mcp` transport the extension
|
||||
uses. Shipped bytes, live data, no second copy of the logic. Baseline owed = 1,
|
||||
agreed by three independent routes (the extension's own wake-up card; a hand
|
||||
derivation of 23 candidates; the shipped predicates). Every expectation was
|
||||
recorded before its measurement:
|
||||
|
||||
| probe | predicted | observed |
|
||||
|---|---|---|
|
||||
| positive control planted | owed 1 → 2 | 2 |
|
||||
| requester withdraws it | back to 1 | 1, and `isAnswered=false isWithdrawn=true` |
|
||||
| **third party** retracts someone else's ask | no effect | still owed |
|
||||
| requester withdraws in **prose**, no marker | no effect | still owed |
|
||||
| cleanup by ordinary replies | owed == baseline | 1, then 0 |
|
||||
|
||||
The count returning to baseline is only arithmetic; the per-predicate verdict is
|
||||
what makes it a statement about mechanism. Probe A remained a raw candidate
|
||||
throughout and no terminal reply of ours existed on its correlation, so its
|
||||
removal is attributable to the withdrawal rule alone. Final attribution: A
|
||||
cleared by `isWithdrawn` only, the two negative controls cleared by `isAnswered`
|
||||
only — each probe retired by the predicate it was built to exercise. Both marker
|
||||
spellings now have live witnesses (`metadata.withdraws` naming the ask's event id,
|
||||
and naming its correlation). Unplanned and worth more than the probes: against
|
||||
live data the rule also fires on the incident it was written for — seq 112 is
|
||||
reported withdrawn-by-requester, i.e. mbp-m1-2020's seq 119 withdrawal now works,
|
||||
so the 41h false obligation cannot recur. Full record in the coordination log at
|
||||
`project/pi-devbox` seq 140; harness preserved as artifact
|
||||
`art_20260914T141704_a7a9a294a4bb`.
|
||||
|
||||
Not proven, and deliberately not claimed: the extension's **own in-process
|
||||
mailbox poll** surfacing a withdrawn ask. That poll fires at `agent_settled` when
|
||||
the agent is idle; two probes were left owed across the rest of the session to
|
||||
give it a window and it did not fire. Calling that confirmed would be a claim
|
||||
about the session's patience, not about the code.
|
||||
|
||||
**Toolkit pickup `dab989b` — the owed-set suite could not run on this image, and
|
||||
its own gate was why.** Reaching for the suite as corroboration exposed a second
|
||||
defect: its precondition line `node --experimental-strip-types --check "$SRC"`
|
||||
exits 2 on an unmodified mempalace.ts under node 24, and with `set -euo pipefail`
|
||||
that skipped all 17 assertions and both regression guards. The cause is narrower
|
||||
than the error suggests — it names the inline type-import, but `node --check` does
|
||||
not type-strip at all: a file containing only `const x: number = 1` fails
|
||||
identically, while executing the same file works. So the gate could never
|
||||
validate TypeScript on any node; v1.9.1's bump from v22.23.2 to v24.21.0 is the
|
||||
most likely trigger, though with no node 22 on the box that half stays labelled
|
||||
inference rather than measurement. It failed **closed** — loud exit 2, never
|
||||
vacuously green — which is the good direction, and the reason it went unnoticed is
|
||||
that nothing in CI runs this suite.
|
||||
|
||||
That matters more than a red test, because this suite is the only thing that makes
|
||||
`isWithdrawn`'s failure mode visible: a wrong rule there does not throw and does
|
||||
not log, it makes a real unanswered ask vanish from a mailbox forever. `dab989b`
|
||||
strips first and syntax-checks the emitted JS, and splits the exit codes so that
|
||||
`3` means the gate cannot run while `2` means the source does not parse —
|
||||
collapsing those is how the defect disguised itself as "mempalace.ts does not
|
||||
parse" while mempalace.ts was fine. Verified in six directions with expectations
|
||||
written first: clean source `rc=0` 17/17; malformed TypeScript `rc=2`; stripper
|
||||
made unavailable `rc=3` without the misleading message; and the four mutation
|
||||
kills back at e2b060a's counts of 3/1/2/1, so sensitivity is restored rather than
|
||||
asserted.
|
||||
|
||||
> **Cost, and it is smaller than it looks:** `MEMPALACE_TOOLKIT_REF` is resolved
|
||||
> by CI to the head of the toolkit's `main` and folded into the `base_tag` hash —
|
||||
> verified at `docker-publish.yml:126-128`, whose own comment gives the reason
|
||||
> ("otherwise a toolkit-only fix never lands"). So `dab989b` moves `base_tag` and
|
||||
> the next tag rebuilds the base, with nothing to remember to trigger. But it adds
|
||||
> no rebuild that was not already owed: `9aaff26` refreshed the vendored skill
|
||||
> snapshot under `rootfs/`, which is also hashed into `base_tag`, so a base
|
||||
> rebuild has been pending since before this fix existed. The toolkit pickup rides
|
||||
> along with it, and the same rebuild is what finally bakes skillset `e9e45f7` and
|
||||
> turns the snapshot canary above green against the image's own floor.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -94,7 +406,13 @@ 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
|
||||
## 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
|
||||
not be run by anyone.** v1.8.14 made shell lint a release gate: `scripts/lint-shell.sh`
|
||||
@@ -175,6 +493,20 @@ derivation's `mine` query at the newest end (`order: "desc"`); with the previous
|
||||
default `asc` + `limit: 100`, a device passing 100 authored events would have its
|
||||
recent replies fall out of the join window and see answered asks resurface.
|
||||
|
||||
> **Corrected 2026-09-14 (`pi@tor-ms22`, the device in the measured cost above).**
|
||||
> "This image still pins `e45f6b4`" and "until an image bakes `e2b060a` or later"
|
||||
> were true when written on 2026-09-09 and are **false for the running fleet**.
|
||||
> **v1.9.1 bakes `e68ee20`**, a descendant of `e2b060a`, so requester-side
|
||||
> withdrawal is LIVE wherever v1.9.1 runs. Measured from the published image's own
|
||||
> label (`se.jordbo.pi-devbox.mempalace-toolkit-ref`) rather than from these
|
||||
> notes, and cross-checked by ancestry and by the baked `mempalace.ts` sha
|
||||
> differing from v1.8.14's. Nothing here was mis-stated on purpose:
|
||||
> `ARG MEMPALACE_TOOLKIT_REF=main` is resolved to a commit SHA by CI at build
|
||||
> time, so the release absorbed the commit without anybody having to name it,
|
||||
> while this paragraph went on asserting it had not. Left standing rather than
|
||||
> rewritten — the sentence is the evidence for how the drift happened. See
|
||||
> Unreleased.
|
||||
|
||||
**Four small packages, each chosen from a gap that was measured rather than
|
||||
imagined.** All four were picked by looking back at a real session — the
|
||||
`gitea.egl.lan`/FreeIPA debugging of 2026-09-09..10 — and asking which absences
|
||||
|
||||
+90
-1
@@ -196,6 +196,71 @@ RUN set -e && \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
# prune_foreign_natives: npm 11 (shipped with Node 24) installs EVERY optional
|
||||
# platform package of a native dependency, not just the one matching the host.
|
||||
# TWO families are affected in this image, and BOTH have been measured — add a
|
||||
# family here only after measuring it, never by widening the pattern on a hunch:
|
||||
#
|
||||
# @esbuild/<platform> 26 dirs, 284 MB (found first, v1.9.1)
|
||||
# @mariozechner/clipboard-<triple> 11 dirs, 12 MB per site, 10 MB foreign
|
||||
#
|
||||
# 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 the host platform, computed from
|
||||
# `node -p process.arch` so one line stays correct on amd64 and arm64.
|
||||
# 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.
|
||||
#
|
||||
# WHY THE CLIPBOARD FAMILY WAS ADDED (2026-09-11): v1.9.1 pruned @esbuild only
|
||||
# and still shipped +131 MB compressed over v1.8.14. That residual was
|
||||
# attributed by listing the PUBLISHED arm64 layer tarballs straight from the
|
||||
# registry (there is no docker CLI inside the container, so `docker history`
|
||||
# was not available): +110 MB /root/.npm/_cacache (purged below) and +21 MB of
|
||||
# clipboard platform packages across the two install sites — 131 MB total, so
|
||||
# the delta is now fully accounted for with no unexplained remainder.
|
||||
#
|
||||
# Keeping linux-$arch-{gnu,musl} is deliberate: clipboard's napi-rs loader
|
||||
# tries ./<name>.node then the platform package, per platform in try/catch, and
|
||||
# chooses gnu vs musl at runtime from its own isMusl() probe — so both host-arch
|
||||
# branches must survive. The musl package is a 420-byte stub, i.e. free. The
|
||||
# bare wrapper `@mariozechner/clipboard` has no hyphen suffix and therefore
|
||||
# cannot match the regex below. Verified on arm64 against a copy of the real
|
||||
# tree before this was written: after pruning to those two,
|
||||
# require('@mariozechner/clipboard') still loads and exports all 18 functions.
|
||||
# esbuild likewise still compiles TS via transformSync at both install sites.
|
||||
# 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.
|
||||
# NOTE the single backslash in -printf '%f\n': Docker passes '\\n' through
|
||||
# verbatim, so v1.9.1's doubled version printed a mangled "li ux-arm64"
|
||||
# (find emitted a literal backslash, then `tr` translated the n out of the
|
||||
# name). Confirmed from the published image's own recorded created_by.
|
||||
prune_foreign_natives() { \
|
||||
arch="$(node -p process.arch)"; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \
|
||||
! -name "linux-$arch" -prune -exec rm -rf {} + ; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@mariozechner/clipboard-[^/]+' \
|
||||
! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" \
|
||||
-prune -exec rm -rf {} + ; \
|
||||
echo "native platform dirs kept: $(find /usr/lib/node_modules /opt -type d \( -regex '.*/@esbuild/[^/]+' -o -regex '.*/@mariozechner/clipboard-[^/]+' \) -printf '%f\n' 2>/dev/null | sort | uniq -c | tr '\n' ' ')"; \
|
||||
} && \
|
||||
# purge_build_caches: the build's own download caches are NOT free — they land
|
||||
# in whichever layer created them. Measured on the published v1.9.1 arm64
|
||||
# variant layer: root/.npm/_cacache was 145.2 MB of a 401.9 MB layer (35.2 MB
|
||||
# in v1.8.14), the single biggest item in the +131 MB residual, because npm 11
|
||||
# caches every platform tarball it fetched — including the ones just pruned.
|
||||
# Nothing at runtime reads it: the build runs as root, the container runs as
|
||||
# `developer` with its own cache under $HOME (and $HOME/.pi is a volume).
|
||||
# DELIBERATELY NOT purged here: /tmp/node-compile-cache (1.3 MB, written by
|
||||
# `pi --version` below). The manifest RUN at the end of this file calls
|
||||
# `pi --version` again, so deleting it here only relocates those bytes into
|
||||
# that layer instead of removing them from the image — measured, not assumed:
|
||||
# today the manifest layer is 128 kB precisely because it finds the cache warm.
|
||||
purge_build_caches() { \
|
||||
npm cache clean --force >/dev/null 2>&1 || true; \
|
||||
rm -rf /root/.npm; \
|
||||
} && \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
@@ -209,6 +274,8 @@ RUN set -e && \
|
||||
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) && \
|
||||
prune_foreign_natives && \
|
||||
purge_build_caches && \
|
||||
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)" && \
|
||||
@@ -309,6 +376,26 @@ ARG PI_STUDIO_REF=main
|
||||
ARG PI_STUDIO_VERSION=none
|
||||
RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
|
||||
set -e; \
|
||||
# Same prune + cache purge as the main install RUN — see the comments there.
|
||||
# They have to be redefined because shell functions do not survive across
|
||||
# layers, and they have 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 — and its
|
||||
# npm install refills /root/.npm, which the main RUN emptied in ITS layer.
|
||||
prune_foreign_natives() { \
|
||||
arch="$(node -p process.arch)"; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \
|
||||
! -name "linux-$arch" -prune -exec rm -rf {} + ; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@mariozechner/clipboard-[^/]+' \
|
||||
! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" \
|
||||
-prune -exec rm -rf {} + ; \
|
||||
echo "native platform dirs kept: $(find /usr/lib/node_modules /opt -type d \( -regex '.*/@esbuild/[^/]+' -o -regex '.*/@mariozechner/clipboard-[^/]+' \) -printf '%f\n' 2>/dev/null | sort | uniq -c | tr '\n' ' ')"; \
|
||||
}; \
|
||||
purge_build_caches() { \
|
||||
npm cache clean --force >/dev/null 2>&1 || true; \
|
||||
rm -rf /root/.npm; \
|
||||
}; \
|
||||
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}" && \
|
||||
@@ -320,6 +407,8 @@ RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
|
||||
done; \
|
||||
[ "$ok" = "1" ] && \
|
||||
(cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \
|
||||
prune_foreign_natives && \
|
||||
purge_build_caches && \
|
||||
echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \
|
||||
fi
|
||||
|
||||
@@ -392,7 +481,7 @@ ARG MEMPALACE_TOOLKIT_REF=main
|
||||
# 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=4d7c0ea9caeb3a1d6d9b04cf34f3fca5f9df4985
|
||||
ARG SKILLSET_SNAPSHOT_REF=e9e45f7acdde490c3b5d24ce5f508bff8785c2c7
|
||||
|
||||
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
|
||||
# and every variant INHERITS it, so both published images used to advertise
|
||||
|
||||
@@ -535,7 +535,10 @@ Two consequences worth internalising:
|
||||
- **"Seen, not doing it" is a legitimate ack** — `status="blocked"` or
|
||||
`"superseded"` plus the reason. Silence is not, and it is not merely rude:
|
||||
with no terminal event of yours to join to, the ask stays in the owed set
|
||||
indefinitely and there is nothing anyone can do about it from the other end.
|
||||
indefinitely. The *original requester* — and nobody else — can release it from
|
||||
the other end, but only by saying so explicitly: see **Withdrawing an ask you
|
||||
sent** below. That is a release by the asker, not an escape for the answerer.
|
||||
While the ask still stands, only *your* terminal event clears it.
|
||||
- **Nothing expires, and it should not.** An `open` with no terminal reply is
|
||||
still live by definition, and the finished threads are valuable history. If
|
||||
content is genuinely perishable ("do not push to main for the next hour"), say
|
||||
@@ -570,6 +573,24 @@ Two consequences worth internalising:
|
||||
be matched to it at all.
|
||||
- **Corrections are new events, never edits.** Say explicitly what you retract
|
||||
and name the id — drawer or event — that carried the withdrawn claim.
|
||||
- **Withdrawing an ask you sent: state it, never imply it.** Your release only
|
||||
counts when the terminal event (a) comes from the same `from_agent` that sent
|
||||
the ask, (b) is directed at that recipient exactly — never `*`, so a broadcast
|
||||
can neither oblige nor release, (c) carries a terminal status (`claimed` and
|
||||
`ready` are not terminal and do not release anything), (d) is strictly after
|
||||
the ask, (e) joins it via `ack_of` or the same `correlation_id`, **and (f)
|
||||
names that ask in `metadata.withdraws` or `metadata.closes`.** Prose in the
|
||||
body does not count, and neither does a bare terminal event on the
|
||||
correlation: inferring release from *any* terminal would let your own
|
||||
bookkeeping silently delete a real obligation, so the release must be stated.
|
||||
Needs toolkit ≥ `e2b060a` (image ≥ `v1.9.1`) — check with
|
||||
`grep -c isWithdrawn /opt/mempalace-toolkit/extensions/pi/mempalace.ts` and
|
||||
read `0` as "my withdrawal will have no effect on their mailbox". Measured
|
||||
cost of getting it wrong: a `v1.8.13` rollout ask was withdrawn by its sender,
|
||||
who recorded it as done; the recipient's derivation never saw the release and
|
||||
still reported the ask owed **41 hours later**, for a release that device
|
||||
never installed — and the asymmetry was invisible from the sender's side
|
||||
(RFC 003 §3.3 clause 4).
|
||||
- **Put a retraction where the reader will look.** A *directed open ask* reaches a
|
||||
live agent's mailbox; a **terminal-status event reaches no mailbox at all**, and
|
||||
a *drawer* is what a future semantic search finds. If you filed advice as a
|
||||
|
||||
+121
-4
@@ -28,6 +28,9 @@
|
||||
# (human, --json, --quiet)
|
||||
# - (studio variant only, auto-detected) pi-studio cloned + prebuilt
|
||||
# client bundle present + registered via `pi install`
|
||||
# - no foreign npm-11 platform packages (@esbuild, clipboard) beyond the host
|
||||
# - no build-time npm cache (/root/.npm) shipped in the image
|
||||
# - esbuild compiles + clipboard native loads at every install site
|
||||
# - image size within threshold
|
||||
|
||||
set -euo pipefail
|
||||
@@ -315,8 +318,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-fork clone + node_modules" \
|
||||
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
|
||||
run "pi-observational-memory clone + node_modules" \
|
||||
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules"
|
||||
# om is checked differently from pi-fork ON PURPOSE. It 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 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
|
||||
# pre-flight hasUsableAuth() check silently disabled `recall` for ~8 weeks once
|
||||
# pi moved to request-time SigV4 signing and stopped exposing a static Bedrock
|
||||
@@ -678,7 +697,22 @@ exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills
|
||||
# (forgotten bump AND re-vendored stale snapshot). Upstream content behind this
|
||||
# refresh: the bare project-name wing convention and the <harness>@<device>
|
||||
# added_by rule.
|
||||
exec_test "mempalace skill snapshot is current" 'f=$HOME/.agents/skills/mempalace/SKILL.md; grep -q "Diaries self-heal; plain drawers do not" "$f" && ! grep -q "Agent diaries live in" "$f" && echo ok'
|
||||
#
|
||||
# Unreleased: RE-PINNED again on refresh e9e09d9 -> e9e45f7. The retired pair was
|
||||
# still green against the new snapshot (the diaries section was untouched), so it
|
||||
# was blind to this refresh for the same reason the v1.8.13 pair was blind to
|
||||
# that one. The replacement pair is unusually strong because BOTH witnesses come
|
||||
# out of the same upstream commit: skillset e9e45f7 ADDED the "Withdrawing an ask
|
||||
# you sent" bullet and DELETED the sentence "there is nothing anyone can do about
|
||||
# it from the other end" that the new bullet contradicts. Directions were
|
||||
# MEASURED against both files, not read off the diff: "Withdrawing an ask you
|
||||
# sent" is new=1/old=0, "nothing anyone can do about it from the other end" is
|
||||
# new=0/old=1. A canary whose negative witness was removed by the very commit it
|
||||
# pins fails loudly on the OLD bytes instead of merely failing to notice them,
|
||||
# which is the property every previous pair here lacked. Upstream content:
|
||||
# requester-side ask withdrawal became DEPLOYED behaviour once v1.9.1 baked
|
||||
# mempalace-toolkit e68ee20 (>= e2b060a) through the floating MEMPALACE_TOOLKIT_REF.
|
||||
exec_test "mempalace skill snapshot is current" 'f=$HOME/.agents/skills/mempalace/SKILL.md; grep -q "Withdrawing an ask you sent" "$f" && ! grep -q "nothing anyone can do about it from the other end" "$f" && echo ok'
|
||||
# Link TARGETS, not just link existence: with no skillset mounted (as here) the
|
||||
# baked tree must be what resolves, for all four vendored skills.
|
||||
exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
|
||||
@@ -697,9 +731,17 @@ exec_test "pi-devbox-version reports skill sources (all baked, no skillset here)
|
||||
'out=$(pi-devbox-version)
|
||||
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
|
||||
echo "$out" | grep -qE "^ $s +baked$" \
|
||||
echo "$out" | grep -qE "^ $s +baked( \([^)]*\))?$" \
|
||||
|| { echo "$s not reported as baked" >&2; exit 1; }
|
||||
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
|
||||
# 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
|
||||
@@ -855,6 +897,55 @@ exec_test "pi-fork extensions floor is [] (forks cannot write to the palace)" \
|
||||
exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \
|
||||
'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok'
|
||||
|
||||
# ── Build-time leftovers (npm 11 bloat sentinels) ─────────────────────
|
||||
# Both of these are worth a PASS/FAIL assertion rather than a size-gate
|
||||
# diagnostic, because the size gate has ~225 MB of deliberate margin: v1.9.1
|
||||
# shipped +131 MB of pure build residue and stayed green. These name the
|
||||
# residue directly, so a regression is legible instead of merely "bigger".
|
||||
echo ""
|
||||
echo "── Build-time leftovers ──"
|
||||
|
||||
# npm 11 installs EVERY optional platform package of a native dependency, not
|
||||
# just the host's (it ignores os/cpu, --os/--cpu and npmrc os=/cpu=). Two
|
||||
# families are affected and pruned in Dockerfile.variant: @esbuild/<platform>
|
||||
# and @mariozechner/clipboard-<triple>. Keep-set is the host arch only, plus
|
||||
# clipboard's gnu AND musl (its napi loader picks between them at runtime).
|
||||
# Runs as root because the image declares no USER; that is also what lets the
|
||||
# cache assertion below read /root.
|
||||
run "no foreign platform packages (npm 11 sentinel)" \
|
||||
'arch=$(node -p process.arch); bad=$(find /usr/lib/node_modules /opt -type d \( -regex ".*/@esbuild/[^/]+" -o -regex ".*/@mariozechner/clipboard-[^/]+" \) ! -name "linux-$arch" ! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" -prune -print 2>/dev/null); if [ -n "$bad" ]; then echo "foreign platform dirs shipped:" >&2; echo "$bad" >&2; du -sm $bad 2>/dev/null | sort -rn | head -5 >&2; exit 1; fi; echo ok'
|
||||
|
||||
# The build's own npm download cache is not free: it lands in the layer that
|
||||
# created it. v1.9.1 shipped 145 MB of /root/.npm/_cacache (35 MB in v1.8.14)
|
||||
# — the largest single item in its +131 MB residual, and invisible to the
|
||||
# size-gate diagnostics because those only looked under node_modules and /opt.
|
||||
# Nothing at runtime reads it: root's cache, while the container runs as
|
||||
# `developer`. NOTE the assertion must run as root or a permission error on
|
||||
# mode-700 /root would make `test ! -d` pass for the wrong reason.
|
||||
run "no build-time npm cache shipped (/root/.npm)" \
|
||||
'test "$(id -u)" = "0" || { echo "assertion needs root to read /root" >&2; exit 1; }; if [ -e /root/.npm ]; then echo "/root/.npm shipped: $(du -sm /root/.npm | cut -f1) MB" >&2; exit 1; fi; echo ok'
|
||||
|
||||
# The prune's risk is not "too big" but "removed something needed", and only a
|
||||
# FUNCTIONAL check covers that. These load the natives from every install site
|
||||
# found in the image, so they also scale to the studio variant's third site.
|
||||
#
|
||||
# NOTE THE PATH-QUALIFIED require(). The obvious form, `node -e
|
||||
# 'require("esbuild")...'`, resolves by walking up from the CURRENT DIRECTORY —
|
||||
# so it fails with MODULE_NOT_FOUND from /workspace on a perfectly good image,
|
||||
# because esbuild lives nested inside the pi trees and global installs are not
|
||||
# on node's require path (NODE_PATH is unset). That exact command was left in a
|
||||
# runbook as "if this fails, revert the release", and it duly failed for the
|
||||
# wrong reason on the first machine that ran it. A check must fail only for the
|
||||
# thing it is checking.
|
||||
run "esbuild works at every install site (prune removed weight, not function)" \
|
||||
'sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/esbuild" -prune 2>/dev/null); if [ -z "$sites" ]; then echo "no esbuild install found at all" >&2; exit 1; fi; for d in $sites; do node -e "require(\"$d\").transformSync(\"const x:number=1\",{loader:\"ts\"})" || { echo "esbuild broken at $d" >&2; exit 1; }; done; echo ok'
|
||||
|
||||
# Clipboard is the family pruned second, and its napi-rs loader picks its native
|
||||
# binding at require() time — so a successful load IS the proof that the kept
|
||||
# platform package is the one this image needs.
|
||||
run "clipboard native loads at every install site" \
|
||||
'sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/@mariozechner/clipboard" -prune 2>/dev/null); if [ -z "$sites" ]; then echo "no @mariozechner/clipboard install found at all" >&2; exit 1; fi; for d in $sites; do node -e "var c=require(\"$d\"); if (typeof c.setText !== \"function\") { throw new Error(\"native binding missing\"); }" || { echo "clipboard native broken at $d" >&2; exit 1; }; done; echo ok'
|
||||
|
||||
# ── Image size ────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Image size ──"
|
||||
@@ -882,6 +973,32 @@ elif [ "$SIZE_MB" -le "$SIZE_THRESHOLD_MB" ]; then
|
||||
printf " ✅ size: %d MB (threshold %d MB)\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; PASS=$((PASS+1))
|
||||
else
|
||||
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 " ── build caches that should not be in the image ──"
|
||||
# v1.9.1's residual was 145 MB of npm cache under /root, and the du list
|
||||
# above cannot see it: it enumerates node_modules and /opt only. A gate whose
|
||||
# diagnostic looks only where the bytes were LAST time sends the next reader
|
||||
# spelunking again, so name the cache paths explicitly.
|
||||
docker run --rm --entrypoint sh "$IMAGE" -c \
|
||||
'du -sm /root/.npm /root/.cache /tmp/node-compile-cache /home/developer/.npm 2>/dev/null | sort -rn' \
|
||||
2>/dev/null | sed 's/^/ /' || true
|
||||
echo " ── foreign platform dirs (npm 11 regression sentinel) ──"
|
||||
docker run --rm --entrypoint sh "$IMAGE" -c \
|
||||
'find /usr/lib/node_modules /opt -type d \( -regex ".*/@esbuild/[^/]+" -o -regex ".*/@mariozechner/clipboard-[^/]+" \) -printf "%f\n" 2>/dev/null | sort | uniq -c | sort -rn | head' \
|
||||
2>/dev/null | sed 's/^/ /' || true
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user