From 53b41cd76ba5f4d43ac4919484f2f48bba02ba54 Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Sat, 15 Aug 2026 12:29:32 +0200 Subject: [PATCH] smoke: assert the pi stage $HOME-relative, and add a smoke_only dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/docker-publish.yml | 21 ++++++++ CHANGELOG.md | 78 +++++++++++++++++++++++++++++ scripts/smoke-test.sh | 42 +++++++++++++++- 3 files changed, 140 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index bee31cf..3d0b4cd 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -18,6 +18,14 @@ name: Publish Docker Image # 5. build-variant multi-arch push of latest + vX.Y.Z tags. # 6. promote-base-latest re-tag base- → base-latest with `crane copy`. # 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: push: @@ -33,6 +41,10 @@ on: description: 'Update latest aliases (default true for tag-push, false for manual test runs)' required: 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: group: ${{ github.workflow }}-${{ github.ref }} @@ -482,6 +494,14 @@ jobs: # ── Phase 4: multi-arch publish ───────────────────────────────────── build-variant: 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 container: image: catthehacker/ubuntu:act-latest @@ -574,6 +594,7 @@ jobs: # or fail independently of the core release. build-variant-studio: needs: [base-decide, smoke-studio, resolve-versions] + if: inputs.smoke_only != 'true' runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 0beaa19..ee5d3b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 Minor release. Headline: **pi sessions now feed MemPalace by themselves.** The diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 1e32c17..48016d1 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -43,12 +43,23 @@ PASS=0; FAIL=0 # catching an unexpected +GB regression. 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() { 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)) else 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 } @@ -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 # finds into the real stage, which is how a synthetic test session ends up # 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)" ' 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:-} 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/" ' run "pi stage follows MEMPALACE_PALACE_PATH" '