Compare commits

..

34 Commits

Author SHA1 Message Date
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
joakimp 4cce39d167 AGENTS: add 'Upstream sources' section pointing at anomalyco/opencode
Validate / base-change-warning (push) Successful in 14s
Validate / docs-check (push) Successful in 16s
Validate / validate-base (push) Successful in 3m39s
Validate / validate-with-pi (push) Successful in 4m8s
Validate / validate-omos (push) Successful in 6m46s
Validate / validate-omos-with-pi (push) Successful in 13m35s
Publish Docker Image / build-base (push) Has been cancelled
Publish Docker Image / smoke-base (push) Has been cancelled
Publish Docker Image / smoke-omos (push) Has been cancelled
Publish Docker Image / smoke-with-pi (push) Has been cancelled
Publish Docker Image / smoke-omos-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-base (push) Has been cancelled
Publish Docker Image / build-variant-omos (push) Has been cancelled
Publish Docker Image / build-variant-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-omos-with-pi (push) Has been cancelled
Publish Docker Image / promote-base-latest (push) Has been cancelled
Publish Docker Image / update-description (push) Has been cancelled
Publish Docker Image / base-decide (push) Failing after 14m23s
Tonight's v1.15.10 release surfaced a documentation drift footgun: I
checked github.com/sst/opencode (a fork) for release notes instead of
the canonical github.com/anomalyco/opencode. Empty bodies on sst led
me to write 'upstream releases ship empty bodies and no CHANGELOG'
in the v1.15.10 CHANGELOG, which was wrong — anomalyco's release
pages have rich Core/TUI/Desktop/SDK sections.

Added a new 'Upstream sources — where to look up release notes'
section between 'Versioning scheme' and 'Critical conventions',
documenting:
  - Canonical upstream for opencode-ai (anomalyco/opencode), pi
    (npm tarball CHANGELOG.md), other floated tools.
  - The sst/opencode trap explicitly named so future-pi doesn't
    repeat the mistake.
  - Working fetch commands as muscle memory: 'npm view ... time'
    for latest stable filtering, 'curl /releases/tags/' for body,
    'npm pack' for pi's changelog.

No CI implications, doc-only.
2026-05-23 19:26:46 +02:00
joakimp 72d2c99885 docs: enrich v1.15.10 CHANGELOG with actual upstream release notes
The v1.15.10 release commit (80e57d7) said upstream releases ship
empty bodies — that was incorrect. I was checking sst/opencode which
is a fork; the canonical upstream this devbox tracks is
github.com/anomalyco/opencode (per user correction). Three of the
four versions (1.15.7, 1.15.9, 1.15.10) have rich release notes;
only 1.15.8 is empty.

Expanded the v1.15.10 CHANGELOG entry with per-version summaries:

- v1.15.7: Grok OAuth + device-code login, v2 session API safe-error
  responses with reference IDs, Codex OAuth refresh dedup, restored
  OpenAI OAuth + reasoning streams, friendly tool-schema errors,
  Grok PDF attachments, several TUI and desktop improvements.

- v1.15.8: empty release body (assumed internal/no user-visible).

- v1.15.9: redesigned diff viewer with file tree + enabled by default,
  MCP OAuth callbackPort and scope-in-clientMetadata, Vertex Anthropic
  multi-region endpoint fix, many 'show clearer error' improvements,
  native reasoning metadata preservation across turns, TUI worktree-
  copy shortcut, desktop titlebar tab navigation.

- v1.15.10: single fix — restored legacy production desktop flows
  for opening projects and starting sessions.

Doc-only commit on main. The v1.15.10 tag snapshot is unchanged
because CI is mid-flight against it (5 jobs running at the time of
this correction); cancelling and re-tagging would cost ~50 min of
CI re-run for changelog wording with no image-content effect.
The Hub description for v1.15.10 will reflect the thin tag-snapshot
CHANGELOG; main has the correct content for the next release and
for anyone reading current docs.
2026-05-23 19:19:19 +02:00
joakimp 80e57d732b Bump opencode 1.15.6 -> 1.15.10 + cut v1.15.10
Validate / validate-base (push) Waiting to run
Validate / validate-with-pi (push) Waiting to run
Validate / docs-check (push) Successful in 15s
Validate / base-change-warning (push) Successful in 59s
Validate / validate-omos (push) Successful in 4m33s
Validate / validate-omos-with-pi (push) Successful in 7m50s
Publish Docker Image / base-decide (push) Failing after 10m22s
Publish Docker Image / build-base (push) Has been cancelled
Publish Docker Image / smoke-base (push) Has been cancelled
Publish Docker Image / smoke-omos (push) Has been cancelled
Publish Docker Image / smoke-with-pi (push) Has been cancelled
Publish Docker Image / smoke-omos-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-base (push) Has been cancelled
Publish Docker Image / build-variant-omos (push) Has been cancelled
Publish Docker Image / build-variant-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-omos-with-pi (push) Has been cancelled
Publish Docker Image / promote-base-latest (push) Has been cancelled
Publish Docker Image / update-description (push) Has been cancelled
Four upstream patch releases over two days. Upstream releases ship
empty bodies and no CHANGELOG; the patch sequence (1.15.7-1.15.10)
is fixes only per typical sst/opencode cadence.

The with-pi and omos-with-pi variants will also implicitly bump
pi 0.75.4 -> 0.75.5 since PI_VERSION=latest resolves at build time.

omos-with-pi smoke threshold remains 3700 MB (set v1.15.4b 2026-05-18).
Four opencode patches plus one pi patch typically add only a few MB
across both, not expected to trip. If it does, recovery is the
well-worn letter-suffix pattern (v1.15.10b with threshold bump).

Cache hit expected on base-35ee5fe7861a since neither Dockerfile.base
nor rootfs/ have changed since v1.14.50b. Built on the same CI path
as v1.15.6 — pinned-crane install (T14), skip-promote-on-cache-hit
(T15), and update-description-always-on-base-success (v1.15.4b)
all expected to remain quiet on this cache-hit run.
2026-05-23 19:14:58 +02:00
joakimp 19f8c043bd Bump opencode 1.15.4 -> 1.15.6 + cut v1.15.6
Validate / docs-check (push) Successful in 14s
Validate / base-change-warning (push) Successful in 10s
Validate / validate-base (push) Successful in 3m33s
Validate / validate-omos-with-pi (push) Successful in 4m57s
Validate / validate-with-pi (push) Successful in 6m18s
Validate / validate-omos (push) Successful in 12m13s
Publish Docker Image / base-decide (push) Successful in 13s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-base (push) Successful in 3m27s
Publish Docker Image / smoke-omos (push) Successful in 4m29s
Publish Docker Image / smoke-with-pi (push) Successful in 6m13s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 12m31s
Publish Docker Image / build-variant-base (push) Successful in 14m33s
Publish Docker Image / build-variant-omos (push) Successful in 19m38s
Publish Docker Image / build-variant-with-pi (push) Successful in 19m0s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 30m37s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 9s
Two upstream patch releases since v1.15.4b. Plus this release picks up
two workflow improvements that landed on main between v1.15.4b and
now (b6e4d89 pycache/DS_Store filter in base-decide, 90e5a1f doc-drift
sweep clause in AGENTS.md). No image-content changes beyond the
version bump; cache hit expected on base-35ee5fe7861a since neither
Dockerfile.base nor rootfs/ have changed.

The with-pi and omos-with-pi variants will also implicitly bump pi
0.75.3 -> 0.75.4 because PI_VERSION=latest resolves at build time.
The omos-with-pi smoke threshold (3700 MB after the v1.15.4b bump)
should accommodate two opencode patch versions plus one pi patch
without recurrence of the trip; a future bump-bump pattern would
push it again.

First release on the new CI path that exercises:
- pinned crane install (T14, v1.15.3) - only fires on real base rebuild,
  cache-hit on base means it stays unexercised this run too
- skip promote-base-latest on cache-hit (T15, v1.15.4) - active
- update-description always-and-success-of-base wrap (v1.15.4b) -
  active, will run since base variant publishes
2026-05-21 00:09:15 +02:00
joakimp 90e5a1f5d0 AGENTS.md: documentation-drift sweep as explicit pre-commit step
Validate / docs-check (push) Successful in 8s
Validate / base-change-warning (push) Successful in 12s
Validate / validate-omos (push) Successful in 4m20s
Validate / validate-omos-with-pi (push) Successful in 7m32s
Validate / validate-base (push) Successful in 9m25s
Validate / validate-with-pi (push) Failing after 14m46s
Companion to the same addition in the cloud-init and ansible repos.
Caught real drift in those repos in a recent session only because
the user explicitly asked. Codify the sweep with concrete, repo-
specific drift hotspots rather than a vague 'watch for drift' rule
that gets ignored.

Each AGENTS.md addition lists the doc files most likely to fall
behind code changes here, plus a quick-triage one-liner using
'git diff --name-only HEAD | xargs grep -l ...' so the rule is
actionable not aspirational.
2026-05-20 23:11:57 +02:00
joakimp b6e4d89a2c ci: filter __pycache__ and macOS metadata from base hash compute
Validate / docs-check (push) Successful in 14s
Validate / base-change-warning (push) Successful in 18s
Validate / validate-omos (push) Successful in 4m34s
Validate / validate-omos-with-pi (push) Successful in 4m57s
Validate / validate-with-pi (push) Successful in 6m9s
Validate / validate-base (push) Successful in 14m48s
Defensive against local-vs-CI hash divergence. `find rootfs -type f`
includes gitignored junk like rootfs/__pycache__/*.pyc and macOS
.DS_Store/._AppleDouble files, which CI's clean checkout never sees.

This bit us during v1.15.4 debugging when a stale generate-config.cpython-314.pyc
on the local rootfs/ produced base-3605aa6b6ab1 while CI computed
base-35ee5fe7861a. Took meaningful time to track down because git status
doesn't surface gitignored files.

Verified: same filter applied to current clean tree still produces
35ee5fe7861a (the published v1.15.4b base digest).
2026-05-20 22:45:27 +02:00
joakimp 8f2c9f5112 v1.15.4b: omos-with-pi threshold bump + update-description partial-publish fix
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 20s
Validate / validate-base (push) Successful in 3m36s
Publish Docker Image / base-decide (push) Successful in 13s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-with-pi (push) Successful in 4m14s
Validate / validate-omos (push) Successful in 7m1s
Publish Docker Image / smoke-base (push) Successful in 3m37s
Publish Docker Image / smoke-omos (push) Successful in 4m39s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 5m7s
Publish Docker Image / smoke-with-pi (push) Successful in 6m24s
Validate / validate-omos-with-pi (push) Successful in 15m59s
Publish Docker Image / build-variant-base (push) Successful in 14m12s
Publish Docker Image / build-variant-omos (push) Successful in 19m29s
Publish Docker Image / build-variant-with-pi (push) Successful in 23m7s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 26m16s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 8s
Recovery for v1.15.4's partial publish (omos-with-pi exceeded 3500 MB
smoke threshold; other 3 variants published cleanly). Two changes:

1. omos-with-pi threshold 3500 -> 3700 MB. Compounded growth from
   opencode 1.15.0 -> 1.15.4 (4 patch versions) plus pi 0.74.0 -> 0.75.3
   (minor + 3 patches) summed in the omos-with-pi variant, just over
   the existing limit. Same pattern as prior threshold bumps (v1.14.31c,
   v1.15.0b). Restores ~150 MB headroom for routine apt-upgrade drift.

2. update-description workflow bug fix. Pre-existing latent bug exposed
   by v1.15.4's partial publish: update-description.needs includes all 4
   build-variant-* jobs, and gitea Actions' default behavior is
   'skipped need => skip dependent' \u2014 even when the job's own if:
   condition is satisfied. So when build-variant-omos-with-pi was
   skipped (because its smoke failed), update-description cascaded into
   a skip too, and Hub description didn't refresh on v1.15.4 despite
   3 variants publishing.

   Fix: wrap if: in always() + explicit success check on the base
   variant. Same fix applied to promote-base-latest preemptively (it
   has the same latent bug, currently masked by the cache-hit gate).

No image-side changes \u2014 cache hit on base-35ee5fe7861a.
2026-05-18 22:30:59 +02:00
joakimp 60eb49469e v1.15.4: bump opencode 1.15.3 -> 1.15.4
Publish Docker Image / base-decide (push) Successful in 14s
Validate / docs-check (push) Successful in 8s
Validate / base-change-warning (push) Successful in 7s
Validate / validate-base (push) Successful in 3m30s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-with-pi (push) Successful in 4m12s
Validate / validate-omos (push) Successful in 7m8s
Validate / validate-omos-with-pi (push) Successful in 5m7s
Publish Docker Image / smoke-omos (push) Successful in 4m23s
Publish Docker Image / smoke-base (push) Successful in 8m17s
Publish Docker Image / smoke-with-pi (push) Successful in 6m24s
Publish Docker Image / smoke-omos-with-pi (push) Failing after 11m14s
Publish Docker Image / build-variant-base (push) Successful in 14m38s
Publish Docker Image / build-variant-omos-with-pi (push) Has been skipped
Publish Docker Image / build-variant-omos (push) Successful in 19m40s
Publish Docker Image / build-variant-with-pi (push) Successful in 19m49s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Bundles with the CI hardening landed on main since v1.15.3 (T14/T15 in
the operator backlog):

- Pinned crane install in promote-base-latest (replaces flaky
  imjasonh/setup-crane@v0.4 that depends on api.github.com/releases/latest
  at runtime and periodically rate-limits)
- Skip promote-base-latest on cache-hit base builds (need_build='false')

These will be exercised on this release run \u2014 if the base hash hasn't
drifted since v1.15.3 (likely cache hit), promote-base-latest should
SKIP rather than RUN, and update-description picks up the new tag.
2026-05-18 21:51:15 +02:00
joakimp 18b9c9c549 CI: harden promote-base-latest (pinned crane + skip on cache-hit)
Validate / docs-check (push) Successful in 10s
Validate / base-change-warning (push) Successful in 16s
Validate / validate-with-pi (push) Successful in 4m10s
Validate / validate-omos (push) Successful in 4m34s
Validate / validate-base (push) Has been cancelled
Validate / validate-omos-with-pi (push) Has been cancelled
Two workflow-only changes for promote-base-latest, no image-side impact:

T14 \u2014 replace imjasonh/setup-crane@v0.4 with direct pinned crane install.
The action's bootstrap script calls api.github.com/.../releases/latest
at every run to discover the crane version. That call periodically
rate-limits and returns JSON without .tag_name, jq emits 'null', the
action then downloads .../releases/download/null/... \u2192 404 \u2192 'gzip:
unexpected end of file' \u2192 exit 2. We hit this on the v1.15.3 release
(2026-05-16) where it was cosmetic only \u2014 base-latest was already
correct from cache hit \u2014 but the red-X is annoying.

Replaced with curl + tar pinned to crane v0.21.6 (latest at time of
change). Same pattern as other GitHub-sourced binaries in the
Dockerfile layer (gosu, fzf, eza etc.); operator bumps CRANE_VERSION
deliberately when wanting updates.

T15 \u2014 gate promote-base-latest on need_build == 'true'. When the base
layer's content hash hasn't changed (cache hit on existing base-<hash>
from a prior run), base-latest already points at the correct digest.
The retag is a tautology, and any transient failure of it produces a
red-X for an operation that didn't need to happen. Skipping the job
entirely on cache-hit is correct and removes a whole class of cosmetic
failure. Manual workflow_dispatch with promote_latest=true still bypasses
the gate as an escape hatch (e.g., if base-latest got hand-deleted and
needs regeneration without rebuilding the base).

This will not trigger a CI publish run (main-branch commit, no tag).
2026-05-18 21:45:10 +02:00
joakimp ad4a12b3ab v1.15.3: bump opencode 1.15.0 -> 1.15.3
Validate / base-change-warning (push) Successful in 10s
Validate / docs-check (push) Successful in 17s
Validate / validate-omos (push) Successful in 4m29s
Validate / validate-with-pi (push) Successful in 4m17s
Validate / validate-omos-with-pi (push) Successful in 8m13s
Validate / validate-base (push) Successful in 8m48s
Publish Docker Image / base-decide (push) Successful in 12s
Publish Docker Image / promote-base-latest (push) Failing after 6s
Publish Docker Image / build-base (push) Has been cancelled
Publish Docker Image / smoke-base (push) Has been cancelled
Publish Docker Image / smoke-omos (push) Has been cancelled
Publish Docker Image / smoke-with-pi (push) Has been cancelled
Publish Docker Image / smoke-omos-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-base (push) Has been cancelled
Publish Docker Image / build-variant-omos (push) Has been cancelled
Publish Docker Image / build-variant-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-omos-with-pi (push) Has been cancelled
Publish Docker Image / update-description (push) Has been cancelled
2026-05-16 19:54:15 +02:00
joakimp fde5a89e8b README + DOCKER_HUB: lead with no-git-clone curl-template path
Validate / base-change-warning (push) Successful in 27s
Validate / docs-check (push) Successful in 39s
Validate / validate-omos (push) Successful in 4m39s
Validate / validate-with-pi (push) Successful in 4m14s
Validate / validate-omos-with-pi (push) Successful in 8m7s
Validate / validate-base (push) Successful in 9m50s
The previous Quick Start in both surfaces led with 'git clone',
which is overkill for users who just want to run the published image.
Match pi-devbox's pattern: lead with 'mkdir; curl docker-compose.yml;
curl .env.example; edit .env; docker compose run --rm devbox'. Keep
the git-clone path as 'for hackers/forkers'.

Required pre-step: make the gitea repo public so unauthenticated
curl to the raw URL works (done out of band — repo was private until
this commit landed).
2026-05-15 18:02:37 +02:00
joakimp 034830710c workflow: use github.ref_type directly in promote/update-description if-conditions
Validate / docs-check (push) Successful in 8s
Validate / base-change-warning (push) Successful in 10s
Validate / validate-with-pi (push) Successful in 4m23s
Validate / validate-omos-with-pi (push) Successful in 5m10s
Validate / validate-omos (push) Successful in 7m5s
Validate / validate-base (push) Successful in 10m5s
Gitea Actions evaluates 'env.PROMOTE_LATEST' as empty in YAML 'if:'
contexts even though the same env var substitutes correctly in
shell run: blocks. Result: on v1.15.0/v1.15.0b tag pushes, the
build-variant-* jobs correctly pushed latest-* aliases (shell context),
but promote-base-latest and update-description got skipped (YAML
context), so the Hub README description wasn't refreshed.

Switch to evaluating github.ref_type directly in the if-conditions —
matches the production-trigger semantics and avoids the env-var
indirection that gitea evaluates inconsistently.
2026-05-15 13:50:46 +02:00
joakimp d293ddc202 v1.15.0b: bump omos smoke threshold 3200->3300, omos-with-pi 3400->3500
Validate / base-change-warning (push) Successful in 9s
Validate / docs-check (push) Successful in 18s
Validate / validate-omos (push) Successful in 4m22s
Validate / validate-with-pi (push) Successful in 4m10s
Publish Docker Image / base-decide (push) Successful in 15s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-base (push) Successful in 5m20s
Publish Docker Image / smoke-base (push) Successful in 3m34s
Publish Docker Image / smoke-with-pi (push) Successful in 4m12s
Publish Docker Image / smoke-omos (push) Successful in 7m2s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 4m58s
Validate / validate-omos-with-pi (push) Successful in 17m33s
Publish Docker Image / build-variant-base (push) Successful in 14m18s
Publish Docker Image / build-variant-with-pi (push) Successful in 19m22s
Publish Docker Image / build-variant-omos (push) Successful in 18m50s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 31m58s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
opencode 1.15.0 grew the omos image to 3206 MB, 6 MB over the existing
3200 MB threshold, causing smoke-omos to fail and build-variant-omos
to be skipped in v1.15.0. Bump thresholds with ~100 MB headroom for
routine apt-get upgrade drift.

No image-side changes — pure smoke threshold update. v1.15.0b will hit
the base hash cache and run only the variant deltas.
2026-05-15 10:35:08 +02:00
joakimp 910378fe06 v1.15.0: opencode bump + git clone retry + pi-devbox sibling mention
Validate / docs-check (push) Successful in 11s
Validate / base-change-warning (push) Successful in 56s
Publish Docker Image / base-decide (push) Successful in 17s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-base (push) Successful in 3m23s
Publish Docker Image / smoke-base (push) Successful in 3m34s
Validate / validate-omos (push) Successful in 6m52s
Publish Docker Image / smoke-with-pi (push) Successful in 4m10s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 4m58s
Validate / validate-omos-with-pi (push) Failing after 10m27s
Validate / validate-with-pi (push) Failing after 10m38s
Publish Docker Image / smoke-omos (push) Failing after 9m35s
Publish Docker Image / build-variant-omos (push) Has been skipped
Publish Docker Image / build-variant-base (push) Successful in 15m36s
Publish Docker Image / build-variant-with-pi (push) Successful in 16m52s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 22m5s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
- Bump OPENCODE_VERSION 1.14.50 -> 1.15.0 in Dockerfile.variant.
- Wrap pi-toolkit/pi-extensions git clone in Dockerfile.variant in a
  5-attempt retry loop with linear backoff (matches pi-devbox pattern).
  gitea.jordbo.se occasionally returns transient HTTP 500s that
  previously broke with-pi/omos-with-pi variant builds.
- Add 'Sibling images' section to DOCKER_HUB.md mentioning
  joakimp/pi-devbox as the pi-only counterpart.
- CHANGELOG entry for v1.15.0 with full notes.
2026-05-15 09:56:01 +02:00
joakimp f06a70a3bc v1.14.50c: tag-only retag to recover v1.14.50b's missing variants
Publish Docker Image / base-decide (push) Successful in 1m12s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-omos (push) Successful in 4m39s
Publish Docker Image / smoke-with-pi (push) Successful in 7m22s
Publish Docker Image / smoke-base (push) Successful in 8m1s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 10m56s
Publish Docker Image / build-variant-omos (push) Failing after 12m49s
Publish Docker Image / build-variant-with-pi (push) Successful in 17m0s
Publish Docker Image / build-variant-base (push) Successful in 17m33s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 26m46s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
CHANGELOG entry for v1.14.50c with full postmortem on the v1.14.50/50b
runner-fleet incident (AVX shadowing + containerd race + Proxmox CPU
default + base-latest auto-promote gap). No container-side code changes
\u2014 the rebuild on the now-healthy fleet is sufficient.
2026-05-14 23:32:46 +02:00
joakimp dba05da7d1 validate.yml: use Hub base-latest as variant parent + warn on base-input changes
Validate / docs-check (push) Successful in 9s
Validate / base-change-warning (push) Successful in 11s
Validate / validate-base (push) Failing after 21s
Validate / validate-omos (push) Failing after 1m49s
Validate / validate-with-pi (push) Failing after 1m46s
Validate / validate-omos-with-pi (push) Failing after 13m9s
The previous two-step approach (build Dockerfile.base \ then
Dockerfile.variant FROM the local image) doesn't work: each
docker/build-push-action@v7 invocation runs in its own buildx
container context, and an image loaded into the host docker daemon
by step N is not visible to step N+1's buildx invocation.

Variant builds in validate.yml now FROM joakimp/opencode-devbox:base-latest
on Docker Hub, matching the production smokes' parent. Trade-off:
PRs/pushes that change Dockerfile.base, rootfs/, or entrypoint*.sh
are not exercised here \u2014 only release tags rebuild the base via
docker-publish-split.yml.

The new base-change-warning job surfaces a runtime warning when a
commit modifies any base-image input, telling the author to run a
workflow_dispatch test if they want full validation before merging.
2026-05-14 20:53:19 +02:00
joakimp 8359fef949 Force fresh base rebuild for v1.14.50b
Validate / validate-base (push) Failing after 17s
Validate / docs-check (push) Successful in 20s
Publish Docker Image / base-decide (push) Successful in 13s
Validate / validate-with-pi (push) Failing after 2m57s
Validate / validate-omos (push) Failing after 9m29s
Validate / validate-omos-with-pi (push) Failing after 14m25s
Publish Docker Image / build-base (push) Successful in 13m58s
Publish Docker Image / smoke-omos-with-pi (push) Failing after 27s
Publish Docker Image / build-variant-omos-with-pi (push) Has been skipped
Publish Docker Image / smoke-omos (push) Failing after 1m51s
Publish Docker Image / build-variant-omos (push) Has been skipped
Publish Docker Image / smoke-base (push) Successful in 9m1s
Publish Docker Image / smoke-with-pi (push) Successful in 11m52s
Publish Docker Image / build-variant-base (push) Successful in 17m50s
Publish Docker Image / build-variant-with-pi (push) Failing after 16m10s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Add BASE_REBUILD_DATE comment to Dockerfile.base to invalidate the
content hash and trigger a full base rebuild. Picks up ~5 days of
Debian trixie security updates since the previous base-bf9df274db7a
was built on 2026-05-09.

The comment also documents the pattern for future intentional
base-rebuilds without other code changes — recommended cadence is
once per release for security currency.

Required because v1.14.50 hash inputs were unchanged from v1.14.44,
hitting the existing base-bf9df274db7a cache and shipping stale apt
packages. v1.14.50 also failed mid-flight before promote-base-latest
could publish base-latest to Hub — pi-devbox and other downstream
images that FROM base-latest were blocked.
2026-05-14 20:17:37 +02:00
joakimp a438c67f06 fix: update validate.yml for split-base Dockerfiles
Validate / validate-omos (push) Failing after 20s
Validate / docs-check (push) Successful in 22s
Validate / validate-with-pi (push) Failing after 3m8s
Validate / validate-omos-with-pi (push) Failing after 3m6s
Validate / validate-base (push) Failing after 14m57s
Replace single-Dockerfile build with two-step: build Dockerfile.base
first (loads as opencode-devbox:validate-base), then build
Dockerfile.variant with BASE_IMAGE pointing at the local base image.
All four validate jobs updated.
2026-05-14 19:48:46 +02:00
joakimp 07e07ec611 Bump opencode 1.14.44 -> 1.14.50; cut over to split-base pipeline
Validate / validate-omos-with-pi (push) Waiting to run
Validate / docs-check (push) Successful in 1m7s
Validate / validate-with-pi (push) Failing after 3m16s
Validate / validate-omos (push) Failing after 3m15s
Validate / validate-base (push) Failing after 6m31s
Publish Docker Image / base-decide (push) Failing after 11m59s
Publish Docker Image / build-base (push) Has been cancelled
Publish Docker Image / smoke-base (push) Has been cancelled
Publish Docker Image / smoke-omos (push) Has been cancelled
Publish Docker Image / smoke-with-pi (push) Has been cancelled
Publish Docker Image / smoke-omos-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-base (push) Has been cancelled
Publish Docker Image / build-variant-omos (push) Has been cancelled
Publish Docker Image / build-variant-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-omos-with-pi (push) Has been cancelled
Publish Docker Image / promote-base-latest (push) Has been cancelled
Publish Docker Image / update-description (push) Has been cancelled
- Bump OPENCODE_VERSION 1.14.44 -> 1.14.50 in Dockerfile.variant
- Cut over: docker-publish-split.yml now triggers on push: tags: v*
  (was workflow_dispatch only). RELEASE_TAG and PROMOTE_LATEST derived
  from github.ref_type/ref_name for tag-push; inputs still available
  for manual workflow_dispatch runs.
- Delete docker-publish.yml (retired, replaced by split-base pipeline)
- Delete Dockerfile (retired, replaced by Dockerfile.base + Dockerfile.variant)
- Update CHANGELOG: promote Unreleased -> v1.14.50
- Update AGENTS.md, .gitea/README.md, validate.yml: remove all references
  to the old single-Dockerfile pipeline and WIP migration plan
2026-05-14 19:39:45 +02:00
joakimp 7dc836ab66 fix: replace echo -e heredoc with brace-block in build-variant tags steps
Validate / docs-check (push) Successful in 13s
Validate / validate-base (push) Successful in 12m21s
Validate / validate-omos (push) Successful in 18m38s
Validate / validate-with-pi (push) Successful in 13m23s
Validate / validate-omos-with-pi (push) Successful in 16m34s
echo -e doesn't interpret \n in /bin/sh (dash), which is the default
shell in catthehacker/ubuntu:act-latest. This caused steps.tags.outputs.tags
to be empty, resulting in 'tag is needed when pushing to registry' from buildx.

Also fixes a secondary bug: TAGS='${TAGS}\n...' stored a literal backslash-n
rather than a real newline, which would have broken multi-tag output when
promote_latest=true.

Fix: replace with a brace block using plain echo, which produces actual newlines
and works in both sh and bash.
2026-05-10 11:59:04 +02:00
joakimp a3ff601bf0 Bump opencode 1.14.42 -> 1.14.44; close v1.14.42 omos-with-pi gap
Validate / docs-check (push) Successful in 18s
Validate / validate-base (push) Successful in 11m46s
Validate / validate-omos (push) Successful in 13m32s
Validate / validate-with-pi (push) Successful in 13m20s
Validate / validate-omos-with-pi (push) Successful in 19m23s
Publish Docker Image / smoke-base (push) Successful in 11m46s
Publish Docker Image / smoke-omos (push) Successful in 13m45s
Publish Docker Image / smoke-with-pi (push) Successful in 13m13s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 15m47s
Publish Docker Image / build-base (push) Successful in 42m18s
Publish Docker Image / build-omos (push) Successful in 52m1s
Publish Docker Image / build-with-pi (push) Successful in 46m28s
Publish Docker Image / build-omos-with-pi (push) Successful in 54m36s
Publish Docker Image / update-description (push) Successful in 14s
opencode-ai 1.14.44 published 20:26 UTC (1.14.43 skipped upstream).
Bumping to 1.14.44 instead of re-running the failed v1.14.42 build
gives us the same 3h CI cost and picks up upstream bug fixes.

Closes the v1.14.42 omos-with-pi gap. The v1.14.42 tag's
build-omos-with-pi job failed during publish: oh-my-opencode-slim@1.0.7
had been published with a dependency on @opencode-ai/sdk@1.14.44, and
our build hit the npm registry within ~2 minutes of that SDK version
landing -- before the tarball had propagated across npm's CDN. The
manifest's dist-tags.latest pointed at 1.14.44 but a tarball fetch on
/-/sdk-1.14.44.tgz returned 404. Tarball is now fully fetchable.

Result on Docker Hub once v1.14.44 publishes:
  v1.14.42 / latest                        -> stable (3 of 4 variants)
  v1.14.42-omos / latest-omos              -> stable
  v1.14.42-with-pi / latest-with-pi        -> stable
  v1.14.42-omos-with-pi                    -> NEVER PUBLISHED (404 if pulled)
  latest-omos-with-pi                      -> still v1.14.41b until v1.14.44
  v1.14.44 / latest                        -> NEW (replaces latest)
  v1.14.44-omos / latest-omos              -> NEW
  v1.14.44-with-pi / latest-with-pi        -> NEW
  v1.14.44-omos-with-pi / latest-omos-with-pi -> NEW (closes the gap)

CHANGELOG: v1.14.44 entry added with the propagation-race rationale,
v1.14.42 entry annotated with the known gap. Reverse-chrono preserved.
2026-05-09 22:33:16 +02:00
joakimp 6fde27c212 Document the build pipeline architecture in .gitea/README.md
Validate / docs-check (push) Successful in 16s
Validate / validate-base (push) Successful in 12m9s
Validate / validate-omos (push) Successful in 16m45s
Validate / validate-with-pi (push) Successful in 13m30s
Validate / validate-omos-with-pi (push) Successful in 15m15s
The split-base build architecture, the NPM_CONFIG_PREFIX gotcha, the
hash-driven base cache reuse mechanism, and the cutover plan from
docker-publish.yml to docker-publish-split.yml were previously
scattered across:
  - inline Dockerfile.base / Dockerfile.variant comments
  - CHANGELOG Unreleased entries
  - AGENTS.md mentions
  - docker-publish-split.yml header comment
  - my own session notes

Consolidate into .gitea/README.md as the canonical architectural doc.
Gitea (like GitHub) auto-renders this when navigating to .gitea/ in
the web UI, so anyone investigating 'why is CI shaped this way?'
finds it on the first click. Cross-referenced from AGENTS.md as the
first thing to read when touching CI.

Covers:
  - The two release pipelines and why both exist
  - Why split-base: cross-variant cache misses on layer-hash-divergence
  - The 6 phases of the split-base pipeline with an ASCII diagram
  - base-decide hash inputs and Docker Hub probe logic
  - NPM_CONFIG_PREFIX variant-override pattern (the volume-shadow trap)
  - Registry cache strategy (mode=max for cross-arch reuse)
  - Wall-clock estimates: version-bump vs base-touching releases
  - Validate workflow role
  - Runner expectations: catthehacker image, disk reclaim, concurrency,
    Gitea Actions @v4 artifact incompatibility
  - 4-step migration plan from docker-publish.yml to .split.yml
  - Cross-refs to related docs

Does not duplicate AGENTS.md content; links to it for domain facts and
release-day checklist.
2026-05-09 19:28:03 +02:00
16 changed files with 1546 additions and 1222 deletions
+314
View File
@@ -0,0 +1,314 @@
# CI / Build Pipeline
This directory contains the gitea Actions workflows and the supporting
documentation for opencode-devbox's CI. If you're investigating *why*
the build pipeline is shaped the way it is, you're in the right place.
## Workflows in this directory
| File | Trigger | Role |
|---|---|---|
| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `push: tags: v*` | **Production release pipeline.** Two-phase split-base build: shared `base-<hash>` published once (skipped on cache hit), then four parallel variant deltas. ~4080 min wall clock depending on runner count and whether base needs rebuilding. |
| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of all four variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. |
## Why the split-base pipeline exists
opencode-devbox publishes **four image variants** (`base`, `omos`, `with-pi`, `omos-with-pi`) × **two architectures** (amd64, arm64) = **eight image tags per release**. Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~35x slower than native).
The four variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build.
Two improvements were considered:
1. **Reorder the original Dockerfile** so all variant-gated RUNs land at the bottom — modest gain, ~1020% wall-clock reduction. *Not pursued.*
2. **Split into `Dockerfile.base` + `Dockerfile.variant`** with the base published as a long-lived shared image — significant gain, ~5070% wall-clock reduction with hash-driven cache reuse. *Pursued.*
The split-base architecture is what the `docker-publish-split.yml` workflow exercises.
## How the split-base pipeline works
```
┌──────────────────┐
│ base-decide │ compute base-<hash>;
│ │ probe Docker Hub.
│ hash inputs: │ (resolve-versions
│ Dockerfile.base│ runs in parallel:
│ rootfs/ │ npm view pi/omos
│ entrypoint*.sh │ → concrete versions)
└────────┬─────────┘
┌─────────────┴─────────────┐
│ need_build = true? │
└─────────────┬─────────────┘
yes │ no
┌──────────────────┐
│ build-base │ multi-arch build,
│ │ push base-<hash>
└────────┬─────────┘ to Docker Hub.
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│smoke-base│ │smoke-omos│ ... │smoke-omos-pi │ amd64 only,
└────┬─────┘ └────┬─────┘ └──────┬───────┘ parallel.
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│build- │ │build- │ │build- │ multi-arch,
│variant- │ │variant- │ ... │variant- │ parallel,
│base │ │omos │ │omos-with-pi │ tag push.
└────┬─────┘ └────┬─────┘ └──────┬───────┘
└───────────────────────┴──────────────────────┘
┌──────────────────────────┐
│ promote-base-latest │ crane copy
│ │ base-<hash>
│ │ → base-latest
└────────┬─────────────────┘
┌──────────────────────────┐
│ update-description │
└──────────────────────────┘
```
### Step 1: `base-decide` (and `resolve-versions` in parallel)
**`base-decide`** computes a SHA-256 hash over the inputs that determine
the base image's content:
```sh
{
cat Dockerfile.base
find rootfs -type f \
! -path '*/__pycache__/*' \
! -name '*.pyc' \
! -name '.DS_Store' \
! -name '._*' \
-print0 | sort -z | xargs -0 cat
cat entrypoint.sh entrypoint-user.sh
} | sha256sum | cut -c1-12
```
Junk filters keep the local recompute reproducible against CI's clean
checkout — `__pycache__/*.pyc` and macOS metadata files (`.DS_Store`,
`._AppleDouble`) are gitignored but still walked by `find -type f`.
The 12-character truncated hash becomes `base-<hash>`. Probe Docker Hub
for this tag via `docker manifest inspect`:
- If it exists → set `need_build=false`. `build-base` is skipped entirely.
- If it doesn't → set `need_build=true`. `build-base` runs.
This is the core cache-reuse mechanism. Version-bump-only releases
(only `Dockerfile.variant` or build-args changed) hit the cache. 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
`Dockerfile.base`, pushed to `joakimp/opencode-devbox:base-<hash>`.
Registry cache via `--cache-from/--cache-to` reduces incremental rebuilds
when only one or two layers changed.
The base image is **not** tagged `base-latest` here — that promotion
happens at the very end after all variants succeed (see step 5).
### Step 3: `smoke-*` (×4, parallel)
For each variant: build amd64-only against the base tag, load into
local docker, run [`scripts/smoke-test.sh`](../scripts/smoke-test.sh).
Variant build-args:
| variant | INSTALL_OPENCODE | INSTALL_OMOS | INSTALL_PI |
|---|---|---|---|
| `base` | true | false | false |
| `omos` | true | true | false |
| `with-pi` | true | false | true |
| `omos-with-pi` | true | true | true |
Smoke runs `--variant <name>` to enable variant-specific assertions.
Gate the publish: a smoke failure for variant X blocks `build-variant-X`.
### Step 4: `build-variant-*` (×4, parallel)
For each variant that passed smoke: multi-arch (amd64 + arm64) build of
`Dockerfile.variant`, pushed to Docker Hub with the user-facing release
tags:
| Build job | Tags pushed |
|---|---|
| `build-variant-base` | `vX.Y.Z`, `latest` |
| `build-variant-omos` | `vX.Y.Z-omos`, `latest-omos` |
| `build-variant-with-pi` | `vX.Y.Z-with-pi`, `latest-with-pi` |
| `build-variant-omos-with-pi` | `vX.Y.Z-omos-with-pi`, `latest-omos-with-pi` |
The `latest*` aliases are only updated when `promote_latest=true` (the
manual dispatch input) — for test runs, `promote_latest=false` keeps the
production aliases pointing at the previous good release.
### Step 5: `promote-base-latest`
Once all four variants successfully publish, re-tag `base-<hash>` as
`base-latest` using `crane copy`. This is a **manifest-level re-tag, not
a rebuild** — it touches only Docker Hub's image index, takes seconds,
and is atomic.
The reason this happens *after* variants succeed (rather than alongside
`build-base`) is so a partial failure leaves `base-latest` pointing at
the previous known-good base. External consumers who pin to
`base-latest` (e.g. the planned pi-devbox repo) never see a broken base.
### Step 6: `update-description`
Push the generated `DOCKER_HUB.md` to the Hub repo's `full_description`
field via the Hub REST API. Same step as the production pipeline.
## NPM_CONFIG_PREFIX gotcha (variant override pattern)
The base sets
```
ENV NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global
```
This is intentional — it makes `pi install npm:<pkg>` and `npm install -g`
land on the `devbox-pi-config` named volume at runtime, so user-installed
packages survive container recreate AND image rebuild.
But the *variant build* inherits this prefix at build time. If left as-is,
`npm install -g opencode-ai@$VERSION` in `Dockerfile.variant` would
install opencode into `/home/developer/.pi/npm-global/...`, which is then
**shadowed by the volume mount at runtime** → opencode disappears from
PATH on first start.
Fix: each `npm install -g` in `Dockerfile.variant` overrides the prefix
per-RUN:
```dockerfile
RUN NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION}
```
Baked binaries land on `/usr/bin/...` (system prefix), survive the volume
mount. Runtime-installed user packages still land on
`~/.pi/npm-global/...`. Both visible on PATH.
## Cache strategy
Two registry caches are configured:
```yaml
cache-from: type=registry,ref=joakimp/opencode-devbox:base-buildcache
cache-to: type=registry,ref=joakimp/opencode-devbox:base-buildcache,mode=max
cache-from: type=registry,ref=joakimp/opencode-devbox:base-variant-buildcache
cache-to: type=registry,ref=joakimp/opencode-devbox:base-variant-buildcache,mode=max
```
`mode=max` exports cache for *all* layers, not just the final image's
layers. Important for multi-arch builds where the cross-arch layer reuse
matters more.
## Wall-clock estimates
| Scenario | Production pipeline | Split-base pipeline |
|---|---|---|
| Version-bump-only release (only opencode/pi/omos version changed) | ~165180 min | **~3040 min** (base cache hit) |
| Base-touching release (apt/Node/Debian/entrypoint change) | ~165180 min | **~7090 min** (base rebuilds) |
The split-base pipeline pays its dues on base-touching releases (which are
infrequent — a few times a year for Debian / Node major version bumps).
Most releases are version-bumps and ride the cache.
## Validate workflow
[`validate.yml`](workflows/validate.yml) is the lightweight gate that runs
on every push to `main` and on PRs. It:
1. Runs `scripts/generate-dockerhub-md.py --check` to enforce
`DOCKER_HUB.md` is in sync with `HUB_TEMPLATE`.
2. Builds each of the four variants amd64-only (no multi-arch, no push)
and runs `scripts/smoke-test.sh`.
This catches regressions before they reach a tag push. Wall clock ~30 min.
## Runner expectations
- **Image:** `catthehacker/ubuntu:act-latest`. Each job runs inside a
fresh container of this image. Don't assume any pre-installed
toolchains beyond what catthehacker ships.
- **Disk pressure:** the runner host has ~40 GB of usable overlay space,
often 70%+ used at job start. Every job that does `load: true` (smoke)
starts with a `Reclaim runner disk` step that strips
catthehacker-resident toolchains (Android SDK, .NET, Swift, GHC, JVM,
Boost, Chromium, PowerShell) and prunes stale docker state. Don't
remove these steps without testing on a fresh runner.
- **Concurrency:** 2 runners. Jobs in the same workflow run can fan out to
both; jobs in *different* workflow runs are serialized by gitea's queue.
The `concurrency: { group: ${{ workflow }}-${{ ref }}, cancel-in-progress: false }`
setting keeps tag pushes from racing each other but allows
per-PR/per-branch parallelism.
- **Workflow visibility in UI:** gitea Actions only surfaces workflows
from the **default branch** in the web UI's workflow list, even for
`workflow_dispatch` triggers. Workflows on feature branches are
invisible until merged to `main`.
- **Disk reclaim quirk:** `actions/{upload,download}-artifact@v4+` does
not work on Gitea (depends on a GitHub-only Artifact API). Stick to
`@v3` if matrix-fanout-with-artifacts is ever needed. We avoided this
by using `docker/build-push-action@v7` with comma-separated
`platforms: linux/amd64,linux/arm64` — natively does multi-arch push
in a single job, no artifact dance.
## Migration plan: split-base → production
1. **Validate the split-base dispatch.** Trigger
`docker-publish-split.yml` manually with `release_tag=v0.0.0-split-test`
and `promote_latest=false`. Confirm all jobs go green, image sizes
match the production baseline within ~10%, and no unexpected layer
rebuilds appear in `build-variant-*` logs after the FROM line.
2. **Run a second dispatch** to confirm cache-hit behavior:
`base-decide` should set `need_build=false`, `build-base` should be
skipped entirely, total wall clock should drop to ~2540 min.
3. **Cut over***done as of v1.14.50.* `docker-publish-split.yml` now
triggers on `push: tags: v*`. `docker-publish.yml` and original
`Dockerfile` deleted.
4. **Tag a release.** First production release on the new pipeline.
## Related docs
- [`AGENTS.md`](../AGENTS.md) — domain facts, release-day checklist,
documentation coupling rules. Read first when modifying CI behavior.
- [`CHANGELOG.md`](../CHANGELOG.md) — build pipeline rewrite landed in v1.14.50.
- `Dockerfile.base`, `Dockerfile.variant` — the split-base Dockerfiles.
Comments at the top of each explain their role.
- [`scripts/smoke-test.sh`](../scripts/smoke-test.sh) — invoked by all
three workflows; this is the single source of truth for "what does a
built image have to satisfy".
- [`scripts/generate-dockerhub-md.py`](../scripts/generate-dockerhub-md.py)
— generates `DOCKER_HUB.md` from `HUB_TEMPLATE`. `--check` enforces
sync in `validate.yml`.
+333 -109
View File
@@ -1,13 +1,7 @@
name: Publish Docker Image (split-base)
name: Publish Docker Image
# Two-phase split-base build pipeline. Lives ALONGSIDE the original
# docker-publish.yml during the migration window. Triggers only on
# workflow_dispatch (manual) so it doesn't conflict with the production
# tag-trigger pipeline.
#
# Once we've validated 1-2 successful runs and verified output
# byte-for-byte against the original, this workflow takes over `on:
# push: tags: v*` and the original is retired.
# Two-phase split-base build pipeline. Replaces the original
# docker-publish.yml single-Dockerfile pipeline.
#
# Pipeline shape:
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
@@ -22,14 +16,17 @@ name: Publish Docker Image (split-base)
# 6. update-description patch Docker Hub description (unchanged).
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag to publish (e.g. v1.14.42-split). Pushed to Docker Hub as both the literal tag and `latest*`-aliases.'
required: true
default: 'v0.0.0-split-test'
description: 'Release tag to publish (e.g. v1.14.50). Used only for workflow_dispatch runs — tag-triggered runs derive the tag from github.ref.'
required: false
default: ''
promote_latest:
description: 'Update latest/latest-omos/latest-with-pi/latest-omos-with-pi aliases (set false for test runs)'
description: 'Update latest/* aliases (default true for tag-push, set false for manual test runs)'
required: false
default: 'false'
@@ -40,6 +37,8 @@ concurrency:
env:
BUILDKIT_PROGRESS: plain
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }}
# ───────────────────────────────────────────────────────────────────
# Reusable disk-reclaim snippet — strips catthehacker toolchains and
@@ -64,10 +63,19 @@ jobs:
run: |
# Hash inputs that determine the base image's contents.
# Order is fixed via `find -print0 | sort -z` for reproducibility.
# Junk filters: __pycache__/*.pyc and macOS metadata (.DS_Store,
# ._AppleDouble) are gitignored locally but still picked up by
# `find rootfs -type f`, which would diverge the local hash from
# CI's clean checkout. Exclude them defensively here.
HASH=$(
{
cat Dockerfile.base
find rootfs -type f -print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
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
)
@@ -94,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]
@@ -140,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
@@ -203,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:
@@ -241,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:
@@ -282,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:
@@ -323,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 ────────────────────────
@@ -354,27 +439,51 @@ jobs:
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ inputs.release_tag }}"
TAGS="${IMAGE}:${VERSION}"
if [ "${{ inputs.promote_latest }}" = "true" ]; then
TAGS="${TAGS}\n${IMAGE}:latest"
fi
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$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 }}
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push variant (with retry)
shell: bash
env:
TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
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
@@ -400,27 +509,52 @@ jobs:
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ inputs.release_tag }}"
TAGS="${IMAGE}:${VERSION}-omos"
if [ "${{ inputs.promote_latest }}" = "true" ]; then
TAGS="${TAGS}\n${IMAGE}:latest-omos"
fi
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$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 }}
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-omos"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-omos"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push variant (with retry)
shell: bash
env:
TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
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
@@ -446,27 +580,52 @@ jobs:
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ inputs.release_tag }}"
TAGS="${IMAGE}:${VERSION}-with-pi"
if [ "${{ inputs.promote_latest }}" = "true" ]; then
TAGS="${TAGS}\n${IMAGE}:latest-with-pi"
fi
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$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 }}
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-with-pi"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-with-pi"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push variant (with retry)
shell: bash
env:
TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
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
@@ -492,24 +651,51 @@ jobs:
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ inputs.release_tag }}"
TAGS="${IMAGE}:${VERSION}-omos-with-pi"
if [ "${{ inputs.promote_latest }}" = "true" ]; then
TAGS="${TAGS}\n${IMAGE}:latest-omos-with-pi"
fi
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$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 }}
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-omos-with-pi"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-omos-with-pi"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push variant (with retry)
shell: bash
env:
TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
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:
@@ -519,12 +705,40 @@ jobs:
- build-variant-omos
- build-variant-with-pi
- build-variant-omos-with-pi
if: inputs.promote_latest == 'true'
# Skip on cache-hit base builds: when need_build=false, base-latest
# already points at the same digest as base-<hash>, so the retag is
# a tautology and any transient failure of it is purely cosmetic.
# Manual workflow_dispatch with promote_latest=true overrides this
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
#
# `always()` wrapper + explicit base-variant success check protects
# against the gitea-Actions default of "skipped need => skip dependent":
# a partial-publish run (e.g., omos-with-pi smoke fails) shouldn't
# prevent the base-latest alias from advancing on a real base rebuild.
if: |
always() &&
needs.build-variant-base.result == 'success' &&
(inputs.promote_latest == 'true' ||
(github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true'))
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: imjasonh/setup-crane@v0.4
# Direct pinned install instead of imjasonh/setup-crane@v0.4. The
# action's bootstrap script calls api.github.com/.../releases/latest
# to discover the crane version, which periodically rate-limits and
# produces tag=null → download from .../download/null/... → 404 →
# 'gzip: unexpected end of file' → exit 2. Pinning removes the
# runtime dependency on GitHub API entirely. Bump CRANE_VERSION
# deliberately when you want updates.
- name: Install crane (pinned)
env:
CRANE_VERSION: v0.21.6
run: |
set -eux
curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
| tar -xz -C /usr/local/bin crane
crane version
- name: Login (crane)
run: |
crane auth login docker.io \
@@ -543,7 +757,17 @@ jobs:
- build-variant-omos
- build-variant-with-pi
- build-variant-omos-with-pi
if: inputs.promote_latest == 'true'
# Run when at least the base variant published — don't let a single
# variant failure (e.g., omos-with-pi smoke threshold) prevent Hub
# description refresh for the other variants that did publish.
# Without this `always()` wrapper, gitea Actions' default behavior
# of "skipped need => skip dependent" cascades from any failed/
# skipped build-variant-* into update-description, and the Hub
# description goes stale on partial-publish releases.
if: |
always() &&
needs.build-variant-base.result == 'success' &&
(github.ref_type == 'tag' || inputs.promote_latest == 'true')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
-581
View File
@@ -1,581 +0,0 @@
name: Publish Docker Image
on:
push:
tags:
- 'v*'
# Serialize concurrent runs of the same workflow on the same ref so the
# build jobs can't race `docker system prune` in the smoke gates
# (pruning from one job can nuke another job's in-flight buildx cache).
# cancel-in-progress: false — tag pushes are release events, we never
# want to silently drop one.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
# Plain progress output from BuildKit — critical for diagnosing stalls
# inside arm64-under-QEMU builds where the default collapsed progress UI
# hides which step is stuck.
env:
BUILDKIT_PROGRESS: plain
# Runner disk pressure notes:
# Gitea Actions runners use `catthehacker/ubuntu:act-latest` on a shared host
# with limited overlay space (~40 GB, often 70%+ used at start). Two jobs
# per variant:
# * smoke gate (amd64 only, `load: true` into local dockerd for smoke
# testing) — peak disk = tarball + unpacked image + buildx cache. The
# `Reclaim runner disk` step below strips catthehacker-resident
# toolchains and prunes stale docker state before buildx starts.
# * build job (amd64 + arm64, `push-by-digest` streaming directly to
# Docker Hub, no local unpack). Peak disk on push-by-digest is
# BuildKit's content store only — much smaller than `load: true`.
# `docker/build-push-action@v7` with comma-separated platforms
# publishes a proper multi-arch manifest in one step.
#
# Why not matrix + digest artifacts?
# An earlier revision split each arch into its own matrix job and used
# `actions/upload-artifact` to pass digests to a merge job. On Gitea
# Actions, `actions/{upload,download}-artifact@v4+` fails with
# `GHESNotSupportedError` — v4 relies on a GitHub-specific Artifact
# API that Gitea doesn't implement. Rather than downgrade to @v3 (the
# last Gitea-compatible release) we collapsed back to single-job
# multi-arch push. The matrix only helps when the build literally
# cannot fit on one runner, which push-by-digest + reclaim no longer
# hits for this image.
#
# Gitea Actions gotchas baked into this file:
# * `actions/{upload,download}-artifact` must stay at @v3 on Gitea.
# * Step scripts run under /bin/sh (dash) — no bash-isms like
# ${VAR//a/b}. Use `tr` or explicit `shell: bash`.
# * `docker/build-push-action@v7` with `platforms: a,b` works for
# multi-arch push natively; no matrix/merge dance needed.
jobs:
# ── Smoke test (amd64 only, gates the push jobs) ────────────────────
smoke-base:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
# See docker-publish.yml preamble. `load: true` peak disk = tarball
# + unpacked image + buildx cache; the image now crosses the 40 GB
# runner overlay's starting headroom. Strip catthehacker-resident
# toolchains and any stale docker state up front.
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache \
/opt/microsoft \
/opt/az \
/opt/ghc \
/usr/local/.ghcup \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/powershell \
/usr/local/share/chromium \
/usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker system df || true
docker system prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build and load amd64 image for smoke test
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-base
- name: Smoke test (amd64)
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
smoke-omos:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache \
/opt/microsoft \
/opt/az \
/opt/ghc \
/usr/local/.ghcup \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/powershell \
/usr/local/share/chromium \
/usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker system df || true
docker system prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build and load amd64 image for smoke test
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: false
load: true
build-args: |
INSTALL_OMOS=true
tags: opencode-devbox:smoke-omos
- name: Smoke test (amd64)
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
smoke-with-pi:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache \
/opt/microsoft \
/opt/az \
/opt/ghc \
/usr/local/.ghcup \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/powershell \
/usr/local/share/chromium \
/usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker system df || true
docker system prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build and load amd64 image for smoke test
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: false
load: true
build-args: |
INSTALL_PI=true
tags: opencode-devbox:smoke-with-pi
- name: Smoke test (amd64)
run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
smoke-omos-with-pi:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache \
/opt/microsoft \
/opt/az \
/opt/ghc \
/usr/local/.ghcup \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/powershell \
/usr/local/share/chromium \
/usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker system df || true
docker system prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build and load amd64 image for smoke test
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: false
load: true
build-args: |
INSTALL_OMOS=true
INSTALL_PI=true
tags: opencode-devbox:smoke-omos-with-pi
- name: Smoke test (amd64)
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
# ── Multi-arch push (single job per variant, comma-separated platforms) ─
build-base:
runs-on: ubuntu-latest
needs: smoke-base
timeout-minutes: 90
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
# Lighter reclaim than the smoke-gate version: push-by-digest
# doesn't write to host dockerd, so `docker system prune` adds
# little. BuildKit cache from prior runs is the thing to clear.
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache \
/opt/microsoft \
/opt/az \
/opt/ghc \
/usr/local/.ghcup \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/powershell \
/usr/local/share/chromium \
/usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker builder prune -af || true
df -h / || true
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build and push (multi-arch)
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest
build-omos:
runs-on: ubuntu-latest
needs: smoke-omos
timeout-minutes: 90
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache \
/opt/microsoft \
/opt/az \
/opt/ghc \
/usr/local/.ghcup \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/powershell \
/usr/local/share/chromium \
/usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker builder prune -af || true
df -h / || true
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build and push (multi-arch)
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
build-args: |
INSTALL_OMOS=true
tags: |
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-omos
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-omos
build-with-pi:
runs-on: ubuntu-latest
needs: smoke-with-pi
timeout-minutes: 90
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache \
/opt/microsoft \
/opt/az \
/opt/ghc \
/usr/local/.ghcup \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/powershell \
/usr/local/share/chromium \
/usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker builder prune -af || true
df -h / || true
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build and push (multi-arch)
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
build-args: |
INSTALL_PI=true
tags: |
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-with-pi
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-with-pi
build-omos-with-pi:
runs-on: ubuntu-latest
needs: smoke-omos-with-pi
timeout-minutes: 90
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache \
/opt/microsoft \
/opt/az \
/opt/ghc \
/usr/local/.ghcup \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/powershell \
/usr/local/share/chromium \
/usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker builder prune -af || true
df -h / || true
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build and push (multi-arch)
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
build-args: |
INSTALL_OMOS=true
INSTALL_PI=true
tags: |
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-omos-with-pi
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-omos-with-pi
update-description:
runs-on: ubuntu-latest
needs: [build-base, build-omos, build-with-pi, build-omos-with-pi]
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Update Docker Hub description
run: |
TOKEN=$(curl -s -X POST https://hub.docker.com/v2/auth/token \
-H "Content-Type: application/json" \
-d '{"identifier":"${{ vars.DOCKERHUB_USERNAME }}","secret":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
| jq -r .access_token)
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
echo "::error::Failed to authenticate with Docker Hub API"
exit 1
fi
HTTP_CODE=$(jq -n \
--rawfile full DOCKER_HUB.md \
--arg short "Portable AI dev environment for opencode. Debian-based with git, Node.js, AWS CLI, and SSH support." \
'{"full_description": $full, "description": $short}' | \
curl -s -o /tmp/hub-response.txt -w "%{http_code}" -X PATCH \
"https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox/" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @-)
echo "Docker Hub API returned: $HTTP_CODE"
if [ "$HTTP_CODE" != "200" ]; then
echo "Response body:"
cat /tmp/hub-response.txt
echo "::error::Docker Hub description update failed with HTTP $HTTP_CODE"
exit 1
fi
+54 -2
View File
@@ -2,8 +2,24 @@ name: Validate
# Lightweight validation on pushes to main. Builds single-arch (amd64),
# runs the smoke test, and checks image size — without pushing anything
# to Docker Hub. Tag pushes are handled by docker-publish.yml which
# does the full multi-arch build-and-push.
# to Docker Hub. Tag pushes are handled by docker-publish-split.yml which
# does the full multi-arch split-base build-and-push.
#
# Trade-off: variant builds here use the published `base-latest` image
# from Docker Hub as their parent, NOT a locally-built base. This is
# because `docker/build-push-action@v7` runs each invocation in its own
# buildx container context, so an image loaded into the host docker
# daemon by step N is not visible to step N+1's buildx invocation.
# Building base + variant in the same job would require either pushing
# the base to a registry or sharing a buildx instance across steps — both
# significantly more complex than just using the published base.
#
# Consequence: PRs/pushes that change Dockerfile.base, rootfs/, or
# entrypoint*.sh are NOT exercised by this workflow. The release path
# (docker-publish-split.yml on tag push) does build the new base, so
# release tags are the gate that fully validates base-image changes.
# The base-change-warning job below surfaces a runtime warning when this
# blind-spot applies.
on:
push:
@@ -34,6 +50,33 @@ jobs:
run: |
python3 scripts/generate-dockerhub-md.py --check
base-change-warning:
# Surfaces a warning when this commit changes base-image inputs
# (Dockerfile.base, rootfs/, entrypoint*.sh). validate.yml uses
# Hub's base-latest as the parent for variant builds, so changes to
# those files are NOT exercised here — only release tags rebuild the
# base via docker-publish-split.yml.
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect base-input changes
run: |
set -e
if ! git diff --name-only HEAD~1 HEAD 2>/dev/null \
| grep -qE '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'; then
echo "No base-image inputs changed in this commit — validate.yml fully exercises the published base-latest."
exit 0
fi
echo "::warning::This commit changes base-image inputs (Dockerfile.base, rootfs/, or entrypoint*.sh). validate.yml uses Hub's base-latest as the parent for variant builds, so the new base is NOT exercised by this workflow. Cut a release tag, or run a workflow_dispatch of docker-publish-split.yml against a test tag (e.g. v0.0.0-base-test, promote_latest=false) for end-to-end validation of the new base."
echo "Changed base-input files:"
git diff --name-only HEAD~1 HEAD | grep -E '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'
validate-base:
runs-on: ubuntu-latest
container:
@@ -83,9 +126,12 @@ jobs:
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
build-args: |
BASE_IMAGE=joakimp/opencode-devbox:base-latest
tags: opencode-devbox:ci-base
- name: Smoke test
@@ -137,10 +183,12 @@ jobs:
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
build-args: |
BASE_IMAGE=joakimp/opencode-devbox:base-latest
INSTALL_OMOS=true
tags: opencode-devbox:ci-omos
@@ -193,10 +241,12 @@ jobs:
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
build-args: |
BASE_IMAGE=joakimp/opencode-devbox:base-latest
INSTALL_PI=true
tags: opencode-devbox:ci-with-pi
@@ -249,10 +299,12 @@ jobs:
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
build-args: |
BASE_IMAGE=joakimp/opencode-devbox:base-latest
INSTALL_OMOS=true
INSTALL_PI=true
tags: opencode-devbox:ci-omos-with-pi
+46 -9
View File
@@ -2,12 +2,12 @@
## Project overview
Docker image packaging [opencode](https://opencode.ai) into a production-ready dev container. Two image variants (base and omos) are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfile, entrypoint scripts, docker-compose, documentation).
Docker image packaging [opencode](https://opencode.ai) into a production-ready dev container. Image variants are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfiles, entrypoint scripts, docker-compose, documentation).
## File roles
- `Dockerfile` — production single-Dockerfile build. Variants are gated by build args: `INSTALL_OMOS` (Bun + multi-agent layer), `INSTALL_OPENCODE` (default true), `INSTALL_PI` (default false), `INSTALL_MEMPALACE` (default true). All GitHub-sourced binaries are pinned with version ARGs.
- `Dockerfile.base` and `Dockerfile.variant`**WIP, branch `feat/split-build` only.** Two-Dockerfile split-base build: base contains all variant-independent layers; variant `FROM`s the base and adds only opencode/omos/pi installs. Used by `docker-publish-split.yml` (workflow_dispatch only) for parallel testing alongside the production pipeline. See CHANGELOG `Unreleased` for the migration plan and trade-offs.
- `Dockerfile.base` — variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published as `joakimp/opencode-devbox:base-<sha12>`. Rebuilt only when its content hash changes.
- `Dockerfile.variant` `FROM`s the base and adds only opencode/omos/pi installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, `INSTALL_PI`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs.
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. Volume ownership loop covers `~/.pi/` when `INSTALL_PI=true`.
- `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, skillset auto-deploy from mounted skillset repo, OMOS setup.
- `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.jsonc` from env vars. Never overwrites an existing config (checks both `.json` and `.jsonc`). Auto-registers MCP servers for detected tools (mempalace via `mempalace-mcp`, gitea-mcp, context7 remote endpoint).
@@ -15,22 +15,54 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d
- `scripts/generate-dockerhub-md.py` — generates `DOCKER_HUB.md` from a hand-maintained `HUB_TEMPLATE` constant. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow).
- `DOCKER_HUB.md`**auto-generated** from `HUB_TEMPLATE` in `scripts/generate-dockerhub-md.py`. Do not edit directly. Pushed to Docker Hub description via CI API call. Must stay under 25 kB. Short description field must be ≤100 bytes.
- `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth.
- `.gitea/README.md`**read this first** if you're touching CI. Architectural overview of the build pipeline (production vs split-base), wall-clock estimates, NPM_CONFIG_PREFIX gotcha, runner expectations, migration plan.
- `.gitea/workflows/validate.yml` — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check.
- `.gitea/workflows/docker-publish.yml` — production CI pipeline on tag push: smoke-test each variant on amd64, then full multi-arch (amd64 + arm64) build-and-push, then update Docker Hub description.
- `.gitea/workflows/docker-publish-split.yml`**WIP, branch `feat/split-build` only.** Two-phase split-base pipeline. Triggers on `workflow_dispatch` only so it runs alongside the production pipeline without conflict. Pushes to user-supplied `release_tag` input (e.g. `v0.0.0-split-test`); `latest*` aliases only updated when `promote_latest: true`. Compute base hash, conditionally build base, then 4 variant deltas in parallel.
- `.gitea/workflows/docker-publish-split.yml` — production CI pipeline on tag push (`v*`). Two-phase split-base: computes base hash, conditionally builds base, runs 4 parallel smoke tests, then 4 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description.
## Versioning scheme
Tags follow `v{opencode_version}[letter]` — e.g. `v1.14.20` for the first build on a new opencode release, and `v1.14.20b`, `v1.14.20c`, … for subsequent rebuilds on the same opencode version.
- The number tracks the opencode npm version (see `OPENCODE_VERSION` ARG in `Dockerfile`).
- The number tracks the opencode npm version (see `OPENCODE_VERSION` ARG in `Dockerfile.variant`).
- **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.
When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile` and update the comment in `.env.example` if it names a specific model/version for context.
When bumping the opencode version, bump `OPENCODE_VERSION` in `Dockerfile.variant` and update the comment in `.env.example` if it names a specific model/version for context.
## Upstream sources — where to look up release notes
When drafting a release CHANGELOG entry, pull notes from the **canonical upstream repo for each tracked package**. Getting this wrong leads to thin or wrong release notes; the image bytes are unaffected but the documentation suffers.
| Package | Canonical upstream | What you'll find there |
|---|---|---|
| `opencode-ai` (npm) | <https://github.com/anomalyco/opencode/releases> | Per-version release notes with Core / TUI / Desktop / SDK sections, contributor attributions. Some versions have empty bodies (internal/no-user-visible); most do not. |
| `@earendil-works/pi-coding-agent` (npm) | The `CHANGELOG.md` shipped inside the npm tarball: `npm pack @earendil-works/pi-coding-agent@<version>` then extract `package/CHANGELOG.md`. | Rich changelog with New Features / Added / Changed / Fixed sections per version. |
| Other floated tools (gosu, fzf, bat, eza, zoxide, uv, nvim, gitea-mcp, Go, oh-my-opencode-slim) | Each project's own GitHub releases page | Usually less material per release; quote selectively. |
**Trap to avoid:** there is a `github.com/sst/opencode` repo that some search results surface; that's a fork (and probably the historical name people associate with opencode given the upstream lineage). It does NOT track the same release timeline. Use `anomalyco/opencode` for opencode release notes.
Fetch pattern (saved here for muscle memory):
```bash
# Latest stable opencode-ai versions on npm
npm view opencode-ai time --json | python3 -c 'import sys,json,re; d=json.load(sys.stdin); print(*sorted([(v,t) for v,t in d.items() if re.fullmatch(r"\d+\.\d+\.\d+",v)], key=lambda x:x[1], reverse=True)[:6], sep="\n")'
# Release notes for a specific version
curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 | python3 -c 'import sys,json; print(json.load(sys.stdin).get("body","(empty)"))'
# pi changelog
cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil-works-pi-coding-agent-0.75.5.tgz package/CHANGELOG.md && head -40 package/CHANGELOG.md
```
## Critical conventions
@@ -43,8 +75,13 @@ When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile`
- `.env.example` must be hand-updated to match Dockerfile/entrypoint behavior — it is not auto-generated.
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.
- **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.
**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, 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.
@@ -65,7 +102,7 @@ When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile`
- **`docker/build-push-action@v7` with `platforms: linux/amd64,linux/arm64` handles multi-arch push natively in a single job** — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires `actions/{upload,download}-artifact@v4+` which Gitea Actions doesn't support (see below).
- **`actions/upload-artifact` and `actions/download-artifact` must stay at @v3 on Gitea.** v4+ uses a GitHub-Enterprise-specific Artifact API; runs fail with `GHESNotSupportedError`. If you need artifacts for a new reason (build logs, SBOMs, etc.), pin @v3 explicitly.
- **Step scripts run under `/bin/sh` (dash), not bash.** Avoid bash-isms like `${VAR//a/b}` parameter-pattern substitution; use POSIX alternatives (`tr`, `sed`) or declare `shell: bash` on the step.
- **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish.yml` so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive.
- **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish-split.yml` so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive.
## Testing changes
+335 -2
View File
@@ -8,12 +8,345 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a
## Unreleased
Build pipeline (merged to main as `Dockerfile.base` + `Dockerfile.variant` + `.gitea/workflows/docker-publish-split.yml`, NOT yet validated end-to-end — the `workflow_dispatch` test against `:base-<hash>` + `:v0.0.0-split-test*` aliases is still the gating step before this can take over `on: push: tags: v*`):
_(no changes since v1.15.12)_
- **New: split-base build pipeline.** `Dockerfile.base` (variant-independent layers — apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints) builds once and is published as `joakimp/opencode-devbox:base-<sha>`. `Dockerfile.variant` `FROM`s that base and adds only opencode/omos/pi installs (or skips them per build-args). Companion workflow `.gitea/workflows/docker-publish-split.yml` runs as a `workflow_dispatch`-only pipeline alongside the existing `docker-publish.yml` so they don't conflict. Hash-driven base reuse: a content hash of `Dockerfile.base + rootfs/ + entrypoint*.sh` becomes the base tag; if the tag already exists on Docker Hub, the base build is skipped entirely. Estimated wall clock: version-bump-only release ~3040 min (vs ~165180 min today); base-touching release ~6070 min. Trade-off: two Dockerfiles to maintain, and `npm install -g` in the variant must override `NPM_CONFIG_PREFIX=/usr` per-RUN to keep baked binaries off the volume-shadowed path. Once 12 successful workflow_dispatch runs validate the output against the existing pipeline, the new workflow takes over `on: push: tags: v*` and the original is retired.
## 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.
No image-content changes beyond the version bumps; cache hit expected on `base-35ee5fe7861a` (no `Dockerfile.base` or `rootfs/` edits since v1.14.50b).
### Notable upstream opencode changes
Sourced from <https://github.com/anomalyco/opencode/releases> (the upstream this devbox tracks).
**v1.15.7** — Grok OAuth (SuperGrok) sign-in including device-code login (@Jaaneek). V2 session APIs gain safe error responses with reference IDs (UnknownError, SessionNotFoundError, ServiceUnavailableError) so generic 500s no longer leak config details. Codex OAuth refreshes deduped to avoid repeated refresh failures (@cooper-oai). Native OpenAI OAuth requests restored. Tool schema failures now surface as friendly tool errors. PDF attachment support for Grok. Restored OpenAI reasoning streams. TUI: clearer collapsed-thinking punctuation, new sessions default to local project, single-select question checkmarks no longer collide with labels. Desktop: pinch zoom, new home view + session entry flow + titlebar, log export.
**v1.15.8** — Upstream release body empty; assumed internal/no user-visible changes.
**v1.15.9** — Redesigned diff viewer with file tree, **enabled by default**. MCP OAuth configs can set callback port and include configured scopes in client metadata (@sebin). Vertex Anthropic provider uses working `.rep.googleapis.com` endpoints for US/EU multi-region (@JPFrancoia). Many "show clearer error" improvements (default model invalid, missing PTY session, skill invocation failure, installation upgrade failure, project not found via HTTP API, MCP server not found, session busy). Native reasoning continuation metadata preserved across turns. TUI: copy worktree path from command palette, refined diff viewer shortcuts, spinner color aligned with active agent (@OpeOginni). Desktop: tab navigation in titlebar, session status in titlebar, multi-colon callback URL fix (@OpeOginni), debounced VCS refreshes.
**v1.15.10** — Single fix: restored the legacy production desktop flows for opening projects and starting sessions.
### Devbox-side notes
- **Bump:** opencode 1.15.6 → 1.15.10 (`OPENCODE_VERSION` in `Dockerfile.variant`).
- **Implicit pi bump:** `with-pi` and `omos-with-pi` variants pick up pi 0.75.5 (one patch release with cleaner read-tool cards, async file tools, more reliable package updates, Bedrock token cap fix, etc.). See [pi-devbox v0.75.5 CHANGELOG](https://gitea.jordbo.se/joakimp/pi-devbox/src/branch/main/CHANGELOG.md) for the full list.
- **Smoke threshold check:** `omos-with-pi` threshold remains at 3700 MB (set v1.15.4b 2026-05-18). Four opencode patches plus one pi patch typically add only a few MB across both; not expected to trip. If it does, recovery is the well-worn letter-suffix pattern (v1.15.10b with threshold bump).
- Built on the same CI path as v1.15.6 (pinned-crane install on real-base-rebuild, skip-promote-on-cache-hit, update-description-always-on-base-success) — all expected to remain quiet on this cache-hit run.
### Note on this CHANGELOG vs the v1.15.10 tag snapshot
The v1.15.10 tag itself was pushed before the upstream release notes were located (originally I checked `sst/opencode` which is a fork; the canonical upstream is `anomalyco/opencode`). The image content under the tag is correct, but the CHANGELOG snapshot at the tag was thinner. This expanded version is on `main` going forward; the tag's snapshot will not be retroactively rewritten.
## v1.15.6 — 2026-05-21
opencode 1.15.4 → 1.15.6 bump (two upstream patch releases) plus two workflow improvements that landed on `main` between v1.15.4b and now. No image-content changes beyond the version bump; cache hit expected on `base-35ee5fe7861a` (no `Dockerfile.base` or `rootfs/` edits).
- **Bump:** opencode 1.15.4 → 1.15.6 (`OPENCODE_VERSION` in `Dockerfile.variant`). The `with-pi` and `omos-with-pi` variants will also implicitly pick up pi 0.75.3 → 0.75.4 since `PI_VERSION=latest` resolves at build time.
- **CI: defensive `__pycache__` and macOS-metadata filter in `base-decide` hash compute.** `find rootfs -type f` previously included gitignored junk like `rootfs/__pycache__/*.pyc`, `.DS_Store`, and `._AppleDouble` files — which CI's clean checkout never sees. This bit us during v1.15.4 debugging when a stale `generate-config.cpython-314.pyc` on the local rootfs/ produced `base-3605aa6b6ab1` while CI computed `base-35ee5fe7861a`. The filter is a no-op on a clean tree (verified to still produce `35ee5fe7861a` post-filter), but defends against future stale-pyc / Finder-touched-rootfs hash mismatches. `.gitea/README.md` updated in lockstep. (commit `b6e4d89`)
- **AGENTS.md: documentation drift sweep as explicit pre-commit workflow step.** Codifies the rule that non-release commits must also grep docs for stale claims about behaviour they change, with concrete repo-specific drift hotspots. Companion clause added across the wider repo set (cloud-init, ansible, pi-devbox, pi-extensions, pi-toolkit, cli_utils, proxmox) the same day. (commit `90e5a1f`)
- **First release that exercises both the pinned-crane install (T14, v1.15.3) and the skip-promote-on-cache-hit guard (T15, v1.15.4) on this CI run path** — still cache-hit on base, so `promote-base-latest` should remain skipped via T15 and the pinned crane install will only fire when a real base rebuild happens.
## v1.15.4b — 2026-05-18
Recovery release for v1.15.4 — the `omos-with-pi` variant landed at >3500 MB and tripped the smoke threshold, so `smoke-omos-with-pi` and `build-variant-omos-with-pi` were skipped. The other three variants (base, omos, with-pi) published cleanly. Plus a latent workflow bug fix exposed by the partial publish.
- **Smoke threshold bump:** `omos-with-pi` 3500 → 3700 MB. Compounded growth: opencode 1.15.0 → 1.15.4 (4 patch versions) plus pi 0.74.0 → 0.75.3 (minor + 3 patches) both added a few MB each, and they sum in the omos-with-pi variant. Same pattern as previous threshold bumps (v1.14.31c, v1.15.0b); restores ~150 MB headroom.
- **Workflow fix — `update-description` no longer skips on partial publish.** Pre-existing latent bug: `update-description.needs` includes all four `build-variant-*` jobs, and gitea Actions' default behavior is "skipped need ⇒ skip dependent". When `build-variant-omos-with-pi` got skipped (because its smoke failed), `update-description` cascaded into a skip even though the job's `if:` condition (`tag pushed`) was true. Result: Hub description wasn't refreshed on v1.15.4 despite three variants publishing. Fix: wrap the `if:` in `always() && needs.build-variant-base.result == 'success' && ...` so the job runs as long as the base variant published, regardless of what other variants did.
- **Same fix applied to `promote-base-latest`** — had the identical latent bug. Currently masked by the cache-hit skip, but would have surfaced on a real-base-rebuild release with a single failed variant.
- No image-side changes from v1.15.4. Cache hit on the same base hash (`base-35ee5fe7861a`).
## v1.15.4 — 2026-05-18
opencode 1.15.3 → 1.15.4 bump (one upstream patch release), bundled with the CI hardening that landed on main between v1.15.3 and now.
- **Bump:** opencode 1.15.3 → 1.15.4 (`OPENCODE_VERSION` in `Dockerfile.variant`).
- **CI: pinned crane install in `promote-base-latest`.** Replaced `imjasonh/setup-crane@v0.4` with a direct `curl + tar` install pinned to crane v0.21.6. The action's bootstrap script calls `api.github.com/.../releases/latest` to discover what crane version to install. That call periodically rate-limits and produces `tag=null` → the action downloads `releases/download/null/...` → 404 → `gzip: unexpected end of file` → exit 2. We hit this on v1.15.3 (cosmetic failure since base-latest was already correct from cache hit). Pinned install removes the runtime GitHub API dependency entirely. Bump `CRANE_VERSION` deliberately when wanting updates, same pattern as the other GitHub-sourced binaries in the Dockerfile layer.
- **CI: skip `promote-base-latest` on cache-hit base builds.** When the base layer hash hasn't changed (cache-hit on the existing `base-<hash>` from a previous run), `base-latest` already points at the correct digest, so the retag is a tautology. Job now skipped entirely when `needs.base-decide.outputs.need_build == 'false'`. Manual `workflow_dispatch` with `promote_latest: true` overrides the gate as an escape hatch for hand-recovery scenarios.
- No image-side changes from the v1.15.3 baseline beyond the opencode npm version. Smoke thresholds unchanged.
## v1.15.3 — 2026-05-16
opencode 1.15.0 → 1.15.3 bump (three upstream patch releases).
- **Bump:** opencode 1.15.0 → 1.15.3 (`OPENCODE_VERSION` in `Dockerfile.variant`).
- No container-side changes. Smoke thresholds from v1.15.0b unchanged.
## v1.15.0b — 2026-05-15
Rebuild of v1.15.0 with one fix — v1.15.0's `omos` variant landed at 3206 MB, 6 MB over the 3200 MB smoke threshold, so `smoke-omos` failed and `build-variant-omos` was skipped. opencode 1.15.0 grew slightly vs 1.14.50, leaving zero headroom on the existing threshold.
- **Smoke threshold bump:** `omos` 3200 → 3300 MB, `omos-with-pi` 3400 → 3500 MB. Restores ~100 MB headroom for routine apt-get upgrade drift between releases. Documented inline in `scripts/smoke-test.sh`. No image-side changes — cache hits across the board, just a re-publish on the bumped threshold.
## v1.15.0 — 2026-05-15
opencode 1.14.50 → 1.15.0 bump (upstream minor release).
- **Bump:** opencode 1.14.50 → 1.15.0 (`OPENCODE_VERSION` in `Dockerfile.variant`).
- **Resilience:** `git clone` for pi-toolkit and pi-extensions in `Dockerfile.variant` is now wrapped in a 5-attempt retry loop with linear backoff (5s, 10s, 15s, 20s, 25s = up to ~75s total). gitea.jordbo.se occasionally returns transient HTTP 500s on the first request after idle, which previously broke the with-pi and omos-with-pi variant builds. Same pattern landed in pi-devbox repo concurrently.
- **Docs:** `DOCKER_HUB.md` mentions `joakimp/pi-devbox` as a sibling image — the pi-only build that uses this image's base layer as its parent. Generator template (`scripts/generate-dockerhub-md.py`) updated and regenerated. Hub size: 5905 bytes (well under the 25 kB limit).
- **Recovery from v1.14.50c partial publish:** the `latest-omos`, `v1.14.50c-omos` Hub gap is closed by this release — `latest-omos` will move forward to v1.15.0 once all four variants publish cleanly. Users on the floating tag were unaffected (still pointing at v1.14.41b until now).
## v1.14.50c — 2026-05-14
Recovery release for v1.14.50b's missing variants. v1.14.50b shipped only the `base` variant; `omos`, `with-pi`, and `omos-with-pi` were lost to a runner-fleet incident (see postmortem below).
No container-side changes. This is a tag-only retag to re-run the build on a now-healthy runner fleet. Same `base-35ee5fe7861a` from v1.14.50b is reused via hash-cache hit; only the four variant deltas are rebuilt and published.
### Postmortem: v1.14.50 / v1.14.50b runner-fleet incident
Two orthogonal runner-host issues compounded across runs 285291:
1. **AVX-less runner shadowing the new fleet.** A pre-migration `act_runner` container on `nyvaken` (Sandy Bridge E3-12xx, has AVX but no AVX2; 4 weeks old, name `act_runner-runner-1`) collided with the orchestrator's freshly deployed `runner-1` VM (Broadwell-EP host, fully AVX2-capable). Gitea scheduled jobs to both. Jobs landing on the nyvaken container `npm install -g opencode-ai@1.14.50` succeeded, then ran `opencode --version` postinstall → the bundled Bun (v1.3.13 baseline) emitted `CPU lacks AVX support`, panicked, and SIGILLed (exit code 132).
2. **Containerd shared-state race at `capacity: 2`.** The new VM-based runners initially ran `act_runner` with `capacity: 2`, scheduling two concurrent jobs on a single host. Both jobs would invoke `docker/setup-buildx-action@v4`, which pulls `moby/buildkit:buildx-stable-1`. Containerd's content store raced on identical sha256 ingestion, surfacing as `commit failed: rename .../ingest/.../data .../blobs/sha256/...: no such file or directory` or `failed to extract layer: failed to Lchown ...`.
A secondary issue surfaced: **Proxmox VM `cpu:` field defaults mask AVX**. The newly-cloned runner VMs had no explicit `cpu:` line in `qm config` and inherited Proxmox's recent default `x86-64-v2-AES`, which excludes AVX even though the Broadwell-EP host silicon has full `avx2`. Fix: `qm set <vmid> --cpu x86-64-v3` (or `host` for full passthrough), then `qm shutdown` + `qm start` (live reboot is not enough). Verified inside guest with `grep -m1 -oE 'avx[2]?' /proc/cpuinfo`.
Additionally, when `promote-base-latest`'s `needs:` graph requires *all four* `build-variant-*` jobs to succeed, partial publishes leave the `base-latest` Hub alias never advancing. Workaround used during recovery: manually re-tag the new base hash via Docker Hub registry manifest API (`PUT /v2/<repo>/manifests/base-latest` with the body of `GET /v2/<repo>/manifests/base-<sha>`) using a granular Hub PAT. No blob copy needed since blobs are content-addressed.
### Recovery actions taken (orchestrator + this repo)
- Orchestrator (cloud-init + ansible repos): set explicit `cpu_type: x86-64-v3` in all runner host yaml files; provision.sh now applies `qm set --cpu` after clone; added runner-3 on proxmox003 for anti-affinity (one runner per Proxmox node); dropped `capacity: 2 → 1` on all runners; bumped `act_runner` 0.3.1 → 0.6.1 across the fleet; documented the CPU-type gotcha as gotcha #9 in cloud-init AGENTS.md and a section in proxmox-guide.md.
- User: retired the legacy `act_runner-runner-1` container on nyvaken; cleaned up stale runner registrations in Gitea Site Admin → Actions → Runners.
- This repo: no changes needed in Dockerfile.base / Dockerfile.variant; v1.14.50c is a tag-only retag.
### Fleet state at v1.14.50c
3 runners (runner-1@proxmox001, runner-2@proxmox002, runner-3@proxmox003), all `act_runner` v0.6.1, all `capacity: 1`, all expose AVX + AVX2 to the guest. No name collisions. Estimated wall clock for v1.14.50c (cache-hit base, 4 variant deltas across 3 runners with capacity:1): ~4050 min.
## v1.14.50b — 2026-05-14
Rebuild of v1.14.50 with two fixes — the v1.14.50 release was incomplete (smokes failed under containerd contention; build-variant jobs skipped; base-latest never promoted to Docker Hub).
- **Force fresh base rebuild.** Added a `BASE_REBUILD_DATE` comment header to `Dockerfile.base` to invalidate the content hash and trigger a full base rebuild. Picks up ~5 days of Debian trixie security updates and other apt-tracked packages. The comment also documents the pattern for future intentional base-rebuilds without other code changes (recommended cadence: once per release).
- **First publish of `base-latest` alias.** `promote-base-latest` runs unconditionally on tag push (`PROMOTE_LATEST=true`), so this release is the first to put `joakimp/opencode-devbox:base-latest` on Docker Hub. Required before pi-devbox (and any other downstream image FROMing the base) can build.
## v1.14.50 — 2026-05-14
opencode 1.14.44 → 1.14.50 bump. First release on the split-base build pipeline.
- **Bump:** opencode 1.14.44 → 1.14.50 (`OPENCODE_VERSION` in `Dockerfile.variant`).
- **Infrastructure: split-base pipeline cutover.** `Dockerfile.base` + `Dockerfile.variant` replace the single `Dockerfile`. `docker-publish-split.yml` (now renamed to `docker-publish.yml` in spirit — triggers on `push: tags: v*`) replaces the old `docker-publish.yml`. The original `Dockerfile` and `docker-publish.yml` are deleted. Hash-driven base reuse: version-bump-only releases skip the base build entirely (~4080 min wall clock with 4 runners vs ~165180 min previously). Validated across two `workflow_dispatch` test runs (`:v0.0.0-split-test` tags on Docker Hub).
- **Fix:** `echo -e` heredoc replaced with POSIX-compatible brace-block for multiline `$GITHUB_OUTPUT` writes in the four `build-variant-*` jobs. `echo -e` does not interpret `\n` in `/bin/sh` (dash), causing `steps.tags.outputs.tags` to be empty and buildx to fail with "tag is needed when pushing to registry".
- **Docs:** New `.gitea/README.md` — architectural overview of the split-base pipeline, hash logic, wall-clock estimates, runner expectations, and the migration plan.
## v1.14.44 — 2026-05-09
opencode 1.14.42 → 1.14.44 bump (1.14.43 skipped upstream). Also completes the matrix coverage that v1.14.42 missed: `build-omos-with-pi` failed mid-publish on v1.14.42 due to an upstream npm CDN propagation race — `oh-my-opencode-slim@1.0.7` had been published declaring a dependency on `@opencode-ai/sdk@1.14.44`, and our build hit the registry within ~2 minutes of that SDK version landing, before the tarball had propagated across npm's CDN. The build returned 404 on the SDK fetch even though the manifest's `dist-tags.latest` already pointed at 1.14.44. Tarball is now fully fetchable; v1.14.44 builds cleanly across all four variants.
- **Bump:** opencode 1.14.42 → 1.14.44 (`OPENCODE_VERSION` build-arg default in both `Dockerfile` and `Dockerfile.variant`).
Known gap: `joakimp/opencode-devbox:v1.14.42-omos-with-pi` and the corresponding `latest-omos-with-pi` alias were NOT published in the v1.14.42 release (`build-omos-with-pi` job failed for the reason above). `latest-omos-with-pi` continued pointing at v1.14.41b until v1.14.44 published. Users on the `latest-omos-with-pi` floating tag were unaffected; users pulling explicit `:v1.14.42-omos-with-pi` would get a 404 from Hub. Closed by v1.14.44.
## v1.14.42 — 2026-05-09
**Note:** Of the 4 multi-arch variants, 3 published cleanly (`v1.14.42`, `v1.14.42-omos`, `v1.14.42-with-pi`, plus their `latest*` aliases). `build-omos-with-pi` failed during the publish step due to an upstream npm CDN propagation race (see v1.14.44 entry above for detail). Re-running the failed job would have required another full ~3h matrix rerun in gitea Actions; we chose to bump opencode to 1.14.44 instead and let the next tag close the gap.
opencode 1.14.41 → 1.14.42 bump. Carries along all container-side changes accumulated since v1.14.41b: pi package rename to `@earendil-works/*`, npm-prefix-on-volume fix, Hub doc rewrite, README/AGENTS docs catchup.
Image changes:
+20 -16
View File
@@ -17,6 +17,21 @@ All variants support `linux/amd64` and `linux/arm64`.
## Quick Start
For a fully-configured environment with persistent state (opencode config, mempalace memory, neovim plugins, bash history) surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
```bash
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
curl -O https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml
curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
# Edit .env — set OPENCODE_PROVIDER, the matching API key,
# WORKSPACE_PATH, GIT_USER_NAME, GIT_USER_EMAIL.
docker compose run --rm devbox
```
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first — useful for `aws sso login`, `pi` (on `*-with-pi` variants), or multi-harness workflows.
**One-shot run, no persistence:**
```bash
docker run -it --rm \
-e ANTHROPIC_API_KEY=your-key \
@@ -28,22 +43,7 @@ docker run -it --rm \
joakimp/opencode-devbox:latest
```
Drops you straight into opencode with your project mounted at `/workspace`.
For an interactive shell first (useful for AWS SSO login, multi-harness workflows, or just `bash`):
```bash
docker run -it --rm \
-e ANTHROPIC_API_KEY=your-key \
-e OPENCODE_PROVIDER=anthropic \
-v ~/projects:/workspace \
-v ~/.ssh:/home/developer/.ssh:ro \
joakimp/opencode-devbox:latest bash
```
Then run `opencode`, `pi` (on `*-with-pi` variants), or `aws sso login` from the shell.
For docker-compose users, the source repo provides `docker-compose.yml`, `.env.example`, and a one-liner `docker compose up -d` workflow with named volumes pre-wired.
Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedrock SSO + static), persistence model, build args, troubleshooting: <https://gitea.jordbo.se/joakimp/opencode-devbox#readme>
## What's Inside
@@ -86,6 +86,10 @@ Full persistence reference, including multi-user (`SIGNUM`) isolation and host b
- **Issues / source / docker-compose templates:** <https://gitea.jordbo.se/joakimp/opencode-devbox>
- **Agent-facing internals** (for future maintainers / coding agents working in the repo): <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/AGENTS.md>
## Sibling images
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — pi-only image built on top of this image's base layer. Smaller (~700 MB) and version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly. Use this if you want pi without opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
## License
MIT. See <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/LICENSE>.
-475
View File
@@ -1,475 +0,0 @@
# opencode-devbox — portable AI dev environment
# Debian-based container with opencode and configurable dev tools
ARG DEBIAN_VERSION=trixie-slim
FROM debian:${DEBIAN_VERSION} AS base
ARG TARGETARCH
ARG OPENCODE_VERSION=1.14.42
LABEL maintainer="joakimp"
LABEL description="Portable opencode developer container"
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/opencode-devbox"
# Avoid interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive
# ── Core system packages ─────────────────────────────────────────────
# apt-get upgrade picks up any security/CVE fixes published between
# debian:trixie-slim base-image rebuilds. Paired with the index update
# and the install in the same layer so we don't bloat image history.
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
wget \
git \
openssh-client \
gnupg \
jq \
ripgrep \
fd-find \
tree \
less \
htop \
tmux \
make \
patch \
diffutils \
git-crypt \
age \
file \
sudo \
locales \
procps \
unzip \
gcc \
g++ \
rsync \
python3-pip \
python3-venv \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
#
# Version policy for the binaries below:
# • Default is `latest` — resolved at build time by following the
# /releases/latest redirect on GitHub and reading the tag from the
# Location header. This means every tagged image picks up the newest
# upstream release, with no risk of running months-old CVE-affected
# binaries.
# • Explicit pins still work: pass `--build-arg GOSU_VERSION=1.19` etc.
# Useful for reproducibility or rolling back a bad upstream release.
# • Resolved versions are printed during build and re-checked by the
# smoke test (scripts/smoke-test.sh), so drift is visible in CI logs.
#
# The helper `resolve_latest` reads the redirected tag (e.g. "v0.26.1")
# and strips a leading "v" if present, yielding a plain version string.
# gosu — privilege de-escalation
ARG GOSU_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
V="${GOSU_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing gosu ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
chmod +x /usr/local/bin/gosu && \
gosu --version
# fzf — fuzzy finder
ARG FZF_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
V="${FZF_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing fzf ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
fzf --version
# git-lfs — Git Large File Storage
ARG GIT_LFS_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
V="${GIT_LFS_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing git-lfs ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \
install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \
rm -rf /tmp/git-lfs-${V} && \
git lfs install --system && \
git-lfs --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) && \
V="${NVIM_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing neovim ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
nvim --version | head -1
# bat — syntax-highlighted cat replacement
ARG BAT_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${BAT_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing bat ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
rm -rf /tmp/bat-v${V}-* && \
bat --version
# eza — modern ls replacement
ARG EZA_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${EZA_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing eza ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
eza --version | head -1
# zoxide — smarter cd command
ARG ZOXIDE_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${ZOXIDE_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing zoxide ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
zoxide --version
# uv — fast Python package manager (replaces pip, venv, pyenv)
# Note: uv releases don't prefix tags with "v" (e.g. tag is "0.11.8").
ARG UV_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${UV_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing uv ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
rm -rf /tmp/uv-* && \
uv --version
# ── Optional: MemPalace — local-first AI memory system ───────────────
# Provides semantic search over conversation history via 29 MCP tools.
# Palace data persists via the devbox-palace named volume.
# The embedding model (~300 MB) is downloaded on first use and cached
# in the palace directory.
#
# Installed via `uv tool install` into an isolated venv at
# /opt/uv-tools/mempalace/. The `mempalace` CLI goes directly on PATH;
# the MCP server is reached via the /usr/local/bin/mempalace-mcp-server
# wrapper (rootfs/usr/local/bin/mempalace-mcp-server), since system
# python3 cannot import from the isolated venv.
#
# Disable with --build-arg INSTALL_MEMPALACE=false to shave ~300 MB off
# the image (chromadb, torch-adjacent deps).
ARG INSTALL_MEMPALACE=true
ENV UV_TOOL_DIR=/opt/uv-tools
ENV UV_TOOL_BIN_DIR=/usr/local/bin
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
mkdir -p /opt/uv-tools && \
uv tool install --no-cache mempalace && \
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
fi
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
# Thin wrappers (`mempalace-session`, `mempalace-docs`) that delegate to
# the mempalace Python CLI for two common scheduled tasks:
# - mempalace-session: mines opencode's SQLite session history into
# the palace (wing_conversations). Referenced by contrib/ scheduler
# templates (systemd user timer, cron) in the toolkit repo.
# - mempalace-docs: mines project docs into a per-project wing.
# Repo source of truth: https://gitea.jordbo.se/joakimp/mempalace-toolkit
#
# Requires INSTALL_MEMPALACE=true (wrappers shell out to `mempalace`).
# Disable with --build-arg INSTALL_MEMPALACE_TOOLKIT=false if you don't
# use the scheduled-mining workflow.
ARG INSTALL_MEMPALACE_TOOLKIT=true
ARG MEMPALACE_TOOLKIT_REF=main
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
mempalace-session --help >/dev/null && \
mempalace-docs --help >/dev/null && \
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
fi
# rustup — Rust toolchain manager
# Installs the rustup-init binary only. Users bootstrap Rust with:
# rustup-init -y && source ~/.cargo/env
# Toolchains persist via devbox-rustup and devbox-cargo volumes.
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://static.rust-lang.org/rustup/dist/${ARCH}-unknown-linux-gnu/rustup-init" -o /usr/local/bin/rustup-init && \
chmod +x /usr/local/bin/rustup-init
# gitea-mcp — MCP server for Gitea API (official, Go binary, hosted on gitea.com)
ARG GITEA_MCP_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
V="${GITEA_MCP_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing gitea-mcp ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/download/v${V}/gitea-mcp_Linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin/ gitea-mcp && \
chmod +x /usr/local/bin/gitea-mcp && \
gitea-mcp --version
# Set locale — generate common UTF-8 locales (override via LANG/LC_ALL env vars)
# To add more locales, run: sudo sed -i '/<locale>.UTF-8/s/^# //g' /etc/locale.gen && sudo locale-gen
RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
ENV EDITOR=nvim
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
# ── Node.js (required for opencode v1.x install + MCP servers) ──────
ARG NODE_VERSION=22
RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*
# ── Install opencode via npm ─────────────────────────────────────────
# v1.x is distributed as an npm package with platform-specific binaries.
# Disable with --build-arg INSTALL_OPENCODE=false to build a slimmer
# image without opencode (e.g. when only pi is needed). For a fully
# pi-only stripped image (no Bun, no opencode), see the pi-devbox repo.
ARG INSTALL_OPENCODE=true
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
npm install -g opencode-ai@${OPENCODE_VERSION} && \
opencode --version ; \
fi
# ── Optional: pi coding-agent ────────────────────────────────────────
# Installs pi as an alternative/complementary harness. Coexists with
# opencode in the same image — both share the mempalace install and
# palace path, so wing data is mutually visible to either harness.
#
# pi-toolkit (keybindings.json + pi-env.zsh + settings.example.json)
# and pi-extensions (confirm-destructive, ext-toggle, git-checkpoint,
# notify, ssh-controlmaster, todo, …) are cloned into /opt/ at build
# time. entrypoint-user.sh runs each repo's install.sh on container
# start so symlinks land under ~/.pi/agent/ on the named volume.
#
# Pi version is pinned by PI_VERSION (default: latest at build time).
# The baked pi binary lives at /usr/bin/pi (system npm prefix); the
# user-writable NPM_CONFIG_PREFIX (~/.pi/npm-global, set further down)
# is only consulted by `pi install npm:<pkg>` and `npm install -g` at
# runtime — it does NOT shadow the baked pi unless the user does
# `npm install -g @earendil-works/pi-coding-agent` themselves, in which
# case the user-installed copy on the volume wins via PATH order. Same
# contract as OPENCODE_VERSION otherwise: rebuild the image to upgrade
# the baked pi.
ARG INSTALL_PI=false
ARG PI_VERSION=latest
ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main
RUN if [ "${INSTALL_PI}" = "true" ]; then \
if [ "${PI_VERSION}" = "latest" ]; then \
npm install -g @earendil-works/pi-coding-agent ; \
else \
npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
fi && \
pi --version && \
git clone --depth 1 --branch "${PI_TOOLKIT_REF}" \
https://gitea.jordbo.se/joakimp/pi-toolkit.git /opt/pi-toolkit && \
git clone --depth 1 --branch "${PI_EXTENSIONS_REF}" \
https://gitea.jordbo.se/joakimp/pi-extensions.git /opt/pi-extensions && \
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
fi
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
RUN ARCH=$(case "${TARGETARCH}" in \
amd64) echo "x86_64" ;; \
arm64) echo "aarch64" ;; \
*) echo "x86_64" ;; \
esac) && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
unzip -q /tmp/awscli.zip -d /tmp && \
/tmp/aws/install && \
rm -rf /tmp/aws /tmp/awscli.zip && \
aws --version
# ── Optional: Go ─────────────────────────────────────────────────────
# Latest stable Go is resolved from https://go.dev/dl/?mode=json when
# GO_VERSION=latest (default). Pass an explicit version like "1.26.2"
# to pin.
ARG INSTALL_GO=false
ARG GO_VERSION=latest
RUN if [ "${INSTALL_GO}" = "true" ]; then \
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
V="${GO_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \
awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \
fi && \
[ -n "$V" ] && \
echo "Installing Go ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
fi
# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ────────
# Installs Bun runtime and the oh-my-opencode-slim npm package.
# Runtime activation is controlled by ENABLE_OMOS env var in entrypoint.
# Uses the baseline Bun build (SSE4.2 only) for compatibility with older
# CPUs that lack AVX2 (e.g. Sandy Bridge on OpenStack).
ARG INSTALL_OMOS=false
ARG OMOS_VERSION=latest
RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then \
BUN_ARCH="x64-baseline"; \
elif [ "$ARCH" = "aarch64" ]; then \
BUN_ARCH="aarch64"; \
fi && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-${BUN_ARCH}.zip" -o /tmp/bun.zip && \
unzip -o /tmp/bun.zip -d /tmp/bun && \
mv /tmp/bun/bun-linux-${BUN_ARCH}/bun /usr/local/bin/bun && \
chmod +x /usr/local/bin/bun && \
ln -sf bun /usr/local/bin/bunx && \
rm -rf /tmp/bun /tmp/bun.zip && \
bun --version && \
test -L /usr/local/bin/bunx && \
npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \
fi
# ── Non-root user ────────────────────────────────────────────────────
ARG USER_NAME=developer
ARG USER_UID=1000
ARG USER_GID=1000
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
# Create standard directories
#
# ~/.pi/agent/extensions/ is created proactively so the named volume
# mount has a real owner from the first start. The directory is also
# what mempalace-toolkit's install_pi_extension probes to decide
# whether to deploy the pi↔mempalace bridge — must exist before that
# step runs in entrypoint-user.sh.
RUN mkdir -p /workspace \
/home/${USER_NAME}/.config/opencode/skills \
/home/${USER_NAME}/.pi/agent/extensions \
/home/${USER_NAME}/.agents/skills \
/home/${USER_NAME}/.local/share/opencode \
/home/${USER_NAME}/.cache/bash \
/home/${USER_NAME}/.ssh && \
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
# ── Pre-warm chromadb embedding model ──────────────────────────────
# Mempalace uses chromadb's ONNXMiniLM_L6_V2 embedding function, which
# downloads ~80 MB of all-MiniLM-L6-v2 ONNX weights from chromadb's CDN
# on first use. Without pre-warming this happens silently (output is
# suppressed by the entrypoint init step) and stalls first container
# start by minutes on a slow network. We bake the cache at build time
# under the developer user's home so the runtime first-start is fast.
#
# Cache path comes from chromadb's hardcoded `Path.home() / .cache /
# chroma / onnx_models / all-MiniLM-L6-v2`. Run as gosu developer so
# Path.home() resolves correctly and ownership is right from the start.
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \
ef = ONNXMiniLM_L6_V2(); \
_ = ef(['warmup']); \
print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
fi
# ── User-writable npm global prefix on the devbox-pi-config volume ──
# By default npm's global prefix is /usr (writable only by root) so any
# `pi install npm:<pkg>` or `npm install -g <pkg>` invoked by the
# developer user would EACCES. Pointing the prefix into ~/.pi places
# user-installed packages on the named volume, which means they survive
# container recreation AND image rebuilds (complementing pi's auto-
# restore from settings.json with one less cold-start step).
#
# These ENVs land AFTER all build-time `npm install -g` calls
# (opencode, pi, oh-my-opencode-slim) so those still install to /usr at
# build time. They take effect for every runtime invocation regardless
# of shell init: docker compose run/exec, login shells, non-interactive
# commands. npm auto-creates the prefix directory on first install.
#
# Harmless when INSTALL_PI=false (and no named volume mounted at ~/.pi):
# the dir just lives on the container's writable layer.
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
# ── Shell defaults (bash history, aliases, readline) ─────────────────
# Shipped under /etc/skel-devbox/ rather than copied directly to the
# user's home. The entrypoint copies them to /home/developer/ only if
# the target file does not already exist, so host bind-mounts and
# previously-customized files are never overwritten. Users can restore
# the baked defaults anytime via:
# cp /etc/skel-devbox/.bash_aliases ~/.bash_aliases
# History itself persists via the devbox-shell-history named volume
# mounted at ~/.cache/bash (HISTFILE points there).
RUN mkdir -p /etc/skel-devbox
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
# ── Entrypoint ────────────────────────────────────────────────────────
COPY rootfs/usr/local/lib/opencode-devbox/ /usr/local/lib/opencode-devbox/
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
/usr/local/lib/opencode-devbox/*.py
# Start as root — entrypoint adjusts UID/GID then drops to developer
WORKDIR /workspace
ENTRYPOINT ["entrypoint.sh"]
# Default to a login shell. `docker compose run --rm devbox` drops
# the user into bash to choose: `aws sso login`, then `opencode`
# or `pi`. To launch a harness directly, pass it explicitly:
# docker compose run --rm devbox opencode
# docker compose run --rm devbox pi
# `docker compose exec` bypasses the entrypoint and CMD entirely, so
# this default has no effect on attach-style workflows.
CMD ["bash", "-l"]
+63
View File
@@ -11,6 +11,13 @@
# changes (rootfs/, entrypoint*.sh). Version bumps to OPENCODE_VERSION,
# OMOS_VERSION, PI_VERSION, etc. do NOT trigger a base rebuild.
#
# To force a base rebuild for fresh apt packages without other code
# changes, bump the BASE_REBUILD_DATE comment below. The hash is
# content-addressed over this file, so any byte change invalidates the
# cache. Recommended cadence: once per release for security updates.
#
# BASE_REBUILD_DATE: 2026-05-14 (v1.14.50b — fresh apt + first promote-base-latest)
#
# See the project README's "Build pipeline" section for the rationale.
ARG DEBIAN_VERSION=trixie-slim
@@ -64,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:
@@ -119,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) && \
+34 -5
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.14.42
ARG OPENCODE_VERSION=1.15.12
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
opencode --version ; \
@@ -42,21 +46,42 @@ 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
ARG PI_EXTENSIONS_REF=main
RUN if [ "${INSTALL_PI}" = "true" ]; then \
set -e && \
git_clone_retry() { \
url="$1"; ref="$2"; dest="$3"; \
for i in 1 2 3 4 5; do \
if git clone --depth 1 --branch "$ref" "$url" "$dest"; then return 0; fi; \
rm -rf "$dest"; \
echo "git clone $url failed (attempt $i/5), retrying in $((i*5))s..."; \
sleep $((i*5)); \
done; \
return 1; \
} && \
if [ "${PI_VERSION}" = "latest" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
else \
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
fi && \
pi --version && \
git clone --depth 1 --branch "${PI_TOOLKIT_REF}" \
https://gitea.jordbo.se/joakimp/pi-toolkit.git /opt/pi-toolkit && \
git clone --depth 1 --branch "${PI_EXTENSIONS_REF}" \
https://gitea.jordbo.se/joakimp/pi-extensions.git /opt/pi-extensions && \
git_clone_retry https://gitea.jordbo.se/joakimp/pi-toolkit.git "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
git_clone_retry https://gitea.jordbo.se/joakimp/pi-extensions.git "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
fi
@@ -80,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 \
+23 -3
View File
@@ -8,8 +8,28 @@ The official `ghcr.io/anomalyco/opencode` image (now archived) was Alpine-based
## Quick Start
**Just want to run it?** No git clone needed — grab the two template files:
```bash
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
# Pull docker-compose.yml and the .env template
curl -O https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml
curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
# Edit .env — at minimum: OPENCODE_PROVIDER, the matching API key,
# WORKSPACE_PATH, GIT_USER_NAME, GIT_USER_EMAIL.
$EDITOR .env
# Pull and run
docker compose run --rm devbox
```
This pulls `joakimp/opencode-devbox:latest` from Docker Hub, mounts `WORKSPACE_PATH` at `/workspace`, and drops you straight into opencode. Use `bash` instead of (no command) to land in a shell first — useful for `aws sso login`, `pi`, `omos`, etc.
**Want to hack on the image itself, follow upstream changes, or rebuild from source?** Clone the repo:
```bash
# Clone
git clone ssh://gitea.jordbo.se:2222/joakimp/opencode-devbox.git
cd opencode-devbox
@@ -17,7 +37,7 @@ cd opencode-devbox
cp .env.example .env
# Edit .env with your provider, API key, workspace path, git config
# Install git hooks (secret scanning)
# Install git hooks (secret scanning) before committing
brew install gitleaks # macOS / Linuxbrew
./setup-hooks.sh
@@ -742,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
+20 -16
View File
@@ -71,6 +71,21 @@ All variants support `linux/amd64` and `linux/arm64`.
## Quick Start
For a fully-configured environment with persistent state (opencode config, mempalace memory, neovim plugins, bash history) surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
```bash
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
curl -O https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml
curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
# Edit .env — set OPENCODE_PROVIDER, the matching API key,
# WORKSPACE_PATH, GIT_USER_NAME, GIT_USER_EMAIL.
docker compose run --rm devbox
```
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first — useful for `aws sso login`, `pi` (on `*-with-pi` variants), or multi-harness workflows.
**One-shot run, no persistence:**
```bash
docker run -it --rm \\
-e ANTHROPIC_API_KEY=your-key \\
@@ -82,22 +97,7 @@ docker run -it --rm \\
joakimp/opencode-devbox:latest
```
Drops you straight into opencode with your project mounted at `/workspace`.
For an interactive shell first (useful for AWS SSO login, multi-harness workflows, or just `bash`):
```bash
docker run -it --rm \\
-e ANTHROPIC_API_KEY=your-key \\
-e OPENCODE_PROVIDER=anthropic \\
-v ~/projects:/workspace \\
-v ~/.ssh:/home/developer/.ssh:ro \\
joakimp/opencode-devbox:latest bash
```
Then run `opencode`, `pi` (on `*-with-pi` variants), or `aws sso login` from the shell.
For docker-compose users, the source repo provides `docker-compose.yml`, `.env.example`, and a one-liner `docker compose up -d` workflow with named volumes pre-wired.
Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedrock SSO + static), persistence model, build args, troubleshooting: <{GITEA}#readme>
## What's Inside
@@ -140,6 +140,10 @@ Full persistence reference, including multi-user (`SIGNUM`) isolation and host b
- **Issues / source / docker-compose templates:** <{GITEA}>
- **Agent-facing internals** (for future maintainers / coding agents working in the repo): <{GITEA}/src/branch/main/AGENTS.md>
## Sibling images
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — pi-only image built on top of this image's base layer. Smaller (~700 MB) and version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly. Use this if you want pi without opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
## License
MIT. See <{GITEA}/src/branch/main/LICENSE>.
+49 -4
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
run "pi" "pi --version"
if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
run_expect "pi version matches build-arg" "pi --version" "$EXPECTED_PI_VERSION"
else
run "pi" "pi --version"
fi
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
@@ -192,6 +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"
@@ -289,14 +328,20 @@ SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE")
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
echo " Uncompressed size: ${SIZE_MB} MB"
# Thresholds (uncompressed): base 2500 MB, omos 3200 MB, with-pi adds ~150 MB.
# Thresholds (uncompressed): base 2500 MB, omos 3300 MB, with-pi adds ~150 MB.
# omos bumped 3000→3200 on v1.14.31c — mempalace-toolkit bake-in pushed the
# baseline; bumped 3200→3300 on v1.15.0 — opencode 1.15.0 came in at
# 3206 MB, leaving zero headroom for routine apt-get upgrade drift.
# omos-with-pi bumped 3400→3500 on v1.15.0 alongside the omos bump.
# omos-with-pi bumped 3500→3700 on v1.15.4b — omos+pi compounded as both
# upstream packages grew (opencode 1.15.0→1.15.4, pi 0.74.0→0.75.3) and
# the variant landed just over 3500 in v1.15.4's smoke.
# omos variant to ~3.1 GB. Functional smoke checks all pass; this is a
# guardrail, not a performance limit.
THRESHOLD=2500
[ "$VARIANT" = "omos" ] && THRESHOLD=3200
[ "$VARIANT" = "omos" ] && THRESHOLD=3300
[ "$VARIANT" = "with-pi" ] && THRESHOLD=2700
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3400
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3700
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
else