From f7c34091b17ed908f7f0181a39cb97183cc4a845 Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Sun, 24 May 2026 15:38:36 +0000 Subject: [PATCH] CI: preventative fix for PI_VERSION/OMOS_VERSION cache-hit silent regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the pi-devbox v0.75.5b fix (2026-05-23) onto the four-variant pipeline here. The with-pi, omos, and omos-with-pi variants install upstream npm packages whose *_VERSION build-args defaulted to 'latest'. When the build-arg string is byte-identical across builds, the layer hash is identical and the registry buildcache silently reuses the layer from whatever upstream version was current when the cache was first populated — same mechanism that shipped pi-devbox v0.74.0..v0.75.5 with identical image bytes. Currently masked here because OPENCODE_VERSION is a hard-coded ARG that bumps every release; parent-chain cache invalidation flushes the downstream pi/omos layers. Masking would fail on any vN.N.Nb opencode- version-unchanged release that only bumps pi or omos. Filed last night as parked followup; fixing preventatively now that #5 (AWS SSO inside tor-ms22 container) cleared. CHANGES .gitea/workflows/docker-publish-split.yml — new resolve-versions job running 'npm view @earendil-works/pi-coding-agent version' and 'npm view oh-my-opencode-slim version', exposing concrete strings as job outputs. All six affected jobs (smoke-omos, smoke-with-pi, smoke-omos-with-pi, build-variant-omos, build-variant-with-pi, build-variant-omos-with-pi) now consume them as PI_VERSION / OMOS_VERSION build-args. smoke-base / build-variant-base unaffected. scripts/smoke-test.sh — new run_expect helper asserting an expected substring in command output. The pi check uses EXPECTED_PI_VERSION; the omos check uses EXPECTED_OMOS_VERSION against npm ls -g. Both env vars are wired from resolve-versions outputs in the smoke jobs. Catches this regression class on the next release, not four releases later. Dockerfile.variant — comment blocks above OPENCODE_VERSION (source- pinned, not subject to the bug), PI_VERSION (CI-resolved), and OMOS_VERSION (CI-resolved) explaining the cache-hit footgun. AGENTS.md — new convention bullet under 'Critical conventions' naming the resolve-versions job + EXPECTED_*_VERSION wiring as the contract to keep in lockstep when modifying variant build-args. .gitea/README.md — Step 1 expanded to cover the parallel resolve- versions job alongside base-decide; pipeline diagram updated. CHANGELOG.md — Unreleased entry describing the fix, masking mechanism, and audit footprint. No image-content change expected on the next release vs what 'latest' would have resolved to anyway. Purely makes the cache invalidate correctly going forward. --- .gitea/README.md | 37 ++++++++++--- .gitea/workflows/docker-publish-split.yml | 67 ++++++++++++++++++++--- AGENTS.md | 1 + CHANGELOG.md | 13 +++++ Dockerfile.variant | 20 +++++++ scripts/smoke-test.sh | 30 +++++++++- 6 files changed, 151 insertions(+), 17 deletions(-) diff --git a/.gitea/README.md b/.gitea/README.md index 36fb81c..c615e14 100644 --- a/.gitea/README.md +++ b/.gitea/README.md @@ -30,10 +30,10 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer ┌──────────────────┐ │ base-decide │ compute base-; │ │ probe Docker Hub. - │ hash inputs: │ - │ Dockerfile.base│ - │ rootfs/ │ - │ entrypoint*.sh │ + │ hash inputs: │ (resolve-versions + │ Dockerfile.base│ runs in parallel: + │ rootfs/ │ npm view pi/omos + │ entrypoint*.sh │ → concrete versions) └────────┬─────────┘ │ ┌─────────────┴─────────────┐ @@ -73,10 +73,10 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer └──────────────────────────┘ ``` -### Step 1: `base-decide` +### Step 1: `base-decide` (and `resolve-versions` in parallel) -Compute a SHA-256 hash over the inputs that determine the base image's -content: +**`base-decide`** computes a SHA-256 hash over the inputs that determine +the base image's content: ```sh { @@ -106,6 +106,29 @@ This is the core cache-reuse mechanism. Version-bump-only releases that change anything in the base — apt packages, AWS CLI, Node version, locale list, entrypoint scripts — pay the full base-build cost once. +**`resolve-versions`** runs alongside `base-decide` (no `needs:` +dependency between them) and resolves the floating npm packages whose +`*_VERSION` build-args default to `latest`: + +```sh +PI_VERSION=$(npm view @earendil-works/pi-coding-agent version) +OMOS_VERSION=$(npm view oh-my-opencode-slim version) +``` + +The outputs (`pi_version`, `omos_version`) are consumed by every variant +smoke and build job that installs pi or omos. **Why this exists:** without +it, the `npm install -g` RUN layer in `Dockerfile.variant` hashes +identically across builds (same ARG default, same command string), so +the registry buildcache silently reuses the layer from whatever upstream +version was current when the cache was first populated. This is the +cache-hit silent-regression class of bug that shipped pi-devbox v0.74.0 +through v0.75.5 with identical image bytes (fixed in pi-devbox v0.75.5b +2026-05-23). Currently masked here by `OPENCODE_VERSION` bumping every +release (parent-chain cache-key invalidation), but masking would fail on +a `vN.N.Nb` opencode-version-unchanged release that only bumps pi or +omos. Smoke jobs additionally assert `EXPECTED_PI_VERSION` / +`EXPECTED_OMOS_VERSION` against the resolved values. + ### Step 2: `build-base` (conditional) Only runs when `need_build=true`. Multi-arch (amd64 + arm64) build of diff --git a/.gitea/workflows/docker-publish-split.yml b/.gitea/workflows/docker-publish-split.yml index 6064581..2e60f65 100644 --- a/.gitea/workflows/docker-publish-split.yml +++ b/.gitea/workflows/docker-publish-split.yml @@ -102,6 +102,37 @@ jobs: echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build." fi + # ── Phase 1b: resolve floating npm versions (pi, omos) to concrete + # versions so the variant build-args carry a different value when an + # upstream package bumps. Without this, when PI_VERSION / OMOS_VERSION + # default to 'latest', the docker/build-push-action build-arg string + # is byte-identical across builds, so the resulting layer-hash is + # identical, so the registry buildcache silently reuses the layer + # from whatever pi/omos 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). Currently masked here because OPENCODE_VERSION + # is hard-coded in Dockerfile.variant and bumps every release — + # invalidating the parent-chain cache key for the pi/omos layers — but + # that masking would fail the moment we cut a vN.N.Nb opencode-version- + # unchanged release that only bumps pi or omos. Fix is preventative. + resolve-versions: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + outputs: + pi_version: ${{ steps.resolve.outputs.pi_version }} + omos_version: ${{ steps.resolve.outputs.omos_version }} + steps: + - name: Resolve pi + omos versions from npm + id: resolve + run: | + set -eu + PI_VERSION=$(npm view @earendil-works/pi-coding-agent version) + OMOS_VERSION=$(npm view oh-my-opencode-slim version) + echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT" + echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT" + echo "Resolved PI_VERSION=${PI_VERSION}, OMOS_VERSION=${OMOS_VERSION}" + # ── Phase 2: build & push base (multi-arch), only when needed ────── build-base: needs: [base-decide] @@ -211,10 +242,11 @@ jobs: run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base smoke-omos: - needs: [base-decide, build-base] + needs: [base-decide, build-base, resolve-versions] if: | always() && needs.base-decide.result == 'success' && + needs.resolve-versions.result == 'success' && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') runs-on: ubuntu-latest container: @@ -249,13 +281,17 @@ jobs: INSTALL_OPENCODE=true INSTALL_OMOS=true INSTALL_PI=false - - run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos + OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }} + - env: + EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }} + run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos smoke-with-pi: - needs: [base-decide, build-base] + needs: [base-decide, build-base, resolve-versions] if: | always() && needs.base-decide.result == 'success' && + needs.resolve-versions.result == 'success' && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') runs-on: ubuntu-latest container: @@ -290,13 +326,17 @@ jobs: INSTALL_OPENCODE=true INSTALL_OMOS=false INSTALL_PI=true - - run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi + PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }} + - env: + EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} + run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi smoke-omos-with-pi: - needs: [base-decide, build-base] + needs: [base-decide, build-base, resolve-versions] if: | always() && needs.base-decide.result == 'success' && + needs.resolve-versions.result == 'success' && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') runs-on: ubuntu-latest container: @@ -331,7 +371,12 @@ jobs: INSTALL_OPENCODE=true INSTALL_OMOS=true INSTALL_PI=true - - run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi + PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }} + OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }} + - env: + EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} + EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }} + run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi # ── Phase 4: multi-arch publish per variant ──────────────────────── @@ -384,7 +429,7 @@ jobs: tags: ${{ steps.tags.outputs.tags }} build-variant-omos: - needs: [base-decide, smoke-omos] + needs: [base-decide, smoke-omos, resolve-versions] runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest @@ -429,10 +474,11 @@ jobs: INSTALL_OPENCODE=true INSTALL_OMOS=true INSTALL_PI=false + OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }} tags: ${{ steps.tags.outputs.tags }} build-variant-with-pi: - needs: [base-decide, smoke-with-pi] + needs: [base-decide, smoke-with-pi, resolve-versions] runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest @@ -477,10 +523,11 @@ jobs: INSTALL_OPENCODE=true INSTALL_OMOS=false INSTALL_PI=true + PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }} tags: ${{ steps.tags.outputs.tags }} build-variant-omos-with-pi: - needs: [base-decide, smoke-omos-with-pi] + needs: [base-decide, smoke-omos-with-pi, resolve-versions] runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest @@ -525,6 +572,8 @@ jobs: INSTALL_OPENCODE=true INSTALL_OMOS=true INSTALL_PI=true + PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }} + OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }} tags: ${{ steps.tags.outputs.tags }} # ── Phase 5: promote base- → base-latest (manifest copy only) ─ diff --git a/AGENTS.md b/AGENTS.md index 0ad4395..bac71e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,7 @@ cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil- **Between releases the same coupling applies.** Doc drift is not just a release-day concern — a workflow tweak, entrypoint change, or `generate-config.py` refactor can leave any of these four files lying. Before committing a non-release change, grep the docs for references to what you touched: `git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md DOCKER_HUB.md .gitea/README.md .env.example`. If a doc says "four variants" / "two phases" / "runs on amd64 only" and your change made that no longer true, fix it in the same commit. - **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag. - **Resolved versions are logged by the smoke test** — `scripts/smoke-test.sh` prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to `latest`. +- **`PI_VERSION` and `OMOS_VERSION` MUST be passed by CI as concrete versions**, not left at the `latest` default. The npm install steps in `Dockerfile.variant` (`npm install -g @earendil-works/pi-coding-agent` / `oh-my-opencode-slim@${OMOS_VERSION}`) produce identical layer-hashes when the ARG values are byte-identical across builds; combined with the registry buildcache (`base-buildcache`) the layer gets reused even when `latest` would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently *masked* in opencode-devbox by `OPENCODE_VERSION` being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream pi/omos layers — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps pi or omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view @earendil-works/pi-coding-agent version` and `npm view oh-my-opencode-slim version`, exposing concrete values as outputs that every variant smoke + build job consumes via build-args. Smoke tests assert via `EXPECTED_PI_VERSION` / `EXPECTED_OMOS_VERSION` env vars — would catch the regression on the next release rather than four releases later. **If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.** - **Shell scripts use `set -euo pipefail`** — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with `|| true`. - **MemPalace install path** — installed via `uv tool install` into `/opt/uv-tools/mempalace/`. Both the `mempalace` CLI and the `mempalace-mcp` MCP server binary are shipped as entry points by the mempalace package itself and placed on PATH by uv as shims whose shebangs point at the venv's Python. No hand-rolled wrapper is needed. Do not use `pip install --break-system-packages` — that was the previous approach and has been removed. Do not use `["python3", "-m", "mempalace.mcp_server"]` in `opencode.jsonc` — system Python can't import from the uv venv. - **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.jsonc` or legacy `opencode.json`. Config persists in the `devbox-opencode-config` named volume; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this. diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ede54..9aca7df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a ## Unreleased +### CI: preventative fix for PI_VERSION/OMOS_VERSION cache-hit silent regression + +Mirrors the pi-devbox v0.75.5b fix (2026-05-23) onto the four-variant pipeline here. The `with-pi`, `omos`, and `omos-with-pi` variants all install upstream npm packages (`@earendil-works/pi-coding-agent`, `oh-my-opencode-slim`) whose `*_VERSION` build-args defaulted to `latest`. When the build-arg string is byte-identical across builds, the resulting layer-hash is identical, and the registry buildcache (`base-buildcache` / variant cache-from chain) silently reuses the layer from whatever upstream version was current when the cache was first populated — the same mechanism that caused pi-devbox v0.74.0 through v0.75.5 to ship the same image bytes. + +Currently masked here because `OPENCODE_VERSION` is a hard-coded ARG that bumps every release — changing a parent layer invalidates the downstream cache key for the pi/omos install layers. Masking would fail the moment we cut a `vN.N.Nb` opencode-version-unchanged release that only bumps pi or omos. Filed as a parked followup that bedtime; fixing it preventatively now. + +- **`.gitea/workflows/docker-publish-split.yml`** — new `resolve-versions` job runs `npm view @earendil-works/pi-coding-agent version` and `npm view oh-my-opencode-slim version`, exposing concrete strings as job outputs. All six affected jobs (`smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`, `build-variant-omos`, `build-variant-with-pi`, `build-variant-omos-with-pi`) now `needs:` it and pass the concrete versions as `PI_VERSION` / `OMOS_VERSION` build-args. `smoke-base` and `build-variant-base` are unaffected (no pi or omos). +- **`scripts/smoke-test.sh`** — new `run_expect` helper asserts an expected substring in command output. The pi-version check uses `EXPECTED_PI_VERSION` when set; the omos check uses `EXPECTED_OMOS_VERSION` against `npm ls -g`. Both env vars are wired from `resolve-versions` outputs in the smoke jobs. Catches the regression on the next release rather than four releases later. +- **`Dockerfile.variant`** — comment block above each affected `ARG` (`OPENCODE_VERSION`, `PI_VERSION`, `OMOS_VERSION`) documenting the cache-hit footgun + which ones are CI-resolved vs source-pinned. +- **`AGENTS.md`** — new convention bullet explaining the cache-hit class of bug and naming the resolve-versions job + EXPECTED_*_VERSION wiring as the contract to keep in lockstep. + +No image-content change expected on the next release vs what `latest` would have resolved to anyway — this is purely about making sure the cache invalidates correctly going forward. + ## v1.15.10 — 2026-05-23 opencode 1.15.6 → 1.15.10 bump (four upstream patch releases over two days). Plus implicit pi 0.75.4 → 0.75.5 in the `with-pi` and `omos-with-pi` variants since `PI_VERSION=latest` resolves at build time. diff --git a/Dockerfile.variant b/Dockerfile.variant index 30d8a63..9d6c47a 100644 --- a/Dockerfile.variant +++ b/Dockerfile.variant @@ -31,6 +31,10 @@ ARG TARGETARCH ARG USER_NAME=developer # ── Install opencode via npm ───────────────────────────────────────── +# OPENCODE_VERSION is intentionally pinned in this Dockerfile (not +# 'latest'). It drives the release tag and gets bumped via a source +# edit, so the cache-hit class of bug that bit pi-devbox v0.74.0.. +# v0.75.5 cannot apply here. ARG INSTALL_OPENCODE=true ARG OPENCODE_VERSION=1.15.10 RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \ @@ -42,6 +46,18 @@ RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \ # pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh # runs each repo's install.sh on container start so symlinks land under # ~/.pi/agent/ on the named volume. +# PI_VERSION should be passed explicitly by CI as a concrete version +# (resolved from `npm view @earendil-works/pi-coding-agent version`, +# see .gitea/workflows/docker-publish-split.yml § resolve-versions). +# The default `latest` is for local dev convenience only — it has a +# known cache-hit footgun when used in registry-cached CI builds: the +# resulting build-arg string is byte-identical across builds, the +# layer-hash is identical, and the registry buildcache silently reuses +# the layer from whatever pi version was current when the cache was +# first populated. Currently masked here because OPENCODE_VERSION (a +# parent layer) bumps every release; will manifest the moment a +# vN.N.Nb opencode-version-unchanged release ships. See pi-devbox +# v0.75.5b 2026-05-23 for the discovery + canonical fix. ARG INSTALL_PI=false ARG PI_VERSION=latest ARG PI_TOOLKIT_REF=main @@ -89,6 +105,10 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \ # ── Optional: oh-my-opencode-slim (multi-agent orchestration) ──────── # Installs Bun runtime and the oh-my-opencode-slim npm package. +# OMOS_VERSION shares the same cache-hit footgun as PI_VERSION when +# left at the `latest` default in registry-cached CI builds. CI +# resolves it via `npm view oh-my-opencode-slim version` and passes +# the concrete value as a build-arg. See PI_VERSION block above. ARG INSTALL_OMOS=false ARG OMOS_VERSION=latest RUN if [ "${INSTALL_OMOS}" = "true" ]; then \ diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 1c4aeb9..11d6743 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -43,6 +43,25 @@ run() { fi } +# Stricter version of `run` that also asserts an expected substring in +# the command's stdout. Used to catch the "image bytes silently identical +# to previous release" class of regression — Docker layer-cache hit on +# a bare `npm install -g ` (or @latest) because the build-arg +# string is identical across builds, even when 'latest' would have +# resolved differently. Discovered in pi-devbox 2026-05-23 (every +# release v0.74.0..v0.75.5 shipped the same image bytes); preventatively +# applied here for PI_VERSION + OMOS_VERSION. +run_expect() { + local label="$1"; local cmd="$2"; local expect="$3" + local out + out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1) || true + if echo "$out" | grep -Fq "$expect"; then + pass "$label (got $expect)" + else + fail "$label — expected substring '$expect', got: $out" + fi +} + echo "=== Smoke test: $IMAGE (variant: $VARIANT) ===" echo echo "-- Resolved component versions --" @@ -134,7 +153,11 @@ fi # entrypoint-user.sh on first start, so we test by running the entry # point chain (not just `docker run --entrypoint=""`). if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&1; then - run "pi" "pi --version" + if [ -n "${EXPECTED_PI_VERSION:-}" ]; then + run_expect "pi version matches build-arg" "pi --version" "$EXPECTED_PI_VERSION" + else + run "pi" "pi --version" + fi run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD" run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD" @@ -192,6 +215,11 @@ if [ "$VARIANT" = "omos" ] || [ "$VARIANT" = "omos-with-pi" ]; then # queries the user prefix and would miss the baked binaries even though # they're correctly on PATH at /usr/bin. run "oh-my-opencode-slim" "NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim" + if [ -n "${EXPECTED_OMOS_VERSION:-}" ]; then + run_expect "omos version matches build-arg" \ + "NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim" \ + "$EXPECTED_OMOS_VERSION" + fi else if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v bun" >/dev/null 2>&1; then fail "bun should NOT be in base image but was found"