From 697b05b1184bfb90d75bffa94d2a307b30b13ad4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 23 Sep 2026 11:36:01 +0200 Subject: [PATCH] fix(docker): merge-time fixes for Update-Codeman.sh (#465) - Remove exactly the codeman-node-modules/codeman-dist volumes by Compose label after a plain `down`, instead of `down --volumes` (which also takes any volume an override file declares while the message named two). `down --volumes` remains only as a warned fallback when the project name cannot be resolved. - Report a failing first `docker compose config --format json` call with a clear error instead of exiting silently under `set -e`. - Filter empty label lines in the collision guard so an unlabelled container cannot hide a real collision; name the moved-checkout exit in its error. - Comments no longer cite a guard or incident in Start-Codeman.sh that does not exist; the README states the real gap (a Node base-image bump leaves codeman-node-modules stale because the lockfile did not move). - docs: Update-Codeman.sh in the docker-self-update.md short-version table and a mention in docker-compose.md; "Major updates" moved under "Updating" in docker/README.md. - test: smoke test covers the new sequence, the config failure and the empty-line case; quiet stdio; @fileoverview names the fourth concern. Co-Authored-By: Claude Opus 5.5 (1M context) --- docker/README.md | 49 ++++++++++--------- docker/Update-Codeman.sh | 89 +++++++++++++++++++++++++--------- docs/docker-compose.md | 2 +- docs/docker-self-update.md | 22 +++++---- test/docker-entrypoint.test.ts | 74 +++++++++++++++++++++------- 5 files changed, 162 insertions(+), 74 deletions(-) diff --git a/docker/README.md b/docker/README.md index 672de49b..03396845 100644 --- a/docker/README.md +++ b/docker/README.md @@ -41,6 +41,32 @@ 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 on every start, but with the layer cache, +and it refreshes the build-artefact volumes selectively: `codeman-dist` when +the checkout's HEAD moved, `codeman-node-modules` only when `package-lock.json` +changed. That is right for an ordinary `git pull`. It is not enough when a +`server.Dockerfile` change bumps the Node base image without touching the +lockfile: `node-pty` is compiled from source (there is no Linux prebuild), so +the old `codeman-node-modules` volume would keep a build made for the previous +Node version. For that case, or whenever you want to be certain of what ships, +`docker/Update-Codeman.sh` force-rebuilds the image with no layer cache, stops +the stack, removes the `codeman-node-modules` and `codeman-dist` volumes, then +hands off to `Start-Codeman.sh` for the usual start: + +```sh +bash docker/Update-Codeman.sh +``` + +Pass `--keep-volumes` to skip clearing them (safe only if you know the +rebuilt image's `node_modules`/`dist` did not change). The scripted default +is the "Resetting the build artefacts" procedure in +[`../docs/docker-self-update.md`](../docs/docker-self-update.md). Only those +two volumes are removed, by name within this Compose project; any volume a +`docker-compose.override.yml` adds is left alone, and application data and +case workspaces are host bind mounts, never touched either way. + ## Private repositories (GitHub and Azure DevOps) The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`. @@ -103,29 +129,6 @@ gh skill update gh # after a later gh release Both CLIs, and the extension, are installed from their vendors' repositories with no version pinned, so they arrive at whatever is current when that build step runs. Docker caches the step, though: `Start-Codeman.sh` rebuilds with the cache, which keeps the versions from the first build until the Dockerfile changes at or above that step or the image is rebuilt with `--no-cache`. They are apt packages owned by root, so they cannot be upgraded from a session; `az extension update --name azure-devops` is the exception and works without a rebuild. -### Major updates - -`Start-Codeman.sh` rebuilds the image on every start, but only clears the -`codeman-node-modules`/`codeman-dist` build-artefact volumes when it detects -the checkout's HEAD or `package-lock.json` moved — exactly right for an -ordinary `git pull`, too narrow when a release note (or the updater's own -blocker message) calls for starting over on a Dockerfile-only change, which -touches neither. For that case, `docker/Update-Codeman.sh` force-rebuilds the -image with no layer cache, clears those two volumes, stops the stack, then -hands off to `Start-Codeman.sh` for the usual start: - -```sh -bash docker/Update-Codeman.sh -``` - -Pass `--keep-volumes` to skip clearing them (safe only if you know the -rebuilt image's `node_modules`/`dist` did not change) — the scripted default -is the "Resetting the build artefacts" procedure in -[`../docs/docker-self-update.md`](../docs/docker-self-update.md). Those two -are the only named volumes `docker-compose.yaml` itself declares; a -`docker-compose.override.yml` could add more, and application data and case -workspaces are host bind mounts, 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. diff --git a/docker/Update-Codeman.sh b/docker/Update-Codeman.sh index cc318ed0..d3915a1f 100644 --- a/docker/Update-Codeman.sh +++ b/docker/Update-Codeman.sh @@ -25,11 +25,17 @@ # so a rebuilt image's fresh node_modules/dist otherwise sit unused behind a # volume's old content and the container comes back up looking unchanged — # exactly wrong for a script whose whole point is "be certain of what ships". -# Start-Codeman.sh only clears them when it detects the checkout's HEAD or -# `package-lock.json` moved, which is right for its own ordinary-start case but -# too narrow here: nothing about a Dockerfile-only change (the case that sends -# people to this script in the first place) touches either of those. Pass -# --keep-volumes to opt out and reuse whatever is already in them. +# Start-Codeman.sh clears codeman-dist when the checkout's HEAD moved and +# codeman-node-modules only when `package-lock.json` changed. A released +# server.Dockerfile change arrives through `git pull`, so HEAD moves and dist +# is refreshed, but a Dockerfile change that bumps the Node base image leaves +# the lockfile untouched while every native module (node-pty is compiled from +# source, there is no Linux prebuild) has to be rebuilt against the new Node +# ABI. Start-Codeman.sh would keep the old codeman-node-modules volume, and it +# never builds with --no-cache. This script clears BOTH volumes, and ONLY +# those two (targeted `docker volume rm` by Compose label, never +# `down --volumes`, which would also take any volume an override file adds). +# Pass --keep-volumes to opt out and reuse whatever is already in them. # # Usage: docker/Update-Codeman.sh [--keep-volumes] # --keep-volumes Do not clear codeman-node-modules/codeman-dist. Safe to @@ -88,20 +94,26 @@ for override_file in "$override_yml" "$override_yaml"; do done compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}") -# Same collision guard as Start-Codeman.sh, and load-bearing HERE rather than -# left to that script's own copy: this script's --no-cache build and its -# `down`/`down --volumes` (below) both run BEFORE the handoff at the bottom of -# this file, so Start-Codeman.sh's guard would only fire after the damage this -# one exists to prevent has already happened. docker-compose.yaml hard-codes -# `name: codeman`, so a second checkout run without COMPOSE_PROJECT_NAME -# resolves to the SAME Compose project as any other checkout on the host and -# operates on ITS containers and volumes — this script's default `down -# --volumes` makes that worse than Start-Codeman.sh's own targeted refresh, -# since it clears every named volume the resolved project has, not just the -# two this script means to. See Start-Codeman.sh's own guard for the full -# incident this is written against (2026-09-21). +# Collision guard. Start-Codeman.sh has no equivalent; this is the only one, +# and it has to run before this script's own --no-cache build, `down` and +# volume removal below. docker-compose.yaml hard-codes `name: codeman`, so a +# second checkout run without COMPOSE_PROJECT_NAME resolves to the SAME Compose +# project as any other checkout on the host and would operate on ITS +# containers and volumes. +# +# The project name is read from the resolved config's top-level `name` key +# (the first `name` in the output; nested ones come later), the same parse +# Start-Codeman.sh uses. `--format json` needs Compose v2.3+. This is the first +# `docker` call the script makes, so its failure is reported here rather than +# left to `set -e`, which would exit with no output at all. +if ! project_config=$("${compose_command[@]}" config --format json); then + printf 'Error: `docker compose config --format json` failed (see the message above, if any).\n' >&2 + printf 'Check that Docker and Compose v2.3+ are installed and on PATH, and that\n' >&2 + printf '%s and the Compose files in %s are valid.\n' "$env_file" "$script_dir" >&2 + exit 1 +fi project_name=$( - "${compose_command[@]}" config --format json 2>/dev/null | + printf '%s\n' "$project_config" | sed -n 's/^[[:space:]]*"name":[[:space:]]*"\([^"]*\)".*$/\1/p' | head -n1 ) if [[ -n "$project_name" ]]; then @@ -112,11 +124,13 @@ if [[ -n "$project_name" ]]; then # status propagates through the command substitution and `set -e` aborts the # WHOLE script right here, every time, regardless of whether a collision # actually exists — caught only by actually running this end-to-end (a - # static text/regex check on the source cannot see it). + # static text/regex check on the source cannot see it). The empty-line + # filter keeps a container with no working_dir label from winning head -n1 + # and hiding a real collision behind it. other_working_dir=$( docker ps -a --filter "label=com.docker.compose.project=$project_name" \ --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null | - grep -v -F -x -- "$script_dir" | head -n1 || true + grep -v -F -x -- "$script_dir" | grep -v '^$' | head -n1 || true ) if [[ -n "$other_working_dir" ]]; then printf 'Error: Compose project "%s" is already in use by a DIFFERENT checkout:\n' "$project_name" >&2 @@ -127,10 +141,15 @@ if [[ -n "$project_name" ]]; then printf 'docker-compose.yaml hard-codes `name: %s`, so two checkouts on the same host\n' "$project_name" >&2 printf 'collide unless each one sets a distinct COMPOSE_PROJECT_NAME. Continuing would\n' >&2 printf 'rebuild and stop the OTHER checkout'"'"'s running container and, by default,\n' >&2 - printf 'delete ALL of its named volumes.\n' >&2 + printf 'delete its codeman-node-modules/codeman-dist volumes.\n' >&2 printf '\n' >&2 printf 'Fix: export COMPOSE_PROJECT_NAME= before\n' >&2 printf 'running this script, then retry.\n' >&2 + printf '\n' >&2 + printf 'If instead THIS checkout was moved or renamed after its container was created,\n' >&2 + printf 'the path above is its own old location: remove the old container (for example\n' >&2 + printf '`docker rm -f ` for the codeman container) and retry, rather than\n' >&2 + printf 'setting COMPOSE_PROJECT_NAME, which would start a second project beside it.\n' >&2 exit 1 fi fi @@ -189,13 +208,37 @@ printf 'Building a fresh image (--no-cache)...\n' "${compose_command[@]}" build --no-cache printf 'Stopping the stack...\n' -if [[ "$keep_volumes" == '1' ]]; then +if [[ "$keep_volumes" == '1' || -n "$project_name" ]]; then "${compose_command[@]}" down else - printf 'Also clearing the codeman-node-modules/codeman-dist volumes (pass --keep-volumes to skip).\n' + # No resolvable project name means the label filter below could match + # nothing, so fall back to Compose's own removal, and say what it really does. + printf 'Warning: could not resolve the Compose project name; clearing EVERY named volume\n' >&2 + printf 'in this Compose project (override file included) with `down --volumes` instead.\n' >&2 "${compose_command[@]}" down --volumes fi +# Targeted removal of exactly the two build-artefact volumes, scoped by label to +# THIS project (the volume key alone is shared by any other stack declaring the +# same key). Same lookup as Start-Codeman.sh's refresh. A failure is reported, +# not fatal: the stack is already down, and the handoff below is what brings +# it back up. +if [[ "$keep_volumes" != '1' && -n "$project_name" ]]; then + printf 'Clearing the codeman-node-modules/codeman-dist volumes (pass --keep-volumes to skip).\n' + for key in codeman-node-modules codeman-dist; do + volume_name=$( + docker volume ls -q \ + --filter "label=com.docker.compose.volume=$key" \ + --filter "label=com.docker.compose.project=$project_name" | + head -n1 + ) || volume_name='' + if [[ -n "$volume_name" ]] && ! docker volume rm -- "$volume_name"; then + printf 'Warning: could not remove volume %s; the container may keep serving the\n' "$volume_name" >&2 + printf 'previous build from it. Remove it by hand and rerun this script.\n' >&2 + fi + done +fi + # Start-Codeman.sh does everything a plain `up -d` does not: re-derives # PUID/PGID, pre-creates CODEMAN_CASES_PATH with the right ownership, resolves # DOCKER_SOCKET_GID, records the server.Dockerfile/docker-compose.yaml diff --git a/docs/docker-compose.md b/docs/docker-compose.md index ed8ef130..d1c73721 100644 --- a/docs/docker-compose.md +++ b/docs/docker-compose.md @@ -69,7 +69,7 @@ If that directory was created by an earlier root-running image, change its owner Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build. -That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those. +That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those. For a major update, or a base-image change `Start-Codeman.sh` does not fully pick up, `docker/Update-Codeman.sh` rebuilds with no layer cache and clears the two build-artefact volumes before handing off to it (see "Major updates" in `docker/README.md`). `CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable. diff --git a/docs/docker-self-update.md b/docs/docker-self-update.md index d66592f3..f8f0bd92 100644 --- a/docs/docker-self-update.md +++ b/docs/docker-self-update.md @@ -10,15 +10,18 @@ this file covers only what the container changes. ## The short version -| Change in the release | Applied by | -| -------------------------------- | ------------------------------------------------ | -| Application code | The in-app updater | -| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host | -| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host | -| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` | +| Change in the release | Applied by | +| ----------------------------------------- | ------------------------------------------------------------------------------- | +| Application code | The in-app updater | +| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host | +| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host | +| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` | +| A major update, or a Node base-image bump | `docker/Update-Codeman.sh` on the host (no-cache rebuild + fresh build volumes) | -The in-app updater detects all three of the bottom rows itself and refuses with a +The in-app updater detects the three middle rows itself and refuses with a message naming what changed, so you never have to work out which case you are in. +`Update-Codeman.sh` is the heavier option for when `Start-Codeman.sh` is not +enough: see "Major updates" in `docker/README.md`. ## Why the container needs its own path @@ -224,9 +227,10 @@ 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. `docker/Update-Codeman.sh` scripts exactly this by default (plus an +image. `docker/Update-Codeman.sh` scripts the same reset by default for the two +build-artefact volumes (`codeman-node-modules`, `codeman-dist`) only, 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`. +force on its own. See "Major updates" in `docker/README.md`. ## Disabling it diff --git a/test/docker-entrypoint.test.ts b/test/docker-entrypoint.test.ts index c4a8cabe..b46a0ec0 100644 --- a/test/docker-entrypoint.test.ts +++ b/test/docker-entrypoint.test.ts @@ -18,6 +18,11 @@ * `CODEMAN_CASES_PATH`, so the directory it creates has the owner the * container will accept, and its `git_head_commit` helper (a pure function * over `.git`) resolves the three ref layouts a checkout can have. + * 4. `Update-Codeman.sh` (the scripted major update) runs its collision guard + * before its own `--no-cache` build and `down`, removes exactly the two + * build-artefact volumes rather than every volume in the project, and hands + * off to `Start-Codeman.sh`; checked statically and by an end-to-end run + * against a stub `docker`. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -193,10 +198,8 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md }); it('resolves the collision guard BEFORE the --no-cache build and the down, not after', () => { - // This script's own build/down run before the handoff to Start-Codeman.sh, - // so its copy of the guard has to be early here too - Start-Codeman.sh's - // copy alone would only catch the collision after this script's own - // destructive calls already ran. + // Start-Codeman.sh has no such guard, so this is the only one, and it has + // to run before this script's own build, down and volume removal. const projectName = updateScript.indexOf('project_name=$('); const guard = updateScript.indexOf('other_working_dir=$('); const build = updateScript.indexOf('"${compose_command[@]}" build --no-cache'); @@ -210,14 +213,17 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md expect(updateScript).toMatch(/grep -v -F -x -- "\$script_dir"/); }); - it('clears the named volumes by DEFAULT; --keep-volumes opts out to a plain `down`', () => { + it('clears exactly the two build-artefact volumes by DEFAULT, by label, scoped to the project', () => { expect(updateScript).toMatch(/--keep-volumes\)\s*\n\s*keep_volumes=1/); - expect(updateScript).toMatch(/"\$\{compose_command\[@\]\}" down --volumes/); - // The keep_volumes branch must stay a plain `down` — merging the two would - // silently start wiping the build-artefact volumes even when asked not to. - expect(updateScript).toMatch( - /if \[\[ "\$keep_volumes" == '1' \]\]; then\s*\n\s*"\$\{compose_command\[@\]\}" down\s*\n\s*else/ - ); + expect(updateScript).toMatch(/for key in codeman-node-modules codeman-dist; do/); + expect(updateScript).toMatch(/--filter "label=com\.docker\.compose\.volume=\$key"/); + expect(updateScript).toMatch(/docker volume rm -- "\$volume_name"/); + // `down --volumes` survives only as the fallback for an unresolvable + // project name, where the label filter could not match anything. + const fallback = updateScript.indexOf('"${compose_command[@]}" down --volumes'); + const warning = updateScript.indexOf('could not resolve the Compose project name'); + expect(warning).toBeGreaterThan(-1); + expect(fallback).toBeGreaterThan(warning); }); it('--help/-h prints usage and exits 0, rather than falling into the unrecognised-argument branch', () => { @@ -354,6 +360,7 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md ' exit 0', ' fi', ' if [[ " $* " == *" config "* && " $* " == *" --format json "* ]]; then', + ' if [[ -n "${STUB_CONFIG_JSON_FAIL:-}" ]]; then echo "unknown flag: --format" >&2; exit 1; fi', // Real `docker compose config --format json` pretty-prints, so // `"name"` starts its OWN line rather than sharing one with `{` - // the sed extraction both scripts use anchors on that, and a @@ -371,7 +378,15 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md // see no output here and proceed exactly as before; a test that // wants to exercise the guard itself sets STUB_PS_WORKING_DIR. 'if [[ "$1" == "ps" && -n "${STUB_PS_WORKING_DIR:-}" ]]; then', - ' echo "$STUB_PS_WORKING_DIR"', + ' printf "%s\\n" "$STUB_PS_WORKING_DIR"', + ' exit 0', + 'fi', + // `docker volume ls -q --filter label=com.docker.compose.volume= ...`: + // answer with the Compose-style `_` name for that key. + 'if [[ "$1" == "volume" && "$2" == "ls" ]]; then', + ' for a in "$@"; do', + ' case "$a" in label=com.docker.compose.volume=*) echo "codeman_${a#label=com.docker.compose.volume=}" ;; esac', + ' done', ' exit 0', 'fi', 'exit 0', @@ -389,6 +404,7 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md execFileSync('bash', [join(dockerDir, 'Update-Codeman.sh'), ...args], { env: { ...process.env, PATH: `${binDir}:${process.env.PATH}`, CMDLOG: logPath, ...extraEnv }, encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], }); } catch (err) { const e = err as { status?: number; stderr?: string }; @@ -405,14 +421,21 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md } } - it('default: build --no-cache, THEN down --volumes, THEN the handoff genuinely runs Start-Codeman.sh', () => { + it('default: build --no-cache, THEN a plain down, THEN removes exactly the two volumes, THEN the handoff runs Start-Codeman.sh', () => { const { status, stderr, log } = runSmokeTest([]); const buildIdx = log.findIndex((l) => l.includes('build --no-cache')); - const downIdx = log.findIndex((l) => l.includes(' down --volumes') || l.endsWith(' down')); + const downIdx = log.findIndex((l) => / down(\s|$)/.test(l)); expect(buildIdx).toBeGreaterThan(-1); expect(downIdx).toBeGreaterThan(buildIdx); - expect(log[downIdx]).toContain('down --volumes'); + expect(log[downIdx]).not.toContain('--volumes'); + expect(log.some((l) => l.includes('down --volumes'))).toBe(false); + const removed = log.filter((l) => l.startsWith('docker volume rm')); + expect(removed).toEqual([ + 'docker volume rm -- codeman_codeman-node-modules', + 'docker volume rm -- codeman_codeman-dist', + ]); + expect(log.findIndex((l) => l.startsWith('docker volume rm'))).toBeGreaterThan(downIdx); // Proof the handoff really executed Start-Codeman.sh rather than dying // with EACCES right after printing "Handing off...": more `docker` @@ -434,12 +457,27 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md const downLine = log.find((l) => / down(\s|$)/.test(l)); expect(downLine).toBeDefined(); expect(downLine).not.toContain('--volumes'); + expect(log.some((l) => l.startsWith('docker volume rm'))).toBe(false); + }); + + it('reports a failing first `docker compose config` call instead of exiting silently', () => { + const { status, stderr, log } = runSmokeTest([], { STUB_CONFIG_JSON_FAIL: '1' }); + expect(status).not.toBe(0); + expect(stderr).toMatch(/docker compose config --format json` failed/); + expect(stderr).toMatch(/unknown flag: --format/); + expect(log.some((l) => l.includes('build --no-cache'))).toBe(false); + }); + + it('still refuses when an unlabelled container prints an empty line ahead of the other checkout', () => { + const { status, stderr, log } = runSmokeTest([], { STUB_PS_WORKING_DIR: '\n/some/other/checkout/docker' }); + expect(status).toBe(1); + expect(stderr).toMatch(/already in use by a DIFFERENT checkout/); + expect(log.some((l) => l.includes('build --no-cache'))).toBe(false); }); it('refuses BEFORE the --no-cache build when the resolved project belongs to a different checkout', () => { - // The whole reason this guard lives here rather than only in - // Start-Codeman.sh: this script's own build/down run before the handoff - // ever reaches that script's copy of the same check. + // Start-Codeman.sh has no such guard, so nothing downstream of this + // script would catch the collision. const { status, stderr, log } = runSmokeTest([], { STUB_PS_WORKING_DIR: '/some/other/checkout/docker' }); expect(status).toBe(1); expect(stderr).toMatch(/already in use by a DIFFERENT checkout/);