Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c31c641d6 | |||
| d9dc85d825 | |||
| 0b78ab4a94 | |||
| 440218fc4c | |||
| a56a5846a5 | |||
| 053dac5308 | |||
| c71c03f0f1 | |||
| 1e98b53113 | |||
| 30380abdef | |||
| 237588253f | |||
| fc034ceade | |||
| f09a4f382a | |||
| f61b5a4977 | |||
| 870da12c92 | |||
| cb50e6ea60 | |||
| 1fe5b5df91 | |||
| 6cc2670a93 | |||
| 51ec4a88cf | |||
| be2a16834c | |||
| a16da2f041 | |||
| 608304c3de | |||
| 668592da0d | |||
| 3cbcb44cf5 | |||
| 73a7f96056 | |||
| f7c34091b1 | |||
| 4cce39d167 | |||
| 72d2c99885 | |||
| 80e57d732b | |||
| 19f8c043bd | |||
| 90e5a1f5d0 | |||
| b6e4d89a2c | |||
| 8f2c9f5112 | |||
| 60eb49469e | |||
| 18b9c9c549 | |||
| ad4a12b3ab | |||
| fde5a89e8b | |||
| 034830710c | |||
| d293ddc202 | |||
| 910378fe06 | |||
| f06a70a3bc | |||
| dba05da7d1 | |||
| 8359fef949 | |||
| a438c67f06 | |||
| 07e07ec611 | |||
| 7dc836ab66 | |||
| a3ff601bf0 | |||
| 6fde27c212 | |||
| b30ffc83bd | |||
| 896380bb9c | |||
| 911d6dd26b |
@@ -31,6 +31,39 @@ WORKSPACE_PATH=~/projects
|
||||
# Path to SSH keys on host
|
||||
SSH_KEY_PATH=~/.ssh
|
||||
|
||||
# ── LAN access from the container (host-OS-agnostic) ─────────────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop, also Docker Desktop
|
||||
# on Windows) the container runs in a Linux VM and CANNOT reach the host's
|
||||
# directly-attached LAN peers by default. On native Linux Docker the LAN is
|
||||
# reachable directly and nothing is needed. The entrypoint detects this and,
|
||||
# on VM-backed hosts, generates ~/.ssh-local/config so the host can be used
|
||||
# as an SSH jump (use the `dssh` alias). Reach the host itself with
|
||||
# `dssh host`. To reach named LAN peers, put `ProxyJump host` overrides in a
|
||||
# host-owned ~/.config/devbox-shell/ssh-lan.conf (bind-mounted in) rather than
|
||||
# editing your ~/.ssh/config — see ssh-lan.conf.example. Public-IP hosts (and
|
||||
# anything reached via a public jump host) connect directly, no jump needed.
|
||||
#
|
||||
# DEVBOX_LAN_ACCESS: auto (default) | jump | off
|
||||
# auto = set up the jump only on VM-backed hosts; no-op on native Linux.
|
||||
# jump = always set up (e.g. native Linux with extra_hosts host-gateway).
|
||||
# off = disable entirely.
|
||||
# DEVBOX_LAN_ACCESS=auto
|
||||
#
|
||||
# HOST_SSH_USER: your username on the host. REQUIRED for the jump to
|
||||
# authenticate. On first start the entrypoint prints the public key to
|
||||
# authorize on the host (append to the host's ~/.ssh/authorized_keys) and
|
||||
# reminds you to enable the host's SSH server (e.g. macOS Remote Login).
|
||||
# HOST_SSH_USER=
|
||||
#
|
||||
# DEVBOX_HOST_ALIAS: host hostname to reach (default host.docker.internal).
|
||||
# DEVBOX_HOST_ALIAS=host.docker.internal
|
||||
#
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE: 1 = ProxyJump ANY RFC1918 (private) IP through
|
||||
# the host, so bare `dssh user@<ip>` works on whatever LAN the (roaming) host
|
||||
# is currently joined to, without naming peers. Matches the typed address, not
|
||||
# the resolved HostName, so named hosts with their own ProxyJump are unaffected.
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE=0
|
||||
|
||||
# ── 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
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
# CI / Build Pipeline
|
||||
|
||||
This directory contains the gitea Actions workflows and the supporting
|
||||
documentation for opencode-devbox's CI. If you're investigating *why*
|
||||
the build pipeline is shaped the way it is, you're in the right place.
|
||||
|
||||
## Workflows in this directory
|
||||
|
||||
| File | Trigger | Role |
|
||||
|---|---|---|
|
||||
| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `push: tags: v*` | **Production release pipeline.** Two-phase split-base build: shared `base-<hash>` published once (skipped on cache hit), then five parallel variant deltas. ~40–80 min wall clock depending on runner count and whether base needs rebuilding. |
|
||||
| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of all five variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. |
|
||||
|
||||
## Why the split-base pipeline exists
|
||||
|
||||
opencode-devbox builds **five image variants** (`base`, `omos`, `with-pi`, `omos-with-pi`, `pi-only`) × **two architectures** (amd64, arm64). Four opencode-bearing variants publish under this repo (**eight tags per release** + the floating `base-latest`); the `pi-only` build is pushed into the separate `joakimp/pi-devbox` repo as `base-pi-only` (so no opencode-less tag appears here). Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~3–5x slower than native).
|
||||
|
||||
The five variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build.
|
||||
|
||||
Two improvements were considered:
|
||||
|
||||
1. **Reorder the original Dockerfile** so all variant-gated RUNs land at the bottom — modest gain, ~10–20% wall-clock reduction. *Not pursued.*
|
||||
2. **Split into `Dockerfile.base` + `Dockerfile.variant`** with the base published as a long-lived shared image — significant gain, ~50–70% wall-clock reduction with hash-driven cache reuse. *Pursued.*
|
||||
|
||||
The split-base architecture is what the `docker-publish-split.yml` workflow exercises.
|
||||
|
||||
## How the split-base pipeline works
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ base-decide │ compute base-<hash>;
|
||||
│ │ probe Docker Hub.
|
||||
│ hash inputs: │ (resolve-versions
|
||||
│ Dockerfile.base│ runs in parallel:
|
||||
│ rootfs/ │ npm view pi/omos
|
||||
│ entrypoint*.sh │ → concrete versions)
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ need_build = true? │
|
||||
└─────────────┬─────────────┘
|
||||
yes │ no
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ build-base │ multi-arch build,
|
||||
│ │ push base-<hash>
|
||||
└────────┬─────────┘ to Docker Hub.
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────────┐
|
||||
│smoke-base│ │smoke-omos│ ... │smoke-omos-pi │ amd64 only,
|
||||
└────┬─────┘ └────┬─────┘ └──────┬───────┘ parallel.
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────────┐
|
||||
│build- │ │build- │ │build- │ multi-arch,
|
||||
│variant- │ │variant- │ ... │variant- │ parallel,
|
||||
│base │ │omos │ │omos-with-pi │ tag push.
|
||||
└────┬─────┘ └────┬─────┘ └──────┬───────┘
|
||||
└───────────────────────┴──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ promote-base-latest │ crane copy
|
||||
│ │ base-<hash>
|
||||
│ │ → base-latest
|
||||
└────────┬─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ update-description │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### Step 1: `base-decide` (and `resolve-versions` in parallel)
|
||||
|
||||
**`base-decide`** computes a SHA-256 hash over the inputs that determine
|
||||
the base image's content:
|
||||
|
||||
```sh
|
||||
{
|
||||
cat Dockerfile.base
|
||||
find rootfs -type f \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '.DS_Store' \
|
||||
! -name '._*' \
|
||||
-print0 | sort -z | xargs -0 cat
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
} | sha256sum | cut -c1-12
|
||||
```
|
||||
|
||||
Junk filters keep the local recompute reproducible against CI's clean
|
||||
checkout — `__pycache__/*.pyc` and macOS metadata files (`.DS_Store`,
|
||||
`._AppleDouble`) are gitignored but still walked by `find -type f`.
|
||||
|
||||
The 12-character truncated hash becomes `base-<hash>`. Probe Docker Hub
|
||||
for this tag via `docker manifest inspect`:
|
||||
|
||||
- If it exists → set `need_build=false`. `build-base` is skipped entirely.
|
||||
- If it doesn't → set `need_build=true`. `build-base` runs.
|
||||
|
||||
This is the core cache-reuse mechanism. Version-bump-only releases
|
||||
(only `Dockerfile.variant` or build-args changed) hit the cache. Releases
|
||||
that change anything in the base — apt packages, AWS CLI, Node version,
|
||||
locale list, entrypoint scripts — pay the full base-build cost once.
|
||||
|
||||
**`resolve-versions`** runs alongside `base-decide` (no `needs:`
|
||||
dependency between them) and resolves the floating npm packages whose
|
||||
`*_VERSION` build-args default to `latest`:
|
||||
|
||||
```sh
|
||||
PI_VERSION=$(npm view @earendil-works/pi-coding-agent version)
|
||||
OMOS_VERSION=$(npm view oh-my-opencode-slim version)
|
||||
```
|
||||
|
||||
The outputs (`pi_version`, `omos_version`) are consumed by every variant
|
||||
smoke and build job that installs pi or omos. **Why this exists:** without
|
||||
it, the `npm install -g` RUN layer in `Dockerfile.variant` hashes
|
||||
identically across builds (same ARG default, same command string), so
|
||||
the registry buildcache silently reuses the layer from whatever upstream
|
||||
version was current when the cache was first populated. This is the
|
||||
cache-hit silent-regression class of bug that shipped pi-devbox v0.74.0
|
||||
through v0.75.5 with identical image bytes (fixed in pi-devbox v0.75.5b
|
||||
2026-05-23). Currently masked here by `OPENCODE_VERSION` bumping every
|
||||
release (parent-chain cache-key invalidation), but masking would fail on
|
||||
a `vN.N.Nb` opencode-version-unchanged release that only bumps pi or
|
||||
omos. Smoke jobs additionally assert `EXPECTED_PI_VERSION` /
|
||||
`EXPECTED_OMOS_VERSION` against the resolved values.
|
||||
|
||||
### Step 2: `build-base` (conditional)
|
||||
|
||||
Only runs when `need_build=true`. Multi-arch (amd64 + arm64) build of
|
||||
`Dockerfile.base`, pushed to `joakimp/opencode-devbox:base-<hash>`.
|
||||
Registry cache via `--cache-from/--cache-to` reduces incremental rebuilds
|
||||
when only one or two layers changed.
|
||||
|
||||
The base image is **not** tagged `base-latest` here — that promotion
|
||||
happens at the very end after all variants succeed (see step 5).
|
||||
|
||||
### Step 3: `smoke-*` (×4, parallel)
|
||||
|
||||
For each variant: build amd64-only against the base tag, load into
|
||||
local docker, run [`scripts/smoke-test.sh`](../scripts/smoke-test.sh).
|
||||
Variant build-args:
|
||||
|
||||
| variant | INSTALL_OPENCODE | INSTALL_OMOS | INSTALL_PI |
|
||||
|---|---|---|---|
|
||||
| `base` | true | false | false |
|
||||
| `omos` | true | true | false |
|
||||
| `with-pi` | true | false | true |
|
||||
| `omos-with-pi` | true | true | true |
|
||||
|
||||
Smoke runs `--variant <name>` to enable variant-specific assertions.
|
||||
Gate the publish: a smoke failure for variant X blocks `build-variant-X`.
|
||||
|
||||
### Step 4: `build-variant-*` (×4, parallel)
|
||||
|
||||
For each variant that passed smoke: multi-arch (amd64 + arm64) build of
|
||||
`Dockerfile.variant`, pushed to Docker Hub with the user-facing release
|
||||
tags:
|
||||
|
||||
| Build job | Tags pushed |
|
||||
|---|---|
|
||||
| `build-variant-base` | `vX.Y.Z`, `latest` |
|
||||
| `build-variant-omos` | `vX.Y.Z-omos`, `latest-omos` |
|
||||
| `build-variant-with-pi` | `vX.Y.Z-with-pi`, `latest-with-pi` |
|
||||
| `build-variant-omos-with-pi` | `vX.Y.Z-omos-with-pi`, `latest-omos-with-pi` |
|
||||
|
||||
The `latest*` aliases are only updated when `promote_latest=true` (the
|
||||
manual dispatch input) — for test runs, `promote_latest=false` keeps the
|
||||
production aliases pointing at the previous good release.
|
||||
|
||||
### Step 5: `promote-base-latest`
|
||||
|
||||
Once all five variants successfully publish, re-tag `base-<hash>` as
|
||||
`base-latest` using `crane copy`. This is a **manifest-level re-tag, not
|
||||
a rebuild** — it touches only Docker Hub's image index, takes seconds,
|
||||
and is atomic.
|
||||
|
||||
The reason this happens *after* variants succeed (rather than alongside
|
||||
`build-base`) is so a partial failure leaves `base-latest` pointing at
|
||||
the previous known-good base. External consumers who pin to
|
||||
`base-latest` (e.g. the planned pi-devbox repo) never see a broken base.
|
||||
|
||||
### Step 6: `update-description`
|
||||
|
||||
Push the generated `DOCKER_HUB.md` to the Hub repo's `full_description`
|
||||
field via the Hub REST API. Same step as the production pipeline.
|
||||
|
||||
## NPM_CONFIG_PREFIX gotcha (variant override pattern)
|
||||
|
||||
The base sets
|
||||
|
||||
```
|
||||
ENV NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global
|
||||
```
|
||||
|
||||
This is intentional — it makes `pi install npm:<pkg>` and `npm install -g`
|
||||
land on the `devbox-pi-config` named volume at runtime, so user-installed
|
||||
packages survive container recreate AND image rebuild.
|
||||
|
||||
But the *variant build* inherits this prefix at build time. If left as-is,
|
||||
`npm install -g opencode-ai@$VERSION` in `Dockerfile.variant` would
|
||||
install opencode into `/home/developer/.pi/npm-global/...`, which is then
|
||||
**shadowed by the volume mount at runtime** → opencode disappears from
|
||||
PATH on first start.
|
||||
|
||||
Fix: each `npm install -g` in `Dockerfile.variant` overrides the prefix
|
||||
per-RUN:
|
||||
|
||||
```dockerfile
|
||||
RUN NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION}
|
||||
```
|
||||
|
||||
Baked binaries land on `/usr/bin/...` (system prefix), survive the volume
|
||||
mount. Runtime-installed user packages still land on
|
||||
`~/.pi/npm-global/...`. Both visible on PATH.
|
||||
|
||||
## Cache strategy
|
||||
|
||||
Two registry caches are configured:
|
||||
|
||||
```yaml
|
||||
cache-from: type=registry,ref=joakimp/opencode-devbox:base-buildcache
|
||||
cache-to: type=registry,ref=joakimp/opencode-devbox:base-buildcache,mode=max
|
||||
|
||||
cache-from: type=registry,ref=joakimp/opencode-devbox:base-variant-buildcache
|
||||
cache-to: type=registry,ref=joakimp/opencode-devbox:base-variant-buildcache,mode=max
|
||||
```
|
||||
|
||||
`mode=max` exports cache for *all* layers, not just the final image's
|
||||
layers. Important for multi-arch builds where the cross-arch layer reuse
|
||||
matters more.
|
||||
|
||||
## Wall-clock estimates
|
||||
|
||||
| Scenario | Production pipeline | Split-base pipeline |
|
||||
|---|---|---|
|
||||
| Version-bump-only release (only opencode/pi/omos version changed) | ~165–180 min | **~30–40 min** (base cache hit) |
|
||||
| Base-touching release (apt/Node/Debian/entrypoint change) | ~165–180 min | **~70–90 min** (base rebuilds) |
|
||||
|
||||
The split-base pipeline pays its dues on base-touching releases (which are
|
||||
infrequent — a few times a year for Debian / Node major version bumps).
|
||||
Most releases are version-bumps and ride the cache.
|
||||
|
||||
## Validate workflow
|
||||
|
||||
[`validate.yml`](workflows/validate.yml) is the lightweight gate that runs
|
||||
on every push to `main` and on PRs. It:
|
||||
|
||||
1. Runs `scripts/generate-dockerhub-md.py --check` to enforce
|
||||
`DOCKER_HUB.md` is in sync with `HUB_TEMPLATE`.
|
||||
2. Builds each of the five variants amd64-only (no multi-arch, no push)
|
||||
and runs `scripts/smoke-test.sh`.
|
||||
|
||||
This catches regressions before they reach a tag push. Wall clock ~30 min.
|
||||
|
||||
## Runner expectations
|
||||
|
||||
- **Image:** `catthehacker/ubuntu:act-latest`. Each job runs inside a
|
||||
fresh container of this image. Don't assume any pre-installed
|
||||
toolchains beyond what catthehacker ships.
|
||||
- **Disk pressure:** the runner host has ~40 GB of usable overlay space,
|
||||
often 70%+ used at job start. Every job that does `load: true` (smoke)
|
||||
starts with a `Reclaim runner disk` step that strips
|
||||
catthehacker-resident toolchains (Android SDK, .NET, Swift, GHC, JVM,
|
||||
Boost, Chromium, PowerShell) and prunes stale docker state. Don't
|
||||
remove these steps without testing on a fresh runner.
|
||||
- **Concurrency:** 2 runners. Jobs in the same workflow run can fan out to
|
||||
both; jobs in *different* workflow runs are serialized by gitea's queue.
|
||||
The `concurrency: { group: ${{ workflow }}-${{ ref }}, cancel-in-progress: false }`
|
||||
setting keeps tag pushes from racing each other but allows
|
||||
per-PR/per-branch parallelism.
|
||||
- **Workflow visibility in UI:** gitea Actions only surfaces workflows
|
||||
from the **default branch** in the web UI's workflow list, even for
|
||||
`workflow_dispatch` triggers. Workflows on feature branches are
|
||||
invisible until merged to `main`.
|
||||
- **Disk reclaim quirk:** `actions/{upload,download}-artifact@v4+` does
|
||||
not work on Gitea (depends on a GitHub-only Artifact API). Stick to
|
||||
`@v3` if matrix-fanout-with-artifacts is ever needed. We avoided this
|
||||
by using `docker/build-push-action@v7` with comma-separated
|
||||
`platforms: linux/amd64,linux/arm64` — natively does multi-arch push
|
||||
in a single job, no artifact dance.
|
||||
|
||||
## Migration plan: split-base → production
|
||||
|
||||
1. **Validate the split-base dispatch.** Trigger
|
||||
`docker-publish-split.yml` manually with `release_tag=v0.0.0-split-test`
|
||||
and `promote_latest=false`. Confirm all jobs go green, image sizes
|
||||
match the production baseline within ~10%, and no unexpected layer
|
||||
rebuilds appear in `build-variant-*` logs after the FROM line.
|
||||
2. **Run a second dispatch** to confirm cache-hit behavior:
|
||||
`base-decide` should set `need_build=false`, `build-base` should be
|
||||
skipped entirely, total wall clock should drop to ~25–40 min.
|
||||
3. **Cut over** — *done as of v1.14.50.* `docker-publish-split.yml` now
|
||||
triggers on `push: tags: v*`. `docker-publish.yml` and original
|
||||
`Dockerfile` deleted.
|
||||
4. **Tag a release.** First production release on the new pipeline.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [`AGENTS.md`](../AGENTS.md) — domain facts, release-day checklist,
|
||||
documentation coupling rules. Read first when modifying CI behavior.
|
||||
- [`CHANGELOG.md`](../CHANGELOG.md) — build pipeline rewrite landed in v1.14.50.
|
||||
- `Dockerfile.base`, `Dockerfile.variant` — the split-base Dockerfiles.
|
||||
Comments at the top of each explain their role.
|
||||
- [`scripts/smoke-test.sh`](../scripts/smoke-test.sh) — invoked by all
|
||||
three workflows; this is the single source of truth for "what does a
|
||||
built image have to satisfy".
|
||||
- [`scripts/generate-dockerhub-md.py`](../scripts/generate-dockerhub-md.py)
|
||||
— generates `DOCKER_HUB.md` from `HUB_TEMPLATE`. `--check` enforces
|
||||
sync in `validate.yml`.
|
||||
@@ -1,13 +1,7 @@
|
||||
name: Publish Docker Image (split-base)
|
||||
name: Publish Docker Image
|
||||
|
||||
# Two-phase split-base build pipeline. Lives ALONGSIDE the original
|
||||
# docker-publish.yml during the migration window. Triggers only on
|
||||
# workflow_dispatch (manual) so it doesn't conflict with the production
|
||||
# tag-trigger pipeline.
|
||||
#
|
||||
# Once we've validated 1-2 successful runs and verified output
|
||||
# byte-for-byte against the original, this workflow takes over `on:
|
||||
# push: tags: v*` and the original is retired.
|
||||
# Two-phase split-base build pipeline. Replaces the original
|
||||
# docker-publish.yml single-Dockerfile pipeline.
|
||||
#
|
||||
# Pipeline shape:
|
||||
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
|
||||
@@ -22,14 +16,17 @@ name: Publish Docker Image (split-base)
|
||||
# 6. update-description patch Docker Hub description (unchanged).
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag to publish (e.g. v1.14.42-split). Pushed to Docker Hub as both the literal tag and `latest*`-aliases.'
|
||||
required: true
|
||||
default: 'v0.0.0-split-test'
|
||||
description: 'Release tag to publish (e.g. v1.14.50). Used only for workflow_dispatch runs — tag-triggered runs derive the tag from github.ref.'
|
||||
required: false
|
||||
default: ''
|
||||
promote_latest:
|
||||
description: 'Update latest/latest-omos/latest-with-pi/latest-omos-with-pi aliases (set false for test runs)'
|
||||
description: 'Update latest/* aliases (default true for tag-push, set false for manual test runs)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
@@ -40,6 +37,13 @@ concurrency:
|
||||
env:
|
||||
BUILDKIT_PROGRESS: plain
|
||||
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
|
||||
# The pi-only variant is built here (single source of truth for the pi stack)
|
||||
# but published into the pi-devbox repo as an internal building-block tag,
|
||||
# NOT under opencode-devbox — so opencode-devbox never shows a tag with no
|
||||
# opencode in it. pi-devbox's own CI FROMs PI_IMAGE:base-pi-only.
|
||||
PI_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/pi-devbox
|
||||
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
|
||||
PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }}
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────
|
||||
# Reusable disk-reclaim snippet — strips catthehacker toolchains and
|
||||
@@ -64,10 +68,19 @@ jobs:
|
||||
run: |
|
||||
# Hash inputs that determine the base image's contents.
|
||||
# Order is fixed via `find -print0 | sort -z` for reproducibility.
|
||||
# Junk filters: __pycache__/*.pyc and macOS metadata (.DS_Store,
|
||||
# ._AppleDouble) are gitignored locally but still picked up by
|
||||
# `find rootfs -type f`, which would diverge the local hash from
|
||||
# CI's clean checkout. Exclude them defensively here.
|
||||
HASH=$(
|
||||
{
|
||||
cat Dockerfile.base
|
||||
find rootfs -type f -print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
find rootfs -type f \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '.DS_Store' \
|
||||
! -name '._*' \
|
||||
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
} | sha256sum | cut -c1-12
|
||||
)
|
||||
@@ -94,6 +107,60 @@ jobs:
|
||||
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
|
||||
fi
|
||||
|
||||
# ── Phase 1b: resolve floating npm versions (pi, omos) to concrete
|
||||
# versions so the variant build-args carry a different value when an
|
||||
# upstream package bumps. Without this, when PI_VERSION / OMOS_VERSION
|
||||
# default to 'latest', the docker/build-push-action build-arg string
|
||||
# is byte-identical across builds, so the resulting layer-hash is
|
||||
# identical, so the registry buildcache silently reuses the layer
|
||||
# from whatever pi/omos version was current when the cache was first
|
||||
# populated. Same class of bug as pi-devbox v0.74.0..v0.75.5 (fixed in
|
||||
# v0.75.5b 2026-05-23). Currently masked here because OPENCODE_VERSION
|
||||
# is hard-coded in Dockerfile.variant and bumps every release —
|
||||
# invalidating the parent-chain cache key for the pi/omos layers — but
|
||||
# that masking would fail the moment we cut a vN.N.Nb opencode-version-
|
||||
# unchanged release that only bumps pi or omos. Fix is preventative.
|
||||
resolve-versions:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
pi_version: ${{ steps.resolve.outputs.pi_version }}
|
||||
omos_version: ${{ steps.resolve.outputs.omos_version }}
|
||||
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
|
||||
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
|
||||
steps:
|
||||
- name: Resolve pi + omos versions from npm registry
|
||||
id: resolve
|
||||
run: |
|
||||
set -eu
|
||||
# Query the npm registry directly via curl+jq rather than `npm view`.
|
||||
# catthehacker/ubuntu:act-latest ships Node/npm under /opt/acttoolcache/
|
||||
# and adds it to PATH only via /etc/environment — which act_runner never
|
||||
# sources (it reads the Docker image's ENV instructions, not /etc/environment).
|
||||
# curl and jq are both guaranteed present in every job in this workflow.
|
||||
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version')
|
||||
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version')
|
||||
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
# Resolve the pi-fork / pi-observational-memory git refs (default
|
||||
# branch master) to concrete commit SHAs so the build-arg string
|
||||
# changes whenever upstream moves — defeating the same registry-
|
||||
# buildcache cache-hit footgun that PI_VERSION/OMOS_VERSION guard
|
||||
# against. The Accept: application/vnd.github.sha media type returns
|
||||
# the bare SHA. Falls back to the branch name if the API is
|
||||
# unreachable/rate-limited (still functional, just cache-stale-prone).
|
||||
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || echo "master")
|
||||
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || echo "master")
|
||||
[ -n "$FORK_REF" ] || FORK_REF=master
|
||||
[ -n "$OBSMEM_REF" ] || OBSMEM_REF=master
|
||||
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved PI_VERSION=${PI_VERSION}, OMOS_VERSION=${OMOS_VERSION}"
|
||||
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
|
||||
|
||||
# ── Phase 2: build & push base (multi-arch), only when needed ──────
|
||||
build-base:
|
||||
needs: [base-decide]
|
||||
@@ -140,17 +207,44 @@ jobs:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base (multi-arch)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
# Registry cache for faster repeat base rebuilds (e.g. Node bump).
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:base-buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:base-buildcache,mode=max
|
||||
- name: Build and push base (multi-arch) — with retry
|
||||
shell: bash
|
||||
env:
|
||||
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 3-attempt retry around `docker buildx build --push` for transient
|
||||
# registry-1.docker.io blips. Does NOT mask deterministic failures:
|
||||
# a true regression (e.g. cache-export 400 hit 2026-05-23..28) will
|
||||
# fail all 3 attempts identically and the job still fails — by
|
||||
# design.
|
||||
# Registry cache disabled: buildkit's cache-export (mode=max) hits a
|
||||
# reproducible HTTP 400 from registry-1.docker.io on the resumable-
|
||||
# upload PUT (state-token format mismatch on Hub CDN, suspected to
|
||||
# have started ~2026-05-23). Image push itself works fine. We pay
|
||||
# the full base build on every Dockerfile.base change, but the base
|
||||
# tag itself is content-addressed (base-<hash>) so unchanged bases
|
||||
# short-circuit at the probe step and never re-build anyway. Re-
|
||||
# enable when upstream resolves; tracked in CHANGELOG v1.15.12.
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.base \
|
||||
--push \
|
||||
--tag "${BASE_TAG_FULL}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
# ── Phase 3: amd64 smoke per variant (gates the multi-arch publish) ─
|
||||
# Each smoke job builds amd64-only against the base tag and runs
|
||||
@@ -203,10 +297,11 @@ jobs:
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
|
||||
|
||||
smoke-omos:
|
||||
needs: [base-decide, build-base]
|
||||
needs: [base-decide, build-base, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
@@ -241,13 +336,17 @@ jobs:
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=false
|
||||
- run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
|
||||
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
|
||||
- env:
|
||||
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
|
||||
|
||||
smoke-with-pi:
|
||||
needs: [base-decide, build-base]
|
||||
needs: [base-decide, build-base, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
@@ -282,13 +381,20 @@ jobs:
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=true
|
||||
- run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
- env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
STRICT_REGISTRATION: "1"
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
|
||||
|
||||
smoke-omos-with-pi:
|
||||
needs: [base-decide, build-base]
|
||||
needs: [base-decide, build-base, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
@@ -323,7 +429,63 @@ jobs:
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=true
|
||||
- run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
|
||||
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
- env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||||
STRICT_REGISTRATION: "1"
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
|
||||
|
||||
smoke-pi-only:
|
||||
needs: [base-decide, build-base, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
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-pi-only
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=false
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=true
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
- env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
STRICT_REGISTRATION: "1"
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-pi-only --variant pi-only
|
||||
|
||||
# ── Phase 4: multi-arch publish per variant ────────────────────────
|
||||
|
||||
@@ -354,27 +516,51 @@ jobs:
|
||||
- name: Compute version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ inputs.release_tag }}"
|
||||
TAGS="${IMAGE}:${VERSION}"
|
||||
if [ "${{ inputs.promote_latest }}" = "true" ]; then
|
||||
TAGS="${TAGS}\n${IMAGE}:latest"
|
||||
fi
|
||||
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=false
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry around `docker buildx build --push` (see build-base
|
||||
# step for full rationale). Variant: base (opencode only).
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.variant \
|
||||
--push \
|
||||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||||
--build-arg "INSTALL_OPENCODE=true" \
|
||||
--build-arg "INSTALL_OMOS=false" \
|
||||
--build-arg "INSTALL_PI=false" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
build-variant-omos:
|
||||
needs: [base-decide, smoke-omos]
|
||||
needs: [base-decide, smoke-omos, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -400,27 +586,52 @@ jobs:
|
||||
- name: Compute version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ inputs.release_tag }}"
|
||||
TAGS="${IMAGE}:${VERSION}-omos"
|
||||
if [ "${{ inputs.promote_latest }}" = "true" ]; then
|
||||
TAGS="${TAGS}\n${IMAGE}:latest-omos"
|
||||
fi
|
||||
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=false
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}-omos"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest-omos"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry (see build-base step for rationale). Variant: omos.
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.variant \
|
||||
--push \
|
||||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||||
--build-arg "INSTALL_OPENCODE=true" \
|
||||
--build-arg "INSTALL_OMOS=true" \
|
||||
--build-arg "INSTALL_PI=false" \
|
||||
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
build-variant-with-pi:
|
||||
needs: [base-decide, smoke-with-pi]
|
||||
needs: [base-decide, smoke-with-pi, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -446,27 +657,56 @@ jobs:
|
||||
- name: Compute version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ inputs.release_tag }}"
|
||||
TAGS="${IMAGE}:${VERSION}-with-pi"
|
||||
if [ "${{ inputs.promote_latest }}" = "true" ]; then
|
||||
TAGS="${TAGS}\n${IMAGE}:latest-with-pi"
|
||||
fi
|
||||
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}-with-pi"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest-with-pi"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry (see build-base step for rationale). Variant: with-pi.
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.variant \
|
||||
--push \
|
||||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||||
--build-arg "INSTALL_OPENCODE=true" \
|
||||
--build-arg "INSTALL_OMOS=false" \
|
||||
--build-arg "INSTALL_PI=true" \
|
||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
build-variant-omos-with-pi:
|
||||
needs: [base-decide, smoke-omos-with-pi]
|
||||
needs: [base-decide, smoke-omos-with-pi, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -492,24 +732,133 @@ jobs:
|
||||
- name: Compute version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ inputs.release_tag }}"
|
||||
TAGS="${IMAGE}:${VERSION}-omos-with-pi"
|
||||
if [ "${{ inputs.promote_latest }}" = "true" ]; then
|
||||
TAGS="${TAGS}\n${IMAGE}:latest-omos-with-pi"
|
||||
fi
|
||||
echo -e "tags<<EOF\n${TAGS}\nEOF" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}-omos-with-pi"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest-omos-with-pi"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||||
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry (see build-base step for rationale). Variant: omos-with-pi.
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.variant \
|
||||
--push \
|
||||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||||
--build-arg "INSTALL_OPENCODE=true" \
|
||||
--build-arg "INSTALL_OMOS=true" \
|
||||
--build-arg "INSTALL_PI=true" \
|
||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
|
||||
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
build-variant-pi-only:
|
||||
needs: [base-decide, smoke-pi-only, resolve-versions]
|
||||
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:
|
||||
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 }}
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Compute version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
# Option B: push the pi-only build into the pi-devbox repo as an
|
||||
# internal building-block tag (base-pi-only[-<version>]), NOT under
|
||||
# opencode-devbox. pi-devbox's CI FROMs ${PI_IMAGE}:base-pi-only.
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${PI_IMAGE}:base-pi-only-${VERSION}"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${PI_IMAGE}:base-pi-only"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry (see build-base step for rationale). Variant: pi-only.
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.variant \
|
||||
--push \
|
||||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||||
--build-arg "INSTALL_OPENCODE=false" \
|
||||
--build-arg "INSTALL_OMOS=false" \
|
||||
--build-arg "INSTALL_PI=true" \
|
||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
# ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
|
||||
promote-base-latest:
|
||||
@@ -519,12 +868,41 @@ jobs:
|
||||
- build-variant-omos
|
||||
- build-variant-with-pi
|
||||
- build-variant-omos-with-pi
|
||||
if: inputs.promote_latest == 'true'
|
||||
- build-variant-pi-only
|
||||
# Skip on cache-hit base builds: when need_build=false, base-latest
|
||||
# already points at the same digest as base-<hash>, so the retag is
|
||||
# a tautology and any transient failure of it is purely cosmetic.
|
||||
# Manual workflow_dispatch with promote_latest=true overrides this
|
||||
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
|
||||
#
|
||||
# `always()` wrapper + explicit base-variant success check protects
|
||||
# against the gitea-Actions default of "skipped need => skip dependent":
|
||||
# a partial-publish run (e.g., omos-with-pi smoke fails) shouldn't
|
||||
# prevent the base-latest alias from advancing on a real base rebuild.
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-variant-base.result == 'success' &&
|
||||
(inputs.promote_latest == 'true' ||
|
||||
(github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true'))
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: imjasonh/setup-crane@v0.4
|
||||
# Direct pinned install instead of imjasonh/setup-crane@v0.4. The
|
||||
# action's bootstrap script calls api.github.com/.../releases/latest
|
||||
# to discover the crane version, which periodically rate-limits and
|
||||
# produces tag=null → download from .../download/null/... → 404 →
|
||||
# 'gzip: unexpected end of file' → exit 2. Pinning removes the
|
||||
# runtime dependency on GitHub API entirely. Bump CRANE_VERSION
|
||||
# deliberately when you want updates.
|
||||
- name: Install crane (pinned)
|
||||
env:
|
||||
CRANE_VERSION: v0.21.6
|
||||
run: |
|
||||
set -eux
|
||||
curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin crane
|
||||
crane version
|
||||
- name: Login (crane)
|
||||
run: |
|
||||
crane auth login docker.io \
|
||||
@@ -543,7 +921,18 @@ jobs:
|
||||
- build-variant-omos
|
||||
- build-variant-with-pi
|
||||
- build-variant-omos-with-pi
|
||||
if: inputs.promote_latest == 'true'
|
||||
- build-variant-pi-only
|
||||
# Run when at least the base variant published — don't let a single
|
||||
# variant failure (e.g., omos-with-pi smoke threshold) prevent Hub
|
||||
# description refresh for the other variants that did publish.
|
||||
# Without this `always()` wrapper, gitea Actions' default behavior
|
||||
# of "skipped need => skip dependent" cascades from any failed/
|
||||
# skipped build-variant-* into update-description, and the Hub
|
||||
# description goes stale on partial-publish releases.
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-variant-base.result == 'success' &&
|
||||
(github.ref_type == 'tag' || inputs.promote_latest == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
@@ -1,581 +0,0 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# Serialize concurrent runs of the same workflow on the same ref so the
|
||||
# build jobs can't race `docker system prune` in the smoke gates
|
||||
# (pruning from one job can nuke another job's in-flight buildx cache).
|
||||
# cancel-in-progress: false — tag pushes are release events, we never
|
||||
# want to silently drop one.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# Plain progress output from BuildKit — critical for diagnosing stalls
|
||||
# inside arm64-under-QEMU builds where the default collapsed progress UI
|
||||
# hides which step is stuck.
|
||||
env:
|
||||
BUILDKIT_PROGRESS: plain
|
||||
|
||||
# Runner disk pressure notes:
|
||||
# Gitea Actions runners use `catthehacker/ubuntu:act-latest` on a shared host
|
||||
# with limited overlay space (~40 GB, often 70%+ used at start). Two jobs
|
||||
# per variant:
|
||||
# * smoke gate (amd64 only, `load: true` into local dockerd for smoke
|
||||
# testing) — peak disk = tarball + unpacked image + buildx cache. The
|
||||
# `Reclaim runner disk` step below strips catthehacker-resident
|
||||
# toolchains and prunes stale docker state before buildx starts.
|
||||
# * build job (amd64 + arm64, `push-by-digest` streaming directly to
|
||||
# Docker Hub, no local unpack). Peak disk on push-by-digest is
|
||||
# BuildKit's content store only — much smaller than `load: true`.
|
||||
# `docker/build-push-action@v7` with comma-separated platforms
|
||||
# publishes a proper multi-arch manifest in one step.
|
||||
#
|
||||
# Why not matrix + digest artifacts?
|
||||
# An earlier revision split each arch into its own matrix job and used
|
||||
# `actions/upload-artifact` to pass digests to a merge job. On Gitea
|
||||
# Actions, `actions/{upload,download}-artifact@v4+` fails with
|
||||
# `GHESNotSupportedError` — v4 relies on a GitHub-specific Artifact
|
||||
# API that Gitea doesn't implement. Rather than downgrade to @v3 (the
|
||||
# last Gitea-compatible release) we collapsed back to single-job
|
||||
# multi-arch push. The matrix only helps when the build literally
|
||||
# cannot fit on one runner, which push-by-digest + reclaim no longer
|
||||
# hits for this image.
|
||||
#
|
||||
# Gitea Actions gotchas baked into this file:
|
||||
# * `actions/{upload,download}-artifact` must stay at @v3 on Gitea.
|
||||
# * Step scripts run under /bin/sh (dash) — no bash-isms like
|
||||
# ${VAR//a/b}. Use `tr` or explicit `shell: bash`.
|
||||
# * `docker/build-push-action@v7` with `platforms: a,b` works for
|
||||
# multi-arch push natively; no matrix/merge dance needed.
|
||||
|
||||
jobs:
|
||||
# ── Smoke test (amd64 only, gates the push jobs) ────────────────────
|
||||
smoke-base:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
# See docker-publish.yml preamble. `load: true` peak disk = tarball
|
||||
# + unpacked image + buildx cache; the image now crosses the 40 GB
|
||||
# runner overlay's starting headroom. Strip catthehacker-resident
|
||||
# toolchains and any stale docker state up front.
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker system df || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build and load amd64 image for smoke test
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: opencode-devbox:smoke-base
|
||||
|
||||
- name: Smoke test (amd64)
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
|
||||
|
||||
smoke-omos:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker system df || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build and load amd64 image for smoke test
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
INSTALL_OMOS=true
|
||||
tags: opencode-devbox:smoke-omos
|
||||
|
||||
- name: Smoke test (amd64)
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
|
||||
|
||||
smoke-with-pi:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker system df || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build and load amd64 image for smoke test
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
INSTALL_PI=true
|
||||
tags: opencode-devbox:smoke-with-pi
|
||||
|
||||
- name: Smoke test (amd64)
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
|
||||
|
||||
smoke-omos-with-pi:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker system df || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build and load amd64 image for smoke test
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=true
|
||||
tags: opencode-devbox:smoke-omos-with-pi
|
||||
|
||||
- name: Smoke test (amd64)
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
|
||||
|
||||
# ── Multi-arch push (single job per variant, comma-separated platforms) ─
|
||||
build-base:
|
||||
runs-on: ubuntu-latest
|
||||
needs: smoke-base
|
||||
timeout-minutes: 90
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
# Lighter reclaim than the smoke-gate version: push-by-digest
|
||||
# doesn't write to host dockerd, so `docker system prune` adds
|
||||
# little. BuildKit cache from prior runs is the thing to clear.
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest
|
||||
|
||||
build-omos:
|
||||
runs-on: ubuntu-latest
|
||||
needs: smoke-omos
|
||||
timeout-minutes: 90
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
INSTALL_OMOS=true
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-omos
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-omos
|
||||
|
||||
build-with-pi:
|
||||
runs-on: ubuntu-latest
|
||||
needs: smoke-with-pi
|
||||
timeout-minutes: 90
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
INSTALL_PI=true
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-with-pi
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-with-pi
|
||||
|
||||
build-omos-with-pi:
|
||||
runs-on: ubuntu-latest
|
||||
needs: smoke-omos-with-pi
|
||||
timeout-minutes: 90
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=true
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-omos-with-pi
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-omos-with-pi
|
||||
|
||||
update-description:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-base, build-omos, build-with-pi, build-omos-with-pi]
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update Docker Hub description
|
||||
run: |
|
||||
TOKEN=$(curl -s -X POST https://hub.docker.com/v2/auth/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"identifier":"${{ vars.DOCKERHUB_USERNAME }}","secret":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
|
||||
| jq -r .access_token)
|
||||
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
|
||||
echo "::error::Failed to authenticate with Docker Hub API"
|
||||
exit 1
|
||||
fi
|
||||
HTTP_CODE=$(jq -n \
|
||||
--rawfile full DOCKER_HUB.md \
|
||||
--arg short "Portable AI dev environment for opencode. Debian-based with git, Node.js, AWS CLI, and SSH support." \
|
||||
'{"full_description": $full, "description": $short}' | \
|
||||
curl -s -o /tmp/hub-response.txt -w "%{http_code}" -X PATCH \
|
||||
"https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox/" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @-)
|
||||
echo "Docker Hub API returned: $HTTP_CODE"
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "Response body:"
|
||||
cat /tmp/hub-response.txt
|
||||
echo "::error::Docker Hub description update failed with HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
@@ -2,8 +2,32 @@ name: Validate
|
||||
|
||||
# Lightweight validation on pushes to main. Builds single-arch (amd64),
|
||||
# runs the smoke test, and checks image size — without pushing anything
|
||||
# to Docker Hub. Tag pushes are handled by docker-publish.yml which
|
||||
# does the full multi-arch build-and-push.
|
||||
# to Docker Hub. Tag pushes are handled by docker-publish-split.yml which
|
||||
# does the full multi-arch split-base build-and-push.
|
||||
#
|
||||
# Trade-off: variant builds here use the published `base-latest` image
|
||||
# from Docker Hub as their parent, NOT a locally-built base. This is
|
||||
# because `docker/build-push-action@v7` runs each invocation in its own
|
||||
# buildx container context, so an image loaded into the host docker
|
||||
# daemon by step N is not visible to step N+1's buildx invocation.
|
||||
# Building base + variant in the same job would require either pushing
|
||||
# the base to a registry or sharing a buildx instance across steps — both
|
||||
# significantly more complex than just using the published base.
|
||||
#
|
||||
# Consequence: PRs/pushes that change Dockerfile.base, rootfs/, or
|
||||
# entrypoint*.sh are NOT exercised by this workflow. The release path
|
||||
# (docker-publish-split.yml on tag push) does build the new base, so
|
||||
# release tags are the gate that fully validates base-image changes.
|
||||
# The base-change-warning job below surfaces a runtime warning when this
|
||||
# blind-spot applies.
|
||||
#
|
||||
# Because of this, the fork/recall *registration* smoke checks (which depend on
|
||||
# the base entrypoint running `pi install /opt/<pkg>`) are warn-only here:
|
||||
# smoke-test.sh leaves STRICT_REGISTRATION unset on this path, so a base-latest
|
||||
# that lags the entrypoint in the current commit can't red the run with a false
|
||||
# negative. The release smoke jobs build the base fresh and set
|
||||
# STRICT_REGISTRATION=1 to enforce those checks. The build-time /opt +
|
||||
# node_modules checks stay hard in both paths.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -34,6 +58,33 @@ jobs:
|
||||
run: |
|
||||
python3 scripts/generate-dockerhub-md.py --check
|
||||
|
||||
base-change-warning:
|
||||
# Surfaces a warning when this commit changes base-image inputs
|
||||
# (Dockerfile.base, rootfs/, entrypoint*.sh). validate.yml uses
|
||||
# Hub's base-latest as the parent for variant builds, so changes to
|
||||
# those files are NOT exercised here — only release tags rebuild the
|
||||
# base via docker-publish-split.yml.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect base-input changes
|
||||
run: |
|
||||
set -e
|
||||
if ! git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
| grep -qE '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'; then
|
||||
echo "No base-image inputs changed in this commit — validate.yml fully exercises the published base-latest."
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::This commit changes base-image inputs (Dockerfile.base, rootfs/, or entrypoint*.sh). validate.yml uses Hub's base-latest as the parent for variant builds, so the new base is NOT exercised by this workflow. Cut a release tag, or run a workflow_dispatch of docker-publish-split.yml against a test tag (e.g. v0.0.0-base-test, promote_latest=false) for end-to-end validation of the new base."
|
||||
echo "Changed base-input files:"
|
||||
git diff --name-only HEAD~1 HEAD | grep -E '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'
|
||||
|
||||
validate-base:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
@@ -83,9 +134,12 @@ jobs:
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
BASE_IMAGE=joakimp/opencode-devbox:base-latest
|
||||
tags: opencode-devbox:ci-base
|
||||
|
||||
- name: Smoke test
|
||||
@@ -137,10 +191,12 @@ jobs:
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
BASE_IMAGE=joakimp/opencode-devbox:base-latest
|
||||
INSTALL_OMOS=true
|
||||
tags: opencode-devbox:ci-omos
|
||||
|
||||
@@ -193,10 +249,12 @@ jobs:
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
BASE_IMAGE=joakimp/opencode-devbox:base-latest
|
||||
INSTALL_PI=true
|
||||
tags: opencode-devbox:ci-with-pi
|
||||
|
||||
@@ -249,10 +307,12 @@ jobs:
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
BASE_IMAGE=joakimp/opencode-devbox:base-latest
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=true
|
||||
tags: opencode-devbox:ci-omos-with-pi
|
||||
@@ -260,3 +320,62 @@ jobs:
|
||||
- name: Smoke test
|
||||
run: |
|
||||
bash scripts/smoke-test.sh opencode-devbox:ci-omos-with-pi --variant omos-with-pi
|
||||
|
||||
validate-pi-only:
|
||||
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 pi-only image (amd64, load to local daemon)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
BASE_IMAGE=joakimp/opencode-devbox:base-latest
|
||||
INSTALL_OPENCODE=false
|
||||
INSTALL_PI=true
|
||||
tags: opencode-devbox:ci-pi-only
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
bash scripts/smoke-test.sh opencode-devbox:ci-pi-only --variant pi-only
|
||||
|
||||
@@ -2,35 +2,68 @@
|
||||
|
||||
## Project overview
|
||||
|
||||
Docker image packaging [opencode](https://opencode.ai) into a production-ready dev container. Two image variants (base and omos) are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfile, entrypoint scripts, docker-compose, documentation).
|
||||
Docker image packaging [opencode](https://opencode.ai) into a production-ready dev container. Image variants are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfiles, entrypoint scripts, docker-compose, documentation).
|
||||
|
||||
## File roles
|
||||
|
||||
- `Dockerfile` — production single-Dockerfile build. Variants are gated by build args: `INSTALL_OMOS` (Bun + multi-agent layer), `INSTALL_OPENCODE` (default true), `INSTALL_PI` (default false), `INSTALL_MEMPALACE` (default true). All GitHub-sourced binaries are pinned with version ARGs.
|
||||
- `Dockerfile.base` and `Dockerfile.variant` — **WIP, branch `feat/split-build` only.** Two-Dockerfile split-base build: base contains all variant-independent layers; variant `FROM`s the base and adds only opencode/omos/pi installs. Used by `docker-publish-split.yml` (workflow_dispatch only) for parallel testing alongside the production pipeline. See CHANGELOG `Unreleased` for the migration plan and trade-offs.
|
||||
- `Dockerfile.base` — variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published as `joakimp/opencode-devbox:base-<sha12>`. Rebuilt only when its content hash changes.
|
||||
- `Dockerfile.variant` — `FROM`s the base and adds only opencode/omos/pi installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, `INSTALL_PI`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. When `INSTALL_PI=true` it also clones `pi-fork` + `pi-observational-memory` (from `github.com/elpapi42`, refs `PI_FORK_REF`/`PI_OBSMEM_REF`) to `/opt` and runs `npm install` there at build time so the `fork`/`recall` extensions can load (a local-path `pi install` does not npm-install). The `pi-only` variant sets `INSTALL_OPENCODE=false`, `INSTALL_PI=true` — pi without opencode, the single source of truth for the separate `pi-devbox` image. It is built and smoke-tested here, but **published into the `joakimp/pi-devbox` repo** as the internal building-block tag `base-pi-only[-vX.Y.Z]` (NOT under `opencode-devbox`), so an opencode-devbox tag never ships without opencode.
|
||||
- `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.
|
||||
- `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), LAN-access setup (delegated to `setup-lan-access.sh`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, runtime `pi install /opt/{pi-fork,pi-observational-memory}` registration (idempotent), skillset auto-deploy from mounted skillset repo, OMOS setup.
|
||||
- `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` — host-OS-agnostic LAN reachability helper. Detects VM-backed hosts (macOS OrbStack / Docker Desktop, via `host.docker.internal` resolution) and generates a writable `~/.ssh-local/config` using the host as an SSH jump; no-op on native Linux. Controlled by `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_HOST_ALIAS` / `DEVBOX_LAN_AUTOJUMP_PRIVATE`. Ships the mechanism only (generic `host` jump alias); user targets stay host-side — named-peer `ProxyJump host` overrides go in a bind-mounted `~/.config/devbox-shell/ssh-lan.conf` (Included before `~/.ssh/config`), never baked into the image. **Scoping invariant:** every `Include` in the generated config MUST be preceded by a bare `Host *` reset — an `Include` is scoped to the enclosing `Host`/`Match` block, so without the reset the included config only applies when targeting `host`/`mac` and named peers fall back to SSH defaults. The top `Host *` block also overrides `UserKnownHostsFile` and `ControlPath` into the writable `~/.ssh-local` sidecar (first-value-wins), because the bind-mounted `~/.ssh` is read-only — otherwise multiplexed hosts (`ControlPath ~/.ssh/cm/...`) fail to create their master socket. Non-fatal. Counted in the base hash, so editing it advances `base-latest`.
|
||||
- `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 a hand-maintained `HUB_TEMPLATE` constant. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow).
|
||||
- `DOCKER_HUB.md` — **auto-generated** from `HUB_TEMPLATE` in `scripts/generate-dockerhub-md.py`. Do not edit directly. Pushed to Docker Hub description via CI API call. Must stay under 25 kB. Short description field must be ≤100 bytes.
|
||||
- `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth.
|
||||
- `.gitea/README.md` — **read this first** if you're touching CI. Architectural overview of the build pipeline (production vs split-base), wall-clock estimates, NPM_CONFIG_PREFIX gotcha, runner expectations, migration plan.
|
||||
- `.gitea/workflows/validate.yml` — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check.
|
||||
- `.gitea/workflows/docker-publish.yml` — production CI pipeline on tag push: smoke-test each variant on amd64, then full multi-arch (amd64 + arm64) build-and-push, then update Docker Hub description.
|
||||
- `.gitea/workflows/docker-publish-split.yml` — **WIP, branch `feat/split-build` only.** Two-phase split-base pipeline. Triggers on `workflow_dispatch` only so it runs alongside the production pipeline without conflict. Pushes to user-supplied `release_tag` input (e.g. `v0.0.0-split-test`); `latest*` aliases only updated when `promote_latest: true`. Compute base hash, conditionally build base, then 4 variant deltas in parallel.
|
||||
- `.gitea/workflows/docker-publish-split.yml` — production CI pipeline on tag push (`v*`). Two-phase split-base: computes base hash, conditionally builds base, runs 5 parallel smoke tests, then 5 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description.
|
||||
|
||||
## Versioning scheme
|
||||
|
||||
Tags follow `v{opencode_version}[letter]` — e.g. `v1.14.20` for the first build on a new opencode release, and `v1.14.20b`, `v1.14.20c`, … for subsequent rebuilds on the same opencode version.
|
||||
|
||||
- The number tracks the opencode npm version (see `OPENCODE_VERSION` ARG in `Dockerfile`).
|
||||
- The number tracks the opencode npm version (see `OPENCODE_VERSION` ARG in `Dockerfile.variant`).
|
||||
- **No letter suffix** on the first build of a new opencode version — the bare `v{opencode_version}` tag is the canonical release.
|
||||
- **Letter suffix is the build ordinal**, starting at `b` for the second build. The letter `a` is **never used** — think of the suffix as counting rebuilds: `b = 2nd, c = 3rd, d = 4th, …`. For opencode version `1.14.20`: first build `v1.14.20`, second `v1.14.20b`, third `v1.14.20c`, and so on.
|
||||
- A letter suffix is only used for container-level rebuilds — tooling changes, CVE fixes, doc-driven rebuilds, entrypoint bugfixes — that don't change the underlying opencode version.
|
||||
- **Pre-flight check before cutting any non-letter-suffixed tag** — verify the bump is real:
|
||||
```bash
|
||||
npm view opencode-ai version # must equal the X.Y.Z in your tag
|
||||
```
|
||||
If the npm version equals the *previous* release's `X.Y.Z`, you're cutting a letter-suffix rebuild (`vX.Y.Zc`, `vX.Y.Zd`, …), not a new minor. **A bare `vX.Y.Z` tag is a claim that opencode upstream just released `X.Y.Z`** — if that claim is wrong, future opencode releases will collide with your tag namespace and the version-tracking story breaks.
|
||||
|
||||
Cautionary example: 2026-05-28 morning, `v1.15.12` was cut while opencode-ai was still at `1.15.11`. The commit message itself acknowledged "OPENCODE_VERSION stays at 1.15.11" but tagged `v1.15.12` anyway. Re-cut as `v1.15.11c` the same afternoon (see CHANGELOG). The `v1.15.12` git tag and Hub images stayed as historical artifacts; the slip cost a CI cycle and a CHANGELOG-rewrite. **Run the npm view check at the top of every release-day cut.**
|
||||
|
||||
CI produces eight Docker Hub tags per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`, `vX.Y.Z[n]-with-pi`, `latest-with-pi`, `vX.Y.Z[n]-omos-with-pi`, `latest-omos-with-pi` — one tag pair (versioned + floating alias) per build variant.
|
||||
CI produces eight Docker Hub tags **under `opencode-devbox`** 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 opencode-bearing variant (four variants). A fifth build, `pi-only`, is built+smoked here but pushed into the **`joakimp/pi-devbox`** repo as `base-pi-only-vX.Y.Z` (+ `base-pi-only` on tag builds), where it becomes the base for that image.
|
||||
|
||||
When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile` and update the comment in `.env.example` if it names a specific model/version for context.
|
||||
When bumping the opencode version, bump `OPENCODE_VERSION` in `Dockerfile.variant` and update the comment in `.env.example` if it names a specific model/version for context.
|
||||
|
||||
## Upstream sources — where to look up release notes
|
||||
|
||||
When drafting a release CHANGELOG entry, pull notes from the **canonical upstream repo for each tracked package**. Getting this wrong leads to thin or wrong release notes; the image bytes are unaffected but the documentation suffers.
|
||||
|
||||
| Package | Canonical upstream | What you'll find there |
|
||||
|---|---|---|
|
||||
| `opencode-ai` (npm) | <https://github.com/anomalyco/opencode/releases> | Per-version release notes with Core / TUI / Desktop / SDK sections, contributor attributions. Some versions have empty bodies (internal/no-user-visible); most do not. |
|
||||
| `@earendil-works/pi-coding-agent` (npm) | The `CHANGELOG.md` shipped inside the npm tarball: `npm pack @earendil-works/pi-coding-agent@<version>` then extract `package/CHANGELOG.md`. | Rich changelog with New Features / Added / Changed / Fixed sections per version. |
|
||||
| Other floated tools (gosu, fzf, bat, eza, zoxide, uv, nvim, gitea-mcp, Go, oh-my-opencode-slim) | Each project's own GitHub releases page | Usually less material per release; quote selectively. |
|
||||
|
||||
**Trap to avoid:** there is a `github.com/sst/opencode` repo that some search results surface; that's a fork (and probably the historical name people associate with opencode given the upstream lineage). It does NOT track the same release timeline. Use `anomalyco/opencode` for opencode release notes.
|
||||
|
||||
Fetch pattern (saved here for muscle memory):
|
||||
|
||||
```bash
|
||||
# Latest stable opencode-ai versions on npm
|
||||
npm view opencode-ai time --json | python3 -c 'import sys,json,re; d=json.load(sys.stdin); print(*sorted([(v,t) for v,t in d.items() if re.fullmatch(r"\d+\.\d+\.\d+",v)], key=lambda x:x[1], reverse=True)[:6], sep="\n")'
|
||||
|
||||
# Release notes for a specific version
|
||||
curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 | python3 -c 'import sys,json; print(json.load(sys.stdin).get("body","(empty)"))'
|
||||
|
||||
# pi changelog
|
||||
cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil-works-pi-coding-agent-0.75.5.tgz package/CHANGELOG.md && head -40 package/CHANGELOG.md
|
||||
```
|
||||
|
||||
## Critical conventions
|
||||
|
||||
@@ -43,14 +76,19 @@ When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile`
|
||||
- `.env.example` must be hand-updated to match Dockerfile/entrypoint behavior — it is not auto-generated.
|
||||
|
||||
Release-day checklist: README → (regenerate DOCKER_HUB.md only if HUB_TEMPLATE changed) → promote CHANGELOG Unreleased → grep AGENTS.md for stale counts → commit → tag → push tag.
|
||||
- **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag.
|
||||
|
||||
**Between releases the same coupling applies.** Doc drift is not just a release-day concern — a workflow tweak, entrypoint change, or `generate-config.py` refactor can leave any of these four files lying. Before committing a non-release change, grep the docs for references to what you touched: `git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md DOCKER_HUB.md .gitea/README.md .env.example`. If a doc says "four variants" / "two phases" / "runs on amd64 only" and your change made that no longer true, fix it in the same commit.
|
||||
- **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, gitleaks, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64) — mind project-specific arch-name deviations (gitleaks uses `x64`, bat/eza/zoxide use `x86_64`/`aarch64`, gosu uses `amd64`/`arm64`). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag.
|
||||
- **Resolved versions are logged by the smoke test** — `scripts/smoke-test.sh` prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to `latest`.
|
||||
- **`PI_VERSION` and `OMOS_VERSION` MUST be passed by CI as concrete versions**, not left at the `latest` default. The npm install steps in `Dockerfile.variant` (`npm install -g @earendil-works/pi-coding-agent` / `oh-my-opencode-slim@${OMOS_VERSION}`) produce identical layer-hashes when the ARG values are byte-identical across builds; combined with the registry buildcache (`base-buildcache`) the layer gets reused even when `latest` would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently *masked* in opencode-devbox by `OPENCODE_VERSION` being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream pi/omos layers — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps pi or omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view @earendil-works/pi-coding-agent version` and `npm view oh-my-opencode-slim version`, exposing concrete values as outputs that every variant smoke + build job consumes via build-args. Smoke tests assert via `EXPECTED_PI_VERSION` / `EXPECTED_OMOS_VERSION` env vars — would catch the regression on the next release rather than four releases later. **If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.**
|
||||
- **Registry buildkit cache-export is currently disabled** — do NOT re-add `cache-from`/`cache-to` to the `build-base` step in `.gitea/workflows/docker-publish-split.yml` without first verifying that buildkit's `mode=max` cache-export to `registry-1.docker.io` no longer returns HTTP 400 from the Hub CDN edge. The regression surfaced ~2026-05-23 and broke five consecutive opencode-devbox publish attempts (runs #332/333/334/336 + a rerun); root-caused on 2026-05-28 by a manual host-side publish that reproduced the same 400 only on `--cache-to` while image push worked fine. Failure shape is stable (`Offset:0` in the `_state` token, HTML response body = CDN-tier rejection, not registry backend), repo-specific (we're the only repo writing `:base-buildcache` mode=max), and explains why pinning `setup-buildx-action@v4.0.0` didn't help (action pin doesn't change the bundled buildkit version on the catthehacker runner image). Trade-off: dockerfile.base changes pay a full ~3 min rebuild instead of pulling cached layers; unchanged bases short-circuit at the Hub-probe step in `base-decide` and never re-build anyway. Variants don't use registry cache so they're unaffected. Re-enable condition: upstream moby/buildkit fix lands AND a low-risk test run succeeds without 400s. See CHANGELOG v1.15.12 `Unreleased` block for the full diagnostic chain. Manual escape-hatch publish procedure: `docs/manual-host-publish.md`.
|
||||
- **Push steps wrap `docker buildx build --push` in a 3-attempt retry loop** (15s, 30s backoff) for transient `registry-1.docker.io` blips — rate limits, brief 5xx, CDN flap. Implemented as inline `shell: bash` steps with `docker buildx build` raw rather than `docker/build-push-action@v7` so the loop is visible and tweakable. Affects the 1 base + 5 variant push steps in `.gitea/workflows/docker-publish-split.yml`; smoke-test builds (`load: true`, no push) are untouched. **This does NOT mask deterministic failures** — a true regression (like the cache-export 400 of 2026-05-23..28) fails all 3 attempts identically and the job still fails. Orthogonal to the cache-export disablement above: cache-export was about a deterministic protocol mismatch, retry is about absorbing genuine transients. Both are belt-and-braces with the `ci-release-watcher` skill's transient-rerun heuristic. If you change the matrix of push steps, keep the retry wrapper consistent across them — the pattern is duplicated rather than factored out because Gitea Actions doesn't support reusable composite shell steps cleanly.
|
||||
- **Shell scripts use `set -euo pipefail`** — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with `|| true`.
|
||||
- **MemPalace install path** — installed via `uv tool install` into `/opt/uv-tools/mempalace/`. Both the `mempalace` CLI and the `mempalace-mcp` MCP server binary are shipped as entry points by the mempalace package itself and placed on PATH by uv as shims whose shebangs point at the venv's Python. No hand-rolled wrapper is needed. Do not use `pip install --break-system-packages` — that was the previous approach and has been removed. Do not use `["python3", "-m", "mempalace.mcp_server"]` in `opencode.jsonc` — system Python can't import from the uv venv.
|
||||
- **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.jsonc` or legacy `opencode.json`. Config persists in the `devbox-opencode-config` named volume; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this.
|
||||
- **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 @mariozechner/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 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.
|
||||
@@ -61,11 +99,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.
|
||||
- **Gitea Actions runner has ~40 GB disk, often 70%+ used at job start.** All ten `load: true` jobs (`validate-base`, `validate-omos`, `validate-with-pi`, `validate-omos-with-pi`, `validate-pi-only`, `smoke-base`, `smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`, `smoke-pi-only`) 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.
|
||||
- **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish-split.yml` so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive.
|
||||
|
||||
## Testing changes
|
||||
|
||||
|
||||
+519
-14
@@ -8,32 +8,537 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a
|
||||
|
||||
## Unreleased
|
||||
|
||||
Docs-only updates that don't trigger a new image build (Docker Hub description was patched live via the API without re-tagging):
|
||||
### Fixed: validate.yml false-negative on fork/recall registration checks
|
||||
|
||||
- **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 gains a tailored `pi (alternative/complementary harness)` section covering run, mempalace integration, and persistence — the full README pi section is too large to include verbatim under the 25 kB Hub limit, so a `replace` rule in the generator emits a Hub-tailored excerpt that links out to the gitea README anchor for full build args / extension list / toolkit detail. DOCKER_HUB.md size 24 862 bytes (138 byte headroom).
|
||||
- **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.
|
||||
- **Docs:** AGENTS.md tag-scheme paragraph corrected from "four Docker Hub tags per release" to eight (the v1.14.41b CI matrix expansion). Reclaim-disk job list updated from the four pre-pi jobs to all eight current `load: true` jobs.
|
||||
The push-to-main `validate.yml` builds variants FROM the published `base-latest`
|
||||
image, which lags the entrypoint in the current commit until a release tag
|
||||
rebuilds the base. The fork/recall *registration* smoke checks depend on the
|
||||
base entrypoint running `pi install /opt/<pkg>`, so a stale `base-latest` reded
|
||||
those runs with a false negative even when the variant layer was correct.
|
||||
`smoke-test.sh` now gates the two registration assertions behind
|
||||
`STRICT_REGISTRATION` (warn-only when unset). `validate.yml` leaves it unset;
|
||||
the release pipeline (`docker-publish-split.yml`), which builds the base fresh
|
||||
in the same run, sets `STRICT_REGISTRATION=1` on the pi-bearing smoke jobs to
|
||||
enforce them. The build-time `/opt` + `node_modules` checks stay hard in both
|
||||
paths.
|
||||
|
||||
Image changes (will ship in the next tagged release):
|
||||
### Added: persist the LAN-jump key + one-line authorize hint (authorize once per machine)
|
||||
|
||||
The jump keypair (`~/.ssh-local/devbox_jump_ed25519`) was stored on the
|
||||
container's ephemeral overlay, so `docker compose up --force-recreate` (every
|
||||
image update) regenerated it — forcing you to re-authorize the new key on the
|
||||
host each time. The compose files now persist `~/.ssh-local` via a named volume
|
||||
(`devbox-ssh-local`), matching the pattern already used for `.pi`, shell
|
||||
history, etc. The key is generated **once** and reused across updates, so you
|
||||
authorize it on the host **once per machine**.
|
||||
|
||||
`setup-lan-access.sh` now also prints a ready-to-paste authorize line whenever
|
||||
it generates a **new** key (not just when `HOST_SSH_USER` is unset), e.g.
|
||||
`echo 'ssh-ed25519 …' >> ~/.ssh/authorized_keys` — no helper file to locate, no
|
||||
workspace path to guess. It stays silent once the key is persisted.
|
||||
|
||||
### Fixed: chown the `devbox-ssh-local` volume so the jump key can be generated
|
||||
|
||||
The previous change persisted `~/.ssh-local` via a named volume, but the
|
||||
entrypoint's volume-ownership loop was never updated to include it. Docker
|
||||
creates named volumes as `root:root`, so on a fresh volume `~/.ssh-local`
|
||||
stayed root-owned while `setup-lan-access.sh` runs as `developer` — both its
|
||||
`mkdir cm` and `ssh-keygen` failed silently (`|| true` / `|| exit 0`), leaving
|
||||
**no jump key and no config**, breaking LAN access on the first recreate after
|
||||
the persistence change. `entrypoint.sh` now chowns `~/.ssh-local` to the
|
||||
developer user alongside the other named-volume mount points.
|
||||
|
||||
_(no other changes since v1.15.13d)_
|
||||
|
||||
## v1.15.13d — 2026-06-04
|
||||
|
||||
LAN-access fixes + ergonomics. Letter-suffix rebuild on opencode `1.15.13`
|
||||
(version unchanged). Touches `setup-lan-access.sh`, which is in the base hash,
|
||||
so `base-latest` / `base-pi-only` advance and the fix propagates to `pi-devbox`.
|
||||
|
||||
### Fixed: LAN-access `Include` was scoped to the `host`/`mac` block (named peers ignored)
|
||||
|
||||
The generated `~/.ssh-local/config` placed `Include ~/.ssh/config` *inside* the
|
||||
`Host host mac` block. Because SSH scopes an `Include` to the enclosing
|
||||
`Host`/`Match` block, the user's `~/.ssh/config` was only consulted when
|
||||
targeting `host`/`mac` — so `dssh pve` / `dssh <peer>` by name silently fell
|
||||
back to SSH defaults (wrong user, unresolved hostname) and never applied the
|
||||
peer's settings or any `ProxyJump`. Fixed by emitting a bare `Host *` scope
|
||||
reset before every `Include`.
|
||||
|
||||
### Fixed: read-only `~/.ssh/cm` ControlPath broke multiplexed hosts
|
||||
|
||||
The bind-mounted `~/.ssh/config` commonly sets `ControlPath ~/.ssh/cm/...`
|
||||
(CGNAT flow-cap multiplexing), but `~/.ssh` is read-only in the container, so
|
||||
every `ControlMaster`-enabled host (e.g. `pmx-jh`, `proxmox*`, `synlig`) failed
|
||||
with `cannot bind to path … Read-only file system`. The generated config now
|
||||
sets `ControlPath ~/.ssh-local/cm/%r@%h:%p` in the top `Host *` block
|
||||
(first-value-wins) so master sockets land in the writable sidecar.
|
||||
|
||||
### Added: host-owned `ssh-lan.conf` for named-peer jump overrides
|
||||
|
||||
When the host bind-mounts `~/.config/devbox-shell/ssh-lan.conf`, the generated
|
||||
config now Includes it *before* `~/.ssh/config`. Put `ProxyJump host` overrides
|
||||
there (first-value-wins inherits HostName/User/IdentityFile from `~/.ssh/config`)
|
||||
instead of editing the shared `~/.ssh/config` — which would break the host's own
|
||||
direct access to those peers and is read-only from the container anyway. New
|
||||
[`ssh-lan.conf.example`](ssh-lan.conf.example).
|
||||
|
||||
### Added: `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` opt-in RFC1918 auto-jump
|
||||
|
||||
Emits a catch-all that ProxyJumps any private (RFC1918) IP through the host, so
|
||||
bare `dssh user@<ip>` reaches whatever LAN the (roaming) host is currently on,
|
||||
without naming peers. Matches the typed address (not the resolved HostName), so
|
||||
named hosts carrying their own ProxyJump are unaffected; public IPs stay direct.
|
||||
|
||||
All three land in `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh`,
|
||||
which is counted in the base hash → advances `base-latest` and propagates to
|
||||
`pi-devbox` (built `FROM` the base).
|
||||
|
||||
## v1.15.13c — 2026-06-03
|
||||
|
||||
Follow-up to v1.15.13b: relocates the pi-only build out of the `opencode-devbox`
|
||||
repo (Option B) and fixes the base size threshold that blocked `promote-base-latest`.
|
||||
|
||||
### Changed: `pi-only` build now publishes to the `joakimp/pi-devbox` repo (not `opencode-devbox`)
|
||||
|
||||
The `pi-only` variant (added in v1.15.13b) was published under `opencode-devbox`
|
||||
as `latest-pi-only` / `vX.Y.Z-pi-only` — an "opencode-devbox" tag that contains
|
||||
**no opencode**, which confused users browsing the tag list.
|
||||
|
||||
- The `build-variant-pi-only` CI job now pushes the artifact into the
|
||||
**`joakimp/pi-devbox`** repo as `base-pi-only-vX.Y.Z` (+ floating `base-pi-only`
|
||||
on tag builds) instead of `opencode-devbox:*-pi-only`. New `PI_IMAGE` workflow env.
|
||||
- It is still built from the same `Dockerfile.variant` (single source of truth)
|
||||
and still smoke-tested by `smoke-pi-only` / `validate-pi-only` before publish.
|
||||
- `opencode-devbox` now publishes **eight** tags per release (four opencode-bearing
|
||||
variants) plus `base-latest`; the pi-only pair lives in the pi-devbox repo.
|
||||
- De-advertised the pi-only tag from the README, `DOCKER_HUB.md` (HUB_TEMPLATE),
|
||||
and AGENTS docs.
|
||||
- The old `opencode-devbox:latest-pi-only` / `vX.Y.Z-pi-only` tags from v1.15.13b
|
||||
are superseded and should be deleted from Docker Hub.
|
||||
|
||||
### Fixed: base image size threshold (unblocks `promote-base-latest`)
|
||||
|
||||
- Bumped the `base` variant smoke size threshold 2500 → 2600 MB. In the v1.15.13b
|
||||
run the base crept to 2506 MB (LAN-access script + updated entrypoint + apt
|
||||
drift) and tripped the deliberately zero-headroom 2500 ceiling, which failed
|
||||
`smoke-base` and cascaded into skipping `build-variant-base` **and**
|
||||
`promote-base-latest` — so `base-latest` never advanced. (`base-<hash>` and the
|
||||
omos/with-pi/omos-with-pi/pi-only variants did publish on the fresh base.)
|
||||
|
||||
## v1.15.13b — 2026-06-03
|
||||
|
||||
Container-level rebuild on opencode `1.15.13` (unchanged) and pi `0.78.0` (unchanged) — adds host-OS-agnostic LAN access, the `fork`/`recall` pi extensions, and a new `pi-only` variant. Letter-suffix release per the `v{opencode_version}[letter]` scheme since no upstream version moved.
|
||||
|
||||
### Added: host-OS-agnostic LAN access (base image)
|
||||
|
||||
The container can now reach LAN peers that the **host** can reach, regardless of host OS — addressing the macOS/Docker-Desktop limitation where a container in the Linux VM cannot see the host's directly-attached LAN.
|
||||
|
||||
- New `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh`, invoked (non-fatally) by `entrypoint-user.sh` on every start.
|
||||
- **Detection:** on VM-backed hosts (macOS OrbStack / Docker Desktop, Windows Docker Desktop — detected via `host.docker.internal` resolution) it generates a writable `~/.ssh-local/config` that uses the host as an SSH **jump**. On native Linux Docker (LAN reachable directly) it is a **no-op**.
|
||||
- **Mechanism, not policy:** ships a generic `host` (alias `mac`) jump entry + a generated jump key in the writable `~/.ssh-local/` sidecar (necessary because `~/.ssh` is bind-mounted read-only). Your own targets stay in your bind-mounted `~/.ssh/config` (add `ProxyJump host`), pulled in via `Include ~/.ssh/config`.
|
||||
- New env knobs: `DEVBOX_LAN_ACCESS` (`auto`|`jump`|`off`, default `auto`), `HOST_SSH_USER`, `DEVBOX_HOST_ALIAS`. When `HOST_SSH_USER` is unset the entrypoint prints the public key to authorize on the host.
|
||||
- New `dssh` / `dscp` aliases in `.bash_aliases` (wrap `ssh -F ~/.ssh-local/config`), guarded so they only appear when the jump config was generated.
|
||||
- Because this touches `Dockerfile.base` inputs (`rootfs/`, `entrypoint-user.sh`), the base image rebuilds and `base-latest` advances.
|
||||
|
||||
### Added: pi-fork (`fork`) + pi-observational-memory (`recall`) in pi variants
|
||||
|
||||
The `with-pi` and `omos-with-pi` variants now bake in two pi extensions from `github.com/elpapi42`:
|
||||
|
||||
- `Dockerfile.variant` clones both repos to `/opt/pi-fork` and `/opt/pi-observational-memory` and runs `npm install` there at **build** time (a local-path `pi install` does not npm-install, so deps must be present for the extension to load).
|
||||
- `entrypoint-user.sh` registers them at runtime via `pi install /opt/<pkg>` (instant, in-place, idempotent; `fork`/`recall` tools bind on the next pi start).
|
||||
- CI (`resolve-versions`) resolves the `master` HEAD of each repo to a concrete commit SHA and passes it as `PI_FORK_REF` / `PI_OBSMEM_REF` build-args — same registry-buildcache cache-hit guard used for `PI_VERSION` / `OMOS_VERSION`.
|
||||
- New build-args: `PI_FORK_REPO`, `PI_FORK_REF`, `PI_OBSMEM_REPO`, `PI_OBSMEM_REF`.
|
||||
- Smoke test asserts the `/opt` clones + baked `node_modules` exist and that both packages register in `settings.json`. Size thresholds bumped: `with-pi` 2700→2900 MB, `omos-with-pi` 3700→3900 MB (fork's `@earendil-works` peer deps add ~150 MB).
|
||||
|
||||
### Added: `pi-only` variant (basis for `pi-devbox`)
|
||||
|
||||
New fifth published variant built with `INSTALL_OPENCODE=false`, `INSTALL_PI=true` — pi + companions (toolkit, extensions, `fork`, `recall`) and all base tooling, but **without** opencode (~145 MB lighter than `with-pi`).
|
||||
|
||||
- Published as `latest-pi-only` / `vX.Y.Z-pi-only` (multi-arch). New CI jobs `smoke-pi-only` and `build-variant-pi-only`; wired into `promote-base-latest` / `update-description` needs.
|
||||
- This is the **single source of truth** for the separate [`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox) image, which now `FROM`s `latest-pi-only` instead of duplicating the pi-install logic. Lets pi-devbox stay lean and pi-focused while the install logic lives in one place.
|
||||
- Smoke size threshold: 2750 MB (`with-pi` minus opencode).
|
||||
|
||||
_Versions unchanged: opencode-ai `1.15.13`, pi `0.78.0` (both still latest at time of writing)._
|
||||
|
||||
## v1.15.13 — 2026-05-29
|
||||
|
||||
First container build on `opencode-ai@1.15.13` upstream release (published 2026-05-29). Also picks up pi `0.77.0` → `0.78.0` (resolved from npm at build time).
|
||||
|
||||
### Bumped: opencode-ai 1.15.12 → 1.15.13
|
||||
|
||||
**Core**
|
||||
- Gateway Anthropic Opus 4.7+ adaptive reasoning now keeps summarized thinking instead of returning empty thinking blocks (bugfix).
|
||||
- Sessions can now store custom metadata through the API and SDK ([@shantur](https://github.com/shantur)).
|
||||
- Config now loads from the opened location upward, so directory-specific settings and provider policies apply more predictably.
|
||||
|
||||
**TUI**
|
||||
- Wrapped inline tool rows now stay aligned, and failed inline tools can expand their error details in place (bugfix).
|
||||
|
||||
### Bumped: pi 0.77.0 → 0.78.0 (resolved from npm at build time)
|
||||
|
||||
See [pi-devbox v0.78.0](https://github.com/joakimp/pi-devbox/releases/tag/v0.78.0) for full pi release notes.
|
||||
|
||||
## v1.15.12 — 2026-05-29
|
||||
|
||||
First container build on the genuine `opencode-ai@1.15.12` upstream release (published 2026-05-28). Also bumps pi `0.76.0` → `0.77.0`.
|
||||
|
||||
> **Note on the `v1.15.12` git tag:** an earlier `v1.15.12` git tag existed at commit `be2a168` as a historical artifact from the 2026-05-28 versioning slip (re-cut as `v1.15.11c` once the slip was caught). The corresponding Hub `v1.15.12*` images were manually deleted at the time. Now that opencode upstream has actually released 1.15.12, the tag is being re-used at HEAD per the `v{opencode_version}[letter]` scheme — the old tag was force-overwritten locally and on origin. Commit `be2a168` and the v1.15.11c CHANGELOG block (which references the slip) remain in history.
|
||||
|
||||
### Bumped: opencode-ai 1.15.11 → 1.15.12
|
||||
|
||||
Notable upstream changes (from the [anomalyco/opencode v1.15.12 release](https://github.com/anomalyco/opencode/releases/tag/v1.15.12)):
|
||||
|
||||
- **Core** — ACP integrations can send prompts/slash-commands/usage updates through `acp-next`; experimental WebSocket transport for OpenAI Responses (`OPENCODE_EXPERIMENTAL_WEBSOCKETS=true`); adaptive reasoning enabled for Anthropic Opus 4.7+.
|
||||
- **Bugfixes** — colons allowed in passwords; faster warm `acp-next` model/config switches; OpenAI WebSocket response timeouts kept active with retries before fallback; `acp-next` permission prompts handled correctly; persisted session directory used for existing-session requests; remote workspace request bodies forwarded correctly; custom base URLs supported for OpenAI WebSocket Responses.
|
||||
- **TUI** — workspace management dialog; session navigation works while prompt modes are open; thinking spinner restored; subagent retry status surfaced; opening editors from non-Git project paths fixed.
|
||||
- **Desktop** — tab-layout setting; home empty state and V2 font usage improved; tab close buttons showing reliably.
|
||||
|
||||
### Bumped: pi 0.76.0 → 0.77.0
|
||||
|
||||
Notable upstream changes (from pi's CHANGELOG):
|
||||
|
||||
- **Claude Opus 4.8 support** — model metadata + adaptive-thinking coverage updated.
|
||||
- **Selective tool disablement** — `--exclude-tools` / `-xt` disables specific built-in, extension, or custom tools while keeping the rest available.
|
||||
- **Headless Codex subscription login** — `/login` can use device-code auth for ChatGPT Plus/Pro Codex subscriptions.
|
||||
- **Streaming-aware extension input** — `InputEvent.streamingBehavior` lets extensions distinguish idle prompts, mid-stream steers, and queued follow-ups.
|
||||
- **Bugfixes** — startup timing output excludes `createAgentSessionRuntime`; OpenRouter DeepSeek V4 `xhigh` reasoning preserved; SIGTERM/SIGHUP run extension `session_shutdown` cleanly; keyboard protocol negotiation ignores delayed terminal responses; Windows MSYS2 ucrt64 startup crash fixed; API-key/header config resolution treats plain strings as literals with `$ENV_VAR` interpolation; session disposal aborts in-flight work; numerous provider-specific reasoning/metadata fixes (Codex Responses replay, OpenAI/OpenRouter GPT-5.5 Pro, Kimi K2.6, Xiaomi Token Plan).
|
||||
|
||||
### Inheritance from base
|
||||
|
||||
No base change — `base-latest` is reused unchanged from v1.15.11c (`base-decide` short-circuits at the Hub-probe step). SSH ControlMaster on a writable socket path, gitleaks, and git-crypt continue to ride along from the base.
|
||||
|
||||
### Workflow status
|
||||
|
||||
This is the first opencode-version-bump publish exercising the afternoon-of-2026-05-28 workflow changes (cache-export removal + 3-attempt retry wrapper) end-to-end on a real upstream release. v1.15.11c proved the publish path mechanically; v1.15.12 is the first one with both an opencode bump and a pi bump driving fresh variant layers.
|
||||
|
||||
## v1.15.11c — 2026-05-28
|
||||
|
||||
**Re-cut of v1.15.12 to fix a versioning-scheme violation.** The morning's v1.15.12 release was tagged in error: `opencode-ai` stayed at `1.15.11` upstream (no 1.15.12 exists on npm), so per the project's `v{opencode_version}[letter]` scheme this should have been the third container build on opencode 1.15.11 — `v1.15.11c` — not a new minor version bump. The `v1.15.12` git tag and the eight `v1.15.12*` / `latest*` Docker Hub images remain as historical artifacts but are superseded by this release. Future builds on opencode 1.15.11 continue the letter sequence as `v1.15.11d`, `v1.15.11e`, … — v1.15.12 will only be reused if and when opencode upstream actually releases 1.15.12.
|
||||
|
||||
Content inherited from v1.15.12 (see that block below for the full diagnostic chain on the v4.0.0 pin disproof and the manual host-side publish):
|
||||
|
||||
- pi `0.75.5` → `0.76.0`.
|
||||
- `setup-buildx-action` pin reverted from `@v4.0.0` back to `@v4` (the v1.15.11b regression hypothesis was disproven).
|
||||
- Inheritance from base: SSH ControlMaster on a writable socket path, gitleaks, git-crypt.
|
||||
- Cache-hit silent same-bytes regression fix carried forward from v0.75.5b's pattern.
|
||||
|
||||
Additional changes since v1.15.12 (afternoon 2026-05-28 followup work):
|
||||
|
||||
### Hub-push regression — root cause identified, CI fixed
|
||||
|
||||
The `400 Bad request` from `registry-1.docker.io` that broke CI publishing across runs #332/333/334/336 (and forced v1.15.12 to ship via manual host-side push) is **buildkit's registry cache-export with `mode=max`**, not the image push itself.
|
||||
|
||||
**Diagnostic that nailed it:** the manual v1.15.12 publish from an Orbstack host reproduced the exact same 400 — but only on the cache-export step. Image layers pushed cleanly (911s for the base, all variants succeeded). Dropping `--cache-to` from the manual script let the publish complete. Running the same buildx version against the same Hub account from the same network, the only differential was cache export vs. image export.
|
||||
|
||||
This explains every observation:
|
||||
|
||||
- Failure shape stable across attempts (`Offset:0`, HTML body, CDN-tier rejection): cache-export protocol-level mismatch, not transient network or per-blob corruption.
|
||||
- Repo-specific (`joakimp/opencode-devbox` only): we're the only Hub repo currently writing a `:base-buildcache` tag with `mode=max`.
|
||||
- Started ~2026-05-23: lines up with buildx 0.34.x rolling out and bundling moby/buildkit v0.30.0, which changed the `_state` token format on resumable cache uploads.
|
||||
- Image push works fine: cache-export is a separate codepath using a different manifest/layer scheme.
|
||||
- Action-pin to `setup-buildx-action@v4.0.0` didn't help: that pin pulls older actions-toolkit, but the bundled buildkit was still 0.34.x via Buildx CLI on the runner image. Pin was correctly disproven by run #336.
|
||||
|
||||
### Workflow change
|
||||
|
||||
- **`.gitea/workflows/docker-publish-split.yml`** — registry cache (`cache-from`/`cache-to`) removed from the `build-base` step. Comment in place documenting the regression and the re-enable condition. Variants don't use registry cache so they're untouched. The base tag is content-addressed (`base-<hash>` derived from Dockerfile.base + rootfs/* + entrypoint*.sh) so unchanged bases short-circuit at the Hub-probe step in `base-decide` and never re-build anyway — the lost cache only affects the rare case of a Dockerfile.base change, where we now pay the full ~3 min build instead of pulling cached layers. Acceptable trade-off vs. broken publishes.
|
||||
|
||||
Next tag push (e.g. v1.15.13) is expected to publish cleanly via Gitea CI again. validate.yml on this main push will be the first real-time test of the smoke side; full publish path will be tested on the next opencode bump or by a deliberate letter-suffix re-tag.
|
||||
|
||||
### Status of earlier suspects
|
||||
|
||||
- ~~`setup-buildx-action@v4.1.0`~~ — disproven by v1.15.11b CI run #336 with v4.0.0 pin failing identically. Pin reverted in v1.15.12. Not the regressor.
|
||||
- ~~`@docker/actions-toolkit 0.79.0 → 0.90.0`~~ — rolled back via the action pin; same failure. Not the regressor.
|
||||
- ~~Account / repo / Hub-CDN globally~~ — local pushes from developer host succeed. Always was healthy.
|
||||
- ~~`catthehacker/ubuntu:act-latest`~~ / ~~act-runner egress~~ — manual publish from host reproduced the same 400, ruling out runner-side network. Not the cause.
|
||||
- **Confirmed:** buildkit cache-export protocol (mode=max) hitting Hub-CDN edge rejection. Workaround: don't export cache to registry. Long-term: track moby/buildkit upstream for protocol fix or switch to GHA cache (not portable to Gitea Actions).
|
||||
|
||||
### Docs: manual host-publish runbook + script archive
|
||||
|
||||
- `docs/manual-host-publish.sh` — the literal script that shipped v1.15.12 from a developer Mac via Orbstack, preserved as-is.
|
||||
- `docs/manual-host-publish.md` — runbook explaining when to reach for the escape hatch, the four constants to edit (`RELEASE_TAG`, `BASE_HASH`, `PI_VERSION`, `OMOS_VERSION`), three sources for `BASE_HASH` (CI's `base-decide` log = canonical, Hub `base-latest` probe, local recompute matching CI's exact recipe including `__pycache__`/`.DS_Store`/`._*` junk filters), and adaptations for pi-devbox / letter-suffix rebuilds / partial-failure single-variant recovery.
|
||||
- `AGENTS.md` — new Critical conventions bullet documenting that `cache-from`/`cache-to` is currently disabled, why, and the re-enable condition.
|
||||
|
||||
### CI: workflow-level retry around `docker buildx build --push`
|
||||
|
||||
All five push steps in `.gitea/workflows/docker-publish-split.yml` (1 base + 4 variants) are now wrapped in a 3-attempt retry loop with backoff (15s, 30s) as belt-and-braces against transient `registry-1.docker.io` blips. Replaces the `docker/build-push-action@v7` invocations with `shell: bash` steps that run `docker buildx build --push` directly so the loop is visible and tweakable. Smoke-test build steps (`load: true`, no push) are unchanged — they don't suffer from registry-side flakiness.
|
||||
|
||||
Does **not** mask deterministic failures: a true regression (e.g. the cache-export 400 documented above) will fail all 3 attempts identically and the job still fails by design. Belt-and-braces with the workflow-level retry-on-failure rerun heuristic in the `ci-release-watcher` skill, which catches transient-shaped runner-side failures separately. No image-side change.
|
||||
|
||||
### AGENTS.md addition: pre-flight scheme check
|
||||
|
||||
New "Versioning scheme" subsection documenting the **mandatory `npm view opencode-ai version` pre-flight check** before cutting any non-letter-suffixed tag, with this slip cited as the cautionary example.
|
||||
|
||||
---
|
||||
|
||||
## v1.15.12 — 2026-05-28
|
||||
|
||||
> **Note (2026-05-28 PM):** this tag violates the project's `v{opencode_version}[letter]` versioning scheme — there is no `opencode-ai@1.15.12` on npm; OPENCODE_VERSION stayed at 1.15.11 across this build. Re-cut as `v1.15.11c` at HEAD per the scheme. The git tag and Hub images for `v1.15.12*` remain as historical artifacts but are superseded by `v1.15.11c`. See the `v1.15.11c` block above for the corrected release notes.
|
||||
|
||||
Manual-published release. Reverts the `setup-buildx-action@v4.0.0` pin from v1.15.11b (hypothesis was disproven — see below) and bumps the bundled `pi-coding-agent` to 0.76.0 via the floating `PI_VERSION=latest` resolution.
|
||||
|
||||
### Why "manual-published"
|
||||
|
||||
v1.15.11b reproduced the exact same Hub `400 Bad request` regression as v1.15.11 (CI run #336, build-base failed twice including a Gitea auto-rerun), confirming `setup-buildx-action@v4.1.0` is **not** the regressor. After four consecutive identical CI failures across two days, the SSH-CM and gitleaks fixes were shipped by hand from a developer host's Orbstack/Docker-Desktop — a path we already knew worked in ~25s for the same multi-arch build to the same Hub account.
|
||||
|
||||
This release ships the same content the runner-side build would have shipped; it just bypasses the broken runner-network → Hub-CDN combo. CI auto-publishing remains broken pending separate runner-side investigation (see [AGENTS.md — known issues](AGENTS.md)).
|
||||
|
||||
### Workflow change
|
||||
|
||||
- **`.gitea/workflows/docker-publish-split.yml`** — all nine `setup-buildx-action@v4.0.0` pins reverted to `@v4`. The pin added no value (failure reproduced) and was holding us off action improvements.
|
||||
|
||||
### Bumped: pi-coding-agent (latest → 0.76.0)
|
||||
|
||||
`PI_VERSION=latest` in `Dockerfile.variant` resolves at build time. 0.76.0 was published 2026-05-27 20:03 UTC. No Dockerfile edit needed; floating-`latest` is intentional so each opencode-devbox release pulls the freshest pi without a manual bump.
|
||||
|
||||
### Hub-push regression — ruled out / still suspect
|
||||
|
||||
**Ruled out:**
|
||||
- `setup-buildx-action@v4.1.0` — v4.0.0 reproduces the failure identically.
|
||||
- `@docker/actions-toolkit 0.79.0 → 0.90.0` — rolled back via the action pin; same failure.
|
||||
- Account / repo / Hub-CDN globally — local pushes from a developer host succeed.
|
||||
- Multi-arch as such — pi-devbox v0.75.5b pushed multi-arch on 2026-05-23.
|
||||
|
||||
**Still suspect:**
|
||||
- `catthehacker/ubuntu:act-latest` runner image (floating, not pinned in workflows).
|
||||
- act-runner host network egress from `runner-2` (sustained CDN-edge rejection from this specific source IP).
|
||||
- buildx 0.34.x's signed `_state` token format hitting a Hub-edge WAF/length rule that didn't apply to 0.33.x.
|
||||
- Hub-side per-repo state for `joakimp/opencode-devbox` specifically (other Hub repos from the same account work).
|
||||
|
||||
Four failing runs share the exact failure shape: HTTP 400 with HTML body (CDN-tier, not registry backend) on the very first PUT (`Offset:0`) of the resumable layer-blob upload. UUIDs and `_state` signatures differ across attempts — only the failure pattern is stable.
|
||||
|
||||
---
|
||||
|
||||
## v1.15.11b — 2026-05-27
|
||||
|
||||
Container-level rebuild of v1.15.11. The original v1.15.11 release-day publish failed three times in a row (CI runs #332/333/334) with identical `400 Bad request` responses from `registry-1.docker.io` on the buildx layer-blob PUT. Build itself succeeded 30/30 each time; only the multi-arch push failed. Triaged on 2026-05-27 evening:
|
||||
|
||||
- **Local multi-arch buildx push from a developer host succeeds in ~25s** — same Hub account, same multi-arch path. Account, repo, and Hub-CDN are all healthy.
|
||||
- **Last known-good Gitea Actions Hub push: 2026-05-23 ~20:26 UTC** (`pi-devbox v0.75.5b`). All Gitea-runner-driven pushes since 2026-05-24 have failed identically.
|
||||
- **Smoking gun candidate:** `docker/setup-buildx-action@v4` floats to `v4.1.0` (published 2026-05-22 16:00 UTC). Action-resolver caches on the runner appear to have rolled forward to v4.1.0 sometime between the May 23 success and the first May 24 failure. v4.1.0 ships a newer bundled buildx/buildkit which may be using a different push protocol that trips Hub's CDN URI-length cap (the failing `_state` query string is ~1.4 KB).
|
||||
|
||||
### Workflow change
|
||||
|
||||
- **`.gitea/workflows/docker-publish-split.yml`** — all nine `docker/setup-buildx-action@v4` uses pinned to `@v4.0.0`. `setup-qemu-action@v3` left floating since QEMU wasn't in the suspected blast radius and was working on May 23. If v4.0.0 publishes cleanly we keep the pin and file an upstream buildkit/buildx issue documenting the regression.
|
||||
|
||||
No other source changes — same `OPENCODE_VERSION=1.15.11`, same `Dockerfile.base` and `Dockerfile.variant`, same SSH-CM bake, same gitleaks. v1.15.11 (the original tag) is preserved in the repo as a historical marker of the first publish attempt; v1.15.11b is the canonical release.
|
||||
|
||||
### v1.15.11
|
||||
|
||||
First release on opencode 1.15.11. Also bakes in four devbox-side fixes accumulated since v1.15.10 (SSH ControlMaster on a writable path, gitleaks added to base, CI resolve-versions hardening, CI cache-hit regression fix). Downstream pi-devbox inherits all of these on its next build against `base-latest`.
|
||||
|
||||
### Bumped: opencode 1.15.10 → 1.15.11
|
||||
|
||||
`OPENCODE_VERSION` ARG bumped in `Dockerfile.variant`. Highlights from the upstream release (full notes: <https://github.com/anomalyco/opencode/releases/tag/v1.15.11>):
|
||||
|
||||
- **Core / Improvements** — new `headerTimeout` config for provider requests (10s default for default OpenAI setups); experimental background agents now push updates without polling; remote-backed projects resolve a stable project identity; `modalities.input` / `modalities.output` can be set independently.
|
||||
- **Core / Bugfixes** — dynamically added MCP servers now disconnect cleanly on removal; Google tool calling fixed after upstream tool-ID regression; resumed sessions no longer continue orphaned interrupted tools; OpenAI reasoning summaries render as separate blocks; the `shell` tool now advertises its configured timeout to the model; config loading falls back cleanly when user info is unavailable.
|
||||
- **TUI** — prompt resizes with terminal width (new prompt-size config); accelerated diff-viewer scrolling; external editors open from the worktree directory when available.
|
||||
- **Desktop** — refined v2 home screen, prompt, status popover, and session controls; fixed V2 titlebar errors when a session sync cache was deleted; web deployments no longer run desktop health checks; duplicate server connections are merged.
|
||||
- **Extensions** — new `dispose` hook for plugins; Codex plugin now sends the expected session-ID header.
|
||||
|
||||
No `opencode-devbox`-side changes were required to consume 1.15.11 — pure version bump.
|
||||
|
||||
### Base: SSH ControlMaster default on a writable socket path
|
||||
|
||||
Devboxes typically mount `~/.ssh` from the host as **read-only** (security: keys remain readable but agents can't tamper with config / known_hosts / authorized_keys / plant a malicious ProxyCommand). OpenSSH's default `ControlPath` lands inside `~/.ssh/cm/`, which is unwritable on such mounts — so any attempt to use `ControlMaster auto` (or anything that wants to multiplex) fails with:
|
||||
|
||||
```
|
||||
unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
|
||||
kex_exchange_identification: Connection closed by remote host
|
||||
```
|
||||
|
||||
The second line is downstream: when ControlMaster fails the ssh client falls back to a fresh TCP connection, and on residential CGNAT (most European ISPs) the per-(src,dst) concurrent-flow cap (~4) silently drops further SYNs once exceeded — manifesting as banner-exchange timeouts that look like a remote problem.
|
||||
|
||||
- **`Dockerfile.base`** — new section right after the apt block bakes `/etc/ssh/ssh_config.d/00-devbox-controlmaster.conf` with `Host *` defaults: `ControlMaster auto`, `ControlPath /tmp/sshcm/%r@%h:%p`, `ControlPersist 10m`, plus `ServerAliveInterval 30` / `ServerAliveCountMax 6` for resilience to mid-stream NAT timeouts. `/tmp` is per-container and always writable, so the read-only `~/.ssh` mount is left untouched. Debian's stock `/etc/ssh/ssh_config` includes `ssh_config.d/*.conf` *before* its own `Host *` block, so user `~/.ssh/config` overrides still win.
|
||||
- **`entrypoint-user.sh`** — creates `/tmp/sshcm` mode 700 on every container start. `/tmp` is per-container so the dir doesn't survive recreation; baking it into a Dockerfile layer would be wrong. Mode 700 is required — OpenSSH refuses to use a `ControlPath` directory others can write to.
|
||||
- **`scripts/smoke-test.sh`** — two new assertions: (a) the conf file exists at the expected path; (b) `ssh -G example.invalid` reports a `controlpath` rooted at `/tmp/sshcm/`. The second catches the silent regression where something later in the SSH config chain shadows the bake-in.
|
||||
- **No size/threshold impact:** the conf file is ~250 bytes.
|
||||
|
||||
Downstream pi-devbox and any other variant inherits this on its next build against `base-latest`. Discovered while running a recon-shell from inside pi-devbox to a Proxmox node — fresh ssh hit banner timeout, debug output pointed at the read-only socket dir.
|
||||
|
||||
_(Originally landed on `main` 2026-05-24 as commit `668592d`; first ships in v1.15.11.)_
|
||||
|
||||
### Base: gitleaks added; git-crypt confirmed already installed
|
||||
|
||||
`gitleaks` is now baked into `Dockerfile.base` (Go-compiled binary fetched from GitHub releases, same `/releases/latest` redirect-resolution pattern as gosu/fzf/git-lfs/etc.). It pairs with `git-crypt`, which has been installed via apt all along but wasn't asserted by smoke or called out in user-facing docs. Several of the user's repos use both as part of their secret-management setup (gitleaks pre-commit hook + git-crypt for selectively-encrypted canonical config); having them in the devbox means `pi install`-style hooks fire correctly inside the container instead of warning that gitleaks is missing.
|
||||
|
||||
- **`Dockerfile.base`** — new `GITLEAKS_VERSION=latest` ARG + install RUN block right after `git-lfs`. Arch suffix is `x64` (not `x86_64` or `amd64`) on this project; comment in the Dockerfile flags the deviation. Adds ~21 MB to the base layer.
|
||||
- **`scripts/smoke-test.sh`** — adds `git-crypt` and `gitleaks` to the "Resolved component versions" table and to the "Core binaries" assertion list. Now fails fast if either binary disappears from the base.
|
||||
- **`README.md`** — "What's in the image" tree updated to name `gitleaks` alongside `git-crypt` in the dev-tools line.
|
||||
- **No threshold bumps:** 21 MB on a 2500–3700 MB envelope is noise; existing variant thresholds keep their headroom.
|
||||
|
||||
This is a base-layer change — `base-decide` will compute a fresh `base-<hash>`, `build-base` will run on the next release (no cache hit), and all four variants will rebuild against the new base. **Downstream pi-devbox** picks up gitleaks automatically on its next release that resolves `joakimp/opencode-devbox:base-latest` to the new digest — no Dockerfile change needed there.
|
||||
|
||||
### CI: preventative fix for PI_VERSION/OMOS_VERSION cache-hit silent regression
|
||||
|
||||
Mirrors the pi-devbox v0.75.5b fix (2026-05-23) onto the four-variant pipeline here. The `with-pi`, `omos`, and `omos-with-pi` variants all install upstream npm packages (`@earendil-works/pi-coding-agent`, `oh-my-opencode-slim`) whose `*_VERSION` build-args defaulted to `latest`. When the build-arg string is byte-identical across builds, the resulting layer-hash is identical, and the registry buildcache (`base-buildcache` / variant cache-from chain) silently reuses the layer from whatever upstream version was current when the cache was first populated — the same mechanism that caused pi-devbox v0.74.0 through v0.75.5 to ship the same image bytes.
|
||||
|
||||
Currently masked here because `OPENCODE_VERSION` is a hard-coded ARG that bumps every release — changing a parent layer invalidates the downstream cache key for the pi/omos install layers. Masking would fail the moment we cut a `vN.N.Nb` opencode-version-unchanged release that only bumps pi or omos. Filed as a parked followup that bedtime; fixing it preventatively now.
|
||||
|
||||
- **`.gitea/workflows/docker-publish-split.yml`** — new `resolve-versions` job runs `npm view @earendil-works/pi-coding-agent version` and `npm view oh-my-opencode-slim version`, exposing concrete strings as job outputs. All six affected jobs (`smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`, `build-variant-omos`, `build-variant-with-pi`, `build-variant-omos-with-pi`) now `needs:` it and pass the concrete versions as `PI_VERSION` / `OMOS_VERSION` build-args. `smoke-base` and `build-variant-base` are unaffected (no pi or omos).
|
||||
- **`scripts/smoke-test.sh`** — new `run_expect` helper asserts an expected substring in command output. The pi-version check uses `EXPECTED_PI_VERSION` when set; the omos check uses `EXPECTED_OMOS_VERSION` against `npm ls -g`. Both env vars are wired from `resolve-versions` outputs in the smoke jobs. Catches the regression on the next release rather than four releases later.
|
||||
- **`Dockerfile.variant`** — comment block above each affected `ARG` (`OPENCODE_VERSION`, `PI_VERSION`, `OMOS_VERSION`) documenting the cache-hit footgun + which ones are CI-resolved vs source-pinned.
|
||||
- **`AGENTS.md`** — new convention bullet explaining the cache-hit class of bug and naming the resolve-versions job + EXPECTED_*_VERSION wiring as the contract to keep in lockstep.
|
||||
|
||||
No image-content change expected on the next release vs what `latest` would have resolved to anyway — this is purely about making sure the cache invalidates correctly going forward.
|
||||
|
||||
## v1.15.10 — 2026-05-23
|
||||
|
||||
opencode 1.15.6 → 1.15.10 bump (four upstream patch releases over two days). Plus implicit pi 0.75.4 → 0.75.5 in the `with-pi` and `omos-with-pi` variants since `PI_VERSION=latest` resolves at build time.
|
||||
|
||||
No image-content changes beyond the version bumps; cache hit expected on `base-35ee5fe7861a` (no `Dockerfile.base` or `rootfs/` edits since v1.14.50b).
|
||||
|
||||
### Notable upstream opencode changes
|
||||
|
||||
Sourced from <https://github.com/anomalyco/opencode/releases> (the upstream this devbox tracks).
|
||||
|
||||
**v1.15.7** — Grok OAuth (SuperGrok) sign-in including device-code login (@Jaaneek). V2 session APIs gain safe error responses with reference IDs (UnknownError, SessionNotFoundError, ServiceUnavailableError) so generic 500s no longer leak config details. Codex OAuth refreshes deduped to avoid repeated refresh failures (@cooper-oai). Native OpenAI OAuth requests restored. Tool schema failures now surface as friendly tool errors. PDF attachment support for Grok. Restored OpenAI reasoning streams. TUI: clearer collapsed-thinking punctuation, new sessions default to local project, single-select question checkmarks no longer collide with labels. Desktop: pinch zoom, new home view + session entry flow + titlebar, log export.
|
||||
|
||||
**v1.15.8** — Upstream release body empty; assumed internal/no user-visible changes.
|
||||
|
||||
**v1.15.9** — Redesigned diff viewer with file tree, **enabled by default**. MCP OAuth configs can set callback port and include configured scopes in client metadata (@sebin). Vertex Anthropic provider uses working `.rep.googleapis.com` endpoints for US/EU multi-region (@JPFrancoia). Many "show clearer error" improvements (default model invalid, missing PTY session, skill invocation failure, installation upgrade failure, project not found via HTTP API, MCP server not found, session busy). Native reasoning continuation metadata preserved across turns. TUI: copy worktree path from command palette, refined diff viewer shortcuts, spinner color aligned with active agent (@OpeOginni). Desktop: tab navigation in titlebar, session status in titlebar, multi-colon callback URL fix (@OpeOginni), debounced VCS refreshes.
|
||||
|
||||
**v1.15.10** — Single fix: restored the legacy production desktop flows for opening projects and starting sessions.
|
||||
|
||||
### Devbox-side notes
|
||||
|
||||
- **Bump:** opencode 1.15.6 → 1.15.10 (`OPENCODE_VERSION` in `Dockerfile.variant`).
|
||||
- **Implicit pi bump:** `with-pi` and `omos-with-pi` variants pick up pi 0.75.5 (one patch release with cleaner read-tool cards, async file tools, more reliable package updates, Bedrock token cap fix, etc.). See [pi-devbox v0.75.5 CHANGELOG](https://gitea.jordbo.se/joakimp/pi-devbox/src/branch/main/CHANGELOG.md) for the full list.
|
||||
- **Smoke threshold check:** `omos-with-pi` threshold remains at 3700 MB (set v1.15.4b 2026-05-18). Four opencode patches plus one pi patch typically add only a few MB across both; not expected to trip. If it does, recovery is the well-worn letter-suffix pattern (v1.15.10b with threshold bump).
|
||||
- Built on the same CI path as v1.15.6 (pinned-crane install on real-base-rebuild, skip-promote-on-cache-hit, update-description-always-on-base-success) — all expected to remain quiet on this cache-hit run.
|
||||
|
||||
### Note on this CHANGELOG vs the v1.15.10 tag snapshot
|
||||
|
||||
The v1.15.10 tag itself was pushed before the upstream release notes were located (originally I checked `sst/opencode` which is a fork; the canonical upstream is `anomalyco/opencode`). The image content under the tag is correct, but the CHANGELOG snapshot at the tag was thinner. This expanded version is on `main` going forward; the tag's snapshot will not be retroactively rewritten.
|
||||
|
||||
## v1.15.6 — 2026-05-21
|
||||
|
||||
opencode 1.15.4 → 1.15.6 bump (two upstream patch releases) plus two workflow improvements that landed on `main` between v1.15.4b and now. No image-content changes beyond the version bump; cache hit expected on `base-35ee5fe7861a` (no `Dockerfile.base` or `rootfs/` edits).
|
||||
|
||||
- **Bump:** opencode 1.15.4 → 1.15.6 (`OPENCODE_VERSION` in `Dockerfile.variant`). The `with-pi` and `omos-with-pi` variants will also implicitly pick up pi 0.75.3 → 0.75.4 since `PI_VERSION=latest` resolves at build time.
|
||||
- **CI: defensive `__pycache__` and macOS-metadata filter in `base-decide` hash compute.** `find rootfs -type f` previously included gitignored junk like `rootfs/__pycache__/*.pyc`, `.DS_Store`, and `._AppleDouble` files — which CI's clean checkout never sees. This bit us during v1.15.4 debugging when a stale `generate-config.cpython-314.pyc` on the local rootfs/ produced `base-3605aa6b6ab1` while CI computed `base-35ee5fe7861a`. The filter is a no-op on a clean tree (verified to still produce `35ee5fe7861a` post-filter), but defends against future stale-pyc / Finder-touched-rootfs hash mismatches. `.gitea/README.md` updated in lockstep. (commit `b6e4d89`)
|
||||
- **AGENTS.md: documentation drift sweep as explicit pre-commit workflow step.** Codifies the rule that non-release commits must also grep docs for stale claims about behaviour they change, with concrete repo-specific drift hotspots. Companion clause added across the wider repo set (cloud-init, ansible, pi-devbox, pi-extensions, pi-toolkit, cli_utils, proxmox) the same day. (commit `90e5a1f`)
|
||||
- **First release that exercises both the pinned-crane install (T14, v1.15.3) and the skip-promote-on-cache-hit guard (T15, v1.15.4) on this CI run path** — still cache-hit on base, so `promote-base-latest` should remain skipped via T15 and the pinned crane install will only fire when a real base rebuild happens.
|
||||
|
||||
## v1.15.4b — 2026-05-18
|
||||
|
||||
Recovery release for v1.15.4 — the `omos-with-pi` variant landed at >3500 MB and tripped the smoke threshold, so `smoke-omos-with-pi` and `build-variant-omos-with-pi` were skipped. The other three variants (base, omos, with-pi) published cleanly. Plus a latent workflow bug fix exposed by the partial publish.
|
||||
|
||||
- **Smoke threshold bump:** `omos-with-pi` 3500 → 3700 MB. Compounded growth: opencode 1.15.0 → 1.15.4 (4 patch versions) plus pi 0.74.0 → 0.75.3 (minor + 3 patches) both added a few MB each, and they sum in the omos-with-pi variant. Same pattern as previous threshold bumps (v1.14.31c, v1.15.0b); restores ~150 MB headroom.
|
||||
- **Workflow fix — `update-description` no longer skips on partial publish.** Pre-existing latent bug: `update-description.needs` includes all four `build-variant-*` jobs, and gitea Actions' default behavior is "skipped need ⇒ skip dependent". When `build-variant-omos-with-pi` got skipped (because its smoke failed), `update-description` cascaded into a skip even though the job's `if:` condition (`tag pushed`) was true. Result: Hub description wasn't refreshed on v1.15.4 despite three variants publishing. Fix: wrap the `if:` in `always() && needs.build-variant-base.result == 'success' && ...` so the job runs as long as the base variant published, regardless of what other variants did.
|
||||
- **Same fix applied to `promote-base-latest`** — had the identical latent bug. Currently masked by the cache-hit skip, but would have surfaced on a real-base-rebuild release with a single failed variant.
|
||||
- No image-side changes from v1.15.4. Cache hit on the same base hash (`base-35ee5fe7861a`).
|
||||
|
||||
## v1.15.4 — 2026-05-18
|
||||
|
||||
opencode 1.15.3 → 1.15.4 bump (one upstream patch release), bundled with the CI hardening that landed on main between v1.15.3 and now.
|
||||
|
||||
- **Bump:** opencode 1.15.3 → 1.15.4 (`OPENCODE_VERSION` in `Dockerfile.variant`).
|
||||
- **CI: pinned crane install in `promote-base-latest`.** Replaced `imjasonh/setup-crane@v0.4` with a direct `curl + tar` install pinned to crane v0.21.6. The action's bootstrap script calls `api.github.com/.../releases/latest` to discover what crane version to install. That call periodically rate-limits and produces `tag=null` → the action downloads `releases/download/null/...` → 404 → `gzip: unexpected end of file` → exit 2. We hit this on v1.15.3 (cosmetic failure since base-latest was already correct from cache hit). Pinned install removes the runtime GitHub API dependency entirely. Bump `CRANE_VERSION` deliberately when wanting updates, same pattern as the other GitHub-sourced binaries in the Dockerfile layer.
|
||||
- **CI: skip `promote-base-latest` on cache-hit base builds.** When the base layer hash hasn't changed (cache-hit on the existing `base-<hash>` from a previous run), `base-latest` already points at the correct digest, so the retag is a tautology. Job now skipped entirely when `needs.base-decide.outputs.need_build == 'false'`. Manual `workflow_dispatch` with `promote_latest: true` overrides the gate as an escape hatch for hand-recovery scenarios.
|
||||
- No image-side changes from the v1.15.3 baseline beyond the opencode npm version. Smoke thresholds unchanged.
|
||||
|
||||
## v1.15.3 — 2026-05-16
|
||||
|
||||
opencode 1.15.0 → 1.15.3 bump (three upstream patch releases).
|
||||
|
||||
- **Bump:** opencode 1.15.0 → 1.15.3 (`OPENCODE_VERSION` in `Dockerfile.variant`).
|
||||
- No container-side changes. Smoke thresholds from v1.15.0b unchanged.
|
||||
|
||||
## v1.15.0b — 2026-05-15
|
||||
|
||||
Rebuild of v1.15.0 with one fix — v1.15.0's `omos` variant landed at 3206 MB, 6 MB over the 3200 MB smoke threshold, so `smoke-omos` failed and `build-variant-omos` was skipped. opencode 1.15.0 grew slightly vs 1.14.50, leaving zero headroom on the existing threshold.
|
||||
|
||||
- **Smoke threshold bump:** `omos` 3200 → 3300 MB, `omos-with-pi` 3400 → 3500 MB. Restores ~100 MB headroom for routine apt-get upgrade drift between releases. Documented inline in `scripts/smoke-test.sh`. No image-side changes — cache hits across the board, just a re-publish on the bumped threshold.
|
||||
|
||||
## v1.15.0 — 2026-05-15
|
||||
|
||||
opencode 1.14.50 → 1.15.0 bump (upstream minor release).
|
||||
|
||||
- **Bump:** opencode 1.14.50 → 1.15.0 (`OPENCODE_VERSION` in `Dockerfile.variant`).
|
||||
- **Resilience:** `git clone` for pi-toolkit and pi-extensions in `Dockerfile.variant` is now wrapped in a 5-attempt retry loop with linear backoff (5s, 10s, 15s, 20s, 25s = up to ~75s total). gitea.jordbo.se occasionally returns transient HTTP 500s on the first request after idle, which previously broke the with-pi and omos-with-pi variant builds. Same pattern landed in pi-devbox repo concurrently.
|
||||
- **Docs:** `DOCKER_HUB.md` mentions `joakimp/pi-devbox` as a sibling image — the pi-only build that uses this image's base layer as its parent. Generator template (`scripts/generate-dockerhub-md.py`) updated and regenerated. Hub size: 5905 bytes (well under the 25 kB limit).
|
||||
- **Recovery from v1.14.50c partial publish:** the `latest-omos`, `v1.14.50c-omos` Hub gap is closed by this release — `latest-omos` will move forward to v1.15.0 once all four variants publish cleanly. Users on the floating tag were unaffected (still pointing at v1.14.41b until now).
|
||||
|
||||
## v1.14.50c — 2026-05-14
|
||||
|
||||
Recovery release for v1.14.50b's missing variants. v1.14.50b shipped only the `base` variant; `omos`, `with-pi`, and `omos-with-pi` were lost to a runner-fleet incident (see postmortem below).
|
||||
|
||||
No container-side changes. This is a tag-only retag to re-run the build on a now-healthy runner fleet. Same `base-35ee5fe7861a` from v1.14.50b is reused via hash-cache hit; only the four variant deltas are rebuilt and published.
|
||||
|
||||
### Postmortem: v1.14.50 / v1.14.50b runner-fleet incident
|
||||
|
||||
Two orthogonal runner-host issues compounded across runs 285–291:
|
||||
|
||||
1. **AVX-less runner shadowing the new fleet.** A pre-migration `act_runner` container on `nyvaken` (Sandy Bridge E3-12xx, has AVX but no AVX2; 4 weeks old, name `act_runner-runner-1`) collided with the orchestrator's freshly deployed `runner-1` VM (Broadwell-EP host, fully AVX2-capable). Gitea scheduled jobs to both. Jobs landing on the nyvaken container `npm install -g opencode-ai@1.14.50` succeeded, then ran `opencode --version` postinstall → the bundled Bun (v1.3.13 baseline) emitted `CPU lacks AVX support`, panicked, and SIGILLed (exit code 132).
|
||||
2. **Containerd shared-state race at `capacity: 2`.** The new VM-based runners initially ran `act_runner` with `capacity: 2`, scheduling two concurrent jobs on a single host. Both jobs would invoke `docker/setup-buildx-action@v4`, which pulls `moby/buildkit:buildx-stable-1`. Containerd's content store raced on identical sha256 ingestion, surfacing as `commit failed: rename .../ingest/.../data .../blobs/sha256/...: no such file or directory` or `failed to extract layer: failed to Lchown ...`.
|
||||
|
||||
A secondary issue surfaced: **Proxmox VM `cpu:` field defaults mask AVX**. The newly-cloned runner VMs had no explicit `cpu:` line in `qm config` and inherited Proxmox's recent default `x86-64-v2-AES`, which excludes AVX even though the Broadwell-EP host silicon has full `avx2`. Fix: `qm set <vmid> --cpu x86-64-v3` (or `host` for full passthrough), then `qm shutdown` + `qm start` (live reboot is not enough). Verified inside guest with `grep -m1 -oE 'avx[2]?' /proc/cpuinfo`.
|
||||
|
||||
Additionally, when `promote-base-latest`'s `needs:` graph requires *all four* `build-variant-*` jobs to succeed, partial publishes leave the `base-latest` Hub alias never advancing. Workaround used during recovery: manually re-tag the new base hash via Docker Hub registry manifest API (`PUT /v2/<repo>/manifests/base-latest` with the body of `GET /v2/<repo>/manifests/base-<sha>`) using a granular Hub PAT. No blob copy needed since blobs are content-addressed.
|
||||
|
||||
### Recovery actions taken (orchestrator + this repo)
|
||||
|
||||
- Orchestrator (cloud-init + ansible repos): set explicit `cpu_type: x86-64-v3` in all runner host yaml files; provision.sh now applies `qm set --cpu` after clone; added runner-3 on proxmox003 for anti-affinity (one runner per Proxmox node); dropped `capacity: 2 → 1` on all runners; bumped `act_runner` 0.3.1 → 0.6.1 across the fleet; documented the CPU-type gotcha as gotcha #9 in cloud-init AGENTS.md and a section in proxmox-guide.md.
|
||||
- User: retired the legacy `act_runner-runner-1` container on nyvaken; cleaned up stale runner registrations in Gitea Site Admin → Actions → Runners.
|
||||
- This repo: no changes needed in Dockerfile.base / Dockerfile.variant; v1.14.50c is a tag-only retag.
|
||||
|
||||
### Fleet state at v1.14.50c
|
||||
|
||||
3 runners (runner-1@proxmox001, runner-2@proxmox002, runner-3@proxmox003), all `act_runner` v0.6.1, all `capacity: 1`, all expose AVX + AVX2 to the guest. No name collisions. Estimated wall clock for v1.14.50c (cache-hit base, 4 variant deltas across 3 runners with capacity:1): ~40–50 min.
|
||||
|
||||
## v1.14.50b — 2026-05-14
|
||||
|
||||
Rebuild of v1.14.50 with two fixes — the v1.14.50 release was incomplete (smokes failed under containerd contention; build-variant jobs skipped; base-latest never promoted to Docker Hub).
|
||||
|
||||
- **Force fresh base rebuild.** Added a `BASE_REBUILD_DATE` comment header to `Dockerfile.base` to invalidate the content hash and trigger a full base rebuild. Picks up ~5 days of Debian trixie security updates and other apt-tracked packages. The comment also documents the pattern for future intentional base-rebuilds without other code changes (recommended cadence: once per release).
|
||||
- **First publish of `base-latest` alias.** `promote-base-latest` runs unconditionally on tag push (`PROMOTE_LATEST=true`), so this release is the first to put `joakimp/opencode-devbox:base-latest` on Docker Hub. Required before pi-devbox (and any other downstream image FROMing the base) can build.
|
||||
|
||||
## v1.14.50 — 2026-05-14
|
||||
|
||||
opencode 1.14.44 → 1.14.50 bump. First release on the split-base build pipeline.
|
||||
|
||||
- **Bump:** opencode 1.14.44 → 1.14.50 (`OPENCODE_VERSION` in `Dockerfile.variant`).
|
||||
- **Infrastructure: split-base pipeline cutover.** `Dockerfile.base` + `Dockerfile.variant` replace the single `Dockerfile`. `docker-publish-split.yml` (now renamed to `docker-publish.yml` in spirit — triggers on `push: tags: v*`) replaces the old `docker-publish.yml`. The original `Dockerfile` and `docker-publish.yml` are deleted. Hash-driven base reuse: version-bump-only releases skip the base build entirely (~40–80 min wall clock with 4 runners vs ~165–180 min previously). Validated across two `workflow_dispatch` test runs (`:v0.0.0-split-test` tags on Docker Hub).
|
||||
- **Fix:** `echo -e` heredoc replaced with POSIX-compatible brace-block for multiline `$GITHUB_OUTPUT` writes in the four `build-variant-*` jobs. `echo -e` does not interpret `\n` in `/bin/sh` (dash), causing `steps.tags.outputs.tags` to be empty and buildx to fail with "tag is needed when pushing to registry".
|
||||
- **Docs:** New `.gitea/README.md` — architectural overview of the split-base pipeline, hash logic, wall-clock estimates, runner expectations, and the migration plan.
|
||||
|
||||
## v1.14.44 — 2026-05-09
|
||||
|
||||
opencode 1.14.42 → 1.14.44 bump (1.14.43 skipped upstream). Also completes the matrix coverage that v1.14.42 missed: `build-omos-with-pi` failed mid-publish on v1.14.42 due to an upstream npm CDN propagation race — `oh-my-opencode-slim@1.0.7` had been published declaring a dependency on `@opencode-ai/sdk@1.14.44`, and our build hit the registry within ~2 minutes of that SDK version landing, before the tarball had propagated across npm's CDN. The build returned 404 on the SDK fetch even though the manifest's `dist-tags.latest` already pointed at 1.14.44. Tarball is now fully fetchable; v1.14.44 builds cleanly across all four variants.
|
||||
|
||||
- **Bump:** opencode 1.14.42 → 1.14.44 (`OPENCODE_VERSION` build-arg default in both `Dockerfile` and `Dockerfile.variant`).
|
||||
|
||||
Known gap: `joakimp/opencode-devbox:v1.14.42-omos-with-pi` and the corresponding `latest-omos-with-pi` alias were NOT published in the v1.14.42 release (`build-omos-with-pi` job failed for the reason above). `latest-omos-with-pi` continued pointing at v1.14.41b until v1.14.44 published. Users on the `latest-omos-with-pi` floating tag were unaffected; users pulling explicit `:v1.14.42-omos-with-pi` would get a 404 from Hub. Closed by v1.14.44.
|
||||
|
||||
## v1.14.42 — 2026-05-09
|
||||
|
||||
**Note:** Of the 4 multi-arch variants, 3 published cleanly (`v1.14.42`, `v1.14.42-omos`, `v1.14.42-with-pi`, plus their `latest*` aliases). `build-omos-with-pi` failed during the publish step due to an upstream npm CDN propagation race (see v1.14.44 entry above for detail). Re-running the failed job would have required another full ~3h matrix rerun in gitea Actions; we chose to bump opencode to 1.14.44 instead and let the next tag close the gap.
|
||||
|
||||
opencode 1.14.41 → 1.14.42 bump. Carries along all container-side changes accumulated since v1.14.41b: pi package rename to `@earendil-works/*`, npm-prefix-on-volume fix, Hub doc rewrite, README/AGENTS docs catchup.
|
||||
|
||||
Image changes:
|
||||
|
||||
- **Bump:** opencode 1.14.41 → 1.14.42 (`OPENCODE_VERSION` build-arg default in both `Dockerfile` and `Dockerfile.variant`).
|
||||
- **Rename:** `npm install -g @mariozechner/pi-coding-agent` -> `npm install -g @earendil-works/pi-coding-agent` in the `INSTALL_PI=true` build path. Pi moved to its new home at earendil-works on 2026-05-07 (https://pi.dev/news/2026/5/7/pi-has-a-new-home); the old `@mariozechner/*` packages are deprecated on npm with the explicit message 'please use @earendil-works/pi-coding-agent instead going forward', and the version stream has moved on (old top-out 0.73.1; new currently 0.74.0). Anyone npm-installing the old name today gets a deprecation warning + a stale binary. Affects both `Dockerfile` (production single-Dockerfile path) and `Dockerfile.variant` (split-base path on main). README, AGENTS, and `HUB_TEMPLATE` URL refs updated from `github.com/mariozechner/pi-coding-agent` (which now 404s) to `github.com/earendil-works/pi`. Brew install references (`brew install pi-coding-agent`) left as-is: formula still works at 0.73.1 and a homebrew tap update is tracked upstream at earendil-works/pi#2755.
|
||||
- **Fix:** `pi install npm:<pkg>` (and any `npm install -g`) by the `developer` user no longer EACCES against the system npm prefix. `NPM_CONFIG_PREFIX` is now `/home/developer/.pi/npm-global` and the prefix's `bin/` is prepended to `PATH`. The directory lives on the `devbox-pi-config` named volume, so user-installed pi packages (themes, skills, extensions) survive container recreation and image rebuilds. Build-time `npm install -g` calls (opencode, pi, oh-my-opencode-slim) are unaffected because the new ENVs are declared after those steps in the Dockerfile, so the baked binaries still install to `/usr` and are not shadowed by the volume mount.
|
||||
- **Fix (smoke-test):** `scripts/smoke-test.sh` `oh-my-opencode-slim` check now invokes `npm ls -g` with `NPM_CONFIG_PREFIX=/usr` so it queries the system prefix where the baked install lives. Latent regression from the npm-prefix fix above: default `npm ls -g` started querying the user prefix (`/home/developer/.pi/npm-global`, empty at build time) and missed the baked OMOS install — surfaced when `validate.yml` ran on main after the merge of `feat/split-build`.
|
||||
|
||||
Build pipeline (branch `feat/split-build`, not yet merged):
|
||||
Docs:
|
||||
|
||||
- **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.
|
||||
|
||||
Docs-only (the DOCKER_HUB.md change can be patched live to Hub without a CI rebuild; AGENTS.md change is repo-internal):
|
||||
|
||||
- **Hub doc rewrite:** `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 551 bytes (~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. Hub copy stays slim and links out to the gitea README for full depth (build args, multi-user setup, AWS Bedrock walkthrough, MemPalace deep-dive, language-specific dev sections). Trade-off: image-variants table and quick-start flow are now coupled to `HUB_TEMPLATE` and need a manual edit when they change — explicit and local rather than spread across rules.
|
||||
- **AGENTS.md:** "Documentation coupling on release" rule updated — README edits no longer require regenerating DOCKER_HUB.md. Release-day checklist tightened.
|
||||
- **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/mariozechner/pi-coding-agent) 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_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.
|
||||
|
||||
+27
-18
@@ -10,13 +10,33 @@ Designed for teams who want a reproducible coding-agent setup that runs the same
|
||||
|---|---|
|
||||
| `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/mariozechner/pi-coding-agent) as alternative/complementary harness (shares the mempalace install with opencode) |
|
||||
| `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`.
|
||||
|
||||
> A fifth, pi-without-opencode build is produced from the same `Dockerfile.variant`
|
||||
> (`INSTALL_OPENCODE=false`) but is **not** published under this repo — it ships as
|
||||
> the separate [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)
|
||||
> image so an "opencode-devbox" tag never lacks opencode.
|
||||
|
||||
## Quick Start
|
||||
|
||||
For a fully-configured environment with persistent state (opencode config, mempalace memory, neovim plugins, bash history) surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
|
||||
curl -O https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml
|
||||
curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
|
||||
# Edit .env — set OPENCODE_PROVIDER, the matching API key,
|
||||
# WORKSPACE_PATH, GIT_USER_NAME, GIT_USER_EMAIL.
|
||||
docker compose run --rm devbox
|
||||
```
|
||||
|
||||
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first — useful for `aws sso login`, `pi` (on `*-with-pi` variants), or multi-harness workflows.
|
||||
|
||||
**One-shot run, no persistence:**
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
-e ANTHROPIC_API_KEY=your-key \
|
||||
@@ -28,27 +48,12 @@ docker run -it --rm \
|
||||
joakimp/opencode-devbox:latest
|
||||
```
|
||||
|
||||
Drops you straight into opencode with your project mounted at `/workspace`.
|
||||
|
||||
For an interactive shell first (useful for AWS SSO login, multi-harness workflows, or just `bash`):
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
-e ANTHROPIC_API_KEY=your-key \
|
||||
-e OPENCODE_PROVIDER=anthropic \
|
||||
-v ~/projects:/workspace \
|
||||
-v ~/.ssh:/home/developer/.ssh:ro \
|
||||
joakimp/opencode-devbox:latest bash
|
||||
```
|
||||
|
||||
Then run `opencode`, `pi` (on `*-with-pi` variants), or `aws sso login` from the shell.
|
||||
|
||||
For docker-compose users, the source repo provides `docker-compose.yml`, `.env.example`, and a one-liner `docker compose up -d` workflow with named volumes pre-wired.
|
||||
Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedrock SSO + static), persistence model, build args, troubleshooting: <https://gitea.jordbo.se/joakimp/opencode-devbox#readme>
|
||||
|
||||
## What's Inside
|
||||
|
||||
- **[opencode](https://opencode.ai)** — primary coding-agent harness. Multi-provider (Anthropic, OpenAI, Bedrock, Google, Groq, etc.).
|
||||
- **[pi](https://github.com/mariozechner/pi-coding-agent)** *(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`.
|
||||
- **[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.
|
||||
@@ -86,6 +91,10 @@ Full persistence reference, including multi-user (`SIGNUM`) isolation and host b
|
||||
- **Issues / source / docker-compose templates:** <https://gitea.jordbo.se/joakimp/opencode-devbox>
|
||||
- **Agent-facing internals** (for future maintainers / coding agents working in the repo): <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/AGENTS.md>
|
||||
|
||||
## Sibling images
|
||||
|
||||
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — pi-only image built on top of this image's base layer. Smaller (~700 MB) and version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly. Use this if you want pi without opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
|
||||
|
||||
## License
|
||||
|
||||
MIT. See <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/LICENSE>.
|
||||
|
||||
-475
@@ -1,475 +0,0 @@
|
||||
# opencode-devbox — portable AI dev environment
|
||||
# Debian-based container with opencode and configurable dev tools
|
||||
|
||||
ARG DEBIAN_VERSION=trixie-slim
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG OPENCODE_VERSION=1.14.41
|
||||
|
||||
LABEL maintainer="joakimp"
|
||||
LABEL description="Portable opencode developer container"
|
||||
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/opencode-devbox"
|
||||
|
||||
# Avoid interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ── Core system packages ─────────────────────────────────────────────
|
||||
# apt-get upgrade picks up any security/CVE fixes published between
|
||||
# debian:trixie-slim base-image rebuilds. Paired with the index update
|
||||
# and the install in the same layer so we don't bloat image history.
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y --no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
openssh-client \
|
||||
gnupg \
|
||||
jq \
|
||||
ripgrep \
|
||||
fd-find \
|
||||
tree \
|
||||
less \
|
||||
htop \
|
||||
tmux \
|
||||
make \
|
||||
patch \
|
||||
diffutils \
|
||||
git-crypt \
|
||||
age \
|
||||
file \
|
||||
sudo \
|
||||
locales \
|
||||
procps \
|
||||
unzip \
|
||||
gcc \
|
||||
g++ \
|
||||
rsync \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
|
||||
#
|
||||
# Version policy for the binaries below:
|
||||
# • Default is `latest` — resolved at build time by following the
|
||||
# /releases/latest redirect on GitHub and reading the tag from the
|
||||
# Location header. This means every tagged image picks up the newest
|
||||
# upstream release, with no risk of running months-old CVE-affected
|
||||
# binaries.
|
||||
# • Explicit pins still work: pass `--build-arg GOSU_VERSION=1.19` etc.
|
||||
# Useful for reproducibility or rolling back a bad upstream release.
|
||||
# • Resolved versions are printed during build and re-checked by the
|
||||
# smoke test (scripts/smoke-test.sh), so drift is visible in CI logs.
|
||||
#
|
||||
# The helper `resolve_latest` reads the redirected tag (e.g. "v0.26.1")
|
||||
# and strips a leading "v" if present, yielding a plain version string.
|
||||
|
||||
# gosu — privilege de-escalation
|
||||
ARG GOSU_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GOSU_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing gosu ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
|
||||
chmod +x /usr/local/bin/gosu && \
|
||||
gosu --version
|
||||
|
||||
# fzf — fuzzy finder
|
||||
ARG FZF_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${FZF_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing fzf ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
|
||||
fzf --version
|
||||
|
||||
# git-lfs — Git Large File Storage
|
||||
ARG GIT_LFS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GIT_LFS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing git-lfs ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \
|
||||
rm -rf /tmp/git-lfs-${V} && \
|
||||
git lfs install --system && \
|
||||
git-lfs --version
|
||||
|
||||
# neovim — modern text editor
|
||||
ARG NVIM_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${NVIM_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing neovim ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
|
||||
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
|
||||
nvim --version | head -1
|
||||
|
||||
# bat — syntax-highlighted cat replacement
|
||||
ARG BAT_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${BAT_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing bat ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
|
||||
rm -rf /tmp/bat-v${V}-* && \
|
||||
bat --version
|
||||
|
||||
# eza — modern ls replacement
|
||||
ARG EZA_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${EZA_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing eza ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
|
||||
eza --version | head -1
|
||||
|
||||
# zoxide — smarter cd command
|
||||
ARG ZOXIDE_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${ZOXIDE_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing zoxide ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
|
||||
zoxide --version
|
||||
|
||||
# uv — fast Python package manager (replaces pip, venv, pyenv)
|
||||
# Note: uv releases don't prefix tags with "v" (e.g. tag is "0.11.8").
|
||||
ARG UV_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${UV_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing uv ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
|
||||
rm -rf /tmp/uv-* && \
|
||||
uv --version
|
||||
|
||||
# ── Optional: MemPalace — local-first AI memory system ───────────────
|
||||
# Provides semantic search over conversation history via 29 MCP tools.
|
||||
# Palace data persists via the devbox-palace named volume.
|
||||
# The embedding model (~300 MB) is downloaded on first use and cached
|
||||
# in the palace directory.
|
||||
#
|
||||
# Installed via `uv tool install` into an isolated venv at
|
||||
# /opt/uv-tools/mempalace/. The `mempalace` CLI goes directly on PATH;
|
||||
# the MCP server is reached via the /usr/local/bin/mempalace-mcp-server
|
||||
# wrapper (rootfs/usr/local/bin/mempalace-mcp-server), since system
|
||||
# python3 cannot import from the isolated venv.
|
||||
#
|
||||
# Disable with --build-arg INSTALL_MEMPALACE=false to shave ~300 MB off
|
||||
# the image (chromadb, torch-adjacent deps).
|
||||
ARG INSTALL_MEMPALACE=true
|
||||
ENV UV_TOOL_DIR=/opt/uv-tools
|
||||
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
mkdir -p /opt/uv-tools && \
|
||||
uv tool install --no-cache mempalace && \
|
||||
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
|
||||
fi
|
||||
|
||||
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
|
||||
# Thin wrappers (`mempalace-session`, `mempalace-docs`) that delegate to
|
||||
# the mempalace Python CLI for two common scheduled tasks:
|
||||
# - mempalace-session: mines opencode's SQLite session history into
|
||||
# the palace (wing_conversations). Referenced by contrib/ scheduler
|
||||
# templates (systemd user timer, cron) in the toolkit repo.
|
||||
# - mempalace-docs: mines project docs into a per-project wing.
|
||||
# Repo source of truth: https://gitea.jordbo.se/joakimp/mempalace-toolkit
|
||||
#
|
||||
# Requires INSTALL_MEMPALACE=true (wrappers shell out to `mempalace`).
|
||||
# Disable with --build-arg INSTALL_MEMPALACE_TOOLKIT=false if you don't
|
||||
# use the scheduled-mining workflow.
|
||||
ARG INSTALL_MEMPALACE_TOOLKIT=true
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
|
||||
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
|
||||
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
|
||||
mempalace-session --help >/dev/null && \
|
||||
mempalace-docs --help >/dev/null && \
|
||||
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
# rustup — Rust toolchain manager
|
||||
# Installs the rustup-init binary only. Users bootstrap Rust with:
|
||||
# rustup-init -y && source ~/.cargo/env
|
||||
# Toolchains persist via devbox-rustup and devbox-cargo volumes.
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://static.rust-lang.org/rustup/dist/${ARCH}-unknown-linux-gnu/rustup-init" -o /usr/local/bin/rustup-init && \
|
||||
chmod +x /usr/local/bin/rustup-init
|
||||
|
||||
# gitea-mcp — MCP server for Gitea API (official, Go binary, hosted on gitea.com)
|
||||
ARG GITEA_MCP_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${GITEA_MCP_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing gitea-mcp ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/download/v${V}/gitea-mcp_Linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin/ gitea-mcp && \
|
||||
chmod +x /usr/local/bin/gitea-mcp && \
|
||||
gitea-mcp --version
|
||||
|
||||
# Set locale — generate common UTF-8 locales (override via LANG/LC_ALL env vars)
|
||||
# To add more locales, run: sudo sed -i '/<locale>.UTF-8/s/^# //g' /etc/locale.gen && sudo locale-gen
|
||||
RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LANGUAGE=en_US:en
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
ENV EDITOR=nvim
|
||||
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
|
||||
|
||||
# ── Node.js (required for opencode v1.x install + MCP servers) ──────
|
||||
ARG NODE_VERSION=22
|
||||
RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Install opencode via npm ─────────────────────────────────────────
|
||||
# v1.x is distributed as an npm package with platform-specific binaries.
|
||||
# Disable with --build-arg INSTALL_OPENCODE=false to build a slimmer
|
||||
# image without opencode (e.g. when only pi is needed). For a fully
|
||||
# pi-only stripped image (no Bun, no opencode), see the pi-devbox repo.
|
||||
ARG INSTALL_OPENCODE=true
|
||||
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
|
||||
npm install -g opencode-ai@${OPENCODE_VERSION} && \
|
||||
opencode --version ; \
|
||||
fi
|
||||
|
||||
# ── Optional: pi coding-agent ────────────────────────────────────────
|
||||
# Installs pi as an alternative/complementary harness. Coexists with
|
||||
# opencode in the same image — both share the mempalace install and
|
||||
# palace path, so wing data is mutually visible to either harness.
|
||||
#
|
||||
# pi-toolkit (keybindings.json + pi-env.zsh + settings.example.json)
|
||||
# and pi-extensions (confirm-destructive, ext-toggle, git-checkpoint,
|
||||
# notify, ssh-controlmaster, todo, …) are cloned into /opt/ at build
|
||||
# time. entrypoint-user.sh runs each repo's install.sh on container
|
||||
# start so symlinks land under ~/.pi/agent/ on the named volume.
|
||||
#
|
||||
# Pi version is pinned by PI_VERSION (default: latest at build time).
|
||||
# The baked pi binary lives at /usr/bin/pi (system npm prefix); the
|
||||
# user-writable NPM_CONFIG_PREFIX (~/.pi/npm-global, set further down)
|
||||
# is only consulted by `pi install npm:<pkg>` and `npm install -g` at
|
||||
# runtime — it does NOT shadow the baked pi unless the user does
|
||||
# `npm install -g @mariozechner/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 @mariozechner/pi-coding-agent ; \
|
||||
else \
|
||||
npm install -g @mariozechner/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git clone --depth 1 --branch "${PI_TOOLKIT_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/pi-toolkit.git /opt/pi-toolkit && \
|
||||
git clone --depth 1 --branch "${PI_EXTENSIONS_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/pi-extensions.git /opt/pi-extensions && \
|
||||
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
||||
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
|
||||
RUN ARCH=$(case "${TARGETARCH}" in \
|
||||
amd64) echo "x86_64" ;; \
|
||||
arm64) echo "aarch64" ;; \
|
||||
*) echo "x86_64" ;; \
|
||||
esac) && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
|
||||
unzip -q /tmp/awscli.zip -d /tmp && \
|
||||
/tmp/aws/install && \
|
||||
rm -rf /tmp/aws /tmp/awscli.zip && \
|
||||
aws --version
|
||||
|
||||
# ── Optional: Go ─────────────────────────────────────────────────────
|
||||
# Latest stable Go is resolved from https://go.dev/dl/?mode=json when
|
||||
# GO_VERSION=latest (default). Pass an explicit version like "1.26.2"
|
||||
# to pin.
|
||||
ARG INSTALL_GO=false
|
||||
ARG GO_VERSION=latest
|
||||
RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
||||
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GO_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \
|
||||
awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \
|
||||
fi && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing Go ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
|
||||
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
|
||||
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
|
||||
fi
|
||||
|
||||
# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ────────
|
||||
# Installs Bun runtime and the oh-my-opencode-slim npm package.
|
||||
# Runtime activation is controlled by ENABLE_OMOS env var in entrypoint.
|
||||
# Uses the baseline Bun build (SSE4.2 only) for compatibility with older
|
||||
# CPUs that lack AVX2 (e.g. Sandy Bridge on OpenStack).
|
||||
ARG INSTALL_OMOS=false
|
||||
ARG OMOS_VERSION=latest
|
||||
RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
|
||||
ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "x86_64" ]; then \
|
||||
BUN_ARCH="x64-baseline"; \
|
||||
elif [ "$ARCH" = "aarch64" ]; then \
|
||||
BUN_ARCH="aarch64"; \
|
||||
fi && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-${BUN_ARCH}.zip" -o /tmp/bun.zip && \
|
||||
unzip -o /tmp/bun.zip -d /tmp/bun && \
|
||||
mv /tmp/bun/bun-linux-${BUN_ARCH}/bun /usr/local/bin/bun && \
|
||||
chmod +x /usr/local/bin/bun && \
|
||||
ln -sf bun /usr/local/bin/bunx && \
|
||||
rm -rf /tmp/bun /tmp/bun.zip && \
|
||||
bun --version && \
|
||||
test -L /usr/local/bin/bunx && \
|
||||
npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \
|
||||
fi
|
||||
|
||||
# ── Non-root user ────────────────────────────────────────────────────
|
||||
ARG USER_NAME=developer
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
|
||||
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
|
||||
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
|
||||
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
|
||||
|
||||
# Create standard directories
|
||||
#
|
||||
# ~/.pi/agent/extensions/ is created proactively so the named volume
|
||||
# mount has a real owner from the first start. The directory is also
|
||||
# what mempalace-toolkit's install_pi_extension probes to decide
|
||||
# whether to deploy the pi↔mempalace bridge — must exist before that
|
||||
# step runs in entrypoint-user.sh.
|
||||
RUN mkdir -p /workspace \
|
||||
/home/${USER_NAME}/.config/opencode/skills \
|
||||
/home/${USER_NAME}/.pi/agent/extensions \
|
||||
/home/${USER_NAME}/.agents/skills \
|
||||
/home/${USER_NAME}/.local/share/opencode \
|
||||
/home/${USER_NAME}/.cache/bash \
|
||||
/home/${USER_NAME}/.ssh && \
|
||||
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
|
||||
|
||||
# ── Pre-warm chromadb embedding model ──────────────────────────────
|
||||
# Mempalace uses chromadb's ONNXMiniLM_L6_V2 embedding function, which
|
||||
# downloads ~80 MB of all-MiniLM-L6-v2 ONNX weights from chromadb's CDN
|
||||
# on first use. Without pre-warming this happens silently (output is
|
||||
# suppressed by the entrypoint init step) and stalls first container
|
||||
# start by minutes on a slow network. We bake the cache at build time
|
||||
# under the developer user's home so the runtime first-start is fast.
|
||||
#
|
||||
# Cache path comes from chromadb's hardcoded `Path.home() / .cache /
|
||||
# chroma / onnx_models / all-MiniLM-L6-v2`. Run as gosu developer so
|
||||
# Path.home() resolves correctly and ownership is right from the start.
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\
|
||||
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \
|
||||
ef = ONNXMiniLM_L6_V2(); \
|
||||
_ = ef(['warmup']); \
|
||||
print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
|
||||
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
|
||||
fi
|
||||
|
||||
# ── User-writable npm global prefix on the devbox-pi-config volume ──
|
||||
# By default npm's global prefix is /usr (writable only by root) so any
|
||||
# `pi install npm:<pkg>` or `npm install -g <pkg>` invoked by the
|
||||
# developer user would EACCES. Pointing the prefix into ~/.pi places
|
||||
# user-installed packages on the named volume, which means they survive
|
||||
# container recreation AND image rebuilds (complementing pi's auto-
|
||||
# restore from settings.json with one less cold-start step).
|
||||
#
|
||||
# These ENVs land AFTER all build-time `npm install -g` calls
|
||||
# (opencode, pi, oh-my-opencode-slim) so those still install to /usr at
|
||||
# build time. They take effect for every runtime invocation regardless
|
||||
# of shell init: docker compose run/exec, login shells, non-interactive
|
||||
# commands. npm auto-creates the prefix directory on first install.
|
||||
#
|
||||
# Harmless when INSTALL_PI=false (and no named volume mounted at ~/.pi):
|
||||
# the dir just lives on the container's writable layer.
|
||||
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
|
||||
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
||||
|
||||
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
||||
# Shipped under /etc/skel-devbox/ rather than copied directly to the
|
||||
# user's home. The entrypoint copies them to /home/developer/ only if
|
||||
# the target file does not already exist, so host bind-mounts and
|
||||
# previously-customized files are never overwritten. Users can restore
|
||||
# the baked defaults anytime via:
|
||||
# cp /etc/skel-devbox/.bash_aliases ~/.bash_aliases
|
||||
# History itself persists via the devbox-shell-history named volume
|
||||
# mounted at ~/.cache/bash (HISTFILE points there).
|
||||
RUN mkdir -p /etc/skel-devbox
|
||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
|
||||
# ── Entrypoint ────────────────────────────────────────────────────────
|
||||
COPY rootfs/usr/local/lib/opencode-devbox/ /usr/local/lib/opencode-devbox/
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
||||
/usr/local/lib/opencode-devbox/*.py
|
||||
|
||||
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
||||
WORKDIR /workspace
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
# Default to a login shell. `docker compose run --rm devbox` drops
|
||||
# the user into bash to choose: `aws sso login`, then `opencode`
|
||||
# or `pi`. To launch a harness directly, pass it explicitly:
|
||||
# docker compose run --rm devbox opencode
|
||||
# docker compose run --rm devbox pi
|
||||
# `docker compose exec` bypasses the entrypoint and CMD entirely, so
|
||||
# this default has no effect on attach-style workflows.
|
||||
CMD ["bash", "-l"]
|
||||
@@ -11,6 +11,13 @@
|
||||
# changes (rootfs/, entrypoint*.sh). Version bumps to OPENCODE_VERSION,
|
||||
# OMOS_VERSION, PI_VERSION, etc. do NOT trigger a base rebuild.
|
||||
#
|
||||
# To force a base rebuild for fresh apt packages without other code
|
||||
# changes, bump the BASE_REBUILD_DATE comment below. The hash is
|
||||
# content-addressed over this file, so any byte change invalidates the
|
||||
# cache. Recommended cadence: once per release for security updates.
|
||||
#
|
||||
# BASE_REBUILD_DATE: 2026-05-14 (v1.14.50b — fresh apt + first promote-base-latest)
|
||||
#
|
||||
# See the project README's "Build pipeline" section for the rationale.
|
||||
|
||||
ARG DEBIAN_VERSION=trixie-slim
|
||||
@@ -64,6 +71,44 @@ RUN apt-get update && \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── SSH client defaults: ControlMaster on a writable socket path ──────
|
||||
# Why this exists: the devbox typically mounts ~/.ssh from the host as
|
||||
# read-only (security: keys are readable, but agents can't tamper with
|
||||
# config / known_hosts / authorized_keys / plant a malicious ProxyCommand).
|
||||
# OpenSSH's default ControlPath is ~/.ssh/cm/... which is unwritable on
|
||||
# such mounts, so any attempt to use ControlMaster fails. Symptoms:
|
||||
# unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
|
||||
# kex_exchange_identification: Connection closed by remote host
|
||||
# The latter manifests downstream of CGNAT per-destination flow caps
|
||||
# (~4 concurrent flows on most European residential ISPs) which silently
|
||||
# drop further SYNs once exceeded — making fresh ssh attempts fail with
|
||||
# banner-exchange timeouts that look like a remote problem.
|
||||
#
|
||||
# Fix: set a system-wide default ControlPath in /tmp (per-container,
|
||||
# tmpfs-friendly, always writable) so multiplexing Just Works without
|
||||
# touching the read-only ~/.ssh mount. Per-host overrides in user's
|
||||
# ~/.ssh/config still win — Debian's default /etc/ssh/ssh_config has
|
||||
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
|
||||
# so user config can override these defaults if desired.
|
||||
#
|
||||
# ControlPersist=10m means the master socket sticks around 10 min after
|
||||
# the last session closes, so consecutive ssh calls in a workflow reuse
|
||||
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
|
||||
# (mode 700) on each container start.
|
||||
RUN mkdir -p /etc/ssh/ssh_config.d && \
|
||||
printf '%s\n' \
|
||||
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
|
||||
'# Override per-host in ~/.ssh/config if the master socket location' \
|
||||
'# needs to differ.' \
|
||||
'Host *' \
|
||||
' ControlMaster auto' \
|
||||
' ControlPath /tmp/sshcm/%r@%h:%p' \
|
||||
' ControlPersist 10m' \
|
||||
' ServerAliveInterval 30' \
|
||||
' ServerAliveCountMax 6' \
|
||||
> /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \
|
||||
chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf
|
||||
|
||||
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
|
||||
#
|
||||
# Version policy for the binaries below:
|
||||
@@ -119,6 +164,24 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;;
|
||||
git lfs install --system && \
|
||||
git-lfs --version
|
||||
|
||||
# gitleaks — secret scanner (used as a pre-commit hook in several of the
|
||||
# repos this devbox is meant to operate on; pairs with git-crypt below).
|
||||
# Distributed as a Go-compiled tarball; arch suffix is `x64` (not `x86_64`
|
||||
# or `amd64`) on this project — mind the deviation from the surrounding
|
||||
# tools' naming.
|
||||
ARG GITLEAKS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x64" ;; arm64) echo "arm64" ;; *) echo "x64" ;; esac) && \
|
||||
V="${GITLEAKS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing gitleaks ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin gitleaks && \
|
||||
chmod +x /usr/local/bin/gitleaks && \
|
||||
gitleaks version
|
||||
|
||||
# neovim — modern text editor
|
||||
ARG NVIM_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
|
||||
+71
-8
@@ -12,6 +12,12 @@
|
||||
# omos true true false
|
||||
# with-pi true false true
|
||||
# omos-with-pi true true true
|
||||
# pi-only false false true
|
||||
#
|
||||
# The `pi-only` variant is the single source of truth for the pi-devbox
|
||||
# image (pi + companions, no opencode). It exists so pi-devbox can FROM it
|
||||
# without inheriting opencode, while the pi install logic stays defined
|
||||
# here in one place.
|
||||
#
|
||||
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
|
||||
# The CI workflow computes the base hash from Dockerfile.base + rootfs/
|
||||
@@ -31,8 +37,12 @@ ARG TARGETARCH
|
||||
ARG USER_NAME=developer
|
||||
|
||||
# ── Install opencode via npm ─────────────────────────────────────────
|
||||
# OPENCODE_VERSION is intentionally pinned in this Dockerfile (not
|
||||
# 'latest'). It drives the release tag and gets bumped via a source
|
||||
# edit, so the cache-hit class of bug that bit pi-devbox v0.74.0..
|
||||
# v0.75.5 cannot apply here.
|
||||
ARG INSTALL_OPENCODE=true
|
||||
ARG OPENCODE_VERSION=1.14.41
|
||||
ARG OPENCODE_VERSION=1.15.13
|
||||
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
|
||||
opencode --version ; \
|
||||
@@ -42,23 +52,72 @@ RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
|
||||
# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh
|
||||
# runs each repo's install.sh on container start so symlinks land under
|
||||
# ~/.pi/agent/ on the named volume.
|
||||
# PI_VERSION should be passed explicitly by CI as a concrete version
|
||||
# (resolved from `npm view @earendil-works/pi-coding-agent version`,
|
||||
# see .gitea/workflows/docker-publish-split.yml § resolve-versions).
|
||||
# The default `latest` is for local dev convenience only — it has a
|
||||
# known cache-hit footgun when used in registry-cached CI builds: the
|
||||
# resulting build-arg string is byte-identical across builds, the
|
||||
# layer-hash is identical, and the registry buildcache silently reuses
|
||||
# the layer from whatever pi version was current when the cache was
|
||||
# first populated. Currently masked here because OPENCODE_VERSION (a
|
||||
# parent layer) bumps every release; will manifest the moment a
|
||||
# vN.N.Nb opencode-version-unchanged release ships. See pi-devbox
|
||||
# v0.75.5b 2026-05-23 for the discovery + canonical fix.
|
||||
ARG INSTALL_PI=false
|
||||
ARG PI_VERSION=latest
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
|
||||
# under elpapi42. Refs default to the tracked branch for local dev; CI resolves
|
||||
# them to concrete commit SHAs (see resolve-versions in docker-publish-split.yml)
|
||||
# so the build-arg string changes when upstream moves — same registry-buildcache
|
||||
# cache-hit footgun the PI_VERSION/OMOS_VERSION pins guard against. The clone
|
||||
# helper for these uses `git fetch <ref>` (not `--branch`) so it accepts both
|
||||
# branch names and raw commit SHAs.
|
||||
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
||||
ARG PI_FORK_REF=master
|
||||
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
||||
ARG PI_OBSMEM_REF=master
|
||||
RUN if [ "${INSTALL_PI}" = "true" ]; then \
|
||||
set -e && \
|
||||
git_clone_retry() { \
|
||||
url="$1"; ref="$2"; dest="$3"; \
|
||||
for i in 1 2 3 4 5; do \
|
||||
if git clone --depth 1 --branch "$ref" "$url" "$dest"; then return 0; fi; \
|
||||
rm -rf "$dest"; \
|
||||
echo "git clone $url failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
git_fetch_ref() { \
|
||||
url="$1"; ref="$2"; dest="$3"; \
|
||||
rm -rf "$dest"; mkdir -p "$dest"; \
|
||||
git -C "$dest" init -q && git -C "$dest" remote add origin "$url" && \
|
||||
for i in 1 2 3 4 5; do \
|
||||
if git -C "$dest" fetch --depth 1 origin "$ref" && git -C "$dest" checkout -q FETCH_HEAD; then return 0; fi; \
|
||||
echo "git fetch $url@$ref failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @mariozechner/pi-coding-agent ; \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @mariozechner/pi-coding-agent@${PI_VERSION} ; \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git clone --depth 1 --branch "${PI_TOOLKIT_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/pi-toolkit.git /opt/pi-toolkit && \
|
||||
git clone --depth 1 --branch "${PI_EXTENSIONS_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/pi-extensions.git /opt/pi-extensions && \
|
||||
git_clone_retry https://gitea.jordbo.se/joakimp/pi-toolkit.git "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_clone_retry https://gitea.jordbo.se/joakimp/pi-extensions.git "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
|
||||
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
|
||||
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
|
||||
(cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \
|
||||
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)" ; \
|
||||
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" && \
|
||||
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
|
||||
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
# ── Optional: Go ─────────────────────────────────────────────────────
|
||||
@@ -80,6 +139,10 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
||||
|
||||
# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ────────
|
||||
# Installs Bun runtime and the oh-my-opencode-slim npm package.
|
||||
# OMOS_VERSION shares the same cache-hit footgun as PI_VERSION when
|
||||
# left at the `latest` default in registry-cached CI builds. CI
|
||||
# resolves it via `npm view oh-my-opencode-slim version` and passes
|
||||
# the concrete value as a build-arg. See PI_VERSION block above.
|
||||
ARG INSTALL_OMOS=false
|
||||
ARG OMOS_VERSION=latest
|
||||
RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
|
||||
|
||||
@@ -8,8 +8,28 @@ The official `ghcr.io/anomalyco/opencode` image (now archived) was Alpine-based
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Just want to run it?** No git clone needed — grab the two template files:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
|
||||
|
||||
# Pull docker-compose.yml and the .env template
|
||||
curl -O https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml
|
||||
curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
|
||||
|
||||
# Edit .env — at minimum: OPENCODE_PROVIDER, the matching API key,
|
||||
# WORKSPACE_PATH, GIT_USER_NAME, GIT_USER_EMAIL.
|
||||
$EDITOR .env
|
||||
|
||||
# Pull and run
|
||||
docker compose run --rm devbox
|
||||
```
|
||||
|
||||
This pulls `joakimp/opencode-devbox:latest` from Docker Hub, mounts `WORKSPACE_PATH` at `/workspace`, and drops you straight into opencode. Use `bash` instead of (no command) to land in a shell first — useful for `aws sso login`, `pi`, `omos`, etc.
|
||||
|
||||
**Want to hack on the image itself, follow upstream changes, or rebuild from source?** Clone the repo:
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone ssh://gitea.jordbo.se:2222/joakimp/opencode-devbox.git
|
||||
cd opencode-devbox
|
||||
|
||||
@@ -17,7 +37,7 @@ cd opencode-devbox
|
||||
cp .env.example .env
|
||||
# Edit .env with your provider, API key, workspace path, git config
|
||||
|
||||
# Install git hooks (secret scanning)
|
||||
# Install git hooks (secret scanning) before committing
|
||||
brew install gitleaks # macOS / Linuxbrew
|
||||
./setup-hooks.sh
|
||||
|
||||
@@ -112,6 +132,10 @@ docker compose exec -u developer devbox aws --version
|
||||
| `GIT_USER_EMAIL` | Git commit author email | — |
|
||||
| `WORKSPACE_PATH` | Host path to mount | `.` |
|
||||
| `SSH_KEY_PATH` | Host SSH key directory | `~/.ssh` |
|
||||
| `DEVBOX_LAN_ACCESS` | LAN-access mode: `auto` (jump only on VM-backed hosts), `jump` (always), `off` | `auto` |
|
||||
| `HOST_SSH_USER` | Username to SSH into the host as (required for the LAN jump) | — |
|
||||
| `DEVBOX_HOST_ALIAS` | Hostname used to reach the container host | `host.docker.internal` |
|
||||
| `DEVBOX_LAN_AUTOJUMP_PRIVATE` | `1` = ProxyJump *any* RFC1918 (private) IP through the host, so bare `dssh user@<ip>` works on whatever LAN the host is currently on | `0` |
|
||||
| `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` |
|
||||
@@ -124,6 +148,45 @@ docker compose exec -u developer devbox aws --version
|
||||
| `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 |
|
||||
|
||||
### Reaching your LAN from the container
|
||||
|
||||
The devbox works the same way whether the host is **native Linux Docker** or a **VM-backed** runtime (macOS OrbStack / Docker Desktop, or Docker Desktop on Windows) — but their networking differs:
|
||||
|
||||
- **Native Linux Docker:** the host NATs container egress onto its LAN, so other devices on your LAN are reachable directly. Nothing to configure.
|
||||
- **VM-backed (macOS / Docker Desktop):** the container runs in a Linux VM behind the host's network stack. The host's *directly-attached* LAN peers are **not** bridged into the container by default — only the host itself and *routed* subnets are reachable.
|
||||
|
||||
On every start the entrypoint detects which case applies. On VM-backed hosts it generates a writable `~/.ssh-local/config` that uses the **host as an SSH jump** to reach LAN peers; on native Linux it does nothing. The jump keypair lives in `~/.ssh-local`, which is persisted by the `devbox-ssh-local` named volume — so it's generated **once** and reused across container updates.
|
||||
|
||||
**To enable it on a VM-backed host (one-time setup per machine):**
|
||||
|
||||
1. Set `HOST_SSH_USER=<your host username>` in `.env`.
|
||||
2. Start the container once. When it generates the jump key it prints a ready-to-paste line — run it **on the host** to authorize the key:
|
||||
```bash
|
||||
echo 'ssh-ed25519 AAAA…devbox-jump@…' >> ~/.ssh/authorized_keys
|
||||
```
|
||||
3. Ensure the host's SSH server is on (on macOS: System Settings → General → Sharing → Remote Login).
|
||||
4. Reach the host itself with `dssh host`. (`dssh`/`dscp` wrap `ssh -F ~/.ssh-local/config`.)
|
||||
|
||||
Because the key is persisted, you do this **once per machine** — not after every `docker compose up --force-recreate`. You'll only see the authorize line again if you reset the `devbox-ssh-local` volume.
|
||||
|
||||
That alone gets you `container → host`. To reach **named LAN peers** by name, give them a `ProxyJump host` override. Don't add it to the shared `~/.ssh/config` entries — the host itself reaches those peers *directly*, and a jump-through-`host` would break the host's own access (and that file is mounted read-only anyway). Instead, drop the overrides in a **host-owned** file that the container Includes ahead of your `~/.ssh/config`:
|
||||
|
||||
```sshconfig
|
||||
# ~/.config/devbox-shell/ssh-lan.conf — on the host, bind-mounted in
|
||||
# Only ProxyJump goes here; HostName/User/IdentityFile are inherited
|
||||
# (first-value-wins) from the matching block in your ~/.ssh/config.
|
||||
Host my-nas pve pbs
|
||||
ProxyJump host
|
||||
```
|
||||
|
||||
Now `dssh my-nas` routes container → host → LAN peer, pulling HostName/User/key from your existing `~/.ssh/config`. See [`ssh-lan.conf.example`](ssh-lan.conf.example).
|
||||
|
||||
**Roaming / unnamed peers.** Because the jump always targets `host` (= the host on whatever LAN it's currently joined to), you can reach the *current* LAN from anywhere. To make bare `dssh user@<private-ip>` jump automatically without naming peers, set `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` — it ProxyJumps any RFC1918 address through the host. It matches the address you *type* (not the resolved HostName), so named hosts that already carry their own ProxyJump are unaffected.
|
||||
|
||||
**Public IPs go direct.** The container has normal internet egress, so a host with a public IP (or one reached via a *public* jump host) connects straight out — the local `host` jump is not involved. e.g. a `Host bastion` whose `HostName` is public, and everything that `ProxyJump bastion`, works from the container by name with no extra setup.
|
||||
|
||||
> This ships the **mechanism** only — your specific target hosts are facts about *your* network (and a laptop roams between several), so they live in your own host-side config, never baked into the image. Set `DEVBOX_LAN_ACCESS=off` to disable, or `=jump` to force it (e.g. native Linux with `extra_hosts: ["host.docker.internal:host-gateway"]`).
|
||||
|
||||
### Custom opencode config
|
||||
|
||||
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.
|
||||
@@ -342,8 +405,8 @@ docker compose build --build-arg NVIM_VERSION=0.12.1 # pin to a specific versi
|
||||
| `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/mariozechner/pi-coding-agent) 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 `@mariozechner/pi-coding-agent`. Floats by default (image rebuild = pi update). |
|
||||
| `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. |
|
||||
@@ -408,11 +471,11 @@ All six agents should respond if your provider authentication is working.
|
||||
|
||||
## pi (alternative/complementary harness)
|
||||
|
||||
[pi](https://github.com/mariozechner/pi-coding-agent) 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.
|
||||
[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:
|
||||
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. If you want pi **without** opencode, use the separate, leaner [`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox) image instead (it's built from the same `Dockerfile.variant` with `INSTALL_OPENCODE=false`, published in its own repo so an opencode-devbox tag never ships without opencode). Alternatively, build from source:
|
||||
|
||||
### Build
|
||||
|
||||
@@ -467,7 +530,7 @@ docker compose exec -u developer devbox bash
|
||||
- `~/.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 @mariozechner/pi-coding-agent` yourself, the user-installed copy on the volume wins via `PATH` order and survives image rebuilds.
|
||||
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
|
||||
|
||||
@@ -742,7 +805,7 @@ Container (Debian trixie)
|
||||
├── oh-my-opencode-slim (optional — multi-agent orchestration plugin, includes Bun)
|
||||
├── AWS CLI v2 (SSO + Bedrock auth)
|
||||
├── neovim 0.12, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
|
||||
├── git, git-crypt, age, ssh, ripgrep, fd, fzf, jq, curl, tree
|
||||
├── git, git-crypt, age, gitleaks, ssh, ripgrep, fd, fzf, jq, curl, tree
|
||||
├── Node.js (for MCP servers)
|
||||
├── Bun (optional — included with oh-my-opencode-slim)
|
||||
├── entrypoint.sh (UID adjustment, git config, provider setup)
|
||||
|
||||
@@ -59,6 +59,11 @@ services:
|
||||
# allowing both native and containerized opencode on the same machine.
|
||||
- devbox-opencode-config:/home/developer/.config/opencode
|
||||
- devbox-pi-config:/home/developer/.pi
|
||||
# Persist the generated LAN-jump keypair (~/.ssh-local) across recreates.
|
||||
# setup-lan-access.sh generates this key once and reuses it; persisting
|
||||
# it means you authorize it on the host ONCE rather than re-authorizing
|
||||
# after every `docker compose up --force-recreate`.
|
||||
- devbox-ssh-local:/home/developer/.ssh-local
|
||||
|
||||
# NOTE: Do NOT bind-mount ~/.agents/skills/ from the host. The
|
||||
# container manages its own skills directory independently — the
|
||||
@@ -95,6 +100,14 @@ services:
|
||||
# - ~/.bash_aliases:/home/developer/.bash_aliases:ro
|
||||
# - ~/.inputrc:/home/developer/.inputrc:ro
|
||||
|
||||
# Optional: host-owned shell config + LAN jump overrides (recommended
|
||||
# over the single-file ~/.bash_aliases mount above — it's a directory,
|
||||
# so it survives editors' atomic-save). The image's ~/.bash_aliases
|
||||
# sources ~/.config/devbox-shell/bash_aliases if present, and
|
||||
# setup-lan-access.sh reads ~/.config/devbox-shell/ssh-lan.conf for
|
||||
# named-peer `ProxyJump host` overrides (see ssh-lan.conf.example).
|
||||
# - ~/.config/devbox-shell:/home/developer/.config/devbox-shell:ro
|
||||
|
||||
# Optional: persist uv data (Python installs, tool installs)
|
||||
# Without this, 'uv python install' must be re-run after container removal.
|
||||
- devbox-uv:/home/developer/.local/share/uv
|
||||
@@ -126,6 +139,7 @@ services:
|
||||
volumes:
|
||||
devbox-opencode-config:
|
||||
devbox-pi-config:
|
||||
devbox-ssh-local:
|
||||
devbox-data:
|
||||
devbox-state:
|
||||
devbox-shell-history:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Manual host-side publish — escape hatch when CI is broken
|
||||
|
||||
This runbook is the procedure for publishing an opencode-devbox release **directly from a developer host** when the Gitea Actions → Docker Hub path is broken. Used in anger on 2026-05-28 to ship `v1.15.12` after five consecutive CI publish failures (runs #332/333/334/336 + a rerun) and as a parallel diagnostic that pinpointed the root cause (buildkit `cache-export mode=max` returning HTTP 400 from the Hub CDN).
|
||||
|
||||
The procedure is also a **diagnostic probe**. If the host-side publish succeeds where CI fails, the failure is somewhere in the runner → Hub path (cache-export, runner egress, runner-image, action versions). If host-side fails the same way, the failure is in your local buildx + Hub combination and you need a different escape (different network, different account, file an upstream).
|
||||
|
||||
## When to reach for this
|
||||
|
||||
- Tag pushed, CI keeps failing on `docker buildx build --push`, the failure shape is stable across reruns.
|
||||
- Failure body looks like a registry-tier rejection (HTTP 4xx, HTML response body, repeats on every retry) — i.e. not a transient.
|
||||
- You've already disproved the obvious suspects (action pin, runner image, network) per the [`ci-release-watcher` skill](../../../.agents/skills/ci-release-watcher/SKILL.md) playbook.
|
||||
- You need the release **shipped today** and don't want to wait for a CI fix to land + re-trigger.
|
||||
|
||||
If CI is broken because **a workflow change you just made is bad**, fix the workflow and re-tag with a letter suffix. This runbook is for when the workflow looks correct but the publish path itself is broken.
|
||||
|
||||
## Prerequisites on the host
|
||||
|
||||
- Docker (or Orbstack on macOS) with `docker buildx` available — multi-arch publish needs `setup-qemu` equivalent. Orbstack ships QEMU emulators for both archs by default; on Linux install `qemu-user-static` and run `docker run --privileged --rm tonistiigi/binfmt --install all` once per host.
|
||||
- `docker login` credentials for `joakimp` on Docker Hub (PAT or password). Confirm with `docker info | grep Username`.
|
||||
- A clone of `opencode-devbox` checked out at the **exact tag** you want to publish. `git status` clean. `git describe --tags --exact-match HEAD` should print the tag.
|
||||
- Network connectivity to `registry-1.docker.io` from the host. Verify with `curl -sI https://registry-1.docker.io/v2/ | head -1` (expects `401 Unauthorized` — that's the v2 API saying "auth required", which means you can reach it).
|
||||
|
||||
## How to use this runbook
|
||||
|
||||
A working reference script lives next to this doc: **[`docs/manual-host-publish.sh`](manual-host-publish.sh)**. It is the literal script that shipped opencode-devbox v1.15.12 on 2026-05-28 from a developer Mac via Orbstack, with the BASE_HASH and version pins of that release. To publish a different release, **copy it to a new file, edit four constants at the top, and run it**:
|
||||
|
||||
```bash
|
||||
cp docs/manual-host-publish.sh /tmp/manual-publish-vX.Y.Z.sh
|
||||
# Edit at top of file:
|
||||
# RELEASE_TAG="vX.Y.Z"
|
||||
# BASE_HASH="<12-char hash from CI's base-decide step>"
|
||||
# PI_VERSION="<from npm registry, see step 2 below>"
|
||||
# OMOS_VERSION="<from npm registry, see step 2 below>"
|
||||
bash /tmp/manual-publish-vX.Y.Z.sh
|
||||
```
|
||||
|
||||
Keep the historical script in `docs/` as-is — it's an archive of the v1.15.12 publish, useful as a reference if a future debug needs to compare exact arg sets across releases. Don't edit it in place.
|
||||
|
||||
The sections below explain what the script does and what you need to know to edit those four constants safely.
|
||||
|
||||
## 1. Pin RELEASE_TAG
|
||||
|
||||
The git tag you're publishing. Must match a tag in the local clone:
|
||||
|
||||
```bash
|
||||
git fetch && git checkout v1.15.13 # whatever you're publishing
|
||||
git describe --tags --exact-match HEAD
|
||||
```
|
||||
|
||||
The script asserts `HEAD == ${RELEASE_TAG}^{commit}` before doing anything destructive. If you've drifted, fix it with `git checkout` before running.
|
||||
|
||||
## 2. Pin PI_VERSION and OMOS_VERSION
|
||||
|
||||
Gitea CI's `resolve-versions` job queries the npm registry at workflow time and threads concrete versions through every variant build, mitigating the silent same-bytes-across-releases regression class documented in `AGENTS.md`. Do the same by hand:
|
||||
|
||||
```bash
|
||||
curl -sf https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest | jq -r .version
|
||||
curl -sf https://registry.npmjs.org/oh-my-opencode-slim/latest | jq -r .version
|
||||
```
|
||||
|
||||
Paste the two version strings into the script's `PI_VERSION` / `OMOS_VERSION` constants. Don't leave the script defaulting to `latest` — the registry buildcache will silently reuse a stale layer if the build-arg byte-equals a previous build.
|
||||
|
||||
## 3. Pin BASE_HASH
|
||||
|
||||
This is the 12-char hash that CI's `base-decide` job computes from `Dockerfile.base` + `rootfs/**` + `entrypoint*.sh`. Three ways to get it, in order of preference:
|
||||
|
||||
**A. From a prior CI run on the same commit** (cheapest — if the Gitea Actions run that triggered on this tag got far enough to log `base-decide`'s output, just read it):
|
||||
|
||||
```
|
||||
Gitea Actions → the run for vX.Y.Z → base-decide job → "Compute base tag" step → last line:
|
||||
Computed base tag: base-XXXXXXXXXXXX
|
||||
```
|
||||
|
||||
This is the canonical source. The whole reason for the manual escape is that *something later in CI broke* — `base-decide` itself is fast, deterministic, and almost always succeeds.
|
||||
|
||||
**B. From an existing image on the Hub** if a recent release already published a `base-<hash>` tag and the inputs haven't changed, you can copy that hash. Confirm with `docker manifest inspect joakimp/opencode-devbox:base-latest` and read the digest — if it matches a `base-<hash>` you already see on the Hub, that hash is yours.
|
||||
|
||||
**C. Compute it locally**, replicating CI's exact recipe (the script in `.gitea/workflows/docker-publish-split.yml` `base-decide.compute`):
|
||||
|
||||
```bash
|
||||
{
|
||||
cat Dockerfile.base
|
||||
find rootfs -type f \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '.DS_Store' \
|
||||
! -name '._*' \
|
||||
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
} | sha256sum | cut -c1-12
|
||||
```
|
||||
|
||||
The junk-file filters (`__pycache__`, `.DS_Store`, `._*` AppleDouble) matter — they are gitignored but `find -type f` picks them up locally and would diverge your hash from CI's clean checkout. Don't skip them.
|
||||
|
||||
If method C disagrees with method A, **trust A** and find out why your local tree differs. The hash in CI is what's on the Hub; that's what variants must FROM.
|
||||
|
||||
## What the script does (high level)
|
||||
|
||||
After the constants are set, the script runs a 5-step procedure. No editing needed inside the body; the whole flow is parameterised by the four constants above plus `IMAGE` (which is fixed to `joakimp/opencode-devbox`).
|
||||
|
||||
1. **Preflight** — buildx present, tag exists, `HEAD == tag`, multi-arch builder created if missing.
|
||||
2. **Base build (conditional)** — probe `${IMAGE}:base-${BASE_HASH}` on the Hub; if missing, build it multi-arch and push. **No `--cache-from` / `--cache-to`.** That's the whole point of this escape. If the base push itself fails the same way CI did, stop — the regression has spread to image push and you need a different host or account, not this runbook.
|
||||
3. **Promote `base-latest`** — `docker buildx imagetools create` re-tags by manifest reference. No rebuild.
|
||||
4. **Variants × 5** — sequential (not parallel; one host's egress can't saturate five multi-arch pushes safely). Each variant is `Dockerfile.variant` `FROM ${IMAGE}:base-${BASE_HASH}` plus the appropriate `INSTALL_OPENCODE` / `INSTALL_OMOS` / `INSTALL_PI` build-args, tagged `${RELEASE_TAG}${suffix}` and `latest${suffix}`.
|
||||
5. **Verify** — prints the digest of all 12 expected tags (10 variant + base-hash + base-latest). Spot-check that each `vX.Y.Z*` and its `latest*` alias share a digest.
|
||||
|
||||
Expected wall time on a recent Mac: ~25-40 min (base ~3 min if rebuilt, each variant ~3-7 min mostly QEMU arm64 emulation).
|
||||
|
||||
## Optional: update DOCKER_HUB.md description
|
||||
|
||||
CI's `update-description` job posts the rendered Hub description via the Hub API. The manual script does **not** do this — the release works fine without it. If you want parity, copy the curl invocation from the `update-description` job in `.gitea/workflows/docker-publish-split.yml` and run it from the host with a Hub PAT loaded into `HUB_PAT`. Cosmetic; can wait until CI is healthy and the next release pushes a fresh description automatically.
|
||||
|
||||
## After: capture diagnostic value
|
||||
|
||||
The whole point of running this manually is the diagnostic. Three things to record before moving on:
|
||||
|
||||
1. **Did the host publish succeed?** If yes and CI was failing on the same exact code, you've localised the failure to the runner side (cache-export, network, runner image). If no, the failure is in your local buildx + Hub combination and CI is a victim, not a cause.
|
||||
2. **What was different from CI?** Document at minimum: `docker buildx version`, the host's `buildx ls` output (driver name + version), whether you used `--cache-to` or not, and which network you were on.
|
||||
3. **File the upstream.** If the diagnostic narrowed the failure to a specific buildkit/buildx behaviour, file at `moby/buildkit` or `docker/buildx` with: stable failure shape, the exact request URL fragment (`Offset:0` / `_state=...` / digest if visible), the timeline boundary when failures started, and what worked vs what failed in your repro. The 2026-05-28 cache-export-mode=max regression is a worked example.
|
||||
|
||||
Restore CI as the primary publish path as soon as the underlying regression is fixed or worked around at workflow level. This runbook should be exercised rarely.
|
||||
|
||||
## Variants of this runbook
|
||||
|
||||
- **pi-devbox** — same idea, simpler: only one image (`joakimp/pi-devbox`), one tag pair (`vX.Y.Z` + `latest`), no split base. Adapt the script: drop the `BASE_HASH` constant + steps 2-3 + the variant function; replace with a single `docker buildx build --file Dockerfile --build-arg PI_VERSION=... --tag joakimp/pi-devbox:${RELEASE_TAG} --tag joakimp/pi-devbox:latest --push .`.
|
||||
- **opencode-devbox letter-suffix rebuild** (e.g. `v1.15.12b`) — same procedure end-to-end. The `BASE_HASH` will probably be unchanged from the prior release if no rootfs/entrypoint/Dockerfile.base changes shipped, so the base-build step skips itself automatically via the Hub probe.
|
||||
- **Single-variant publish** for partial-failure recovery (e.g. CI succeeded for base + 3 variants but the 4th failed) — comment out the three completed `build_variant` calls in your copy of the script. Keep `imagetools create` for `base-latest` only if it didn't already promote. Then re-run.
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# Manual publish of opencode-devbox v1.15.12 — bypasses broken Gitea-runner
|
||||
# Hub push by building & pushing from a developer host (Orbstack/Docker Desktop).
|
||||
#
|
||||
# Mirrors what .gitea/workflows/docker-publish-split.yml would do:
|
||||
# 1. Build & push Dockerfile.base → joakimp/opencode-devbox:base-<hash>
|
||||
# 2. Promote → joakimp/opencode-devbox:base-latest
|
||||
# 3. Build & push 5 variants on top of base-<hash>:
|
||||
# :v1.15.12 :latest (INSTALL_OPENCODE only)
|
||||
# :v1.15.12-omos :latest-omos (+ OMOS)
|
||||
# :v1.15.12-with-pi :latest-with-pi (+ pi)
|
||||
# :v1.15.12-omos-with-pi :latest-omos-with-pi (+ both)
|
||||
# :v1.15.12-pi-only :latest-pi-only (pi, no opencode)
|
||||
#
|
||||
# Usage on your host:
|
||||
# 1. Make sure Orbstack/Docker Desktop is running with multi-arch enabled
|
||||
# (docker buildx ls should show linux/amd64,linux/arm64).
|
||||
# 2. docker login docker.io (joakimp account)
|
||||
# 3. cd ~/path/to/opencode-devbox && git fetch && git checkout v1.15.12
|
||||
# 4. bash /path/to/this/script.sh
|
||||
#
|
||||
# Total expected time: ~25-40 min on a recent Mac (4 multi-arch builds, base
|
||||
# layers cache after the first variant).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="joakimp/opencode-devbox"
|
||||
RELEASE_TAG="v1.15.12"
|
||||
BASE_HASH="8d72a9e44796" # sha256 of Dockerfile.base + rootfs/* + entrypoints (computed by CI logic)
|
||||
BASE_TAG="base-${BASE_HASH}"
|
||||
PI_VERSION="0.76.0" # resolved from npm @earendil-works/pi-coding-agent latest (2026-05-28)
|
||||
OMOS_VERSION="1.1.1" # resolved from npm oh-my-opencode-slim latest (2026-05-28)
|
||||
PLATFORMS="linux/amd64,linux/arm64"
|
||||
|
||||
# -------- preflight --------
|
||||
echo "==> Preflight"
|
||||
docker buildx version >/dev/null || { echo "buildx not available"; exit 1; }
|
||||
git rev-parse --verify "$RELEASE_TAG" >/dev/null 2>&1 || {
|
||||
echo "Tag $RELEASE_TAG not found locally. git fetch && git checkout $RELEASE_TAG first."; exit 1; }
|
||||
[[ "$(git rev-parse HEAD)" == "$(git rev-parse "${RELEASE_TAG}^{commit}")" ]] || {
|
||||
echo "HEAD is not at $RELEASE_TAG. git checkout $RELEASE_TAG first."; exit 1; }
|
||||
docker buildx inspect default >/dev/null 2>&1 || docker buildx create --use --name multi --driver docker-container
|
||||
|
||||
# Probe whether base-<hash> already exists on Hub (CI does this; saves 10 min if yes)
|
||||
if docker manifest inspect "${IMAGE}:${BASE_TAG}" >/dev/null 2>&1; then
|
||||
echo "==> Base tag ${IMAGE}:${BASE_TAG} already exists on Hub — skipping base rebuild"
|
||||
SKIP_BASE=1
|
||||
else
|
||||
echo "==> Base tag ${IMAGE}:${BASE_TAG} missing — will build"
|
||||
SKIP_BASE=0
|
||||
fi
|
||||
|
||||
# -------- 1. base (if needed) --------
|
||||
if [[ "$SKIP_BASE" == "0" ]]; then
|
||||
echo "==> [1/7] Build & push Dockerfile.base → ${IMAGE}:${BASE_TAG}"
|
||||
docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
-f Dockerfile.base \
|
||||
-t "${IMAGE}:${BASE_TAG}" \
|
||||
--push \
|
||||
.
|
||||
fi
|
||||
|
||||
# -------- 2. promote base-latest --------
|
||||
echo "==> [2/7] Promote ${IMAGE}:${BASE_TAG} → ${IMAGE}:base-latest"
|
||||
docker buildx imagetools create -t "${IMAGE}:base-latest" "${IMAGE}:${BASE_TAG}"
|
||||
|
||||
# -------- 3-5. variants --------
|
||||
build_variant() {
|
||||
local suffix="$1" # "" | "-omos" | "-with-pi" | "-omos-with-pi" | "-pi-only"
|
||||
local install_omos="$2"
|
||||
local install_pi="$3"
|
||||
local install_opencode="${4:-true}"
|
||||
local extra_args=()
|
||||
[[ "$install_pi" == "true" ]] && extra_args+=(--build-arg "PI_VERSION=${PI_VERSION}")
|
||||
[[ "$install_omos" == "true" ]] && extra_args+=(--build-arg "OMOS_VERSION=${OMOS_VERSION}")
|
||||
|
||||
local versioned="${IMAGE}:${RELEASE_TAG}${suffix}"
|
||||
local floating="${IMAGE}:latest${suffix}"
|
||||
|
||||
echo "==> Build & push variant${suffix:-(default)} → ${versioned} + ${floating}"
|
||||
docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
-f Dockerfile.variant \
|
||||
--build-arg "BASE_IMAGE=${IMAGE}:${BASE_TAG}" \
|
||||
--build-arg "INSTALL_OPENCODE=${install_opencode}" \
|
||||
--build-arg "INSTALL_OMOS=${install_omos}" \
|
||||
--build-arg "INSTALL_PI=${install_pi}" \
|
||||
${extra_args[@]+"${extra_args[@]}"} \
|
||||
-t "${versioned}" \
|
||||
-t "${floating}" \
|
||||
--push \
|
||||
.
|
||||
}
|
||||
|
||||
echo "==> [3/7] Variant: base (opencode only)"
|
||||
build_variant "" false false
|
||||
|
||||
echo "==> [4/7] Variant: omos"
|
||||
build_variant "-omos" true false
|
||||
|
||||
echo "==> [5/7] Variant: with-pi"
|
||||
build_variant "-with-pi" false true
|
||||
|
||||
echo "==> [6/7] Variant: omos-with-pi"
|
||||
build_variant "-omos-with-pi" true true
|
||||
|
||||
echo "==> [7/7] Variant: pi-only (pi without opencode)"
|
||||
build_variant "-pi-only" false true false
|
||||
|
||||
echo
|
||||
echo "==> Done. Verifying tags on Hub:"
|
||||
for t in \
|
||||
"${RELEASE_TAG}" "latest" \
|
||||
"${RELEASE_TAG}-omos" "latest-omos" \
|
||||
"${RELEASE_TAG}-with-pi" "latest-with-pi" \
|
||||
"${RELEASE_TAG}-omos-with-pi" "latest-omos-with-pi" \
|
||||
"${RELEASE_TAG}-pi-only" "latest-pi-only" \
|
||||
"${BASE_TAG}" "base-latest"
|
||||
do
|
||||
d=$(docker manifest inspect "${IMAGE}:${t}" 2>/dev/null | python3 -c "import json,sys,hashlib; m=json.load(sys.stdin); print(m.get('digest','-'))" 2>/dev/null || echo "MISSING")
|
||||
printf " %-32s %s\n" "$t" "$d"
|
||||
done
|
||||
@@ -0,0 +1,235 @@
|
||||
# Plan: LAN-access mechanism + pi-fork/pi-observational-memory in the builds
|
||||
|
||||
Status: PROPOSED (2026-06-03, decisions folded in). Author: pi (devbox session).
|
||||
Scope: opencode-devbox base + variant, pi-devbox. Two independent work items.
|
||||
|
||||
---
|
||||
|
||||
## Layering decision
|
||||
|
||||
| Capability | Lives in | Why |
|
||||
|---|---|---|
|
||||
| **LAN-access (smart-detect host-jump)** | opencode-devbox **base** | Both opencode-devbox and pi-devbox inherit it; not pi-specific. |
|
||||
| **pi-fork + pi-observational-memory** | **pi layer** (variant `with-pi`/`omos-with-pi` + pi-devbox/Dockerfile) | Only meaningful when `pi` is present. Runtime deploy via the shared base `entrypoint-user.sh`, guarded by `command -v pi`. |
|
||||
|
||||
Guiding principle for LAN access: **ship the mechanism, not the policy.**
|
||||
The image provides a generic `host` jump alias + writable SSH config + detection.
|
||||
A user's *specific* targets (e.g. pve/pve-2) come from their bind-mounted
|
||||
`~/.ssh/config` (`ProxyJump host`) or an env list — never hardcoded in the image.
|
||||
|
||||
---
|
||||
|
||||
## ITEM A — LAN access (opencode-devbox base)
|
||||
|
||||
### Why it can't "just work" unattended
|
||||
- macOS (OrbStack / Docker Desktop): container is in a Linux VM behind the host's
|
||||
stack. Directly-attached LAN peers are not bridged by default; only the host +
|
||||
routed subnets are reachable.
|
||||
- Linux Docker: default bridge already NATs container egress onto the host's LAN,
|
||||
so LAN peers are usually directly reachable. The jump is unnecessary.
|
||||
- The jump path needs the host running sshd + the container's pubkey authorized.
|
||||
The average DockerHub t"kick the tires" user has neither → setup must be
|
||||
**opt-in / non-fatal**, never block startup.
|
||||
|
||||
### New file: `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh`
|
||||
COPY'd automatically (base already does `COPY rootfs/usr/local/lib/opencode-devbox/`).
|
||||
|
||||
Behavior, driven by `DEVBOX_LAN_ACCESS=auto|jump|off` (default `auto`):
|
||||
|
||||
1. `off` → return immediately.
|
||||
2. Detect environment:
|
||||
- VM-backed Docker (OrbStack / Docker Desktop) iff `getent hosts host.docker.internal`
|
||||
resolves (OrbStack also exposes `host.orb.internal`). Native Linux → no resolution
|
||||
(unless the user added `extra_hosts: host.docker.internal:host-gateway`).
|
||||
3. `auto` + native Linux → do nothing (direct LAN works); print one info line.
|
||||
4. `auto` + VM-backed, or `jump` forced →
|
||||
- Create writable `~/.ssh-local/{,cm/}`, `chmod 700`.
|
||||
- Generate `~/.ssh-local/devbox_jump_ed25519` if absent (preserve across restarts).
|
||||
- Render `~/.ssh-local/config`:
|
||||
```
|
||||
Host *
|
||||
UserKnownHostsFile ~/.ssh-local/known_hosts
|
||||
StrictHostKeyChecking accept-new
|
||||
Host host mac # 'mac' kept as friendly alias
|
||||
HostName host.docker.internal
|
||||
User ${HOST_SSH_USER} # REQUIRED for auth; see below
|
||||
IdentityFile ~/.ssh-local/devbox_jump_ed25519
|
||||
IdentitiesOnly yes
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
ControlPersist 4h
|
||||
# Optional per-target blocks generated from DEVBOX_LAN_HOSTS (see below)
|
||||
Include ~/.ssh/config # user's bind-mounted targets still resolve
|
||||
```
|
||||
- If `HOST_SSH_USER` unset → still render config but print a clear hint block:
|
||||
the generated **public key** + the one-liner to authorize it on the host
|
||||
(`echo '<pubkey>' >> ~/.ssh/authorized_keys`) + "enable Remote Login".
|
||||
- Idempotent: re-render config each start (cheap); never regenerate the key.
|
||||
- DECISION #5: NO `DEVBOX_LAN_HOSTS` env. Keep the image policy-free. Users add
|
||||
`ProxyJump host` to their own target entries in the bind-mounted `~/.ssh/config`
|
||||
(pulled in by the `Include ~/.ssh/config` line).
|
||||
|
||||
### `entrypoint-user.sh`
|
||||
Call `setup-lan-access.sh` right after the existing `/tmp/sshcm` block
|
||||
(non-fatal: `… || true`). It's environment-gated so it self-skips on Linux.
|
||||
|
||||
### `rootfs/home/developer/.bash_aliases` (per your note — alias goes HERE)
|
||||
Append, guarded:
|
||||
```bash
|
||||
# dssh — ssh using the container's writable LAN-access config (host-jump).
|
||||
# Only useful when setup-lan-access.sh generated ~/.ssh-local/config.
|
||||
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||
alias dssh='ssh -F "$HOME/.ssh-local/config"'
|
||||
alias dscp='scp -F "$HOME/.ssh-local/config"'
|
||||
fi
|
||||
```
|
||||
Migration caveat: skel `.bash_aliases` is only copied when absent, so existing
|
||||
volumes/containers won't get `dssh` until they `rm ~/.bash_aliases` and recreate,
|
||||
OR drop the alias into the host-shared `~/.config/devbox-shell/bash_aliases`
|
||||
(already sourced at the top of the skel file).
|
||||
|
||||
### Dockerfile.base
|
||||
No structural change required (script ships via existing rootfs COPY). Optionally
|
||||
document `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_LAN_HOSTS` in `.env.example`
|
||||
and README.
|
||||
|
||||
---
|
||||
|
||||
## ITEM B — pi-fork + pi-observational-memory (pi layer)
|
||||
|
||||
Sources (pinned this week):
|
||||
- `github.com/elpapi42/pi-fork` (registers `fork`; ~v0.1.0)
|
||||
- `github.com/elpapi42/pi-observational-memory` (registers `recall`; default branch **master**, v3.0.2)
|
||||
|
||||
### B1 RESOLVED (verified live 2026-06-03 in this container)
|
||||
- `pi install <local-path>` is INSTANT (~0.5s): NO copy, NO npm install. pi registers
|
||||
the path and loads the extension IN PLACE from that dir.
|
||||
- settings.json stores a RELATIVE path (e.g. `../../../opt/pi-fork` from ~/.pi/agent).
|
||||
Points into the image-layer `/opt` → stable across volume recreate. Good.
|
||||
- Idempotent: a second `pi install <same path>` does NOT duplicate the entry.
|
||||
- CONSEQUENCE: because pi does NOT npm-install a local path, deps must already exist
|
||||
at `/opt/<pkg>/node_modules`. pi-fork imports `@sinclair/typebox` + `@earendil-works/*`
|
||||
peers; git-install produced a 148 MB node_modules. So we MUST `npm install` inside
|
||||
each `/opt/<pkg>` AT BUILD TIME.
|
||||
- BAKE RECIPE: clone to /opt -> `npm install` there (build) -> `pi install /opt/<pkg>`
|
||||
at runtime (instant, idempotent).
|
||||
- (Optional size win, verify-first: prune to external-only deps if pi provides the
|
||||
`@earendil-works/*` peers from its own runtime resolution. ~148M is mostly those.)
|
||||
|
||||
### DECISION #3: refactor to remove duplication
|
||||
`pi-devbox/Dockerfile` currently duplicates the pi-install + /opt-clone logic from
|
||||
`Dockerfile.variant`. Refactor `pi-devbox/Dockerfile` to `FROM` the `with-pi` variant
|
||||
image so pi-install logic (incl. the new fork/obsmem clones) lives in ONE place.
|
||||
|
||||
> **Implementation update (2026-06-03):** `FROM with-pi` would have dragged opencode
|
||||
> into pi-devbox (all opencode-devbox variants set `INSTALL_OPENCODE=true`), making it
|
||||
> nearly identical to `latest-with-pi`. So a 5th variant **`pi-only`**
|
||||
> (`INSTALL_OPENCODE=false`, `INSTALL_PI=true`) was added to opencode-devbox, and
|
||||
> pi-devbox now `FROM`s `latest-pi-only`. Same single-source-of-truth win, but
|
||||
> pi-devbox stays lean (no opencode, ~145 MB lighter than with-pi).
|
||||
>
|
||||
> **Update 2 (2026-06-03, Option B):** publishing the pi-only variant as
|
||||
> `opencode-devbox:latest-pi-only` meant an "opencode-devbox" Hub tag that
|
||||
> contains no opencode — confusing. Final scheme: the pi-only build is still
|
||||
> produced by opencode-devbox CI (single source of truth) but its
|
||||
> `build-variant-pi-only` job pushes into the **`joakimp/pi-devbox`** repo as
|
||||
> the internal building-block tag `base-pi-only` (+ `base-pi-only-vX.Y.Z`), and
|
||||
> pi-devbox now `FROM`s `joakimp/pi-devbox:base-pi-only`. No opencode-less tag
|
||||
> ever appears under opencode-devbox; pi-only is de-advertised from
|
||||
> opencode-devbox's README/DOCKER_HUB. New `PI_IMAGE` workflow env.
|
||||
|
||||
### Build time — clone to /opt + npm install (mirror pi-toolkit/extensions pattern)
|
||||
Add to the single `INSTALL_PI=true` block in `opencode-devbox/Dockerfile.variant`
|
||||
(after refactor, pi-devbox inherits it):
|
||||
```dockerfile
|
||||
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
||||
ARG PI_FORK_REF=<pin: tag or commit SHA>
|
||||
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
||||
ARG PI_OBSMEM_REF=master # pin to SHA in CI to dodge cache-hit footgun
|
||||
# ... inside the INSTALL_PI / pi-install RUN, after the pi-toolkit/extensions clones:
|
||||
git_clone_retry "$PI_FORK_REPO" "$PI_FORK_REF" /opt/pi-fork && \
|
||||
git_clone_retry "$PI_OBSMEM_REPO" "$PI_OBSMEM_REF" /opt/pi-observational-memory && \
|
||||
(cd /opt/pi-fork && npm install --no-audit --no-fund) && \
|
||||
(cd /opt/pi-observational-memory && npm install --no-audit --no-fund) && \
|
||||
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
|
||||
echo "pi-obsmem at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)"
|
||||
```
|
||||
NOTE: `git_clone_retry` uses `--branch "$ref"`, which accepts tags & branches but
|
||||
NOT arbitrary commit SHAs. For SHA pinning use `git clone <url> <dest> && git -C
|
||||
<dest> checkout <sha>` for these two repos.
|
||||
|
||||
### Why not bake the install result
|
||||
`~/.pi` is a named volume mounted at runtime — anything `pi install`'d into
|
||||
`~/.pi/agent/...` at BUILD time is hidden by the volume. Same reason
|
||||
pi-toolkit/extensions deploy at runtime via `entrypoint-user.sh`. So:
|
||||
|
||||
### Runtime deploy — `entrypoint-user.sh` (shared base, in the `command -v pi` block)
|
||||
After the pi-extensions `install.sh` call, add an idempotent install of each /opt pkg:
|
||||
```bash
|
||||
for pkg in /opt/pi-fork /opt/pi-observational-memory; do
|
||||
[ -d "$pkg" ] || continue
|
||||
name=$(basename "$pkg")
|
||||
# skip if already registered in settings.json packages
|
||||
if ! grep -q "$name" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||
(cd "$HOME" && pi install "$pkg") || echo "WARN: pi install $name failed (continuing)"
|
||||
fi
|
||||
done
|
||||
```
|
||||
`fork` + `recall` tools register on the NEXT pi start after deploy (exts bind at
|
||||
startup). First deploy after a volume recreate pays an `npm install` cost
|
||||
(pi-fork pulls ~133 deps) — acceptable, one-time per volume lifetime.
|
||||
|
||||
OPEN ITEM B1 (verify before finalizing): exact `pi install <local-path>` semantics
|
||||
— does it copy/symlink, and does it npm-install at run each time? If it re-resolves
|
||||
deps every start, pre-populate `/opt/<pkg>/node_modules` at build (`npm install
|
||||
--omit=dev`) and confirm the runtime install reuses it. Quick test in this container:
|
||||
`pi install /opt/pi-fork` twice, observe settings.json + timing + tool registration.
|
||||
|
||||
### CI — `.gitea/workflows/docker-publish-split.yml` (DECISION #2: latest-but-pinned)
|
||||
- USE LATEST CONTENT, BUT RESOLVE TO A SHA IN CI (same pattern as PI_VERSION/OMOS).
|
||||
The existing `resolve-versions` job curls npm `latest` for pi/omos to defeat the
|
||||
build-arg cache-hit footgun. Add an analogous resolve for the two git repos:
|
||||
query the GitHub API for the HEAD commit SHA of the tracked branch (master) and
|
||||
pass it as `PI_FORK_REF` / `PI_OBSMEM_REF` build-args, so the layer hash changes
|
||||
when upstream moves AND we still get newest-at-build-time.
|
||||
- Passing a bare branch name would be byte-identical across builds -> stale cached
|
||||
layer (the documented footgun). SHA resolution fixes both.
|
||||
- Pass the new build-args in the `with-pi` and `omos-with-pi` build steps.
|
||||
- The resolved SHAs print in build logs (and ideally as image labels) so a bad
|
||||
upstream is diagnosable and we can pin back to a known-good SHA.
|
||||
|
||||
### Version coupling risk (carry-over from prior session)
|
||||
pi-fork/obsmem extensions are coupled to the host pi version (AGENTS.md warns).
|
||||
pi-fork had a `fix/effort-string-enum-schema` branch from recent API churn. So:
|
||||
- Pin against the SAME `PI_VERSION` the image ships.
|
||||
- smoke-test must assert the tools actually register (below), not just that files exist.
|
||||
|
||||
### Smoke test — `scripts/smoke-test.sh`
|
||||
Add (for `with-pi`/`omos-with-pi`/pi-devbox):
|
||||
1. `/opt/pi-fork/package.json` and `/opt/pi-observational-memory/package.json` exist.
|
||||
2. Run a container, then assert `~/.pi/agent/settings.json` "packages" includes both.
|
||||
3. Best-effort: headless `pi` tool-list contains `fork` and `recall` (if pi exposes a
|
||||
non-interactive list; otherwise step 2 is the gate).
|
||||
|
||||
---
|
||||
|
||||
## Decisions — RESOLVED 2026-06-03
|
||||
1. **B1**: VERIFIED. Local-path install is instant/in-place; bake `npm install` into
|
||||
`/opt/<pkg>` at build; runtime `pi install /opt/<pkg>` is instant + idempotent. ✓
|
||||
2. **Latest-but-pinned**: track latest (master HEAD), resolve to SHA in CI build-arg. ✓
|
||||
3. **Refactor**: pi-devbox/Dockerfile -> `FROM` the with-pi variant; pi-install in ONE place. ✓
|
||||
4. **LAN default** `DEVBOX_LAN_ACCESS=auto`: generate config + print authorize hint when
|
||||
`HOST_SSH_USER` unset; silent no-op on native Linux. ✓
|
||||
5. **No `DEVBOX_LAN_HOSTS`**: rely on user's bind-mounted `~/.ssh/config` (`ProxyJump host`). ✓
|
||||
|
||||
## Remaining verify-before-merge items
|
||||
- Confirm the fork/recall extensions LOAD at runtime from `/opt/<pkg>` WITH the baked
|
||||
node_modules (smoke test asserts tool registration, not just files).
|
||||
- Optional: confirm whether pi supplies `@earendil-works/*` peers at runtime so /opt
|
||||
node_modules can be pruned to external-only deps (size optimization, ~148M -> small).
|
||||
|
||||
## Rollout order
|
||||
1. Verify B1 in this live container (cheap, no build).
|
||||
2. Land ITEM A in base (rootfs script + entrypoint call + alias) → rebuild base → smoke.
|
||||
3. Land ITEM B in variant + pi-devbox + CI resolve + smoke assertions.
|
||||
4. CHANGELOG + tag both repos; CI rebuild; verify fork+recall+dssh survive a volume recreate.
|
||||
@@ -1,6 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── SSH ControlMaster socket dir ────────────────────────────────
|
||||
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
|
||||
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
|
||||
# creates the directory with the right permissions on every container
|
||||
# start. /tmp is per-container so the dir doesn't survive recreation;
|
||||
# baking it into a Dockerfile layer would be wrong.
|
||||
# Mode 700 is required — OpenSSH refuses to use a ControlPath dir that
|
||||
# others can write to.
|
||||
mkdir -p /tmp/sshcm
|
||||
chmod 700 /tmp/sshcm
|
||||
|
||||
# ── LAN access: generic host-OS-agnostic reachability helper ────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
|
||||
# reach the host's directly-attached LAN peers by default; this generates a
|
||||
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native
|
||||
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS
|
||||
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header.
|
||||
if [ -r /usr/local/lib/opencode-devbox/setup-lan-access.sh ]; then
|
||||
bash /usr/local/lib/opencode-devbox/setup-lan-access.sh || true
|
||||
fi
|
||||
|
||||
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
|
||||
# Respects host bind-mounts and user customizations — existing files
|
||||
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
|
||||
@@ -85,6 +106,24 @@ if command -v pi &>/dev/null; then
|
||||
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
||||
"$HOME/.pi/agent/extensions/mempalace.ts"
|
||||
fi
|
||||
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool).
|
||||
# These are pi packages (not symlink-style extensions): they're cloned to
|
||||
# /opt with node_modules baked at BUILD time, then registered here via
|
||||
# `pi install <local-path>`. Verified 2026-06-03: a local-path install is
|
||||
# instant + in-place (pi loads the extension directly from /opt) + idempotent
|
||||
# (no duplicate package entry on re-run), and stores a relative path that
|
||||
# resolves into the image-layer /opt so it survives volume recreate. The
|
||||
# fork/recall tools register on the NEXT pi start (extensions bind at
|
||||
# startup). Guard on settings.json so we only install once per volume.
|
||||
for _pkg in /opt/pi-fork /opt/pi-observational-memory; do
|
||||
[ -d "$_pkg" ] || continue
|
||||
_name=$(basename "$_pkg")
|
||||
if ! grep -q "$_name" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||
pi install "$_pkg" >/dev/null 2>&1 || \
|
||||
echo "WARN: pi install $_name failed (continuing)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
||||
|
||||
@@ -88,6 +88,7 @@ for dir in \
|
||||
/home/"$USER_NAME"/.config/opencode \
|
||||
/home/"$USER_NAME"/.config/nvim \
|
||||
/home/"$USER_NAME"/.pi \
|
||||
/home/"$USER_NAME"/.ssh-local \
|
||||
/home/"$USER_NAME"/.agents/skills; do
|
||||
[ -d "$dir" ] || continue
|
||||
|
||||
|
||||
@@ -54,6 +54,17 @@ alias gs='git status'
|
||||
alias gd='git diff'
|
||||
alias gl='git log --oneline --graph --decorate -20'
|
||||
|
||||
# ── LAN access via the host (dssh) ───────────────────────────────────
|
||||
# When running on a VM-backed host (macOS OrbStack / Docker Desktop), the
|
||||
# entrypoint's setup-lan-access.sh generates ~/.ssh-local/config so the host
|
||||
# can be used as an SSH jump to reach LAN peers. These aliases wrap `ssh -F`
|
||||
# / `scp -F` against that config. Guarded so they only appear when the config
|
||||
# was actually generated (no-op / absent on native Linux hosts).
|
||||
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||
alias dssh='ssh -F "$HOME/.ssh-local/config"'
|
||||
alias dscp='scp -F "$HOME/.ssh-local/config"'
|
||||
fi
|
||||
|
||||
# Safety: confirm before destructive ops
|
||||
alias rm='rm -i'
|
||||
alias mv='mv -i'
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-lan-access.sh — generic, host-OS-agnostic LAN reachability helper.
|
||||
#
|
||||
# THE PROBLEM
|
||||
# On macOS (OrbStack / Docker Desktop) and Docker Desktop on Windows, the
|
||||
# container runs inside a Linux VM behind the host's network stack. The
|
||||
# host's *directly-attached* LAN peers (e.g. other boxes on 192.168.1.0/24)
|
||||
# are NOT bridged into the container by default — only the host itself and
|
||||
# *routed* subnets are reachable. On native Linux Docker the default bridge
|
||||
# already NATs container egress onto the host's LAN, so LAN peers are usually
|
||||
# reachable directly and no workaround is needed.
|
||||
#
|
||||
# THE APPROACH ("detect, and on a VM-backed host use the host as a jump")
|
||||
# The one thing reachable from a container on every OS is the host itself
|
||||
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
|
||||
# config that reaches the host and lets the user ProxyJump onward to LAN
|
||||
# peers the host can reach. On native Linux we do nothing.
|
||||
#
|
||||
# We ship the MECHANISM (a generic `host` jump alias + writable config),
|
||||
# never the POLICY: the user's specific target hosts live in their own
|
||||
# bind-mounted ~/.ssh/config (add `ProxyJump host` to those entries) — which
|
||||
# is pulled in via the `Include ~/.ssh/config` line below.
|
||||
#
|
||||
# WHY A WRITABLE SIDECAR (~/.ssh-local)
|
||||
# The devbox typically bind-mounts the host's ~/.ssh READ-ONLY (so agents
|
||||
# can read keys for git but can't tamper with config/known_hosts/authorized_
|
||||
# keys). That means we cannot edit ~/.ssh/config or write ~/.ssh/known_hosts.
|
||||
# So everything generated here lives under the writable ~/.ssh-local, used
|
||||
# via `ssh -F ~/.ssh-local/config` (the `dssh`/`dscp` aliases wrap that).
|
||||
#
|
||||
# CONTROLS (env)
|
||||
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
|
||||
# auto → set up the jump config only on VM-backed hosts; no-op on Linux.
|
||||
# jump → always set up (e.g. native Linux with extra_hosts host-gateway).
|
||||
# off → do nothing.
|
||||
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
|
||||
# jump to authenticate. If unset we still generate the config but print
|
||||
# a hint with the public key to authorize on the host.
|
||||
# DEVBOX_HOST_ALIAS — host hostname to reach (default host.docker.internal).
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE = 0 (default) | 1
|
||||
# 1 → also emit a catch-all that ProxyJumps *any* RFC1918 (private) IP
|
||||
# through the host. Lets bare `dssh user@<private-IP>` work on whatever
|
||||
# LAN the (roaming) host is currently joined to, without naming peers.
|
||||
# Matches by the address you TYPE, not the resolved HostName, so it never
|
||||
# overrides named hosts that already carry their own ProxyJump.
|
||||
#
|
||||
# HOST-OWNED PEER POLICY (portable; keeps this image generic)
|
||||
# Named LAN peers are facts about a *specific* host's network, not about the
|
||||
# image — a roaming laptop sees different LANs. So we never bake peer names
|
||||
# here. Instead, if the host bind-mounts ~/.config/devbox-shell/ssh-lan.conf
|
||||
# (the same devbox-shell bridge dir used for shared aliases), we Include it
|
||||
# *before* ~/.ssh/config. That file holds the host's own jump overrides, e.g.
|
||||
# Host pve pve-2 pbs-vm
|
||||
# ProxyJump host
|
||||
# First-value-wins means ProxyJump is taken from there while HostName/User/
|
||||
# IdentityFile are inherited from the matching block in ~/.ssh/config.
|
||||
#
|
||||
# SCOPING NOTE (important)
|
||||
# `Include` is scoped to the enclosing Host/Match block. So every Include
|
||||
# below is preceded by a bare `Host *` to reset the active context to
|
||||
# match-all — otherwise the included config would only apply when targeting
|
||||
# `host`/`mac` and named peers like `pve` would silently fall back to ssh
|
||||
# defaults.
|
||||
#
|
||||
# Idempotent: re-renders the config every run (cheap); never regenerates the
|
||||
# key. Always non-fatal — never blocks container startup.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
MODE="${DEVBOX_LAN_ACCESS:-auto}"
|
||||
[ "$MODE" = "off" ] && exit 0
|
||||
|
||||
HOST_ALIAS_HOSTNAME="${DEVBOX_HOST_ALIAS:-host.docker.internal}"
|
||||
SSH_LOCAL="${HOME}/.ssh-local"
|
||||
CONFIG="${SSH_LOCAL}/config"
|
||||
KEY="${SSH_LOCAL}/devbox_jump_ed25519"
|
||||
|
||||
# ── Detection: is this a VM-backed host (macOS / Docker Desktop)? ──────
|
||||
# host.docker.internal resolves on OrbStack and Docker Desktop (mac/win) but
|
||||
# NOT on native Linux Docker (unless the user added extra_hosts: host-gateway,
|
||||
# in which case the jump is still harmless / usable, and they can force it
|
||||
# with DEVBOX_LAN_ACCESS=jump).
|
||||
is_vm_backed() {
|
||||
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ "$MODE" = "auto" ] && ! is_vm_backed; then
|
||||
# Native Linux host: LAN peers are reachable directly. Nothing to do.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# From here: MODE=jump, or MODE=auto on a VM-backed host.
|
||||
|
||||
command -v ssh-keygen >/dev/null 2>&1 || exit 0
|
||||
|
||||
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
|
||||
# ── Jump key (generated once; preserved across restarts) ──────────────
|
||||
# Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key
|
||||
# is generated only on the very first start (or if the volume is wiped). When
|
||||
# we DO generate one it must be (re-)authorized on the host, so we flag it and
|
||||
# print a copy-paste authorize line below.
|
||||
KEY_JUST_GENERATED=0
|
||||
if [ ! -f "$KEY" ]; then
|
||||
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0
|
||||
chmod 600 "$KEY" 2>/dev/null || true
|
||||
KEY_JUST_GENERATED=1
|
||||
fi
|
||||
|
||||
# ── Render the writable config ────────────────────────────────────────
|
||||
USER_LINE=""
|
||||
if [ -n "${HOST_SSH_USER:-}" ]; then
|
||||
USER_LINE=" User ${HOST_SSH_USER}"
|
||||
fi
|
||||
|
||||
# Optional host-owned named-peer jump overrides (portable: lives on the host,
|
||||
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
|
||||
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
|
||||
LAN_CONF_BLOCK=""
|
||||
if [ -r "$SSH_LAN_CONF" ]; then
|
||||
LAN_CONF_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Host-owned named-peer jump overrides (bind-mounted; edit on the host).
|
||||
# Scope reset to match-all so the Include applies to every target host.
|
||||
Host *
|
||||
Include ~/.config/devbox-shell/ssh-lan.conf
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
|
||||
# host. Matches the typed address, never the resolved HostName, so named hosts
|
||||
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
|
||||
AUTOJUMP_BLOCK=""
|
||||
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
|
||||
AUTOJUMP_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
|
||||
# the host's CURRENT LAN via bare `dssh user@<ip>`. Public IPs are unmatched
|
||||
# and go direct via the container's NAT egress. NOTE: also matches the
|
||||
# container's own bridge subnet and any private IP the host can't actually
|
||||
# reach — for non-LAN private hosts behind a different jump, use their named
|
||||
# entry (which matches first by name and keeps its own ProxyJump).
|
||||
Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22.* 172.23.* 172.24.* 172.25.* 172.26.* 172.27.* 172.28.* 172.29.* 172.30.* 172.31.*
|
||||
ProxyJump host
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
INCLUDE_BLOCK=""
|
||||
if [ -r "${HOME}/.ssh/config" ]; then
|
||||
INCLUDE_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Your own target hosts. Scope reset to match-all so this Include applies to
|
||||
# every target (an Include is otherwise scoped to the enclosing Host block).
|
||||
# Add 'ProxyJump host' to LAN entries here (or in ssh-lan.conf above).
|
||||
Host *
|
||||
Include ~/.ssh/config
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
cat > "$CONFIG" <<EOF
|
||||
# AUTO-GENERATED by setup-lan-access.sh on every container start. Do not edit
|
||||
# by hand — edits are overwritten. Used via: ssh -F ~/.ssh-local/config <host>
|
||||
# (or the dssh / dscp aliases). See the script header for the full rationale.
|
||||
|
||||
# ~/.ssh is typically mounted read-only, so keep our own known_hosts here.
|
||||
# Also redirect ControlPath into the writable sidecar: the bind-mounted
|
||||
# ~/.ssh/config commonly sets 'ControlPath ~/.ssh/cm/...' for CGNAT multiplexing,
|
||||
# but ~/.ssh is read-only here so the master socket can't be created and those
|
||||
# hosts fail to connect. First-value-wins: setting it here (before the Include)
|
||||
# overrides the read-only path for every host. Harmless when ControlMaster is off.
|
||||
Host *
|
||||
UserKnownHostsFile ~/.ssh-local/known_hosts
|
||||
StrictHostKeyChecking accept-new
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
|
||||
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
|
||||
Host host mac
|
||||
HostName ${HOST_ALIAS_HOSTNAME}
|
||||
${USER_LINE}
|
||||
IdentityFile ~/.ssh-local/devbox_jump_ed25519
|
||||
IdentitiesOnly yes
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
ControlPersist 4h
|
||||
ServerAliveInterval 30
|
||||
${LAN_CONF_BLOCK}
|
||||
${AUTOJUMP_BLOCK}
|
||||
${INCLUDE_BLOCK}
|
||||
EOF
|
||||
chmod 600 "$CONFIG" 2>/dev/null || true
|
||||
|
||||
# ── Authorize hints ───────────────────────────────────────────────────
|
||||
# Print the copy-paste authorize line whenever we either (a) can't yet
|
||||
# authenticate (HOST_SSH_USER unset) or (b) just generated a NEW key that the
|
||||
# host won't recognize. With ~/.ssh-local persisted via a named volume, case
|
||||
# (b) fires only on first-ever start (or after the volume is reset) — so this
|
||||
# is normally a one-time, one-line step per machine, with no file to locate.
|
||||
PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
|
||||
if [ -z "${HOST_SSH_USER:-}" ]; then
|
||||
cat <<EOF
|
||||
[devbox] LAN-access jump config generated at ~/.ssh-local/config, but
|
||||
HOST_SSH_USER is unset so it can't authenticate to the host yet.
|
||||
To enable container -> host -> LAN-peer access:
|
||||
1. Set HOST_SSH_USER=<your host username> in the container env.
|
||||
2. Authorize this key on the host (run ON THE HOST, once):
|
||||
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
||||
3. Ensure the host's SSH server (Remote Login) is enabled.
|
||||
Then: dssh host (or add 'ProxyJump host' to targets in ~/.ssh/config)
|
||||
EOF
|
||||
elif [ "$KEY_JUST_GENERATED" = "1" ]; then
|
||||
cat <<EOF
|
||||
[devbox] Generated a NEW LAN-jump key. Authorize it on the host (${HOST_SSH_USER}@host),
|
||||
then 'dssh host' and your LAN peers will work. Run this ONCE, ON THE HOST:
|
||||
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
||||
(Ensure the host's SSH server / Remote Login is enabled.)
|
||||
This key is persisted in the ~/.ssh-local volume, so you won't need to
|
||||
repeat this on container updates — only if that volume is reset.
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -64,13 +64,33 @@ Designed for teams who want a reproducible coding-agent setup that runs the same
|
||||
|---|---|
|
||||
| `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/mariozechner/pi-coding-agent) as alternative/complementary harness (shares the mempalace install with opencode) |
|
||||
| `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`.
|
||||
|
||||
> A fifth, pi-without-opencode build is produced from the same `Dockerfile.variant`
|
||||
> (`INSTALL_OPENCODE=false`) but is **not** published under this repo — it ships as
|
||||
> the separate [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)
|
||||
> image so an "opencode-devbox" tag never lacks opencode.
|
||||
|
||||
## Quick Start
|
||||
|
||||
For a fully-configured environment with persistent state (opencode config, mempalace memory, neovim plugins, bash history) surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
|
||||
curl -O https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml
|
||||
curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
|
||||
# Edit .env — set OPENCODE_PROVIDER, the matching API key,
|
||||
# WORKSPACE_PATH, GIT_USER_NAME, GIT_USER_EMAIL.
|
||||
docker compose run --rm devbox
|
||||
```
|
||||
|
||||
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first — useful for `aws sso login`, `pi` (on `*-with-pi` variants), or multi-harness workflows.
|
||||
|
||||
**One-shot run, no persistence:**
|
||||
|
||||
```bash
|
||||
docker run -it --rm \\
|
||||
-e ANTHROPIC_API_KEY=your-key \\
|
||||
@@ -82,27 +102,12 @@ docker run -it --rm \\
|
||||
joakimp/opencode-devbox:latest
|
||||
```
|
||||
|
||||
Drops you straight into opencode with your project mounted at `/workspace`.
|
||||
|
||||
For an interactive shell first (useful for AWS SSO login, multi-harness workflows, or just `bash`):
|
||||
|
||||
```bash
|
||||
docker run -it --rm \\
|
||||
-e ANTHROPIC_API_KEY=your-key \\
|
||||
-e OPENCODE_PROVIDER=anthropic \\
|
||||
-v ~/projects:/workspace \\
|
||||
-v ~/.ssh:/home/developer/.ssh:ro \\
|
||||
joakimp/opencode-devbox:latest bash
|
||||
```
|
||||
|
||||
Then run `opencode`, `pi` (on `*-with-pi` variants), or `aws sso login` from the shell.
|
||||
|
||||
For docker-compose users, the source repo provides `docker-compose.yml`, `.env.example`, and a one-liner `docker compose up -d` workflow with named volumes pre-wired.
|
||||
Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedrock SSO + static), persistence model, build args, troubleshooting: <{GITEA}#readme>
|
||||
|
||||
## What's Inside
|
||||
|
||||
- **[opencode](https://opencode.ai)** — primary coding-agent harness. Multi-provider (Anthropic, OpenAI, Bedrock, Google, Groq, etc.).
|
||||
- **[pi](https://github.com/mariozechner/pi-coding-agent)** *(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`.
|
||||
- **[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.
|
||||
@@ -140,6 +145,10 @@ Full persistence reference, including multi-user (`SIGNUM`) isolation and host b
|
||||
- **Issues / source / docker-compose templates:** <{GITEA}>
|
||||
- **Agent-facing internals** (for future maintainers / coding agents working in the repo): <{GITEA}/src/branch/main/AGENTS.md>
|
||||
|
||||
## Sibling images
|
||||
|
||||
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — pi-only image built on top of this image's base layer. Smaller (~700 MB) and version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly. Use this if you want pi without opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
|
||||
|
||||
## License
|
||||
|
||||
MIT. See <{GITEA}/src/branch/main/LICENSE>.
|
||||
|
||||
+117
-10
@@ -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|with-pi|omos-with-pi]
|
||||
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos|with-pi|omos-with-pi|pi-only]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 all checks passed
|
||||
@@ -23,13 +23,26 @@ if [ "${2:-}" = "--variant" ]; then
|
||||
fi
|
||||
|
||||
if [ -z "$IMAGE" ]; then
|
||||
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi]" >&2
|
||||
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi|pi-only]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
pass() { echo " ✓ $1"; }
|
||||
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
||||
warn() { echo " ⚠ $1" >&2; }
|
||||
|
||||
# Registration assertions (fork/recall installed by the BASE image's
|
||||
# entrypoint-user.sh via `pi install /opt/<pkg>`) depend on the base, not the
|
||||
# variant layer built here. validate.yml builds variants FROM the published
|
||||
# base-latest, which can lag the entrypoint in the current commit (the base
|
||||
# only rebuilds on a release tag), so a stale base-latest would red the
|
||||
# push-to-main run with a false negative. These checks are therefore warn-only
|
||||
# by default; the release pipeline (docker-publish-split.yml) builds the base
|
||||
# fresh in the same run and sets STRICT_REGISTRATION=1 to enforce them hard.
|
||||
# The build-time /opt + node_modules checks below stay hard in every path —
|
||||
# those are produced by the variant layer and must always be correct.
|
||||
STRICT_REGISTRATION="${STRICT_REGISTRATION:-0}"
|
||||
|
||||
run() {
|
||||
# Run a command inside the image and capture its output.
|
||||
@@ -43,6 +56,25 @@ run() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Stricter version of `run` that also asserts an expected substring in
|
||||
# the command's stdout. Used to catch the "image bytes silently identical
|
||||
# to previous release" class of regression — Docker layer-cache hit on
|
||||
# a bare `npm install -g <pkg>` (or @latest) because the build-arg
|
||||
# string is identical across builds, even when 'latest' would have
|
||||
# resolved differently. Discovered in pi-devbox 2026-05-23 (every
|
||||
# release v0.74.0..v0.75.5 shipped the same image bytes); preventatively
|
||||
# applied here for PI_VERSION + OMOS_VERSION.
|
||||
run_expect() {
|
||||
local label="$1"; local cmd="$2"; local expect="$3"
|
||||
local out
|
||||
out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1) || true
|
||||
if echo "$out" | grep -Fq "$expect"; then
|
||||
pass "$label (got $expect)"
|
||||
else
|
||||
fail "$label — expected substring '$expect', got: $out"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Smoke test: $IMAGE (variant: $VARIANT) ==="
|
||||
echo
|
||||
echo "-- Resolved component versions --"
|
||||
@@ -68,6 +100,8 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
|
||||
printf " %-15s %s\n" "rg" "$(rg --version | head -1)"
|
||||
printf " %-15s %s\n" "gosu" "$(gosu --version)"
|
||||
printf " %-15s %s\n" "git-lfs" "$(git-lfs --version)"
|
||||
printf " %-15s %s\n" "git-crypt" "$(git-crypt --version 2>&1 | head -1)"
|
||||
printf " %-15s %s\n" "gitleaks" "$(gitleaks version 2>&1 | head -1)"
|
||||
printf " %-15s %s\n" "gitea-mcp" "$(gitea-mcp --version 2>&1 | head -1)"
|
||||
printf " %-15s %s\n" "aws" "$(aws --version 2>&1)"
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
@@ -103,11 +137,20 @@ run "fzf" "fzf --version"
|
||||
run "fd" "fd --version"
|
||||
run "rg" "rg --version | head -1"
|
||||
run "jq" "jq --version"
|
||||
run "git-crypt" "git-crypt --version | head -1"
|
||||
run "gitleaks" "gitleaks version"
|
||||
run "aws" "aws --version"
|
||||
run "gitea-mcp" "gitea-mcp --version"
|
||||
run "gosu" "gosu --version"
|
||||
run "tmux" "tmux -V"
|
||||
|
||||
# SSH ControlMaster baked defaults: the config file must exist (image-level)
|
||||
# and ssh -G must report ControlPath rooted at /tmp/sshcm/ for an arbitrary
|
||||
# host. Catches both regressions: someone removing the conf file, OR something
|
||||
# else later in the config chain shadowing the ControlPath setting.
|
||||
run "ssh-config-cm-file" "test -f /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf"
|
||||
run_expect "ssh-config-cm-path" "ssh -G example.invalid 2>/dev/null | grep -i ^controlpath" "/tmp/sshcm/"
|
||||
|
||||
echo
|
||||
echo "-- Optional / variant-gated --"
|
||||
# mempalace: present unless built with INSTALL_MEMPALACE=false
|
||||
@@ -134,9 +177,20 @@ fi
|
||||
# entrypoint-user.sh on first start, so we test by running the entry
|
||||
# point chain (not just `docker run --entrypoint=""`).
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&1; then
|
||||
run "pi" "pi --version"
|
||||
if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
|
||||
run_expect "pi version matches build-arg" "pi --version" "$EXPECTED_PI_VERSION"
|
||||
else
|
||||
run "pi" "pi --version"
|
||||
fi
|
||||
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
|
||||
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool): cloned to
|
||||
# /opt with node_modules baked at build time (a local-path `pi install` does
|
||||
# NOT npm-install, so deps MUST already be present for the extension to load).
|
||||
run "pi-fork clone + node_modules" \
|
||||
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules && echo ok"
|
||||
run "pi-observational-memory clone + node_modules" \
|
||||
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules && echo ok"
|
||||
|
||||
# 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
|
||||
@@ -165,6 +219,19 @@ if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&
|
||||
fi
|
||||
}
|
||||
|
||||
# Like exec_test but warn-only unless STRICT_REGISTRATION=1 (see note at top).
|
||||
exec_test_reg() {
|
||||
local label="$1"; shift
|
||||
local out
|
||||
if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then
|
||||
pass "$label ($(echo "$out" | head -1))"
|
||||
elif [ "$STRICT_REGISTRATION" = "1" ]; then
|
||||
fail "$label: $out"
|
||||
else
|
||||
warn "$label (warn-only — stale base-latest? set STRICT_REGISTRATION=1 to enforce): $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)" \
|
||||
@@ -174,6 +241,21 @@ if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&
|
||||
exec_test "~/.pi/agent/settings.json (template bootstrap)" \
|
||||
'test -f $HOME/.pi/agent/settings.json && echo ok'
|
||||
|
||||
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
|
||||
# `pi install /opt/<pkg>` (records a relative path into settings.json
|
||||
# packages). That runs slightly after the keybindings marker, so wait for it.
|
||||
for _ in $(seq 1 15); do
|
||||
if docker exec "$CID" grep -q pi-observational-memory \
|
||||
/home/developer/.pi/agent/settings.json 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
exec_test_reg "pi-fork registered in settings.json (fork tool)" \
|
||||
'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok'
|
||||
exec_test_reg "pi-observational-memory registered in settings.json (recall tool)" \
|
||||
'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok'
|
||||
|
||||
docker rm -f "$CID" >/dev/null 2>&1 || true
|
||||
trap - EXIT
|
||||
else
|
||||
@@ -185,8 +267,18 @@ 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"
|
||||
if [ -n "${EXPECTED_OMOS_VERSION:-}" ]; then
|
||||
run_expect "omos version matches build-arg" \
|
||||
"NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim" \
|
||||
"$EXPECTED_OMOS_VERSION"
|
||||
fi
|
||||
else
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v bun" >/dev/null 2>&1; then
|
||||
fail "bun should NOT be in base image but was found"
|
||||
@@ -284,14 +376,29 @@ SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE")
|
||||
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
|
||||
echo " Uncompressed size: ${SIZE_MB} MB"
|
||||
|
||||
# Thresholds (uncompressed): base 2500 MB, omos 3200 MB, with-pi adds ~150 MB.
|
||||
# Thresholds (uncompressed): base 2500 MB, omos 3300 MB, with-pi adds ~150 MB.
|
||||
# omos bumped 3000→3200 on v1.14.31c — mempalace-toolkit bake-in pushed the
|
||||
# baseline; bumped 3200→3300 on v1.15.0 — opencode 1.15.0 came in at
|
||||
# 3206 MB, leaving zero headroom for routine apt-get upgrade drift.
|
||||
# omos-with-pi bumped 3400→3500 on v1.15.0 alongside the omos bump.
|
||||
# omos-with-pi bumped 3500→3700 on v1.15.4b — omos+pi compounded as both
|
||||
# upstream packages grew (opencode 1.15.0→1.15.4, pi 0.74.0→0.75.3) and
|
||||
# the variant landed just over 3500 in v1.15.4's smoke.
|
||||
# with-pi 2700→2900 and omos-with-pi 3700→3900: baking pi-fork +
|
||||
# pi-observational-memory node_modules into /opt (fork pulls its
|
||||
# @earendil-works peer deps, ~150 MB) adds to both pi-bearing variants.
|
||||
# base 2500→2600 on v1.15.13c — base crept to 2506 MB (LAN-access script +
|
||||
# updated entrypoint + routine apt-get upgrade drift), tripping the
|
||||
# deliberately zero-headroom 2500 ceiling and skipping promote-base-latest.
|
||||
# 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
|
||||
THRESHOLD=2600
|
||||
[ "$VARIANT" = "omos" ] && THRESHOLD=3300
|
||||
[ "$VARIANT" = "with-pi" ] && THRESHOLD=2900
|
||||
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3900
|
||||
# pi-only = with-pi minus opencode (its platform binary is ~145 MB), so it
|
||||
# lands a bit under base. Threshold 2750 leaves the same headroom pattern.
|
||||
[ "$VARIANT" = "pi-only" ] && THRESHOLD=2750
|
||||
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
|
||||
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# ssh-lan.conf.example — host-owned LAN-peer jump overrides for opencode-devbox
|
||||
# ============================================================================
|
||||
# WHAT THIS IS
|
||||
# On a VM-backed host (macOS OrbStack / Docker Desktop) the container can't
|
||||
# reach the host's LAN directly; it tunnels through the host via the `host`
|
||||
# SSH jump that the entrypoint sets up (see the README "Reaching your LAN"
|
||||
# section). To reach your LAN peers *by name*, they need `ProxyJump host`.
|
||||
#
|
||||
# WHY NOT JUST EDIT ~/.ssh/config?
|
||||
# The host itself reaches those peers DIRECTLY — adding `ProxyJump host`
|
||||
# there would break the host's own access (and ~/.ssh is mounted read-only
|
||||
# into the container anyway). So container-only jump overrides live HERE.
|
||||
#
|
||||
# HOW IT'S WIRED
|
||||
# If this file exists at ~/.config/devbox-shell/ssh-lan.conf on the host
|
||||
# (the same bind-mounted devbox-shell bridge dir used for shared aliases),
|
||||
# the generated ~/.ssh-local/config Includes it BEFORE your ~/.ssh/config.
|
||||
# SSH's first-value-wins rule means ProxyJump is taken from here, while
|
||||
# HostName / User / IdentityFile are inherited from the matching block in
|
||||
# your ~/.ssh/config. So you only list the names + the jump — nothing else.
|
||||
#
|
||||
# SETUP
|
||||
# 1. Copy to your host: cp ssh-lan.conf.example ~/.config/devbox-shell/ssh-lan.conf
|
||||
# 2. Bind-mount ~/.config/devbox-shell into the container (most setups
|
||||
# already do this for shared shell aliases).
|
||||
# 3. List the host aliases (as named in your ~/.ssh/config) that should be
|
||||
# reached through the host jump.
|
||||
# 4. Restart the container, then: dssh <name>
|
||||
#
|
||||
# NOTE: these are facts about ONE host's LAN. A roaming laptop sees different
|
||||
# networks — keep this per-host, never in the image. For ad-hoc private IPs on
|
||||
# whatever LAN you're currently on, prefer DEVBOX_LAN_AUTOJUMP_PRIVATE=1
|
||||
# instead of naming every peer.
|
||||
|
||||
# Example — names must match Host blocks already defined in your ~/.ssh/config:
|
||||
Host pve pve-2 pbs-vm my-nas
|
||||
ProxyJump host
|
||||
|
||||
# You can also give a peer its own settings here if it isn't in ~/.ssh/config
|
||||
# at all (then specify everything, not just ProxyJump):
|
||||
# Host lab-box
|
||||
# HostName 192.168.1.77
|
||||
# User admin
|
||||
# IdentityFile ~/.ssh/id_ed25519
|
||||
# ProxyJump host
|
||||
Reference in New Issue
Block a user