Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad4a12b3ab | |||
| fde5a89e8b | |||
| 034830710c | |||
| d293ddc202 | |||
| 910378fe06 | |||
| f06a70a3bc | |||
| dba05da7d1 | |||
| 8359fef949 | |||
| a438c67f06 | |||
| 07e07ec611 | |||
| 7dc836ab66 | |||
| a3ff601bf0 | |||
| 6fde27c212 | |||
| b30ffc83bd | |||
| 896380bb9c | |||
| 911d6dd26b | |||
| 4c27e6fd8a | |||
| b5da6a5cf8 | |||
| f86c4b18cf | |||
| 9df126c7a9 | |||
| 148f4bce8c | |||
| cc98722d84 | |||
| d01cff38d5 | |||
| 8083cd1a6f | |||
| f46c4ed017 | |||
| bf811f2170 | |||
| c76b1e8aa3 | |||
| 23bf383a37 | |||
| 5006b01170 | |||
| f51e9f52a1 | |||
| a208b073b0 | |||
| a803fe4653 | |||
| 79b697dea0 | |||
| 3e3abc8672 | |||
| 59e58a9d00 | |||
| 26ce9aa490 | |||
| 3d4e739529 | |||
| a6b0b59946 | |||
| fc74a8f906 | |||
| 5a2d06340e | |||
| 23894bc19f | |||
| f0918ba915 | |||
| 1683650240 | |||
| 9d7c3e5ad8 | |||
| 23bae2ab7d | |||
| e0b6c2082f | |||
| 2c889b472e | |||
| 349bb633ff | |||
| 3b3533d40b | |||
| 113c9f0bb0 | |||
| 4efc4e8005 | |||
| 49fad7cad9 | |||
| ca44da71e1 | |||
| 8e605e87d4 | |||
| 7a8de0463f | |||
| adaf7ba2ff | |||
| d426e92745 | |||
| b9c08c3dbb | |||
| 45d7e02faf |
@@ -31,6 +31,31 @@ WORKSPACE_PATH=~/projects
|
|||||||
# Path to SSH keys on host
|
# Path to SSH keys on host
|
||||||
SSH_KEY_PATH=~/.ssh
|
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) ─────────────────────────────────
|
# ── Locale (defaults to en_US.UTF-8) ─────────────────────────────────
|
||||||
# LANG=sv_SE.UTF-8
|
# LANG=sv_SE.UTF-8
|
||||||
# LANGUAGE=sv_SE:sv
|
# LANGUAGE=sv_SE:sv
|
||||||
@@ -42,3 +67,32 @@ SSH_KEY_PATH=~/.ssh
|
|||||||
# OMOS_TMUX=false # Enable tmux multiplexer integration
|
# OMOS_TMUX=false # Enable tmux multiplexer integration
|
||||||
# OMOS_SKILLS=true # Install recommended skills (simplify, agent-browser, cartography)
|
# OMOS_SKILLS=true # Install recommended skills (simplify, agent-browser, cartography)
|
||||||
# OMOS_RESET=false # Force regenerate oh-my-opencode-slim config on next start
|
# 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)
|
||||||
|
|||||||
@@ -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. ~40–80 min wall clock depending on runner count and whether base needs rebuilding. |
|
||||||
|
| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of all four variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. |
|
||||||
|
|
||||||
|
## Why the split-base pipeline exists
|
||||||
|
|
||||||
|
opencode-devbox publishes **four image variants** (`base`, `omos`, `with-pi`, `omos-with-pi`) × **two architectures** (amd64, arm64) = **eight image tags per release**. Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~3–5x slower than native).
|
||||||
|
|
||||||
|
The four variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build.
|
||||||
|
|
||||||
|
Two improvements were considered:
|
||||||
|
|
||||||
|
1. **Reorder the original Dockerfile** so all variant-gated RUNs land at the bottom — modest gain, ~10–20% wall-clock reduction. *Not pursued.*
|
||||||
|
2. **Split into `Dockerfile.base` + `Dockerfile.variant`** with the base published as a long-lived shared image — significant gain, ~50–70% 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) | ~165–180 min | **~30–40 min** (base cache hit) |
|
||||||
|
| Base-touching release (apt/Node/Debian/entrypoint change) | ~165–180 min | **~70–90 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 ~25–40 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`.
|
||||||
@@ -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
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
name: Publish Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-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: |
|
|
||||||
# Prefer IPv4 to avoid intermittent IPv6 connectivity failures
|
|
||||||
echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
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: Extract version from tag
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
VERSION=${GITHUB_REF#refs/tags/}
|
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and push (base)
|
|
||||||
uses: docker/build-push-action@v7
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}
|
|
||||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest
|
|
||||||
|
|
||||||
build-omos:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Force IPv4 for Docker Hub
|
|
||||||
run: |
|
|
||||||
# Prefer IPv4 to avoid intermittent IPv6 connectivity failures
|
|
||||||
echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
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: Extract version from tag
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
VERSION=${GITHUB_REF#refs/tags/}
|
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and push (omos)
|
|
||||||
uses: docker/build-push-action@v7
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
build-args: |
|
|
||||||
INSTALL_OMOS=true
|
|
||||||
tags: |
|
|
||||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-omos
|
|
||||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-omos
|
|
||||||
|
|
||||||
update-description:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build-base, build-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
|
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
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-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:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- 'CHANGELOG.md'
|
||||||
|
- 'README.md'
|
||||||
|
- 'DOCKER_HUB.md'
|
||||||
|
- 'deploy/**'
|
||||||
|
- '.gitleaks.toml'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docs-check:
|
||||||
|
# Fails if DOCKER_HUB.md is out of sync with what generate-dockerhub-md.py
|
||||||
|
# would produce from README.md. Keeps the two docs from drifting.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check DOCKER_HUB.md is in sync with README.md
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
|
- name: Build base 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
|
||||||
|
tags: opencode-devbox:ci-base
|
||||||
|
|
||||||
|
- name: Smoke test
|
||||||
|
run: |
|
||||||
|
bash scripts/smoke-test.sh opencode-devbox:ci-base --variant base
|
||||||
|
|
||||||
|
validate-omos:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Force IPv4 for Docker Hub
|
||||||
|
run: |
|
||||||
|
echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||||
|
|
||||||
|
- name: Reclaim runner disk
|
||||||
|
run: |
|
||||||
|
set -x
|
||||||
|
df -h / || true
|
||||||
|
rm -rf \
|
||||||
|
/opt/hostedtoolcache \
|
||||||
|
/opt/microsoft \
|
||||||
|
/opt/az \
|
||||||
|
/opt/ghc \
|
||||||
|
/usr/local/.ghcup \
|
||||||
|
/usr/share/dotnet \
|
||||||
|
/usr/share/swift \
|
||||||
|
/usr/local/lib/android \
|
||||||
|
/usr/local/share/powershell \
|
||||||
|
/usr/local/share/chromium \
|
||||||
|
/usr/local/share/boost \
|
||||||
|
/usr/lib/jvm 2>/dev/null || true
|
||||||
|
apt-get clean || true
|
||||||
|
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||||
|
docker system df || true
|
||||||
|
docker system prune -af --volumes || true
|
||||||
|
docker builder prune -af || true
|
||||||
|
df -h / || true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
with:
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
|
- name: Build omos 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
|
||||||
|
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
|
||||||
+11
@@ -4,5 +4,16 @@
|
|||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Docker buildx state (created by 'docker compose build')
|
||||||
|
.docker/
|
||||||
|
|
||||||
# Personal cloud-init overrides (not shared)
|
# Personal cloud-init overrides (not shared)
|
||||||
deploy/my-cloud-init.yml
|
deploy/my-cloud-init.yml
|
||||||
|
|
||||||
|
# MemPalace per-project files (issue #185)
|
||||||
|
mempalace.yaml
|
||||||
|
entities.json
|
||||||
|
|
||||||
|
# Python bytecode (from running scripts/ and rootfs/.../*.py locally)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -2,51 +2,90 @@
|
|||||||
|
|
||||||
## Project overview
|
## 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
|
## File roles
|
||||||
|
|
||||||
- `Dockerfile` — single multi-stage build for both variants. OMOS variant is controlled by `INSTALL_OMOS=true` build arg. All GitHub-sourced binaries are pinned with version ARGs.
|
- `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.
|
||||||
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes. Then drops to developer via gosu.
|
- `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-user.sh` — runs as developer: git config, opencode.json generation from env vars, OMOS setup.
|
- `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`.
|
||||||
- `DOCKER_HUB.md` — pushed to Docker Hub description via CI API call. Must stay under 25KB. Short description field must be ≤100 bytes.
|
- `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.
|
||||||
- `README.md` — source repo documentation. Must stay in sync with DOCKER_HUB.md (both describe the same features but for different audiences).
|
- `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).
|
||||||
- `.gitea/workflows/docker-publish.yml` — CI pipeline: three parallel jobs (build-base, build-omos, update-description). Triggered by tag push only.
|
- `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 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-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
|
## 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.
|
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.
|
- **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.
|
- **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.
|
- 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
|
## 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.
|
- **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.
|
||||||
- **Three docs to keep in sync** — Dockerfile changes that add tools or features must be reflected in `README.md`, `DOCKER_HUB.md`, and `.env.example`. The docker-compose examples in both docs must match the source `docker-compose.yml` pattern.
|
- **Documentation coupling on release** — four docs co-vary and drift in lockstep when not updated together:
|
||||||
- **GitHub-sourced binaries** — fzf, gosu, git-lfs, neovim, bat, eza, zoxide, uv, rustup are installed from upstream releases (not apt) with pinned versions. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64).
|
- `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`.
|
- **Shell scripts use `set -euo pipefail`** — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with `|| true`.
|
||||||
|
- **MemPalace install path** — installed via `uv tool install` into `/opt/uv-tools/mempalace/`. Both the `mempalace` CLI and the `mempalace-mcp` MCP server binary are shipped as entry points by the mempalace package itself and placed on PATH by uv as shims whose shebangs point at the venv's Python. No hand-rolled wrapper is needed. Do not use `pip install --break-system-packages` — that was the previous approach and has been removed. Do not use `["python3", "-m", "mempalace.mcp_server"]` in `opencode.jsonc` — system Python can't import from the uv venv.
|
||||||
|
- **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.jsonc` or legacy `opencode.json`. Config persists in the `devbox-opencode-config` named volume; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this.
|
||||||
|
- **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.
|
- **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
|
## CI quirks
|
||||||
|
|
||||||
- Both build jobs include an IPv4 preference step (`gai.conf` + `driver-opts: network=host` for buildx) to work around intermittent IPv6 failures on the Gitea runners.
|
- Both build jobs include an IPv4 preference step (`gai.conf` + `driver-opts: network=host` for buildx) to work around intermittent IPv6 failures on the Gitea runners.
|
||||||
- `update-description` job runs only when both builds succeed (`needs: [build-base, build-omos]`).
|
- `update-description` job runs only when both builds succeed (`needs: [build-base, build-omos]`).
|
||||||
- Tags must be pushed to trigger CI. Pushing to `main` alone does not build images.
|
- 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
|
## Testing changes
|
||||||
|
|
||||||
No test suite. Verify by:
|
The smoke test (`scripts/smoke-test.sh`) is the canonical check and runs automatically in CI. To run locally:
|
||||||
1. Building locally: `docker compose build`
|
|
||||||
2. Running: `docker compose run --rm devbox bash`
|
```bash
|
||||||
3. Checking tool availability inside container: `nvim --version`, `bat --version`, `uv --version`, etc.
|
# Base image
|
||||||
4. For entrypoint changes: test with a non-1000 UID workspace to verify UID adjustment and volume ownership fixes.
|
docker compose build
|
||||||
|
bash scripts/smoke-test.sh opencode-devbox --variant base
|
||||||
|
|
||||||
|
# OMOS image
|
||||||
|
docker build --build-arg INSTALL_OMOS=true -t opencode-devbox:omos .
|
||||||
|
bash scripts/smoke-test.sh opencode-devbox:omos --variant omos
|
||||||
|
```
|
||||||
|
|
||||||
|
For manual/exploratory testing:
|
||||||
|
1. `docker compose run --rm devbox bash`
|
||||||
|
2. Check specific tools inside: `nvim --version`, `bat --version`, `uv --version`, `mempalace --help`, etc.
|
||||||
|
3. For entrypoint changes: test with a non-1000 UID workspace to verify UID adjustment, volume ownership fixes, and the `.devbox-owner` sentinel behavior.
|
||||||
|
4. For `generate-config.py` changes: run standalone with `HOME=/tmp/fake OPENCODE_PROVIDER=anthropic python3 rootfs/usr/local/lib/opencode-devbox/generate-config.py`.
|
||||||
|
|
||||||
## Commit style
|
## Commit style
|
||||||
|
|
||||||
|
|||||||
+262
@@ -6,6 +6,268 @@ 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 285–291:
|
||||||
|
|
||||||
|
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): ~40–50 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 (~40–80 min wall clock with 4 runners vs ~165–180 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 6–12 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.
|
||||||
|
|
||||||
|
**CI infrastructure: split multi-arch publish across separate runners.**
|
||||||
|
|
||||||
|
- **Fix:** The `publish` workflow exhausted runner disk space on `v1.14.30b` and would have hit the same wall on any subsequent release. Both variants built both architectures on a single `catthehacker/ubuntu:act-latest` container with ~40 GB of shared overlay space, and the peak disk footprint during the nodejs dpkg unpack / git-lfs layer export pushed it over the edge (`No space left on device`). The mempalace-toolkit bake-in from v1.14.30b added the final straw; the underlying issue is that QEMU-emulated arm64 layers were stored alongside the amd64 build on the same runner.
|
||||||
|
- `docker-publish.yml` refactored to the canonical `push-by-digest` + manifest-merge pattern: smoke test (amd64) runs on its own runner, each `(variant × arch)` push target runs on its own fresh runner with `outputs: type=image,...,push-by-digest=true,push=true` (no local image store), then a tiny merge job assembles the multi-arch manifest with `docker buildx imagetools create` from digest artifacts.
|
||||||
|
- Per-runner disk peak is now roughly one-quarter of the old single-job peak. The four Docker Hub tags produced per release (`vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`) are unchanged.
|
||||||
|
- Also parallelizes the amd64 and arm64 builds, so wall-clock time for a release should drop noticeably despite the added merge hop.
|
||||||
|
|
||||||
|
## v1.14.30b — 2026-04-30
|
||||||
|
|
||||||
|
**Bake mempalace-toolkit wrappers into the image.**
|
||||||
|
|
||||||
|
- **Fix:** The scheduler templates in [mempalace-toolkit's `contrib/`](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/contrib) assume `mempalace-session` is available inside the container, but the image never actually installed it. Users following the `*-devbox` scheduler docs would silently lose the wrappers on every `docker compose up --force-recreate`, because the only way to get them was a post-hoc `./install.sh --yes` inside the container — which lives in the ephemeral layer. The host-side systemd timer would then fire, `docker exec` in, and hit `mempalace-session: command not found`. Caught during runtime validation on 2026-04-30.
|
||||||
|
- New Dockerfile block clones `mempalace-toolkit` at build time (depth-1) to `/opt/mempalace-toolkit/`, symlinks `bin/mempalace-session` and `bin/mempalace-docs` into `/usr/local/bin/`, and asserts both respond to `--help` before the layer succeeds.
|
||||||
|
- Gated by `ARG INSTALL_MEMPALACE_TOOLKIT=true` (defaults on, depends on `INSTALL_MEMPALACE=true`).
|
||||||
|
- Floated ref via `ARG MEMPALACE_TOOLKIT_REF=main` — override for reproducible builds once the toolkit starts tagging releases.
|
||||||
|
- **Tests:** Smoke test gains three toolkit assertions (`mempalace-session --help`, `mempalace-docs --help`, symlink target check). The resolved-versions preamble now logs the toolkit git short-SHA alongside the other floated components.
|
||||||
|
- **Docs:** README's MemPalace section gains a `Scheduled mining (mempalace-toolkit)` subsection covering the new wrappers and pointing at `contrib/` for scheduling. New build-args table entry for `INSTALL_MEMPALACE_TOOLKIT`.
|
||||||
|
|
||||||
|
## v1.14.30 — 2026-04-30
|
||||||
|
|
||||||
|
Bump opencode to 1.14.30.
|
||||||
|
|
||||||
|
## v1.14.29c — 2026-04-29
|
||||||
|
|
||||||
|
**Drop redundant mempalace-mcp-server wrapper, use the entry point mempalace ships.**
|
||||||
|
|
||||||
|
- **Fix:** MCP integration with mempalace was still broken for users with custom `opencode.json` files because they typically had `["python3", "-m", "mempalace.mcp_server"]` from v1.14.28b and earlier. With the uv-tool install path, system python3 can't import mempalace and the MCP server subprocess exits immediately — opencode surfaced this as `MCP error -32000: connection closed`. Users should migrate to `["mempalace-mcp"]`. The auto-generated config in new containers already emits the new form.
|
||||||
|
- **Cleanup:** Remove the hand-rolled `/usr/local/bin/mempalace-mcp-server` wrapper. The mempalace Python package ships a `mempalace-mcp` console entry point; `uv tool install` places it on PATH as a shim whose shebang points at the isolated venv's Python. The wrapper was duplicating what uv installs for free. Removed `rootfs/usr/local/bin/` and its COPY + chmod lines from the Dockerfile.
|
||||||
|
- **Docs:** README's MemPalace section now shows `["mempalace-mcp"]` and explicitly warns against `["python3", "-m", "mempalace.mcp_server"]` with the observed failure mode.
|
||||||
|
- **Tests:** Smoke test asserts `/usr/local/bin/mempalace-mcp` is executable and prints its symlink target, replacing the previous wrapper-present check.
|
||||||
|
|
||||||
|
## v1.14.29b — 2026-04-29
|
||||||
|
|
||||||
|
**Fix OMOS `bunx` detection + CI build reliability.**
|
||||||
|
|
||||||
|
- **Fix:** `entrypoint-user.sh` checked `command -v bunx` to gate the OMOS auto-install, but the OMOS image only ships the `bun` binary — upstream's bun installer never creates a `bunx` symlink and neither did our Dockerfile. The check always failed on a fresh OMOS image, so `bun x oh-my-opencode-slim@latest install` never ran and first-start OMOS setup would have printed `ENABLE_OMOS=true but bun is not installed.` even though bun was right there. Latent until now because the only exercised path had a persisted `oh-my-opencode-slim.json` from a prior install.
|
||||||
|
- Changed the gate to `command -v bun`.
|
||||||
|
- Changed both install invocations from `bunx oh-my-opencode-slim@latest install ...` to `bun x oh-my-opencode-slim@latest install ...`.
|
||||||
|
- Added `ln -sf bun /usr/local/bin/bunx` to the Dockerfile's OMOS block so interactive users can still type `bunx` by habit, and verified the symlink at build time (`test -L /usr/local/bin/bunx`).
|
||||||
|
- Smoke test now asserts the `bunx` symlink is present on the OMOS variant.
|
||||||
|
- **Fix:** CI build robustness against transient GitHub/Gitea CDN failures. The first attempt at building v1.14.29b tripped on a single HTTP 502 from GitHub's release CDN mid-download (`zoxide-0.9.9-x86_64-unknown-linux-musl.tar.gz`), failing the entire OMOS build with no retry. Fix applied to every tool-download curl in the Dockerfile:
|
||||||
|
- `curl --retry 5 --retry-delay 5 --retry-all-errors` on both the `-fsSL` GET requests and the `-sI` HEAD requests used for `/releases/latest` redirect resolution. 5 attempts with 5 s back-off eats most transient CDN hiccups without failing the build.
|
||||||
|
- Added `[ -n "$V" ]` assertion after each version-resolution step. If the HEAD redirect ever fails to produce a tag name, the build fails fast with an empty-version message rather than trying to download `.../v//...` and producing a confusing 404.
|
||||||
|
- Same hardening applied to the optional Go install block (go.dev JSON feed + tarball download) and the nodesource apt-repo setup script.
|
||||||
|
- **Security:** Added `apt-get upgrade -y` to the core-packages RUN step. Picks up any security/CVE fixes published between `debian:trixie-slim` base-image rebuilds. Paired with the existing `update` and `install` in the same layer so image history isn't bloated. Today this produced `0 upgraded` (base image is current), but it future-proofs against the next CVE drop.
|
||||||
|
|
||||||
|
## v1.14.29 — 2026-04-28
|
||||||
|
|
||||||
|
**Opencode 1.14.29 + infrastructure and maintainability pass.**
|
||||||
|
|
||||||
|
- Bump opencode to 1.14.29.
|
||||||
|
- **Cleanup:** Remove dead `INSTALL_PYTHON` build arg. Python 3 + pip + venv have been unconditionally installed in the base layer since mempalace was added; the flag was a no-op. Users should use `uv` (pre-installed) or `uvx` for Python tooling.
|
||||||
|
- **Fix:** `mempalace init` in `entrypoint-user.sh` now uses `--yes` for non-interactive operation. Previously the command prompted the user (`Your choice [enter/edit/add]:`) on first container start, which either hung or printed prompts into the user's terminal. The init is still gated by `[ ! -d "$PALACE_DIR/palace" ]` so existing palace data from prior versions is preserved untouched on upgrade.
|
||||||
|
- **Feature:** MemPalace is now installed via `uv tool install` into an isolated venv at `/opt/uv-tools/mempalace/`, reached through a new `/usr/local/bin/mempalace-mcp-server` wrapper. Replaces the previous `pip install --break-system-packages` approach — removes the PEP 668 workaround and keeps mempalace deps out of system Python site-packages. The wrapper is what `generate-config.py` now references in the auto-generated `opencode.json`. Users with custom `opencode.json` files should update their mempalace MCP command from `["python3", "-m", "mempalace.mcp_server"]` to `["mempalace-mcp-server"]`.
|
||||||
|
- **Feature:** New `INSTALL_MEMPALACE` build arg (default `true`). Rebuild with `--build-arg INSTALL_MEMPALACE=false` to shave ~300 MB off the image when local AI memory isn't needed.
|
||||||
|
- **Refactor:** `opencode.json` generation extracted from `entrypoint-user.sh` into a standalone Python script at `/usr/local/lib/opencode-devbox/generate-config.py`. Easier to read, test, and extend with new providers. Default models are declared at the top of the script rather than hard-coded in bash heredocs. Reduces `entrypoint-user.sh` from 176 to 97 lines. Behavior is unchanged — the script preserves the critical guarantee of never overwriting an existing `opencode.json`.
|
||||||
|
- **Perf:** Container startup avoids the recursive `chown -R` on named volumes that already have correct ownership. A `.devbox-owner` sentinel file written after a successful chown lets subsequent starts short-circuit via a single `cat`. On volumes with thousands of files (nvim plugins, palace data) this cuts multi-second startup costs to milliseconds. If `USER_UID` changes between runs, the sentinel mismatches and the full chown still runs.
|
||||||
|
- **CI:** New `validate` workflow runs on every push to main and PR — single-arch amd64 build, smoke test, and DOCKER_HUB.md sync check. Catches broken Dockerfile changes without waiting for a tag push.
|
||||||
|
- **CI:** `docker-publish.yml` now smoke-tests each variant on amd64 before the full multi-arch push. A failing smoke test blocks the release.
|
||||||
|
- **CI:** Image size is tracked and fails the build if it exceeds thresholds (base: 2500 MB uncompressed, OMOS: 3000 MB). Makes bloat visible rather than silent.
|
||||||
|
- **Docs:** `DOCKER_HUB.md` is now auto-generated from `README.md` via `scripts/generate-dockerhub-md.py`. Editing it directly is a mistake — the `--check` step in CI fails if the committed file is out of sync. Section inclusion is controlled by explicit rules (`SECTION_RULES`, `TRIM_SUBSECTIONS`); adding a new section to README forces an explicit keep/drop/replace decision. Keeps the 25 kB Docker Hub limit in sight and eliminates manual sync burden.
|
||||||
|
- **Tests:** New `scripts/smoke-test.sh` asserts: (a) all core binaries are runnable and print a version, (b) opencode starts, (c) entrypoint correctly drops to the developer user, (d) `generate-config.py` produces valid JSON with the expected shape, (e) `generate-config.py` never overwrites an existing config, (f) bun is present only in the OMOS variant, (g) image size is under threshold. The smoke test also logs resolved versions of every component as its first step so CI output always records what got baked in.
|
||||||
|
- **Versioning:** All GitHub/Gitea-hosted binaries (gosu, fzf, git-lfs, neovim, bat, eza, zoxide, uv, gitea-mcp) and the go.dev-hosted Go toolchain now default to `latest` at build time. Each `*_VERSION` ARG resolves the newest upstream release by reading the `/releases/latest` Location redirect (or the go.dev JSON feed). Previously these were hand-pinned to a specific version, which meant rebuilds didn't pick up upstream CVE fixes until someone remembered to bump the pin. Pinning is still supported — pass `--build-arg NVIM_VERSION=0.12.1` etc. to lock a specific version. Intentionally still pinned: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major only), `DEBIAN_VERSION=trixie-slim` (OS base).
|
||||||
|
|
||||||
|
## v1.14.28b — 2026-04-27
|
||||||
|
|
||||||
|
- **Feature:** Add MemPalace local-first AI memory system to base image. Provides 29 MCP tools for semantic search over conversation history, knowledge graph queries, and agent diaries. Palace data persists via optional `devbox-palace` named volume, ChromaDB embedding model cache via `devbox-chroma-cache`. No API keys required.
|
||||||
|
- **Feature:** Auto-register mempalace MCP server in generated opencode.json (when mempalace is installed and config is auto-generated from OPENCODE_PROVIDER).
|
||||||
|
- **Feature:** Add official Gitea MCP server (`gitea-mcp`) to base image. Provides 50+ MCP tools for Gitea API (repos, issues, PRs, releases, Actions). Disabled by default — requires `GITEA_ACCESS_TOKEN` and `GITEA_HOST` env vars.
|
||||||
|
|
||||||
|
## v1.14.28 — 2026-04-26
|
||||||
|
|
||||||
|
Bump opencode to 1.14.28.
|
||||||
|
|
||||||
## v1.14.25 — 2026-04-25
|
## v1.14.25 — 2026-04-25
|
||||||
|
|
||||||
Bump opencode to 1.14.25. Also includes container-level changes since v1.14.22b:
|
Bump opencode to 1.14.25. Also includes container-level changes since v1.14.22b:
|
||||||
|
|||||||
+56
-517
@@ -1,20 +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.
|
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 |
|
| Tag | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
|
| `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-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`.
|
All variants support `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
## Quick Start
|
## 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
|
```bash
|
||||||
docker run -it --rm \
|
docker run -it --rm \
|
||||||
-e ANTHROPIC_API_KEY=your-key \
|
-e ANTHROPIC_API_KEY=your-key \
|
||||||
@@ -26,535 +43,57 @@ docker run -it --rm \
|
|||||||
joakimp/opencode-devbox:latest
|
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>
|
||||||
|
|
||||||
## Interactive Shell
|
## What's Inside
|
||||||
|
|
||||||
To get a shell first (useful for AWS SSO login or running other commands):
|
- **[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.
|
||||||
|
|
||||||
```bash
|
## Authentication
|
||||||
docker run -it --rm \
|
|
||||||
-e ANTHROPIC_API_KEY=your-key \
|
|
||||||
-e OPENCODE_PROVIDER=anthropic \
|
|
||||||
-v ~/projects:/workspace \
|
|
||||||
-v ~/.ssh:/home/developer/.ssh:ro \
|
|
||||||
joakimp/opencode-devbox:latest bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run `opencode` when ready.
|
The container reads provider credentials from environment variables and host-mounted config:
|
||||||
|
|
||||||
## Running Multiple Shells
|
- **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.
|
||||||
|
|
||||||
Once opencode is running it takes over the terminal. To have a separate shell for `aws`, `git`, or other commands, run the container in the background and attach multiple times:
|
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.
|
||||||
|
|
||||||
```bash
|
## Persistence
|
||||||
# Start in background
|
|
||||||
docker run -d --name devbox \
|
|
||||||
-e ANTHROPIC_API_KEY=your-key \
|
|
||||||
-e OPENCODE_PROVIDER=anthropic \
|
|
||||||
-v ~/projects:/workspace \
|
|
||||||
-v ~/.ssh:/home/developer/.ssh:ro \
|
|
||||||
joakimp/opencode-devbox:latest sleep infinity
|
|
||||||
|
|
||||||
# Shell 1: run opencode
|
| Volume | Mount | Survives |
|
||||||
docker exec -it -u developer devbox opencode
|
|
||||||
|
|
||||||
# Shell 2 (separate terminal): aws, git, etc.
|
|
||||||
docker exec -it -u developer devbox bash
|
|
||||||
|
|
||||||
# When done
|
|
||||||
docker rm -f devbox
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** Always use `-u developer` with `docker exec` — the container starts as root for UID adjustment, then drops to `developer`. Without `-u developer`, exec runs as root.
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
All configuration is done via environment variables, typically stored in a `.env` file.
|
|
||||||
|
|
||||||
### Provider Configuration
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `OPENCODE_PROVIDER` | LLM provider (`anthropic`, `openai`, `amazon-bedrock`) | `anthropic` |
|
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild |
|
||||||
| `OPENCODE_MODEL` | Model override | Provider default |
|
| `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) |
|
||||||
|
|
||||||
### API Keys
|
Workspace bind-mount (`/workspace`) is your project directory on the host, so source code is never inside the container.
|
||||||
|
|
||||||
Set the key matching your provider:
|
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).
|
||||||
|
|
||||||
| Variable | Provider |
|
## Where to Go Next
|
||||||
|---|---|
|
|
||||||
| `ANTHROPIC_API_KEY` | Anthropic |
|
|
||||||
| `OPENAI_API_KEY` | OpenAI |
|
|
||||||
| `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (static creds) |
|
|
||||||
|
|
||||||
### AWS Bedrock
|
- **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>
|
||||||
|
|
||||||
| Variable | Description | Default |
|
## Sibling images
|
||||||
|---|---|---|
|
|
||||||
| `AWS_REGION` | AWS region | `us-east-1` |
|
|
||||||
| `AWS_PROFILE` | AWS SSO profile name | `default` |
|
|
||||||
|
|
||||||
### Git
|
- **[`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>
|
||||||
|
|
||||||
| Variable | Description |
|
## License
|
||||||
|---|---|
|
|
||||||
| `GIT_USER_NAME` | Git commit author name |
|
|
||||||
| `GIT_USER_EMAIL` | Git commit author email |
|
|
||||||
|
|
||||||
### User ID Mapping
|
MIT. See <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/LICENSE>.
|
||||||
|
|
||||||
The container runs as user `developer` (UID 1000 by default). If your host user has a different UID, file permission mismatches can occur on mounted volumes.
|
---
|
||||||
|
|
||||||
The entrypoint automatically detects the owner of `/workspace` and adjusts the container user's UID/GID to match. You can also set it explicitly:
|
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|---|---|---|
|
|
||||||
| `USER_UID` | Container user UID | Auto-detect from `/workspace` owner |
|
|
||||||
| `USER_GID` | Container user GID | Auto-detect from `/workspace` owner |
|
|
||||||
|
|
||||||
### Locale and Editor
|
|
||||||
|
|
||||||
The container defaults to English (`en_US.UTF-8`) and neovim as the editor. Override via environment variables:
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|---|---|---|
|
|
||||||
| `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` |
|
|
||||||
|
|
||||||
Pre-generated locales: `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` (all UTF-8).
|
|
||||||
|
|
||||||
Example for Swedish:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
LANG=sv_SE.UTF-8
|
|
||||||
LANGUAGE=sv_SE:sv
|
|
||||||
LC_ALL=sv_SE.UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
To add a locale not in the list, run inside the container:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo sed -i '/xx_XX.UTF-8/s/^# //g' /etc/locale.gen
|
|
||||||
sudo locale-gen
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `xx_XX` with the desired locale (e.g. `ru_RU`, `tr_TR`). This change does not persist across container restarts — for permanent additions, build from source and modify the Dockerfile.
|
|
||||||
|
|
||||||
## Initial Setup
|
|
||||||
|
|
||||||
### 1. Create host directories
|
|
||||||
|
|
||||||
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
|
|
||||||
mkdir -p ~/projects
|
|
||||||
|
|
||||||
# If mounting opencode config (recommended for persistent settings)
|
|
||||||
mkdir -p ~/.config/opencode
|
|
||||||
|
|
||||||
# If using AWS Bedrock
|
|
||||||
# mkdir -p ~/.aws
|
|
||||||
|
|
||||||
# If mounting neovim config
|
|
||||||
# mkdir -p ~/.config/nvim
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Create a `.env` file
|
|
||||||
|
|
||||||
Create a `.env` file with your configuration. Examples for each provider:
|
|
||||||
|
|
||||||
**Anthropic:**
|
|
||||||
```bash
|
|
||||||
OPENCODE_PROVIDER=anthropic
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
GIT_USER_NAME=Your Name
|
|
||||||
GIT_USER_EMAIL=you@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**OpenAI:**
|
|
||||||
```bash
|
|
||||||
OPENCODE_PROVIDER=openai
|
|
||||||
OPENAI_API_KEY=sk-...
|
|
||||||
GIT_USER_NAME=Your Name
|
|
||||||
GIT_USER_EMAIL=you@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**AWS Bedrock (SSO):**
|
|
||||||
```bash
|
|
||||||
OPENCODE_PROVIDER=amazon-bedrock
|
|
||||||
OPENCODE_MODEL=amazon-bedrock/eu.anthropic.claude-opus-4-6-v1
|
|
||||||
AWS_REGION=eu-west-1
|
|
||||||
AWS_PROFILE=your-profile-name
|
|
||||||
GIT_USER_NAME=Your Name
|
|
||||||
GIT_USER_EMAIL=you@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. AWS SSO setup (Bedrock users only)
|
|
||||||
|
|
||||||
AWS SSO requires a `~/.aws/config` file on the host with your 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:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
aws configure sso
|
|
||||||
```
|
|
||||||
|
|
||||||
You'll be prompted for:
|
|
||||||
- SSO session name
|
|
||||||
- SSO start URL
|
|
||||||
- SSO region
|
|
||||||
- Registration scopes (typically `sso:account:access`)
|
|
||||||
|
|
||||||
The `~/.aws` directory must be mounted into the container (see docker-compose example below).
|
|
||||||
|
|
||||||
## Data Storage and Persistence
|
|
||||||
|
|
||||||
Understanding what survives container restarts and what doesn't:
|
|
||||||
|
|
||||||
| Path in container | Source | Survives restart? | Contains |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `/workspace` | Host bind mount | ✅ Yes — lives on host | Your project files |
|
|
||||||
| `/home/developer/.ssh` | Host bind mount (ro) | ✅ Yes — lives on host | SSH keys |
|
|
||||||
| `/home/developer/.aws` | Host bind mount | ✅ Yes — lives on host | AWS credentials/SSO cache |
|
|
||||||
| `/home/developer/.local/share/opencode` | Named volume (if configured) | ✅ Yes — Docker volume | Session history, memory, auth tokens |
|
|
||||||
| `/home/developer/.local/state/opencode` | Named volume (if configured) | ✅ Yes — Docker volume | TUI settings (theme, toggles) |
|
|
||||||
| `/home/developer/.cache/bash` | Named volume `devbox-shell-history` | ✅ Yes — Docker volume | Bash history (`$HISTFILE`) — survives container recreate |
|
|
||||||
| `/home/developer/.local/share/zoxide` | Named volume `devbox-zoxide` | ✅ Yes — Docker volume | Zoxide directory history (`z <fragment>` jump targets) |
|
|
||||||
| `/home/developer/.local/share/nvim` | Named volume `devbox-nvim-data` | ✅ Yes — Docker volume | Neovim plugins, Mason LSP installs, Lazy plugin cache |
|
|
||||||
| `/home/developer/.local/share/uv` | Named volume (if configured) | ✅ Yes — Docker volume | Python installs, uv tool installs |
|
|
||||||
| `/home/developer/.rustup` | Named volume (if configured) | ✅ Yes — Docker volume | Rust toolchains |
|
|
||||||
| `/home/developer/.cargo` | Named volume (if configured) | ✅ Yes — Docker volume | Cargo binaries, registry cache |
|
|
||||||
| `/home/developer/.vscode-server` | Named volume (if configured) | ✅ Yes — Docker volume | VS Code server and extensions |
|
|
||||||
| `/home/developer/.config/opencode` | Host bind mount (if configured) | ✅ Yes — lives on host | opencode.json, skills, plus `oh-my-opencode-slim.json` on the OMOS variant |
|
|
||||||
|
|
||||||
### Key points
|
|
||||||
|
|
||||||
- **Project files** (`/workspace`) are always safe — they're your host filesystem.
|
|
||||||
- **opencode config** is auto-generated from `OPENCODE_PROVIDER` env var on each start if no existing config is found. To persist config changes, mount the config directory from the host (see Custom opencode Config below).
|
|
||||||
- **opencode data** (session history, memory) is lost on container recreation unless you add a named volume.
|
|
||||||
- **TUI settings** (theme, toggles) are lost on container recreation unless you add the `devbox-state` named volume.
|
|
||||||
- **Bash history** persists via the `devbox-shell-history` volume mounted at `~/.cache/bash`. `HISTFILE` is pre-configured; no setup required.
|
|
||||||
- **Python installs** via `uv python install` are lost on container recreation unless you add the `devbox-uv` named volume.
|
|
||||||
- **Rust toolchains** via `rustup-init` are lost on container recreation unless you add the `devbox-rustup` and `devbox-cargo` named volumes.
|
|
||||||
- **AWS SSO tokens** persist across restarts when `~/.aws` is mounted (recommended for Bedrock users).
|
|
||||||
|
|
||||||
## Custom opencode Config
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -it --rm \
|
|
||||||
-v ~/.config/opencode:/home/developer/.config/opencode \
|
|
||||||
... \
|
|
||||||
joakimp/opencode-devbox:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
This persists all configuration changes across container restarts. 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.
|
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -it --rm \
|
|
||||||
-v ~/.config/nvim:/home/developer/.config/nvim:ro \
|
|
||||||
... \
|
|
||||||
joakimp/opencode-devbox:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|
||||||
To persist Python installs across container restarts, add a named volume:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -it --rm \
|
|
||||||
-v devbox-uv:/home/developer/.local/share/uv \
|
|
||||||
... \
|
|
||||||
joakimp/opencode-devbox:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -it --rm \
|
|
||||||
-v devbox-rustup:/home/developer/.rustup \
|
|
||||||
-v devbox-cargo:/home/developer/.cargo \
|
|
||||||
... \
|
|
||||||
joakimp/opencode-devbox:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
**Requirements:** Install the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension. For remote Docker hosts, also install [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh).
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
|
|
||||||
1. Start the container: `docker compose up -d`
|
|
||||||
2. In VS Code: `Ctrl+Shift+P` → "Dev Containers: Attach to Running Container" → select `opencode-devbox`
|
|
||||||
|
|
||||||
For remote Docker hosts (e.g. connecting to a server via SSH), first connect to the remote host with Remote-SSH, then attach to the container from there.
|
|
||||||
|
|
||||||
VS Code extensions installed inside the container persist as long as the container exists. For persistent extension storage across container recreations, add a named volume:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -it --rm \
|
|
||||||
-v devbox-vscode:/home/developer/.vscode-server \
|
|
||||||
... \
|
|
||||||
joakimp/opencode-devbox:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Using docker-compose
|
|
||||||
|
|
||||||
Create a directory with a `docker-compose.yml` and a `.env` file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir opencode-devbox && cd opencode-devbox
|
|
||||||
```
|
|
||||||
|
|
||||||
`.env` — your settings (never commit this):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENCODE_PROVIDER=amazon-bedrock
|
|
||||||
OPENCODE_MODEL=amazon-bedrock/eu.anthropic.claude-opus-4-6-v1
|
|
||||||
AWS_REGION=eu-west-1
|
|
||||||
AWS_PROFILE=your-profile-name
|
|
||||||
GIT_USER_NAME=Your Name
|
|
||||||
GIT_USER_EMAIL=you@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
`docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
devbox:
|
|
||||||
image: joakimp/opencode-devbox:latest
|
|
||||||
# For multi-agent orchestration, use the omos variant instead:
|
|
||||||
# image: joakimp/opencode-devbox:latest-omos
|
|
||||||
container_name: opencode-devbox
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- TERM=xterm-256color
|
|
||||||
volumes:
|
|
||||||
- ~/projects:/workspace
|
|
||||||
- ~/.ssh:/home/developer/.ssh:ro
|
|
||||||
- devbox-data:/home/developer/.local/share/opencode
|
|
||||||
- devbox-state:/home/developer/.local/state/opencode
|
|
||||||
- devbox-uv:/home/developer/.local/share/uv
|
|
||||||
# Optional: persist Rust toolchains and cargo data
|
|
||||||
# - devbox-rustup:/home/developer/.rustup
|
|
||||||
# - devbox-cargo:/home/developer/.cargo
|
|
||||||
# Optional: persist VS Code server and extensions
|
|
||||||
# - devbox-vscode:/home/developer/.vscode-server
|
|
||||||
# Mount AWS config for Bedrock SSO (required for amazon-bedrock provider)
|
|
||||||
# - ~/.aws:/home/developer/.aws
|
|
||||||
# Optional: mount opencode config directory (persists config changes across restarts)
|
|
||||||
# - ~/.config/opencode:/home/developer/.config/opencode
|
|
||||||
# Optional: mount opencode agent skills from host
|
|
||||||
# - ~/.agents/skills:/home/developer/.agents/skills:ro
|
|
||||||
# Optional: mount neovim config from host (plugins auto-install on first start)
|
|
||||||
# - ~/.config/nvim:/home/developer/.config/nvim:ro
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
devbox-data:
|
|
||||||
devbox-state:
|
|
||||||
devbox-uv:
|
|
||||||
# devbox-rustup:
|
|
||||||
# devbox-cargo:
|
|
||||||
# devbox-vscode:
|
|
||||||
```
|
|
||||||
|
|
||||||
Docker Compose loads `.env` automatically from the same directory. All variables from `.env` are passed to the container via `env_file`. Do **not** hardcode provider settings in the `environment:` section — use `.env` instead.
|
|
||||||
|
|
||||||
Then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start in background
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Open a shell (always use -u developer with exec)
|
|
||||||
docker compose exec -u developer devbox bash
|
|
||||||
|
|
||||||
# For Bedrock: authenticate, then start opencode
|
|
||||||
aws sso login --sso-session <your-session> --use-device-code
|
|
||||||
opencode
|
|
||||||
|
|
||||||
# Or run opencode directly (if no SSO needed)
|
|
||||||
docker compose exec -u developer devbox opencode
|
|
||||||
|
|
||||||
# One-shot mode (creates and removes container)
|
|
||||||
docker compose run --rm devbox # direct to opencode
|
|
||||||
docker compose run --rm devbox bash # interactive shell
|
|
||||||
```
|
|
||||||
|
|
||||||
## Shell defaults
|
|
||||||
|
|
||||||
The image ships baked `.bash_aliases` and `.inputrc` in `/etc/skel-devbox/`. On first container start the entrypoint copies them to `/home/developer/` **only if the target file does not already exist**, so your host bind-mounts or any in-container customization are preserved across upgrades.
|
|
||||||
|
|
||||||
- **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** via `$HISTFILE=~/.cache/bash/history`, backed by the `devbox-shell-history` named volume. Survives container recreate. 100 000 entries, time-stamped, dedup.
|
|
||||||
- **Case-insensitive tab completion** and coloured completion lists.
|
|
||||||
- **Aliases** — `ls`/`ll`/`la` → `eza`, `cat` → `bat`, `gs`/`gd`/`gl` for git, interactive `rm`/`mv`/`cp`.
|
|
||||||
- **Integrations** — `zoxide` (`z <fragment>`), `fzf` key bindings (`Ctrl-R`, `Ctrl-T`).
|
|
||||||
- **`[devbox]` prompt prefix** so you always know you're in the container.
|
|
||||||
|
|
||||||
To override with your host's own files, uncomment the matching bind-mount lines in `docker-compose.yml`. To restore the baked defaults any time: `cp /etc/skel-devbox/.bash_aliases ~/` (or delete the file and recreate the container).
|
|
||||||
|
|
||||||
## What's Included
|
|
||||||
|
|
||||||
### Base image (`latest`)
|
|
||||||
|
|
||||||
- **Debian trixie-slim** — glibc, full terminal/PTY support
|
|
||||||
- **opencode** — AI coding assistant
|
|
||||||
- **Node.js 22** — for npx-based MCP servers
|
|
||||||
- **AWS CLI v2** — SSO and Bedrock authentication
|
|
||||||
- **Dev tools** — git, git-lfs, git-crypt, age, ssh, ripgrep, fd, fzf, bat, eza, zoxide, uv, rustup, jq, make, gcc, g++, curl, wget, neovim 0.12, tmux, htop, tree, rsync
|
|
||||||
- **Non-root user** — runs as `developer` with UID auto-matched to workspace owner (sudo available)
|
|
||||||
|
|
||||||
### OMOS image (`latest-omos`)
|
|
||||||
|
|
||||||
Everything in the base image, plus:
|
|
||||||
|
|
||||||
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** — multi-agent orchestration plugin
|
|
||||||
- **Bun** — JavaScript runtime required by oh-my-opencode-slim
|
|
||||||
- **6 specialized agents** — Orchestrator, Explorer, Oracle, Librarian, Designer, Fixer
|
|
||||||
|
|
||||||
### Additional runtimes (build from source)
|
|
||||||
|
|
||||||
When [building from source](https://gitea.jordbo.se/joakimp/opencode-devbox), additional runtimes are available via build args:
|
|
||||||
|
|
||||||
- **Python 3** (`INSTALL_PYTHON=true`) — Python 3 + pip + venv
|
|
||||||
- **Go** (`INSTALL_GO=true`) — Go toolchain
|
|
||||||
|
|
||||||
## oh-my-opencode-slim (OMOS variant)
|
|
||||||
|
|
||||||
The `-omos` image variant includes [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim), which adds a multi-agent layer on top of opencode. An Orchestrator delegates tasks to specialized agents, each configurable with different models and providers.
|
|
||||||
|
|
||||||
### Quick start with OMOS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -it --rm \
|
|
||||||
-e OPENAI_API_KEY=your-key \
|
|
||||||
-e OPENCODE_PROVIDER=openai \
|
|
||||||
-e ENABLE_OMOS=true \
|
|
||||||
-v ~/projects:/workspace \
|
|
||||||
-v ~/.ssh:/home/developer/.ssh:ro \
|
|
||||||
joakimp/opencode-devbox:latest-omos
|
|
||||||
```
|
|
||||||
|
|
||||||
On first start, the entrypoint configures oh-my-opencode-slim automatically. The default preset uses OpenAI models.
|
|
||||||
|
|
||||||
### OMOS environment variables
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `ENABLE_OMOS` | `false` | Activate oh-my-opencode-slim on container start |
|
|
||||||
| `OMOS_TMUX` | `false` | Enable tmux pane integration (watch agents in split panes) |
|
|
||||||
| `OMOS_SKILLS` | `true` | Install recommended skills (simplify, agent-browser, cartography) |
|
|
||||||
| `OMOS_RESET` | `false` | Force regenerate config on next start (backs up existing config) |
|
|
||||||
|
|
||||||
### Custom OMOS 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.
|
|
||||||
|
|
||||||
## Multi-User Setup
|
|
||||||
|
|
||||||
This guide covers single-user setup. For running multiple opencode-devbox instances in parallel — whether each user has their own OS account or everyone shares one login — see the [Multi-user setup section](https://gitea.jordbo.se/joakimp/opencode-devbox#multi-user-setup) in the source repository. It covers volume isolation, the `docker-compose.shared.yml` layout, and the `SIGNUM` / `$USER` auto-detection mechanism.
|
|
||||||
|
|
||||||
## Source
|
|
||||||
|
|
||||||
Build from source or contribute: [opencode-devbox on Gitea](https://gitea.jordbo.se/joakimp/opencode-devbox)
|
|
||||||
|
|
||||||
See the [Changelog](https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/CHANGELOG.md) for a full release history.
|
|
||||||
|
|||||||
-231
@@ -1,231 +0,0 @@
|
|||||||
# opencode-devbox — portable AI dev environment
|
|
||||||
# Debian-based container with opencode and configurable dev tools
|
|
||||||
|
|
||||||
ARG DEBIAN_VERSION=trixie-slim
|
|
||||||
FROM debian:${DEBIAN_VERSION} AS base
|
|
||||||
|
|
||||||
ARG TARGETARCH
|
|
||||||
ARG OPENCODE_VERSION=1.14.25
|
|
||||||
|
|
||||||
LABEL maintainer="joakimp"
|
|
||||||
LABEL description="Portable opencode developer container"
|
|
||||||
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/opencode-devbox"
|
|
||||||
|
|
||||||
# Avoid interactive prompts during build
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
# ── Core system packages ─────────────────────────────────────────────
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
wget \
|
|
||||||
git \
|
|
||||||
openssh-client \
|
|
||||||
gnupg \
|
|
||||||
jq \
|
|
||||||
ripgrep \
|
|
||||||
fd-find \
|
|
||||||
tree \
|
|
||||||
less \
|
|
||||||
htop \
|
|
||||||
tmux \
|
|
||||||
make \
|
|
||||||
patch \
|
|
||||||
diffutils \
|
|
||||||
git-crypt \
|
|
||||||
age \
|
|
||||||
file \
|
|
||||||
sudo \
|
|
||||||
locales \
|
|
||||||
procps \
|
|
||||||
unzip \
|
|
||||||
gcc \
|
|
||||||
g++ \
|
|
||||||
rsync \
|
|
||||||
python3-pip \
|
|
||||||
python3-venv \
|
|
||||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
|
|
||||||
|
|
||||||
# gosu — privilege de-escalation (built with Go 1.24.6)
|
|
||||||
ARG GOSU_VERSION=1.19
|
|
||||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
|
||||||
curl -fsSL "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
|
|
||||||
chmod +x /usr/local/bin/gosu && \
|
|
||||||
gosu --version
|
|
||||||
|
|
||||||
# fzf — fuzzy finder (built with Go 1.23.12)
|
|
||||||
ARG FZF_VERSION=0.71.0
|
|
||||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
|
||||||
curl -fsSL "https://github.com/junegunn/fzf/releases/download/v${FZF_VERSION}/fzf-${FZF_VERSION}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
|
|
||||||
fzf --version
|
|
||||||
|
|
||||||
# git-lfs — Git Large File Storage (built with Go 1.25)
|
|
||||||
ARG GIT_LFS_VERSION=3.7.1
|
|
||||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
|
||||||
curl -fsSL "https://github.com/git-lfs/git-lfs/releases/download/v${GIT_LFS_VERSION}/git-lfs-linux-${ARCH}-v${GIT_LFS_VERSION}.tar.gz" | tar -xz -C /tmp && \
|
|
||||||
install /tmp/git-lfs-${GIT_LFS_VERSION}/git-lfs /usr/local/bin/git-lfs && \
|
|
||||||
rm -rf /tmp/git-lfs-${GIT_LFS_VERSION} && \
|
|
||||||
git lfs install --system && \
|
|
||||||
git-lfs --version
|
|
||||||
|
|
||||||
# neovim — modern text editor (pre-built release from GitHub)
|
|
||||||
ARG NVIM_VERSION=0.12.1
|
|
||||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
|
||||||
curl -fsSL "https://github.com/neovim/neovim/releases/download/v${NVIM_VERSION}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
|
|
||||||
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
|
|
||||||
nvim --version | head -1
|
|
||||||
|
|
||||||
# bat — syntax-highlighted cat replacement
|
|
||||||
ARG BAT_VERSION=0.26.1
|
|
||||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
||||||
curl -fsSL "https://github.com/sharkdp/bat/releases/download/v${BAT_VERSION}/bat-v${BAT_VERSION}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
|
||||||
install /tmp/bat-v${BAT_VERSION}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
|
|
||||||
rm -rf /tmp/bat-v${BAT_VERSION}-* && \
|
|
||||||
bat --version
|
|
||||||
|
|
||||||
# eza — modern ls replacement
|
|
||||||
ARG EZA_VERSION=0.23.4
|
|
||||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
||||||
curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
|
|
||||||
eza --version | head -1
|
|
||||||
|
|
||||||
# zoxide — smarter cd command
|
|
||||||
ARG ZOXIDE_VERSION=0.9.9
|
|
||||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
||||||
curl -fsSL "https://github.com/ajeetdsouza/zoxide/releases/download/v${ZOXIDE_VERSION}/zoxide-${ZOXIDE_VERSION}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
|
|
||||||
zoxide --version
|
|
||||||
|
|
||||||
# uv — fast Python package manager (replaces pip, venv, pyenv)
|
|
||||||
ARG UV_VERSION=0.11.7
|
|
||||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
||||||
curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
|
||||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
|
|
||||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
|
|
||||||
rm -rf /tmp/uv-* && \
|
|
||||||
uv --version
|
|
||||||
|
|
||||||
# rustup — Rust toolchain manager
|
|
||||||
# Installs the rustup-init binary only. Users bootstrap Rust with:
|
|
||||||
# rustup-init -y && source ~/.cargo/env
|
|
||||||
# Toolchains persist via devbox-rustup and devbox-cargo volumes.
|
|
||||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
||||||
curl -fsSL "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
|
|
||||||
|
|
||||||
# Set locale — generate common UTF-8 locales (override via LANG/LC_ALL env vars)
|
|
||||||
# To add more locales, run: sudo sed -i '/<locale>.UTF-8/s/^# //g' /etc/locale.gen && sudo locale-gen
|
|
||||||
RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
|
||||||
ENV LANG=en_US.UTF-8
|
|
||||||
ENV LANGUAGE=en_US:en
|
|
||||||
ENV LC_ALL=en_US.UTF-8
|
|
||||||
ENV EDITOR=nvim
|
|
||||||
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
|
|
||||||
|
|
||||||
# ── Node.js (required for opencode v1.x install + MCP servers) ──────
|
|
||||||
ARG NODE_VERSION=22
|
|
||||||
RUN curl -fsSL 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" ;; \
|
|
||||||
arm64) echo "aarch64" ;; \
|
|
||||||
*) echo "x86_64" ;; \
|
|
||||||
esac) && \
|
|
||||||
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
|
|
||||||
unzip -q /tmp/awscli.zip -d /tmp && \
|
|
||||||
/tmp/aws/install && \
|
|
||||||
rm -rf /tmp/aws /tmp/awscli.zip && \
|
|
||||||
aws --version
|
|
||||||
|
|
||||||
# ── Optional: Python ─────────────────────────────────────────────────
|
|
||||||
ARG INSTALL_PYTHON=false
|
|
||||||
RUN if [ "${INSTALL_PYTHON}" = "true" ]; then \
|
|
||||||
apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
python3 python3-pip python3-venv && \
|
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Optional: Go ─────────────────────────────────────────────────────
|
|
||||||
ARG INSTALL_GO=false
|
|
||||||
ARG GO_VERSION=1.26.2
|
|
||||||
RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
|
||||||
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
|
||||||
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.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 "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 && \
|
|
||||||
rm -rf /tmp/bun /tmp/bun.zip && \
|
|
||||||
bun --version && \
|
|
||||||
npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Non-root user ────────────────────────────────────────────────────
|
|
||||||
ARG USER_NAME=developer
|
|
||||||
ARG USER_UID=1000
|
|
||||||
ARG USER_GID=1000
|
|
||||||
|
|
||||||
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
|
|
||||||
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
|
|
||||||
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
|
|
||||||
|
|
||||||
# Create standard directories
|
|
||||||
RUN mkdir -p /workspace \
|
|
||||||
/home/${USER_NAME}/.config/opencode/skills \
|
|
||||||
/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}
|
|
||||||
|
|
||||||
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
|
||||||
# Shipped under /etc/skel-devbox/ rather than copied directly to the
|
|
||||||
# user's home. The entrypoint copies them to /home/developer/ only if
|
|
||||||
# the target file does not already exist, so host bind-mounts and
|
|
||||||
# previously-customized files are never overwritten. Users can restore
|
|
||||||
# the baked defaults anytime via:
|
|
||||||
# cp /etc/skel-devbox/.bash_aliases ~/.bash_aliases
|
|
||||||
# History itself persists via the devbox-shell-history named volume
|
|
||||||
# mounted at ~/.cache/bash (HISTFILE points there).
|
|
||||||
RUN mkdir -p /etc/skel-devbox
|
|
||||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
|
||||||
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
|
||||||
|
|
||||||
# ── Entrypoint ────────────────────────────────────────────────────────
|
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
|
||||||
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh
|
|
||||||
|
|
||||||
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
|
||||||
WORKDIR /workspace
|
|
||||||
|
|
||||||
ENTRYPOINT ["entrypoint.sh"]
|
|
||||||
CMD ["opencode"]
|
|
||||||
+337
@@ -0,0 +1,337 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
LABEL maintainer="joakimp"
|
||||||
|
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
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# ── Core system packages ─────────────────────────────────────────────
|
||||||
|
# apt-get upgrade picks up any security/CVE fixes published between
|
||||||
|
# debian:trixie-slim base-image rebuilds. Paired with the index update
|
||||||
|
# and the install in the same layer so we don't bloat image history.
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get upgrade -y --no-install-recommends && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
git \
|
||||||
|
openssh-client \
|
||||||
|
gnupg \
|
||||||
|
jq \
|
||||||
|
ripgrep \
|
||||||
|
fd-find \
|
||||||
|
tree \
|
||||||
|
less \
|
||||||
|
htop \
|
||||||
|
tmux \
|
||||||
|
make \
|
||||||
|
patch \
|
||||||
|
diffutils \
|
||||||
|
git-crypt \
|
||||||
|
age \
|
||||||
|
file \
|
||||||
|
sudo \
|
||||||
|
locales \
|
||||||
|
procps \
|
||||||
|
unzip \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
rsync \
|
||||||
|
python3-pip \
|
||||||
|
python3-venv \
|
||||||
|
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
|
||||||
|
#
|
||||||
|
# Version policy for the binaries below:
|
||||||
|
# • Default is `latest` — resolved at build time by following the
|
||||||
|
# /releases/latest redirect on GitHub and reading the tag from the
|
||||||
|
# Location header. This means every 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.
|
||||||
|
# • Resolved versions are printed during build and re-checked by the
|
||||||
|
# smoke test (scripts/smoke-test.sh), so drift is visible in CI logs.
|
||||||
|
|
||||||
|
# gosu — privilege de-escalation
|
||||||
|
ARG GOSU_VERSION=latest
|
||||||
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||||
|
V="${GOSU_VERSION}" && \
|
||||||
|
if [ "$V" = "latest" ]; then \
|
||||||
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||||
|
fi && \
|
||||||
|
V="${V#v}" && \
|
||||||
|
[ -n "$V" ] && \
|
||||||
|
echo "Installing gosu ${V}" && \
|
||||||
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
|
||||||
|
chmod +x /usr/local/bin/gosu && \
|
||||||
|
gosu --version
|
||||||
|
|
||||||
|
# fzf — fuzzy finder
|
||||||
|
ARG FZF_VERSION=latest
|
||||||
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||||
|
V="${FZF_VERSION}" && \
|
||||||
|
if [ "$V" = "latest" ]; then \
|
||||||
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||||
|
fi && \
|
||||||
|
V="${V#v}" && \
|
||||||
|
[ -n "$V" ] && \
|
||||||
|
echo "Installing fzf ${V}" && \
|
||||||
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
|
||||||
|
fzf --version
|
||||||
|
|
||||||
|
# git-lfs — Git Large File Storage
|
||||||
|
ARG GIT_LFS_VERSION=latest
|
||||||
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||||
|
V="${GIT_LFS_VERSION}" && \
|
||||||
|
if [ "$V" = "latest" ]; then \
|
||||||
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||||
|
fi && \
|
||||||
|
V="${V#v}" && \
|
||||||
|
[ -n "$V" ] && \
|
||||||
|
echo "Installing git-lfs ${V}" && \
|
||||||
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \
|
||||||
|
install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \
|
||||||
|
rm -rf /tmp/git-lfs-${V} && \
|
||||||
|
git lfs install --system && \
|
||||||
|
git-lfs --version
|
||||||
|
|
||||||
|
# neovim — modern text editor
|
||||||
|
ARG NVIM_VERSION=latest
|
||||||
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||||
|
V="${NVIM_VERSION}" && \
|
||||||
|
if [ "$V" = "latest" ]; then \
|
||||||
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||||
|
fi && \
|
||||||
|
V="${V#v}" && \
|
||||||
|
[ -n "$V" ] && \
|
||||||
|
echo "Installing neovim ${V}" && \
|
||||||
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
|
||||||
|
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
|
||||||
|
nvim --version | head -1
|
||||||
|
|
||||||
|
# bat — syntax-highlighted cat replacement
|
||||||
|
ARG BAT_VERSION=latest
|
||||||
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||||
|
V="${BAT_VERSION}" && \
|
||||||
|
if [ "$V" = "latest" ]; then \
|
||||||
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||||
|
fi && \
|
||||||
|
V="${V#v}" && \
|
||||||
|
[ -n "$V" ] && \
|
||||||
|
echo "Installing bat ${V}" && \
|
||||||
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||||
|
install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
|
||||||
|
rm -rf /tmp/bat-v${V}-* && \
|
||||||
|
bat --version
|
||||||
|
|
||||||
|
# eza — modern ls replacement
|
||||||
|
ARG EZA_VERSION=latest
|
||||||
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||||
|
V="${EZA_VERSION}" && \
|
||||||
|
if [ "$V" = "latest" ]; then \
|
||||||
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||||
|
fi && \
|
||||||
|
V="${V#v}" && \
|
||||||
|
[ -n "$V" ] && \
|
||||||
|
echo "Installing eza ${V}" && \
|
||||||
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
|
||||||
|
eza --version | head -1
|
||||||
|
|
||||||
|
# zoxide — smarter cd command
|
||||||
|
ARG ZOXIDE_VERSION=latest
|
||||||
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||||
|
V="${ZOXIDE_VERSION}" && \
|
||||||
|
if [ "$V" = "latest" ]; then \
|
||||||
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||||
|
fi && \
|
||||||
|
V="${V#v}" && \
|
||||||
|
[ -n "$V" ] && \
|
||||||
|
echo "Installing zoxide ${V}" && \
|
||||||
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
|
||||||
|
zoxide --version
|
||||||
|
|
||||||
|
# uv — fast Python package manager (replaces pip, venv, pyenv)
|
||||||
|
# Note: uv releases don't prefix tags with "v" (e.g. tag is "0.11.8").
|
||||||
|
ARG UV_VERSION=latest
|
||||||
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||||
|
V="${UV_VERSION}" && \
|
||||||
|
if [ "$V" = "latest" ]; then \
|
||||||
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||||
|
fi && \
|
||||||
|
V="${V#v}" && \
|
||||||
|
[ -n "$V" ] && \
|
||||||
|
echo "Installing uv ${V}" && \
|
||||||
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||||
|
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
|
||||||
|
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
|
||||||
|
rm -rf /tmp/uv-* && \
|
||||||
|
uv --version
|
||||||
|
|
||||||
|
# ── MemPalace — local-first AI memory system ─────────────────────────
|
||||||
|
# Provides semantic search over conversation history via 29 MCP tools.
|
||||||
|
# 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
|
||||||
|
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||||
|
mkdir -p /opt/uv-tools && \
|
||||||
|
uv tool install --no-cache mempalace && \
|
||||||
|
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
|
||||||
|
ARG INSTALL_MEMPALACE_TOOLKIT=true
|
||||||
|
ARG MEMPALACE_TOOLKIT_REF=main
|
||||||
|
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
|
||||||
|
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \
|
||||||
|
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \
|
||||||
|
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
|
||||||
|
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
|
||||||
|
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
|
||||||
|
mempalace-session --help >/dev/null && \
|
||||||
|
mempalace-docs --help >/dev/null && \
|
||||||
|
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# rustup — Rust toolchain manager (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
|
||||||
|
ARG GITEA_MCP_VERSION=latest
|
||||||
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||||
|
V="${GITEA_MCP_VERSION}" && \
|
||||||
|
if [ "$V" = "latest" ]; then \
|
||||||
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||||
|
fi && \
|
||||||
|
V="${V#v}" && \
|
||||||
|
[ -n "$V" ] && \
|
||||||
|
echo "Installing gitea-mcp ${V}" && \
|
||||||
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/download/v${V}/gitea-mcp_Linux_${ARCH}.tar.gz" \
|
||||||
|
| tar -xz -C /usr/local/bin/ gitea-mcp && \
|
||||||
|
chmod +x /usr/local/bin/gitea-mcp && \
|
||||||
|
gitea-mcp --version
|
||||||
|
|
||||||
|
# Set locale — generate common UTF-8 locales (override via LANG/LC_ALL env vars)
|
||||||
|
RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||||
|
ENV LANG=en_US.UTF-8
|
||||||
|
ENV LANGUAGE=en_US:en
|
||||||
|
ENV LC_ALL=en_US.UTF-8
|
||||||
|
ENV EDITOR=nvim
|
||||||
|
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
|
# ── Node.js (required for opencode/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/*
|
||||||
|
|
||||||
|
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
|
||||||
|
RUN ARCH=$(case "${TARGETARCH}" in \
|
||||||
|
amd64) echo "x86_64" ;; \
|
||||||
|
arm64) echo "aarch64" ;; \
|
||||||
|
*) echo "x86_64" ;; \
|
||||||
|
esac) && \
|
||||||
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
|
||||||
|
unzip -q /tmp/awscli.zip -d /tmp && \
|
||||||
|
/tmp/aws/install && \
|
||||||
|
rm -rf /tmp/aws /tmp/awscli.zip && \
|
||||||
|
aws --version
|
||||||
|
|
||||||
|
# ── Non-root user ────────────────────────────────────────────────────
|
||||||
|
ARG USER_NAME=developer
|
||||||
|
ARG USER_UID=1000
|
||||||
|
ARG USER_GID=1000
|
||||||
|
|
||||||
|
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
|
||||||
|
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
|
||||||
|
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
|
||||||
|
|
||||||
|
# Create standard directories
|
||||||
|
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) ─────────────────
|
||||||
|
RUN mkdir -p /etc/skel-devbox
|
||||||
|
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||||
|
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||||
|
|
||||||
|
# ── Entrypoint ────────────────────────────────────────────────────────
|
||||||
|
COPY rootfs/usr/local/lib/opencode-devbox/ /usr/local/lib/opencode-devbox/
|
||||||
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
||||||
|
/usr/local/lib/opencode-devbox/*.py
|
||||||
|
|
||||||
|
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
ENTRYPOINT ["entrypoint.sh"]
|
||||||
|
CMD ["bash", "-l"]
|
||||||
@@ -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.
|
||||||
@@ -8,8 +8,28 @@ The official `ghcr.io/anomalyco/opencode` image (now archived) was Alpine-based
|
|||||||
|
|
||||||
## Quick Start
|
## 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
|
```bash
|
||||||
# Clone
|
|
||||||
git clone ssh://gitea.jordbo.se:2222/joakimp/opencode-devbox.git
|
git clone ssh://gitea.jordbo.se:2222/joakimp/opencode-devbox.git
|
||||||
cd opencode-devbox
|
cd opencode-devbox
|
||||||
|
|
||||||
@@ -17,7 +37,7 @@ cd opencode-devbox
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your provider, API key, workspace path, git config
|
# 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
|
brew install gitleaks # macOS / Linuxbrew
|
||||||
./setup-hooks.sh
|
./setup-hooks.sh
|
||||||
|
|
||||||
@@ -49,9 +69,6 @@ Bind-mounted directories must exist on the host before starting the container. D
|
|||||||
```bash
|
```bash
|
||||||
# Required: workspace for your projects
|
# Required: workspace for your projects
|
||||||
mkdir -p ~/projects
|
mkdir -p ~/projects
|
||||||
|
|
||||||
# If mounting opencode config (recommended for persistent settings)
|
|
||||||
mkdir -p ~/.config/opencode
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Connecting to the container
|
### 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_TMUX` | Enable tmux pane integration for OMOS | `false` |
|
||||||
| `OMOS_SKILLS` | Install OMOS recommended skills on first run | `true` |
|
| `OMOS_SKILLS` | Install OMOS recommended skills on first run | `true` |
|
||||||
| `OMOS_RESET` | Force regenerate OMOS config on next start | `false` |
|
| `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
|
### Custom opencode config
|
||||||
|
|
||||||
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:
|
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
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
- ~/.config/opencode:/home/developer/.config/opencode
|
- ~/.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.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.
|
||||||
|
|
||||||
> **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.
|
|
||||||
|
|
||||||
### Custom skills
|
### 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
|
1. `SKILLSET_CONTAINER_PATH` env var (explicit path to skillset repo inside container)
|
||||||
volumes:
|
2. `~/skillset` mount (if present)
|
||||||
- ~/.agents/skills:/home/developer/.agents/skills:ro
|
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
|
### 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/docker-compose.shared.yml docker-compose.yml
|
||||||
cp /path/to/opencode-devbox/.env.shared.example .env
|
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
|
# Edit .env — set SIGNUM only if you're in shared-account mode
|
||||||
vim .env
|
vim .env
|
||||||
|
|
||||||
@@ -308,7 +328,7 @@ docker compose exec -u developer devbox opencode
|
|||||||
Each user's container, config, and named volumes are fully isolated:
|
Each user's container, config, and named volumes are fully isolated:
|
||||||
- Container name: `devbox-<signum>` (or `devbox-$USER` in own-account mode)
|
- 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
|
- 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.
|
See `docker-compose.shared.yml` and `.env.shared.example` for the full configuration.
|
||||||
|
|
||||||
@@ -327,19 +347,29 @@ docker compose run --rm --build devbox
|
|||||||
|
|
||||||
### Build Args
|
### Build Args
|
||||||
|
|
||||||
Enable optional language runtimes or pin a specific opencode version:
|
Enable optional language runtimes, pin a specific opencode version, or lock any of the tooling components:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose build --build-arg INSTALL_PYTHON=true --build-arg INSTALL_GO=true
|
docker compose build --build-arg INSTALL_GO=true
|
||||||
docker compose build --build-arg OPENCODE_VERSION=1.5.0
|
docker compose build --build-arg OPENCODE_VERSION=1.5.0
|
||||||
|
docker compose build --build-arg NVIM_VERSION=0.12.1 # pin to a specific version
|
||||||
```
|
```
|
||||||
|
|
||||||
| Arg | Default | Description |
|
| Arg | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `INSTALL_PYTHON` | `false` | Python 3 + pip + venv |
|
| `INSTALL_GO` | `false` | Go toolchain (resolves latest stable from go.dev when `GO_VERSION=latest`) |
|
||||||
| `INSTALL_GO` | `false` | Go toolchain |
|
| `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_OMOS` | `false` | [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration (installs Bun and plugin) |
|
||||||
| `OMOS_VERSION` | `latest` | Pin a specific oh-my-opencode-slim version |
|
| `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. |
|
||||||
|
|
||||||
|
> **Reproducibility note:** With `latest` defaults, two builds of the same `v{opencode}` tag may embed different tool versions if upstream releases have happened in between. This is intentional — it means every rebuild picks up upstream CVE fixes automatically. If you need a bit-for-bit reproducible build, pass explicit `*_VERSION` args. The CI smoke test logs the resolved versions for every release build.
|
||||||
|
|
||||||
## oh-my-opencode-slim (Multi-Agent Orchestration)
|
## oh-my-opencode-slim (Multi-Agent Orchestration)
|
||||||
|
|
||||||
@@ -396,6 +426,79 @@ ping all agents
|
|||||||
|
|
||||||
All six agents should respond if your provider authentication is working.
|
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
|
## AWS Bedrock Authentication
|
||||||
|
|
||||||
When using AWS Bedrock as your LLM provider, you need:
|
When using AWS Bedrock as your LLM provider, you need:
|
||||||
@@ -444,6 +547,134 @@ The `--use-device-code` flag outputs a URL and short code instead of trying to o
|
|||||||
|
|
||||||
SSO sessions typically last 8–12 hours before requiring re-authentication. Since `~/.aws` is mounted from the host, tokens persist across container restarts.
|
SSO sessions typically last 8–12 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.jsonc` (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.jsonc`:
|
||||||
|
```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.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
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.
|
||||||
@@ -554,9 +785,9 @@ Container (Debian trixie)
|
|||||||
| `/home/developer/.rustup` | Named volume `devbox-rustup` (if configured) | ✅ Yes | Rust toolchains |
|
| `/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/.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/.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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- TERM=xterm-256color
|
- TERM=xterm-256color
|
||||||
|
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
|
||||||
|
- GITEA_HOST=${GITEA_HOST:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Host workspace — user's project directory
|
# Host workspace — user's project directory
|
||||||
- ${WORKSPACE_PATH:-~/src}:/workspace
|
- ${WORKSPACE_PATH:-~/src}:/workspace
|
||||||
@@ -43,8 +45,18 @@ services:
|
|||||||
# SSH keys — user-specific if available, else shared
|
# SSH keys — user-specific if available, else shared
|
||||||
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
|
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
|
||||||
|
|
||||||
# Opencode config — per-user (persists settings across restarts)
|
# Optional: mount skillset repo for automatic skill/instruction deployment.
|
||||||
- ${HOME}/${SIGNUM}/.config/opencode:/home/developer/.config/opencode
|
# 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)
|
# Persist opencode data (auth, memory, session history)
|
||||||
- devbox-data:/home/developer/.local/share/opencode
|
- devbox-data:/home/developer/.local/share/opencode
|
||||||
@@ -61,12 +73,21 @@ services:
|
|||||||
# Persist uv data (Python installs)
|
# Persist uv data (Python installs)
|
||||||
- devbox-uv:/home/developer/.local/share/uv
|
- devbox-uv:/home/developer/.local/share/uv
|
||||||
|
|
||||||
|
# Optional: persist MemPalace data (conversation memory, knowledge graph)
|
||||||
|
# - devbox-palace:/home/developer/.mempalace
|
||||||
|
|
||||||
|
# Optional: persist ChromaDB embedding model cache (~79 MB)
|
||||||
|
# - devbox-chroma-cache:/home/developer/.cache/chroma
|
||||||
|
|
||||||
# Optional: AWS credentials (per-user if available)
|
# Optional: AWS credentials (per-user if available)
|
||||||
# - ${HOME}/${SIGNUM}/.aws:/home/developer/.aws
|
# - ${HOME}/${SIGNUM}/.aws:/home/developer/.aws
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
devbox-opencode-config:
|
||||||
devbox-data:
|
devbox-data:
|
||||||
devbox-shell-history:
|
devbox-shell-history:
|
||||||
devbox-zoxide:
|
devbox-zoxide:
|
||||||
devbox-nvim-data:
|
devbox-nvim-data:
|
||||||
devbox-uv:
|
devbox-uv:
|
||||||
|
# devbox-palace:
|
||||||
|
# devbox-chroma-cache:
|
||||||
|
|||||||
+40
-8
@@ -23,9 +23,11 @@ services:
|
|||||||
# build:
|
# build:
|
||||||
# context: .
|
# context: .
|
||||||
# args:
|
# args:
|
||||||
# INSTALL_PYTHON: "false"
|
|
||||||
# INSTALL_GO: "false"
|
# INSTALL_GO: "false"
|
||||||
# INSTALL_OMOS: "false"
|
# INSTALL_OMOS: "false"
|
||||||
|
# INSTALL_PI: "false"
|
||||||
|
# # PI_VERSION: "latest"
|
||||||
|
# # INSTALL_OPENCODE: "true"
|
||||||
container_name: opencode-devbox
|
container_name: opencode-devbox
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
@@ -33,6 +35,9 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- TERM=xterm-256color
|
- TERM=xterm-256color
|
||||||
|
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
|
||||||
|
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
|
||||||
|
- GITEA_HOST=${GITEA_HOST:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Host workspace — mount your project here
|
# Host workspace — mount your project here
|
||||||
- ${WORKSPACE_PATH:-.}:/workspace
|
- ${WORKSPACE_PATH:-.}:/workspace
|
||||||
@@ -40,13 +45,26 @@ services:
|
|||||||
# SSH keys (read-only) — for git push/pull
|
# SSH keys (read-only) — for git push/pull
|
||||||
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
|
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
|
||||||
|
|
||||||
# Optional: mount opencode config directory (persists config changes across restarts)
|
# Optional: mount skillset repo for automatic skill/instruction deployment.
|
||||||
# Includes opencode.json, oh-my-opencode-slim.json, skills, etc.
|
# The entrypoint runs deploy-skills.sh --bootstrap on start, creating
|
||||||
# When mounted, OPENCODE_PROVIDER auto-config is skipped if opencode.json exists.
|
# relative symlinks that resolve inside the container regardless of
|
||||||
# - ~/.config/opencode:/home/developer/.config/opencode
|
# where the repo lives on the host. Set SKILLSET_PATH in .env.
|
||||||
|
# - ${SKILLSET_PATH}:/home/developer/skillset
|
||||||
|
|
||||||
# Optional: mount opencode agent skills from host
|
# Persist opencode config (opencode.jsonc, oh-my-opencode-slim.json,
|
||||||
# - ~/.agents/skills:/home/developer/.agents/skills:ro
|
# 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)
|
# Optional: mount neovim config from host (plugins auto-install on first start)
|
||||||
# - ~/.config/nvim:/home/developer/.config/nvim:ro
|
# - ~/.config/nvim:/home/developer/.config/nvim:ro
|
||||||
@@ -75,7 +93,7 @@ services:
|
|||||||
# in the container, mount the parent directory instead — see the
|
# in the container, mount the parent directory instead — see the
|
||||||
# "Shell defaults" section in README.md.
|
# "Shell defaults" section in README.md.
|
||||||
# - ~/.bash_aliases:/home/developer/.bash_aliases:ro
|
# - ~/.bash_aliases:/home/developer/.bash_aliases:ro
|
||||||
# - ~/.inputrc:/home/developer/.inputrc:ro
|
# - ~/.inputrc:/home/developer/.inputrc:ro
|
||||||
|
|
||||||
# Optional: persist uv data (Python installs, tool installs)
|
# Optional: persist uv data (Python installs, tool installs)
|
||||||
# Without this, 'uv python install' must be re-run after container removal.
|
# Without this, 'uv python install' must be re-run after container removal.
|
||||||
@@ -92,16 +110,30 @@ services:
|
|||||||
# Persist neovim plugin/Mason data (avoids re-downloading on every recreate)
|
# Persist neovim plugin/Mason data (avoids re-downloading on every recreate)
|
||||||
- devbox-nvim-data:/home/developer/.local/share/nvim
|
- devbox-nvim-data:/home/developer/.local/share/nvim
|
||||||
|
|
||||||
|
# Optional: persist MemPalace data (conversation memory, knowledge graph,
|
||||||
|
# embeddings). Without this, palace data is lost on container recreation.
|
||||||
|
# - devbox-palace:/home/developer/.mempalace
|
||||||
|
|
||||||
|
# Optional: persist ChromaDB embedding model cache (~79 MB, downloaded on
|
||||||
|
# first mempalace search). Without this, the model re-downloads on every
|
||||||
|
# container recreation. Separate from palace data — model cache is
|
||||||
|
# disposable, palace data is precious.
|
||||||
|
# - devbox-chroma-cache:/home/developer/.cache/chroma
|
||||||
|
|
||||||
# Optional: AWS credentials/SSO config (not read-only — SSO writes token cache)
|
# Optional: AWS credentials/SSO config (not read-only — SSO writes token cache)
|
||||||
# - ~/.aws:/home/developer/.aws
|
# - ~/.aws:/home/developer/.aws
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
devbox-opencode-config:
|
||||||
|
devbox-pi-config:
|
||||||
devbox-data:
|
devbox-data:
|
||||||
devbox-state:
|
devbox-state:
|
||||||
devbox-shell-history:
|
devbox-shell-history:
|
||||||
devbox-zoxide:
|
devbox-zoxide:
|
||||||
devbox-nvim-data:
|
devbox-nvim-data:
|
||||||
devbox-uv:
|
devbox-uv:
|
||||||
|
# devbox-palace:
|
||||||
|
# devbox-chroma-cache:
|
||||||
# devbox-rustup:
|
# devbox-rustup:
|
||||||
# devbox-cargo:
|
# devbox-cargo:
|
||||||
# devbox-vscode:
|
# devbox-vscode:
|
||||||
|
|||||||
+87
-58
@@ -15,6 +15,25 @@ if [ -d "$SKEL_DIR" ]; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── MemPalace: initialize palace for the workspace if mempalace is installed
|
||||||
|
# Creates the palace directory structure on first run. Idempotent — skips
|
||||||
|
# if palace already exists, so upgrades from older versions preserve
|
||||||
|
# existing data. `--yes` auto-accepts detected entities so the init is
|
||||||
|
# non-interactive — the container entrypoint has no usable stdin for
|
||||||
|
# prompts anyway.
|
||||||
|
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)..."
|
||||||
|
# </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
|
||||||
|
|
||||||
# ── Git config defaults ──────────────────────────────────────────────
|
# ── Git config defaults ──────────────────────────────────────────────
|
||||||
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
||||||
git config --global user.name "$GIT_USER_NAME"
|
git config --global user.name "$GIT_USER_NAME"
|
||||||
@@ -24,72 +43,82 @@ if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null;
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Generate opencode config from env vars if no config mounted ──────
|
# ── Generate opencode config from env vars if no config mounted ──────
|
||||||
CONFIG_DIR="$HOME/.config/opencode"
|
# Delegated to a standalone Python script for clarity and testability.
|
||||||
CONFIG_FILE="$CONFIG_DIR/opencode.json"
|
# The script is idempotent: it never overwrites an existing opencode.json
|
||||||
|
# (bind-mounted from host, persisted in named volume, or previously
|
||||||
|
# generated) and no-ops if OPENCODE_PROVIDER is unset.
|
||||||
|
python3 /usr/local/lib/opencode-devbox/generate-config.py
|
||||||
|
|
||||||
if [ ! -f "$CONFIG_FILE" ] && [ -n "${OPENCODE_PROVIDER:-}" ]; then
|
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
||||||
echo "Generating opencode config for provider: $OPENCODE_PROVIDER"
|
# Runs only when pi was baked into the image (INSTALL_PI=true at build).
|
||||||
mkdir -p "$CONFIG_DIR"
|
# 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
|
||||||
|
|
||||||
case "$OPENCODE_PROVIDER" in
|
if [ -d /opt/pi-extensions ]; then
|
||||||
anthropic)
|
(cd /opt/pi-extensions && ./install.sh --yes) || \
|
||||||
cat > "$CONFIG_FILE" <<EOF
|
echo "WARN: pi-extensions install.sh failed (continuing)"
|
||||||
{
|
fi
|
||||||
"\$schema": "https://opencode.ai/config.json",
|
|
||||||
"model": "${OPENCODE_MODEL:-anthropic/claude-sonnet-4-6}",
|
# Bootstrap settings.json from template if absent (pi rewrites this
|
||||||
"share": "disabled",
|
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
||||||
"autoupdate": false
|
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
|
||||||
}
|
[ -f /opt/pi-toolkit/settings.example.json ]; then
|
||||||
EOF
|
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
|
||||||
;;
|
fi
|
||||||
openai)
|
|
||||||
cat > "$CONFIG_FILE" <<EOF
|
# pi↔mempalace MCP bridge — single extension symlink.
|
||||||
{
|
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
|
||||||
"\$schema": "https://opencode.ai/config.json",
|
command -v mempalace &>/dev/null && \
|
||||||
"model": "${OPENCODE_MODEL:-openai/gpt-5.4}",
|
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||||
"share": "disabled",
|
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
||||||
"autoupdate": false
|
"$HOME/.pi/agent/extensions/mempalace.ts"
|
||||||
}
|
fi
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
amazon-bedrock)
|
|
||||||
cat > "$CONFIG_FILE" <<EOF
|
|
||||||
{
|
|
||||||
"\$schema": "https://opencode.ai/config.json",
|
|
||||||
"model": "${OPENCODE_MODEL:-amazon-bedrock/global.anthropic.claude-sonnet-4-5-20250929-v1:0}",
|
|
||||||
"share": "disabled",
|
|
||||||
"autoupdate": false,
|
|
||||||
"provider": {
|
|
||||||
"amazon-bedrock": {
|
|
||||||
"options": {
|
|
||||||
"region": "${AWS_REGION:-us-east-1}",
|
|
||||||
"profile": "${AWS_PROFILE:-default}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
cat > "$CONFIG_FILE" <<EOF
|
|
||||||
{
|
|
||||||
"\$schema": "https://opencode.ai/config.json",
|
|
||||||
"model": "${OPENCODE_MODEL:-anthropic/claude-sonnet-4-6}",
|
|
||||||
"share": "disabled",
|
|
||||||
"autoupdate": false
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
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"
|
||||||
|
|
||||||
# ── oh-my-opencode-slim setup (multi-agent orchestration) ────────────
|
# ── oh-my-opencode-slim setup (multi-agent orchestration) ────────────
|
||||||
# Activated by ENABLE_OMOS=true. Requires the image to be built with
|
# Activated by ENABLE_OMOS=true. Requires the image to be built with
|
||||||
# INSTALL_OMOS=true (which installs bun + the oh-my-opencode-slim package).
|
# INSTALL_OMOS=true (which installs bun + the oh-my-opencode-slim package).
|
||||||
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
|
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
|
||||||
|
|
||||||
if [ "${ENABLE_OMOS:-false}" = "true" ]; then
|
if [ "${ENABLE_OMOS:-false}" = "true" ]; then
|
||||||
if ! command -v bunx &>/dev/null; then
|
if ! command -v bun &>/dev/null; then
|
||||||
echo "WARNING: ENABLE_OMOS=true but bun is not installed."
|
echo "WARNING: ENABLE_OMOS=true but bun is not installed."
|
||||||
echo "Rebuild with: docker compose build --build-arg INSTALL_OMOS=true"
|
echo "Rebuild with: docker compose build --build-arg INSTALL_OMOS=true"
|
||||||
elif [ ! -f "$OMOS_CONFIG" ]; then
|
elif [ ! -f "$OMOS_CONFIG" ]; then
|
||||||
@@ -106,7 +135,7 @@ if [ "${ENABLE_OMOS:-false}" = "true" ]; then
|
|||||||
OMOS_SKILLS_FLAG="no"
|
OMOS_SKILLS_FLAG="no"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
bunx oh-my-opencode-slim@latest install \
|
bun x oh-my-opencode-slim@latest install \
|
||||||
--no-tui \
|
--no-tui \
|
||||||
--tmux="${OMOS_TMUX_FLAG}" \
|
--tmux="${OMOS_TMUX_FLAG}" \
|
||||||
--skills="${OMOS_SKILLS_FLAG}"
|
--skills="${OMOS_SKILLS_FLAG}"
|
||||||
@@ -123,7 +152,7 @@ if [ "${ENABLE_OMOS:-false}" = "true" ]; then
|
|||||||
OMOS_SKILLS_FLAG="yes"
|
OMOS_SKILLS_FLAG="yes"
|
||||||
[ "${OMOS_SKILLS:-true}" = "false" ] && OMOS_SKILLS_FLAG="no"
|
[ "${OMOS_SKILLS:-true}" = "false" ] && OMOS_SKILLS_FLAG="no"
|
||||||
|
|
||||||
bunx oh-my-opencode-slim@latest install \
|
bun x oh-my-opencode-slim@latest install \
|
||||||
--no-tui \
|
--no-tui \
|
||||||
--tmux="${OMOS_TMUX_FLAG}" \
|
--tmux="${OMOS_TMUX_FLAG}" \
|
||||||
--skills="${OMOS_SKILLS_FLAG}" \
|
--skills="${OMOS_SKILLS_FLAG}" \
|
||||||
|
|||||||
+27
-1
@@ -79,16 +79,42 @@ for dir in \
|
|||||||
/home/"$USER_NAME"/.local/share/uv \
|
/home/"$USER_NAME"/.local/share/uv \
|
||||||
/home/"$USER_NAME"/.local/share/zoxide \
|
/home/"$USER_NAME"/.local/share/zoxide \
|
||||||
/home/"$USER_NAME"/.local/share/nvim \
|
/home/"$USER_NAME"/.local/share/nvim \
|
||||||
|
/home/"$USER_NAME"/.mempalace \
|
||||||
/home/"$USER_NAME"/.cache/bash \
|
/home/"$USER_NAME"/.cache/bash \
|
||||||
|
/home/"$USER_NAME"/.cache/chroma \
|
||||||
/home/"$USER_NAME"/.rustup \
|
/home/"$USER_NAME"/.rustup \
|
||||||
/home/"$USER_NAME"/.cargo \
|
/home/"$USER_NAME"/.cargo \
|
||||||
/home/"$USER_NAME"/.vscode-server \
|
/home/"$USER_NAME"/.vscode-server \
|
||||||
/home/"$USER_NAME"/.config/opencode \
|
/home/"$USER_NAME"/.config/opencode \
|
||||||
/home/"$USER_NAME"/.config/nvim \
|
/home/"$USER_NAME"/.config/nvim \
|
||||||
|
/home/"$USER_NAME"/.pi \
|
||||||
/home/"$USER_NAME"/.agents/skills; do
|
/home/"$USER_NAME"/.agents/skills; do
|
||||||
if [ -d "$dir" ] && [ "$(stat -c '%u' "$dir" 2>/dev/null)" != "$FINAL_UID" ]; then
|
[ -d "$dir" ] || continue
|
||||||
|
|
||||||
|
# Sentinel-file fast path: on volumes with thousands of files (nvim
|
||||||
|
# plugins, palace data) the recursive chown used to cost multiple
|
||||||
|
# seconds on every container start even when ownership was already
|
||||||
|
# correct. Now we write a sentinel after a successful chown and skip
|
||||||
|
# the walk when the sentinel matches the target UID:GID.
|
||||||
|
#
|
||||||
|
# If USER_UID changes between runs (user switches hosts, different
|
||||||
|
# workspace owner), the sentinel won't match and the full chown runs.
|
||||||
|
sentinel="$dir/.devbox-owner"
|
||||||
|
expected="$FINAL_UID:$FINAL_GID"
|
||||||
|
if [ -f "$sentinel" ] && [ "$(cat "$sentinel" 2>/dev/null)" = "$expected" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Recursive chown needed. Only do it when the top-level differs too
|
||||||
|
# (covers the common case of fresh root-owned named volumes).
|
||||||
|
if [ "$(stat -c '%u' "$dir" 2>/dev/null)" != "$FINAL_UID" ]; then
|
||||||
chown -R "$FINAL_UID":"$FINAL_GID" "$dir" 2>/dev/null || true
|
chown -R "$FINAL_UID":"$FINAL_GID" "$dir" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Write sentinel so subsequent starts skip the recursive walk.
|
||||||
|
# Suppress errors — a read-only mount would fail here, but that would
|
||||||
|
# already have failed above on the chown itself.
|
||||||
|
echo "$expected" > "$sentinel" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
|
|
||||||
# ── Drop to developer user for remaining setup ──────────────────────
|
# ── Drop to developer user for remaining setup ──────────────────────
|
||||||
|
|||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate opencode.json from environment variables on first container start.
|
||||||
|
|
||||||
|
Safety guarantees:
|
||||||
|
- NEVER overwrites an existing opencode.json. If the file is present
|
||||||
|
(whether bind-mounted from the host, persisted in a named volume, or
|
||||||
|
previously generated), this script exits immediately without writing.
|
||||||
|
- Requires OPENCODE_PROVIDER to be set. Without it, no file is written.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
OPENCODE_PROVIDER Required. One of: anthropic, openai, amazon-bedrock.
|
||||||
|
OPENCODE_MODEL Optional. Overrides the provider default model.
|
||||||
|
AWS_REGION Bedrock only. Default: us-east-1.
|
||||||
|
AWS_PROFILE Bedrock only. Default: default.
|
||||||
|
|
||||||
|
MCP servers are auto-registered for tools detected on PATH:
|
||||||
|
- mempalace (if installed) — enabled
|
||||||
|
- gitea-mcp (if installed) — registered but disabled by default
|
||||||
|
|
||||||
|
Output path: $HOME/.config/opencode/opencode.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Default model per provider. Update here when upstream changes.
|
||||||
|
DEFAULT_MODELS: dict[str, str] = {
|
||||||
|
"anthropic": "anthropic/claude-sonnet-4-6",
|
||||||
|
"openai": "openai/gpt-5.4",
|
||||||
|
"amazon-bedrock": (
|
||||||
|
"amazon-bedrock/global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback when OPENCODE_PROVIDER is set but not recognized.
|
||||||
|
FALLBACK_MODEL = DEFAULT_MODELS["anthropic"]
|
||||||
|
|
||||||
|
SCHEMA_URL = "https://opencode.ai/config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def build_config(provider: str, model: str) -> dict:
|
||||||
|
"""Build the base opencode.json structure for a provider."""
|
||||||
|
config: dict = {
|
||||||
|
"$schema": SCHEMA_URL,
|
||||||
|
"model": model,
|
||||||
|
"share": "disabled",
|
||||||
|
"autoupdate": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider == "amazon-bedrock":
|
||||||
|
config["provider"] = {
|
||||||
|
"amazon-bedrock": {
|
||||||
|
"options": {
|
||||||
|
"region": os.environ.get("AWS_REGION", "us-east-1"),
|
||||||
|
"profile": os.environ.get("AWS_PROFILE", "default"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def register_mcp_servers(config: dict) -> list[str]:
|
||||||
|
"""Auto-register MCP servers for tools detected on PATH.
|
||||||
|
|
||||||
|
Returns the list of server names that were added. The "mcp" key
|
||||||
|
is only added to the config when at least one server is registered.
|
||||||
|
"""
|
||||||
|
servers: dict[str, dict] = {}
|
||||||
|
|
||||||
|
# MemPalace — local-first AI memory (if installed).
|
||||||
|
# `mempalace-mcp` is the entry-point binary shipped by the mempalace
|
||||||
|
# Python package. `uv tool install mempalace` places it on PATH as a
|
||||||
|
# shim whose shebang points at the isolated venv's Python, so system
|
||||||
|
# `python3 -m mempalace.mcp_server` (which would fail — system
|
||||||
|
# python3 can't import from the uv venv) is unnecessary here.
|
||||||
|
if shutil.which("mempalace-mcp"):
|
||||||
|
servers["mempalace"] = {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["mempalace-mcp"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gitea — self-hosted Git forge API (if installed).
|
||||||
|
# Disabled by default; user must set GITEA_ACCESS_TOKEN + GITEA_HOST
|
||||||
|
# and flip enabled=true in their config.
|
||||||
|
if shutil.which("gitea-mcp"):
|
||||||
|
servers["gitea"] = {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["gitea-mcp", "-t", "stdio"],
|
||||||
|
"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
|
||||||
|
|
||||||
|
return list(servers.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
provider = os.environ.get("OPENCODE_PROVIDER", "").strip()
|
||||||
|
if not provider:
|
||||||
|
# No provider set — nothing to do. Not an error.
|
||||||
|
return 0
|
||||||
|
|
||||||
|
home = Path(os.environ.get("HOME", "/home/developer"))
|
||||||
|
config_dir = home / ".config" / "opencode"
|
||||||
|
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.
|
||||||
|
# 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 config found at {existing} — "
|
||||||
|
"skipping generation.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if provider not in DEFAULT_MODELS:
|
||||||
|
print(
|
||||||
|
f"WARNING: unknown OPENCODE_PROVIDER={provider!r}, "
|
||||||
|
f"falling back to default model {FALLBACK_MODEL!r}.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
model = os.environ.get("OPENCODE_MODEL", "").strip() or DEFAULT_MODELS.get(
|
||||||
|
provider, FALLBACK_MODEL
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Generating opencode config for provider: {provider}", file=sys.stderr)
|
||||||
|
|
||||||
|
config = build_config(provider, model)
|
||||||
|
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:
|
||||||
|
f.write(content)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(
|
||||||
|
f"MCP servers registered in opencode config: {', '.join(added)}.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+215
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate DOCKER_HUB.md.
|
||||||
|
|
||||||
|
Rationale
|
||||||
|
---------
|
||||||
|
DOCKER_HUB.md is the public-facing description shown on Docker Hub. It
|
||||||
|
has two hard constraints the README does not:
|
||||||
|
|
||||||
|
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
|
||||||
|
-----
|
||||||
|
Regenerate in place:
|
||||||
|
python3 scripts/generate-dockerhub-md.py
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DOCKER_HUB = REPO_ROOT / "DOCKER_HUB.md"
|
||||||
|
|
||||||
|
# Max size for Docker Hub full_description (bytes, UTF-8).
|
||||||
|
MAX_SIZE_BYTES = 25_000
|
||||||
|
|
||||||
|
# Where readers go for the full reference.
|
||||||
|
GITEA = "https://gitea.jordbo.se/joakimp/opencode-devbox"
|
||||||
|
|
||||||
|
|
||||||
|
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 \\
|
||||||
|
-e ANTHROPIC_API_KEY=your-key \\
|
||||||
|
-e OPENCODE_PROVIDER=anthropic \\
|
||||||
|
-e GIT_USER_NAME="Your Name" \\
|
||||||
|
-e GIT_USER_EMAIL="you@example.com" \\
|
||||||
|
-v ~/projects:/workspace \\
|
||||||
|
-v ~/.ssh:/home/developer/.ssh:ro \\
|
||||||
|
joakimp/opencode-devbox:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedrock SSO + static), persistence model, build args, troubleshooting: <{GITEA}#readme>
|
||||||
|
|
||||||
|
## What's Inside
|
||||||
|
|
||||||
|
- **[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.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The container reads provider credentials from environment variables and host-mounted config:
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
| 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) |
|
||||||
|
|
||||||
|
Workspace bind-mount (`/workspace`) is your project directory on the host, so source code is never inside the container.
|
||||||
|
|
||||||
|
Full persistence reference, including multi-user (`SIGNUM`) isolation and host bind-mount alternatives: see the [README on gitea]({GITEA}#persistence).
|
||||||
|
|
||||||
|
## Where to Go Next
|
||||||
|
|
||||||
|
- **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>
|
||||||
|
|
||||||
|
## 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 generate() -> str:
|
||||||
|
return HUB_TEMPLATE
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check",
|
||||||
|
action="store_true",
|
||||||
|
help="Fail if DOCKER_HUB.md differs from generated content.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
content = generate()
|
||||||
|
size = len(content.encode("utf-8"))
|
||||||
|
if size > MAX_SIZE_BYTES:
|
||||||
|
print(
|
||||||
|
f"ERROR: generated DOCKER_HUB.md is {size} bytes, exceeding the "
|
||||||
|
f"Docker Hub limit of {MAX_SIZE_BYTES} bytes.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
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 the template.\n"
|
||||||
|
"Run: python3 scripts/generate-dockerhub-md.py",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
existing.splitlines(keepends=True),
|
||||||
|
content.splitlines(keepends=True),
|
||||||
|
fromfile="DOCKER_HUB.md (committed)",
|
||||||
|
tofile="DOCKER_HUB.md (generated)",
|
||||||
|
n=2,
|
||||||
|
)
|
||||||
|
sys.stderr.writelines(list(diff)[:80])
|
||||||
|
return 1
|
||||||
|
print(
|
||||||
|
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"{MAX_SIZE_BYTES - size} bytes headroom).",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+313
@@ -0,0 +1,313 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smoke-test a freshly-built opencode-devbox image.
|
||||||
|
#
|
||||||
|
# Verifies:
|
||||||
|
# - Core binaries are on PATH and runnable
|
||||||
|
# - opencode itself starts and prints a version
|
||||||
|
# - Entrypoint runs cleanly as non-root after UID adjustment
|
||||||
|
# - Generated opencode.json has the expected shape
|
||||||
|
# - MCP wrapper works (when mempalace is installed)
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos|with-pi|omos-with-pi]
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 all checks passed
|
||||||
|
# 1 one or more checks failed
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE="${1:-}"
|
||||||
|
VARIANT="base"
|
||||||
|
if [ "${2:-}" = "--variant" ]; then
|
||||||
|
VARIANT="${3:-base}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$IMAGE" ]; then
|
||||||
|
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
FAILED=0
|
||||||
|
pass() { echo " ✓ $1"; }
|
||||||
|
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
||||||
|
|
||||||
|
run() {
|
||||||
|
# Run a command inside the image and capture its output.
|
||||||
|
# First arg is a label, rest is the shell command.
|
||||||
|
local label="$1"; shift
|
||||||
|
local out
|
||||||
|
if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$*" 2>&1); then
|
||||||
|
pass "$label ($(echo "$out" | head -1))"
|
||||||
|
else
|
||||||
|
fail "$label: $out"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Smoke test: $IMAGE (variant: $VARIANT) ==="
|
||||||
|
echo
|
||||||
|
echo "-- Resolved component versions --"
|
||||||
|
# Prints the actual version of every floating component so CI logs
|
||||||
|
# always record what got baked into this image, even when Dockerfile
|
||||||
|
# ARGs default to "latest".
|
||||||
|
docker run --rm --entrypoint="" "$IMAGE" sh -c '
|
||||||
|
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)"
|
||||||
|
printf " %-15s %s\n" "bat" "$(bat --version)"
|
||||||
|
printf " %-15s %s\n" "eza" "$(eza --version | head -2 | tail -1)"
|
||||||
|
printf " %-15s %s\n" "zoxide" "$(zoxide --version)"
|
||||||
|
printf " %-15s %s\n" "uv" "$(uv --version)"
|
||||||
|
printf " %-15s %s\n" "fzf" "$(fzf --version)"
|
||||||
|
printf " %-15s %s\n" "fd" "$(fd --version)"
|
||||||
|
printf " %-15s %s\n" "rg" "$(rg --version | head -1)"
|
||||||
|
printf " %-15s %s\n" "gosu" "$(gosu --version)"
|
||||||
|
printf " %-15s %s\n" "git-lfs" "$(git-lfs --version)"
|
||||||
|
printf " %-15s %s\n" "gitea-mcp" "$(gitea-mcp --version 2>&1 | head -1)"
|
||||||
|
printf " %-15s %s\n" "aws" "$(aws --version 2>&1)"
|
||||||
|
if command -v bun >/dev/null 2>&1; then
|
||||||
|
printf " %-15s %s\n" "bun" "$(bun --version)"
|
||||||
|
fi
|
||||||
|
if command -v mempalace >/dev/null 2>&1; then
|
||||||
|
printf " %-15s %s\n" "mempalace" "$(mempalace --version 2>&1 | head -1 || echo installed)"
|
||||||
|
fi
|
||||||
|
if command -v mempalace-session >/dev/null 2>&1 && [ -d /opt/mempalace-toolkit ]; then
|
||||||
|
printf " %-15s %s\n" "toolkit" "$(git -C /opt/mempalace-toolkit rev-parse --short HEAD 2>/dev/null || echo installed)"
|
||||||
|
fi
|
||||||
|
'
|
||||||
|
echo
|
||||||
|
echo "-- Core binaries --"
|
||||||
|
# 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"
|
||||||
|
run "nvim" "nvim --version | head -1"
|
||||||
|
run "bat" "bat --version"
|
||||||
|
run "eza" "eza --version | head -1"
|
||||||
|
run "zoxide" "zoxide --version"
|
||||||
|
run "uv" "uv --version"
|
||||||
|
run "uvx" "uvx --version"
|
||||||
|
run "rustup-init" "rustup-init --version"
|
||||||
|
run "fzf" "fzf --version"
|
||||||
|
run "fd" "fd --version"
|
||||||
|
run "rg" "rg --version | head -1"
|
||||||
|
run "jq" "jq --version"
|
||||||
|
run "aws" "aws --version"
|
||||||
|
run "gitea-mcp" "gitea-mcp --version"
|
||||||
|
run "gosu" "gosu --version"
|
||||||
|
run "tmux" "tmux -V"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "-- Optional / variant-gated --"
|
||||||
|
# mempalace: present unless built with INSTALL_MEMPALACE=false
|
||||||
|
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then
|
||||||
|
run "mempalace" "mempalace --help | head -1"
|
||||||
|
run "mempalace-mcp" "test -x /usr/local/bin/mempalace-mcp && readlink /usr/local/bin/mempalace-mcp"
|
||||||
|
else
|
||||||
|
echo " - mempalace not installed (INSTALL_MEMPALACE=false)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# mempalace-toolkit wrappers: present unless built with INSTALL_MEMPALACE_TOOLKIT=false
|
||||||
|
# Gated on mempalace presence — wrappers are useless without the CLI.
|
||||||
|
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace && command -v mempalace-session" >/dev/null 2>&1; then
|
||||||
|
run "mempalace-session (toolkit)" "mempalace-session --help | head -1"
|
||||||
|
run "mempalace-docs (toolkit)" "mempalace-docs --help | head -1"
|
||||||
|
run "toolkit symlink target" "test -L /usr/local/bin/mempalace-session && readlink /usr/local/bin/mempalace-session"
|
||||||
|
elif docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then
|
||||||
|
echo " - mempalace-toolkit not installed (INSTALL_MEMPALACE_TOOLKIT=false)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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. 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"
|
||||||
|
else
|
||||||
|
pass "bun correctly absent from base image"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "-- Entrypoint behaviour --"
|
||||||
|
|
||||||
|
# Generate-config script exists and has valid syntax.
|
||||||
|
run "generate-config.py exists" \
|
||||||
|
"test -x /usr/local/lib/opencode-devbox/generate-config.py && python3 -m py_compile /usr/local/lib/opencode-devbox/generate-config.py && echo ok"
|
||||||
|
|
||||||
|
# Entrypoint drops to developer user and runs a trivial command.
|
||||||
|
# Writes the result to a file inside the container so we don't have to
|
||||||
|
# disentangle entrypoint log output from command stdout on the host.
|
||||||
|
label="entrypoint drops to developer"
|
||||||
|
tmpout=$(mktemp)
|
||||||
|
if docker run --rm -e OPENCODE_PROVIDER= "$IMAGE" \
|
||||||
|
sh -c 'whoami > /tmp/who && cat /tmp/who' > "$tmpout" 2>/dev/null; then
|
||||||
|
# The last line of stdout is the whoami output. Entrypoint log lines
|
||||||
|
# (MemPalace init, "Adjusted developer UID", etc.) go to stderr or
|
||||||
|
# get printed before our sh command runs.
|
||||||
|
actual=$(tail -1 "$tmpout" | tr -d '[:space:]')
|
||||||
|
if [ "$actual" = "developer" ]; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label: expected 'developer', got '$actual' (full output: $(cat "$tmpout"))"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "$label: container failed"
|
||||||
|
fi
|
||||||
|
rm -f "$tmpout"
|
||||||
|
|
||||||
|
# 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 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 \
|
||||||
|
-e HOME=/tmp/home \
|
||||||
|
--entrypoint="" \
|
||||||
|
"$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.jsonc
|
||||||
|
' > "$tmp/out.jsonc" 2>/dev/null; then
|
||||||
|
# Strip single-line // comments for JSON validation (respecting strings)
|
||||||
|
if python3 -c "
|
||||||
|
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.jsonc")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
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 \
|
||||||
|
-e HOME=/tmp/home \
|
||||||
|
--entrypoint="" \
|
||||||
|
"$IMAGE" sh -c '
|
||||||
|
mkdir -p /tmp/home/.config/opencode
|
||||||
|
echo "{\"sentinel\": \"user-config\"}" > /tmp/home/.config/opencode/opencode.json
|
||||||
|
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
|
||||||
|
cat /tmp/home/.config/opencode/opencode.json
|
||||||
|
' 2>/dev/null | grep -q '"sentinel": "user-config"'; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label: existing config was modified!"
|
||||||
|
fi
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "-- Image size --"
|
||||||
|
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 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=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
|
||||||
|
pass "image size ${SIZE_MB} MB within threshold ${THRESHOLD} MB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAILED" -gt 0 ]; then
|
||||||
|
echo "=== FAILED: $FAILED check(s) ===" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "=== PASSED ==="
|
||||||
Reference in New Issue
Block a user