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 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.
This commit is contained in:
2026-05-24 15:38:36 +00:00
parent 4cce39d167
commit f7c34091b1
6 changed files with 151 additions and 17 deletions
+30 -7
View File
@@ -30,10 +30,10 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
┌──────────────────┐ ┌──────────────────┐
│ base-decide │ compute base-<hash>; │ base-decide │ compute base-<hash>;
│ │ probe Docker Hub. │ │ probe Docker Hub.
│ hash inputs: │ │ hash inputs: │ (resolve-versions
│ Dockerfile.base│ │ Dockerfile.base│ runs in parallel:
│ rootfs/ │ │ rootfs/ │ npm view pi/omos
│ entrypoint*.sh │ │ 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 **`base-decide`** computes a SHA-256 hash over the inputs that determine
content: the base image's content:
```sh ```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, that change anything in the base — apt packages, AWS CLI, Node version,
locale list, entrypoint scripts — pay the full base-build cost once. 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) ### Step 2: `build-base` (conditional)
Only runs when `need_build=true`. Multi-arch (amd64 + arm64) build of Only runs when `need_build=true`. Multi-arch (amd64 + arm64) build of
+58 -9
View File
@@ -102,6 +102,37 @@ jobs:
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build." echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
fi 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 ────── # ── Phase 2: build & push base (multi-arch), only when needed ──────
build-base: build-base:
needs: [base-decide] needs: [base-decide]
@@ -211,10 +242,11 @@ jobs:
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
smoke-omos: smoke-omos:
needs: [base-decide, build-base] needs: [base-decide, build-base, resolve-versions]
if: | if: |
always() && always() &&
needs.base-decide.result == 'success' && needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped') (needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@@ -249,13 +281,17 @@ jobs:
INSTALL_OPENCODE=true INSTALL_OPENCODE=true
INSTALL_OMOS=true INSTALL_OMOS=true
INSTALL_PI=false 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: smoke-with-pi:
needs: [base-decide, build-base] needs: [base-decide, build-base, resolve-versions]
if: | if: |
always() && always() &&
needs.base-decide.result == 'success' && needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped') (needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@@ -290,13 +326,17 @@ jobs:
INSTALL_OPENCODE=true INSTALL_OPENCODE=true
INSTALL_OMOS=false INSTALL_OMOS=false
INSTALL_PI=true 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: smoke-omos-with-pi:
needs: [base-decide, build-base] needs: [base-decide, build-base, resolve-versions]
if: | if: |
always() && always() &&
needs.base-decide.result == 'success' && needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped') (needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@@ -331,7 +371,12 @@ jobs:
INSTALL_OPENCODE=true INSTALL_OPENCODE=true
INSTALL_OMOS=true INSTALL_OMOS=true
INSTALL_PI=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 ──────────────────────── # ── Phase 4: multi-arch publish per variant ────────────────────────
@@ -384,7 +429,7 @@ jobs:
tags: ${{ steps.tags.outputs.tags }} tags: ${{ steps.tags.outputs.tags }}
build-variant-omos: build-variant-omos:
needs: [base-decide, smoke-omos] needs: [base-decide, smoke-omos, resolve-versions]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -429,10 +474,11 @@ jobs:
INSTALL_OPENCODE=true INSTALL_OPENCODE=true
INSTALL_OMOS=true INSTALL_OMOS=true
INSTALL_PI=false INSTALL_PI=false
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
tags: ${{ steps.tags.outputs.tags }} tags: ${{ steps.tags.outputs.tags }}
build-variant-with-pi: build-variant-with-pi:
needs: [base-decide, smoke-with-pi] needs: [base-decide, smoke-with-pi, resolve-versions]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -477,10 +523,11 @@ jobs:
INSTALL_OPENCODE=true INSTALL_OPENCODE=true
INSTALL_OMOS=false INSTALL_OMOS=false
INSTALL_PI=true INSTALL_PI=true
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
tags: ${{ steps.tags.outputs.tags }} tags: ${{ steps.tags.outputs.tags }}
build-variant-omos-with-pi: 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 runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -525,6 +572,8 @@ jobs:
INSTALL_OPENCODE=true INSTALL_OPENCODE=true
INSTALL_OMOS=true INSTALL_OMOS=true
INSTALL_PI=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 }} tags: ${{ steps.tags.outputs.tags }}
# ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─ # ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
+1
View File
@@ -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. **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. - **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`. - **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`. - **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. - **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. - **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.
+13
View File
@@ -8,6 +8,19 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a
## Unreleased ## 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 ## 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. 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.
+20
View File
@@ -31,6 +31,10 @@ ARG TARGETARCH
ARG USER_NAME=developer ARG USER_NAME=developer
# ── Install opencode via npm ───────────────────────────────────────── # ── 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 INSTALL_OPENCODE=true
ARG OPENCODE_VERSION=1.15.10 ARG OPENCODE_VERSION=1.15.10
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \ 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 # 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 # runs each repo's install.sh on container start so symlinks land under
# ~/.pi/agent/ on the named volume. # ~/.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 INSTALL_PI=false
ARG PI_VERSION=latest ARG PI_VERSION=latest
ARG PI_TOOLKIT_REF=main ARG PI_TOOLKIT_REF=main
@@ -89,6 +105,10 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \
# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ──────── # ── Optional: oh-my-opencode-slim (multi-agent orchestration) ────────
# Installs Bun runtime and the oh-my-opencode-slim npm package. # 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 INSTALL_OMOS=false
ARG OMOS_VERSION=latest ARG OMOS_VERSION=latest
RUN if [ "${INSTALL_OMOS}" = "true" ]; then \ RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
+28
View File
@@ -43,6 +43,25 @@ run() {
fi 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 <pkg>` (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 "=== Smoke test: $IMAGE (variant: $VARIANT) ==="
echo echo
echo "-- Resolved component versions --" echo "-- Resolved component versions --"
@@ -134,7 +153,11 @@ fi
# entrypoint-user.sh on first start, so we test by running the entry # entrypoint-user.sh on first start, so we test by running the entry
# point chain (not just `docker run --entrypoint=""`). # point chain (not just `docker run --entrypoint=""`).
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&1; then if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&1; then
if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
run_expect "pi version matches build-arg" "pi --version" "$EXPECTED_PI_VERSION"
else
run "pi" "pi --version" 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-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" 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 # queries the user prefix and would miss the baked binaries even though
# they're correctly on PATH at /usr/bin. # 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" 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 else
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v bun" >/dev/null 2>&1; then 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" fail "bun should NOT be in base image but was found"