From 8fe3f34fc5a21ed0182adf74f3d22bbc67c01dd1 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:38:57 +0800 Subject: [PATCH] fix(docker): detect and refresh stale build-artefact volumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codeman-node-modules and codeman-dist (docker-compose.yaml) are seeded from the image only while empty, so a rebuilt image's fresh dist/ node_modules sat unused behind old volume content until something cleared it. The in-app self-updater never hit this (it rebuilds INSIDE the running container, into the very volume already in use), but a `docker compose build` triggered from outside it — Start-Codeman.sh, after a manual `git pull` — did: the container came back up looking unchanged, serving stale compiled routes against current source. Start-Codeman.sh now compares the checkout's HEAD commit and package-lock.json hash against a recorded marker (docker-build-source.json) and clears just the affected volume(s) before its own --build when either moved. The in-place self-update path writes that same marker after a successful build, so the two mechanisms agree on what the volumes currently reflect — without it, the next plain Start-Codeman.sh run would see the HEAD self-update just checked out, not recognise it as already accounted for, and wipe the volumes self-update just correctly rebuilt right back to the older baked image. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01R9ZSTEenc8soSu9bTi8Xru --- docker/Start-Codeman.sh | 66 ++++++++++++++++++++++++++++++++++++++ docs/docker-self-update.md | 15 +++++++++ scripts/self-update.sh | 23 +++++++++++++ 3 files changed, 104 insertions(+) diff --git a/docker/Start-Codeman.sh b/docker/Start-Codeman.sh index 9c5dc775..92a37424 100644 --- a/docker/Start-Codeman.sh +++ b/docker/Start-Codeman.sh @@ -106,6 +106,26 @@ if [[ ! -d "$repo_path/.git" ]]; then printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2 fi +# Reads HEAD without requiring a `git` binary on the host — this script +# otherwise checks the checkout only by testing for `.git` as a directory, and +# resolving refs by hand keeps that the same "no host git needed" guarantee. +git_head_commit() { + local git_dir="$1/.git" head_ref ref_path + [[ -d "$git_dir" ]] || return 1 + head_ref=$(cat -- "$git_dir/HEAD" 2>/dev/null) || return 1 + if [[ "$head_ref" == ref:* ]]; then + ref_path="${head_ref#ref: }" + if [[ -f "$git_dir/$ref_path" ]]; then + cat -- "$git_dir/$ref_path" + else + # Packed after a `git gc`; the loose ref file above is gone. + awk -v ref="$ref_path" '$2 == ref { print $1; exit }' "$git_dir/packed-refs" 2>/dev/null + fi + else + printf '%s' "$head_ref" + fi +} + # Record what the container is about to be built and created FROM. The in-app # updater compares these against the release it wants to apply: a release that # changes either file cannot be applied by the container restarting itself (a @@ -140,4 +160,50 @@ else printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2 fi +# codeman-node-modules and codeman-dist (docker-compose.yaml) are seeded from +# the image only while EMPTY, so a rebuilt image's fresh output sits unused +# behind old volume content until something clears it. The in-app self-updater +# never hits this — it rebuilds INSIDE the running container, into the very +# volume already in use — but a `docker compose build` triggered from outside +# it (this script, after a `git pull`) does: the container comes back up +# looking unchanged. Detect that here and clear just the affected volume(s) so +# `--build` below actually takes effect. Best-effort: with no sha256 tool this +# quietly does nothing, same as the environment-gate block above. +if [[ -n "$dockerfile_sha" ]]; then + repo_head=$(git_head_commit "$repo_path" || true) + lockfile_sha=$(sha256_of "$repo_path/package-lock.json" 2>/dev/null || true) + source_state_file="$state_dir/docker-build-source.json" + prev_head='' + prev_lockfile_sha='' + if [[ -f "$source_state_file" ]]; then + prev_head=$(sed -n 's/.*"headCommit": *"\([^"]*\)".*/\1/p' "$source_state_file") + prev_lockfile_sha=$(sed -n 's/.*"lockfileSha256": *"\([^"]*\)".*/\1/p' "$source_state_file") + fi + + volumes_to_refresh=() + [[ -n "$repo_head" && "$repo_head" != "$prev_head" ]] && volumes_to_refresh+=('codeman-dist') + [[ -n "$lockfile_sha" && "$lockfile_sha" != "$prev_lockfile_sha" ]] && volumes_to_refresh+=('codeman-node-modules') + + if [[ ${#volumes_to_refresh[@]} -gt 0 ]]; then + # Runs even on this script's very first invocation against an EXISTING + # deployment, deliberately: that deployment's volumes may already be + # stale (there was no earlier version of this check to have caught it), + # and clearing an already-empty or nonexistent volume is a harmless + # no-op, so there is no fresh-install case this needs to avoid. + printf 'Source changed since the last start; refreshing: %s\n' "${volumes_to_refresh[*]}" + "${compose_command[@]}" down + for key in "${volumes_to_refresh[@]}"; do + volume_name=$(docker volume ls -q --filter "label=com.docker.compose.volume=$key" | head -n1) + [[ -n "$volume_name" ]] && docker volume rm -- "$volume_name" + done + fi + + printf '{\n "headCommit": "%s",\n "lockfileSha256": "%s"\n}\n' \ + "$repo_head" "$lockfile_sha" >"$source_state_file.tmp" + mv -- "$source_state_file.tmp" "$source_state_file" + if [[ "$EUID" == '0' ]]; then + chown -- "$PUID:$PGID" "$source_state_file" + fi +fi + exec "${compose_command[@]}" up --build -d diff --git a/docs/docker-self-update.md b/docs/docker-self-update.md index 6521abd0..6f41505b 100644 --- a/docs/docker-self-update.md +++ b/docs/docker-self-update.md @@ -59,6 +59,7 @@ unchanged. The container path is a new `SupervisorKind`, not a new updater. | `CODEMAN_RESTART_BY_EXIT=1` | The Compose file's declaration of that policy, so the updater may exit even with no Docker socket. | | Toolchain + devDependencies in the image | Lets `npm install` and `npm run build` run inside the container. | | `docker-env-applied.json` | Fingerprint baseline, written by `Start-Codeman.sh` on every start. | +| `docker-build-source.json` | What HEAD/`package-lock.json` the build artefact volumes currently reflect. Written by both `Start-Codeman.sh` and this in-place update, so the two agree on whether those volumes are stale. | ### Why build artefacts are in named volumes @@ -72,6 +73,20 @@ Docker seeds an empty named volume from the image, so the first start inherits t image's already-built `node_modules` and `dist` and pays no bootstrap cost. `docker compose down -v` is the supported reset: the next start re-seeds them. +That seeding-only-while-empty behaviour has a second, less obvious edge: it also +means a plain `docker compose build` triggered from OUTSIDE the container (for +example `Start-Codeman.sh`, after a `git pull` done by hand rather than through +this in-app updater) produces a fresh image whose freshly-built `dist`/ +`node_modules` then sit unused behind the volumes' OLD content — the container +comes back up looking unchanged. `Start-Codeman.sh` detects this by comparing the +checkout's current HEAD and `package-lock.json` hash against `docker-build-source.json`, +and clears just the affected volume(s) before its own `--build` if they moved. +This in-place update writes that same file after a successful build precisely so +that comparison does not fire on stale information: without it, the next plain +`Start-Codeman.sh` run would see the HEAD this update just checked out, not +recognise it as already accounted for, and wipe the volumes this update just +correctly rebuilt right back to the OLDER image. + ### Why the runtime image carries a build toolchain `npm run build` is `tsc` plus `esbuild`, both devDependencies, so the image no diff --git a/scripts/self-update.sh b/scripts/self-update.sh index 6ac3f80d..fb3e7874 100755 --- a/scripts/self-update.sh +++ b/scripts/self-update.sh @@ -193,6 +193,29 @@ run_step "installing" "Installing dependencies" npm install --no-fund --no-audit # 5) Build (gate the restart on success — never restart into a torn dist/). run_step "building" "Building" npm run build || rollback_and_fail "Build failed" +# Docker Compose only: record what HEAD/package-lock.json the freshly-built +# codeman-dist/codeman-node-modules volumes now reflect. `Start-Codeman.sh` +# reads this same file (`$appdata_path/.codeman/…`, i.e. this container's own +# $HOME/.codeman since that path IS the appdata bind mount) to detect source +# changes an EXTERNAL `docker compose build` made and refresh those volumes — +# without this, the next plain `Start-Codeman.sh` run would see the HEAD this +# update just checked out, not recognise it as already accounted for, and wipe +# the volumes this update just correctly rebuilt right back to the OLDER image. +if [[ "$SUPERVISOR" == "docker-compose" ]]; then + build_source_file="$HOME/.codeman/docker-build-source.json" + mkdir -p -- "$HOME/.codeman" + build_head=$(git rev-parse HEAD 2>/dev/null || true) + build_lockfile_sha='' + if command -v sha256sum >/dev/null 2>&1; then + build_lockfile_sha=$(sha256sum -- package-lock.json 2>/dev/null | cut -d' ' -f1) + elif command -v shasum >/dev/null 2>&1; then + build_lockfile_sha=$(shasum -a 256 package-lock.json 2>/dev/null | cut -d' ' -f1) + fi + printf '{\n "headCommit": "%s",\n "lockfileSha256": "%s"\n}\n' \ + "$build_head" "$build_lockfile_sha" >"$build_source_file.tmp" \ + && mv -- "$build_source_file.tmp" "$build_source_file" +fi + # 6) Restart the service so the new code loads. Write the terminal pre-restart # marker FIRST so the freshly-booted server can reconcile it deterministically. write_status "restarting" "Restarting Codeman…"