Compare commits

..

13 Commits

Author SHA1 Message Date
pi f61b5a4977 Cut v1.15.13 — opencode 1.15.12→1.15.13 upstream, pi 0.77.0→0.78.0
Validate / base-change-warning (push) Successful in 23s
Validate / docs-check (push) Successful in 51s
Validate / validate-base (push) Successful in 3m50s
Publish Docker Image / base-decide (push) Successful in 13s
Publish Docker Image / resolve-versions (push) Successful in 4s
Validate / validate-with-pi (push) Successful in 4m3s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-omos (push) Successful in 7m34s
Publish Docker Image / smoke-base (push) Successful in 3m42s
Publish Docker Image / smoke-omos (push) Successful in 4m31s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 4m57s
Publish Docker Image / smoke-with-pi (push) Successful in 6m22s
Validate / validate-omos-with-pi (push) Successful in 15m13s
Publish Docker Image / build-variant-base (push) Successful in 14m10s
Publish Docker Image / build-variant-omos (push) Successful in 23m24s
Publish Docker Image / build-variant-with-pi (push) Successful in 16m3s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 31m50s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 6s
2026-05-31 22:26:21 +02:00
pi 870da12c92 Cut v1.15.12 — opencode 1.15.11→1.15.12 upstream, pi 0.76.0→0.77.0
Validate / docs-check (push) Successful in 12s
Validate / base-change-warning (push) Successful in 52s
Validate / validate-base (push) Successful in 3m34s
Publish Docker Image / base-decide (push) Successful in 12s
Publish Docker Image / resolve-versions (push) Successful in 5s
Validate / validate-omos (push) Successful in 4m33s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-with-pi (push) Successful in 6m29s
Publish Docker Image / smoke-base (push) Successful in 3m45s
Publish Docker Image / smoke-omos (push) Successful in 4m37s
Publish Docker Image / smoke-with-pi (push) Successful in 6m29s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 4m59s
Validate / validate-omos-with-pi (push) Successful in 12m42s
Publish Docker Image / build-variant-base (push) Successful in 16m17s
Publish Docker Image / build-variant-omos (push) Successful in 19m12s
Publish Docker Image / build-variant-with-pi (push) Successful in 20m22s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 21m20s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 10s
opencode-ai actually released 1.15.12 upstream (2026-05-28); this is
the genuine first container build on it, plus the pi 0.77.0 bump
(Claude Opus 4.8, --exclude-tools, headless Codex subscription login,
streaming-aware extension input, plus a long fix list).

Re-uses the v1.15.12 git tag, force-overwriting the historical
artifact tag at be2a168 from the 2026-05-28 versioning slip (caught
same day and re-cut as v1.15.11c; corresponding Hub images already
manually deleted). Commit be2a168 and the v1.15.11c CHANGELOG block
referencing the slip remain in history.

No base-image change — unchanged Dockerfile.base/rootfs/entrypoint
will hit base-decide cache-hit short-circuit; only the four variant
builds + manifest tagging will run.

See CHANGELOG v1.15.12 for the full upstream notes.
2026-05-29 09:06:54 +02:00
pi cb50e6ea60 Cut v1.15.11c — re-tag of v1.15.12 to fix versioning-scheme violation
Validate / base-change-warning (push) Successful in 5s
Validate / docs-check (push) Successful in 13s
Validate / validate-with-pi (push) Successful in 4m8s
Publish Docker Image / base-decide (push) Successful in 13s
Validate / validate-omos (push) Successful in 4m34s
Publish Docker Image / resolve-versions (push) Successful in 5s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-base (push) Successful in 5m19s
Publish Docker Image / smoke-base (push) Successful in 3m43s
Publish Docker Image / smoke-omos (push) Successful in 4m41s
Publish Docker Image / smoke-with-pi (push) Successful in 6m38s
Validate / validate-omos-with-pi (push) Successful in 12m30s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 4m53s
Publish Docker Image / build-variant-base (push) Successful in 14m29s
Publish Docker Image / build-variant-with-pi (push) Successful in 21m5s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 21m6s
Publish Docker Image / build-variant-omos (push) Successful in 23m14s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Has been skipped
The 2026-05-28 morning v1.15.12 release violated the project's
v{opencode_version}[letter] tagging scheme: opencode-ai stayed at
1.15.11 upstream (no 1.15.12 exists on npm), so the third container
build on opencode 1.15.11 should have been v1.15.11c.

The commit message of the slipped tag (be2a168) itself said
'OPENCODE_VERSION stays at 1.15.11 (no upstream change)' — the slip
was caught the same afternoon during a versioning audit.

This release re-cuts at HEAD and supersedes v1.15.12. The slipped
git tag and the eight v1.15.12* / latest* Docker Hub images remain
as historical artifacts. Future builds on opencode 1.15.11 continue
the letter sequence as v1.15.11d, v1.15.11e, etc; v1.15.12 will only
be reused if and when opencode upstream actually releases 1.15.12.

Includes everything in v1.15.12 plus the afternoon followup work:
- CI: registry cache-export disabled (Hub 400 root-cause fix)
- Docs: manual host-publish runbook + script archive
- CI: workflow-level 3-attempt retry around buildx build --push

AGENTS.md: new pre-flight check requirement under Versioning scheme
documenting the slip as a cautionary example. Mandatory
'npm view opencode-ai version' check before any non-letter-suffix tag.

CHANGELOG: new v1.15.11c block with full content list; v1.15.12 block
gets a note documenting the supersession.
2026-05-28 16:54:23 +02:00
pi 1fe5b5df91 ci: workflow-level 3-attempt retry around buildx build --push
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 6s
Validate / validate-with-pi (push) Successful in 4m11s
Validate / validate-omos (push) Successful in 4m31s
Validate / validate-base (push) Successful in 5m19s
Validate / validate-omos-with-pi (push) Successful in 11m38s
Belt-and-braces against transient registry-1.docker.io blips (rate
limits, brief 5xx, CDN flap). Replaces all five push docker/build-push-
action@v7 invocations (1 base + 4 variants) with shell: bash steps that
run docker buildx build --push in a for-loop with backoff (15s, 30s).
Smoke build steps (load: true, no push) are untouched.

Does NOT mask deterministic failures: a true regression (e.g. the
cache-export 400 we hit 2026-05-23..28) fails all 3 attempts
identically and the job still fails by design. Orthogonal layer to
both cache-export disablement and the ci-release-watcher skill's
transient-rerun heuristic.

- AGENTS.md: new Critical conventions bullet documenting the retry
  pattern, the consistency rule across push steps, and why it's
  duplicated rather than factored (Gitea Actions doesn't support
  reusable composite shell steps cleanly).
- CHANGELOG.md: Unreleased section addendum, no image-side change.

No image-side change.
2026-05-28 16:32:41 +02:00
pi 6cc2670a93 docs: manual host-publish runbook + cache-export gotcha in AGENTS.md
Validate / docs-check (push) Successful in 6s
Validate / base-change-warning (push) Successful in 12s
Validate / validate-with-pi (push) Successful in 4m5s
Validate / validate-omos (push) Successful in 4m27s
Validate / validate-base (push) Successful in 5m33s
Validate / validate-omos-with-pi (push) Successful in 12m18s
Captures the escape-hatch procedure used to ship v1.15.12 on 2026-05-28
when buildkit cache-export mode=max started returning HTTP 400 from the
Hub CDN, breaking five consecutive CI publishes (runs #332/333/334/336
+ a rerun).

- docs/manual-host-publish.sh: the literal script that shipped v1.15.12
  from a developer Mac via Orbstack, preserved as-is for future reference.
- docs/manual-host-publish.md: runbook explaining when to reach for it,
  the four constants to edit, three ways to source BASE_HASH (CI log /
  Hub probe / local recompute matching base-decide's exact recipe
  including __pycache__/.DS_Store junk filters), and adaptations for
  pi-devbox / letter-suffix rebuilds / partial-failure recovery.
- AGENTS.md: new Critical conventions bullet documenting the cache-from
  /cache-to disablement, failure shape, repo-specificity, why action
  pinning didn't help, the trade-off, and the re-enable condition.
  Cross-references CHANGELOG v1.15.12 Unreleased + the new runbook.
2026-05-28 16:21:40 +02:00
joakimp 51ec4a88cf CI: drop registry cache-export from build-base (Hub 400 root cause)
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 13s
Validate / validate-with-pi (push) Successful in 4m9s
Validate / validate-omos (push) Successful in 4m31s
Validate / validate-base (push) Successful in 5m40s
Validate / validate-omos-with-pi (push) Successful in 12m49s
Diagnosed during manual v1.15.12 publish: buildkit's mode=max cache export
to registry-1.docker.io reproducibly returns HTTP 400 with HTML body on the
resumable-upload PUT. Image push (layers + manifest) works fine in parallel;
only --cache-to fails. Removing cache-from/cache-to lets the publish complete.

This explains all four prior CI failures (runs 332/333/334/336) which shared
the exact same failure shape. Action-pin hypothesis (setup-buildx-action
v4.1.0) was correctly disproven by run 336 with v4.0.0 pinned.

Trade-off: every Dockerfile.base change now pays the full ~3 min multi-arch
build. Unchanged bases short-circuit at the content-addressed probe step in
base-decide and never re-build, so day-to-day cost is zero.

Re-enable when moby/buildkit upstream resolves the cache-export protocol
mismatch with Hub CDN, or when we can switch to a non-registry cache backend.

CHANGELOG.md: full root-cause writeup in Unreleased section, including
status update on every prior suspect (all ruled out).
2026-05-28 10:40:08 +00:00
joakimp be2a16834c Cut v1.15.12 — revert v4.0.0 pin (busted), bump pi to 0.76.0
Validate / docs-check (push) Successful in 8s
Validate / base-change-warning (push) Successful in 52s
Validate / validate-base (push) Failing after 3m34s
Publish Docker Image / base-decide (push) Successful in 10s
Publish Docker Image / resolve-versions (push) Successful in 4s
Validate / validate-with-pi (push) Failing after 4m0s
Validate / validate-omos (push) Failing after 6m50s
Validate / validate-omos-with-pi (push) Failing after 12m15s
Publish Docker Image / build-base (push) Failing after 30m40s
Publish Docker Image / smoke-base (push) Has been skipped
Publish Docker Image / smoke-with-pi (push) Has been skipped
Publish Docker Image / build-variant-base (push) Has been skipped
Publish Docker Image / build-variant-with-pi (push) Has been skipped
Publish Docker Image / smoke-omos (push) Has been skipped
Publish Docker Image / build-variant-omos-with-pi (push) Has been skipped
Publish Docker Image / build-variant-omos (push) Has been skipped
Publish Docker Image / smoke-omos-with-pi (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
The v1.15.11b experiment confirmed setup-buildx-action@v4.1.0 is NOT
the regressor: pinning all 9 references to @v4.0.0 reproduced the
exact same '400 Bad request' from registry-1.docker.io on the first
layer-blob PUT. CI run #336 failed twice (original + Gitea auto-rerun),
both with HTML 400 bodies (CDN-tier rejection) at Offset:0. UUIDs and
_state signatures differ across attempts; only the failure pattern is
stable.

Reverting all 9 pins back to @v4 — keeping a wrong pin holds us off
action improvements with no benefit. Real suspects now narrow to:
runner-image (catthehacker:act-latest, floating), runner-2 host
network egress, buildx 0.34.x signed _state token format, or per-repo
Hub-side state. Investigation deferred; this release ships via manual
docker buildx build --push from a developer Orbstack to bypass the
broken runner-network → Hub-CDN combo (we know that path works in
~25s for the same multi-arch build to the same Hub account).

PI_VERSION=latest resolves to pi-coding-agent 0.76.0 (published
2026-05-27 20:03 UTC). OPENCODE_VERSION stays at 1.15.11 (no upstream
bump since 1.15.11 was published 2026-05-27 03:59 UTC).

Files:
- .gitea/workflows/docker-publish-split.yml: 9 setup-buildx-action
  references reverted from @v4.0.0 to @v4
- CHANGELOG.md: v1.15.12 entry with regression triage status
  (ruled-out vs still-suspect)
2026-05-28 08:11:00 +00:00
joakimp a16da2f041 Cut v1.15.11b — pin setup-buildx-action@v4.0.0
Validate / docs-check (push) Successful in 6s
Validate / base-change-warning (push) Successful in 6s
Validate / validate-with-pi (push) Failing after 4m1s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / resolve-versions (push) Successful in 5s
Validate / validate-omos-with-pi (push) Failing after 4m52s
Validate / validate-omos (push) Failing after 6m41s
Validate / validate-base (push) Failing after 8m55s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Publish Docker Image / build-base (push) Failing after 37m43s
Publish Docker Image / smoke-base (push) Has been skipped
Publish Docker Image / smoke-omos (push) Has been skipped
Publish Docker Image / smoke-with-pi (push) Has been skipped
Publish Docker Image / build-variant-omos (push) Has been skipped
Publish Docker Image / build-variant-with-pi (push) Has been skipped
Publish Docker Image / smoke-omos-with-pi (push) Has been skipped
Publish Docker Image / build-variant-base (push) Has been skipped
Publish Docker Image / build-variant-omos-with-pi (push) Has been skipped
The v1.15.11 publish failed three times in a row (runs #332/333/334)
with identical '400 Bad request' from registry-1.docker.io on the
multi-arch buildx layer-blob PUT. Triage on 2026-05-27 confirmed:

  - Multi-arch buildx push from a developer host: succeeds in 25s
    (same Hub account, same multi-arch path)
  - Account / repo / Hub-CDN: all healthy
  - Last known-good Gitea-runner Hub push: 2026-05-23 ~20:26 UTC
    (pi-devbox v0.75.5b) — predates docker/setup-buildx-action v4.1.0
    by <24h
  - docker/setup-buildx-action@v4 floats to v4.1.0 (published
    2026-05-22 16:00 UTC), bundling a newer buildx/buildkit whose
    push protocol may trip Hub's CDN URI-length cap on the ~1.4 KB
    _state query string in resumable-upload PUT URLs.

Pinning all nine setup-buildx-action references to @v4.0.0 to
test the hypothesis. setup-qemu-action@v3 left floating since
QEMU wasn't in the suspected blast radius. If v4.0.0 publishes
cleanly we keep the pin and file an upstream buildkit/buildx
issue.

No source changes — same OPENCODE_VERSION=1.15.11, same Dockerfile.base
and Dockerfile.variant. v1.15.11 (original tag) is preserved as a
historical marker of the first publish attempt; v1.15.11b becomes
the canonical release.
2026-05-27 21:05:17 +00:00
joakimp 608304c3de Bump opencode 1.15.10 -> 1.15.11 + cut v1.15.11
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 5s
Validate / base-change-warning (push) Successful in 5s
Validate / docs-check (push) Successful in 49s
Validate / validate-with-pi (push) Failing after 4m8s
Validate / validate-omos (push) Failing after 4m53s
Validate / validate-base (push) Failing after 5m22s
Validate / validate-omos-with-pi (push) Failing after 14m49s
Publish Docker Image / build-base (push) Failing after 30m39s
Publish Docker Image / smoke-base (push) Has been skipped
Publish Docker Image / smoke-omos (push) Has been skipped
Publish Docker Image / build-variant-omos (push) Has been skipped
Publish Docker Image / build-variant-base (push) Has been skipped
Publish Docker Image / smoke-with-pi (push) Has been skipped
Publish Docker Image / build-variant-with-pi (push) Has been skipped
Publish Docker Image / smoke-omos-with-pi (push) Has been skipped
Publish Docker Image / build-variant-omos-with-pi (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
First release on opencode 1.15.11. Also ships the four devbox-side fixes
accumulated since v1.15.10:

  - 668592d Base: SSH ControlMaster default on a writable socket path
  - 73a7f96 Base: gitleaks added; git-crypt confirmed installed
  - 3cbcb44 CI: fix resolve-versions to use curl+jq instead of npm view
  - f7c3409 CI: preventative fix for PI_VERSION/OMOS_VERSION cache-hit regression

Downstream pi-devbox inherits all of these on its next build against
base-latest.

Upstream release notes:
  https://github.com/anomalyco/opencode/releases/tag/v1.15.11
2026-05-27 15:02:24 +00:00
joakimp 668592da0d Base: SSH ControlMaster default on a writable socket path
Validate / docs-check (push) Successful in 9s
Validate / base-change-warning (push) Successful in 11s
Validate / validate-with-pi (push) Failing after 4m6s
Validate / validate-omos (push) Failing after 4m31s
Validate / validate-omos-with-pi (push) Failing after 4m52s
Validate / validate-base (push) Failing after 13m20s
Devboxes typically mount ~/.ssh from the host read-only (security: keys
readable, but agents can't tamper with config / known_hosts /
authorized_keys / plant a malicious ProxyCommand). OpenSSH's default
ControlPath is ~/.ssh/cm/... which is unwritable on such mounts, so
any attempt to multiplex fails with:

  unix_listener: cannot bind to path .../cm/...: Read-only file system
  kex_exchange_identification: Connection closed by remote host

The second line is downstream — when ControlMaster fails, ssh falls
back to fresh TCP connections, and on residential CGNAT (most European
ISPs) the per-(src,dst) concurrent-flow cap (~4) silently drops further
SYNs once exceeded, manifesting as banner-exchange timeouts that look
like a remote problem.

Fix: bake /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
base image with Host * defaults — ControlPath rooted at /tmp/sshcm/
(per-container, always writable), ControlMaster auto, ControlPersist
10m, ServerAlive{Interval=30,CountMax=6}. Companion entrypoint-user.sh
creates /tmp/sshcm mode 700 on each container start (/tmp is
per-container so the dir can't be baked into a layer; mode 700 is
required by OpenSSH for ControlPath dirs). Debian's stock ssh_config
sources ssh_config.d/*.conf before its own Host * block, so user
~/.ssh/config overrides still win.

Two smoke assertions catch regressions: (a) the conf file exists, (b)
ssh -G reports a controlpath rooted at /tmp/sshcm/ — second one catches
the silent case where something later in the config chain shadows the
bake-in.

Discovered while running a recon shell from inside pi-devbox to a
Proxmox node — fresh ssh hit banner-exchange timeout, debug output
pointed at the read-only socket dir as the actual root cause.

Cascades to all variants and to pi-devbox automatically on next build
against base-latest. No size/threshold impact (~250-byte conf file).
2026-05-24 19:51:38 +00:00
joakimp 3cbcb44cf5 CI: fix resolve-versions to use curl+jq instead of npm view
catthehacker/ubuntu:act-latest ships Node/npm under /opt/acttoolcache/
with PATH updated only in /etc/environment. act_runner (nektos/act) does
not source /etc/environment — it reads the Docker image's ENV instructions
(inspectResult.Config.Env) which only contain DEBIAN_FRONTEND=noninteractive.
So npm is NOT on PATH and 'npm view ...' would have CI-failed on first run.

Fix: query the npm registry HTTP API directly with curl+jq, both of which
are already used extensively by this workflow (curl for Hub auth/manifest
inspect, jq for token parsing). The endpoint
  https://registry.npmjs.org/<pkg>/latest
returns JSON with a 'version' field — equivalent to 'npm view <pkg> version'
but with no toolchain dependency.

Verified locally: both URLs resolve correctly to 0.75.5 (pi) and 1.1.1 (omos).

Evidence: nektos/act pkg/container/docker_run.go reads imageEnv from
inspectResult.Config.Env, not /etc/environment. DefaultPathVariable() in
linux_container_environment_extensions.go returns a hardcoded path with no
/opt/acttoolcache in it.
2026-05-24 15:59:53 +00:00
joakimp 73a7f96056 Base: add gitleaks; surface git-crypt in smoke + docs
Both tools are used as part of the secret-management setup in several
of the repos this devbox operates on (gitleaks pre-commit hook +
git-crypt for selectively-encrypted canonical config). Having them in
the container means hooks fire correctly inside instead of warning
'gitleaks not installed' on every commit.

git-crypt was already installed via apt in Dockerfile.base (line 58),
just unasserted by smoke and unmentioned in user-facing docs.

gitleaks is new: Go-compiled binary fetched from GitHub releases via
the same /releases/latest redirect-resolution pattern as gosu, fzf,
git-lfs, etc. Arch suffix is 'x64' (not 'x86_64' / 'amd64') on this
project — flagged in the Dockerfile comment and in AGENTS.md's
floated-binaries gotcha list.

Adds ~21 MB to the base layer (gitleaks 8.30.1 binary). No variant
threshold bumps needed (2500–3700 MB envelope, 21 MB is noise).

CHANGES

Dockerfile.base — new GITLEAKS_VERSION=latest ARG + install RUN
right after the git-lfs block. Multi-arch (linux/amd64=x64,
linux/arm64=arm64). Echoes resolved version + runs 'gitleaks version'
to fail the build on any install error.

scripts/smoke-test.sh — git-crypt and gitleaks added to the
'Resolved component versions' table (printed first thing in CI logs)
and to the 'Core binaries' assertion list (run helper). Smoke now
fails fast if either binary regresses.

README.md — 'What's in the image' tree line names gitleaks alongside
the existing git-crypt.

AGENTS.md — gitleaks added to the 'GitHub-sourced binaries float by
default' list with a new clause flagging project-specific arch-name
deviations (gitleaks=x64, bat/eza/zoxide=x86_64/aarch64, gosu=
amd64/arm64). Saves the next person from the 'why does this not
download' debugging session.

CHANGELOG.md — sub-entry under existing Unreleased, before the
PI_VERSION/OMOS_VERSION cache-hit fix entry.

DOWNSTREAM IMPACT

This is a base-layer change — base-decide will compute a fresh
base-<hash>, build-base will run (no cache hit), all four variants
will rebuild. First real base rebuild since v1.14.50b. Pi-devbox's
next FROM base-latest pull picks up gitleaks automatically with no
Dockerfile change there.

Verified end-to-end on host: gitleaks 8.30.1 21 MB binary extracts
cleanly from the URL the Dockerfile constructs and 'gitleaks version'
prints '8.30.1'.

Holding off on tagging — opencode + pi upstreams unchanged at 1.15.10
and 0.75.5 respectively. Will ride along with the next upstream-bump
release rather than burning a base rebuild on a no-upstream-change
container-only roll.
2026-05-24 15:49:38 +00:00
joakimp f7c34091b1 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.
2026-05-24 15:38:36 +00:00
11 changed files with 876 additions and 79 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>;
│ │ 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
+238 -68
View File
@@ -102,6 +102,42 @@ 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 registry
id: resolve
run: |
set -eu
# Query the npm registry directly via curl+jq rather than `npm view`.
# catthehacker/ubuntu:act-latest ships Node/npm under /opt/acttoolcache/
# and adds it to PATH only via /etc/environment — which act_runner never
# sources (it reads the Docker image's ENV instructions, not /etc/environment).
# curl and jq are both guaranteed present in every job in this workflow.
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version')
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.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]
@@ -148,17 +184,44 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base (multi-arch)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.base
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
# Registry cache for faster repeat base rebuilds (e.g. Node bump).
cache-from: type=registry,ref=${{ env.IMAGE }}:base-buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:base-buildcache,mode=max
- name: Build and push base (multi-arch) — with retry
shell: bash
env:
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
run: |
set -euo pipefail
# 3-attempt retry around `docker buildx build --push` for transient
# registry-1.docker.io blips. Does NOT mask deterministic failures:
# a true regression (e.g. cache-export 400 hit 2026-05-23..28) will
# fail all 3 attempts identically and the job still fails — by
# design.
# Registry cache disabled: buildkit's cache-export (mode=max) hits a
# reproducible HTTP 400 from registry-1.docker.io on the resumable-
# upload PUT (state-token format mismatch on Hub CDN, suspected to
# have started ~2026-05-23). Image push itself works fine. We pay
# the full base build on every Dockerfile.base change, but the base
# tag itself is content-addressed (base-<hash>) so unchanged bases
# short-circuit at the probe step and never re-build anyway. Re-
# enable when upstream resolves; tracked in CHANGELOG v1.15.12.
for attempt in 1 2 3; do
echo "==> Build+push attempt ${attempt}/3"
if docker buildx build \
--platform linux/amd64,linux/arm64 \
--file Dockerfile.base \
--push \
--tag "${BASE_TAG_FULL}" \
.; 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 3: amd64 smoke per variant (gates the multi-arch publish) ─
# Each smoke job builds amd64-only against the base tag and runs
@@ -211,10 +274,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 +313,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 +358,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 +403,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 ────────────────────────
@@ -370,21 +447,43 @@ jobs:
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=false
tags: ${{ steps.tags.outputs.tags }}
- 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 }}
run: |
set -euo pipefail
TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
# 3-attempt retry around `docker buildx build --push` (see build-base
# step for full rationale). Variant: base (opencode 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=true" \
--build-arg "INSTALL_OMOS=false" \
--build-arg "INSTALL_PI=false" \
"${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
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
@@ -418,21 +517,44 @@ jobs:
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=false
tags: ${{ steps.tags.outputs.tags }}
- 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 }}
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
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: omos.
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=true" \
--build-arg "INSTALL_OMOS=true" \
--build-arg "INSTALL_PI=false" \
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
"${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
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
@@ -466,21 +588,44 @@ jobs:
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=true
tags: ${{ steps.tags.outputs.tags }}
- 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 }}
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: with-pi.
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=true" \
--build-arg "INSTALL_OMOS=false" \
--build-arg "INSTALL_PI=true" \
--build-arg "PI_VERSION=${PI_VERSION}" \
"${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
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
@@ -514,18 +659,43 @@ jobs:
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=true
tags: ${{ steps.tags.outputs.tags }}
- 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 }}
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
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: omos-with-pi.
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=true" \
--build-arg "INSTALL_OMOS=true" \
--build-arg "INSTALL_PI=true" \
--build-arg "PI_VERSION=${PI_VERSION}" \
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
"${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-<hash> → base-latest (manifest copy only) ─
promote-base-latest:
+11 -1
View File
@@ -27,6 +27,13 @@ Tags follow `v{opencode_version}[letter]` — e.g. `v1.14.20` for the first buil
- **No letter suffix** on the first build of a new opencode version — the bare `v{opencode_version}` tag is the canonical release.
- **Letter suffix is the build ordinal**, starting at `b` for the second build. The letter `a` is **never used** — think of the suffix as counting rebuilds: `b = 2nd, c = 3rd, d = 4th, …`. For opencode version `1.14.20`: first build `v1.14.20`, second `v1.14.20b`, third `v1.14.20c`, and so on.
- A letter suffix is only used for container-level rebuilds — tooling changes, CVE fixes, doc-driven rebuilds, entrypoint bugfixes — that don't change the underlying opencode version.
- **Pre-flight check before cutting any non-letter-suffixed tag** — verify the bump is real:
```bash
npm view opencode-ai version # must equal the X.Y.Z in your tag
```
If the npm version equals the *previous* release's `X.Y.Z`, you're cutting a letter-suffix rebuild (`vX.Y.Zc`, `vX.Y.Zd`, …), not a new minor. **A bare `vX.Y.Z` tag is a claim that opencode upstream just released `X.Y.Z`** — if that claim is wrong, future opencode releases will collide with your tag namespace and the version-tracking story breaks.
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.
@@ -70,8 +77,11 @@ cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil-
Release-day checklist: README → (regenerate DOCKER_HUB.md only if HUB_TEMPLATE changed) → promote CHANGELOG Unreleased → grep AGENTS.md for stale counts → commit → tag → push tag.
**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, gitleaks, 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) — mind project-specific arch-name deviations (gitleaks uses `x64`, bat/eza/zoxide use `x86_64`/`aarch64`, gosu uses `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.**
- **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.
- **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.
+224
View File
@@ -8,6 +8,230 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a
## Unreleased
_(no changes since v1.15.13)_
## v1.15.13 — 2026-05-29
First container build on `opencode-ai@1.15.13` upstream release (published 2026-05-29). Also picks up pi `0.77.0``0.78.0` (resolved from npm at build time).
### Bumped: opencode-ai 1.15.12 → 1.15.13
**Core**
- Gateway Anthropic Opus 4.7+ adaptive reasoning now keeps summarized thinking instead of returning empty thinking blocks (bugfix).
- Sessions can now store custom metadata through the API and SDK ([@shantur](https://github.com/shantur)).
- Config now loads from the opened location upward, so directory-specific settings and provider policies apply more predictably.
**TUI**
- Wrapped inline tool rows now stay aligned, and failed inline tools can expand their error details in place (bugfix).
### Bumped: pi 0.77.0 → 0.78.0 (resolved from npm at build time)
See [pi-devbox v0.78.0](https://github.com/joakimp/pi-devbox/releases/tag/v0.78.0) for full pi release notes.
## v1.15.12 — 2026-05-29
First container build on the genuine `opencode-ai@1.15.12` upstream release (published 2026-05-28). Also bumps pi `0.76.0``0.77.0`.
> **Note on the `v1.15.12` git tag:** an earlier `v1.15.12` git tag existed at commit `be2a168` as a historical artifact from the 2026-05-28 versioning slip (re-cut as `v1.15.11c` once the slip was caught). The corresponding Hub `v1.15.12*` images were manually deleted at the time. Now that opencode upstream has actually released 1.15.12, the tag is being re-used at HEAD per the `v{opencode_version}[letter]` scheme — the old tag was force-overwritten locally and on origin. Commit `be2a168` and the v1.15.11c CHANGELOG block (which references the slip) remain in history.
### Bumped: opencode-ai 1.15.11 → 1.15.12
Notable upstream changes (from the [anomalyco/opencode v1.15.12 release](https://github.com/anomalyco/opencode/releases/tag/v1.15.12)):
- **Core** — ACP integrations can send prompts/slash-commands/usage updates through `acp-next`; experimental WebSocket transport for OpenAI Responses (`OPENCODE_EXPERIMENTAL_WEBSOCKETS=true`); adaptive reasoning enabled for Anthropic Opus 4.7+.
- **Bugfixes** — colons allowed in passwords; faster warm `acp-next` model/config switches; OpenAI WebSocket response timeouts kept active with retries before fallback; `acp-next` permission prompts handled correctly; persisted session directory used for existing-session requests; remote workspace request bodies forwarded correctly; custom base URLs supported for OpenAI WebSocket Responses.
- **TUI** — workspace management dialog; session navigation works while prompt modes are open; thinking spinner restored; subagent retry status surfaced; opening editors from non-Git project paths fixed.
- **Desktop** — tab-layout setting; home empty state and V2 font usage improved; tab close buttons showing reliably.
### Bumped: pi 0.76.0 → 0.77.0
Notable upstream changes (from pi's CHANGELOG):
- **Claude Opus 4.8 support** — model metadata + adaptive-thinking coverage updated.
- **Selective tool disablement** — `--exclude-tools` / `-xt` disables specific built-in, extension, or custom tools while keeping the rest available.
- **Headless Codex subscription login** — `/login` can use device-code auth for ChatGPT Plus/Pro Codex subscriptions.
- **Streaming-aware extension input** — `InputEvent.streamingBehavior` lets extensions distinguish idle prompts, mid-stream steers, and queued follow-ups.
- **Bugfixes** — startup timing output excludes `createAgentSessionRuntime`; OpenRouter DeepSeek V4 `xhigh` reasoning preserved; SIGTERM/SIGHUP run extension `session_shutdown` cleanly; keyboard protocol negotiation ignores delayed terminal responses; Windows MSYS2 ucrt64 startup crash fixed; API-key/header config resolution treats plain strings as literals with `$ENV_VAR` interpolation; session disposal aborts in-flight work; numerous provider-specific reasoning/metadata fixes (Codex Responses replay, OpenAI/OpenRouter GPT-5.5 Pro, Kimi K2.6, Xiaomi Token Plan).
### Inheritance from base
No base change — `base-latest` is reused unchanged from v1.15.11c (`base-decide` short-circuits at the Hub-probe step). SSH ControlMaster on a writable socket path, gitleaks, and git-crypt continue to ride along from the base.
### Workflow status
This is the first opencode-version-bump publish exercising the afternoon-of-2026-05-28 workflow changes (cache-export removal + 3-attempt retry wrapper) end-to-end on a real upstream release. v1.15.11c proved the publish path mechanically; v1.15.12 is the first one with both an opencode bump and a pi bump driving fresh variant layers.
## v1.15.11c — 2026-05-28
**Re-cut of v1.15.12 to fix a versioning-scheme violation.** The morning's v1.15.12 release was tagged in error: `opencode-ai` stayed at `1.15.11` upstream (no 1.15.12 exists on npm), so per the project's `v{opencode_version}[letter]` scheme this should have been the third container build on opencode 1.15.11 — `v1.15.11c` — not a new minor version bump. The `v1.15.12` git tag and the eight `v1.15.12*` / `latest*` Docker Hub images remain as historical artifacts but are superseded by this release. Future builds on opencode 1.15.11 continue the letter sequence as `v1.15.11d`, `v1.15.11e`, … — v1.15.12 will only be reused if and when opencode upstream actually releases 1.15.12.
Content inherited from v1.15.12 (see that block below for the full diagnostic chain on the v4.0.0 pin disproof and the manual host-side publish):
- pi `0.75.5``0.76.0`.
- `setup-buildx-action` pin reverted from `@v4.0.0` back to `@v4` (the v1.15.11b regression hypothesis was disproven).
- Inheritance from base: SSH ControlMaster on a writable socket path, gitleaks, git-crypt.
- Cache-hit silent same-bytes regression fix carried forward from v0.75.5b's pattern.
Additional changes since v1.15.12 (afternoon 2026-05-28 followup work):
### Hub-push regression — root cause identified, CI fixed
The `400 Bad request` from `registry-1.docker.io` that broke CI publishing across runs #332/333/334/336 (and forced v1.15.12 to ship via manual host-side push) is **buildkit's registry cache-export with `mode=max`**, not the image push itself.
**Diagnostic that nailed it:** the manual v1.15.12 publish from an Orbstack host reproduced the exact same 400 — but only on the cache-export step. Image layers pushed cleanly (911s for the base, all variants succeeded). Dropping `--cache-to` from the manual script let the publish complete. Running the same buildx version against the same Hub account from the same network, the only differential was cache export vs. image export.
This explains every observation:
- Failure shape stable across attempts (`Offset:0`, HTML body, CDN-tier rejection): cache-export protocol-level mismatch, not transient network or per-blob corruption.
- Repo-specific (`joakimp/opencode-devbox` only): we're the only Hub repo currently writing a `:base-buildcache` tag with `mode=max`.
- Started ~2026-05-23: lines up with buildx 0.34.x rolling out and bundling moby/buildkit v0.30.0, which changed the `_state` token format on resumable cache uploads.
- Image push works fine: cache-export is a separate codepath using a different manifest/layer scheme.
- Action-pin to `setup-buildx-action@v4.0.0` didn't help: that pin pulls older actions-toolkit, but the bundled buildkit was still 0.34.x via Buildx CLI on the runner image. Pin was correctly disproven by run #336.
### Workflow change
- **`.gitea/workflows/docker-publish-split.yml`** — registry cache (`cache-from`/`cache-to`) removed from the `build-base` step. Comment in place documenting the regression and the re-enable condition. Variants don't use registry cache so they're untouched. The base tag is content-addressed (`base-<hash>` derived from Dockerfile.base + rootfs/* + entrypoint*.sh) so unchanged bases short-circuit at the Hub-probe step in `base-decide` and never re-build anyway — the lost cache only affects the rare case of a Dockerfile.base change, where we now pay the full ~3 min build instead of pulling cached layers. Acceptable trade-off vs. broken publishes.
Next tag push (e.g. v1.15.13) is expected to publish cleanly via Gitea CI again. validate.yml on this main push will be the first real-time test of the smoke side; full publish path will be tested on the next opencode bump or by a deliberate letter-suffix re-tag.
### Status of earlier suspects
- ~~`setup-buildx-action@v4.1.0`~~ — disproven by v1.15.11b CI run #336 with v4.0.0 pin failing identically. Pin reverted in v1.15.12. Not the regressor.
- ~~`@docker/actions-toolkit 0.79.0 → 0.90.0`~~ — rolled back via the action pin; same failure. Not the regressor.
- ~~Account / repo / Hub-CDN globally~~ — local pushes from developer host succeed. Always was healthy.
- ~~`catthehacker/ubuntu:act-latest`~~ / ~~act-runner egress~~ — manual publish from host reproduced the same 400, ruling out runner-side network. Not the cause.
- **Confirmed:** buildkit cache-export protocol (mode=max) hitting Hub-CDN edge rejection. Workaround: don't export cache to registry. Long-term: track moby/buildkit upstream for protocol fix or switch to GHA cache (not portable to Gitea Actions).
### Docs: manual host-publish runbook + script archive
- `docs/manual-host-publish.sh` — the literal script that shipped v1.15.12 from a developer Mac via Orbstack, preserved as-is.
- `docs/manual-host-publish.md` — runbook explaining when to reach for the escape hatch, the four constants to edit (`RELEASE_TAG`, `BASE_HASH`, `PI_VERSION`, `OMOS_VERSION`), three sources for `BASE_HASH` (CI's `base-decide` log = canonical, Hub `base-latest` probe, local recompute matching CI's exact recipe including `__pycache__`/`.DS_Store`/`._*` junk filters), and adaptations for pi-devbox / letter-suffix rebuilds / partial-failure single-variant recovery.
- `AGENTS.md` — new Critical conventions bullet documenting that `cache-from`/`cache-to` is currently disabled, why, and the re-enable condition.
### CI: workflow-level retry around `docker buildx build --push`
All five push steps in `.gitea/workflows/docker-publish-split.yml` (1 base + 4 variants) are now wrapped in a 3-attempt retry loop with backoff (15s, 30s) as belt-and-braces against transient `registry-1.docker.io` blips. Replaces the `docker/build-push-action@v7` invocations with `shell: bash` steps that run `docker buildx build --push` directly so the loop is visible and tweakable. Smoke-test build steps (`load: true`, no push) are unchanged — they don't suffer from registry-side flakiness.
Does **not** mask deterministic failures: a true regression (e.g. the cache-export 400 documented above) will fail all 3 attempts identically and the job still fails by design. Belt-and-braces with the workflow-level retry-on-failure rerun heuristic in the `ci-release-watcher` skill, which catches transient-shaped runner-side failures separately. No image-side change.
### AGENTS.md addition: pre-flight scheme check
New "Versioning scheme" subsection documenting the **mandatory `npm view opencode-ai version` pre-flight check** before cutting any non-letter-suffixed tag, with this slip cited as the cautionary example.
---
## v1.15.12 — 2026-05-28
> **Note (2026-05-28 PM):** this tag violates the project's `v{opencode_version}[letter]` versioning scheme — there is no `opencode-ai@1.15.12` on npm; OPENCODE_VERSION stayed at 1.15.11 across this build. Re-cut as `v1.15.11c` at HEAD per the scheme. The git tag and Hub images for `v1.15.12*` remain as historical artifacts but are superseded by `v1.15.11c`. See the `v1.15.11c` block above for the corrected release notes.
Manual-published release. Reverts the `setup-buildx-action@v4.0.0` pin from v1.15.11b (hypothesis was disproven — see below) and bumps the bundled `pi-coding-agent` to 0.76.0 via the floating `PI_VERSION=latest` resolution.
### Why "manual-published"
v1.15.11b reproduced the exact same Hub `400 Bad request` regression as v1.15.11 (CI run #336, build-base failed twice including a Gitea auto-rerun), confirming `setup-buildx-action@v4.1.0` is **not** the regressor. After four consecutive identical CI failures across two days, the SSH-CM and gitleaks fixes were shipped by hand from a developer host's Orbstack/Docker-Desktop — a path we already knew worked in ~25s for the same multi-arch build to the same Hub account.
This release ships the same content the runner-side build would have shipped; it just bypasses the broken runner-network → Hub-CDN combo. CI auto-publishing remains broken pending separate runner-side investigation (see [AGENTS.md — known issues](AGENTS.md)).
### Workflow change
- **`.gitea/workflows/docker-publish-split.yml`** — all nine `setup-buildx-action@v4.0.0` pins reverted to `@v4`. The pin added no value (failure reproduced) and was holding us off action improvements.
### Bumped: pi-coding-agent (latest → 0.76.0)
`PI_VERSION=latest` in `Dockerfile.variant` resolves at build time. 0.76.0 was published 2026-05-27 20:03 UTC. No Dockerfile edit needed; floating-`latest` is intentional so each opencode-devbox release pulls the freshest pi without a manual bump.
### Hub-push regression — ruled out / still suspect
**Ruled out:**
- `setup-buildx-action@v4.1.0` — v4.0.0 reproduces the failure identically.
- `@docker/actions-toolkit 0.79.0 → 0.90.0` — rolled back via the action pin; same failure.
- Account / repo / Hub-CDN globally — local pushes from a developer host succeed.
- Multi-arch as such — pi-devbox v0.75.5b pushed multi-arch on 2026-05-23.
**Still suspect:**
- `catthehacker/ubuntu:act-latest` runner image (floating, not pinned in workflows).
- act-runner host network egress from `runner-2` (sustained CDN-edge rejection from this specific source IP).
- buildx 0.34.x's signed `_state` token format hitting a Hub-edge WAF/length rule that didn't apply to 0.33.x.
- Hub-side per-repo state for `joakimp/opencode-devbox` specifically (other Hub repos from the same account work).
Four failing runs share the exact failure shape: HTTP 400 with HTML body (CDN-tier, not registry backend) on the very first PUT (`Offset:0`) of the resumable layer-blob upload. UUIDs and `_state` signatures differ across attempts — only the failure pattern is stable.
---
## v1.15.11b — 2026-05-27
Container-level rebuild of v1.15.11. The original v1.15.11 release-day publish failed three times in a row (CI runs #332/333/334) with identical `400 Bad request` responses from `registry-1.docker.io` on the buildx layer-blob PUT. Build itself succeeded 30/30 each time; only the multi-arch push failed. Triaged on 2026-05-27 evening:
- **Local multi-arch buildx push from a developer host succeeds in ~25s** — same Hub account, same multi-arch path. Account, repo, and Hub-CDN are all healthy.
- **Last known-good Gitea Actions Hub push: 2026-05-23 ~20:26 UTC** (`pi-devbox v0.75.5b`). All Gitea-runner-driven pushes since 2026-05-24 have failed identically.
- **Smoking gun candidate:** `docker/setup-buildx-action@v4` floats to `v4.1.0` (published 2026-05-22 16:00 UTC). Action-resolver caches on the runner appear to have rolled forward to v4.1.0 sometime between the May 23 success and the first May 24 failure. v4.1.0 ships a newer bundled buildx/buildkit which may be using a different push protocol that trips Hub's CDN URI-length cap (the failing `_state` query string is ~1.4 KB).
### Workflow change
- **`.gitea/workflows/docker-publish-split.yml`** — all nine `docker/setup-buildx-action@v4` uses pinned to `@v4.0.0`. `setup-qemu-action@v3` left floating since QEMU wasn't in the suspected blast radius and was working on May 23. If v4.0.0 publishes cleanly we keep the pin and file an upstream buildkit/buildx issue documenting the regression.
No other source changes — same `OPENCODE_VERSION=1.15.11`, same `Dockerfile.base` and `Dockerfile.variant`, same SSH-CM bake, same gitleaks. v1.15.11 (the original tag) is preserved in the repo as a historical marker of the first publish attempt; v1.15.11b is the canonical release.
### v1.15.11
First release on opencode 1.15.11. Also bakes in four devbox-side fixes accumulated since v1.15.10 (SSH ControlMaster on a writable path, gitleaks added to base, CI resolve-versions hardening, CI cache-hit regression fix). Downstream pi-devbox inherits all of these on its next build against `base-latest`.
### Bumped: opencode 1.15.10 → 1.15.11
`OPENCODE_VERSION` ARG bumped in `Dockerfile.variant`. Highlights from the upstream release (full notes: <https://github.com/anomalyco/opencode/releases/tag/v1.15.11>):
- **Core / Improvements** — new `headerTimeout` config for provider requests (10s default for default OpenAI setups); experimental background agents now push updates without polling; remote-backed projects resolve a stable project identity; `modalities.input` / `modalities.output` can be set independently.
- **Core / Bugfixes** — dynamically added MCP servers now disconnect cleanly on removal; Google tool calling fixed after upstream tool-ID regression; resumed sessions no longer continue orphaned interrupted tools; OpenAI reasoning summaries render as separate blocks; the `shell` tool now advertises its configured timeout to the model; config loading falls back cleanly when user info is unavailable.
- **TUI** — prompt resizes with terminal width (new prompt-size config); accelerated diff-viewer scrolling; external editors open from the worktree directory when available.
- **Desktop** — refined v2 home screen, prompt, status popover, and session controls; fixed V2 titlebar errors when a session sync cache was deleted; web deployments no longer run desktop health checks; duplicate server connections are merged.
- **Extensions** — new `dispose` hook for plugins; Codex plugin now sends the expected session-ID header.
No `opencode-devbox`-side changes were required to consume 1.15.11 — pure version bump.
### Base: SSH ControlMaster default on a writable socket path
Devboxes typically mount `~/.ssh` from the host as **read-only** (security: keys remain readable but agents can't tamper with config / known_hosts / authorized_keys / plant a malicious ProxyCommand). OpenSSH's default `ControlPath` lands inside `~/.ssh/cm/`, which is unwritable on such mounts — so any attempt to use `ControlMaster auto` (or anything that wants to multiplex) fails with:
```
unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
kex_exchange_identification: Connection closed by remote host
```
The second line is downstream: when ControlMaster fails the ssh client falls back to a fresh TCP connection, and on residential CGNAT (most European ISPs) the per-(src,dst) concurrent-flow cap (~4) silently drops further SYNs once exceeded — manifesting as banner-exchange timeouts that look like a remote problem.
- **`Dockerfile.base`** — new section right after the apt block bakes `/etc/ssh/ssh_config.d/00-devbox-controlmaster.conf` with `Host *` defaults: `ControlMaster auto`, `ControlPath /tmp/sshcm/%r@%h:%p`, `ControlPersist 10m`, plus `ServerAliveInterval 30` / `ServerAliveCountMax 6` for resilience to mid-stream NAT timeouts. `/tmp` is per-container and always writable, so the read-only `~/.ssh` mount is left untouched. Debian's stock `/etc/ssh/ssh_config` includes `ssh_config.d/*.conf` *before* its own `Host *` block, so user `~/.ssh/config` overrides still win.
- **`entrypoint-user.sh`** — creates `/tmp/sshcm` mode 700 on every container start. `/tmp` is per-container so the dir doesn't survive recreation; baking it into a Dockerfile layer would be wrong. Mode 700 is required — OpenSSH refuses to use a `ControlPath` directory others can write to.
- **`scripts/smoke-test.sh`** — two new assertions: (a) the conf file exists at the expected path; (b) `ssh -G example.invalid` reports a `controlpath` rooted at `/tmp/sshcm/`. The second catches the silent regression where something later in the SSH config chain shadows the bake-in.
- **No size/threshold impact:** the conf file is ~250 bytes.
Downstream pi-devbox and any other variant inherits this on its next build against `base-latest`. Discovered while running a recon-shell from inside pi-devbox to a Proxmox node — fresh ssh hit banner timeout, debug output pointed at the read-only socket dir.
_(Originally landed on `main` 2026-05-24 as commit `668592d`; first ships in v1.15.11.)_
### Base: gitleaks added; git-crypt confirmed already installed
`gitleaks` is now baked into `Dockerfile.base` (Go-compiled binary fetched from GitHub releases, same `/releases/latest` redirect-resolution pattern as gosu/fzf/git-lfs/etc.). It pairs with `git-crypt`, which has been installed via apt all along but wasn't asserted by smoke or called out in user-facing docs. Several of the user's repos use both as part of their secret-management setup (gitleaks pre-commit hook + git-crypt for selectively-encrypted canonical config); having them in the devbox means `pi install`-style hooks fire correctly inside the container instead of warning that gitleaks is missing.
- **`Dockerfile.base`** — new `GITLEAKS_VERSION=latest` ARG + install RUN block right after `git-lfs`. Arch suffix is `x64` (not `x86_64` or `amd64`) on this project; comment in the Dockerfile flags the deviation. Adds ~21 MB to the base layer.
- **`scripts/smoke-test.sh`** — adds `git-crypt` and `gitleaks` to the "Resolved component versions" table and to the "Core binaries" assertion list. Now fails fast if either binary disappears from the base.
- **`README.md`** — "What's in the image" tree updated to name `gitleaks` alongside `git-crypt` in the dev-tools line.
- **No threshold bumps:** 21 MB on a 25003700 MB envelope is noise; existing variant thresholds keep their headroom.
This is a base-layer change — `base-decide` will compute a fresh `base-<hash>`, `build-base` will run on the next release (no cache hit), and all four variants will rebuild against the new base. **Downstream pi-devbox** picks up gitleaks automatically on its next release that resolves `joakimp/opencode-devbox:base-latest` to the new digest — no Dockerfile change needed there.
### 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.
+56
View File
@@ -71,6 +71,44 @@ RUN apt-get update && \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# ── SSH client defaults: ControlMaster on a writable socket path ──────
# Why this exists: the devbox typically mounts ~/.ssh from the host as
# read-only (security: keys are readable, but agents can't tamper with
# config / known_hosts / authorized_keys / plant a malicious ProxyCommand).
# OpenSSH's default ControlPath is ~/.ssh/cm/... which is unwritable on
# such mounts, so any attempt to use ControlMaster fails. Symptoms:
# unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
# kex_exchange_identification: Connection closed by remote host
# The latter manifests downstream of CGNAT per-destination flow caps
# (~4 concurrent flows on most European residential ISPs) which silently
# drop further SYNs once exceeded — making fresh ssh attempts fail with
# banner-exchange timeouts that look like a remote problem.
#
# Fix: set a system-wide default ControlPath in /tmp (per-container,
# tmpfs-friendly, always writable) so multiplexing Just Works without
# touching the read-only ~/.ssh mount. Per-host overrides in user's
# ~/.ssh/config still win — Debian's default /etc/ssh/ssh_config has
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
# so user config can override these defaults if desired.
#
# ControlPersist=10m means the master socket sticks around 10 min after
# the last session closes, so consecutive ssh calls in a workflow reuse
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
# (mode 700) on each container start.
RUN mkdir -p /etc/ssh/ssh_config.d && \
printf '%s\n' \
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
'# Override per-host in ~/.ssh/config if the master socket location' \
'# needs to differ.' \
'Host *' \
' ControlMaster auto' \
' ControlPath /tmp/sshcm/%r@%h:%p' \
' ControlPersist 10m' \
' ServerAliveInterval 30' \
' ServerAliveCountMax 6' \
> /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \
chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
#
# Version policy for the binaries below:
@@ -126,6 +164,24 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;;
git lfs install --system && \
git-lfs --version
# gitleaks — secret scanner (used as a pre-commit hook in several of the
# repos this devbox is meant to operate on; pairs with git-crypt below).
# Distributed as a Go-compiled tarball; arch suffix is `x64` (not `x86_64`
# or `amd64`) on this project — mind the deviation from the surrounding
# tools' naming.
ARG GITLEAKS_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x64" ;; arm64) echo "arm64" ;; *) echo "x64" ;; esac) && \
V="${GITLEAKS_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing gitleaks ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin gitleaks && \
chmod +x /usr/local/bin/gitleaks && \
gitleaks version
# neovim — modern text editor
ARG NVIM_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
+21 -1
View File
@@ -31,8 +31,12 @@ 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
ARG OPENCODE_VERSION=1.15.13
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
opencode --version ; \
@@ -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 \
+1 -1
View File
@@ -762,7 +762,7 @@ Container (Debian trixie)
├── oh-my-opencode-slim (optional — multi-agent orchestration plugin, includes Bun)
├── AWS CLI v2 (SSO + Bedrock auth)
├── neovim 0.12, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
├── git, git-crypt, age, ssh, ripgrep, fd, fzf, jq, curl, tree
├── git, git-crypt, age, gitleaks, ssh, ripgrep, fd, fzf, jq, curl, tree
├── Node.js (for MCP servers)
├── Bun (optional — included with oh-my-opencode-slim)
├── entrypoint.sh (UID adjustment, git config, provider setup)
+127
View File
@@ -0,0 +1,127 @@
# Manual host-side publish — escape hatch when CI is broken
This runbook is the procedure for publishing an opencode-devbox release **directly from a developer host** when the Gitea Actions → Docker Hub path is broken. Used in anger on 2026-05-28 to ship `v1.15.12` after five consecutive CI publish failures (runs #332/333/334/336 + a rerun) and as a parallel diagnostic that pinpointed the root cause (buildkit `cache-export mode=max` returning HTTP 400 from the Hub CDN).
The procedure is also a **diagnostic probe**. If the host-side publish succeeds where CI fails, the failure is somewhere in the runner → Hub path (cache-export, runner egress, runner-image, action versions). If host-side fails the same way, the failure is in your local buildx + Hub combination and you need a different escape (different network, different account, file an upstream).
## When to reach for this
- Tag pushed, CI keeps failing on `docker buildx build --push`, the failure shape is stable across reruns.
- Failure body looks like a registry-tier rejection (HTTP 4xx, HTML response body, repeats on every retry) — i.e. not a transient.
- You've already disproved the obvious suspects (action pin, runner image, network) per the [`ci-release-watcher` skill](../../../.agents/skills/ci-release-watcher/SKILL.md) playbook.
- You need the release **shipped today** and don't want to wait for a CI fix to land + re-trigger.
If CI is broken because **a workflow change you just made is bad**, fix the workflow and re-tag with a letter suffix. This runbook is for when the workflow looks correct but the publish path itself is broken.
## Prerequisites on the host
- Docker (or Orbstack on macOS) with `docker buildx` available — multi-arch publish needs `setup-qemu` equivalent. Orbstack ships QEMU emulators for both archs by default; on Linux install `qemu-user-static` and run `docker run --privileged --rm tonistiigi/binfmt --install all` once per host.
- `docker login` credentials for `joakimp` on Docker Hub (PAT or password). Confirm with `docker info | grep Username`.
- A clone of `opencode-devbox` checked out at the **exact tag** you want to publish. `git status` clean. `git describe --tags --exact-match HEAD` should print the tag.
- Network connectivity to `registry-1.docker.io` from the host. Verify with `curl -sI https://registry-1.docker.io/v2/ | head -1` (expects `401 Unauthorized` — that's the v2 API saying "auth required", which means you can reach it).
## How to use this runbook
A working reference script lives next to this doc: **[`docs/manual-host-publish.sh`](manual-host-publish.sh)**. It is the literal script that shipped opencode-devbox v1.15.12 on 2026-05-28 from a developer Mac via Orbstack, with the BASE_HASH and version pins of that release. To publish a different release, **copy it to a new file, edit four constants at the top, and run it**:
```bash
cp docs/manual-host-publish.sh /tmp/manual-publish-vX.Y.Z.sh
# Edit at top of file:
# RELEASE_TAG="vX.Y.Z"
# BASE_HASH="<12-char hash from CI's base-decide step>"
# PI_VERSION="<from npm registry, see step 2 below>"
# OMOS_VERSION="<from npm registry, see step 2 below>"
bash /tmp/manual-publish-vX.Y.Z.sh
```
Keep the historical script in `docs/` as-is — it's an archive of the v1.15.12 publish, useful as a reference if a future debug needs to compare exact arg sets across releases. Don't edit it in place.
The sections below explain what the script does and what you need to know to edit those four constants safely.
## 1. Pin RELEASE_TAG
The git tag you're publishing. Must match a tag in the local clone:
```bash
git fetch && git checkout v1.15.13 # whatever you're publishing
git describe --tags --exact-match HEAD
```
The script asserts `HEAD == ${RELEASE_TAG}^{commit}` before doing anything destructive. If you've drifted, fix it with `git checkout` before running.
## 2. Pin PI_VERSION and OMOS_VERSION
Gitea CI's `resolve-versions` job queries the npm registry at workflow time and threads concrete versions through every variant build, mitigating the silent same-bytes-across-releases regression class documented in `AGENTS.md`. Do the same by hand:
```bash
curl -sf https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest | jq -r .version
curl -sf https://registry.npmjs.org/oh-my-opencode-slim/latest | jq -r .version
```
Paste the two version strings into the script's `PI_VERSION` / `OMOS_VERSION` constants. Don't leave the script defaulting to `latest` — the registry buildcache will silently reuse a stale layer if the build-arg byte-equals a previous build.
## 3. Pin BASE_HASH
This is the 12-char hash that CI's `base-decide` job computes from `Dockerfile.base` + `rootfs/**` + `entrypoint*.sh`. Three ways to get it, in order of preference:
**A. From a prior CI run on the same commit** (cheapest — if the Gitea Actions run that triggered on this tag got far enough to log `base-decide`'s output, just read it):
```
Gitea Actions → the run for vX.Y.Z → base-decide job → "Compute base tag" step → last line:
Computed base tag: base-XXXXXXXXXXXX
```
This is the canonical source. The whole reason for the manual escape is that *something later in CI broke*`base-decide` itself is fast, deterministic, and almost always succeeds.
**B. From an existing image on the Hub** if a recent release already published a `base-<hash>` tag and the inputs haven't changed, you can copy that hash. Confirm with `docker manifest inspect joakimp/opencode-devbox:base-latest` and read the digest — if it matches a `base-<hash>` you already see on the Hub, that hash is yours.
**C. Compute it locally**, replicating CI's exact recipe (the script in `.gitea/workflows/docker-publish-split.yml` `base-decide.compute`):
```bash
{
cat Dockerfile.base
find rootfs -type f \
! -path '*/__pycache__/*' \
! -name '*.pyc' \
! -name '.DS_Store' \
! -name '._*' \
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
cat entrypoint.sh entrypoint-user.sh
} | sha256sum | cut -c1-12
```
The junk-file filters (`__pycache__`, `.DS_Store`, `._*` AppleDouble) matter — they are gitignored but `find -type f` picks them up locally and would diverge your hash from CI's clean checkout. Don't skip them.
If method C disagrees with method A, **trust A** and find out why your local tree differs. The hash in CI is what's on the Hub; that's what variants must FROM.
## What the script does (high level)
After the constants are set, the script runs a 5-step procedure. No editing needed inside the body; the whole flow is parameterised by the four constants above plus `IMAGE` (which is fixed to `joakimp/opencode-devbox`).
1. **Preflight** — buildx present, tag exists, `HEAD == tag`, multi-arch builder created if missing.
2. **Base build (conditional)** — probe `${IMAGE}:base-${BASE_HASH}` on the Hub; if missing, build it multi-arch and push. **No `--cache-from` / `--cache-to`.** That's the whole point of this escape. If the base push itself fails the same way CI did, stop — the regression has spread to image push and you need a different host or account, not this runbook.
3. **Promote `base-latest`**`docker buildx imagetools create` re-tags by manifest reference. No rebuild.
4. **Variants × 4** — sequential (not parallel; one host's egress can't saturate four multi-arch pushes safely). Each variant is `Dockerfile.variant` `FROM ${IMAGE}:base-${BASE_HASH}` plus the appropriate `INSTALL_OMOS` / `INSTALL_PI` build-args, tagged `${RELEASE_TAG}${suffix}` and `latest${suffix}`.
5. **Verify** — prints the digest of all 10 expected tags (8 variant + base-hash + base-latest). Spot-check that each `vX.Y.Z*` and its `latest*` alias share a digest.
Expected wall time on a recent Mac: ~25-40 min (base ~3 min if rebuilt, each variant ~3-7 min mostly QEMU arm64 emulation).
## Optional: update DOCKER_HUB.md description
CI's `update-description` job posts the rendered Hub description via the Hub API. The manual script does **not** do this — the release works fine without it. If you want parity, copy the curl invocation from the `update-description` job in `.gitea/workflows/docker-publish-split.yml` and run it from the host with a Hub PAT loaded into `HUB_PAT`. Cosmetic; can wait until CI is healthy and the next release pushes a fresh description automatically.
## After: capture diagnostic value
The whole point of running this manually is the diagnostic. Three things to record before moving on:
1. **Did the host publish succeed?** If yes and CI was failing on the same exact code, you've localised the failure to the runner side (cache-export, network, runner image). If no, the failure is in your local buildx + Hub combination and CI is a victim, not a cause.
2. **What was different from CI?** Document at minimum: `docker buildx version`, the host's `buildx ls` output (driver name + version), whether you used `--cache-to` or not, and which network you were on.
3. **File the upstream.** If the diagnostic narrowed the failure to a specific buildkit/buildx behaviour, file at `moby/buildkit` or `docker/buildx` with: stable failure shape, the exact request URL fragment (`Offset:0` / `_state=...` / digest if visible), the timeline boundary when failures started, and what worked vs what failed in your repro. The 2026-05-28 cache-export-mode=max regression is a worked example.
Restore CI as the primary publish path as soon as the underlying regression is fixed or worked around at workflow level. This runbook should be exercised rarely.
## Variants of this runbook
- **pi-devbox** — same idea, simpler: only one image (`joakimp/pi-devbox`), one tag pair (`vX.Y.Z` + `latest`), no split base. Adapt the script: drop the `BASE_HASH` constant + steps 2-3 + the variant function; replace with a single `docker buildx build --file Dockerfile --build-arg PI_VERSION=... --tag joakimp/pi-devbox:${RELEASE_TAG} --tag joakimp/pi-devbox:latest --push .`.
- **opencode-devbox letter-suffix rebuild** (e.g. `v1.15.12b`) — same procedure end-to-end. The `BASE_HASH` will probably be unchanged from the prior release if no rootfs/entrypoint/Dockerfile.base changes shipped, so the base-build step skips itself automatically via the Hub probe.
- **Single-variant publish** for partial-failure recovery (e.g. CI succeeded for base + 3 variants but the 4th failed) — comment out the three completed `build_variant` calls in your copy of the script. Keep `imagetools create` for `base-latest` only if it didn't already promote. Then re-run.
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Manual publish of opencode-devbox v1.15.12 — bypasses broken Gitea-runner
# Hub push by building & pushing from a developer host (Orbstack/Docker Desktop).
#
# Mirrors what .gitea/workflows/docker-publish-split.yml would do:
# 1. Build & push Dockerfile.base → joakimp/opencode-devbox:base-<hash>
# 2. Promote → joakimp/opencode-devbox:base-latest
# 3. Build & push 4 variants on top of base-<hash>:
# :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)
#
# Usage on your host:
# 1. Make sure Orbstack/Docker Desktop is running with multi-arch enabled
# (docker buildx ls should show linux/amd64,linux/arm64).
# 2. docker login docker.io (joakimp account)
# 3. cd ~/path/to/opencode-devbox && git fetch && git checkout v1.15.12
# 4. bash /path/to/this/script.sh
#
# Total expected time: ~25-40 min on a recent Mac (4 multi-arch builds, base
# layers cache after the first variant).
set -euo pipefail
IMAGE="joakimp/opencode-devbox"
RELEASE_TAG="v1.15.12"
BASE_HASH="8d72a9e44796" # sha256 of Dockerfile.base + rootfs/* + entrypoints (computed by CI logic)
BASE_TAG="base-${BASE_HASH}"
PI_VERSION="0.76.0" # resolved from npm @earendil-works/pi-coding-agent latest (2026-05-28)
OMOS_VERSION="1.1.1" # resolved from npm oh-my-opencode-slim latest (2026-05-28)
PLATFORMS="linux/amd64,linux/arm64"
# -------- preflight --------
echo "==> Preflight"
docker buildx version >/dev/null || { echo "buildx not available"; exit 1; }
git rev-parse --verify "$RELEASE_TAG" >/dev/null 2>&1 || {
echo "Tag $RELEASE_TAG not found locally. git fetch && git checkout $RELEASE_TAG first."; exit 1; }
[[ "$(git rev-parse HEAD)" == "$(git rev-parse "${RELEASE_TAG}^{commit}")" ]] || {
echo "HEAD is not at $RELEASE_TAG. git checkout $RELEASE_TAG first."; exit 1; }
docker buildx inspect default >/dev/null 2>&1 || docker buildx create --use --name multi --driver docker-container
# Probe whether base-<hash> already exists on Hub (CI does this; saves 10 min if yes)
if docker manifest inspect "${IMAGE}:${BASE_TAG}" >/dev/null 2>&1; then
echo "==> Base tag ${IMAGE}:${BASE_TAG} already exists on Hub — skipping base rebuild"
SKIP_BASE=1
else
echo "==> Base tag ${IMAGE}:${BASE_TAG} missing — will build"
SKIP_BASE=0
fi
# -------- 1. base (if needed) --------
if [[ "$SKIP_BASE" == "0" ]]; then
echo "==> [1/5] Build & push Dockerfile.base → ${IMAGE}:${BASE_TAG}"
docker buildx build \
--platform "$PLATFORMS" \
-f Dockerfile.base \
-t "${IMAGE}:${BASE_TAG}" \
--push \
.
fi
# -------- 2. promote base-latest --------
echo "==> [2/5] 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 install_omos="$2"
local install_pi="$3"
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}")
local versioned="${IMAGE}:${RELEASE_TAG}${suffix}"
local floating="${IMAGE}:latest${suffix}"
echo "==> Build & push variant${suffix:-(default)}${versioned} + ${floating}"
docker buildx build \
--platform "$PLATFORMS" \
-f Dockerfile.variant \
--build-arg "BASE_IMAGE=${IMAGE}:${BASE_TAG}" \
--build-arg "INSTALL_OPENCODE=true" \
--build-arg "INSTALL_OMOS=${install_omos}" \
--build-arg "INSTALL_PI=${install_pi}" \
${extra_args[@]+"${extra_args[@]}"} \
-t "${versioned}" \
-t "${floating}" \
--push \
.
}
echo "==> [3/5] Variant: base (opencode only)"
build_variant "" false false
echo "==> [4/5] Variant: omos"
build_variant "-omos" true false
echo "==> [4/5] Variant: with-pi"
build_variant "-with-pi" false true
echo "==> [5/5] Variant: omos-with-pi"
build_variant "-omos-with-pi" true true
echo
echo "==> Done. Verifying tags on Hub:"
for t in \
"${RELEASE_TAG}" "latest" \
"${RELEASE_TAG}-omos" "latest-omos" \
"${RELEASE_TAG}-with-pi" "latest-with-pi" \
"${RELEASE_TAG}-omos-with-pi" "latest-omos-with-pi" \
"${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")
printf " %-32s %s\n" "$t" "$d"
done
+11
View File
@@ -1,6 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
# ── SSH ControlMaster socket dir ────────────────────────────────
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
# creates the directory with the right permissions on every container
# start. /tmp is per-container so the dir doesn't survive recreation;
# baking it into a Dockerfile layer would be wrong.
# Mode 700 is required — OpenSSH refuses to use a ControlPath dir that
# others can write to.
mkdir -p /tmp/sshcm
chmod 700 /tmp/sshcm
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
# Respects host bind-mounts and user customizations — existing files
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
+39
View File
@@ -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 <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
echo "-- Resolved component versions --"
@@ -68,6 +87,8 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
printf " %-15s %s\n" "rg" "$(rg --version | head -1)"
printf " %-15s %s\n" "gosu" "$(gosu --version)"
printf " %-15s %s\n" "git-lfs" "$(git-lfs --version)"
printf " %-15s %s\n" "git-crypt" "$(git-crypt --version 2>&1 | head -1)"
printf " %-15s %s\n" "gitleaks" "$(gitleaks version 2>&1 | head -1)"
printf " %-15s %s\n" "gitea-mcp" "$(gitea-mcp --version 2>&1 | head -1)"
printf " %-15s %s\n" "aws" "$(aws --version 2>&1)"
if command -v bun >/dev/null 2>&1; then
@@ -103,11 +124,20 @@ run "fzf" "fzf --version"
run "fd" "fd --version"
run "rg" "rg --version | head -1"
run "jq" "jq --version"
run "git-crypt" "git-crypt --version | head -1"
run "gitleaks" "gitleaks version"
run "aws" "aws --version"
run "gitea-mcp" "gitea-mcp --version"
run "gosu" "gosu --version"
run "tmux" "tmux -V"
# SSH ControlMaster baked defaults: the config file must exist (image-level)
# and ssh -G must report ControlPath rooted at /tmp/sshcm/ for an arbitrary
# host. Catches both regressions: someone removing the conf file, OR something
# else later in the config chain shadowing the ControlPath setting.
run "ssh-config-cm-file" "test -f /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf"
run_expect "ssh-config-cm-path" "ssh -G example.invalid 2>/dev/null | grep -i ^controlpath" "/tmp/sshcm/"
echo
echo "-- Optional / variant-gated --"
# mempalace: present unless built with INSTALL_MEMPALACE=false
@@ -134,7 +164,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
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 +226,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"