Infrastructure pass: CI smoke tests, floating versions, chown sentinel, generate-config script

Main changes:

- Extract opencode.json generation from entrypoint-user.sh into a
  standalone Python script (rootfs/usr/local/lib/opencode-devbox/
  generate-config.py). Preserves the never-overwrite-existing-config
  guarantee. Cuts entrypoint-user.sh from 176 to 97 lines.

- Install MemPalace via 'uv tool install' into an isolated venv at
  /opt/uv-tools/mempalace/ with a /usr/local/bin/mempalace-mcp-server
  wrapper, replacing the 'pip install --break-system-packages' escape
  hatch. The wrapper is what generate-config.py references in the
  auto-generated opencode.json. Also fix 'mempalace init' in
  entrypoint-user.sh to use --yes so first-start initialization isn't
  interactive (this used to hang or print prompts into the user's
  terminal). Gated by INSTALL_MEMPALACE build arg (default true) so
  users who don't need AI memory can shave ~300 MB.

- Sentinel-file pattern in entrypoint.sh volume-ownership loop: write
  .devbox-owner after a successful chown -R, skip the recursive walk
  on subsequent starts when the sentinel matches FINAL_UID:FINAL_GID.
  Cuts multi-second startup costs to milliseconds on large volumes
  (nvim plugins, palace data). UID changes still trigger a full chown.

- Float all GitHub/Gitea-hosted binary versions: gosu, fzf, git-lfs,
  neovim, bat, eza, zoxide, uv, gitea-mcp now default to 'latest' and
  resolve the newest upstream release at build time via the /releases/
  latest redirect. Go (go.dev JSON feed) and oh-my-opencode-slim (npm
  @latest) likewise. Intentional pins still in place: OPENCODE_VERSION,
  NODE_VERSION=22, DEBIAN_VERSION=trixie-slim. Each *_VERSION ARG
  accepts an explicit value to lock a specific version when needed.

- New scripts/smoke-test.sh verifies binary presence, opencode startup,
  entrypoint user drop, generate-config idempotency, bun's presence-
  per-variant, and image size against thresholds (2500 MB base, 3000
  MB OMOS). Prints resolved component versions as its first step so
  CI logs always record what got baked into a given image.

- New .gitea/workflows/validate.yml runs on push to main and PRs:
  single-arch amd64 build, smoke test, DOCKER_HUB.md sync check. Tag-
  triggered docker-publish.yml now smoke-tests each variant on amd64
  before the full multi-arch push.

- scripts/generate-dockerhub-md.py auto-generates DOCKER_HUB.md from
  README.md using explicit SECTION_RULES. --check mode fails CI when
  the committed file is out of sync. Enforces the 25 kB Docker Hub
  limit. Adding a new README section forces an explicit keep/drop/
  replace decision.

- Remove dead INSTALL_PYTHON build arg (was a no-op since mempalace
  added python3 unconditionally).
This commit is contained in:
2026-04-28 23:28:43 +02:00
parent 4efc4e8005
commit 113c9f0bb0
14 changed files with 1434 additions and 530 deletions
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""
Generate DOCKER_HUB.md from README.md.
Rationale
---------
README.md is the authoritative source. DOCKER_HUB.md is a subset
intended for users pulling the pre-built image from Docker Hub — so
build-from-source instructions, developer setup (git hooks, gitleaks),
and CI/contribution content are dropped.
Docker Hub enforces a 25 kB limit on the full description field.
Usage
-----
Regenerate in place:
python3 scripts/generate-dockerhub-md.py
Fail if DOCKER_HUB.md is out of sync with what this script would emit
(run this in CI):
python3 scripts/generate-dockerhub-md.py --check
Design
------
Sections are selected and in some cases rewritten via `SECTION_RULES`
below. This keeps the transformation explicit and easy to audit — if
a new section is added to README.md that should also appear on Docker
Hub, extend SECTION_RULES rather than inventing implicit heuristics.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
README = REPO_ROOT / "README.md"
DOCKER_HUB = REPO_ROOT / "DOCKER_HUB.md"
# Max size for Docker Hub full_description (bytes, UTF-8).
MAX_SIZE_BYTES = 25_000
# Per-section transformation.
#
# Each key is a top-level section title as it appears in README.md
# (without the leading "## ").
#
# The value is one of:
# "keep" — include verbatim.
# "drop" — exclude entirely.
# "replace" — substitute a custom body (see REPLACEMENTS).
# "trim" — keep but drop selected level-3 sub-sections listed
# in TRIM_SUBSECTIONS[title].
#
# Unknown sections default to "drop" with a warning — forcing an
# explicit decision whenever README gains a new section.
SECTION_RULES: dict[str, str] = {
"Why?": "drop", # build-motivation, not user-facing
"Quick Start": "replace", # swap docker compose clone flow for docker run
"Features": "keep",
"Usage": "keep",
"Configuration": "trim", # drop dev-build sub-sections
"oh-my-opencode-slim (Multi-Agent Orchestration)": "keep",
"AWS Bedrock Authentication": "keep",
"MemPalace — persistent AI memory": "keep",
"Gitea MCP server": "keep",
"Shell defaults": "keep",
"Secret Scanning": "drop", # dev-only — gitleaks is for committers
"Architecture": "keep",
"License": "replace", # point at source repo instead
}
# Level-3 sub-section titles (without the leading "### ") to drop from
# sections flagged as "trim". These are dev/build-oriented — Docker Hub
# users already have the image and don't need rebuild or multi-user
# compose instructions.
TRIM_SUBSECTIONS: dict[str, set[str]] = {
"Configuration": {
"Multi-user setup",
"Rebuilding the Image",
"Build Args",
},
}
# Replacement bodies. Keys match SECTION_RULES entries marked "replace".
# Each value is the full section including the "## Title" heading.
REPLACEMENTS: dict[str, str] = {
"Quick Start": """## Quick Start
```bash
docker run -it --rm \\
-e ANTHROPIC_API_KEY=your-key \\
-e OPENCODE_PROVIDER=anthropic \\
-e GIT_USER_NAME="Your Name" \\
-e GIT_USER_EMAIL="you@example.com" \\
-v ~/projects:/workspace \\
-v ~/.ssh:/home/developer/.ssh:ro \\
joakimp/opencode-devbox:latest
```
This drops you straight into opencode with your project mounted at `/workspace`.
For an interactive shell first (useful for AWS SSO login):
```bash
docker run -it --rm \\
-e ANTHROPIC_API_KEY=your-key \\
-e OPENCODE_PROVIDER=anthropic \\
-v ~/projects:/workspace \\
-v ~/.ssh:/home/developer/.ssh:ro \\
joakimp/opencode-devbox:latest bash
```
Then run `opencode` when ready.
For docker-compose users, see the source repo for `docker-compose.yml` and `.env.example` templates.
""",
"License": """## Source
MIT licensed. Source, issues, and `docker-compose.yml` templates: <https://gitea.jordbo.se/joakimp/opencode-devbox>
""",
}
# Prepended to the generated file.
HEADER = """# opencode-devbox — Docker Hub
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
## Image Variants
Two image variants are published for each release:
| Tag | Description |
|---|---|
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
Both variants support `linux/amd64` and `linux/arm64`.
> **NOTE:** This file is auto-generated from `README.md` by `scripts/generate-dockerhub-md.py`. Edit README.md and regenerate rather than editing this file directly.
"""
def split_sections(md: str) -> list[tuple[str, str]]:
"""Split markdown on level-2 headings, returning (title, body) pairs.
The body includes the heading line and everything up to (but not
including) the next level-2 heading or EOF. Content before the first
``## `` is returned with an empty title (the document preamble).
"""
pattern = re.compile(r"^## ", re.MULTILINE)
parts = pattern.split(md)
preamble, *rest = parts
sections: list[tuple[str, str]] = []
if preamble.strip():
sections.append(("", preamble))
for part in rest:
line, _, body = part.partition("\n")
sections.append((line.strip(), f"## {line}\n{body}"))
return sections
def trim_subsections(body: str, drop: set[str]) -> str:
"""Remove level-3 sub-sections whose title is in `drop`.
A sub-section starts at a line beginning with "### " and ends at
the next "### " or "## " (or EOF).
"""
if not drop:
return body
# Split on level-3 headings while preserving the level-2 header
# block. First piece is everything up to the first "### ".
parts = re.split(r"(^### .+\n)", body, flags=re.MULTILINE)
# parts alternates: [before_first_h3, "### Title\n", body, "### Title\n", body, ...]
kept: list[str] = [parts[0]] if parts else []
i = 1
while i < len(parts):
heading = parts[i]
content = parts[i + 1] if i + 1 < len(parts) else ""
title = heading[4:].strip()
if title not in drop:
kept.append(heading)
kept.append(content)
i += 2
return "".join(kept)
def generate() -> str:
"""Produce the DOCKER_HUB.md content string."""
readme = README.read_text(encoding="utf-8")
sections = split_sections(readme)
out: list[str] = [HEADER]
unknown: list[str] = []
for title, body in sections:
if title == "":
# README preamble is replaced by our HEADER; skip.
continue
rule = SECTION_RULES.get(title)
if rule is None:
unknown.append(title)
continue
if rule == "drop":
continue
if rule == "keep":
out.append(body.rstrip() + "\n\n")
elif rule == "trim":
trimmed = trim_subsections(body, TRIM_SUBSECTIONS.get(title, set()))
out.append(trimmed.rstrip() + "\n\n")
elif rule == "replace":
out.append(REPLACEMENTS[title].rstrip() + "\n\n")
else: # pragma: no cover — programmer error
raise AssertionError(f"unknown rule {rule!r} for section {title!r}")
if unknown:
print(
"ERROR: README.md contains sections not classified in "
"SECTION_RULES:\n - "
+ "\n - ".join(unknown)
+ "\n\nAdd each to SECTION_RULES in "
"scripts/generate-dockerhub-md.py (choose keep/drop/replace).",
file=sys.stderr,
)
raise SystemExit(2)
return "".join(out).rstrip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="Fail if DOCKER_HUB.md differs from generated content.",
)
args = parser.parse_args()
content = generate()
size = len(content.encode("utf-8"))
if size > MAX_SIZE_BYTES:
print(
f"ERROR: generated DOCKER_HUB.md is {size} bytes, exceeding the "
f"Docker Hub limit of {MAX_SIZE_BYTES} bytes.",
file=sys.stderr,
)
return 1
if args.check:
existing = DOCKER_HUB.read_text(encoding="utf-8") if DOCKER_HUB.exists() else ""
if existing != content:
print(
"ERROR: DOCKER_HUB.md is out of sync with README.md.\n"
"Run: python3 scripts/generate-dockerhub-md.py",
file=sys.stderr,
)
# Show a small diff hint.
import difflib
diff = difflib.unified_diff(
existing.splitlines(keepends=True),
content.splitlines(keepends=True),
fromfile="DOCKER_HUB.md (committed)",
tofile="DOCKER_HUB.md (generated)",
n=2,
)
sys.stderr.writelines(list(diff)[:80])
return 1
print(
f"OK: DOCKER_HUB.md is in sync with README.md "
f"({size} bytes, {MAX_SIZE_BYTES} limit).",
)
return 0
DOCKER_HUB.write_text(content, encoding="utf-8")
print(
f"Wrote {DOCKER_HUB} ({size} bytes, {MAX_SIZE_BYTES} limit).",
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# Smoke-test a freshly-built opencode-devbox image.
#
# Verifies:
# - Core binaries are on PATH and runnable
# - opencode itself starts and prints a version
# - Entrypoint runs cleanly as non-root after UID adjustment
# - Generated opencode.json has the expected shape
# - MCP wrapper works (when mempalace is installed)
#
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos]
#
# Exit codes:
# 0 all checks passed
# 1 one or more checks failed
set -euo pipefail
IMAGE="${1:-}"
VARIANT="base"
if [ "${2:-}" = "--variant" ]; then
VARIANT="${3:-base}"
fi
if [ -z "$IMAGE" ]; then
echo "usage: $0 <image> [--variant base|omos]" >&2
exit 2
fi
FAILED=0
pass() { echo "$1"; }
fail() { echo "$1" >&2; FAILED=$((FAILED + 1)); }
run() {
# Run a command inside the image and capture its output.
# First arg is a label, rest is the shell command.
local label="$1"; shift
local out
if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$*" 2>&1); then
pass "$label ($(echo "$out" | head -1))"
else
fail "$label: $out"
fi
}
echo "=== Smoke test: $IMAGE (variant: $VARIANT) ==="
echo
echo "-- Resolved component versions --"
# Prints the actual version of every floating component so CI logs
# always record what got baked into this image, even when Dockerfile
# ARGs default to "latest".
docker run --rm --entrypoint="" "$IMAGE" sh -c '
printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)"
printf " %-15s %s\n" "node" "$(node --version)"
printf " %-15s %s\n" "npm" "$(npm --version)"
printf " %-15s %s\n" "nvim" "$(nvim --version | head -1)"
printf " %-15s %s\n" "bat" "$(bat --version)"
printf " %-15s %s\n" "eza" "$(eza --version | head -2 | tail -1)"
printf " %-15s %s\n" "zoxide" "$(zoxide --version)"
printf " %-15s %s\n" "uv" "$(uv --version)"
printf " %-15s %s\n" "fzf" "$(fzf --version)"
printf " %-15s %s\n" "fd" "$(fd --version)"
printf " %-15s %s\n" "rg" "$(rg --version | head -1)"
printf " %-15s %s\n" "gosu" "$(gosu --version)"
printf " %-15s %s\n" "git-lfs" "$(git-lfs --version)"
printf " %-15s %s\n" "gitea-mcp" "$(gitea-mcp --version 2>&1 | head -1)"
printf " %-15s %s\n" "aws" "$(aws --version 2>&1)"
if command -v bun >/dev/null 2>&1; then
printf " %-15s %s\n" "bun" "$(bun --version)"
fi
if command -v mempalace >/dev/null 2>&1; then
printf " %-15s %s\n" "mempalace" "$(mempalace --version 2>&1 | head -1 || echo installed)"
fi
'
echo
echo "-- Core binaries --"
run "opencode" "opencode --version"
run "node" "node --version"
run "npm" "npm --version"
run "git" "git --version"
run "nvim" "nvim --version | head -1"
run "bat" "bat --version"
run "eza" "eza --version | head -1"
run "zoxide" "zoxide --version"
run "uv" "uv --version"
run "uvx" "uvx --version"
run "rustup-init" "rustup-init --version"
run "fzf" "fzf --version"
run "fd" "fd --version"
run "rg" "rg --version | head -1"
run "jq" "jq --version"
run "aws" "aws --version"
run "gitea-mcp" "gitea-mcp --version"
run "gosu" "gosu --version"
run "tmux" "tmux -V"
echo
echo "-- Optional / variant-gated --"
# mempalace: present unless built with INSTALL_MEMPALACE=false
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then
run "mempalace" "mempalace --help | head -1"
run "mempalace-mcp-server" "test -x /usr/local/bin/mempalace-mcp-server && echo wrapper-present"
else
echo " - mempalace not installed (INSTALL_MEMPALACE=false)"
fi
# bun: only in the omos variant
if [ "$VARIANT" = "omos" ]; then
run "bun (omos)" "bun --version"
# oh-my-opencode-slim is npm-installed globally (not a bun install);
# verify it shows up in the global module list.
run "oh-my-opencode-slim" "npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim"
else
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v bun" >/dev/null 2>&1; then
fail "bun should NOT be in base image but was found"
else
pass "bun correctly absent from base image"
fi
fi
echo
echo "-- Entrypoint behaviour --"
# Generate-config script exists and has valid syntax.
run "generate-config.py exists" \
"test -x /usr/local/lib/opencode-devbox/generate-config.py && python3 -m py_compile /usr/local/lib/opencode-devbox/generate-config.py && echo ok"
# Entrypoint drops to developer user and runs a trivial command.
# Writes the result to a file inside the container so we don't have to
# disentangle entrypoint log output from command stdout on the host.
label="entrypoint drops to developer"
tmpout=$(mktemp)
if docker run --rm -e OPENCODE_PROVIDER= "$IMAGE" \
sh -c 'whoami > /tmp/who && cat /tmp/who' > "$tmpout" 2>/dev/null; then
# The last line of stdout is the whoami output. Entrypoint log lines
# (MemPalace init, "Adjusted developer UID", etc.) go to stderr or
# get printed before our sh command runs.
actual=$(tail -1 "$tmpout" | tr -d '[:space:]')
if [ "$actual" = "developer" ]; then
pass "$label"
else
fail "$label: expected 'developer', got '$actual' (full output: $(cat "$tmpout"))"
fi
else
fail "$label: container failed"
fi
rm -f "$tmpout"
# Config generation with anthropic provider writes valid JSON with the
# expected shape. The script's log message goes to stderr (line 1 of
# generate-config.py uses file=sys.stderr) so capturing only stdout
# gives us clean JSON.
label="generate-config produces valid opencode.json"
tmp=$(mktemp -d)
if docker run --rm \
-e OPENCODE_PROVIDER=anthropic \
-e HOME=/tmp/home \
--entrypoint="" \
"$IMAGE" sh -c '
mkdir -p /tmp/home
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
cat /tmp/home/.config/opencode/opencode.json
' > "$tmp/out.json" 2>/dev/null; then
if python3 -c "
import json, sys
c = json.load(open('$tmp/out.json'))
assert c['model'].startswith('anthropic/'), c
assert c['autoupdate'] is False
assert c['share'] == 'disabled'
" 2>&1; then
pass "$label"
else
fail "$label: output doesn't match expected shape: $(cat "$tmp/out.json")"
fi
else
fail "$label: container failed: $(cat "$tmp/out.json")"
fi
# Config generation is idempotent — running twice must not overwrite.
label="generate-config never overwrites existing config"
if docker run --rm \
-e OPENCODE_PROVIDER=anthropic \
-e HOME=/tmp/home \
--entrypoint="" \
"$IMAGE" sh -c '
mkdir -p /tmp/home/.config/opencode
echo "{\"sentinel\": \"user-config\"}" > /tmp/home/.config/opencode/opencode.json
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
cat /tmp/home/.config/opencode/opencode.json
' 2>/dev/null | grep -q '"sentinel": "user-config"'; then
pass "$label"
else
fail "$label: existing config was modified!"
fi
rm -rf "$tmp"
echo
echo "-- Image size --"
SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE")
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
echo " Uncompressed size: ${SIZE_MB} MB"
# Thresholds (uncompressed): base 2500 MB, omos 3000 MB. Adjust as image content evolves.
THRESHOLD=2500
[ "$VARIANT" = "omos" ] && THRESHOLD=3000
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
else
pass "image size ${SIZE_MB} MB within threshold ${THRESHOLD} MB"
fi
echo
if [ "$FAILED" -gt 0 ]; then
echo "=== FAILED: $FAILED check(s) ===" >&2
exit 1
fi
echo "=== PASSED ==="