diff --git a/.gitea/workflows/docker-publish-split.yml b/.gitea/workflows/docker-publish-split.yml new file mode 100644 index 0000000..f2a95dd --- /dev/null +++ b/.gitea/workflows/docker-publish-split.yml @@ -0,0 +1,576 @@ +name: Publish Docker Image (split-base) + +# Two-phase split-base build pipeline. Lives ALONGSIDE the original +# docker-publish.yml during the migration window. Triggers only on +# workflow_dispatch (manual) so it doesn't conflict with the production +# tag-trigger pipeline. +# +# Once we've validated 1-2 successful runs and verified output +# byte-for-byte against the original, this workflow takes over `on: +# push: tags: v*` and the original is retired. +# +# Pipeline shape: +# 1. base-decide compute base hash from Dockerfile.base + rootfs/ +# + entrypoints; probe Docker Hub for existing tag. +# 2. build-base only if probe missed; multi-arch push of base-. +# 3. smoke-* (×4) amd64-only build of each variant FROMing the base +# tag; runs scripts/smoke-test.sh. +# 4. build-variant-* multi-arch push of each variant tag (the user- +# (×4) facing release tags, unchanged in shape). +# 5. promote-base-latest re-tag base- → base-latest with `crane copy` +# (manifest copy, no rebuild). +# 6. update-description patch Docker Hub description (unchanged). + +on: + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag to publish (e.g. v1.14.42-split). Pushed to Docker Hub as both the literal tag and `latest*`-aliases.' + required: true + default: 'v0.0.0-split-test' + promote_latest: + description: 'Update latest/latest-omos/latest-with-pi/latest-omos-with-pi aliases (set false for test runs)' + required: false + default: 'false' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + BUILDKIT_PROGRESS: plain + IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox + +# ─────────────────────────────────────────────────────────────────── +# Reusable disk-reclaim snippet — strips catthehacker toolchains and +# stale docker state. Identical to the production workflow's pattern. +# ─────────────────────────────────────────────────────────────────── + +jobs: + # ── Phase 1: decide whether base needs rebuilding ────────────────── + base-decide: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + outputs: + base_tag: ${{ steps.compute.outputs.base_tag }} + need_build: ${{ steps.probe.outputs.need_build }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compute base tag from Dockerfile.base + dependencies + id: compute + run: | + # Hash inputs that determine the base image's contents. + # Order is fixed via `find -print0 | sort -z` for reproducibility. + HASH=$( + { + cat Dockerfile.base + find rootfs -type f -print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null + cat entrypoint.sh entrypoint-user.sh + } | sha256sum | cut -c1-12 + ) + BASE_TAG="base-${HASH}" + echo "base_tag=${BASE_TAG}" >> "$GITHUB_OUTPUT" + echo "Computed base tag: ${BASE_TAG}" + + - name: Force IPv4 for Docker Hub + run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + + - name: Probe Docker Hub for existing base tag + id: probe + run: | + set +e + docker manifest inspect "${IMAGE}:${{ steps.compute.outputs.base_tag }}" \ + > /dev/null 2>&1 + PROBE_RC=$? + set -e + if [ "${PROBE_RC}" = "0" ]; then + echo "need_build=false" >> "$GITHUB_OUTPUT" + echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} exists — skipping rebuild." + else + echo "need_build=true" >> "$GITHUB_OUTPUT" + echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build." + fi + + # ── Phase 2: build & push base (multi-arch), only when needed ────── + build-base: + needs: [base-decide] + if: needs.base-decide.outputs.need_build == 'true' + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Force IPv4 for Docker Hub + run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + + - name: Reclaim runner disk + run: | + set -x + df -h / || true + rm -rf \ + /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ + /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ + /usr/local/lib/android /usr/local/share/powershell \ + /usr/local/share/chromium /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + apt-get clean || true + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true + docker system prune -af --volumes || true + docker builder prune -af || true + df -h / || true + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + driver-opts: network=host + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push base (multi-arch) + uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.base + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + # Registry cache for faster repeat base rebuilds (e.g. Node bump). + cache-from: type=registry,ref=${{ env.IMAGE }}:base-buildcache + cache-to: type=registry,ref=${{ env.IMAGE }}:base-buildcache,mode=max + + # ── Phase 3: amd64 smoke per variant (gates the multi-arch publish) ─ + # Each smoke job builds amd64-only against the base tag and runs + # scripts/smoke-test.sh. base-decide.outputs.base_tag is always set; + # build-base may have been skipped (cache hit) but the tag exists either way. + + smoke-base: + needs: [base-decide, build-base] + if: | + always() && + needs.base-decide.result == 'success' && + (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - name: Force IPv4 for Docker Hub + run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + - name: Reclaim runner disk + run: | + rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ + /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ + /usr/local/lib/android /usr/local/share/powershell \ + /usr/local/share/chromium /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + docker system prune -af --volumes || true + docker builder prune -af || true + - uses: docker/setup-buildx-action@v4 + with: {driver-opts: network=host} + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build amd64 variant for smoke + uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.variant + platforms: linux/amd64 + push: false + load: true + tags: opencode-devbox:smoke-base + build-args: | + BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + INSTALL_OPENCODE=true + INSTALL_OMOS=false + INSTALL_PI=false + - name: Smoke test (amd64) + run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base + + smoke-omos: + needs: [base-decide, build-base] + if: | + always() && + needs.base-decide.result == 'success' && + (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + - run: | + rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ + /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ + /usr/local/lib/android /usr/local/share/powershell \ + /usr/local/share/chromium /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + docker system prune -af --volumes || true + docker builder prune -af || true + - uses: docker/setup-buildx-action@v4 + with: {driver-opts: network=host} + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.variant + platforms: linux/amd64 + push: false + load: true + tags: opencode-devbox:smoke-omos + build-args: | + BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + INSTALL_OPENCODE=true + INSTALL_OMOS=true + INSTALL_PI=false + - run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos + + smoke-with-pi: + needs: [base-decide, build-base] + if: | + always() && + needs.base-decide.result == 'success' && + (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + - run: | + rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ + /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ + /usr/local/lib/android /usr/local/share/powershell \ + /usr/local/share/chromium /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + docker system prune -af --volumes || true + docker builder prune -af || true + - uses: docker/setup-buildx-action@v4 + with: {driver-opts: network=host} + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.variant + platforms: linux/amd64 + push: false + load: true + tags: opencode-devbox:smoke-with-pi + build-args: | + BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + INSTALL_OPENCODE=true + INSTALL_OMOS=false + INSTALL_PI=true + - run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi + + smoke-omos-with-pi: + needs: [base-decide, build-base] + if: | + always() && + needs.base-decide.result == 'success' && + (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + - run: | + rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ + /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ + /usr/local/lib/android /usr/local/share/powershell \ + /usr/local/share/chromium /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + docker system prune -af --volumes || true + docker builder prune -af || true + - uses: docker/setup-buildx-action@v4 + with: {driver-opts: network=host} + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.variant + platforms: linux/amd64 + push: false + load: true + tags: opencode-devbox:smoke-omos-with-pi + build-args: | + BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + INSTALL_OPENCODE=true + INSTALL_OMOS=true + INSTALL_PI=true + - run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi + + # ── Phase 4: multi-arch publish per variant ──────────────────────── + + build-variant-base: + needs: [base-decide, smoke-base] + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + - run: | + rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ + /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ + /usr/local/lib/android /usr/local/share/powershell \ + /usr/local/share/chromium /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + docker system prune -af --volumes || true + docker builder prune -af || true + - uses: docker/setup-qemu-action@v3 + with: {platforms: arm64} + - uses: docker/setup-buildx-action@v4 + with: {driver-opts: network=host} + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Compute version-specific tags + id: tags + run: | + VERSION="${{ inputs.release_tag }}" + TAGS="${IMAGE}:${VERSION}" + if [ "${{ inputs.promote_latest }}" = "true" ]; then + TAGS="${TAGS}\n${IMAGE}:latest" + fi + echo -e "tags<> "$GITHUB_OUTPUT" + - uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.variant + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + INSTALL_OPENCODE=true + INSTALL_OMOS=false + INSTALL_PI=false + tags: ${{ steps.tags.outputs.tags }} + + build-variant-omos: + needs: [base-decide, smoke-omos] + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + - run: | + rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ + /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ + /usr/local/lib/android /usr/local/share/powershell \ + /usr/local/share/chromium /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + docker system prune -af --volumes || true + docker builder prune -af || true + - uses: docker/setup-qemu-action@v3 + with: {platforms: arm64} + - uses: docker/setup-buildx-action@v4 + with: {driver-opts: network=host} + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Compute version-specific tags + id: tags + run: | + VERSION="${{ inputs.release_tag }}" + TAGS="${IMAGE}:${VERSION}-omos" + if [ "${{ inputs.promote_latest }}" = "true" ]; then + TAGS="${TAGS}\n${IMAGE}:latest-omos" + fi + echo -e "tags<> "$GITHUB_OUTPUT" + - uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.variant + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + INSTALL_OPENCODE=true + INSTALL_OMOS=true + INSTALL_PI=false + tags: ${{ steps.tags.outputs.tags }} + + build-variant-with-pi: + needs: [base-decide, smoke-with-pi] + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + - run: | + rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ + /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ + /usr/local/lib/android /usr/local/share/powershell \ + /usr/local/share/chromium /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + docker system prune -af --volumes || true + docker builder prune -af || true + - uses: docker/setup-qemu-action@v3 + with: {platforms: arm64} + - uses: docker/setup-buildx-action@v4 + with: {driver-opts: network=host} + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Compute version-specific tags + id: tags + run: | + VERSION="${{ inputs.release_tag }}" + TAGS="${IMAGE}:${VERSION}-with-pi" + if [ "${{ inputs.promote_latest }}" = "true" ]; then + TAGS="${TAGS}\n${IMAGE}:latest-with-pi" + fi + echo -e "tags<> "$GITHUB_OUTPUT" + - uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.variant + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + INSTALL_OPENCODE=true + INSTALL_OMOS=false + INSTALL_PI=true + tags: ${{ steps.tags.outputs.tags }} + + build-variant-omos-with-pi: + needs: [base-decide, smoke-omos-with-pi] + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf + - run: | + rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ + /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ + /usr/local/lib/android /usr/local/share/powershell \ + /usr/local/share/chromium /usr/local/share/boost \ + /usr/lib/jvm 2>/dev/null || true + docker system prune -af --volumes || true + docker builder prune -af || true + - uses: docker/setup-qemu-action@v3 + with: {platforms: arm64} + - uses: docker/setup-buildx-action@v4 + with: {driver-opts: network=host} + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Compute version-specific tags + id: tags + run: | + VERSION="${{ inputs.release_tag }}" + TAGS="${IMAGE}:${VERSION}-omos-with-pi" + if [ "${{ inputs.promote_latest }}" = "true" ]; then + TAGS="${TAGS}\n${IMAGE}:latest-omos-with-pi" + fi + echo -e "tags<> "$GITHUB_OUTPUT" + - uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile.variant + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + INSTALL_OPENCODE=true + INSTALL_OMOS=true + INSTALL_PI=true + tags: ${{ steps.tags.outputs.tags }} + + # ── Phase 5: promote base- → base-latest (manifest copy only) ─ + promote-base-latest: + needs: + - base-decide + - build-variant-base + - build-variant-omos + - build-variant-with-pi + - build-variant-omos-with-pi + if: inputs.promote_latest == 'true' + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: imjasonh/setup-crane@v0.4 + - name: Login (crane) + run: | + crane auth login docker.io \ + -u ${{ vars.DOCKERHUB_USERNAME }} \ + -p "${{ secrets.DOCKERHUB_TOKEN }}" + - name: Re-tag base- as base-latest + run: | + crane copy \ + ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \ + ${{ env.IMAGE }}:base-latest + + # ── Phase 6: update Hub description (only on real release runs) ──── + update-description: + needs: + - build-variant-base + - build-variant-omos + - build-variant-with-pi + - build-variant-omos-with-pi + if: inputs.promote_latest == 'true' + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - name: Update Docker Hub description + run: | + TOKEN=$(curl -s -X POST https://hub.docker.com/v2/auth/token \ + -H "Content-Type: application/json" \ + -d '{"identifier":"${{ vars.DOCKERHUB_USERNAME }}","secret":"${{ secrets.DOCKERHUB_TOKEN }}"}' \ + | jq -r .access_token) + if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then + echo "::error::Failed to authenticate with Docker Hub API" + exit 1 + fi + HTTP_CODE=$(jq -n \ + --rawfile full DOCKER_HUB.md \ + --arg short "Portable AI dev environment for opencode. Debian-based with git, Node.js, AWS CLI, and SSH support." \ + '{"full_description": $full, "description": $short}' | \ + curl -s -o /tmp/hub-response.txt -w "%{http_code}" -X PATCH \ + "https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox/" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d @-) + if [ "$HTTP_CODE" != "200" ]; then + echo "Response body:" + cat /tmp/hub-response.txt + echo "::error::Docker Hub description update failed with HTTP $HTTP_CODE" + exit 1 + fi diff --git a/AGENTS.md b/AGENTS.md index f8db447..8cd39a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,8 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d ## File roles -- `Dockerfile` — single multi-stage 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` — production single-Dockerfile build. Variants are gated by build args: `INSTALL_OMOS` (Bun + multi-agent layer), `INSTALL_OPENCODE` (default true), `INSTALL_PI` (default false), `INSTALL_MEMPALACE` (default true). All GitHub-sourced binaries are pinned with version ARGs. +- `Dockerfile.base` and `Dockerfile.variant` — **WIP, branch `feat/split-build` only.** Two-Dockerfile split-base build: base contains all variant-independent layers; variant `FROM`s the base and adds only opencode/omos/pi installs. Used by `docker-publish-split.yml` (workflow_dispatch only) for parallel testing alongside the production pipeline. See CHANGELOG `Unreleased` for the migration plan and trade-offs. - `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. Volume ownership loop covers `~/.pi/` when `INSTALL_PI=true`. - `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, skillset auto-deploy from mounted skillset repo, OMOS setup. - `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.jsonc` from env vars. Never overwrites an existing config (checks both `.json` and `.jsonc`). Auto-registers MCP servers for detected tools (mempalace via `mempalace-mcp`, gitea-mcp, context7 remote endpoint). @@ -15,7 +16,8 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d - `DOCKER_HUB.md` — **auto-generated** from `HUB_TEMPLATE` in `scripts/generate-dockerhub-md.py`. Do not edit directly. Pushed to Docker Hub description via CI API call. Must stay under 25 kB. Short description field must be ≤100 bytes. - `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth. - `.gitea/workflows/validate.yml` — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check. -- `.gitea/workflows/docker-publish.yml` — CI pipeline on tag push: smoke-test each variant on amd64, then full multi-arch (amd64 + arm64) build-and-push, then update Docker Hub description. +- `.gitea/workflows/docker-publish.yml` — production CI pipeline on tag push: smoke-test each variant on amd64, then full multi-arch (amd64 + arm64) build-and-push, then update Docker Hub description. +- `.gitea/workflows/docker-publish-split.yml` — **WIP, branch `feat/split-build` only.** Two-phase split-base pipeline. Triggers on `workflow_dispatch` only so it runs alongside the production pipeline without conflict. Pushes to user-supplied `release_tag` input (e.g. `v0.0.0-split-test`); `latest*` aliases only updated when `promote_latest: true`. Compute base hash, conditionally build base, then 4 variant deltas in parallel. ## Versioning scheme diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ea9f9..9aabbbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ Image changes (will ship in the next tagged release): - **Fix:** `pi install npm:` (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. +Build pipeline (branch `feat/split-build`, not yet merged): + +- **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. + 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. diff --git a/Dockerfile.base b/Dockerfile.base new file mode 100644 index 0000000..65583ce --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,330 @@ +# opencode-devbox — base image (variant-independent layers) +# +# This Dockerfile produces an image tagged base-, used as the parent +# for all four published variants (base, omos, with-pi, omos-with-pi). +# It contains everything that does not depend on variant-specific +# build-args (INSTALL_OPENCODE, INSTALL_OMOS, INSTALL_PI). The variant +# Dockerfile (Dockerfile.variant) FROMs the base and adds only those +# deltas. +# +# The base is rebuilt only when this file or anything it COPYs in +# changes (rootfs/, entrypoint*.sh). Version bumps to OPENCODE_VERSION, +# OMOS_VERSION, PI_VERSION, etc. do NOT trigger a base rebuild. +# +# See the project README's "Build pipeline" section for the rationale. + +ARG DEBIAN_VERSION=trixie-slim +FROM debian:${DEBIAN_VERSION} AS base + +ARG TARGETARCH + +LABEL maintainer="joakimp" +LABEL description="opencode-devbox — base image (variant-independent)" +LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/opencode-devbox" + +# Avoid interactive prompts during build +ENV DEBIAN_FRONTEND=noninteractive + +# ── Core system packages ───────────────────────────────────────────── +# apt-get upgrade picks up any security/CVE fixes published between +# debian:trixie-slim base-image rebuilds. Paired with the index update +# and the install in the same layer so we don't bloat image history. +RUN apt-get update && \ + apt-get upgrade -y --no-install-recommends && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + wget \ + git \ + openssh-client \ + gnupg \ + jq \ + ripgrep \ + fd-find \ + tree \ + less \ + htop \ + tmux \ + make \ + patch \ + diffutils \ + git-crypt \ + age \ + file \ + sudo \ + locales \ + procps \ + unzip \ + gcc \ + g++ \ + rsync \ + python3-pip \ + python3-venv \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds) +# +# Version policy for the binaries below: +# • Default is `latest` — resolved at build time by following the +# /releases/latest redirect on GitHub and reading the tag from the +# Location header. This means every base rebuild picks up the newest +# upstream release, with no risk of running months-old CVE-affected +# binaries. +# • Explicit pins still work: pass `--build-arg GOSU_VERSION=1.19` etc. +# • Resolved versions are printed during build and re-checked by the +# smoke test (scripts/smoke-test.sh), so drift is visible in CI logs. + +# gosu — privilege de-escalation +ARG GOSU_VERSION=latest +RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \ + V="${GOSU_VERSION}" && \ + if [ "$V" = "latest" ]; then \ + V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \ + fi && \ + V="${V#v}" && \ + [ -n "$V" ] && \ + echo "Installing gosu ${V}" && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \ + chmod +x /usr/local/bin/gosu && \ + gosu --version + +# fzf — fuzzy finder +ARG FZF_VERSION=latest +RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \ + V="${FZF_VERSION}" && \ + if [ "$V" = "latest" ]; then \ + V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \ + fi && \ + V="${V#v}" && \ + [ -n "$V" ] && \ + echo "Installing fzf ${V}" && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \ + fzf --version + +# git-lfs — Git Large File Storage +ARG GIT_LFS_VERSION=latest +RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \ + V="${GIT_LFS_VERSION}" && \ + if [ "$V" = "latest" ]; then \ + V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \ + fi && \ + V="${V#v}" && \ + [ -n "$V" ] && \ + echo "Installing git-lfs ${V}" && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \ + install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \ + rm -rf /tmp/git-lfs-${V} && \ + git lfs install --system && \ + git-lfs --version + +# neovim — modern text editor +ARG NVIM_VERSION=latest +RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \ + V="${NVIM_VERSION}" && \ + if [ "$V" = "latest" ]; then \ + V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \ + fi && \ + V="${V#v}" && \ + [ -n "$V" ] && \ + echo "Installing neovim ${V}" && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \ + ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \ + nvim --version | head -1 + +# bat — syntax-highlighted cat replacement +ARG BAT_VERSION=latest +RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \ + V="${BAT_VERSION}" && \ + if [ "$V" = "latest" ]; then \ + V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \ + fi && \ + V="${V#v}" && \ + [ -n "$V" ] && \ + echo "Installing bat ${V}" && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \ + install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \ + rm -rf /tmp/bat-v${V}-* && \ + bat --version + +# eza — modern ls replacement +ARG EZA_VERSION=latest +RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \ + V="${EZA_VERSION}" && \ + if [ "$V" = "latest" ]; then \ + V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \ + fi && \ + V="${V#v}" && \ + [ -n "$V" ] && \ + echo "Installing eza ${V}" && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \ + eza --version | head -1 + +# zoxide — smarter cd command +ARG ZOXIDE_VERSION=latest +RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \ + V="${ZOXIDE_VERSION}" && \ + if [ "$V" = "latest" ]; then \ + V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \ + fi && \ + V="${V#v}" && \ + [ -n "$V" ] && \ + echo "Installing zoxide ${V}" && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \ + zoxide --version + +# uv — fast Python package manager (replaces pip, venv, pyenv) +# Note: uv releases don't prefix tags with "v" (e.g. tag is "0.11.8"). +ARG UV_VERSION=latest +RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \ + V="${UV_VERSION}" && \ + if [ "$V" = "latest" ]; then \ + V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \ + fi && \ + V="${V#v}" && \ + [ -n "$V" ] && \ + echo "Installing uv ${V}" && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \ + install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \ + install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \ + rm -rf /tmp/uv-* && \ + uv --version + +# ── MemPalace — local-first AI memory system ───────────────────────── +# Provides semantic search over conversation history via 29 MCP tools. +# Always installed in the base (variant-independent). Set +# INSTALL_MEMPALACE=false at base-build time to shave ~300 MB. +ARG INSTALL_MEMPALACE=true +ENV UV_TOOL_DIR=/opt/uv-tools +ENV UV_TOOL_BIN_DIR=/usr/local/bin +RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \ + mkdir -p /opt/uv-tools && \ + uv tool install --no-cache mempalace && \ + /opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \ + fi + +# ── mempalace-toolkit — bash wrappers for session/docs mining ──────── +ARG INSTALL_MEMPALACE_TOOLKIT=true +ARG MEMPALACE_TOOLKIT_REF=main +RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \ + git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \ + https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \ + ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \ + ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \ + chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \ + mempalace-session --help >/dev/null && \ + mempalace-docs --help >/dev/null && \ + echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \ + fi + +# rustup — Rust toolchain manager (init binary only; toolchains installed at runtime) +RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://static.rust-lang.org/rustup/dist/${ARCH}-unknown-linux-gnu/rustup-init" -o /usr/local/bin/rustup-init && \ + chmod +x /usr/local/bin/rustup-init + +# gitea-mcp — MCP server for Gitea API +ARG GITEA_MCP_VERSION=latest +RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \ + V="${GITEA_MCP_VERSION}" && \ + if [ "$V" = "latest" ]; then \ + V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \ + fi && \ + V="${V#v}" && \ + [ -n "$V" ] && \ + echo "Installing gitea-mcp ${V}" && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/download/v${V}/gitea-mcp_Linux_${ARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin/ gitea-mcp && \ + chmod +x /usr/local/bin/gitea-mcp && \ + gitea-mcp --version + +# Set locale — generate common UTF-8 locales (override via LANG/LC_ALL env vars) +RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 +ENV EDITOR=nvim +ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}" + +# ── Node.js (required for opencode/pi/omos at variant build + MCP servers) ── +ARG NODE_VERSION=22 +RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \ + apt-get install -y --no-install-recommends nodejs && \ + rm -rf /var/lib/apt/lists/* + +# ── AWS CLI v2 (for SSO/Bedrock authentication) ───────────────────── +RUN ARCH=$(case "${TARGETARCH}" in \ + amd64) echo "x86_64" ;; \ + arm64) echo "aarch64" ;; \ + *) echo "x86_64" ;; \ + esac) && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \ + unzip -q /tmp/awscli.zip -d /tmp && \ + /tmp/aws/install && \ + rm -rf /tmp/aws /tmp/awscli.zip && \ + aws --version + +# ── Non-root user ──────────────────────────────────────────────────── +ARG USER_NAME=developer +ARG USER_UID=1000 +ARG USER_GID=1000 + +RUN groupadd --gid ${USER_GID} ${USER_NAME} && \ + useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \ + echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME} + +# Create standard directories +RUN mkdir -p /workspace \ + /home/${USER_NAME}/.config/opencode/skills \ + /home/${USER_NAME}/.pi/agent/extensions \ + /home/${USER_NAME}/.agents/skills \ + /home/${USER_NAME}/.local/share/opencode \ + /home/${USER_NAME}/.cache/bash \ + /home/${USER_NAME}/.ssh && \ + chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME} + +# ── Pre-warm chromadb embedding model ────────────────────────────── +# Runs as gosu developer so Path.home() resolves correctly. Uses +# the mempalace venv's python, which is the only one that has +# chromadb importable (system python3 cannot reach the isolated venv). +RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \ + gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\ +from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \ +ef = ONNXMiniLM_L6_V2(); \ +_ = ef(['warmup']); \ +print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \ + ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \ + fi + +# ── User-writable npm global prefix on the devbox-pi-config volume ── +# By default npm's global prefix is /usr (writable only by root) so any +# `pi install npm:` 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. +# +# IMPORTANT: in this split-build layout the variant Dockerfile inherits +# this prefix at build time. To keep the baked binaries on /usr (so the +# ~/.pi volume mount doesn't shadow them), the variant Dockerfile MUST +# run each `npm install -g` with NPM_CONFIG_PREFIX=/usr in the per-RUN +# environment. See Dockerfile.variant. +ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global +ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}" + +# ── Shell defaults (bash history, aliases, readline) ───────────────── +RUN mkdir -p /etc/skel-devbox +COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases +COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc + +# ── Entrypoint ──────────────────────────────────────────────────────── +COPY rootfs/usr/local/lib/opencode-devbox/ /usr/local/lib/opencode-devbox/ +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \ + /usr/local/lib/opencode-devbox/*.py + +# Start as root — entrypoint adjusts UID/GID then drops to developer +WORKDIR /workspace + +ENTRYPOINT ["entrypoint.sh"] +CMD ["bash", "-l"] diff --git a/Dockerfile.variant b/Dockerfile.variant new file mode 100644 index 0000000..ce2a7f4 --- /dev/null +++ b/Dockerfile.variant @@ -0,0 +1,103 @@ +# opencode-devbox — variant image +# +# FROMs a base- image produced by Dockerfile.base and adds only +# the variant-specific tools (opencode, pi, oh-my-opencode-slim, Go). +# +# The four published variants are produced from THIS Dockerfile by +# varying build args: +# +# variant INSTALL_OPENCODE INSTALL_OMOS INSTALL_PI +# ───────────────── ──────────────── ──────────── ────────── +# base true false false +# omos true true false +# with-pi true false true +# omos-with-pi true true true +# +# Pass `--build-arg BASE_IMAGE=:base-` to select the base. +# The CI workflow computes the base hash from Dockerfile.base + rootfs/ +# + entrypoint*.sh and feeds it in. +# +# IMPORTANT: the base image sets NPM_CONFIG_PREFIX to +# /home/developer/.pi/npm-global so runtime `pi install npm:...` and +# `npm install -g` by the developer user lands on the named volume. +# At BUILD time we want the baked binaries on /usr so they survive the +# volume mount. Each `npm install -g` below therefore prefixes the +# command with `NPM_CONFIG_PREFIX=/usr`. + +ARG BASE_IMAGE +FROM ${BASE_IMAGE} + +ARG TARGETARCH +ARG USER_NAME=developer + +# ── Install opencode via npm ───────────────────────────────────────── +ARG INSTALL_OPENCODE=true +ARG OPENCODE_VERSION=1.14.41 +RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \ + NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \ + opencode --version ; \ + fi + +# ── Optional: pi coding-agent ──────────────────────────────────────── +# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh +# runs each repo's install.sh on container start so symlinks land under +# ~/.pi/agent/ on the named volume. +ARG INSTALL_PI=false +ARG PI_VERSION=latest +ARG PI_TOOLKIT_REF=main +ARG PI_EXTENSIONS_REF=main +RUN if [ "${INSTALL_PI}" = "true" ]; then \ + if [ "${PI_VERSION}" = "latest" ]; then \ + NPM_CONFIG_PREFIX=/usr npm install -g @mariozechner/pi-coding-agent ; \ + else \ + NPM_CONFIG_PREFIX=/usr 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 + +# ── Optional: Go ───────────────────────────────────────────────────── +ARG INSTALL_GO=false +ARG GO_VERSION=latest +RUN if [ "${INSTALL_GO}" = "true" ]; then \ + GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \ + V="${GO_VERSION}" && \ + if [ "$V" = "latest" ]; then \ + V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \ + awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \ + fi && \ + [ -n "$V" ] && \ + echo "Installing Go ${V}" && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \ + ln -s /usr/local/go/bin/go /usr/local/bin/go && \ + ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \ + fi + +# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ──────── +# Installs Bun runtime and the oh-my-opencode-slim npm package. +ARG INSTALL_OMOS=false +ARG OMOS_VERSION=latest +RUN if [ "${INSTALL_OMOS}" = "true" ]; then \ + ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + BUN_ARCH="x64-baseline"; \ + elif [ "$ARCH" = "aarch64" ]; then \ + BUN_ARCH="aarch64"; \ + fi && \ + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-${BUN_ARCH}.zip" -o /tmp/bun.zip && \ + unzip -o /tmp/bun.zip -d /tmp/bun && \ + mv /tmp/bun/bun-linux-${BUN_ARCH}/bun /usr/local/bin/bun && \ + chmod +x /usr/local/bin/bun && \ + ln -sf bun /usr/local/bin/bunx && \ + rm -rf /tmp/bun /tmp/bun.zip && \ + bun --version && \ + test -L /usr/local/bin/bunx && \ + NPM_CONFIG_PREFIX=/usr npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \ + fi + +# WORKDIR / ENTRYPOINT / CMD inherited from base.