feat: publish versioned agent images — compose should use image: not build: (#429)

- Generated compose now uses `image: ghcr.io/disinto/{agents,edge}` instead
  of `build:` directives; `disinto init --build` restores local-build mode
- Add VOLUME declarations to agents, reproduce, and edge Dockerfiles
- Add CI pipeline (.woodpecker/publish-images.yml) to build and push images
  to ghcr.io/disinto on tag events
- Mount projects/, .env, and state/ into agents container for runtime config
- Skip pre-build binary download when compose uses registry images

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-04-15 09:24:05 +00:00
parent be463c5b43
commit 92f19cb2b3
6 changed files with 100 additions and 18 deletions

View file

@ -0,0 +1,64 @@
# .woodpecker/publish-images.yml — Build and push versioned container images
# Triggered on tag pushes (e.g. v1.2.3). Builds and pushes:
# - ghcr.io/disinto/agents:<tag>
# - ghcr.io/disinto/reproduce:<tag>
# - ghcr.io/disinto/edge:<tag>
#
# Requires GHCR_TOKEN secret configured in Woodpecker with push access
# to ghcr.io/disinto.
when:
event: tag
ref: refs/tags/v*
clone:
git:
image: alpine/git
commands:
- AUTH_URL=$(printf '%s' "$CI_REPO_CLONE_URL" | sed "s|://|://token:$FORGE_TOKEN@|")
- git clone --depth 1 "$AUTH_URL" .
- git fetch --depth 1 origin "$CI_COMMIT_REF"
- git checkout FETCH_HEAD
steps:
- name: build-and-push-agents
image: plugins/docker
settings:
repo: ghcr.io/disinto/agents
registry: ghcr.io
dockerfile: docker/agents/Dockerfile
context: .
tags:
- ${CI_COMMIT_TAG}
- latest
username: disinto
password:
from_secret: GHCR_TOKEN
- name: build-and-push-reproduce
image: plugins/docker
settings:
repo: ghcr.io/disinto/reproduce
registry: ghcr.io
dockerfile: docker/reproduce/Dockerfile
context: .
tags:
- ${CI_COMMIT_TAG}
- latest
username: disinto
password:
from_secret: GHCR_TOKEN
- name: build-and-push-edge
image: plugins/docker
settings:
repo: ghcr.io/disinto/edge
registry: ghcr.io
dockerfile: docker/edge/Dockerfile
context: docker/edge
tags:
- ${CI_COMMIT_TAG}
- latest
username: disinto
password:
from_secret: GHCR_TOKEN

View file

@ -82,6 +82,7 @@ Init options:
--ci-id <n> Woodpecker CI repo ID (default: 0 = no CI) --ci-id <n> Woodpecker CI repo ID (default: 0 = no CI)
--forge-url <url> Forge base URL (default: http://localhost:3000) --forge-url <url> Forge base URL (default: http://localhost:3000)
--bare Skip compose generation (bare-metal setup) --bare Skip compose generation (bare-metal setup)
--build Use local docker build instead of registry images (dev mode)
--yes Skip confirmation prompts --yes Skip confirmation prompts
--rotate-tokens Force regeneration of all bot tokens/passwords (idempotent by default) --rotate-tokens Force regeneration of all bot tokens/passwords (idempotent by default)
@ -652,7 +653,7 @@ disinto_init() {
shift shift
# Parse flags # Parse flags
local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false rotate_tokens=false local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false rotate_tokens=false use_build=false
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--branch) branch="$2"; shift 2 ;; --branch) branch="$2"; shift 2 ;;
@ -660,6 +661,7 @@ disinto_init() {
--ci-id) ci_id="$2"; shift 2 ;; --ci-id) ci_id="$2"; shift 2 ;;
--forge-url) forge_url_flag="$2"; shift 2 ;; --forge-url) forge_url_flag="$2"; shift 2 ;;
--bare) bare=true; shift ;; --bare) bare=true; shift ;;
--build) use_build=true; shift ;;
--yes) auto_yes=true; shift ;; --yes) auto_yes=true; shift ;;
--rotate-tokens) rotate_tokens=true; shift ;; --rotate-tokens) rotate_tokens=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;; *) echo "Unknown option: $1" >&2; exit 1 ;;
@ -743,7 +745,7 @@ p.write_text(text)
local forge_port local forge_port
forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|') forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|')
forge_port="${forge_port:-3000}" forge_port="${forge_port:-3000}"
generate_compose "$forge_port" generate_compose "$forge_port" "$use_build"
generate_agent_docker generate_agent_docker
generate_caddyfile generate_caddyfile
generate_staging_index generate_staging_index
@ -1412,13 +1414,15 @@ disinto_up() {
exit 1 exit 1
fi fi
# Pre-build: download binaries to docker/agents/bin/ to avoid network calls during docker build # Pre-build: download binaries only when compose uses local build
if grep -q '^\s*build:' "$compose_file"; then
echo "── Pre-build: downloading agent binaries ────────────────────────" echo "── Pre-build: downloading agent binaries ────────────────────────"
if ! download_agent_binaries; then if ! download_agent_binaries; then
echo "Error: failed to download agent binaries" >&2 echo "Error: failed to download agent binaries" >&2
exit 1 exit 1
fi fi
echo "" echo ""
fi
# Decrypt secrets to temp .env if SOPS available and .env.enc exists # Decrypt secrets to temp .env if SOPS available and .env.enc exists
local tmp_env="" local tmp_env=""

View file

@ -28,6 +28,9 @@ RUN chmod +x /entrypoint.sh
# Entrypoint runs polling loop directly, dropping to agent user via gosu. # Entrypoint runs polling loop directly, dropping to agent user via gosu.
# All scripts execute as the agent user (UID 1000) while preserving env vars. # All scripts execute as the agent user (UID 1000) while preserving env vars.
VOLUME /home/agent/data
VOLUME /home/agent/repos
WORKDIR /home/agent/disinto WORKDIR /home/agent/disinto
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]

View file

@ -1,4 +1,7 @@
FROM caddy:latest FROM caddy:latest
RUN apk add --no-cache bash jq curl git docker-cli python3 openssh-client autossh RUN apk add --no-cache bash jq curl git docker-cli python3 openssh-client autossh
COPY entrypoint-edge.sh /usr/local/bin/entrypoint-edge.sh COPY entrypoint-edge.sh /usr/local/bin/entrypoint-edge.sh
VOLUME /data
ENTRYPOINT ["bash", "/usr/local/bin/entrypoint-edge.sh"] ENTRYPOINT ["bash", "/usr/local/bin/entrypoint-edge.sh"]

View file

@ -7,5 +7,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN useradd -m -u 1000 -s /bin/bash agent RUN useradd -m -u 1000 -s /bin/bash agent
COPY docker/reproduce/entrypoint-reproduce.sh /entrypoint-reproduce.sh COPY docker/reproduce/entrypoint-reproduce.sh /entrypoint-reproduce.sh
RUN chmod +x /entrypoint-reproduce.sh RUN chmod +x /entrypoint-reproduce.sh
VOLUME /home/agent/data
VOLUME /home/agent/repos
WORKDIR /home/agent WORKDIR /home/agent
ENTRYPOINT ["/entrypoint-reproduce.sh"] ENTRYPOINT ["/entrypoint-reproduce.sh"]

View file

@ -100,9 +100,7 @@ _generate_local_model_services() {
cat >> "$temp_file" <<EOF cat >> "$temp_file" <<EOF
agents-${service_name}: agents-${service_name}:
build: image: ghcr.io/disinto/agents:\${DISINTO_IMAGE_TAG:-latest}
context: .
dockerfile: docker/agents/Dockerfile
container_name: disinto-agents-${service_name} container_name: disinto-agents-${service_name}
restart: unless-stopped restart: unless-stopped
security_opt: security_opt:
@ -233,6 +231,7 @@ for name, config in agents.items():
# to materialize a working stack on a fresh checkout. # to materialize a working stack on a fresh checkout.
_generate_compose_impl() { _generate_compose_impl() {
local forge_port="${1:-3000}" local forge_port="${1:-3000}"
local use_build="${2:-false}"
local compose_file="${FACTORY_ROOT}/docker-compose.yml" local compose_file="${FACTORY_ROOT}/docker-compose.yml"
# Check if compose file already exists # Check if compose file already exists
@ -324,9 +323,7 @@ services:
- woodpecker - woodpecker
agents: agents:
build: image: ghcr.io/disinto/agents:${DISINTO_IMAGE_TAG:-latest}
context: .
dockerfile: docker/agents/Dockerfile
container_name: disinto-agents container_name: disinto-agents
restart: unless-stopped restart: unless-stopped
security_opt: security_opt:
@ -340,6 +337,9 @@ services:
- ${HOME}/.ssh:/home/agent/.ssh:ro - ${HOME}/.ssh:/home/agent/.ssh:ro
- ${HOME}/.config/sops/age:/home/agent/.config/sops/age:ro - ${HOME}/.config/sops/age:/home/agent/.config/sops/age:ro
- woodpecker-data:/woodpecker-data:ro - woodpecker-data:/woodpecker-data:ro
- ./projects:/home/agent/disinto/projects:ro
- ./.env:/home/agent/disinto/.env:ro
- ./state:/home/agent/disinto/state
environment: environment:
FORGE_URL: http://forgejo:3000 FORGE_URL: http://forgejo:3000
FORGE_REPO: ${FORGE_REPO:-disinto-admin/disinto} FORGE_REPO: ${FORGE_REPO:-disinto-admin/disinto}
@ -382,9 +382,7 @@ services:
- disinto-net - disinto-net
runner: runner:
build: image: ghcr.io/disinto/agents:${DISINTO_IMAGE_TAG:-latest}
context: .
dockerfile: docker/agents/Dockerfile
profiles: ["vault"] profiles: ["vault"]
security_opt: security_opt:
- apparmor=unconfined - apparmor=unconfined
@ -405,7 +403,7 @@ services:
# Edge proxy — reverse proxy to Forgejo, Woodpecker, and staging # Edge proxy — reverse proxy to Forgejo, Woodpecker, and staging
# Serves on ports 80/443, routes based on path # Serves on ports 80/443, routes based on path
edge: edge:
build: ./docker/edge image: ghcr.io/disinto/edge:${DISINTO_IMAGE_TAG:-latest}
container_name: disinto-edge container_name: disinto-edge
restart: unless-stopped restart: unless-stopped
security_opt: security_opt:
@ -573,6 +571,13 @@ COMPOSEEOF
sed -i "s|CLAUDE_BIN_PLACEHOLDER|/usr/local/bin/claude|g" "$compose_file" sed -i "s|CLAUDE_BIN_PLACEHOLDER|/usr/local/bin/claude|g" "$compose_file"
fi fi
# In build mode, replace image: with build: for locally-built images
if [ "$use_build" = true ]; then
sed -i 's|^\( agents:\)|\1|' "$compose_file"
sed -i '/^ image: ghcr\.io\/disinto\/agents:/{s|image: ghcr\.io/disinto/agents:.*|build:\n context: .\n dockerfile: docker/agents/Dockerfile|}' "$compose_file"
sed -i '/^ image: ghcr\.io\/disinto\/edge:/{s|image: ghcr\.io/disinto/edge:.*|build: ./docker/edge|}' "$compose_file"
fi
echo "Created: ${compose_file}" echo "Created: ${compose_file}"
} }