Compare commits

..

1 Commits

Author SHA1 Message Date
Joakim Persson 53b41cd76b smoke: assert the pi stage $HOME-relative, and add a smoke_only dispatch
Lint / actionlint (push) Successful in 16s
Lint / hadolint (push) Successful in 13s
Publish Docker Image / resolve-versions (push) Successful in 10s
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 5m20s
Publish Docker Image / smoke (push) Successful in 14m40s
Publish Docker Image / build-variant-studio (push) Successful in 21m51s
Publish Docker Image / build-variant (push) Successful in 15m59s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 17s
v1.8.0 never shipped: smoke (67/68) and smoke-studio (70/71) each failed the
same single assertion, so build-variant and everything downstream skipped and
latest stayed on v1.7.0.

The assertion was wrong, not the product. It grepped for a literal
stage=/home/developer/.mempalace/pi-stage/, but run() invokes

  docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd"

and neither Dockerfile sets USER or ENV HOME — the published base image config
has no HOME at all; it is normally set by entrypoint-user.sh, which
--entrypoint="" skips on purpose. So the assertion executed as root with
HOME=/root, mempalace-pi-session correctly resolved
stage=/root/.mempalace/pi-stage/... (the stage is $HOME-relative by design), and
the literal grep could never match under any circumstances.

The tell was one line below in the log: the sibling assertion "pi stage follows
MEMPALACE_PALACE_PATH" PASSED, because it sets the variable explicitly and never
consults HOME. Default fails + explicit passes = wrong HOME, not broken staging.

Now asserts the invariant actually intended — the stage sits beside the resolved
palace, sharing its lifetime — which is user-independent:

  case "$stage" in "stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;; *) exit 1 ;; esac

$HOME is expanded by the container's own shell, so it holds as root, as
developer, or under any future user. Verified all four cases against the real
bin/mempalace-pi-session by extracting the committed assertion bodies and
running them under sh -c: virgin HOME -> exit 0; HOME=/home/developer -> exit 0;
MEMPALACE_PI_STAGE pinned to a .cache path -> exit 1 (the regression this
assertion exists to catch still fails it); developer-identity companion -> 0.

Added that companion assertion, "pi stage is palace-adjacent for the developer
user", which covers the deployment-specific path properly by SUPPLYING
HOME=/home/developer rather than assuming it.

Why this took a release to surface: docker-publish.yml triggers on push tags v*
only. The assertion was added on a push to main (7c00dd6), where only lint.yml
runs, so v1.8.0 was its first execution ever. Any smoke assertion written
outside a release was unvalidated until a release consumed it.

New workflow_dispatch input smoke_only probes/builds the base, runs both smoke
jobs against HEAD, and stops before publishing. Implemented as
`if: inputs.smoke_only != 'true'` on build-variant and build-variant-studio,
deliberately WITHOUT always() so the implicit needs-succeeded gate survives and
a red smoke still blocks a release. promote-base-latest and update-description
already require build-variant success, so they skip on their own. On a tag push
inputs is unset and null != 'true' is true, so releases are unaffected.

Finally, run() no longer discards output. A red  carried zero diagnostic
weight: explaining this one-line failure needed a CI-log dig plus a registry
image-config inspection, when the container had already printed the answer.
Failures now show the last lines of output (guarded with `if`, not a trailing
`&&`, which would abort under set -e), and the stage assertions echo the
resolved stage and the HOME they saw to stderr — invisible while they pass.
2026-08-15 12:29:32 +02:00
3 changed files with 140 additions and 1 deletions
+21
View File
@@ -18,6 +18,14 @@ name: Publish Docker Image
# 5. build-variant multi-arch push of latest + vX.Y.Z tags. # 5. build-variant multi-arch push of latest + vX.Y.Z tags.
# 6. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`. # 6. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`.
# 7. update-description patch Docker Hub description. # 7. update-description patch Docker Hub description.
#
# Note the trigger: `push: tags: v*` (plus workflow_dispatch). Nothing here runs
# on a push to main, so a smoke assertion added outside a release is UNVALIDATED
# until the next tag — which is exactly how v1.8.0 shipped a broken assertion
# written three days earlier (it asserted a literal /home/developer stage path,
# while `run` executes `docker run --entrypoint=""` as root with HOME=/root).
# The `smoke_only` dispatch input exists to close that gap: it runs steps 1-4
# against HEAD and stops before anything is published.
on: on:
push: push:
@@ -33,6 +41,10 @@ on:
description: 'Update latest aliases (default true for tag-push, false for manual test runs)' description: 'Update latest aliases (default true for tag-push, false for manual test runs)'
required: false required: false
default: 'false' default: 'false'
smoke_only:
description: 'Build base + run both smoke jobs against HEAD, then stop. Publishes nothing. Use to validate smoke assertions without cutting a tag.'
required: false
default: 'false'
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -482,6 +494,14 @@ jobs:
# ── Phase 4: multi-arch publish ───────────────────────────────────── # ── Phase 4: multi-arch publish ─────────────────────────────────────
build-variant: build-variant:
needs: [base-decide, smoke, resolve-versions] needs: [base-decide, smoke, resolve-versions]
# A `smoke_only` dispatch stops the pipeline here: base is probed/built and
# both smoke jobs run, but nothing is published. Deliberately NOT wrapped in
# always() — specifying `if:` keeps the implicit "all needs succeeded" gate,
# so a failing smoke still blocks the release. On a tag push `inputs` is
# unset, and `null != 'true'` is true, so releases are unaffected.
# promote-base-latest and update-description need build-variant to have
# succeeded, so they skip on their own — no extra guard required.
if: inputs.smoke_only != 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -574,6 +594,7 @@ jobs:
# or fail independently of the core release. # or fail independently of the core release.
build-variant-studio: build-variant-studio:
needs: [base-decide, smoke-studio, resolve-versions] needs: [base-decide, smoke-studio, resolve-versions]
if: inputs.smoke_only != 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
+78
View File
@@ -11,6 +11,84 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
--- ---
## v1.8.1 — 2026-08-15
Patch release. **Unblocks v1.8.0, which never shipped.** Its `smoke` and
`smoke-studio` jobs each failed exactly one assertion (67/68 and 70/71 passed),
so `build-variant` and everything downstream skipped: no `v1.8.0` tag reached
Docker Hub and `latest` stayed on v1.7.0 from 2026-08-07. Image content is
unchanged from what v1.8.0 intended — the pins here are identical (pi `0.84.2`,
pi-atelier `v0.8.1`).
The failing assertion was `pi stage defaults next to the palace (not a cache
dir)`, added three days earlier in 7c00dd6. **It was a test bug, not a product
regression.** It asserted a literal path:
```sh
echo "$out" | grep -q "stage=/home/developer/.mempalace/pi-stage/"
```
but the `run` helper invokes `docker run --rm --entrypoint="" $IMAGE sh -c …`,
and neither `Dockerfile.base` nor `Dockerfile.variant` sets `USER` or `ENV HOME`
(the published base image config carries no `HOME` at all — `HOME` is normally
set by `entrypoint-user.sh`, which `--entrypoint=""` deliberately skips). So the
assertion ran as **root with `HOME=/root`**, `mempalace-pi-session` correctly
resolved `stage=/root/.mempalace/pi-stage/…` (it is `$HOME`-relative by design:
`$MEMPALACE_PALACE_PATH``$MEMPAL_PALACE_PATH``~/.mempalace/config.json`
`~/.mempalace/palace`), and the literal grep could never match under any
circumstances. The tell was one line below it in the log: the sibling assertion
`pi stage follows MEMPALACE_PALACE_PATH` **passed**, because it sets the variable
explicitly and so never consults `HOME`. Default fails while explicit passes is
the signature of a wrong `HOME`, not of broken staging.
Fixed by asserting the invariant that was actually meant — the stage sits beside
the resolved palace, sharing its lifetime — which is user-independent:
```sh
case "$stage" in
"stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;;
*) exit 1 ;;
esac
```
`$HOME` is expanded by the container's own shell, so this holds as root, as
`developer`, or under any future user, while a cache-dir default — the
regression the assertion exists to catch — still fails it (verified against all
three cases plus a simulated `MEMPALACE_PI_STAGE` cache pin). A second
assertion, `pi stage is palace-adjacent for the developer user`, now covers the
deployment-specific path properly, by *supplying* `HOME=/home/developer` instead
of assuming it.
### Why it took a release to notice — and the `smoke_only` input
`docker-publish.yml` triggers on `push: tags: v*` only. 7c00dd6 was a push to
**main**, so only `lint.yml` ran; v1.8.0 was the first tag afterwards and
therefore the assertion's **first execution ever**. Any smoke assertion written
outside a release was unvalidated until the next release consumed it — the
worst possible moment to discover it.
New `workflow_dispatch` input **`smoke_only`** closes that: it probes/builds the
base and runs both smoke jobs against HEAD, then stops before publishing
anything. Implemented as `if: inputs.smoke_only != 'true'` on `build-variant`
and `build-variant-studio`, deliberately *without* `always()` so the implicit
"needs succeeded" gate survives and a red smoke still blocks a release;
`promote-base-latest` and `update-description` already require `build-variant`
success and so skip on their own. On a tag push `inputs` is unset and
`null != 'true'` is true, so releases behave exactly as before. This release was
validated with a `smoke_only` dispatch before the tag was cut.
### Smoke failures now explain themselves
`run` discarded all output (`>/dev/null 2>&1`), so a red ❌ carried zero
diagnostic weight — explaining this one-line failure took a CI-log dig plus a
registry image-config inspection, when the container had already printed the
answer and thrown it away. It now captures output and prints the last few lines
under a failed assertion only. Assertions that want a diagnostic echo it to
stderr (the stage checks now report the resolved stage and the `HOME` they saw),
which stays invisible while they pass.
---
## v1.8.0 — 2026-08-15 ## v1.8.0 — 2026-08-15
Minor release. Headline: **pi sessions now feed MemPalace by themselves.** The Minor release. Headline: **pi sessions now feed MemPalace by themselves.** The
+41 -1
View File
@@ -43,12 +43,23 @@ PASS=0; FAIL=0
# catching an unexpected +GB regression. # catching an unexpected +GB regression.
SIZE_THRESHOLD_MB=3800 SIZE_THRESHOLD_MB=3800
# On failure, surface the last few lines the command produced. This used to
# discard output entirely (`>/dev/null 2>&1`), which made a red ❌ carry zero
# diagnostic weight: explaining the single v1.8.0 stage-default failure took a
# full CI-log dig plus a registry-config inspection, when the container had
# already printed the answer and thrown it away. Assertions that want a
# diagnostic just echo it to stderr — it stays hidden while they pass.
run() { run() {
local label="$1"; local cmd="$2" local label="$1"; local cmd="$2"
if docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" >/dev/null 2>&1; then local out
if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1); then
printf " ✅ %s\n" "$label"; PASS=$((PASS+1)) printf " ✅ %s\n" "$label"; PASS=$((PASS+1))
else else
printf " ❌ %s\n" "$label"; FAIL=$((FAIL+1)) printf " ❌ %s\n" "$label"; FAIL=$((FAIL+1))
# `if`, not `&&` — a trailing false under `set -e` would abort the script.
if [ -n "$out" ]; then
printf " └─ %s\n" "$(printf '%s' "$out" | tail -3 | tr '\n' ' ' | cut -c1-300)"
fi
fi fi
} }
@@ -102,8 +113,37 @@ run "mempalace-pi-session on PATH" "mempalace-pi-session --help"
# default-staged run at a populated dir would export whatever transcripts it # default-staged run at a populated dir would export whatever transcripts it
# finds into the real stage, which is how a synthetic test session ends up # finds into the real stage, which is how a synthetic test session ends up
# staged for mining as if it were a real conversation. # staged for mining as if it were a real conversation.
#
# Asserted $HOME-RELATIVE, not against a literal /home/developer. `run` invokes
# `docker run --entrypoint=""`, and neither Dockerfile sets USER or ENV HOME
# (HOME is set by entrypoint-user.sh, which --entrypoint="" deliberately skips),
# so these assertions execute as root with HOME=/root. The original literal
# /home/developer form could therefore never match and failed the v1.8.0
# release — a test bug, not a product one: the stage resolution was correct all
# along, it just follows $HOME. The invariant under test ("the stage sits beside
# the palace, sharing its lifetime") is user-independent, so pinning the user
# was never part of it. A cache-dir default still fails the pattern below, which
# is the regression this guards.
#
# It went unnoticed for three days because this workflow only triggers on
# `push: tags: v*` — the assertion was added on a main push, so v1.8.0 was its
# first execution ever. Use the `smoke_only` workflow_dispatch input to run
# smoke against HEAD without cutting a tag.
run "pi stage defaults next to the palace (not a cache dir)" ' run "pi stage defaults next to the palace (not a cache dir)" '
out=$(mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true out=$(mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
stage=$(echo "$out" | grep -oE "stage=[^ ]+" | head -1)
echo "resolved ${stage:-<no stage= line>} with HOME=$HOME" >&2
case "$stage" in
"stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;;
*) exit 1 ;;
esac
'
# Companion to the above: the deployment-specific case the literal assertion was
# reaching for, done properly by supplying the HOME the container actually runs
# with instead of assuming it.
run "pi stage is palace-adjacent for the developer user" '
out=$(HOME=/home/developer mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
echo "$out" | grep -oE "stage=[^ ]+" | head -1 >&2
echo "$out" | grep -q "stage=/home/developer/.mempalace/pi-stage/" echo "$out" | grep -q "stage=/home/developer/.mempalace/pi-stage/"
' '
run "pi stage follows MEMPALACE_PALACE_PATH" ' run "pi stage follows MEMPALACE_PALACE_PATH" '