chore(docker): add Update-Codeman.sh for scripted major-update rebuilds

docker/README.md and docs/docker-self-update.md both already point operators
at "stop the stack, rebuild, restart" for anything the in-app updater refuses
to apply (a changed server.Dockerfile, a changed docker-compose.yaml, or a
new required .env key) — but that was a manual, hand-typed procedure with no
script of its own, unlike every other start/update path this deployment has.

docker/Update-Codeman.sh scripts it: `docker compose down`, then an
unconditional `docker compose build --no-cache` (a major update should be
certain of what actually ships, not reuse whatever layers happened to be
cached), then hands off to the existing Start-Codeman.sh for the same
careful PUID/PGID, override-file and fingerprint handling every other start
already goes through — rather than reimplementing any of that by hand and
risking it drifting out of step.

An optional --volumes/-v flag also removes the codeman-node-modules/
codeman-dist named volumes, the scripted form of the "Resetting the build
artefacts" procedure docs/docker-self-update.md already documents by hand.
Safe: those two are the only named volumes this stack declares; application
data and case workspaces are host bind mounts, never touched by
`docker compose down` either way.

Docs updated: a "Major updates" section in docker/README.md, and a pointer
from docs/docker-self-update.md's existing "Resetting the build artefacts"
troubleshooting entry.

Tests: extended test/docker-entrypoint.test.ts (the existing home for
Start-Codeman.sh's own static checks) with a bash -n parse check, the
down-before-build-before-handoff ordering, the --volumes flag's effect,
unrecognised-argument handling, and byte-for-byte agreement with
Start-Codeman.sh's own override-file resolution logic (so `down` here and
`up` there can never target different Compose files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
This commit is contained in:
Devvyn
2026-09-21 13:57:54 +08:00
co-authored by Claude Sonnet 5
parent 9466acfc1a
commit 9ba90a674a
4 changed files with 186 additions and 2 deletions
+20
View File
@@ -41,6 +41,26 @@ Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
### Major updates
`Start-Codeman.sh` rebuilds the image and clears the build-artefact volumes on
its own, but only when it detects the checkout's HEAD or `package-lock.json`
moved — exactly right for an ordinary `git pull`, too conservative when a
release note (or the updater's own blocker message) calls for starting over.
For that case, `docker/Update-Codeman.sh` stops the stack, force-rebuilds the
image with no layer cache, then hands off to `Start-Codeman.sh` for the usual
start:
```sh
bash docker/Update-Codeman.sh
```
Add `--volumes` to also clear the `codeman-node-modules`/`codeman-dist`
volumes — the scripted form of "Resetting the build artefacts" in
[`../docs/docker-self-update.md`](../docs/docker-self-update.md). Those two
are the only named volumes this stack declares; application data and case
workspaces are host bind mounts and are never touched either way.
## Local customisation
Compose merges `docker-compose.override.yml` on top of `docker-compose.yaml`. Keep host-specific changes there rather than editing `docker-compose.yaml`, so this repository can be updated without losing them. Both `docker-compose.override.yml` and `docker-compose.override.yaml` are ignored by Git.
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
#
# The scripted major-update path for the Docker Compose deployment.
#
# docker/README.md and docs/docker-self-update.md both point operators here for
# anything the in-app updater itself refuses to apply: a changed
# `server.Dockerfile`, a changed `docker-compose.yaml`, or a new required
# `.env.example` key. None of those can be applied by a container restarting
# itself — a restart reuses the existing image and configuration (see "The
# environment gate" in docs/docker-self-update.md) — so this script does the
# three things an in-place update cannot: stop the stack, force a real image
# rebuild with no layer cache, then hand off to Start-Codeman.sh for the same
# careful PUID/PGID, override-file and fingerprint handling every other start
# goes through.
#
# This is the scripted form of "Resetting the build artefacts" in
# docs/docker-self-update.md (`docker compose down -v`, then
# `Start-Codeman.sh`), plus the unconditional `--no-cache` a major update
# warrants: `Start-Codeman.sh` on its own only rebuilds without the cache flag,
# and only clears the two build-artefact volumes when it detects the checkout's
# HEAD or `package-lock.json` moved — exactly right for an ordinary `git pull`,
# too conservative when the ask is "start over, certain of what ships".
#
# Usage: docker/Update-Codeman.sh [--volumes]
# --volumes, -v Also remove the codeman-node-modules/codeman-dist named
# volumes, so the fresh image's own node_modules/dist are
# what actually run instead of sitting unused behind a
# Docker-seeded volume's old content (Docker only seeds a
# named volume from the image while that volume is EMPTY).
# Safe: those two are the ONLY named volumes this stack
# declares (`docker-compose.yaml`) — application data and
# case workspaces are host bind mounts, never touched by
# `docker compose down`, with or without this flag.
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
env_file="$script_dir/.env"
compose_file="$script_dir/docker-compose.yaml"
remove_volumes=0
for arg in "$@"; do
case "$arg" in
--volumes | -v)
remove_volumes=1
;;
*)
printf 'Error: unrecognised argument: %s\n' "$arg" >&2
printf 'Usage: %s [--volumes]\n' "$0" >&2
exit 1
;;
esac
done
if [[ ! -f "$env_file" ]]; then
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2
printf 'Create it from %s/.env.example before running this script.\n' "$script_dir" >&2
exit 1
fi
# Same override-file discovery as Start-Codeman.sh, and deliberately kept in
# step with it: a stack started through one script and updated through the
# other must resolve to the exact same Compose files, or `down` here and `up`
# there could target different configurations. Compose's own precedence
# (measured on v5.5.0 with both present: it uses .yml and ignores .yaml).
override_yml="$script_dir/docker-compose.override.yml"
override_yaml="$script_dir/docker-compose.override.yaml"
if [[ -f "$override_yml" && -f "$override_yaml" ]]; then
printf 'Warning: both %s and %s exist; Compose uses .yml and ignores .yaml.\n' \
"$override_yml" "$override_yaml" >&2
fi
compose_files=(-f "$compose_file")
for override_file in "$override_yml" "$override_yaml"; do
if [[ -f "$override_file" ]]; then
compose_files+=(-f "$override_file")
printf 'Using Compose override file: %s\n' "$override_file"
break
fi
done
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
printf 'Stopping the stack...\n'
if [[ "$remove_volumes" == '1' ]]; then
printf 'Also removing the codeman-node-modules/codeman-dist volumes (--volumes).\n'
"${compose_command[@]}" down --volumes
else
"${compose_command[@]}" down
fi
# --no-cache, always: a plain `build` reuses cached layers (npm install, apt
# packages, the CLI installs baked into the image) and can silently keep them
# frozen at whatever they were the day the cache was populated — exactly wrong
# for a major update, whose whole point is being certain of what actually
# ships. `scripts/build-agent-image.mjs` makes the same call for the same
# reason (see its entry in CLAUDE.md's Additional Commands table).
printf 'Building a fresh image (--no-cache)...\n'
"${compose_command[@]}" build --no-cache
# Start-Codeman.sh does everything a plain `up -d` does not: resolves
# PUID/PGID from CODEMAN_APPDATA_PATH's owner, pre-creates CODEMAN_CASES_PATH
# with the right ownership, resolves DOCKER_SOCKET_GID, records the
# server.Dockerfile/docker-compose.yaml fingerprint the in-app updater's gate
# reads on every future update, and clears the build-artefact volumes itself
# if it finds the checkout's source moved since the last start. Reimplementing
# any of that here would only risk drifting out of step with it — hand off
# instead, exactly as docs/docker-self-update.md's own reset procedure does.
printf 'Handing off to Start-Codeman.sh...\n'
exec "$script_dir/Start-Codeman.sh"
+3 -1
View File
@@ -224,7 +224,9 @@ the host and the in-app path works from then on.
**Resetting the build artefacts** — `docker compose down -v`, then
`Start-Codeman.sh`. This discards the named volumes and re-seeds them from a fresh
image.
image. `docker/Update-Codeman.sh --volumes` scripts exactly this (plus an
unconditional `--no-cache` rebuild, which a plain `Start-Codeman.sh` run does not
force on its own) — see "Major updates" in `docker/README.md`.
## Disabling it
+55 -1
View File
@@ -21,7 +21,7 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { readFileSync, mkdtempSync, rmSync, writeFileSync, statSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -33,6 +33,7 @@ const compose = read('docker/docker-compose.yaml');
const entrypoint = read('docker/entrypoint.sh');
const dockerfile = read('docker/server.Dockerfile');
const startScript = read('docker/Start-Codeman.sh');
const updateScript = read('docker/Update-Codeman.sh');
/** The `- NAME` entries under `cap_add:` (the block ends at the next key at the same indent). */
function composeCapAdd(text: string): string[] {
@@ -174,6 +175,59 @@ describe('Start-Codeman.sh', () => {
});
});
describe('Update-Codeman.sh (the scripted major-update path — docker/README.md "Major updates")', () => {
it('parses under bash -n', () => {
execFileSync('bash', ['-n', join(ROOT, 'docker/Update-Codeman.sh')]);
});
it('is executable, like every other script this deployment runs directly', () => {
// Windows checkouts (this repo is developed on both) do not carry a real
// execute bit, so this only meaningfully asserts on POSIX — matching how
// docker/README.md documents running it (`bash docker/Update-Codeman.sh`,
// not `./docker/Update-Codeman.sh`) either way.
if (process.platform === 'win32') return;
const mode = statSync(join(ROOT, 'docker/Update-Codeman.sh')).mode;
expect(mode & 0o111).not.toBe(0);
});
it('stops the stack, THEN force-rebuilds with --no-cache, THEN hands off to Start-Codeman.sh', () => {
const down = updateScript.indexOf('"${compose_command[@]}" down');
const build = updateScript.indexOf('"${compose_command[@]}" build --no-cache');
const handoff = updateScript.indexOf('exec "$script_dir/Start-Codeman.sh"');
expect(down).toBeGreaterThan(-1);
expect(build).toBeGreaterThan(down);
expect(handoff).toBeGreaterThan(build);
});
it('--volumes (or -v) removes the named volumes on the way down; the default path does not', () => {
expect(updateScript).toMatch(/--volumes \| -v\)\s*\n\s*remove_volumes=1/);
expect(updateScript).toMatch(/"\$\{compose_command\[@\]\}" down --volumes/);
// The unconditional call further down (the else branch) must stay a plain
// `down` — accidentally merging the two branches would silently start
// wiping the build-artefact volumes on every major update, not just when
// the flag is passed.
expect(updateScript).toMatch(/else\s*\n\s*"\$\{compose_command\[@\]\}" down\s*\n\s*fi/);
});
it('rejects an unrecognised argument rather than silently ignoring it', () => {
expect(updateScript).toMatch(/Error: unrecognised argument/);
expect(updateScript).toMatch(/exit 1/);
});
it('resolves the override file exactly like Start-Codeman.sh, so `down` and `up` never target different Compose files', () => {
// \r stripped before comparing: git's autocrlf normalises the COMMITTED blob to LF
// either way, but a Windows checkout can have already converted one file's line
// endings on disk and not the other's (e.g. Start-Codeman.sh checked out before this
// script existed), which would fail a raw byte comparison for a reason that has
// nothing to do with the two scripts actually agreeing.
const overrideBlock = (script: string) =>
script
.slice(script.indexOf('override_yml='), script.indexOf('compose_command=(docker compose'))
.replace(/\r\n/g, '\n');
expect(overrideBlock(updateScript)).toBe(overrideBlock(startScript));
});
});
describe('git_head_commit resolves every ref layout a checkout can have', () => {
let base: string;
const git = (cwd: string, ...args: string[]) =>