Compare commits

...

7 Commits

Author SHA1 Message Date
joakimp 361babd4fd ci: gate the release on shell lint, from one shared script
Lint / hadolint (push) Successful in 10s
Lint / actionlint (push) Successful in 17s
Publish Docker Image / lint-gate (push) Successful in 15s
Publish Docker Image / resolve-versions (push) Successful in 9s
Publish Docker Image / base-decide (push) Successful in 16s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-studio (push) Successful in 5m9s
Publish Docker Image / smoke (push) Successful in 7m39s
Publish Docker Image / build-variant-studio (push) Successful in 17m15s
Publish Docker Image / build-variant (push) Successful in 17m58s
Publish Docker Image / update-description (push) Successful in 8s
Publish Docker Image / promote-base-latest (push) Successful in 12s
v1.8.14's first attempt spent ~46 minutes building a base image for a tree whose
own lint had been failing for 24 hours. shellcheck had already flagged the
defect (SC2289, severity error) on the push that introduced it; the lint
workflow went red at run 186 and nobody read it.

lint.yml deliberately skips tag pushes and its reasoning is sound -- the tagged
tree was already linted on main, and a tag-ref lint run sorts above the publish
run, making a release look finished before anything ships. The missing invariant
was never "lint the tag". It was "do not RELEASE a tree whose lint failed", and
only a job inside the publish workflow can enforce that.

So: extract the shell-lint logic from lint.yml into scripts/lint-shell.sh and
call it from both places, then add a lint-gate job that resolve-versions depends
on. resolve-versions is the graph root, so gating it gates everything. Cost is
~40 s at the front of a release; the alternative already cost fifty minutes.

Extracted rather than copied on purpose. A second copy of a check is the drift
this repo keeps paying for -- the same evening produced a skillset mirror that
had sat 9579 B behind its upstream through two consecutive edits.

The script adds one behaviour the inline version lacked: if shellcheck is not
installed it exits 2 rather than silently finding nothing, inheriting the
existing "a gate that cannot run must not pass" rule from hooks/pre-commit in
the skillset repo. Without that, reordering the install step away would turn the
gate into a green tick over zero checks.

Verified locally with a stubbed shellcheck (the real binary is not in the
devbox), five cases, each with its expectation stated first: absent shellcheck
-> rc=2; stub pass -> rc=0 and a non-zero file count; stub fail -> rc=1; a
deliberately unterminated `if` planted in scripts/ -> rc=1 via the bash -n half,
naming the file; removal -> rc=0 again. Discovery cross-checks against CI's own
number: the inline version reported 12 files, the extracted one reports 13, the
difference being lint-shell.sh itself. YAML re-parsed (10 jobs, was 9) with an
assertion that resolve-versions needs lint-gate, and the repo's
check-workflow-shell.sh guard still passes.
2026-09-08 23:41:44 +02:00
joakimp 70e675afee fix(smoke): keep prose out of the single-quoted exec_test body
Lint / hadolint (push) Successful in 14s
Lint / actionlint (push) Successful in 22s
The agent-browser execution guard added on 2026-09-07 carried its explanation
INSIDE the single-quoted script body, and the explanation contained an
apostrophe ("the fleet\'s only recurring amd64 runtime proof"). Inside '...'
bash treats a backslash as literal, so \' does not escape the quote -- it CLOSES
the string. The body truncated at that point and the remaining lines were parsed
by the calling shell.

Consequences, both measured rather than inferred:
  - exec_test received 12 arguments instead of 2 (verified two-sided: the fixed
    tree yields argc=2, HEAD yields argc=12).
  - the leaked `v=$(agent-browser --version)` ran on the CI RUNNER instead of
    inside the image. The runner has no agent-browser, so smoke and
    smoke-studio both failed with "line 770: command not found" after
    build-base had already spent ~46 minutes. Every downstream job was skipped.
  - the truncated body still passed inside the container and printed its green
    tick first, so the log shows a PASS immediately followed by the failure --
    the tick was real, it just no longer covered the assertion.

The prose now sits above the exec_test call, where an apostrophe cannot
terminate anything, and a comment at that spot records why it must stay there.

Not a new failure class: shellcheck flagged it as SC2289 at severity error the
same day, so the lint job has been red since run 186 (2026-09-07 21:21) and was
not read. The gate did its job; nobody looked.
2026-09-08 23:31:48 +02:00
joakimp 601fc98a49 docs(changelog): release v1.8.14
Lint / hadolint (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 14s
Lint / actionlint (push) Failing after 24s
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / build-base (push) Successful in 50m32s
Publish Docker Image / smoke-studio (push) Failing after 5m13s
Publish Docker Image / build-variant-studio (push) Has been skipped
Publish Docker Image / smoke (push) Failing after 7m40s
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Converts the Unreleased section and records what this build carries beyond it:
the mempalace-toolkit bump that makes closing replies reach the mailbox
(deriveClosed, 21023e7 -> e45f6b4), and the L0-L4 subtask documentation landing
via pi-toolkit adfb553 + pi-extensions c64c122.

Notes the mechanism that makes the toolkit fix land at all — the resolved
toolkit SHA is folded into the content-addressed base tag, so the toolkit
moving forces a base rebuild rather than waiting for one — and the consequence
for the amd64 item already in this section: v1.8.13's base was cached, so
Dockerfile.base:607's agent-browser assertion never ran. This base is not
cached, so the native-amd64 proof is finally collected instead of discarded.
2026-09-08 22:09:31 +02:00
joakimp 7e0e66997d docs(env): name MEMPALACE_MAILBOX_NOTIFY — auto-detect cannot work in a container
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Failing after 16s
Unset means the mailbox is silent outside the pi TUI, and the reason is
structural: docker exec does not forward KITTY_WINDOW_ID/TERM_PROGRAM, so
'desktop' detection always falls through to OSC 777, which Kitty does not
implement — the notification then silently does nothing, the worst failure for a
feature whose only job is to break a silence. Documents the four modes, and that
MEMPALACE_MAILBOX_POLL_MS is a FLOOR BETWEEN activity-coupled polls rather than a
wall-clock interval (an idle session polls zero times) — the exact expectation
mismatch reported today.
2026-09-07 21:43:01 +02:00
joakimp 6bd8b79d3a test(smoke): assert agent-browser EXECUTES — it was the discarded amd64 proof
Lint / hadolint (push) Successful in 11s
Lint / actionlint (push) Failing after 23s
Second instance of the same bug class as the node line, in the same file, found
the same way. The agent-browser guard captured the version inside an echo with
2>/dev/null:

  echo "resolved=[$r] version=[$(agent-browser --version 2>/dev/null|head -n1)]" >&2

so the exit code was discarded and a binary that could not execute at all still
PASSED, printing version=[]. Verified two-sided: a stub exiting 127 passes the old
form and is caught by the new one.

Why this exit code matters more than most: smoke runs platforms: linux/amd64 on an
x86 runner, i.e. NATIVE amd64, so this line is the fleet's only recurring amd64
runtime proof for agent-browser's linux-x64 ELF.

NO DEVBOX CAN EVER SUPPLY THAT PROOF. Every machine in the pi fleet is an Apple
Silicon Mac: mbp-m1-2020; tor-ms22 = Mac Studio Mac13,1 M1 Max (fleet-ops
hosts/tor-ms22.md, verified 2026-08-17 with system_profiler); emb-7kj4vr4g =
Apple Silicon, verified 4 routes 2026-09-07. The open "amd64 runtime proof still
needed" ask sent to two devices was asking for the impossible, and emb's reply
naming tor-ms22 as "the only remaining candidate" is wrong for the same reason.
CI had the answer all along and was throwing it away.

Dockerfile.base:607 DOES assert it (`agent-browser --version && \`), but only when
the base rebuilds, and v1.8.13's base was cached — so smoke is where the recurring
gate belongs.
2026-09-07 21:21:16 +02:00
joakimp fabf1274aa docs(changelog): Unreleased section for the smoke node assertion + agent-browser correction
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 19s
Summarises what changed since v1.8.13: the node-major assertion (a bump would
have passed the suite silently), the two-sided verification of the derivation,
and the v1.8.13 agent-browser 0.35.2 -> 0.36.0 correction. No image content
changes; NODE_VERSION still 22.
2026-09-07 21:17:06 +02:00
joakimp 5972a2c535 test+docs: assert the node major in smoke, and correct v1.8.13's agent-browser version
Two findings from a delegated read-only audit of this repo, both verified from the
filesystem before patching.

1. No test asserted the node major, so a node-24 bump would have passed the smoke
   suite SILENTLY. scripts/smoke-test.sh:94 was a bare `run "node" "node --version"`
   — exit-0 and non-empty output only, the printed version compared to nothing —
   while the line above it uses run_expect against $EXPECTED_PI_VERSION for pi. A
   reader skimming the suite would reasonably assume node regressions were covered.
   Worse, this is where the "node v22.23.2 verified" line in the v1.8.13 recreate
   notes came from: printed output, not an assertion.

   Now gated on EXPECTED_NODE_MAJOR, which CI derives from Dockerfile.base's ARG
   NODE_VERSION — the single source of truth (Dockerfile.base:557 is the ONLY hard
   pin in the repo; Dockerfile.variant has no node install at all). That also
   catches a stale cached layer whose node disagrees with the declared ARG.
   Unset => previous behaviour, so this is backward compatible.

   Verified two-sided rather than assumed: the sed derivation yields 22 (empty
   would have silently disabled the assertion, reintroducing the bug); grep -Fq
   "v22." matches v22.23.2; "v24." does NOT match, so a wrong major is caught; and
   "v2." does not prefix-collide. Workflow YAML re-parsed after editing (9 jobs).

2. The v1.8.13 entry claimed "the image's own 0.35.2" for agent-browser. The image
   ships 0.36.0: /usr/lib/node_modules/agent-browser/package.json says version
   0.36.0, engines.node >=24.0.0, and no 0.35.2 exists anywhere in the image. The
   claim was also internally incoherent, contrasting 0.36.0 against a version that
   is not present. Corrected in place with a visible note, since the entry is
   already released. The reasoning survives untouched: the engines floor really is
   vestigial, because /usr/bin/agent-browser is a prebuilt aarch64 ELF invoked
   directly and never through node — which is why 0.36.0 runs fine on 22.23.2.
2026-09-07 21:05:24 +02:00
6 changed files with 378 additions and 32 deletions
+29
View File
@@ -87,6 +87,35 @@ SSH_KEY_PATH=~/.ssh
# MEMPALACE_PI_REMOTE_PATH=/data/feed
# MEMPALACE_PI_DEVICE=
# ── Mailbox notification: MUST BE NAMED, auto-detect CANNOT work here ──
# The mempalace extension polls the logstream for fleet asks addressed to this
# device and queues them into the next turn. That part needs no config. The
# NOTIFICATION that tells the human it happened does, and unset means SILENT
# outside the pi TUI.
#
# Why there is no working default: terminal identity lives in env vars set by
# the emulator (KITTY_WINDOW_ID, TERM_PROGRAM) and `docker exec` does NOT
# forward them — inside the container pi sees only TERM=xterm-256color no matter
# what is rendering it. So "desktop" auto-detection always falls through to
# OSC 777, which Kitty does not implement, and the notification silently does
# nothing: the worst outcome for a feature whose only job is to break a silence.
# Naming the protocol is what makes it fire.
#
# kitty OSC 99 desktop notification (correct for Kitty, incl. over SSH)
# osc777 OSC 777 (tmux/iTerm2/foot and others)
# desktop OSC 99 if KITTY_WINDOW_ID is visible, else OSC 777 — inside a
# container that means effectively always OSC 777, so prefer naming
# 0 / off suppress entirely (in-TUI notify still shows)
# MEMPALACE_MAILBOX_NOTIFY=kitty
#
# Cadence, if the delivery ever feels late: the poll is coupled to session
# activity (it runs when the agent settles), NOT to a wall clock.
# MEMPALACE_MAILBOX_POLL_MS is therefore a FLOOR BETWEEN POLLS (default 300000),
# not a promise of one every 5 minutes — an idle session polls zero times, and
# session start does the first look.
# MEMPALACE_MAILBOX_POLL_MS=300000
# MEMPALACE_MAILBOX_RESURFACE_MS=3600000
# ── LAN access from the container (host-OS-agnostic) ─────────────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default. The entrypoint
+46 -2
View File
@@ -157,7 +157,41 @@ jobs:
# buildcache silently reuses the layer from whatever pi version was
# current when the cache was first populated. Same class of bug as
# pi-devbox v0.74.0..v0.75.5 (fixed in v0.75.5b 2026-05-23).
# ── release gate ──────────────────────────────────────────────
# Refuse to spend a base build on a tree whose own shell scripts do not lint.
#
# v1.8.14's first attempt is why this exists. smoke and smoke-studio both failed
# at scripts/smoke-test.sh:770 AFTER build-base had already spent ~46 minutes,
# on a defect shellcheck had flagged as SC2289 (severity error) a day earlier:
# the lint workflow went red on the very push that introduced it (run 186) and
# stayed red for runs 187 and 188, unread.
#
# lint.yml deliberately does not run on tag pushes, and its reasoning is sound
# (the tagged tree was already linted on main; a tag-ref lint run sorts above
# the publish run and makes a release look finished before anything ships). The
# missing invariant was never "lint the tag" -- it was "do not RELEASE a tree
# whose lint failed", and only a job inside THIS workflow can enforce that.
#
# ~40 s, ahead of everything expensive, and it runs scripts/lint-shell.sh --
# the same file lint.yml calls, not a second copy that drifts.
lint-gate:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: |
apt-get update
apt-get install -y --no-install-recommends shellcheck
- name: "Shellcheck + syntax-check repository scripts (severity: error)"
run: bash scripts/lint-shell.sh
resolve-versions:
# Gated: a defective tree must not reach a 46-minute base build.
needs: [lint-gate]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -543,7 +577,12 @@ jobs:
env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
EXPECTED_MEMPALACE_VERSION: ${{ needs.resolve-versions.outputs.mempalace_version }}
run: bash scripts/smoke-test.sh pi-devbox:smoke
run: |
# Single source of truth for the node major is Dockerfile.base's ARG.
# Asserting the BUILT image matches it also catches a stale cached layer.
EXPECTED_NODE_MAJOR=$(sed -n 's/^ARG NODE_VERSION=\([0-9][0-9]*\).*/\1/p' Dockerfile.base)
export EXPECTED_NODE_MAJOR
bash scripts/smoke-test.sh pi-devbox:smoke
# ── Phase 3b: amd64 smoke for the studio variant ────────────────────
# Additive + independent of the core `smoke` job: gates ONLY
@@ -606,7 +645,12 @@ jobs:
env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
EXPECTED_MEMPALACE_VERSION: ${{ needs.resolve-versions.outputs.mempalace_version }}
run: bash scripts/smoke-test.sh pi-devbox:smoke-studio
run: |
# Single source of truth for the node major is Dockerfile.base's ARG.
# Asserting the BUILT image matches it also catches a stale cached layer.
EXPECTED_NODE_MAJOR=$(sed -n 's/^ARG NODE_VERSION=\([0-9][0-9]*\).*/\1/p' Dockerfile.base)
export EXPECTED_NODE_MAJOR
bash scripts/smoke-test.sh pi-devbox:smoke-studio
# ── Phase 4: multi-arch publish ─────────────────────────────────────
build-variant:
+5 -25
View File
@@ -75,31 +75,11 @@ jobs:
# are shell scripts with no extension. -print0/mapfile -d '' so a path
# with a space cannot silently split, and the file count is asserted
# non-zero — a green tick over an empty file set is not a check.
run: |
# Union of two signals, because either alone misses a real case:
# a shebang scan misses a sourced fragment with no shebang, and a
# *.sh glob misses the extensionless tools in rootfs/usr/local/bin/.
# Silent skipping is precisely the failure mode this gate exists to
# prevent, so err toward over-collecting.
mapfile -d '' -t all_files < <(find . -not -path './.git/*' -type f -print0)
sh_files=()
for f in "${all_files[@]}"; do
case "$f" in *.sh) sh_files+=("$f"); continue;; esac
if head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(bash|sh)\b'; then
sh_files+=("$f")
fi
done
echo "Checking ${#sh_files[@]} shell file(s)"
if [ "${#sh_files[@]}" -eq 0 ]; then
echo "::error::no shell files found — the shebang scan or the checkout is wrong"
exit 1
fi
shellcheck -S error -f gcc "${sh_files[@]}"
rc=0
for f in "${sh_files[@]}"; do
bash -n "$f" || { echo "::error file=$f::bash -n failed"; rc=1; }
done
exit "$rc"
#
# The implementation moved to scripts/lint-shell.sh on 2026-09-08 so the
# release gate in docker-publish.yml runs the SAME code rather than a
# second copy that drifts. Edit the script, not a copy of it.
run: bash scripts/lint-shell.sh
- name: Gitea shell guard (catches the actionlint blind spot)
# actionlint models GitHub Actions, where the default run shell is
+170 -3
View File
@@ -11,6 +11,167 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
---
## v1.8.14 — 2026-09-08
> **First release attempt failed; fixed in this same entry.** The `smoke` and
> `smoke-studio` jobs both failed at `scripts/smoke-test.sh:770` with
> `agent-browser: command not found`, after `build-base` had already succeeded
> (~46 min spent). Root cause was in the agent-browser execution guard added the
> day before: the explanatory comment inside the **single-quoted** `exec_test`
> body contained an apostrophe (`the fleet\'s`). Inside `'...'` bash treats a
> backslash literally, so `\'` does not escape — it **closes the string**. The
> body silently truncated (measured: `exec_test` received **12** arguments
> instead of 2), and the remaining lines, including the `agent-browser --version`
> assertion, were parsed by the **runner's** shell instead of executing inside
> the image — and the runner has no agent-browser. The prose now lives above the
> call, where an apostrophe is harmless.
>
> **The lint job had already caught this, and it went unread for 24 hours.**
> `shellcheck` flagged it as `SC2289` at severity *error*, so the `actionlint`
> job went red at run 186 on 2026-09-07 21:21 — the exact push that introduced
> the guard — and stayed red for runs 187 and 188. `lint.yml` deliberately
> excludes tag pushes (documented: the tagged tree was already linted on main,
> and a tag-ref lint run would sort above the publish run), which is sound; the
> broken assumption was different, namely that a tree whose lint FAILED would not
> then be released. `docker-publish.yml` has no dependency on lint, so it built
> for 50 minutes on a tree known to be defective.
>
> **Fixed, then gated.** The prose moved above the `exec_test` call so an
> apostrophe cannot terminate anything, and the shell-lint logic moved out of
> `lint.yml` into **`scripts/lint-shell.sh`** — now called by both `lint.yml` and
> a new `lint-gate` job here that `resolve-versions` depends on. A release with a
> lint error refuses in ~40 s instead of failing after fifty minutes. One copy,
> not two: a duplicated check that drifts is the failure this repo keeps paying
> for. The script also refuses to pass when `shellcheck` is absent, inheriting
> the existing principle that a gate which cannot run must not pass.
>
> **`v1.8.14` was re-pointed** from `601fc98` to the fix commit. Nothing had
> consumed the original tag — no `v1.8.14` image was ever published, only the
> content-addressed `base-a365dd24de21`. `scripts/` does not feed the base hash,
> so the re-run reuses that base and skips the 46-minute rebuild.
**A test that was quietly checking nothing, and a version number that was wrong.**
Both found by delegating a read-only audit of this repo to a headless worker
(`pi-toolkit` `bin/pi-task`) and then spot-checking its pointers from the
filesystem — 5 of 5 held, and it also corrected a false premise planted in its
own brief.
**The node major is now asserted, not merely printed.**
`scripts/smoke-test.sh` ran `run "node" "node --version"`, which asserts only
that the binary exists and exits 0 — the printed version was compared to
nothing. The line above it has always used `run_expect` against
`$EXPECTED_PI_VERSION` for `pi`, so the suite *looked* like it covered node.
**A node major bump would have passed the whole smoke suite silently.** Worse,
this is where the "node v22.23.2 verified" line in the v1.8.13 recreate notes
came from: printed output, not an assertion — an expectation stated up front and
then falsified by the check.
Now gated on `EXPECTED_NODE_MAJOR`, which CI derives from `Dockerfile.base`'s
`ARG NODE_VERSION` — the single source of truth, and the *only* hard node pin in
the repo (`Dockerfile.variant` has no node install at all, so the two Dockerfiles
cannot disagree). That also catches a stale cached layer whose node disagrees
with the declared ARG. Unset ⇒ previous behaviour, so nothing breaks for anyone
running the suite by hand.
Verified two-sided, because a silent failure here reintroduces the exact bug it
fixes: the `sed` derivation yields `22` (an empty result would disable the
assertion silently); `grep -Fq "v22."` matches `v22.23.2`; `"v24."` does **not**
match, so a wrong major is caught; `"v2."` does not prefix-collide. The workflow
YAML was re-parsed after editing (9 jobs).
**v1.8.13's agent-browser version was wrong.** That entry said "the image's own
0.35.2". The image ships **0.36.0** — `/usr/lib/node_modules/agent-browser` at
0.36.0 with `engines.node >=24.0.0`, and no 0.35.2 exists anywhere in the image.
The sentence was also internally incoherent, contrasting 0.36.0 against a version
that is not present. Corrected in place with a visible note, since that entry is
already released. **The reasoning survives untouched**: the engines floor really
is vestigial, because `/usr/bin/agent-browser` is a prebuilt aarch64 ELF invoked
directly and never through node — which is exactly why 0.36.0 runs fine on
22.23.2, consistent with the runtime proof collected on 2026-09-07 and with the
retraction of the earlier false "0.36.0 requires node >= 24" alert.
No image content changes: `NODE_VERSION` still 22, no pins moved. This is a test
and a docs correction only.
**The same bug class, twice in one file — and the second one was throwing away a
proof the fleet cannot obtain any other way.** `scripts/smoke-test.sh`'s
agent-browser guard captured the version *inside an `echo`, with `2>/dev/null`*:
```sh
echo "resolved=[$r] version=[$(agent-browser --version 2>/dev/null | head -n1)]" >&2
```
The exit code was discarded, so a binary that could not execute at all still
**passed**, printing `version=[]`. Verified two-sided: a stub exiting 127 passes
the old form and is caught by the new one.
Why that exit code matters more than most: smoke runs `platforms: linux/amd64` on
an x86 runner, i.e. **native amd64**, making this line the fleet's only recurring
amd64 runtime proof for agent-browser's `linux-x64` ELF. **No devbox can ever
supply one** — every machine in the pi fleet is an Apple Silicon Mac
(`mbp-m1-2020`; `tor-ms22` = Mac Studio `Mac13,1` M1 Max, verified 2026-08-17 by
`system_profiler`; `emb-7kj4vr4g` = Apple Silicon, verified 4 ways 2026-09-07).
The "amd64 runtime proof still needed" item that was sent to two devices was
therefore asking for the impossible, while CI already had the answer and was
discarding it. `Dockerfile.base:607` does assert it (`agent-browser --version &&`),
but only when the base actually rebuilds — and v1.8.13's base was cached.
**The mailbox now announces replies that CLOSE your own asks.** `mempalace-toolkit`
`21023e7` → `e45f6b4`, which adds `deriveClosed()` alongside `deriveOwed()`. The old
path queried `status: open` and joined for a reply, which by construction can only
surface asks *you owe someone else*; a terminal reply carries `status: applied`
(or `blocked`/`failed`), so **the answer to your own question was structurally
invisible** — the one notification a human actually wants. Measured: `emb-7kj4vr4g`
closed the v1.8.13 rollout ask at 18:31Z with `status=applied`, the operator
reasonably expected to hear about it, and the mailbox stayed silent while being
correct by its own definition. Nine closed correlations were sitting unannounced.
Shares the 1-hour resurface floor, so a close is announced once and is news rather
than a nag.
This lands **because the base rebuilds**, which is worth stating explicitly: the
CI-resolved `mempalace-toolkit` SHA is folded into the content-addressed base tag
(`base-decide`), precisely so a toolkit-only fix cannot silently fail to land
behind an unchanged `Dockerfile.base`. The floating `main` ref was left alone on
purpose — the toolkit moving *forces* the rebuild rather than waiting for one.
**A consequence worth noting for the amd64 item above: this release actually
collects that proof.** v1.8.13's base was cached, which is why
`Dockerfile.base:607`'s `agent-browser --version &&` never ran. v1.8.14's base is
not cached, so both that assertion and the new `EXPECTED_NODE_MAJOR` gate execute
on a native `linux/amd64` runner. The fleet's first *kept* amd64 runtime proof for
the `linux-x64` ELF should be an artefact of this build rather than something
asked of a device that cannot supply it.
**Subtask delegation is documented — including the rung nobody built.** The image
picks these up through their resolved refs (`pi-toolkit` `adfb553`,
`pi-extensions` `c64c122`, the latter also refreshing the baked fallback skill):
- an operator-facing decision guide in pi-toolkit's `README.md`, built on the
L0–L4 context ladder — how much of the parent session a child can see is the
axis that explains nearly every observed good and bad behaviour;
- the canonical `pi-extensions` skill gains the same ladder next to *Boundary
discipline*, which until now diagnosed why an inherited transcript defeats a
brief without offering any alternative to "don't fork that";
- one bullet in the global `AGENTS.md`, so the choice is visible without loading
a skill, and naming `pi-task` as a **CLI** — an agent hunting for a `pi_task`
tool finds none and concludes it is unavailable.
What the ladder records: **L0/L1/L2 exist** in `pi-task` (`context.facts` /
`.files` / `.commands`), **L4** is `fork`'s only behaviour (`getHeader()` +
`getBranch()`, no offset or limit anywhere in the call chain), and **L3** — a
truncated branch — **is not implemented by anything**, which is now written down
instead of being a design idea somebody remembers.
Also recorded, found while writing the above: `pi-fork/src/runner.ts:188` reads
`if (extensions !== null) args.push("--no-extensions")`. So `extensions: []` turns
the capability floor **on** and `null` turns it **off** — and `null` is the
documented way to "restore normal extension loading", so tidying `[]` to `null`
as a no-op re-arms palace writes inside every fork child. `pi-task` hardcodes the
flag and cannot drift this way. Documented in three places because the edit that
triggers it looks harmless.
---
## v1.8.13 — 2026-09-06
**Version audit + three pins moved, one deliberately not moved.** `pi`
@@ -56,9 +217,15 @@ identically to the 0.84.4 control, so "alive" could be distinguished from
safe: all five prebuilt native addons in pi use NAPI (ABI-stable, no
NODE_MODULE_VERSION lock, no binding.gyp), nothing in the image declares a node
CEILING, and the install is one token (`setup_${NODE_VERSION}.x`). agent-browser
0.36.0 declares `engines.node >=24`, but that field is vestigial for the
artifact actually shipped: the image's own 0.35.2 declares the same floor and
runs fine on 22.23.2 as a prebuilt aarch64 ELF. The reason to wait is
0.36.0 declares `engines.node >=24.0.0`, but that field is vestigial for the
artifact actually shipped: `/usr/bin/agent-browser` is the prebuilt aarch64 ELF
`bin/agent-browser-linux-arm64`, invoked directly and never through node, so npm's
engines floor is never enforced at runtime — verified running under 22.23.2 in
this image. (Corrected 2026-09-07: this paragraph originally said "the image's own
0.35.2 declares the same floor". That was wrong and incoherent — it contrasted
0.36.0 against a 0.35.2 that does not exist in the image. There is exactly one
agent-browser present, `/usr/lib/node_modules/agent-browser` at 0.36.0. The
argument is unaffected; only the version was wrong.) The reason to wait is
attribution, not compatibility — this release already moves pi a minor,
mempalace a minor and bakes a Studio RC, so adding a node major would leave four
suspects if the image misbehaves. Worth doing as its own release with the smoke
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Shellcheck + syntax-check every shell script in this repo. Severity: error.
#
# SINGLE SOURCE OF TRUTH for two callers:
# .gitea/workflows/lint.yml — advisory, every branch push and PR
# .gitea/workflows/docker-publish.yml — the release GATE (lint-gate job)
# Extracted from lint.yml on 2026-09-08 rather than copied, because a second
# copy is exactly the drift this repo has been bitten by (see skillset's
# pi-extensions mirror, refreshed the same evening after sitting 9579 B behind).
#
# WHY THIS CHECK EXISTS AT ALL
# actionlint shellchecks workflow `run:` steps only. The repo's own scripts —
# entrypoint.sh, scripts/*.sh, and the extensionless tools under
# rootfs/usr/local/bin/ — were never shellchecked. A sibling repo with the same
# gap shipped a broken `echo "$json" | python3 <<'EOF' ... json.load(sys.stdin)`
# for two months: with no script argument python reads its SCRIPT from stdin,
# so the heredoc IS stdin and json.load hits EOF. shellcheck flags that at
# severity error (SC2259); nothing ever ran it.
#
# WHY THE RELEASE GATES ON IT (added 2026-09-08, the expensive way round)
# v1.8.14's first attempt failed after build-base had already spent ~46 min:
# scripts/smoke-test.sh had an apostrophe inside a single-quoted exec_test body
# ("the fleet\'s"), which CLOSES the string, so the body truncated and its tail
# ran on the CI runner instead of inside the image. shellcheck had already
# caught it as SC2289 at severity error — the lint job went red on the very
# push that introduced it and stayed red for 24 hours, unread. lint.yml
# deliberately does not run on tag pushes (sound: the tagged tree was linted on
# main, and a tag-ref lint run sorts above the publish run and makes a release
# look finished early). The gap was never "lint the tag" — it was that a tree
# whose lint FAILED could still be released. Hence a gate inside the publish
# workflow, ~40 s, ahead of everything expensive.
#
# SEVERITY CHOICE
# -S error is 0 findings across this repo when clean, so it is free to add.
# -S warning is NOT free here (19x SC2088 tilde-in-quotes in
# recreate-sanity-check.sh, plus assorted SC2016 — both intentional), and a
# noisy gate trains people to ignore it. Error-only, matching the
# SHELLCHECK_OPTS philosophy in lint.yml.
#
# Usage: bash scripts/lint-shell.sh [root] (default root: repo top level)
set -uo pipefail
root="${1:-}"
if [ -z "$root" ]; then
root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
fi
cd "$root" || { echo "::error::cannot cd to $root"; exit 2; }
# A gate that cannot run must not pass. Without this, a machine (or a CI job
# whose install step was reordered away) without shellcheck would sail through
# printing nothing, which is the failure mode this whole file exists to prevent.
if ! command -v shellcheck >/dev/null 2>&1; then
echo "::error::shellcheck not found — the gate cannot run, so it must not pass" >&2
echo " install it (apt-get install -y shellcheck) or run this in CI" >&2
exit 2
fi
# Union of two signals, because either alone misses a real case: a shebang scan
# misses a sourced fragment with no shebang, and a *.sh glob misses the
# extensionless tools in rootfs/usr/local/bin/. Silent skipping is precisely the
# failure mode this gate exists to prevent, so err toward over-collecting.
# -print0/mapfile -d '' so a path containing a space cannot silently split.
mapfile -d '' -t all_files < <(find . -not -path './.git/*' -type f -print0)
sh_files=()
for f in "${all_files[@]}"; do
case "$f" in *.sh) sh_files+=("$f"); continue;; esac
if head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(bash|sh)\b'; then
sh_files+=("$f")
fi
done
echo "Checking ${#sh_files[@]} shell file(s) with $(shellcheck --version | awk '/version:/{print $2}')"
# A green tick over an empty file set is not a check.
if [ "${#sh_files[@]}" -eq 0 ]; then
echo "::error::no shell files found — the shebang scan or the checkout is wrong"
exit 1
fi
rc=0
shellcheck -S error -f gcc "${sh_files[@]}" || rc=1
# bash -n catches a different class than shellcheck (unbalanced constructs it
# declines to parse), so both run and both count.
for f in "${sh_files[@]}"; do
bash -n "$f" || { echo "::error file=$f::bash -n failed"; rc=1; }
done
if [ "$rc" -eq 0 ]; then
echo "OK: ${#sh_files[@]} shell file(s) clean at severity error"
fi
exit "$rc"
+37 -2
View File
@@ -5,6 +5,7 @@
#
# Verifies:
# - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version
# - node MAJOR matches Dockerfile.base's ARG NODE_VERSION (if EXPECTED_NODE_MAJOR set)
# - mempalace core matches the audited pin (if EXPECTED_MEMPALACE_VERSION set)
# - new v1.0.0 base additions (pandoc, graphviz, imagemagick, yq, tealdeer)
# - typst PDF engine for pandoc (v1.4.0) — `pandoc --pdf-engine=typst`
@@ -91,7 +92,18 @@ if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
else
run "pi" "pi --version"
fi
run "node" "node --version"
# Until 2026-09-07 this was a bare `run "node" "node --version"`, which asserts
# only that the binary exists and exits 0 — the printed version was never
# compared to anything. A node major bump would therefore have passed this suite
# SILENTLY, while a reader skimming it would reasonably assume node regressions
# were covered. EXPECTED_NODE_MAJOR closes that: CI derives it from
# Dockerfile.base's ARG NODE_VERSION (the single source of truth), so this also
# catches a stale cached layer whose node does not match the declared ARG.
if [ -n "${EXPECTED_NODE_MAJOR:-}" ]; then
run_expect "node major matches Dockerfile ARG" "node --version" "v${EXPECTED_NODE_MAJOR}."
else
run "node" "node --version"
fi
run "git" "git --version"
run "aws" "aws --version"
run "uv" "uv --version"
@@ -739,10 +751,33 @@ exec_test "pi-atelier registered from /opt, not npm: (volume-shadowing guard)" \
# shadows it. The check that actually bites lives in
# recreate-sanity-check.sh, which runs where the volume is real — that is
# where a 7-week-old 0.27.0 was caught shadowing 0.35.2 on 2026-09-06.
# EXECUTION is ASSERTED here, not printed. Until 2026-09-07 the version was
# captured inside an echo with 2>/dev/null, so a binary that could not run at all
# still PASSED and simply printed version=[] -- the same failure class as the bare
# `node --version` two hundred lines up: a value displayed rather than compared.
#
# Why this exit code matters more than most: smoke runs `platforms: linux/amd64`
# on an x86 runner, i.e. NATIVE amd64, so this is the fleet's only recurring
# amd64 runtime proof for the linux-x64 ELF. No devbox can supply one -- every
# machine in the pi fleet is an Apple Silicon Mac (mbp-m1-2020; tor-ms22 = Mac
# Studio Mac13,1 M1 Max, verified 2026-08-17 by system_profiler; emb-7kj4vr4g =
# Apple Silicon, 4 routes 2026-09-07). Asking a device for that proof is asking
# for the impossible; CI already had it and was discarding it.
#
# KEEP PROSE OUT OF THE QUOTED BODY BELOW. On 2026-09-07 this explanation lived
# INSIDE the single-quoted argument and contained an apostrophe ("the fleet's").
# Inside '...' bash treats a backslash literally, so \' does not escape -- it
# CLOSES the string. The body silently truncated, the remaining lines were parsed
# by the RUNNER's shell instead of the container's, and `agent-browser --version`
# ran on a host that has no agent-browser: "line 770: command not found", release
# v1.8.14's smoke job failed after the base had already built. shellcheck caught
# it as SC2289 the same day and the red lint job went unread for 24h.
exec_test "agent-browser resolves under /usr (volume-shadowing guard, build-time half)" '
p=$(command -v agent-browser) || { echo "agent-browser not on PATH" >&2; exit 1; }
r=$(readlink -f "$p")
echo "resolved=[$r] version=[$(agent-browser --version 2>/dev/null | head -n1)]" >&2
v=$(agent-browser --version) || { echo "agent-browser did not EXECUTE" >&2; exit 1; }
test -n "$v" || { echo "agent-browser --version produced no output" >&2; exit 1; }
echo "resolved=[$r] version=[$(printf %s "$v" | head -n1)]" >&2
case "$r" in /usr/*) ;; *) exit 1 ;; esac
test ! -d "$HOME/.pi/npm-global/lib/node_modules/agent-browser" || exit 1
echo ok