From 07e07ec6117f9f45f0ab3bfa02076b528b947c2a Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Thu, 14 May 2026 19:39:45 +0200 Subject: [PATCH] Bump opencode 1.14.44 -> 1.14.50; cut over to split-base pipeline - Bump OPENCODE_VERSION 1.14.44 -> 1.14.50 in Dockerfile.variant - Cut over: docker-publish-split.yml now triggers on push: tags: v* (was workflow_dispatch only). RELEASE_TAG and PROMOTE_LATEST derived from github.ref_type/ref_name for tag-push; inputs still available for manual workflow_dispatch runs. - Delete docker-publish.yml (retired, replaced by split-base pipeline) - Delete Dockerfile (retired, replaced by Dockerfile.base + Dockerfile.variant) - Update CHANGELOG: promote Unreleased -> v1.14.50 - Update AGENTS.md, .gitea/README.md, validate.yml: remove all references to the old single-Dockerfile pipeline and WIP migration plan --- .gitea/README.md | 27 +- .gitea/workflows/docker-publish-split.yml | 45 +- .gitea/workflows/docker-publish.yml | 581 ---------------------- .gitea/workflows/validate.yml | 4 +- AGENTS.md | 15 +- CHANGELOG.md | 11 +- Dockerfile | 475 ------------------ Dockerfile.variant | 2 +- 8 files changed, 47 insertions(+), 1113 deletions(-) delete mode 100644 .gitea/workflows/docker-publish.yml delete mode 100644 Dockerfile diff --git a/.gitea/README.md b/.gitea/README.md index 8838ab7..72c3554 100644 --- a/.gitea/README.md +++ b/.gitea/README.md @@ -8,11 +8,10 @@ the build pipeline is shaped the way it is, you're in the right place. | File | Trigger | Role | |---|---|---| -| [`workflows/docker-publish.yml`](workflows/docker-publish.yml) | `push: tags: v*` | **Production release pipeline.** Multi-arch build of all four variants (`base`, `omos`, `with-pi`, `omos-with-pi`), publish to Docker Hub, update Hub description. ~165–180 min wall clock. | -| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `workflow_dispatch` (manual) | **Experimental split-base pipeline.** Two-phase build: shared `base-` published once, then four thin variant deltas. Estimated ~30–40 min on cache hit, ~70–90 min when base needs rebuilding. Not yet validated end-to-end; once 1–2 dispatch test runs prove it, this will take over `on: push: tags: v*` and `docker-publish.yml` will be retired. | +| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `push: tags: v*` | **Production release pipeline.** Two-phase split-base build: shared `base-` published once (skipped on cache hit), then four parallel variant deltas. ~40–80 min wall clock depending on runner count and whether base needs rebuilding. | | [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of all four variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. | -## Why two release pipelines exist +## Why the split-base pipeline exists opencode-devbox publishes **four image variants** (`base`, `omos`, `with-pi`, `omos-with-pi`) × **two architectures** (amd64, arm64) = **eight image tags per release**. Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~3–5x slower than native). @@ -263,26 +262,18 @@ This catches regressions before they reach a tag push. Wall clock ~30 min. 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.** In a single commit: - - Edit `docker-publish-split.yml`: change `on: workflow_dispatch:` to - `on: push: tags: v*` and wire `$GITHUB_REF` into the `release_tag` - input, set `promote_latest=true` for production runs. - - Delete `docker-publish.yml`. - - Delete the original `Dockerfile` (keep `Dockerfile.base` + - `Dockerfile.variant`). - - Update `CHANGELOG.md`: promote the "Build pipeline" Unreleased entry. -4. **Tag a release.** First production release on the new pipeline. Watch - it like a hawk for the first run. +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) — the build pipeline rewrite is - recorded under `Unreleased` until the cutover lands. -- `Dockerfile`, `Dockerfile.base`, `Dockerfile.variant` — production - single-Dockerfile build and the split-base counterparts. Comments at - the top of each explain its role. +- [`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". diff --git a/.gitea/workflows/docker-publish-split.yml b/.gitea/workflows/docker-publish-split.yml index 3614176..af18090 100644 --- a/.gitea/workflows/docker-publish-split.yml +++ b/.gitea/workflows/docker-publish-split.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,8 @@ concurrency: env: BUILDKIT_PROGRESS: plain IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox + RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }} + PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }} # ─────────────────────────────────────────────────────────────────── # Reusable disk-reclaim snippet — strips catthehacker toolchains and @@ -354,10 +353,10 @@ jobs: - name: Compute version-specific tags id: tags run: | - VERSION="${{ inputs.release_tag }}" + VERSION="${{ env.RELEASE_TAG }}" { echo "tags<> /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 diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index 2be1d5c..ad2040a 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -2,8 +2,8 @@ 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. on: push: diff --git a/AGENTS.md b/AGENTS.md index 587783a..e9e3cef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,12 @@ ## Project overview -Docker image packaging [opencode](https://opencode.ai) into a production-ready dev container. Two image variants (base and omos) are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfile, entrypoint scripts, docker-compose, documentation). +Docker image packaging [opencode](https://opencode.ai) into a production-ready dev container. Image variants are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfiles, entrypoint scripts, docker-compose, documentation). ## File roles -- `Dockerfile` — production single-Dockerfile build. Variants are gated by build args: `INSTALL_OMOS` (Bun + multi-agent layer), `INSTALL_OPENCODE` (default true), `INSTALL_PI` (default false), `INSTALL_MEMPALACE` (default true). All GitHub-sourced binaries are pinned with version ARGs. -- `Dockerfile.base` and `Dockerfile.variant` — **WIP, branch `feat/split-build` only.** Two-Dockerfile split-base build: base contains all variant-independent layers; variant `FROM`s the base and adds only opencode/omos/pi installs. Used by `docker-publish-split.yml` (workflow_dispatch only) for parallel testing alongside the production pipeline. See CHANGELOG `Unreleased` for the migration plan and trade-offs. +- `Dockerfile.base` — variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published as `joakimp/opencode-devbox:base-`. Rebuilt only when its content hash changes. +- `Dockerfile.variant` — `FROM`s the base and adds only opencode/omos/pi installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, `INSTALL_PI`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. - `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. Volume ownership loop covers `~/.pi/` when `INSTALL_PI=true`. - `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, skillset auto-deploy from mounted skillset repo, OMOS setup. - `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.jsonc` from env vars. Never overwrites an existing config (checks both `.json` and `.jsonc`). Auto-registers MCP servers for detected tools (mempalace via `mempalace-mcp`, gitea-mcp, context7 remote endpoint). @@ -17,21 +17,20 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d - `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth. - `.gitea/README.md` — **read this first** if you're touching CI. Architectural overview of the build pipeline (production vs split-base), wall-clock estimates, NPM_CONFIG_PREFIX gotcha, runner expectations, migration plan. - `.gitea/workflows/validate.yml` — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check. -- `.gitea/workflows/docker-publish.yml` — production CI pipeline on tag push: smoke-test each variant on amd64, then full multi-arch (amd64 + arm64) build-and-push, then update Docker Hub description. -- `.gitea/workflows/docker-publish-split.yml` — **WIP, branch `feat/split-build` only.** Two-phase split-base pipeline. Triggers on `workflow_dispatch` only so it runs alongside the production pipeline without conflict. Pushes to user-supplied `release_tag` input (e.g. `v0.0.0-split-test`); `latest*` aliases only updated when `promote_latest: true`. Compute base hash, conditionally build base, then 4 variant deltas in parallel. +- `.gitea/workflows/docker-publish-split.yml` — production CI pipeline on tag push (`v*`). Two-phase split-base: computes base hash, conditionally builds base, runs 4 parallel smoke tests, then 4 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description. ## Versioning scheme Tags follow `v{opencode_version}[letter]` — e.g. `v1.14.20` for the first build on a new opencode release, and `v1.14.20b`, `v1.14.20c`, … for subsequent rebuilds on the same opencode version. -- The number tracks the opencode npm version (see `OPENCODE_VERSION` ARG in `Dockerfile`). +- The number tracks the opencode npm version (see `OPENCODE_VERSION` ARG in `Dockerfile.variant`). - **No letter suffix** on the first build of a new opencode version — the bare `v{opencode_version}` tag is the canonical release. - **Letter suffix is the build ordinal**, starting at `b` for the second build. The letter `a` is **never used** — think of the suffix as counting rebuilds: `b = 2nd, c = 3rd, d = 4th, …`. For opencode version `1.14.20`: first build `v1.14.20`, second `v1.14.20b`, third `v1.14.20c`, and so on. - A letter suffix is only used for container-level rebuilds — tooling changes, CVE fixes, doc-driven rebuilds, entrypoint bugfixes — that don't change the underlying opencode version. CI produces eight Docker Hub tags per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`, `vX.Y.Z[n]-with-pi`, `latest-with-pi`, `vX.Y.Z[n]-omos-with-pi`, `latest-omos-with-pi` — one tag pair (versioned + floating alias) per build variant. -When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile` and update the comment in `.env.example` if it names a specific model/version for context. +When bumping the opencode version, bump `OPENCODE_VERSION` in `Dockerfile.variant` and update the comment in `.env.example` if it names a specific model/version for context. ## Critical conventions @@ -66,7 +65,7 @@ When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile` - **`docker/build-push-action@v7` with `platforms: linux/amd64,linux/arm64` handles multi-arch push natively in a single job** — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires `actions/{upload,download}-artifact@v4+` which Gitea Actions doesn't support (see below). - **`actions/upload-artifact` and `actions/download-artifact` must stay at @v3 on Gitea.** v4+ uses a GitHub-Enterprise-specific Artifact API; runs fail with `GHESNotSupportedError`. If you need artifacts for a new reason (build logs, SBOMs, etc.), pin @v3 explicitly. - **Step scripts run under `/bin/sh` (dash), not bash.** Avoid bash-isms like `${VAR//a/b}` parameter-pattern substitution; use POSIX alternatives (`tr`, `sed`) or declare `shell: bash` on the step. -- **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish.yml` so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive. +- **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish-split.yml` so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive. ## Testing changes diff --git a/CHANGELOG.md b/CHANGELOG.md index 4957206..a911ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,14 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a ## Unreleased -Docs: +## v1.14.50 — 2026-05-14 -- **New: `.gitea/README.md`** — architectural overview of the build pipeline. Documents the production single-Dockerfile path vs the merged-but-unvalidated split-base path, hash-driven base cache reuse, wall-clock estimates, the `NPM_CONFIG_PREFIX` variant-override pattern, runner expectations (catthehacker container, disk reclaim, concurrency, gitea-actions @v4 artifact gotcha), and the cutover plan. Auto-renders when navigating to `.gitea/` in the gitea web UI. Linked from `AGENTS.md` as the first thing to read when touching CI. +opencode 1.14.44 → 1.14.50 bump. First release on the split-base build pipeline. -Build pipeline (merged to main as `Dockerfile.base` + `Dockerfile.variant` + `.gitea/workflows/docker-publish-split.yml`, NOT yet validated end-to-end — the `workflow_dispatch` test against `:base-` + `:v0.0.0-split-test*` aliases is still the gating step before this can take over `on: push: tags: v*`): - -- **New: split-base build pipeline.** `Dockerfile.base` (variant-independent layers — apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints) builds once and is published as `joakimp/opencode-devbox:base-`. `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. +- **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 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9a9558e..0000000 --- a/Dockerfile +++ /dev/null @@ -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.44 - -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 '/.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:` and `npm install -g` at -# runtime — it does NOT shadow the baked pi unless the user does -# `npm install -g @earendil-works/pi-coding-agent` themselves, in which -# case the user-installed copy on the volume wins via PATH order. Same -# contract as OPENCODE_VERSION otherwise: rebuild the image to upgrade -# the baked pi. -ARG INSTALL_PI=false -ARG PI_VERSION=latest -ARG PI_TOOLKIT_REF=main -ARG PI_EXTENSIONS_REF=main -RUN if [ "${INSTALL_PI}" = "true" ]; then \ - if [ "${PI_VERSION}" = "latest" ]; then \ - npm install -g @earendil-works/pi-coding-agent ; \ - else \ - npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \ - fi && \ - pi --version && \ - git clone --depth 1 --branch "${PI_TOOLKIT_REF}" \ - https://gitea.jordbo.se/joakimp/pi-toolkit.git /opt/pi-toolkit && \ - git clone --depth 1 --branch "${PI_EXTENSIONS_REF}" \ - https://gitea.jordbo.se/joakimp/pi-extensions.git /opt/pi-extensions && \ - echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \ - echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \ - fi - -# ── AWS CLI v2 (for SSO/Bedrock authentication) ───────────────────── -RUN ARCH=$(case "${TARGETARCH}" in \ - amd64) echo "x86_64" ;; \ - arm64) echo "aarch64" ;; \ - *) echo "x86_64" ;; \ - esac) && \ - curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \ - unzip -q /tmp/awscli.zip -d /tmp && \ - /tmp/aws/install && \ - rm -rf /tmp/aws /tmp/awscli.zip && \ - aws --version - -# ── Optional: Go ───────────────────────────────────────────────────── -# Latest stable Go is resolved from https://go.dev/dl/?mode=json when -# GO_VERSION=latest (default). Pass an explicit version like "1.26.2" -# to pin. -ARG INSTALL_GO=false -ARG GO_VERSION=latest -RUN if [ "${INSTALL_GO}" = "true" ]; then \ - GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \ - V="${GO_VERSION}" && \ - if [ "$V" = "latest" ]; then \ - V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \ - awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \ - fi && \ - [ -n "$V" ] && \ - echo "Installing Go ${V}" && \ - curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \ - ln -s /usr/local/go/bin/go /usr/local/bin/go && \ - ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \ - fi - -# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ──────── -# Installs Bun runtime and the oh-my-opencode-slim npm package. -# Runtime activation is controlled by ENABLE_OMOS env var in entrypoint. -# Uses the baseline Bun build (SSE4.2 only) for compatibility with older -# CPUs that lack AVX2 (e.g. Sandy Bridge on OpenStack). -ARG INSTALL_OMOS=false -ARG OMOS_VERSION=latest -RUN if [ "${INSTALL_OMOS}" = "true" ]; then \ - ARCH=$(uname -m) && \ - if [ "$ARCH" = "x86_64" ]; then \ - BUN_ARCH="x64-baseline"; \ - elif [ "$ARCH" = "aarch64" ]; then \ - BUN_ARCH="aarch64"; \ - fi && \ - curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-${BUN_ARCH}.zip" -o /tmp/bun.zip && \ - unzip -o /tmp/bun.zip -d /tmp/bun && \ - mv /tmp/bun/bun-linux-${BUN_ARCH}/bun /usr/local/bin/bun && \ - chmod +x /usr/local/bin/bun && \ - ln -sf bun /usr/local/bin/bunx && \ - rm -rf /tmp/bun /tmp/bun.zip && \ - bun --version && \ - test -L /usr/local/bin/bunx && \ - npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \ - fi - -# ── Non-root user ──────────────────────────────────────────────────── -ARG USER_NAME=developer -ARG USER_UID=1000 -ARG USER_GID=1000 - -RUN groupadd --gid ${USER_GID} ${USER_NAME} && \ - useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \ - echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME} - -# Create standard directories -# -# ~/.pi/agent/extensions/ is created proactively so the named volume -# mount has a real owner from the first start. The directory is also -# what mempalace-toolkit's install_pi_extension probes to decide -# whether to deploy the pi↔mempalace bridge — must exist before that -# step runs in entrypoint-user.sh. -RUN mkdir -p /workspace \ - /home/${USER_NAME}/.config/opencode/skills \ - /home/${USER_NAME}/.pi/agent/extensions \ - /home/${USER_NAME}/.agents/skills \ - /home/${USER_NAME}/.local/share/opencode \ - /home/${USER_NAME}/.cache/bash \ - /home/${USER_NAME}/.ssh && \ - chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME} - -# ── Pre-warm chromadb embedding model ────────────────────────────── -# Mempalace uses chromadb's ONNXMiniLM_L6_V2 embedding function, which -# downloads ~80 MB of all-MiniLM-L6-v2 ONNX weights from chromadb's CDN -# on first use. Without pre-warming this happens silently (output is -# suppressed by the entrypoint init step) and stalls first container -# start by minutes on a slow network. We bake the cache at build time -# under the developer user's home so the runtime first-start is fast. -# -# Cache path comes from chromadb's hardcoded `Path.home() / .cache / -# chroma / onnx_models / all-MiniLM-L6-v2`. Run as gosu developer so -# Path.home() resolves correctly and ownership is right from the start. -RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \ - gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\ -from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \ -ef = ONNXMiniLM_L6_V2(); \ -_ = ef(['warmup']); \ -print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \ - ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \ - fi - -# ── User-writable npm global prefix on the devbox-pi-config volume ── -# By default npm's global prefix is /usr (writable only by root) so any -# `pi install npm:` or `npm install -g ` 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"] diff --git a/Dockerfile.variant b/Dockerfile.variant index dbceaaa..7b8b099 100644 --- a/Dockerfile.variant +++ b/Dockerfile.variant @@ -32,7 +32,7 @@ ARG USER_NAME=developer # ── Install opencode via npm ───────────────────────────────────────── ARG INSTALL_OPENCODE=true -ARG OPENCODE_VERSION=1.14.44 +ARG OPENCODE_VERSION=1.14.50 RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \ NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \ opencode --version ; \