Rewrite DOCKER_HUB.md as a hand-maintained slim template
Validate / docs-check (push) Successful in 16s
Validate / validate-base (push) Successful in 11m16s
Validate / validate-omos (push) Failing after 18m33s
Validate / validate-with-pi (push) Successful in 13m46s
Validate / validate-omos-with-pi (push) Failing after 19m52s
Validate / docs-check (push) Successful in 16s
Validate / validate-base (push) Successful in 11m16s
Validate / validate-omos (push) Failing after 18m33s
Validate / validate-with-pi (push) Successful in 13m46s
Validate / validate-omos-with-pi (push) Failing after 19m52s
The previous derive-from-README mechanism (split_sections, SECTION_RULES, TRIM_SUBSECTIONS, REPLACEMENTS) generated a 24 997 byte Hub doc with 3 byte headroom against the 25 kB Hub limit. Every README addition forced a 'trim something else first' exercise, and the resulting copy was awkward (terse, repetitive linkbacks injected mid-section). Replace with a single hand-maintained HUB_TEMPLATE constant. The Hub doc is now intentionally slim (~5.5 kB, ~78 percent headroom) and focuses on what Hub readers actually need: elevator pitch, image variants, quick start, what's inside, auth, persistence, and link-outs to the gitea README for depth. Trade-off: when image-variants or quick-start change, update HUB_TEMPLATE here too. That coupling is now explicit and local rather than spread across SECTION_RULES + REPLACEMENTS + TRIM machinery, and most README edits no longer require regenerating DOCKER_HUB.md at all. Generator simplified from 323 lines to 199 lines (270-line net reduction across the script + DOCKER_HUB.md). README and Hub doc are now independent surfaces. CHANGELOG and AGENTS updated to reflect the new coupling. Release-day checklist tightened: README -> regenerate DOCKER_HUB ONLY if HUB_TEMPLATE changed -> promote CHANGELOG -> grep AGENTS -> commit -> tag.
This commit is contained in:
@@ -1,15 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate DOCKER_HUB.md from README.md.
|
||||
Generate DOCKER_HUB.md.
|
||||
|
||||
Rationale
|
||||
---------
|
||||
README.md is the authoritative source. DOCKER_HUB.md is a subset
|
||||
intended for users pulling the pre-built image from Docker Hub — so
|
||||
build-from-source instructions, developer setup (git hooks, gitleaks),
|
||||
and CI/contribution content are dropped.
|
||||
DOCKER_HUB.md is the public-facing description shown on Docker Hub. It
|
||||
has two hard constraints the README does not:
|
||||
|
||||
Docker Hub enforces a 25 kB limit on the full description field.
|
||||
1. A 25 kB byte limit on the full_description field.
|
||||
2. A different audience: Hub readers want a 30-second evaluation —
|
||||
"what is this, how do I run it, does it have what I need" — and
|
||||
reference material is better consulted in context on gitea.
|
||||
|
||||
For a long time this script tried to derive DOCKER_HUB.md from README.md
|
||||
by section selection + targeted replacement. As the README grew that
|
||||
approach pushed against the 25 kB ceiling on every change, costing a
|
||||
trim-something-else exercise per edit (final state: 3 byte headroom).
|
||||
|
||||
The new approach is much simpler: a hand-written HUB_TEMPLATE below.
|
||||
The template intentionally stays slim and links out to the gitea README
|
||||
for everything that benefits from depth. README.md grows freely.
|
||||
|
||||
Trade-off: when image-variants table or quick-start flow changes,
|
||||
update HUB_TEMPLATE here too. That coupling is now explicit and
|
||||
local rather than spread across SECTION_RULES + REPLACEMENTS + TRIM
|
||||
machinery.
|
||||
|
||||
Usage
|
||||
-----
|
||||
@@ -19,77 +34,42 @@ Regenerate in place:
|
||||
Fail if DOCKER_HUB.md is out of sync with what this script would emit
|
||||
(run this in CI):
|
||||
python3 scripts/generate-dockerhub-md.py --check
|
||||
|
||||
Design
|
||||
------
|
||||
Sections are selected and in some cases rewritten via `SECTION_RULES`
|
||||
below. This keeps the transformation explicit and easy to audit — if
|
||||
a new section is added to README.md that should also appear on Docker
|
||||
Hub, extend SECTION_RULES rather than inventing implicit heuristics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
README = REPO_ROOT / "README.md"
|
||||
DOCKER_HUB = REPO_ROOT / "DOCKER_HUB.md"
|
||||
|
||||
# Max size for Docker Hub full_description (bytes, UTF-8).
|
||||
MAX_SIZE_BYTES = 25_000
|
||||
|
||||
# Per-section transformation.
|
||||
#
|
||||
# Each key is a top-level section title as it appears in README.md
|
||||
# (without the leading "## ").
|
||||
#
|
||||
# The value is one of:
|
||||
# "keep" — include verbatim.
|
||||
# "drop" — exclude entirely.
|
||||
# "replace" — substitute a custom body (see REPLACEMENTS).
|
||||
# "trim" — keep but drop selected level-3 sub-sections listed
|
||||
# in TRIM_SUBSECTIONS[title].
|
||||
#
|
||||
# Unknown sections default to "drop" with a warning — forcing an
|
||||
# explicit decision whenever README gains a new section.
|
||||
SECTION_RULES: dict[str, str] = {
|
||||
"Why?": "drop", # build-motivation, not user-facing
|
||||
"Quick Start": "replace", # swap docker compose clone flow for docker run
|
||||
"Features": "keep",
|
||||
"Usage": "keep",
|
||||
"Configuration": "trim", # drop dev-build sub-sections
|
||||
"oh-my-opencode-slim (Multi-Agent Orchestration)": "keep",
|
||||
"pi (alternative/complementary harness)": "replace", # tailored Hub version, full detail in README
|
||||
"AWS Bedrock Authentication": "keep",
|
||||
"MemPalace — persistent AI memory": "keep",
|
||||
"Gitea MCP server": "keep",
|
||||
"Context7 MCP server": "drop",
|
||||
"Shell defaults": "drop", # detail, full README covers it
|
||||
"Secret Scanning": "drop", # dev-only — gitleaks is for committers
|
||||
"Architecture": "keep",
|
||||
"License": "replace", # point at source repo instead
|
||||
}
|
||||
# Where readers go for the full reference.
|
||||
GITEA = "https://gitea.jordbo.se/joakimp/opencode-devbox"
|
||||
|
||||
# Level-3 sub-section titles (without the leading "### ") to drop from
|
||||
# sections flagged as "trim". These are dev/build-oriented — Docker Hub
|
||||
# users already have the image and don't need rebuild or multi-user
|
||||
# compose instructions.
|
||||
TRIM_SUBSECTIONS: dict[str, set[str]] = {
|
||||
"Configuration": {
|
||||
"Multi-user setup",
|
||||
"Rebuilding the Image",
|
||||
"Build Args",
|
||||
},
|
||||
}
|
||||
|
||||
# Replacement bodies. Keys match SECTION_RULES entries marked "replace".
|
||||
# Each value is the full section including the "## Title" heading.
|
||||
REPLACEMENTS: dict[str, str] = {
|
||||
"Quick Start": """## Quick Start
|
||||
HUB_TEMPLATE = f"""# opencode-devbox
|
||||
|
||||
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
|
||||
|
||||
Designed for teams who want a reproducible coding-agent setup that runs the same on every laptop and CI runner — without forcing each developer to install Bun, Node, AWS CLI, mempalace, or maintain shell config drift across machines.
|
||||
|
||||
## Image Variants
|
||||
|
||||
| Tag | Description |
|
||||
|---|---|
|
||||
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
|
||||
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
|
||||
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/mariozechner/pi-coding-agent) as alternative/complementary harness (shares the mempalace install with opencode) |
|
||||
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
|
||||
|
||||
All variants support `linux/amd64` and `linux/arm64`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker run -it --rm \\
|
||||
@@ -102,9 +82,9 @@ docker run -it --rm \\
|
||||
joakimp/opencode-devbox:latest
|
||||
```
|
||||
|
||||
This drops you straight into opencode with your project mounted at `/workspace`.
|
||||
Drops you straight into opencode with your project mounted at `/workspace`.
|
||||
|
||||
For an interactive shell first (useful for AWS SSO login):
|
||||
For an interactive shell first (useful for AWS SSO login, multi-harness workflows, or just `bash`):
|
||||
|
||||
```bash
|
||||
docker run -it --rm \\
|
||||
@@ -115,156 +95,63 @@ docker run -it --rm \\
|
||||
joakimp/opencode-devbox:latest bash
|
||||
```
|
||||
|
||||
Then run `opencode` when ready.
|
||||
Then run `opencode`, `pi` (on `*-with-pi` variants), or `aws sso login` from the shell.
|
||||
|
||||
For docker-compose users, see the source repo for `docker-compose.yml` and `.env.example` templates.
|
||||
""",
|
||||
"License": """## Source
|
||||
For docker-compose users, the source repo provides `docker-compose.yml`, `.env.example`, and a one-liner `docker compose up -d` workflow with named volumes pre-wired.
|
||||
|
||||
MIT licensed. Source, issues, and `docker-compose.yml` templates: <https://gitea.jordbo.se/joakimp/opencode-devbox>
|
||||
""",
|
||||
"pi (alternative/complementary harness)": """## pi (alternative/complementary harness)
|
||||
## What's Inside
|
||||
|
||||
[pi](https://github.com/mariozechner/pi-coding-agent) is a lightweight TUI coding-agent that can run alongside opencode in the same container. Both harnesses share the same mempalace install and palace data — wing/diary entries created by one are visible to the other. Available on the `*-with-pi` and `*-omos-with-pi` image tags.
|
||||
- **[opencode](https://opencode.ai)** — primary coding-agent harness. Multi-provider (Anthropic, OpenAI, Bedrock, Google, Groq, etc.).
|
||||
- **[pi](https://github.com/mariozechner/pi-coding-agent)** *(in `*-with-pi` variants)* — lightweight TUI coding-agent that coexists with opencode and shares the same mempalace install. Includes the `mcp-loader` extension so any local-stdio or remote streamable-HTTP MCP server (searxng, gitea, context7, …) can be added by editing `~/.pi/agent/settings.json`.
|
||||
- **[mempalace](https://github.com/MemPalace/mempalace)** — persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are mutually visible to opencode and pi.
|
||||
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** *(in `*-omos` variants)* — multi-agent orchestration on top of opencode (council, fallback chains, named agents).
|
||||
- **AWS CLI v2** with SSO support, **Node.js LTS**, **Bun** (OMOS variants), **uv** (Python), **gosu** for clean UID/GID adjustment to match your host workspace.
|
||||
- **MCP wrappers** for mempalace pre-installed and pre-wired to both harnesses.
|
||||
|
||||
### Run
|
||||
## Authentication
|
||||
|
||||
The default container CMD is `bash -l`, so `compose run --rm devbox` drops to a login shell. From there pick a harness:
|
||||
The container reads provider credentials from environment variables and host-mounted config:
|
||||
|
||||
```bash
|
||||
docker compose run --rm devbox # bash — then run `pi`, `opencode`, or `aws sso login`
|
||||
docker compose run --rm devbox pi # launch pi directly
|
||||
docker compose exec -u developer devbox pi # attach into a `compose up -d` container
|
||||
```
|
||||
- **Anthropic / OpenAI / Groq / others:** set `OPENCODE_PROVIDER` and the corresponding `*_API_KEY` via `-e` or `.env`.
|
||||
- **AWS Bedrock (SSO):** mount `~/.aws` from the host, `OPENCODE_PROVIDER=amazon-bedrock`, then `aws sso login` inside the container. Tokens persist across container restarts via the host bind-mount.
|
||||
- **OAuth / device-code providers:** auth state lives in opencode's config, which is persisted via the `devbox-opencode-config` named volume.
|
||||
|
||||
### MemPalace integration
|
||||
Full Bedrock walkthrough (IAM roles, permissions, multi-account setups): see the [AWS Bedrock Authentication](
|
||||
{GITEA}#aws-bedrock-authentication
|
||||
) section on gitea.
|
||||
|
||||
pi ships pre-wired with the mempalace bridge — the `mempalace.ts` extension is symlinked into `~/.pi/agent/extensions/` at first start, exposing palace MCP tools (search, diary, knowledge graph) to pi natively. The same `~/.mempalace/` palace is shared with opencode, so memories are mutually visible across harnesses. To persist palace data across container recreate, uncomment the `devbox-palace` volume in `docker-compose.yml` (see *MemPalace* section below).
|
||||
## Persistence
|
||||
|
||||
### Persistence
|
||||
|
||||
| Path in container | Volume | Contains |
|
||||
| Volume | Mount | Survives |
|
||||
|---|---|---|
|
||||
| `/home/developer/.pi` | `devbox-pi-config` (default) | `settings.json`, `agent/extensions/`, `auth.json`, `sessions/`, plus `git/` and `npm-global/` (`NPM_CONFIG_PREFIX` points here) so `pi install npm:` / `git:` and `npm i -g` survive container recreate and image rebuild. |
|
||||
| `/home/developer/.mempalace` | `devbox-palace` (uncomment) | Shared palace — visible to pi and opencode |
|
||||
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild |
|
||||
| `devbox-pi-config` | `~/.pi` | container recreate, image rebuild — incl. user-installed pi packages via `pi install` (`NPM_CONFIG_PREFIX` points into the volume) |
|
||||
| `devbox-palace` (uncomment) | `~/.mempalace` | container recreate, image rebuild — palace data is precious, treat as primary storage |
|
||||
| `devbox-chroma-cache` | `~/.cache/chroma` | container recreate (model cache, disposable — re-downloads in seconds) |
|
||||
|
||||
Baked pi (`/usr/bin/pi`, `/opt/pi-*`) ships in the image; rebuild to upgrade. `npm i -g @mariozechner/pi-coding-agent` lands on the volume and wins via `PATH`.
|
||||
Workspace bind-mount (`/workspace`) is your project directory on the host, so source code is never inside the container.
|
||||
|
||||
Full build args, extension list, and toolkit detail: <https://gitea.jordbo.se/joakimp/opencode-devbox#pi-alternativecomplementary-harness>
|
||||
""",
|
||||
}
|
||||
Full persistence reference, including multi-user (`SIGNUM`) isolation and host bind-mount alternatives: see the [README on gitea]({GITEA}#persistence).
|
||||
|
||||
## Where to Go Next
|
||||
|
||||
# Prepended to the generated file.
|
||||
HEADER = """# opencode-devbox — Docker Hub
|
||||
- **Full README** with build args, every feature in detail, troubleshooting: <{GITEA}>
|
||||
- **CHANGELOG** for version history: <{GITEA}/src/branch/main/CHANGELOG.md>
|
||||
- **Issues / source / docker-compose templates:** <{GITEA}>
|
||||
- **Agent-facing internals** (for future maintainers / coding agents working in the repo): <{GITEA}/src/branch/main/AGENTS.md>
|
||||
|
||||
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.
|
||||
## License
|
||||
|
||||
## Image Variants
|
||||
MIT. See <{GITEA}/src/branch/main/LICENSE>.
|
||||
|
||||
Four 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 |
|
||||
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/mariozechner/pi-coding-agent) as alternative/complementary harness (shares the mempalace install with opencode) |
|
||||
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
|
||||
|
||||
All 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.
|
||||
---
|
||||
|
||||
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
|
||||
"""
|
||||
|
||||
|
||||
def split_sections(md: str) -> list[tuple[str, str]]:
|
||||
"""Split markdown on level-2 headings, returning (title, body) pairs.
|
||||
|
||||
The body includes the heading line and everything up to (but not
|
||||
including) the next level-2 heading or EOF. Content before the first
|
||||
``## `` is returned with an empty title (the document preamble).
|
||||
"""
|
||||
pattern = re.compile(r"^## ", re.MULTILINE)
|
||||
parts = pattern.split(md)
|
||||
preamble, *rest = parts
|
||||
|
||||
sections: list[tuple[str, str]] = []
|
||||
if preamble.strip():
|
||||
sections.append(("", preamble))
|
||||
for part in rest:
|
||||
line, _, body = part.partition("\n")
|
||||
sections.append((line.strip(), f"## {line}\n{body}"))
|
||||
return sections
|
||||
|
||||
|
||||
def trim_subsections(body: str, drop: set[str]) -> str:
|
||||
"""Remove level-3 sub-sections whose title is in `drop`.
|
||||
|
||||
A sub-section starts at a line beginning with "### " and ends at
|
||||
the next "### " or "## " (or EOF).
|
||||
"""
|
||||
if not drop:
|
||||
return body
|
||||
|
||||
# Split on level-3 headings while preserving the level-2 header
|
||||
# block. First piece is everything up to the first "### ".
|
||||
parts = re.split(r"(^### .+\n)", body, flags=re.MULTILINE)
|
||||
# parts alternates: [before_first_h3, "### Title\n", body, "### Title\n", body, ...]
|
||||
kept: list[str] = [parts[0]] if parts else []
|
||||
i = 1
|
||||
while i < len(parts):
|
||||
heading = parts[i]
|
||||
content = parts[i + 1] if i + 1 < len(parts) else ""
|
||||
title = heading[4:].strip()
|
||||
if title not in drop:
|
||||
kept.append(heading)
|
||||
kept.append(content)
|
||||
i += 2
|
||||
return "".join(kept)
|
||||
|
||||
|
||||
def generate() -> str:
|
||||
"""Produce the DOCKER_HUB.md content string."""
|
||||
readme = README.read_text(encoding="utf-8")
|
||||
sections = split_sections(readme)
|
||||
|
||||
out: list[str] = [HEADER]
|
||||
unknown: list[str] = []
|
||||
|
||||
for title, body in sections:
|
||||
if title == "":
|
||||
# README preamble is replaced by our HEADER; skip.
|
||||
continue
|
||||
|
||||
rule = SECTION_RULES.get(title)
|
||||
if rule is None:
|
||||
unknown.append(title)
|
||||
continue
|
||||
if rule == "drop":
|
||||
continue
|
||||
if rule == "keep":
|
||||
out.append(body.rstrip() + "\n\n")
|
||||
elif rule == "trim":
|
||||
trimmed = trim_subsections(body, TRIM_SUBSECTIONS.get(title, set()))
|
||||
out.append(trimmed.rstrip() + "\n\n")
|
||||
elif rule == "replace":
|
||||
out.append(REPLACEMENTS[title].rstrip() + "\n\n")
|
||||
else: # pragma: no cover — programmer error
|
||||
raise AssertionError(f"unknown rule {rule!r} for section {title!r}")
|
||||
|
||||
if unknown:
|
||||
print(
|
||||
"ERROR: README.md contains sections not classified in "
|
||||
"SECTION_RULES:\n - "
|
||||
+ "\n - ".join(unknown)
|
||||
+ "\n\nAdd each to SECTION_RULES in "
|
||||
"scripts/generate-dockerhub-md.py (choose keep/drop/replace).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(2)
|
||||
|
||||
return "".join(out).rstrip() + "\n"
|
||||
return HUB_TEMPLATE
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -290,11 +177,10 @@ def main() -> int:
|
||||
existing = DOCKER_HUB.read_text(encoding="utf-8") if DOCKER_HUB.exists() else ""
|
||||
if existing != content:
|
||||
print(
|
||||
"ERROR: DOCKER_HUB.md is out of sync with README.md.\n"
|
||||
"ERROR: DOCKER_HUB.md is out of sync with the template.\n"
|
||||
"Run: python3 scripts/generate-dockerhub-md.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Show a small diff hint.
|
||||
import difflib
|
||||
|
||||
diff = difflib.unified_diff(
|
||||
@@ -307,14 +193,16 @@ def main() -> int:
|
||||
sys.stderr.writelines(list(diff)[:80])
|
||||
return 1
|
||||
print(
|
||||
f"OK: DOCKER_HUB.md is in sync with README.md "
|
||||
f"({size} bytes, {MAX_SIZE_BYTES} limit).",
|
||||
f"OK: DOCKER_HUB.md is in sync with HUB_TEMPLATE "
|
||||
f"({size} bytes, {MAX_SIZE_BYTES} limit, "
|
||||
f"{MAX_SIZE_BYTES - size} bytes headroom).",
|
||||
)
|
||||
return 0
|
||||
|
||||
DOCKER_HUB.write_text(content, encoding="utf-8")
|
||||
print(
|
||||
f"Wrote {DOCKER_HUB} ({size} bytes, {MAX_SIZE_BYTES} limit).",
|
||||
f"Wrote {DOCKER_HUB} ({size} bytes, {MAX_SIZE_BYTES} limit, "
|
||||
f"{MAX_SIZE_BYTES - size} bytes headroom).",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user