Compare commits

..

41 Commits

Author SHA1 Message Date
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
joakimp b30ffc83bd Bump opencode 1.14.41 -> 1.14.42
Validate / docs-check (push) Successful in 17s
Validate / validate-base (push) Has started running
Validate / validate-omos (push) Has been cancelled
Validate / validate-with-pi (push) Has been cancelled
Validate / validate-omos-with-pi (push) Has been cancelled
Publish Docker Image / smoke-base (push) Successful in 12m41s
Publish Docker Image / smoke-omos (push) Successful in 13m0s
Publish Docker Image / smoke-with-pi (push) Successful in 14m23s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 15m48s
Publish Docker Image / build-base (push) Successful in 44m25s
Publish Docker Image / build-omos (push) Successful in 53m33s
Publish Docker Image / build-omos-with-pi (push) Failing after 17m9s
Publish Docker Image / build-with-pi (push) Successful in 46m17s
Publish Docker Image / update-description (push) Has been skipped
opencode-ai 1.14.42 was released; bump OPENCODE_VERSION default in
Dockerfile and Dockerfile.variant. Container changes accumulated since
v1.14.41b ride along with this tagged release: pi package rename to
@earendil-works/*, npm-prefix-on-volume fix, smoke-test query fix, Hub
doc rewrite, README/AGENTS docs catchup.

CHANGELOG promoted: Unreleased -> v1.14.42 (2026-05-09). The
split-base build pipeline note stays under a fresh Unreleased \u2014 it's
merged to main but not yet validated end-to-end via dispatch test, so
it does not ship with v1.14.42 (the production docker-publish.yml on
tag push is still authoritative).

Release contents:
- Bump: opencode 1.14.41 -> 1.14.42
- Rename: @mariozechner/pi-coding-agent -> @earendil-works/pi-coding-agent
- Fix: NPM_CONFIG_PREFIX on volume so 'pi install npm:<pkg>' as
  developer survives container recreate AND image rebuild
- Fix: smoke-test queries /usr prefix for npm ls -g check
- Docs: Hub doc rewrite (24997 -> 5529 bytes), README pi section
  catchup (6 -> 7 extensions, mcp-loader documented), AGENTS
  release-day checklist updates
2026-05-09 19:17:10 +02:00
joakimp 896380bb9c Rename @mariozechner/pi-coding-agent to @earendil-works/pi-coding-agent
Validate / docs-check (push) Successful in 16s
Validate / validate-base (push) Successful in 12m25s
Validate / validate-omos (push) Successful in 16m40s
Validate / validate-with-pi (push) Successful in 14m0s
Validate / validate-omos-with-pi (push) Successful in 18m18s
Pi moved to its new home at earendil-works on 2026-05-07
(https://pi.dev/news/2026/5/7/pi-has-a-new-home).

The old @mariozechner/pi-coding-agent npm package is deprecated with
the explicit message 'please use @earendil-works/pi-coding-agent
instead going forward', and the version stream has moved on (old
top-out 0.73.1; new currently 0.74.0). Anyone npm-installing the old
name today gets a deprecation warning + a stale binary, so this is
a non-optional migration before the next tagged release.

Sweep:
- Dockerfile (production single-Dockerfile path) and Dockerfile.variant
  (split-base path on main): npm install -g target updated.
- README, AGENTS, HUB_TEMPLATE: github.com/mariozechner/pi-coding-agent
  URL refs (which now 404) -> github.com/earendil-works/pi.
- DOCKER_HUB.md regenerated (5529 bytes, ~78% headroom).
- CHANGELOG Unreleased: rename entry added with migration context.

Brew install references (`brew install pi-coding-agent`) left as-is:
formula still works at 0.73.1 and a homebrew tap update is tracked
upstream at earendil-works/pi#2755.

Historical CHANGELOG entries: only github URL refs updated (the
package name was never spelled out in those entries; we're correcting
dead hyperlinks, not rewriting feature descriptions).
2026-05-09 17:58:07 +02:00
joakimp 911d6dd26b smoke-test: query /usr prefix for npm ls -g
Validate / docs-check (push) Successful in 13s
Validate / validate-base (push) Successful in 11m54s
Validate / validate-omos (push) Successful in 14m31s
Validate / validate-with-pi (push) Successful in 12m48s
Validate / validate-omos-with-pi (push) Successful in 19m1s
The npm-prefix-on-volume fix (commit 9df126c) sets
NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global in the image ENV so
user-installed pi packages survive container recreation. Side effect:
default 'npm ls -g' now queries the user prefix, missing the baked
opencode/pi/omos binaries that live in /usr.

The smoke test's oh-my-opencode-slim check ran 'npm ls -g | grep ...'
and started failing on validate-omos / validate-omos-with-pi after the
prefix fix landed on main, even though the package itself is correctly
installed and runnable.

Fix: explicitly invert the prefix per-call:
  NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 | grep ...

Other smoke checks (opencode --version, pi --version, bun --version)
go through PATH which already includes /usr/bin, so they were
unaffected. Only oh-my-opencode-slim was checked via npm rather than
a binary-on-PATH because it's a library, not a CLI.
2026-05-09 17:13:22 +02:00
joakimp 4c27e6fd8a feat: split-base build pipeline (parallel, manual-trigger only)
Validate / docs-check (push) Successful in 15s
Validate / validate-base (push) Successful in 12m13s
Validate / validate-omos (push) Failing after 15m48s
Validate / validate-with-pi (push) Successful in 13m43s
Validate / validate-omos-with-pi (push) Has been cancelled
Two-Dockerfile split-base build alongside the existing single-Dockerfile
pipeline. Goal: cut CI wall clock from ~165-180min to ~30-40min on
typical version-bump-only releases by reusing a base image across the
four variants.

Files added:
- Dockerfile.base       variant-independent layers (apt, locales, AWS
                        CLI, Node.js, mempalace, gitea-mcp, user setup,
                        chromadb prewarm, ENVs, entrypoints).
- Dockerfile.variant    FROMs ${BASE_IMAGE} and adds opencode / pi /
                        omos / Go installs gated by INSTALL_* args.
                        Each npm install -g uses NPM_CONFIG_PREFIX=/usr
                        per-RUN to keep baked binaries off the volume-
                        shadowed ~/.pi/npm-global path inherited from
                        base.
- .gitea/workflows/docker-publish-split.yml
                        workflow_dispatch-only pipeline:
                        base-decide -> build-base (conditional) ->
                        smoke-* (4 parallel) -> build-variant-*
                        (4 parallel) -> promote-base-latest ->
                        update-description. Hash-driven base reuse:
                        if base-<sha> already exists on Docker Hub,
                        the build is skipped entirely. Inputs:
                        release_tag (test tag suffix, default
                        v0.0.0-split-test) and promote_latest
                        (default false; gates latest-* aliases and
                        Hub description update).

Files unchanged:
- Dockerfile, docker-publish.yml, validate.yml all left in place so
  the production tag-push pipeline keeps working untouched.

Migration plan (in CHANGELOG Unreleased):
1. workflow_dispatch test run with promote_latest=false; verify the
   four variant images smoke-pass and have plausible sizes.
2. Compare manifest digests against the same-version output from the
   production pipeline (independent test run on the same commit).
3. Once verified across 1-2 release cycles, swap docker-publish-split.yml
   to on: push: tags: v* and retire docker-publish.yml.

AGENTS.md and CHANGELOG.md updated with file roles and the migration
plan. Production pipeline behavior is bit-for-bit unchanged on this
branch.
2026-05-09 16:16:25 +02:00
joakimp b5da6a5cf8 README: pi 'What gets installed' section catchup
Was stale:
- Claimed 6 pi-extensions (actually 7 — mcp-loader was added in
  pi-extensions 141bf64 / 7eec49b / 37cc49e but the count was never
  propagated here).
- No mention of mcp-loader's dual-transport (local stdio + remote
  streamable-HTTP per MCP spec 2025-03-26) or the /mcp slash command.
- Mempalace bridge bullet didn't note that it coexists with mcp-loader
  rather than being replaced by it (don't list mempalace in mcp block).
- No explicit 'no MCP servers baked in' line, leaving readers to
  guess whether searxng/context7 ship by default.

Each extension now gets a one-line description; mcp-loader gets a
paragraph covering its capabilities and a link to the pi-extensions
AGENTS for transport detail. Added an opt-in note for MCP servers.
2026-05-09 15:54:48 +02:00
joakimp f86c4b18cf Rewrite DOCKER_HUB.md as a hand-maintained slim template
Validate / docs-check (push) Successful in 16s
Validate / validate-base (push) Successful in 11m16s
Validate / validate-omos (push) Failing after 18m33s
Validate / validate-with-pi (push) Successful in 13m46s
Validate / validate-omos-with-pi (push) Failing after 19m52s
The previous derive-from-README mechanism (split_sections, SECTION_RULES,
TRIM_SUBSECTIONS, REPLACEMENTS) generated a 24 997 byte Hub doc with
3 byte headroom against the 25 kB Hub limit. Every README addition
forced a 'trim something else first' exercise, and the resulting copy
was awkward (terse, repetitive linkbacks injected mid-section).

Replace with a single hand-maintained HUB_TEMPLATE constant. The Hub
doc is now intentionally slim (~5.5 kB, ~78 percent headroom) and
focuses on what Hub readers actually need: elevator pitch, image
variants, quick start, what's inside, auth, persistence, and link-outs
to the gitea README for depth.

Trade-off: when image-variants or quick-start change, update
HUB_TEMPLATE here too. That coupling is now explicit and local rather
than spread across SECTION_RULES + REPLACEMENTS + TRIM machinery,
and most README edits no longer require regenerating DOCKER_HUB.md
at all.

Generator simplified from 323 lines to 199 lines (270-line net
reduction across the script + DOCKER_HUB.md). README and Hub doc are
now independent surfaces.

CHANGELOG and AGENTS updated to reflect the new coupling. Release-day
checklist tightened: README -> regenerate DOCKER_HUB ONLY if
HUB_TEMPLATE changed -> promote CHANGELOG -> grep AGENTS -> commit
-> tag.
2026-05-09 15:49:43 +02:00
joakimp 9df126c7a9 Fix: developer-writable npm prefix for pi install
Validate / docs-check (push) Successful in 23s
Validate / validate-base (push) Has started running
Validate / validate-omos (push) Has started running
Validate / validate-with-pi (push) Has been cancelled
Validate / validate-omos-with-pi (push) Has been cancelled
NPM_CONFIG_PREFIX is now /home/developer/.pi/npm-global, with that
prefix's bin/ prepended to PATH. Without this, 'pi install npm:<pkg>'
(and any 'npm install -g') by the developer user would EACCES against
the system prefix (/usr).

The new prefix lives on the devbox-pi-config named volume, so:
  - User-installed pi packages (themes, skills, extensions) survive
    container recreate AND image rebuild, complementing pi's auto-
    restore from settings.json with one less cold-start step.
  - A user-driven 'npm install -g @mariozechner/pi-coding-agent' lands
    on the volume and wins over the baked pi via PATH order.

Build-time 'npm install -g' calls (opencode, pi, oh-my-opencode-slim)
are unaffected: the new ENVs are declared after those steps in the
Dockerfile, so the baked binaries still install to /usr at build time
and are not shadowed by the volume mount at runtime.

Verified end-to-end with a Bun-driven smoke test: as developer,
'npm install -g cowsay' inside the container succeeds, the binary
lands on PATH, and survives a fresh container against the same volume.

DOCKER_HUB.md regenerated (24997/25000 bytes, 3-byte headroom — was
138 before; future README additions to the persistence section need
to trim something else first).

Docs updated: Dockerfile inline comments, README persistence section,
AGENTS install contract, DOCKER_HUB persistence table, .env.example
notes, CHANGELOG Unreleased entry.
2026-05-09 15:41:33 +02:00
joakimp 148f4bce8c AGENTS.md: expand doc-coupling rule with release-day checklist
Validate / docs-check (push) Successful in 14s
Validate / validate-base (push) Successful in 11m9s
Validate / validate-omos (push) Successful in 16m33s
Validate / validate-with-pi (push) Successful in 12m9s
Validate / validate-omos-with-pi (push) Successful in 16m41s
The previous 'Two docs to keep in sync' bullet only mentioned
README + DOCKER_HUB.md + .env.example. Today's session surfaced
two additional drift points the rule didn't cover:

- CHANGELOG.md still claimed 'Unreleased — will become v1.14.41b
  on release' even though the tag had been pushed and shipped
  (caught a full session later when user asked about doc drift).
- AGENTS.md itself carried stale 'four Docker Hub tags' /
  'four load:true jobs' from before the v1.14.41b CI matrix
  expansion to eight.

Replaced the bullet with a full 'Documentation coupling on release'
rule listing all four coupled docs (README, DOCKER_HUB.md,
CHANGELOG.md, AGENTS.md, plus .env.example) and an explicit
release-day checklist. Calls out the 25 kB Hub limit on
DOCKER_HUB.md as a hard constraint to keep in mind when adding
sections to README.
2026-05-08 21:35:23 +02:00
joakimp cc98722d84 docs: catch up CHANGELOG and AGENTS.md with v1.14.41b reality
Validate / docs-check (push) Has been cancelled
Validate / validate-base (push) Has been cancelled
Validate / validate-omos (push) Has been cancelled
Validate / validate-with-pi (push) Has been cancelled
Validate / validate-omos-with-pi (push) Has been cancelled
CHANGELOG drift:
- 'Unreleased' still claimed 'Will become v1.14.41b on release' even
  though the tag was cut and shipped today. Promoted to a proper
  '## v1.14.41b — 2026-05-08' release header (re-ordered above v1.14.41
  to keep reverse-chronological invariant).
- New 'Unreleased' entry records today's docs-only updates (commits
  8083cd1, d01cff3, this commit) which were patched to Docker Hub via
  the API rather than re-tagging.

AGENTS.md drift introduced by the v1.14.41b CI matrix expansion:
- 'CI produces four Docker Hub tags per release' → eight (one tag pair
  per build variant: base, omos, with-pi, omos-with-pi).
- 'all four `load: true` jobs (validate-base, validate-omos, smoke-base,
  smoke-omos)' → all eight (added validate-with-pi, validate-omos-with-pi,
  smoke-with-pi, smoke-omos-with-pi).

DOCKER_HUB.md unchanged (already in sync, regenerator confirms).
2026-05-08 21:32:11 +02:00
joakimp d01cff38d5 DOCKER_HUB: add tailored pi section (run, mempalace, persistence)
Validate / docs-check (push) Successful in 12s
Validate / validate-base (push) Has started running
Validate / validate-omos (push) Has started running
Validate / validate-with-pi (push) Has been cancelled
Validate / validate-omos-with-pi (push) Has been cancelled
Hub readers had no signal at all about pi beyond the variant tag names
in the Image Variants table. The full README pi section is too large
to include verbatim (would push past 25 kB), so this adds a custom
`replace` rule in generate-dockerhub-md.py with a slimmed Hub-tailored
version covering the three things a Hub user actually needs:

  - Run: how to start pi via compose run / compose exec
  - MemPalace integration: shared palace with opencode, mempalace.ts
    bridge symlinked at first start
  - Persistence: which paths are on volumes, what survives --rm,
    upgrade path for the pi binary itself

Build args, extension list, and toolkit detail link out to the gitea
README anchor for users who want full reference.

DOCKER_HUB.md now 24862 bytes (138 byte headroom under the 25k limit).
2026-05-08 21:27:59 +02:00
joakimp 8083cd1a6f docs: surface with-pi and omos-with-pi variants on Docker Hub
Validate / docs-check (push) Successful in 14s
Validate / validate-base (push) Successful in 11m37s
Validate / validate-omos (push) Has been cancelled
Validate / validate-omos-with-pi (push) Has been cancelled
Validate / validate-with-pi (push) Has been cancelled
The v1.14.41b release expanded the CI matrix to four image variants
and pushed eight tags total, but the docs lagged behind:

- DOCKER_HUB.md's Image Variants table still listed only base + omos.
- README.md's pi section only described building from source; no
  mention that prebuilt latest-with-pi / latest-omos-with-pi tags
  exist (asymmetric vs the OMOS section which does mention latest-omos).

Fix:
- generate-dockerhub-md.py HEADER: extend Image Variants table to all
  four variants (base, omos, with-pi, omos-with-pi).
- README.md pi section: add a Setup subsection mentioning the prebuilt
  pi-enabled tags, mirroring the OMOS section's pattern.
- Regenerate DOCKER_HUB.md (22975 bytes, well under the 25k Hub limit).

The pi section itself remains intentionally dropped from DOCKER_HUB.md
to fit the 25k limit (SECTION_RULES); the Image Variants table at the
top is sufficient signal for Hub readers.
2026-05-08 21:14:01 +02:00
joakimp f46c4ed017 CI matrix: add with-pi and omos-with-pi build variants
Validate / docs-check (push) Successful in 39s
Validate / validate-base (push) Successful in 13m40s
Validate / validate-omos (push) Successful in 19m15s
Validate / validate-with-pi (push) Successful in 13m53s
Validate / validate-omos-with-pi (push) Successful in 18m26s
Publish Docker Image / smoke-base (push) Successful in 12m21s
Publish Docker Image / smoke-with-pi (push) Successful in 14m17s
Publish Docker Image / smoke-omos (push) Successful in 16m55s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 16m22s
Publish Docker Image / build-base (push) Successful in 40m52s
Publish Docker Image / build-with-pi (push) Successful in 47m32s
Publish Docker Image / build-omos (push) Successful in 51m41s
Publish Docker Image / build-omos-with-pi (push) Successful in 56m44s
Publish Docker Image / update-description (push) Successful in 15s
.gitea/workflows/validate.yml:
  Adds validate-with-pi (INSTALL_PI=true) and validate-omos-with-pi
  (INSTALL_OMOS=true + INSTALL_PI=true). amd64 single-arch with smoke
  test, no push.

.gitea/workflows/docker-publish.yml:
  Adds smoke-with-pi → build-with-pi and smoke-omos-with-pi →
  build-omos-with-pi job pairs. Each push-by-digest multi-arch
  (amd64+arm64) to Docker Hub with two tags:
    ${VERSION}-with-pi      + latest-with-pi
    ${VERSION}-omos-with-pi + latest-omos-with-pi
  update-description.needs[] extended to wait on both new build jobs.

scripts/smoke-test.sh:
  bun-presence check now treats omos and omos-with-pi as the bun
  variants. Pi state assertions wait up to 30s for entrypoint-user.sh
  to finish deploying pi-toolkit + extensions (omos-with-pi has more
  setup work than the base+pi path; the previous sleep-1 was too short
  and caused empty-error assertion failures on cold starts).

Local verification (arm64 via OrbStack):
  base            → 1871 MB, all checks PASS
  omos            → 2813 MB, all checks PASS
  with-pi         → 2277 MB, all checks PASS
  omos-with-pi    → 3030 MB, all checks PASS

CI now produces 8 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
2026-05-08 13:53:08 +02:00
joakimp bf811f2170 Merge main (v1.14.41 bump) into feat/install-pi
# Conflicts:
#	CHANGELOG.md
2026-05-08 13:46:03 +02:00
joakimp c76b1e8aa3 Bump opencode to 1.14.41
Validate / docs-check (push) Successful in 1m4s
Validate / validate-base (push) Successful in 13m20s
Validate / validate-omos (push) Successful in 24m0s
Publish Docker Image / smoke-base (push) Successful in 12m13s
Publish Docker Image / smoke-omos (push) Successful in 16m34s
Publish Docker Image / build-base (push) Successful in 39m22s
Publish Docker Image / build-omos (push) Successful in 51m43s
Publish Docker Image / update-description (push) Successful in 14s
Restored formatter output handling for stdout/stderr-writing formatters;
warping a session to another workspace can now carry over uncommitted
file changes; restored custom provider setup in /connect; macOS Settings
menu entry; desktop local server split into a separate utility process;
ACP clients restore last model/mode/effort when loading sessions and can
close sessions cleanly.

No container-level changes.
2026-05-08 13:08:50 +02:00
joakimp 23bf383a37 Fix mempalace init hang on stdin in docker run -it
mempalace init has an interactive 'Mine this directory now? [Y/n]' prompt
at the end that --yes does not auto-answer in all paths (notably empty
or near-empty workspaces). The entrypoint redirected stdout/stderr to
/dev/null but left stdin connected to the TTY. When invoked from
'docker run -it' the process blocked forever on stdin with 0% CPU,
silently — the user's symptom of 'still hangs at Initializing MemPalace
for workspace'.

Fix: redirect stdin from /dev/null too. EOF on stdin makes the prompt
fall through to its default (skip), and the process exits cleanly.

Verified locally: fresh-container start now completes in 1.3 seconds
(vs hanging indefinitely).
2026-05-08 00:36:02 +02:00
joakimp 5006b01170 Pre-warm chromadb embedding model at build time
Mempalace's embedding function is chromadb's ONNXMiniLM_L6_V2, which
downloads ~80 MB of all-MiniLM-L6-v2 ONNX weights from chromadb's CDN
on first use. Without pre-warming this happened silently in the
entrypoint init step (output redirected to /dev/null) and stalled
first container start by multiple minutes on slow networks — the
symptom user reported as 'hangs at Initializing MemPalace for
workspace'.

Fix: invoke the embedding function once at build time as gosu
developer so the cache lands at the runtime user's
~/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ with correct ownership
and survives container recreate (cache path is not on a named
volume, so it lives in the image layer).

Build-time cost: ~3-5 s to download. Runtime saving: minutes per
fresh container.

Image size: 2110 → 2277 MB for the with-pi variant. Still within
the 2700 MB smoke-test threshold.
2026-05-08 00:25:22 +02:00
joakimp f51e9f52a1 Add INSTALL_PI build arg for pi as second harness
Optional integration of pi-coding-agent alongside opencode in the same
container. Both harnesses share the mempalace install and palace path —
wing/diary entries are mutually visible.

Build:
  --build-arg INSTALL_PI=true              # opt-in
  --build-arg PI_VERSION=0.73.1            # pin a version (default: latest)
  --build-arg INSTALL_OPENCODE=false       # build pi-only image

Dockerfile:
  • New INSTALL_PI block: npm install -g @mariozechner/pi-coding-agent
    + git-clones pi-toolkit and pi-extensions to /opt/.
  • Existing opencode install gated behind new INSTALL_OPENCODE arg
    (default true; existing builds unaffected).
  • mkdir adds ~/.pi/agent/extensions for the named volume mount root.
  • CMD changed from ['opencode'] to ['bash', '-l']. compose run --rm
    devbox now drops to a login shell so users pick the harness; pass
    'opencode' or 'pi' explicitly to launch directly. compose exec
    workflows are unaffected (bypass entrypoint+CMD).

entrypoint.sh:
  • Adds ~/.pi to volume ownership loop.

entrypoint-user.sh:
  • New 'pi: deploy toolkit + extensions + mempalace bridge' block runs
    pi-toolkit/install.sh, pi-extensions/install.sh, settings.json
    template bootstrap, then symlinks the mempalace.ts bridge directly.
    Order: toolkit before extensions before bridge. mempalace-toolkit's
    full install.sh is intentionally NOT called (its install_skill
    would race with skillset auto-deploy --prune-stale).

docker-compose.yml:
  • New devbox-pi-config named volume mounted at /home/developer/.pi.
    Persists user toggles (/ext-disabled extensions) and settings.json
    edits across container recreate. Mirrors devbox-opencode-config
    pattern from v1.14.33.

scripts/smoke-test.sh:
  • New --variant with-pi (threshold 2700 MB) and --variant omos-with-pi
    (3400 MB).
  • Pi assertions gated on `command -v pi`: version, /opt/pi-toolkit
    clone HEAD, /opt/pi-extensions clone HEAD, deployed keybindings
    symlink, ≥4 extension symlinks, mempalace.ts bridge symlink,
    settings.json bootstrap.
  • Pi state assertions use docker exec from the host (not 'run'),
    since the container has no docker CLI.
  • opencode core test now gated on INSTALL_OPENCODE presence.

scripts/generate-dockerhub-md.py:
  • SECTION_RULES adds 'pi (alternative/complementary harness)': drop.
    Section stays in README; dropped from DOCKER_HUB.md to keep under
    the 25 kB Docker Hub limit.

Docs:
  • README adds full 'pi (alternative/complementary harness)' section.
  • AGENTS.md codifies pi install contract, deploy ordering, named
    volume rationale, and CMD change.
  • CHANGELOG.md gets an Unreleased entry.
  • .env.example documents new build args.
  • docker-compose.yml example args block updated.

Verification (local builds on arm64):
  • Default (INSTALL_PI=false): 1871 MB, all assertions pass — no
    regression.
  • INSTALL_PI=true: 2110 MB (within 2700 threshold), 37 assertions
    pass including pi version, all 7 extensions deployed (6 from
    pi-extensions + mempalace.ts bridge), settings.json bootstrap.

Not yet:
  • CI workflow updates to add -with-pi tag variants. Deferred until
    local path stabilizes through user testing.
  • pi-devbox separate repo for fully stripped pi-only image. Phase 2.
2026-05-07 23:58:37 +02:00
joakimp a208b073b0 Bump opencode to 1.14.40
Validate / docs-check (push) Successful in 1m5s
Validate / validate-base (push) Successful in 16m54s
Validate / validate-omos (push) Successful in 21m36s
Publish Docker Image / smoke-base (push) Successful in 11m17s
Publish Docker Image / smoke-omos (push) Successful in 14m52s
Publish Docker Image / build-base (push) Successful in 39m10s
Publish Docker Image / build-omos (push) Successful in 52m16s
Publish Docker Image / update-description (push) Successful in 14s
Rolls up upstream v1.14.34 through v1.14.40 (no v1.14.36 was published).
Highlights: .well-known/opencode remote config support; HTTP_PROXY honored
in desktop app; CORS/CSP fixes; warp-session-to-workspace feature; PTY
auth tickets; v2 session failure events; debug info CLI command. No
container-level changes.
2026-05-07 10:52:50 +02:00
joakimp a803fe4653 Fix smoke-test JSONC parsing to respect URLs
Validate / docs-check (push) Successful in 20s
Validate / validate-base (push) Successful in 13m50s
Validate / validate-omos (push) Successful in 14m37s
Publish Docker Image / smoke-base (push) Successful in 12m14s
Publish Docker Image / smoke-omos (push) Successful in 12m52s
Publish Docker Image / build-base (push) Successful in 43m6s
Publish Docker Image / build-omos (push) Successful in 45m45s
Publish Docker Image / update-description (push) Successful in 13s
The previous 'sed "s|//.*$||"' approach greedily stripped '//' from
URLs like https://mcp.context7.com/mcp, corrupting the JSON and causing
smoke-test failures with json.decoder.JSONDecodeError. Replaced the sed
step with a Python regex that respects string literals so URLs pass
through while only line comments are removed.
2026-05-03 10:34:16 +02:00
joakimp 79b697dea0 Bump opencode to 1.14.33
Validate / docs-check (push) Successful in 1m16s
Validate / validate-base (push) Has started running
Validate / validate-omos (push) Has started running
Publish Docker Image / smoke-base (push) Has been cancelled
Publish Docker Image / smoke-omos (push) Has been cancelled
Publish Docker Image / build-base (push) Has been cancelled
Publish Docker Image / build-omos (push) Has been cancelled
Publish Docker Image / update-description (push) Has been cancelled
2026-05-03 10:31:17 +02:00
Joakim Persson 3e3abc8672 Update docs for named volume config, skillset auto-deploy, opencode.jsonc
Validate / docs-check (push) Successful in 15s
Validate / validate-base (push) Failing after 10m35s
Validate / validate-omos (push) Failing after 13m15s
- README: rewrite config/skills sections for named volume and auto-deploy,
  add Context7 MCP docs, update all opencode.json→opencode.jsonc refs,
  add SKILLSET_CONTAINER_PATH to env var table
- CHANGELOG: add v1.14.32b entry documenting breaking changes and features
- AGENTS.md: update file roles, add skillset and config volume conventions
- DOCKER_HUB.md: regenerated (drop Context7 and Shell defaults sections
  to stay within 25KB Docker Hub limit)
- generate-dockerhub-md.py: add Context7 (drop) and Shell defaults (drop)
  to SECTION_RULES
2026-05-02 23:00:41 +00:00
Joakim Persson 59e58a9d00 Use named volume for opencode config instead of host bind mount
Validate / docs-check (push) Successful in 14s
Validate / validate-base (push) Has started running
Validate / validate-omos (push) Has been cancelled
Switching from a host bind mount (~/.config/opencode) to a named volume
(devbox-opencode-config) eliminates the symlink conflict between host
and container environments. Each manages its own skill/instruction
symlinks independently, allowing native opencode and containerized
opencode to coexist on the same machine.

Also removes the ~/.agents/skills bind mount recommendation — the
container manages its own skills directory via the entrypoint deploy,
and sharing it with the host causes relative-path conflicts.
2026-05-02 22:50:09 +00:00
Joakim Persson 26ce9aa490 Auto-deploy skillset on container start for portable skill resolution
Validate / docs-check (push) Successful in 18s
Validate / validate-base (push) Failing after 11m8s
Validate / validate-omos (push) Failing after 15m55s
Add entrypoint logic to detect and run the skillset deploy script on
container start. Detection order: SKILLSET_CONTAINER_PATH env var,
then ~/skillset dedicated mount, then /workspace/skillset fallback.

The deploy script (from the skillset repo) creates relative symlinks
that resolve inside the container regardless of the host path layout.

Also adds SKILLSET_PATH volume mount option to docker-compose files
and documents SKILLSET_CONTAINER_PATH in .env.example for hosts where
the skillset lives in a workspace subdirectory.
2026-05-02 22:21:57 +00:00
Joakim Persson 3d4e739529 Add Context7 remote MCP server to auto-generated config
Validate / docs-check (push) Successful in 18s
Validate / validate-base (push) Failing after 11m26s
Validate / validate-omos (push) Failing after 13m14s
Context7 provides up-to-date library documentation for LLMs via a
remote endpoint — no local binary needed. Always registered since it
has no PATH dependency.

Also switches generated config from .json to .jsonc so we can include
a comment about the optional API key for higher rate limits. The
existing-config check now detects both file extensions.
2026-05-02 21:24:04 +00:00
joakimp a6b0b59946 Bump opencode to 1.14.32
Validate / docs-check (push) Successful in 22s
Validate / validate-omos (push) Successful in 15m3s
Validate / validate-base (push) Successful in 16m13s
Publish Docker Image / smoke-base (push) Successful in 11m53s
Publish Docker Image / smoke-omos (push) Successful in 13m33s
Publish Docker Image / build-base (push) Successful in 42m37s
Publish Docker Image / build-omos (push) Successful in 47m20s
Publish Docker Image / update-description (push) Successful in 14s
2026-05-02 18:04:00 +02:00
Joakim Persson fc74a8f906 Collapse per-arch matrix back into single multi-arch push jobs
Validate / docs-check (push) Successful in 17s
Validate / validate-omos (push) Successful in 14m21s
Validate / validate-base (push) Successful in 14m50s
Publish Docker Image / smoke-base (push) Successful in 11m12s
Publish Docker Image / smoke-omos (push) Successful in 22m0s
Publish Docker Image / build-base (push) Successful in 42m25s
Publish Docker Image / build-omos (push) Failing after 1h16m24s
Publish Docker Image / update-description (push) Has been cancelled
v1.14.31c's matrix jobs failed on Upload digest with GHESNotSupportedError
— Gitea Actions doesn't support actions/upload-artifact@v4+.
Separately, build-omos arm64 hung silently for 12 min in Set-up job,
likely catthehacker pull contention between concurrent matrix children.

Rather than downgrade artifacts to @v3, collapse the matrix entirely.
docker/build-push-action@v7 with platforms: linux/amd64,linux/arm64
publishes a proper multi-arch manifest in one job, so the
artifact-passing and imagetools create merge dance only existed to
support a matrix split we no longer need.

The matrix was designed around load: true disk exhaustion (v1.14.30b),
but push-by-digest streams straight to the registry with fundamentally
different disk profile. Reclaim step gives enough headroom for the
combined amd64+arm64 push case.

Workflow: 7 jobs → 5. docker-publish.yml: 263 → ~110 lines of YAML.

Also:
- timeout-minutes: 90 on build jobs so hung builds fail explicitly
- BUILDKIT_PROGRESS=plain at workflow level for line-by-line arm64 logs
- AGENTS.md §CI quirks documents the Gitea-specific traps
  (upload-artifact@v3-only, dash-not-bash, build-push-action@v7
  multi-arch convention, reclaim requirement)
2026-05-01 12:28:34 +00:00
Joakim Persson 5a2d06340e Fix dash-incompatible slash substitution and bump omos size threshold
Validate / docs-check (push) Successful in 18s
Validate / validate-base (push) Successful in 15m44s
Validate / validate-omos (push) Successful in 15m21s
Publish Docker Image / smoke-base (push) Successful in 14m30s
Publish Docker Image / smoke-omos (push) Successful in 15m51s
Publish Docker Image / build-base (linux/amd64) (push) Failing after 10m58s
Publish Docker Image / build-omos (linux/amd64) (push) Failing after 15m9s
Publish Docker Image / build-omos (linux/arm64) (push) Failing after 11m57s
Publish Docker Image / build-base (linux/arm64) (push) Failing after 39m30s
Publish Docker Image / merge-base (push) Has been skipped
Publish Docker Image / merge-omos (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
v1.14.31b made it through smoke-base and validate-base (reclaim worked),
but two narrow bugs blocked the rest:

1. 'Derive platform slug' in the per-arch matrix jobs used bash
   ${PLATFORM_PAIR//\//-} which dash (/bin/sh in the runner) can't
   parse — 'Bad substitution'. Rewrote with 'tr / -'.

2. smoke-omos image size 3107 MB tripped the 3000 MB guardrail. All
   functional checks pass; the mempalace-toolkit bake-in from v1.14.30b
   added ~100 MB and the threshold was stale. Bumped to 3200 MB.

No image-level changes.
2026-05-01 10:43:04 +00:00
Joakim Persson 23894bc19f Reclaim runner disk before load: true smoke builds
Validate / docs-check (push) Successful in 22s
Validate / validate-base (push) Successful in 18m10s
Validate / validate-omos (push) Failing after 25m54s
Publish Docker Image / smoke-base (push) Successful in 11m50s
Publish Docker Image / build-base (linux/amd64) (push) Failing after 38s
Publish Docker Image / build-base (linux/arm64) (push) Failing after 21s
Publish Docker Image / merge-base (push) Has been skipped
Publish Docker Image / smoke-omos (push) Failing after 19m18s
Publish Docker Image / build-omos (linux/amd64) (push) Has been skipped
Publish Docker Image / build-omos (linux/arm64) (push) Has been skipped
Publish Docker Image / merge-omos (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
v1.14.31 publish and validate both hit 'No space left on device' on
single-arch amd64 smoke/validate builds. The image has crossed ~3 GB
and the runner's ~40 GB overlay starts ~70% full, so 'load: true'
peak disk (tarball + unpacked image + buildx cache) no longer fits.

Add a 'Reclaim runner disk' step to validate-base, validate-omos,
smoke-base, smoke-omos. Strips catthehacker-resident toolchains we
never use (hosted-tool-cache, dotnet, android, powershell, swift,
ghc, jvm, microsoft, chromium, boost), then runs 'docker system
prune -af --volumes' + 'docker builder prune -af' against the
runner's dockerd before setup-buildx-action. Expected reclaim is
6-12 GB depending on what's resident.

Deliberately NOT in the per-arch matrix build jobs — push-by-digest
doesn't need it and pruning in parallel jobs risks one job nuking
another's in-flight buildx cache. Also add workflow-level
concurrency on docker-publish.yml so concurrent tag pushes serialize
cleanly.
2026-05-01 09:34:52 +00:00
18 changed files with 2048 additions and 1239 deletions
+54
View File
@@ -31,6 +31,31 @@ WORKSPACE_PATH=~/projects
# Path to SSH keys on host
SSH_KEY_PATH=~/.ssh
# ── Skillset (agent skills and instructions) ─────────────────────────
# If you have a skillset repo, the entrypoint auto-deploys skills and
# instructions on container start using relative symlinks (portable
# across host/container).
#
# Detection is automatic if the skillset lives directly at the workspace
# root (i.e. WORKSPACE_PATH/skillset → /workspace/skillset in container).
#
# If the skillset lives in a subdirectory of your workspace, set
# SKILLSET_CONTAINER_PATH to its location *inside the container*. This
# is determined by the workspace mount: whatever is at
# WORKSPACE_PATH/<subpath> on the host becomes /workspace/<subpath>
# in the container.
#
# Examples:
# Host skillset at ~/projects/skillset → already at /workspace/skillset (auto-detected, no config needed)
# Host skillset at ~/projects/tools/skillset → SKILLSET_CONTAINER_PATH=/workspace/tools/skillset
# Host skillset at ~/projects/local/skillset → SKILLSET_CONTAINER_PATH=/workspace/local/skillset
#
# Alternatively, mount the skillset repo at a dedicated path using the
# SKILLSET_PATH volume in docker-compose.yml (see comments there). In
# that case the entrypoint finds it at ~/skillset automatically.
#
# SKILLSET_CONTAINER_PATH=
# ── Locale (defaults to en_US.UTF-8) ─────────────────────────────────
# LANG=sv_SE.UTF-8
# LANGUAGE=sv_SE:sv
@@ -42,3 +67,32 @@ SSH_KEY_PATH=~/.ssh
# OMOS_TMUX=false # Enable tmux multiplexer integration
# OMOS_SKILLS=true # Install recommended skills (simplify, agent-browser, cartography)
# OMOS_RESET=false # Force regenerate oh-my-opencode-slim config on next start
# ── pi coding-agent (alternative/complementary harness) ─────────────────
# Requires image built with INSTALL_PI=true.
# When the image is built with both INSTALL_OPENCODE=true (default) and
# INSTALL_PI=true, both harnesses share the same mempalace install and
# palace path — wing data is mutually visible to either harness.
#
# Pi version is baked at build time via PI_VERSION (default: latest at
# build). The baked `pi` binary is at /usr/bin/pi (system npm prefix);
# rebuild the image to upgrade it. NPM_CONFIG_PREFIX is set to
# /home/developer/.pi/npm-global, so anything installed via
# `pi install npm:...` or `npm install -g` as the developer user
# (themes, skills, extensions, including a user-installed pi itself)
# lands on the named volume and survives container recreate AND image
# rebuilds. A user-installed pi wins via PATH order over the baked one.
#
# Pi config (settings.json, extensions toggle state, sessions, auth) persists in the
# devbox-pi-config named volume mounted at ~/.pi/.
#
# To launch pi from a `compose run` invocation:
# docker compose run --rm devbox pi
# To attach to a running container:
# docker compose exec -u developer devbox pi
# Default `compose run` (no args) drops to bash; pick the harness yourself.
#
# Build args (set in docker-compose.yml or via --build-arg on docker build):
# INSTALL_PI=true # default false; opt-in
# PI_VERSION=latest # pin a specific version, e.g. 0.73.0
# INSTALL_OPENCODE=false # build a pi-only image (still has Bun in -omos)
+282
View File
@@ -0,0 +1,282 @@
# 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: │
│ Dockerfile.base│
│ rootfs/ │
│ entrypoint*.sh │
└────────┬─────────┘
┌─────────────┴─────────────┐
│ 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`
Compute a SHA-256 hash over the inputs that determine the base image's
content:
```sh
{
cat Dockerfile.base
find rootfs -type f -print0 | sort -z | xargs -0 cat
cat entrypoint.sh entrypoint-user.sh
} | sha256sum | cut -c1-12
```
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.
### 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`.
+583
View File
@@ -0,0 +1,583 @@
name: Publish Docker Image
# 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/
# + entrypoints; probe Docker Hub for existing tag.
# 2. build-base only if probe missed; multi-arch push of base-<hash>.
# 3. smoke-* (×4) amd64-only build of each variant FROMing the base
# tag; runs scripts/smoke-test.sh.
# 4. build-variant-* multi-arch push of each variant tag (the user-
# (×4) facing release tags, unchanged in shape).
# 5. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`
# (manifest copy, no rebuild).
# 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.50). Used only for workflow_dispatch runs — tag-triggered runs derive the tag from github.ref.'
required: false
default: ''
promote_latest:
description: 'Update latest/* aliases (default true for tag-push, set false for manual test runs)'
required: false
default: 'false'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
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
# stale docker state. Identical to the production workflow's pattern.
# ───────────────────────────────────────────────────────────────────
jobs:
# ── Phase 1: decide whether base needs rebuilding ──────────────────
base-decide:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
outputs:
base_tag: ${{ steps.compute.outputs.base_tag }}
need_build: ${{ steps.probe.outputs.need_build }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compute base tag from Dockerfile.base + dependencies
id: compute
run: |
# Hash inputs that determine the base image's contents.
# Order is fixed via `find -print0 | sort -z` for reproducibility.
HASH=$(
{
cat Dockerfile.base
find rootfs -type f -print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
cat entrypoint.sh entrypoint-user.sh
} | sha256sum | cut -c1-12
)
BASE_TAG="base-${HASH}"
echo "base_tag=${BASE_TAG}" >> "$GITHUB_OUTPUT"
echo "Computed base tag: ${BASE_TAG}"
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Probe Docker Hub for existing base tag
id: probe
run: |
set +e
docker manifest inspect "${IMAGE}:${{ steps.compute.outputs.base_tag }}" \
> /dev/null 2>&1
PROBE_RC=$?
set -e
if [ "${PROBE_RC}" = "0" ]; then
echo "need_build=false" >> "$GITHUB_OUTPUT"
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} exists — skipping rebuild."
else
echo "need_build=true" >> "$GITHUB_OUTPUT"
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
fi
# ── Phase 2: build & push base (multi-arch), only when needed ──────
build-base:
needs: [base-decide]
if: needs.base-decide.outputs.need_build == 'true'
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 prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
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@v3
with:
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
# ── Phase 3: amd64 smoke per variant (gates the multi-arch publish) ─
# Each smoke job builds amd64-only against the base tag and runs
# scripts/smoke-test.sh. base-decide.outputs.base_tag is always set;
# build-base may have been skipped (cache hit) but the tag exists either way.
smoke-base:
needs: [base-decide, build-base]
if: |
always() &&
needs.base-decide.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build amd64 variant for smoke
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-base
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=false
- name: Smoke test (amd64)
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
smoke-omos:
needs: [base-decide, build-base]
if: |
always() &&
needs.base-decide.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-omos
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=false
- run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
smoke-with-pi:
needs: [base-decide, build-base]
if: |
always() &&
needs.base-decide.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-with-pi
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=true
- run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
smoke-omos-with-pi:
needs: [base-decide, build-base]
if: |
always() &&
needs.base-decide.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-omos-with-pi
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
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
# ── Phase 4: multi-arch publish per variant ────────────────────────
build-variant-base:
needs: [base-decide, smoke-base]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=false
tags: ${{ steps.tags.outputs.tags }}
build-variant-omos:
needs: [base-decide, smoke-omos]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-omos"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-omos"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=false
tags: ${{ steps.tags.outputs.tags }}
build-variant-with-pi:
needs: [base-decide, smoke-with-pi]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-with-pi"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-with-pi"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=true
tags: ${{ steps.tags.outputs.tags }}
build-variant-omos-with-pi:
needs: [base-decide, smoke-omos-with-pi]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-omos-with-pi"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-omos-with-pi"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=true
tags: ${{ steps.tags.outputs.tags }}
# ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
promote-base-latest:
needs:
- base-decide
- build-variant-base
- build-variant-omos
- build-variant-with-pi
- build-variant-omos-with-pi
if: ${{ github.ref_type == 'tag' || inputs.promote_latest == 'true' }}
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: imjasonh/setup-crane@v0.4
- name: Login (crane)
run: |
crane auth login docker.io \
-u ${{ vars.DOCKERHUB_USERNAME }} \
-p "${{ secrets.DOCKERHUB_TOKEN }}"
- name: Re-tag base-<hash> as base-latest
run: |
crane copy \
${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \
${{ env.IMAGE }}:base-latest
# ── Phase 6: update Hub description (only on real release runs) ────
update-description:
needs:
- build-variant-base
- build-variant-omos
- build-variant-with-pi
- build-variant-omos-with-pi
if: ${{ github.ref_type == 'tag' || inputs.promote_latest == 'true' }}
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- 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 @-)
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
-327
View File
@@ -1,327 +0,0 @@
name: Publish Docker Image
on:
push:
tags:
- 'v*'
# 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). Building both
# architectures of both variants on a single runner exhausted disk around the
# nodejs dpkg unpack / git-lfs layer export. To fix this:
# * smoke test (amd64 only, load into daemon) runs on its own runner
# * each push target (variant × arch) runs on its own runner, pushes by
# digest (no local image store), uploads digest as an artifact
# * a merge job composes the multi-arch manifest with `imagetools create`
# Per-runner disk pressure is now one-quarter of the old single-job peak.
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
- 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: 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
# ── Per-arch push (by digest, no local image) ───────────────────────
build-base:
runs-on: ubuntu-latest
needs: smoke-base
container:
image: catthehacker/ubuntu:act-latest
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
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: Derive platform slug
id: platform
run: |
PLATFORM_PAIR="${{ matrix.platform }}"
echo "pair=${PLATFORM_PAIR//\//-}" >> $GITHUB_OUTPUT
- name: Set up QEMU
if: matrix.platform != 'linux/amd64'
uses: docker/setup-qemu-action@v4
- 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: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-base-${{ steps.platform.outputs.pair }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-omos:
runs-on: ubuntu-latest
needs: smoke-omos
container:
image: catthehacker/ubuntu:act-latest
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
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: Derive platform slug
id: platform
run: |
PLATFORM_PAIR="${{ matrix.platform }}"
echo "pair=${PLATFORM_PAIR//\//-}" >> $GITHUB_OUTPUT
- name: Set up QEMU
if: matrix.platform != 'linux/amd64'
uses: docker/setup-qemu-action@v4
- 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: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.platform }}
build-args: |
INSTALL_OMOS=true
outputs: type=image,name=${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-omos-${{ steps.platform.outputs.pair }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# ── Merge per-arch digests into multi-arch tags ─────────────────────
merge-base:
runs-on: ubuntu-latest
needs: build-base
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-base-*
merge-multiple: true
- 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: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
-t ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }} \
-t ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest \
$(printf '${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect \
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}
merge-omos:
runs-on: ubuntu-latest
needs: build-omos
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-omos-*
merge-multiple: true
- 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: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
-t ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-omos \
-t ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-omos \
$(printf '${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect \
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-omos
update-description:
runs-on: ubuntu-latest
needs: [merge-base, merge-omos]
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
+219 -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:
@@ -46,6 +89,34 @@ jobs:
run: |
echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
# The runner's overlay disk starts ~70% full. `load: true` peak disk
# is tarball + unpacked image + buildx cache, which tips it over
# once the image crosses ~3 GB. Strip catthehacker-resident
# toolchains we never use 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:
@@ -55,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
@@ -76,6 +150,30 @@ jobs:
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:
@@ -85,13 +183,132 @@ 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
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-omos --variant omos
validate-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 with-pi image (amd64, load to local daemon)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
build-args: |
BASE_IMAGE=joakimp/opencode-devbox:base-latest
INSTALL_PI=true
tags: opencode-devbox:ci-with-pi
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-with-pi --variant with-pi
validate-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 omos+with-pi image (amd64, load to local daemon)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
build-args: |
BASE_IMAGE=joakimp/opencode-devbox:base-latest
INSTALL_OMOS=true
INSTALL_PI=true
tags: opencode-devbox:ci-omos-with-pi
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-omos-with-pi --variant omos-with-pi
+34 -15
View File
@@ -2,43 +2,57 @@
## 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` — single multi-stage build for both variants. OMOS variant is controlled by `INSTALL_OMOS=true` build arg; mempalace is controlled by `INSTALL_MEMPALACE` (default `true`). 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.
- `entrypoint-user.sh` — runs as developer: git config, opencode.json generation (delegated to `generate-config.py`), OMOS setup.
- `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.json` from env vars. Never overwrites an existing config. Auto-registers MCP servers for detected tools (mempalace via the `mempalace-mcp` entry point, gitea-mcp).
- `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).
- `scripts/smoke-test.sh` — post-build image verification. Asserts binary presence, opencode startup, entrypoint correctness, config generation idempotency, and image size thresholds. Used by both CI workflows.
- `scripts/generate-dockerhub-md.py` — generates `DOCKER_HUB.md` from `README.md` using explicit section rules. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow).
- `DOCKER_HUB.md`**auto-generated** from README. 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. Sections are selected/dropped/replaced for DOCKER_HUB.md per `SECTION_RULES` in `scripts/generate-dockerhub-md.py`.
- `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` — 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` 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.
CI produces four Docker Hub tags per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`.
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.
## Critical conventions
- **entrypoint.sh volume ownership loop** — when adding a new named volume mount point, add it to the `for dir in ...` loop in `entrypoint.sh` so root-owned volumes get chowned on startup. The loop writes a `.devbox-owner` sentinel after a successful chown so subsequent starts skip the recursive walk. Users should not touch these files.
- **Two docs to keep in sync (automated)** — `README.md` is the source of truth. `DOCKER_HUB.md` is auto-generated by `scripts/generate-dockerhub-md.py`. When adding a new top-level section to README, either add it to `SECTION_RULES` in that script or the `--check` run will fail CI. `.env.example` must still be hand-updated to match Dockerfile/entrypoint behavior.
- **Documentation coupling on release** — four docs co-vary and drift in lockstep when not updated together:
- `README.md` is the source of truth for user-facing build/run/config detail.
- `DOCKER_HUB.md` is auto-generated from `HUB_TEMPLATE` in `scripts/generate-dockerhub-md.py`. CI's `--check` run fails if it's stale. Hub-facing copy is intentionally slim (~5.5 kB, ~78% headroom against the 25 kB Hub limit) — update the template here when image variants, quick-start flow, or the elevator pitch change. README.md no longer feeds into Hub, so README edits do NOT require regenerating DOCKER_HUB.md.
- `CHANGELOG.md` records every release. When cutting a tag, **promote `## Unreleased` to `## vX.Y.Z[n] — YYYY-MM-DD` BEFORE pushing the tag** so the tag points at a CHANGELOG that names itself. Keep entries reverse-chronological (newest at top, after the `Unreleased` block). Doc-only updates that happen post-tag (Hub description live-patches, README clarifications) get a fresh `## Unreleased` block with a note that they don't trigger a new image build.
- `AGENTS.md` (this file) carries domain facts that change on structural releases — tag-count statements, CI job lists, install contracts. After any change to `.gitea/workflows/*.yml` or the variant matrix, grep this file for stale numbers (`grep -nE "four|eight|all [0-9]"`).
- `.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.
- **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`.
- **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.json` — system Python can't import from the uv venv.
- **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.json`. Users bind-mount their config directory or persist it across container recreations; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this.
- **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.
- **Skillset auto-deploy** — on every container start, `entrypoint-user.sh` looks for a skillset repo (detection order: `$SKILLSET_CONTAINER_PATH``$HOME/skillset``/workspace/skillset`) and runs `deploy-skills.sh --bootstrap --prune-stale`. This creates relative symlinks in `~/.agents/skills/` and `~/.config/opencode/instructions/`. Do NOT bind-mount `~/.agents/skills/` from the host — the container manages its own skills with relative symlinks that differ from the host's. The named volume `devbox-opencode-config` persists the deployed config across restarts.
- **Config persistence via named volume** — `devbox-opencode-config` is a Docker named volume mounted at `~/.config/opencode/`. It is NOT a host bind mount by default. This separation allows both native and containerized opencode to coexist on the same machine without symlink conflicts. Users who need to override can replace the named volume with a host bind mount in their compose file. **Same pattern for pi:** `devbox-pi-config` is mounted at `~/.pi/` and persists user toggles (`/ext`-disabled extensions), `~/.pi/agent/settings.json` edits, and — because `NPM_CONFIG_PREFIX` is set to `~/.pi/npm-global` — anything installed via `pi install npm:...` or `npm install -g` as the developer user, across container recreate AND image rebuild.
- **pi install contract** — `INSTALL_PI=true` (default false) opt-in build arg. The baked `pi` binary is npm-installed globally to `/usr` at build time (system prefix). At runtime, `NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global` is set in the image ENV with that prefix's `bin/` prepended to `PATH` — so any `pi install npm:...` or `npm install -g` invoked by the developer user lands on the named volume and survives everything except `docker compose down -v`. The new ENVs are declared *after* all build-time `npm install -g` calls in the Dockerfile so they don't redirect the baked installs into a path that the volume mount would later shadow. If the user runs `npm install -g @earendil-works/pi-coding-agent` themselves, the user-installed copy on the volume wins via `PATH` order; otherwise image rebuild is the upgrade path for the baked pi (same contract as `OPENCODE_VERSION`). The pi-toolkit and pi-extensions repos are git-cloned into `/opt/` at build time, then their `install.sh` runs from `entrypoint-user.sh` on each container start to symlink into `~/.pi/agent/` (which lives on the named volume). The mempalace pi-bridge is symlinked manually from `/opt/mempalace-toolkit/extensions/pi/mempalace.ts` — we do NOT call mempalace-toolkit's full `install.sh` because its `install_skill` step would race with skillset auto-deploy `--prune-stale`.
- **Pi deploy ordering matters in entrypoint-user.sh** — `pi-toolkit` runs first (creates `keybindings.json` symlink and writes pi-env.zsh), then `pi-extensions`, then `settings.json` template bootstrap, then mempalace bridge symlink. mempalace-toolkit's `check_pi_toolkit` probe (when called from the host install path) expects keybindings to already be present — not currently called from container, but ordering matches host convention.
- **Default CMD is `bash -l`** — not a harness. `docker compose run --rm devbox` drops the user into a login shell to choose: `aws sso login`, then `opencode` or `pi` (or any tool). Pass the harness explicitly to launch directly: `docker compose run --rm devbox opencode` / `docker compose run --rm devbox pi`. `docker compose exec` bypasses entrypoint+CMD entirely (existing user workflow unchanged).
- **Docker Hub description update** — uses `/v2/auth/token` endpoint (not the deprecated `/v2/users/login`). Auth uses `identifier`/`secret` fields, returns `access_token`, sent as `Bearer`. Short description must be ≤100 bytes.
## CI quirks
@@ -47,6 +61,11 @@ When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile`
- `update-description` job runs only when both builds succeed (`needs: [build-base, build-omos]`).
- Tags must be pushed to trigger the publish workflow. The validate workflow runs on push to main and PRs.
- Smoke tests run on amd64 only (single-arch load into the local daemon). The multi-arch push happens after smoke passes.
- **Gitea Actions runner has ~40 GB disk, often 70%+ used at job start.** All eight `load: true` jobs (`validate-base`, `validate-omos`, `validate-with-pi`, `validate-omos-with-pi`, `smoke-base`, `smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner.
- **`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-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
+184
View File
@@ -6,6 +6,190 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a
---
## Unreleased
## 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:
- **Bump:** opencode 1.14.41 → 1.14.42 (`OPENCODE_VERSION` build-arg default in both `Dockerfile` and `Dockerfile.variant`).
- **Rename:** `npm install -g @mariozechner/pi-coding-agent` -> `npm install -g @earendil-works/pi-coding-agent` in the `INSTALL_PI=true` build path. Pi moved to its new home at earendil-works on 2026-05-07 (https://pi.dev/news/2026/5/7/pi-has-a-new-home); the old `@mariozechner/*` packages are deprecated on npm with the explicit message 'please use @earendil-works/pi-coding-agent instead going forward', and the version stream has moved on (old top-out 0.73.1; new currently 0.74.0). Anyone npm-installing the old name today gets a deprecation warning + a stale binary. Affects both `Dockerfile` (production single-Dockerfile path) and `Dockerfile.variant` (split-base path on main). README, AGENTS, and `HUB_TEMPLATE` URL refs updated from `github.com/mariozechner/pi-coding-agent` (which now 404s) to `github.com/earendil-works/pi`. Brew install references (`brew install pi-coding-agent`) left as-is: formula still works at 0.73.1 and a homebrew tap update is tracked upstream at earendil-works/pi#2755.
- **Fix:** `pi install npm:<pkg>` (and any `npm install -g`) by the `developer` user no longer EACCES against the system npm prefix. `NPM_CONFIG_PREFIX` is now `/home/developer/.pi/npm-global` and the prefix's `bin/` is prepended to `PATH`. The directory lives on the `devbox-pi-config` named volume, so user-installed pi packages (themes, skills, extensions) survive container recreation and image rebuilds. Build-time `npm install -g` calls (opencode, pi, oh-my-opencode-slim) are unaffected because the new ENVs are declared after those steps in the Dockerfile, so the baked binaries still install to `/usr` and are not shadowed by the volume mount.
- **Fix (smoke-test):** `scripts/smoke-test.sh` `oh-my-opencode-slim` check now invokes `npm ls -g` with `NPM_CONFIG_PREFIX=/usr` so it queries the system prefix where the baked install lives. Latent regression from the npm-prefix fix above: default `npm ls -g` started querying the user prefix (`/home/developer/.pi/npm-global`, empty at build time) and missed the baked OMOS install — surfaced when `validate.yml` ran on main after the merge of `feat/split-build`.
Docs:
- **Docs:** `DOCKER_HUB.md` `Image Variants` table now lists all four published variants (`latest`, `latest-omos`, `latest-with-pi`, `latest-omos-with-pi`) instead of only the first two. Generator (`scripts/generate-dockerhub-md.py`) HEADER updated to match.
- **Docs:** `DOCKER_HUB.md` is now generated from a hand-maintained `HUB_TEMPLATE` constant in `scripts/generate-dockerhub-md.py` instead of a section-by-section transformation of `README.md`. Drops from 24 997 bytes (3 byte headroom) to ~5.5 kB (~78% headroom). The old derive-from-README mechanism (`SECTION_RULES`, `TRIM_SUBSECTIONS`, `REPLACEMENTS`, `split_sections`, `trim_subsections`) is gone — README and Hub doc are now independent surfaces, and most README edits no longer require regenerating `DOCKER_HUB.md`. Trade-off: image-variants table and quick-start flow are now coupled to `HUB_TEMPLATE` and need a manual edit when they change.
- **Docs:** README pi section gains a `### Setup` paragraph mentioning the prebuilt `latest-with-pi` and `latest-omos-with-pi` Docker Hub tags, mirroring the OMOS section's `latest-omos` mention. "What gets installed" updated to reflect the actual shipped state: 7 pi-extensions (was stale at 6 — mcp-loader was added in pi-extensions but not propagated here), each with a one-line description; mcp-loader gets a paragraph covering its dual-transport (local stdio + remote streamable-HTTP per MCP spec 2025-03-26) capability and the `/mcp` slash command. Clarified that the mempalace bridge is a separate MCP entry point that coexists with mcp-loader rather than being replaced by it.
- **Docs:** AGENTS.md tag-scheme paragraph corrected from "four Docker Hub tags per release" to eight (the v1.14.41b CI matrix expansion). "Documentation coupling on release" rule updated — README edits no longer require regenerating `DOCKER_HUB.md`. Release-day checklist tightened.
- **README pi section:** "What gets installed" sub-section updated to reflect the actual shipped state. Was stale: claimed 6 pi-extensions (actually 7 — mcp-loader was added in pi-extensions commit 141bf64 / 7eec49b / 37cc49e but never propagated here). Each extension now has a one-line description; mcp-loader gets a paragraph covering its dual-transport (local stdio + remote streamable-HTTP per MCP spec 2025-03-26) capability and the `/mcp` slash command. Clarified that the mempalace bridge is a separate MCP entry point that coexists with mcp-loader rather than being replaced by it. Added an explicit note that no MCP servers are baked in beyond mempalace — the loader is opt-in via settings.json edits.
## v1.14.41b — 2026-05-08
**Optional pi as second harness.**
- **Feature:** New `INSTALL_PI=true` build arg installs [pi](https://github.com/earendil-works/pi) as an alternative or complementary harness alongside opencode. Both harnesses share the same mempalace install and palace path — wing/diary entries are mutually visible. Adds ~150 MB to the image. Pi version pinned by `PI_VERSION` (default: latest at build time); `pi update` inside the container does not persist across `--rm` containers — image rebuild is the upgrade path, same contract as `OPENCODE_VERSION`.
- **Feature:** New `INSTALL_OPENCODE=false` build arg builds an image without opencode (e.g. for pi-only use). Default remains `true`. Existing builds and tags are unaffected.
- **Feature:** New `devbox-pi-config` named volume mounted at `~/.pi/` persists pi user state (settings.json, `/ext`-disabled extensions) across container recreate. Mirrors the `devbox-opencode-config` pattern from v1.14.33.
- **Feature:** Container clones [pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit) (keybindings, env loader, settings template) and [pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions) (6 extensions including ext-toggle, todo, ssh-controlmaster, notify, git-checkpoint, confirm-destructive) into `/opt/` at build time. New `PI_TOOLKIT_REF` and `PI_EXTENSIONS_REF` build args (default `main`) pin git refs. The mempalace pi-bridge `mempalace.ts` is symlinked from the existing `/opt/mempalace-toolkit/` clone.
- **Behavior change:** Default container CMD changed from `["opencode"]` to `["bash", "-l"]`. `docker compose run --rm devbox` (no command) now drops to a login shell so users can pick `opencode` or `pi` (or run `aws sso login` first). To preserve the old behavior, pass the harness explicitly: `docker compose run --rm devbox opencode`. `docker compose exec` workflows are unaffected (they bypass the entrypoint and CMD).
- **Performance:** chromadb's all-MiniLM-L6-v2 ONNX embedding model (~80 MB) is now pre-warmed at image build time under `~/.cache/chroma/onnx_models/`. Without this, mempalace's `init` step in entrypoint-user.sh would download the model silently on first container start (suppressed via `>/dev/null 2>&1`), stalling startup by minutes on a fresh image. Pre-warming runs as `gosu developer` so the cache lands at the right path and is owned by the runtime user.
- **Bugfix:** entrypoint-user.sh now redirects stdin from `/dev/null` for the `mempalace init --yes` call. Without this, the interactive `Mine this directory now? [Y/n]` prompt at the end of init would silently block forever when the container was started with `docker run -it` (TTY keeps stdin open). EOF on stdin makes the prompt fall through to its default.
- **Smoke-test:** New `--variant with-pi` (threshold 2700 MB) and `--variant omos-with-pi` (3400 MB). Pi-specific assertions verify pi binary, pi-toolkit clone, pi-extensions clone, deployed keybindings symlink, extension count ≥ 4, mempalace bridge symlink, and settings.json bootstrap. Pi state assertions use `docker exec` from the host (not `run`-inside-container) since the container has no docker CLI.
- **CI:** `.gitea/workflows/{validate,docker-publish}.yml` extended with `with-pi` and `omos-with-pi` matrix entries. Each release now produces eight Docker Hub tags: `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`.
- **Docs:** README adds a "pi (alternative/complementary harness)" section. AGENTS.md codifies pi install contract, deploy ordering in entrypoint-user.sh, and rationale for not calling mempalace-toolkit's full `install.sh` from container.
## v1.14.41 — 2026-05-08
Bump opencode to 1.14.41.
- **v1.14.41 (upstream):** restored formatter output handling for stdout/stderr writes; warping a session to another workspace can now carry over uncommitted file changes; restored custom provider setup in `/connect`; macOS Settings menu entry added; desktop local server split into a separate utility process; ACP clients restore last model/mode/effort when loading sessions and can close sessions cleanly.
No container-level changes in this release. Dockerfile bump only.
## v1.14.40 — 2026-05-07
Bump opencode to 1.14.40.
Rolls up upstream releases v1.14.34 → v1.14.40 (no v1.14.36). Highlights:
- **v1.14.40:** support `.well-known/opencode` configs that point to a separate remote config file; assistant text preserved in signed reasoning blocks; CORS, network options, web terminal, and Cloudflare AI Gateway provider fixes; Mistral Medium 3.5 variants restored.
- **v1.14.39:** desktop app respects `HTTP_PROXY` and friends; storage reads return `null` instead of failing when keys are missing.
- **v1.14.38:** embedded UI requests work with arbitrary `connect-src` origins under the default CSP; desktop trusts system CA certificates for HTTPS.
- **v1.14.37:** cancelling a task now cancels child subtask sessions; v2 session rendering improvements (cleaner tool states, better compaction summaries); new "warp a session into another workspace or back to local project" feature; Windows titlebar stable across zoom changes.
- **v1.14.35:** preserve diff patch boundaries so session diffs render correctly when file contents themselves contain `diff --git` text.
- **v1.14.34:** PTY connection tickets for authenticated terminal websockets; v2 session failure events for clients to detect failed runs; improved shell command handling for Bash/PowerShell/cmd; new `debug info` command; `--username` option for basic-auth server connections.
No container-level changes in this release. Dockerfile bump only.
## v1.14.33 — 2026-05-03
**Bump opencode to 1.14.33. Named volume for opencode config, skillset auto-deploy, Context7 MCP.**
Rolls up the image-structure changes originally planned for v1.14.32b onto the current opencode release. v1.14.32 was built but never deployed (wrong deploy dir caught the tag mid-flight); skipped in favor of landing everything together on 1.14.33.
- **Breaking:** `~/.config/opencode/` now uses a named volume (`devbox-opencode-config`) instead of a host bind mount. The container's config, skills, and instructions are independent from the host. Users who relied on the bind mount should either re-add it explicitly in their compose file (overriding the volume) or migrate hand-edits into the container.
- **Breaking:** `~/.agents/skills/` is no longer bind-mounted from the host. The container manages its own skills directory — the entrypoint deploys skills from the skillset repo on each start.
- **Feature:** Skillset auto-deploy on container start. The entrypoint runs `deploy-skills.sh --bootstrap --prune-stale` from the first skillset repo found at: `$SKILLSET_CONTAINER_PATH``~/skillset``/workspace/skillset`. Creates relative symlinks that resolve inside the container regardless of host path layout. Idempotent.
- **Feature:** Context7 remote MCP server registered in auto-generated config. No local binary; provides up-to-date library documentation to LLMs. Config file is now `opencode.jsonc` (supports comments) with a note about the optional API key for higher rate limits. Existing-config check detects both `.json` and `.jsonc`.
- **Env:** New `SKILLSET_CONTAINER_PATH` env var for specifying skillset repo location inside the container when it's not at `/workspace/skillset`.
- **Docs:** README updated for named volume config, skillset auto-deploy, Context7 MCP server, `opencode.jsonc` references. AGENTS.md, DOCKER_HUB.md regenerated.
Upstream opencode 1.14.32 notes (shipped in this build since v1.14.32 was skipped): shell-mode input in the prompt is editable again (backspace, cursor keys); HTTP API workspace adapters no longer lose instance context, restoring workspace create/sync/routing; experimental workspace creation requests that omit `extra` are fixed; OpenAPI parameter schemas now match the public API so generated clients stop drifting; unsupported image formats fall back to text reads instead of being sent as image attachments; agents can use the global temp directory without extra permission prompts; Bedrock sessions that include reasoning content no longer break when switching models; session archive timestamps reject non-finite values to avoid invalid JSON. TUI: reduced startup theme flashing under the system theme, animated logo avoids subpixel rendering on terminals without truecolor support.
Upstream opencode 1.14.33 release notes: see https://github.com/sst/opencode/releases/tag/v1.14.33.
## v1.14.31d — 2026-05-01
**CI: collapse per-arch matrix back into single multi-arch push jobs.**
- **Fix:** `v1.14.31c`'s per-arch matrix build jobs failed on `Upload digest` with `GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+ are not currently supported on GHES`. Gitea Actions only implements the v3-compatible artifact API; `@v4` uses a GitHub-Enterprise-specific backend. Separately, `build-omos linux/arm64` hung silently for 12 minutes in "Set-up job" and then failed with no log output — likely catthehacker image-pull contention between concurrent matrix children on the same runner host.
- Rather than downgrade to `actions/{upload,download}-artifact@v3`, collapsed the per-arch matrix entirely. `docker/build-push-action@v7` with `platforms: linux/amd64,linux/arm64` publishes a proper multi-arch manifest in a single job, so the whole artifact-passing and `imagetools create` merge dance existed only to support a matrix split we no longer need.
- The original matrix split was designed around `load: true` disk exhaustion (v1.14.30b). With `push-by-digest`/`push: true` streaming straight to the registry — no local unpack — the peak disk story is fundamentally different. Validated in v1.14.31b that the reclaim step gives sufficient headroom for a single-job amd64 build; oracle-reviewed call that this should extend to the combined amd64+arm64 push case.
- Workflow goes from 7 jobs to 5 (smoke-base, smoke-omos, build-base, build-omos, update-description). 263 → ~110 lines of YAML in `docker-publish.yml`.
- **Add:** `timeout-minutes: 90` on both build jobs so a hung arm64 build produces an explicit failure with logs rather than runner-default silent truncation.
- **Add:** `BUILDKIT_PROGRESS=plain` at workflow level so arm64-under-QEMU build output is line-by-line (the default collapsed progress UI was obscuring earlier stalls).
- **Add:** `AGENTS.md §CI quirks` documents the Gitea-specific traps encountered this week: `upload-artifact@v3`-only on Gitea, `/bin/sh` is dash, `build-push-action@v7` does multi-arch natively with comma-separated platforms, reclaim step is mandatory on `load: true` jobs.
- No image changes. Rebuild of v1.14.31 content only.
## v1.14.31c — 2026-05-01
**CI: fix bash-specific parameter expansion and bump omos size threshold.**
- **Fix:** `Derive platform slug` step in the per-arch matrix build jobs (`build-base`, `build-omos`) used `${PLATFORM_PAIR//\//-}` which is a bash parameter-expansion. The runner container executes step scripts via `/bin/sh` (dash), which errored with `Bad substitution`. Rewrote using `tr / -` which is POSIX and behaves identically. Both `build-base` and `build-omos` matrix jobs were blocked on this on `v1.14.31b`.
- **Fix:** smoke-test image-size threshold for the `omos` variant bumped from 3000 MB to 3200 MB. The mempalace-toolkit bake-in added ~100 MB to omos; measured 3107 MB on `v1.14.31b`. All functional smoke checks (opencode, node, mempalace CLIs, toolkit wrappers, oh-my-opencode-slim) pass — this is a guardrail recalibration, not a performance concession. The underlying image genuinely grew.
- The runner-disk reclaim step from v1.14.31b did its job: `smoke-base` and `validate-base` now pass cleanly. Only `smoke-omos` was blocked this iteration, and only on the threshold.
- No image changes beyond what shipped in v1.14.31. Rebuild of v1.14.31 content only.
## v1.14.31b — 2026-05-01
**CI: reclaim runner disk before `load: true` smoke builds.**
- **Fix:** v1.14.31's publish workflow and the `validate` workflow both hit `No space left on device` on the single-arch amd64 smoke/validate builds (`/opt/uv-tools/mempalace/lib/python3.13/site-packages/hf_xet/hf_xet.abi3.so`, `/usr/local/bin/git-lfs`). Root cause is not the build itself but the `load: true` step: peak disk during export equals tarball + unpacked image + buildx cache, and the image has crossed the ~3 GB threshold where this no longer fits in the ~12 GB of free space the runner container starts with. The v1.14.30c refactor split multi-arch into per-arch push-by-digest jobs (which don't `load`), but the smoke gates still do and still hit the wall.
- Added a `Reclaim runner disk` step to all four `load: true` jobs (`validate-base`, `validate-omos`, `smoke-base`, `smoke-omos`). The step strips `catthehacker/ubuntu:act-latest`-resident toolchains we never use (hosted-tool-cache, dotnet, android, powershell, swift, ghc, jvm, microsoft, chromium, boost) and runs `docker system prune -af --volumes` + `docker builder prune -af` against the runner's dockerd before `setup-buildx-action`. Expected reclaim is 612 GB depending on what's resident.
- Added workflow-level `concurrency: { group: ..., cancel-in-progress: false }` on `docker-publish.yml` so concurrent tag pushes can't race `docker system prune` in one job against an in-flight buildx cache in another.
- Pruning is deliberately kept out of the per-arch matrix push-by-digest jobs (`build-base`/`build-omos`) — those don't need it (no `load: true`), and pruning in parallel jobs risks one job nuking another's cache.
- **Follow-up** (not in this release): image-size reduction via a dedicated `uv tool install mempalace` build stage (strips uv's cache from the final image), pinning `mempalace-toolkit` to a commit SHA with `--depth=1 --filter=blob:none`, and auditing whether `hf_xet` is actually required by mempalace at runtime. These will ship in the next release that rebases on a new opencode version.
- No image changes. Rebuild of v1.14.31 content only.
## v1.14.31 — 2026-05-01
Bump opencode to 1.14.31.
+56 -564
View File
@@ -1,22 +1,37 @@
# opencode-devbox — Docker Hub
# opencode-devbox
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
## Image Variants
Designed for teams who want a reproducible coding-agent setup that runs the same on every laptop and CI runner — without forcing each developer to install Bun, Node, AWS CLI, mempalace, or maintain shell config drift across machines.
Two image variants are published for each release:
## Image Variants
| Tag | Description |
|---|---|
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/earendil-works/pi) as alternative/complementary harness (shares the mempalace install with opencode) |
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
Both variants support `linux/amd64` and `linux/arm64`.
> **NOTE:** This file is auto-generated from `README.md` by `scripts/generate-dockerhub-md.py`. Edit README.md and regenerate rather than editing this file directly.
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,580 +43,57 @@ docker run -it --rm \
joakimp/opencode-devbox:latest
```
This drops you straight into opencode with your project mounted at `/workspace`.
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>
For an interactive shell first (useful for AWS SSO login):
## What's Inside
```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
```
- **[opencode](https://opencode.ai)** — primary coding-agent harness. Multi-provider (Anthropic, OpenAI, Bedrock, Google, Groq, etc.).
- **[pi](https://github.com/earendil-works/pi)** *(in `*-with-pi` variants)* — lightweight TUI coding-agent that coexists with opencode and shares the same mempalace install. Includes the `mcp-loader` extension so any local-stdio or remote streamable-HTTP MCP server (searxng, gitea, context7, …) can be added by editing `~/.pi/agent/settings.json`.
- **[mempalace](https://github.com/MemPalace/mempalace)** — persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are mutually visible to opencode and pi.
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** *(in `*-omos` variants)* — multi-agent orchestration on top of opencode (council, fallback chains, named agents).
- **AWS CLI v2** with SSO support, **Node.js LTS**, **Bun** (OMOS variants), **uv** (Python), **gosu** for clean UID/GID adjustment to match your host workspace.
- **MCP wrappers** for mempalace pre-installed and pre-wired to both harnesses.
Then run `opencode` when ready.
## Authentication
For docker-compose users, see the source repo for `docker-compose.yml` and `.env.example` templates.
The container reads provider credentials from environment variables and host-mounted config:
## Features
- **Anthropic / OpenAI / Groq / others:** set `OPENCODE_PROVIDER` and the corresponding `*_API_KEY` via `-e` or `.env`.
- **AWS Bedrock (SSO):** mount `~/.aws` from the host, `OPENCODE_PROVIDER=amazon-bedrock`, then `aws sso login` inside the container. Tokens persist across container restarts via the host bind-mount.
- **OAuth / device-code providers:** auth state lives in opencode's config, which is persisted via the `devbox-opencode-config` named volume.
- **Debian trixie** base — glibc, full PTY/terminal support
- **Configurable providers** — Anthropic, OpenAI, AWS Bedrock via env vars
- **Host filesystem access** — bind mount any directory as `/workspace`
- **SSH key forwarding** — git push/pull to private repos
- **MCP server support** — Node.js included for `npx`-based MCP servers
- **Non-root user** — runs as `developer` with UID auto-matched to workspace owner (sudo available)
- **Python via uv** — `uv` package manager included; install Python on demand with `uv python install`
- **Rust via rustup** — `rustup-init` included; bootstrap Rust on demand with `rustup-init -y`
- **Optional runtimes** — Python (apt), Go via build args (Node.js always included — required for opencode v1.x)
- **Multi-agent orchestration** — optional [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) integration via build arg
- **AWS CLI v2** — built-in SSO/Bedrock authentication with headless device-code flow
- **Multi-arch** — amd64 and arm64
Full Bedrock walkthrough (IAM roles, permissions, multi-account setups): see the [AWS Bedrock Authentication](
https://gitea.jordbo.se/joakimp/opencode-devbox#aws-bedrock-authentication
) section on gitea.
## Usage
## Persistence
### Prerequisites
Bind-mounted directories must exist on the host before starting the container. Docker creates missing directories as root-owned, which causes permission issues.
```bash
# Required: workspace for your projects
mkdir -p ~/projects
# If mounting opencode config (recommended for persistent settings)
mkdir -p ~/.config/opencode
```
### Connecting to the container
From your laptop, SSH into the remote server where Docker is running, then start the container:
```bash
# 1. SSH into the remote server
ssh user@remote-server
# 2. Navigate to the project
cd opencode-devbox
# 3. Start the container with an interactive shell
docker compose run --rm devbox bash
# You're now inside the container — run commands here
aws sso login --sso-session <your-sso-session> --use-device-code
opencode
```
### Running modes
**Interactive shell** — enter the container, run multiple commands:
```bash
docker compose run --rm devbox bash
```
**Direct to opencode** — skips the shell, launches opencode immediately:
```bash
docker compose run --rm devbox
```
**Background container** — keep it running, attach when needed:
```bash
# Start in background
docker compose up -d
# Attach a shell to the running container
docker compose exec -u developer devbox bash
# Or run a single command inside it
docker compose exec -u developer devbox aws --version
```
> `run` creates a new container (cleaned up with `--rm`). `exec` attaches to an already running one.
## Configuration
### Environment Variables
| Variable | Description | Default |
| Volume | Mount | Survives |
|---|---|---|
| `OPENCODE_PROVIDER` | LLM provider (`anthropic`, `openai`, `amazon-bedrock`) | `anthropic` |
| `OPENCODE_MODEL` | Model override | Provider default |
| `ANTHROPIC_API_KEY` | Anthropic API key | — |
| `OPENAI_API_KEY` | OpenAI API key | — |
| `AWS_REGION` | AWS region for Bedrock | `us-east-1` |
| `AWS_PROFILE` | AWS SSO profile name | `default` |
| `GIT_USER_NAME` | Git commit author name | — |
| `GIT_USER_EMAIL` | Git commit author email | — |
| `WORKSPACE_PATH` | Host path to mount | `.` |
| `SSH_KEY_PATH` | Host SSH key directory | `~/.ssh` |
| `USER_UID` | Override container user UID | Auto-detect from `/workspace` |
| `USER_GID` | Override container user GID | Auto-detect from `/workspace` |
| `LANG` | System locale | `en_US.UTF-8` |
| `LANGUAGE` | Language priority list | `en_US:en` |
| `LC_ALL` | Override all locale settings | `en_US.UTF-8` |
| `EDITOR` | Default text editor | `nvim` |
| `ENABLE_OMOS` | Enable oh-my-opencode-slim multi-agent orchestration | `false` |
| `OMOS_TMUX` | Enable tmux pane integration for OMOS | `false` |
| `OMOS_SKILLS` | Install OMOS recommended skills on first run | `true` |
| `OMOS_RESET` | Force regenerate OMOS config on next start | `false` |
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild |
| `devbox-pi-config` | `~/.pi` | container recreate, image rebuild — incl. user-installed pi packages via `pi install` (`NPM_CONFIG_PREFIX` points into the volume) |
| `devbox-palace` (uncomment) | `~/.mempalace` | container recreate, image rebuild — palace data is precious, treat as primary storage |
| `devbox-chroma-cache` | `~/.cache/chroma` | container recreate (model cache, disposable — re-downloads in seconds) |
### Custom opencode config
Workspace bind-mount (`/workspace`) is your project directory on the host, so source code is never inside the container.
For full control over opencode settings (MCP servers, custom models, and — on the OMOS variant — oh-my-opencode-slim agents), mount the entire config directory from the host:
Full persistence reference, including multi-user (`SIGNUM`) isolation and host bind-mount alternatives: see the [README on gitea](https://gitea.jordbo.se/joakimp/opencode-devbox#persistence).
```yaml
volumes:
- ~/.config/opencode:/home/developer/.config/opencode
```
## Where to Go Next
This persists all configuration changes across container restarts, including `opencode.json`, skills, and (on the OMOS variant) `oh-my-opencode-slim.json`. When an existing `opencode.json` is found, the `OPENCODE_PROVIDER` auto-config is skipped.
- **Full README** with build args, every feature in detail, troubleshooting: <https://gitea.jordbo.se/joakimp/opencode-devbox>
- **CHANGELOG** for version history: <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/CHANGELOG.md>
- **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>
> **Portability note:** The mounted config runs inside a Linux container. Any absolute paths inside `opencode.json` (for example, host-specific `plugin` entries like `file:///usr/local/lib/node_modules/...` or `file:///opt/homebrew/...`) will not resolve inside the container. Prefer bare package specifiers (e.g. `"oh-my-opencode-slim"`) that resolve via `node_modules` lookup, which works on both macOS and Linux hosts.
## Sibling images
### Custom skills
- **[`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>
Mount agent skills from the host:
## License
```yaml
volumes:
- ~/.agents/skills:/home/developer/.agents/skills:ro
```
MIT. See <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/LICENSE>.
### Neovim configuration
---
The image includes neovim 0.12 with `EDITOR=nvim` set by default. To use your own neovim config (and have plugins auto-install via lazy.nvim on first start), mount it from the host:
```yaml
volumes:
- ~/.config/nvim:/home/developer/.config/nvim:ro
```
### Python development with uv
The image includes Python 3.13 (from Debian Trixie) and [uv](https://docs.astral.sh/uv/), a fast Python package manager that replaces pip, venv, and pyenv:
```bash
# Python 3.13 is available out of the box
python3 --version
# Use uv for package management
uv venv
uv pip install -r requirements.txt
# Or use uv's project workflow (reads pyproject.toml)
uv sync
# Run a Python script
uv run python script.py
# Install standalone Python tools
uvx ruff check .
# Install a newer Python version (persists with devbox-uv volume)
uv python install 3.14
```
Python installations are stored in `~/.local/share/uv/`. To persist them across container restarts, add the `devbox-uv` named volume to your `docker-compose.yml`:
```yaml
volumes:
- devbox-uv:/home/developer/.local/share/uv
volumes:
devbox-uv:
```
Project virtual environments (`.venv`) are stored in your workspace directory and persist automatically via the `/workspace` bind mount.
### Rust development with rustup
The image includes `rustup-init`, the Rust toolchain installer. Rust is not pre-installed but can be bootstrapped on demand:
```bash
# One-time setup: install Rust toolchain (~300MB, persists with volumes)
rustup-init -y
source ~/.cargo/env
# Now use Rust normally
cargo new my-project
cargo build
cargo run
```
To persist Rust toolchains and cargo data across container restarts, add named volumes to your `docker-compose.yml`:
```yaml
volumes:
- devbox-rustup:/home/developer/.rustup
- devbox-cargo:/home/developer/.cargo
volumes:
devbox-rustup:
devbox-cargo:
```
### JavaScript and TypeScript
The base image includes **Node.js 22** and **npm** — sufficient for most JavaScript and TypeScript development:
```bash
# Initialize a new project
npm init -y
# Install dependencies
npm install
# Run TypeScript (via tsx, ts-node, etc.)
npx tsx src/index.ts
# Use npx for one-off tools
npx tsc --init
```
The OMOS image variant also includes **Bun**, a faster JavaScript runtime and package manager:
```bash
bun init
bun install
bun run src/index.ts
```
Node modules are stored in your project directory under `/workspace` and persist automatically.
### VS Code integration
VS Code can connect directly to a running opencode-devbox container for a full IDE experience with IntelliSense, debugging, and extensions running inside the container.
**Local Docker (Docker running on your workstation):**
1. Install the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension
2. Start the container: `docker compose up -d`
3. In VS Code: `Ctrl+Shift+P` → "Dev Containers: Attach to Running Container" → select `opencode-devbox`
**Remote Docker (Docker running on a remote server, e.g. via SSH):**
1. Install the [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) and [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extensions
2. Connect to the remote host: `Ctrl+Shift+P` → "Remote-SSH: Connect to Host"
3. On the remote host, start the container: `docker compose up -d`
4. In VS Code (now connected to the remote): `Ctrl+Shift+P` → "Dev Containers: Attach to Running Container"
VS Code extensions installed inside the container persist as long as the container exists (not removed with `docker compose down`). For persistent extension storage across container recreations, add a named volume:
```yaml
volumes:
- devbox-vscode:/home/developer/.vscode-server
```
## oh-my-opencode-slim (Multi-Agent Orchestration)
[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) adds a multi-agent layer on top of opencode — an Orchestrator delegates tasks to specialized agents (Explorer, Oracle, Librarian, Designer, Fixer), each configurable with different models and providers.
### Setup
A pre-built OMOS image is available on Docker Hub as `joakimp/opencode-devbox:latest-omos`. Alternatively, build from source:
**1. Build the image with OMOS support:**
```bash
docker compose build --build-arg INSTALL_OMOS=true
```
This installs Bun and the oh-my-opencode-slim package into the image.
**2. Enable in `.env`:**
```bash
ENABLE_OMOS=true
```
**3. Run as normal:**
```bash
docker compose run --rm devbox
```
On first start, the entrypoint runs the oh-my-opencode-slim installer in non-interactive mode. It generates agent configuration at `~/.config/opencode/oh-my-opencode-slim.json` inside the container. The default preset uses OpenAI models — edit the generated config or mount your own to customize.
### OMOS Environment Variables
| Variable | Default | Description |
|---|---|---|
| `ENABLE_OMOS` | `false` | Activate oh-my-opencode-slim on container start |
| `OMOS_TMUX` | `false` | Enable tmux pane integration (tmux is included in the base image) |
| `OMOS_SKILLS` | `true` | Install recommended skills (simplify, agent-browser, cartography) |
| `OMOS_RESET` | `false` | Force regenerate config on next start (backs up existing config) |
### Custom Configuration
If you mount the opencode config directory (see Custom opencode config above), the `oh-my-opencode-slim.json` file is included and persists across restarts. Edit it directly to control which models power each agent, fallback chains, council setup, and more.
See the [oh-my-opencode-slim configuration docs](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/docs/configuration.md) for the full reference.
### Verifying Agents
After starting opencode with OMOS enabled, run inside the opencode session:
```
ping all agents
```
All six agents should respond if your provider authentication is working.
## AWS Bedrock Authentication
When using AWS Bedrock as your LLM provider, you need:
### 1. AWS config on the host
The container needs access to your `~/.aws/config` with SSO session configuration. If you already have this on another machine, copy it:
```bash
scp -r user@other-machine:~/.aws ~/.aws
```
Or configure from scratch on the host:
```bash
aws configure sso
```
### 2. Mount `~/.aws` into the container
Uncomment the AWS volume mount in `docker-compose.yml`:
```yaml
- ~/.aws:/home/developer/.aws
```
Note: do **not** use `:ro` — SSO writes token cache files to this directory.
### 3. Authenticate inside the container
Since the container runs headless (no browser), use the device-code flow:
```bash
# Start the container
docker compose up -d
docker compose exec -u developer devbox bash
# Authenticate — prints a URL and code you open in your local browser
aws sso login --sso-session <your-sso-session> --use-device-code
# Once approved in the browser, start opencode
opencode
```
The `--use-device-code` flag outputs a URL and short code instead of trying to open a browser. Copy the URL into any browser (on your laptop, phone, etc.), enter the code, and complete the 2FA flow. The CLI in the container picks up the session automatically.
SSO sessions typically last 812 hours before requiring re-authentication. Since `~/.aws` is mounted from the host, tokens persist across container restarts.
## MemPalace — persistent AI memory
The image includes [MemPalace](https://github.com/MemPalace/mempalace), a local-first AI memory system that stores conversation history verbatim and retrieves it via semantic search. Nothing leaves your machine.
> MemPalace adds ~300 MB to the image (chromadb, embedding model deps). If you don't use it, rebuild with `--build-arg INSTALL_MEMPALACE=false` to shrink the image.
### Enabling persistence
Uncomment the palace volume in `docker-compose.yml`:
```yaml
- devbox-palace:/home/developer/.mempalace
```
Without the volume, palace data lives in the container's writable layer and is lost on `--force-recreate`.
### MCP integration with opencode
Add mempalace as an MCP server in your `opencode.json` (inside `~/.config/opencode/`):
```json
{
"mcp": {
"mempalace": {
"type": "local",
"command": ["mempalace-mcp"]
}
}
}
```
> The image installs mempalace into an isolated `uv tool` venv at `/opt/uv-tools/mempalace/`. `uv tool install` places `mempalace-mcp` on `PATH` as a shim whose shebang points at the venv's Python, so MCP clients can invoke it as a normal binary without worrying about the venv. Do **not** use `["python3", "-m", "mempalace.mcp_server"]` — the system Python cannot import from the uv-managed venv and you'll get `ModuleNotFoundError` / `MCP error -32000: connection closed`.
This gives opencode access to 29 MCP tools for searching memory, querying the knowledge graph, managing wings/rooms/drawers, and agent diaries.
### Basic usage
```bash
# Mine project files into the palace
mempalace mine /workspace
# Mine conversation transcripts
mempalace mine ~/.local/share/opencode/ --mode convos
# Search memory
mempalace search "why did we switch to eno1"
# Load context for a new session
mempalace wake-up
```
Each workspace gets its own isolated "wing" — memories never leak between projects.
### Scheduled mining (mempalace-toolkit)
The image bakes in [mempalace-toolkit](https://gitea.jordbo.se/joakimp/mempalace-toolkit), a small set of bash wrappers that pair with mempalace for two common routines:
```bash
# Mine opencode session history (reads ~/.local/share/opencode/opencode.db, stages JSONL, mines into wing_conversations)
mempalace-session
# Mine a project's docs into a dedicated wing
mempalace-docs /workspace/my-project
```
Both wrappers are idempotent and dedup-aware — re-running them on unchanged input is a cheap no-op.
For weekly automated runs, the toolkit ships ready-to-use scheduler templates (systemd user timer, launchd user agent, cron) in its [`contrib/`](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/contrib) directory. The `*-devbox` variants are designed for this container: host-side schedulers that `docker exec` into the running opencode-devbox.
Disable the toolkit (keeps mempalace itself) with `--build-arg INSTALL_MEMPALACE_TOOLKIT=false`. Pin to a specific ref with `--build-arg MEMPALACE_TOOLKIT_REF=v0.3.0` once tagged releases exist.
### Storage
Two separate named volumes keep different data classes apart:
- **Palace data** (`~/.mempalace/`): ChromaDB vectors, SQLite knowledge graph, drawers. This is your memory — back it up, treat it as precious. Persists via the `devbox-palace` named volume.
- **Embedding model cache** (`~/.cache/chroma/`): ONNX model (~79 MB), downloaded automatically on first search. Disposable — blow it away and it re-downloads in ~4 seconds. Persists via the `devbox-chroma-cache` named volume so you don't re-download on every container recreation.
- **No API keys required** for core functionality (local embeddings via ONNX).
Both volumes are commented out by default in `docker-compose.yml` — uncomment to enable:
```yaml
- devbox-palace:/home/developer/.mempalace
- devbox-chroma-cache:/home/developer/.cache/chroma
```
**Air-gapped environments:** pre-populate the `devbox-chroma-cache` volume with the `all-MiniLM-L6-v2/` model contents. The palace volume needs no pre-population.
## Gitea MCP server
The image includes the [official Gitea MCP server](https://gitea.com/gitea/gitea-mcp) (`gitea-mcp`), providing 50+ MCP tools for interacting with self-hosted Gitea instances — repositories, issues, pull requests, releases, branches, wiki, and Actions.
### Setup
1. Create a Personal Access Token on your Gitea instance (Settings → Applications → Generate Token, scopes: `repo`, `read:user`).
2. Add to your `.env`:
```env
GITEA_HOST=https://your-gitea-instance.example.com
GITEA_ACCESS_TOKEN=your_token_here
```
3. Enable the gitea MCP server in your `opencode.json`:
```json
{
"mcp": {
"gitea": {
"type": "local",
"command": ["gitea-mcp", "-t", "stdio", "--host", "{env:GITEA_HOST}"],
"environment": {
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"
},
"enabled": true
}
}
}
```
The server is installed but disabled by default — it requires authentication to be useful.
## Shell defaults
The image ships a baked `.bash_aliases` and `.inputrc` with quality-of-life defaults. On first container start they are copied from `/etc/skel-devbox/` into `/home/developer/` **only if the target file does not already exist** — so host bind-mounts and any version you've customized inside the container are never overwritten on upgrade.
Defaults you get out of the box:
- **Prefix history search** on Up/Down arrows (type `git `, press Up, walk back through prior `git ...` commands only). Ctrl-Up / Ctrl-Down still step through full history.
- **Persistent history** — `$HISTFILE` points at `~/.cache/bash/history`, backed by the `devbox-shell-history` named volume so history survives container recreation. Timestamps, 100 000 entries, dedup.
- **Case-insensitive tab completion**, coloured completion lists, `show-all-if-ambiguous`.
- **Aliases** — `ls`/`ll`/`la` use `eza`, `cat` uses `bat`, `gs`/`gd`/`gl` for git, safe `rm`/`mv`/`cp`.
- **Integrations** — `zoxide` (`z <fragment>` to jump), `fzf` Ctrl-R / Ctrl-T key bindings.
- **Prompt marker** — `[devbox]` prefix so it's always obvious you're inside the container.
### Overriding the defaults
**Option A — bind-mount host files.** Uncomment the bind-mount lines in `docker-compose.yml`:
```yaml
- ~/.bash_aliases:/home/developer/.bash_aliases:ro
- ~/.inputrc:/home/developer/.inputrc:ro
```
> **Single-file bind-mount caveat (all platforms):** Docker bind-mounts the file's **inode**, not its path. When editors like vim, nvim, VS Code, or `sed -i` save a file, they write to a temp file and `rename()` it over the original — creating a new inode. The container stays pinned to the old (now unlinked) inode and never sees the update. This is a kernel limitation ([Docker #15793](https://github.com/moby/moby/issues/15793)), not fixable by Docker. Append-only writes (`echo "alias foo=bar" >> file`) are safe because they modify the same inode. **Workaround:** mount the parent directory instead of the single file (e.g. `~/.config/devbox-shell:/home/developer/.config/devbox-shell:ro`) and source files from there.
**Option B — customize inside the container.** Just edit `~/.bash_aliases` or `~/.inputrc` as normal. Pair this with a bind-mount or named volume on the home dir if you want the edits to survive container recreation.
### Restoring or diffing defaults
The skel files remain available inside every container at `/etc/skel-devbox/`. Useful commands:
```bash
# See what the image currently ships
cat /etc/skel-devbox/.bash_aliases
# Diff your current config against the upstream defaults
diff ~/.bash_aliases /etc/skel-devbox/.bash_aliases
# Reset to the baked defaults
cp /etc/skel-devbox/.bash_aliases ~/.bash_aliases
# …or delete the file and recreate the container — the entrypoint
# copies from /etc/skel-devbox/ on next start if the target is absent
rm ~/.bash_aliases
```
## Architecture
```
Host Machine
├── ~/projects/my-app ──bind mount──▶ /workspace (container)
├── ~/.ssh ──bind mount──▶ /home/developer/.ssh (ro)
├── ~/.aws ──bind mount──▶ /home/developer/.aws (Bedrock SSO)
└── .env ──env vars───▶ provider config + API keys
Container (Debian trixie)
├── opencode binary
├── 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
├── Node.js (for MCP servers)
├── Bun (optional — included with oh-my-opencode-slim)
├── entrypoint.sh (UID adjustment, git config, provider setup)
└── /workspace ← your code lives here
```
### Data persistence
| Path in container | Source | Survives `--rm`? | Contains |
|---|---|---|---|
| `/workspace` | Host bind mount | ✅ Yes | Your project files |
| `/home/developer/.ssh` | Host bind mount (ro) | ✅ Yes | SSH keys |
| `/home/developer/.aws` | Host bind mount (if configured) | ✅ Yes | AWS credentials/SSO cache |
| `/home/developer/.local/share/opencode` | Named volume `devbox-data` | ✅ Yes | Session history, memory |
| `/home/developer/.local/state/opencode` | Named volume `devbox-state` | ✅ Yes | TUI settings (theme, toggles) |
| `/home/developer/.cache/bash` | Named volume `devbox-shell-history` | ✅ Yes | Bash history (`$HISTFILE`), survives container recreate |
| `/home/developer/.local/share/zoxide` | Named volume `devbox-zoxide` | ✅ Yes | Zoxide directory history (`z <fragment>` jump targets) |
| `/home/developer/.local/share/nvim` | Named volume `devbox-nvim-data` | ✅ Yes | Neovim plugins, Mason LSP installs, Lazy plugin cache |
| `/home/developer/.local/share/uv` | Named volume `devbox-uv` (if configured) | ✅ Yes | Python installs, uv tool installs |
| `/home/developer/.rustup` | Named volume `devbox-rustup` (if configured) | ✅ Yes | Rust toolchains |
| `/home/developer/.cargo` | Named volume `devbox-cargo` (if configured) | ✅ Yes | Cargo binaries, registry cache |
| `/home/developer/.vscode-server` | Named volume `devbox-vscode` (if configured) | ✅ Yes | VS Code server and extensions |
| `/home/developer/.config/opencode` | Host bind mount (if configured) | ✅ Yes | opencode.json, skills, plus `oh-my-opencode-slim.json` on the OMOS variant |
**opencode config** (`opencode.json`) is auto-generated from `OPENCODE_PROVIDER` on each start. It sets provider and model only — no MCP servers. To persist config changes and use custom settings, mount the config directory from the host (see Custom opencode config above).
## Source
MIT licensed. Source, issues, and `docker-compose.yml` templates: <https://gitea.jordbo.se/joakimp/opencode-devbox>
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
+59 -99
View File
@@ -1,14 +1,32 @@
# opencode-devbox — portable AI dev environment
# Debian-based container with opencode and configurable dev tools
# opencode-devbox — base image (variant-independent layers)
#
# This Dockerfile produces an image tagged base-<hash>, used as the parent
# for all four published variants (base, omos, with-pi, omos-with-pi).
# It contains everything that does not depend on variant-specific
# build-args (INSTALL_OPENCODE, INSTALL_OMOS, INSTALL_PI). The variant
# Dockerfile (Dockerfile.variant) FROMs the base and adds only those
# deltas.
#
# The base is rebuilt only when this file or anything it COPYs in
# 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
FROM debian:${DEBIAN_VERSION} AS base
ARG TARGETARCH
ARG OPENCODE_VERSION=1.14.31
LABEL maintainer="joakimp"
LABEL description="Portable opencode developer container"
LABEL description="opencode-devbox — base image (variant-independent)"
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/opencode-devbox"
# Avoid interactive prompts during build
@@ -58,16 +76,12 @@ RUN apt-get update && \
# 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
# Location header. This means every base rebuild 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
@@ -184,20 +198,10 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
rm -rf /tmp/uv-* && \
uv --version
# ── Optional: MemPalace — local-first AI memory system ───────────────
# ── 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).
# Always installed in the base (variant-independent). Set
# INSTALL_MEMPALACE=false at base-build time to shave ~300 MB.
ARG INSTALL_MEMPALACE=true
ENV UV_TOOL_DIR=/opt/uv-tools
ENV UV_TOOL_BIN_DIR=/usr/local/bin
@@ -208,17 +212,6 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
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 \
@@ -232,15 +225,12 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" =
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.
# rustup — Rust toolchain manager (init binary only; toolchains installed at runtime)
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)
# gitea-mcp — MCP server for Gitea API
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}" && \
@@ -256,7 +246,6 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;
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
@@ -264,17 +253,12 @@ 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) ──────
# ── Node.js (required for opencode/pi/omos at variant build + 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
RUN npm install -g opencode-ai@${OPENCODE_VERSION} && \
opencode --version
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
RUN ARCH=$(case "${TARGETARCH}" in \
amd64) echo "x86_64" ;; \
@@ -287,51 +271,6 @@ RUN ARCH=$(case "${TARGETARCH}" in \
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
@@ -344,21 +283,42 @@ RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
# Create standard directories
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 ──────────────────────────────
# Runs as gosu developer so Path.home() resolves correctly. Uses
# the mempalace venv's python, which is the only one that has
# chromadb importable (system python3 cannot reach the isolated venv).
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.
#
# IMPORTANT: in this split-build layout the variant Dockerfile inherits
# this prefix at build time. To keep the baked binaries on /usr (so the
# ~/.pi volume mount doesn't shadow them), the variant Dockerfile MUST
# run each `npm install -g` with NPM_CONFIG_PREFIX=/usr in the per-RUN
# environment. See Dockerfile.variant.
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
@@ -374,4 +334,4 @@ RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
WORKDIR /workspace
ENTRYPOINT ["entrypoint.sh"]
CMD ["opencode"]
CMD ["bash", "-l"]
+112
View File
@@ -0,0 +1,112 @@
# opencode-devbox — variant image
#
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
# the variant-specific tools (opencode, pi, oh-my-opencode-slim, Go).
#
# The four published variants are produced from THIS Dockerfile by
# varying 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
#
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
# The CI workflow computes the base hash from Dockerfile.base + rootfs/
# + entrypoint*.sh and feeds it in.
#
# IMPORTANT: the base image sets NPM_CONFIG_PREFIX to
# /home/developer/.pi/npm-global so runtime `pi install npm:...` and
# `npm install -g` by the developer user lands on the named volume.
# At BUILD time we want the baked binaries on /usr so they survive the
# volume mount. Each `npm install -g` below therefore prefixes the
# command with `NPM_CONFIG_PREFIX=/usr`.
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
ARG TARGETARCH
ARG USER_NAME=developer
# ── Install opencode via npm ─────────────────────────────────────────
ARG INSTALL_OPENCODE=true
ARG OPENCODE_VERSION=1.15.3
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
opencode --version ; \
fi
# ── Optional: pi coding-agent ────────────────────────────────────────
# 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.
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_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
# ── Optional: Go ─────────────────────────────────────────────────────
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.
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_CONFIG_PREFIX=/usr npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \
fi
# WORKDIR / ENTRYPOINT / CMD inherited from base.
+127 -22
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
@@ -49,9 +69,6 @@ Bind-mounted directories must exist on the host before starting the container. D
```bash
# Required: workspace for your projects
mkdir -p ~/projects
# If mounting opencode config (recommended for persistent settings)
mkdir -p ~/.config/opencode
```
### Connecting to the container
@@ -125,28 +142,34 @@ docker compose exec -u developer devbox aws --version
| `OMOS_TMUX` | Enable tmux pane integration for OMOS | `false` |
| `OMOS_SKILLS` | Install OMOS recommended skills on first run | `true` |
| `OMOS_RESET` | Force regenerate OMOS config on next start | `false` |
| `SKILLSET_CONTAINER_PATH` | Path to skillset repo inside container (for auto-deploy when not at /workspace/skillset) | Auto-detect |
### Custom opencode config
For full control over opencode settings (MCP servers, custom models, and on the OMOS variantoh-my-opencode-slim agents), mount the entire config directory from the host:
Opencode configuration is persisted automatically via the named volume `devbox-opencode-config`. This volume is mounted at `/home/developer/.config/opencode` by default — no host directory setup required. All changes to `opencode.jsonc`, skills, and (on the OMOS variant) `oh-my-opencode-slim.json` survive container recreation.
When an existing `opencode.jsonc` is found in the volume, the `OPENCODE_PROVIDER` auto-config is skipped.
**Alternative: host bind-mount** — if you specifically want to share config from the host (e.g. to version-control it or sync across machines), replace the named volume with a bind mount:
```yaml
volumes:
- ~/.config/opencode:/home/developer/.config/opencode
```
This persists all configuration changes across container restarts, including `opencode.json`, skills, and (on the OMOS variant) `oh-my-opencode-slim.json`. When an existing `opencode.json` is found, the `OPENCODE_PROVIDER` auto-config is skipped.
> **Portability note:** The mounted config runs inside a Linux container. Any absolute paths inside `opencode.json` (for example, host-specific `plugin` entries like `file:///usr/local/lib/node_modules/...` or `file:///opt/homebrew/...`) will not resolve inside the container. Prefer bare package specifiers (e.g. `"oh-my-opencode-slim"`) that resolve via `node_modules` lookup, which works on both macOS and Linux hosts.
> **Portability note:** The mounted config runs inside a Linux container. Any absolute paths inside `opencode.jsonc` (for example, host-specific `plugin` entries like `file:///usr/local/lib/node_modules/...` or `file:///opt/homebrew/...`) will not resolve inside the container. Prefer bare package specifiers (e.g. `"oh-my-opencode-slim"`) that resolve via `node_modules` lookup, which works on both macOS and Linux hosts.
### Custom skills
Mount agent skills from the host:
Skills are deployed automatically from a skillset repo on container start. The entrypoint detects the skillset location in this order:
```yaml
volumes:
- ~/.agents/skills:/home/developer/.agents/skills:ro
```
1. `SKILLSET_CONTAINER_PATH` env var (explicit path to skillset repo inside container)
2. `~/skillset` mount (if present)
3. `/workspace/skillset` fallback (if your workspace contains a `skillset/` directory)
When a skillset repo is detected, its skills are symlinked into `~/.agents/skills/` automatically. No manual configuration needed.
> **Warning:** Do not bind-mount a host `~/.agents/skills` directory directly into the container. This conflicts with the symlink-based auto-deploy mechanism and causes broken skill references.
### Neovim configuration
@@ -294,9 +317,6 @@ cd ~/<signum>/opencode-devbox
cp /path/to/opencode-devbox/docker-compose.shared.yml docker-compose.yml
cp /path/to/opencode-devbox/.env.shared.example .env
# Create per-user config directory
mkdir -p ~/<signum>/.config/opencode
# Edit .env — set SIGNUM only if you're in shared-account mode
vim .env
@@ -308,7 +328,7 @@ docker compose exec -u developer devbox opencode
Each user's container, config, and named volumes are fully isolated:
- Container name: `devbox-<signum>` (or `devbox-$USER` in own-account mode)
- Named volumes: prefixed with the project name (`devbox-<signum>_devbox-data`, etc.) — the Docker daemon is system-wide, so directory-name prefixing alone is NOT sufficient for isolation
- Opencode config: `~/<signum>/.config/opencode/` (per-user settings, OMOS config, etc.)
- Opencode config: persisted via per-user named volume (`devbox-<signum>_devbox-opencode-config`)
See `docker-compose.shared.yml` and `.env.shared.example` for the full configuration.
@@ -341,6 +361,10 @@ docker compose build --build-arg NVIM_VERSION=0.12.1 # pin to a specific versi
| `INSTALL_MEMPALACE` | `true` | [MemPalace](https://github.com/MemPalace/mempalace) local AI memory system (~300 MB — disable to shrink image if you don't need MCP memory) |
| `INSTALL_MEMPALACE_TOOLKIT` | `true` | [mempalace-toolkit](https://gitea.jordbo.se/joakimp/mempalace-toolkit) bash wrappers (`mempalace-session`, `mempalace-docs`). Cloned at build time from `MEMPALACE_TOOLKIT_REF` (default `main`). Requires `INSTALL_MEMPALACE=true`. |
| `INSTALL_OMOS` | `false` | [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration (installs Bun and plugin) |
| `INSTALL_OPENCODE` | `true` | Install opencode. Set `false` to build a pi-only image (still includes Bun if `INSTALL_OMOS=true`; for a fully stripped pi-only image see the `pi-devbox` repo). |
| `INSTALL_PI` | `false` | Install [pi](https://github.com/earendil-works/pi) as alternative/complementary harness. Both clones [pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit) (~5 MB) and [pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions) (~1 MB) into `/opt/`; entrypoint deploys them on container start. ~150 MB total image growth. |
| `PI_VERSION` | `latest` | npm version of `@earendil-works/pi-coding-agent`. Floats by default (image rebuild = pi update). |
| `PI_TOOLKIT_REF`, `PI_EXTENSIONS_REF` | `main` | Git refs for the toolkit/extensions clones. Pin to a tag/commit for reproducibility. |
| `OPENCODE_VERSION` | *(pinned per release)* | opencode npm version. Drives the image tag and is intentionally not floated. |
| `NODE_VERSION` | `22` | Node.js major version. Pinned to protect against upstream breaking changes across majors. |
| `GOSU_VERSION`, `FZF_VERSION`, `GIT_LFS_VERSION`, `NVIM_VERSION`, `BAT_VERSION`, `EZA_VERSION`, `ZOXIDE_VERSION`, `UV_VERSION`, `GITEA_MCP_VERSION`, `GO_VERSION`, `OMOS_VERSION` | `latest` | All GitHub/Gitea/go.dev-hosted binaries resolve to the newest upstream release at build time. Override with a specific version to pin. Resolved versions are logged in CI output. |
@@ -402,6 +426,79 @@ ping all agents
All six agents should respond if your provider authentication is working.
## pi (alternative/complementary harness)
[pi](https://github.com/earendil-works/pi) is a lightweight TUI coding-agent that can run alongside opencode in the same container. Both harnesses share the mempalace install and palace data — wing/diary entries created by one are visible to the other.
### Setup
Pre-built pi-enabled images are available on Docker Hub as `joakimp/opencode-devbox:latest-with-pi` (base + pi) and `joakimp/opencode-devbox:latest-omos-with-pi` (OMOS + pi). Pulling one of those tags is the fastest path. Alternatively, build from source:
### Build
```bash
docker compose build --build-arg INSTALL_PI=true
# Or: pin a pi version
docker compose build --build-arg INSTALL_PI=true --build-arg PI_VERSION=0.73.0
# Or: pi-only image (no opencode, smaller)
docker compose build --build-arg INSTALL_PI=true --build-arg INSTALL_OPENCODE=false
```
### Run
The default `compose run --rm devbox` invocation drops to a login bash so you can choose:
```bash
docker compose run --rm devbox # bash, then `pi` or `opencode` or `aws sso login`
docker compose run --rm devbox pi # launch pi directly
docker compose run --rm devbox opencode
```
For an attached `compose up -d` container, both harnesses are reachable via `compose exec`:
```bash
docker compose exec -u developer devbox pi
docker compose exec -u developer devbox opencode
docker compose exec -u developer devbox bash
```
### What gets installed
- **`pi` CLI** — npm-installed globally at build time. Version pinned by `PI_VERSION`.
- **pi-toolkit** — keybindings.json (mosh/tmux newline fixes), pi-env.zsh (AWS env loader), settings.json template. Cloned to `/opt/pi-toolkit`; deployed to `~/.pi/agent/` on first container start.
- **pi-extensions** — 7 extensions, cloned to `/opt/pi-extensions` and symlinked into `~/.pi/agent/extensions/`:
- `confirm-destructive` — confirm-prompt before dangerous bash commands and session actions.
- `ext-toggle``/ext` slash command to list and enable/disable extensions at runtime (rename-to-disable; survives `/reload`).
- `git-checkpoint` — per-turn `git stash` checkpoint, restorable on `/fork`.
- `mcp-loader` — generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json` (same shape as opencode and Claude Desktop) and connects to each declared server, exposing the tools as native pi tools. Supports both **local stdio** subprocesses (`uvx mcp-searxng`, `gitea-mcp`, …) and **remote streamable-HTTP** servers per MCP spec 2025-03-26 (e.g. `https://mcp.context7.com/mcp`). Adds a `/mcp` slash command for runtime status / toggle (same UX as `/ext`). See [`pi-extensions/AGENTS.md`](https://gitea.jordbo.se/joakimp/pi-extensions/src/branch/main/AGENTS.md) for transport details and the `headers` config for auth tokens.
- `notify` — native terminal notification when the agent finishes.
- `ssh-controlmaster` — transparent SSH remote execution via persistent ControlMaster socket (when pi is launched with `--ssh user@host`).
- `todo``todo` tool for the agent + `/todos` for the user.
- **mempalace bridge** — separate `mempalace.ts` extension symlinked from the cloned `mempalace-toolkit`. Provides pi's MCP tools for palace search/diary/knowledge-graph with bespoke agent-identity injection from `$MEMPALACE_AGENT_NAME`. Coexists with `mcp-loader` rather than replacing it — don't list `mempalace` in settings.json's `mcp` block too, or you'll get duplicate tool registrations.
- **MCP servers (none baked in beyond mempalace)** — the loader registers nothing by default. Add servers by editing `~/.pi/agent/settings.json` and `/reload`. Examples (mcp-searxng for web search, context7 for live library docs) are in the `pi-extensions` README.
### Persistence
`~/.pi/` is mounted on the `devbox-pi-config` named volume. Everything below survives container recreate **and** image rebuilds:
- `~/.pi/agent/settings.json` (provider/model, theme selection, the `mcp` block, and the `packages` array tracking installed pi packages).
- `~/.pi/agent/extensions/` (hand-placed extensions and the symlinks deployed by `pi-extensions/install.sh`).
- `~/.pi/agent/sessions/`, `~/.pi/agent/auth.json`.
- `~/.pi/agent/git/<host>/<path>/` (pi packages installed via `pi install git:...`).
- `~/.pi/npm-global/` (pi packages installed via `pi install npm:...`, plus any `npm install -g` invoked as the `developer` user). `NPM_CONFIG_PREFIX` is pre-set in the image, the prefix's `bin/` is on `PATH`, and the directory itself lives on the volume — so user-installed themes, skills, and extensions survive everything short of `docker compose down -v`.
The **baked** pi binary (and pi-toolkit / pi-extensions repos under `/opt/`) live on the image filesystem, not the volume. Image rebuild is the upgrade path for those — same contract as `OPENCODE_VERSION`. If you `npm install -g @earendil-works/pi-coding-agent` yourself, the user-installed copy on the volume wins via `PATH` order and survives image rebuilds.
### Configuration
The entrypoint copies `pi-toolkit/settings.example.json` to `~/.pi/agent/settings.json` on first start. Edit it to set provider/model:
```bash
docker compose exec -u developer devbox $EDITOR ~/.pi/agent/settings.json
```
The AWS env loader (`pi-env.zsh`) reads `~/.config/pi/.env` if you bind-mount one; otherwise pi uses container env vars passed via `.env`.
## AWS Bedrock Authentication
When using AWS Bedrock as your LLM provider, you need:
@@ -468,7 +565,7 @@ Without the volume, palace data lives in the container's writable layer and is l
### MCP integration with opencode
Add mempalace as an MCP server in your `opencode.json` (inside `~/.config/opencode/`):
Add mempalace as an MCP server in your `opencode.jsonc` (inside `~/.config/opencode/`):
```json
{
@@ -552,7 +649,7 @@ The image includes the [official Gitea MCP server](https://gitea.com/gitea/gitea
GITEA_ACCESS_TOKEN=your_token_here
```
3. Enable the gitea MCP server in your `opencode.json`:
3. Enable the gitea MCP server in your `opencode.jsonc`:
```json
{
"mcp": {
@@ -570,6 +667,14 @@ The image includes the [official Gitea MCP server](https://gitea.com/gitea/gitea
The server is installed but disabled by default — it requires authentication to be useful.
## Context7 MCP server
The image auto-registers a [Context7](https://context7.com) MCP server, which provides up-to-date library documentation and code examples to LLMs at query time. This is a remote MCP server at `mcp.context7.com/mcp` — no local binary is needed.
- Auto-registered in the generated `opencode.jsonc` (no manual setup required)
- Provides documentation for any programming library/framework on demand
- Requires internet access — useless in air-gapped/offline environments
## Shell defaults
The image ships a baked `.bash_aliases` and `.inputrc` with quality-of-life defaults. On first container start they are copied from `/etc/skel-devbox/` into `/home/developer/` **only if the target file does not already exist** — so host bind-mounts and any version you've customized inside the container are never overwritten on upgrade.
@@ -680,9 +785,9 @@ Container (Debian trixie)
| `/home/developer/.rustup` | Named volume `devbox-rustup` (if configured) | ✅ Yes | Rust toolchains |
| `/home/developer/.cargo` | Named volume `devbox-cargo` (if configured) | ✅ Yes | Cargo binaries, registry cache |
| `/home/developer/.vscode-server` | Named volume `devbox-vscode` (if configured) | ✅ Yes | VS Code server and extensions |
| `/home/developer/.config/opencode` | Host bind mount (if configured) | ✅ Yes | opencode.json, skills, plus `oh-my-opencode-slim.json` on the OMOS variant |
| `/home/developer/.config/opencode` | Named volume `devbox-opencode-config` | ✅ Yes | `opencode.jsonc`, skills, plus `oh-my-opencode-slim.json` on the OMOS variant |
**opencode config** (`opencode.json`) is auto-generated from `OPENCODE_PROVIDER` on each start. It sets provider and model only — no MCP servers. To persist config changes and use custom settings, mount the config directory from the host (see Custom opencode config above).
**opencode config** (`opencode.jsonc`) is auto-generated from `OPENCODE_PROVIDER` on each start. It sets provider and model only — no MCP servers. To persist config changes and use custom settings, use the named volume (default) or bind-mount from host (see Custom opencode config above).
## License
+13 -2
View File
@@ -45,8 +45,18 @@ services:
# SSH keys — user-specific if available, else shared
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
# Opencode config — per-user (persists settings across restarts)
- ${HOME}/${SIGNUM}/.config/opencode:/home/developer/.config/opencode
# Optional: mount skillset repo for automatic skill/instruction deployment.
# The entrypoint runs deploy-skills.sh --bootstrap on start, creating
# relative symlinks that resolve inside the container regardless of
# where the repo lives on the host. Set SKILLSET_PATH in .env.
# - ${SKILLSET_PATH}:/home/developer/skillset
# Persist opencode config (opencode.jsonc, oh-my-opencode-slim.json,
# instructions, etc.) across container recreations. Auto-generated on
# first start from env vars by generate-config.py and the skillset
# deploy script. Using a named volume keeps the container's symlinks
# independent from the host.
- devbox-opencode-config:/home/developer/.config/opencode
# Persist opencode data (auth, memory, session history)
- devbox-data:/home/developer/.local/share/opencode
@@ -73,6 +83,7 @@ services:
# - ${HOME}/${SIGNUM}/.aws:/home/developer/.aws
volumes:
devbox-opencode-config:
devbox-data:
devbox-shell-history:
devbox-zoxide:
+24 -6
View File
@@ -25,6 +25,9 @@ services:
# args:
# INSTALL_GO: "false"
# INSTALL_OMOS: "false"
# INSTALL_PI: "false"
# # PI_VERSION: "latest"
# # INSTALL_OPENCODE: "true"
container_name: opencode-devbox
stdin_open: true
tty: true
@@ -42,13 +45,26 @@ services:
# SSH keys (read-only) — for git push/pull
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
# Optional: mount opencode config directory (persists config changes across restarts)
# Includes opencode.json, oh-my-opencode-slim.json, skills, etc.
# When mounted, OPENCODE_PROVIDER auto-config is skipped if opencode.json exists.
# - ~/.config/opencode:/home/developer/.config/opencode
# Optional: mount skillset repo for automatic skill/instruction deployment.
# The entrypoint runs deploy-skills.sh --bootstrap on start, creating
# relative symlinks that resolve inside the container regardless of
# where the repo lives on the host. Set SKILLSET_PATH in .env.
# - ${SKILLSET_PATH}:/home/developer/skillset
# Optional: mount opencode agent skills from host
# - ~/.agents/skills:/home/developer/.agents/skills:ro
# Persist opencode config (opencode.jsonc, oh-my-opencode-slim.json,
# instructions, etc.) across container recreations. Auto-generated on
# first start from env vars by generate-config.py and the skillset
# deploy script. Using a named volume (not a host bind mount) keeps
# the container's skill/instruction symlinks independent from the host,
# allowing both native and containerized opencode on the same machine.
- devbox-opencode-config:/home/developer/.config/opencode
- devbox-pi-config:/home/developer/.pi
# NOTE: Do NOT bind-mount ~/.agents/skills/ from the host. The
# container manages its own skills directory independently — the
# entrypoint deploys skills from the skillset repo on each start.
# Sharing it with the host causes symlink conflicts (relative paths
# differ between host and container filesystem namespaces).
# Optional: mount neovim config from host (plugins auto-install on first start)
# - ~/.config/nvim:/home/developer/.config/nvim:ro
@@ -108,6 +124,8 @@ services:
# - ~/.aws:/home/developer/.aws
volumes:
devbox-opencode-config:
devbox-pi-config:
devbox-data:
devbox-state:
devbox-shell-history:
+66 -1
View File
@@ -25,7 +25,12 @@ if command -v mempalace &>/dev/null && [ -d /workspace ]; then
PALACE_DIR="${HOME}/.mempalace"
if [ ! -d "$PALACE_DIR/palace" ]; then
echo "Initializing MemPalace for workspace (non-interactive)..."
mempalace init --yes /workspace >/dev/null 2>&1 || true
# </dev/null: mempalace init has an interactive "Mine this directory
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
# Without redirected stdin, the process blocks here forever when run
# from `docker run -it` (the TTY keeps stdin open). EOF on stdin
# makes the prompt fall through to its default (skip).
mempalace init --yes /workspace </dev/null >/dev/null 2>&1 || true
fi
fi
@@ -44,6 +49,66 @@ fi
# generated) and no-ops if OPENCODE_PROVIDER is unset.
python3 /usr/local/lib/opencode-devbox/generate-config.py
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
# Runs only when pi was baked into the image (INSTALL_PI=true at build).
# Each install.sh is idempotent and backs up real files before linking,
# so re-running across container restarts is safe.
#
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
# and writes the AWS env loader), then pi-extensions (symlinks our 6
# extensions), then settings.json bootstrap from the toolkit template,
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
# install_skill is intentionally skipped to avoid racing with skillset
# auto-deploy below).
if command -v pi &>/dev/null; then
if [ -d /opt/pi-toolkit ]; then
(cd /opt/pi-toolkit && ./install.sh --yes) || \
echo "WARN: pi-toolkit install.sh failed (continuing)"
fi
if [ -d /opt/pi-extensions ]; then
(cd /opt/pi-extensions && ./install.sh --yes) || \
echo "WARN: pi-extensions install.sh failed (continuing)"
fi
# Bootstrap settings.json from template if absent (pi rewrites this
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
[ -f /opt/pi-toolkit/settings.example.json ]; then
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
fi
# pi↔mempalace MCP bridge — single extension symlink.
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
command -v mempalace &>/dev/null && \
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
"$HOME/.pi/agent/extensions/mempalace.ts"
fi
fi
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
# run the deploy script to create relative symlinks for skills and instructions.
# This ensures skills resolve correctly inside the container regardless of
# where the repo lives on the host. Idempotent — second run is a no-op.
#
# Detection order:
# 1. SKILLSET_CONTAINER_PATH env var (explicit, for non-standard layouts)
# 2. $HOME/skillset (dedicated volume mount via SKILLSET_PATH in compose)
# 3. /workspace/skillset (skillset is directly inside workspace root)
SKILLSET_DEPLOY=""
if [ -n "${SKILLSET_CONTAINER_PATH:-}" ] && [ -x "${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" ]; then
SKILLSET_DEPLOY="${SKILLSET_CONTAINER_PATH}/deploy-skills.sh"
elif [ -x "$HOME/skillset/deploy-skills.sh" ]; then
SKILLSET_DEPLOY="$HOME/skillset/deploy-skills.sh"
elif [ -x /workspace/skillset/deploy-skills.sh ]; then
SKILLSET_DEPLOY="/workspace/skillset/deploy-skills.sh"
fi
if [ -n "$SKILLSET_DEPLOY" ]; then
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
fi
CONFIG_DIR="$HOME/.config/opencode"
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
+1
View File
@@ -87,6 +87,7 @@ for dir in \
/home/"$USER_NAME"/.vscode-server \
/home/"$USER_NAME"/.config/opencode \
/home/"$USER_NAME"/.config/nvim \
/home/"$USER_NAME"/.pi \
/home/"$USER_NAME"/.agents/skills; do
[ -d "$dir" ] || continue
@@ -96,6 +96,14 @@ def register_mcp_servers(config: dict) -> list[str]:
"enabled": False,
}
# Context7 — up-to-date library documentation for LLMs (remote).
# Free tier works without an API key; set CONTEXT7_API_KEY for higher
# rate limits. No local binary needed — purely a remote MCP endpoint.
servers["context7"] = {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
}
if servers:
config["mcp"] = servers
@@ -110,14 +118,17 @@ def main() -> int:
home = Path(os.environ.get("HOME", "/home/developer"))
config_dir = home / ".config" / "opencode"
config_file = config_dir / "opencode.json"
config_file = config_dir / "opencode.jsonc"
config_file_legacy = config_dir / "opencode.json"
# CRITICAL: never overwrite an existing config. Users may have
# bind-mounted their host config directory, or their config may be
# persisted in a named volume from a previous run.
if config_file.exists():
# Check both .json and .jsonc variants.
if config_file.exists() or config_file_legacy.exists():
existing = config_file if config_file.exists() else config_file_legacy
print(
f"Existing opencode.json found at {config_file}"
f"Existing config found at {existing}"
"skipping generation.",
file=sys.stderr,
)
@@ -140,8 +151,23 @@ def main() -> int:
added = register_mcp_servers(config)
config_dir.mkdir(parents=True, exist_ok=True)
# Write as JSONC so we can include helpful comments.
content = json.dumps(config, indent=2)
# Insert a comment about Context7 API key after the context7 url line.
context7_comment = (
' "url": "https://mcp.context7.com/mcp"\n'
" // For higher rate limits, sign up at https://context7.com/dashboard\n"
' // and add: "headers": { "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" }'
)
content = content.replace(
' "url": "https://mcp.context7.com/mcp"',
context7_comment,
)
with config_file.open("w") as f:
json.dump(config, f, indent=2)
f.write(content)
f.write("\n")
if added:
+103 -178
View File
@@ -1,15 +1,30 @@
#!/usr/bin/env python3
"""
Generate DOCKER_HUB.md from README.md.
Generate DOCKER_HUB.md.
Rationale
---------
README.md is the authoritative source. DOCKER_HUB.md is a subset
intended for users pulling the pre-built image from Docker Hub — so
build-from-source instructions, developer setup (git hooks, gitleaks),
and CI/contribution content are dropped.
DOCKER_HUB.md is the public-facing description shown on Docker Hub. It
has two hard constraints the README does not:
Docker Hub enforces a 25 kB limit on the full description field.
1. A 25 kB byte limit on the full_description field.
2. A different audience: Hub readers want a 30-second evaluation —
"what is this, how do I run it, does it have what I need" — and
reference material is better consulted in context on gitea.
For a long time this script tried to derive DOCKER_HUB.md from README.md
by section selection + targeted replacement. As the README grew that
approach pushed against the 25 kB ceiling on every change, costing a
trim-something-else exercise per edit (final state: 3 byte headroom).
The new approach is much simpler: a hand-written HUB_TEMPLATE below.
The template intentionally stays slim and links out to the gitea README
for everything that benefits from depth. README.md grows freely.
Trade-off: when image-variants table or quick-start flow changes,
update HUB_TEMPLATE here too. That coupling is now explicit and
local rather than spread across SECTION_RULES + REPLACEMENTS + TRIM
machinery.
Usage
-----
@@ -19,75 +34,57 @@ Regenerate in place:
Fail if DOCKER_HUB.md is out of sync with what this script would emit
(run this in CI):
python3 scripts/generate-dockerhub-md.py --check
Design
------
Sections are selected and in some cases rewritten via `SECTION_RULES`
below. This keeps the transformation explicit and easy to audit — if
a new section is added to README.md that should also appear on Docker
Hub, extend SECTION_RULES rather than inventing implicit heuristics.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
README = REPO_ROOT / "README.md"
DOCKER_HUB = REPO_ROOT / "DOCKER_HUB.md"
# Max size for Docker Hub full_description (bytes, UTF-8).
MAX_SIZE_BYTES = 25_000
# Per-section transformation.
#
# Each key is a top-level section title as it appears in README.md
# (without the leading "## ").
#
# The value is one of:
# "keep" — include verbatim.
# "drop" — exclude entirely.
# "replace" — substitute a custom body (see REPLACEMENTS).
# "trim" — keep but drop selected level-3 sub-sections listed
# in TRIM_SUBSECTIONS[title].
#
# Unknown sections default to "drop" with a warning — forcing an
# explicit decision whenever README gains a new section.
SECTION_RULES: dict[str, str] = {
"Why?": "drop", # build-motivation, not user-facing
"Quick Start": "replace", # swap docker compose clone flow for docker run
"Features": "keep",
"Usage": "keep",
"Configuration": "trim", # drop dev-build sub-sections
"oh-my-opencode-slim (Multi-Agent Orchestration)": "keep",
"AWS Bedrock Authentication": "keep",
"MemPalace — persistent AI memory": "keep",
"Gitea MCP server": "keep",
"Shell defaults": "keep",
"Secret Scanning": "drop", # dev-only — gitleaks is for committers
"Architecture": "keep",
"License": "replace", # point at source repo instead
}
# Where readers go for the full reference.
GITEA = "https://gitea.jordbo.se/joakimp/opencode-devbox"
# Level-3 sub-section titles (without the leading "### ") to drop from
# sections flagged as "trim". These are dev/build-oriented — Docker Hub
# users already have the image and don't need rebuild or multi-user
# compose instructions.
TRIM_SUBSECTIONS: dict[str, set[str]] = {
"Configuration": {
"Multi-user setup",
"Rebuilding the Image",
"Build Args",
},
}
# Replacement bodies. Keys match SECTION_RULES entries marked "replace".
# Each value is the full section including the "## Title" heading.
REPLACEMENTS: dict[str, str] = {
"Quick Start": """## Quick Start
HUB_TEMPLATE = f"""# opencode-devbox
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
Designed for teams who want a reproducible coding-agent setup that runs the same on every laptop and CI runner — without forcing each developer to install Bun, Node, AWS CLI, mempalace, or maintain shell config drift across machines.
## Image Variants
| Tag | Description |
|---|---|
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/earendil-works/pi) as alternative/complementary harness (shares the mempalace install with opencode) |
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
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 \\
@@ -100,138 +97,65 @@ docker run -it --rm \\
joakimp/opencode-devbox:latest
```
This drops you straight into opencode with your project mounted at `/workspace`.
Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedrock SSO + static), persistence model, build args, troubleshooting: <{GITEA}#readme>
For an interactive shell first (useful for AWS SSO login):
## What's Inside
```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
```
- **[opencode](https://opencode.ai)** — primary coding-agent harness. Multi-provider (Anthropic, OpenAI, Bedrock, Google, Groq, etc.).
- **[pi](https://github.com/earendil-works/pi)** *(in `*-with-pi` variants)* — lightweight TUI coding-agent that coexists with opencode and shares the same mempalace install. Includes the `mcp-loader` extension so any local-stdio or remote streamable-HTTP MCP server (searxng, gitea, context7, …) can be added by editing `~/.pi/agent/settings.json`.
- **[mempalace](https://github.com/MemPalace/mempalace)** — persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are mutually visible to opencode and pi.
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** *(in `*-omos` variants)* — multi-agent orchestration on top of opencode (council, fallback chains, named agents).
- **AWS CLI v2** with SSO support, **Node.js LTS**, **Bun** (OMOS variants), **uv** (Python), **gosu** for clean UID/GID adjustment to match your host workspace.
- **MCP wrappers** for mempalace pre-installed and pre-wired to both harnesses.
Then run `opencode` when ready.
## Authentication
For docker-compose users, see the source repo for `docker-compose.yml` and `.env.example` templates.
""",
"License": """## Source
The container reads provider credentials from environment variables and host-mounted config:
MIT licensed. Source, issues, and `docker-compose.yml` templates: <https://gitea.jordbo.se/joakimp/opencode-devbox>
""",
}
- **Anthropic / OpenAI / Groq / others:** set `OPENCODE_PROVIDER` and the corresponding `*_API_KEY` via `-e` or `.env`.
- **AWS Bedrock (SSO):** mount `~/.aws` from the host, `OPENCODE_PROVIDER=amazon-bedrock`, then `aws sso login` inside the container. Tokens persist across container restarts via the host bind-mount.
- **OAuth / device-code providers:** auth state lives in opencode's config, which is persisted via the `devbox-opencode-config` named volume.
Full Bedrock walkthrough (IAM roles, permissions, multi-account setups): see the [AWS Bedrock Authentication](
{GITEA}#aws-bedrock-authentication
) section on gitea.
# Prepended to the generated file.
HEADER = """# opencode-devbox — Docker Hub
## Persistence
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
| Volume | Mount | Survives |
|---|---|---|
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild |
| `devbox-pi-config` | `~/.pi` | container recreate, image rebuild — incl. user-installed pi packages via `pi install` (`NPM_CONFIG_PREFIX` points into the volume) |
| `devbox-palace` (uncomment) | `~/.mempalace` | container recreate, image rebuild — palace data is precious, treat as primary storage |
| `devbox-chroma-cache` | `~/.cache/chroma` | container recreate (model cache, disposable — re-downloads in seconds) |
## Image Variants
Workspace bind-mount (`/workspace`) is your project directory on the host, so source code is never inside the container.
Two image variants are published for each release:
Full persistence reference, including multi-user (`SIGNUM`) isolation and host bind-mount alternatives: see the [README on gitea]({GITEA}#persistence).
| Tag | Description |
|---|---|
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
## Where to Go Next
Both variants support `linux/amd64` and `linux/arm64`.
- **Full README** with build args, every feature in detail, troubleshooting: <{GITEA}>
- **CHANGELOG** for version history: <{GITEA}/src/branch/main/CHANGELOG.md>
- **Issues / source / docker-compose templates:** <{GITEA}>
- **Agent-facing internals** (for future maintainers / coding agents working in the repo): <{GITEA}/src/branch/main/AGENTS.md>
> **NOTE:** This file is auto-generated from `README.md` by `scripts/generate-dockerhub-md.py`. Edit README.md and regenerate rather than editing this file directly.
## 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>.
---
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
"""
def split_sections(md: str) -> list[tuple[str, str]]:
"""Split markdown on level-2 headings, returning (title, body) pairs.
The body includes the heading line and everything up to (but not
including) the next level-2 heading or EOF. Content before the first
``## `` is returned with an empty title (the document preamble).
"""
pattern = re.compile(r"^## ", re.MULTILINE)
parts = pattern.split(md)
preamble, *rest = parts
sections: list[tuple[str, str]] = []
if preamble.strip():
sections.append(("", preamble))
for part in rest:
line, _, body = part.partition("\n")
sections.append((line.strip(), f"## {line}\n{body}"))
return sections
def trim_subsections(body: str, drop: set[str]) -> str:
"""Remove level-3 sub-sections whose title is in `drop`.
A sub-section starts at a line beginning with "### " and ends at
the next "### " or "## " (or EOF).
"""
if not drop:
return body
# Split on level-3 headings while preserving the level-2 header
# block. First piece is everything up to the first "### ".
parts = re.split(r"(^### .+\n)", body, flags=re.MULTILINE)
# parts alternates: [before_first_h3, "### Title\n", body, "### Title\n", body, ...]
kept: list[str] = [parts[0]] if parts else []
i = 1
while i < len(parts):
heading = parts[i]
content = parts[i + 1] if i + 1 < len(parts) else ""
title = heading[4:].strip()
if title not in drop:
kept.append(heading)
kept.append(content)
i += 2
return "".join(kept)
def generate() -> str:
"""Produce the DOCKER_HUB.md content string."""
readme = README.read_text(encoding="utf-8")
sections = split_sections(readme)
out: list[str] = [HEADER]
unknown: list[str] = []
for title, body in sections:
if title == "":
# README preamble is replaced by our HEADER; skip.
continue
rule = SECTION_RULES.get(title)
if rule is None:
unknown.append(title)
continue
if rule == "drop":
continue
if rule == "keep":
out.append(body.rstrip() + "\n\n")
elif rule == "trim":
trimmed = trim_subsections(body, TRIM_SUBSECTIONS.get(title, set()))
out.append(trimmed.rstrip() + "\n\n")
elif rule == "replace":
out.append(REPLACEMENTS[title].rstrip() + "\n\n")
else: # pragma: no cover — programmer error
raise AssertionError(f"unknown rule {rule!r} for section {title!r}")
if unknown:
print(
"ERROR: README.md contains sections not classified in "
"SECTION_RULES:\n - "
+ "\n - ".join(unknown)
+ "\n\nAdd each to SECTION_RULES in "
"scripts/generate-dockerhub-md.py (choose keep/drop/replace).",
file=sys.stderr,
)
raise SystemExit(2)
return "".join(out).rstrip() + "\n"
return HUB_TEMPLATE
def main() -> int:
@@ -257,11 +181,10 @@ def main() -> int:
existing = DOCKER_HUB.read_text(encoding="utf-8") if DOCKER_HUB.exists() else ""
if existing != content:
print(
"ERROR: DOCKER_HUB.md is out of sync with README.md.\n"
"ERROR: DOCKER_HUB.md is out of sync with the template.\n"
"Run: python3 scripts/generate-dockerhub-md.py",
file=sys.stderr,
)
# Show a small diff hint.
import difflib
diff = difflib.unified_diff(
@@ -274,14 +197,16 @@ def main() -> int:
sys.stderr.writelines(list(diff)[:80])
return 1
print(
f"OK: DOCKER_HUB.md is in sync with README.md "
f"({size} bytes, {MAX_SIZE_BYTES} limit).",
f"OK: DOCKER_HUB.md is in sync with HUB_TEMPLATE "
f"({size} bytes, {MAX_SIZE_BYTES} limit, "
f"{MAX_SIZE_BYTES - size} bytes headroom).",
)
return 0
DOCKER_HUB.write_text(content, encoding="utf-8")
print(
f"Wrote {DOCKER_HUB} ({size} bytes, {MAX_SIZE_BYTES} limit).",
f"Wrote {DOCKER_HUB} ({size} bytes, {MAX_SIZE_BYTES} limit, "
f"{MAX_SIZE_BYTES - size} bytes headroom).",
)
return 0
+101 -19
View File
@@ -8,7 +8,7 @@
# - Generated opencode.json has the expected shape
# - MCP wrapper works (when mempalace is installed)
#
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos]
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos|with-pi|omos-with-pi]
#
# Exit codes:
# 0 all checks passed
@@ -23,7 +23,7 @@ if [ "${2:-}" = "--variant" ]; then
fi
if [ -z "$IMAGE" ]; then
echo "usage: $0 <image> [--variant base|omos]" >&2
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi]" >&2
exit 2
fi
@@ -50,7 +50,12 @@ echo "-- Resolved component versions --"
# always record what got baked into this image, even when Dockerfile
# ARGs default to "latest".
docker run --rm --entrypoint="" "$IMAGE" sh -c '
printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)"
if command -v opencode >/dev/null 2>&1; then
printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)"
fi
if command -v pi >/dev/null 2>&1; then
printf " %-15s %s\n" "pi" "$(pi --version 2>&1 | head -1)"
fi
printf " %-15s %s\n" "node" "$(node --version)"
printf " %-15s %s\n" "npm" "$(npm --version)"
printf " %-15s %s\n" "nvim" "$(nvim --version | head -1)"
@@ -77,7 +82,13 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
'
echo
echo "-- Core binaries --"
run "opencode" "opencode --version"
# opencode is gated on INSTALL_OPENCODE=true (default). When absent, the
# image is a pi-only build (or a pure base — no harness at all).
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v opencode" >/dev/null 2>&1; then
run "opencode" "opencode --version"
else
echo " - opencode not installed (INSTALL_OPENCODE=false)"
fi
run "node" "node --version"
run "npm" "npm --version"
run "git" "git --version"
@@ -117,13 +128,70 @@ elif docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev
echo " - mempalace-toolkit not installed (INSTALL_MEMPALACE_TOOLKIT=false)"
fi
# bun: only in the omos variant
if [ "$VARIANT" = "omos" ]; then
# pi: present when built with INSTALL_PI=true. Verifies pi itself plus
# the runtime-deployed pi-toolkit + pi-extensions + mempalace bridge
# symlinks under ~/.pi/agent/. Note: extension symlinks are created by
# 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"
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
# Run the full entrypoint as developer to verify install.sh deployment.
# Spin up a long-running container so we can `docker exec` into it from
# the host — the `run` helper above invokes commands INSIDE the image
# and has no docker CLI to nest with.
CID=$(docker run -d --rm "$IMAGE" tail -f /dev/null)
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions.
# Marker: keybindings.json symlink lands once pi-toolkit/install.sh has run.
# Up to 30s — omos-with-pi has more setup work than base+pi.
for _ in $(seq 1 30); do
if docker exec "$CID" test -L /home/developer/.pi/agent/keybindings.json 2>/dev/null; then
break
fi
sleep 1
done
exec_test() {
local label="$1"; shift
local out
if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then
pass "$label ($(echo "$out" | head -1))"
else
fail "$label: $out"
fi
}
exec_test "~/.pi/agent/keybindings.json (pi-toolkit)" \
'test -L $HOME/.pi/agent/keybindings.json && echo ok'
exec_test "~/.pi/agent/extensions/*.ts ≥ 4 (pi-extensions)" \
'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
exec_test "~/.pi/agent/extensions/mempalace.ts (bridge)" \
'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
exec_test "~/.pi/agent/settings.json (template bootstrap)" \
'test -f $HOME/.pi/agent/settings.json && echo ok'
docker rm -f "$CID" >/dev/null 2>&1 || true
trap - EXIT
else
echo " - pi not installed (INSTALL_PI=false)"
fi
# bun: only in the omos and omos-with-pi variants
if [ "$VARIANT" = "omos" ] || [ "$VARIANT" = "omos-with-pi" ]; then
run "bun (omos)" "bun --version"
run "bunx symlink (omos)" "test -L /usr/local/bin/bunx && readlink /usr/local/bin/bunx"
# oh-my-opencode-slim is npm-installed globally (not a bun install);
# verify it shows up in the global module list.
run "oh-my-opencode-slim" "npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim"
# verify it shows up in the global module list. We must explicitly point
# npm at the system prefix (/usr) here: the image's NPM_CONFIG_PREFIX env
# is set to /home/developer/.pi/npm-global so user-installed packages
# land on the persistent volume — which means a default `npm ls -g`
# 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"
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"
@@ -160,11 +228,11 @@ else
fi
rm -f "$tmpout"
# Config generation with anthropic provider writes valid JSON with the
# Config generation with anthropic provider writes valid JSONC with the
# expected shape. The script's log message goes to stderr (line 1 of
# generate-config.py uses file=sys.stderr) so capturing only stdout
# gives us clean JSON.
label="generate-config produces valid opencode.json"
# gives us clean JSONC. We strip // comments before validating JSON.
label="generate-config produces valid opencode.jsonc"
tmp=$(mktemp -d)
if docker run --rm \
-e OPENCODE_PROVIDER=anthropic \
@@ -173,24 +241,31 @@ if docker run --rm \
"$IMAGE" sh -c '
mkdir -p /tmp/home
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
cat /tmp/home/.config/opencode/opencode.json
' > "$tmp/out.json" 2>/dev/null; then
cat /tmp/home/.config/opencode/opencode.jsonc
' > "$tmp/out.jsonc" 2>/dev/null; then
# Strip single-line // comments for JSON validation (respecting strings)
if python3 -c "
import json, sys
c = json.load(open('$tmp/out.json'))
import re, json, sys
text = open('$tmp/out.jsonc').read()
# Match either a string literal or a // comment; keep strings, drop comments
pattern = r'\"(?:\\\\.|[^\"\\\\])*\"|//[^\n]*'
stripped = re.sub(pattern, lambda m: m.group(0) if m.group(0).startswith('\"') else '', text)
c = json.loads(stripped)
assert c['model'].startswith('anthropic/'), c
assert c['autoupdate'] is False
assert c['share'] == 'disabled'
assert 'context7' in c.get('mcp', {}), 'context7 MCP not registered'
" 2>&1; then
pass "$label"
else
fail "$label: output doesn't match expected shape: $(cat "$tmp/out.json")"
fail "$label: output doesn't match expected shape: $(cat "$tmp/out.jsonc")"
fi
else
fail "$label: container failed: $(cat "$tmp/out.json")"
fail "$label: container failed: $(cat "$tmp/out.jsonc")"
fi
# Config generation is idempotent — running twice must not overwrite.
# Tests both legacy .json and new .jsonc detection.
label="generate-config never overwrites existing config"
if docker run --rm \
-e OPENCODE_PROVIDER=anthropic \
@@ -214,9 +289,16 @@ 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 3000 MB. Adjust as image content evolves.
# 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 variant to ~3.1 GB. Functional smoke checks all pass; this is a
# guardrail, not a performance limit.
THRESHOLD=2500
[ "$VARIANT" = "omos" ] && THRESHOLD=3000
[ "$VARIANT" = "omos" ] && THRESHOLD=3300
[ "$VARIANT" = "with-pi" ] && THRESHOLD=2700
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3500
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
else