diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index 281b383..83e87da 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -142,6 +142,7 @@ jobs: image: catthehacker/ubuntu:act-latest outputs: pi_version: ${{ steps.resolve.outputs.pi_version }} + mempalace_version: ${{ steps.resolve.outputs.mempalace_version }} fork_ref: ${{ steps.resolve.outputs.fork_ref }} obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }} toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }} @@ -242,6 +243,54 @@ jobs: fi echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT" + # ── mempalace core: same audit as pi, from Dockerfile.base ──── + # Until now this pin had NO CI-side audit at all — a literal string + # in Dockerfile.base with zero references in this workflow, while + # PI_VERSION got a concreteness gate, a published-on-registry check + # and a drift warning. It is the same class of risk: the palace's MCP + # tool schema is the agent-facing contract, and a client/server skew + # against the shared central palace is a fleet-wide, not local, + # problem. Read from Dockerfile.base (not duplicated here) so a local + # `docker build` and CI install the same version by construction. + MEMPALACE_VERSION=$(sed -n 's/^ARG MEMPALACE_VERSION=\([^[:space:]]*\).*/\1/p' Dockerfile.base | head -n1) + if ! printf '%s' "${MEMPALACE_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::ARG MEMPALACE_VERSION in Dockerfile.base is not a concrete version (got '${MEMPALACE_VERSION:-}'). CI refuses to build from a floating palace version — see the pin policy comment above that ARG." + exit 1 + fi + # One fetch, two gates. `curl -sf` exits non-zero and prints nothing + # on 404 (PyPI's answer for an unpublished version), so an empty body + # lands in the "not published" branch with its own message. + MEMPALACE_PYPI=$(curl -sf "https://pypi.org/pypi/mempalace/${MEMPALACE_VERSION}/json" || true) + MEMPALACE_PUBLISHED=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.version // empty' 2>/dev/null || true) + if [ "${MEMPALACE_PUBLISHED:-}" != "${MEMPALACE_VERSION}" ]; then + echo "::error::Pinned mempalace version ${MEMPALACE_VERSION} is not published on PyPI (registry returned '${MEMPALACE_PUBLISHED:-}'). Fix ARG MEMPALACE_VERSION in Dockerfile.base." + exit 1 + fi + # A yanked release still installs when pinned exactly (PEP 592), so + # `uv tool install mempalace==X` would succeed silently and ship a + # version upstream has withdrawn to the whole fleet. The escape hatch + # is the same one-line bump that got us here. + MEMPALACE_YANKED=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.yanked // false' 2>/dev/null || true) + if [ "${MEMPALACE_YANKED:-false}" = "true" ]; then + # Reason hoisted into its own variable rather than inlined as a + # $(...) inside the message: a jq program nested in a substitution + # inside a double-quoted string needs escaping that silently breaks + # the FILTER (jq compile error) while the surrounding `exit 1` still + # fires, so the gate looks correct and reports garbage. Caught by + # the mutation test, not by review. + MEMPALACE_YANK_REASON=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.yanked_reason // "no reason given"' 2>/dev/null || true) + echo "::error::Pinned mempalace version ${MEMPALACE_VERSION} is YANKED on PyPI (${MEMPALACE_YANK_REASON:-no reason given}). An exact pin installs a yanked release without complaint — bump ARG MEMPALACE_VERSION in Dockerfile.base." + exit 1 + fi + # Informational only, exactly like pi's npm drift warning: a newer + # palace must never be adopted implicitly. `|| true` so a transient + # PyPI failure cannot fail a release whose pin is already verified. + MEMPALACE_PYPI_LATEST=$(curl -sf "https://pypi.org/pypi/mempalace/json" | jq -r '.info.version // empty' 2>/dev/null || true) + if [ -n "${MEMPALACE_PYPI_LATEST:-}" ] && [ "${MEMPALACE_PYPI_LATEST}" != "${MEMPALACE_VERSION}" ]; then + echo "::warning::mempalace ${MEMPALACE_PYPI_LATEST} is published; this build ships the audited pin ${MEMPALACE_VERSION}. To adopt it: read the upstream CHANGELOG for MCP tool-schema changes (the agent-facing contract) and for sync/delete semantics, check the skew it introduces against the central palace host's server version, then bump ARG MEMPALACE_VERSION in Dockerfile.base and note the audit in CHANGELOG.md." + fi + echo "mempalace_version=${MEMPALACE_VERSION}" >> "$GITHUB_OUTPUT" + # pi-fork / pi-observational-memory (GitHub) → commit SHAs. FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \ "https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true) @@ -337,6 +386,7 @@ jobs: echo "studio_tag=${STUDIO_TAG}" >> "$GITHUB_OUTPUT" echo "Resolved PI_VERSION=${PI_VERSION} (pinned in Dockerfile.variant; npm latest is ${PI_NPM_LATEST:-unknown})" + echo "Resolved MEMPALACE_VERSION=${MEMPALACE_VERSION} (pinned in Dockerfile.base; PyPI latest is ${MEMPALACE_PYPI_LATEST:-unknown})" echo "Resolved PI_ATELIER_REF=${ATELIER_REF} (pi-atelier ${ATELIER_TAG}, pinned)" echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}" echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}" @@ -471,6 +521,7 @@ jobs: - name: Smoke test (amd64) 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 # ── Phase 3b: amd64 smoke for the studio variant ──────────────────── @@ -533,6 +584,7 @@ jobs: - name: Smoke test studio (amd64) 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 # ── Phase 4: multi-arch publish ───────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index bcbcda8..d0841d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,57 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`). ## Unreleased +### Added + +- **`MEMPALACE_VERSION` now gets the same CI audit as `PI_VERSION`** — closing + the item v1.8.6 (and v1.8.5 before it) listed as "Still open". The pin was a + literal string in `Dockerfile.base` with **zero** references anywhere in + `.gitea/workflows/docker-publish.yml`, while `PI_VERSION` had ~20: a + concreteness gate, a published-on-registry check, and a never-silently-adopt + drift warning. `resolve-versions` now applies all of them to the palace pin, + read from `Dockerfile.base` (not duplicated in the workflow, so a local + `docker build` and CI install the same version by construction): + + | Gate | Behaviour | + |---|---| + | not a concrete `X.Y.Z` | **error** — no floating palace version, same policy as pi | + | not published on PyPI | **error** at resolve time, instead of a `uv tool install` failure mid-build | + | **yanked** on PyPI | **error** — an exact pin installs a yanked release silently under PEP 592, so `mempalace==X` would have shipped a withdrawn client to the whole fleet | + | newer release exists | **warning** naming what to audit before adopting (MCP tool-schema = the agent-facing contract; client/server skew against the central palace) | + + Plus one smoke assertion, `installed mempalace matches CI's audited pin`, + gated on a new `EXPECTED_MEMPALACE_VERSION` env threaded into both the `smoke` + and `smoke-studio` jobs. It is **not** redundant with the existing `manifest + mempalace_version matches the installed core`: that one compares two + properties of a single image and therefore cannot notice that *both* are the + wrong version. The failure mode this one covers is a variant built `FROM` a + cached base whose `MEMPALACE_VERSION` pin was older — internally consistent, + silently stale, invisible to every other assertion (the risk + `scripts/check-base-hash.sh` exists to reduce but cannot eliminate). + + **Mutation-tested rather than reasoned about**, by extracting the shipped + block out of the YAML and running it with a stubbed `curl`: 9 cases — + `latest` / `3.8` / absent ARG refused; 404 and a registry echoing a different + version refused; a yanked release refused *with its reason*; a newer release + warning without failing; a transient PyPI outage not failing a build whose pin + is already verified; happy path silent and emitting the job output. Then once + more end-to-end against live PyPI with the real `Dockerfile.base`. **This + found a genuine defect in the first draft**: the yank message inlined a jq + program inside a `$(...)` inside a double-quoted string, where the escaping + broke the *filter* (jq compile error) while the surrounding `exit 1` still + fired — a gate that looked correct and reported garbage. The reason is now + hoisted into its own variable. The new smoke assertion was checked the same + way, through the real `run` helper's `sh -c` quoting path: passes on `3.8.0`, + fails on `3.7.1` *and* on `3.8.01` (exact equality, not the substring match + the pi assertion uses), and skips cleanly when the env is unset so a local + `smoke-test.sh` run is unaffected. + + ⚠️ **Costs a base rebuild on the next tag**: `Dockerfile.base` is hashed + wholesale into `base_tag`, and its now-false "Known gap, carried forward" + comment had to be corrected in place (leaving a comment that says the audit + does not exist would repeat the shipped-false-claim mistake corrected below). + Expect ~67 min, as for v1.8.5/v1.8.6. + ### Fixed - **Four build-provenance smoke assertions verified the presence of a manifest diff --git a/Dockerfile.base b/Dockerfile.base index dbbea58..92e5047 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -419,12 +419,16 @@ ARG INSTALL_MEMPALACE=true # CLI commands against a running server), a different scenario #2307 # does not touch. # -# Known gap, carried forward (flagged in prior release notes, not fixed here): -# unlike PI_VERSION, which CI's resolve-versions job verifies is published and -# warns — never silently adopts — on npm drift, MEMPALACE_VERSION has NO -# equivalent CI-side audit (confirmed: zero references to MEMPALACE_VERSION in -# .gitea/workflows/docker-publish.yml). This is a literal Dockerfile string -# with no automated freshness or publish check. +# CI-side audit (added after v1.8.6, closing that release's "Still open" item): +# resolve-versions now treats this pin exactly as it treats PI_VERSION — it +# reads the ARG from THIS file, refuses a non-concrete value, verifies the +# version is published on PyPI, refuses a YANKED release (an exact pin installs +# one silently under PEP 592), and WARNS — never silently adopts — when PyPI has +# a newer release. smoke-test.sh then asserts the installed core equals that +# audited pin, which catches a stale cached base layer that no manifest-internal +# check can see. So a bump here is now gated end to end; what remains manual is +# the JUDGEMENT above (MCP schema review, server/client sequencing), which is +# the part that should stay manual. # # Deployment sequencing note for whoever ships this bump: synlig (the shared # central palace host) currently serves mempalace 3.7.1 SERVER-SIDE via diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 968d10e..e9bfe8c 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -5,6 +5,7 @@ # # Verifies: # - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version +# - 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 (Unreleased) — `pandoc --pdf-engine=typst` # - non-modal editors nano + micro (alongside nvim) @@ -427,6 +428,21 @@ run "manifest mempalace_version matches the installed core" ' echo "manifest=[$m] installed=[$b]" >&2 [ -n "$m" ] && [ "$m" = "$b" ] ' +# ... and, when CI supplies it, that the installed core is the version CI +# actually AUDITED (published + not yanked on PyPI, in resolve-versions). This +# does NOT duplicate the check above, which compares two properties of one +# image and so cannot notice that BOTH are the wrong version. The live failure +# mode it covers: the variant builds `FROM` a base tag chosen by base-decide's +# content hash, so a bug in that hashing (the reason scripts/check-base-hash.sh +# exists) could reuse a cached base built from an OLDER MEMPALACE_VERSION pin — +# internally consistent, silently stale, invisible to every other assertion. +if [ -n "${EXPECTED_MEMPALACE_VERSION:-}" ]; then + run "installed mempalace matches CI's audited pin (${EXPECTED_MEMPALACE_VERSION})" " + b=\$(mempalace --version 2>/dev/null | head -n1 | tr -d '\r'); b=\${b##* } + echo \"installed=[\$b] audited_pin=[${EXPECTED_MEMPALACE_VERSION}]\" >&2 + [ \"\$b\" = \"${EXPECTED_MEMPALACE_VERSION}\" ] + " +fi # Every component must be a resolved commit (or null for pi-studio in the # non-studio variant) — now enforced by the 40-hex value check above, which # strictly subsumes the old whole-file grep for '"unknown"'. Only rev() ever