From fc034ceade09fa0945d1811face15af1de885a6f Mon Sep 17 00:00:00 2001 From: pi Date: Wed, 3 Jun 2026 16:13:44 +0200 Subject: [PATCH] feat: add pi-only variant (pi without opencode) as basis for pi-devbox All opencode-devbox variants set INSTALL_OPENCODE=true, so pointing pi-devbox at with-pi dragged opencode along and made it ~a re-tag of latest-with-pi. Add a 5th variant pi-only (INSTALL_OPENCODE=false, INSTALL_PI=true): pi + companions (toolkit, extensions, fork, recall) + base tooling, no opencode (~145 MB lighter than with-pi). - Dockerfile.variant: document pi-only in the variant table. - CI docker-publish-split.yml: new smoke-pi-only + build-variant-pi-only jobs (tags :VERSION-pi-only / :latest-pi-only, multi-arch); wired into promote-base-latest and update-description needs. - validate.yml: new validate-pi-only main-branch gate job. - smoke-test.sh: accept --variant pi-only; threshold 2750 MB; opencode-absent path already handled. - Docs: HUB_TEMPLATE (regenerated DOCKER_HUB.md), README, AGENTS (variant/tag counts 4->5, 8->10 tags), .gitea/README, manual-host-publish.sh (5 variants), plan doc implementation note. This is the single source of truth for joakimp/pi-devbox, which now FROMs latest-pi-only. Versions unchanged (opencode 1.15.13, pi 0.78.0). --- .gitea/README.md | 10 +- .gitea/workflows/docker-publish-split.yml | 124 ++++++++++++++++++++++ .gitea/workflows/validate.yml | 59 ++++++++++ AGENTS.md | 8 +- CHANGELOG.md | 8 ++ DOCKER_HUB.md | 1 + Dockerfile.variant | 6 ++ README.md | 2 +- docs/manual-host-publish.sh | 24 +++-- docs/plan-lan-access-and-pi-extensions.md | 7 ++ scripts/generate-dockerhub-md.py | 1 + scripts/smoke-test.sh | 7 +- 12 files changed, 236 insertions(+), 21 deletions(-) diff --git a/.gitea/README.md b/.gitea/README.md index c615e14..85a5000 100644 --- a/.gitea/README.md +++ b/.gitea/README.md @@ -8,14 +8,14 @@ the build pipeline is shaped the way it is, you're in the right place. | File | Trigger | Role | |---|---|---| -| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `push: tags: v*` | **Production release pipeline.** Two-phase split-base build: shared `base-` published once (skipped on cache hit), then four parallel variant deltas. ~40–80 min wall clock depending on runner count and whether base needs rebuilding. | -| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of all four variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. | +| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `push: tags: v*` | **Production release pipeline.** Two-phase split-base build: shared `base-` published once (skipped on cache hit), then five parallel variant deltas. ~40–80 min wall clock depending on runner count and whether base needs rebuilding. | +| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of all five variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. | ## Why the split-base pipeline exists opencode-devbox publishes **four image variants** (`base`, `omos`, `with-pi`, `omos-with-pi`) × **two architectures** (amd64, arm64) = **eight image tags per release**. Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~3–5x slower than native). -The four variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build. +The five variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build. Two improvements were considered: @@ -174,7 +174,7 @@ production aliases pointing at the previous good release. ### Step 5: `promote-base-latest` -Once all four variants successfully publish, re-tag `base-` as +Once all five variants successfully publish, re-tag `base-` as `base-latest` using `crane copy`. This is a **manifest-level re-tag, not a rebuild** — it touches only Docker Hub's image index, takes seconds, and is atomic. @@ -252,7 +252,7 @@ on every push to `main` and on PRs. It: 1. Runs `scripts/generate-dockerhub-md.py --check` to enforce `DOCKER_HUB.md` is in sync with `HUB_TEMPLATE`. -2. Builds each of the four variants amd64-only (no multi-arch, no push) +2. Builds each of the five variants amd64-only (no multi-arch, no push) and runs `scripts/smoke-test.sh`. This catches regressions before they reach a tag push. Wall clock ~30 min. diff --git a/.gitea/workflows/docker-publish-split.yml b/.gitea/workflows/docker-publish-split.yml index a83d3b3..8d330fa 100644 --- a/.gitea/workflows/docker-publish-split.yml +++ b/.gitea/workflows/docker-publish-split.yml @@ -432,6 +432,53 @@ jobs: 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 + smoke-pi-only: + 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: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + - run: | + rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ + /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ + /usr/local/lib/android /usr/local/share/powershell \ + /usr/local/share/chromium /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + docker system prune -af --volumes || true + docker builder prune -af || true + - uses: docker/setup-buildx-action@v4 + with: {driver-opts: network=host} + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.variant + platforms: linux/amd64 + push: false + load: true + tags: opencode-devbox:smoke-pi-only + build-args: | + BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + INSTALL_OPENCODE=false + INSTALL_OMOS=false + INSTALL_PI=true + PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }} + PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }} + PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }} + - env: + EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} + run: bash scripts/smoke-test.sh opencode-devbox:smoke-pi-only --variant pi-only + # ── Phase 4: multi-arch publish per variant ──────────────────────── build-variant-base: @@ -727,6 +774,81 @@ jobs: echo "==> All 3 build+push attempts failed" exit 1 + build-variant-pi-only: + needs: [base-decide, smoke-pi-only, resolve-versions] + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + - run: | + rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ + /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ + /usr/local/lib/android /usr/local/share/powershell \ + /usr/local/share/chromium /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + docker system prune -af --volumes || true + docker builder prune -af || true + - uses: docker/setup-qemu-action@v3 + with: {platforms: arm64} + - uses: docker/setup-buildx-action@v4 + with: {driver-opts: network=host} + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Compute version-specific tags + id: tags + run: | + VERSION="${{ env.RELEASE_TAG }}" + { echo "tags<> "$GITHUB_OUTPUT" + - name: Build and push variant (with retry) + shell: bash + env: + TAGS: ${{ steps.tags.outputs.tags }} + BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} + FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }} + OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }} + run: | + set -euo pipefail + TAG_FLAGS=() + while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}" + # 3-attempt retry (see build-base step for rationale). Variant: pi-only. + for attempt in 1 2 3; do + echo "==> Build+push attempt ${attempt}/3" + if docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --file Dockerfile.variant \ + --push \ + --build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \ + --build-arg "INSTALL_OPENCODE=false" \ + --build-arg "INSTALL_OMOS=false" \ + --build-arg "INSTALL_PI=true" \ + --build-arg "PI_VERSION=${PI_VERSION}" \ + --build-arg "PI_FORK_REF=${FORK_REF}" \ + --build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \ + "${TAG_FLAGS[@]}" \ + .; then + echo "==> Attempt ${attempt} succeeded" + exit 0 + fi + if [[ "${attempt}" -lt 3 ]]; then + backoff=$(( attempt * 15 )) + echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry" + sleep "${backoff}" + fi + done + echo "==> All 3 build+push attempts failed" + exit 1 + # ── Phase 5: promote base- → base-latest (manifest copy only) ─ promote-base-latest: needs: @@ -735,6 +857,7 @@ jobs: - build-variant-omos - build-variant-with-pi - build-variant-omos-with-pi + - build-variant-pi-only # Skip on cache-hit base builds: when need_build=false, base-latest # already points at the same digest as base-, so the retag is # a tautology and any transient failure of it is purely cosmetic. @@ -787,6 +910,7 @@ jobs: - build-variant-omos - build-variant-with-pi - build-variant-omos-with-pi + - build-variant-pi-only # Run when at least the base variant published — don't let a single # variant failure (e.g., omos-with-pi smoke threshold) prevent Hub # description refresh for the other variants that did publish. diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index 3666da2..bddfb4a 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -312,3 +312,62 @@ jobs: - name: Smoke test run: | bash scripts/smoke-test.sh opencode-devbox:ci-omos-with-pi --variant omos-with-pi + + validate-pi-only: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Force IPv4 for Docker Hub + run: | + echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + + - name: Reclaim runner disk + run: | + set -x + df -h / || true + rm -rf \ + /opt/hostedtoolcache \ + /opt/microsoft \ + /opt/az \ + /opt/ghc \ + /usr/local/.ghcup \ + /usr/share/dotnet \ + /usr/share/swift \ + /usr/local/lib/android \ + /usr/local/share/powershell \ + /usr/local/share/chromium \ + /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + apt-get clean || true + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true + docker system df || true + docker system prune -af --volumes || true + docker builder prune -af || true + df -h / || true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + driver-opts: network=host + + - name: Build pi-only image (amd64, load to local daemon) + uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.variant + platforms: linux/amd64 + push: false + load: true + build-args: | + BASE_IMAGE=joakimp/opencode-devbox:base-latest + INSTALL_OPENCODE=false + INSTALL_PI=true + tags: opencode-devbox:ci-pi-only + + - name: Smoke test + run: | + bash scripts/smoke-test.sh opencode-devbox:ci-pi-only --variant pi-only diff --git a/AGENTS.md b/AGENTS.md index cb200fd..f766324 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d ## File roles - `Dockerfile.base` — variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published as `joakimp/opencode-devbox:base-`. Rebuilt only when its content hash changes. -- `Dockerfile.variant` — `FROM`s the base and adds only opencode/omos/pi installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, `INSTALL_PI`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. When `INSTALL_PI=true` it also clones `pi-fork` + `pi-observational-memory` (from `github.com/elpapi42`, refs `PI_FORK_REF`/`PI_OBSMEM_REF`) to `/opt` and runs `npm install` there at build time so the `fork`/`recall` extensions can load (a local-path `pi install` does not npm-install). +- `Dockerfile.variant` — `FROM`s the base and adds only opencode/omos/pi installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, `INSTALL_PI`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. When `INSTALL_PI=true` it also clones `pi-fork` + `pi-observational-memory` (from `github.com/elpapi42`, refs `PI_FORK_REF`/`PI_OBSMEM_REF`) to `/opt` and runs `npm install` there at build time so the `fork`/`recall` extensions can load (a local-path `pi install` does not npm-install). The `pi-only` variant sets `INSTALL_OPENCODE=false`, `INSTALL_PI=true` — pi without opencode, the single source of truth for the separate `pi-devbox` image. - `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. Volume ownership loop covers `~/.pi/` when `INSTALL_PI=true`. - `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), LAN-access setup (delegated to `setup-lan-access.sh`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, runtime `pi install /opt/{pi-fork,pi-observational-memory}` registration (idempotent), skillset auto-deploy from mounted skillset repo, OMOS setup. - `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` — host-OS-agnostic LAN reachability helper. Detects VM-backed hosts (macOS OrbStack / Docker Desktop, via `host.docker.internal` resolution) and generates a writable `~/.ssh-local/config` using the host as an SSH jump; no-op on native Linux. Controlled by `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_HOST_ALIAS`. Ships the mechanism only (generic `host` jump alias); user targets stay in their bind-mounted `~/.ssh/config`. Non-fatal. Counted in the base hash, so editing it advances `base-latest`. @@ -18,7 +18,7 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d - `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth. - `.gitea/README.md` — **read this first** if you're touching CI. Architectural overview of the build pipeline (production vs split-base), wall-clock estimates, NPM_CONFIG_PREFIX gotcha, runner expectations, migration plan. - `.gitea/workflows/validate.yml` — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check. -- `.gitea/workflows/docker-publish-split.yml` — production CI pipeline on tag push (`v*`). Two-phase split-base: computes base hash, conditionally builds base, runs 4 parallel smoke tests, then 4 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description. +- `.gitea/workflows/docker-publish-split.yml` — production CI pipeline on tag push (`v*`). Two-phase split-base: computes base hash, conditionally builds base, runs 5 parallel smoke tests, then 5 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description. ## Versioning scheme @@ -36,7 +36,7 @@ Tags follow `v{opencode_version}[letter]` — e.g. `v1.14.20` for the first buil Cautionary example: 2026-05-28 morning, `v1.15.12` was cut while opencode-ai was still at `1.15.11`. The commit message itself acknowledged "OPENCODE_VERSION stays at 1.15.11" but tagged `v1.15.12` anyway. Re-cut as `v1.15.11c` the same afternoon (see CHANGELOG). The `v1.15.12` git tag and Hub images stayed as historical artifacts; the slip cost a CI cycle and a CHANGELOG-rewrite. **Run the npm view check at the top of every release-day cut.** -CI produces eight Docker Hub tags per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`, `vX.Y.Z[n]-with-pi`, `latest-with-pi`, `vX.Y.Z[n]-omos-with-pi`, `latest-omos-with-pi` — one tag pair (versioned + floating alias) per build variant. +CI produces ten Docker Hub tags per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`, `vX.Y.Z[n]-with-pi`, `latest-with-pi`, `vX.Y.Z[n]-omos-with-pi`, `latest-omos-with-pi`, `vX.Y.Z[n]-pi-only`, `latest-pi-only` — one tag pair (versioned + floating alias) per build variant (five variants). When bumping the opencode version, bump `OPENCODE_VERSION` in `Dockerfile.variant` and update the comment in `.env.example` if it names a specific model/version for context. @@ -82,7 +82,7 @@ cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil- - **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.** - **Registry buildkit cache-export is currently disabled** — do NOT re-add `cache-from`/`cache-to` to the `build-base` step in `.gitea/workflows/docker-publish-split.yml` without first verifying that buildkit's `mode=max` cache-export to `registry-1.docker.io` no longer returns HTTP 400 from the Hub CDN edge. The regression surfaced ~2026-05-23 and broke five consecutive opencode-devbox publish attempts (runs #332/333/334/336 + a rerun); root-caused on 2026-05-28 by a manual host-side publish that reproduced the same 400 only on `--cache-to` while image push worked fine. Failure shape is stable (`Offset:0` in the `_state` token, HTML response body = CDN-tier rejection, not registry backend), repo-specific (we're the only repo writing `:base-buildcache` mode=max), and explains why pinning `setup-buildx-action@v4.0.0` didn't help (action pin doesn't change the bundled buildkit version on the catthehacker runner image). Trade-off: dockerfile.base changes pay a full ~3 min rebuild instead of pulling cached layers; unchanged bases short-circuit at the Hub-probe step in `base-decide` and never re-build anyway. Variants don't use registry cache so they're unaffected. Re-enable condition: upstream moby/buildkit fix lands AND a low-risk test run succeeds without 400s. See CHANGELOG v1.15.12 `Unreleased` block for the full diagnostic chain. Manual escape-hatch publish procedure: `docs/manual-host-publish.md`. -- **Push steps wrap `docker buildx build --push` in a 3-attempt retry loop** (15s, 30s backoff) for transient `registry-1.docker.io` blips — rate limits, brief 5xx, CDN flap. Implemented as inline `shell: bash` steps with `docker buildx build` raw rather than `docker/build-push-action@v7` so the loop is visible and tweakable. Affects the 1 base + 4 variant push steps in `.gitea/workflows/docker-publish-split.yml`; smoke-test builds (`load: true`, no push) are untouched. **This does NOT mask deterministic failures** — a true regression (like the cache-export 400 of 2026-05-23..28) fails all 3 attempts identically and the job still fails. Orthogonal to the cache-export disablement above: cache-export was about a deterministic protocol mismatch, retry is about absorbing genuine transients. Both are belt-and-braces with the `ci-release-watcher` skill's transient-rerun heuristic. If you change the matrix of push steps, keep the retry wrapper consistent across them — the pattern is duplicated rather than factored out because Gitea Actions doesn't support reusable composite shell steps cleanly. +- **Push steps wrap `docker buildx build --push` in a 3-attempt retry loop** (15s, 30s backoff) for transient `registry-1.docker.io` blips — rate limits, brief 5xx, CDN flap. Implemented as inline `shell: bash` steps with `docker buildx build` raw rather than `docker/build-push-action@v7` so the loop is visible and tweakable. Affects the 1 base + 5 variant push steps in `.gitea/workflows/docker-publish-split.yml`; smoke-test builds (`load: true`, no push) are untouched. **This does NOT mask deterministic failures** — a true regression (like the cache-export 400 of 2026-05-23..28) fails all 3 attempts identically and the job still fails. Orthogonal to the cache-export disablement above: cache-export was about a deterministic protocol mismatch, retry is about absorbing genuine transients. Both are belt-and-braces with the `ci-release-watcher` skill's transient-rerun heuristic. If you change the matrix of push steps, keep the retry wrapper consistent across them — the pattern is duplicated rather than factored out because Gitea Actions doesn't support reusable composite shell steps cleanly. - **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 3800900..1ce691b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,14 @@ The `with-pi` and `omos-with-pi` variants now bake in two pi extensions from `gi - New build-args: `PI_FORK_REPO`, `PI_FORK_REF`, `PI_OBSMEM_REPO`, `PI_OBSMEM_REF`. - Smoke test asserts the `/opt` clones + baked `node_modules` exist and that both packages register in `settings.json`. Size thresholds bumped: `with-pi` 2700→2900 MB, `omos-with-pi` 3700→3900 MB (fork's `@earendil-works` peer deps add ~150 MB). +### Added: `pi-only` variant (basis for `pi-devbox`) + +New fifth published variant built with `INSTALL_OPENCODE=false`, `INSTALL_PI=true` — pi + companions (toolkit, extensions, `fork`, `recall`) and all base tooling, but **without** opencode (~145 MB lighter than `with-pi`). + +- Published as `latest-pi-only` / `vX.Y.Z-pi-only` (multi-arch). New CI jobs `smoke-pi-only` and `build-variant-pi-only`; wired into `promote-base-latest` / `update-description` needs. +- This is the **single source of truth** for the separate [`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox) image, which now `FROM`s `latest-pi-only` instead of duplicating the pi-install logic. Lets pi-devbox stay lean and pi-focused while the install logic lives in one place. +- Smoke size threshold: 2750 MB (`with-pi` minus opencode). + _Versions unchanged: opencode-ai `1.15.13`, pi `0.78.0` (both still latest at time of writing)._ ## v1.15.13 — 2026-05-29 diff --git a/DOCKER_HUB.md b/DOCKER_HUB.md index 0db209c..cc083a0 100644 --- a/DOCKER_HUB.md +++ b/DOCKER_HUB.md @@ -12,6 +12,7 @@ Designed for teams who want a reproducible coding-agent setup that runs the same | `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun | | `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/earendil-works/pi) as alternative/complementary harness (shares the mempalace install with opencode) | | `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together | +| `latest-pi-only` / `vX.Y.Z-pi-only` | pi without opencode — the lean, pi-focused variant (basis of the separate `joakimp/pi-devbox` image) | All variants support `linux/amd64` and `linux/arm64`. diff --git a/Dockerfile.variant b/Dockerfile.variant index bda6f30..d7de251 100644 --- a/Dockerfile.variant +++ b/Dockerfile.variant @@ -12,6 +12,12 @@ # omos true true false # with-pi true false true # omos-with-pi true true true +# pi-only false false true +# +# The `pi-only` variant is the single source of truth for the pi-devbox +# image (pi + companions, no opencode). It exists so pi-devbox can FROM it +# without inheriting opencode, while the pi install logic stays defined +# here in one place. # # Pass `--build-arg BASE_IMAGE=:base-` to select the base. # The CI workflow computes the base hash from Dockerfile.base + rootfs/ diff --git a/README.md b/README.md index c2dca0a..6bbed48 100644 --- a/README.md +++ b/README.md @@ -463,7 +463,7 @@ All six agents should respond if your provider authentication is working. ### Setup -Pre-built pi-enabled images are available on Docker Hub as `joakimp/opencode-devbox:latest-with-pi` (base + pi) and `joakimp/opencode-devbox:latest-omos-with-pi` (OMOS + pi). Pulling one of those tags is the fastest path. Alternatively, build from source: +Pre-built pi-enabled images are available on Docker Hub as `joakimp/opencode-devbox:latest-with-pi` (base + pi) and `joakimp/opencode-devbox:latest-omos-with-pi` (OMOS + pi). Pulling one of those tags is the fastest path. There is also a `latest-pi-only` variant (pi **without** opencode, `INSTALL_OPENCODE=false`) — it's the lean basis for the separate [`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox) image. Alternatively, build from source: ### Build diff --git a/docs/manual-host-publish.sh b/docs/manual-host-publish.sh index 0e563fb..67a7f44 100755 --- a/docs/manual-host-publish.sh +++ b/docs/manual-host-publish.sh @@ -5,11 +5,12 @@ # Mirrors what .gitea/workflows/docker-publish-split.yml would do: # 1. Build & push Dockerfile.base → joakimp/opencode-devbox:base- # 2. Promote → joakimp/opencode-devbox:base-latest -# 3. Build & push 4 variants on top of base-: +# 3. Build & push 5 variants on top of base-: # :v1.15.12 :latest (INSTALL_OPENCODE only) # :v1.15.12-omos :latest-omos (+ OMOS) # :v1.15.12-with-pi :latest-with-pi (+ pi) # :v1.15.12-omos-with-pi :latest-omos-with-pi (+ both) +# :v1.15.12-pi-only :latest-pi-only (pi, no opencode) # # Usage on your host: # 1. Make sure Orbstack/Docker Desktop is running with multi-arch enabled @@ -51,7 +52,7 @@ fi # -------- 1. base (if needed) -------- if [[ "$SKIP_BASE" == "0" ]]; then - echo "==> [1/5] Build & push Dockerfile.base → ${IMAGE}:${BASE_TAG}" + echo "==> [1/7] Build & push Dockerfile.base → ${IMAGE}:${BASE_TAG}" docker buildx build \ --platform "$PLATFORMS" \ -f Dockerfile.base \ @@ -61,14 +62,15 @@ if [[ "$SKIP_BASE" == "0" ]]; then fi # -------- 2. promote base-latest -------- -echo "==> [2/5] Promote ${IMAGE}:${BASE_TAG} → ${IMAGE}:base-latest" +echo "==> [2/7] Promote ${IMAGE}:${BASE_TAG} → ${IMAGE}:base-latest" docker buildx imagetools create -t "${IMAGE}:base-latest" "${IMAGE}:${BASE_TAG}" # -------- 3-5. variants -------- build_variant() { - local suffix="$1" # "" | "-omos" | "-with-pi" | "-omos-with-pi" + local suffix="$1" # "" | "-omos" | "-with-pi" | "-omos-with-pi" | "-pi-only" local install_omos="$2" local install_pi="$3" + local install_opencode="${4:-true}" local extra_args=() [[ "$install_pi" == "true" ]] && extra_args+=(--build-arg "PI_VERSION=${PI_VERSION}") [[ "$install_omos" == "true" ]] && extra_args+=(--build-arg "OMOS_VERSION=${OMOS_VERSION}") @@ -81,7 +83,7 @@ build_variant() { --platform "$PLATFORMS" \ -f Dockerfile.variant \ --build-arg "BASE_IMAGE=${IMAGE}:${BASE_TAG}" \ - --build-arg "INSTALL_OPENCODE=true" \ + --build-arg "INSTALL_OPENCODE=${install_opencode}" \ --build-arg "INSTALL_OMOS=${install_omos}" \ --build-arg "INSTALL_PI=${install_pi}" \ ${extra_args[@]+"${extra_args[@]}"} \ @@ -91,18 +93,21 @@ build_variant() { . } -echo "==> [3/5] Variant: base (opencode only)" +echo "==> [3/7] Variant: base (opencode only)" build_variant "" false false -echo "==> [4/5] Variant: omos" +echo "==> [4/7] Variant: omos" build_variant "-omos" true false -echo "==> [4/5] Variant: with-pi" +echo "==> [5/7] Variant: with-pi" build_variant "-with-pi" false true -echo "==> [5/5] Variant: omos-with-pi" +echo "==> [6/7] Variant: omos-with-pi" build_variant "-omos-with-pi" true true +echo "==> [7/7] Variant: pi-only (pi without opencode)" +build_variant "-pi-only" false true false + echo echo "==> Done. Verifying tags on Hub:" for t in \ @@ -110,6 +115,7 @@ for t in \ "${RELEASE_TAG}-omos" "latest-omos" \ "${RELEASE_TAG}-with-pi" "latest-with-pi" \ "${RELEASE_TAG}-omos-with-pi" "latest-omos-with-pi" \ + "${RELEASE_TAG}-pi-only" "latest-pi-only" \ "${BASE_TAG}" "base-latest" do d=$(docker manifest inspect "${IMAGE}:${t}" 2>/dev/null | python3 -c "import json,sys,hashlib; m=json.load(sys.stdin); print(m.get('digest','-'))" 2>/dev/null || echo "MISSING") diff --git a/docs/plan-lan-access-and-pi-extensions.md b/docs/plan-lan-access-and-pi-extensions.md index 9a7d501..e99f9c1 100644 --- a/docs/plan-lan-access-and-pi-extensions.md +++ b/docs/plan-lan-access-and-pi-extensions.md @@ -121,6 +121,13 @@ Sources (pinned this week): `Dockerfile.variant`. Refactor `pi-devbox/Dockerfile` to `FROM` the `with-pi` variant image so pi-install logic (incl. the new fork/obsmem clones) lives in ONE place. +> **Implementation update (2026-06-03):** `FROM with-pi` would have dragged opencode +> into pi-devbox (all opencode-devbox variants set `INSTALL_OPENCODE=true`), making it +> nearly identical to `latest-with-pi`. So a 5th variant **`pi-only`** +> (`INSTALL_OPENCODE=false`, `INSTALL_PI=true`) was added to opencode-devbox, and +> pi-devbox now `FROM`s `latest-pi-only`. Same single-source-of-truth win, but +> pi-devbox stays lean (no opencode, ~145 MB lighter than with-pi). + ### Build time — clone to /opt + npm install (mirror pi-toolkit/extensions pattern) Add to the single `INSTALL_PI=true` block in `opencode-devbox/Dockerfile.variant` (after refactor, pi-devbox inherits it): diff --git a/scripts/generate-dockerhub-md.py b/scripts/generate-dockerhub-md.py index aaad3d2..33703ae 100755 --- a/scripts/generate-dockerhub-md.py +++ b/scripts/generate-dockerhub-md.py @@ -66,6 +66,7 @@ Designed for teams who want a reproducible coding-agent setup that runs the same | `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun | | `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/earendil-works/pi) as alternative/complementary harness (shares the mempalace install with opencode) | | `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together | +| `latest-pi-only` / `vX.Y.Z-pi-only` | pi without opencode — the lean, pi-focused variant (basis of the separate `joakimp/pi-devbox` image) | All variants support `linux/amd64` and `linux/arm64`. diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index c2f5caf..3af3535 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -8,7 +8,7 @@ # - Generated opencode.json has the expected shape # - MCP wrapper works (when mempalace is installed) # -# Usage: ./scripts/smoke-test.sh [--variant base|omos|with-pi|omos-with-pi] +# Usage: ./scripts/smoke-test.sh [--variant base|omos|with-pi|omos-with-pi|pi-only] # # Exit codes: # 0 all checks passed @@ -23,7 +23,7 @@ if [ "${2:-}" = "--variant" ]; then fi if [ -z "$IMAGE" ]; then - echo "usage: $0 [--variant base|omos|with-pi|omos-with-pi]" >&2 + echo "usage: $0 [--variant base|omos|with-pi|omos-with-pi|pi-only]" >&2 exit 2 fi @@ -367,6 +367,9 @@ THRESHOLD=2500 [ "$VARIANT" = "omos" ] && THRESHOLD=3300 [ "$VARIANT" = "with-pi" ] && THRESHOLD=2900 [ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3900 +# pi-only = with-pi minus opencode (its platform binary is ~145 MB), so it +# lands a bit under base. Threshold 2750 leaves the same headroom pattern. +[ "$VARIANT" = "pi-only" ] && THRESHOLD=2750 if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT" else