feat(docker): restore in-app self-update in the Compose deployment

Codeman running under docker/docker-compose.yaml lost the ability to update
itself from App Settings -> Updates. The image had no .git (excluded by
.dockerignore), so the install reported as "unknown"; there was no init system
for detectSupervisor() to find; the runtime stage had neither devDependencies
nor a build toolchain; and a pull into the baked /opt/codeman would have landed
in the container's writable layer and been discarded by the next `up`.

Restore it through configuration rather than a second updater, so the release
channel, auto-stash, status file and boot reconcile are all reused unchanged:

- The checkout Compose builds from is bind-mounted over /opt/codeman, so the
  update's git checkout and rebuild land on the host and survive recreation.
- The restart is the server exiting; `restart: unless-stopped` relaunches the
  container on the new dist/. This is the one supervisor whose updater does NOT
  outlive the restart, which is safe only because the terminal "restarting"
  marker is written first.
- node_modules and dist are named volumes over the bind mount, so
  container-compiled native modules never enter the host checkout.
- The runtime image keeps devDependencies and gains python3/make/g++, since
  `npm run build` is tsc + esbuild and node-pty has no Linux prebuild.

An in-place container update applies code only, because a restart reuses the
existing image and config. evaluateEnvironmentGate() reads the target release's
own files with `git show <tag>:<path>` and refuses when server.Dockerfile or
docker-compose.yaml changed, when .env.example gained keys the user's .env
lacks, or when the restart policy would not bring the container back. The
missing-key check matters most: Compose resolves an unset ${VAR} to the empty
string and starts anyway, so a new required setting would otherwise arrive as a
silently blank variable. Every unknown fails open, and the gate is re-evaluated
server-side on POST /api/system/update.

The four global agent CLIs are pinned, because an unpinned CLI bump is the one
environment change no diff-derived gate can see; pinning turns it into a
Dockerfile change the gate already detects.

Adds test/docker-compose-env-parity.test.ts as the merge-side guard (every
compose ${VAR} has an .env.example entry and the reverse) and
test/docker-self-update.test.ts for the pure gate decisions.

Documented in docs/docker-self-update.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yAQ2y9t81jzSfpStUxx5T
This commit is contained in:
Devvyn
2026-09-02 19:33:32 +08:00
co-authored by Claude Opus 5
parent 1e24817b51
commit 66eb01ba8f
16 changed files with 1054 additions and 34 deletions
+40
View File
@@ -15,6 +15,11 @@ services:
ports:
- "${CODEMAN_PORT}:${CODEMAN_PORT}"
environment:
# Tells the self-updater to restart by exiting (the restart policy below
# relaunches it) rather than by looking for an init system that is not
# here. Also set in the image; repeated so a container started without the
# image default still self-identifies.
CODEMAN_IN_CONTAINER: "1"
CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS}
# Host-side equivalent of the runtime user's HOME. Docker case seed,
# credential and hook mounts are translated into the daemon namespace.
@@ -48,6 +53,32 @@ services:
- type: bind
source: ${DOCKER_SOCKET}
target: /var/run/docker.sock
# The application source, so App Settings -> Updates can update in place.
# This is the SAME checkout used as the build context above, mounted over
# the image's baked copy: a `git checkout` performed inside the container
# then lands on the host and survives the container being recreated.
# Without it the pull would go to the container's writable layer and be
# silently discarded by the next `up`. See docs/docker-self-update.md.
# Defaults to `..` — the build context above — which Compose resolves
# against the project directory, so plain `docker compose up` works with
# no extra configuration. Set CODEMAN_REPO_PATH only to point elsewhere.
- type: bind
source: ${CODEMAN_REPO_PATH:-..}
target: /opt/codeman
# Build artefacts live in named volumes layered OVER the repo bind mount,
# so `npm install` and `npm run build` inside the container never write
# into the host checkout. That keeps container-compiled native modules
# (node-pty is built from source here) out of a checkout that may also be
# used to run Codeman natively, and keeps `git status` clean. Docker seeds
# an EMPTY named volume from the image, so the first start inherits the
# image's already-built node_modules and dist rather than paying for a
# bootstrap build.
- type: volume
source: codeman-node-modules
target: /opt/codeman/node_modules
- type: volume
source: codeman-dist
target: /opt/codeman/dist
extra_hosts:
- "host.docker.internal:host-gateway"
security_opt:
@@ -63,3 +94,12 @@ services:
timeout: 5s
retries: 3
start_period: 30s
volumes:
# Container-owned build artefacts. They persist across container recreation,
# so an in-app update's `npm install` output is not thrown away by the next
# `up`, and they are seeded from the image on first use. Removing them (or
# `docker compose down -v`) is the supported reset: the next start rebuilds
# from the image.
codeman-node-modules:
codeman-dist: