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
+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[]) =>