Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -31,6 +31,31 @@ WORKSPACE_PATH=~/projects
|
||||
# Path to SSH keys on host
|
||||
SSH_KEY_PATH=~/.ssh
|
||||
|
||||
# ── Skillset (agent skills and instructions) ─────────────────────────
|
||||
# If you have a skillset repo, the entrypoint auto-deploys skills and
|
||||
# instructions on container start using relative symlinks (portable
|
||||
# across host/container).
|
||||
#
|
||||
# Detection is automatic if the skillset lives directly at the workspace
|
||||
# root (i.e. WORKSPACE_PATH/skillset → /workspace/skillset in container).
|
||||
#
|
||||
# If the skillset lives in a subdirectory of your workspace, set
|
||||
# SKILLSET_CONTAINER_PATH to its location *inside the container*. This
|
||||
# is determined by the workspace mount: whatever is at
|
||||
# WORKSPACE_PATH/<subpath> on the host becomes /workspace/<subpath>
|
||||
# in the container.
|
||||
#
|
||||
# Examples:
|
||||
# Host skillset at ~/projects/skillset → already at /workspace/skillset (auto-detected, no config needed)
|
||||
# Host skillset at ~/projects/tools/skillset → SKILLSET_CONTAINER_PATH=/workspace/tools/skillset
|
||||
# Host skillset at ~/projects/local/skillset → SKILLSET_CONTAINER_PATH=/workspace/local/skillset
|
||||
#
|
||||
# Alternatively, mount the skillset repo at a dedicated path using the
|
||||
# SKILLSET_PATH volume in docker-compose.yml (see comments there). In
|
||||
# that case the entrypoint finds it at ~/skillset automatically.
|
||||
#
|
||||
# SKILLSET_CONTAINER_PATH=
|
||||
|
||||
# ── Locale (defaults to en_US.UTF-8) ─────────────────────────────────
|
||||
# LANG=sv_SE.UTF-8
|
||||
# LANGUAGE=sv_SE:sv
|
||||
@@ -42,3 +67,32 @@ SSH_KEY_PATH=~/.ssh
|
||||
# OMOS_TMUX=false # Enable tmux multiplexer integration
|
||||
# OMOS_SKILLS=true # Install recommended skills (simplify, agent-browser, cartography)
|
||||
# OMOS_RESET=false # Force regenerate oh-my-opencode-slim config on next start
|
||||
|
||||
# ── pi coding-agent (alternative/complementary harness) ─────────────────
|
||||
# Requires image built with INSTALL_PI=true.
|
||||
# When the image is built with both INSTALL_OPENCODE=true (default) and
|
||||
# INSTALL_PI=true, both harnesses share the same mempalace install and
|
||||
# palace path — wing data is mutually visible to either harness.
|
||||
#
|
||||
# Pi version is baked at build time via PI_VERSION (default: latest at
|
||||
# build). The baked `pi` binary is at /usr/bin/pi (system npm prefix);
|
||||
# rebuild the image to upgrade it. NPM_CONFIG_PREFIX is set to
|
||||
# /home/developer/.pi/npm-global, so anything installed via
|
||||
# `pi install npm:...` or `npm install -g` as the developer user
|
||||
# (themes, skills, extensions, including a user-installed pi itself)
|
||||
# lands on the named volume and survives container recreate AND image
|
||||
# rebuilds. A user-installed pi wins via PATH order over the baked one.
|
||||
#
|
||||
# Pi config (settings.json, extensions toggle state, sessions, auth) persists in the
|
||||
# devbox-pi-config named volume mounted at ~/.pi/.
|
||||
#
|
||||
# To launch pi from a `compose run` invocation:
|
||||
# docker compose run --rm devbox pi
|
||||
# To attach to a running container:
|
||||
# docker compose exec -u developer devbox pi
|
||||
# Default `compose run` (no args) drops to bash; pick the harness yourself.
|
||||
#
|
||||
# Build args (set in docker-compose.yml or via --build-arg on docker build):
|
||||
# INSTALL_PI=true # default false; opt-in
|
||||
# PI_VERSION=latest # pin a specific version, e.g. 0.73.0
|
||||
# INSTALL_OPENCODE=false # build a pi-only image (still has Bun in -omos)
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
name: Publish Docker Image (split-base)
|
||||
|
||||
# Two-phase split-base build pipeline. Lives ALONGSIDE the original
|
||||
# docker-publish.yml during the migration window. Triggers only on
|
||||
# workflow_dispatch (manual) so it doesn't conflict with the production
|
||||
# tag-trigger pipeline.
|
||||
#
|
||||
# Once we've validated 1-2 successful runs and verified output
|
||||
# byte-for-byte against the original, this workflow takes over `on:
|
||||
# push: tags: v*` and the original is retired.
|
||||
#
|
||||
# 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:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag to publish (e.g. v1.14.42-split). Pushed to Docker Hub as both the literal tag and `latest*`-aliases.'
|
||||
required: true
|
||||
default: 'v0.0.0-split-test'
|
||||
promote_latest:
|
||||
description: 'Update latest/latest-omos/latest-with-pi/latest-omos-with-pi aliases (set false for 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
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────
|
||||
# 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="${{ inputs.release_tag }}"
|
||||
TAGS="${IMAGE}:${VERSION}"
|
||||
if [ "${{ inputs.promote_latest }}" = "true" ]; then
|
||||
TAGS="${TAGS}\n${IMAGE}:latest"
|
||||
fi
|
||||
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=false
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
|
||||
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="${{ inputs.release_tag }}"
|
||||
TAGS="${IMAGE}:${VERSION}-omos"
|
||||
if [ "${{ inputs.promote_latest }}" = "true" ]; then
|
||||
TAGS="${TAGS}\n${IMAGE}:latest-omos"
|
||||
fi
|
||||
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=false
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
|
||||
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="${{ inputs.release_tag }}"
|
||||
TAGS="${IMAGE}:${VERSION}-with-pi"
|
||||
if [ "${{ inputs.promote_latest }}" = "true" ]; then
|
||||
TAGS="${TAGS}\n${IMAGE}:latest-with-pi"
|
||||
fi
|
||||
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
|
||||
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="${{ inputs.release_tag }}"
|
||||
TAGS="${IMAGE}:${VERSION}-omos-with-pi"
|
||||
if [ "${{ inputs.promote_latest }}" = "true" ]; then
|
||||
TAGS="${TAGS}\n${IMAGE}:latest-omos-with-pi"
|
||||
fi
|
||||
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
|
||||
# ── 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: 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: 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
|
||||
+352
-161
@@ -6,7 +6,7 @@ on:
|
||||
- 'v*'
|
||||
|
||||
# Serialize concurrent runs of the same workflow on the same ref so the
|
||||
# matrix build jobs can't race `docker system prune` in the smoke gates
|
||||
# build jobs can't race `docker system prune` in the smoke gates
|
||||
# (pruning from one job can nuke another job's in-flight buildx cache).
|
||||
# cancel-in-progress: false — tag pushes are release events, we never
|
||||
# want to silently drop one.
|
||||
@@ -14,16 +14,43 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# Plain progress output from BuildKit — critical for diagnosing stalls
|
||||
# inside arm64-under-QEMU builds where the default collapsed progress UI
|
||||
# hides which step is stuck.
|
||||
env:
|
||||
BUILDKIT_PROGRESS: plain
|
||||
|
||||
# Runner disk pressure notes:
|
||||
# Gitea Actions runners use `catthehacker/ubuntu:act-latest` on a shared host
|
||||
# with limited overlay space (~40 GB, often 70%+ used at start). Building both
|
||||
# architectures of both variants on a single runner exhausted disk around the
|
||||
# nodejs dpkg unpack / git-lfs layer export. To fix this:
|
||||
# * smoke test (amd64 only, load into daemon) runs on its own runner
|
||||
# * each push target (variant × arch) runs on its own runner, pushes by
|
||||
# digest (no local image store), uploads digest as an artifact
|
||||
# * a merge job composes the multi-arch manifest with `imagetools create`
|
||||
# Per-runner disk pressure is now one-quarter of the old single-job peak.
|
||||
# with limited overlay space (~40 GB, often 70%+ used at start). Two jobs
|
||||
# per variant:
|
||||
# * smoke gate (amd64 only, `load: true` into local dockerd for smoke
|
||||
# testing) — peak disk = tarball + unpacked image + buildx cache. The
|
||||
# `Reclaim runner disk` step below strips catthehacker-resident
|
||||
# toolchains and prunes stale docker state before buildx starts.
|
||||
# * build job (amd64 + arm64, `push-by-digest` streaming directly to
|
||||
# Docker Hub, no local unpack). Peak disk on push-by-digest is
|
||||
# BuildKit's content store only — much smaller than `load: true`.
|
||||
# `docker/build-push-action@v7` with comma-separated platforms
|
||||
# publishes a proper multi-arch manifest in one step.
|
||||
#
|
||||
# Why not matrix + digest artifacts?
|
||||
# An earlier revision split each arch into its own matrix job and used
|
||||
# `actions/upload-artifact` to pass digests to a merge job. On Gitea
|
||||
# Actions, `actions/{upload,download}-artifact@v4+` fails with
|
||||
# `GHESNotSupportedError` — v4 relies on a GitHub-specific Artifact
|
||||
# API that Gitea doesn't implement. Rather than downgrade to @v3 (the
|
||||
# last Gitea-compatible release) we collapsed back to single-job
|
||||
# multi-arch push. The matrix only helps when the build literally
|
||||
# cannot fit on one runner, which push-by-digest + reclaim no longer
|
||||
# hits for this image.
|
||||
#
|
||||
# Gitea Actions gotchas baked into this file:
|
||||
# * `actions/{upload,download}-artifact` must stay at @v3 on Gitea.
|
||||
# * Step scripts run under /bin/sh (dash) — no bash-isms like
|
||||
# ${VAR//a/b}. Use `tr` or explicit `shell: bash`.
|
||||
# * `docker/build-push-action@v7` with `platforms: a,b` works for
|
||||
# multi-arch push natively; no matrix/merge dance needed.
|
||||
|
||||
jobs:
|
||||
# ── Smoke test (amd64 only, gates the push jobs) ────────────────────
|
||||
@@ -137,18 +164,10 @@ jobs:
|
||||
- name: Smoke test (amd64)
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
|
||||
|
||||
# ── Per-arch push (by digest, no local image) ───────────────────────
|
||||
build-base:
|
||||
smoke-with-pi:
|
||||
runs-on: ubuntu-latest
|
||||
needs: smoke-base
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -156,16 +175,147 @@ jobs:
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Derive platform slug
|
||||
id: platform
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
# POSIX-safe slash substitution — act's runner container ships
|
||||
# /bin/sh as dash, which doesn't support bash's ${VAR//a/b}.
|
||||
echo "pair=$(echo '${{ matrix.platform }}' | tr / -)" >> $GITHUB_OUTPUT
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker system df || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build and load amd64 image for smoke test
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
INSTALL_PI=true
|
||||
tags: opencode-devbox:smoke-with-pi
|
||||
|
||||
- name: Smoke test (amd64)
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
|
||||
|
||||
smoke-omos-with-pi:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker system df || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build and load amd64 image for smoke test
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=true
|
||||
tags: opencode-devbox:smoke-omos-with-pi
|
||||
|
||||
- name: Smoke test (amd64)
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
|
||||
|
||||
# ── Multi-arch push (single job per variant, comma-separated platforms) ─
|
||||
build-base:
|
||||
runs-on: ubuntu-latest
|
||||
needs: smoke-base
|
||||
timeout-minutes: 90
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
# Lighter reclaim than the smoke-gate version: push-by-digest
|
||||
# doesn't write to host dockerd, so `docker system prune` adds
|
||||
# little. BuildKit cache from prior runs is the thing to clear.
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up QEMU
|
||||
if: matrix.platform != 'linux/amd64'
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -178,39 +328,26 @@ jobs:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-base-${{ steps.platform.outputs.pair }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest
|
||||
|
||||
build-omos:
|
||||
runs-on: ubuntu-latest
|
||||
needs: smoke-omos
|
||||
timeout-minutes: 90
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -218,16 +355,32 @@ jobs:
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Derive platform slug
|
||||
id: platform
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
# POSIX-safe slash substitution — act's runner container ships
|
||||
# /bin/sh as dash, which doesn't support bash's ${VAR//a/b}.
|
||||
echo "pair=$(echo '${{ matrix.platform }}' | tr / -)" >> $GITHUB_OUTPUT
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up QEMU
|
||||
if: matrix.platform != 'linux/amd64'
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -240,122 +393,160 @@ jobs:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
INSTALL_OMOS=true
|
||||
outputs: type=image,name=${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-omos-${{ steps.platform.outputs.pair }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ── Merge per-arch digests into multi-arch tags ─────────────────────
|
||||
merge-base:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-base
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-base-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }} \
|
||||
-t ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest \
|
||||
$(printf '${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect \
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}
|
||||
|
||||
merge-omos:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-omos
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-omos-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-omos \
|
||||
-t ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-omos \
|
||||
$(printf '${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect \
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-omos
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-omos
|
||||
|
||||
build-with-pi:
|
||||
runs-on: ubuntu-latest
|
||||
needs: smoke-with-pi
|
||||
timeout-minutes: 90
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
INSTALL_PI=true
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-with-pi
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-with-pi
|
||||
|
||||
build-omos-with-pi:
|
||||
runs-on: ubuntu-latest
|
||||
needs: smoke-omos-with-pi
|
||||
timeout-minutes: 90
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=true
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-omos-with-pi
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-omos-with-pi
|
||||
|
||||
update-description:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [merge-base, merge-omos]
|
||||
needs: [build-base, build-omos, build-with-pi, build-omos-with-pi]
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
|
||||
@@ -147,3 +147,116 @@ jobs:
|
||||
- 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: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
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: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
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
|
||||
|
||||
@@ -6,16 +6,18 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d
|
||||
|
||||
## File roles
|
||||
|
||||
- `Dockerfile` — single multi-stage build for both variants. OMOS variant is controlled by `INSTALL_OMOS=true` build arg; mempalace is controlled by `INSTALL_MEMPALACE` (default `true`). All GitHub-sourced binaries are pinned with version ARGs.
|
||||
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu.
|
||||
- `entrypoint-user.sh` — runs as developer: git config, opencode.json generation (delegated to `generate-config.py`), OMOS setup.
|
||||
- `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.json` from env vars. Never overwrites an existing config. Auto-registers MCP servers for detected tools (mempalace via the `mempalace-mcp` entry point, gitea-mcp).
|
||||
- `Dockerfile` — production single-Dockerfile build. Variants are gated by build args: `INSTALL_OMOS` (Bun + multi-agent layer), `INSTALL_OPENCODE` (default true), `INSTALL_PI` (default false), `INSTALL_MEMPALACE` (default true). All GitHub-sourced binaries are pinned with version ARGs.
|
||||
- `Dockerfile.base` and `Dockerfile.variant` — **WIP, branch `feat/split-build` only.** Two-Dockerfile split-base build: base contains all variant-independent layers; variant `FROM`s the base and adds only opencode/omos/pi installs. Used by `docker-publish-split.yml` (workflow_dispatch only) for parallel testing alongside the production pipeline. See CHANGELOG `Unreleased` for the migration plan and trade-offs.
|
||||
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. Volume ownership loop covers `~/.pi/` when `INSTALL_PI=true`.
|
||||
- `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, skillset auto-deploy from mounted skillset repo, OMOS setup.
|
||||
- `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.jsonc` from env vars. Never overwrites an existing config (checks both `.json` and `.jsonc`). Auto-registers MCP servers for detected tools (mempalace via `mempalace-mcp`, gitea-mcp, context7 remote endpoint).
|
||||
- `scripts/smoke-test.sh` — post-build image verification. Asserts binary presence, opencode startup, entrypoint correctness, config generation idempotency, and image size thresholds. Used by both CI workflows.
|
||||
- `scripts/generate-dockerhub-md.py` — generates `DOCKER_HUB.md` from `README.md` using explicit section rules. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow).
|
||||
- `DOCKER_HUB.md` — **auto-generated** from README. Do not edit directly. Pushed to Docker Hub description via CI API call. Must stay under 25 kB. Short description field must be ≤100 bytes.
|
||||
- `README.md` — authoritative source documentation. Sections are selected/dropped/replaced for DOCKER_HUB.md per `SECTION_RULES` in `scripts/generate-dockerhub-md.py`.
|
||||
- `scripts/generate-dockerhub-md.py` — generates `DOCKER_HUB.md` from a hand-maintained `HUB_TEMPLATE` constant. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow).
|
||||
- `DOCKER_HUB.md` — **auto-generated** from `HUB_TEMPLATE` in `scripts/generate-dockerhub-md.py`. Do not edit directly. Pushed to Docker Hub description via CI API call. Must stay under 25 kB. Short description field must be ≤100 bytes.
|
||||
- `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth.
|
||||
- `.gitea/workflows/validate.yml` — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check.
|
||||
- `.gitea/workflows/docker-publish.yml` — CI pipeline on tag push: smoke-test each variant on amd64, then full multi-arch (amd64 + arm64) build-and-push, then update Docker Hub description.
|
||||
- `.gitea/workflows/docker-publish.yml` — production CI pipeline on tag push: smoke-test each variant on amd64, then full multi-arch (amd64 + arm64) build-and-push, then update Docker Hub description.
|
||||
- `.gitea/workflows/docker-publish-split.yml` — **WIP, branch `feat/split-build` only.** Two-phase split-base pipeline. Triggers on `workflow_dispatch` only so it runs alongside the production pipeline without conflict. Pushes to user-supplied `release_tag` input (e.g. `v0.0.0-split-test`); `latest*` aliases only updated when `promote_latest: true`. Compute base hash, conditionally build base, then 4 variant deltas in parallel.
|
||||
|
||||
## Versioning scheme
|
||||
|
||||
@@ -26,19 +28,31 @@ Tags follow `v{opencode_version}[letter]` — e.g. `v1.14.20` for the first buil
|
||||
- **Letter suffix is the build ordinal**, starting at `b` for the second build. The letter `a` is **never used** — think of the suffix as counting rebuilds: `b = 2nd, c = 3rd, d = 4th, …`. For opencode version `1.14.20`: first build `v1.14.20`, second `v1.14.20b`, third `v1.14.20c`, and so on.
|
||||
- A letter suffix is only used for container-level rebuilds — tooling changes, CVE fixes, doc-driven rebuilds, entrypoint bugfixes — that don't change the underlying opencode version.
|
||||
|
||||
CI produces four Docker Hub tags per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`.
|
||||
CI produces eight Docker Hub tags per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`, `vX.Y.Z[n]-with-pi`, `latest-with-pi`, `vX.Y.Z[n]-omos-with-pi`, `latest-omos-with-pi` — one tag pair (versioned + floating alias) per build variant.
|
||||
|
||||
When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile` and update the comment in `.env.example` if it names a specific model/version for context.
|
||||
|
||||
## Critical conventions
|
||||
|
||||
- **entrypoint.sh volume ownership loop** — when adding a new named volume mount point, add it to the `for dir in ...` loop in `entrypoint.sh` so root-owned volumes get chowned on startup. The loop writes a `.devbox-owner` sentinel after a successful chown so subsequent starts skip the recursive walk. Users should not touch these files.
|
||||
- **Two docs to keep in sync (automated)** — `README.md` is the source of truth. `DOCKER_HUB.md` is auto-generated by `scripts/generate-dockerhub-md.py`. When adding a new top-level section to README, either add it to `SECTION_RULES` in that script or the `--check` run will fail CI. `.env.example` must still be hand-updated to match Dockerfile/entrypoint behavior.
|
||||
- **Documentation coupling on release** — four docs co-vary and drift in lockstep when not updated together:
|
||||
- `README.md` is the source of truth for user-facing build/run/config detail.
|
||||
- `DOCKER_HUB.md` is auto-generated from `HUB_TEMPLATE` in `scripts/generate-dockerhub-md.py`. CI's `--check` run fails if it's stale. Hub-facing copy is intentionally slim (~5.5 kB, ~78% headroom against the 25 kB Hub limit) — update the template here when image variants, quick-start flow, or the elevator pitch change. README.md no longer feeds into Hub, so README edits do NOT require regenerating DOCKER_HUB.md.
|
||||
- `CHANGELOG.md` records every release. When cutting a tag, **promote `## Unreleased` to `## vX.Y.Z[n] — YYYY-MM-DD` BEFORE pushing the tag** so the tag points at a CHANGELOG that names itself. Keep entries reverse-chronological (newest at top, after the `Unreleased` block). Doc-only updates that happen post-tag (Hub description live-patches, README clarifications) get a fresh `## Unreleased` block with a note that they don't trigger a new image build.
|
||||
- `AGENTS.md` (this file) carries domain facts that change on structural releases — tag-count statements, CI job lists, install contracts. After any change to `.gitea/workflows/*.yml` or the variant matrix, grep this file for stale numbers (`grep -nE "four|eight|all [0-9]"`).
|
||||
- `.env.example` must be hand-updated to match Dockerfile/entrypoint behavior — it is not auto-generated.
|
||||
|
||||
Release-day checklist: README → (regenerate DOCKER_HUB.md only if HUB_TEMPLATE changed) → promote CHANGELOG Unreleased → grep AGENTS.md for stale counts → commit → tag → push tag.
|
||||
- **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag.
|
||||
- **Resolved versions are logged by the smoke test** — `scripts/smoke-test.sh` prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to `latest`.
|
||||
- **Shell scripts use `set -euo pipefail`** — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with `|| true`.
|
||||
- **MemPalace install path** — installed via `uv tool install` into `/opt/uv-tools/mempalace/`. Both the `mempalace` CLI and the `mempalace-mcp` MCP server binary are shipped as entry points by the mempalace package itself and placed on PATH by uv as shims whose shebangs point at the venv's Python. No hand-rolled wrapper is needed. Do not use `pip install --break-system-packages` — that was the previous approach and has been removed. Do not use `["python3", "-m", "mempalace.mcp_server"]` in `opencode.json` — system Python can't import from the uv venv.
|
||||
- **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.json`. Users bind-mount their config directory or persist it across container recreations; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this.
|
||||
- **MemPalace install path** — installed via `uv tool install` into `/opt/uv-tools/mempalace/`. Both the `mempalace` CLI and the `mempalace-mcp` MCP server binary are shipped as entry points by the mempalace package itself and placed on PATH by uv as shims whose shebangs point at the venv's Python. No hand-rolled wrapper is needed. Do not use `pip install --break-system-packages` — that was the previous approach and has been removed. Do not use `["python3", "-m", "mempalace.mcp_server"]` in `opencode.jsonc` — system Python can't import from the uv venv.
|
||||
- **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.jsonc` or legacy `opencode.json`. Config persists in the `devbox-opencode-config` named volume; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this.
|
||||
- **Skillset auto-deploy** — on every container start, `entrypoint-user.sh` looks for a skillset repo (detection order: `$SKILLSET_CONTAINER_PATH` → `$HOME/skillset` → `/workspace/skillset`) and runs `deploy-skills.sh --bootstrap --prune-stale`. This creates relative symlinks in `~/.agents/skills/` and `~/.config/opencode/instructions/`. Do NOT bind-mount `~/.agents/skills/` from the host — the container manages its own skills with relative symlinks that differ from the host's. The named volume `devbox-opencode-config` persists the deployed config across restarts.
|
||||
- **Config persistence via named volume** — `devbox-opencode-config` is a Docker named volume mounted at `~/.config/opencode/`. It is NOT a host bind mount by default. This separation allows both native and containerized opencode to coexist on the same machine without symlink conflicts. Users who need to override can replace the named volume with a host bind mount in their compose file. **Same pattern for pi:** `devbox-pi-config` is mounted at `~/.pi/` and persists user toggles (`/ext`-disabled extensions), `~/.pi/agent/settings.json` edits, and — because `NPM_CONFIG_PREFIX` is set to `~/.pi/npm-global` — anything installed via `pi install npm:...` or `npm install -g` as the developer user, across container recreate AND image rebuild.
|
||||
- **pi install contract** — `INSTALL_PI=true` (default false) opt-in build arg. The baked `pi` binary is npm-installed globally to `/usr` at build time (system prefix). At runtime, `NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global` is set in the image ENV with that prefix's `bin/` prepended to `PATH` — so any `pi install npm:...` or `npm install -g` invoked by the developer user lands on the named volume and survives everything except `docker compose down -v`. The new ENVs are declared *after* all build-time `npm install -g` calls in the Dockerfile so they don't redirect the baked installs into a path that the volume mount would later shadow. If the user runs `npm install -g @earendil-works/pi-coding-agent` themselves, the user-installed copy on the volume wins via `PATH` order; otherwise image rebuild is the upgrade path for the baked pi (same contract as `OPENCODE_VERSION`). The pi-toolkit and pi-extensions repos are git-cloned into `/opt/` at build time, then their `install.sh` runs from `entrypoint-user.sh` on each container start to symlink into `~/.pi/agent/` (which lives on the named volume). The mempalace pi-bridge is symlinked manually from `/opt/mempalace-toolkit/extensions/pi/mempalace.ts` — we do NOT call mempalace-toolkit's full `install.sh` because its `install_skill` step would race with skillset auto-deploy `--prune-stale`.
|
||||
- **Pi deploy ordering matters in entrypoint-user.sh** — `pi-toolkit` runs first (creates `keybindings.json` symlink and writes pi-env.zsh), then `pi-extensions`, then `settings.json` template bootstrap, then mempalace bridge symlink. mempalace-toolkit's `check_pi_toolkit` probe (when called from the host install path) expects keybindings to already be present — not currently called from container, but ordering matches host convention.
|
||||
- **Default CMD is `bash -l`** — not a harness. `docker compose run --rm devbox` drops the user into a login shell to choose: `aws sso login`, then `opencode` or `pi` (or any tool). Pass the harness explicitly to launch directly: `docker compose run --rm devbox opencode` / `docker compose run --rm devbox pi`. `docker compose exec` bypasses entrypoint+CMD entirely (existing user workflow unchanged).
|
||||
- **Docker Hub description update** — uses `/v2/auth/token` endpoint (not the deprecated `/v2/users/login`). Auth uses `identifier`/`secret` fields, returns `access_token`, sent as `Bearer`. Short description must be ≤100 bytes.
|
||||
|
||||
## CI quirks
|
||||
@@ -47,6 +61,11 @@ When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile`
|
||||
- `update-description` job runs only when both builds succeed (`needs: [build-base, build-omos]`).
|
||||
- Tags must be pushed to trigger the publish workflow. The validate workflow runs on push to main and PRs.
|
||||
- Smoke tests run on amd64 only (single-arch load into the local daemon). The multi-arch push happens after smoke passes.
|
||||
- **Gitea Actions runner has ~40 GB disk, often 70%+ used at job start.** All eight `load: true` jobs (`validate-base`, `validate-omos`, `validate-with-pi`, `validate-omos-with-pi`, `smoke-base`, `smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner.
|
||||
- **`docker/build-push-action@v7` with `platforms: linux/amd64,linux/arm64` handles multi-arch push natively in a single job** — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires `actions/{upload,download}-artifact@v4+` which Gitea Actions doesn't support (see below).
|
||||
- **`actions/upload-artifact` and `actions/download-artifact` must stay at @v3 on Gitea.** v4+ uses a GitHub-Enterprise-specific Artifact API; runs fail with `GHESNotSupportedError`. If you need artifacts for a new reason (build logs, SBOMs, etc.), pin @v3 explicitly.
|
||||
- **Step scripts run under `/bin/sh` (dash), not bash.** Avoid bash-isms like `${VAR//a/b}` parameter-pattern substitution; use POSIX alternatives (`tr`, `sed`) or declare `shell: bash` on the step.
|
||||
- **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish.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
|
||||
|
||||
|
||||
@@ -6,6 +6,99 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
|
||||
Build pipeline (merged to main as `Dockerfile.base` + `Dockerfile.variant` + `.gitea/workflows/docker-publish-split.yml`, NOT yet validated end-to-end — the `workflow_dispatch` test against `:base-<hash>` + `:v0.0.0-split-test*` aliases is still the gating step before this can take over `on: push: tags: v*`):
|
||||
|
||||
- **New: split-base build pipeline.** `Dockerfile.base` (variant-independent layers — apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints) builds once and is published as `joakimp/opencode-devbox:base-<sha>`. `Dockerfile.variant` `FROM`s that base and adds only opencode/omos/pi installs (or skips them per build-args). Companion workflow `.gitea/workflows/docker-publish-split.yml` runs as a `workflow_dispatch`-only pipeline alongside the existing `docker-publish.yml` so they don't conflict. Hash-driven base reuse: a content hash of `Dockerfile.base + rootfs/ + entrypoint*.sh` becomes the base tag; if the tag already exists on Docker Hub, the base build is skipped entirely. Estimated wall clock: version-bump-only release ~30–40 min (vs ~165–180 min today); base-touching release ~60–70 min. Trade-off: two Dockerfiles to maintain, and `npm install -g` in the variant must override `NPM_CONFIG_PREFIX=/usr` per-RUN to keep baked binaries off the volume-shadowed path. Once 1–2 successful workflow_dispatch runs validate the output against the existing pipeline, the new workflow takes over `on: push: tags: v*` and the original is retired.
|
||||
|
||||
## v1.14.42 — 2026-05-09
|
||||
|
||||
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.**
|
||||
|
||||
+42
-554
@@ -1,19 +1,19 @@
|
||||
# opencode-devbox — Docker Hub
|
||||
# opencode-devbox
|
||||
|
||||
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
|
||||
|
||||
## Image Variants
|
||||
Designed for teams who want a reproducible coding-agent setup that runs the same on every laptop and CI runner — without forcing each developer to install Bun, Node, AWS CLI, mempalace, or maintain shell config drift across machines.
|
||||
|
||||
Two image variants are published for each release:
|
||||
## Image Variants
|
||||
|
||||
| Tag | Description |
|
||||
|---|---|
|
||||
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
|
||||
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
|
||||
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/earendil-works/pi) as alternative/complementary harness (shares the mempalace install with opencode) |
|
||||
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
|
||||
|
||||
Both variants support `linux/amd64` and `linux/arm64`.
|
||||
|
||||
> **NOTE:** This file is auto-generated from `README.md` by `scripts/generate-dockerhub-md.py`. Edit README.md and regenerate rather than editing this file directly.
|
||||
All variants support `linux/amd64` and `linux/arm64`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -28,9 +28,9 @@ docker run -it --rm \
|
||||
joakimp/opencode-devbox:latest
|
||||
```
|
||||
|
||||
This drops you straight into opencode with your project mounted at `/workspace`.
|
||||
Drops you straight into opencode with your project mounted at `/workspace`.
|
||||
|
||||
For an interactive shell first (useful for AWS SSO login):
|
||||
For an interactive shell first (useful for AWS SSO login, multi-harness workflows, or just `bash`):
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
@@ -41,567 +41,55 @@ docker run -it --rm \
|
||||
joakimp/opencode-devbox:latest bash
|
||||
```
|
||||
|
||||
Then run `opencode` when ready.
|
||||
Then run `opencode`, `pi` (on `*-with-pi` variants), or `aws sso login` from the shell.
|
||||
|
||||
For docker-compose users, see the source repo for `docker-compose.yml` and `.env.example` templates.
|
||||
For docker-compose users, the source repo provides `docker-compose.yml`, `.env.example`, and a one-liner `docker compose up -d` workflow with named volumes pre-wired.
|
||||
|
||||
## Features
|
||||
## What's Inside
|
||||
|
||||
- **Debian trixie** base — glibc, full PTY/terminal support
|
||||
- **Configurable providers** — Anthropic, OpenAI, AWS Bedrock via env vars
|
||||
- **Host filesystem access** — bind mount any directory as `/workspace`
|
||||
- **SSH key forwarding** — git push/pull to private repos
|
||||
- **MCP server support** — Node.js included for `npx`-based MCP servers
|
||||
- **Non-root user** — runs as `developer` with UID auto-matched to workspace owner (sudo available)
|
||||
- **Python via uv** — `uv` package manager included; install Python on demand with `uv python install`
|
||||
- **Rust via rustup** — `rustup-init` included; bootstrap Rust on demand with `rustup-init -y`
|
||||
- **Optional runtimes** — Python (apt), Go via build args (Node.js always included — required for opencode v1.x)
|
||||
- **Multi-agent orchestration** — optional [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) integration via build arg
|
||||
- **AWS CLI v2** — built-in SSO/Bedrock authentication with headless device-code flow
|
||||
- **Multi-arch** — amd64 and arm64
|
||||
- **[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.
|
||||
|
||||
## Usage
|
||||
## Authentication
|
||||
|
||||
### Prerequisites
|
||||
The container reads provider credentials from environment variables and host-mounted config:
|
||||
|
||||
Bind-mounted directories must exist on the host before starting the container. Docker creates missing directories as root-owned, which causes permission issues.
|
||||
- **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.
|
||||
|
||||
```bash
|
||||
# Required: workspace for your projects
|
||||
mkdir -p ~/projects
|
||||
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.
|
||||
|
||||
# If mounting opencode config (recommended for persistent settings)
|
||||
mkdir -p ~/.config/opencode
|
||||
```
|
||||
## Persistence
|
||||
|
||||
### Connecting to the container
|
||||
|
||||
From your laptop, SSH into the remote server where Docker is running, then start the container:
|
||||
|
||||
```bash
|
||||
# 1. SSH into the remote server
|
||||
ssh user@remote-server
|
||||
|
||||
# 2. Navigate to the project
|
||||
cd opencode-devbox
|
||||
|
||||
# 3. Start the container with an interactive shell
|
||||
docker compose run --rm devbox bash
|
||||
|
||||
# You're now inside the container — run commands here
|
||||
aws sso login --sso-session <your-sso-session> --use-device-code
|
||||
opencode
|
||||
```
|
||||
|
||||
### Running modes
|
||||
|
||||
**Interactive shell** — enter the container, run multiple commands:
|
||||
```bash
|
||||
docker compose run --rm devbox bash
|
||||
```
|
||||
|
||||
**Direct to opencode** — skips the shell, launches opencode immediately:
|
||||
```bash
|
||||
docker compose run --rm devbox
|
||||
```
|
||||
|
||||
**Background container** — keep it running, attach when needed:
|
||||
```bash
|
||||
# Start in background
|
||||
docker compose up -d
|
||||
|
||||
# Attach a shell to the running container
|
||||
docker compose exec -u developer devbox bash
|
||||
|
||||
# Or run a single command inside it
|
||||
docker compose exec -u developer devbox aws --version
|
||||
```
|
||||
|
||||
> `run` creates a new container (cleaned up with `--rm`). `exec` attaches to an already running one.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| Volume | Mount | Survives |
|
||||
|---|---|---|
|
||||
| `OPENCODE_PROVIDER` | LLM provider (`anthropic`, `openai`, `amazon-bedrock`) | `anthropic` |
|
||||
| `OPENCODE_MODEL` | Model override | Provider default |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic API key | — |
|
||||
| `OPENAI_API_KEY` | OpenAI API key | — |
|
||||
| `AWS_REGION` | AWS region for Bedrock | `us-east-1` |
|
||||
| `AWS_PROFILE` | AWS SSO profile name | `default` |
|
||||
| `GIT_USER_NAME` | Git commit author name | — |
|
||||
| `GIT_USER_EMAIL` | Git commit author email | — |
|
||||
| `WORKSPACE_PATH` | Host path to mount | `.` |
|
||||
| `SSH_KEY_PATH` | Host SSH key directory | `~/.ssh` |
|
||||
| `USER_UID` | Override container user UID | Auto-detect from `/workspace` |
|
||||
| `USER_GID` | Override container user GID | Auto-detect from `/workspace` |
|
||||
| `LANG` | System locale | `en_US.UTF-8` |
|
||||
| `LANGUAGE` | Language priority list | `en_US:en` |
|
||||
| `LC_ALL` | Override all locale settings | `en_US.UTF-8` |
|
||||
| `EDITOR` | Default text editor | `nvim` |
|
||||
| `ENABLE_OMOS` | Enable oh-my-opencode-slim multi-agent orchestration | `false` |
|
||||
| `OMOS_TMUX` | Enable tmux pane integration for OMOS | `false` |
|
||||
| `OMOS_SKILLS` | Install OMOS recommended skills on first run | `true` |
|
||||
| `OMOS_RESET` | Force regenerate OMOS config on next start | `false` |
|
||||
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild |
|
||||
| `devbox-pi-config` | `~/.pi` | container recreate, image rebuild — incl. user-installed pi packages via `pi install` (`NPM_CONFIG_PREFIX` points into the volume) |
|
||||
| `devbox-palace` (uncomment) | `~/.mempalace` | container recreate, image rebuild — palace data is precious, treat as primary storage |
|
||||
| `devbox-chroma-cache` | `~/.cache/chroma` | container recreate (model cache, disposable — re-downloads in seconds) |
|
||||
|
||||
### Custom opencode config
|
||||
Workspace bind-mount (`/workspace`) is your project directory on the host, so source code is never inside the container.
|
||||
|
||||
For full control over opencode settings (MCP servers, custom models, and — on the OMOS variant — oh-my-opencode-slim agents), mount the entire config directory from the host:
|
||||
Full persistence reference, including multi-user (`SIGNUM`) isolation and host bind-mount alternatives: see the [README on gitea](https://gitea.jordbo.se/joakimp/opencode-devbox#persistence).
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ~/.config/opencode:/home/developer/.config/opencode
|
||||
```
|
||||
## Where to Go Next
|
||||
|
||||
This persists all configuration changes across container restarts, including `opencode.json`, skills, and (on the OMOS variant) `oh-my-opencode-slim.json`. When an existing `opencode.json` is found, the `OPENCODE_PROVIDER` auto-config is skipped.
|
||||
- **Full README** with build args, every feature in detail, troubleshooting: <https://gitea.jordbo.se/joakimp/opencode-devbox>
|
||||
- **CHANGELOG** for version history: <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/CHANGELOG.md>
|
||||
- **Issues / source / docker-compose templates:** <https://gitea.jordbo.se/joakimp/opencode-devbox>
|
||||
- **Agent-facing internals** (for future maintainers / coding agents working in the repo): <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/AGENTS.md>
|
||||
|
||||
> **Portability note:** The mounted config runs inside a Linux container. Any absolute paths inside `opencode.json` (for example, host-specific `plugin` entries like `file:///usr/local/lib/node_modules/...` or `file:///opt/homebrew/...`) will not resolve inside the container. Prefer bare package specifiers (e.g. `"oh-my-opencode-slim"`) that resolve via `node_modules` lookup, which works on both macOS and Linux hosts.
|
||||
## License
|
||||
|
||||
### Custom skills
|
||||
MIT. See <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/LICENSE>.
|
||||
|
||||
Mount agent skills from the host:
|
||||
---
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ~/.agents/skills:/home/developer/.agents/skills:ro
|
||||
```
|
||||
|
||||
### Neovim configuration
|
||||
|
||||
The image includes neovim 0.12 with `EDITOR=nvim` set by default. To use your own neovim config (and have plugins auto-install via lazy.nvim on first start), mount it from the host:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ~/.config/nvim:/home/developer/.config/nvim:ro
|
||||
```
|
||||
|
||||
### Python development with uv
|
||||
|
||||
The image includes Python 3.13 (from Debian Trixie) and [uv](https://docs.astral.sh/uv/), a fast Python package manager that replaces pip, venv, and pyenv:
|
||||
|
||||
```bash
|
||||
# Python 3.13 is available out of the box
|
||||
python3 --version
|
||||
|
||||
# Use uv for package management
|
||||
uv venv
|
||||
uv pip install -r requirements.txt
|
||||
|
||||
# Or use uv's project workflow (reads pyproject.toml)
|
||||
uv sync
|
||||
|
||||
# Run a Python script
|
||||
uv run python script.py
|
||||
|
||||
# Install standalone Python tools
|
||||
uvx ruff check .
|
||||
|
||||
# Install a newer Python version (persists with devbox-uv volume)
|
||||
uv python install 3.14
|
||||
```
|
||||
|
||||
Python installations are stored in `~/.local/share/uv/`. To persist them across container restarts, add the `devbox-uv` named volume to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- devbox-uv:/home/developer/.local/share/uv
|
||||
|
||||
volumes:
|
||||
devbox-uv:
|
||||
```
|
||||
|
||||
Project virtual environments (`.venv`) are stored in your workspace directory and persist automatically via the `/workspace` bind mount.
|
||||
|
||||
### Rust development with rustup
|
||||
|
||||
The image includes `rustup-init`, the Rust toolchain installer. Rust is not pre-installed but can be bootstrapped on demand:
|
||||
|
||||
```bash
|
||||
# One-time setup: install Rust toolchain (~300MB, persists with volumes)
|
||||
rustup-init -y
|
||||
source ~/.cargo/env
|
||||
|
||||
# Now use Rust normally
|
||||
cargo new my-project
|
||||
cargo build
|
||||
cargo run
|
||||
```
|
||||
|
||||
To persist Rust toolchains and cargo data across container restarts, add named volumes to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- devbox-rustup:/home/developer/.rustup
|
||||
- devbox-cargo:/home/developer/.cargo
|
||||
|
||||
volumes:
|
||||
devbox-rustup:
|
||||
devbox-cargo:
|
||||
```
|
||||
|
||||
### JavaScript and TypeScript
|
||||
|
||||
The base image includes **Node.js 22** and **npm** — sufficient for most JavaScript and TypeScript development:
|
||||
|
||||
```bash
|
||||
# Initialize a new project
|
||||
npm init -y
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run TypeScript (via tsx, ts-node, etc.)
|
||||
npx tsx src/index.ts
|
||||
|
||||
# Use npx for one-off tools
|
||||
npx tsc --init
|
||||
```
|
||||
|
||||
The OMOS image variant also includes **Bun**, a faster JavaScript runtime and package manager:
|
||||
|
||||
```bash
|
||||
bun init
|
||||
bun install
|
||||
bun run src/index.ts
|
||||
```
|
||||
|
||||
Node modules are stored in your project directory under `/workspace` and persist automatically.
|
||||
|
||||
### VS Code integration
|
||||
|
||||
VS Code can connect directly to a running opencode-devbox container for a full IDE experience with IntelliSense, debugging, and extensions running inside the container.
|
||||
|
||||
**Local Docker (Docker running on your workstation):**
|
||||
|
||||
1. Install the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension
|
||||
2. Start the container: `docker compose up -d`
|
||||
3. In VS Code: `Ctrl+Shift+P` → "Dev Containers: Attach to Running Container" → select `opencode-devbox`
|
||||
|
||||
**Remote Docker (Docker running on a remote server, e.g. via SSH):**
|
||||
|
||||
1. Install the [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) and [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extensions
|
||||
2. Connect to the remote host: `Ctrl+Shift+P` → "Remote-SSH: Connect to Host"
|
||||
3. On the remote host, start the container: `docker compose up -d`
|
||||
4. In VS Code (now connected to the remote): `Ctrl+Shift+P` → "Dev Containers: Attach to Running Container"
|
||||
|
||||
VS Code extensions installed inside the container persist as long as the container exists (not removed with `docker compose down`). For persistent extension storage across container recreations, add a named volume:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- devbox-vscode:/home/developer/.vscode-server
|
||||
```
|
||||
|
||||
## oh-my-opencode-slim (Multi-Agent Orchestration)
|
||||
|
||||
[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) adds a multi-agent layer on top of opencode — an Orchestrator delegates tasks to specialized agents (Explorer, Oracle, Librarian, Designer, Fixer), each configurable with different models and providers.
|
||||
|
||||
### Setup
|
||||
|
||||
A pre-built OMOS image is available on Docker Hub as `joakimp/opencode-devbox:latest-omos`. Alternatively, build from source:
|
||||
|
||||
**1. Build the image with OMOS support:**
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg INSTALL_OMOS=true
|
||||
```
|
||||
|
||||
This installs Bun and the oh-my-opencode-slim package into the image.
|
||||
|
||||
**2. Enable in `.env`:**
|
||||
|
||||
```bash
|
||||
ENABLE_OMOS=true
|
||||
```
|
||||
|
||||
**3. Run as normal:**
|
||||
|
||||
```bash
|
||||
docker compose run --rm devbox
|
||||
```
|
||||
|
||||
On first start, the entrypoint runs the oh-my-opencode-slim installer in non-interactive mode. It generates agent configuration at `~/.config/opencode/oh-my-opencode-slim.json` inside the container. The default preset uses OpenAI models — edit the generated config or mount your own to customize.
|
||||
|
||||
### OMOS Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `ENABLE_OMOS` | `false` | Activate oh-my-opencode-slim on container start |
|
||||
| `OMOS_TMUX` | `false` | Enable tmux pane integration (tmux is included in the base image) |
|
||||
| `OMOS_SKILLS` | `true` | Install recommended skills (simplify, agent-browser, cartography) |
|
||||
| `OMOS_RESET` | `false` | Force regenerate config on next start (backs up existing config) |
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
If you mount the opencode config directory (see Custom opencode config above), the `oh-my-opencode-slim.json` file is included and persists across restarts. Edit it directly to control which models power each agent, fallback chains, council setup, and more.
|
||||
|
||||
See the [oh-my-opencode-slim configuration docs](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/docs/configuration.md) for the full reference.
|
||||
|
||||
### Verifying Agents
|
||||
|
||||
After starting opencode with OMOS enabled, run inside the opencode session:
|
||||
|
||||
```
|
||||
ping all agents
|
||||
```
|
||||
|
||||
All six agents should respond if your provider authentication is working.
|
||||
|
||||
## AWS Bedrock Authentication
|
||||
|
||||
When using AWS Bedrock as your LLM provider, you need:
|
||||
|
||||
### 1. AWS config on the host
|
||||
|
||||
The container needs access to your `~/.aws/config` with SSO session configuration. If you already have this on another machine, copy it:
|
||||
|
||||
```bash
|
||||
scp -r user@other-machine:~/.aws ~/.aws
|
||||
```
|
||||
|
||||
Or configure from scratch on the host:
|
||||
|
||||
```bash
|
||||
aws configure sso
|
||||
```
|
||||
|
||||
### 2. Mount `~/.aws` into the container
|
||||
|
||||
Uncomment the AWS volume mount in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
- ~/.aws:/home/developer/.aws
|
||||
```
|
||||
|
||||
Note: do **not** use `:ro` — SSO writes token cache files to this directory.
|
||||
|
||||
### 3. Authenticate inside the container
|
||||
|
||||
Since the container runs headless (no browser), use the device-code flow:
|
||||
|
||||
```bash
|
||||
# Start the container
|
||||
docker compose up -d
|
||||
docker compose exec -u developer devbox bash
|
||||
|
||||
# Authenticate — prints a URL and code you open in your local browser
|
||||
aws sso login --sso-session <your-sso-session> --use-device-code
|
||||
|
||||
# Once approved in the browser, start opencode
|
||||
opencode
|
||||
```
|
||||
|
||||
The `--use-device-code` flag outputs a URL and short code instead of trying to open a browser. Copy the URL into any browser (on your laptop, phone, etc.), enter the code, and complete the 2FA flow. The CLI in the container picks up the session automatically.
|
||||
|
||||
SSO sessions typically last 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.json` (inside `~/.config/opencode/`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"mempalace": {
|
||||
"type": "local",
|
||||
"command": ["mempalace-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> The image installs mempalace into an isolated `uv tool` venv at `/opt/uv-tools/mempalace/`. `uv tool install` places `mempalace-mcp` on `PATH` as a shim whose shebang points at the venv's Python, so MCP clients can invoke it as a normal binary without worrying about the venv. Do **not** use `["python3", "-m", "mempalace.mcp_server"]` — the system Python cannot import from the uv-managed venv and you'll get `ModuleNotFoundError` / `MCP error -32000: connection closed`.
|
||||
|
||||
This gives opencode access to 29 MCP tools for searching memory, querying the knowledge graph, managing wings/rooms/drawers, and agent diaries.
|
||||
|
||||
### Basic usage
|
||||
|
||||
```bash
|
||||
# Mine project files into the palace
|
||||
mempalace mine /workspace
|
||||
|
||||
# Mine conversation transcripts
|
||||
mempalace mine ~/.local/share/opencode/ --mode convos
|
||||
|
||||
# Search memory
|
||||
mempalace search "why did we switch to eno1"
|
||||
|
||||
# Load context for a new session
|
||||
mempalace wake-up
|
||||
```
|
||||
|
||||
Each workspace gets its own isolated "wing" — memories never leak between projects.
|
||||
|
||||
### Scheduled mining (mempalace-toolkit)
|
||||
|
||||
The image bakes in [mempalace-toolkit](https://gitea.jordbo.se/joakimp/mempalace-toolkit), a small set of bash wrappers that pair with mempalace for two common routines:
|
||||
|
||||
```bash
|
||||
# Mine opencode session history (reads ~/.local/share/opencode/opencode.db, stages JSONL, mines into wing_conversations)
|
||||
mempalace-session
|
||||
|
||||
# Mine a project's docs into a dedicated wing
|
||||
mempalace-docs /workspace/my-project
|
||||
```
|
||||
|
||||
Both wrappers are idempotent and dedup-aware — re-running them on unchanged input is a cheap no-op.
|
||||
|
||||
For weekly automated runs, the toolkit ships ready-to-use scheduler templates (systemd user timer, launchd user agent, cron) in its [`contrib/`](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/contrib) directory. The `*-devbox` variants are designed for this container: host-side schedulers that `docker exec` into the running opencode-devbox.
|
||||
|
||||
Disable the toolkit (keeps mempalace itself) with `--build-arg INSTALL_MEMPALACE_TOOLKIT=false`. Pin to a specific ref with `--build-arg MEMPALACE_TOOLKIT_REF=v0.3.0` once tagged releases exist.
|
||||
|
||||
### Storage
|
||||
|
||||
Two separate named volumes keep different data classes apart:
|
||||
|
||||
- **Palace data** (`~/.mempalace/`): ChromaDB vectors, SQLite knowledge graph, drawers. This is your memory — back it up, treat it as precious. Persists via the `devbox-palace` named volume.
|
||||
- **Embedding model cache** (`~/.cache/chroma/`): ONNX model (~79 MB), downloaded automatically on first search. Disposable — blow it away and it re-downloads in ~4 seconds. Persists via the `devbox-chroma-cache` named volume so you don't re-download on every container recreation.
|
||||
- **No API keys required** for core functionality (local embeddings via ONNX).
|
||||
|
||||
Both volumes are commented out by default in `docker-compose.yml` — uncomment to enable:
|
||||
|
||||
```yaml
|
||||
- devbox-palace:/home/developer/.mempalace
|
||||
- devbox-chroma-cache:/home/developer/.cache/chroma
|
||||
```
|
||||
|
||||
**Air-gapped environments:** pre-populate the `devbox-chroma-cache` volume with the `all-MiniLM-L6-v2/` model contents. The palace volume needs no pre-population.
|
||||
|
||||
## Gitea MCP server
|
||||
|
||||
The image includes the [official Gitea MCP server](https://gitea.com/gitea/gitea-mcp) (`gitea-mcp`), providing 50+ MCP tools for interacting with self-hosted Gitea instances — repositories, issues, pull requests, releases, branches, wiki, and Actions.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Create a Personal Access Token on your Gitea instance (Settings → Applications → Generate Token, scopes: `repo`, `read:user`).
|
||||
|
||||
2. Add to your `.env`:
|
||||
```env
|
||||
GITEA_HOST=https://your-gitea-instance.example.com
|
||||
GITEA_ACCESS_TOKEN=your_token_here
|
||||
```
|
||||
|
||||
3. Enable the gitea MCP server in your `opencode.json`:
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"gitea": {
|
||||
"type": "local",
|
||||
"command": ["gitea-mcp", "-t", "stdio", "--host", "{env:GITEA_HOST}"],
|
||||
"environment": {
|
||||
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The server is installed but disabled by default — it requires authentication to be useful.
|
||||
|
||||
## Shell defaults
|
||||
|
||||
The image ships a baked `.bash_aliases` and `.inputrc` with quality-of-life defaults. On first container start they are copied from `/etc/skel-devbox/` into `/home/developer/` **only if the target file does not already exist** — so host bind-mounts and any version you've customized inside the container are never overwritten on upgrade.
|
||||
|
||||
Defaults you get out of the box:
|
||||
|
||||
- **Prefix history search** on Up/Down arrows (type `git `, press Up, walk back through prior `git ...` commands only). Ctrl-Up / Ctrl-Down still step through full history.
|
||||
- **Persistent history** — `$HISTFILE` points at `~/.cache/bash/history`, backed by the `devbox-shell-history` named volume so history survives container recreation. Timestamps, 100 000 entries, dedup.
|
||||
- **Case-insensitive tab completion**, coloured completion lists, `show-all-if-ambiguous`.
|
||||
- **Aliases** — `ls`/`ll`/`la` use `eza`, `cat` uses `bat`, `gs`/`gd`/`gl` for git, safe `rm`/`mv`/`cp`.
|
||||
- **Integrations** — `zoxide` (`z <fragment>` to jump), `fzf` Ctrl-R / Ctrl-T key bindings.
|
||||
- **Prompt marker** — `[devbox]` prefix so it's always obvious you're inside the container.
|
||||
|
||||
### Overriding the defaults
|
||||
|
||||
**Option A — bind-mount host files.** Uncomment the bind-mount lines in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
- ~/.bash_aliases:/home/developer/.bash_aliases:ro
|
||||
- ~/.inputrc:/home/developer/.inputrc:ro
|
||||
```
|
||||
|
||||
> **Single-file bind-mount caveat (all platforms):** Docker bind-mounts the file's **inode**, not its path. When editors like vim, nvim, VS Code, or `sed -i` save a file, they write to a temp file and `rename()` it over the original — creating a new inode. The container stays pinned to the old (now unlinked) inode and never sees the update. This is a kernel limitation ([Docker #15793](https://github.com/moby/moby/issues/15793)), not fixable by Docker. Append-only writes (`echo "alias foo=bar" >> file`) are safe because they modify the same inode. **Workaround:** mount the parent directory instead of the single file (e.g. `~/.config/devbox-shell:/home/developer/.config/devbox-shell:ro`) and source files from there.
|
||||
|
||||
**Option B — customize inside the container.** Just edit `~/.bash_aliases` or `~/.inputrc` as normal. Pair this with a bind-mount or named volume on the home dir if you want the edits to survive container recreation.
|
||||
|
||||
### Restoring or diffing defaults
|
||||
|
||||
The skel files remain available inside every container at `/etc/skel-devbox/`. Useful commands:
|
||||
|
||||
```bash
|
||||
# See what the image currently ships
|
||||
cat /etc/skel-devbox/.bash_aliases
|
||||
|
||||
# Diff your current config against the upstream defaults
|
||||
diff ~/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
|
||||
# Reset to the baked defaults
|
||||
cp /etc/skel-devbox/.bash_aliases ~/.bash_aliases
|
||||
|
||||
# …or delete the file and recreate the container — the entrypoint
|
||||
# copies from /etc/skel-devbox/ on next start if the target is absent
|
||||
rm ~/.bash_aliases
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Host Machine
|
||||
├── ~/projects/my-app ──bind mount──▶ /workspace (container)
|
||||
├── ~/.ssh ──bind mount──▶ /home/developer/.ssh (ro)
|
||||
├── ~/.aws ──bind mount──▶ /home/developer/.aws (Bedrock SSO)
|
||||
└── .env ──env vars───▶ provider config + API keys
|
||||
|
||||
Container (Debian trixie)
|
||||
├── opencode binary
|
||||
├── oh-my-opencode-slim (optional — multi-agent orchestration plugin, includes Bun)
|
||||
├── AWS CLI v2 (SSO + Bedrock auth)
|
||||
├── neovim 0.12, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
|
||||
├── git, git-crypt, age, ssh, ripgrep, fd, fzf, jq, curl, tree
|
||||
├── Node.js (for MCP servers)
|
||||
├── Bun (optional — included with oh-my-opencode-slim)
|
||||
├── entrypoint.sh (UID adjustment, git config, provider setup)
|
||||
└── /workspace ← your code lives here
|
||||
```
|
||||
|
||||
### Data persistence
|
||||
|
||||
| Path in container | Source | Survives `--rm`? | Contains |
|
||||
|---|---|---|---|
|
||||
| `/workspace` | Host bind mount | ✅ Yes | Your project files |
|
||||
| `/home/developer/.ssh` | Host bind mount (ro) | ✅ Yes | SSH keys |
|
||||
| `/home/developer/.aws` | Host bind mount (if configured) | ✅ Yes | AWS credentials/SSO cache |
|
||||
| `/home/developer/.local/share/opencode` | Named volume `devbox-data` | ✅ Yes | Session history, memory |
|
||||
| `/home/developer/.local/state/opencode` | Named volume `devbox-state` | ✅ Yes | TUI settings (theme, toggles) |
|
||||
| `/home/developer/.cache/bash` | Named volume `devbox-shell-history` | ✅ Yes | Bash history (`$HISTFILE`), survives container recreate |
|
||||
| `/home/developer/.local/share/zoxide` | Named volume `devbox-zoxide` | ✅ Yes | Zoxide directory history (`z <fragment>` jump targets) |
|
||||
| `/home/developer/.local/share/nvim` | Named volume `devbox-nvim-data` | ✅ Yes | Neovim plugins, Mason LSP installs, Lazy plugin cache |
|
||||
| `/home/developer/.local/share/uv` | Named volume `devbox-uv` (if configured) | ✅ Yes | Python installs, uv tool installs |
|
||||
| `/home/developer/.rustup` | Named volume `devbox-rustup` (if configured) | ✅ Yes | Rust toolchains |
|
||||
| `/home/developer/.cargo` | Named volume `devbox-cargo` (if configured) | ✅ Yes | Cargo binaries, registry cache |
|
||||
| `/home/developer/.vscode-server` | Named volume `devbox-vscode` (if configured) | ✅ Yes | VS Code server and extensions |
|
||||
| `/home/developer/.config/opencode` | Host bind mount (if configured) | ✅ Yes | opencode.json, skills, plus `oh-my-opencode-slim.json` on the OMOS variant |
|
||||
|
||||
**opencode config** (`opencode.json`) is auto-generated from `OPENCODE_PROVIDER` on each start. It sets provider and model only — no MCP servers. To persist config changes and use custom settings, mount the config directory from the host (see Custom opencode config above).
|
||||
|
||||
## Source
|
||||
|
||||
MIT licensed. Source, issues, and `docker-compose.yml` templates: <https://gitea.jordbo.se/joakimp/opencode-devbox>
|
||||
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
|
||||
|
||||
+103
-5
@@ -5,7 +5,7 @@ ARG DEBIAN_VERSION=trixie-slim
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG OPENCODE_VERSION=1.14.31
|
||||
ARG OPENCODE_VERSION=1.14.42
|
||||
|
||||
LABEL maintainer="joakimp"
|
||||
LABEL description="Portable opencode developer container"
|
||||
@@ -271,9 +271,54 @@ RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesour
|
||||
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
|
||||
# v1.x is distributed as an npm package with platform-specific binaries.
|
||||
# Disable with --build-arg INSTALL_OPENCODE=false to build a slimmer
|
||||
# image without opencode (e.g. when only pi is needed). For a fully
|
||||
# pi-only stripped image (no Bun, no opencode), see the pi-devbox repo.
|
||||
ARG INSTALL_OPENCODE=true
|
||||
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
|
||||
npm install -g opencode-ai@${OPENCODE_VERSION} && \
|
||||
opencode --version ; \
|
||||
fi
|
||||
|
||||
# ── Optional: pi coding-agent ────────────────────────────────────────
|
||||
# Installs pi as an alternative/complementary harness. Coexists with
|
||||
# opencode in the same image — both share the mempalace install and
|
||||
# palace path, so wing data is mutually visible to either harness.
|
||||
#
|
||||
# pi-toolkit (keybindings.json + pi-env.zsh + settings.example.json)
|
||||
# and pi-extensions (confirm-destructive, ext-toggle, git-checkpoint,
|
||||
# notify, ssh-controlmaster, todo, …) are cloned into /opt/ at build
|
||||
# time. entrypoint-user.sh runs each repo's install.sh on container
|
||||
# start so symlinks land under ~/.pi/agent/ on the named volume.
|
||||
#
|
||||
# Pi version is pinned by PI_VERSION (default: latest at build time).
|
||||
# The baked pi binary lives at /usr/bin/pi (system npm prefix); the
|
||||
# user-writable NPM_CONFIG_PREFIX (~/.pi/npm-global, set further down)
|
||||
# is only consulted by `pi install npm:<pkg>` and `npm install -g` at
|
||||
# runtime — it does NOT shadow the baked pi unless the user does
|
||||
# `npm install -g @earendil-works/pi-coding-agent` themselves, in which
|
||||
# case the user-installed copy on the volume wins via PATH order. Same
|
||||
# contract as OPENCODE_VERSION otherwise: rebuild the image to upgrade
|
||||
# the baked pi.
|
||||
ARG INSTALL_PI=false
|
||||
ARG PI_VERSION=latest
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
RUN if [ "${INSTALL_PI}" = "true" ]; then \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git clone --depth 1 --branch "${PI_TOOLKIT_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/pi-toolkit.git /opt/pi-toolkit && \
|
||||
git clone --depth 1 --branch "${PI_EXTENSIONS_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/pi-extensions.git /opt/pi-extensions && \
|
||||
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
||||
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
|
||||
RUN ARCH=$(case "${TARGETARCH}" in \
|
||||
@@ -342,14 +387,60 @@ RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
|
||||
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
|
||||
|
||||
# Create standard directories
|
||||
#
|
||||
# ~/.pi/agent/extensions/ is created proactively so the named volume
|
||||
# mount has a real owner from the first start. The directory is also
|
||||
# what mempalace-toolkit's install_pi_extension probes to decide
|
||||
# whether to deploy the pi↔mempalace bridge — must exist before that
|
||||
# step runs in entrypoint-user.sh.
|
||||
RUN mkdir -p /workspace \
|
||||
/home/${USER_NAME}/.config/opencode/skills \
|
||||
/home/${USER_NAME}/.pi/agent/extensions \
|
||||
/home/${USER_NAME}/.agents/skills \
|
||||
/home/${USER_NAME}/.local/share/opencode \
|
||||
/home/${USER_NAME}/.cache/bash \
|
||||
/home/${USER_NAME}/.ssh && \
|
||||
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
|
||||
|
||||
# ── Pre-warm chromadb embedding model ──────────────────────────────
|
||||
# Mempalace uses chromadb's ONNXMiniLM_L6_V2 embedding function, which
|
||||
# downloads ~80 MB of all-MiniLM-L6-v2 ONNX weights from chromadb's CDN
|
||||
# on first use. Without pre-warming this happens silently (output is
|
||||
# suppressed by the entrypoint init step) and stalls first container
|
||||
# start by minutes on a slow network. We bake the cache at build time
|
||||
# under the developer user's home so the runtime first-start is fast.
|
||||
#
|
||||
# Cache path comes from chromadb's hardcoded `Path.home() / .cache /
|
||||
# chroma / onnx_models / all-MiniLM-L6-v2`. Run as gosu developer so
|
||||
# Path.home() resolves correctly and ownership is right from the start.
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\
|
||||
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \
|
||||
ef = ONNXMiniLM_L6_V2(); \
|
||||
_ = ef(['warmup']); \
|
||||
print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
|
||||
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
|
||||
fi
|
||||
|
||||
# ── User-writable npm global prefix on the devbox-pi-config volume ──
|
||||
# By default npm's global prefix is /usr (writable only by root) so any
|
||||
# `pi install npm:<pkg>` or `npm install -g <pkg>` invoked by the
|
||||
# developer user would EACCES. Pointing the prefix into ~/.pi places
|
||||
# user-installed packages on the named volume, which means they survive
|
||||
# container recreation AND image rebuilds (complementing pi's auto-
|
||||
# restore from settings.json with one less cold-start step).
|
||||
#
|
||||
# These ENVs land AFTER all build-time `npm install -g` calls
|
||||
# (opencode, pi, oh-my-opencode-slim) so those still install to /usr at
|
||||
# build time. They take effect for every runtime invocation regardless
|
||||
# of shell init: docker compose run/exec, login shells, non-interactive
|
||||
# commands. npm auto-creates the prefix directory on first install.
|
||||
#
|
||||
# Harmless when INSTALL_PI=false (and no named volume mounted at ~/.pi):
|
||||
# the dir just lives on the container's writable layer.
|
||||
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
|
||||
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
||||
|
||||
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
||||
# Shipped under /etc/skel-devbox/ rather than copied directly to the
|
||||
# user's home. The entrypoint copies them to /home/developer/ only if
|
||||
@@ -374,4 +465,11 @@ RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
||||
WORKDIR /workspace
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["opencode"]
|
||||
# Default to a login shell. `docker compose run --rm devbox` drops
|
||||
# the user into bash to choose: `aws sso login`, then `opencode`
|
||||
# or `pi`. To launch a harness directly, pass it explicitly:
|
||||
# docker compose run --rm devbox opencode
|
||||
# docker compose run --rm devbox pi
|
||||
# `docker compose exec` bypasses the entrypoint and CMD entirely, so
|
||||
# this default has no effect on attach-style workflows.
|
||||
CMD ["bash", "-l"]
|
||||
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
# 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.
|
||||
#
|
||||
# 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,103 @@
|
||||
# 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.14.42
|
||||
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 \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git clone --depth 1 --branch "${PI_TOOLKIT_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/pi-toolkit.git /opt/pi-toolkit && \
|
||||
git clone --depth 1 --branch "${PI_EXTENSIONS_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/pi-extensions.git /opt/pi-extensions && \
|
||||
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.
|
||||
@@ -49,9 +49,6 @@ Bind-mounted directories must exist on the host before starting the container. D
|
||||
```bash
|
||||
# Required: workspace for your projects
|
||||
mkdir -p ~/projects
|
||||
|
||||
# If mounting opencode config (recommended for persistent settings)
|
||||
mkdir -p ~/.config/opencode
|
||||
```
|
||||
|
||||
### Connecting to the container
|
||||
@@ -125,28 +122,34 @@ docker compose exec -u developer devbox aws --version
|
||||
| `OMOS_TMUX` | Enable tmux pane integration for OMOS | `false` |
|
||||
| `OMOS_SKILLS` | Install OMOS recommended skills on first run | `true` |
|
||||
| `OMOS_RESET` | Force regenerate OMOS config on next start | `false` |
|
||||
| `SKILLSET_CONTAINER_PATH` | Path to skillset repo inside container (for auto-deploy when not at /workspace/skillset) | Auto-detect |
|
||||
|
||||
### Custom opencode config
|
||||
|
||||
For full control over opencode settings (MCP servers, custom models, and — on the OMOS 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
|
||||
volumes:
|
||||
- ~/.config/opencode:/home/developer/.config/opencode
|
||||
```
|
||||
|
||||
This persists all configuration changes across container restarts, including `opencode.json`, skills, and (on the OMOS variant) `oh-my-opencode-slim.json`. When an existing `opencode.json` is found, the `OPENCODE_PROVIDER` auto-config is skipped.
|
||||
|
||||
> **Portability note:** The mounted config runs inside a Linux container. Any absolute paths inside `opencode.json` (for example, host-specific `plugin` entries like `file:///usr/local/lib/node_modules/...` or `file:///opt/homebrew/...`) will not resolve inside the container. Prefer bare package specifiers (e.g. `"oh-my-opencode-slim"`) that resolve via `node_modules` lookup, which works on both macOS and Linux hosts.
|
||||
> **Portability note:** The mounted config runs inside a Linux container. Any absolute paths inside `opencode.jsonc` (for example, host-specific `plugin` entries like `file:///usr/local/lib/node_modules/...` or `file:///opt/homebrew/...`) will not resolve inside the container. Prefer bare package specifiers (e.g. `"oh-my-opencode-slim"`) that resolve via `node_modules` lookup, which works on both macOS and Linux hosts.
|
||||
|
||||
### Custom skills
|
||||
|
||||
Mount agent skills from the host:
|
||||
Skills are deployed automatically from a skillset repo on container start. The entrypoint detects the skillset location in this order:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ~/.agents/skills:/home/developer/.agents/skills:ro
|
||||
```
|
||||
1. `SKILLSET_CONTAINER_PATH` env var (explicit path to skillset repo inside container)
|
||||
2. `~/skillset` mount (if present)
|
||||
3. `/workspace/skillset` fallback (if your workspace contains a `skillset/` directory)
|
||||
|
||||
When a skillset repo is detected, its skills are symlinked into `~/.agents/skills/` automatically. No manual configuration needed.
|
||||
|
||||
> **Warning:** Do not bind-mount a host `~/.agents/skills` directory directly into the container. This conflicts with the symlink-based auto-deploy mechanism and causes broken skill references.
|
||||
|
||||
### Neovim configuration
|
||||
|
||||
@@ -294,9 +297,6 @@ cd ~/<signum>/opencode-devbox
|
||||
cp /path/to/opencode-devbox/docker-compose.shared.yml docker-compose.yml
|
||||
cp /path/to/opencode-devbox/.env.shared.example .env
|
||||
|
||||
# Create per-user config directory
|
||||
mkdir -p ~/<signum>/.config/opencode
|
||||
|
||||
# Edit .env — set SIGNUM only if you're in shared-account mode
|
||||
vim .env
|
||||
|
||||
@@ -308,7 +308,7 @@ docker compose exec -u developer devbox opencode
|
||||
Each user's container, config, and named volumes are fully isolated:
|
||||
- Container name: `devbox-<signum>` (or `devbox-$USER` in own-account mode)
|
||||
- Named volumes: prefixed with the project name (`devbox-<signum>_devbox-data`, etc.) — the Docker daemon is system-wide, so directory-name prefixing alone is NOT sufficient for isolation
|
||||
- Opencode config: `~/<signum>/.config/opencode/` (per-user settings, OMOS config, etc.)
|
||||
- Opencode config: persisted via per-user named volume (`devbox-<signum>_devbox-opencode-config`)
|
||||
|
||||
See `docker-compose.shared.yml` and `.env.shared.example` for the full configuration.
|
||||
|
||||
@@ -341,6 +341,10 @@ docker compose build --build-arg NVIM_VERSION=0.12.1 # pin to a specific versi
|
||||
| `INSTALL_MEMPALACE` | `true` | [MemPalace](https://github.com/MemPalace/mempalace) local AI memory system (~300 MB — disable to shrink image if you don't need MCP memory) |
|
||||
| `INSTALL_MEMPALACE_TOOLKIT` | `true` | [mempalace-toolkit](https://gitea.jordbo.se/joakimp/mempalace-toolkit) bash wrappers (`mempalace-session`, `mempalace-docs`). Cloned at build time from `MEMPALACE_TOOLKIT_REF` (default `main`). Requires `INSTALL_MEMPALACE=true`. |
|
||||
| `INSTALL_OMOS` | `false` | [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration (installs Bun and plugin) |
|
||||
| `INSTALL_OPENCODE` | `true` | Install opencode. Set `false` to build a pi-only image (still includes Bun if `INSTALL_OMOS=true`; for a fully stripped pi-only image see the `pi-devbox` repo). |
|
||||
| `INSTALL_PI` | `false` | Install [pi](https://github.com/earendil-works/pi) as alternative/complementary harness. Both clones [pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit) (~5 MB) and [pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions) (~1 MB) into `/opt/`; entrypoint deploys them on container start. ~150 MB total image growth. |
|
||||
| `PI_VERSION` | `latest` | npm version of `@earendil-works/pi-coding-agent`. Floats by default (image rebuild = pi update). |
|
||||
| `PI_TOOLKIT_REF`, `PI_EXTENSIONS_REF` | `main` | Git refs for the toolkit/extensions clones. Pin to a tag/commit for reproducibility. |
|
||||
| `OPENCODE_VERSION` | *(pinned per release)* | opencode npm version. Drives the image tag and is intentionally not floated. |
|
||||
| `NODE_VERSION` | `22` | Node.js major version. Pinned to protect against upstream breaking changes across majors. |
|
||||
| `GOSU_VERSION`, `FZF_VERSION`, `GIT_LFS_VERSION`, `NVIM_VERSION`, `BAT_VERSION`, `EZA_VERSION`, `ZOXIDE_VERSION`, `UV_VERSION`, `GITEA_MCP_VERSION`, `GO_VERSION`, `OMOS_VERSION` | `latest` | All GitHub/Gitea/go.dev-hosted binaries resolve to the newest upstream release at build time. Override with a specific version to pin. Resolved versions are logged in CI output. |
|
||||
@@ -402,6 +406,79 @@ ping all agents
|
||||
|
||||
All six agents should respond if your provider authentication is working.
|
||||
|
||||
## pi (alternative/complementary harness)
|
||||
|
||||
[pi](https://github.com/earendil-works/pi) is a lightweight TUI coding-agent that can run alongside opencode in the same container. Both harnesses share the mempalace install and palace data — wing/diary entries created by one are visible to the other.
|
||||
|
||||
### Setup
|
||||
|
||||
Pre-built pi-enabled images are available on Docker Hub as `joakimp/opencode-devbox:latest-with-pi` (base + pi) and `joakimp/opencode-devbox:latest-omos-with-pi` (OMOS + pi). Pulling one of those tags is the fastest path. Alternatively, build from source:
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg INSTALL_PI=true
|
||||
# Or: pin a pi version
|
||||
docker compose build --build-arg INSTALL_PI=true --build-arg PI_VERSION=0.73.0
|
||||
# Or: pi-only image (no opencode, smaller)
|
||||
docker compose build --build-arg INSTALL_PI=true --build-arg INSTALL_OPENCODE=false
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
The default `compose run --rm devbox` invocation drops to a login bash so you can choose:
|
||||
|
||||
```bash
|
||||
docker compose run --rm devbox # bash, then `pi` or `opencode` or `aws sso login`
|
||||
docker compose run --rm devbox pi # launch pi directly
|
||||
docker compose run --rm devbox opencode
|
||||
```
|
||||
|
||||
For an attached `compose up -d` container, both harnesses are reachable via `compose exec`:
|
||||
|
||||
```bash
|
||||
docker compose exec -u developer devbox pi
|
||||
docker compose exec -u developer devbox opencode
|
||||
docker compose exec -u developer devbox bash
|
||||
```
|
||||
|
||||
### What gets installed
|
||||
|
||||
- **`pi` CLI** — npm-installed globally at build time. Version pinned by `PI_VERSION`.
|
||||
- **pi-toolkit** — keybindings.json (mosh/tmux newline fixes), pi-env.zsh (AWS env loader), settings.json template. Cloned to `/opt/pi-toolkit`; deployed to `~/.pi/agent/` on first container start.
|
||||
- **pi-extensions** — 7 extensions, cloned to `/opt/pi-extensions` and symlinked into `~/.pi/agent/extensions/`:
|
||||
- `confirm-destructive` — confirm-prompt before dangerous bash commands and session actions.
|
||||
- `ext-toggle` — `/ext` slash command to list and enable/disable extensions at runtime (rename-to-disable; survives `/reload`).
|
||||
- `git-checkpoint` — per-turn `git stash` checkpoint, restorable on `/fork`.
|
||||
- `mcp-loader` — generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json` (same shape as opencode and Claude Desktop) and connects to each declared server, exposing the tools as native pi tools. Supports both **local stdio** subprocesses (`uvx mcp-searxng`, `gitea-mcp`, …) and **remote streamable-HTTP** servers per MCP spec 2025-03-26 (e.g. `https://mcp.context7.com/mcp`). Adds a `/mcp` slash command for runtime status / toggle (same UX as `/ext`). See [`pi-extensions/AGENTS.md`](https://gitea.jordbo.se/joakimp/pi-extensions/src/branch/main/AGENTS.md) for transport details and the `headers` config for auth tokens.
|
||||
- `notify` — native terminal notification when the agent finishes.
|
||||
- `ssh-controlmaster` — transparent SSH remote execution via persistent ControlMaster socket (when pi is launched with `--ssh user@host`).
|
||||
- `todo` — `todo` tool for the agent + `/todos` for the user.
|
||||
- **mempalace bridge** — separate `mempalace.ts` extension symlinked from the cloned `mempalace-toolkit`. Provides pi's MCP tools for palace search/diary/knowledge-graph with bespoke agent-identity injection from `$MEMPALACE_AGENT_NAME`. Coexists with `mcp-loader` rather than replacing it — don't list `mempalace` in settings.json's `mcp` block too, or you'll get duplicate tool registrations.
|
||||
- **MCP servers (none baked in beyond mempalace)** — the loader registers nothing by default. Add servers by editing `~/.pi/agent/settings.json` and `/reload`. Examples (mcp-searxng for web search, context7 for live library docs) are in the `pi-extensions` README.
|
||||
|
||||
### Persistence
|
||||
|
||||
`~/.pi/` is mounted on the `devbox-pi-config` named volume. Everything below survives container recreate **and** image rebuilds:
|
||||
|
||||
- `~/.pi/agent/settings.json` (provider/model, theme selection, the `mcp` block, and the `packages` array tracking installed pi packages).
|
||||
- `~/.pi/agent/extensions/` (hand-placed extensions and the symlinks deployed by `pi-extensions/install.sh`).
|
||||
- `~/.pi/agent/sessions/`, `~/.pi/agent/auth.json`.
|
||||
- `~/.pi/agent/git/<host>/<path>/` (pi packages installed via `pi install git:...`).
|
||||
- `~/.pi/npm-global/` (pi packages installed via `pi install npm:...`, plus any `npm install -g` invoked as the `developer` user). `NPM_CONFIG_PREFIX` is pre-set in the image, the prefix's `bin/` is on `PATH`, and the directory itself lives on the volume — so user-installed themes, skills, and extensions survive everything short of `docker compose down -v`.
|
||||
|
||||
The **baked** pi binary (and pi-toolkit / pi-extensions repos under `/opt/`) live on the image filesystem, not the volume. Image rebuild is the upgrade path for those — same contract as `OPENCODE_VERSION`. If you `npm install -g @earendil-works/pi-coding-agent` yourself, the user-installed copy on the volume wins via `PATH` order and survives image rebuilds.
|
||||
|
||||
### Configuration
|
||||
|
||||
The entrypoint copies `pi-toolkit/settings.example.json` to `~/.pi/agent/settings.json` on first start. Edit it to set provider/model:
|
||||
|
||||
```bash
|
||||
docker compose exec -u developer devbox $EDITOR ~/.pi/agent/settings.json
|
||||
```
|
||||
|
||||
The AWS env loader (`pi-env.zsh`) reads `~/.config/pi/.env` if you bind-mount one; otherwise pi uses container env vars passed via `.env`.
|
||||
|
||||
## AWS Bedrock Authentication
|
||||
|
||||
When using AWS Bedrock as your LLM provider, you need:
|
||||
@@ -468,7 +545,7 @@ Without the volume, palace data lives in the container's writable layer and is l
|
||||
|
||||
### MCP integration with opencode
|
||||
|
||||
Add mempalace as an MCP server in your `opencode.json` (inside `~/.config/opencode/`):
|
||||
Add mempalace as an MCP server in your `opencode.jsonc` (inside `~/.config/opencode/`):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -552,7 +629,7 @@ The image includes the [official Gitea MCP server](https://gitea.com/gitea/gitea
|
||||
GITEA_ACCESS_TOKEN=your_token_here
|
||||
```
|
||||
|
||||
3. Enable the gitea MCP server in your `opencode.json`:
|
||||
3. Enable the gitea MCP server in your `opencode.jsonc`:
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
@@ -570,6 +647,14 @@ The image includes the [official Gitea MCP server](https://gitea.com/gitea/gitea
|
||||
|
||||
The server is installed but disabled by default — it requires authentication to be useful.
|
||||
|
||||
## Context7 MCP server
|
||||
|
||||
The image auto-registers a [Context7](https://context7.com) MCP server, which provides up-to-date library documentation and code examples to LLMs at query time. This is a remote MCP server at `mcp.context7.com/mcp` — no local binary is needed.
|
||||
|
||||
- Auto-registered in the generated `opencode.jsonc` (no manual setup required)
|
||||
- Provides documentation for any programming library/framework on demand
|
||||
- Requires internet access — useless in air-gapped/offline environments
|
||||
|
||||
## Shell defaults
|
||||
|
||||
The image ships a baked `.bash_aliases` and `.inputrc` with quality-of-life defaults. On first container start they are copied from `/etc/skel-devbox/` into `/home/developer/` **only if the target file does not already exist** — so host bind-mounts and any version you've customized inside the container are never overwritten on upgrade.
|
||||
@@ -680,9 +765,9 @@ Container (Debian trixie)
|
||||
| `/home/developer/.rustup` | Named volume `devbox-rustup` (if configured) | ✅ Yes | Rust toolchains |
|
||||
| `/home/developer/.cargo` | Named volume `devbox-cargo` (if configured) | ✅ Yes | Cargo binaries, registry cache |
|
||||
| `/home/developer/.vscode-server` | Named volume `devbox-vscode` (if configured) | ✅ Yes | VS Code server and extensions |
|
||||
| `/home/developer/.config/opencode` | Host bind mount (if configured) | ✅ Yes | opencode.json, skills, plus `oh-my-opencode-slim.json` on the OMOS variant |
|
||||
| `/home/developer/.config/opencode` | Named volume `devbox-opencode-config` | ✅ Yes | `opencode.jsonc`, skills, plus `oh-my-opencode-slim.json` on the OMOS variant |
|
||||
|
||||
**opencode config** (`opencode.json`) is auto-generated from `OPENCODE_PROVIDER` on each start. It sets provider and model only — no MCP servers. To persist config changes and use custom settings, mount the config directory from the host (see Custom opencode config above).
|
||||
**opencode config** (`opencode.jsonc`) is auto-generated from `OPENCODE_PROVIDER` on each start. It sets provider and model only — no MCP servers. To persist config changes and use custom settings, use the named volume (default) or bind-mount from host (see Custom opencode config above).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -45,8 +45,18 @@ services:
|
||||
# SSH keys — user-specific if available, else shared
|
||||
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
|
||||
|
||||
# Opencode config — per-user (persists settings across restarts)
|
||||
- ${HOME}/${SIGNUM}/.config/opencode:/home/developer/.config/opencode
|
||||
# Optional: mount skillset repo for automatic skill/instruction deployment.
|
||||
# The entrypoint runs deploy-skills.sh --bootstrap on start, creating
|
||||
# relative symlinks that resolve inside the container regardless of
|
||||
# where the repo lives on the host. Set SKILLSET_PATH in .env.
|
||||
# - ${SKILLSET_PATH}:/home/developer/skillset
|
||||
|
||||
# Persist opencode config (opencode.jsonc, oh-my-opencode-slim.json,
|
||||
# instructions, etc.) across container recreations. Auto-generated on
|
||||
# first start from env vars by generate-config.py and the skillset
|
||||
# deploy script. Using a named volume keeps the container's symlinks
|
||||
# independent from the host.
|
||||
- devbox-opencode-config:/home/developer/.config/opencode
|
||||
|
||||
# Persist opencode data (auth, memory, session history)
|
||||
- devbox-data:/home/developer/.local/share/opencode
|
||||
@@ -73,6 +83,7 @@ services:
|
||||
# - ${HOME}/${SIGNUM}/.aws:/home/developer/.aws
|
||||
|
||||
volumes:
|
||||
devbox-opencode-config:
|
||||
devbox-data:
|
||||
devbox-shell-history:
|
||||
devbox-zoxide:
|
||||
|
||||
+24
-6
@@ -25,6 +25,9 @@ services:
|
||||
# args:
|
||||
# INSTALL_GO: "false"
|
||||
# INSTALL_OMOS: "false"
|
||||
# INSTALL_PI: "false"
|
||||
# # PI_VERSION: "latest"
|
||||
# # INSTALL_OPENCODE: "true"
|
||||
container_name: opencode-devbox
|
||||
stdin_open: true
|
||||
tty: true
|
||||
@@ -42,13 +45,26 @@ services:
|
||||
# SSH keys (read-only) — for git push/pull
|
||||
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
|
||||
|
||||
# Optional: mount opencode config directory (persists config changes across restarts)
|
||||
# Includes opencode.json, oh-my-opencode-slim.json, skills, etc.
|
||||
# When mounted, OPENCODE_PROVIDER auto-config is skipped if opencode.json exists.
|
||||
# - ~/.config/opencode:/home/developer/.config/opencode
|
||||
# Optional: mount skillset repo for automatic skill/instruction deployment.
|
||||
# The entrypoint runs deploy-skills.sh --bootstrap on start, creating
|
||||
# relative symlinks that resolve inside the container regardless of
|
||||
# where the repo lives on the host. Set SKILLSET_PATH in .env.
|
||||
# - ${SKILLSET_PATH}:/home/developer/skillset
|
||||
|
||||
# Optional: mount opencode agent skills from host
|
||||
# - ~/.agents/skills:/home/developer/.agents/skills:ro
|
||||
# Persist opencode config (opencode.jsonc, oh-my-opencode-slim.json,
|
||||
# instructions, etc.) across container recreations. Auto-generated on
|
||||
# first start from env vars by generate-config.py and the skillset
|
||||
# deploy script. Using a named volume (not a host bind mount) keeps
|
||||
# the container's skill/instruction symlinks independent from the host,
|
||||
# allowing both native and containerized opencode on the same machine.
|
||||
- devbox-opencode-config:/home/developer/.config/opencode
|
||||
- devbox-pi-config:/home/developer/.pi
|
||||
|
||||
# NOTE: Do NOT bind-mount ~/.agents/skills/ from the host. The
|
||||
# container manages its own skills directory independently — the
|
||||
# entrypoint deploys skills from the skillset repo on each start.
|
||||
# Sharing it with the host causes symlink conflicts (relative paths
|
||||
# differ between host and container filesystem namespaces).
|
||||
|
||||
# Optional: mount neovim config from host (plugins auto-install on first start)
|
||||
# - ~/.config/nvim:/home/developer/.config/nvim:ro
|
||||
@@ -108,6 +124,8 @@ services:
|
||||
# - ~/.aws:/home/developer/.aws
|
||||
|
||||
volumes:
|
||||
devbox-opencode-config:
|
||||
devbox-pi-config:
|
||||
devbox-data:
|
||||
devbox-state:
|
||||
devbox-shell-history:
|
||||
|
||||
+66
-1
@@ -25,7 +25,12 @@ if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
||||
PALACE_DIR="${HOME}/.mempalace"
|
||||
if [ ! -d "$PALACE_DIR/palace" ]; then
|
||||
echo "Initializing MemPalace for workspace (non-interactive)..."
|
||||
mempalace init --yes /workspace >/dev/null 2>&1 || true
|
||||
# </dev/null: mempalace init has an interactive "Mine this directory
|
||||
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
|
||||
# Without redirected stdin, the process blocks here forever when run
|
||||
# from `docker run -it` (the TTY keeps stdin open). EOF on stdin
|
||||
# makes the prompt fall through to its default (skip).
|
||||
mempalace init --yes /workspace </dev/null >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -44,6 +49,66 @@ fi
|
||||
# generated) and no-ops if OPENCODE_PROVIDER is unset.
|
||||
python3 /usr/local/lib/opencode-devbox/generate-config.py
|
||||
|
||||
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
||||
# Runs only when pi was baked into the image (INSTALL_PI=true at build).
|
||||
# Each install.sh is idempotent and backs up real files before linking,
|
||||
# so re-running across container restarts is safe.
|
||||
#
|
||||
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
|
||||
# and writes the AWS env loader), then pi-extensions (symlinks our 6
|
||||
# extensions), then settings.json bootstrap from the toolkit template,
|
||||
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
|
||||
# install_skill is intentionally skipped to avoid racing with skillset
|
||||
# auto-deploy below).
|
||||
if command -v pi &>/dev/null; then
|
||||
if [ -d /opt/pi-toolkit ]; then
|
||||
(cd /opt/pi-toolkit && ./install.sh --yes) || \
|
||||
echo "WARN: pi-toolkit install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
if [ -d /opt/pi-extensions ]; then
|
||||
(cd /opt/pi-extensions && ./install.sh --yes) || \
|
||||
echo "WARN: pi-extensions install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
# Bootstrap settings.json from template if absent (pi rewrites this
|
||||
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
||||
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
|
||||
[ -f /opt/pi-toolkit/settings.example.json ]; then
|
||||
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
|
||||
fi
|
||||
|
||||
# pi↔mempalace MCP bridge — single extension symlink.
|
||||
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
|
||||
command -v mempalace &>/dev/null && \
|
||||
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
||||
"$HOME/.pi/agent/extensions/mempalace.ts"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
||||
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
|
||||
# run the deploy script to create relative symlinks for skills and instructions.
|
||||
# This ensures skills resolve correctly inside the container regardless of
|
||||
# where the repo lives on the host. Idempotent — second run is a no-op.
|
||||
#
|
||||
# Detection order:
|
||||
# 1. SKILLSET_CONTAINER_PATH env var (explicit, for non-standard layouts)
|
||||
# 2. $HOME/skillset (dedicated volume mount via SKILLSET_PATH in compose)
|
||||
# 3. /workspace/skillset (skillset is directly inside workspace root)
|
||||
SKILLSET_DEPLOY=""
|
||||
if [ -n "${SKILLSET_CONTAINER_PATH:-}" ] && [ -x "${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" ]; then
|
||||
SKILLSET_DEPLOY="${SKILLSET_CONTAINER_PATH}/deploy-skills.sh"
|
||||
elif [ -x "$HOME/skillset/deploy-skills.sh" ]; then
|
||||
SKILLSET_DEPLOY="$HOME/skillset/deploy-skills.sh"
|
||||
elif [ -x /workspace/skillset/deploy-skills.sh ]; then
|
||||
SKILLSET_DEPLOY="/workspace/skillset/deploy-skills.sh"
|
||||
fi
|
||||
if [ -n "$SKILLSET_DEPLOY" ]; then
|
||||
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
CONFIG_DIR="$HOME/.config/opencode"
|
||||
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ for dir in \
|
||||
/home/"$USER_NAME"/.vscode-server \
|
||||
/home/"$USER_NAME"/.config/opencode \
|
||||
/home/"$USER_NAME"/.config/nvim \
|
||||
/home/"$USER_NAME"/.pi \
|
||||
/home/"$USER_NAME"/.agents/skills; do
|
||||
[ -d "$dir" ] || continue
|
||||
|
||||
|
||||
@@ -96,6 +96,14 @@ def register_mcp_servers(config: dict) -> list[str]:
|
||||
"enabled": False,
|
||||
}
|
||||
|
||||
# Context7 — up-to-date library documentation for LLMs (remote).
|
||||
# Free tier works without an API key; set CONTEXT7_API_KEY for higher
|
||||
# rate limits. No local binary needed — purely a remote MCP endpoint.
|
||||
servers["context7"] = {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
}
|
||||
|
||||
if servers:
|
||||
config["mcp"] = servers
|
||||
|
||||
@@ -110,14 +118,17 @@ def main() -> int:
|
||||
|
||||
home = Path(os.environ.get("HOME", "/home/developer"))
|
||||
config_dir = home / ".config" / "opencode"
|
||||
config_file = config_dir / "opencode.json"
|
||||
config_file = config_dir / "opencode.jsonc"
|
||||
config_file_legacy = config_dir / "opencode.json"
|
||||
|
||||
# CRITICAL: never overwrite an existing config. Users may have
|
||||
# bind-mounted their host config directory, or their config may be
|
||||
# persisted in a named volume from a previous run.
|
||||
if config_file.exists():
|
||||
# Check both .json and .jsonc variants.
|
||||
if config_file.exists() or config_file_legacy.exists():
|
||||
existing = config_file if config_file.exists() else config_file_legacy
|
||||
print(
|
||||
f"Existing opencode.json found at {config_file} — "
|
||||
f"Existing config found at {existing} — "
|
||||
"skipping generation.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -140,8 +151,23 @@ def main() -> int:
|
||||
added = register_mcp_servers(config)
|
||||
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write as JSONC so we can include helpful comments.
|
||||
content = json.dumps(config, indent=2)
|
||||
|
||||
# Insert a comment about Context7 API key after the context7 url line.
|
||||
context7_comment = (
|
||||
' "url": "https://mcp.context7.com/mcp"\n'
|
||||
" // For higher rate limits, sign up at https://context7.com/dashboard\n"
|
||||
' // and add: "headers": { "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" }'
|
||||
)
|
||||
content = content.replace(
|
||||
' "url": "https://mcp.context7.com/mcp"',
|
||||
context7_comment,
|
||||
)
|
||||
|
||||
with config_file.open("w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
f.write(content)
|
||||
f.write("\n")
|
||||
|
||||
if added:
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate DOCKER_HUB.md from README.md.
|
||||
Generate DOCKER_HUB.md.
|
||||
|
||||
Rationale
|
||||
---------
|
||||
README.md is the authoritative source. DOCKER_HUB.md is a subset
|
||||
intended for users pulling the pre-built image from Docker Hub — so
|
||||
build-from-source instructions, developer setup (git hooks, gitleaks),
|
||||
and CI/contribution content are dropped.
|
||||
DOCKER_HUB.md is the public-facing description shown on Docker Hub. It
|
||||
has two hard constraints the README does not:
|
||||
|
||||
Docker Hub enforces a 25 kB limit on the full description field.
|
||||
1. A 25 kB byte limit on the full_description field.
|
||||
2. A different audience: Hub readers want a 30-second evaluation —
|
||||
"what is this, how do I run it, does it have what I need" — and
|
||||
reference material is better consulted in context on gitea.
|
||||
|
||||
For a long time this script tried to derive DOCKER_HUB.md from README.md
|
||||
by section selection + targeted replacement. As the README grew that
|
||||
approach pushed against the 25 kB ceiling on every change, costing a
|
||||
trim-something-else exercise per edit (final state: 3 byte headroom).
|
||||
|
||||
The new approach is much simpler: a hand-written HUB_TEMPLATE below.
|
||||
The template intentionally stays slim and links out to the gitea README
|
||||
for everything that benefits from depth. README.md grows freely.
|
||||
|
||||
Trade-off: when image-variants table or quick-start flow changes,
|
||||
update HUB_TEMPLATE here too. That coupling is now explicit and
|
||||
local rather than spread across SECTION_RULES + REPLACEMENTS + TRIM
|
||||
machinery.
|
||||
|
||||
Usage
|
||||
-----
|
||||
@@ -19,75 +34,42 @@ Regenerate in place:
|
||||
Fail if DOCKER_HUB.md is out of sync with what this script would emit
|
||||
(run this in CI):
|
||||
python3 scripts/generate-dockerhub-md.py --check
|
||||
|
||||
Design
|
||||
------
|
||||
Sections are selected and in some cases rewritten via `SECTION_RULES`
|
||||
below. This keeps the transformation explicit and easy to audit — if
|
||||
a new section is added to README.md that should also appear on Docker
|
||||
Hub, extend SECTION_RULES rather than inventing implicit heuristics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
README = REPO_ROOT / "README.md"
|
||||
DOCKER_HUB = REPO_ROOT / "DOCKER_HUB.md"
|
||||
|
||||
# Max size for Docker Hub full_description (bytes, UTF-8).
|
||||
MAX_SIZE_BYTES = 25_000
|
||||
|
||||
# Per-section transformation.
|
||||
#
|
||||
# Each key is a top-level section title as it appears in README.md
|
||||
# (without the leading "## ").
|
||||
#
|
||||
# The value is one of:
|
||||
# "keep" — include verbatim.
|
||||
# "drop" — exclude entirely.
|
||||
# "replace" — substitute a custom body (see REPLACEMENTS).
|
||||
# "trim" — keep but drop selected level-3 sub-sections listed
|
||||
# in TRIM_SUBSECTIONS[title].
|
||||
#
|
||||
# Unknown sections default to "drop" with a warning — forcing an
|
||||
# explicit decision whenever README gains a new section.
|
||||
SECTION_RULES: dict[str, str] = {
|
||||
"Why?": "drop", # build-motivation, not user-facing
|
||||
"Quick Start": "replace", # swap docker compose clone flow for docker run
|
||||
"Features": "keep",
|
||||
"Usage": "keep",
|
||||
"Configuration": "trim", # drop dev-build sub-sections
|
||||
"oh-my-opencode-slim (Multi-Agent Orchestration)": "keep",
|
||||
"AWS Bedrock Authentication": "keep",
|
||||
"MemPalace — persistent AI memory": "keep",
|
||||
"Gitea MCP server": "keep",
|
||||
"Shell defaults": "keep",
|
||||
"Secret Scanning": "drop", # dev-only — gitleaks is for committers
|
||||
"Architecture": "keep",
|
||||
"License": "replace", # point at source repo instead
|
||||
}
|
||||
# Where readers go for the full reference.
|
||||
GITEA = "https://gitea.jordbo.se/joakimp/opencode-devbox"
|
||||
|
||||
# Level-3 sub-section titles (without the leading "### ") to drop from
|
||||
# sections flagged as "trim". These are dev/build-oriented — Docker Hub
|
||||
# users already have the image and don't need rebuild or multi-user
|
||||
# compose instructions.
|
||||
TRIM_SUBSECTIONS: dict[str, set[str]] = {
|
||||
"Configuration": {
|
||||
"Multi-user setup",
|
||||
"Rebuilding the Image",
|
||||
"Build Args",
|
||||
},
|
||||
}
|
||||
|
||||
# Replacement bodies. Keys match SECTION_RULES entries marked "replace".
|
||||
# Each value is the full section including the "## Title" heading.
|
||||
REPLACEMENTS: dict[str, str] = {
|
||||
"Quick Start": """## Quick Start
|
||||
HUB_TEMPLATE = f"""# opencode-devbox
|
||||
|
||||
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
|
||||
|
||||
Designed for teams who want a reproducible coding-agent setup that runs the same on every laptop and CI runner — without forcing each developer to install Bun, Node, AWS CLI, mempalace, or maintain shell config drift across machines.
|
||||
|
||||
## Image Variants
|
||||
|
||||
| Tag | Description |
|
||||
|---|---|
|
||||
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
|
||||
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
|
||||
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/earendil-works/pi) as alternative/complementary harness (shares the mempalace install with opencode) |
|
||||
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
|
||||
|
||||
All variants support `linux/amd64` and `linux/arm64`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker run -it --rm \\
|
||||
@@ -100,9 +82,9 @@ docker run -it --rm \\
|
||||
joakimp/opencode-devbox:latest
|
||||
```
|
||||
|
||||
This drops you straight into opencode with your project mounted at `/workspace`.
|
||||
Drops you straight into opencode with your project mounted at `/workspace`.
|
||||
|
||||
For an interactive shell first (useful for AWS SSO login):
|
||||
For an interactive shell first (useful for AWS SSO login, multi-harness workflows, or just `bash`):
|
||||
|
||||
```bash
|
||||
docker run -it --rm \\
|
||||
@@ -113,125 +95,63 @@ docker run -it --rm \\
|
||||
joakimp/opencode-devbox:latest bash
|
||||
```
|
||||
|
||||
Then run `opencode` when ready.
|
||||
Then run `opencode`, `pi` (on `*-with-pi` variants), or `aws sso login` from the shell.
|
||||
|
||||
For docker-compose users, see the source repo for `docker-compose.yml` and `.env.example` templates.
|
||||
""",
|
||||
"License": """## Source
|
||||
For docker-compose users, the source repo provides `docker-compose.yml`, `.env.example`, and a one-liner `docker compose up -d` workflow with named volumes pre-wired.
|
||||
|
||||
MIT licensed. Source, issues, and `docker-compose.yml` templates: <https://gitea.jordbo.se/joakimp/opencode-devbox>
|
||||
""",
|
||||
}
|
||||
## 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.
|
||||
|
||||
# Prepended to the generated file.
|
||||
HEADER = """# opencode-devbox — Docker Hub
|
||||
## Authentication
|
||||
|
||||
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.
|
||||
The container reads provider credentials from environment variables and host-mounted config:
|
||||
|
||||
## Image Variants
|
||||
- **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.
|
||||
|
||||
Two image variants are published for each release:
|
||||
Full Bedrock walkthrough (IAM roles, permissions, multi-account setups): see the [AWS Bedrock Authentication](
|
||||
{GITEA}#aws-bedrock-authentication
|
||||
) section on gitea.
|
||||
|
||||
| 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 |
|
||||
## Persistence
|
||||
|
||||
Both variants support `linux/amd64` and `linux/arm64`.
|
||||
| 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) |
|
||||
|
||||
> **NOTE:** This file is auto-generated from `README.md` by `scripts/generate-dockerhub-md.py`. Edit README.md and regenerate rather than editing this file directly.
|
||||
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>
|
||||
|
||||
## License
|
||||
|
||||
MIT. See <{GITEA}/src/branch/main/LICENSE>.
|
||||
|
||||
---
|
||||
|
||||
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
|
||||
"""
|
||||
|
||||
|
||||
def split_sections(md: str) -> list[tuple[str, str]]:
|
||||
"""Split markdown on level-2 headings, returning (title, body) pairs.
|
||||
|
||||
The body includes the heading line and everything up to (but not
|
||||
including) the next level-2 heading or EOF. Content before the first
|
||||
``## `` is returned with an empty title (the document preamble).
|
||||
"""
|
||||
pattern = re.compile(r"^## ", re.MULTILINE)
|
||||
parts = pattern.split(md)
|
||||
preamble, *rest = parts
|
||||
|
||||
sections: list[tuple[str, str]] = []
|
||||
if preamble.strip():
|
||||
sections.append(("", preamble))
|
||||
for part in rest:
|
||||
line, _, body = part.partition("\n")
|
||||
sections.append((line.strip(), f"## {line}\n{body}"))
|
||||
return sections
|
||||
|
||||
|
||||
def trim_subsections(body: str, drop: set[str]) -> str:
|
||||
"""Remove level-3 sub-sections whose title is in `drop`.
|
||||
|
||||
A sub-section starts at a line beginning with "### " and ends at
|
||||
the next "### " or "## " (or EOF).
|
||||
"""
|
||||
if not drop:
|
||||
return body
|
||||
|
||||
# Split on level-3 headings while preserving the level-2 header
|
||||
# block. First piece is everything up to the first "### ".
|
||||
parts = re.split(r"(^### .+\n)", body, flags=re.MULTILINE)
|
||||
# parts alternates: [before_first_h3, "### Title\n", body, "### Title\n", body, ...]
|
||||
kept: list[str] = [parts[0]] if parts else []
|
||||
i = 1
|
||||
while i < len(parts):
|
||||
heading = parts[i]
|
||||
content = parts[i + 1] if i + 1 < len(parts) else ""
|
||||
title = heading[4:].strip()
|
||||
if title not in drop:
|
||||
kept.append(heading)
|
||||
kept.append(content)
|
||||
i += 2
|
||||
return "".join(kept)
|
||||
|
||||
|
||||
def generate() -> str:
|
||||
"""Produce the DOCKER_HUB.md content string."""
|
||||
readme = README.read_text(encoding="utf-8")
|
||||
sections = split_sections(readme)
|
||||
|
||||
out: list[str] = [HEADER]
|
||||
unknown: list[str] = []
|
||||
|
||||
for title, body in sections:
|
||||
if title == "":
|
||||
# README preamble is replaced by our HEADER; skip.
|
||||
continue
|
||||
|
||||
rule = SECTION_RULES.get(title)
|
||||
if rule is None:
|
||||
unknown.append(title)
|
||||
continue
|
||||
if rule == "drop":
|
||||
continue
|
||||
if rule == "keep":
|
||||
out.append(body.rstrip() + "\n\n")
|
||||
elif rule == "trim":
|
||||
trimmed = trim_subsections(body, TRIM_SUBSECTIONS.get(title, set()))
|
||||
out.append(trimmed.rstrip() + "\n\n")
|
||||
elif rule == "replace":
|
||||
out.append(REPLACEMENTS[title].rstrip() + "\n\n")
|
||||
else: # pragma: no cover — programmer error
|
||||
raise AssertionError(f"unknown rule {rule!r} for section {title!r}")
|
||||
|
||||
if unknown:
|
||||
print(
|
||||
"ERROR: README.md contains sections not classified in "
|
||||
"SECTION_RULES:\n - "
|
||||
+ "\n - ".join(unknown)
|
||||
+ "\n\nAdd each to SECTION_RULES in "
|
||||
"scripts/generate-dockerhub-md.py (choose keep/drop/replace).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(2)
|
||||
|
||||
return "".join(out).rstrip() + "\n"
|
||||
return HUB_TEMPLATE
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -257,11 +177,10 @@ def main() -> int:
|
||||
existing = DOCKER_HUB.read_text(encoding="utf-8") if DOCKER_HUB.exists() else ""
|
||||
if existing != content:
|
||||
print(
|
||||
"ERROR: DOCKER_HUB.md is out of sync with README.md.\n"
|
||||
"ERROR: DOCKER_HUB.md is out of sync with the template.\n"
|
||||
"Run: python3 scripts/generate-dockerhub-md.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Show a small diff hint.
|
||||
import difflib
|
||||
|
||||
diff = difflib.unified_diff(
|
||||
@@ -274,14 +193,16 @@ def main() -> int:
|
||||
sys.stderr.writelines(list(diff)[:80])
|
||||
return 1
|
||||
print(
|
||||
f"OK: DOCKER_HUB.md is in sync with README.md "
|
||||
f"({size} bytes, {MAX_SIZE_BYTES} limit).",
|
||||
f"OK: DOCKER_HUB.md is in sync with HUB_TEMPLATE "
|
||||
f"({size} bytes, {MAX_SIZE_BYTES} limit, "
|
||||
f"{MAX_SIZE_BYTES - size} bytes headroom).",
|
||||
)
|
||||
return 0
|
||||
|
||||
DOCKER_HUB.write_text(content, encoding="utf-8")
|
||||
print(
|
||||
f"Wrote {DOCKER_HUB} ({size} bytes, {MAX_SIZE_BYTES} limit).",
|
||||
f"Wrote {DOCKER_HUB} ({size} bytes, {MAX_SIZE_BYTES} limit, "
|
||||
f"{MAX_SIZE_BYTES - size} bytes headroom).",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
+95
-18
@@ -8,7 +8,7 @@
|
||||
# - Generated opencode.json has the expected shape
|
||||
# - MCP wrapper works (when mempalace is installed)
|
||||
#
|
||||
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos]
|
||||
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos|with-pi|omos-with-pi]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 all checks passed
|
||||
@@ -23,7 +23,7 @@ if [ "${2:-}" = "--variant" ]; then
|
||||
fi
|
||||
|
||||
if [ -z "$IMAGE" ]; then
|
||||
echo "usage: $0 <image> [--variant base|omos]" >&2
|
||||
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
@@ -50,7 +50,12 @@ echo "-- Resolved component versions --"
|
||||
# always record what got baked into this image, even when Dockerfile
|
||||
# ARGs default to "latest".
|
||||
docker run --rm --entrypoint="" "$IMAGE" sh -c '
|
||||
printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)"
|
||||
if command -v opencode >/dev/null 2>&1; then
|
||||
printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)"
|
||||
fi
|
||||
if command -v pi >/dev/null 2>&1; then
|
||||
printf " %-15s %s\n" "pi" "$(pi --version 2>&1 | head -1)"
|
||||
fi
|
||||
printf " %-15s %s\n" "node" "$(node --version)"
|
||||
printf " %-15s %s\n" "npm" "$(npm --version)"
|
||||
printf " %-15s %s\n" "nvim" "$(nvim --version | head -1)"
|
||||
@@ -77,7 +82,13 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
|
||||
'
|
||||
echo
|
||||
echo "-- Core binaries --"
|
||||
run "opencode" "opencode --version"
|
||||
# opencode is gated on INSTALL_OPENCODE=true (default). When absent, the
|
||||
# image is a pi-only build (or a pure base — no harness at all).
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v opencode" >/dev/null 2>&1; then
|
||||
run "opencode" "opencode --version"
|
||||
else
|
||||
echo " - opencode not installed (INSTALL_OPENCODE=false)"
|
||||
fi
|
||||
run "node" "node --version"
|
||||
run "npm" "npm --version"
|
||||
run "git" "git --version"
|
||||
@@ -117,13 +128,70 @@ elif docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev
|
||||
echo " - mempalace-toolkit not installed (INSTALL_MEMPALACE_TOOLKIT=false)"
|
||||
fi
|
||||
|
||||
# bun: only in the omos variant
|
||||
if [ "$VARIANT" = "omos" ]; then
|
||||
# pi: present when built with INSTALL_PI=true. Verifies pi itself plus
|
||||
# the runtime-deployed pi-toolkit + pi-extensions + mempalace bridge
|
||||
# symlinks under ~/.pi/agent/. Note: extension symlinks are created by
|
||||
# entrypoint-user.sh on first start, so we test by running the entry
|
||||
# point chain (not just `docker run --entrypoint=""`).
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&1; then
|
||||
run "pi" "pi --version"
|
||||
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
|
||||
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
|
||||
|
||||
# Run the full entrypoint as developer to verify install.sh deployment.
|
||||
# Spin up a long-running container so we can `docker exec` into it from
|
||||
# the host — the `run` helper above invokes commands INSIDE the image
|
||||
# and has no docker CLI to nest with.
|
||||
CID=$(docker run -d --rm "$IMAGE" tail -f /dev/null)
|
||||
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions.
|
||||
# Marker: keybindings.json symlink lands once pi-toolkit/install.sh has run.
|
||||
# Up to 30s — omos-with-pi has more setup work than base+pi.
|
||||
for _ in $(seq 1 30); do
|
||||
if docker exec "$CID" test -L /home/developer/.pi/agent/keybindings.json 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
exec_test() {
|
||||
local label="$1"; shift
|
||||
local out
|
||||
if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then
|
||||
pass "$label ($(echo "$out" | head -1))"
|
||||
else
|
||||
fail "$label: $out"
|
||||
fi
|
||||
}
|
||||
|
||||
exec_test "~/.pi/agent/keybindings.json (pi-toolkit)" \
|
||||
'test -L $HOME/.pi/agent/keybindings.json && echo ok'
|
||||
exec_test "~/.pi/agent/extensions/*.ts ≥ 4 (pi-extensions)" \
|
||||
'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
|
||||
exec_test "~/.pi/agent/extensions/mempalace.ts (bridge)" \
|
||||
'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
|
||||
exec_test "~/.pi/agent/settings.json (template bootstrap)" \
|
||||
'test -f $HOME/.pi/agent/settings.json && echo ok'
|
||||
|
||||
docker rm -f "$CID" >/dev/null 2>&1 || true
|
||||
trap - EXIT
|
||||
else
|
||||
echo " - pi not installed (INSTALL_PI=false)"
|
||||
fi
|
||||
|
||||
# bun: only in the omos and omos-with-pi variants
|
||||
if [ "$VARIANT" = "omos" ] || [ "$VARIANT" = "omos-with-pi" ]; then
|
||||
run "bun (omos)" "bun --version"
|
||||
run "bunx symlink (omos)" "test -L /usr/local/bin/bunx && readlink /usr/local/bin/bunx"
|
||||
# oh-my-opencode-slim is npm-installed globally (not a bun install);
|
||||
# verify it shows up in the global module list.
|
||||
run "oh-my-opencode-slim" "npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim"
|
||||
# verify it shows up in the global module list. We must explicitly point
|
||||
# npm at the system prefix (/usr) here: the image's NPM_CONFIG_PREFIX env
|
||||
# is set to /home/developer/.pi/npm-global so user-installed packages
|
||||
# land on the persistent volume — which means a default `npm ls -g`
|
||||
# queries the user prefix and would miss the baked binaries even though
|
||||
# they're correctly on PATH at /usr/bin.
|
||||
run "oh-my-opencode-slim" "NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim"
|
||||
else
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v bun" >/dev/null 2>&1; then
|
||||
fail "bun should NOT be in base image but was found"
|
||||
@@ -160,11 +228,11 @@ else
|
||||
fi
|
||||
rm -f "$tmpout"
|
||||
|
||||
# Config generation with anthropic provider writes valid JSON with the
|
||||
# Config generation with anthropic provider writes valid JSONC with the
|
||||
# expected shape. The script's log message goes to stderr (line 1 of
|
||||
# generate-config.py uses file=sys.stderr) so capturing only stdout
|
||||
# gives us clean JSON.
|
||||
label="generate-config produces valid opencode.json"
|
||||
# gives us clean JSONC. We strip // comments before validating JSON.
|
||||
label="generate-config produces valid opencode.jsonc"
|
||||
tmp=$(mktemp -d)
|
||||
if docker run --rm \
|
||||
-e OPENCODE_PROVIDER=anthropic \
|
||||
@@ -173,24 +241,31 @@ if docker run --rm \
|
||||
"$IMAGE" sh -c '
|
||||
mkdir -p /tmp/home
|
||||
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
|
||||
cat /tmp/home/.config/opencode/opencode.json
|
||||
' > "$tmp/out.json" 2>/dev/null; then
|
||||
cat /tmp/home/.config/opencode/opencode.jsonc
|
||||
' > "$tmp/out.jsonc" 2>/dev/null; then
|
||||
# Strip single-line // comments for JSON validation (respecting strings)
|
||||
if python3 -c "
|
||||
import json, sys
|
||||
c = json.load(open('$tmp/out.json'))
|
||||
import re, json, sys
|
||||
text = open('$tmp/out.jsonc').read()
|
||||
# Match either a string literal or a // comment; keep strings, drop comments
|
||||
pattern = r'\"(?:\\\\.|[^\"\\\\])*\"|//[^\n]*'
|
||||
stripped = re.sub(pattern, lambda m: m.group(0) if m.group(0).startswith('\"') else '', text)
|
||||
c = json.loads(stripped)
|
||||
assert c['model'].startswith('anthropic/'), c
|
||||
assert c['autoupdate'] is False
|
||||
assert c['share'] == 'disabled'
|
||||
assert 'context7' in c.get('mcp', {}), 'context7 MCP not registered'
|
||||
" 2>&1; then
|
||||
pass "$label"
|
||||
else
|
||||
fail "$label: output doesn't match expected shape: $(cat "$tmp/out.json")"
|
||||
fail "$label: output doesn't match expected shape: $(cat "$tmp/out.jsonc")"
|
||||
fi
|
||||
else
|
||||
fail "$label: container failed: $(cat "$tmp/out.json")"
|
||||
fail "$label: container failed: $(cat "$tmp/out.jsonc")"
|
||||
fi
|
||||
|
||||
# Config generation is idempotent — running twice must not overwrite.
|
||||
# Tests both legacy .json and new .jsonc detection.
|
||||
label="generate-config never overwrites existing config"
|
||||
if docker run --rm \
|
||||
-e OPENCODE_PROVIDER=anthropic \
|
||||
@@ -214,12 +289,14 @@ SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE")
|
||||
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
|
||||
echo " Uncompressed size: ${SIZE_MB} MB"
|
||||
|
||||
# Thresholds (uncompressed): base 2500 MB, omos 3200 MB. Adjust as image content evolves.
|
||||
# Thresholds (uncompressed): base 2500 MB, omos 3200 MB, with-pi adds ~150 MB.
|
||||
# omos bumped 3000→3200 on v1.14.31c — mempalace-toolkit bake-in pushed the
|
||||
# omos variant to ~3.1 GB. Functional smoke checks all pass; this is a
|
||||
# guardrail, not a performance limit.
|
||||
THRESHOLD=2500
|
||||
[ "$VARIANT" = "omos" ] && THRESHOLD=3200
|
||||
[ "$VARIANT" = "with-pi" ] && THRESHOLD=2700
|
||||
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3400
|
||||
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
|
||||
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user